Преглед изворни кода

feat(macos+audio): 完善macOS端语音识别权限与音频传输逻辑

1. 为macOS编译配置添加麦克风权限相关的权限配置与描述文案
2. 重构音频捕获服务:改用直接读取音频缓冲区而非base64编码传输
3. 新增websocket二进制数据发送支持,替换原有的文本传输方式
4. 简化权限申请逻辑,移除依赖的permission_handler包
5. 添加实时音频音量监听功能
wenhongquan пре 3 недеља
родитељ
комит
f05b0139b0

+ 8 - 6
flutter_asr_client/lib/pages/recording/notifiers/recording_notifier.dart

@@ -214,16 +214,15 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
 
   void _startAudioSendTimer() {
     _audioSendTimer?.cancel();
-    _audioSendTimer = Timer.periodic(AppConstants.audioSendInterval, (_) async {
+    _audioSendTimer = Timer.periodic(AppConstants.audioSendInterval, (_) {
       if (!(state.value?.isRecording ?? false) || _isSendingAudio) return;
       _isSendingAudio = true;
       try {
-        final chunk = await _audioCaptureService.readLatestChunkAsBase64();
-        if (chunk.isNotEmpty) {
-          _webSocketService.send(AudioMessage(data: chunk));
+        final bytes = _audioCaptureService.takeBuffer();
+        if (bytes != null && bytes.isNotEmpty) {
+          _webSocketService.sendBinary(bytes);
         }
       } on Exception catch (_) {
-        // Swallow single chunk errors; the next tick will retry.
       } finally {
         _isSendingAudio = false;
       }
@@ -239,11 +238,14 @@ final class RecordingNotifier extends AutoDisposeAsyncNotifier<RecordingState> {
 
   void _listenToWebSocketMessages() {
     _webSocketService.messageStream.listen((message) {
+      print('[ws] msg: ${message.runtimeType} ${message is TranscriptMessage ? "text='${message.text}'" : ""}');
       switch (message) {
         case ConnectedMessage():
           _updateState((s) => s.copyWith(isConnected: true));
         case TranscriptMessage(:final text):
-          _addTranscript(text);
+          if (text.isNotEmpty) {
+            _addTranscript(text);
+          }
         case ErrorMessage(:final message):
           _updateState((s) => s.copyWith(errorMessage: message));
         case ClearedMessage():

+ 29 - 39
flutter_asr_client/lib/services/audio_capture_service.dart

@@ -1,9 +1,8 @@
 import 'dart:async';
-import 'dart:convert';
 import 'dart:io';
+import 'dart:typed_data';
 
 import 'package:record/record.dart';
-import 'package:permission_handler/permission_handler.dart';
 
 import 'package:asr_client/common/constants.dart';
 
@@ -14,7 +13,7 @@ abstract interface class AudioCaptureService {
   Future<void> openSettings();
   Future<void> startRecording();
   Future<void> stopRecording();
-  Future<String> readLatestChunkAsBase64();
+  Uint8List? takeBuffer();
   bool get isRecording;
   void dispose();
 }
@@ -25,8 +24,8 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
 
   final AudioRecorder _recorder;
   final _levelController = StreamController<double>.broadcast();
-  StreamSubscription? _amplitudeSubscription;
-  String? _latestPath;
+  StreamSubscription<Amplitude>? _amplitudeSubscription;
+  String? _path;
   var _readOffset = 0;
   bool _isRecording = false;
 
@@ -38,17 +37,6 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
 
   @override
   Future<bool> requestPermission() async {
-    // Use record's native hasPermission() which reads AVAudioSession directly.
-    // permission_handler is broken on iOS 18 Simulator (always returns
-    // permanentlyDenied even when TCC.db shows granted).
-    final hasPermission = await _recorder.hasPermission();
-    if (hasPermission) return true;
-
-    // Fallback: trigger permission_handler to show the system dialog.
-    final status = await Permission.microphone.request();
-    if (status.isGranted) return true;
-
-    // Last resort: re-check record in case the dialog just granted it.
     return await _recorder.hasPermission();
   }
 
@@ -56,7 +44,7 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
   Future<bool> get isPermissionPermanentlyDenied async => false;
 
   @override
-  Future<void> openSettings() => openAppSettings();
+  Future<void> openSettings() async {}
 
   @override
   Future<void> startRecording() async {
@@ -69,8 +57,9 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
     final dir = await Directory.systemTemp.createTemp('asr_');
     final path =
         '${dir.path}/asr_buffer_${DateTime.now().millisecondsSinceEpoch}.pcm';
-    _latestPath = path;
+    _path = path;
     _readOffset = 0;
+
     await _recorder.start(
       const RecordConfig(
         encoder: AudioEncoder.pcm16bits,
@@ -80,6 +69,13 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
       path: path,
     );
 
+    _amplitudeSubscription = _recorder.onAmplitudeChanged(
+      const Duration(milliseconds: 200),
+    ).listen((amp) {
+      final normalized = ((amp.current + 160) / 160).clamp(0.0, 1.0);
+      _levelController.add(normalized);
+    });
+
     _isRecording = true;
   }
 
@@ -88,40 +84,34 @@ final class AudioCaptureServiceImpl implements AudioCaptureService {
     _amplitudeSubscription?.cancel();
     _amplitudeSubscription = null;
     if (_isRecording) {
-      _latestPath = await _recorder.stop();
+      await _recorder.stop();
       _isRecording = false;
       _levelController.add(0);
     }
   }
 
   @override
-  Future<String> readLatestChunkAsBase64() async {
-    final path = _latestPath;
-    if (path == null) return '';
-
+  Uint8List? takeBuffer() {
+    final path = _path;
+    if (path == null) return null;
     final file = File(path);
-    if (!await file.exists()) return '';
+    if (!file.existsSync()) return null;
 
     RandomAccessFile? raf;
     try {
-      raf = await file.open(mode: FileMode.read);
-      final length = await raf.length();
-      if (length <= _readOffset) return '';
+      raf = file.openSync(mode: FileMode.read);
+      final length = raf.lengthSync();
+      if (length <= _readOffset) return null;
 
       final available = length - _readOffset;
-      final count = available > AppConstants.maxAudioChunkBytes
-          ? AppConstants.maxAudioChunkBytes
-          : available;
-      if (count <= 0) return '';
-
-      await raf.setPosition(_readOffset);
-      final bytes = await raf.read(count);
-      _readOffset += count;
-      return base64Encode(bytes);
-    } on Exception catch (_) {
-      return '';
+      raf.setPositionSync(_readOffset);
+      final bytes = raf.readSync(available);
+      _readOffset += available;
+      return bytes;
+    } catch (_) {
+      return null;
     } finally {
-      await raf?.close();
+      raf?.closeSync();
     }
   }
 

+ 9 - 0
flutter_asr_client/lib/services/websocket_service.dart

@@ -20,6 +20,7 @@ abstract interface class WebSocketService {
 
   Future<void> connect(String url);
   void send(WebSocketMessage message);
+  void sendBinary(List<int> bytes);
   void disconnect();
 }
 
@@ -75,6 +76,7 @@ final class WebSocketServiceImpl implements WebSocketService {
       _channel!.stream.listen(
         (event) {
           if (event is String) {
+            print('[ws-raw] $event');
             final message = ServerMessage.fromJsonString(event);
             _messageController.add(message);
           }
@@ -139,6 +141,13 @@ final class WebSocketServiceImpl implements WebSocketService {
   }
 
   @override
+  void sendBinary(List<int> bytes) {
+    if (_channel != null && _lastState == ConnectionState.connected) {
+      _channel!.sink.add(bytes);
+    }
+  }
+
+  @override
   void disconnect() {
     _shouldReconnect = false;
     _reconnectTimer?.cancel();

+ 2 - 0
flutter_asr_client/macos/Runner/DebugProfile.entitlements

@@ -10,5 +10,7 @@
 	<true/>
 	<key>com.apple.security.network.server</key>
 	<true/>
+	<key>com.apple.security.device.audio-input</key>
+	<true/>
 </dict>
 </plist>

+ 2 - 0
flutter_asr_client/macos/Runner/Info.plist

@@ -30,5 +30,7 @@
 	<string>NSApplication</string>
 	<key>NSLocalNetworkUsageDescription</key>
 	<string>需要访问本地网络以连接语音识别服务</string>
+	<key>NSMicrophoneUsageDescription</key>
+	<string>需要使用麦克风进行语音识别</string>
 </dict>
 </plist>

+ 2 - 0
flutter_asr_client/macos/Runner/Release.entitlements

@@ -6,5 +6,7 @@
 	<true/>
 	<key>com.apple.security.network.client</key>
 	<true/>
+	<key>com.apple.security.device.audio-input</key>
+	<true/>
 </dict>
 </plist>

+ 12 - 4
flutter_asr_client/test/fakes/fake_services.dart

@@ -1,4 +1,5 @@
 import 'dart:async';
+import 'dart:typed_data';
 
 import 'package:asr_client/models/websocket_message.dart';
 import 'package:asr_client/services/audio_capture_service.dart';
@@ -54,6 +55,11 @@ class FakeWebSocketService implements ws.WebSocketService {
   }
 
   @override
+  void sendBinary(List<int> bytes) {
+    // no-op for tests
+  }
+
+  @override
   void disconnect() {
     _state = ws.ConnectionState.disconnected;
   }
@@ -67,7 +73,7 @@ class FakeWebSocketService implements ws.WebSocketService {
 class FakeAudioCaptureService implements AudioCaptureService {
   final _levelController = StreamController<double>.broadcast();
   var _recording = false;
-  String chunkData = '';
+  Uint8List? pendingBytes;
 
   @override
   Stream<double> get audioLevelStream => _levelController.stream;
@@ -95,9 +101,11 @@ class FakeAudioCaptureService implements AudioCaptureService {
   }
 
   @override
-  Future<String> readLatestChunkAsBase64() async {
-    if (chunkData.isEmpty || !_recording) return '';
-    return chunkData;
+  Uint8List? takeBuffer() {
+    if (!_recording) return null;
+    final data = pendingBytes;
+    pendingBytes = null;
+    return data;
   }
 
   @override