diff --git a/it0_app/lib/features/agent_call/presentation/pages/voice_test_page.dart b/it0_app/lib/features/agent_call/presentation/pages/voice_test_page.dart index fd3be39..07724a7 100644 --- a/it0_app/lib/features/agent_call/presentation/pages/voice_test_page.dart +++ b/it0_app/lib/features/agent_call/presentation/pages/voice_test_page.dart @@ -1,15 +1,17 @@ import 'dart:async'; +import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:dio/dio.dart'; -import 'package:record/record.dart'; import 'package:flutter_sound/flutter_sound.dart'; import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as p; +import 'package:permission_handler/permission_handler.dart'; import '../../../../core/network/dio_client.dart'; /// Temporary voice I/O test page — TTS + STT + Round-trip. +/// Uses flutter_sound for both recording and playback. class VoiceTestPage extends ConsumerStatefulWidget { const VoiceTestPage({super.key}); @@ -21,9 +23,10 @@ class _VoiceTestPageState extends ConsumerState { final _ttsController = TextEditingController( text: '你好,我是IT0运维助手。很高兴为您服务!', ); - final _audioPlayer = FlutterSoundPlayer(); - final _recorder = AudioRecorder(); - bool _playerInitialized = false; + final _player = FlutterSoundPlayer(); + final _recorder = FlutterSoundRecorder(); + bool _playerReady = false; + bool _recorderReady = false; String _ttsStatus = ''; String _sttStatus = ''; @@ -33,8 +36,8 @@ class _VoiceTestPageState extends ConsumerState { bool _isRecording = false; bool _isSynthesizing = false; String _recordMode = ''; // 'stt' or 'rt' + String? _recordingPath; - /// Dio for binary responses (TTS audio). Dio get _dioBinary { final base = ref.read(dioClientProvider); return Dio(BaseOptions( @@ -46,25 +49,27 @@ class _VoiceTestPageState extends ConsumerState { ))..interceptors.addAll(base.interceptors); } - /// Dio for JSON responses (STT). Dio get _dioJson => ref.read(dioClientProvider); @override void initState() { super.initState(); - _initPlayer(); + _init(); } - Future _initPlayer() async { - await _audioPlayer.openPlayer(); - _playerInitialized = true; + Future _init() async { + await _player.openPlayer(); + _playerReady = true; + await _recorder.openRecorder(); + _recorderReady = true; + if (mounted) setState(() {}); } @override void dispose() { _ttsController.dispose(); - if (_playerInitialized) _audioPlayer.closePlayer(); - _recorder.dispose(); + if (_playerReady) _player.closePlayer(); + if (_recorderReady) _recorder.closeRecorder(); super.dispose(); } @@ -85,9 +90,10 @@ class _VoiceTestPageState extends ConsumerState { sw.stop(); final bytes = resp.data as List; setState(() { - _ttsStatus = '完成!耗时 ${sw.elapsedMilliseconds}ms,大小 ${(bytes.length / 1024).toStringAsFixed(1)}KB'; + _ttsStatus = + '完成!耗时 ${sw.elapsedMilliseconds}ms,大小 ${(bytes.length / 1024).toStringAsFixed(1)}KB'; }); - await _playWavBytes(Uint8List.fromList(bytes)); + await _playWav(Uint8List.fromList(bytes)); } catch (e) { sw.stop(); setState(() => _ttsStatus = '错误: $e'); @@ -98,19 +104,26 @@ class _VoiceTestPageState extends ConsumerState { // ---- Recording ---- Future _startRecording(String mode) async { - if (_isRecording) return; - final hasPermission = await _recorder.hasPermission(); - if (!hasPermission) { + if (_isRecording || !_recorderReady) return; + + final status = await Permission.microphone.request(); + if (!status.isGranted) { setState(() { + final msg = '麦克风权限被拒绝'; if (mode == 'stt') { - _sttStatus = '麦克风权限被拒绝'; + _sttStatus = msg; } else { - _rtStatus = '麦克风权限被拒绝'; + _rtStatus = msg; } }); return; } + + final dir = await getTemporaryDirectory(); + _recordingPath = p.join( + dir.path, 'voice_test_${DateTime.now().millisecondsSinceEpoch}.wav'); _recordMode = mode; + setState(() { _isRecording = true; if (mode == 'stt') { @@ -121,29 +134,28 @@ class _VoiceTestPageState extends ConsumerState { _rtResult = ''; } }); - final dir = await getTemporaryDirectory(); - final filePath = p.join(dir.path, 'voice_test_${DateTime.now().millisecondsSinceEpoch}.wav'); - await _recorder.start( - const RecordConfig( - encoder: AudioEncoder.wav, - sampleRate: 16000, - numChannels: 1, - bitRate: 256000, - ), - path: filePath, + + await _recorder.startRecorder( + toFile: _recordingPath, + codec: Codec.pcm16WAV, + sampleRate: 16000, + numChannels: 1, ); } Future _stopRecording() async { - if (!_isRecording) return; - final path = await _recorder.stop(); + if (!_isRecording || !_recorderReady) return; + final path = await _recorder.stopRecorder(); setState(() => _isRecording = false); - if (path == null || path.isEmpty) return; + + final filePath = path ?? _recordingPath; + if (filePath == null || filePath.isEmpty) return; + if (!File(filePath).existsSync()) return; if (_recordMode == 'stt') { - _doSTT(path); + _doSTT(filePath); } else { - _doRoundTrip(path); + _doRoundTrip(filePath); } } @@ -153,14 +165,17 @@ class _VoiceTestPageState extends ConsumerState { final sw = Stopwatch()..start(); try { final formData = FormData.fromMap({ - 'audio': await MultipartFile.fromFile(audioPath, filename: 'recording.wav'), + 'audio': + await MultipartFile.fromFile(audioPath, filename: 'recording.wav'), }); - final resp = await _dioJson.post('/api/v1/test/stt/transcribe', data: formData); + final resp = + await _dioJson.post('/api/v1/test/stt/transcribe', data: formData); sw.stop(); final data = resp.data as Map; setState(() { _sttResult = data['text'] ?? '(empty)'; - _sttStatus = '完成!耗时 ${sw.elapsedMilliseconds}ms,时长 ${data['duration'] ?? 0}s'; + _sttStatus = + '完成!耗时 ${sw.elapsedMilliseconds}ms,时长 ${data['duration'] ?? 0}s'; }); } catch (e) { sw.stop(); @@ -168,6 +183,8 @@ class _VoiceTestPageState extends ConsumerState { _sttStatus = '错误: $e'; _sttResult = ''; }); + } finally { + _cleanupFile(audioPath); } } @@ -179,9 +196,11 @@ class _VoiceTestPageState extends ConsumerState { // 1. STT final sttSw = Stopwatch()..start(); final formData = FormData.fromMap({ - 'audio': await MultipartFile.fromFile(audioPath, filename: 'recording.wav'), + 'audio': + await MultipartFile.fromFile(audioPath, filename: 'recording.wav'), }); - final sttResp = await _dioJson.post('/api/v1/test/stt/transcribe', data: formData); + final sttResp = + await _dioJson.post('/api/v1/test/stt/transcribe', data: formData); sttSw.stop(); final sttData = sttResp.data as Map; final text = sttData['text'] ?? ''; @@ -204,28 +223,35 @@ class _VoiceTestPageState extends ConsumerState { totalSw.stop(); final audioBytes = ttsResp.data as List; setState(() { - _rtResult += '\nTTS (${ttsSw.elapsedMilliseconds}ms): ${(audioBytes.length / 1024).toStringAsFixed(1)}KB'; - _rtStatus = '完成!STT=${sttSw.elapsedMilliseconds}ms + TTS=${ttsSw.elapsedMilliseconds}ms = ${totalSw.elapsedMilliseconds}ms'; + _rtResult += + '\nTTS (${ttsSw.elapsedMilliseconds}ms): ${(audioBytes.length / 1024).toStringAsFixed(1)}KB'; + _rtStatus = + '完成!STT=${sttSw.elapsedMilliseconds}ms + TTS=${ttsSw.elapsedMilliseconds}ms = ${totalSw.elapsedMilliseconds}ms'; }); - await _playWavBytes(Uint8List.fromList(audioBytes)); + await _playWav(Uint8List.fromList(audioBytes)); } catch (e) { totalSw.stop(); - setState(() { - _rtStatus = '错误: $e'; - }); + setState(() => _rtStatus = '错误: $e'); + } finally { + _cleanupFile(audioPath); } } - /// Play WAV bytes through flutter_sound player. - Future _playWavBytes(Uint8List wavBytes) async { - if (!_playerInitialized) return; - await _audioPlayer.startPlayer( + Future _playWav(Uint8List wavBytes) async { + if (!_playerReady) return; + await _player.startPlayer( fromDataBuffer: wavBytes, codec: Codec.pcm16WAV, whenFinished: () {}, ); } + void _cleanupFile(String path) { + try { + File(path).deleteSync(); + } catch (_) {} + } + @override Widget build(BuildContext context) { return Scaffold( @@ -233,7 +259,6 @@ class _VoiceTestPageState extends ConsumerState { body: ListView( padding: const EdgeInsets.all(16), children: [ - // TTS Section _buildSection( title: 'TTS (文本转语音)', child: Column( @@ -251,21 +276,23 @@ class _VoiceTestPageState extends ConsumerState { ElevatedButton.icon( onPressed: _isSynthesizing ? null : _doTTS, icon: _isSynthesizing - ? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2)) + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.volume_up), label: Text(_isSynthesizing ? '合成中...' : '合成语音'), ), if (_ttsStatus.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), - child: Text(_ttsStatus, style: TextStyle(color: Colors.grey[600], fontSize: 13)), + child: Text(_ttsStatus, + style: TextStyle(color: Colors.grey[600], fontSize: 13)), ), ], ), ), const SizedBox(height: 16), - - // STT Section _buildSection( title: 'STT (语音转文本)', child: Column( @@ -277,9 +304,8 @@ class _VoiceTestPageState extends ConsumerState { child: ElevatedButton.icon( onPressed: () {}, style: ElevatedButton.styleFrom( - backgroundColor: _isRecording && _recordMode == 'stt' - ? Colors.red - : null, + backgroundColor: + _isRecording && _recordMode == 'stt' ? Colors.red : null, ), icon: Icon(_isRecording && _recordMode == 'stt' ? Icons.mic @@ -292,7 +318,8 @@ class _VoiceTestPageState extends ConsumerState { if (_sttStatus.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), - child: Text(_sttStatus, style: TextStyle(color: Colors.grey[600], fontSize: 13)), + child: Text(_sttStatus, + style: TextStyle(color: Colors.grey[600], fontSize: 13)), ), if (_sttResult.isNotEmpty) Container( @@ -309,8 +336,6 @@ class _VoiceTestPageState extends ConsumerState { ), ), const SizedBox(height: 16), - - // Round-trip Section _buildSection( title: 'Round-trip (STT + TTS)', subtitle: '录音 → 识别文本 → 合成语音播放', @@ -323,9 +348,8 @@ class _VoiceTestPageState extends ConsumerState { child: ElevatedButton.icon( onPressed: () {}, style: ElevatedButton.styleFrom( - backgroundColor: _isRecording && _recordMode == 'rt' - ? Colors.red - : null, + backgroundColor: + _isRecording && _recordMode == 'rt' ? Colors.red : null, ), icon: Icon(_isRecording && _recordMode == 'rt' ? Icons.mic @@ -338,7 +362,8 @@ class _VoiceTestPageState extends ConsumerState { if (_rtStatus.isNotEmpty) Padding( padding: const EdgeInsets.only(top: 8), - child: Text(_rtStatus, style: TextStyle(color: Colors.grey[600], fontSize: 13)), + child: Text(_rtStatus, + style: TextStyle(color: Colors.grey[600], fontSize: 13)), ), if (_rtResult.isNotEmpty) Container( @@ -370,11 +395,14 @@ class _VoiceTestPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), + Text(title, + style: + const TextStyle(fontSize: 16, fontWeight: FontWeight.bold)), if (subtitle != null) Padding( padding: const EdgeInsets.only(top: 4), - child: Text(subtitle, style: TextStyle(fontSize: 12, color: Colors.grey[500])), + child: Text(subtitle, + style: TextStyle(fontSize: 12, color: Colors.grey[500])), ), const SizedBox(height: 12), child,