fix: rewrite voice test page using flutter_sound for both record and play

- Remove record package dependency, use FlutterSoundRecorder instead
- Use permission_handler for microphone permission (already in pubspec)
- Proper temp file path via path_provider
- Cleanup temp files after upload
- Single package (flutter_sound) handles both recording and playback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-02-24 05:41:10 -08:00
parent d4783a3497
commit ac0b8ee1c6
1 changed files with 94 additions and 66 deletions

View File

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