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 |
package com.example.deep_voice |
||||
|
|
||||
import io.flutter.embedding.android.FlutterActivity |
|
||||
import com.ryanheise.audioservice.AudioServiceActivity |
import com.ryanheise.audioservice.AudioServiceActivity |
||||
|
|
||||
class MainActivity: 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: |
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) |
- 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 |
- Flutter |
||||
- FlutterMacOS |
- FlutterMacOS |
||||
|
|
||||
DEPENDENCIES: |
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`) |
- 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: |
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: |
Flutter: |
||||
:path: Flutter |
:path: Flutter |
||||
shared_preferences_foundation: |
flutter_local_notifications: |
||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin" |
: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: |
SPEC CHECKSUMS: |
||||
|
audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd |
||||
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 |
||||
|
azure_speech_recognition_null_safety: aed1d7ededc73a1d0b7a342b1f4803ea166e2747 |
||||
|
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 |
||||
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 |
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 |
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 'package:get/get.dart'; |
||||
import '../controllers/chat_controller.dart'; |
import '../controllers/chat_controller.dart'; |
||||
|
import '../../../data/services/volcano_tts_service.dart'; |
||||
|
|
||||
class ChatBinding implements Bindings { |
class ChatBinding implements Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
|
// Ensure TTS service is registered first |
||||
|
if (!Get.isRegistered<VolcanoTtsService>()) { |
||||
|
Get.put(VolcanoTtsService()); |
||||
|
} |
||||
|
|
||||
|
// Create new controller instance |
||||
Get.put(ChatController()); |
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