570 lines
18 KiB
Dart
570 lines
18 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:yx_asr/yx_asr.dart';
|
|
|
|
void main() {
|
|
runApp(const MyApp());
|
|
}
|
|
|
|
class MyApp extends StatelessWidget {
|
|
const MyApp({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return MaterialApp(
|
|
title: 'YX ASR Example',
|
|
theme: ThemeData(
|
|
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
|
|
useMaterial3: true,
|
|
),
|
|
home: const SpeechRecognitionPage(),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 语音识别演示页面
|
|
class SpeechRecognitionPage extends StatefulWidget {
|
|
const SpeechRecognitionPage({super.key});
|
|
|
|
@override
|
|
State<SpeechRecognitionPage> createState() => _SpeechRecognitionPageState();
|
|
}
|
|
|
|
class _SpeechRecognitionPageState extends State<SpeechRecognitionPage> {
|
|
final YxAsrService _speechService = YxAsrService();
|
|
final TextEditingController _textController = TextEditingController();
|
|
final FocusNode _textFocusNode = FocusNode();
|
|
|
|
// 状态变量
|
|
bool _isInitialized = false;
|
|
bool _isListening = false;
|
|
String _currentText = '';
|
|
String _errorMessage = '';
|
|
List<String> _recognitionHistory = [];
|
|
|
|
// 录音相关
|
|
final List<String> _realtimeResults = []; // 存储实时识别片段
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_initializeSpeechService();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_textController.dispose();
|
|
_textFocusNode.dispose();
|
|
_speechService.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 初始化语音识别服务
|
|
Future<void> _initializeSpeechService() async {
|
|
try {
|
|
// 🚀 使用高质量模式获得最佳识别效果
|
|
_speechService.setRecognitionQuality(RecognitionQuality.highQuality);
|
|
|
|
// 🎵 设置标准采样率,与模型匹配获得最佳效果
|
|
_speechService.setSampleRate(SampleRate.standard);
|
|
|
|
// 🔧 可选:进一步自定义高级配置(禁用端点检测,用户手动控制)
|
|
_speechService.setAdvancedConfig(
|
|
const AdvancedRecognitionConfig(
|
|
decodingMethod: DecodingMethod.modifiedBeamSearch,
|
|
maxActivePaths: 8,
|
|
enableEndpoint: false, // 禁用自动端点检测
|
|
rule1MinTrailingSilence: 2.4,
|
|
rule2MinTrailingSilence: 1.2,
|
|
rule3MinUtteranceLength: 20.0,
|
|
featureDim: 80,
|
|
blankPenalty: 0.0,
|
|
),
|
|
);
|
|
|
|
// 初始化服务 - 使用2023年模型
|
|
final success =
|
|
await _speechService.initializeWithDefaultModel(ModelConfig.zh2023);
|
|
|
|
if (success) {
|
|
// 监听识别结果
|
|
_speechService.onResult.listen((result) {
|
|
print('📱 [Example] 接收到识别结果: "${result.recognizedWords}"');
|
|
setState(() {
|
|
// 更新当前识别的文本(实时显示)
|
|
if (result.recognizedWords.isNotEmpty) {
|
|
print('📱 [Example] 实时识别: ${result.recognizedWords}');
|
|
_currentText = result.recognizedWords;
|
|
// 更新文本框显示
|
|
_updateTextController();
|
|
}
|
|
});
|
|
});
|
|
|
|
// 监听错误
|
|
_speechService.onError.listen((error) {
|
|
setState(() {
|
|
_errorMessage = error.errorMsg;
|
|
_isListening = false;
|
|
});
|
|
_showErrorSnackBar(error.errorMsg);
|
|
});
|
|
|
|
// 监听状态变化
|
|
_speechService.onListeningStatusChanged.listen((isListening) {
|
|
setState(() {
|
|
_isListening = isListening;
|
|
if (!isListening) {
|
|
_currentText = '';
|
|
}
|
|
});
|
|
});
|
|
|
|
setState(() {
|
|
_isInitialized = true;
|
|
_errorMessage = '';
|
|
});
|
|
} else {
|
|
setState(() {
|
|
_errorMessage = '初始化失败,请检查权限和模型文件';
|
|
});
|
|
}
|
|
} catch (e) {
|
|
setState(() {
|
|
_errorMessage = '初始化异常: $e';
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 显示错误提示
|
|
void _showErrorSnackBar(String message) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
backgroundColor: Colors.red,
|
|
action: SnackBarAction(
|
|
label: '重试',
|
|
textColor: Colors.white,
|
|
onPressed: _initializeSpeechService,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 清除历史记录
|
|
void _clearHistory() {
|
|
setState(() {
|
|
_recognitionHistory.clear();
|
|
_realtimeResults.clear();
|
|
_currentText = '';
|
|
_textController.clear();
|
|
_errorMessage = '';
|
|
});
|
|
}
|
|
|
|
/// 更新文本控制器内容
|
|
void _updateTextController() {
|
|
// 组合已确认的结果和当前实时结果
|
|
final confirmedText = _realtimeResults.join(' ');
|
|
final displayText = _currentText.isNotEmpty
|
|
? '$confirmedText ${_currentText}'.trim()
|
|
: confirmedText;
|
|
|
|
// 只有当内容真正改变时才更新,避免光标跳动
|
|
if (_textController.text != displayText) {
|
|
final cursorPosition = _textController.selection.baseOffset;
|
|
_textController.text = displayText;
|
|
|
|
// 尝试保持光标位置,如果超出范围则移到末尾
|
|
if (cursorPosition <= displayText.length) {
|
|
_textController.selection = TextSelection.fromPosition(
|
|
TextPosition(offset: cursorPosition),
|
|
);
|
|
} else {
|
|
_textController.selection = TextSelection.fromPosition(
|
|
TextPosition(offset: displayText.length),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 切换录音状态
|
|
Future<void> _toggleRecording() async {
|
|
if (!_isInitialized) return;
|
|
|
|
try {
|
|
if (_isListening) {
|
|
print('📱 [Example] 停止录音');
|
|
await _speechService.stopListening();
|
|
|
|
// 录音结束后,将当前识别的文本保存到历史记录
|
|
if (_currentText.isNotEmpty) {
|
|
setState(() {
|
|
_realtimeResults.add(_currentText);
|
|
_recognitionHistory.insert(0, _currentText);
|
|
print('📱 [Example] 添加到历史记录: $_currentText');
|
|
// 保持历史记录在合理数量
|
|
if (_recognitionHistory.length > 10) {
|
|
_recognitionHistory.removeLast();
|
|
}
|
|
// 清空当前文本
|
|
_currentText = '';
|
|
_updateTextController();
|
|
});
|
|
}
|
|
} else {
|
|
print('📱 [Example] 开始录音');
|
|
// 清空之前的结果,开始新的录音
|
|
setState(() {
|
|
_realtimeResults.clear();
|
|
_currentText = '';
|
|
_textController.clear();
|
|
});
|
|
await _speechService.startListening(partialResults: true);
|
|
}
|
|
} catch (e) {
|
|
print('📱 [Example] 录音操作失败: $e');
|
|
_showErrorSnackBar('录音操作失败: $e');
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('YX ASR 语音识别演示'),
|
|
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.clear_all),
|
|
onPressed: _clearHistory,
|
|
tooltip: '清除历史',
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
children: [
|
|
// 状态卡片
|
|
_buildStatusCard(),
|
|
const SizedBox(height: 16),
|
|
|
|
// 识别结果卡片
|
|
_buildRecognitionCard(),
|
|
const SizedBox(height: 16),
|
|
|
|
// 历史记录
|
|
Expanded(child: _buildHistoryCard()),
|
|
],
|
|
),
|
|
),
|
|
floatingActionButton: _buildFloatingActionButton(),
|
|
floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat,
|
|
);
|
|
}
|
|
|
|
/// 构建状态卡片
|
|
Widget _buildStatusCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'服务状态',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Icon(
|
|
_isInitialized ? Icons.check_circle : Icons.error,
|
|
color: _isInitialized ? Colors.green : Colors.red,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
_isInitialized ? '已初始化' : '未初始化',
|
|
style: TextStyle(
|
|
color: _isInitialized ? Colors.green : Colors.red,
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
if (_isInitialized) ...[
|
|
const SizedBox(height: 8),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.model_training,
|
|
size: 16, color: Colors.purple),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'模型: ${ModelConfig.getModelDescription(ModelConfig.zh2023)}',
|
|
style: const TextStyle(color: Colors.purple, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.speed, size: 16, color: Colors.blue),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'识别速度: ${_speechService.recognitionSpeed.description}',
|
|
style: const TextStyle(color: Colors.blue, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.graphic_eq, size: 16, color: Colors.green),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'采样率: ${_speechService.sampleRate.description}',
|
|
style: const TextStyle(color: Colors.green, fontSize: 12),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
if (_errorMessage.isNotEmpty) ...[
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'错误: $_errorMessage',
|
|
style: const TextStyle(color: Colors.red, fontSize: 12),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建识别结果卡片(可编辑文本框)
|
|
Widget _buildRecognitionCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Text(
|
|
'语音识别',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const Spacer(),
|
|
if (_isListening) ...[
|
|
const SizedBox(
|
|
width: 16,
|
|
height: 16,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
'正在录音...',
|
|
style: TextStyle(
|
|
color: Colors.red[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// 可编辑的文本框
|
|
Container(
|
|
width: double.infinity,
|
|
constraints: const BoxConstraints(
|
|
minHeight: 120,
|
|
maxHeight: 300,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
borderRadius: BorderRadius.circular(8.0),
|
|
border: Border.all(
|
|
color: _isListening ? Colors.red[300]! : Colors.grey[300]!,
|
|
width: _isListening ? 2.0 : 1.0,
|
|
),
|
|
),
|
|
child: TextField(
|
|
controller: _textController,
|
|
focusNode: _textFocusNode,
|
|
maxLines: null,
|
|
style: const TextStyle(fontSize: 16),
|
|
decoration: InputDecoration(
|
|
hintText: _isListening
|
|
? '🎤 正在监听,请说话...\n实时识别结果会显示在这里,您可以随时编辑'
|
|
: _isInitialized
|
|
? '点击麦克风按钮开始录音\n录音结束后可以编辑识别结果'
|
|
: '正在初始化...',
|
|
hintStyle: TextStyle(
|
|
color: Colors.grey[500],
|
|
fontSize: 14,
|
|
),
|
|
border: InputBorder.none,
|
|
contentPadding: const EdgeInsets.all(16.0),
|
|
),
|
|
),
|
|
),
|
|
|
|
// 实时状态提示
|
|
if (_currentText.isNotEmpty && _isListening) ...[
|
|
const SizedBox(height: 8),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: Colors.blue[50],
|
|
borderRadius: BorderRadius.circular(4),
|
|
border: Border.all(color: Colors.blue[200]!),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.mic, size: 14, color: Colors.blue[600]),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
'实时识别中...',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.blue[600],
|
|
fontWeight: FontWeight.w500,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
|
|
// 操作提示
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
'💡 提示:录音过程中会实时显示识别结果,录音结束后您可以编辑文本内容',
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey[600],
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建历史记录卡片
|
|
Widget _buildHistoryCard() {
|
|
return Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'识别历史',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Expanded(
|
|
child: _recognitionHistory.isEmpty
|
|
? Center(
|
|
child: Text(
|
|
'暂无识别历史',
|
|
style: TextStyle(
|
|
color: Colors.grey[600],
|
|
fontStyle: FontStyle.italic,
|
|
),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
itemCount: _recognitionHistory.length,
|
|
itemBuilder: (context, index) {
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 8.0),
|
|
child: ListTile(
|
|
leading: CircleAvatar(
|
|
backgroundColor: Theme.of(context).primaryColor,
|
|
child: Text(
|
|
'${index + 1}',
|
|
style: const TextStyle(color: Colors.white),
|
|
),
|
|
),
|
|
title: Text(_recognitionHistory[index]),
|
|
trailing: IconButton(
|
|
icon: const Icon(Icons.copy),
|
|
onPressed: () {
|
|
// TODO: 实现复制到剪贴板功能
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('已复制到剪贴板'),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// 构建浮动操作按钮
|
|
Widget _buildFloatingActionButton() {
|
|
if (!_isInitialized) {
|
|
return FloatingActionButton(
|
|
onPressed: _initializeSpeechService,
|
|
backgroundColor: Colors.orange,
|
|
child: const Icon(Icons.refresh),
|
|
);
|
|
}
|
|
|
|
return GestureDetector(
|
|
onTap: () {
|
|
// 触觉反馈
|
|
HapticFeedback.mediumImpact();
|
|
_toggleRecording();
|
|
},
|
|
child: Container(
|
|
width: 80,
|
|
height: 80,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: _isListening
|
|
? [Colors.red.shade400, Colors.red.shade600]
|
|
: [Colors.blue.shade400, Colors.blue.shade600],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: (_isListening ? Colors.red : Colors.blue)
|
|
.withValues(alpha: 0.4),
|
|
spreadRadius: 4,
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 6),
|
|
),
|
|
],
|
|
),
|
|
child: AnimatedSwitcher(
|
|
duration: const Duration(milliseconds: 300),
|
|
child: Icon(
|
|
_isListening ? Icons.stop_rounded : Icons.mic_rounded,
|
|
key: ValueKey(_isListening),
|
|
size: 32,
|
|
color: Colors.white,
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|