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:
parent
d4783a3497
commit
ac0b8ee1c6
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue