feat: add voice I/O test page in Flutter settings
- TTS: text input → Kokoro synthesis → audio playback - STT: long-press record → faster-whisper transcription - Round-trip: record → STT → TTS → playback - Added /api/v1/test route to Kong gateway for voice-service - Accessible from Settings → 语音 I/O 测试 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0bd050c80f
commit
6e832c7615
|
|
@ -0,0 +1,360 @@
|
||||||
|
import 'dart:async';
|
||||||
|
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:audioplayers/audioplayers.dart';
|
||||||
|
import '../../../../core/network/dio_client.dart';
|
||||||
|
|
||||||
|
/// Temporary voice I/O test page — TTS + STT + Round-trip.
|
||||||
|
class VoiceTestPage extends ConsumerStatefulWidget {
|
||||||
|
const VoiceTestPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
ConsumerState<VoiceTestPage> createState() => _VoiceTestPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VoiceTestPageState extends ConsumerState<VoiceTestPage> {
|
||||||
|
final _ttsController = TextEditingController(
|
||||||
|
text: '你好,我是IT0运维助手。很高兴为您服务!',
|
||||||
|
);
|
||||||
|
final _audioPlayer = AudioPlayer();
|
||||||
|
final _recorder = AudioRecorder();
|
||||||
|
|
||||||
|
String _ttsStatus = '';
|
||||||
|
String _sttStatus = '';
|
||||||
|
String _sttResult = '';
|
||||||
|
String _rtStatus = '';
|
||||||
|
String _rtResult = '';
|
||||||
|
bool _isRecording = false;
|
||||||
|
bool _isSynthesizing = false;
|
||||||
|
String _recordMode = ''; // 'stt' or 'rt'
|
||||||
|
|
||||||
|
/// Dio for binary responses (TTS audio).
|
||||||
|
Dio get _dioBinary {
|
||||||
|
final base = ref.read(dioClientProvider);
|
||||||
|
return Dio(BaseOptions(
|
||||||
|
baseUrl: base.options.baseUrl,
|
||||||
|
headers: Map.from(base.options.headers),
|
||||||
|
connectTimeout: const Duration(seconds: 30),
|
||||||
|
receiveTimeout: const Duration(seconds: 60),
|
||||||
|
responseType: ResponseType.bytes,
|
||||||
|
))..interceptors.addAll(base.interceptors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dio for JSON responses (STT).
|
||||||
|
Dio get _dioJson => ref.read(dioClientProvider);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_ttsController.dispose();
|
||||||
|
_audioPlayer.dispose();
|
||||||
|
_recorder.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- TTS ----
|
||||||
|
Future<void> _doTTS() async {
|
||||||
|
final text = _ttsController.text.trim();
|
||||||
|
if (text.isEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_isSynthesizing = true;
|
||||||
|
_ttsStatus = '合成中...';
|
||||||
|
});
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final resp = await _dioBinary.get(
|
||||||
|
'/api/v1/test/tts/synthesize',
|
||||||
|
queryParameters: {'text': text},
|
||||||
|
);
|
||||||
|
sw.stop();
|
||||||
|
final bytes = resp.data as List<int>;
|
||||||
|
setState(() {
|
||||||
|
_ttsStatus = '完成!耗时 ${sw.elapsedMilliseconds}ms,大小 ${(bytes.length / 1024).toStringAsFixed(1)}KB';
|
||||||
|
});
|
||||||
|
await _audioPlayer.play(BytesSource(Uint8List.fromList(bytes)));
|
||||||
|
} catch (e) {
|
||||||
|
sw.stop();
|
||||||
|
setState(() => _ttsStatus = '错误: $e');
|
||||||
|
} finally {
|
||||||
|
setState(() => _isSynthesizing = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Recording ----
|
||||||
|
Future<void> _startRecording(String mode) async {
|
||||||
|
if (_isRecording) return;
|
||||||
|
final hasPermission = await _recorder.hasPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
setState(() {
|
||||||
|
if (mode == 'stt') {
|
||||||
|
_sttStatus = '麦克风权限被拒绝';
|
||||||
|
} else {
|
||||||
|
_rtStatus = '麦克风权限被拒绝';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recordMode = mode;
|
||||||
|
setState(() {
|
||||||
|
_isRecording = true;
|
||||||
|
if (mode == 'stt') {
|
||||||
|
_sttStatus = '录音中...';
|
||||||
|
_sttResult = '';
|
||||||
|
} else {
|
||||||
|
_rtStatus = '录音中...';
|
||||||
|
_rtResult = '';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await _recorder.start(
|
||||||
|
const RecordConfig(
|
||||||
|
encoder: AudioEncoder.wav,
|
||||||
|
sampleRate: 16000,
|
||||||
|
numChannels: 1,
|
||||||
|
bitRate: 256000,
|
||||||
|
),
|
||||||
|
path: '', // temp file
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopRecording() async {
|
||||||
|
if (!_isRecording) return;
|
||||||
|
final path = await _recorder.stop();
|
||||||
|
setState(() => _isRecording = false);
|
||||||
|
if (path == null || path.isEmpty) return;
|
||||||
|
|
||||||
|
if (_recordMode == 'stt') {
|
||||||
|
_doSTT(path);
|
||||||
|
} else {
|
||||||
|
_doRoundTrip(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- STT ----
|
||||||
|
Future<void> _doSTT(String audioPath) async {
|
||||||
|
setState(() => _sttStatus = '识别中...');
|
||||||
|
final sw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'audio': await MultipartFile.fromFile(audioPath, filename: 'recording.wav'),
|
||||||
|
});
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
sw.stop();
|
||||||
|
setState(() {
|
||||||
|
_sttStatus = '错误: $e';
|
||||||
|
_sttResult = '';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Round-trip: STT → TTS ----
|
||||||
|
Future<void> _doRoundTrip(String audioPath) async {
|
||||||
|
setState(() => _rtStatus = 'STT 识别中...');
|
||||||
|
final totalSw = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
// 1. STT
|
||||||
|
final sttSw = Stopwatch()..start();
|
||||||
|
final formData = FormData.fromMap({
|
||||||
|
'audio': await MultipartFile.fromFile(audioPath, filename: 'recording.wav'),
|
||||||
|
});
|
||||||
|
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'] ?? '';
|
||||||
|
setState(() {
|
||||||
|
_rtResult = 'STT (${sttSw.elapsedMilliseconds}ms): $text';
|
||||||
|
_rtStatus = 'TTS 合成中...';
|
||||||
|
});
|
||||||
|
if (text.isEmpty) {
|
||||||
|
setState(() => _rtStatus = 'STT 识别为空');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. TTS
|
||||||
|
final ttsSw = Stopwatch()..start();
|
||||||
|
final ttsResp = await _dioBinary.get(
|
||||||
|
'/api/v1/test/tts/synthesize',
|
||||||
|
queryParameters: {'text': text},
|
||||||
|
);
|
||||||
|
ttsSw.stop();
|
||||||
|
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';
|
||||||
|
});
|
||||||
|
await _audioPlayer.play(BytesSource(Uint8List.fromList(audioBytes)));
|
||||||
|
} catch (e) {
|
||||||
|
totalSw.stop();
|
||||||
|
setState(() {
|
||||||
|
_rtStatus = '错误: $e';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('语音 I/O 测试')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
// TTS Section
|
||||||
|
_buildSection(
|
||||||
|
title: 'TTS (文本转语音)',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: _ttsController,
|
||||||
|
maxLines: 3,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '输入要合成的文本...',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _isSynthesizing ? null : _doTTS,
|
||||||
|
icon: _isSynthesizing
|
||||||
|
? 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)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// STT Section
|
||||||
|
_buildSection(
|
||||||
|
title: 'STT (语音转文本)',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onLongPressStart: (_) => _startRecording('stt'),
|
||||||
|
onLongPressEnd: (_) => _stopRecording(),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _isRecording && _recordMode == 'stt'
|
||||||
|
? Colors.red
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
icon: Icon(_isRecording && _recordMode == 'stt'
|
||||||
|
? Icons.mic
|
||||||
|
: Icons.mic_none),
|
||||||
|
label: Text(_isRecording && _recordMode == 'stt'
|
||||||
|
? '录音中... 松开结束'
|
||||||
|
: '长按录音'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_sttStatus.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(_sttStatus, style: TextStyle(color: Colors.grey[600], fontSize: 13)),
|
||||||
|
),
|
||||||
|
if (_sttResult.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(_sttResult, style: const TextStyle(fontSize: 16)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// Round-trip Section
|
||||||
|
_buildSection(
|
||||||
|
title: 'Round-trip (STT + TTS)',
|
||||||
|
subtitle: '录音 → 识别文本 → 合成语音播放',
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onLongPressStart: (_) => _startRecording('rt'),
|
||||||
|
onLongPressEnd: (_) => _stopRecording(),
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed: () {},
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: _isRecording && _recordMode == 'rt'
|
||||||
|
? Colors.red
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
icon: Icon(_isRecording && _recordMode == 'rt'
|
||||||
|
? Icons.mic
|
||||||
|
: Icons.swap_horiz),
|
||||||
|
label: Text(_isRecording && _recordMode == 'rt'
|
||||||
|
? '录音中... 松开结束'
|
||||||
|
: '长按说话 (Round-trip)'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_rtStatus.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: Text(_rtStatus, style: TextStyle(color: Colors.grey[600], fontSize: 13)),
|
||||||
|
),
|
||||||
|
if (_rtResult.isNotEmpty)
|
||||||
|
Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.only(top: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[100],
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Text(_rtResult, style: const TextStyle(fontSize: 14)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSection({
|
||||||
|
required String title,
|
||||||
|
String? subtitle,
|
||||||
|
required Widget child,
|
||||||
|
}) {
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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])),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import '../../../../core/theme/app_colors.dart';
|
import '../../../../core/theme/app_colors.dart';
|
||||||
import '../../../auth/data/providers/auth_provider.dart';
|
import '../../../auth/data/providers/auth_provider.dart';
|
||||||
|
import '../../../agent_call/presentation/pages/voice_test_page.dart';
|
||||||
import '../providers/settings_providers.dart';
|
import '../providers/settings_providers.dart';
|
||||||
|
|
||||||
class SettingsPage extends ConsumerStatefulWidget {
|
class SettingsPage extends ConsumerStatefulWidget {
|
||||||
|
|
@ -132,6 +133,25 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ===== Dev / Debug Group =====
|
||||||
|
_SettingsGroup(
|
||||||
|
cardColor: cardColor,
|
||||||
|
children: [
|
||||||
|
_SettingsRow(
|
||||||
|
icon: Icons.record_voice_over,
|
||||||
|
iconBg: const Color(0xFF10B981),
|
||||||
|
title: '语音 I/O 测试',
|
||||||
|
trailing: Text('TTS / STT',
|
||||||
|
style: TextStyle(color: subtitleColor, fontSize: 14)),
|
||||||
|
onTap: () => Navigator.push(
|
||||||
|
context,
|
||||||
|
MaterialPageRoute(builder: (_) => const VoiceTestPage()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
|
|
||||||
// ===== Logout =====
|
// ===== Logout =====
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,10 @@ services:
|
||||||
paths:
|
paths:
|
||||||
- /api/v1/twilio
|
- /api/v1/twilio
|
||||||
strip_path: false
|
strip_path: false
|
||||||
|
- name: voice-test
|
||||||
|
paths:
|
||||||
|
- /api/v1/test
|
||||||
|
strip_path: false
|
||||||
|
|
||||||
- name: audit-service
|
- name: audit-service
|
||||||
url: http://audit-service:3007
|
url: http://audit-service:3007
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue