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: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<VoiceTestPage> {
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<VoiceTestPage> {
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<VoiceTestPage> {
))..interceptors.addAll(base.interceptors);
}
/// Dio for JSON responses (STT).
Dio get _dioJson => ref.read(dioClientProvider);
@override
void initState() {
super.initState();
_initPlayer();
_init();
}
Future<void> _initPlayer() async {
await _audioPlayer.openPlayer();
_playerInitialized = true;
Future<void> _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<VoiceTestPage> {
sw.stop();
final bytes = resp.data as List<int>;
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<VoiceTestPage> {
// ---- Recording ----
Future<void> _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<VoiceTestPage> {
_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<void> _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<VoiceTestPage> {
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<String, dynamic>;
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<VoiceTestPage> {
_sttStatus = '错误: $e';
_sttResult = '';
});
} finally {
_cleanupFile(audioPath);
}
}
@ -179,9 +196,11 @@ class _VoiceTestPageState extends ConsumerState<VoiceTestPage> {
// 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<String, dynamic>;
final text = sttData['text'] ?? '';
@ -204,28 +223,35 @@ class _VoiceTestPageState extends ConsumerState<VoiceTestPage> {
totalSw.stop();
final audioBytes = ttsResp.data as List<int>;
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<void> _playWavBytes(Uint8List wavBytes) async {
if (!_playerInitialized) return;
await _audioPlayer.startPlayer(
Future<void> _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<VoiceTestPage> {
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// TTS Section
_buildSection(
title: 'TTS (文本转语音)',
child: Column(
@ -251,21 +276,23 @@ class _VoiceTestPageState extends ConsumerState<VoiceTestPage> {
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<VoiceTestPage> {
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<VoiceTestPage> {
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<VoiceTestPage> {
),
),
const SizedBox(height: 16),
// Round-trip Section
_buildSection(
title: 'Round-trip (STT + TTS)',
subtitle: '录音 → 识别文本 → 合成语音播放',
@ -323,9 +348,8 @@ class _VoiceTestPageState extends ConsumerState<VoiceTestPage> {
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<VoiceTestPage> {
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<VoiceTestPage> {
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,