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