34 changed files with 1915 additions and 511 deletions
@ -0,0 +1,30 @@ |
|||
# This file tracks properties of this Flutter project. |
|||
# Used by Flutter tool to assess capabilities and perform upgrades etc. |
|||
# |
|||
# This file should be version controlled and should not be manually edited. |
|||
|
|||
version: |
|||
revision: "35c388afb57ef061d06a39b537336c87e0e3d1b1" |
|||
channel: "stable" |
|||
|
|||
project_type: app |
|||
|
|||
# Tracks metadata for the flutter migrate command |
|||
migration: |
|||
platforms: |
|||
- platform: root |
|||
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
|||
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
|||
- platform: android |
|||
create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
|||
base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 |
|||
|
|||
# User provided section |
|||
|
|||
# List of Local paths (relative to this file) that should be |
|||
# ignored by the migrate tool. |
|||
# |
|||
# Files that are not part of the templates will be ignored by default. |
|||
unmanaged_files: |
|||
- 'lib/main.dart' |
|||
- 'ios/Runner.xcodeproj/project.pbxproj' |
|||
@ -1,6 +1,5 @@ |
|||
package com.example.deep_voice |
|||
|
|||
import io.flutter.embedding.android.FlutterActivity |
|||
import com.ryanheise.audioservice.AudioServiceActivity |
|||
|
|||
class MainActivity: AudioServiceActivity() |
|||
|
|||
@ -1,8 +0,0 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<network-security-config> |
|||
<base-config cleartextTrafficPermitted="true"> |
|||
<trust-anchors> |
|||
<certificates src="system" /> |
|||
</trust-anchors> |
|||
</base-config> |
|||
</network-security-config> |
|||
@ -0,0 +1,84 @@ |
|||
Flutter crash report. |
|||
Please report a bug at https://github.com/flutter/flutter/issues. |
|||
|
|||
## command |
|||
|
|||
flutter run --machine --start-paused -d 3063725814005V5 --devtools-server-address http://127.0.0.1:9100/ --target /Users/wolfplus/Developer/deep_voice/lib/main.dart |
|||
|
|||
## exception |
|||
|
|||
RPCError: setIsolatePauseMode: (112) Service has disappeared |
|||
|
|||
``` |
|||
#0 new _OutstandingRequest (package:vm_service/src/vm_service.dart:265:34) |
|||
#1 VmService._call.<anonymous closure> (package:vm_service/src/vm_service.dart:1921:25) |
|||
#2 VmService._call (package:vm_service/src/vm_service.dart:1933:8) |
|||
#3 VmService.setIsolatePauseMode (package:vm_service/src/vm_service.dart:1685:7) |
|||
#4 HotRunner._restartFromSources.<anonymous closure> (package:flutter_tools/src/run_hot.dart:662:43) |
|||
#5 _rootRunUnary (dart:async/zone.dart:1538:47) |
|||
#6 Future._propagateToListeners.handleValueCallback (dart:async/future_impl.dart:932:45) |
|||
#7 Future._propagateToListeners (dart:async/future_impl.dart:961:13) |
|||
#8 Future._completeWithValue (dart:async/future_impl.dart:712:5) |
|||
#9 Future._asyncCompleteWithValue.<anonymous closure> (dart:async/future_impl.dart:792:7) |
|||
#10 _rootRun (dart:async/zone.dart:1525:13) |
|||
#11 _CustomZone.run (dart:async/zone.dart:1422:19) |
|||
#12 _CustomZone.runGuarded (dart:async/zone.dart:1321:7) |
|||
#13 _CustomZone.bindCallbackGuarded.<anonymous closure> (dart:async/zone.dart:1362:23) |
|||
#14 _microtaskLoop (dart:async/schedule_microtask.dart:40:21) |
|||
#15 _startMicrotaskLoop (dart:async/schedule_microtask.dart:49:5) |
|||
#16 _runPendingImmediateCallback (dart:isolate-patch/isolate_patch.dart:128:13) |
|||
#17 _RawReceivePort._handleMessage (dart:isolate-patch/isolate_patch.dart:195:5) |
|||
``` |
|||
|
|||
## flutter doctor |
|||
|
|||
``` |
|||
[!] Flutter (Channel stable, 3.29.0, on macOS 15.3.1 24D70 darwin-arm64, locale en-US) [179ms] |
|||
• Flutter version 3.29.0 on channel stable at /Users/wolfplus/Developer/SDKs/flutter |
|||
! The flutter binary is not on your path. Consider adding /Users/wolfplus/Developer/SDKs/flutter/bin to your path. |
|||
! The dart binary is not on your path. Consider adding /Users/wolfplus/Developer/SDKs/flutter/bin to your path. |
|||
• Upstream repository https://github.com/flutter/flutter.git |
|||
• Framework revision 35c388afb5 (2 weeks ago), 2025-02-10 12:48:41 -0800 |
|||
• Engine revision f73bfc4522 |
|||
• Dart version 3.7.0 |
|||
• DevTools version 2.42.2 |
|||
• If those were intentional, you can disregard the above warnings; however it is recommended to use "git" directly to perform update checks and upgrades. |
|||
|
|||
[✓] Android toolchain - develop for Android devices (Android SDK version 35.0.1) [1,851ms] |
|||
• Android SDK at /Users/wolfplus/Library/Android/sdk |
|||
• Platform android-35, build-tools 35.0.1 |
|||
• ANDROID_HOME = /Users/wolfplus/Library/Android/sdk |
|||
• Java binary at: /Applications/Android Studio.app/Contents/jbr/Contents/Home/bin/java |
|||
This is the JDK bundled with the latest Android Studio installation on this machine. |
|||
To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`. |
|||
• Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1) |
|||
• All Android licenses accepted. |
|||
|
|||
[✓] Xcode - develop for iOS and macOS (Xcode 16.2) [663ms] |
|||
• Xcode at /Applications/Xcode.app/Contents/Developer |
|||
• Build 16C5032a |
|||
• CocoaPods version 1.16.2 |
|||
|
|||
[✓] Chrome - develop for the web [2ms] |
|||
• Chrome at /Applications/Google Chrome.app/Contents/MacOS/Google Chrome |
|||
|
|||
[✓] Android Studio (version 2024.2) [2ms] |
|||
• Android Studio at /Applications/Android Studio.app/Contents |
|||
• Flutter plugin can be installed from: |
|||
🔨 https://plugins.jetbrains.com/plugin/9212-flutter |
|||
• Dart plugin can be installed from: |
|||
🔨 https://plugins.jetbrains.com/plugin/6351-dart |
|||
• Java version OpenJDK Runtime Environment (build 21.0.4+-12422083-b607.1) |
|||
|
|||
[✓] Connected device (5 available) [5.6s] |
|||
• V2049A (mobile) • 3063725814005V5 • android-arm64 • Android 14 (API 34) |
|||
• Chris’s iPhone16 (wireless) (mobile) • 00008140-0006402C3CF3001C • ios • iOS 18.2.1 22C161 |
|||
• macOS (desktop) • macos • darwin-arm64 • macOS 15.3.1 24D70 darwin-arm64 |
|||
• Mac Designed for iPad (desktop) • mac-designed-for-ipad • darwin • macOS 15.3.1 24D70 darwin-arm64 |
|||
• Chrome (web) • chrome • web-javascript • Google Chrome 133.0.6943.127 |
|||
|
|||
[✓] Network resources [550ms] |
|||
• All expected network resources are available. |
|||
|
|||
! Doctor found issues in 1 category. |
|||
``` |
|||
@ -1,23 +1,87 @@ |
|||
PODS: |
|||
- audio_service (0.0.1): |
|||
- Flutter |
|||
- FlutterMacOS |
|||
- audio_session (0.0.1): |
|||
- Flutter |
|||
- azure_speech_recognition_null_safety (0.0.1): |
|||
- Flutter |
|||
- MicrosoftCognitiveServicesSpeech-iOS (~> 1.29.0) |
|||
- device_info_plus (0.0.1): |
|||
- Flutter |
|||
- Flutter (1.0.0) |
|||
- shared_preferences_foundation (0.0.1): |
|||
- flutter_local_notifications (0.0.1): |
|||
- Flutter |
|||
- just_audio (0.0.1): |
|||
- Flutter |
|||
- FlutterMacOS |
|||
- MicrosoftCognitiveServicesSpeech-iOS (1.29.0) |
|||
- path_provider_foundation (0.0.1): |
|||
- Flutter |
|||
- FlutterMacOS |
|||
- permission_handler_apple (9.3.0): |
|||
- Flutter |
|||
- record_darwin (1.0.0): |
|||
- Flutter |
|||
- sqflite_darwin (0.0.4): |
|||
- Flutter |
|||
- FlutterMacOS |
|||
|
|||
DEPENDENCIES: |
|||
- audio_service (from `.symlinks/plugins/audio_service/darwin`) |
|||
- audio_session (from `.symlinks/plugins/audio_session/ios`) |
|||
- azure_speech_recognition_null_safety (from `.symlinks/plugins/azure_speech_recognition_null_safety/ios`) |
|||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) |
|||
- Flutter (from `Flutter`) |
|||
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) |
|||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) |
|||
- just_audio (from `.symlinks/plugins/just_audio/darwin`) |
|||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) |
|||
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) |
|||
- record_darwin (from `.symlinks/plugins/record_darwin/ios`) |
|||
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) |
|||
|
|||
SPEC REPOS: |
|||
trunk: |
|||
- MicrosoftCognitiveServicesSpeech-iOS |
|||
|
|||
EXTERNAL SOURCES: |
|||
audio_service: |
|||
:path: ".symlinks/plugins/audio_service/darwin" |
|||
audio_session: |
|||
:path: ".symlinks/plugins/audio_session/ios" |
|||
azure_speech_recognition_null_safety: |
|||
:path: ".symlinks/plugins/azure_speech_recognition_null_safety/ios" |
|||
device_info_plus: |
|||
:path: ".symlinks/plugins/device_info_plus/ios" |
|||
Flutter: |
|||
:path: Flutter |
|||
shared_preferences_foundation: |
|||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" |
|||
flutter_local_notifications: |
|||
:path: ".symlinks/plugins/flutter_local_notifications/ios" |
|||
just_audio: |
|||
:path: ".symlinks/plugins/just_audio/darwin" |
|||
path_provider_foundation: |
|||
:path: ".symlinks/plugins/path_provider_foundation/darwin" |
|||
permission_handler_apple: |
|||
:path: ".symlinks/plugins/permission_handler_apple/ios" |
|||
record_darwin: |
|||
:path: ".symlinks/plugins/record_darwin/ios" |
|||
sqflite_darwin: |
|||
:path: ".symlinks/plugins/sqflite_darwin/darwin" |
|||
|
|||
SPEC CHECKSUMS: |
|||
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd |
|||
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 |
|||
azure_speech_recognition_null_safety: aed1d7ededc73a1d0b7a342b1f4803ea166e2747 |
|||
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 |
|||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 |
|||
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7 |
|||
flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 |
|||
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed |
|||
MicrosoftCognitiveServicesSpeech-iOS: 0de20a8ebb6bbd87decaeaa9a707dd40830106a6 |
|||
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 |
|||
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d |
|||
record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a |
|||
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 |
|||
|
|||
PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 |
|||
PODFILE CHECKSUM: 359b0564325fb2f8842a19a47bd7e63cbdcd9c04 |
|||
|
|||
COCOAPODS: 1.16.2 |
|||
|
|||
@ -0,0 +1,167 @@ |
|||
// voice_recognition_service.dart |
|||
|
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'volcano_asr_service.dart'; |
|||
import 'package:get/get.dart'; |
|||
|
|||
class VoiceRecognitionService extends GetxService { |
|||
VolcanoAsrService? _asrService; |
|||
bool _isInitialized = false; |
|||
bool _isStoppingRecognition = false; |
|||
|
|||
final isRecognizing = false.obs; |
|||
|
|||
Function(String)? onRecognitionResult; |
|||
Function(String)? onError; |
|||
Function()? onRecognitionComplete; |
|||
Function(String)? onSentenceComplete; |
|||
Function(String)? onConnectionStatusChanged; |
|||
|
|||
VoiceRecognitionService(); |
|||
|
|||
void _logError(String message, [dynamic error, StackTrace? stackTrace]) { |
|||
debugPrint('🔴 VoiceRecognitionService Error: $message'); |
|||
if (error != null) debugPrint('Error details: $error'); |
|||
if (stackTrace != null) debugPrint('Stack trace: $stackTrace'); |
|||
} |
|||
|
|||
@override |
|||
Future<void> onInit() async { |
|||
super.onInit(); |
|||
if (!_isInitialized) { |
|||
try { |
|||
await _initializeServices(); |
|||
_isInitialized = true; |
|||
debugPrint('✅ VoiceRecognitionService initialized successfully'); |
|||
} catch (e, stackTrace) { |
|||
_logError('Failed to initialize services', e, stackTrace); |
|||
rethrow; |
|||
} |
|||
} |
|||
} |
|||
|
|||
Future<void> _initializeServices() async { |
|||
try { |
|||
final appKey = dotenv.env['VOLCANO_APP_KEY']; |
|||
final accessKey = dotenv.env['VOLCANO_ACCESS_KEY']; |
|||
if (appKey == null || accessKey == null) { |
|||
throw Exception('Volcano ASR service configuration is missing. Please check your .env file.'); |
|||
} |
|||
_asrService = VolcanoAsrService( |
|||
appKey: appKey, |
|||
accessKey: accessKey, |
|||
onResult: _handleRecognitionResult, |
|||
onError: _handleError, |
|||
onConnectionStatusChanged: _handleConnectionStatusChanged, |
|||
); |
|||
await _setupServices(); |
|||
} catch (e, stackTrace) { |
|||
_logError('Error during service initialization', e, stackTrace); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
Future<void> _setupServices() async { |
|||
try { |
|||
_asrService?.onResult = (result) { |
|||
debugPrint('ASR Result: $result'); |
|||
onRecognitionResult?.call(result); |
|||
onSentenceComplete?.call(result); |
|||
}; |
|||
_asrService?.onError = (error) { |
|||
_logError('ASR service error', error); |
|||
onError?.call('ASR error: $error'); |
|||
}; |
|||
_asrService?.onConnectionStatusChanged = (status) { |
|||
final isConnected = status == 'connected'; |
|||
debugPrint('ASR Connection status: $status'); |
|||
if (!isConnected && isRecognizing.value && !_isStoppingRecognition) { |
|||
stopRecognition(); |
|||
onRecognitionComplete?.call(); |
|||
} |
|||
onConnectionStatusChanged?.call(status); |
|||
}; |
|||
} catch (e, stackTrace) { |
|||
_logError('Error during services setup', e, stackTrace); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
Future<void> startRecognition() async { |
|||
if (isRecognizing.value || _asrService == null) { |
|||
debugPrint('⚠️ Cannot start recognition: ${isRecognizing.value ? 'Already recognizing' : 'ASR service not initialized'}'); |
|||
return; |
|||
} |
|||
try { |
|||
_isStoppingRecognition = false; |
|||
debugPrint('Starting voice recognition...'); |
|||
await _asrService?.startRecognition(); |
|||
isRecognizing.value = true; |
|||
} catch (e, stackTrace) { |
|||
_logError('Failed to start recognition', e, stackTrace); |
|||
onError?.call('Failed to start recognition: $e'); |
|||
await stopRecognition(); |
|||
} |
|||
} |
|||
|
|||
Future<void> stopRecognition() async { |
|||
if (!isRecognizing.value || _asrService == null || _isStoppingRecognition) return; |
|||
try { |
|||
_isStoppingRecognition = true; |
|||
debugPrint('Stopping voice recognition...'); |
|||
isRecognizing.value = false; |
|||
await _asrService?.stopRecognition().timeout( |
|||
const Duration(seconds: 3), |
|||
onTimeout: () { |
|||
debugPrint('ASR service stop timeout, forcing cleanup'); |
|||
_cleanupResources(); |
|||
}, |
|||
); |
|||
final complete = onRecognitionComplete; |
|||
if (complete != null) { |
|||
Future.microtask(complete); |
|||
} |
|||
} catch (e, stackTrace) { |
|||
_logError('Failed to stop recognition', e, stackTrace); |
|||
onError?.call('Failed to stop recognition: $e'); |
|||
_cleanupResources(); |
|||
} finally { |
|||
_isStoppingRecognition = false; |
|||
} |
|||
} |
|||
|
|||
void _cleanupResources() { |
|||
if (_asrService != null) { |
|||
_asrService!.onResult = null; |
|||
_asrService!.onError = null; |
|||
_asrService!.onConnectionStatusChanged = null; |
|||
} |
|||
isRecognizing.value = false; |
|||
_isStoppingRecognition = false; |
|||
} |
|||
|
|||
void _handleRecognitionResult(String result) { |
|||
onRecognitionResult?.call(result); |
|||
} |
|||
|
|||
void _handleError(String error) { |
|||
onError?.call(error); |
|||
} |
|||
|
|||
void _handleConnectionStatusChanged(String status) { |
|||
onConnectionStatusChanged?.call(status); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
debugPrint('Cleaning up VoiceRecognitionService...'); |
|||
stopRecognition().then((_) { |
|||
debugPrint('VoiceRecognitionService cleanup completed'); |
|||
super.onClose(); |
|||
}).catchError((e) { |
|||
debugPrint('Error during VoiceRecognitionService cleanup: $e'); |
|||
super.onClose(); |
|||
}); |
|||
} |
|||
} |
|||
@ -0,0 +1,384 @@ |
|||
import 'dart:async'; |
|||
import 'dart:convert'; |
|||
import 'dart:io'; |
|||
import 'package:uuid/uuid.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import 'package:record/record.dart'; |
|||
|
|||
/// 自定义异常类 |
|||
class AsrServiceException implements Exception { |
|||
final String message; |
|||
AsrServiceException(this.message); |
|||
|
|||
@override |
|||
String toString() => message; |
|||
} |
|||
|
|||
/// Volcano ASR 服务,负责音频采集、数据打包、WebSocket 连接和数据传输 |
|||
class VolcanoAsrService extends GetxService { |
|||
// 服务地址 |
|||
static const String _baseUrl = 'wss://openspeech.bytedance.com/api/v3/sauc/bigmodel'; |
|||
|
|||
// 配置参数 |
|||
final String appKey; |
|||
final String accessKey; |
|||
|
|||
// WebSocket 与录音相关 |
|||
WebSocket? _webSocket; |
|||
AudioRecorder? _audioRecorder; |
|||
StreamSubscription<List<int>>? _audioStreamSubscription; |
|||
|
|||
// 状态管理 |
|||
bool _isConnected = false; |
|||
bool _isRecording = false; |
|||
int _currentSequence = 1; |
|||
int _serverSequence = 0; // 添加服务器序列号跟踪 |
|||
bool _isInitialized = false; |
|||
bool _waitingForInitResponse = false; |
|||
Completer<void>? _initCompleter; |
|||
|
|||
// 回调 |
|||
Function(String)? onResult; |
|||
Function(String)? onError; |
|||
Function(String)? onConnectionStatusChanged; |
|||
|
|||
VolcanoAsrService({ |
|||
required this.appKey, |
|||
required this.accessKey, |
|||
this.onResult, |
|||
this.onError, |
|||
this.onConnectionStatusChanged, |
|||
}); |
|||
|
|||
Future<void> _connectWebSocket() async { |
|||
if (_webSocket != null) { |
|||
debugPrint('⚠️ ASR: WebSocket already exists'); |
|||
return; |
|||
} |
|||
|
|||
final connectId = const Uuid().v4(); |
|||
final headers = { |
|||
'X-Api-App-Key': appKey, |
|||
'X-Api-Access-Key': accessKey, |
|||
'X-Api-Resource-Id': 'volc.bigasr.sauc.duration', |
|||
'X-Api-Connect-Id': connectId, |
|||
'Connection': 'Upgrade', // 添加 WebSocket 升级头 |
|||
'Upgrade': 'websocket', |
|||
}; |
|||
|
|||
try { |
|||
_webSocket = await WebSocket.connect( |
|||
_baseUrl, |
|||
headers: headers, |
|||
protocols: ['binary'], // 指定二进制协议 |
|||
); |
|||
debugPrint('ℹ️ ASR: WebSocket connected'); |
|||
|
|||
_webSocket!.listen( |
|||
_handleWebSocketMessage, |
|||
onError: (error) { |
|||
debugPrint('🔴 ASR: WebSocket error: $error'); |
|||
onError?.call('WebSocket error: $error'); |
|||
_handleDisconnect(); |
|||
}, |
|||
onDone: () { |
|||
debugPrint('ℹ️ ASR: WebSocket closed'); |
|||
if (_waitingForInitResponse) { |
|||
_initCompleter?.completeError('WebSocket closed during initialization'); |
|||
_initCompleter = null; |
|||
} |
|||
_handleDisconnect(); |
|||
}, |
|||
cancelOnError: false, // 不要在错误时自动取消 |
|||
); |
|||
|
|||
_isConnected = true; |
|||
_currentSequence = 1; // 重置序列号 |
|||
_isInitialized = false; // 重置初始化状态 |
|||
onConnectionStatusChanged?.call('connected'); |
|||
|
|||
// 发送初始化请求前等待一下 |
|||
await Future.delayed(const Duration(milliseconds: 100)); |
|||
await _sendInitRequest(connectId); |
|||
|
|||
} catch (e) { |
|||
debugPrint('🔴 ASR: Failed to connect: $e'); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
Future<void> _sendInitRequest(String requestId) async { |
|||
_waitingForInitResponse = true; |
|||
_initCompleter = Completer<void>(); |
|||
|
|||
final initRequest = { |
|||
'audio': { |
|||
'format': 'raw', |
|||
'sample_rate': 16000, |
|||
'channels': 1, |
|||
'encoding': 'pcm', |
|||
'bit_depth': 16, |
|||
}, |
|||
'request': { |
|||
'request_id': requestId, |
|||
'language': 'zh', |
|||
'model_name': 'bigmodel', |
|||
'enable_punc': true, |
|||
'enable_itn': true, |
|||
'enable_voice_detection': true, |
|||
} |
|||
}; |
|||
|
|||
final jsonBytes = utf8.encode(json.encode(initRequest)); |
|||
debugPrint('ℹ️ ASR: Sending init request: ${json.encode(initRequest)}'); |
|||
|
|||
final header = Uint8List(4) |
|||
..[0] = 0x11 // version=1, header_size=1 |
|||
..[1] = 0x11 // message_type=1(FULL_CLIENT_REQUEST), flags=1(POS_SEQUENCE) |
|||
..[2] = 0x10 // serialization=1(JSON), compression=0 |
|||
..[3] = 0x00; // reserved |
|||
|
|||
final seqBytes = ByteData(4)..setInt32(0, _currentSequence, Endian.big); |
|||
final sizeBytes = ByteData(4)..setInt32(0, jsonBytes.length, Endian.big); |
|||
|
|||
final fullRequest = Uint8List(header.length + 8 + jsonBytes.length) |
|||
..setAll(0, header) |
|||
..setAll(4, seqBytes.buffer.asUint8List()) |
|||
..setAll(8, sizeBytes.buffer.asUint8List()) |
|||
..setAll(12, jsonBytes); |
|||
|
|||
_webSocket?.add(fullRequest); |
|||
debugPrint('ℹ️ ASR: Init request sent with sequence: $_currentSequence'); |
|||
|
|||
// 设置超时 |
|||
Future.delayed(const Duration(seconds: 10), () { |
|||
if (_waitingForInitResponse) { |
|||
debugPrint('⚠️ ASR: Init request timeout after 10 seconds'); |
|||
_waitingForInitResponse = false; |
|||
_initCompleter?.completeError('Init request timeout'); |
|||
_initCompleter = null; |
|||
} |
|||
}); |
|||
|
|||
try { |
|||
await _initCompleter?.future; |
|||
} catch (e) { |
|||
debugPrint('🔴 ASR: Init request failed: $e'); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
void _handleWebSocketMessage(dynamic message) { |
|||
debugPrint('ℹ️ ASR: Received message type: ${message.runtimeType}'); |
|||
|
|||
if (message is String) { |
|||
debugPrint('ℹ️ ASR: Received string message: $message'); |
|||
try { |
|||
final data = json.decode(message); |
|||
if (data is Map) { |
|||
if (data['error'] != null) { |
|||
debugPrint('🔴 ASR: Server error: ${data['error']}'); |
|||
if (_waitingForInitResponse) { |
|||
_waitingForInitResponse = false; |
|||
_initCompleter?.completeError(data['error']); |
|||
_initCompleter = null; |
|||
} |
|||
onError?.call(data['error']); |
|||
return; |
|||
} |
|||
} |
|||
} catch (e) { |
|||
debugPrint('⚠️ ASR: Failed to parse string message: $e'); |
|||
} |
|||
} |
|||
|
|||
if (message is! Uint8List || message.length < 12) { |
|||
debugPrint('⚠️ ASR: Invalid binary message format'); |
|||
return; |
|||
} |
|||
|
|||
final version = message[0] >> 4; |
|||
final headerSize = message[0] & 0x0F; |
|||
final messageType = message[1] >> 4; |
|||
final flags = message[1] & 0x0F; |
|||
final serialization = message[2] >> 4; |
|||
final compression = message[2] & 0x0F; |
|||
|
|||
debugPrint('ℹ️ ASR: Binary message: version=$version, headerSize=$headerSize, type=$messageType, flags=$flags, serialization=$serialization, compression=$compression'); |
|||
|
|||
final sequence = ByteData.view(message.buffer, 4, 4).getInt32(0, Endian.big); |
|||
debugPrint('ℹ️ ASR: Message sequence: $sequence'); |
|||
_serverSequence = sequence; |
|||
|
|||
final payloadSize = ByteData.view(message.buffer, 8, 4).getInt32(0, Endian.big); |
|||
if (message.length < 12 + payloadSize) { |
|||
debugPrint('⚠️ ASR: Incomplete message received'); |
|||
return; |
|||
} |
|||
|
|||
if (messageType == 0x0F) { // INIT_RESPONSE |
|||
if (_waitingForInitResponse) { |
|||
debugPrint('ℹ️ ASR: Received init response (type 15)'); |
|||
if (payloadSize > 0) { |
|||
final payload = message.sublist(12, 12 + payloadSize); |
|||
final jsonStr = utf8.decode(payload); |
|||
debugPrint('ℹ️ ASR: Init response payload: $jsonStr'); |
|||
try { |
|||
final response = json.decode(jsonStr); |
|||
if (response is Map) { |
|||
if (response['error'] != null) { |
|||
final error = response['error']; |
|||
debugPrint('🔴 ASR: Init response error: $error'); |
|||
_waitingForInitResponse = false; |
|||
_initCompleter?.completeError(error); |
|||
_initCompleter = null; |
|||
return; |
|||
} |
|||
debugPrint('ℹ️ ASR: Init response success'); |
|||
_waitingForInitResponse = false; |
|||
_currentSequence = (_serverSequence + 1); |
|||
_isInitialized = true; |
|||
_initCompleter?.complete(); |
|||
_initCompleter = null; |
|||
return; |
|||
} |
|||
} catch (e) { |
|||
debugPrint('⚠️ ASR: Failed to parse init response: $e'); |
|||
} |
|||
} else { |
|||
debugPrint('ℹ️ ASR: Empty init response success'); |
|||
_waitingForInitResponse = false; |
|||
_currentSequence = (_serverSequence + 1); |
|||
_isInitialized = true; |
|||
_initCompleter?.complete(); |
|||
_initCompleter = null; |
|||
return; |
|||
} |
|||
} |
|||
} else if (messageType == 0x09) { // FULL_SERVER_RESPONSE |
|||
final payload = message.sublist(12, 12 + payloadSize); |
|||
final jsonStr = utf8.decode(payload); |
|||
debugPrint('ℹ️ ASR: Received binary payload: $jsonStr'); |
|||
|
|||
try { |
|||
final response = json.decode(jsonStr); |
|||
if (response is Map && response['result'] != null) { |
|||
final result = response['result']; |
|||
if (result['text'] != null && result['text'].isNotEmpty) { |
|||
debugPrint('ℹ️ ASR: Result: ${result['text']}'); |
|||
onResult?.call(result['text']); |
|||
} |
|||
} |
|||
} catch (e) { |
|||
debugPrint('⚠️ ASR: Failed to parse binary payload: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
Future<void> _startRecording() async { |
|||
_audioRecorder ??= AudioRecorder(); |
|||
|
|||
try { |
|||
if (!await _audioRecorder!.hasPermission()) { |
|||
throw Exception('Microphone permission denied'); |
|||
} |
|||
|
|||
const config = RecordConfig( |
|||
encoder: AudioEncoder.pcm16bits, |
|||
bitRate: 256000, |
|||
sampleRate: 16000, |
|||
numChannels: 1, |
|||
); |
|||
|
|||
// 启动录音流 |
|||
final stream = await _audioRecorder!.startStream(config); |
|||
_isRecording = true; |
|||
|
|||
// 监听音频流 |
|||
_audioStreamSubscription = stream.listen( |
|||
(data) { |
|||
if (_isRecording && _isConnected) { |
|||
_sendAudioData(data, false); |
|||
} |
|||
}, |
|||
onError: (error) { |
|||
debugPrint('🔴 ASR: Stream error: $error'); |
|||
_stopRecording(); |
|||
}, |
|||
); |
|||
|
|||
} catch (e) { |
|||
debugPrint('🔴 ASR: Recording error: $e'); |
|||
await _stopRecording(); |
|||
rethrow; |
|||
} |
|||
} |
|||
|
|||
void _sendAudioData(List<int> audioData, bool isLast) { |
|||
if (!_isConnected || _webSocket == null || !_isInitialized) { |
|||
debugPrint('⚠️ ASR: Cannot send audio data: not ready'); |
|||
return; |
|||
} |
|||
|
|||
final header = Uint8List(4) |
|||
..[0] = 0x11 // version=1, header_size=1 |
|||
..[1] = isLast ? 0x23 : 0x21 // message_type=2(AUDIO_ONLY), flags=3/1 |
|||
..[2] = 0x00 // no serialization, no compression |
|||
..[3] = 0x00; // reserved |
|||
|
|||
final seqBytes = ByteData(4)..setInt32(0, _currentSequence, Endian.big); |
|||
final sizeBytes = ByteData(4)..setInt32(0, audioData.length, Endian.big); |
|||
|
|||
final packet = Uint8List(12 + audioData.length) |
|||
..setAll(0, header) |
|||
..setAll(4, seqBytes.buffer.asUint8List()) |
|||
..setAll(8, sizeBytes.buffer.asUint8List()) |
|||
..setAll(12, audioData); |
|||
|
|||
_webSocket?.add(packet); |
|||
debugPrint('🔍 ASR: Sent audio data with sequence: $_currentSequence'); |
|||
_currentSequence++; // 递增序列号 |
|||
} |
|||
|
|||
void _handleDisconnect() { |
|||
_webSocket = null; |
|||
_isConnected = false; |
|||
_isInitialized = false; // 重置初始化状态 |
|||
_currentSequence = 1; // 重置序列号 |
|||
onConnectionStatusChanged?.call('disconnected'); |
|||
_stopRecording(); |
|||
} |
|||
|
|||
Future<void> _stopRecording() async { |
|||
_isRecording = false; |
|||
|
|||
try { |
|||
await _audioStreamSubscription?.cancel(); |
|||
_audioStreamSubscription = null; |
|||
|
|||
await _audioRecorder?.stop(); |
|||
_audioRecorder = null; |
|||
} catch (e) { |
|||
debugPrint('⚠️ ASR: Error stopping recording: $e'); |
|||
} |
|||
} |
|||
|
|||
Future<void> startRecognition() async { |
|||
await _connectWebSocket(); |
|||
await _startRecording(); |
|||
} |
|||
|
|||
Future<void> stopRecognition() async { |
|||
await _stopRecording(); |
|||
_webSocket?.close(); |
|||
_handleDisconnect(); |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
_audioStreamSubscription?.cancel(); |
|||
stopRecognition(); |
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -1,9 +1,16 @@ |
|||
import 'package:get/get.dart'; |
|||
import '../controllers/chat_controller.dart'; |
|||
import '../../../data/services/volcano_tts_service.dart'; |
|||
|
|||
class ChatBinding implements Bindings { |
|||
@override |
|||
void dependencies() { |
|||
// Ensure TTS service is registered first |
|||
if (!Get.isRegistered<VolcanoTtsService>()) { |
|||
Get.put(VolcanoTtsService()); |
|||
} |
|||
|
|||
// Create new controller instance |
|||
Get.put(ChatController()); |
|||
} |
|||
} |
|||
@ -0,0 +1,128 @@ |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter/foundation.dart'; |
|||
import '../../../data/services/voice_recognition_service.dart'; |
|||
|
|||
class VoiceInputController extends GetxController { |
|||
final VoiceRecognitionService _recognitionService; |
|||
|
|||
// Observable states |
|||
final isConnecting = true.obs; |
|||
final isMuted = false.obs; |
|||
final isRecording = false.obs; |
|||
final recognizedText = ''.obs; |
|||
|
|||
// Callbacks |
|||
final Function(String) onRecordingResult; |
|||
final VoidCallback onClosePanel; |
|||
final Function(String) onRecognizing; |
|||
|
|||
VoiceInputController({ |
|||
required this.onRecordingResult, |
|||
required this.onClosePanel, |
|||
required this.onRecognizing, |
|||
}) : _recognitionService = Get.find<VoiceRecognitionService>(); |
|||
|
|||
@override |
|||
void onInit() { |
|||
super.onInit(); |
|||
_setupRecognitionCallbacks(); |
|||
_initializeRecognition(); |
|||
} |
|||
|
|||
void _setupRecognitionCallbacks() { |
|||
_recognitionService.onRecognitionResult = (result) { |
|||
if (isMuted.value) return; |
|||
recognizedText.value = result; |
|||
onRecognizing(result); |
|||
}; |
|||
|
|||
_recognitionService.onError = (error) { |
|||
Get.snackbar( |
|||
'Error', |
|||
'语音识别失败: $error', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
); |
|||
stopRecording(); |
|||
}; |
|||
|
|||
_recognitionService.onRecognitionComplete = () { |
|||
stopRecording(); |
|||
if (!isMuted.value && recognizedText.value.isNotEmpty) { |
|||
onRecordingResult(recognizedText.value); |
|||
recognizedText.value = ''; |
|||
startRecording(); |
|||
} |
|||
}; |
|||
} |
|||
|
|||
Future<void> _initializeRecognition() async { |
|||
try { |
|||
isConnecting.value = true; |
|||
await _recognitionService.startRecognition(); |
|||
isConnecting.value = false; |
|||
} catch (e) { |
|||
Get.snackbar( |
|||
'Error', |
|||
'初始化语音识别失败: $e', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
); |
|||
onClosePanel(); |
|||
} |
|||
} |
|||
|
|||
Future<void> startRecording() async { |
|||
try { |
|||
await _recognitionService.startRecognition(); |
|||
isRecording.value = true; |
|||
recognizedText.value = ''; |
|||
} catch (e) { |
|||
Get.snackbar( |
|||
'Error', |
|||
'启动语音识别失败: $e', |
|||
snackPosition: SnackPosition.BOTTOM, |
|||
); |
|||
} |
|||
} |
|||
|
|||
Future<void> stopRecording() async { |
|||
if (!isRecording.value) return; |
|||
|
|||
try { |
|||
await _recognitionService.stopRecognition(); |
|||
} catch (e) { |
|||
debugPrint('Error stopping recording: $e'); |
|||
} finally { |
|||
isRecording.value = false; |
|||
} |
|||
} |
|||
|
|||
void toggleMute() { |
|||
isMuted.value = !isMuted.value; |
|||
} |
|||
|
|||
@override |
|||
void onClose() { |
|||
// 停止录音 |
|||
stopRecording(); |
|||
|
|||
// 清理回调 |
|||
_recognitionService.onRecognitionResult = null; |
|||
_recognitionService.onError = null; |
|||
_recognitionService.onRecognitionComplete = null; |
|||
|
|||
// 重置状态 |
|||
isConnecting.value = false; |
|||
isMuted.value = false; |
|||
isRecording.value = false; |
|||
recognizedText.value = ''; |
|||
|
|||
// 确保语音识别服务停止 |
|||
try { |
|||
_recognitionService.stopRecognition(); |
|||
} catch (e) { |
|||
debugPrint('Error stopping recognition service: $e'); |
|||
} |
|||
|
|||
super.onClose(); |
|||
} |
|||
} |
|||
@ -0,0 +1,130 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_screenutil/flutter_screenutil.dart'; |
|||
import '../controllers/voice_input_controller.dart'; |
|||
|
|||
class VoiceInputPanel extends GetView<VoiceInputController> { |
|||
const VoiceInputPanel({ |
|||
Key? key, |
|||
required this.onRecordingResult, |
|||
required this.onClose, |
|||
required this.onRecognizing, |
|||
}) : super(key: key); |
|||
|
|||
final Function(String) onRecordingResult; |
|||
final VoidCallback onClose; |
|||
final Function(String) onRecognizing; |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Container( |
|||
decoration: BoxDecoration( |
|||
gradient: LinearGradient( |
|||
begin: Alignment.topCenter, |
|||
end: Alignment.bottomCenter, |
|||
colors: [ |
|||
Colors.white, |
|||
Colors.pink[50]!.withOpacity(0.3), |
|||
], |
|||
), |
|||
), |
|||
child: Container( |
|||
height: 120.h + MediaQuery.of(context).padding.bottom, |
|||
padding: EdgeInsets.only( |
|||
left: 40.w, |
|||
right: 40.w, |
|||
top: 12.h, |
|||
bottom: 12.h + MediaQuery.of(context).padding.bottom, |
|||
), |
|||
child: Row( |
|||
children: [ |
|||
// 左侧静音开关 |
|||
Obx(() => Container( |
|||
width: 52.w, |
|||
height: 52.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: controller.isMuted.value ? Colors.grey[200] : Colors.white, |
|||
border: Border.all( |
|||
color: controller.isMuted.value ? Colors.grey[400]! : Colors.grey[300]!, |
|||
width: 1, |
|||
), |
|||
), |
|||
child: IconButton( |
|||
padding: EdgeInsets.zero, |
|||
icon: Icon( |
|||
controller.isMuted.value ? Icons.mic_off_rounded : Icons.mic_rounded, |
|||
color: controller.isConnecting.value ? Colors.grey : Colors.black87, |
|||
size: 28.sp, |
|||
), |
|||
onPressed: controller.isConnecting.value ? null : controller.toggleMute, |
|||
), |
|||
)), |
|||
SizedBox(width: 20.w), |
|||
// 中间文字 |
|||
Expanded( |
|||
child: Center( |
|||
child: Obx(() => Row( |
|||
mainAxisAlignment: MainAxisAlignment.center, |
|||
children: [ |
|||
Text( |
|||
controller.isConnecting.value ? '连接中...' : '你可以开始说话', |
|||
style: TextStyle( |
|||
fontSize: 16.sp, |
|||
fontWeight: FontWeight.w500, |
|||
color: controller.isConnecting.value ? Colors.grey[500] : Colors.black87, |
|||
), |
|||
), |
|||
if (controller.isConnecting.value) ...[ |
|||
SizedBox(width: 8.w), |
|||
SizedBox( |
|||
width: 32.w, |
|||
child: Row( |
|||
children: List.generate( |
|||
3, |
|||
(index) => Container( |
|||
width: 4.w, |
|||
height: 4.w, |
|||
margin: EdgeInsets.only(right: 4.w), |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Colors.grey[400], |
|||
), |
|||
), |
|||
), |
|||
), |
|||
), |
|||
], |
|||
], |
|||
)), |
|||
), |
|||
), |
|||
SizedBox(width: 20.w), |
|||
// 右侧关闭按钮 |
|||
Container( |
|||
width: 52.w, |
|||
height: 52.w, |
|||
decoration: BoxDecoration( |
|||
shape: BoxShape.circle, |
|||
color: Colors.white, |
|||
border: Border.all( |
|||
color: Colors.grey[300]!, |
|||
width: 1, |
|||
), |
|||
), |
|||
child: IconButton( |
|||
padding: EdgeInsets.zero, |
|||
icon: Icon( |
|||
Icons.close_rounded, |
|||
color: Colors.black87, |
|||
size: 28.sp, |
|||
), |
|||
onPressed: onClose, |
|||
), |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue