20 changed files with 2054 additions and 380 deletions
@ -0,0 +1,165 @@ |
|||
package com.example.deep_voice |
|||
|
|||
import android.util.Log |
|||
import com.microsoft.cognitiveservices.speech.* |
|||
import java.util.concurrent.Future |
|||
|
|||
/** |
|||
* Microsoft Text-to-Speech Helper |
|||
* |
|||
* 该类封装了微软语音 SDK 的 TTS 功能,提供简单的接口供 Flutter 调用 |
|||
*/ |
|||
class TextToSpeechHelper { |
|||
private val TAG = "TextToSpeechHelper" |
|||
private var speechConfig: SpeechConfig? = null |
|||
private var synthesizer: SpeechSynthesizer? = null |
|||
private var isInitialized = false |
|||
|
|||
/** |
|||
* 初始化 TTS 引擎 |
|||
* |
|||
* @param subscriptionKey Azure 语音服务订阅密钥 |
|||
* @param serviceRegion Azure 语音服务区域 |
|||
*/ |
|||
fun initialize(subscriptionKey: String, serviceRegion: String) { |
|||
try { |
|||
speechConfig = SpeechConfig.fromSubscription(subscriptionKey, serviceRegion) |
|||
// 默认设置中文女声 |
|||
speechConfig?.setSpeechSynthesisVoiceName("zh-CN-XiaoxiaoNeural") |
|||
synthesizer = SpeechSynthesizer(speechConfig) |
|||
isInitialized = true |
|||
Log.d(TAG, "TTS 引擎初始化成功") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "TTS 引擎初始化失败: ${e.message}") |
|||
throw e |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 设置语音 |
|||
* |
|||
* @param voiceName 语音名称,例如 "zh-CN-XiaoxiaoNeural" |
|||
*/ |
|||
fun setVoice(voiceName: String) { |
|||
if (!isInitialized) { |
|||
throw Exception("TTS 引擎尚未初始化") |
|||
} |
|||
|
|||
try { |
|||
speechConfig?.setSpeechSynthesisVoiceName(voiceName) |
|||
// 重新创建合成器以应用新的语音设置 |
|||
synthesizer?.close() |
|||
synthesizer = SpeechSynthesizer(speechConfig) |
|||
Log.d(TAG, "已设置语音: $voiceName") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "设置语音失败: ${e.message}") |
|||
throw e |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 合成文本为语音并播放 |
|||
* |
|||
* @param text 要合成的文本 |
|||
* @param callback 回调接口,用于返回结果或错误 |
|||
*/ |
|||
fun speakText(text: String, callback: TTSCallback) { |
|||
if (!isInitialized) { |
|||
callback.onError("TTS 引擎尚未初始化") |
|||
return |
|||
} |
|||
|
|||
try { |
|||
Log.d(TAG, "开始合成文本: $text") |
|||
val task: Future<SpeechSynthesisResult> = synthesizer!!.SpeakTextAsync(text) |
|||
|
|||
// 异步获取结果 |
|||
val result = task.get() |
|||
|
|||
when (result.reason) { |
|||
ResultReason.SynthesizingAudioCompleted -> { |
|||
Log.d(TAG, "语音合成完成") |
|||
callback.onSuccess("语音合成完成") |
|||
} |
|||
ResultReason.Canceled -> { |
|||
val cancellation = SpeechSynthesisCancellationDetails.fromResult(result) |
|||
Log.e(TAG, "语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|||
callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|||
} |
|||
else -> { |
|||
Log.e(TAG, "语音合成失败: ${result.reason}") |
|||
callback.onError("语音合成失败: ${result.reason}") |
|||
} |
|||
} |
|||
|
|||
result.close() |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "语音合成异常: ${e.message}") |
|||
callback.onError("语音合成异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 合成 SSML 为语音并播放 |
|||
* |
|||
* @param ssml SSML 格式的文本 |
|||
* @param callback 回调接口,用于返回结果或错误 |
|||
*/ |
|||
fun speakSsml(ssml: String, callback: TTSCallback) { |
|||
if (!isInitialized) { |
|||
callback.onError("TTS 引擎尚未初始化") |
|||
return |
|||
} |
|||
|
|||
try { |
|||
Log.d(TAG, "开始合成 SSML") |
|||
val task: Future<SpeechSynthesisResult> = synthesizer!!.SpeakSsmlAsync(ssml) |
|||
|
|||
// 异步获取结果 |
|||
val result = task.get() |
|||
|
|||
when (result.reason) { |
|||
ResultReason.SynthesizingAudioCompleted -> { |
|||
Log.d(TAG, "语音合成完成") |
|||
callback.onSuccess("语音合成完成") |
|||
} |
|||
ResultReason.Canceled -> { |
|||
val cancellation = SpeechSynthesisCancellationDetails.fromResult(result) |
|||
Log.e(TAG, "语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|||
callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") |
|||
} |
|||
else -> { |
|||
Log.e(TAG, "语音合成失败: ${result.reason}") |
|||
callback.onError("语音合成失败: ${result.reason}") |
|||
} |
|||
} |
|||
|
|||
result.close() |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "语音合成异常: ${e.message}") |
|||
callback.onError("语音合成异常: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* 释放资源 |
|||
*/ |
|||
fun dispose() { |
|||
try { |
|||
synthesizer?.close() |
|||
speechConfig?.close() |
|||
isInitialized = false |
|||
Log.d(TAG, "TTS 引擎已释放") |
|||
} catch (e: Exception) { |
|||
Log.e(TAG, "释放 TTS 引擎失败: ${e.message}") |
|||
} |
|||
} |
|||
|
|||
/** |
|||
* TTS 回调接口 |
|||
*/ |
|||
interface TTSCallback { |
|||
fun onSuccess(message: String) |
|||
fun onError(error: String) |
|||
} |
|||
} |
|||
@ -0,0 +1,11 @@ |
|||
<?xml version="1.0" encoding="utf-8"?> |
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android" |
|||
android:width="24dp" |
|||
android:height="24dp" |
|||
android:viewportWidth="24.0" |
|||
android:viewportHeight="24.0" |
|||
android:tint="#FFFFFF"> |
|||
<path |
|||
android:fillColor="#FFFFFF" |
|||
android:pathData="M3,9v6h4l5,5L12,4L7,9L3,9zM16.5,12c0,-1.77 -1.02,-3.29 -2.5,-4.03v8.05c1.48,-0.73 2.5,-2.25 2.5,-4.02zM14,3.23v2.06c2.89,0.86 5,3.54 5,6.71s-2.11,5.85 -5,6.71v2.06c4.01,-0.91 7,-4.49 7,-8.77s-2.99,-7.86 -7,-8.77z"/> |
|||
</vector> |
|||
@ -0,0 +1,346 @@ |
|||
import 'dart:async'; |
|||
import 'package:flutter/services.dart'; |
|||
import 'package:get/get.dart'; |
|||
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
|||
|
|||
/// 微软 Text-to-Speech 服务异常 |
|||
class MicrosoftTtsException implements Exception { |
|||
final String message; |
|||
MicrosoftTtsException(this.message); |
|||
|
|||
@override |
|||
String toString() => message; |
|||
} |
|||
|
|||
/// 微软 Text-to-Speech 服务 |
|||
/// |
|||
/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, |
|||
/// 提供文本转语音功能。 |
|||
class MicrosoftTtsService extends GetxService { |
|||
static const MethodChannel _channel = MethodChannel('com.example.deep_voice/text_to_speech'); |
|||
|
|||
bool _isInitialized = false; |
|||
late final String _subscriptionKey; |
|||
late final String _serviceRegion; |
|||
|
|||
// 当前使用的语音 |
|||
String _currentVoice = 'zh-CN-XiaoxiaoNeural'; |
|||
String get currentVoice => _currentVoice; |
|||
|
|||
// 语音合成队列 |
|||
final List<String> _textQueue = []; |
|||
bool _isProcessingQueue = false; |
|||
bool _isSpeaking = false; |
|||
|
|||
// 可观察状态 |
|||
final isEnabled = true.obs; |
|||
final isSpeaking = false.obs; |
|||
|
|||
MicrosoftTtsService() { |
|||
_loadConfig(); |
|||
} |
|||
|
|||
/// 从环境变量加载配置 |
|||
void _loadConfig() { |
|||
_subscriptionKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; |
|||
_serviceRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; |
|||
|
|||
if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { |
|||
throw MicrosoftTtsException('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); |
|||
} |
|||
} |
|||
|
|||
/// 初始化微软 TTS SDK |
|||
/// |
|||
/// 返回 true 表示初始化成功,否则抛出 PlatformException |
|||
Future<bool> initialize() async { |
|||
if (_isInitialized) return true; |
|||
|
|||
try { |
|||
final bool result = await _channel.invokeMethod('initialize', { |
|||
'subscriptionKey': _subscriptionKey, |
|||
'serviceRegion': _serviceRegion, |
|||
}); |
|||
|
|||
_isInitialized = result; |
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw MicrosoftTtsException('初始化失败: ${e.message}'); |
|||
} |
|||
} |
|||
|
|||
/// 设置语音 |
|||
/// |
|||
/// [voiceName] 语音名称,例如 "zh-CN-XiaoxiaoNeural" |
|||
/// |
|||
/// 返回 true 表示设置成功,否则抛出 PlatformException |
|||
Future<bool> setVoice(String voiceName) async { |
|||
if (!_isInitialized) { |
|||
await initialize(); |
|||
} |
|||
|
|||
try { |
|||
final bool result = await _channel.invokeMethod('setVoice', { |
|||
'voiceName': voiceName, |
|||
}); |
|||
|
|||
if (result) { |
|||
_currentVoice = voiceName; |
|||
} |
|||
|
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
throw MicrosoftTtsException('设置语音失败: ${e.message}'); |
|||
} |
|||
} |
|||
|
|||
/// 将文本转换为语音并播放 |
|||
/// |
|||
/// [text] 要转换的文本 |
|||
/// |
|||
/// 返回合成结果消息,否则抛出 PlatformException |
|||
Future<String> speakText(String text) async { |
|||
if (!_isInitialized) { |
|||
await initialize(); |
|||
} |
|||
|
|||
if (!isEnabled.value) { |
|||
return "TTS 服务已禁用"; |
|||
} |
|||
|
|||
try { |
|||
_isSpeaking = true; |
|||
isSpeaking.value = true; |
|||
|
|||
final String result = await _channel.invokeMethod('speakText', { |
|||
'text': text, |
|||
}); |
|||
|
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
|
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
throw MicrosoftTtsException('语音合成失败: ${e.message}'); |
|||
} |
|||
} |
|||
|
|||
/// 将 SSML 转换为语音并播放 |
|||
/// |
|||
/// [ssml] SSML 格式的文本 |
|||
/// |
|||
/// 返回合成结果消息,否则抛出 PlatformException |
|||
Future<String> speakSsml(String ssml) async { |
|||
if (!_isInitialized) { |
|||
await initialize(); |
|||
} |
|||
|
|||
if (!isEnabled.value) { |
|||
return "TTS 服务已禁用"; |
|||
} |
|||
|
|||
try { |
|||
_isSpeaking = true; |
|||
isSpeaking.value = true; |
|||
|
|||
final String result = await _channel.invokeMethod('speakSsml', { |
|||
'ssml': ssml, |
|||
}); |
|||
|
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
|
|||
return result; |
|||
} on PlatformException catch (e) { |
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
throw MicrosoftTtsException('SSML 语音合成失败: ${e.message}'); |
|||
} |
|||
} |
|||
|
|||
/// 添加文本到队列并开始处理 |
|||
/// |
|||
/// [text] 要添加到队列的文本 |
|||
/// [rate] 可选,语速,范围 -100 到 100,默认为 0 |
|||
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0 |
|||
/// |
|||
/// 返回 true 表示成功添加到队列 |
|||
Future<bool> speak(String text, {int rate = 0, int pitch = 0}) async { |
|||
if (!isEnabled.value) { |
|||
return false; |
|||
} |
|||
|
|||
if (text.isEmpty) { |
|||
return false; |
|||
} |
|||
|
|||
// 生成 SSML |
|||
final ssml = generateSsml( |
|||
text: text, |
|||
rate: rate, |
|||
pitch: pitch, |
|||
); |
|||
|
|||
// 添加到队列 |
|||
_textQueue.add(ssml); |
|||
|
|||
// 如果队列未在处理中,开始处理 |
|||
if (!_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/// 连续播放多段文本 |
|||
/// |
|||
/// [texts] 要连续播放的文本列表 |
|||
/// [rate] 可选,语速,范围 -100 到 100,默认为 0 |
|||
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0 |
|||
/// |
|||
/// 返回 true 表示成功添加到队列 |
|||
Future<bool> speakMultiple(List<String> texts, {int rate = 0, int pitch = 0}) async { |
|||
if (!isEnabled.value) { |
|||
return false; |
|||
} |
|||
|
|||
if (texts.isEmpty) { |
|||
return false; |
|||
} |
|||
|
|||
// 将所有文本添加到队列 |
|||
for (final text in texts) { |
|||
if (text.isNotEmpty) { |
|||
final ssml = generateSsml( |
|||
text: text, |
|||
rate: rate, |
|||
pitch: pitch, |
|||
); |
|||
_textQueue.add(ssml); |
|||
} |
|||
} |
|||
|
|||
// 如果队列未在处理中,开始处理 |
|||
if (!_isProcessingQueue) { |
|||
_processQueue(); |
|||
} |
|||
|
|||
return true; |
|||
} |
|||
|
|||
/// 处理语音合成队列 |
|||
Future<void> _processQueue() async { |
|||
if (_textQueue.isEmpty || _isProcessingQueue) { |
|||
return; |
|||
} |
|||
|
|||
_isProcessingQueue = true; |
|||
|
|||
try { |
|||
while (_textQueue.isNotEmpty) { |
|||
// 如果服务被禁用,清空队列并退出 |
|||
if (!isEnabled.value) { |
|||
_textQueue.clear(); |
|||
break; |
|||
} |
|||
|
|||
// 获取队列中的下一个 SSML |
|||
final ssml = _textQueue.removeAt(0); |
|||
|
|||
// 播放 SSML |
|||
await speakSsml(ssml); |
|||
} |
|||
} catch (e) { |
|||
print('处理语音队列时出错: $e'); |
|||
} finally { |
|||
_isProcessingQueue = false; |
|||
} |
|||
} |
|||
|
|||
/// 停止当前语音合成并清空队列 |
|||
Future<void> stop() async { |
|||
// 清空队列 |
|||
_textQueue.clear(); |
|||
|
|||
// 如果当前正在播放,尝试停止 |
|||
if (_isSpeaking) { |
|||
try { |
|||
await _channel.invokeMethod('dispose'); |
|||
await initialize(); // 重新初始化以确保资源正确释放和重建 |
|||
_isSpeaking = false; |
|||
isSpeaking.value = false; |
|||
} catch (e) { |
|||
print('停止语音合成时出错: $e'); |
|||
} |
|||
} |
|||
} |
|||
|
|||
/// 生成 SSML 文本 |
|||
/// |
|||
/// [text] 要转换的文本 |
|||
/// [voiceName] 可选,语音名称,默认使用当前设置的语音 |
|||
/// [rate] 可选,语速,范围 -100 到 100,默认为 0 |
|||
/// [pitch] 可选,音调,范围 -100 到 100,默认为 0 |
|||
/// |
|||
/// 返回 SSML 格式的文本 |
|||
String generateSsml({ |
|||
required String text, |
|||
String? voiceName, |
|||
int rate = 0, |
|||
int pitch = 0, |
|||
}) { |
|||
final voice = voiceName ?? _currentVoice; |
|||
final rateValue = rate.clamp(-100, 100); |
|||
final pitchValue = pitch.clamp(-100, 100); |
|||
|
|||
// 将 rate 和 pitch 转换为 SSML 格式的值 |
|||
final String rateStr = _convertRateToSsml(rateValue); |
|||
final String pitchStr = _convertPitchToSsml(pitchValue); |
|||
|
|||
return ''' |
|||
<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xmlns:mstts="https://www.w3.org/2001/mstts" xml:lang="zh-CN"> |
|||
<voice name="$voice"> |
|||
<prosody rate="$rateStr" pitch="$pitchStr"> |
|||
$text |
|||
</prosody> |
|||
</voice> |
|||
</speak> |
|||
'''; |
|||
} |
|||
|
|||
/// 将 rate 值转换为 SSML 格式 |
|||
String _convertRateToSsml(int rate) { |
|||
if (rate == 0) return '0%'; |
|||
|
|||
// 将 -100 到 100 的范围映射到 -90% 到 100% |
|||
if (rate < 0) { |
|||
// 负值映射到 -90% 到 0% |
|||
return '${(rate * 0.9).round()}%'; |
|||
} else { |
|||
// 正值映射到 0% 到 100% |
|||
return '${rate}%'; |
|||
} |
|||
} |
|||
|
|||
/// 将 pitch 值转换为 SSML 格式 |
|||
String _convertPitchToSsml(int pitch) { |
|||
if (pitch == 0) return '0%'; |
|||
|
|||
// 将 -100 到 100 的范围映射到 -50% 到 50% |
|||
return '${(pitch * 0.5).round()}%'; |
|||
} |
|||
|
|||
/// 释放资源 |
|||
Future<void> dispose() async { |
|||
if (!_isInitialized) return; |
|||
|
|||
try { |
|||
await _channel.invokeMethod('dispose'); |
|||
_isInitialized = false; |
|||
} on PlatformException catch (e) { |
|||
throw MicrosoftTtsException('释放资源失败: ${e.message}'); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,308 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../data/services/microsoft_tts_service.dart'; |
|||
|
|||
/// 微软 TTS 连续语音输出示例页面 |
|||
class MicrosoftTtsContinuousExample extends StatefulWidget { |
|||
const MicrosoftTtsContinuousExample({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
State<MicrosoftTtsContinuousExample> createState() => _MicrosoftTtsContinuousExampleState(); |
|||
} |
|||
|
|||
class _MicrosoftTtsContinuousExampleState extends State<MicrosoftTtsContinuousExample> { |
|||
final MicrosoftTtsService _ttsService = Get.find<MicrosoftTtsService>(); |
|||
|
|||
// 语音列表 |
|||
final List<Map<String, String>> _voices = [ |
|||
{'name': '晓晓(女声)', 'value': 'zh-CN-XiaoxiaoNeural'}, |
|||
{'name': '云扬(男声)', 'value': 'zh-CN-YunyangNeural'}, |
|||
{'name': '晓双(女声)', 'value': 'zh-CN-XiaoshuangNeural'}, |
|||
{'name': '云皓(男声)', 'value': 'zh-CN-YunhaoNeural'}, |
|||
{'name': '晓墨(女声)', 'value': 'zh-CN-XiaomoNeural'}, |
|||
{'name': '云泽(男声)', 'value': 'zh-CN-YunzeNeural'}, |
|||
]; |
|||
|
|||
String _selectedVoice = 'zh-CN-XiaoxiaoNeural'; |
|||
double _rate = 0; |
|||
double _pitch = 0; |
|||
bool _isLoading = false; |
|||
String _statusMessage = ''; |
|||
|
|||
// 预设的连续语音文本 |
|||
final List<String> _presetTexts = [ |
|||
'欢迎使用微软语音合成服务,这是连续语音输出的第一段文本。', |
|||
'这是第二段文本,用于测试连续语音输出功能。', |
|||
'现在是第三段文本,我们正在测试微软语音合成服务的连续合成能力。', |
|||
'最后一段测试文本,感谢您的收听。', |
|||
]; |
|||
|
|||
// 自定义文本列表 |
|||
final List<TextEditingController> _textControllers = []; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
// 初始化文本控制器 |
|||
for (final text in _presetTexts) { |
|||
_textControllers.add(TextEditingController(text: text)); |
|||
} |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
// 释放文本控制器 |
|||
for (final controller in _textControllers) { |
|||
controller.dispose(); |
|||
} |
|||
super.dispose(); |
|||
} |
|||
|
|||
/// 播放连续文本 |
|||
Future<void> _speakContinuous() async { |
|||
final texts = _textControllers.map((controller) => controller.text).toList(); |
|||
|
|||
if (texts.every((text) => text.isEmpty)) { |
|||
_showSnackBar('请至少输入一段文本'); |
|||
return; |
|||
} |
|||
|
|||
setState(() { |
|||
_isLoading = true; |
|||
_statusMessage = '正在合成语音...'; |
|||
}); |
|||
|
|||
try { |
|||
// 设置语音 |
|||
await _ttsService.setVoice(_selectedVoice); |
|||
|
|||
// 停止之前的播放 |
|||
await _ttsService.stop(); |
|||
|
|||
// 连续播放多段文本 |
|||
final result = await _ttsService.speakMultiple( |
|||
texts.where((text) => text.isNotEmpty).toList(), |
|||
rate: _rate.round(), |
|||
pitch: _pitch.round(), |
|||
); |
|||
|
|||
setState(() { |
|||
_statusMessage = result ? '语音合成已加入队列' : '语音合成失败'; |
|||
}); |
|||
} catch (e) { |
|||
_showSnackBar('语音合成失败: $e'); |
|||
} finally { |
|||
setState(() { |
|||
_isLoading = false; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
/// 停止播放 |
|||
Future<void> _stopSpeaking() async { |
|||
try { |
|||
await _ttsService.stop(); |
|||
setState(() { |
|||
_statusMessage = '语音合成已停止'; |
|||
}); |
|||
} catch (e) { |
|||
_showSnackBar('停止语音合成失败: $e'); |
|||
} |
|||
} |
|||
|
|||
/// 添加文本输入框 |
|||
void _addTextInput() { |
|||
setState(() { |
|||
_textControllers.add(TextEditingController()); |
|||
}); |
|||
} |
|||
|
|||
/// 删除文本输入框 |
|||
void _removeTextInput(int index) { |
|||
if (_textControllers.length <= 1) { |
|||
_showSnackBar('至少需要保留一个文本输入框'); |
|||
return; |
|||
} |
|||
|
|||
setState(() { |
|||
_textControllers[index].dispose(); |
|||
_textControllers.removeAt(index); |
|||
}); |
|||
} |
|||
|
|||
/// 显示提示信息 |
|||
void _showSnackBar(String message) { |
|||
ScaffoldMessenger.of(context).showSnackBar( |
|||
SnackBar(content: Text(message)), |
|||
); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
appBar: AppBar( |
|||
title: const Text('微软连续语音合成示例'), |
|||
), |
|||
body: Padding( |
|||
padding: const EdgeInsets.all(16.0), |
|||
child: ListView( |
|||
children: [ |
|||
// 语音选择 |
|||
DropdownButtonFormField<String>( |
|||
value: _selectedVoice, |
|||
decoration: const InputDecoration( |
|||
labelText: '选择语音', |
|||
border: OutlineInputBorder(), |
|||
), |
|||
items: _voices.map((voice) { |
|||
return DropdownMenuItem<String>( |
|||
value: voice['value'], |
|||
child: Text(voice['name']!), |
|||
); |
|||
}).toList(), |
|||
onChanged: (value) { |
|||
if (value != null) { |
|||
setState(() { |
|||
_selectedVoice = value; |
|||
}); |
|||
} |
|||
}, |
|||
), |
|||
const SizedBox(height: 16), |
|||
|
|||
// 语速调节 |
|||
Row( |
|||
children: [ |
|||
const Text('语速:'), |
|||
Expanded( |
|||
child: Slider( |
|||
min: -100, |
|||
max: 100, |
|||
divisions: 20, |
|||
value: _rate, |
|||
label: _rate.round().toString(), |
|||
onChanged: (value) { |
|||
setState(() { |
|||
_rate = value; |
|||
}); |
|||
}, |
|||
), |
|||
), |
|||
Text('${_rate.round()}%'), |
|||
], |
|||
), |
|||
|
|||
// 音调调节 |
|||
Row( |
|||
children: [ |
|||
const Text('音调:'), |
|||
Expanded( |
|||
child: Slider( |
|||
min: -100, |
|||
max: 100, |
|||
divisions: 20, |
|||
value: _pitch, |
|||
label: _pitch.round().toString(), |
|||
onChanged: (value) { |
|||
setState(() { |
|||
_pitch = value; |
|||
}); |
|||
}, |
|||
), |
|||
), |
|||
Text('${_pitch.round()}%'), |
|||
], |
|||
), |
|||
|
|||
const SizedBox(height: 16), |
|||
|
|||
// 文本输入列表标题 |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceBetween, |
|||
children: [ |
|||
const Text( |
|||
'连续语音文本', |
|||
style: TextStyle( |
|||
fontSize: 16, |
|||
fontWeight: FontWeight.bold, |
|||
), |
|||
), |
|||
ElevatedButton.icon( |
|||
onPressed: _addTextInput, |
|||
icon: const Icon(Icons.add), |
|||
label: const Text('添加文本'), |
|||
), |
|||
], |
|||
), |
|||
|
|||
const SizedBox(height: 8), |
|||
|
|||
// 文本输入列表 |
|||
...List.generate(_textControllers.length, (index) { |
|||
return Padding( |
|||
padding: const EdgeInsets.only(bottom: 8.0), |
|||
child: Row( |
|||
crossAxisAlignment: CrossAxisAlignment.start, |
|||
children: [ |
|||
Expanded( |
|||
child: TextField( |
|||
controller: _textControllers[index], |
|||
maxLines: 3, |
|||
decoration: InputDecoration( |
|||
labelText: '文本 ${index + 1}', |
|||
border: const OutlineInputBorder(), |
|||
), |
|||
), |
|||
), |
|||
IconButton( |
|||
icon: const Icon(Icons.delete), |
|||
onPressed: () => _removeTextInput(index), |
|||
), |
|||
], |
|||
), |
|||
); |
|||
}), |
|||
|
|||
const SizedBox(height: 16), |
|||
|
|||
// 操作按钮 |
|||
Row( |
|||
mainAxisAlignment: MainAxisAlignment.spaceEvenly, |
|||
children: [ |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: _isLoading ? null : _speakContinuous, |
|||
icon: const Icon(Icons.play_arrow), |
|||
label: const Text('播放连续语音'), |
|||
), |
|||
), |
|||
const SizedBox(width: 8), |
|||
Expanded( |
|||
child: ElevatedButton.icon( |
|||
onPressed: _stopSpeaking, |
|||
icon: const Icon(Icons.stop), |
|||
label: const Text('停止'), |
|||
style: ElevatedButton.styleFrom( |
|||
backgroundColor: Colors.red, |
|||
), |
|||
), |
|||
), |
|||
], |
|||
), |
|||
|
|||
const SizedBox(height: 16), |
|||
|
|||
// 状态信息 |
|||
Obx(() => Text( |
|||
_ttsService.isSpeaking.value |
|||
? '正在播放语音...' |
|||
: _statusMessage, |
|||
style: const TextStyle(fontStyle: FontStyle.italic), |
|||
textAlign: TextAlign.center, |
|||
)), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
@ -0,0 +1,202 @@ |
|||
import 'package:flutter/material.dart'; |
|||
import 'package:get/get.dart'; |
|||
import '../data/services/microsoft_tts_service.dart'; |
|||
|
|||
/// 微软 TTS 示例页面 |
|||
class MicrosoftTtsExample extends StatefulWidget { |
|||
const MicrosoftTtsExample({Key? key}) : super(key: key); |
|||
|
|||
@override |
|||
State<MicrosoftTtsExample> createState() => _MicrosoftTtsExampleState(); |
|||
} |
|||
|
|||
class _MicrosoftTtsExampleState extends State<MicrosoftTtsExample> { |
|||
final TextEditingController _textController = TextEditingController(); |
|||
final MicrosoftTtsService _ttsService = Get.find<MicrosoftTtsService>(); |
|||
|
|||
// 语音列表 |
|||
final List<Map<String, String>> _voices = [ |
|||
{'name': '晓晓(女声)', 'value': 'zh-CN-XiaoxiaoNeural'}, |
|||
{'name': '云扬(男声)', 'value': 'zh-CN-YunyangNeural'}, |
|||
{'name': '晓双(女声)', 'value': 'zh-CN-XiaoshuangNeural'}, |
|||
{'name': '云皓(男声)', 'value': 'zh-CN-YunhaoNeural'}, |
|||
{'name': '晓墨(女声)', 'value': 'zh-CN-XiaomoNeural'}, |
|||
{'name': '云泽(男声)', 'value': 'zh-CN-YunzeNeural'}, |
|||
]; |
|||
|
|||
String _selectedVoice = 'zh-CN-XiaoxiaoNeural'; |
|||
double _rate = 0; |
|||
double _pitch = 0; |
|||
bool _isLoading = false; |
|||
String _statusMessage = ''; |
|||
|
|||
@override |
|||
void initState() { |
|||
super.initState(); |
|||
_textController.text = '欢迎使用微软语音合成服务,这是一个示例文本。'; |
|||
} |
|||
|
|||
@override |
|||
void dispose() { |
|||
_textController.dispose(); |
|||
super.dispose(); |
|||
} |
|||
|
|||
/// 播放文本 |
|||
Future<void> _speakText() async { |
|||
if (_textController.text.isEmpty) { |
|||
_showSnackBar('请输入要合成的文本'); |
|||
return; |
|||
} |
|||
|
|||
setState(() { |
|||
_isLoading = true; |
|||
_statusMessage = '正在合成语音...'; |
|||
}); |
|||
|
|||
try { |
|||
// 设置语音 |
|||
await _ttsService.setVoice(_selectedVoice); |
|||
|
|||
// 生成 SSML |
|||
final ssml = _ttsService.generateSsml( |
|||
text: _textController.text, |
|||
rate: _rate.round(), |
|||
pitch: _pitch.round(), |
|||
); |
|||
|
|||
// 播放 SSML |
|||
final result = await _ttsService.speakSsml(ssml); |
|||
|
|||
setState(() { |
|||
_statusMessage = result; |
|||
}); |
|||
} catch (e) { |
|||
_showSnackBar('语音合成失败: $e'); |
|||
} finally { |
|||
setState(() { |
|||
_isLoading = false; |
|||
}); |
|||
} |
|||
} |
|||
|
|||
/// 显示提示信息 |
|||
void _showSnackBar(String message) { |
|||
ScaffoldMessenger.of(context).showSnackBar( |
|||
SnackBar(content: Text(message)), |
|||
); |
|||
} |
|||
|
|||
@override |
|||
Widget build(BuildContext context) { |
|||
return Scaffold( |
|||
appBar: AppBar( |
|||
title: const Text('微软语音合成示例'), |
|||
), |
|||
body: Padding( |
|||
padding: const EdgeInsets.all(16.0), |
|||
child: Column( |
|||
crossAxisAlignment: CrossAxisAlignment.stretch, |
|||
children: [ |
|||
// 文本输入框 |
|||
TextField( |
|||
controller: _textController, |
|||
maxLines: 5, |
|||
decoration: const InputDecoration( |
|||
labelText: '输入要合成的文本', |
|||
border: OutlineInputBorder(), |
|||
), |
|||
), |
|||
const SizedBox(height: 16), |
|||
|
|||
// 语音选择 |
|||
DropdownButtonFormField<String>( |
|||
value: _selectedVoice, |
|||
decoration: const InputDecoration( |
|||
labelText: '选择语音', |
|||
border: OutlineInputBorder(), |
|||
), |
|||
items: _voices.map((voice) { |
|||
return DropdownMenuItem<String>( |
|||
value: voice['value'], |
|||
child: Text(voice['name']!), |
|||
); |
|||
}).toList(), |
|||
onChanged: (value) { |
|||
if (value != null) { |
|||
setState(() { |
|||
_selectedVoice = value; |
|||
}); |
|||
} |
|||
}, |
|||
), |
|||
const SizedBox(height: 16), |
|||
|
|||
// 语速调节 |
|||
Row( |
|||
children: [ |
|||
const Text('语速:'), |
|||
Expanded( |
|||
child: Slider( |
|||
min: -100, |
|||
max: 100, |
|||
divisions: 20, |
|||
value: _rate, |
|||
label: _rate.round().toString(), |
|||
onChanged: (value) { |
|||
setState(() { |
|||
_rate = value; |
|||
}); |
|||
}, |
|||
), |
|||
), |
|||
Text('${_rate.round()}%'), |
|||
], |
|||
), |
|||
|
|||
// 音调调节 |
|||
Row( |
|||
children: [ |
|||
const Text('音调:'), |
|||
Expanded( |
|||
child: Slider( |
|||
min: -100, |
|||
max: 100, |
|||
divisions: 20, |
|||
value: _pitch, |
|||
label: _pitch.round().toString(), |
|||
onChanged: (value) { |
|||
setState(() { |
|||
_pitch = value; |
|||
}); |
|||
}, |
|||
), |
|||
), |
|||
Text('${_pitch.round()}%'), |
|||
], |
|||
), |
|||
|
|||
const SizedBox(height: 16), |
|||
|
|||
// 播放按钮 |
|||
ElevatedButton( |
|||
onPressed: _isLoading ? null : _speakText, |
|||
child: _isLoading |
|||
? const CircularProgressIndicator() |
|||
: const Text('播放'), |
|||
), |
|||
|
|||
const SizedBox(height: 16), |
|||
|
|||
// 状态信息 |
|||
Text( |
|||
_statusMessage, |
|||
style: const TextStyle(fontStyle: FontStyle.italic), |
|||
textAlign: TextAlign.center, |
|||
), |
|||
], |
|||
), |
|||
), |
|||
); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue