diff --git a/.metadata b/.metadata index 6ac59d7fb..b4fb38531 100644 --- a/.metadata +++ b/.metadata @@ -15,7 +15,7 @@ migration: - platform: root create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 - - platform: android + - platform: ios create_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 base_revision: 35c388afb57ef061d06a39b537336c87e0e3d1b1 diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index bd1fef2f5..6bc0b719b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -3,9 +3,8 @@ import java.io.FileInputStream repositories { google() mavenCentral() - maven { - url = uri("https://artifact.bytedance.com/repository/Volcengine/") - } + maven { url = uri("https://artifact.bytedance.com/repository/Volcengine/") } + maven { url = uri("https://oss.sonatype.org/content/repositories/snapshots/") } } plugins { @@ -84,6 +83,9 @@ android { } dependencies { + // WebRTC依赖已移除 + // implementation("io.antmedia:webrtc-android-framework:2.8.0-SNAPSHOT") + // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt index 2918f73f5..171adf80e 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/AzureAsrHelper.kt @@ -1,11 +1,21 @@ package com.yunqiinnovation.deepsound import android.content.Context +import android.media.AudioAttributes +import android.media.AudioFormat +import android.media.AudioRecord +import android.media.MediaRecorder +import android.media.audiofx.AcousticEchoCanceler +import android.media.audiofx.NoiseSuppressor +import android.media.audiofx.AutomaticGainControl +import android.os.Process import com.yunqiinnovation.deepsound.core.utils.FileLogger import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.audio.* import com.microsoft.cognitiveservices.speech.util.EventHandler import java.util.concurrent.ExecutionException +import java.util.concurrent.atomic.AtomicBoolean +// 移除WebRTC相关导入 class AzureAsrHelper(private val context: Context) { private var recognizer: SpeechRecognizer? = null @@ -17,6 +27,14 @@ class AzureAsrHelper(private val context: Context) { private var serviceRegion = "" private var isAutoDetectLanguage = false private var supportedLanguages = arrayOf("zh-CN", "en-US") + + // 是否使用回音消除 - 内部控制常量 + private val useEchoCancellation = true + + // 自定义音频处理相关 + private var customAudioProcessor: CustomAudioProcessor? = null + private var pushStream: PushAudioInputStream? = null + private var audioConfig: AudioConfig? = null // 初始化SDK并创建recognizer fun initialize(subscriptionKey: String, serviceRegion: String, @@ -63,17 +81,31 @@ class AzureAsrHelper(private val context: Context) { // 创建识别器 try { - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) + if (useEchoCancellation) { + // 如果使用回音消除,创建自定义音频输入流 + setupCustomAudioProcessing() + + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) + } else { + recognizer = SpeechRecognizer(speechConfig, audioConfig) + } } else { - recognizer = SpeechRecognizer(speechConfig) + // 使用默认麦克风输入 + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) + } else { + recognizer = SpeechRecognizer(speechConfig) + } } FileLogger.d(TAG, "Azure 语音服务初始化成功") return true } catch (e: Exception) { FileLogger.e(TAG, "创建识别器失败: ${e.message}") + stopCustomAudioProcessing() return false } } catch (e: Exception) { @@ -89,13 +121,29 @@ class AzureAsrHelper(private val context: Context) { recognizer?.close() recognizer = null + // 停止当前的音频处理 + stopCustomAudioProcessing() + // 使用现有配置重新创建 recognizer if (speechConfig != null) { - if (isAutoDetectLanguage) { - val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) - recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) + if (useEchoCancellation) { + // 如果使用回音消除,创建自定义音频输入流 + setupCustomAudioProcessing() + + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig, audioConfig) + } else { + recognizer = SpeechRecognizer(speechConfig, audioConfig) + } } else { - recognizer = SpeechRecognizer(speechConfig) + // 使用默认麦克风输入 + if (isAutoDetectLanguage) { + val autoDetectConfig = AutoDetectSourceLanguageConfig.fromLanguages(supportedLanguages.toList()) + recognizer = SpeechRecognizer(speechConfig, autoDetectConfig) + } else { + recognizer = SpeechRecognizer(speechConfig) + } } return true } else { @@ -122,17 +170,24 @@ class AzureAsrHelper(private val context: Context) { } try { + // 启动音频处理 + startCustomAudioProcessing() + // 执行识别 val result = recognizer?.recognizeOnceAsync()?.get() + // 停止音频处理 + stopCustomAudioProcessing() + if (result != null && result.reason == ResultReason.RecognizedSpeech) { val detectedLanguage = AutoDetectSourceLanguageResult.fromResult(result)?.language - callback.onResult(result.text, detectedLanguage ?: "") } else { callback.onError("未能识别语音") } } catch (e: Exception) { + // 停止音频处理 + stopCustomAudioProcessing() callback.onError("识别异常: ${e.message}") } } @@ -156,6 +211,9 @@ class AzureAsrHelper(private val context: Context) { } try { + // 启动音频处理 + startCustomAudioProcessing() + // 设置识别事件处理 // 最终识别结果 recognizer?.recognized?.addEventListener( @@ -166,6 +224,7 @@ class AzureAsrHelper(private val context: Context) { } else { currentLanguage } + // FileLogger.d(TAG, "最终识别结果: ${event.result.text}") callback.onResult(event.result.text, detectedLanguage) } } @@ -180,6 +239,7 @@ class AzureAsrHelper(private val context: Context) { } else { currentLanguage } + // FileLogger.d(TAG, "识别中结果: ${event.result.text}") callback.onRecognizing(event.result.text, detectedLanguage) } } @@ -217,6 +277,7 @@ class AzureAsrHelper(private val context: Context) { } catch (e: Exception) { callback.onError("开始连续识别失败: ${e.message}") isContinuousRecognitionActive = false + stopCustomAudioProcessing() return false } } @@ -228,13 +289,18 @@ class AzureAsrHelper(private val context: Context) { } try { - recognizer?.stopContinuousRecognitionAsync()?.get() + recognizer?.stopContinuousRecognitionAsync() isContinuousRecognitionActive = false callback.onSessionStopped() + + // 停止音频处理 + stopCustomAudioProcessing() + return true } catch (e: Exception) { callback.onError("停止连续识别失败: ${e.message}") isContinuousRecognitionActive = false + stopCustomAudioProcessing() return false } } @@ -243,10 +309,90 @@ class AzureAsrHelper(private val context: Context) { fun isContinuousRecognitionActive(): Boolean { return isContinuousRecognitionActive } + + // 设置自定义音频处理 + private fun setupCustomAudioProcessing() { + if (!useEchoCancellation) { + return + } + + try { + // 1. 创建PushAudioInputStream + pushStream = PushAudioInputStream.create() + + // 2. 创建AudioConfig + audioConfig = AudioConfig.fromStreamInput(pushStream) + + // 3. 创建自定义音频处理器 + customAudioProcessor = CustomAudioProcessor(pushStream) + + FileLogger.d(TAG, "自定义音频处理设置完成") + } catch (e: Exception) { + FileLogger.e(TAG, "设置自定义音频处理失败: ${e.message}") + releaseCustomAudioProcessing() + + // 降级处理:如果自定义处理设置失败,尝试使用默认麦克风 + try { + FileLogger.d(TAG, "尝试降级到默认麦克风输入") + audioConfig = AudioConfig.fromDefaultMicrophoneInput() + } catch (e2: Exception) { + FileLogger.e(TAG, "默认麦克风输入设置也失败: ${e2.message}") + audioConfig = null + } + } + } + + // 启动自定义音频处理 + private fun startCustomAudioProcessing() { + if (!useEchoCancellation || customAudioProcessor == null) { + return + } + + try { + customAudioProcessor?.startRecording() + FileLogger.d(TAG, "自定义音频处理已启动") + } catch (e: Exception) { + FileLogger.e(TAG, "启动自定义音频处理失败: ${e.message}") + } + } + + // 停止自定义音频处理 + private fun stopCustomAudioProcessing() { + if (!useEchoCancellation || customAudioProcessor == null) { + return + } + + try { + customAudioProcessor?.stopRecording() + FileLogger.d(TAG, "自定义音频处理已停止") + } catch (e: Exception) { + FileLogger.e(TAG, "停止自定义音频处理失败: ${e.message}") + } + } + + // 释放自定义音频处理资源 + private fun releaseCustomAudioProcessing() { + stopCustomAudioProcessing() + + try { + customAudioProcessor = null + pushStream?.close() + pushStream = null + audioConfig?.close() + audioConfig = null + + FileLogger.d(TAG, "自定义音频处理资源已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放自定义音频处理资源时出错: ${e.message}") + } + } // 释放所有资源 fun dispose() { try { + // 停止和释放音频处理 + releaseCustomAudioProcessing() + recognizer?.close() recognizer = null @@ -261,6 +407,235 @@ class AzureAsrHelper(private val context: Context) { } } + // 自定义音频处理器 - 使用Android原生回音消除 + private inner class CustomAudioProcessor(private val pushStream: PushAudioInputStream?) { + private val SAMPLE_RATE = 16000 + private val CHANNEL_CONFIG = AudioFormat.CHANNEL_IN_MONO + private val AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT + private val BUFFER_SIZE = SAMPLE_RATE * 2 // 简化缓冲区大小计算,更稳定 + + private var audioRecord: AudioRecord? = null + private var echoCanceler: AcousticEchoCanceler? = null + private val isRecording = AtomicBoolean(false) + private var recordingThread: Thread? = null + + // 启动录音并处理音频数据 + fun startRecording() { + if (isRecording.get() || pushStream == null) { + return + } + + try { + // 使用Builder模式构建AudioFormat + val audioFormat = AudioFormat.Builder() + .setSampleRate(SAMPLE_RATE) + .setEncoding(AUDIO_FORMAT) + .setChannelMask(CHANNEL_CONFIG) + .build() + + // 使用Builder模式创建AudioRecord实例 + audioRecord = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.VOICE_COMMUNICATION) + .setAudioFormat(audioFormat) + .setBufferSizeInBytes(BUFFER_SIZE) + .build() + + // 检查AudioRecord初始化状态 + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + FileLogger.e(TAG, "AudioRecord初始化失败,状态: ${audioRecord?.state}") + // 尝试使用DEFAULT音频源重试一次 + audioRecord?.release() + audioRecord = AudioRecord.Builder() + .setAudioSource(MediaRecorder.AudioSource.DEFAULT) + .setAudioFormat(audioFormat) + .setBufferSizeInBytes(BUFFER_SIZE) + .build() + + if (audioRecord?.state != AudioRecord.STATE_INITIALIZED) { + FileLogger.e(TAG, "AudioRecord初始化第二次尝试也失败,放弃") + releaseAudioResources() + return + } else { + FileLogger.d(TAG, "使用默认音频源成功初始化AudioRecord") + } + } + + // 启用音频效果(回音消除、噪声抑制等) + enableAudioEffects() + + // 启动录音 + audioRecord?.startRecording() + isRecording.set(true) + + // 创建录音线程 + recordingThread = Thread({ + val buffer = ByteArray(BUFFER_SIZE) + + while (isRecording.get()) { + try { + val readSize = audioRecord?.read(buffer, 0, BUFFER_SIZE) ?: 0 + + if (readSize > 0) { + try { + // 将处理后的音频数据推送到流 + if (readSize == buffer.size) { + // 如果读取的大小等于buffer的大小,直接写入整个buffer + pushStream.write(buffer) + } else { + // 如果只读取了部分数据,创建新的数组只包含有效数据 + val validData = buffer.copyOfRange(0, readSize) + pushStream.write(validData) + } + } catch (e: Exception) { + FileLogger.e(TAG, "写入音频数据失败: ${e.message}") + break + } + } else if (readSize == 0) { + // 读取为0,可能是临时的,等待一下继续尝试 + Thread.sleep(10) + } else { + // 负值表示错误 + FileLogger.e(TAG, "读取音频数据失败,错误码: $readSize") + break + } + } catch (e: Exception) { + FileLogger.e(TAG, "录音线程异常: ${e.message}") + break + } + } + }, "AudioRecordingThread") + + // 设置线程优先级并启动 + recordingThread?.priority = Thread.MAX_PRIORITY + recordingThread?.start() + + FileLogger.d(TAG, "音频录制已启动" + (if(echoCanceler?.enabled == true) ",回音消除已启用" else "")) + } catch (e: Exception) { + FileLogger.e(TAG, "启动音频录制失败: ${e.message}") + releaseAudioResources() + } + } + + // 启用音频效果(回音消除、噪声抑制等) + private fun enableAudioEffects() { + try { + val audioSessionId = audioRecord?.audioSessionId ?: -1 + + if (audioSessionId != -1) { + // 启用回音消除 + if (AcousticEchoCanceler.isAvailable()) { + try { + echoCanceler = AcousticEchoCanceler.create(audioSessionId) + if (echoCanceler != null) { + echoCanceler?.enabled = true + FileLogger.d(TAG, "回音消除已启用,会话ID: $audioSessionId") + } else { + FileLogger.w(TAG, "回音消除器创建返回null") + } + } catch (e: Exception) { + FileLogger.e(TAG, "创建回音消除器时出错: ${e.message}") + } + } else { + FileLogger.d(TAG, "设备不支持回音消除") + } + + // 以下功能暂时不启用,可根据需要取消注释 + /* + // 启用噪声抑制 + if (NoiseSuppressor.isAvailable()) { + try { + val ns = NoiseSuppressor.create(audioSessionId) + ns?.enabled = true + FileLogger.d(TAG, "噪声抑制已启用") + } catch (e: Exception) { + FileLogger.e(TAG, "创建噪声抑制器时出错: ${e.message}") + } + } + + // 启用自动增益控制 + if (AutomaticGainControl.isAvailable()) { + try { + val agc = AutomaticGainControl.create(audioSessionId) + agc?.enabled = true + FileLogger.d(TAG, "自动增益控制已启用") + } catch (e: Exception) { + FileLogger.e(TAG, "创建自动增益控制时出错: ${e.message}") + } + } + */ + } else { + FileLogger.w(TAG, "无效的音频会话ID,无法启用音频效果") + } + } catch (e: Exception) { + FileLogger.e(TAG, "启用音频效果时出错: ${e.message}") + } + } + + // 停止录音 + fun stopRecording() { + if (!isRecording.get()) { + return + } + + isRecording.set(false) + + try { + // 等待录音线程结束 + recordingThread?.join(1000) + + // 释放资源 + releaseAudioResources() + + FileLogger.d(TAG, "音频录制已停止") + } catch (e: Exception) { + FileLogger.e(TAG, "停止音频录制失败: ${e.message}") + } + } + + // 释放音频资源 + private fun releaseAudioResources() { + try { + // 停止录音 + try { + if (audioRecord?.state == AudioRecord.STATE_INITIALIZED) { + audioRecord?.stop() + } + } catch (e: Exception) { + // 忽略可能的IllegalStateException + FileLogger.w(TAG, "停止AudioRecord时出错: ${e.message}") + } + + // 释放回音消除器 + try { + if (echoCanceler != null) { + echoCanceler?.enabled = false + echoCanceler?.release() + echoCanceler = null + } + } catch (e: Exception) { + FileLogger.w(TAG, "释放回音消除器时出错: ${e.message}") + } finally { + echoCanceler = null + } + + // 释放音频记录器 + try { + audioRecord?.release() + } catch (e: Exception) { + FileLogger.w(TAG, "释放AudioRecord时出错: ${e.message}") + } finally { + audioRecord = null + } + + // 重置线程 + recordingThread = null + + } catch (e: Exception) { + FileLogger.e(TAG, "释放音频资源失败: ${e.message}") + } + } + } + // 一次性识别回调接口 interface RecognizeCallback { fun onResult(result: String, detectedLanguage: String = "") @@ -276,5 +651,4 @@ class AzureAsrHelper(private val context: Context) { fun onCanceled(reason: String, errorDetails: String) fun onError(error: String) } - } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt b/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt index c30e27de1..31971c2a4 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/AzureTtsHelper.kt @@ -24,6 +24,9 @@ class AzureTtsHelper(private val context: Context) { private var isInitialized = false private var audioManager: AudioManager? = null + // 跟踪当前是否正在播放语音 + private var speaking = false + // 音频输出设备类型 enum class AudioOutputType { SPEAKER, // 扬声器 @@ -74,8 +77,14 @@ class AzureTtsHelper(private val context: Context) { synthesizer = SpeechSynthesizer(speechConfig, audioConfig) // 设置事件监听 - synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> semaphore.release() } - synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> semaphore.release() } + synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } + synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } isInitialized = true FileLogger.d(TAG, "TTS 引擎初始化成功") @@ -189,8 +198,14 @@ class AzureTtsHelper(private val context: Context) { synthesizer = SpeechSynthesizer(speechConfig, audioConfig) // 重新设置事件监听 - synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> semaphore.release() } - synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> semaphore.release() } + synthesizer?.SynthesisCompleted?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } + synthesizer?.SynthesisCanceled?.addEventListener { _, _ -> + speaking = false + semaphore.release() + } FileLogger.d(TAG, "已重新创建语音合成器") } catch (e: Exception) { @@ -264,7 +279,7 @@ class AzureTtsHelper(private val context: Context) { */ fun speakText(text: String, callback: TTSCallback) { if (!isInitialized) { - callback.onError("TTS 引擎尚未初始化") + callback.onError("TTS 引擎尚未初始化===") return } @@ -323,6 +338,9 @@ class AzureTtsHelper(private val context: Context) { // 异步合成语音 val task: Future = synthesizer!!.SpeakSsmlAsync(ssml) + // 标记为正在播放 + speaking = true + // 等待合成完成 Thread { try { @@ -343,8 +361,8 @@ class AzureTtsHelper(private val context: Context) { callback.onError("语音合成取消: ${cancellation.reason}, ${cancellation.errorDetails}") } else -> { - FileLogger.e(TAG, "语音合成失败: ${result.reason}") - callback.onError("语音合成失败: ${result.reason}") + FileLogger.e(TAG, "语音合成失败kt: ${result.reason}") + callback.onError("语音合成失败kt: ${result.reason}") } } @@ -373,6 +391,7 @@ class AzureTtsHelper(private val context: Context) { try { synthesizer?.StopSpeakingAsync() + speaking = false FileLogger.d(TAG, "已停止语音合成") return true @@ -399,6 +418,7 @@ class AzureTtsHelper(private val context: Context) { } isInitialized = false + speaking = false FileLogger.d(TAG, "TTS 引擎已释放") } catch (e: Exception) { FileLogger.e(TAG, "释放 TTS 引擎失败: ${e.message}") @@ -406,6 +426,15 @@ class AzureTtsHelper(private val context: Context) { } } + /** + * 检查当前是否正在播放语音 + * + * @return 是否正在播放语音 + */ + fun isSpeaking(): Boolean { + return speaking + } + /** * TTS 回调接口 */ diff --git a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt b/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt index 0ca23165c..d60a43c22 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/MainActivity.kt @@ -48,7 +48,7 @@ class MainActivity: FlutterActivity() { val timestamp = intent.getLongExtra("timestamp", 0) Log.d(TAG, "收到语音识别启动广播: timestamp=$timestamp") sendVoiceInteractionEvent(mapOf( - "type" to "recognitionStarted", + "type" to "recognition_started", "timestamp" to timestamp )) } @@ -311,7 +311,6 @@ class MainActivity: FlutterActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { Log.d(TAG, "语音交互事件通道开始监听") voiceInteractionEventSink = events - events?.success(mapOf("type" to "channelReady")) } override fun onCancel(arguments: Any?) { @@ -326,7 +325,6 @@ class MainActivity: FlutterActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { Log.d(TAG, "ASR事件通道开始监听") azureAsrEventSink = events - events?.success(mapOf("type" to "channelReady")) } override fun onCancel(arguments: Any?) { @@ -341,11 +339,6 @@ class MainActivity: FlutterActivity() { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { Log.d(TAG, "蓝牙事件通道开始监听") bluetoothEventSink = events - // 发送就绪事件 - events?.success(mapOf( - "type" to "channelReady", - "timestamp" to System.currentTimeMillis() - )) } override fun onCancel(arguments: Any?) { @@ -542,6 +535,9 @@ class MainActivity: FlutterActivity() { "stopSpeaking" -> { result.success(azureTtsHelper.stopSpeaking()) } + "isSpeaking" -> { + result.success(azureTtsHelper.isSpeaking()) + } "dispose" -> { azureTtsHelper.dispose() result.success(true) @@ -555,14 +551,14 @@ class MainActivity: FlutterActivity() { // 设置语音交互方法通道 MethodChannel(flutterEngine.dartExecutor.binaryMessenger, VOICE_INTERACTION_CHANNEL).setMethodCallHandler { call, result -> when (call.method) { - "startVoiceInteractionService" -> { + "startService" -> { startVoiceInteractionService(call, result) } - "stopVoiceInteractionService" -> { + "stopService" -> { stopVoiceInteractionService() result.success(true) } - "isVoiceInteractionServiceRunning" -> { + "isServiceRunning" -> { result.success(VoiceInteractionService.isServiceRunning()) } "pauseVoiceInteraction" -> { @@ -644,6 +640,10 @@ class MainActivity: FlutterActivity() { result.error("BLUETOOTH_ERROR", "检查蓝牙状态失败: ${e.message}", e.toString()) } } + + "initialize" -> { + result.success(true) + } else -> result.notImplemented() } } @@ -728,7 +728,7 @@ class MainActivity: FlutterActivity() { runOnUiThread { try { val eventData = mapOf( - "type" to "chatHistory", + "type" to "chat_history_updated", "agentId" to agentId, "userMessage" to userMessage, "assistantMessage" to assistantMessage, diff --git a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt b/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt index daa3ab937..f469c136e 100644 --- a/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt +++ b/android/app/src/main/kotlin/com/example/deep_voice/VoiceInteractionService.kt @@ -785,7 +785,7 @@ class VoiceInteractionService : Service() { * 用于从 Flutter 端控制服务 */ private fun pauseVoiceInteraction() { - FileLogger.d(TAG, "暂停语音交互(来自Flutter的请求)") + FileLogger.d(TAG, "暂停后台语音交互(来自Flutter的请求)") // 停止当前TTS播放 stopCurrentTTS() diff --git a/android/app/src/main/res/raw/sample.mp3 b/android/app/src/main/res/raw/sample.mp3 new file mode 100644 index 000000000..31ebc28ce Binary files /dev/null and b/android/app/src/main/res/raw/sample.mp3 differ diff --git a/azure/LICENSE b/azure/LICENSE new file mode 100644 index 000000000..d3df29b8a --- /dev/null +++ b/azure/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Your Company + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/azure/README.md b/azure/README.md new file mode 100644 index 000000000..a19025680 --- /dev/null +++ b/azure/README.md @@ -0,0 +1,66 @@ +# Azure Speech Recognition + +A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. + +## Features + +- Speech-to-text (Azure Speech Recognition) +- Text-to-speech (Azure Speech Synthesis) +- Support for multiple languages +- Language detection +- Continuous recognition +- Streaming synthesis + +## Getting Started + +### Prerequisites + +- Azure Speech service subscription key +- Azure Speech service region + +### Installation + +Add this to your package's `pubspec.yaml` file: + +```yaml +dependencies: + azure_speech_recognition: + path: ./azure +``` + +### Usage + +```dart +import 'package:azure_speech_recognition/azure_speech_recognition.dart'; + +// Initialize the service +await AzureSpeechRecognition.initialize( + subscriptionKey: 'your_subscription_key', + region: 'your_region', + supportedLanguages: ['zh-CN', 'en-US'], +); + +// Start continuous recognition +await AzureSpeechRecognition.startContinuousRecognition(); + +// Listen for recognition events +AzureSpeechRecognition.onRecognitionEvent.listen((event) { + if (event['type'] == 'result') { + print('Recognized: ${event['text']}'); + print('Detected language: ${event['detectedLanguage']}'); + } +}); + +// Stop recognition when done +await AzureSpeechRecognition.stopContinuousRecognition(); + +// Speak text +await AzureSpeechRecognition.speakText('Hello, world!'); + +// Clean up +await AzureSpeechRecognition.dispose(); +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/azure/ios/Classes/AzureAsrHelper.swift b/azure/ios/Classes/AzureAsrHelper.swift new file mode 100644 index 000000000..58b142f60 --- /dev/null +++ b/azure/ios/Classes/AzureAsrHelper.swift @@ -0,0 +1,757 @@ +import Foundation +import MicrosoftCognitiveServicesSpeech +import AVFoundation +import AudioToolbox + +/// Azure ASR工具类,负责实现语音识别服务接口 +@available(iOS 13.0, *) +class AzureAsrHelper: NSObject { + // MARK: - 属性 + + /// 事件处理回调 + private var eventHandler: (String, [String: Any]) -> Void + + /// 语音配置信息 + private var speechSubscriptionKey: String = "" + private var serviceRegion: String = "" + + /// 语音识别相关 + private var speechConfig: SPXSpeechConfiguration? + private var recognizer: SPXSpeechRecognizer? + private var audioConfig: SPXAudioConfiguration? + private var pushStream: SPXPushAudioInputStream? + + /// 音频处理相关 + private var audioProcessor: CustomAudioProcessor? + private var isProcessingAudio = false + private var audioProcessingTimer: Timer? + + /// 状态标志 + private var isInitialized = false + private var _isContinuousRecognitionActive = false + + /// 当前语言和支持的语言 + private var currentLanguage = "zh-CN" + private var supportedLanguages: [String] = ["zh-CN", "en-US"] + private var isAutoDetectLanguage = false + + // MARK: - 初始化 + + init(eventHandler: @escaping (String, [String: Any]) -> Void) { + self.eventHandler = eventHandler + super.init() + } + + deinit { + dispose() + } + + // MARK: - ASR Service 接口实现 + + /// 初始化语音识别服务 + /// - Parameters: + /// - speechSubscriptionKey: Azure 语音服务订阅密钥 + /// - serviceRegion: Azure 服务区域 (如 eastasia) + /// - supportedLanguages: 支持的语言代码数组 (可选) + /// - Returns: 初始化是否成功 + func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { + print("[AzureAsrHelper] 初始化 Azure 语音服务") + + // 检查配置是否为空 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + eventHandler("error", ["message": "Azure 配置信息不完整"]) + return false + } + + // 释放之前的资源 + dispose() + + // 记录配置信息 + self.speechSubscriptionKey = speechSubscriptionKey + self.serviceRegion = serviceRegion + + // 设置语言 + if let languages = supportedLanguages, !languages.isEmpty { + self.supportedLanguages = languages + } + + // 根据支持的语言数量决定是否启用自动语言检测 + isAutoDetectLanguage = self.supportedLanguages.count >= 2 + + // 如果只有一种语言,设置为当前语言 + if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { + currentLanguage = self.supportedLanguages[0] + } + + // 创建识别器和设置回调 + if !createRecognizerAndSetupCallbacks() { + return false + } + + print("[AzureAsrHelper] Azure 语音服务初始化成功") + isInitialized = true + return true + } + + /// 创建识别器并设置回调 + private func createRecognizerAndSetupCallbacks() -> Bool { + // 释放之前的 recognizer + recognizer = nil + audioConfig = nil + + do { + // 创建语音配置 + speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) + + // 设置音频输入参数 + try setupAudioSession() + + // 创建自定义推送流,替代默认的麦克风输入 + pushStream = try SPXPushAudioInputStream() + audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) + + // 初始化自定义音频处理器 + audioProcessor = CustomAudioProcessor() + + // 设置语言配置 + if isAutoDetectLanguage { + // 设置自动语言检测 + speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) + + // 创建自动语言检测配置 + let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) + + // 创建识别器 + recognizer = try SPXSpeechRecognizer( + speechConfiguration: speechConfig!, + autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, + audioConfiguration: audioConfig! + ) + } else { + // 设置指定的识别语言 + speechConfig?.speechRecognitionLanguage = currentLanguage + + // 创建识别器 + recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) + } + + // 设置所有回调 + setupAllCallbacks() + + return true + } catch { + print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") + eventHandler("error", ["message": "创建识别器失败: \(error.localizedDescription)"]) + return false + } + } + + /// 设置音频会话 + private func setupAudioSession() throws { + let audioSession = AVAudioSession.sharedInstance() + + // 使用playAndRecord类别允许同时录音和播放 + try audioSession.setCategory(.playAndRecord, + mode: .voiceChat, // 使用voiceChat模式能够更好地支持回音消除 + options: [.allowBluetooth, .defaultToSpeaker, .allowAirPlay, .mixWithOthers]) + + // 设置首选的输入和输出 + let currentRoute = audioSession.currentRoute + + // 获取当前是否连接了耳机或外部麦克风 + let hasHeadphones = currentRoute.outputs.contains { + $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP + } + + // 如果没有耳机,明确启用内置麦克风和扬声器的回音消除 + if !hasHeadphones { + try audioSession.setMode(.voiceChat) // 语音聊天模式有更强的回音消除 + + // 启用回音消除和噪声抑制 + try audioSession.setInputGain(0.8) // 适当降低输入增益以减少扬声器音频被麦克风捕获的可能性 + } else { + // 耳机模式,可以使用不同的设置 + try audioSession.setMode(.voiceChat) + try audioSession.setInputGain(1.0) + } + + // 设置合适的采样率 + try audioSession.setPreferredSampleRate(16000.0) // Azure语音识别推荐的采样率 + try audioSession.setPreferredIOBufferDuration(0.01) // 较小的缓冲区大小以减少延迟 + + // 激活音频会话 + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + + print("[AzureAsrHelper] 音频会话配置成功,已启用回音消除") + } + + /// 设置所有回调 + private func setupAllCallbacks() { + guard let recognizer = recognizer else { return } + + // 最终识别结果 + recognizer.addRecognizedEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == SPXResultReason.recognizedSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + self.eventHandler("result", [ + "text": event.result.text ?? "", + "detectedLanguage": detectedLanguage + ]) + } + } + + // 识别中事件 + recognizer.addRecognizingEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == SPXResultReason.recognizingSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + // print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + self.eventHandler("recognizing", [ + "text": event.result.text ?? "", + "detectedLanguage": detectedLanguage + ]) + } + } + + // 会话事件 + recognizer.addSessionStartedEventHandler { [weak self] _, _ in + guard let self = self else { return } + + print("[AzureAsrHelper] 识别会话已开始") + self._isContinuousRecognitionActive = true + self.eventHandler("sessionStarted", [:]) + } + + recognizer.addSessionStoppedEventHandler { [weak self] _, _ in + guard let self = self else { return } + + print("[AzureAsrHelper] 识别会话已结束") + self._isContinuousRecognitionActive = false + self.eventHandler("sessionStopped", [:]) + } + + // 取消事件 + recognizer.addCanceledEventHandler { [weak self] _, event in + guard let self = self else { return } + + let reason = event.reason.rawValue + let errorDetails = event.errorDetails ?? "未知错误" + + print("[AzureAsrHelper] 识别取消: \(errorDetails)") + + self.eventHandler("canceled", [ + "reason": reason, + "errorDetails": errorDetails + ]) + + self._isContinuousRecognitionActive = false + } + } + + /// 执行一次性语音识别 + /// - Returns: 是否成功启动识别 + func recognizeOnce() -> Bool { + if !isInitialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + eventHandler("error", ["message": "语音服务未初始化"]) + return false + } + + // 如果正在连续识别,先停止 + if _isContinuousRecognitionActive { + stopContinuousRecognition() + } + + // 确保识别器已创建 + if recognizer == nil && !createRecognizerAndSetupCallbacks() { + return false + } + + do { + // 启动音频处理 + startAudioProcessing() + + // 通知会话开始 + eventHandler("sessionStarted", [:]) + + // 执行识别 + try recognizer?.recognizeOnceAsync { [weak self] result in + guard let self = self else { return } + + // 停止音频处理 + self.stopAudioProcessing() + + if result.reason == SPXResultReason.recognizedSpeech { + let detectedLanguage = self.getDetectedLanguage(from: result) + self.eventHandler("result", [ + "text": result.text ?? "", + "detectedLanguage": detectedLanguage + ]) + } else if result.reason == SPXResultReason.noMatch { + print("[AzureAsrHelper] 无匹配结果") + self.eventHandler("noMatch", [:]) + } else if result.reason == SPXResultReason.canceled { + do { + let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) + let errorDetails = details.errorDetails ?? "未知错误" + self.eventHandler("error", ["message": "识别取消: \(errorDetails)"]) + } catch { + print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") + self.eventHandler("error", ["message": "识别取消,无法获取详细原因"]) + } + } + } + + return true + } catch { + print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") + eventHandler("error", ["message": "识别异常: \(error.localizedDescription)"]) + stopAudioProcessing() + return false + } + } + + /// 开始连续语音识别 + /// - Returns: 是否成功启动识别 + func startContinuousRecognition() -> Bool { + if !isInitialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + eventHandler("error", ["message": "语音服务未初始化"]) + return false + } + + // 如果已经在进行连续识别,先停止 + if _isContinuousRecognitionActive { + stopContinuousRecognition() + } + + // 确保识别器已创建 + if recognizer == nil && !createRecognizerAndSetupCallbacks() { + return false + } + + // 重新确保音频设置正确 + do { + try setupAudioSession() + } catch { + print("[AzureAsrHelper] 警告: 设置音频会话失败: \(error.localizedDescription)") + } + + do { + // 启动音频处理 + startAudioProcessing() + + // 启动连续识别 + try recognizer?.startContinuousRecognition() + _isContinuousRecognitionActive = true + + print("[AzureAsrHelper] 连续识别开始") + return true + } catch { + print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") + eventHandler("error", ["message": "开始连续识别失败: \(error.localizedDescription)"]) + _isContinuousRecognitionActive = false + stopAudioProcessing() + return false + } + } + + /// 停止连续语音识别 + /// - Returns: 是否成功停止识别 + func stopContinuousRecognition() -> Bool { + // 停止音频处理 + stopAudioProcessing() + + if !_isContinuousRecognitionActive || recognizer == nil { + return true + } + + do { + try recognizer?.stopContinuousRecognition() + _isContinuousRecognitionActive = false + print("[AzureAsrHelper] 连续识别已停止") + return true + } catch { + print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") + eventHandler("error", ["message": "停止连续识别失败: \(error.localizedDescription)"]) + _isContinuousRecognitionActive = false + return false + } + } + + /// 检查连续识别是否活跃 + /// - Returns: 连续识别是否处于活跃状态 + func isContinuousRecognitionActive() -> Bool { + return _isContinuousRecognitionActive + } + + /// 释放资源 + func dispose() { + print("[AzureAsrHelper] 释放资源") + + // 停止音频处理 + stopAudioProcessing() + + // 停止连续识别 + if _isContinuousRecognitionActive { + stopContinuousRecognition() + } + + // 释放音频会话 + do { + try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation) + } catch { + print("[AzureAsrHelper] 警告: 释放音频会话失败: \(error.localizedDescription)") + } + + // 释放资源 + recognizer = nil + speechConfig = nil + audioConfig = nil + pushStream = nil + audioProcessor = nil + + // 重置状态 + _isContinuousRecognitionActive = false + isInitialized = false + } + + /// 从结果中获取检测到的语言 + private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { + if isAutoDetectLanguage { + do { + let langResult = try SPXAutoDetectSourceLanguageResult(result) + return langResult.language ?? currentLanguage + } catch { + print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") + return currentLanguage + } + } else { + return currentLanguage + } + } + + // MARK: - 音频处理 + + /// 开始音频处理 + private func startAudioProcessing() { + guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } + + isProcessingAudio = true + + // 启动音频处理器 + if !audioProcessor.startRecord() { + print("[AzureAsrHelper] 错误: 启动音频处理器失败") + eventHandler("error", ["message": "启动音频处理器失败"]) + return + } + + // 启动音频处理定时器 + audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { + return + } + + // 读取处理后的音频数据 + var bytes = [UInt8](repeating: 0, count: 2560) + let bytesRead = processor.read(bytes: &bytes) + + if bytesRead > 0 { + // 推送数据到Azure语音服务 + let data = Data(bytes: bytes, count: bytesRead) + stream.write(data) + + // 通知音频数据可用 + self.eventHandler("audioData", ["data": bytes]) + } + } + + print("[AzureAsrHelper] 音频处理已启动") + } + + /// 停止音频处理 + private func stopAudioProcessing() { + // 停止定时器 + audioProcessingTimer?.invalidate() + audioProcessingTimer = nil + + // 停止音频处理器 + audioProcessor?.stopRecord() + + isProcessingAudio = false + print("[AzureAsrHelper] 音频处理已停止") + } +} + +// MARK: - 自定义音频处理器 + +@available(iOS 13.0, *) +class CustomAudioProcessor: NSObject { + // 音频单元 + private var ioUnit: AudioUnit? + + // 音频格式 + private var audioFormat: AudioStreamBasicDescription + + // 音频缓冲 + private var audioBufferList: AudioBufferList + private var audioList: [Float] = [] + private let audioListQueue = DispatchQueue(label: "audioListQueue") + + // 回音消除状态 + private var isEchoCancellationEnabled = true + + override init() { + // 设置音频格式 - 16kHz, 16位, 单声道 + audioFormat = AudioStreamBasicDescription( + mSampleRate: 16000.0, + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, + mBytesPerPacket: 2, + mFramesPerPacket: 1, + mBytesPerFrame: 2, + mChannelsPerFrame: 1, + mBitsPerChannel: 16, + mReserved: 0 + ) + + // 初始化音频缓冲 + audioBufferList = AudioBufferList( + mNumberBuffers: 1, + mBuffers: AudioBuffer( + mNumberChannels: 1, + mDataByteSize: 4096, + mData: malloc(4096) + ) + ) + + super.init() + } + + deinit { + stopRecord() + free(audioBufferList.mBuffers.mData) + } + + /// 启动音频处理 + /// - Returns: 是否成功启动 + func startRecord() -> Bool { + print("[CustomAudioProcessor] 配置音频单元") + + // 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 + var ioUnitDescription = AudioComponentDescription( + componentType: kAudioUnitType_Output, + componentSubType: kAudioUnitSubType_VoiceProcessingIO, + componentManufacturer: kAudioUnitManufacturer_Apple, + componentFlags: 0, + componentFlagsMask: 0 + ) + + // 查找音频组件 + guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { + print("[CustomAudioProcessor] 错误: 未找到音频组件") + return false + } + + // 创建音频单元实例 + if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { + ioUnit = nil + return false + } + + // 启用输入端口 + var enableInput: UInt32 = 1 + let kInputBus: AudioUnitElement = 1 + let kOutputBus: AudioUnitElement = 0 + if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Input, kInputBus, &enableInput, + UInt32(MemoryLayout.size)), "启用输入端口") { + return false + } + + // 禁用输出端口 (我们只需要输入) + var enableOutput: UInt32 = 0 + if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Output, kOutputBus, + &enableOutput, UInt32(MemoryLayout.size)), "禁用输出端口") { + return false + } + + // 设置缓冲区分配标志 + var flag: UInt32 = 0 + if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, + kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout.size)), "设置缓冲区分配标志") { + return false + } + + // 设置音频格式 + let size = UInt32(MemoryLayout.size) + if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { + return false + } + + if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { + return false + } + + // 启用回音消除 + if isEchoCancellationEnabled { + var echoCancellation: UInt32 = 1 + AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, + kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout.size)) + } + + // 设置输入回调 - 当有新音频数据时调用 + var inputCallback = AURenderCallbackStruct( + inputProc: CustomAudioProcessor.onAudioDataAvailable, + inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + + if checkError(AudioUnitSetProperty(ioUnit!, + kAudioOutputUnitProperty_SetInputCallback, + kAudioUnitScope_Global, kInputBus, + &inputCallback, UInt32(MemoryLayout.size)), "设置输入回调") { + return false + } + + // 初始化音频单元 + var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") + while hasError { + Thread.sleep(forTimeInterval: 0.1) + hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") + } + + // 启动音频单元 + hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") + + print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") + return !hasError + } + + /// 停止音频处理 + func stopRecord() { + print("[CustomAudioProcessor] 停止音频处理器") + + if let ioUnit = ioUnit { + // 停止音频单元 + _ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") + + // 关闭音频单元 + _ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") + _ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") + + self.ioUnit = nil + } + + // 清空音频数据缓冲 + audioListQueue.sync { + audioList.removeAll() + } + } + + /// 音频数据回调 - 当有新的音频数据可用时调用 + private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in + // 获取实例 + let processor = Unmanaged.fromOpaque(inRefCon).takeUnretainedValue() + + // 计算预期数据大小 + let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame + + // 确保缓冲区足够大 + if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { + processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) + processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize + } + + // 渲染音频数据 + let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, + inBusNumber, inNumberFrames, &processor.audioBufferList), + "渲染音频数据") + + // 将Int16数据转换为浮点数据进行处理 + var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) + let buffer = processor.audioBufferList.mBuffers + let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) + + for j in 0...size)) { + // 归一化到[-1.0, 1.0]范围 + audioDataFloat[j] = Float(bufferData[j]) / 32768.0 + } + + // 应用附加处理 (如有需要) + // processor.applyAdditionalProcessing(&audioDataFloat) + + // 保存处理后的数据 + if status == noErr { + processor.audioListQueue.async { + processor.audioList.append(contentsOf: audioDataFloat) + } + } + + return status + } + + /// 读取处理后的音频数据 + /// - Parameter bytes: 输出字节数组 + /// - Returns: 读取的字节数 + func read(bytes: inout [UInt8]) -> Int { + return audioListQueue.sync { + // 如果没有数据,返回0 + if audioList.isEmpty { + return 0 + } + + // 确保有足够的数据 (至少1280个样本) + if audioList.count < 1280 { + return 0 + } + + // 读取一帧数据 (1280个样本) + let frameLength = 1280 + let buffer = Array(audioList.prefix(frameLength)) + audioList.removeFirst(frameLength) + + // 将浮点数据转回Int16格式 + var int16Data = buffer.map { Int16($0 * 32767) } + + // 转换为字节数组 + let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) + bytes = [UInt8](data) + + // 每个样本2字节 (16位PCM) + return frameLength * 2 + } + } + + /// 检查错误并打印日志 + /// - Parameters: + /// - status: 操作状态 + /// - operation: 操作描述 + /// - Returns: 是否发生错误 + private func checkError(_ status: OSStatus, _ operation: String) -> Bool { + if status != noErr { + print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") + return true + } + return false + } + + /// 检查OSStatus并返回状态 + /// - Parameters: + /// - status: 操作状态 + /// - operation: 操作描述 + /// - Returns: 原始状态 + private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { + if status != noErr { + print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") + } + return status + } +} \ No newline at end of file diff --git a/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift b/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift new file mode 100644 index 000000000..141b77ef8 --- /dev/null +++ b/azure/ios/Classes/AzureSpeechRecognitionPlugin.swift @@ -0,0 +1,18 @@ +import Flutter +import UIKit + +public class AzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + if #available(iOS 13.0, *) { + SwiftAzureSpeechRecognitionPlugin.register(with: registrar) + } else { + // 如果低于iOS 13.0,返回不支持的错误 + let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) + channel.setMethodCallHandler { (call, result) in + result(FlutterError(code: "UNSUPPORTED", + message: "需要iOS 13.0及以上系统", + details: nil)) + } + } + } +} \ No newline at end of file diff --git a/azure/ios/Classes/AzureTtsHelper.swift b/azure/ios/Classes/AzureTtsHelper.swift new file mode 100644 index 000000000..589df617c --- /dev/null +++ b/azure/ios/Classes/AzureTtsHelper.swift @@ -0,0 +1,427 @@ +import Foundation +import MicrosoftCognitiveServicesSpeech +import AVFoundation + +/// Azure TTS工具类,负责实现TTS服务接口 +@available(iOS 13.0, *) +class AzureTtsHelper: NSObject { + // MARK: - 属性 + + /// 事件处理回调 + private var eventHandler: (String, [String: Any]) -> Void + + /// 语音配置信息 + private var speechSubscriptionKey: String = "" + private var serviceRegion: String = "" + + /// 语音合成配置 + private var speechConfig: SPXSpeechConfiguration? + + /// 语音合成器 + private var synthesizer: SPXSpeechSynthesizer? + + /// 是否初始化成功 + private var isInitialized = false + + /// 当前是否正在播放 + private var _isSpeaking = false + + /// 音频会话配置 + private var isAudioSessionConfigured = false + + // MARK: - 语音设置 + + /// 当前语音 + private var currentVoice = "zh-CN-XiaoxiaoNeural" + + /// 支持的语音映射 + private var voiceMap: [String: String] = [ + "zh-CN": "zh-CN-XiaoxiaoNeural", + "en-US": "en-US-JennyNeural", + "ja-JP": "ja-JP-NanamiNeural", + "ko-KR": "ko-KR-SunHiNeural", + "zh-TW": "zh-TW-HsiaoChenNeural", + "zh-HK": "zh-HK-HiuMaanNeural" + ] + + /// 当前语音合成参数 + private var currentSpeechRate = "0%" + private var currentPitch = "0%" + private var currentVolume = "100%" + + // MARK: - 初始化 + + init(eventHandler: @escaping (String, [String: Any]) -> Void) { + self.eventHandler = eventHandler + super.init() + } + + deinit { + dispose() + } + + // MARK: - TTS 接口实现 + + /// 初始化语音合成服务 + /// - Parameters: + /// - speechSubscriptionKey: Azure 语音服务订阅密钥 + /// - serviceRegion: Azure 服务区域 (如 eastasia) + /// - language: 语言代码 (默认 zh-CN) + /// - Returns: 初始化是否成功 + func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { + print("[AzureTtsHelper] 初始化语音合成服务") + + // 检查配置是否为空 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + eventHandler("error", ["error": "Azure 配置信息不完整"]) + return false + } + + // 释放之前的资源 + dispose() + + // 记录配置信息 + self.speechSubscriptionKey = speechSubscriptionKey + self.serviceRegion = serviceRegion + + // 配置音频会话 + if !configureAudioSession() { + print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") + } + + do { + // 创建语音配置 + speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) + + // 设置默认语音 + let defaultVoice = getDefaultVoiceForLanguage(language) + currentVoice = defaultVoice + speechConfig?.speechSynthesisVoiceName = defaultVoice + + // 创建语音合成器 + synthesizer = try SPXSpeechSynthesizer(speechConfig!) + + // 设置事件处理器 + setupSynthesizerEvents() + + isInitialized = true + print("[AzureTtsHelper] TTS 引擎初始化成功") + + return true + } catch { + print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") + eventHandler("error", ["error": "初始化语音合成服务失败: \(error.localizedDescription)"]) + return false + } + } + + /// 配置音频会话 + private func configureAudioSession() -> Bool { + let audioSession = AVAudioSession.sharedInstance() + do { + // 使用playback类别,但支持混合和空中播放 + try audioSession.setCategory(.playback, + mode: .spokenAudio, + options: [.mixWithOthers, .allowAirPlay, .duckOthers]) + + // 根据设备类型选择最佳配置 + let currentRoute = audioSession.currentRoute + let hasHeadphones = currentRoute.outputs.contains { + $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP + } + + // 优化音频路由 + if hasHeadphones { + // 耳机模式,使用默认设置 + try audioSession.setPreferredIOBufferDuration(0.005) // 较小的缓冲区大小以减少延迟 + } else { + // 扬声器模式 + try audioSession.setPreferredIOBufferDuration(0.005) + } + + // 避免完全激活音频会话,因为ASR可能已经激活 + // 这里使用setActive(false)是为了不与ASR冲突 + if !audioSession.isOtherAudioPlaying { + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + } + + isAudioSessionConfigured = true + print("[AzureTtsHelper] 音频会话配置成功") + return true + } catch { + print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") + isAudioSessionConfigured = false + return false + } + } + + /// 设置语音 + /// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") + /// - Returns: 设置是否成功 + func setVoice(voiceName: String) -> Bool { + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler("error", ["error": "TTS 引擎尚未初始化"]) + return false + } + + if voiceName.isEmpty { + print("[AzureTtsHelper] 错误: 声音名称为空") + eventHandler("error", ["error": "声音名称不能为空"]) + return false + } + + if voiceName == currentVoice { + print("[AzureTtsHelper] 已设置语音: \(voiceName)") + return true + } + + print("[AzureTtsHelper] 设置声音: \(voiceName)") + currentVoice = voiceName + + // 更新语音配置 + if let speechConfig = speechConfig { + speechConfig.speechSynthesisVoiceName = voiceName + return true + } + + return false + } + + /// 设置语音合成参数 + /// - Parameters: + /// - rate: 语速,范围 -100 到 100,默认为 0 + /// - pitch: 音调,范围 -100 到 100,默认为 0 + /// - volume: 音量,范围 0 到 100,默认为 100 + /// - Returns: 是否设置成功 + func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler("error", ["error": "TTS 引擎尚未初始化"]) + return false + } + + // 转换参数格式 + currentSpeechRate = formatRateParam(rate) + currentPitch = formatPitchParam(pitch) + currentVolume = formatVolumeParam(volume) + + print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") + return true + } + + /// 合成文本为语音并播放 + /// - Parameter text: 要合成的文本 + /// - Returns: 操作是否成功启动 + func speakText(text: String) -> Bool { + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler("error", ["error": "TTS 引擎尚未初始化"]) + return false + } + + if text.isEmpty { + print("[AzureTtsHelper] 警告: 要播放的文本为空") + return true + } + + // 确保音频会话已配置 + if !isAudioSessionConfigured { + _ = configureAudioSession() + } + + print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") + + // 生成SSML + let ssml = generateSsml(text: text) + + // 直接进行SSML合成 + return speakSsmlInternal(text: ssml) + } + + /// 内部SSML合成和播放 + private func speakSsmlInternal(text: String) -> Bool { + guard let synthesizer = synthesizer else { + print("[AzureTtsHelper] 错误: 合成器未初始化") + eventHandler("error", ["error": "合成器未初始化"]) + return false + } + + _isSpeaking = true + eventHandler("started", [:]) + + Task { + do { + // 使用异步方法进行合成并直接播放 + _ = try await synthesizer.startSpeakingSsml(text) + + } catch { + print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler("error", ["error": "语音合成失败: \(error.localizedDescription)"]) + } + } + } + + return true + } + + /// 停止当前语音合成 + /// - Returns: 操作是否成功 + func stopSpeaking() -> Bool { + if !isInitialized || !_isSpeaking { + return true + } + + // 停止合成 + do { + try synthesizer?.stopSpeaking() + _isSpeaking = false + eventHandler("canceled", [:]) + print("[AzureTtsHelper] 已停止语音合成") + return true + } catch { + print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") + eventHandler("error", ["error": "停止语音合成失败: \(error.localizedDescription)"]) + return false + } + } + + /// 检查是否正在播放 + /// - Returns: 当前是否正在播放语音 + func isSpeaking() -> Bool { + return _isSpeaking + } + + /// 释放资源 + func dispose() { + try? stopSpeaking() + + // 释放合成器和配置 + synthesizer = nil + speechConfig = nil + + isInitialized = false + _isSpeaking = false + isAudioSessionConfigured = false + print("[AzureTtsHelper] TTS 引擎已释放") + } + + // MARK: - 私有辅助方法 + + /// 设置合成器事件处理 + private func setupSynthesizerEvents() { + guard let synthesizer = synthesizer else { return } + + // 添加书签到达事件处理 + synthesizer.addBookmarkReachedEventHandler { _, e in + print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") + } + + // 合成完成事件 + synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in + guard let self = self else { return } + print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler("completed", [:]) + } + } + + // 合成取消事件 + synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in + guard let self = self else { return } + + let result = e.result + do { + let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) + print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") + + if cancellationDetails.reason == SPXCancellationReason.error { + print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") + print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") + } + + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler("error", ["error": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) + } + } catch { + print("[AzureTtsHelper] 获取取消详情时出错: \(error)") + + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler("error", ["error": "语音合成被取消"]) + } + } + } + + // 合成开始事件 + synthesizer.addSynthesisStartedEventHandler { _, _ in + // print("[AzureTtsHelper] 语音合成开始") + } + + // 合成中事件 + synthesizer.addSynthesizingEventHandler { _, _ in + // print("[AzureTtsHelper] 语音合成中") + } + } + + /// 生成 SSML 文本 + private func generateSsml(text: String) -> String { + return """ + + + + \(text) + + + + """ + } + + /// 格式化语速参数 + private func formatRateParam(_ rate: Int) -> String { + let clampedRate = rate.clamp(min: -100, max: 100) + if clampedRate == 0 { + return "0%" + } else if clampedRate < 0 { + return "\(Int(Double(clampedRate) * 0.9))%" + } else { + return "+\(clampedRate)%" + } + } + + /// 格式化音调参数 + private func formatPitchParam(_ pitch: Int) -> String { + let clampedPitch = pitch.clamp(min: -100, max: 100) + if clampedPitch == 0 { + return "0%" + } else { + return "\(Int(Double(clampedPitch) * 0.5))%" + } + } + + /// 格式化音量参数 + private func formatVolumeParam(_ volume: Int) -> String { + let clampedVolume = volume.clamp(min: 0, max: 100) + return "\(clampedVolume)%" + } + + /// 获取指定语言的默认语音 + private func getDefaultVoiceForLanguage(_ language: String) -> String { + return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" + } +} + +// MARK: - 扩展 + +extension Int { + func clamp(min: Int, max: Int) -> Int { + if self < min { return min } + if self > max { return max } + return self + } +} \ No newline at end of file diff --git a/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift b/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift new file mode 100644 index 000000000..ea393e215 --- /dev/null +++ b/azure/ios/Classes/SwiftAzureSpeechRecognitionPlugin.swift @@ -0,0 +1,259 @@ +import Flutter +import UIKit +import MicrosoftCognitiveServicesSpeech +import AVFoundation + +@available(iOS 13.0, *) +public class SwiftAzureSpeechRecognitionPlugin: NSObject, FlutterPlugin { + private var azureChannel: FlutterMethodChannel + private var ttsChannel: FlutterMethodChannel + private var asrHelper: AzureAsrHelper + private var ttsHelper: AzureTtsHelper + private static var eventStreamHandler: AzureEventStreamHandler? + + // 创建方法到通道的映射 + private static var ttsMethodHandlers = [String: FlutterMethodCallHandler]() + private static var asrMethodHandlers = [String: FlutterMethodCallHandler]() + + public static func register(with registrar: FlutterPluginRegistrar) { + // ASR通道 + let channel = FlutterMethodChannel(name: "com.deep_voice.azure_asr", binaryMessenger: registrar.messenger()) + + // TTS通道 + let ttsChannel = FlutterMethodChannel(name: "com.deep_voice.azure_tts", binaryMessenger: registrar.messenger()) + + // 设置ASR事件通道 + let eventChannel = FlutterEventChannel(name: "com.deep_voice.azure_asr_events", binaryMessenger: registrar.messenger()) + eventStreamHandler = AzureEventStreamHandler() + eventChannel.setStreamHandler(eventStreamHandler) + + let instance = SwiftAzureSpeechRecognitionPlugin( + azureChannel: channel, + ttsChannel: ttsChannel, + eventStreamHandler: eventStreamHandler! + ) + + // 直接设置各自通道的处理器 + channel.setMethodCallHandler(instance.handleAsrMethodCalls) + ttsChannel.setMethodCallHandler(instance.handleTtsMethodCalls) + } + + + + // 新增直接处理方法调用的函数 + private func handleTtsMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + handleTtsMethod(call, result) + } + + private func handleAsrMethodCalls(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + handleAsrMethod(call, result) + } + + + init(azureChannel: FlutterMethodChannel, ttsChannel: FlutterMethodChannel, eventStreamHandler: AzureEventStreamHandler) { + self.azureChannel = azureChannel + self.ttsChannel = ttsChannel + + // 创建辅助类实例,使用自定义事件回调处理器 + let eventHandler: (String, [String: Any]) -> Void = { eventName, arguments in + DispatchQueue.main.async { + if let eventSink = SwiftAzureSpeechRecognitionPlugin.eventStreamHandler?.eventSink { + var eventData = arguments + eventData["type"] = eventName + eventSink(eventData) + } + } + } + + asrHelper = AzureAsrHelper(eventHandler: eventHandler) + ttsHelper = AzureTtsHelper(eventHandler: eventHandler) + + super.init() + } + + private func handleAsrMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + + let args = call.arguments as? Dictionary + + switch call.method { + case "initialize": + // 仅在初始化时读取必要参数 + guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { + let errorMsg = "语音订阅密钥不能为空" + print("[AzurePlugin] 错误: \(errorMsg)") + result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) + return + } + + guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { + let errorMsg = "服务区域不能为空" + print("[AzurePlugin] 错误: \(errorMsg)") + result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) + return + } + + let supportedLanguages = args?["supportedLanguages"] as? [String] ?? [] + + let success = asrHelper.initialize( + speechSubscriptionKey: speechSubscriptionKey, + serviceRegion: serviceRegion, + supportedLanguages: supportedLanguages.isEmpty ? nil : supportedLanguages + ) + result(success) + + case "startContinuousRecognition": + // 只有使用参数时才验证 + let success = asrHelper.startContinuousRecognition() + result(success) + + case "stopContinuousRecognition": + // 不需要额外参数 + let success = asrHelper.stopContinuousRecognition() + result(success) + + case "recognizeOnce": + // 只有使用参数时才验证 + let success = asrHelper.recognizeOnce() + result(success) + + case "isContinuousRecognitionActive": + // 不需要额外参数 + result(asrHelper.isContinuousRecognitionActive()) + + case "dispose": + // 不需要额外参数 + print("[AzurePlugin] 释放ASR资源") + asrHelper.dispose() + result(true) + + default: + print("[AzurePlugin] 错误: 未知ASR方法: \(call.method)") + result(FlutterMethodNotImplemented) + } + } + + private func handleTtsMethod(_ call: FlutterMethodCall, _ result: @escaping FlutterResult) { + + let args = call.arguments as? Dictionary + + switch call.method { + case "initialize": + // 仅在初始化时验证参数 + guard let speechSubscriptionKey = args?["subscriptionKey"] as? String, !speechSubscriptionKey.isEmpty else { + let errorMsg = "语音订阅密钥不能为空" + print("[AzurePlugin] 错误: \(errorMsg)") + result(FlutterError(code: "INVALID_SUBSCRIPTION_KEY", message: errorMsg, details: nil)) + return + } + + guard let serviceRegion = args?["region"] as? String, !serviceRegion.isEmpty else { + let errorMsg = "服务区域不能为空" + print("[AzurePlugin] 错误: \(errorMsg)") + result(FlutterError(code: "INVALID_REGION", message: errorMsg, details: nil)) + return + } + + let language = args?["language"] as? String ?? "zh-CN" + + print("[AzurePlugin] 初始化TTS,语言: \(language)") + + let success = ttsHelper.initialize(speechSubscriptionKey: speechSubscriptionKey, serviceRegion: serviceRegion, language: language) + result(success) + + case "setVoice": + // 仅获取voice参数 + guard let voiceName = args?["voiceName"] as? String, !voiceName.isEmpty else { + let errorMsg = "声音名称不能为空" + print("[AzurePlugin] 错误: \(errorMsg)") + result(FlutterError(code: "INVALID_VOICE", message: errorMsg, details: nil)) + return + } + + print("[AzurePlugin] 设置声音: \(voiceName)") + + let success = ttsHelper.setVoice(voiceName: voiceName) + result(success) + + case "speakText": + // 仅获取text参数 + let text = args?["text"] as? String ?? "" + + if text.isEmpty { + print("[AzurePlugin] 警告: 要播放的文本为空") + result("OK") + return + } + + print("[AzurePlugin] 播放文本: \(text.prefix(50))...") + + let success = ttsHelper.speakText(text: text) + result(success ? "OK" : "ERROR") + + case "speakSsml": + // 仅获取ssml参数 + guard let ssml = args?["ssml"] as? String, !ssml.isEmpty else { + let errorMsg = "SSML内容不能为空" + print("[AzurePlugin] 错误: \(errorMsg)") + result(FlutterError(code: "INVALID_SSML", message: errorMsg, details: nil)) + return + } + + print("[AzurePlugin] 播放SSML: \(ssml.prefix(100))...") + + // 由于我们移除了speakSsml方法,这里改用speakText方法 + // Azure SDK内部会自动检测是普通文本还是SSML + let success = ttsHelper.speakText(text: ssml) + result(success) + + case "stopSpeaking": + // 不需要参数 + print("[AzurePlugin] 停止播放") + let success = ttsHelper.stopSpeaking() + result(success) + + case "isSpeaking": + // 不需要参数 + result(ttsHelper.isSpeaking()) + + case "setSpeechParams": + // 仅获取语音参数 + let rate = args?["rate"] as? Int ?? 0 + let pitch = args?["pitch"] as? Int ?? 0 + let volume = args?["volume"] as? Int ?? 100 + + print("[AzurePlugin] 设置语音参数: rate=\(rate), pitch=\(pitch), volume=\(volume)") + let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) + result(success) + + case "dispose": + // 释放TTS资源 + print("[AzurePlugin] 释放TTS资源") + ttsHelper.dispose() + result(true) + + default: + print("[AzurePlugin] 错误: 未知TTS方法: \(call.method)") + result(FlutterMethodNotImplemented) + } + } +} + +// 用于处理事件流的辅助类 +@available(iOS 13.0, *) +class AzureEventStreamHandler: NSObject, FlutterStreamHandler { + var eventSink: FlutterEventSink? + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + // 通知Flutter端事件通道已准备好 + DispatchQueue.main.async { + events(["type": "channelReady"]) + } + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + self.eventSink = nil + return nil + } +} \ No newline at end of file diff --git a/azure/ios/azure_speech_recognition.podspec b/azure/ios/azure_speech_recognition.podspec new file mode 100644 index 000000000..88797fb0c --- /dev/null +++ b/azure/ios/azure_speech_recognition.podspec @@ -0,0 +1,24 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint azure_speech_recognition.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'azure_speech_recognition' + s.version = '0.1.0' + s.summary = 'Azure Speech Recognition plugin for Flutter' + s.description = <<-DESC +A Flutter plugin for Microsoft Azure Speech services, providing both speech recognition (ASR) and text-to-speech (TTS) capabilities. + DESC + s.homepage = 'https://github.com/yourusername/azure_speech_recognition' + s.license = { :type => 'MIT', :file => '../LICENSE' } + s.author = { 'Your Company' => 'your-email@example.com' } + s.source = { :path => '.' } + s.source_files = 'Classes/**/*' + s.dependency 'Flutter' + s.dependency 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.34.0' + s.platform = :ios, '12.0' + + # Flutter.framework does not contain a i386 slice. + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES', 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'i386' } + s.swift_version = '5.0' +end \ No newline at end of file diff --git a/azure/lib/azure_speech_recognition.dart b/azure/lib/azure_speech_recognition.dart new file mode 100644 index 000000000..02b3f92d8 --- /dev/null +++ b/azure/lib/azure_speech_recognition.dart @@ -0,0 +1,6 @@ +// This is a placeholder file that exports nothing. +// The actual implementation is in the app's services folder. +// This file exists just to satisfy the Flutter plugin structure requirements. + +// Empty library to satisfy plugin structure +library azure_speech_recognition; \ No newline at end of file diff --git a/azure/pubspec.yaml b/azure/pubspec.yaml new file mode 100644 index 000000000..a36c77bdc --- /dev/null +++ b/azure/pubspec.yaml @@ -0,0 +1,23 @@ +name: azure_speech_recognition +description: Azure Speech Recognition and Text-to-Speech services Flutter plugin +version: 0.1.0 +homepage: https://github.com/yourusername/azure_speech_recognition + +environment: + sdk: '>=2.12.0 <3.0.0' + flutter: ">=2.0.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^1.0.0 + +flutter: + plugin: + platforms: + ios: + pluginClass: AzureSpeechRecognitionPlugin \ No newline at end of file diff --git a/ios/Podfile b/ios/Podfile index e549ee22f..1a255e840 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -# platform :ios, '12.0' +platform :ios, '12.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' @@ -31,6 +31,10 @@ target 'Runner' do use_frameworks! flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) + + # 添加 Microsoft Cognitive Services Speech SDK + pod 'MicrosoftCognitiveServicesSpeech-iOS', '~> 1.34.0' + target 'RunnerTests' do inherit! :search_paths end diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 600c723fe..aa4aa611a 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,21 +1,24 @@ PODS: - - audio_service (0.0.1): - - Flutter - - FlutterMacOS - audio_session (0.0.1): - Flutter - - azure_speech_recognition_null_safety (0.0.1): + - audio_waveforms (0.0.1): - Flutter - - MicrosoftCognitiveServicesSpeech-iOS (~> 1.29.0) - device_info_plus (0.0.1): - Flutter - Flutter (1.0.0) + - flutter_blue_plus_darwin (0.0.2): + - Flutter + - FlutterMacOS - flutter_local_notifications (0.0.1): - Flutter + - flutter_tts (0.0.1): + - Flutter + - image_picker_ios (0.0.1): + - Flutter - just_audio (0.0.1): - Flutter - FlutterMacOS - - MicrosoftCognitiveServicesSpeech-iOS (1.29.0) + - MicrosoftCognitiveServicesSpeech-iOS (1.34.0) - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS @@ -23,40 +26,53 @@ PODS: - Flutter - record_darwin (1.0.0): - Flutter - - sqflite_darwin (0.0.4): + - share_plus (0.0.1): + - Flutter + - speech_to_text (0.0.1): - Flutter - FlutterMacOS + - Try + - Try (2.1.1) DEPENDENCIES: - - audio_service (from `.symlinks/plugins/audio_service/darwin`) - audio_session (from `.symlinks/plugins/audio_session/ios`) - - azure_speech_recognition_null_safety (from `.symlinks/plugins/azure_speech_recognition_null_safety/ios`) + - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - Flutter (from `Flutter`) + - flutter_blue_plus_darwin (from `.symlinks/plugins/flutter_blue_plus_darwin/darwin`) - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_tts (from `.symlinks/plugins/flutter_tts/ios`) + - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`) - just_audio (from `.symlinks/plugins/just_audio/darwin`) + - MicrosoftCognitiveServicesSpeech-iOS (~> 1.34.0) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - record_darwin (from `.symlinks/plugins/record_darwin/ios`) - - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - speech_to_text (from `.symlinks/plugins/speech_to_text/darwin`) SPEC REPOS: trunk: - MicrosoftCognitiveServicesSpeech-iOS + - Try EXTERNAL SOURCES: - audio_service: - :path: ".symlinks/plugins/audio_service/darwin" audio_session: :path: ".symlinks/plugins/audio_session/ios" - azure_speech_recognition_null_safety: - :path: ".symlinks/plugins/azure_speech_recognition_null_safety/ios" + audio_waveforms: + :path: ".symlinks/plugins/audio_waveforms/ios" device_info_plus: :path: ".symlinks/plugins/device_info_plus/ios" Flutter: :path: Flutter + flutter_blue_plus_darwin: + :path: ".symlinks/plugins/flutter_blue_plus_darwin/darwin" flutter_local_notifications: :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_tts: + :path: ".symlinks/plugins/flutter_tts/ios" + image_picker_ios: + :path: ".symlinks/plugins/image_picker_ios/ios" just_audio: :path: ".symlinks/plugins/just_audio/darwin" path_provider_foundation: @@ -65,23 +81,29 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/permission_handler_apple/ios" record_darwin: :path: ".symlinks/plugins/record_darwin/ios" - sqflite_darwin: - :path: ".symlinks/plugins/sqflite_darwin/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + speech_to_text: + :path: ".symlinks/plugins/speech_to_text/darwin" SPEC CHECKSUMS: - audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0 - azure_speech_recognition_null_safety: aed1d7ededc73a1d0b7a342b1f4803ea166e2747 + audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896 Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_blue_plus_darwin: 09444a26fb6bdef523e55b68fc1c59af5a877ea6 flutter_local_notifications: 395056b3175ba4f08480a7c5de30cd36d69827e4 + flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833 + image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed - MicrosoftCognitiveServicesSpeech-iOS: 0de20a8ebb6bbd87decaeaa9a707dd40830106a6 + MicrosoftCognitiveServicesSpeech-iOS: 723eccb3e459bbb530df3d9bfe84a291074b09eb path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d record_darwin: fb1f375f1d9603714f55b8708a903bbb91ffdb0a - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a + speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac + Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 -PODFILE CHECKSUM: 359b0564325fb2f8842a19a47bd7e63cbdcd9c04 +PODFILE CHECKSUM: f35c1d63b4b8ce9c2eb88107f162ac59923c4839 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e8a03afe1..afed04887 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -7,15 +7,24 @@ objects = { /* Begin PBXBuildFile section */ + 0A5EDBFC2D89C4FD00215E26 /* ClassicBluetoothHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */; }; + 0A5EDC042D8B544100215E26 /* BluetoothMediaButtonHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A5EDC032D8B544100215E26 /* BluetoothMediaButtonHelper.swift */; }; + 0A7A32E42D8DBA6D0047F746 /* VoiceInteractionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */; }; + 0A7A32E52D8DBA6D0047F746 /* VolcanoAIService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */; }; + 0ADE29692D8C25660032BFCB /* silence.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 0ADE29682D8C25660032BFCB /* silence.mp3 */; }; + 0ADE296A2D8C25660032BFCB /* sample.mp3 in Resources */ = {isa = PBXBuildFile; fileRef = 0ADE29672D8C25660032BFCB /* sample.mp3 */; }; + 0ADE296D2D8C66D60032BFCB /* AzureTtsHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADE296C2D8C66D60032BFCB /* AzureTtsHelper.swift */; }; + 0ADE296E2D8C66D60032BFCB /* AzureAsrHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADE296B2D8C66D60032BFCB /* AzureAsrHelper.swift */; }; + 0ADE29702D8C6C450032BFCB /* AudioSessionManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ADE296F2D8C6C450032BFCB /* AudioSessionManager.swift */; }; 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; + 3406320CAE3314A73EF78043 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5BBE10AD7954527B6BB12CFB /* Pods_RunnerTests.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - 993AA22300B2BA544C45E2B2 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D59A10400354CAD76662DBB3 /* Pods_RunnerTests.framework */; }; - F3E628587CFA36508BAB3B56 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 735C476F36391FA72F7CA27A /* Pods_Runner.framework */; }; + E1D2128FAB4726BEEED42F4C /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4A9190D0838960F143BDC158 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -42,18 +51,30 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 0906EF7A73CBA5EAFC64E05D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClassicBluetoothHelper.swift; sourceTree = ""; }; + 0A5EDC032D8B544100215E26 /* BluetoothMediaButtonHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothMediaButtonHelper.swift; sourceTree = ""; }; + 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceInteractionService.swift; sourceTree = ""; }; + 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolcanoAIService.swift; sourceTree = ""; }; + 0ADE29672D8C25660032BFCB /* sample.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = sample.mp3; sourceTree = ""; }; + 0ADE29682D8C25660032BFCB /* silence.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = silence.mp3; sourceTree = ""; }; + 0ADE296B2D8C66D60032BFCB /* AzureAsrHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureAsrHelper.swift; sourceTree = ""; }; + 0ADE296C2D8C66D60032BFCB /* AzureTtsHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AzureTtsHelper.swift; sourceTree = ""; }; + 0ADE296F2D8C6C450032BFCB /* AudioSessionManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSessionManager.swift; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 478E46ED4F3AE11E50872120 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; - 5C3B0E63225CBCC97A48145A /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; - 671AD9D18AB66E08CC945D07 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; - 735C476F36391FA72F7CA27A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 4A9190D0838960F143BDC158 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 5BBE10AD7954527B6BB12CFB /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 77D16D761891F29239C8DF02 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 8C1130E20389847CBD7B1AAC /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -61,26 +82,23 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - BCC52C2C6E872DD19D86EA8B /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - C0DB3B999FA4C3E6CFA90079 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - D59A10400354CAD76662DBB3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F83C5E570162235C4FE20CCB /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 9B84B894288BF76332D3F407 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ - 6AE85031FDE82288FAE3C673 /* Frameworks */ = { + 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 993AA22300B2BA544C45E2B2 /* Pods_RunnerTests.framework in Frameworks */, + E1D2128FAB4726BEEED42F4C /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; - 97C146EB1CF9000F007C117D /* Frameworks */ = { + B4459155A2535A0ED1D6C7D8 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - F3E628587CFA36508BAB3B56 /* Pods_Runner.framework in Frameworks */, + 3406320CAE3314A73EF78043 /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -95,6 +113,19 @@ path = RunnerTests; sourceTree = ""; }; + 776E5AB1CE0A62CBDBAB3996 /* Pods */ = { + isa = PBXGroup; + children = ( + 8C1130E20389847CBD7B1AAC /* Pods-Runner.debug.xcconfig */, + 77D16D761891F29239C8DF02 /* Pods-Runner.release.xcconfig */, + 0906EF7A73CBA5EAFC64E05D /* Pods-Runner.profile.xcconfig */, + 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */, + 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */, + 9B84B894288BF76332D3F407 /* Pods-RunnerTests.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -113,8 +144,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, - 9DDB11208866A014130CF5A9 /* Pods */, - D1DDBFE3838710CC012E9C02 /* Frameworks */, + 776E5AB1CE0A62CBDBAB3996 /* Pods */, + A5A9FDE94E78257E9A48E2E8 /* Frameworks */, ); sourceTree = ""; }; @@ -130,6 +161,15 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 0A7A32E22D8DBA6D0047F746 /* VoiceInteractionService.swift */, + 0A7A32E32D8DBA6D0047F746 /* VolcanoAIService.swift */, + 0ADE296F2D8C6C450032BFCB /* AudioSessionManager.swift */, + 0ADE296B2D8C66D60032BFCB /* AzureAsrHelper.swift */, + 0ADE296C2D8C66D60032BFCB /* AzureTtsHelper.swift */, + 0ADE29672D8C25660032BFCB /* sample.mp3 */, + 0ADE29682D8C25660032BFCB /* silence.mp3 */, + 0A5EDC032D8B544100215E26 /* BluetoothMediaButtonHelper.swift */, + 0A5EDBFB2D89C4FD00215E26 /* ClassicBluetoothHelper.swift */, 97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FD1CF9000F007C117D /* Assets.xcassets */, 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, @@ -142,25 +182,11 @@ path = Runner; sourceTree = ""; }; - 9DDB11208866A014130CF5A9 /* Pods */ = { - isa = PBXGroup; - children = ( - F83C5E570162235C4FE20CCB /* Pods-Runner.debug.xcconfig */, - C0DB3B999FA4C3E6CFA90079 /* Pods-Runner.release.xcconfig */, - 478E46ED4F3AE11E50872120 /* Pods-Runner.profile.xcconfig */, - 5C3B0E63225CBCC97A48145A /* Pods-RunnerTests.debug.xcconfig */, - BCC52C2C6E872DD19D86EA8B /* Pods-RunnerTests.release.xcconfig */, - 671AD9D18AB66E08CC945D07 /* Pods-RunnerTests.profile.xcconfig */, - ); - name = Pods; - path = Pods; - sourceTree = ""; - }; - D1DDBFE3838710CC012E9C02 /* Frameworks */ = { + A5A9FDE94E78257E9A48E2E8 /* Frameworks */ = { isa = PBXGroup; children = ( - 735C476F36391FA72F7CA27A /* Pods_Runner.framework */, - D59A10400354CAD76662DBB3 /* Pods_RunnerTests.framework */, + 4A9190D0838960F143BDC158 /* Pods_Runner.framework */, + 5BBE10AD7954527B6BB12CFB /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -172,10 +198,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 4CED93D53BF7D79C894B39B7 /* [CP] Check Pods Manifest.lock */, + 1BD7F444CF127CDFCB7F9F47 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, - 6AE85031FDE82288FAE3C673 /* Frameworks */, + B4459155A2535A0ED1D6C7D8 /* Frameworks */, ); buildRules = ( ); @@ -191,15 +217,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - CB1E1186EB3C828E55784697 /* [CP] Check Pods Manifest.lock */, + 8A6DB356BF41E96BFD7DF109 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - D7B8455F87E542E882C75D57 /* [CP] Embed Pods Frameworks */, - E839EB3BEF50366A68B2A46D /* [CP] Copy Pods Resources */, + 4A5C19D622EF675DB4056F07 /* [CP] Embed Pods Frameworks */, + D49299AF1487DF3783287D24 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -264,6 +290,8 @@ 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 0ADE29692D8C25660032BFCB /* silence.mp3 in Resources */, + 0ADE296A2D8C25660032BFCB /* sample.mp3 in Resources */, 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -271,23 +299,7 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { - isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", - ); - name = "Thin Binary"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; - }; - 4CED93D53BF7D79C894B39B7 /* [CP] Check Pods Manifest.lock */ = { + 1BD7F444CF127CDFCB7F9F47 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -309,22 +321,40 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", ); - name = "Run Script"; + name = "Thin Binary"; outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 4A5C19D622EF675DB4056F07 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; }; - CB1E1186EB3C828E55784697 /* [CP] Check Pods Manifest.lock */ = { + 8A6DB356BF41E96BFD7DF109 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -346,24 +376,22 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - D7B8455F87E542E882C75D57 /* [CP] Embed Pods Frameworks */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + inputPaths = ( ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + name = "Run Script"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; - E839EB3BEF50366A68B2A46D /* [CP] Copy Pods Resources */ = { + D49299AF1487DF3783287D24 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -395,7 +423,14 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0ADE296D2D8C66D60032BFCB /* AzureTtsHelper.swift in Sources */, + 0ADE296E2D8C66D60032BFCB /* AzureAsrHelper.swift in Sources */, + 0A5EDBFC2D89C4FD00215E26 /* ClassicBluetoothHelper.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 0A5EDC042D8B544100215E26 /* BluetoothMediaButtonHelper.swift in Sources */, + 0A7A32E42D8DBA6D0047F746 /* VoiceInteractionService.swift in Sources */, + 0A7A32E52D8DBA6D0047F746 /* VolcanoAIService.swift in Sources */, + 0ADE29702D8C6C450032BFCB /* AudioSessionManager.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -473,7 +508,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -489,13 +524,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; @@ -505,50 +542,64 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5C3B0E63225CBCC97A48145A /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 930FE6D58BDB20688CE0A7CF /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 42C82SZUVX; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Debug; }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BCC52C2C6E872DD19D86EA8B /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 02CF74744224D1C933681603 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 42C82SZUVX; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Release; }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 671AD9D18AB66E08CC945D07 /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 9B84B894288BF76332D3F407 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 42C82SZUVX; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice.RunnerTests; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Runner.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Runner"; }; name = Profile; @@ -602,7 +653,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -653,7 +704,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 12.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -670,15 +721,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; @@ -693,13 +749,15 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = 42C82SZUVX; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.entertainment"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - PRODUCT_BUNDLE_IDENTIFIER = com.example.deepVoice; + PRODUCT_BUNDLE_IDENTIFIER = com.yunqiinnovation.deepsound; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; SWIFT_VERSION = 5.0; diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 626664468..3bfd2c086 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,13 +1,743 @@ import Flutter import UIKit +import AVFoundation @main @objc class AppDelegate: FlutterAppDelegate { + private var classicBluetoothHelper: ClassicBluetoothHelper? + private var mediaButtonHelper: Any? + // 添加Azure语音服务辅助类 + private var azureAsrHelper: Any? + private var azureTtsHelper: Any? + // 添加火山AI服务 + private var volcanoAIService: VolcanoAIService? + // 语音交互服务 + private var voiceInteractionService: VoiceInteractionService? + + private var audioSessionManager: AudioSessionManager? + override func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { + + // 获取flutter控制器 + guard let controller = window?.rootViewController as? FlutterViewController else { + NSLog("AppDelegate: Flutter控制器不可用") + return false + } + + // 初始化音频会话管理器 + audioSessionManager = AudioSessionManager.shared + + + // 统一注册所有方法通道 + registerMethodChannel() + GeneratedPluginRegistrant.register(with: self) return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + private func registerMethodChannel() { + let controller = window?.rootViewController as? FlutterViewController + guard let controller = controller else { + NSLog("AppDelegate: FlutterViewController 不可用") + return + } + + // 注册经典蓝牙方法通道 + registerClassicBluetoothChannels(controller: controller) + + // 仅在iOS 13.0及以上版本注册媒体按钮通道和Azure语音服务通道 + if #available(iOS 13.0, *) { + // 注册媒体按钮方法通道 + registerMediaButtonChannels(controller: controller) + + // 注册Azure ASR方法通道 + registerAzureAsrChannels(controller: controller) + + // 注册Azure TTS方法通道 + registerAzureTtsChannels(controller: controller) + + // 注册火山AI服务方法通道 + registerVolcanoAIServiceChannels(controller: controller) + + // 注册语音交互服务方法通道 + registerVoiceInteractionChannels(controller: controller) + } + } + + // MARK: - 经典蓝牙通道 + + private func registerClassicBluetoothChannels(controller: FlutterViewController) { + // 初始化蓝牙辅助类 + if classicBluetoothHelper == nil { + classicBluetoothHelper = ClassicBluetoothHelper() + } + + guard let classicBluetoothHelper = self.classicBluetoothHelper else { + NSLog("AppDelegate: 经典蓝牙辅助实例不可用") + return + } + + // 注册经典蓝牙方法通道 + let classicBluetoothChannel = FlutterMethodChannel( + name: "com.deep_voice.classic_bluetooth", + binaryMessenger: controller.binaryMessenger + ) + + classicBluetoothChannel.setMethodCallHandler { [weak self, weak classicBluetoothHelper] (call, result) in + guard let _ = self, let classicBluetoothHelper = classicBluetoothHelper else { + result(FlutterError(code: "UNAVAILABLE", message: "经典蓝牙辅助实例不可用", details: nil)) + return + } + + switch call.method { + case "isBluetoothEnabled": + result(classicBluetoothHelper.isBluetoothEnabled()) + + case "getConnectedHeadsetDevices": + // 获取已连接的耳机设备 + classicBluetoothHelper.getConnectedHeadsetDevices { devices, error in + if let error = error { + result(FlutterError(code: "GET_DEVICES_FAILED", message: error, details: nil)) + } else { + result(devices) + } + } + case "initialize": + classicBluetoothHelper.initialize() + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册蓝牙流通道 + let classicBluetoothStreamChannel = FlutterEventChannel( + name: "com.deep_voice.classic_bluetooth_events", + binaryMessenger: controller.binaryMessenger + ) + + // 创建蓝牙流处理器(作为局部变量) + let bluetoothStreamHandler = BluetoothStreamHandler(bluetoothHelper: classicBluetoothHelper) + classicBluetoothStreamChannel.setStreamHandler(bluetoothStreamHandler) + } + + // MARK: - 消息通道设置 + + // MARK: - 蓝牙媒体按键通道 + + @available(iOS 13.0, *) + private func registerMediaButtonChannels(controller: FlutterViewController) { + // 初始化媒体按钮辅助类 + if mediaButtonHelper == nil { + mediaButtonHelper = BluetoothMediaButtonHelper() + } + // guard let mediaButtonHelper = self.mediaButtonHelper as? BluetoothMediaButtonHelper else { + // NSLog("AppDelegate: 媒体按钮辅助实例不可用") + // return + // } + // mediaButtonHelper.startButtonListening() + // 注册媒体按钮方法通道 + let mediaButtonChannel = FlutterMethodChannel( + name: "com.deep_voice.bluetooth_media_button", + binaryMessenger: controller.binaryMessenger + ) + + mediaButtonChannel.setMethodCallHandler { [weak self] (call, result) in + guard let _ = self, let mediaButtonHelper = self?.mediaButtonHelper as? BluetoothMediaButtonHelper else { + result(FlutterError(code: "UNAVAILABLE", message: "媒体按钮辅助实例不可用", details: nil)) + return + } + + switch call.method { + case "startButtonListening": + let success = mediaButtonHelper.startButtonListening() + result(success) + + case "stopButtonListening": + let success = mediaButtonHelper.stopButtonListening() + result(success) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册媒体按钮流通道 + let mediaButtonStreamChannel = FlutterEventChannel( + name: "com.deep_voice.bluetooth_media_button_events", + binaryMessenger: controller.binaryMessenger + ) + + // 创建媒体按钮流处理器 + let mediaButtonStreamHandler = MediaButtonStreamHandler(mediaButtonHelper: mediaButtonHelper as! BluetoothMediaButtonHelper) + mediaButtonStreamChannel.setStreamHandler(mediaButtonStreamHandler) + } + + + // 注册Azure ASR通道 + @available(iOS 13.0, *) + private func registerAzureAsrChannels(controller: FlutterViewController) { + // 注册Azure ASR方法通道 + let azureAsrChannel = FlutterMethodChannel( + name: "com.deep_voice.azure_asr", + binaryMessenger: controller.binaryMessenger + ) + + // 初始化Azure ASR辅助类 + azureAsrHelper = AzureAsrHelper() + + // 设置ASR方法处理器 + azureAsrChannel.setMethodCallHandler { [weak self] (call, result) in + guard let self = self, + let asrHelper = self.azureAsrHelper as? AzureAsrHelper else { + result(FlutterError(code: "UNAVAILABLE", message: "Azure ASR辅助实例不可用", details: nil)) + return + } + + switch call.method { + case "initialize": + guard let args = call.arguments as? [String: Any], + let key = args["subscriptionKey"] as? String, + let region = args["region"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) + return + } + + let languages = args["supportedLanguages"] as? [String] + let success = asrHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, supportedLanguages: languages) + result(success) + + case "recognizeOnce": + let success = asrHelper.recognizeOnce() + result(success) + + case "startContinuousRecognition": + let success = asrHelper.startContinuousRecognition() + result(success) + + case "stopContinuousRecognition": + let success = asrHelper.stopContinuousRecognition() + result(success) + + case "isContinuousRecognitionActive": + let isActive = asrHelper.isContinuousRecognitionActive() + result(isActive) + + case "dispose": + asrHelper.dispose() + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册Azure ASR流通道 + let azureAsrStreamChannel = FlutterEventChannel( + name: "com.deep_voice.azure_asr_events", + binaryMessenger: controller.binaryMessenger + ) + + // 创建ASR流处理器 + let asrStreamHandler = AsrStreamHandler(azureAsrHelper: azureAsrHelper as! AzureAsrHelper) + azureAsrStreamChannel.setStreamHandler(asrStreamHandler) + } + + // 注册Azure TTS通道 + @available(iOS 13.0, *) + private func registerAzureTtsChannels(controller: FlutterViewController) { + // 注册Azure TTS方法通道 + let azureTtsChannel = FlutterMethodChannel( + name: "com.deep_voice.azure_tts", + binaryMessenger: controller.binaryMessenger + ) + + // 初始化Azure TTS辅助类 + azureTtsHelper = AzureTtsHelper() + + // 设置TTS方法处理器 + azureTtsChannel.setMethodCallHandler { [weak self] (call, result) in + guard let self = self, + let ttsHelper = self.azureTtsHelper as? AzureTtsHelper else { + result(FlutterError(code: "UNAVAILABLE", message: "Azure TTS辅助实例不可用", details: nil)) + return + } + + switch call.method { + case "initialize": + guard let args = call.arguments as? [String: Any], + let key = args["subscriptionKey"] as? String, + let region = args["region"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "初始化参数无效", details: nil)) + return + } + + let language = args["language"] as? String ?? "zh-CN" + let success = ttsHelper.initialize(speechSubscriptionKey: key, serviceRegion: region, language: language) + result(success) + + case "setVoice": + guard let args = call.arguments as? [String: Any], + let voiceName = args["voiceName"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "语音名称参数无效", details: nil)) + return + } + + let success = ttsHelper.setVoice(voiceName: voiceName) + result(success) + + case "setSpeechParams": + guard let args = call.arguments as? [String: Any] else { + result(FlutterError(code: "INVALID_ARGS", message: "语音参数无效", details: nil)) + return + } + + let rate = args["rate"] as? Int ?? 0 + let pitch = args["pitch"] as? Int ?? 0 + let volume = args["volume"] as? Int ?? 100 + + let success = ttsHelper.setSpeechParams(rate: rate, pitch: pitch, volume: volume) + result(success) + + case "speakText": + guard let args = call.arguments as? [String: Any], + let text = args["text"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "文本参数无效", details: nil)) + return + } + + let success = ttsHelper.speakText(text: text) + result(success) + + case "stopSpeaking": + let success = ttsHelper.stopSpeaking() + result(success) + + case "isSpeaking": + let isSpeaking = ttsHelper.isSpeaking() + result(isSpeaking) + + case "dispose": + ttsHelper.dispose() + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册Azure TTS流通道 + let azureTtsStreamChannel = FlutterEventChannel( + name: "com.deep_voice.azure_tts_events", + binaryMessenger: controller.binaryMessenger + ) + + // 创建TTS流处理器 + let ttsStreamHandler = TtsStreamHandler(azureTtsHelper: azureTtsHelper as! AzureTtsHelper) + azureTtsStreamChannel.setStreamHandler(ttsStreamHandler) + } + + // 注册火山AI服务方法通道 + @available(iOS 13.0, *) + private func registerVolcanoAIServiceChannels(controller: FlutterViewController) { + guard let volcanoAIService = volcanoAIService else { + return + } + + // 注册火山AI方法通道 + let volcanoAIChannel = FlutterMethodChannel( + name: "com.deep_voice.volcano_ai", + binaryMessenger: controller.binaryMessenger + ) + + volcanoAIChannel.setMethodCallHandler { [weak self, weak volcanoAIService] (call, result) in + guard let _ = self, let volcanoAIService = volcanoAIService else { + result(FlutterError(code: "UNAVAILABLE", message: "火山AI服务实例不可用", details: nil)) + return + } + + switch call.method { + case "initialize": + let apiKey = call.arguments as? String ?? "" + let success = volcanoAIService.initialize(apiKey: apiKey) + result(success) + + case "sendMessage": + guard let arguments = call.arguments as? [String: Any], + let message = arguments["message"] as? String, + let systemPrompt = arguments["systemPrompt"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "参数无效", details: nil)) + return + } + + if message.isEmpty { + result(FlutterError(code: "EMPTY_MESSAGE", message: "消息不能为空", details: nil)) + return + } + + // 异步发送消息 + Task { + do { + // 创建用户消息 + let userMessage = volcanoAIService.createUserMessage(content: message) + + // 发送消息并等待响应 + let response = try await volcanoAIService.sendMessage( + messages: [userMessage], + systemPrompt: systemPrompt + ) + + // 返回响应 + DispatchQueue.main.async { + result(response) + } + } catch { + // 处理错误 + DispatchQueue.main.async { + result(FlutterError( + code: "AI_ERROR", + message: "AI请求失败: \(error.localizedDescription)", + details: nil + )) + } + } + } + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册火山AI流通道 + let volcanoAIStreamChannel = FlutterEventChannel( + name: "com.deep_voice.volcano_ai_stream", + binaryMessenger: controller.binaryMessenger + ) + + // 创建火山AI流处理器(作为局部变量) + let volcanoAIStreamHandler = VolcanoAIStreamHandler(volcanoAIService: volcanoAIService) + volcanoAIStreamChannel.setStreamHandler(volcanoAIStreamHandler) + } + + // 注册语音交互服务方法通道 + @available(iOS 13.0, *) + private func registerVoiceInteractionChannels(controller: FlutterViewController) { + // 初始化语音交互服务 + voiceInteractionService = VoiceInteractionService.shared + + // 注册语音交互服务方法通道 + let voiceInteractionChannel = FlutterMethodChannel( + name: "com.deep_voice.voice_interaction", + binaryMessenger: controller.binaryMessenger + ) + + voiceInteractionChannel.setMethodCallHandler { [weak self, weak voiceInteractionService] (call, result) in + guard let _ = self, let voiceInteractionService = voiceInteractionService else { + result(FlutterError(code: "UNAVAILABLE", message: "语音交互服务实例不可用", details: nil)) + return + } + + switch call.method { + case "startService": + guard let args = call.arguments as? [String: Any], + let azureKey = args["azure_speech_key"] as? String, + let azureRegion = args["azure_speech_region"] as? String, + let volcanoKey = args["volcano_ai_api_key"] as? String else { + result(FlutterError(code: "INVALID_ARGS", message: "启动服务参数无效", details: nil)) + return + } + + let success = voiceInteractionService.start(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) + result(success) + + case "stopService": + voiceInteractionService.stop() + result(true) + + case "isServiceRunning": + let isRunning = voiceInteractionService.isServiceRunning() + result(isRunning) + + case "pauseVoiceInteraction": + voiceInteractionService.pauseVoiceInteraction() + result(true) + + default: + result(FlutterMethodNotImplemented) + } + } + + // 注册语音交互流通道 + let voiceInteractionStreamChannel = FlutterEventChannel( + name: "com.deep_voice.voice_interaction_events", + binaryMessenger: controller.binaryMessenger + ) + + // 创建语音交互流处理器(作为局部变量) + let voiceInteractionStreamHandler = VoiceInteractionStreamHandler(voiceInteractionService: voiceInteractionService!) + voiceInteractionStreamChannel.setStreamHandler(voiceInteractionStreamHandler) + } +} + +// 蓝牙事件流处理器 +class BluetoothStreamHandler: NSObject, FlutterStreamHandler, BluetoothEventHandler { + private let bluetoothHelper: ClassicBluetoothHelper + private var eventSink: FlutterEventSink? + + init(bluetoothHelper: ClassicBluetoothHelper) { + self.bluetoothHelper = bluetoothHelper + super.init() + + // 设置事件处理器 + bluetoothHelper.setEventHandler(self) + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + // NSLog("BluetoothStreamHandler: 开始监听蓝牙事件") + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + // NSLog("BluetoothStreamHandler: 取消监听蓝牙事件") + eventSink = nil + return nil + } + + // 实现BluetoothEventHandler协议 + func sendEvent(_ event: [String: Any]) { + NSLog("BluetoothStreamHandler: 接收到蓝牙事件: \(event)") + DispatchQueue.main.async { [weak self] in + self?.eventSink?(event) + } + } +} + +// 媒体按钮事件流处理器 +@available(iOS 13.0, *) +class MediaButtonStreamHandler: NSObject, FlutterStreamHandler, MediaButtonEventHandler { + private let mediaButtonHelper: BluetoothMediaButtonHelper + private var eventSink: FlutterEventSink? + + init(mediaButtonHelper: BluetoothMediaButtonHelper) { + self.mediaButtonHelper = mediaButtonHelper + super.init() + + // 设置事件处理器 + mediaButtonHelper.setEventHandler(self) + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + NSLog("MediaButtonStreamHandler: 开始监听媒体按钮事件") + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NSLog("MediaButtonStreamHandler: 取消监听媒体按钮事件") + eventSink = nil + return nil + } + + // 实现MediaButtonEventHandler协议 + func sendEvent(_ event: [String: Any]) { + NSLog("MediaButtonStreamHandler: 收到按钮事件 \(event)") + DispatchQueue.main.async { [weak self] in + self?.eventSink?(event) + } + } +} + +// ASR事件处理器协议 +@available(iOS 13.0, *) +protocol AsrEventHandler: AnyObject { + func sendEvent(_ event: [String: Any]) +} + +// ASR事件流处理器 +@available(iOS 13.0, *) +class AsrStreamHandler: NSObject, FlutterStreamHandler, AsrEventHandler { + private let azureAsrHelper: AzureAsrHelper + private var eventSink: FlutterEventSink? + + init(azureAsrHelper: AzureAsrHelper) { + self.azureAsrHelper = azureAsrHelper + super.init() + + // 设置事件处理器 + azureAsrHelper.setEventHandler { [weak self] event in + self?.sendEvent(event) + } + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + NSLog("AsrStreamHandler: 开始监听ASR事件") + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NSLog("AsrStreamHandler: 取消监听ASR事件") + eventSink = nil + return nil + } + + // 实现AsrEventHandler协议 + func sendEvent(_ event: [String: Any]) { + // NSLog("AsrStreamHandler: 收到事件 \(event)") + DispatchQueue.main.async { [weak self] in + self?.eventSink?(event) + } + } +} + +// TTS事件处理器协议 +@available(iOS 13.0, *) +protocol TtsEventHandler: AnyObject { + func sendEvent(_ event: [String: Any]) +} + +// TTS事件流处理器 +@available(iOS 13.0, *) +class TtsStreamHandler: NSObject, FlutterStreamHandler, TtsEventHandler { + private let azureTtsHelper: AzureTtsHelper + private var eventSink: FlutterEventSink? + + init(azureTtsHelper: AzureTtsHelper) { + self.azureTtsHelper = azureTtsHelper + super.init() + + // 设置事件处理器 + azureTtsHelper.setEventHandler { [weak self] event in + self?.sendEvent(event) + } + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + NSLog("TtsStreamHandler: 开始监听TTS事件") + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NSLog("TtsStreamHandler: 取消监听TTS事件") + eventSink = nil + return nil + } + + // 实现TtsEventHandler协议 + func sendEvent(_ event: [String: Any]) { + NSLog("TtsStreamHandler: 收到事件 \(event)") + DispatchQueue.main.async { [weak self] in + self?.eventSink?(event) + } + } +} + +// 火山AI事件流处理器 +@available(iOS 13.0, *) +class VolcanoAIStreamHandler: NSObject, FlutterStreamHandler { + private let volcanoAIService: VolcanoAIService + private var eventSink: FlutterEventSink? + + init(volcanoAIService: VolcanoAIService) { + self.volcanoAIService = volcanoAIService + super.init() + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } + + // 处理来自Flutter的流式请求 + func handleStreamRequest(messages: [[String: Any]], systemPrompt: String) { + volcanoAIService.sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: VolcanoAIService.StreamCallback( + onToken: { [weak self] token in + guard let self = self, let eventSink = self.eventSink else { return } + + let event: [String: Any] = [ + "type": "token", + "token": token + ] + + DispatchQueue.main.async { + eventSink(event) + } + }, + onComplete: { [weak self] in + guard let self = self, let eventSink = self.eventSink else { return } + + let event: [String: Any] = [ + "type": "complete" + ] + + DispatchQueue.main.async { + eventSink(event) + } + }, + onError: { [weak self] error in + guard let self = self, let eventSink = self.eventSink else { return } + + let event: [String: Any] = [ + "type": "error", + "error": error.localizedDescription + ] + + DispatchQueue.main.async { + eventSink(event) + } + } + )) + } +} + +// 语音交互事件处理器协议 +@available(iOS 13.0, *) +protocol VoiceInteractionEventHandler: AnyObject { + func sendEvent(_ event: [String: Any]) +} + +// 语音交互流处理器 +@available(iOS 13.0, *) +class VoiceInteractionStreamHandler: NSObject, FlutterStreamHandler, VoiceInteractionEventHandler { + private let voiceInteractionService: VoiceInteractionService + private var eventSink: FlutterEventSink? + + init(voiceInteractionService: VoiceInteractionService) { + self.voiceInteractionService = voiceInteractionService + super.init() + + // 设置事件处理器 + voiceInteractionService.setEventHandler(self) + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + NSLog("VoiceInteractionStreamHandler: 开始监听语音交互事件") + self.eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + NSLog("VoiceInteractionStreamHandler: 取消监听语音交互事件") + eventSink = nil + return nil + } + + // 实现VoiceInteractionEventHandler协议 + func sendEvent(_ event: [String: Any]) { + NSLog("VoiceInteractionStreamHandler: 收到事件 \(event)") + DispatchQueue.main.async { [weak self] in + self?.eventSink?(event) + } + } } diff --git a/ios/Runner/AudioSessionManager.swift b/ios/Runner/AudioSessionManager.swift new file mode 100644 index 000000000..feaeee830 --- /dev/null +++ b/ios/Runner/AudioSessionManager.swift @@ -0,0 +1,585 @@ +import Foundation +import AVFoundation +import UIKit + +/// 统一音频会话管理类 +/// 用于统一管理所有组件的音频会话配置 +@objc class AudioSessionManager: NSObject { + // MARK: - 单例实现 + + @objc static let shared = AudioSessionManager() + + // 音频会话 + private let audioSession = AVAudioSession.sharedInstance() + + // 音频会话状态 + private var isConfigured = false + private var isActive = false + + // 当前配置类型 + private var currentCategory: AVAudioSession.Category? + private var currentMode: AVAudioSession.Mode? + private var currentOptions: AVAudioSession.CategoryOptions? + + // 中断监听器 + private var interruptionObserver: NSObjectProtocol? + + // 路由变化监听器 + private var routeChangeObserver: NSObjectProtocol? + + // 私有初始化方法,确保只能通过shared访问 + private override init() { + super.init() + NSLog("[AudioSessionManager] 音频会话管理器已初始化") + setupNotifications() + + } + + deinit { + removeNotifications() + } + + + // MARK: - 通知设置 + + private func setupNotifications() { + // 监听中断事件 + interruptionObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.interruptionNotification, + object: nil, + queue: .main) { [weak self] notification in + self?.handleInterruption(notification) + } + + // 监听路由变化事件 + routeChangeObserver = NotificationCenter.default.addObserver( + forName: AVAudioSession.routeChangeNotification, + object: nil, + queue: .main) { [weak self] notification in + self?.handleRouteChange(notification) + } + + // 监听应用进入后台/前台 + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppDidEnterBackground), + name: UIApplication.didEnterBackgroundNotification, + object: nil) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleAppWillEnterForeground), + name: UIApplication.willEnterForegroundNotification, + object: nil) + } + + private func removeNotifications() { + if let interruptionObserver = interruptionObserver { + NotificationCenter.default.removeObserver(interruptionObserver) + } + + if let routeChangeObserver = routeChangeObserver { + NotificationCenter.default.removeObserver(routeChangeObserver) + } + + NotificationCenter.default.removeObserver(self) + } + + // MARK: - 通知处理 + + private func handleInterruption(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt, + let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { + return + } + + switch type { + case .began: + // 中断开始 + NSLog("[AudioSessionManager] 音频会话被中断") + isActive = false + + case .ended: + // 中断结束,尝试恢复 + if let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt { + let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue) + if options.contains(.shouldResume) { + NSLog("[AudioSessionManager] 尝试恢复被中断的音频会话") + try? activateSession() + } + } + + @unknown default: + break + } + } + + private func handleRouteChange(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + switch reason { + case .newDeviceAvailable: + // 新设备连接 + let currentRoute = audioSession.currentRoute + for output in currentRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { + NSLog("[AudioSessionManager] 检测到蓝牙音频设备连接: \(output.portName)") + // 蓝牙设备连接,可能需要更新配置 + if isActive && currentOptions?.contains(.allowBluetooth) == true { + try? forceReconfigure() + } + break + } + + case .oldDeviceUnavailable: + // 设备断开连接 + if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { + for output in previousRoute.outputs where output.portType == .bluetoothA2DP || output.portType == .bluetoothHFP { + NSLog("[AudioSessionManager] 蓝牙音频设备断开连接: \(output.portName)") + // 蓝牙设备断开,重新配置到内置扬声器 + if isActive { + try? forceReconfigure() + } + break + } + } + + default: + break + } + } + + @objc private func handleAppDidEnterBackground() { + NSLog("[AudioSessionManager] 应用进入后台") + // 应用进入后台时可能需要调整音频会话 + + } + + @objc private func handleAppWillEnterForeground() { + NSLog("[AudioSessionManager] 应用即将进入前台") + // 应用返回前台,检查并恢复音频会话 + if isConfigured && !isActive { + try? activateSession() + } + } + + // MARK: - 音频会话配置 + + /// 统一配置方法 - 所有音频配置都通过此方法进行 + @objc func configureSession(category: AVAudioSession.Category, + mode: AVAudioSession.Mode, + options: AVAudioSession.CategoryOptions, + force: Bool = false) throws { + + NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode), 选项=\(options), 强制=\(force)") + + // 如果已配置且不强制重新配置,则直接返回 + if isConfigured && !force && + currentCategory == category && + currentMode == mode && + currentOptions == options { + NSLog("[AudioSessionManager] 音频会话已配置,跳过配置") + return + } + + do { + // NSLog("[AudioSessionManager] 配置音频会话: 类别=\(category), 模式=\(mode)") + + // 暂时停用当前会话(如果正在活动) + if isActive { + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + } + + // 设置新的分类、模式和选项 + try audioSession.setCategory(category, mode: mode, options: options) + + // 设置采样率和缓冲区大小(对于语音识别优化) + // if mode == .spokenAudio { + // try audioSession.setPreferredSampleRate(16000) + // try audioSession.setPreferredIOBufferDuration(0.01) // 10ms + // } + + // 重新激活会话 + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + isActive = true + + // 记录当前配置 + currentCategory = category + currentMode = mode + currentOptions = options + isConfigured = true + + // 检查并设置首选的输入设备(如果需要) + if category == .playAndRecord || category == .record { + try configurePreferredInput() + } + return + } catch { + NSLog("[AudioSessionManager] 配置音频会话失败: \(error.localizedDescription)") + throw error + } + } + + /// 配置首选输入设备 + private func configurePreferredInput() throws { + // 获取可用输入设备 + guard let availableInputs = audioSession.availableInputs else { + NSLog("[AudioSessionManager] 无法获取可用输入设备") + return + } + + // 首选顺序:有线耳机 > 蓝牙耳机 > 内置麦克风 + var preferredInput: AVAudioSessionPortDescription? = nil + + // 先尝试找到有线耳机 + if let wiredHeadset = availableInputs.first(where: { $0.portType == .headsetMic }) { + preferredInput = wiredHeadset + NSLog("[AudioSessionManager] 设置首选输入设备: 有线耳机") + } + // 如果没有有线耳机,尝试使用蓝牙 + else if let bluetooth = availableInputs.first(where: { $0.portType == .bluetoothHFP }) { + preferredInput = bluetooth + NSLog("[AudioSessionManager] 设置首选输入设备: 蓝牙耳机") + } + // 如果没有外部设备,使用内置麦克风 + else if let builtIn = availableInputs.first(where: { $0.portType == .builtInMic }) { + preferredInput = builtIn + NSLog("[AudioSessionManager] 设置首选输入设备: 内置麦克风") + } + + // 设置首选输入设备 + if let input = preferredInput { + try audioSession.setPreferredInput(input) + } + } + + + + /// 激活音频会话(如果已配置) + @objc func activateSession() throws { + if !isConfigured { + NSLog("[AudioSessionManager] 警告: 尝试激活未配置的音频会话,应用默认配置") + // 未配置,应用默认配置 + // try configureSession( + // category: .playAndRecord, + // mode: .spokenAudio, + // options: [.allowBluetooth, .allowBluetoothA2DP, .duckOthers, .defaultToSpeaker], + // force: true + // ) + + try configureSession( + category: .playAndRecord, // 允许同时录音和播放 + mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 + options: [ + .allowBluetooth, // 允许蓝牙设备 + .defaultToSpeaker, // 默认使用扬声器 + .mixWithOthers // 允许与其他应用混音 + ], + force: false // 是否强制重新配置 + ) + + + } else if !isActive { + NSLog("[AudioSessionManager] 激活已配置的音频会话") + try audioSession.setActive(true, options: .notifyOthersOnDeactivation) + isActive = true + } + } + + /// 停用音频会话(保留配置) + @objc func deactivateSession() throws { + if isActive { + NSLog("[AudioSessionManager] 停用音频会话") + try audioSession.setActive(false, options: .notifyOthersOnDeactivation) + isActive = false + } + } + + /// 重新配置当前会话 + @objc func forceReconfigure() throws { + NSLog("[AudioSessionManager] 强制重新配置音频会话") + // try configureSession( + // category: currentCategory ?? .playAndRecord, + // mode: currentMode ?? .default, + // options: currentOptions ?? [.allowBluetooth, .allowBluetoothA2DP, .defaultToSpeaker], + // force: true + // ) + + try configureSession( + category: .playAndRecord, // 允许同时录音和播放 + mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 + options: [ + .allowBluetooth, // 允许蓝牙设备 + .defaultToSpeaker, // 默认使用扬声器 + .mixWithOthers // 允许与其他应用混音 + ], + force: false // 是否强制重新配置 + ) + + } + + // MARK: - 状态检查 + + /// 检查麦克风权限 + @objc func checkMicrophonePermission(completion: @escaping (Bool) -> Void) { + switch AVAudioSession.sharedInstance().recordPermission { + case .granted: + completion(true) + case .denied: + NSLog("[AudioSessionManager] 麦克风权限被拒绝") + completion(false) + case .undetermined: + NSLog("[AudioSessionManager] 请求麦克风权限") + AVAudioSession.sharedInstance().requestRecordPermission { granted in + DispatchQueue.main.async { + completion(granted) + } + } + @unknown default: + completion(false) + } + } + + /// 获取当前音频会话的配置信息 + @objc func getCurrentConfiguration() -> [String: Any] { + return [ + "isConfigured": isConfigured, + "isActive": isActive, + "category": String(describing: currentCategory), + "mode": String(describing: currentMode), + "bluetoothEnabled": currentOptions?.contains(.allowBluetooth) == true, + "currentInputs": audioSession.currentRoute.inputs.map { [$0.portType.rawValue: $0.portName] }, + "currentOutputs": audioSession.currentRoute.outputs.map { [$0.portType.rawValue: $0.portName] } + ] + } + + /// 检查当前是否有蓝牙耳机连接 + @objc func hasBluetoothHeadsetConnected() -> Bool { + let outputs = audioSession.currentRoute.outputs + return outputs.contains { $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE } + } + + /// 检查蓝牙是否启用 + @objc func isBluetoothEnabled() -> Bool { + // 在iOS中无法直接检查蓝牙是否启用,我们可以通过检查音频会话配置来推断 + + // 检查音频会话是否配置了允许蓝牙 + if let options = currentOptions { + return options.contains(.allowBluetooth) || options.contains(.allowBluetoothA2DP) + } + + // 如果尚未配置音频会话,尝试检查当前路由中是否有蓝牙设备 + let outputs = audioSession.currentRoute.outputs + let hasBluetooth = outputs.contains { + $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP || $0.portType == .bluetoothLE + } + + return hasBluetooth + } + + /// 获取当前音频路由 + @objc func getCurrentAudioRoute() -> String { + let outputs = audioSession.currentRoute.outputs + if let output = outputs.first { + return "\(output.portType.rawValue) (\(output.portName))" + } + return "unknown" + } + + /// 获取当前输入设备 + @objc func getCurrentInputDevice() -> String { + let inputs = audioSession.currentRoute.inputs + if let input = inputs.first { + return "\(input.portType.rawValue) (\(input.portName))" + } + return "unknown" + } + + // MARK: - 路由管理和监听 + + /// 获取当前路由信息 + @objc func getCurrentRoute() -> AVAudioSessionRouteDescription { + return audioSession.currentRoute + } + + /// 添加路由变更监听器 + /// - Parameters: + /// - observer: 监听对象 + /// - selector: 监听方法选择器 + @objc func addRouteChangeListener(_ observer: Any, selector: Selector) { + NotificationCenter.default.addObserver( + observer, + selector: selector, + name: AVAudioSession.routeChangeNotification, + object: nil + ) + } + + /// 移除路由变更监听器 + /// - Parameter observer: 要移除的监听对象 + @objc func removeRouteChangeListener(_ observer: Any) { + NotificationCenter.default.removeObserver( + observer, + name: AVAudioSession.routeChangeNotification, + object: nil + ) + } + /// 获取设备类型描述 + private func getDeviceType(portType: AVAudioSession.Port) -> String { + switch portType { + case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE: + return "bluetooth" + case .headphones, .headsetMic: + return "wired" + case .airPlay: + return "airplay" + default: + return "unknown" + } + } + + + + + // /// 为Azure语音识别专门配置的音频会话 + // /// 此函数优化Azure语音识别的音频处理配置,提供更强的回音消除能力 + // /// - Parameters: + // /// - force: 是否强制重新配置 + // /// - configureAdditionalSettings: 配置完成后进行额外的音频设置 + // /// - Returns: 配置是否成功 + // @objc func configureForAzureSpeechRecognition(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { + // NSLog("[AudioSessionManager] 配置Azure ASR语音识别的音频会话") + // do { + // // 配置音频会话以优化语音识别 + // try configureSession( + // category: .playAndRecord, // 允许同时录音和播放 + // mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 + // options: [ + // .allowBluetooth, // 允许蓝牙设备 + // .defaultToSpeaker, // 默认使用扬声器 + // .mixWithOthers // 允许与其他应用混音 + // ], + // force: force // 是否强制重新配置 + // ) + + // // 额外的优化配置 + // if configureAdditionalSettings { + // // 获取当前是否使用耳机 + // let currentRoute = audioSession.currentRoute + // let hasHeadphones = currentRoute.outputs.contains { + // $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP + // } + + // // 设置采样率为16kHz(Azure Speech API推荐) + // try audioSession.setPreferredSampleRate(16000.0) + + // // 设置较小的缓冲区大小以减少延迟 + // try audioSession.setPreferredIOBufferDuration(0.01) + + // // 根据是否有耳机连接调整输入增益 + // if !hasHeadphones { + // // 无耳机时降低输入增益以减少回音 + // try audioSession.setInputGain(0.8) + // NSLog("[AudioSessionManager] 启用扬声器回音消除优化") + // } else { + // // 使用耳机时可以使用较高增益 + // try audioSession.setInputGain(1.0) + // NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") + // } + // } + + // NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") + // return true + // } catch { + // NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") + // return false + // } + // } + + +@objc func configureForVoiceInteraction(force: Bool = false, configureAdditionalSettings: Bool = true) -> Bool { + NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话") + do { + // 配置音频会话以优化语音识别 + try configureSession( + category: .playAndRecord, // 允许同时录音和播放 + mode: .voiceChat, // 使用voiceChat模式获得最佳回音消除效果 + options: [ + .allowBluetooth, // 允许蓝牙设备 + .defaultToSpeaker, // 默认使用扬声器 + .mixWithOthers // 允许与其他应用混音 + ], + force: force // 是否强制重新配置 + ) + + // 额外的优化配置 + if configureAdditionalSettings { + // 获取当前是否使用耳机 + let currentRoute = audioSession.currentRoute + let hasHeadphones = currentRoute.outputs.contains { + $0.portType == .headphones || $0.portType == .bluetoothA2DP || $0.portType == .bluetoothHFP + } + + // 设置采样率为16kHz(Azure Speech API推荐) + try audioSession.setPreferredSampleRate(16000.0) + + // 设置较小的缓冲区大小以减少延迟 + try audioSession.setPreferredIOBufferDuration(0.01) + + // 根据是否有耳机连接调整输入增益 + if !hasHeadphones { + // 无耳机时降低输入增益以减少回音 + try audioSession.setInputGain(0.8) + NSLog("[AudioSessionManager] 启用扬声器回音消除优化") + } else { + // 使用耳机时可以使用较高增益 + try audioSession.setInputGain(1.0) + NSLog("[AudioSessionManager] 检测到耳机连接,应用耳机模式") + } + } + + NSLog("[AudioSessionManager] 已成功配置Azure语音识别的音频会话") + return true + } catch { + NSLog("[AudioSessionManager] 配置Azure语音识别的音频会话失败: \(error.localizedDescription)") + return false + } + } + + + /// 配置用于语音交互(同时支持ASR和TTS)的音频会话 + /// 此函数综合优化语音识别和语音合成,适用于需要双向交互的场景 + /// - Returns: 配置是否成功 + // @objc func configureForVoiceInteraction(force: Bool = false) -> Bool { + // do { + // // 配置音频会话以支持语音交互 + // // 使用playAndRecord类别允许同时录音和播放 + // // 使用spokenAudio模式优化语音交互 + // try configureSession( + // category: .playAndRecord, + // mode: .voiceChat, + // options: [ + // // .allowBluetoothA2DP, // 允许通过蓝牙A2DP连接进行高质量音频输出 + // .allowBluetooth, // 允许通过蓝牙SCO连接进行输入和输出 + // .defaultToSpeaker, // 默认使用扬声器输出 + // .mixWithOthers + // ], + // force: force // 强制重新配置,确保设置生效 + // ) + + // // 设置首选输入设备 + // try configurePreferredInput() + + // NSLog("[AudioSessionManager] 已配置语音交互(ASR+TTS)的音频会话") + // return true + // } catch { + // NSLog("[AudioSessionManager] 配置语音交互(ASR+TTS)的音频会话失败: \(error.localizedDescription)") + // return false + // } + // } + +} \ No newline at end of file diff --git a/ios/Runner/AzureAsrHelper.swift b/ios/Runner/AzureAsrHelper.swift new file mode 100644 index 000000000..5a2b73189 --- /dev/null +++ b/ios/Runner/AzureAsrHelper.swift @@ -0,0 +1,813 @@ +import Foundation +import MicrosoftCognitiveServicesSpeech +import AVFoundation +import AudioToolbox + +/// Azure ASR工具类,负责实现语音识别服务接口 +@available(iOS 13.0, *) +class AzureAsrHelper: NSObject { + // MARK: - 属性 + + /// 是否使用自定义音频处理器 - 设置为false时将使用系统默认麦克风输入 + private let useCustomAudioProcessor = true + + /// 事件处理回调 + private var eventHandler: (([String: Any]) -> Void)? + + /// 语音配置信息 + private var speechSubscriptionKey: String = "" + private var serviceRegion: String = "" + + /// 语音识别相关 + private var speechConfig: SPXSpeechConfiguration? + private var recognizer: SPXSpeechRecognizer? + private var audioConfig: SPXAudioConfiguration? + + /// 添加自定义音频处理相关 + private var pushStream: SPXPushAudioInputStream? + private var audioProcessor: CustomAudioProcessor? + private var isProcessingAudio = false + private var audioProcessingTimer: Timer? + + /// 状态标志 + private var isInitialized = false + private var _isContinuousRecognitionActive = false + + /// 当前语言和支持的语言 + private var currentLanguage = "zh-CN" + private var supportedLanguages: [String] = ["zh-CN", "en-US"] + private var isAutoDetectLanguage = false + + /// 音频会话管理器 + private let audioSessionManager = AudioSessionManager.shared + + // MARK: - 初始化 + + override init() { + super.init() + } + + deinit { + dispose() + } + + + // MARK: - ASR Service 接口实现 + + /// 初始化语音识别服务 + /// - Parameters: + /// - speechSubscriptionKey: Azure 语音服务订阅密钥 + /// - serviceRegion: Azure 服务区域 (如 eastasia) + /// - supportedLanguages: 支持的语言代码数组 (可选) + /// - Returns: 初始化是否成功 + func initialize(speechSubscriptionKey: String, serviceRegion: String, supportedLanguages: [String]? = nil) -> Bool { + print("[AzureAsrHelper] 初始化 Azure 语音服务") + + // 检查配置是否为空 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + // 检查语言参数 + if let languages = supportedLanguages, languages.isEmpty { + print("[AzureAsrHelper] 警告: 传入的支持语言列表为空,将使用默认语言") + } + + // 释放之前的资源 + dispose() + + // 记录配置信息 + self.speechSubscriptionKey = speechSubscriptionKey + self.serviceRegion = serviceRegion + + // 设置语言 + if let languages = supportedLanguages, !languages.isEmpty { + self.supportedLanguages = languages + } + + // 根据支持的语言数量决定是否启用自动语言检测 + isAutoDetectLanguage = self.supportedLanguages.count >= 2 + + // 如果只有一种语言,设置为当前语言 + if !isAutoDetectLanguage && !self.supportedLanguages.isEmpty { + currentLanguage = self.supportedLanguages[0] + } + + // 创建识别器和设置回调 + if !createRecognizerAndSetupCallbacks() { + return false + } + + print("[AzureAsrHelper] Azure 语音服务初始化成功") + isInitialized = true + return true + } + + /// 创建识别器并设置回调 + private func createRecognizerAndSetupCallbacks() -> Bool { + // 释放之前的 recognizer + recognizer = nil + audioConfig = nil + + do { + // 创建语音配置 + speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) + + // 设置音频输入参数 + try setupAudioSession() + + // 根据设置决定是否使用自定义音频处理器 + if useCustomAudioProcessor { + // 创建自定义音频流和处理器,替代默认的麦克风输入 + pushStream = try SPXPushAudioInputStream() + audioConfig = try SPXAudioConfiguration(streamInput: pushStream!) + + // 初始化自定义音频处理器 + audioProcessor = CustomAudioProcessor() + } else { + // 使用默认麦克风输入 + audioConfig = try SPXAudioConfiguration() + } + + // 设置语言配置 + if isAutoDetectLanguage { + // 设置自动语言检测 + speechConfig?.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) + + // 创建自动语言检测配置 + let autoDetectSourceLanguageConfig = try SPXAutoDetectSourceLanguageConfiguration(supportedLanguages) + + // 创建识别器 + recognizer = try SPXSpeechRecognizer( + speechConfiguration: speechConfig!, + autoDetectSourceLanguageConfiguration: autoDetectSourceLanguageConfig, + audioConfiguration: audioConfig! + ) + } else { + // 设置指定的识别语言 + speechConfig?.speechRecognitionLanguage = currentLanguage + + // 创建识别器 + recognizer = try SPXSpeechRecognizer(speechConfiguration: speechConfig!, audioConfiguration: audioConfig!) + } + + // 设置所有回调 + setupAllCallbacks() + + return true + } catch { + print("[AzureAsrHelper] 错误: 创建识别器失败: \(error.localizedDescription)") + eventHandler?(["type": "error", "message": "创建识别器失败: \(error.localizedDescription)"]) + return false + } + } + + /// 设置音频会话 + private func setupAudioSession() throws { + print("[AzureAsrHelper] 开始配置音频会话...") + + // 使用AudioSessionManager配置音频会话,使用专门为Azure ASR优化的配置 + let success = audioSessionManager.configureForVoiceInteraction() + if !success { + print("[AzureAsrHelper] 警告: 通过AudioSessionManager配置音频会话失败") + } + } + + + /// 设置所有回调 + private func setupAllCallbacks() { + guard let recognizer = recognizer else { return } + + // 最终识别结果 + recognizer.addRecognizedEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == SPXResultReason.recognizedSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + print("[AzureAsrHelper] 识别结果: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + self.eventHandler?(["type": "result", + "text": event.result.text ?? "", + "detectedLanguage": detectedLanguage + ]) + } + } + + // 识别中事件 + recognizer.addRecognizingEventHandler { [weak self] _, event in + guard let self = self else { return } + + if event.result.reason == SPXResultReason.recognizingSpeech { + let detectedLanguage = self.getDetectedLanguage(from: event.result) + // print("[AzureAsrHelper] 识别中: \(event.result.text ?? ""), 语言: \(detectedLanguage)") + self.eventHandler?(["type": "recognizing", + "text": event.result.text ?? "", + "detectedLanguage": detectedLanguage + ]) + } + } + + // 会话事件 + recognizer.addSessionStartedEventHandler { [weak self] _, _ in + guard let self = self else { return } + + print("[AzureAsrHelper] 识别会话已开始") + self._isContinuousRecognitionActive = true + self.eventHandler?(["type": "sessionStarted"]) + } + + recognizer.addSessionStoppedEventHandler { [weak self] _, _ in + guard let self = self else { return } + + print("[AzureAsrHelper] 识别会话已结束") + self._isContinuousRecognitionActive = false + self.eventHandler?(["type": "sessionStopped"]) + } + + // 取消事件 + recognizer.addCanceledEventHandler { [weak self] _, event in + guard let self = self else { return } + + let reason = event.reason.rawValue + let errorDetails = event.errorDetails ?? "未知错误" + + print("[AzureAsrHelper] 识别取消: \(errorDetails)") + + self.eventHandler?(["type": "canceled", "reason": reason, "errorDetails": errorDetails]) + + self._isContinuousRecognitionActive = false + } + } + + /// 执行一次性语音识别 + /// - Returns: 是否成功启动识别 + func recognizeOnce() -> Bool { + // 检查是否初始化 + if !isInitialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + eventHandler?(["type": "error", "message": "语音服务未初始化"]) + return false + } + + // 检查参数有效性 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + // 如果正在连续识别,先停止 + if _isContinuousRecognitionActive { + stopContinuousRecognition() + } + + // 确保识别器已创建 + if recognizer == nil && !createRecognizerAndSetupCallbacks() { + return false + } + + do { + // 启动音频处理 + startAudioProcessing() + + // 通知会话开始 + eventHandler?(["type": "sessionStarted"]) + + // 执行识别 + try recognizer?.recognizeOnceAsync { [weak self] result in + guard let self = self else { return } + + // 停止音频处理 + self.stopAudioProcessing() + + if result.reason == SPXResultReason.recognizedSpeech { + let detectedLanguage = self.getDetectedLanguage(from: result) + self.eventHandler?(["type": "result", + "text": result.text ?? "", + "detectedLanguage": detectedLanguage + ]) + } else if result.reason == SPXResultReason.noMatch { + print("[AzureAsrHelper] 无匹配结果") + self.eventHandler?(["type": "noMatch"]) + } else if result.reason == SPXResultReason.canceled { + do { + let details = try SPXCancellationDetails(fromCanceledRecognitionResult: result) + let errorDetails = details.errorDetails ?? "未知错误" + self.eventHandler?(["type": "error", "message": "识别取消: \(errorDetails)"]) + } catch { + print("[AzureAsrHelper] 错误: 获取取消详情失败: \(error.localizedDescription)") + self.eventHandler?(["type": "error", "message": "识别取消,无法获取详细原因"]) + } + } + } + + return true + } catch { + print("[AzureAsrHelper] 错误: 识别异常: \(error.localizedDescription)") + eventHandler?(["type": "error", "message": "识别异常: \(error.localizedDescription)"]) + stopAudioProcessing() + return false + } + } + + /// 开始连续语音识别 + /// - Returns: 是否成功启动识别 + func startContinuousRecognition() -> Bool { + // 检查是否初始化 + if !isInitialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + eventHandler?(["type": "error", "message": "语音服务未初始化"]) + return false + } + + // 检查参数有效性 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureAsrHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + // 检查是否已经在识别 + if _isContinuousRecognitionActive { + print("[AzureAsrHelper] 已经在进行连续识别中,忽略请求") + return true + } + + // 确保识别器已创建 + if recognizer == nil { + print("[AzureAsrHelper] 尝试重新创建识别器...") + if !createRecognizerAndSetupCallbacks() { + eventHandler?(["type": "error", "message": "无法创建语音识别器"]) + return false + } + } + + // 启动音频处理 + startAudioProcessing() + + // 尝试启动连续识别 + do { + // 启动连续识别 + try recognizer?.startContinuousRecognition() + _isContinuousRecognitionActive = true + + print("[AzureAsrHelper] 连续识别已启动") + return true + } catch { + _isContinuousRecognitionActive = false + print("[AzureAsrHelper] 错误: 开始连续识别失败: \(error.localizedDescription)") + + // 停止音频处理 + stopAudioProcessing() + + // 发送错误通知 + eventHandler?(["type": "error", "message": "开始连续识别失败: \(error.localizedDescription)"]) + + return false + } + } + + + /// 停止连续语音识别 + /// - Returns: 是否成功停止识别 + func stopContinuousRecognition() -> Bool { + // 停止音频处理 + stopAudioProcessing() + + // 检查是否初始化 + if !isInitialized { + print("[AzureAsrHelper] 错误: 语音服务未初始化") + eventHandler?(["type": "error", "message": "语音服务未初始化"]) + return false + } + + if !_isContinuousRecognitionActive { + print("[AzureAsrHelper] 未进行连续识别,忽略停止请求") + return true + } + + print("[AzureAsrHelper] 停止连续语音识别") + + // 防止空指针异常 + guard let recognizer = recognizer else { + print("[AzureAsrHelper] 警告: 识别器为空,但状态显示活跃") + _isContinuousRecognitionActive = false + eventHandler?(["type": "sessionStopped"]) + return true + } + + // 先标记为非活跃状态,防止重复调用 + _isContinuousRecognitionActive = false + + // 异步执行停止操作,避免阻塞主线程 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self else { return } + + do { + // 在后台线程执行停止操作 + try recognizer.stopContinuousRecognition() + + // 主线程回调通知结果 + DispatchQueue.main.async { + print("[AzureAsrHelper] 连续识别已停止") + self.eventHandler?(["type": "sessionStopped"]) + } + } catch { + // 主线程回调通知错误 + DispatchQueue.main.async { + print("[AzureAsrHelper] 错误: 停止连续识别失败: \(error.localizedDescription)") + // 停止失败,恢复状态 + self._isContinuousRecognitionActive = true + self.eventHandler?(["type": "error", "message": "停止识别失败: \(error.localizedDescription)"]) + } + } + } + + // 立即返回,不阻塞调用方 + return true + } + + /// 检查连续识别是否活跃 + /// - Returns: 连续识别是否处于活跃状态 + func isContinuousRecognitionActive() -> Bool { + return _isContinuousRecognitionActive + } + + /// 释放资源 + func dispose() { + // 停止音频处理 + stopAudioProcessing() + + // 尝试停止所有识别操作 + if _isContinuousRecognitionActive { + do { + if let recognizer = recognizer { + try recognizer.stopContinuousRecognition() + print("[AzureAsrHelper] 连续识别已停止") + } + } catch { + print("[AzureAsrHelper] 警告: 停止连续识别失败: \(error.localizedDescription)") + } + _isContinuousRecognitionActive = false + } + + // 释放识别器 + recognizer = nil + audioConfig = nil + speechConfig = nil + pushStream = nil + audioProcessor = nil + + // 重置状态 + isInitialized = false + + print("[AzureAsrHelper] 资源已释放") + } + + /// 从结果中获取检测到的语言 + private func getDetectedLanguage(from result: SPXSpeechRecognitionResult) -> String { + if isAutoDetectLanguage { + do { + let langResult = try SPXAutoDetectSourceLanguageResult(result) + return langResult.language ?? currentLanguage + } catch { + print("[AzureAsrHelper] 错误: 获取检测到的语言失败: \(error.localizedDescription)") + return currentLanguage + } + } else { + return currentLanguage + } + } + + /// 设置事件处理器 + @objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { + self.eventHandler = handler + } + + // MARK: - 音频处理 + + /// 开始音频处理 + private func startAudioProcessing() { + // 如果未使用自定义音频处理器,跳过 + if !useCustomAudioProcessor { + return + } + + guard !isProcessingAudio, let audioProcessor = audioProcessor else { return } + + isProcessingAudio = true + + // 启动音频处理器 + if !audioProcessor.startRecord() { + print("[AzureAsrHelper] 错误: 启动音频处理器失败") + eventHandler?(["type": "error", "message": "启动音频处理器失败"]) + return + } + + // 启动音频处理定时器 + audioProcessingTimer = Timer.scheduledTimer(withTimeInterval: 0.08, repeats: true) { [weak self] _ in + guard let self = self, self.isProcessingAudio, let processor = self.audioProcessor, let stream = self.pushStream else { + return + } + + // 读取处理后的音频数据 + var bytes = [UInt8](repeating: 0, count: 2560) + let bytesRead = processor.read(bytes: &bytes) + + if bytesRead > 0 { + // 推送数据到Azure语音服务 + let data = Data(bytes: bytes, count: bytesRead) + stream.write(data) + + // 通知音频数据可用(可选) + // self.eventHandler?(["type": "audioData", "data": bytes]) + } + } + + print("[AzureAsrHelper] 音频处理已启动") + } + + /// 停止音频处理 + private func stopAudioProcessing() { + // 如果未使用自定义音频处理器,跳过 + if !useCustomAudioProcessor { + return + } + + // 停止定时器 + audioProcessingTimer?.invalidate() + audioProcessingTimer = nil + + // 停止音频处理器 + audioProcessor?.stopRecord() + + isProcessingAudio = false + print("[AzureAsrHelper] 音频处理已停止") + } +} + +// MARK: - 自定义音频处理器 + +@available(iOS 13.0, *) +class CustomAudioProcessor: NSObject { + // 音频单元 + private var ioUnit: AudioUnit? + + // 音频格式 + private var audioFormat: AudioStreamBasicDescription + + // 音频缓冲 + private var audioBufferList: AudioBufferList + private var audioList: [Float] = [] + private let audioListQueue = DispatchQueue(label: "audioListQueue") + + // 回音消除状态 + private var isEchoCancellationEnabled = true + + override init() { + // 设置音频格式 - 16kHz, 16位, 单声道 + audioFormat = AudioStreamBasicDescription( + mSampleRate: 16000.0, + mFormatID: kAudioFormatLinearPCM, + mFormatFlags: kAudioFormatFlagIsSignedInteger | kAudioFormatFlagIsPacked, + mBytesPerPacket: 2, + mFramesPerPacket: 1, + mBytesPerFrame: 2, + mChannelsPerFrame: 1, + mBitsPerChannel: 16, + mReserved: 0 + ) + + // 初始化音频缓冲 + audioBufferList = AudioBufferList( + mNumberBuffers: 1, + mBuffers: AudioBuffer( + mNumberChannels: 1, + mDataByteSize: 4096, + mData: malloc(4096) + ) + ) + + super.init() + } + + deinit { + stopRecord() + free(audioBufferList.mBuffers.mData) + } + + /// 启动音频处理 + /// - Returns: 是否成功启动 + func startRecord() -> Bool { + print("[CustomAudioProcessor] 配置音频单元") + + // 创建音频组件描述 - 使用VoiceProcessingIO类型获取回音消除 + var ioUnitDescription = AudioComponentDescription( + componentType: kAudioUnitType_Output, + componentSubType: kAudioUnitSubType_VoiceProcessingIO, + componentManufacturer: kAudioUnitManufacturer_Apple, + componentFlags: 0, + componentFlagsMask: 0 + ) + + // 查找音频组件 + guard let ioUnitRef = AudioComponentFindNext(nil, &ioUnitDescription) else { + print("[CustomAudioProcessor] 错误: 未找到音频组件") + return false + } + + // 创建音频单元实例 + if checkError(AudioComponentInstanceNew(ioUnitRef, &ioUnit), "创建音频单元") { + ioUnit = nil + return false + } + + // 启用输入端口 + var enableInput: UInt32 = 1 + let kInputBus: AudioUnitElement = 1 + let kOutputBus: AudioUnitElement = 0 + if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Input, kInputBus, &enableInput, + UInt32(MemoryLayout.size)), "启用输入端口") { + return false + } + + // 禁用输出端口 (我们只需要输入) + var enableOutput: UInt32 = 0 + if checkError(AudioUnitSetProperty(ioUnit!, kAudioOutputUnitProperty_EnableIO, + kAudioUnitScope_Output, kOutputBus, + &enableOutput, UInt32(MemoryLayout.size)), "禁用输出端口") { + return false + } + + // 设置缓冲区分配标志 + var flag: UInt32 = 0 + if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_ShouldAllocateBuffer, + kAudioUnitScope_Output, kInputBus, &flag, UInt32(MemoryLayout.size)), "设置缓冲区分配标志") { + return false + } + + // 设置音频格式 + let size = UInt32(MemoryLayout.size) + if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Output, kInputBus, &audioFormat, size), "设置输入总线输出范围的流格式") { + return false + } + + if checkError(AudioUnitSetProperty(ioUnit!, kAudioUnitProperty_StreamFormat, + kAudioUnitScope_Input, kOutputBus, &audioFormat, size), "设置输出总线输入范围的流格式") { + return false + } + + // 启用回音消除 - 注意: kAUVoiceIOProperty_BypassVoiceProcessing值为1时表示绕过处理,值为0表示启用处理 + if isEchoCancellationEnabled { + var echoCancellation: UInt32 = 0 // 0表示不绕过,即启用回音消除 + AudioUnitSetProperty(ioUnit!, kAUVoiceIOProperty_BypassVoiceProcessing, + kAudioUnitScope_Global, 0, &echoCancellation, UInt32(MemoryLayout.size)) + } + + // 设置输入回调 - 当有新音频数据时调用 + var inputCallback = AURenderCallbackStruct( + inputProc: CustomAudioProcessor.onAudioDataAvailable, + inputProcRefCon: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()) + ) + + if checkError(AudioUnitSetProperty(ioUnit!, + kAudioOutputUnitProperty_SetInputCallback, + kAudioUnitScope_Global, kInputBus, + &inputCallback, UInt32(MemoryLayout.size)), "设置输入回调") { + return false + } + + // 初始化音频单元 + var hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") + while hasError { + Thread.sleep(forTimeInterval: 0.1) + hasError = checkError(AudioUnitInitialize(ioUnit!), "初始化音频单元") + } + + // 启动音频单元 + hasError = checkError(AudioOutputUnitStart(ioUnit!), "启动音频单元") + + print("[CustomAudioProcessor] 音频处理器已启动,回音消除\(isEchoCancellationEnabled ? "已启用" : "已禁用")") + return !hasError + } + + /// 停止音频处理 + func stopRecord() { + print("[CustomAudioProcessor] 停止音频处理器") + + if let ioUnit = ioUnit { + // 停止音频单元 + _ = checkError(AudioOutputUnitStop(ioUnit), "停止音频单元") + + // 关闭音频单元 + _ = checkError(AudioUnitUninitialize(ioUnit), "反初始化音频单元") + _ = checkError(AudioComponentInstanceDispose(ioUnit), "释放音频单元") + + self.ioUnit = nil + } + + // 清空音频数据缓冲 + audioListQueue.sync { + audioList.removeAll() + } + } + + /// 音频数据回调 - 当有新的音频数据可用时调用 + private static let onAudioDataAvailable: AURenderCallback = { inRefCon, ioActionFlags, inTimeStamp, inBusNumber, inNumberFrames, ioData in + // 获取实例 + let processor = Unmanaged.fromOpaque(inRefCon).takeUnretainedValue() + + // 计算预期数据大小 + let expectedDataByteSize = inNumberFrames * processor.audioFormat.mBytesPerFrame + + // 确保缓冲区足够大 + if processor.audioBufferList.mBuffers.mDataByteSize < expectedDataByteSize { + processor.audioBufferList.mBuffers.mData = realloc(processor.audioBufferList.mBuffers.mData, Int(expectedDataByteSize)) + processor.audioBufferList.mBuffers.mDataByteSize = expectedDataByteSize + } + + // 渲染音频数据 + let status = processor.checkOSStatus(AudioUnitRender(processor.ioUnit!, ioActionFlags, inTimeStamp, + inBusNumber, inNumberFrames, &processor.audioBufferList), + "渲染音频数据") + + // 将Int16数据转换为浮点数据进行处理 + var audioDataFloat = [Float](repeating: 0.0, count: Int(inNumberFrames)) + let buffer = processor.audioBufferList.mBuffers + let bufferData = buffer.mData!.assumingMemoryBound(to: Int16.self) + + for j in 0...size)) { + // 归一化到[-1.0, 1.0]范围 + audioDataFloat[j] = Float(bufferData[j]) / 32768.0 + } + + // 保存处理后的数据 + if status == noErr { + processor.audioListQueue.async { + processor.audioList.append(contentsOf: audioDataFloat) + } + } + + return status + } + + /// 读取处理后的音频数据 + /// - Parameter bytes: 输出字节数组 + /// - Returns: 读取的字节数 + func read(bytes: inout [UInt8]) -> Int { + return audioListQueue.sync { + // 如果没有数据,返回0 + if audioList.isEmpty { + return 0 + } + + // 确保有足够的数据 (至少1280个样本) + if audioList.count < 1280 { + return 0 + } + + // 读取一帧数据 (1280个样本) + let frameLength = 1280 + let buffer = Array(audioList.prefix(frameLength)) + audioList.removeFirst(frameLength) + + // 将浮点数据转回Int16格式 + var int16Data = buffer.map { Int16($0 * 32767) } + + // 转换为字节数组 + let data = Data(buffer: UnsafeBufferPointer(start: &int16Data, count: int16Data.count)) + bytes = [UInt8](data) + + // 每个样本2字节 (16位PCM) + return frameLength * 2 + } + } + + /// 检查错误并打印日志 + /// - Parameters: + /// - status: 操作状态 + /// - operation: 操作描述 + /// - Returns: 是否发生错误 + private func checkError(_ status: OSStatus, _ operation: String) -> Bool { + if status != noErr { + print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") + return true + } + return false + } + + /// 检查OSStatus并返回状态 + /// - Parameters: + /// - status: 操作状态 + /// - operation: 操作描述 + /// - Returns: 原始状态 + private func checkOSStatus(_ status: OSStatus, _ operation: String) -> OSStatus { + if status != noErr { + print("[CustomAudioProcessor] 错误: \(operation)失败: \(status)") + } + return status + } +} \ No newline at end of file diff --git a/ios/Runner/AzureTtsHelper.swift b/ios/Runner/AzureTtsHelper.swift new file mode 100644 index 000000000..f62c3f579 --- /dev/null +++ b/ios/Runner/AzureTtsHelper.swift @@ -0,0 +1,454 @@ +import Foundation +import MicrosoftCognitiveServicesSpeech +import AVFoundation + +/// Azure TTS工具类,负责实现TTS服务接口 +@available(iOS 13.0, *) +class AzureTtsHelper: NSObject { + // MARK: - 属性 + + /// 事件处理回调 + private var eventHandler: (([String: Any]) -> Void)? + + /// 语音配置信息 + private var speechSubscriptionKey: String = "" + private var serviceRegion: String = "" + + /// 语音合成配置 + private var speechConfig: SPXSpeechConfiguration? + + /// 语音合成器 + private var synthesizer: SPXSpeechSynthesizer? + + /// 是否初始化成功 + private var isInitialized = false + + /// 当前是否正在播放 + private var _isSpeaking = false + + /// 音频会话配置 + private var isAudioSessionConfigured = false + + /// 音频会话管理器 + private let audioSessionManager = AudioSessionManager.shared + + // MARK: - 语音设置 + + /// 当前语音 + private var currentVoice = "zh-CN-XiaoxiaoNeural" + + /// 支持的语音映射 + private var voiceMap: [String: String] = [ + "zh-CN": "zh-CN-XiaoxiaoNeural", + "en-US": "en-US-JennyNeural", + "ja-JP": "ja-JP-NanamiNeural", + "ko-KR": "ko-KR-SunHiNeural", + "zh-TW": "zh-TW-HsiaoChenNeural", + "zh-HK": "zh-HK-HiuMaanNeural" + ] + + /// 当前语音合成参数 + private var currentSpeechRate = "0%" + private var currentPitch = "0%" + private var currentVolume = "100%" + + // MARK: - 初始化 + + override init() { + super.init() + } + + deinit { + dispose() + } + + /// 设置事件处理器 + @objc func setEventHandler(_ handler: @escaping ([String: Any]) -> Void) { + self.eventHandler = handler + } + + // MARK: - TTS 接口实现 + + /// 初始化语音合成服务 + /// - Parameters: + /// - speechSubscriptionKey: Azure 语音服务订阅密钥 + /// - serviceRegion: Azure 服务区域 (如 eastasia) + /// - language: 语言代码 (默认 zh-CN) + /// - Returns: 初始化是否成功 + func initialize(speechSubscriptionKey: String, serviceRegion: String, language: String = "zh-CN") -> Bool { + print("[AzureTtsHelper] 初始化语音合成服务") + + // 检查配置是否为空 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + // 释放之前的资源 + dispose() + + // 记录配置信息 + self.speechSubscriptionKey = speechSubscriptionKey + self.serviceRegion = serviceRegion + + // 配置音频会话 + if !configureAudioSession() { + print("[AzureTtsHelper] 警告: 音频会话配置失败,将尝试继续初始化") + } + + do { + // 创建语音配置 + speechConfig = try SPXSpeechConfiguration(subscription: speechSubscriptionKey, region: serviceRegion) + + // 设置默认语音 + let defaultVoice = getDefaultVoiceForLanguage(language) + currentVoice = defaultVoice + speechConfig?.speechSynthesisVoiceName = defaultVoice + + // 创建语音合成器 + synthesizer = try SPXSpeechSynthesizer(speechConfig!) + + // 设置事件处理器 + setupSynthesizerEvents() + + isInitialized = true + print("[AzureTtsHelper] TTS 引擎初始化成功") + + return true + } catch { + print("[AzureTtsHelper] 错误: 初始化语音合成服务失败: \(error.localizedDescription)") + eventHandler?(["type": "error", "message": "初始化语音合成服务失败: \(error.localizedDescription)"]) + return false + } + } + + /// 配置音频会话 + private func configureAudioSession() -> Bool { + do { + // 使用音频会话管理器配置媒体播放模式 + try audioSessionManager.configureForVoiceInteraction() + + isAudioSessionConfigured = true + print("[AzureTtsHelper] 音频会话配置成功") + return true + } catch { + print("[AzureTtsHelper] 警告: 音频会话配置失败: \(error.localizedDescription)") + isAudioSessionConfigured = false + return false + } + } + + /// 设置语音 + /// - Parameter voiceName: 语音名称 (如 "zh-CN-XiaoxiaoNeural") + /// - Returns: 设置是否成功 + func setVoice(voiceName: String) -> Bool { + // 检查是否初始化 + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) + return false + } + + // 检查参数有效性 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + if voiceName.isEmpty { + print("[AzureTtsHelper] 错误: 声音名称为空") + eventHandler?(["type": "error", "message": "声音名称不能为空"]) + return false + } + + if voiceName == currentVoice { + print("[AzureTtsHelper] 已设置语音: \(voiceName)") + return true + } + + print("[AzureTtsHelper] 设置声音: \(voiceName)") + currentVoice = voiceName + + // 更新语音配置 + if let speechConfig = speechConfig { + speechConfig.speechSynthesisVoiceName = voiceName + return true + } + + return false + } + + /// 设置语音合成参数 + /// - Parameters: + /// - rate: 语速,范围 -100 到 100,默认为 0 + /// - pitch: 音调,范围 -100 到 100,默认为 0 + /// - volume: 音量,范围 0 到 100,默认为 100 + /// - Returns: 是否设置成功 + func setSpeechParams(rate: Int = 0, pitch: Int = 0, volume: Int = 100) -> Bool { + // 检查是否初始化 + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) + return false + } + + // 检查参数有效性 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + // 转换参数格式 + currentSpeechRate = formatRateParam(rate) + currentPitch = formatPitchParam(pitch) + currentVolume = formatVolumeParam(volume) + + print("[AzureTtsHelper] 已设置语音参数: 语速=\(currentSpeechRate), 音调=\(currentPitch), 音量=\(currentVolume)") + return true + } + + /// 合成文本为语音并播放 + /// - Parameter text: 要合成的文本 + /// - Returns: 操作是否成功启动 + func speakText(text: String) -> Bool { + // 检查是否初始化 + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) + return false + } + + // 检查参数有效性 + if speechSubscriptionKey.isEmpty || serviceRegion.isEmpty { + print("[AzureTtsHelper] 错误: Azure 配置信息不完整") + eventHandler?(["type": "error", "message": "Azure 配置信息不完整"]) + return false + } + + if text.isEmpty { + print("[AzureTtsHelper] 警告: 要播放的文本为空") + return true + } + + // 确保音频会话已配置 + if !isAudioSessionConfigured { + _ = configureAudioSession() + } + + print("[AzureTtsHelper] 开始语音合成: \(text.prefix(50))...") + + // 生成SSML + let ssml = generateSsml(text: text) + + // 直接进行SSML合成 + return speakSsmlInternal(text: ssml) + } + + /// 内部SSML合成和播放 + private func speakSsmlInternal(text: String) -> Bool { + guard let synthesizer = synthesizer else { + print("[AzureTtsHelper] 错误: 合成器未初始化") + eventHandler?(["type": "error", "message": "合成器未初始化"]) + return false + } + + _isSpeaking = true + eventHandler?(["type": "started"]) + + Task { + do { + // 使用异步方法进行合成并直接播放 + _ = try synthesizer.startSpeakingSsml(text) + + } catch { + print("[AzureTtsHelper] 错误: 语音合成失败: \(error.localizedDescription)") + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler?(["type": "error", "message": "语音合成失败: \(error.localizedDescription)"]) + } + } + } + + return true + } + + /// 停止当前语音合成 + /// - Returns: 操作是否成功 + func stopSpeaking() -> Bool { + // 检查是否初始化 + if !isInitialized { + print("[AzureTtsHelper] 错误: TTS 引擎尚未初始化") + eventHandler?(["type": "error", "message": "TTS 引擎尚未初始化"]) + return false + } + + if !_isSpeaking { + print("[AzureTtsHelper] 未进行语音播放,忽略停止请求") + return true + } + + // 停止合成 + do { + try synthesizer?.stopSpeaking() + _isSpeaking = false + eventHandler?(["type": "canceled"]) + print("[AzureTtsHelper] 已停止语音合成") + return true + } catch { + print("[AzureTtsHelper] 错误: 停止语音合成失败: \(error.localizedDescription)") + eventHandler?(["type": "error", "message": "停止语音合成失败: \(error.localizedDescription)"]) + return false + } + } + + /// 检查是否正在播放 + /// - Returns: 当前是否正在播放语音 + func isSpeaking() -> Bool { + return _isSpeaking + } + + /// 释放资源 + func dispose() { + // 尝试停止所有语音合成 + if _isSpeaking { + do { + if let synthesizer = synthesizer { + try synthesizer.stopSpeaking() + print("[AzureTtsHelper] 语音合成已停止") + } + } catch { + print("[AzureTtsHelper] 警告: 停止语音合成失败: \(error.localizedDescription)") + } + _isSpeaking = false + } + + // 释放合成器 + synthesizer = nil + speechConfig = nil + + // 重置状态 + isInitialized = false + isAudioSessionConfigured = false + + print("[AzureTtsHelper] 资源已释放") + } + + // MARK: - 私有辅助方法 + + /// 设置合成器事件处理 + private func setupSynthesizerEvents() { + guard let synthesizer = synthesizer else { return } + + // 添加书签到达事件处理 + synthesizer.addBookmarkReachedEventHandler { _, e in + print("[AzureTtsHelper] 书签事件: 音频偏移: \((e.audioOffset + 5000) / 10000)ms, 文本: \"\(e.text)\"") + } + + // 合成完成事件 + synthesizer.addSynthesisCompletedEventHandler { [weak self] _, e in + guard let self = self else { return } + print("[AzureTtsHelper] 语音合成完成: 音频持续时间: \(e.result.audioDuration)") + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler?(["type": "completed"]) + } + } + + // 合成取消事件 + synthesizer.addSynthesisCanceledEventHandler { [weak self] _, e in + guard let self = self else { return } + + let result = e.result + do { + let cancellationDetails = try SPXSpeechSynthesisCancellationDetails(fromCanceledSynthesisResult: result) + print("[AzureTtsHelper] 语音合成取消: 原因: \(cancellationDetails.reason)") + + if cancellationDetails.reason == SPXCancellationReason.error { + print("[AzureTtsHelper] 错误代码: \(cancellationDetails.errorCode)") + print("[AzureTtsHelper] 错误详情: \(cancellationDetails.errorDetails ?? "未知")") + } + + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler?(["type": "error", "message": "语音合成取消: \(cancellationDetails.errorDetails ?? "未知错误")"]) + } + } catch { + print("[AzureTtsHelper] 获取取消详情时出错: \(error)") + + DispatchQueue.main.async { + self._isSpeaking = false + self.eventHandler?(["type": "error", "message": "语音合成被取消"]) + } + } + } + + // 合成开始事件 + synthesizer.addSynthesisStartedEventHandler { _, _ in + // print("[AzureTtsHelper] 语音合成开始") + } + + // 合成中事件 + synthesizer.addSynthesizingEventHandler { _, _ in + // print("[AzureTtsHelper] 语音合成中") + } + } + + /// 生成 SSML 文本 + private func generateSsml(text: String) -> String { + return """ + + + + \(text) + + + + """ + } + + /// 格式化语速参数 + private func formatRateParam(_ rate: Int) -> String { + let clampedRate = rate.clamp(min: -100, max: 100) + if clampedRate == 0 { + return "0%" + } else if clampedRate < 0 { + return "\(Int(Double(clampedRate) * 0.9))%" + } else { + return "+\(clampedRate)%" + } + } + + /// 格式化音调参数 + private func formatPitchParam(_ pitch: Int) -> String { + let clampedPitch = pitch.clamp(min: -100, max: 100) + if clampedPitch == 0 { + return "0%" + } else { + return "\(Int(Double(clampedPitch) * 0.5))%" + } + } + + /// 格式化音量参数 + private func formatVolumeParam(_ volume: Int) -> String { + let clampedVolume = volume.clamp(min: 0, max: 100) + return "\(clampedVolume)%" + } + + /// 获取指定语言的默认语音 + private func getDefaultVoiceForLanguage(_ language: String) -> String { + return voiceMap[language] ?? "zh-CN-XiaoxiaoNeural" + } +} + +// MARK: - 扩展 + +extension Int { + func clamp(min: Int, max: Int) -> Int { + if self < min { return min } + if self > max { return max } + return self + } +} \ No newline at end of file diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard index f3c28516f..19d3697a4 100644 --- a/ios/Runner/Base.lproj/Main.storyboard +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -1,8 +1,10 @@ - - + + + - + + @@ -14,13 +16,14 @@ - + - + + diff --git a/ios/Runner/BluetoothMediaButtonHelper.swift b/ios/Runner/BluetoothMediaButtonHelper.swift new file mode 100644 index 000000000..3d203b74d --- /dev/null +++ b/ios/Runner/BluetoothMediaButtonHelper.swift @@ -0,0 +1,464 @@ +import Foundation +import MediaPlayer +import AVFoundation +import UIKit + +/// 媒体按钮事件处理协议 +@available(iOS 13.0, *) +@objc protocol MediaButtonEventHandler: AnyObject { + func sendEvent(_ event: [String: Any]) +} + +/// 蓝牙媒体按键事件处理类 +/// 该类仅在iOS 13.0及以上版本可用 +@available(iOS 13.0, *) +class BluetoothMediaButtonHelper: NSObject, AVAudioPlayerDelegate { + // MARK: - 属性 + + @objc static let shared = BluetoothMediaButtonHelper() + + /// 日志标签 + private let TAG = "BluetoothMediaButtonHelper" + + /// 远程控制中心 + private var remoteCommandCenter: MPRemoteCommandCenter + + /// 事件处理器 - 用于发送事件到Flutter + private weak var eventHandler: MediaButtonEventHandler? + + /// 是否已启用监听 + private var isListening = false + + /// 静音音频播放器 - 用于保持音频会话活跃 + private var silentAudioPlayer: AVAudioPlayer? + + /// 播放计时器 - 用于定期更新播放信息 + private var playbackTimer: Timer? + + /// 当前播放时间 - 模拟播放进度 + private var currentPlaybackTime: TimeInterval = 0 + + /// 总时长 - 模拟 + private let totalDuration: TimeInterval = 300.0 + + /// 音频会话管理器 + private let audioSessionManager = AudioSessionManager.shared + + // MARK: - 初始化 + + override init() { + // 获取远程控制中心单例 + self.remoteCommandCenter = MPRemoteCommandCenter.shared() + + super.init() + + NSLog("\(TAG): 初始化媒体按钮处理器") + + // 初始化静音音频播放器 + initSilentAudioPlayer() + } + + deinit { + stopButtonListening() + NSLog("\(TAG): 媒体按钮处理器已释放") + } + + // MARK: - 公共方法 + + /// 设置事件处理器 + @objc func setEventHandler(_ handler: MediaButtonEventHandler) { + eventHandler = handler + NSLog("\(TAG): 已设置媒体按钮事件处理器") + } + + /// 开始监听蓝牙媒体按钮事件 + @objc func startButtonListening() -> Bool { + if isListening { + NSLog("\(TAG): 已经在监听蓝牙媒体按钮事件") + return true + } + + NSLog("\(TAG): 开始监听蓝牙媒体按钮事件") + + // 立即注册远程命令 - 这不需要音频会话 + registerRemoteCommands() + + // 标记为已在监听 + isListening = true + + // 确保NowPlaying信息可用 - 这也不依赖于活跃的音频会话 + setupNowPlaying(isPlaying: true) + + // 尝试启用静音音频播放(但即使失败,远程命令仍然可用) + do { + // try audioSessionManager.configureForBluetoothMediaButtons() + startSilentAudio() + + // 延时1秒 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.setupNowPlaying(isPlaying: false) + self.stopSilentAudio() + } + + // startUpdatingPlaybackInfo() + } catch { + NSLog("\(TAG): 音频设置或播放失败: \(error.localizedDescription)") + // 继续执行,因为远程命令在某些设备上不需要播放音频也能工作 + } + + NSLog("\(TAG): 蓝牙媒体按钮监听已启动") + return true + } + + /// 停止监听蓝牙媒体按钮事件 + @objc func stopButtonListening() -> Bool { + if !isListening { + return true + } + + NSLog("\(TAG): 停止监听蓝牙媒体按钮事件") + + // 停止播放静音音频 + stopSilentAudio() + + // 停止更新播放信息 + stopUpdatingPlaybackInfo() + + // 取消注册远程命令 + unregisterRemoteCommands() + + // 清除NowPlaying信息 + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + + isListening = false + NSLog("\(TAG): 蓝牙媒体按钮监听已停止") + return true + } + + // MARK: - 私有方法 + + /// 初始化静音音频播放器 + private func initSilentAudioPlayer() { + do { + // 尝试加载sample.mp3文件 + if let audioPath = Bundle.main.path(forResource: "sample", ofType: "mp3") { + let audioUrl = URL(fileURLWithPath: audioPath) + + silentAudioPlayer = try AVAudioPlayer(contentsOf: audioUrl) + silentAudioPlayer?.numberOfLoops = -1 // 无限循环播放 + silentAudioPlayer?.volume = 0.0 // 设置为全音量 + silentAudioPlayer?.prepareToPlay() // 预加载 + silentAudioPlayer?.delegate = self + + NSLog("\(TAG): 音频播放器使用sample.mp3初始化成功") + return + } + + NSLog("\(TAG): 未找到sample.mp3文件,使用生成的静音数据") + + } catch { + NSLog("\(TAG): 初始化静音音频播放器失败: \(error.localizedDescription)") + } + } + + /// 开始播放静音音频 + private func startSilentAudio() { + guard let player = silentAudioPlayer else { + NSLog("\(TAG): 无法开始播放,播放器未初始化") + return + } + + if !player.isPlaying { + let success = player.play() + if success { + NSLog("\(TAG): 开始播放静音音频") + } else { + NSLog("\(TAG): 播放启动失败") + } + } else { + NSLog("\(TAG): 静音音频已在播放中") + } + } + + /// 停止播放静音音频 + private func stopSilentAudio() { + silentAudioPlayer?.stop() + NSLog("\(TAG): 停止播放静音音频") + } + + /// 开始更新播放信息 + private func startUpdatingPlaybackInfo() { + // 初始设置 + setupNowPlaying(isPlaying: true) + + // 定期更新播放信息 - 恢复定时器,确保持续更新播放状态 + playbackTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + guard let self = self else { return } + + // 更新播放时间(模拟) + self.currentPlaybackTime += 1.0 + if self.currentPlaybackTime >= self.totalDuration { + self.currentPlaybackTime = 0.0 + } + + // 更新NowPlaying信息 + self.setupNowPlaying(isPlaying: true) + } + + NSLog("\(TAG): 开始更新播放信息") + } + + /// 停止更新播放信息 + private func stopUpdatingPlaybackInfo() { + playbackTimer?.invalidate() + playbackTimer = nil + + // 清空NowPlaying信息 + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nil + } + + NSLog("\(TAG): 停止更新播放信息") + } + + /// 设置NowPlaying信息 + private func setupNowPlaying(isPlaying: Bool) { + NSLog("\(TAG): 设置NowPlaying信息, isPlaying=\(isPlaying)") + + // 创建音频专辑图片 + let albumArt = UIImage(named: "AppIcon") // 使用应用图标作为专辑图片 + var nowPlayingInfo: [String: Any] = [ + MPMediaItemPropertyTitle: "DeepVoice 语音助手", + MPMediaItemPropertyArtist: "DeepVoice", + MPNowPlayingInfoPropertyPlaybackRate: isPlaying ? 1.0 : 0.0, // 1.0表示正在播放 + MPNowPlayingInfoPropertyElapsedPlaybackTime: currentPlaybackTime, + MPMediaItemPropertyPlaybackDuration: totalDuration + ] + + // 如果有专辑图片,添加到NowPlaying信息中 + if let albumArt = albumArt { + let artwork = MPMediaItemArtwork(boundsSize: albumArt.size) { size in + return albumArt + } + nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork + } + + // 设置NowPlaying信息 (在主线程执行) + DispatchQueue.main.async { + MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo + NSLog("\(self.TAG): NowPlaying信息设置完成") + } + } + + /// 注册远程命令 + private func registerRemoteCommands() { + let commandCenter = MPRemoteCommandCenter.shared() + + // 播放/暂停命令 + commandCenter.togglePlayPauseCommand.isEnabled = true + commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) + commandCenter.playCommand.isEnabled = true + commandCenter.playCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) + commandCenter.pauseCommand.isEnabled = true + commandCenter.pauseCommand.addTarget(self, action: #selector(handlePlayPauseCommand(_:))) + + // 下一曲/上一曲命令 + commandCenter.nextTrackCommand.isEnabled = true + commandCenter.nextTrackCommand.addTarget(self, action: #selector(handleNextTrackCommand(_:))) + commandCenter.previousTrackCommand.isEnabled = true + commandCenter.previousTrackCommand.addTarget(self, action: #selector(handlePreviousTrackCommand(_:))) + + // 快进/快退命令 + commandCenter.seekForwardCommand.isEnabled = true + commandCenter.seekForwardCommand.addTarget(self, action: #selector(handleSeekForwardCommand(_:))) + commandCenter.seekBackwardCommand.isEnabled = true + commandCenter.seekBackwardCommand.addTarget(self, action: #selector(handleSeekBackwardCommand(_:))) + + // 停止命令 + commandCenter.stopCommand.isEnabled = true + commandCenter.stopCommand.addTarget(self, action: #selector(handleStopCommand(_:))) + + + + NSLog("\(TAG): 远程命令已注册") + } + + /// 取消注册远程命令 + private func unregisterRemoteCommands() { + NSLog("\(TAG): 取消注册远程命令") + + // 基本控制命令 + remoteCommandCenter.playCommand.isEnabled = false + remoteCommandCenter.playCommand.removeTarget(nil) + + remoteCommandCenter.pauseCommand.isEnabled = false + remoteCommandCenter.pauseCommand.removeTarget(nil) + + remoteCommandCenter.togglePlayPauseCommand.isEnabled = false + remoteCommandCenter.togglePlayPauseCommand.removeTarget(nil) + + remoteCommandCenter.stopCommand.isEnabled = false + remoteCommandCenter.stopCommand.removeTarget(nil) + + + } + + /// 发送按钮事件到Flutter + private func sendButtonEvent(type: String) { + // 创建事件数据 + let eventData: [String: Any] = [ + "type": "mediaButtonEvent", + "buttonType": type, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + + // 发送事件到处理器 + eventHandler?.sendEvent(eventData) + } + + + // MARK: - 命令处理方法 + + /// 处理播放/暂停命令 + @objc private func handlePlayPauseCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到播放/暂停命令") + + // 发送事件到Flutter + sendButtonEvent(type: "play_pause") + + // 调用VoiceInteractionService处理媒体按钮事件 + if VoiceInteractionService.shared.isServiceRunning() { + NSLog("\(TAG): 转发到VoiceInteractionService处理") + VoiceInteractionService.shared.handleMediaButtonAction() + } + + return .success + } + + /// 处理下一曲命令 + @objc private func handleNextTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到下一曲命令") + + // 发送事件到Flutter + sendButtonEvent(type: "next_track") + + // 也可以转发到VoiceInteractionService + if VoiceInteractionService.shared.isServiceRunning() { + VoiceInteractionService.shared.handleMediaButtonAction() + } + + return .success + } + + /// 处理上一曲命令 + @objc private func handlePreviousTrackCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到上一曲命令") + + // 发送事件到Flutter + sendButtonEvent(type: "previous_track") + + // 也可以转发到VoiceInteractionService + if VoiceInteractionService.shared.isServiceRunning() { + VoiceInteractionService.shared.handleMediaButtonAction() + } + + return .success + } + + /// 处理快进命令 + @objc private func handleSeekForwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到快进命令") + + // 发送事件到Flutter + sendButtonEvent(type: "seek_forward") + + return .success + } + + /// 处理快退命令 + @objc private func handleSeekBackwardCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到快退命令") + + // 发送事件到Flutter + sendButtonEvent(type: "seek_backward") + + return .success + } + + /// 处理停止命令 + @objc private func handleStopCommand(_ event: MPRemoteCommandEvent) -> MPRemoteCommandHandlerStatus { + NSLog("\(TAG): 收到停止命令") + + // 发送事件到Flutter + sendButtonEvent(type: "stop") + + // 调用VoiceInteractionService处理媒体按钮事件 + if VoiceInteractionService.shared.isServiceRunning() { + VoiceInteractionService.shared.handleMediaButtonAction() + } + + return .success + } + + // MARK: - AVAudioPlayerDelegate方法 + + /// 音频播放结束回调 + func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { + NSLog("\(TAG): 音频播放结束, 成功: \(flag)") + + // 如果仍在监听状态,重新开始播放 + if isListening && player == silentAudioPlayer { + player.play() + } + } + + /// 音频播放错误回调 + func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) { + if let error = error { + NSLog("\(TAG): 音频解码错误: \(error.localizedDescription)") + } else { + NSLog("\(TAG): 音频解码发生未知错误") + } + + // 重新初始化播放器 + if player == silentAudioPlayer && isListening { + initSilentAudioPlayer() + startSilentAudio() + } + } + + /// 清理和释放资源 + func dispose() { + NSLog("\(TAG): 释放BluetoothMediaButtonHelper资源") + // 停止监听 + stopButtonListening() + + // 释放播放器和计时器资源 + silentAudioPlayer = nil + if let timer = playbackTimer { + timer.invalidate() + playbackTimer = nil + } + + // 清除引用 + eventHandler = nil + + NSLog("\(TAG): 资源已释放") + } +} + +extension UInt32 { + var data: Data { + var int = self + return Data(bytes: &int, count: MemoryLayout.size) + } +} + +extension UInt16 { + var data: Data { + var int = self + return Data(bytes: &int, count: MemoryLayout.size) + } +} \ No newline at end of file diff --git a/ios/Runner/ClassicBluetoothHelper.swift b/ios/Runner/ClassicBluetoothHelper.swift new file mode 100644 index 000000000..2694075e0 --- /dev/null +++ b/ios/Runner/ClassicBluetoothHelper.swift @@ -0,0 +1,243 @@ +import Foundation +import CoreBluetooth +import AVFoundation +import UIKit + +/// 蓝牙事件处理协议 +@objc protocol BluetoothEventHandler: AnyObject { + func sendEvent(_ event: [String: Any]) +} + +@objc class ClassicBluetoothHelper: NSObject, CBCentralManagerDelegate { + // MARK: - 属性 + + // 音频会话管理器 + private let audioSessionManager = AudioSessionManager.shared + + // 蓝牙管理器 + private var centralManager: CBCentralManager? + + // 事件处理器 + private weak var eventHandler: BluetoothEventHandler? + + // MARK: - 初始化与设置 + + override init() { + super.init() + } + + // 设置事件处理器 + @objc func setEventHandler(_ handler: BluetoothEventHandler) { + eventHandler = handler + } + + + // MARK: - CBCentralManagerDelegate + + func centralManagerDidUpdateState(_ central: CBCentralManager) { + let stateString = getBluetoothStateString(state: central.state) + sendBluetoothStateEvent(state: stateString) + } + + // MARK: - 设备监控 + + // 获取蓝牙状态字符串 + private func getBluetoothStateString(state: CBManagerState) -> String { + switch state { + case .poweredOn: + return "on" + case .poweredOff: + return "off" + default: + return "unknown" + } + } + + // 发送蓝牙状态事件 + private func sendBluetoothStateEvent(state: String) { + let event: [String: Any] = [ + "type": "bluetoothStateChanged", + "state": state, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + eventHandler?.sendEvent(event) + } + + // 启动蓝牙设备监控 + @objc func initialize() { + if centralManager == nil { + centralManager = CBCentralManager(delegate: self, queue: nil) + } + + + + // 发送当前蓝牙状态 + if let manager = centralManager { + let stateString = getBluetoothStateString(state: manager.state) + sendBluetoothStateEvent(state: stateString) + } + // 移除之前的监听器 + NotificationCenter.default.removeObserver(self) + // 添加新的监听器 + audioSessionManager.addRouteChangeListener(self, selector: #selector(handleRouteChange(_:))) + // BluetoothMediaButtonHelper.shared.startButtonListening() + } + + // 处理音频路由变化 + @objc private func handleRouteChange(_ notification: Notification) { + guard let userInfo = notification.userInfo, + let reasonValue = userInfo[AVAudioSessionRouteChangeReasonKey] as? UInt, + let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { + return + } + + // 获取当前设备 + let currentDevices = getConnectedAudioDevices() + + // 检查原因 + switch reason { + case .newDeviceAvailable: + // 新设备已连接 + for device in currentDevices { + // 发送设备连接事件 + NSLog("发送设备连接事件 - 设备: \(device["name"] ?? "未知设备") [\(device["address"] ?? "未知地址")]") + sendDeviceConnectedEvent(device: device) + } + + case .oldDeviceUnavailable: + // 获取断开之前的路由 + if let previousRoute = userInfo[AVAudioSessionRouteChangePreviousRouteKey] as? AVAudioSessionRouteDescription { + // 过滤蓝牙相关设备 + for output in previousRoute.outputs where isBluetoothOutput(output.portType) { + let device: [String: String] = [ + "name": output.portName, + "address": cleanDeviceAddress(output.uid) + ] + // 发送设备断开事件 + sendDeviceDisconnectedEvent(device: device) + } + } + + default: + break + } + } + + // 检查是否为蓝牙输出 + private func isBluetoothOutput(_ portType: AVAudioSession.Port) -> Bool { + return portType == .bluetoothA2DP || portType == .bluetoothHFP || portType == .bluetoothLE + } + + // MARK: - 设备事件 + + // 发送设备连接事件 + private func sendDeviceConnectedEvent(device: [String: String]) { + guard let name = device["name"], let address = device["address"] else { return } + + let deviceMap: [String: Any] = [ + "name": name, + "address": address, + "type": "headset" + ] + + let event: [String: Any] = [ + "type": "deviceConnected", + "device": deviceMap, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + + eventHandler?.sendEvent(event) + } + + // 发送设备断开事件 + private func sendDeviceDisconnectedEvent(device: [String: String]) { + guard let name = device["name"], let address = device["address"] else { return } + + let deviceMap: [String: Any] = [ + "name": name, + "address": address, + "type": "headset" + ] + + let event: [String: Any] = [ + "type": "deviceDisconnected", + "device": deviceMap, + "timestamp": Int(Date().timeIntervalSince1970 * 1000) + ] + + eventHandler?.sendEvent(event) + } + + // MARK: - 公共API + + // 获取当前连接的耳机设备 + @objc func getConnectedHeadsetDevices(_ completion: @escaping ([[String: String]]?, String?) -> Void) { + // 检查蓝牙状态 + if centralManager?.state != .poweredOn { + completion(nil, "蓝牙未启用") + return + } + + do { + // 获取设备 + let devices = getConnectedAudioDevices() + completion(devices, nil) + } catch { + completion(nil, "获取设备失败: \(error.localizedDescription)") + } + } + + // 获取当前连接的音频设备 + private func getConnectedAudioDevices() -> [[String: String]] { + var result: [[String: String]] = [] + + // 获取当前音频路由 + let outputs = audioSessionManager.getCurrentRoute().outputs + + // 过滤蓝牙相关设备 + for output in outputs where isBluetoothOutput(output.portType) { + let device: [String: String] = [ + "name": output.portName, + "address": cleanDeviceAddress(output.uid) // 处理设备地址,移除后缀 + ] + result.append(device) + } + + return result + } + + // 释放资源 + @objc func dispose() { + audioSessionManager.removeRouteChangeListener(self) + eventHandler = nil + } + + + + // 检查蓝牙是否启用 + @objc func isBluetoothEnabled() -> Bool { + return centralManager?.state == .poweredOn + } + + // 清理设备地址,移除后缀 + private func cleanDeviceAddress(_ address: String) -> String { + // 检查是否包含常见分隔符 + if let range = address.range(of: "-") ?? address.range(of: ":") ?? address.range(of: "_") { + // 只保留分隔符之前的部分 + return String(address[.. + CADisableMinimumFrameDurationOnPhone + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName @@ -24,13 +26,20 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSBluetoothAlwaysUsageDescription + 需要蓝牙权限来连接和管理音频设备 + NSBluetoothPeripheralUsageDescription + 需要蓝牙权限来连接和管理音频设备 NSMicrophoneUsageDescription - 需要使用麦克风来进行语音交互 + 需要麦克风权限来进行语音识别 + NSSpeechRecognitionUsageDescription + 需要语音识别权限来提供语音交互服务 + UIApplicationSupportsIndirectInputEvents + UIBackgroundModes audio - fetch - remote-notification + voip UILaunchStoryboardName LaunchScreen @@ -39,14 +48,15 @@ UISupportedInterfaceOrientations UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight - NSUserNotificationsUsageDescription - 需要通知权限来保持与AI助手的实时对话 - UIViewControllerBasedStatusBarAppearance - - CADisableMinimumFrameDurationOnPhone - - UIApplicationSupportsIndirectInputEvents - diff --git a/ios/Runner/Resources/.gitkeep b/ios/Runner/Resources/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/ios/Runner/Resources/sample.mp3 b/ios/Runner/Resources/sample.mp3 new file mode 100644 index 000000000..31ebc28ce Binary files /dev/null and b/ios/Runner/Resources/sample.mp3 differ diff --git a/ios/Runner/Resources/silence.mp3 b/ios/Runner/Resources/silence.mp3 new file mode 100644 index 000000000..f5bd92f88 Binary files /dev/null and b/ios/Runner/Resources/silence.mp3 differ diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h index 308a2a560..72042ccf3 100644 --- a/ios/Runner/Runner-Bridging-Header.h +++ b/ios/Runner/Runner-Bridging-Header.h @@ -1 +1,7 @@ +#ifndef Runner_Bridging_Header_h +#define Runner_Bridging_Header_h + #import "GeneratedPluginRegistrant.h" +#import + +#endif /* Runner_Bridging_Header_h */ diff --git a/ios/Runner/VoiceInteractionService.swift b/ios/Runner/VoiceInteractionService.swift new file mode 100644 index 000000000..f19baf2c9 --- /dev/null +++ b/ios/Runner/VoiceInteractionService.swift @@ -0,0 +1,565 @@ +import Foundation +import UIKit +import AVFoundation + +/** + * 语音交互服务,iOS原生实现 + * + * 负责: + * 1) 监听蓝牙耳机按键 + * 2) 处理语音识别 + * 3) 与火山AI服务交互 + * 4) 文本转语音播放 + */ +@available(iOS 13.0, *) +class VoiceInteractionService: NSObject { + // 常量 + private let TAG = "VoiceInteractionService" + private let RECOGNITION_TIMEOUT: TimeInterval = 8.0 + + // 单例 + static let shared = VoiceInteractionService() + + // 服务状态 + private var isRunning = false + private var isActive = false + private var isRecognitionActive = false + private var isTimeoutPaused = false + private var hasSpeechDetected = false + private var isTtsSpeaking = false + + // 事件处理器 + private weak var eventHandler: VoiceInteractionEventHandler? + + // 按键处理 + private var lastKeyEventTime: TimeInterval = 0 + private var keyEventCount = 0 + + // 活动时间 + private var lastActivityTime: TimeInterval = 0 + + // 当前用户输入 + private var currentUserInput = "" + private var lastUserMessage = "" + private var lastAssistantMessage = "" + + // 服务组件 + private var azureAsrHelper: AzureAsrHelper? + private var azureTtsHelper: AzureTtsHelper? + private var volcanoAIService: VolcanoAIService? + + + private var silenceTimer: Timer? + + // 系统提示词 + private let systemPrompt = """ + 你是一个智能语音助手,能够简洁明了地回答用户的问题。 + 请保持回答简短、准确,避免过长的解释。 + 如果用户的问题不清楚,请礼貌地请求澄清。 + 不要使用复杂的术语,除非用户明确要求。 + 用户用语音和你交互。 + """ + + // 初始化方法 + private override init() { + super.init() + NSLog("%@: VoiceInteractionService 初始化中", TAG) + + + } + + /** + * 启动服务 + * + * @param apiKey Azure语音服务API密钥 + * @param region Azure语音服务区域 + * @param volcanoKey 火山AI服务API密钥 + * @return 启动是否成功 + */ + func start(azureKey: String, azureRegion: String, volcanoKey: String) -> Bool { + guard !isRunning else { + NSLog("%@: 服务已经在运行", TAG) + return true + } + + NSLog("%@: 启动服务中", TAG) + + // 重置状态 + resetState() + + // 初始化各个服务组件 + initServices(azureKey: azureKey, azureRegion: azureRegion, volcanoKey: volcanoKey) + + // 启动monitorService定时器 + silenceTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(monitorService), userInfo: nil, repeats: true) + NSLog("%@: monitorService定时器已启动", TAG) + + // 标记服务为运行状态 + isRunning = true + + NSLog("%@: 服务启动完成,等待蓝牙按键事件", TAG) + return true + } + + /** + * 停止服务 + */ + func stop() { + guard isRunning else { + NSLog("%@: 服务未运行", TAG) + return + } + + NSLog("%@: 停止服务中", TAG) + + // 停止monitorService定时器 + silenceTimer?.invalidate() + silenceTimer = nil + + // 停止语音识别 + if isRecognitionActive { + stopVoiceRecognition() + } + + // 停止TTS + stopCurrentTTS() + + // 释放服务实例 + azureAsrHelper?.dispose() + azureTtsHelper?.dispose() + + // 更新状态 + resetState() + isRunning = false + + NSLog("%@: 服务已停止", TAG) + } + + /** + * 获取服务运行状态 + */ + func isServiceRunning() -> Bool { + return isRunning + } + + /** + * 重置状态 + */ + private func resetState() { + isActive = false + isRecognitionActive = false + isTimeoutPaused = false + hasSpeechDetected = false + isTtsSpeaking = false + lastUserMessage = "" + lastAssistantMessage = "" + } + + /** + * 初始化服务 + */ + private func initServices(azureKey: String, azureRegion: String, volcanoKey: String) { + NSLog("%@: 初始化服务组件", TAG) + + // 初始化Azure ASR + azureAsrHelper = AzureAsrHelper() + if let helper = azureAsrHelper { + helper.setEventHandler({ [weak self] eventData in + // 处理ASR事件 + self?.handleAsrEvent(eventData: eventData) + }) + } + + // 初始化Azure TTS + azureTtsHelper = AzureTtsHelper() + if let helper = azureTtsHelper { + helper.setEventHandler({ [weak self] eventData in + // 处理TTS事件 + self?.handleTtsEvent(eventData: eventData) + }) + } + + // 初始化火山AI服务 + volcanoAIService = VolcanoAIService() + + // 配置Azure ASR + if !azureKey.isEmpty && !azureRegion.isEmpty { + azureAsrHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, supportedLanguages: ["zh-CN"]) + NSLog("%@: Azure ASR初始化完成", TAG) + } else { + NSLog("%@: Azure配置信息不完整,无法初始化Azure ASR", TAG) + } + + // 配置Azure TTS + if !azureKey.isEmpty && !azureRegion.isEmpty { + azureTtsHelper?.initialize(speechSubscriptionKey: azureKey, serviceRegion: azureRegion, language: "zh-CN") + NSLog("%@: Azure TTS初始化完成", TAG) + } else { + NSLog("%@: Azure配置信息不完整,无法初始化Azure TTS", TAG) + } + + // 配置火山AI + if !volcanoKey.isEmpty { + volcanoAIService?.initialize(apiKey: volcanoKey) + NSLog("%@: 火山AI服务初始化完成", TAG) + } else { + NSLog("%@: 火山AI配置信息不完整,无法初始化火山AI服务", TAG) + } + } + + /** + * 监控服务状态 + */ + @objc private func monitorService() { + // 确保服务保持活跃状态 + if !isActive { + isActive = true + } + + // 检查语音识别状态 + if isRecognitionActive { + let currentTime = Date().timeIntervalSince1970 + let elapsedTime = currentTime - lastActivityTime + + // NSLog("%@: 已过去时间: %@, 是否检测到语音: %@, 是否TTS播放: %@", TAG, String(elapsedTime), String(hasSpeechDetected), String(isTtsSpeaking), String(elapsedTime)) + + // 如果超过超时时间没有检测到语音,且不在TTS播放中,暂停语音识别 + if !hasSpeechDetected && !isTtsSpeaking && elapsedTime >= RECOGNITION_TIMEOUT { + // NSLog("%@: 超过%@秒未检测到语音,停止识别", TAG, String(RECOGNITION_TIMEOUT)) + isTimeoutPaused = true + playNotification("没有听到您说话,已暂停对话。双击耳机按钮可重新开始。") + + // 停止语音识别并确保资源完全释放 + stopVoiceRecognition() + + // 清理识别状态 + isRecognitionActive = false + hasSpeechDetected = false + } + } + + } + + /** + * 处理ASR事件 + */ + private func handleAsrEvent(eventData: [String: Any]) { + if let type = eventData["type"] as? String { + // NSLog("%@: 处理ASR事件: %@", TAG, type) + + switch type { + case "recognizing": + if let text = eventData["text"] as? String, !text.isEmpty { + hasSpeechDetected = true + stopCurrentTTS() + updateLastActivityTime() + } else if let data = eventData["data"] as? [String: Any], + let text = data["text"] as? String, + !text.isEmpty { + hasSpeechDetected = true + stopCurrentTTS() + updateLastActivityTime() + } + + case "result": + var text: String? + + // 直接读取text或从data中读取 + if let directText = eventData["text"] as? String, !directText.isEmpty { + text = directText + } else if let data = eventData["data"] as? [String: Any], + let dataText = data["text"] as? String, + !dataText.isEmpty { + text = dataText + } + + if let finalText = text { + updateLastActivityTime() + + // 保存用户消息 + lastUserMessage = finalText + + processWithVolcanoAI(finalText) + } + + // 重置状态,继续识别 + hasSpeechDetected = false + + case "sessionStarted": + updateLastActivityTime() + + case "sessionStopped": + isRecognitionActive = false + + case "canceled": + isRecognitionActive = false + + case "error": + isRecognitionActive = false + if let message = eventData["message"] as? String { + NSLog("%@: ASR错误: %@", TAG, message) + } + playNotification("语音识别出错") + + default: + break + } + } + } + + /** + * 处理TTS事件 + */ + private func handleTtsEvent(eventData: [String: Any]) { + if let type = eventData["type"] as? String { + // NSLog("%@: 处理TTS事件: %@", TAG, type) + + switch type { + case "started": + isTtsSpeaking = true + + case "completed": + isTtsSpeaking = false + updateLastActivityTime() + + case "canceled": + isTtsSpeaking = false + + case "error": + isTtsSpeaking = false + if let message = eventData["message"] as? String { + NSLog("%@: TTS错误: %@", TAG, message) + } + + default: + break + } + } + } + + /** + * 处理双击事件 + */ + func handleMediaButtonAction() { + NSLog("%@: 处理媒体按钮事件", TAG) + + // 屏蔽短时间重复响应的问题 + let currentTime = Date().timeIntervalSince1970 + let timeDiff = currentTime - lastKeyEventTime + if timeDiff < 0.5 { + NSLog("%@: 短时间重复响应,忽略", TAG) + return + } + lastKeyEventTime = currentTime + + // 停止当前TTS播放 + stopCurrentTTS() + + // 检查应用是否在后台 + if UIApplication.shared.applicationState == .background { + NSLog("%@: 应用在后台,无法开始语音识别", TAG) + playNotification("请打开应用后重试!") + return + } + // 播放提示音 + playPrompt("我在!") + + // 重置超时暂停标志 + isTimeoutPaused = false + + // 启动或重置语音识别 + if !isRecognitionActive { + NSLog("%@: 语音识别未激活,开始启动", TAG) + + startVoiceRecognition() + } else { + NSLog("%@: 语音识别已激活,更新活动时间", TAG) + updateLastActivityTime() + hasSpeechDetected = false + } + } + + /** + * 开始语音识别 + */ + private func startVoiceRecognition() { + if isRecognitionActive { return } + + + + // 通知Flutter语音识别已启动 + notifyVoiceRecognitionStarted() + + isActive = true + isRecognitionActive = true + hasSpeechDetected = false + updateLastActivityTime() + + if let asrHelper = azureAsrHelper { + let success = asrHelper.startContinuousRecognition() + if !success { + isRecognitionActive = false + NSLog("%@: 启动语音识别失败", TAG) + playNotification("启动语音识别失败") + } + } else { + isRecognitionActive = false + NSLog("%@: Azure ASR服务未初始化", TAG) + playNotification("语音识别服务未初始化") + } + } + + /** + * 停止语音识别 + */ + private func stopVoiceRecognition() { + if !isRecognitionActive { return } + + NSLog("%@: 停止语音识别", TAG) + + if let asrHelper = azureAsrHelper { + let success = asrHelper.stopContinuousRecognition() + if !success { + NSLog("%@: 停止语音识别失败", TAG) + } + } + + isRecognitionActive = false + hasSpeechDetected = false + } + + /** + * 使用火山AI处理语音识别结果 + */ + private func processWithVolcanoAI(_ text: String) { + // 保存当前用户输入 + currentUserInput = text + + // 在后台线程处理 + DispatchQueue.global(qos: .userInitiated).async { [weak self] in + guard let self = self, let volcanoAIService = self.volcanoAIService else { + return + } + + do { + // 创建消息 + let message = volcanoAIService.createUserMessage(content: text) + let messages = [message] + + // 发送请求 + let response = try volcanoAIService.sendMessage(messages: messages, systemPrompt: self.systemPrompt) + + // 保存助手回复 + self.lastAssistantMessage = response + + // 播放AI回复 + self.speakAIResponse(response) + + // 同步聊天记录到Flutter端 + self.notifyChatHistoryUpdated(userMessage: text, assistantMessage: response) + } catch { + NSLog("%@: AI处理出错: %@", self.TAG, error.localizedDescription) + self.playNotification("AI处理出错") + } + } + } + + /** + * 播放AI回复 + */ + private func speakAIResponse(_ text: String) { + isTtsSpeaking = true + azureTtsHelper?.speakText(text: text) + } + + /** + * 播放提示音 + */ + private func playPrompt(_ message: String) { + isTtsSpeaking = true + azureTtsHelper?.speakText(text: message) + } + + /** + * 播放通知提示音 + */ + private func playNotification(_ message: String) { + isTtsSpeaking = true + azureTtsHelper?.speakText(text: message) + } + + /** + * 停止当前TTS播放 + */ + private func stopCurrentTTS() { + if isTtsSpeaking { + azureTtsHelper?.stopSpeaking() + isTtsSpeaking = false + } + } + + /** + * 更新最后活动时间 + */ + private func updateLastActivityTime() { + lastActivityTime = Date().timeIntervalSince1970 + } + + /** + * 暂停语音交互 + */ + func pauseVoiceInteraction() { + NSLog("%@: 暂停语音交互", TAG) + + // 停止当前语音播放 + stopCurrentTTS() + + // 停止语音识别 + if isRecognitionActive { + stopVoiceRecognition() + } + + // 设置状态为暂停,但服务保持运行 + isTimeoutPaused = true + } + + /** + * 通知语音识别已开始 + */ + private func notifyVoiceRecognitionStarted() { + // 发送识别开始事件 + sendEvent(type: "recognition_started", data: [:]) + } + + /** + * 通知聊天历史更新 + */ + private func notifyChatHistoryUpdated(userMessage: String, assistantMessage: String) { + // 发送聊天历史更新事件 + sendEvent(type: "chat_history_updated", data: [ + "agentId": "personal_assistant", + "userMessage": userMessage, + "assistantMessage": assistantMessage + ]) + } + + /** + * 设置事件处理器 + */ + func setEventHandler(_ handler: VoiceInteractionEventHandler) { + self.eventHandler = handler + } + + /** + * 发送事件到Flutter端 + */ + private func sendEvent(type: String, data: [String: Any] = [:]) { + var eventData = data + eventData["type"] = type + eventData["timestamp"] = Int(Date().timeIntervalSince1970 * 1000) + + // 使用事件处理器发送事件 + eventHandler?.sendEvent(eventData) + } + +} \ No newline at end of file diff --git a/ios/Runner/VolcanoAIService.swift b/ios/Runner/VolcanoAIService.swift new file mode 100644 index 000000000..4289b3b1b --- /dev/null +++ b/ios/Runner/VolcanoAIService.swift @@ -0,0 +1,391 @@ +import Foundation + +/** + * 火山AI服务的iOS原生实现 + * + * 参考Android端的VolcanoAIService实现,提供同步和异步的API调用方式 + */ +class VolcanoAIService { + private let TAG = "VolcanoAIService" + private let baseUrl = "https://ark.cn-beijing.volces.com/api/v3" + private let chatEndpoint = "/chat/completions" + private let session: URLSession + + private var apiKey: String = "" + private var isInitialized = false + + init() { + let config = URLSessionConfiguration.default + config.timeoutIntervalForRequest = 30.0 + config.timeoutIntervalForResource = 30.0 + self.session = URLSession(configuration: config) + } + + /** + * 初始化火山AI服务 + * + * @param apiKey 火山AI API密钥 + * @return 初始化是否成功 + */ + func initialize(apiKey: String) -> Bool { + self.apiKey = apiKey + isInitialized = !apiKey.isEmpty + + if !isInitialized { + NSLog("%@: 初始化失败:API key 不能为空", TAG) + } else { + NSLog("%@: 火山AI服务初始化成功", TAG) + } + + return isInitialized + } + + /** + * 生成个性化问候语 + * + * @param agentName 代理名称 + * @param systemPrompt 系统提示词 + * @param callback 回调函数,返回生成的问候语 + */ + func generateGreeting(agentName: String, systemPrompt: String, callback: @escaping (String?, Error?) -> Void) { + let messages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt], + ["role": "user", "content": "请用一句简短的话向我打个招呼,要符合你的身份和性格特点,不要超过18个字。"] + ] + + let messagesData = try? JSONSerialization.data(withJSONObject: messages, options: []) + let messagesArray = try? JSONSerialization.jsonObject(with: messagesData!, options: []) as? [[String: Any]] + + var result = "" + + sendMessageStream(messages: messagesArray!, systemPrompt: systemPrompt, streamCallback: StreamCallback( + onToken: { token in + result.append(token) + }, + onComplete: { + callback(result, nil) + }, + onError: { error in + callback(nil, error) + } + )) + } + + /** + * 发送消息(非流式输出) + * + * @param messages 消息列表 + * @param systemPrompt 系统提示词 + * @return 返回AI的回复 + * @throws VolcanoAIError 如果API调用失败 + */ + func sendMessage(messages: [[String: Any]], systemPrompt: String) throws -> String { + // 检查是否已初始化 + if !isInitialized || apiKey.isEmpty { + throw VolcanoAIError.serviceNotInitialized + } + + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + + fullMessages.append(contentsOf: messages) + + let requestBody: [String: Any] = [ + "model": "doubao-1-5-lite-32k-250115", + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": false + ] + + guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { + throw VolcanoAIError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) + } catch { + throw VolcanoAIError.invalidRequestBody + } + + let semaphore = DispatchSemaphore(value: 0) + var responseData: Data? + var responseError: Error? + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + responseError = VolcanoAIError.networkError(error.localizedDescription) + semaphore.signal() + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + responseError = VolcanoAIError.invalidResponse + semaphore.signal() + return + } + + if !(200...299).contains(httpResponse.statusCode) { + var errorMessage = "Unknown error occurred" + if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String { + errorMessage = message + } + responseError = VolcanoAIError.apiError(errorMessage) + semaphore.signal() + return + } + + responseData = data + semaphore.signal() + } + + task.resume() + _ = semaphore.wait(timeout: .distantFuture) + + if let error = responseError { + throw error + } + + guard let data = responseData else { + throw VolcanoAIError.emptyResponse + } + + do { + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String else { + throw VolcanoAIError.invalidResponseFormat + } + + return content + } catch { + throw VolcanoAIError.invalidResponseFormat + } + } + + /** + * 发送消息(流式输出) + * + * @param messages 消息列表 + * @param systemPrompt 系统提示词 + * @param streamCallback 回调函数,用于接收流式输出的结果 + */ + func sendMessageStream(messages: [[String: Any]], systemPrompt: String, streamCallback: StreamCallback) { + // 检查是否已初始化 + if !isInitialized || apiKey.isEmpty { + streamCallback.onError(VolcanoAIError.serviceNotInitialized) + return + } + + var fullMessages: [[String: Any]] = [ + ["role": "system", "content": systemPrompt] + ] + + fullMessages.append(contentsOf: messages) + + let requestBody: [String: Any] = [ + "model": "doubao-1-5-lite-32k-250115", + "messages": fullMessages, + "temperature": 0.7, + "max_tokens": 2000, + "stream": true + ] + + guard let url = URL(string: "\(baseUrl)\(chatEndpoint)") else { + streamCallback.onError(VolcanoAIError.invalidURL) + return + } + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") + request.addValue("text/event-stream", forHTTPHeaderField: "Accept") + + do { + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody, options: []) + } catch { + streamCallback.onError(VolcanoAIError.invalidRequestBody) + return + } + + let task = session.dataTask(with: request) { data, response, error in + if let error = error { + streamCallback.onError(VolcanoAIError.networkError(error.localizedDescription)) + return + } + + guard let httpResponse = response as? HTTPURLResponse else { + streamCallback.onError(VolcanoAIError.invalidResponse) + return + } + + if !(200...299).contains(httpResponse.statusCode) { + var errorMessage = "Unknown error occurred" + if let data = data, let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String { + errorMessage = message + } + streamCallback.onError(VolcanoAIError.apiError(errorMessage)) + return + } + + guard let data = data else { + streamCallback.onError(VolcanoAIError.emptyResponse) + return + } + + // 处理SSE流数据 + let responseString = String(data: data, encoding: .utf8) ?? "" + let lines = responseString.components(separatedBy: "\n") + + for line in lines { + if line.isEmpty { continue } + + if line.hasPrefix("data: ") { + let data = String(line.dropFirst(6)) + if data == "[DONE]" { + streamCallback.onComplete() + break + } + + do { + if let jsonData = data.data(using: .utf8), + let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let delta = firstChoice["delta"] as? [String: Any], + let content = delta["content"] as? String { + streamCallback.onToken(content) + } + } catch { + // 忽略无效的JSON数据 + continue + } + } + } + } + + task.resume() + } + + /** + * 同步方式发送消息(流式输出) + * + * 注意:此方法会阻塞当前线程,请在后台线程中调用 + * + * @param messages 消息列表 + * @param systemPrompt 系统提示词 + * @return 返回完整的AI回复 + * @throws VolcanoAIError 如果API调用失败 + */ + func sendMessageStreamSync(messages: [[String: Any]], systemPrompt: String) throws -> String { + var result = "" + let semaphore = DispatchSemaphore(value: 0) + var responseError: Error? + + sendMessageStream(messages: messages, systemPrompt: systemPrompt, streamCallback: StreamCallback( + onToken: { token in + result.append(token) + }, + onComplete: { + semaphore.signal() + }, + onError: { error in + responseError = error + semaphore.signal() + } + )) + + // 等待流式输出完成或出错 + _ = semaphore.wait(timeout: .now() + 60) + + if let error = responseError { + throw error + } + + return result + } + + /** + * 创建用户消息 + */ + func createUserMessage(content: String) -> [String: Any] { + return ["role": "user", "content": content] + } + + /** + * 创建系统消息 + */ + func createSystemMessage(content: String) -> [String: Any] { + return ["role": "system", "content": content] + } + + /** + * 创建助手消息 + */ + func createAssistantMessage(content: String) -> [String: Any] { + return ["role": "assistant", "content": content] + } + + /** + * 流式输出回调类 + */ + class StreamCallback { + let onToken: (String) -> Void + let onComplete: () -> Void + let onError: (Error) -> Void + + init(onToken: @escaping (String) -> Void, onComplete: @escaping () -> Void, onError: @escaping (Error) -> Void) { + self.onToken = onToken + self.onComplete = onComplete + self.onError = onError + } + } +} + +/** + * 火山AI错误枚举 + */ +enum VolcanoAIError: Error { + case serviceNotInitialized + case invalidURL + case invalidRequestBody + case networkError(String) + case invalidResponse + case emptyResponse + case invalidResponseFormat + case apiError(String) + + var localizedDescription: String { + switch self { + case .serviceNotInitialized: + return "火山AI服务未初始化或API key为空,请先调用initialize方法" + case .invalidURL: + return "无效的URL" + case .invalidRequestBody: + return "无效的请求体" + case .networkError(let message): + return "网络错误: \(message)" + case .invalidResponse: + return "无效的响应" + case .emptyResponse: + return "空响应" + case .invalidResponseFormat: + return "无效的响应格式" + case .apiError(let message): + return "API错误: \(message)" + } + } +} \ No newline at end of file diff --git a/ios/Runner/sample.mp3 b/ios/Runner/sample.mp3 new file mode 100644 index 000000000..31ebc28ce Binary files /dev/null and b/ios/Runner/sample.mp3 differ diff --git a/ios/Runner/silence.mp3 b/ios/Runner/silence.mp3 new file mode 100644 index 000000000..f5bd92f88 Binary files /dev/null and b/ios/Runner/silence.mp3 differ diff --git a/lib/core/bindings/initial_binding.dart b/lib/core/bindings/initial_binding.dart index 42752c7d6..3f42d9560 100644 --- a/lib/core/bindings/initial_binding.dart +++ b/lib/core/bindings/initial_binding.dart @@ -2,11 +2,12 @@ import 'package:get/get.dart'; import '../../data/services/speech_factory.dart'; import '../../data/services/chat_history_service.dart'; import '../../data/services/volcano_translation_service.dart'; -import '../../data/services/voice_interaction_service.dart'; import '../../data/services/language_manager.dart'; import '../../data/services/volcano_ai_service.dart'; import '../../data/services/classic_bluetooth_service.dart'; +import '../../data/services/bluetooth_media_button_service.dart'; import '../../core/utils/logger.dart'; +import '../../data/services/voice_interaction_service.dart'; /// 初始绑定,用于管理全局依赖 class InitialBinding extends Bindings { @@ -31,14 +32,18 @@ class InitialBinding extends Bindings { // 火山AI服务 Get.lazyPut(() => VolcanoAIService(), fenix: true); - // 注意:VoiceInteractionService 在 HomeController 中初始化 - Get.lazyPut(() => VoiceInteractionService(), - fenix: true); - // 经典蓝牙服务 Get.lazyPut(() => ClassicBluetoothService(), fenix: true); + // 蓝牙媒体按钮服务 + Get.lazyPut( + () => BluetoothMediaButtonService(), + fenix: true); + + Get.lazyPut(() => VoiceInteractionService(), + fenix: true); + // // 语言管理器(需要最先初始化) // Get.put(LoginService(), permanent: true); // await LoginService().FirebaseInit(); diff --git a/lib/data/models/agent.dart b/lib/data/models/agent.dart index 00e59c5d2..0bdbd278a 100644 --- a/lib/data/models/agent.dart +++ b/lib/data/models/agent.dart @@ -8,6 +8,7 @@ class Agent { final bool isOnline; final String voice; final String welcomeMessage; + final String? voiceWelcomeMessage; final String systemPrompt; const Agent({ @@ -19,6 +20,7 @@ class Agent { required this.isOnline, required this.voice, required this.welcomeMessage, + this.voiceWelcomeMessage, required this.systemPrompt, }); @@ -49,6 +51,7 @@ class Agent { 'voice': voice, 'welcomeMessage': welcomeMessage, 'systemPrompt': systemPrompt, + 'voiceWelcomeMessage': voiceWelcomeMessage, }; } @@ -74,6 +77,7 @@ class Agent { voice: voice ?? this.voice, welcomeMessage: welcomeMessage ?? this.welcomeMessage, systemPrompt: systemPrompt ?? this.systemPrompt, + voiceWelcomeMessage: voiceWelcomeMessage ?? this.voiceWelcomeMessage, ); } } \ No newline at end of file diff --git a/lib/data/models/events/voice_interaction_event.dart b/lib/data/models/events/voice_interaction_event.dart new file mode 100644 index 000000000..4bfa636df --- /dev/null +++ b/lib/data/models/events/voice_interaction_event.dart @@ -0,0 +1,29 @@ +import 'package:meta/meta.dart'; + +/// 语音交互事件基类 +abstract class VoiceInteractionEvent { + final int timestamp; + + VoiceInteractionEvent({required this.timestamp}); +} + +/// 聊天历史事件 +class ChatHistoryEvent extends VoiceInteractionEvent { + final String agentId; + final String userMessage; + final String assistantMessage; + + ChatHistoryEvent({ + required this.agentId, + required this.userMessage, + required this.assistantMessage, + required int timestamp, + }) : super(timestamp: timestamp); +} + +/// 语音识别开始事件 +class RecognitionStartedEvent extends VoiceInteractionEvent { + RecognitionStartedEvent({ + required int timestamp, + }) : super(timestamp: timestamp); +} \ No newline at end of file diff --git a/lib/data/models/message_model.dart b/lib/data/models/message_model.dart new file mode 100644 index 000000000..6307989ae --- /dev/null +++ b/lib/data/models/message_model.dart @@ -0,0 +1,58 @@ +class Message { + final String role; // 'user' or 'assistant' + final String content; + final DateTime timestamp; + final bool isLoading; + + Message({ + required this.role, + required this.content, + required this.timestamp, + this.isLoading = false, + }); + + // 从JSON构造函数 + factory Message.fromJson(Map json) { + return Message( + role: json['role'] as String, + content: json['content'] as String, + timestamp: DateTime.parse(json['timestamp'] as String), + isLoading: json['isLoading'] as bool? ?? false, + ); + } + + // 转换为JSON + Map toJson() { + return { + 'role': role, + 'content': content, + 'timestamp': timestamp.toIso8601String(), + 'isLoading': isLoading, + }; + } + + // 创建一个加载中的消息 + factory Message.loading() { + return Message( + role: 'assistant', + content: '', + timestamp: DateTime.now(), + isLoading: true, + ); + } + + // 复制并修改 + Message copyWith({ + String? role, + String? content, + DateTime? timestamp, + bool? isLoading, + }) { + return Message( + role: role ?? this.role, + content: content ?? this.content, + timestamp: timestamp ?? this.timestamp, + isLoading: isLoading ?? this.isLoading, + ); + } +} \ No newline at end of file diff --git a/lib/data/providers/agent_provider.dart b/lib/data/providers/agent_provider.dart index 6294f7a5d..405baf58a 100644 --- a/lib/data/providers/agent_provider.dart +++ b/lib/data/providers/agent_provider.dart @@ -11,6 +11,7 @@ class AgentProvider { isOnline: true, voice: "zh-CN-XiaoxiaoNeural", welcomeMessage: "你好!我是 Emma,很高兴成为你的私人助理。我可以帮你处理各种任务,比如时间管理、目标规划、信息整理等。有什么我可以帮你的吗?", + voiceWelcomeMessage: "我在呢!", systemPrompt: ''' 你是一位名叫 Emma 的全能型个人助理,擅长处理各类工作和生活事务。 @@ -52,7 +53,7 @@ class AgentProvider { - 及时承认不足并寻求改进 -请始终以专业、高效、周到的态度,协助用户更好地管理工作和生活, 不要输出表情符号。 +请始终以专业、高效、周到的态度,协助用户更好地管理工作和生活, 不要输出表情符号或非可读的符号。 '''), Agent( diff --git a/lib/data/services/bluetooth_media_button_service.dart b/lib/data/services/bluetooth_media_button_service.dart new file mode 100644 index 000000000..98864ba13 --- /dev/null +++ b/lib/data/services/bluetooth_media_button_service.dart @@ -0,0 +1,310 @@ +import 'dart:async'; +import 'dart:io' show Platform; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../core/utils/logger.dart'; +import 'voice_interaction_service.dart'; +/// 蓝牙媒体按钮事件类型 +enum MediaButtonType { + /// 播放/暂停按钮 + playPause, + + /// 未知按钮 + unknown +} + +/// 蓝牙媒体按钮事件 +class MediaButtonEvent { + /// 按钮类型 + final MediaButtonType buttonType; + + /// 事件发生时间戳 + final int timestamp; + + MediaButtonEvent({ + required this.buttonType, + required this.timestamp, + }); + + factory MediaButtonEvent.fromMap(Map map) { + MediaButtonType buttonType; + + switch (map['buttonType']) { + case 'playPause': + buttonType = MediaButtonType.playPause; + break; + default: + // 将所有其他事件类型映射为 playPause + if (map['buttonType'] == 'play' || + map['buttonType'] == 'pause' || + map['buttonType'] == 'togglePlayPause') { + buttonType = MediaButtonType.playPause; + } else { + // 忽略其他类型(next, previous, seekForward, seekBackward) + buttonType = MediaButtonType.unknown; + } + } + + return MediaButtonEvent( + buttonType: buttonType, + timestamp: map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, + ); + } + + @override + String toString() { + switch (buttonType) { + case MediaButtonType.playPause: + return '蓝牙媒体按钮: 播放/暂停'; + case MediaButtonType.unknown: + return '蓝牙媒体按钮: 未知'; + } + } +} + +/// 蓝牙媒体按钮服务 +class BluetoothMediaButtonService extends GetxService { + static BluetoothMediaButtonService get to => Get.find(); + + // 方法通道名称 + final MethodChannel _channel = const MethodChannel('com.deep_voice.bluetooth_media_button'); + + // 事件通道名称 + final EventChannel _eventChannel = const EventChannel('com.deep_voice.bluetooth_media_button_events'); + + /// 按钮事件流控制器 + final _buttonEventController = StreamController.broadcast(); + + /// 按钮事件流 + Stream get buttonEvents => _buttonEventController.stream; + + /// 是否已启用监听 + final _isListening = false.obs; + bool get isListening => _isListening.value; + + /// 是否正在加载 + final isLoading = false.obs; + + /// 错误信息 + final errorMessage = ''.obs; + + /// 按钮事件流订阅 + StreamSubscription? _buttonEventSubscription; + + /// 服务是否已初始化 + bool _isInitialized = false; + + /// 是否支持媒体按钮服务(iOS 13.0及以上) + bool get isSupported { + if (!Platform.isIOS) return true; // 非iOS平台默认支持 + + // 获取平台版本信息 + final String version = Platform.operatingSystemVersion; + + // 尝试提取iOS版本号 (格式如: "Version 14.5 (Build 18E182)") + try { + // 提取版本号 + final RegExp versionRegex = RegExp(r'Version\s+(\d+)\.(\d+)'); + final match = versionRegex.firstMatch(version); + + if (match != null) { + final int majorVersion = int.parse(match.group(1) ?? '0'); + return majorVersion >= 13; // iOS 13及以上支持 + } + } catch (e) { + Logger.error('解析iOS版本失败: $e'); + } + + // 无法确定版本时,假设不支持 + return false; + } + + /// 初始化服务 + BluetoothMediaButtonService() { + Logger.info('创建 BluetoothMediaButtonService 实例'); + } + + /// 初始化媒体按钮服务 + Future initialize() async { + if (_isInitialized) { + Logger.info('BluetoothMediaButtonService 已经初始化'); + return true; + } + + // 检查系统版本是否支持 + if (!isSupported) { + Logger.info('当前iOS版本不支持蓝牙媒体按钮功能(需要iOS 13.0+)'); + return false; + } + + try { + Logger.info('正在初始化 BluetoothMediaButtonService...'); + + // 设置事件监听器 + _setupButtonEventListener(); + + _isInitialized = true; + Logger.info('BluetoothMediaButtonService 初始化成功'); + return true; + } catch (e) { + Logger.error('初始化 BluetoothMediaButtonService 失败: $e'); + errorMessage.value = '初始化蓝牙媒体按钮服务失败'; + return false; + } + } + + /// 设置按钮事件监听器 + void _setupButtonEventListener() { + _buttonEventSubscription?.cancel(); + + // 设置事件通道监听器 + _buttonEventSubscription = _eventChannel + .receiveBroadcastStream() + .listen(_handleButtonEvent, onError: _handleEventStreamError); + + Logger.info('已设置蓝牙媒体按钮事件监听器'); + } + + /// 处理按钮事件 + void _handleButtonEvent(dynamic event) { + Logger.info('BluetoothMediaButtonService 收到原始事件: $event'); + + if (event is! Map) { + Logger.error('收到无效的蓝牙媒体按钮事件 (非Map类型): $event'); + return; + } + + final Map eventMap = event; + + try { + if (eventMap['type'] == 'mediaButtonEvent') { + // 获取按钮类型 + final String buttonType = eventMap['buttonType'] as String? ?? 'unknown'; + + // 判断是否为playPause相关事件 + if (buttonType == 'playPause' || + buttonType == 'play' || + buttonType == 'pause' || + buttonType == 'togglePlayPause') { + + // 创建playPause事件并广播 + final buttonEvent = MediaButtonEvent( + buttonType: MediaButtonType.playPause, + timestamp: eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, + ); + + + + // 广播事件 + _buttonEventController.add(buttonEvent); + + // 广播事件 + _buttonEventController.add(buttonEvent); + Logger.info('发送playPause按钮事件: $buttonEvent'); + } else { + // 忽略其他类型的按钮事件 + Logger.info('忽略非playPause按钮事件: $buttonType'); + } + } else { + Logger.warning('收到未知类型的事件: ${eventMap['type']}'); + } + } catch (e) { + Logger.error('处理蓝牙媒体按钮事件时出错: $e'); + } + } + + /// 处理事件流错误 + void _handleEventStreamError(dynamic error) { + Logger.error('蓝牙媒体按钮事件流错误: $error'); + errorMessage.value = '蓝牙媒体按钮监听出错'; + } + + /// 开始监听蓝牙媒体按钮事件 + Future startButtonListening() async { + // 检查系统版本是否支持 + if (!isSupported) { + Logger.info('当前iOS版本不支持蓝牙媒体按钮功能(需要iOS 13.0+)'); + return false; + } + + if (!_isInitialized && !(await initialize())) { + return false; + } + + if (_isListening.value) { + Logger.info('已经在监听蓝牙媒体按钮事件'); + return true; + } + + isLoading.value = true; + + try { + final result = await _channel.invokeMethod('startButtonListening') ?? false; + + if (result) { + _isListening.value = true; + Logger.info('开始监听蓝牙媒体按钮事件成功'); + } else { + errorMessage.value = '开始监听蓝牙媒体按钮事件失败'; + Logger.error('开始监听蓝牙媒体按钮事件失败'); + } + + isLoading.value = false; + return result; + } catch (e) { + isLoading.value = false; + errorMessage.value = '开始监听蓝牙媒体按钮事件出错: $e'; + Logger.error('开始监听蓝牙媒体按钮事件出错: $e'); + return false; + } + } + + /// 停止监听蓝牙媒体按钮事件 + Future stopButtonListening() async { + if (!_isInitialized) { + return false; + } + + if (!_isListening.value) { + return true; + } + + isLoading.value = true; + + try { + final result = await _channel.invokeMethod('stopButtonListening') ?? false; + + if (result) { + _isListening.value = false; + Logger.info('停止监听蓝牙媒体按钮事件成功'); + } else { + errorMessage.value = '停止监听蓝牙媒体按钮事件失败'; + Logger.error('停止监听蓝牙媒体按钮事件失败'); + } + + isLoading.value = false; + return result; + } catch (e) { + isLoading.value = false; + errorMessage.value = '停止监听蓝牙媒体按钮事件出错: $e'; + Logger.error('停止监听蓝牙媒体按钮事件出错: $e'); + return false; + } + } + + /// 释放资源 + void dispose() { + _buttonEventSubscription?.cancel(); + _buttonEventSubscription = null; + _buttonEventController.close(); + _isInitialized = false; + _isListening.value = false; + Logger.info('BluetoothMediaButtonService 已释放'); + } + + @override + void onClose() { + dispose(); + super.onClose(); + } +} \ No newline at end of file diff --git a/lib/data/services/chat_history_service.dart b/lib/data/services/chat_history_service.dart index 6647a6b41..8211075fc 100644 --- a/lib/data/services/chat_history_service.dart +++ b/lib/data/services/chat_history_service.dart @@ -10,7 +10,7 @@ class ChatHistoryService extends GetxService { // 存储键名 static const String _historyKeyPrefix = 'chat_history_'; - static const int _maxHistoryCount = 20; // 每个对话最多保存的消息数 + static const int _maxHistoryCount = 10; // 每个对话最多保存的消息数 @override void onInit() { @@ -41,7 +41,7 @@ class ChatHistoryService extends GetxService { // 保存到存储 await _storage.write(_getHistoryKey(agentId), jsonString); - Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); + // Logger.info('已保存聊天记录,代理: $agentId,消息数: ${messagesToSave.length}'); } catch (e) { Logger.error('保存聊天记录失败', e); diff --git a/lib/data/services/classic_bluetooth_service.dart b/lib/data/services/classic_bluetooth_service.dart index 3ed682e6b..1bd4e7363 100644 --- a/lib/data/services/classic_bluetooth_service.dart +++ b/lib/data/services/classic_bluetooth_service.dart @@ -2,8 +2,8 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:permission_handler/permission_handler.dart'; -import 'package:app_settings/app_settings.dart'; import '../../core/utils/logger.dart'; +import 'dart:io'; /// 蓝牙设备信息 class BluetoothDevice { @@ -11,434 +11,233 @@ class BluetoothDevice { final String address; final String type; - BluetoothDevice({ - required this.name, - required this.address, - required this.type - }); + BluetoothDevice( + {required this.name, required this.address, required this.type}); @override - String toString() => 'BluetoothDevice(name: $name, address: $address, type: $type)'; -} - -/// 蓝牙事件类型 -enum BluetoothEventType { - /// 设备已连接 - deviceConnected, - - /// 设备已断开 - deviceDisconnected, - - /// 蓝牙状态改变(开启/关闭) - bluetoothStateChanged -} - -/// 蓝牙连接状态事件 -class BluetoothConnectionEvent { - /// 事件类型 - final BluetoothEventType type; - - /// 相关设备(可能为空,比如蓝牙状态改变事件) - final BluetoothDevice? device; - - /// 蓝牙状态(仅适用于bluetoothStateChanged事件) - final String? state; - - /// 事件发生时间戳 - final int timestamp; - - BluetoothConnectionEvent({ - required this.type, - this.device, - this.state, - required this.timestamp, - }); - - factory BluetoothConnectionEvent.fromMap(Map map) { - BluetoothEventType eventType; - - switch (map['type']) { - case 'deviceConnected': - eventType = BluetoothEventType.deviceConnected; - break; - case 'deviceDisconnected': - eventType = BluetoothEventType.deviceDisconnected; - break; - case 'bluetoothStateChanged': - eventType = BluetoothEventType.bluetoothStateChanged; - break; - default: - throw ArgumentError('未知事件类型: ${map['type']}'); - } - - final deviceMap = map['device'] as Map?; - final BluetoothDevice? device = deviceMap != null - ? BluetoothDevice( - name: deviceMap['name'] ?? '未知设备', - address: deviceMap['address'] ?? '', - type: deviceMap['type'] ?? 'unknown' - ) - : null; - - return BluetoothConnectionEvent( - type: eventType, - device: device, - state: map['state'] as String?, - timestamp: map['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch, - ); - } - - @override - String toString() { - switch (type) { - case BluetoothEventType.deviceConnected: - return '蓝牙设备已连接: ${device?.name}'; - case BluetoothEventType.deviceDisconnected: - return '蓝牙设备已断开: ${device?.name}'; - case BluetoothEventType.bluetoothStateChanged: - return '蓝牙状态改变: $state'; - } - } + String toString() => + 'BluetoothDevice(name: $name, address: $address, type: $type)'; } /// 经典蓝牙服务 class ClassicBluetoothService extends GetxService { static ClassicBluetoothService get to => Get.find(); - // 注意: MainActivity.kt中定义的通道名称为 com.deep_voice.classic_bluetooth - final MethodChannel _channel = const MethodChannel('com.deep_voice.classic_bluetooth'); - final EventChannel _eventChannel = const EventChannel('com.deep_voice.classic_bluetooth_events'); - - /// 蓝牙事件流控制器 - final _connectionEventController = StreamController.broadcast(); - - /// 蓝牙事件流 - Stream get connectionEvents => _connectionEventController.stream; - + // 通道名称 + final MethodChannel _channel = + const MethodChannel('com.deep_voice.classic_bluetooth'); + final EventChannel _eventChannel = + const EventChannel('com.deep_voice.classic_bluetooth_events'); + /// 当前连接的设备 final currentDevice = Rx(null); - - /// 已连接设备列表 + + /// 已连接耳机设备列表 final connectedDevices = [].obs; - - /// 蓝牙是否可用 - final _isBluetoothEnabled = true.obs; - bool get isBluetoothEnabled => _isBluetoothEnabled.value; - - /// 是否正在加载 - final isLoading = false.obs; - - /// 错误信息 - final errorMessage = ''.obs; - - /// 蓝牙状态变化事件流 + + /// 蓝牙设备状态监听 StreamSubscription? _bluetoothStateListener; - - /// 设备连接回调 - final List _deviceConnectedCallbacks = []; - - /// 设备断开回调 - final List _deviceDisconnectedCallbacks = []; - - /// 蓝牙状态变化回调 - final List _bluetoothStateChangedCallbacks = []; - + + /// 蓝牙开关状态 + final bluetoothState = 'unknown'.obs; + + /// 蓝牙是否可用 + bool get isBluetoothOn => bluetoothState.value == 'on'; + /// 服务是否已初始化 bool _isInitialized = false; - ClassicBluetoothService() { - // 创建服务时不立即初始化,避免还没有原生支持时就调用方法 - Logger.info('创建 ClassicBluetoothService 实例'); - } - - /// 初始化蓝牙服务 - /// 必须在使用任何方法前调用此方法确保通道已设置 + /// 初始化蓝牙服务 - 单一入口点 Future initialize() async { if (_isInitialized) { - Logger.info('ClassicBluetoothService 已经初始化'); + Logger.info('蓝牙服务已经初始化,跳过'); return true; } - + try { - Logger.info('正在初始化 ClassicBluetoothService...'); - - - - // 设置事件监听器 + Logger.info('正在初始化蓝牙服务...'); + + // 获取当前蓝牙状态 + final bool isEnabled = + await _channel.invokeMethod('isBluetoothEnabled') ?? false; + bluetoothState.value = isEnabled ? 'on' : 'off'; + Logger.info('初始蓝牙状态: ${bluetoothState.value}'); + + await _channel.invokeMethod('initialize'); + + // 设置事件监听 _setupBluetoothStateListener(); - + _isInitialized = true; - Logger.info('ClassicBluetoothService 初始化成功'); + Logger.info('蓝牙服务初始化成功'); return true; } catch (e) { - Logger.error('初始化 ClassicBluetoothService 失败: $e'); - errorMessage.value = '初始化蓝牙服务失败'; + Logger.error('初始化蓝牙服务失败: $e'); return false; } } - - /// 检查并请求蓝牙权限 - Future checkAndRequestPermissions() async { - try { - // 首先确保服务已初始化 - if (!_isInitialized && !(await initialize())) { - Logger.error('无法初始化蓝牙服务'); - errorMessage.value = '无法初始化蓝牙服务'; - return false; - } - - // 检查蓝牙相关权限 - Map statuses = await [ - Permission.bluetooth, - Permission.bluetoothConnect, - Permission.bluetoothScan, - Permission.location, - Permission.microphone, - ].request(); - - // 检查蓝牙连接权限 - if (statuses[Permission.bluetoothConnect] != PermissionStatus.granted) { - Logger.error('蓝牙连接权限被拒绝'); - errorMessage.value = '蓝牙连接权限被拒绝'; - return false; - } - - return true; - } catch (e) { - Logger.error('检查蓝牙权限失败: $e'); - errorMessage.value = '检查蓝牙权限失败'; - return false; - } + + /// 检查蓝牙权限 + Future checkPermissions() async { + // 检查蓝牙相关权限 + Map statuses = await [ + Permission.bluetooth, + Permission.bluetoothConnect, + Permission.bluetoothScan, + Permission.microphone, + ].request(); + + return statuses[Permission.bluetoothConnect] == PermissionStatus.granted; } - - /// 刷新已连接的蓝牙设备 + + /// 刷新已连接的蓝牙耳机设备 Future refreshConnectedDevices() async { + // 确保服务已初始化 + if (!_isInitialized && !(await initialize())) { + return false; + } + + // 如果蓝牙未开启,无法获取设备 + if (!isBluetoothOn) { + Logger.info('蓝牙未开启,无法获取设备'); + return false; + } + try { - // 首先确保服务已初始化 - if (!_isInitialized && !(await initialize())) { - Logger.error('无法初始化蓝牙服务'); - errorMessage.value = '无法初始化蓝牙服务'; - return false; - } - - errorMessage.value = ''; - - // 获取A2DP设备 - final a2dpDevices = await getConnectedA2dpDevices(); - Logger.info('已连接的A2DP设备: ${a2dpDevices.length}'); - - // 获取耳机设备 - final headsetDevices = await getConnectedHeadsetDevices(); - Logger.info('已连接的耳机设备: ${headsetDevices.length}'); - + // 只获取耳机设备 + final devices = await getConnectedHeadsetDevices(); + // 更新设备列表 - connectedDevices.value = [...a2dpDevices, ...headsetDevices]; - + connectedDevices.value = devices; + // 更新当前设备 if (connectedDevices.isNotEmpty && currentDevice.value == null) { currentDevice.value = connectedDevices.first; } - + return connectedDevices.isNotEmpty; } catch (e) { Logger.error('刷新已连接设备失败: $e'); - errorMessage.value = '获取设备信息失败'; return false; } } - - /// 获取已连接的A2DP设备(音频设备) - Future> getConnectedA2dpDevices() async { - try { - // 确保服务已初始化 - if (!_isInitialized && !(await initialize())) { - Logger.error('无法初始化蓝牙服务'); - return []; - } - - final result = await _channel.invokeMethod('getConnectedA2dpDevices'); - Logger.info('获取已连接的A2DP设备结果: $result'); - - if (result is List) { - final devices = result.map((device) => BluetoothDevice( - name: device['name'] ?? '未知设备', - address: device['address'] ?? '', - type: 'a2dp' - )).toList(); - - return devices; - } - - return []; - } catch (e) { - Logger.error('获取已连接的A2DP设备失败: $e'); - return []; - } - } - + /// 获取已连接的耳机设备 Future> getConnectedHeadsetDevices() async { try { // 确保服务已初始化 if (!_isInitialized && !(await initialize())) { - Logger.error('无法初始化蓝牙服务'); return []; } - + final result = await _channel.invokeMethod('getConnectedHeadsetDevices'); - Logger.info('获取已连接的耳机设备结果: $result'); - + if (result is List) { - final devices = result.map((device) => BluetoothDevice( - name: device['name'] ?? '未知设备', - address: device['address'] ?? '', - type: 'headset' - )).toList(); - - return devices; + return result + .map((device) => BluetoothDevice( + name: device['name'] ?? '未知设备', + address: device['address'] ?? '', + type: 'headset')) + .toList(); } - + return []; } catch (e) { Logger.error('获取已连接的耳机设备失败: $e'); return []; } } - + /// 设置蓝牙状态监听 void _setupBluetoothStateListener() { - try { - _bluetoothStateListener = _eventChannel - .receiveBroadcastStream() - .listen((event) { - Logger.info('收到蓝牙事件: $event'); - - if (event is Map) { - final type = event['type'] as String?; - - if (type == 'bluetoothStateChanged') { - final state = event['state'] as String?; - if (state != null) { - _handleBluetoothStateChange(state); - } - } else if (type == 'deviceConnected') { - final deviceMap = event['device'] as Map?; - if (deviceMap != null) { - final device = BluetoothDevice( - name: deviceMap['name'] ?? '未知设备', - address: deviceMap['address'] ?? '', - type: deviceMap['type'] ?? 'unknown' - ); - _handleDeviceConnected(device); - } - } else if (type == 'deviceDisconnected') { - final deviceMap = event['device'] as Map?; - if (deviceMap != null) { - final device = BluetoothDevice( - name: deviceMap['name'] ?? '未知设备', - address: deviceMap['address'] ?? '', - type: deviceMap['type'] ?? 'unknown' - ); - _handleDeviceDisconnected(device); - } - } + _bluetoothStateListener?.cancel(); + + _bluetoothStateListener = + _eventChannel.receiveBroadcastStream().listen((event) { + if (event is Map) { + Logger.info('蓝牙状态监听: $event'); + final type = event['type'] as String?; + + if (type == 'bluetoothStateChanged') { + final state = event['state'] as String?; + if (state != null) { + _handleBluetoothStateChange(state); } - }, onError: (error) { - Logger.error('蓝牙状态监听错误: $error'); - }); - } catch (e) { - Logger.error('设置蓝牙状态监听失败: $e'); + } else if (type == 'deviceConnected') { + _handleDeviceConnected(event); + } else if (type == 'deviceDisconnected') { + _handleDeviceDisconnected(event); + } + } + }, onError: (error) { + Logger.error('蓝牙状态监听错误: $error'); + }); + } + + /// 处理设备连接事件 + void _handleDeviceConnected(Map event) { + final deviceMap = event['device'] as Map?; + if (deviceMap != null) { + final device = BluetoothDevice( + name: deviceMap['name'] ?? '未知设备', + address: deviceMap['address'] ?? '', + type: deviceMap['type'] ?? 'unknown'); + + // 更新设备列表 + if (!connectedDevices.any((d) => d.address == device.address)) { + connectedDevices.add(device); + } + + // 更新当前设备 + currentDevice.value = device; + + Logger.info('设备已连接: ${device.name}'); } } - + + /// 处理设备断开事件 + void _handleDeviceDisconnected(Map event) { + final deviceMap = event['device'] as Map?; + if (deviceMap != null) { + final address = deviceMap['address'] as String?; + + if (address != null) { + // 更新设备列表 + connectedDevices.removeWhere((d) => d.address == address); + + // 如果是当前设备,清除当前设备 + if (currentDevice.value?.address == address) { + currentDevice.value = + connectedDevices.isEmpty ? null : connectedDevices.first; + Logger.info('当前设备已断开连接'); + } + } + } + } + /// 处理蓝牙状态变化 void _handleBluetoothStateChange(String state) { Logger.info('----蓝牙状态变化: $state'); - - // 如果蓝牙关闭,清除设备信息 + + // 更新蓝牙状态 + bluetoothState.value = state; + if (state == 'off') { + // 蓝牙关闭,清空设备信息 connectedDevices.clear(); currentDevice.value = null; - } - - // 通知所有回调 - for (var callback in _bluetoothStateChangedCallbacks) { - callback(state); + } else if (state == 'on') { + // 蓝牙重新打开,延迟一小段时间后刷新设备 + Future.delayed(const Duration(milliseconds: 1000), () { + refreshConnectedDevices(); + }); } } - - /// 处理设备连接 - void _handleDeviceConnected(BluetoothDevice device) { - Logger.info('设备已连接: ${device.name}'); - - // 更新设备列表 - if (!connectedDevices.any((d) => d.address == device.address)) { - connectedDevices.add(device); - } - - // 更新当前设备 - currentDevice.value = device; - - // 通知所有回调 - for (var callback in _deviceConnectedCallbacks) { - callback(device); - } - } - - /// 处理设备断开 - void _handleDeviceDisconnected(BluetoothDevice device) { - Logger.info('设备已断开: ${device.name}'); - - // 更新设备列表 - connectedDevices.removeWhere((d) => d.address == device.address); - - // 如果是当前设备,清除当前设备 - if (currentDevice.value?.address == device.address) { - currentDevice.value = connectedDevices.isEmpty ? null : connectedDevices.first; - } - - // 通知所有回调 - for (var callback in _deviceDisconnectedCallbacks) { - callback(device); - } - } - - /// 注册设备连接回调 - void onDeviceConnected(Function(BluetoothDevice) callback) { - _deviceConnectedCallbacks.add(callback); - } - - /// 注册设备断开回调 - void onDeviceDisconnected(Function(BluetoothDevice) callback) { - _deviceDisconnectedCallbacks.add(callback); - } - - /// 注册蓝牙状态变化回调 - void onBluetoothStateChanged(Function(String) callback) { - _bluetoothStateChangedCallbacks.add(callback); - } - - /// 打开蓝牙设置 - Future openBluetoothSettings() async { - try { - await AppSettings.openAppSettings(type: AppSettingsType.bluetooth); - return true; - } catch (e) { - Logger.error('打开蓝牙设置失败: $e'); - return false; - } - } - + @override void onClose() { // 取消蓝牙状态监听 _bluetoothStateListener?.cancel(); - - // 清除回调 - _deviceConnectedCallbacks.clear(); - _deviceDisconnectedCallbacks.clear(); - _bluetoothStateChangedCallbacks.clear(); - super.onClose(); } -} \ No newline at end of file +} diff --git a/lib/data/services/flutter_tts_service.dart b/lib/data/services/flutter_tts_service.dart deleted file mode 100644 index 61de220ee..000000000 --- a/lib/data/services/flutter_tts_service.dart +++ /dev/null @@ -1,3 +0,0 @@ -// 重新导出FlutterTtsService类 -// 这个文件作为兼容层,将speech_impl/flutter_tts_service.dart中的服务导出 -export 'speech_impl/flutter_tts_service.dart'; \ No newline at end of file diff --git a/lib/data/services/speech_factory.dart b/lib/data/services/speech_factory.dart index a6f1e4d50..b9c1cad0e 100644 --- a/lib/data/services/speech_factory.dart +++ b/lib/data/services/speech_factory.dart @@ -1,70 +1,88 @@ import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; import 'asr_service.dart'; +import 'speech_impl/voice_clone_tts_service.dart'; import 'tts_service.dart'; import 'speech_impl/azure_asr_service.dart'; import 'speech_impl/azure_tts_service.dart'; import 'speech_impl/flutter_asr_service.dart'; import 'speech_impl/flutter_tts_service.dart'; + import '../../core/utils/logger.dart'; /// 语音服务类型 enum SpeechServiceType { /// Azure 语音服务 azure, - + /// Flutter 本地语音服务 flutter, } /// 语音服务工厂类 -/// +/// /// 用于根据配置信息获取不同的TTS和ASR实现 class SpeechFactory extends GetxService { static SpeechFactory get to => Get.find(); - /// 当前使用的语音服务类型 SpeechServiceType _currentServiceType = SpeechServiceType.azure; - + /// 获取当前使用的语音服务类型 SpeechServiceType get currentServiceType => _currentServiceType; - + /// 设置语音服务类型 void setServiceType(SpeechServiceType type) { if (_currentServiceType != type) { _currentServiceType = type; Logger.info('语音服务类型已切换为: ${type.toString().split('.').last}'); - + // 重新注册服务 _registerServices(); } } - + + /// 设置语音音色服务类型 + void setTimbreServiceType() { + // 重新注册服务 + _registerServices(); + } + /// 注册语音服务 void _registerServices() { // 注销现有服务 if (Get.isRegistered()) { Get.delete(force: true); } - + if (Get.isRegistered()) { Get.delete(force: true); } - + + final GetStorage storage = GetStorage(); + String timbre = storage.read('timbre') ?? '1'; + // 根据类型注册新服务 switch (_currentServiceType) { case SpeechServiceType.azure: Get.lazyPut(() => AzureAsrService()); - Get.lazyPut(() => AzureTtsService()); + if (timbre == '1') { + Get.lazyPut(() => AzureTtsService()); + } else { + Get.lazyPut(() => VoiceCloneTtsService()); + } break; - case SpeechServiceType.flutter: Get.lazyPut(() => FlutterAsrService()); - Get.lazyPut(() => FlutterTtsService()); + if (timbre == '1') { + Get.lazyPut(() => FlutterTtsService()); + } else { + Get.lazyPut(() => VoiceCloneTtsService()); + } break; } } - + /// 初始化语音服务 Future initialize({SpeechServiceType? initialType}) async { try { @@ -72,24 +90,25 @@ class SpeechFactory extends GetxService { if (initialType != null) { _currentServiceType = initialType; } - + // 注册服务 _registerServices(); - + // // 初始化服务 // final asrService = Get.find(); // final ttsService = Get.find(); - + // await asrService.initialize(); // await ttsService.initialize(); - - Logger.info('语音服务初始化完成,当前类型: ${_currentServiceType.toString().split('.').last}'); + + Logger.info( + '语音服务初始化完成,当前类型: ${_currentServiceType.toString().split('.').last}'); } catch (e) { Logger.error('语音服务初始化失败: $e'); rethrow; } } - + /// 获取ASR服务 AsrService getAsrService() { if (!Get.isRegistered()) { @@ -97,7 +116,7 @@ class SpeechFactory extends GetxService { } return Get.find(); } - + /// 获取TTS服务 TtsService getTtsService() { if (!Get.isRegistered()) { @@ -105,4 +124,4 @@ class SpeechFactory extends GetxService { } return Get.find(); } -} \ No newline at end of file +} diff --git a/lib/data/services/speech_impl/azure_asr_service.dart b/lib/data/services/speech_impl/azure_asr_service.dart index 95f01bddd..ffb67ed46 100644 --- a/lib/data/services/speech_impl/azure_asr_service.dart +++ b/lib/data/services/speech_impl/azure_asr_service.dart @@ -17,19 +17,15 @@ class AzureAsrService extends GetxService implements AsrService { late final String _subscriptionKey; late final String _serviceRegion; - final List _supportedLanguages = ['zh-CN']; + final List _defaultSupportedLanguages = ['zh-CN']; @override - List get supportedLanguages => _supportedLanguages; + List get supportedLanguages => _defaultSupportedLanguages; // 连续识别相关 bool _isContinuousRecognitionActive = false; StreamController? _eventStreamController; StreamSubscription? _eventSubscription; - // 事件通道状态 - bool _isEventChannelReady = false; - Completer? _eventChannelReadyCompleter; - // 最新的识别结果 String _latestRecognizedText = ''; String get latestRecognizedText => _latestRecognizedText; @@ -40,7 +36,6 @@ class AzureAsrService extends GetxService implements AsrService { AzureAsrService() { _loadConfig(); - _setupEventChannel(); } /// 从环境变量加载配置 @@ -55,50 +50,21 @@ class AzureAsrService extends GetxService implements AsrService { /// 设置事件通道 void _setupEventChannel() { - _eventChannelReadyCompleter = Completer(); - _eventSubscription = _eventChannel .receiveBroadcastStream() .listen((event) { if (event is Map) { - final String eventType = event['type'] as String? ?? ''; - - // 处理通道准备好的事件 - if (eventType == 'channelReady') { - _isEventChannelReady = true; - if (!_eventChannelReadyCompleter!.isCompleted) { - _eventChannelReadyCompleter!.complete(); - } - return; - } - _handleRecognitionEvent(event); } }, onError: _handleRecognitionError); } - /// 等待事件通道准备好 - Future _waitForEventChannel({Duration timeout = const Duration(seconds: 5)}) async { - if (_isEventChannelReady) return true; - - try { - await _eventChannelReadyCompleter!.future.timeout(timeout); - return true; - } on TimeoutException { - Logger.error('等待事件通道准备好超时'); - return false; - } - } - @override Future initialize({ List? supportedLanguages, }) async { try { - if (supportedLanguages != null && supportedLanguages.isNotEmpty) { - _supportedLanguages.clear(); - _supportedLanguages.addAll(supportedLanguages); - } + final List languages = supportedLanguages ?? _defaultSupportedLanguages; // 检查是否需要重新初始化 if (_isInitialized) { @@ -108,9 +74,10 @@ class AzureAsrService extends GetxService implements AsrService { final bool result = await _channel.invokeMethod('initialize', { 'subscriptionKey': _subscriptionKey, 'region': _serviceRegion, - 'supportedLanguages': _supportedLanguages, + 'supportedLanguages': languages, }); - + _setupEventChannel(); + _isInitialized = result; Logger.info('Azure 语音识别服务初始化${result ? '成功' : '失败'}'); return result; @@ -159,11 +126,6 @@ class AzureAsrService extends GetxService implements AsrService { await initialize(); } - // 确保事件通道已准备好 - if (!await _waitForEventChannel()) { - throw Exception('事件通道未准备好,无法开始连续识别'); - } - if (_isContinuousRecognitionActive) { await stopContinuousRecognition(); } @@ -217,6 +179,9 @@ class AzureAsrService extends GetxService implements AsrService { final Map eventMap = event; final String eventType = eventMap['type'] as String? ?? ''; + // 添加日志帮助调试 + Logger.debug('收到语音识别事件: $eventType, 数据: $eventMap'); + switch (eventType) { case 'result': final String text = eventMap['text'] as String? ?? ''; diff --git a/lib/data/services/speech_impl/azure_tts_service.dart b/lib/data/services/speech_impl/azure_tts_service.dart index 73c182cf8..ae5ab9369 100644 --- a/lib/data/services/speech_impl/azure_tts_service.dart +++ b/lib/data/services/speech_impl/azure_tts_service.dart @@ -1,25 +1,14 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import '../../../core/utils/logger.dart'; import '../tts_service.dart'; -/// 音频输出设备类型 -enum AudioOutputType { - /// 扬声器 - speaker, - - /// 听筒 - earpiece, - - /// 自动选择(有耳机用耳机,没有则用听筒) - auto -} - /// 微软 Text-to-Speech 服务 /// -/// 该服务通过平台通道与 Android 上的 Microsoft Speech SDK 交互, +/// 该服务通过平台通道与原生 Microsoft Speech SDK 交互, /// 提供文本转语音功能。 class AzureTtsService extends GetxService implements TtsService { static final AzureTtsService to = Get.put(AzureTtsService()); @@ -34,7 +23,6 @@ class AzureTtsService extends GetxService implements TtsService { int _currentRate = 0; int _currentPitch = 0; int _currentVolume = 100; - AudioOutputType _currentAudioOutputType = AudioOutputType.auto; // 支持的语音列表 final List _supportedVoices = [ @@ -49,29 +37,40 @@ class AzureTtsService extends GetxService implements TtsService { final List<_SpeechItem> _textQueue = []; bool _isProcessingQueue = false; - // 事件流控制器 - final StreamController _eventController = StreamController.broadcast(); - @override - Stream get onEvent => _eventController.stream; - // 可观察状态 final isEnabled = true.obs; - final _isSpeaking = false.obs; - final audioOutputType = AudioOutputType.auto.obs; // 流式文本缓冲区 String _streamBuffer = ''; + /// 缓存的播放状态 + bool _speakingCache = false; + DateTime _lastSpeakingCheck = DateTime.now(); + @override String get currentVoice => _currentVoice; @override - bool get isSpeaking => _isSpeaking.value; + bool get isSpeaking { + // 如果未初始化,直接返回false + if (!_isInitialized) return false; + + // 如果距离上次检查不到50ms,则使用缓存值 + if (DateTime.now().difference(_lastSpeakingCheck).inMilliseconds < 50) { + return _speakingCache; + } + + // 否则异步更新缓存(但仍返回当前缓存值) + _checkSpeakingStatus(); + return _speakingCache; + } + + @override + Stream get onEvent => const Stream.empty(); @override List get supportedVoices => _supportedVoices; - AudioOutputType get currentAudioOutputType => _currentAudioOutputType; AzureTtsService() { _loadConfig(); @@ -87,65 +86,35 @@ class AzureTtsService extends GetxService implements TtsService { Future initialize({ List? supportedLanguages, }) async { - if (_isInitialized) return true; - + // 检查是否需要重新初始化 + if (_isInitialized) { + await dispose(); + } + try { if (_subscriptionKey.isEmpty || _serviceRegion.isEmpty) { Logger.error('未找到Azure语音服务配置'); return false; } - // 始终优先使用中文作为默认语言 - final String defaultLanguage = 'zh-CN'; + // 更新支持的语音列表(如果提供) - if (supportedLanguages != null && supportedLanguages.isNotEmpty) { - // 如果支持中文,优先使用中文,否则使用提供的第一种语言 - String language = supportedLanguages.contains(defaultLanguage) - ? defaultLanguage - : supportedLanguages.first; + final String language = supportedLanguages?.first ?? 'zh-CN'; + final result = await _channel.invokeMethod('initialize', { 'subscriptionKey': _subscriptionKey, 'region': _serviceRegion, 'language': language, }); + - _isInitialized = result; - - // 确保设置默认的音频输出设备类型 - if (result) { - // 不需要显式调用 setAudioOutputType,因为原生层已经设置了默认值 - // 只需要确保 Dart 层的状态与原生层一致 - _currentAudioOutputType = AudioOutputType.auto; - audioOutputType.value = AudioOutputType.auto; - } - - return result; - } else { - // 使用默认语言初始化 - final result = await _channel.invokeMethod('initialize', { - 'subscriptionKey': _subscriptionKey, - 'region': _serviceRegion, - 'language': 'zh-CN', - }); - - _isInitialized = result; - - // 确保设置默认的音频输出设备类型 - if (result) { - _currentAudioOutputType = AudioOutputType.auto; - audioOutputType.value = AudioOutputType.auto; - } - + _isInitialized = true; return result; - } + } catch (e) { Logger.error('初始化失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('TTS初始化失败: $e')); - return false; } } @@ -164,36 +133,6 @@ class AzureTtsService extends GetxService implements TtsService { return result; } catch (e) { Logger.error('设置语音失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('设置语音失败: $e')); - - return false; - } - } - - /// 设置音频输出设备类型 - Future setAudioOutputType(AudioOutputType outputType) async { - if (!_isInitialized) await initialize(); - - try { - final String outputTypeStr = outputType.toString().split('.').last; - final result = await _channel.invokeMethod('setAudioOutputType', { - 'outputType': outputTypeStr, - }); - - if (result) { - _currentAudioOutputType = outputType; - audioOutputType.value = outputType; - } - - return result; - } catch (e) { - Logger.error('设置音频输出设备失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('设置音频输出设备失败: $e')); - return false; } } @@ -218,10 +157,6 @@ class AzureTtsService extends GetxService implements TtsService { return result; } catch (e) { Logger.error('设置语音参数失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('设置语音参数失败: $e')); - return false; } } @@ -232,26 +167,19 @@ class AzureTtsService extends GetxService implements TtsService { if (!isEnabled.value || text.isEmpty) return false; try { - _isSpeaking.value = true; - - // 发送开始事件 - _eventController.add(TtsEvent(type: TtsEventType.started)); + // 主动设置缓存状态为true + _speakingCache = true; final result = await _channel.invokeMethod('speakText', {'text': text}); - _isSpeaking.value = false; - - // 发送完成事件 - _eventController.add(TtsEvent(type: TtsEventType.completed)); + // 调用完成后主动检查状态 + _checkSpeakingStatus(); return result == "OK"; } catch (e) { - _isSpeaking.value = false; - Logger.error('语音合成失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('语音合成失败: $e')); - + // 发生错误时重置状态 + _speakingCache = false; + Logger.error('1语音合成失败: $e'); return false; } } @@ -262,30 +190,44 @@ class AzureTtsService extends GetxService implements TtsService { if (!isEnabled.value || ssml.isEmpty) return false; try { - _isSpeaking.value = true; - - // 发送开始事件 - _eventController.add(TtsEvent(type: TtsEventType.started)); + // 主动设置缓存状态为true + _speakingCache = true; final result = await _channel.invokeMethod('speakSsml', {'ssml': ssml}); - _isSpeaking.value = false; - - // 发送完成事件 - _eventController.add(TtsEvent(type: TtsEventType.completed)); + // 调用完成后主动检查状态 + _checkSpeakingStatus(); return result == "OK"; } catch (e) { - _isSpeaking.value = false; + // 发生错误时重置状态 + _speakingCache = false; Logger.error('SSML语音合成失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('SSML语音合成失败: $e')); - return false; } } + /// 等待语音播放完成 + Future waitForSpeechCompletion({Duration timeout = const Duration(seconds: 30)}) async { + // 首先检查当前状态 + _checkSpeakingStatus(); + if (!_speakingCache) return; + + final stopwatch = Stopwatch()..start(); + + while (_speakingCache && stopwatch.elapsed < timeout) { + // 短暂等待后再次检查 + await Future.delayed(const Duration(milliseconds: 100)); + // 检查状态 + await _channel.invokeMethod('isSpeaking').then((result) { + if (result != null) { + _speakingCache = result; + _lastSpeakingCheck = DateTime.now(); + } + }); + } + } + @override Future speakStream(String text) async { if (!isEnabled.value || text.isEmpty) return false; @@ -343,10 +285,6 @@ class AzureTtsService extends GetxService implements TtsService { return true; } catch (e) { Logger.error('处理流式文本失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('处理流式文本失败: $e')); - return false; } } @@ -380,18 +318,16 @@ class AzureTtsService extends GetxService implements TtsService { Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); // 等待队列处理完成 - while (_isProcessingQueue || _isSpeaking.value) { + await _checkSpeakingStatus(); + while (_isProcessingQueue || _speakingCache) { await Future.delayed(const Duration(milliseconds: 100)); + await _checkSpeakingStatus(); } Logger.info('TTS队列播放完成'); return true; } catch (e) { Logger.error('刷新TTS流失败: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('刷新TTS流失败: $e')); - return false; } } @@ -421,12 +357,12 @@ class AzureTtsService extends GetxService implements TtsService { // 使用speakOnce播放当前项 await speakOnce(item.text); + + // 等待播放完成 + await waitForSpeechCompletion(); } } catch (e) { Logger.error('处理语音队列出错: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('处理语音队列出错: $e')); } finally { _isProcessingQueue = false; } @@ -437,23 +373,16 @@ class AzureTtsService extends GetxService implements TtsService { try { _textQueue.clear(); - if (_isSpeaking.value) { + if (_speakingCache) { final result = await _channel.invokeMethod('stopSpeaking'); - _isSpeaking.value = false; - - // 发送取消事件 - _eventController.add(TtsEvent(type: TtsEventType.canceled)); - + // 重置缓存状态 + _speakingCache = false; return result; } return true; } catch (e) { Logger.error('停止语音合成出错: $e'); - - // 发送错误事件 - _eventController.add(TtsEvent.error('停止语音合成出错: $e')); - return false; } } @@ -492,25 +421,6 @@ class AzureTtsService extends GetxService implements TtsService { if (!isEnabled.value) stop(); } - /// 切换音频输出设备类型 - Future toggleAudioOutputType() async { - AudioOutputType newType; - - switch (_currentAudioOutputType) { - case AudioOutputType.speaker: - newType = AudioOutputType.earpiece; - break; - case AudioOutputType.earpiece: - newType = AudioOutputType.auto; - break; - case AudioOutputType.auto: - newType = AudioOutputType.speaker; - break; - } - - return await setAudioOutputType(newType); - } - @override Future dispose() async { if (!_isInitialized) return; @@ -518,40 +428,27 @@ class AzureTtsService extends GetxService implements TtsService { try { await stop(); await _channel.invokeMethod('dispose'); - - // 关闭事件流控制器 - await _eventController.close(); - _isInitialized = false; } catch (e) { Logger.error('释放资源失败: $e'); } } - /// 处理AI返回的文本流,将其转换为语音 - /// - /// [textStream] 文本流 - /// 返回一个Future,当流处理完成时完成 - Future processAiTextStream(Stream textStream) async { - if (!isEnabled.value) { - Logger.error('TTS服务未启用'); - return false; - } - + /// 异步检查并更新播放状态缓存 + Future _checkSpeakingStatus() async { + _lastSpeakingCheck = DateTime.now(); try { - await for (final text in textStream) { - if (text.isEmpty) continue; - await speakStream(text); + final result = await _channel.invokeMethod('isSpeaking'); + if (result != null) { + _speakingCache = result; } - - // 处理完流后,刷新缓冲区中的剩余文本 - await flushStream(); - return true; + return _speakingCache; } catch (e) { - Logger.error('处理文本流时出错: $e'); - return false; + Logger.error('获取播放状态失败: $e'); + return _speakingCache; } } + } /// 语音合成项目 diff --git a/lib/data/services/speech_impl/flutter_asr_service.dart b/lib/data/services/speech_impl/flutter_asr_service.dart index 0ae32a508..e17fb9198 100644 --- a/lib/data/services/speech_impl/flutter_asr_service.dart +++ b/lib/data/services/speech_impl/flutter_asr_service.dart @@ -165,12 +165,22 @@ class FlutterAsrService extends GetxService implements AsrService { // 如果当前正在监听,先停止 if (_speech.isListening) { await _speech.stop(); - // 短暂延迟确保资源释放 - await Future.delayed(const Duration(milliseconds: 300)); + // 延长等待时间,确保资源完全释放 + await Future.delayed(const Duration(milliseconds: 800)); } + // 在启动前再次检查状态 + if (_speech.isListening) { + Logger.warning('停止识别后,仍处于监听状态,可能有系统冲突'); + await _speech.stop(); + await Future.delayed(const Duration(milliseconds: 500)); + } + + // 启动语音识别前确保状态正确 + _asrState.value = AsrState.listening; + // 启动语音识别 - return await _speech.listen( + final result = await _speech.listen( onResult: onResult, localeId: _currentLocale.value, listenFor: listenFor, @@ -178,10 +188,20 @@ class FlutterAsrService extends GetxService implements AsrService { listenOptions: stt.SpeechListenOptions( partialResults: true, autoPunctuation: autoPunctuation, + // 使用独白模式,减少与系统交互冲突 + listenMode: stt.ListenMode.dictation, ), ) ?? false; + + if (!result) { + Logger.error('语音识别启动失败'); + _asrState.value = AsrState.error; + } + + return result; } catch (e) { Logger.error('启动语音识别失败: $e'); + _asrState.value = AsrState.error; return false; } } @@ -195,12 +215,29 @@ class FlutterAsrService extends GetxService implements AsrService { return; } - // 短暂延迟后再重新启动 - await Future.delayed(const Duration(milliseconds: 300)); + // 检查系统状态,避免频繁重启 + if (_speech.isListening) { + Logger.info('当前已在监听中,无需重启'); + return; + } + + // 延长等待时间,避免与系统资源冲突 + await Future.delayed(const Duration(milliseconds: 1000)); + + // 再次检查状态 + if (_continuousRecognitionController == null || _continuousRecognitionController!.isClosed) { + Logger.info('延迟期间控制器已关闭,跳过重启'); + return; + } + + Logger.info('尝试重新启动连续识别'); // 使用统一方法启动识别 final success = await _startListening( onResult: _onSpeechResult, + // 增加超时时间和暂停时间 + listenFor: const Duration(seconds: 30), + pauseFor: const Duration(seconds: 4), ); if (success) { @@ -208,11 +245,16 @@ class FlutterAsrService extends GetxService implements AsrService { } else { Logger.error('连续识别重启失败'); _continuousRecognitionController?.add( - RecognitionEvent.error('重启语音识别失败') + RecognitionEvent.error('重启语音识别失败,可能存在系统资源冲突') ); } } catch (e) { Logger.error('重启连续识别失败: $e'); + + // 发送错误给控制器 + _continuousRecognitionController?.add( + RecognitionEvent.error('重启失败: $e') + ); } } diff --git a/lib/data/services/speech_impl/voice_clone_tts_service.dart b/lib/data/services/speech_impl/voice_clone_tts_service.dart new file mode 100644 index 000000000..483102adf --- /dev/null +++ b/lib/data/services/speech_impl/voice_clone_tts_service.dart @@ -0,0 +1,393 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:just_audio/just_audio.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; +import '../../../core/utils/logger.dart'; +import '../tts_service.dart'; +import 'volcano_tts_api_service.dart'; + +/// 声音复刻服务,负责基本的文本转语音功能 +/// +/// 使用讯飞声音复刻实现TtsService接口 +class VoiceCloneTtsService extends GetxService implements TtsService { + final String hostUrl = "wss://cn-huabei-1.xf-yun.com/v1/private/voice_clone"; + + final String appId = '43d87d22'; + final String apiKey = '2bd01ce06ac35957fc0c082f9b6284ec'; + final String apiSecret = 'ODU3OTUyYjhjMjY5NDhkMmMxYWY2Zjlm'; + + WebSocketChannel? _channel; + final AudioPlayer _player = AudioPlayer(); + late ConcatenatingAudioSource _playlist; + + // 当前语音 + final RxString _currentVoice = ''.obs; + + @override + String get currentVoice => _currentVoice.value; + + // 播放状态 + final RxBool _isSpeaking = false.obs; + + @override + bool get isSpeaking => _isSpeaking.value; + + // 事件流控制器 + final StreamController _eventController = + StreamController.broadcast(); + + @override + Stream get onEvent => _eventController.stream; + + bool _isInitialized = false; + String _resId = ''; + + // 语音合成队列 + final List _textQueue = []; + bool _isProcessingQueue = false; + + BytesBuilder _audioBytes = BytesBuilder(); + + Completer _completer = Completer(); + + final isEnabled = true.obs; + + // 流式文本缓冲区 + String _streamBuffer = ''; + + @override + Future initialize({ + List? supportedLanguages, + }) async { + if (_isInitialized) return true; + try { + final GetStorage storage = GetStorage(); + String timbre = storage.read('timbre'); + _resId = timbre; + _playlist = ConcatenatingAudioSource( + useLazyPreparation: true, + children: [], + ); + return true; + } catch (e) { + Logger.error('TTS初始化失败: $e'); + return false; + } + } + + @override + Future setVoice(String voiceName) async { + if (!_isInitialized) await initialize(); + try { + Logger.info('设置TTS语音: $voiceName'); + return true; + } catch (e) { + Logger.error('设置TTS语音失败: $e'); + return false; + } + } + + // 语音合成 + Future _speechSynthesis(String text) async { + await _tryConnecting(); + _channel?.sink.add( + json.encode({ + "header": {"app_id": appId, "status": 2, "res_id": _resId}, + "parameter": { + "tts": { + "vcn": "x5_clone", + "audio": {"encoding": "lame", "sample_rate": 16000} + } + }, + "payload": { + "text": { + "status": 2, + "seq": 0, + "text": base64Encode(utf8.encode(text)) + } + }, + }), + ); + } + + Future _tryConnecting() async { + try { + final url = _geturl(); + _channel = WebSocketChannel.connect(Uri.parse(url)); + _channel?.stream.timeout(const Duration(seconds: 10)); //设置超时时间 + await _channel?.ready; + _channel?.stream.listen( + (message) { + _handle(message); + }, + onError: (error) { + Logger.error('连接失败: $error'); + }, + onDone: () { + Logger.error('WebSocket 连接已关闭!'); + }, + cancelOnError: true, + ); + } catch (e) { + Logger.error('初始化异常: $e'); + } + } + + String _geturl() { + // 解析 URL + final uri = Uri.parse(hostUrl); + + // 获取当前时间并格式化为 RFC1123 格式 + final date = HttpDate.format(DateTime.now().toUtc()); + + // 参与签名的字段 + final signString = [ + "host: ${uri.host}", + "date: $date", + "GET ${uri.path} HTTP/1.1", + ]; + + // 拼接签名字符串 + final sgin = signString.join("\n"); + + // 使用 HMAC-SHA256 签名 + final key = utf8.encode(apiSecret); + final bytes = utf8.encode(sgin); + final hmacSha256 = Hmac(sha256, key); + final digest = hmacSha256.convert(bytes); + final sha = base64Encode(digest.bytes); + + // 构建请求参数 + final authUrl = + 'api_key="$apiKey", algorithm="hmac-sha256", headers="host date request-line", signature="$sha"'; + + // 将请求参数使用 base64 编码 + final authorization = base64Encode(utf8.encode(authUrl)); + + // 构建查询参数 + final queryParams = { + "host": uri.host, + "date": date, + "authorization": authorization, + }; + + // 将查询参数编码并拼接到 URL 后面 + final callurl = + Uri.parse(hostUrl).replace(queryParameters: queryParams).toString(); + + return callurl; + } + + void _handle(data) async { + final result = jsonDecode(data); + + int code = result['header']['code']; + if (code == 0) { + int status = result['header']['status']; + if (status == 1 || status == 2) { + Uint8List audio = base64Decode(result['payload']['audio']['audio']); + _audioBytes.add(audio); + } + if (status == 2) { + final newSource = BytesAudioSource(_audioBytes.toBytes()); + await _playlist.add(newSource); + if (!_isSpeaking.value) { + _audioPlay(); + } + _completer.complete(); + _completer = Completer(); + } + } + } + + void _audioPlay() async { + _isSpeaking.value = true; + // 发送开始事件 + _eventController.add(TtsEvent(type: TtsEventType.started)); + await _player.setAudioSource(_playlist); + await _player.play(); + } + + @override + Future speakOnce(String text) async { + if (!isEnabled.value || text.isEmpty) return false; + + try { + _audioBytes.clear(); + await _speechSynthesis(text); + await _completer.future; + + return true; + } catch (e) { + _isSpeaking.value = false; + Logger.error('语音合成失败: $e'); + + // 发送错误事件 + _eventController.add(TtsEvent.error('语音合成失败: $e')); + + return false; + } + } + + @override + Future speakStream(String text) async { + if (!isEnabled.value || text.isEmpty) return false; + + try { + // 添加文本到缓冲区 + _streamBuffer += text; + + // 如果缓冲区为空,直接返回 + if (_streamBuffer.isEmpty) return true; + + // 使用正则表达式匹配句子,包括结束符号 + // 匹配任意字符,直到遇到句子结束符号 + final sentenceRegex = RegExp( + r'([^。.!!??;;:\n\r]+[。.!!??;;::\n\r]|[^。.!!??;;:\n\r]+(?:\.{3,}|…)|[^。.!!??;;:\n\r]+["」』])'); + + bool hasProcessed = false; + + // 查找所有完整句子 + final matches = sentenceRegex.allMatches(_streamBuffer); + final List sentences = []; + int lastMatchEnd = 0; + + for (final match in matches) { + // 提取完整句子(包含结束符号) + final sentence = match.group(1)?.trim(); + if (sentence != null && sentence.isNotEmpty) { + sentences.add(sentence); + lastMatchEnd = match.end; + } + } + + // 处理找到的句子 + for (final sentence in sentences) { + _textQueue.add(sentence); + hasProcessed = true; + } + + // 更新缓冲区,只保留未完成的部分 + if (lastMatchEnd > 0) { + _streamBuffer = _streamBuffer.substring(lastMatchEnd); + } + + // 如果处理了文本并且队列未在处理中,开始处理队列 + if (hasProcessed && !_isProcessingQueue) { + _processQueue(); + } + + return true; + } catch (e) { + Logger.error('处理流式文本失败: $e'); + + // 发送错误事件 + _eventController.add(TtsEvent.error('处理流式文本失败: $e')); + + return false; + } + } + + @override + Future flushStream() async { + if (_textQueue.isEmpty && _streamBuffer.isEmpty) return true; + + try { + // 处理缓冲区中可能的完整句子 + await speakStream(''); + + // 如果缓冲区仍有剩余文本,将其添加到播放队列 + if (_streamBuffer.isNotEmpty && _streamBuffer.trim().isNotEmpty) { + _textQueue.add(_streamBuffer); + + // 清空缓冲区 + _streamBuffer = ''; + + // 如果队列未在处理中,开始处理队列 + if (!_isProcessingQueue) { + _processQueue(); + } + } + + Logger.info('等待TTS队列播放完成,剩余${_textQueue.length}条'); + + return true; + } catch (e) { + Logger.error('刷新TTS流失败: $e'); + + // 发送错误事件 + _eventController.add(TtsEvent.error('刷新TTS流失败: $e')); + + return false; + } + } + + /// 处理语音合成队列 + Future _processQueue() async { + if (_textQueue.isEmpty || _isProcessingQueue) return; + + _isProcessingQueue = true; + + try { + while (_textQueue.isNotEmpty) { + if (!isEnabled.value) { + _textQueue.clear(); + break; + } + + final item = _textQueue.removeAt(0); + + // 使用speakOnce播放当前项 + await speakOnce(item); + } + } catch (e) { + Logger.error('处理语音队列出错: $e'); + + // 发送错误事件 + _eventController.add(TtsEvent.error('处理语音队列出错: $e')); + } finally { + _isProcessingQueue = false; + } + } + + @override + Future stop() async { + try { + _textQueue.clear(); + _player.stop(); + _playlist.clear(); + _audioBytes = BytesBuilder(); + if (_isSpeaking.value) { + _isSpeaking.value = false; + // 发送取消事件 + _eventController.add(TtsEvent(type: TtsEventType.canceled)); + } + return true; + } catch (e) { + Logger.error('停止语音合成出错: $e'); + // 发送错误事件 + _eventController.add(TtsEvent.error('停止语音合成出错: $e')); + return false; + } + } + + @override + Future dispose() async { + if (!_isInitialized) return; + try { + await stop(); + // 关闭事件流控制器 + await _eventController.close(); + _isInitialized = false; + _player.dispose(); + } catch (e) { + Logger.error('释放资源失败: $e'); + } + } +} diff --git a/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart new file mode 100644 index 000000000..5a3831d79 --- /dev/null +++ b/lib/data/services/voice_interaction_impl/android_voice_interaction_service.dart @@ -0,0 +1,340 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; +import 'package:get/get.dart'; +import '../../../core/utils/logger.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../../../modules/chat/models/message_model.dart'; +import '../../models/events/voice_interaction_event.dart'; +import '../chat_history_service.dart'; + +/// Android语音交互服务 +/// +/// 该服务提供了与Android端的VoiceInteractionService.kt通信的接口, +/// 用于管理后台语音交互服务的生命周期和接收语音交互事件 +class AndroidVoiceInteractionService extends GetxService { + static AndroidVoiceInteractionService get to => Get.find(); + // 方法通道和事件通道 + static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); + static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); + + // 服务状态 + final _isServiceRunning = false.obs; + bool get isServiceRunning => _isServiceRunning.value; + + // 事件流控制器 + StreamController? _eventStreamController; + Stream? _eventStream; + @override + Stream get eventStream => _eventStream ?? Stream.empty(); + + // 事件通道状态 + StreamSubscription? _eventSubscription; + + // 配置信息 + late String _azureSpeechKey; + late String _azureSpeechRegion; + late String _volcanoAiApiKey; + + // 初始化状态标志 + static bool _isInitialized = false; + + // 当前会话历史 + final List> _messageHistory = []; + + // 服务ID + static const String _serviceId = 'android_assistant'; + + /// 构造函数 + AndroidVoiceInteractionService() { + + } + + /// 从环境变量加载配置 + void _loadConfig() { + _azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; + _azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; + _volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; + + if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { + Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); + } + + if (_volcanoAiApiKey.isEmpty) { + Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); + } + } + + /// 设置事件通道 + void _setupEventChannel() { + _eventSubscription = _eventChannel + .receiveBroadcastStream() + .listen((event) { + if (event is Map) { + final String eventType = event['type'] as String? ?? ''; + + // 直接处理事件,不再等待channelReady + _handleVoiceInteractionEvent(event); + } + }, onError: (error) { + Logger.error('语音交互事件通道错误: $error'); + }); + } + + /// 创建事件流 + void _createEventStream() { + _eventStreamController = StreamController.broadcast(); + _eventStream = _eventStreamController?.stream; + } + + /// 初始化服务 + @override + Future initialize() async { + try { + if (_isInitialized) { + return this; + } + _isInitialized = true; + + _loadConfig(); + _setupEventChannel(); + _createEventStream(); + // 检查服务是否正在运行 + await _checkServiceStatus(); + + await _startService(); + + Logger.info('语音交互服务初始化完成'); + return this; + } catch (e) { + Logger.error('语音交互服务初始化失败: $e'); + return this; + } + } + + @override + void onWakeup() { + Logger.info('语音交互服务唤醒'); + } + + /// 检查服务状态 + Future _checkServiceStatus() async { + try { + final bool isRunning = await _channel.invokeMethod('isVoiceInteractionServiceRunning') ?? false; + _isServiceRunning.value = isRunning; + Logger.info('语音交互服务状态: ${isRunning ? "运行中" : "未运行"}'); + } catch (e) { + Logger.error('检查语音交互服务状态失败: $e'); + _isServiceRunning.value = false; + } + } + + /// 启动语音交互服务 + Future _startService() async { + if (_isServiceRunning.value) { + Logger.info('语音交互服务已经在运行'); + return true; + } + + try { + final bool result = await _channel.invokeMethod('startVoiceInteractionService', { + 'azure_speech_key': _azureSpeechKey, + 'azure_speech_region': _azureSpeechRegion, + 'volcano_ai_api_key': _volcanoAiApiKey, + }) ?? false; + + if (result) { + _isServiceRunning.value = true; + Logger.info('语音交互服务启动成功'); + } else { + Logger.error('语音交互服务启动失败'); + } + + return result; + } catch (e) { + Logger.error('启动语音交互服务失败: $e'); + return false; + } + } + + /// 停止语音交互服务 + Future _stopService() async { + if (!_isServiceRunning.value) { + Logger.info('语音交互服务未运行'); + return true; + } + + try { + final bool result = await _channel.invokeMethod('stopVoiceInteractionService') ?? false; + + if (result) { + _isServiceRunning.value = false; + Logger.info('语音交互服务停止成功'); + } else { + Logger.error('语音交互服务停止失败'); + } + + return result; + } catch (e) { + Logger.error('停止语音交互服务失败: $e'); + return false; + } + } + + /// 暂停语音交互(停止TTS和语音识别,但保持服务运行) + @override + Future pauseVoiceInteraction() async { + if (!_isServiceRunning.value) { + Logger.info('语音交互服务未运行,无法暂停'); + return false; + } + + try { + final bool result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; + + if (result) { + Logger.info('语音交互暂停成功'); + } else { + Logger.error('语音交互暂停失败'); + } + + return result; + } catch (e) { + Logger.error('暂停语音交互失败: $e'); + return false; + } + } + + /// 处理来自原生端的语音交互事件 + void _handleVoiceInteractionEvent(dynamic event) { + if (event is! Map || _eventStreamController == null) return; + + final Map eventMap = event; + final String eventType = eventMap['type'] as String? ?? ''; + final int timestamp = eventMap['timestamp'] as int? ?? 0; + + // 添加时间戳日志,帮助调试 + Logger.info('收到原生端事件: $eventType, 时间戳: $timestamp, 当前时间: ${DateTime.now().millisecondsSinceEpoch}'); + + switch (eventType) { + case 'chatHistory': + final String agentId = eventMap['agentId'] as String? ?? ''; + final String userMessage = eventMap['userMessage'] as String? ?? ''; + final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; + + // 添加到消息历史 + if (userMessage.isNotEmpty) { + _messageHistory.add({'role': 'user', 'content': userMessage}); + _messageHistory.add({'role': 'assistant', 'content': assistantMessage}); + + // 保持历史记录在一定长度 + while (_messageHistory.length > 10) { + _messageHistory.removeAt(0); + } + } + + _eventStreamController?.add(ChatHistoryEvent( + agentId: agentId, + userMessage: userMessage, + assistantMessage: assistantMessage, + timestamp: timestamp, + )); + + // 保存聊天记录 + _saveChatHistory(agentId, userMessage, assistantMessage, timestamp); + + Logger.info('收到聊天历史事件: agentId=$agentId'); + break; + + case 'recognitionStarted': + _eventStreamController?.add(RecognitionStartedEvent( + timestamp: timestamp, + )); + break; + + default: + Logger.warning('收到未知类型的语音交互事件: $eventType'); + break; + } + } + + @override + void onClose() { + // 清理资源 + _stopService(); + _eventSubscription?.cancel(); + _eventStreamController?.close(); + super.onClose(); + } + + /// 保存聊天记录 + void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { + try { + // 获取ChatHistoryService实例 + final chatHistoryService = Get.find(); + + // 创建用户消息和助手消息 + final userMsg = Message( + role: 'user', + content: userMessage, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), + ); + + final assistantMsg = Message( + role: 'assistant', + content: assistantMessage, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 + ); + + // 加载现有历史记录 + final existingMessages = chatHistoryService.loadHistory(agentId); + + // 添加新消息 + existingMessages.addAll([userMsg, assistantMsg]); + + // 保存更新后的历史记录 + chatHistoryService.saveHistory(agentId, existingMessages); + } catch (e) { + Logger.error('保存聊天记录失败: $e'); + } + } + + /// 是否处于活跃状态 + @override + bool isActive() { + return _isServiceRunning.value; + } + + /// 获取当前对话历史 + @override + List> getMessageHistory() { + return List>.from(_messageHistory); + } + + /// 获取当前服务ID + @override + String getServiceId() { + return _serviceId; + } +} + + +/// 聊天历史事件 +class ChatHistoryEvent extends VoiceInteractionEvent { + final String agentId; + final String userMessage; + final String assistantMessage; + + ChatHistoryEvent({ + required this.agentId, + required this.userMessage, + required this.assistantMessage, + required int timestamp, + }) : super(timestamp: timestamp); +} + +/// 语音识别开始事件 +class RecognitionStartedEvent extends VoiceInteractionEvent { + RecognitionStartedEvent({ + required int timestamp, + }) : super(timestamp: timestamp); +} \ No newline at end of file diff --git a/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart new file mode 100644 index 000000000..2d129b050 --- /dev/null +++ b/lib/data/services/voice_interaction_impl/ios_voice_interaction_service.dart @@ -0,0 +1,467 @@ +import 'dart:async'; +import 'package:get/get.dart'; +import '../asr_service.dart'; +import '../tts_service.dart'; +import '../volcano_ai_service.dart'; +import '../chat_history_service.dart'; +import '../../../modules/chat/models/message_model.dart'; +import '../../models/events/voice_interaction_event.dart'; +import '../../../core/utils/logger.dart'; + +enum VoiceInteractionState { + idle, // 等待唤醒 + active, // 活跃状态 - 可以同时识别和播放 +} + +class IosVoiceInteractionService extends GetxService { + final AsrService _asrService = Get.find(); + final TtsService _ttsService = Get.find(); + final VolcanoAIService _aiService = Get.find(); + final ChatHistoryService _chatHistoryService = Get.find(); + + // 当前状态 + final Rx state = VoiceInteractionState.idle.obs; + + // 是否识别到用户语音 + final RxBool isSpeechDetected = false.obs; + + // 是否正在处理AI响应 + final RxBool isProcessingAI = false.obs; + + // 是否正在播放TTS + final RxBool isSpeaking = false.obs; + + // 对话历史记录 + final List> _messageHistory = []; + + // 最大历史记录数 + static const int _maxHistorySize = 10; + + // 超时计时器 + Timer? _inactivityTimer; + + // 流订阅 + StreamSubscription? _recognitionSubscription; + + // 当前识别的文本 + String _currentRecognizedText = ''; + + // 事件流控制器 + final StreamController _eventStreamController = + StreamController.broadcast(); + + // 获取事件流 + @override + Stream get eventStream => _eventStreamController.stream; + + // 标识符,用于存储聊天历史 + final String _agentId = 'ios_assistant'; + + /// 初始化服务 + @override + Future initialize() async { + try { + // 保持idle状态,不自动启动语音识别 + Logger.info('iOS语音交互服务初始化完成,处于idle状态'); + } catch (e) { + Logger.error('iOS语音交互服务初始化失败: $e'); + } + + return this; + } + + // 处理唤醒事件 + void onWakeup() { + _ttsService.speakOnce('我在呢!'); + isSpeaking.value = true; + + if (state.value == VoiceInteractionState.idle) { + _activateInteraction(); + } + } + + // 激活交互 + void _activateInteraction() { + // 设置状态为活跃 + state.value = VoiceInteractionState.active; + + // 开始连续监听 + _startContinuousListening(); + + // 启动不活动计时器 + _startInactivityTimer(); + + // 发送识别开始事件 + _sendRecognitionStartedEvent(); + } + + // 开始连续监听 + Future _startContinuousListening() async { + // 停止之前的监听 + _recognitionSubscription?.cancel(); + + try { + // 开始连续识别 + final recognitionStream = await _asrService.startContinuousRecognition(); + + // 监听识别结果 + _recognitionSubscription = recognitionStream.listen( + _handleRecognitionEvent, + onError: (error) { + Logger.error('语音识别错误: $error'); + _startContinuousListening(); // 尝试重新启动 + } + ); + + Logger.info('开始连续语音识别'); + } catch (e) { + Logger.error('启动语音识别失败: $e'); + + } + } + + // 处理识别事件 + void _handleRecognitionEvent(RecognitionEvent event) { + // 更新活动时间 + _updateActivityTime(); + + switch (event.type) { + case RecognitionEventType.intermediateResult: + // 检测用户是否开始说话 + if (event.text.isNotEmpty && !isSpeechDetected.value) { + isSpeechDetected.value = true; + + // 用户开始讲话时,立即中断当前响应 + _interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); + } + break; + + case RecognitionEventType.finalResult: + if (event.text.isNotEmpty) { + // 最终结果,处理用户输入 + _processUserInput(event.text); + } + // 重置语音检测状态 + isSpeechDetected.value = false; + break; + + case RecognitionEventType.sessionStarted: + Logger.info('语音识别会话开始'); + break; + + case RecognitionEventType.sessionStopped: + Logger.info('语音识别会话结束'); + + break; + + case RecognitionEventType.error: + case RecognitionEventType.canceled: + Logger.error('语音识别错误: ${event.error}'); + + break; + } + } + + // 中断当前响应 + void _interruptCurrentResponse(String reason) { + Logger.info(reason); + + // 如果正在活跃状态,需要中断当前操作 + if (state.value == VoiceInteractionState.active) { + // 停止TTS播放 + _ttsService.stop(); + isSpeaking.value = false; + isProcessingAI.value = false; + } + } + + // 处理用户输入 + Future _processUserInput(String text) async { + Logger.info('处理用户输入: $text'); + + // 设置为处理状态 + isProcessingAI.value = true; + + // 添加用户消息到历史记录 + final userMessage = {'role': 'user', 'content': text}; + _addToHistory(userMessage); + + // 创建用户消息对象 + final userMsg = Message( + role: 'user', + content: text, + timestamp: DateTime.now(), + ); + + // AI响应处理标志 + bool isProcessingCancelled = false; + + try { + // 构建消息历史 + final messageHistory = _buildMessageHistory(); + + // 调用AI服务获取响应 + final responseStream = _aiService.sendMessageStream( + messages: messageHistory, + systemPrompt: "你是一个智能助手,请简明扼要地回答问题。", + ); + + String aiResponse = ''; + + // 等待AI响应 + await for (final chunk in responseStream) { + // 检查是否被用户打断 + if (isSpeechDetected.value) { + // 用户开始说话,标记处理被取消 + isProcessingCancelled = true; + Logger.info('AI响应生成过程中被用户打断'); + break; + } + + // 如果状态已改变(可能由其他原因导致),停止处理 + if (state.value != VoiceInteractionState.active) { + isProcessingCancelled = true; + break; + } + + aiResponse += chunk; + + // 立即播放当前文本块,实现边生成边播放 + _speakStreamResponse(chunk); + } + + // 如果处理被取消,不继续后续操作 + if (isProcessingCancelled) { + isProcessingAI.value = false; + return; + } + + // 确保完整响应被处理 + if (aiResponse.isNotEmpty) { + // 将AI响应添加到历史记录 + final assistantMessage = {'role': 'assistant', 'content': aiResponse}; + _addToHistory(assistantMessage); + + // 创建助手消息对象 + final assistantMsg = Message( + role: 'assistant', + content: aiResponse, + timestamp: DateTime.now(), + ); + + // 保存聊天历史 + _saveChatHistory(userMsg, assistantMsg); + + // 发送聊天历史事件 + _sendChatHistoryEvent(userMsg.content, assistantMsg.content); + + // 刷新TTS流,确保所有文本都被播放 + await _ttsService.flushStream(); + isSpeaking.value = false; + } + + // 处理完成 + isProcessingAI.value = false; + } catch (e) { + Logger.error('AI响应处理失败: $e'); + // 错误恢复 + isProcessingAI.value = false; + } + } + + // 构建用于AI服务的消息历史 + List> _buildMessageHistory() { + return List>.from(_messageHistory); + } + + // 添加消息到历史记录 + void _addToHistory(Map message) { + _messageHistory.add(message); + + // 限制历史记录大小 + while (_messageHistory.length > _maxHistorySize) { + _messageHistory.removeAt(0); + } + } + + // 播放流式响应 + void _speakStreamResponse(String chunk) { + if (chunk.isEmpty) return; + + // 更新播放状态 + isSpeaking.value = true; + + // 使用流式TTS播放 + _ttsService.speakStream(chunk); + + // 更新活动时间 + _updateActivityTime(); + } + + // 停止TTS + void _stopTts() { + _ttsService.stop(); + isSpeaking.value = false; + } + + // 更新最后活动时间 + DateTime _lastActivityTime = DateTime.now(); + void _updateActivityTime() { + _lastActivityTime = DateTime.now(); + } + + // 启动不活动计时器(长时间无交互会切换到空闲状态) + void _startInactivityTimer() { + _cancelInactivityTimer(); + + _inactivityTimer = Timer.periodic(Duration(seconds: 5), (timer) { + // 计算空闲时间 + final idleTime = DateTime.now().difference(_lastActivityTime).inSeconds; + + // 如果空闲超过30秒,且不在播放或检测到语音,切换到空闲 + if (idleTime > 8 && + !isSpeaking.value && + !isProcessingAI.value && + !isSpeechDetected.value) { + _resetToIdle(); + timer.cancel(); + } + }); + } + + // 取消不活动计时器 + void _cancelInactivityTimer() { + _inactivityTimer?.cancel(); + _inactivityTimer = null; + } + + // 重置到空闲状态 + void _resetToIdle() { + _ttsService.speakOnce('没有听到声音,暂停对话,双击耳机唤醒!'); + _cancelInactivityTimer(); + + // 停止TTS + _stopTts(); + + // 停止语音识别 + _recognitionSubscription?.cancel(); + _asrService.stopContinuousRecognition(); + + // 重置状态 + state.value = VoiceInteractionState.idle; + isSpeechDetected.value = false; + isProcessingAI.value = false; + isSpeaking.value = false; + + // 清空历史记录 + _messageHistory.clear(); + + Logger.info('语音交互已重置为空闲状态'); + } + + // 暂停语音交互 + @override + void pauseVoiceInteraction() { + if (state.value != VoiceInteractionState.idle) { + _stopTts(); + _recognitionSubscription?.cancel(); + _asrService.stopContinuousRecognition(); + + // 重置所有状态 + state.value = VoiceInteractionState.idle; + isSpeechDetected.value = false; + isProcessingAI.value = false; + isSpeaking.value = false; + + // 清空历史记录 + _messageHistory.clear(); + } + } + + // 恢复语音交互 + void resumeVoiceInteraction() { + if (state.value == VoiceInteractionState.idle) { + _activateInteraction(); + } + } + + // 手动停止交互 + void stopInteraction() { + _resetToIdle(); + } + + // 获取当前状态 + VoiceInteractionState getCurrentState() { + return state.value; + } + + // 是否处于活跃状态 + @override + bool isActive() { + return state.value != VoiceInteractionState.idle; + } + + // 获取当前对话历史 + @override + List> getMessageHistory() { + return List>.from(_messageHistory); + } + + // 获取当前服务ID + @override + String getServiceId() { + return _agentId; + } + + // 释放资源 + @override + void onClose() { + _cancelInactivityTimer(); + _recognitionSubscription?.cancel(); + _resetToIdle(); + _eventStreamController.close(); + super.onClose(); + } + + // 保存聊天历史 + void _saveChatHistory(Message userMsg, Message assistantMsg) { + try { + // 加载现有历史记录 + final existingMessages = _chatHistoryService.loadHistory(_agentId); + + // 添加新消息 + existingMessages.addAll([userMsg, assistantMsg]); + + // 保存更新后的历史记录 + _chatHistoryService.saveHistory(_agentId, existingMessages); + + Logger.info('保存聊天历史记录成功'); + } catch (e) { + Logger.error('保存聊天历史记录失败: $e'); + } + } + + // 发送聊天历史事件 + void _sendChatHistoryEvent(String userMessage, String assistantMessage) { + final event = ChatHistoryEvent( + agentId: _agentId, + userMessage: userMessage, + assistantMessage: assistantMessage, + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + + _eventStreamController.add(event); + Logger.info('已发送聊天历史事件'); + } + + // 发送识别开始事件 + void _sendRecognitionStartedEvent() { + final event = RecognitionStartedEvent( + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + + _eventStreamController.add(event); + Logger.info('已发送识别开始事件'); + } +} + diff --git a/lib/data/services/voice_interaction_service.dart b/lib/data/services/voice_interaction_service.dart index 88dbd0d1d..a1d26d509 100644 --- a/lib/data/services/voice_interaction_service.dart +++ b/lib/data/services/voice_interaction_service.dart @@ -1,331 +1,275 @@ import 'dart:async'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import '../../core/utils/logger.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import '../models/events/voice_interaction_event.dart'; +import '../../core/utils/logger.dart'; import '../../modules/chat/models/message_model.dart'; import 'chat_history_service.dart'; -import 'speech_impl/azure_asr_service.dart'; -import 'speech_impl/azure_tts_service.dart'; -import 'asr_service.dart'; -import 'tts_service.dart'; -/// 语音交互服务 -/// -/// 该服务提供了与Android端的VoiceInteractionService.kt通信的接口, -/// 用于管理后台语音交互服务的生命周期和接收语音交互事件 +/// 语音交互服务接口 +/// +/// 管理与平台原生语音交互服务的通信,提供统一的接口供应用使用 class VoiceInteractionService extends GetxService { - static VoiceInteractionService get to => Get.find(); - // 方法通道和事件通道 + static VoiceInteractionService get to => Get.find(); + + // 方法通道 static const MethodChannel _channel = MethodChannel('com.deep_voice.voice_interaction'); + + // 事件通道 static const EventChannel _eventChannel = EventChannel('com.deep_voice.voice_interaction_events'); // 服务状态 final _isServiceRunning = false.obs; bool get isServiceRunning => _isServiceRunning.value; - // 事件流控制器 - StreamController? _eventStreamController; - Stream? _eventStream; - Stream? get eventStream => _eventStream; + // 流控制器 + final _eventStreamController = StreamController.broadcast(); + + // 事件流 + Stream get eventStream => _eventStreamController.stream; - // 事件通道状态 - bool _isEventChannelReady = false; - Completer? _eventChannelReadyCompleter; + // 事件通道订阅 StreamSubscription? _eventSubscription; + // 标记是否初始化 + bool _isInitialized = false; + // 配置信息 late String _azureSpeechKey; late String _azureSpeechRegion; - late String _volcanoAiApiKey; + late String _volcanoAiKey; - // 初始化状态标志 - static bool _isInitialized = false; + // 聊天历史服务 + late final ChatHistoryService _chatHistoryService; - /// 构造函数 - VoiceInteractionService() { - + + /// 设置事件通道 + void _setupEventChannel() { + _eventSubscription = _eventChannel + .receiveBroadcastStream() + .listen(_handleVoiceInteractionEvent, onError: (error) { + Logger.error('语音交互事件通道错误: $error'); + }); } /// 从环境变量加载配置 void _loadConfig() { _azureSpeechKey = dotenv.env['AZURE_SPEECH_KEY'] ?? ''; _azureSpeechRegion = dotenv.env['AZURE_SPEECH_REGION'] ?? ''; - _volcanoAiApiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; + _volcanoAiKey = dotenv.env['VOLCANO_AI_API_KEY'] ?? ''; if (_azureSpeechKey.isEmpty || _azureSpeechRegion.isEmpty) { Logger.warning('未找到 Azure 语音服务配置。请在 .env 文件中设置 AZURE_SPEECH_KEY 和 AZURE_SPEECH_REGION'); } - if (_volcanoAiApiKey.isEmpty) { + if (_volcanoAiKey.isEmpty) { Logger.warning('未找到火山 AI API 密钥。请在 .env 文件中设置 VOLCANO_AI_API_KEY'); } } - /// 设置事件通道 - void _setupEventChannel() { - _eventChannelReadyCompleter = Completer(); + /// 处理来自原生层的事件 + void _handleVoiceInteractionEvent(dynamic event) { + if (event is! Map) return; - _eventSubscription = _eventChannel - .receiveBroadcastStream() - .listen((event) { - if (event is Map) { - final String eventType = event['type'] as String? ?? ''; - - // 处理通道准备好的事件 - if (eventType == 'channelReady') { - _isEventChannelReady = true; - if (!_eventChannelReadyCompleter!.isCompleted) { - _eventChannelReadyCompleter!.complete(); - } - return; - } - - _handleVoiceInteractionEvent(event); - } - }, onError: (error) { - Logger.error('语音交互事件通道错误: $error'); - }); - } - - /// 创建事件流 - void _createEventStream() { - _eventStreamController = StreamController.broadcast(); - _eventStream = _eventStreamController?.stream; + final eventMap = event as Map; + final String eventType = eventMap['type'] as String? ?? ''; + // final int timestamp = eventMap['timestamp'] as int? ?? DateTime.now().millisecondsSinceEpoch; + + Logger.info('收到语音交互事件: $eventType'); + + switch (eventType) { + case 'recognition_started': + // 语音识别开始事件 + final recognitionEvent = RecognitionStartedEvent( + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + _eventStreamController.add(recognitionEvent); + break; + + case 'chat_history_updated': + // 聊天历史更新事件 + final String agentId = eventMap['agentId'] as String? ?? ''; + final String userMessage = eventMap['userMessage'] as String? ?? ''; + final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; + + final chatHistoryEvent = ChatHistoryEvent( + agentId: agentId, + userMessage: userMessage, + assistantMessage: assistantMessage, + timestamp: DateTime.now().millisecondsSinceEpoch, + ); + _eventStreamController.add(chatHistoryEvent); + + // 保存聊天历史到ChatHistoryService + _saveChatHistory(agentId, userMessage, assistantMessage, DateTime.now().millisecondsSinceEpoch); + break; + } } - /// 等待事件通道准备好 - Future _waitForEventChannel({Duration timeout = const Duration(seconds: 5)}) async { - if (_isEventChannelReady) return true; - + /// 保存聊天历史 + void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { try { - await _eventChannelReadyCompleter!.future.timeout(timeout); - return true; - } on TimeoutException { - Logger.error('等待语音交互事件通道准备好超时'); - return false; + // 检查参数有效性 + if (userMessage.isEmpty) { + return; + } + + // 创建用户消息和助手消息 + final userMsg = Message( + role: 'user', + content: userMessage, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), + ); + + final assistantMsg = Message( + role: 'assistant', + content: assistantMessage, + timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 + ); + + // 加载现有历史记录 + final existingMessages = _chatHistoryService.loadHistory(agentId); + + // 添加新消息 + existingMessages.addAll([userMsg, assistantMsg]); + + // 保存更新后的历史记录 + _chatHistoryService.saveHistory(agentId, existingMessages); + + Logger.info('已保存聊天历史: agentId=$agentId'); + } catch (e) { + Logger.error('保存聊天历史失败: $e'); } } /// 初始化服务 - Future initialize() async { + Future initialize() async { + if (_isInitialized) return true; + try { - if (_isInitialized) { - return this; + Logger.info('正在初始化语音交互服务...'); + + // 获取聊天历史服务 + try { + _chatHistoryService = Get.find(); + } catch (e) { + Logger.warning('获取ChatHistoryService失败,将创建新实例'); + _chatHistoryService = Get.put(ChatHistoryService()); } - _isInitialized = true; - - _loadConfig(); + + // 设置事件通道 _setupEventChannel(); - _createEventStream(); - // 检查服务是否正在运行 - await _checkServiceStatus(); - // 等待事件通道准备好 - await _waitForEventChannel(); + // 加载配置 + _loadConfig(); + + // 检查服务是否已在运行 + final bool running = await checkServiceStatus(); + // 如果服务未运行,启动服务 + if (!running) { + // 启动语音交互服务 + final success = await startService(); + if (!success) { + Logger.error('语音交互服务启动失败'); + return false; + } + } + + _isInitialized = true; Logger.info('语音交互服务初始化完成'); - return this; + return true; } catch (e) { Logger.error('语音交互服务初始化失败: $e'); - return this; - } - } - - /// 检查服务状态 - Future _checkServiceStatus() async { - try { - final bool isRunning = await _channel.invokeMethod('isVoiceInteractionServiceRunning') ?? false; - _isServiceRunning.value = isRunning; - Logger.info('语音交互服务状态: ${isRunning ? "运行中" : "未运行"}'); - } catch (e) { - Logger.error('检查语音交互服务状态失败: $e'); - _isServiceRunning.value = false; + return false; } } /// 启动语音交互服务 Future startService() async { - if (_isServiceRunning.value) { - Logger.info('语音交互服务已经在运行'); - return true; - } - try { - final bool result = await _channel.invokeMethod('startVoiceInteractionService', { + Logger.info('启动语音交互服务...'); + + // 使用已加载的配置信息 + final result = await _channel.invokeMethod('startService', { 'azure_speech_key': _azureSpeechKey, 'azure_speech_region': _azureSpeechRegion, - 'volcano_ai_api_key': _volcanoAiApiKey, + 'volcano_ai_api_key': _volcanoAiKey, }) ?? false; if (result) { _isServiceRunning.value = true; - Logger.info('语音交互服务启动成功'); + Logger.info('语音交互服务已启动'); } else { - Logger.error('语音交互服务启动失败'); + Logger.error('启动语音交互服务失败'); } return result; } catch (e) { - Logger.error('启动语音交互服务失败: $e'); + Logger.error('启动语音交互服务时发生错误: $e'); return false; } } /// 停止语音交互服务 Future stopService() async { - if (!_isServiceRunning.value) { - Logger.info('语音交互服务未运行'); - return true; - } - try { - final bool result = await _channel.invokeMethod('stopVoiceInteractionService') ?? false; + Logger.info('停止语音交互服务...'); + + final result = await _channel.invokeMethod('stopService') ?? false; if (result) { _isServiceRunning.value = false; - Logger.info('语音交互服务停止成功'); + Logger.info('语音交互服务已停止'); } else { - Logger.error('语音交互服务停止失败'); + Logger.error('停止语音交互服务失败'); } return result; } catch (e) { - Logger.error('停止语音交互服务失败: $e'); + Logger.error('停止语音交互服务时发生错误: $e'); return false; } } - /// 暂停语音交互(停止TTS和语音识别,但保持服务运行) - Future pauseVoiceInteraction() async { - if (!_isServiceRunning.value) { - Logger.info('语音交互服务未运行,无法暂停'); + /// 检查服务是否运行 + Future checkServiceStatus() async { + try { + final bool result = await _channel.invokeMethod('isServiceRunning') ?? false; + _isServiceRunning.value = result; + return result; + } catch (e) { + Logger.error('检查服务状态时发生错误: $e'); return false; } - + } + + /// 暂停语音交互 + Future pauseVoiceInteraction() async { try { - final bool result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; + Logger.info('暂停后台语音交互...'); + + final result = await _channel.invokeMethod('pauseVoiceInteraction') ?? false; if (result) { - Logger.info('语音交互暂停成功'); + Logger.info('语音交互已暂停'); } else { - Logger.error('语音交互暂停失败'); + Logger.error('暂停语音交互失败'); } return result; } catch (e) { - Logger.error('暂停语音交互失败: $e'); + Logger.error('暂停语音交互时发生错误: $e'); return false; } } - /// 处理来自原生端的语音交互事件 - void _handleVoiceInteractionEvent(dynamic event) { - if (event is! Map || _eventStreamController == null) return; - - final Map eventMap = event; - final String eventType = eventMap['type'] as String? ?? ''; - final int timestamp = eventMap['timestamp'] as int? ?? 0; - - // 添加时间戳日志,帮助调试 - Logger.info('收到原生端事件: $eventType, 时间戳: $timestamp, 当前时间: ${DateTime.now().millisecondsSinceEpoch}'); - - switch (eventType) { - case 'chatHistory': - final String agentId = eventMap['agentId'] as String? ?? ''; - final String userMessage = eventMap['userMessage'] as String? ?? ''; - final String assistantMessage = eventMap['assistantMessage'] as String? ?? ''; - - _eventStreamController?.add(ChatHistoryEvent( - agentId: agentId, - userMessage: userMessage, - assistantMessage: assistantMessage, - timestamp: timestamp, - )); - - // 保存聊天记录 - _saveChatHistory(agentId, userMessage, assistantMessage, timestamp); - - Logger.info('收到聊天历史事件: agentId=$agentId'); - break; - - case 'recognitionStarted': - _eventStreamController?.add(RecognitionStartedEvent( - timestamp: timestamp, - )); - break; - - default: - Logger.warning('收到未知类型的语音交互事件: $eventType'); - break; - } - } - + /// 资源释放 @override void onClose() { - // 清理资源 _eventSubscription?.cancel(); - _eventStreamController?.close(); + _eventStreamController.close(); super.onClose(); } - - /// 保存聊天记录 - void _saveChatHistory(String agentId, String userMessage, String assistantMessage, int timestamp) { - try { - // 获取ChatHistoryService实例 - final chatHistoryService = Get.find(); - - // 创建用户消息和助手消息 - final userMsg = Message( - role: 'user', - content: userMessage, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp), - ); - - final assistantMsg = Message( - role: 'assistant', - content: assistantMessage, - timestamp: DateTime.fromMillisecondsSinceEpoch(timestamp + 1), // 确保助手消息时间戳晚于用户消息 - ); - - // 加载现有历史记录 - final existingMessages = chatHistoryService.loadHistory(agentId); - - // 添加新消息 - existingMessages.addAll([userMsg, assistantMsg]); - - // 保存更新后的历史记录 - chatHistoryService.saveHistory(agentId, existingMessages); - } catch (e) { - Logger.error('保存聊天记录失败: $e'); - } - } -} - -/// 语音交互事件基类 -abstract class VoiceInteractionEvent { - final int timestamp; - - VoiceInteractionEvent({required this.timestamp}); -} - -/// 聊天历史事件 -class ChatHistoryEvent extends VoiceInteractionEvent { - final String agentId; - final String userMessage; - final String assistantMessage; - - ChatHistoryEvent({ - required this.agentId, - required this.userMessage, - required this.assistantMessage, - required int timestamp, - }) : super(timestamp: timestamp); -} - -/// 语音识别开始事件 -class RecognitionStartedEvent extends VoiceInteractionEvent { - RecognitionStartedEvent({ - required int timestamp, - }) : super(timestamp: timestamp); } \ No newline at end of file diff --git a/lib/modules/chat/controllers/chat_controller.dart b/lib/modules/chat/controllers/chat_controller.dart index 152e8d8f5..25d567714 100644 --- a/lib/modules/chat/controllers/chat_controller.dart +++ b/lib/modules/chat/controllers/chat_controller.dart @@ -2,19 +2,21 @@ import 'dart:async'; import 'package:get/get.dart'; import 'package:flutter/widgets.dart'; import '../models/message_model.dart'; +import '../../../core/utils/logger.dart'; import '../../../data/services/volcano_ai_service.dart'; import '../../../data/services/tts_service.dart'; import '../../../data/providers/agent_provider.dart'; import '../../../data/models/agent.dart'; -import '../../../core/utils/logger.dart'; +import '../../../data/services/asr_service.dart'; import '../../../data/services/chat_history_service.dart'; +import '../../../data/models/events/voice_interaction_event.dart'; import '../../../data/services/voice_interaction_service.dart'; -import '../../../data/services/asr_service.dart'; + class ChatController extends GetxController { // 服务 final VolcanoAIService _aiService = Get.find(); - late TtsService _ttsService; - late AsrService _asrService; + final TtsService _ttsService = Get.find(); + final AsrService _asrService = Get.find(); final ChatHistoryService _historyService = Get.find(); final VoiceInteractionService _voiceInteractionService = Get.find(); @@ -35,6 +37,9 @@ class ChatController extends GetxController { final RxBool isSending = false.obs; final RxBool isTtsEnabled = true.obs; final RxBool isVoiceRecognitionActive = false.obs; + + // 语音交互模式 + bool _isVoiceInteractionMode = false; // 语音识别状态 final RxString recognizedText = ''.obs; @@ -43,6 +48,9 @@ class ChatController extends GetxController { // 临时消息索引,用于更新临时消息 int _tempMessageIndex = -1; + + // 防抖计时器 + Timer? _scrollDebounceTimer; // 流式响应控制 StreamSubscription? _aiResponseSubscription; @@ -51,6 +59,12 @@ class ChatController extends GetxController { // 取消标志 bool _shouldCancelAiResponse = false; + + // 自动停止语音识别计时器 + Timer? _autoStopTimer; + + // 记录最后一次活动时间 + DateTime _lastActivityTime = DateTime.now(); @override Future onInit() async { @@ -60,7 +74,7 @@ class ChatController extends GetxController { // 从路由参数获取Agent信息 final Map args = Get.arguments ?? {}; - final String agentId = args['agentId'] ?? 'personal_assistant'; + final String agentId = args['agent'] ?? args['agentId'] ?? 'personal_assistant'; // 获取Agent final Agent? foundAgent = AgentProvider.getAgentById(agentId); @@ -72,8 +86,11 @@ class ChatController extends GetxController { agent = foundAgent; - _ttsService = Get.find(); - _asrService = Get.find(); + _ttsService.initialize(); + _asrService.initialize(); + + // 设置语音交互模式 + _isVoiceInteractionMode = args['isVoiceInteraction'] == true; // 设置TTS服务 await _ttsService.setVoice(agent.voice); @@ -94,9 +111,11 @@ class ChatController extends GetxController { // } } - // 如果playVoiceOnEnter为true,自动激活语音识别 - if (args['playVoiceOnEnter'] == true) { - startVoiceRecognition(); + // 如果isVoiceInteraction为true,自动激活语音识别 + if (args['isVoiceInteraction'] == true) { + _ttsService.speakOnce(agent.voiceWelcomeMessage ?? agent.welcomeMessage); + startVoiceRecognition(); + // 自动停止计时器会在startVoiceRecognition中启动 } // 订阅VoiceInteractionService事件 @@ -111,87 +130,65 @@ class ChatController extends GetxController { /// 订阅VoiceInteractionService事件 void _subscribeToVoiceInteractionEvents() { if (_voiceInteractionService.eventStream != null) { - _voiceInteractionSubscription = _voiceInteractionService.eventStream!.listen((event) { + _voiceInteractionSubscription = _voiceInteractionService.eventStream.listen((event) { if (event is RecognitionStartedEvent) { // 收到recognitionStarted事件时,停止当前的AI交互、录音和TTS - _handleRecognitionStartedEvent(); + _stopCurrentInteraction(); } else if (event is ChatHistoryEvent) { - // 收到chatHistory事件时,更新聊天界面 - _handleChatHistoryEvent(event); + // 收到聊天历史事件,添加到聊天记录 + _processChatHistoryEvent(event); } }); } } - /// 处理recognitionStarted事件 - void _handleRecognitionStartedEvent() { - Logger.info('收到后台语音识别开始事件,停止当前AI交互'); - - // 停止当前AI交互 - if (isSending.value || _aiResponseSubscription != null) { - _interruptCurrentResponse("收到后台语音识别开始事件,中断当前响应"); - } - - // 停止TTS - _ttsService.stop(); - - // 停止语音识别 - if (isVoiceRecognitionActive.value) { - stopVoiceRecognition(); - } - } - - /// 处理chatHistory事件 - void _handleChatHistoryEvent(ChatHistoryEvent event) { + /// 处理聊天历史事件 + void _processChatHistoryEvent(ChatHistoryEvent event) { Logger.info('收到后台聊天历史事件: agentId=${event.agentId}'); - // 检查是否是当前Agent的消息 - if (event.agentId != agent.id) { - Logger.info('收到的消息不属于当前Agent,忽略'); + // 如果消息不是空的且不是当前Agent的,不处理 + if (event.userMessage.isEmpty || event.agentId != agent.id) { return; } - // 创建用户消息和助手消息 + // 添加用户消息 final userMsg = Message( role: 'user', content: event.userMessage, timestamp: DateTime.fromMillisecondsSinceEpoch(event.timestamp), ); + // 添加助手消息 final assistantMsg = Message( role: 'assistant', content: event.assistantMessage, - timestamp: DateTime.fromMillisecondsSinceEpoch(event.timestamp + 1), // 确保助手消息时间戳晚于用户消息 - ); - - // 检查是否已存在相同内容的消息(避免重复添加) - final bool userMsgExists = messages.any((m) => - m.role == userMsg.role && - m.content == userMsg.content && - (m.timestamp.millisecondsSinceEpoch - userMsg.timestamp.millisecondsSinceEpoch).abs() < 5000 + timestamp: DateTime.fromMillisecondsSinceEpoch(event.timestamp + 1), // 助手消息稍后一毫秒 ); - final bool assistantMsgExists = messages.any((m) => - m.role == assistantMsg.role && - m.content == assistantMsg.content && - (m.timestamp.millisecondsSinceEpoch - assistantMsg.timestamp.millisecondsSinceEpoch).abs() < 5000 - ); - - // 如果消息不存在,则添加到聊天记录中 - if (!userMsgExists) { + // 防止消息重复 + if (!_isDuplicateMessage(userMsg) && !_isDuplicateMessage(assistantMsg)) { messages.add(userMsg); + messages.add(assistantMsg); } + _scrollToBottom(); + } + + /// 停止当前交互 + void _stopCurrentInteraction() { + Logger.info('收到后台语音识别开始事件,停止当前AI交互'); - if (!assistantMsgExists) { - messages.add(assistantMsg); + // 停止当前AI交互 + if (isSending.value || _aiResponseSubscription != null) { + _interruptCurrentResponse("收到后台语音识别开始事件,中断当前响应"); } - // 如果有新消息添加,刷新列表并滚动到底部 - if (!userMsgExists || !assistantMsgExists) { - messages.refresh(); + // 停止TTS + _ttsService.stop(); + + // 停止语音识别 + if (isVoiceRecognitionActive.value) { + stopVoiceRecognition(); } - _scrollToBottom(); - } @override @@ -203,6 +200,8 @@ class ChatController extends GetxController { _aiResponseSubscription?.cancel(); _recognitionSubscription?.cancel(); _voiceInteractionSubscription?.cancel(); + _autoStopTimer?.cancel(); + _scrollDebounceTimer?.cancel(); // 清理防抖计时器 // 停止TTS _ttsService.stop(); @@ -211,22 +210,28 @@ class ChatController extends GetxController { _asrService.stopContinuousRecognition(); } + // 保存当前聊天记录 + if (agent.id != null) { + _historyService.saveHistory(agent.id!, messages.toList()); + } + super.onClose(); } - /// 滚动到列表底部方法 + /// 滚动到列表底部方法,添加防抖功能 void _scrollToBottom() { - if (scrollController.hasClients && messages.isNotEmpty) { - try { - scrollController.animateTo( - scrollController.position.maxScrollExtent, - duration: Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } catch (e) { - Logger.error('滚动到底部失败', e); + + if (scrollController.hasClients && messages.isNotEmpty) { + try { + scrollController.animateTo( + scrollController.position.maxScrollExtent, + duration: Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } catch (e) { + Logger.error('滚动到底部失败', e); + } } - } } /// 中断当前响应 @@ -272,7 +277,7 @@ class ChatController extends GetxController { timestamp: DateTime.now(), ); messages.add(userMessage); - _scrollToBottom(); + // 清空输入框 inputText.value = ''; @@ -280,7 +285,10 @@ class ChatController extends GetxController { // 添加加载中的消息 final loadingMessage = Message.loading(); messages.add(loadingMessage); - _scrollToBottom(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); isSending.value = true; @@ -422,7 +430,7 @@ class ChatController extends GetxController { _voiceInteractionService.pauseVoiceInteraction(); try { - // 启动连续语音识别 + // 启动连续语音识别 // 注意:在UI更新后,使用模拟波形动画替代实际麦克风输入的波形数据 final recognitionStream = await _asrService.startContinuousRecognition(); @@ -432,9 +440,10 @@ class ChatController extends GetxController { // 重置临时消息索引 _tempMessageIndex = -1; - - + // 启动自动停止计时器 + _startAutoStopTimer(); + // 监听识别事件 _recognitionSubscription = recognitionStream.listen( _handleRecognitionEvent, @@ -449,6 +458,9 @@ class ChatController extends GetxController { // 处理语音识别事件 void _handleRecognitionEvent(RecognitionEvent event) { + // 更新活动时间 + _updateActivityTime(); + switch (event.type) { case RecognitionEventType.intermediateResult: _handleRecognizing(event.text); @@ -465,6 +477,42 @@ class ChatController extends GetxController { } } + // 更新最后活动时间 + void _updateActivityTime() { + _lastActivityTime = DateTime.now(); + } + + // 启动自动停止计时器 + void _startAutoStopTimer() { + // 取消现有计时器 + _autoStopTimer?.cancel(); + + // 初始化最后活动时间 + _updateActivityTime(); + + // 根据模式设置超时时间 + final int timeoutSeconds = _isVoiceInteractionMode ? 8 : 15; + + // 创建新的计时器,每秒检查一次 + _autoStopTimer = Timer.periodic(Duration(seconds: 1), (timer) { + // 检查是否有活动 + bool isActive = _ttsService.isSpeaking || isSpeechDetected.value; + + // 如果有活动,更新时间并继续等待 + if (isActive) { + _updateActivityTime(); + return; + } + + // 检查空闲时间 + int idleSeconds = DateTime.now().difference(_lastActivityTime).inSeconds; + if (idleSeconds >= timeoutSeconds) { + timer.cancel(); + _stopVoiceRecognitionAutomatically(); + } + }); + } + // 处理语音识别中的状态 void _handleRecognizing(String text) { if (text.isEmpty) return; @@ -476,6 +524,9 @@ class ChatController extends GetxController { isSpeechDetected.value = true; } + // 更新活动时间 + _updateActivityTime(); + // 用户开始讲话时,立即停止当前TTS和中断AI响应 if (isSending.value || _aiResponseSubscription != null) { _interruptCurrentResponse("检测到用户开始讲话,中断当前响应"); @@ -499,8 +550,9 @@ class ChatController extends GetxController { _tempMessageIndex = messages.length - 1; messages.refresh(); } - - _scrollToBottom(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _scrollToBottom(); + }); } // 处理语音识别最终结果 @@ -509,6 +561,9 @@ class ChatController extends GetxController { Logger.info('处理语音识别最终结果: $text'); recognizedText.value = text; + + // 更新活动时间 + _updateActivityTime(); // 查找并移除临时消息 if (_tempMessageIndex >= 0 && _tempMessageIndex < messages.length) { @@ -627,4 +682,19 @@ class ChatController extends GetxController { _saveChatHistory(); _scrollToBottom(); } + + void _stopVoiceRecognitionAutomatically() { + final int timeoutSeconds = _isVoiceInteractionMode ? 8 : 15; + Logger.info('${timeoutSeconds}秒无活动,自动停止语音识别'); + stopVoiceRecognition(); + } + + /// 检查是否为重复消息 + bool _isDuplicateMessage(Message message) { + return messages.any((m) => + m.role == message.role && + m.content == message.content && + (m.timestamp.millisecondsSinceEpoch - message.timestamp.millisecondsSinceEpoch).abs() < 5000 + ); + } } diff --git a/lib/modules/home/controllers/home_controller.dart b/lib/modules/home/controllers/home_controller.dart index 5e5ea4223..1c1de79d5 100644 --- a/lib/modules/home/controllers/home_controller.dart +++ b/lib/modules/home/controllers/home_controller.dart @@ -1,11 +1,10 @@ import 'package:get/get.dart'; +import 'dart:async'; import '../../../routes/app_routes.dart'; -import '../../../data/services/classic_bluetooth_service.dart' as classic; +import '../../../data/services/classic_bluetooth_service.dart'; +import '../../../data/services/bluetooth_media_button_service.dart'; import '../../../core/utils/logger.dart'; -import 'package:flutter_blue_plus/flutter_blue_plus.dart' hide BluetoothDevice; import 'package:flutter/material.dart'; -import '../../../data/services/voice_interaction_service.dart'; -import 'package:permission_handler/permission_handler.dart'; import '../../explore/controllers/explore_controller.dart'; // 连接状态枚举 @@ -21,7 +20,13 @@ class HomeController extends GetxController { final ExploreController exploreController = Get.find(); // 经典蓝牙服务 - late classic.ClassicBluetoothService _bluetoothService; + late ClassicBluetoothService _bluetoothService; + + // 蓝牙媒体按钮服务 + late BluetoothMediaButtonService _mediaButtonService; + + // 媒体按钮订阅 + StreamSubscription? _mediaButtonSubscription; // 设备连接状态 final _isConnected = false.obs; @@ -47,9 +52,14 @@ class HomeController extends GetxController { String get connectionErrorMsg => _connectionErrorMsg.value; // 当前连接的经典蓝牙设备 - final _connectedClassicDevices = [].obs; - List get connectedClassicDevices => - _connectedClassicDevices; + List get connectedClassicDevices => + _bluetoothService.connectedDevices; + + // 蓝牙状态 + RxString get bluetoothState => _bluetoothService.bluetoothState; + + // 状态更新标志,防止多次重复更新 + bool _isUpdatingStatus = false; @override void onInit() { @@ -58,6 +68,9 @@ class HomeController extends GetxController { // 初始化蓝牙服务和状态监控 _initBluetoothService(); + // 初始化蓝牙媒体按钮服务 + // _initMediaButtonService(); + // 电池模拟更新(实际应用中可以通过蓝牙获取) _simulateBatteryUpdate(); } @@ -66,7 +79,7 @@ class HomeController extends GetxController { void _initBluetoothService() { try { // 获取经典蓝牙服务实例 - _bluetoothService = Get.find(); + _bluetoothService = Get.find(); // 更新当前连接状态 _updateConnectionStatus(); @@ -79,6 +92,45 @@ class HomeController extends GetxController { } } + // 初始化蓝牙媒体按钮服务 + void _initMediaButtonService() { + try { + // 获取媒体按钮服务实例 + _mediaButtonService = Get.find(); + + // 初始化媒体按钮服务 + _mediaButtonService.initialize().then((success) { + if (success) { + // 启动按钮监听 + _mediaButtonService.startButtonListening(); + + // 设置按钮事件监听 + _setupMediaButtonListener(); + + Logger.info('蓝牙媒体按钮服务初始化成功'); + } else { + Logger.warning('蓝牙媒体按钮服务初始化失败'); + } + }); + } catch (e) { + Logger.error('初始化蓝牙媒体按钮服务失败: $e'); + } + } + + // 设置媒体按钮事件监听 + void _setupMediaButtonListener() { + // 取消之前的订阅 + _mediaButtonSubscription?.cancel(); + + // 设置新的订阅 + _mediaButtonSubscription = _mediaButtonService.buttonEvents.listen((event) { + Logger.info('收到媒体按钮事件: $event'); + + // 处理按钮事件 + if (event.buttonType == MediaButtonType.playPause) {} + }); + } + // 电池电量模拟更新 void _simulateBatteryUpdate() { // 每5分钟更新一次电池电量(模拟) @@ -97,44 +149,49 @@ class HomeController extends GetxController { // 更新连接状态 void _updateConnectionStatus() { - // 检查当前设备 - final currentDevice = _bluetoothService.currentDevice.value; - - if (currentDevice != null) { - _isConnected.value = true; - _connectionStatus.value = ConnectionStatus.connected; - } else { - _isConnected.value = false; - _connectionStatus.value = ConnectionStatus.disconnected; - } + // 如果正在更新,则跳过 + if (_isUpdatingStatus) return; + + _isUpdatingStatus = true; + + try { + // 检查当前设备 + final currentDevice = _bluetoothService.currentDevice.value; + + if (currentDevice != null) { + _isConnected.value = true; + _connectionStatus.value = ConnectionStatus.connected; + } else { + _isConnected.value = false; + _connectionStatus.value = ConnectionStatus.disconnected; + } - update(); // 通知UI刷新 + update(); // 通知UI刷新 + } finally { + _isUpdatingStatus = false; + } } // 设置蓝牙监听器 void _setupBluetoothListeners() { - - // 监听设备连接 - _bluetoothService.onDeviceConnected((device) { - Logger.info('监听到蓝牙设备连接: ${device.name}'); - _isConnected.value = true; - _connectionStatus.value = ConnectionStatus.connected; - update(); - }); - - // 监听设备断开 - _bluetoothService.onDeviceDisconnected((device) { - Logger.info('监听到蓝牙设备断开: ${device.name}'); - _isConnected.value = false; - _connectionStatus.value = ConnectionStatus.disconnected; - _deviceBattery.value = null; + // 只监听当前设备和蓝牙状态 + + // 监听当前设备状态 + ever(_bluetoothService.currentDevice, (device) { + if (device != null) { + _isConnected.value = true; + _connectionStatus.value = ConnectionStatus.connected; + } else if (_isConnected.value) { + // 如果之前连接着设备但现在变为null,那么设备已断开 + _isConnected.value = false; + _connectionStatus.value = ConnectionStatus.disconnected; + _deviceBattery.value = null; + } update(); }); - // 监听蓝牙状态变化 - _bluetoothService.onBluetoothStateChanged((state) { - Logger.info('监听到蓝牙状态变化: $state'); - + // 蓝牙状态变化监听 + ever(_bluetoothService.bluetoothState, (state) { if (state == 'off') { _isConnected.value = false; _connectionStatus.value = ConnectionStatus.disconnected; @@ -146,6 +203,12 @@ class HomeController extends GetxController { // 刷新设备信息 Future refreshDeviceInfo() async { + return; + // 如果正在更新,则跳过 + if (_isUpdatingStatus) return; + + _isUpdatingStatus = true; + try { // 设置状态为连接中 _connectionStatus.value = ConnectionStatus.connecting; @@ -160,10 +223,7 @@ class HomeController extends GetxController { // 根据结果更新状态 if (!hasDevices) { Logger.info('刷新设备:未找到设备'); - // 这里可以设置状态变量而不是使用snackbar - _connectionErrorMsg.value = _bluetoothService.errorMessage.value.isEmpty - ? 'noDeviceFound'.tr // 未找到已连接的设备 - : _bluetoothService.errorMessage.value; + _connectionErrorMsg.value = '未找到已连接的设备'; } else { _connectionErrorMsg.value = ''; Logger.info('刷新设备:成功'); @@ -173,6 +233,8 @@ class HomeController extends GetxController { _connectionStatus.value = ConnectionStatus.failed; _connectionErrorMsg.value = 'failedDeviceInformation'.tr; // 无法获取设备信息 update(); + } finally { + _isUpdatingStatus = false; } } @@ -188,7 +250,7 @@ class HomeController extends GetxController { // 打开AI伴侣功能 void openAIGirlfriend() { - exploreController.onAIFeatureTap(2);//进入AI女友页面 + exploreController.onAIFeatureTap(2); //进入AI女友页面 } // 展示AI功能 @@ -214,6 +276,11 @@ class HomeController extends GetxController { // 获取连接状态对应的文本 String getConnectionStatusText() { + // 如果蓝牙关闭,优先显示蓝牙状态 + if (_bluetoothService.bluetoothState.value == 'off') { + return '蓝牙已关闭'; + } + switch (_connectionStatus.value) { case ConnectionStatus.connected: return 'connected'.tr; // 已连接 @@ -256,4 +323,12 @@ class HomeController extends GetxController { final device = _bluetoothService.currentDevice.value; return device?.address ?? 'unknown'.tr; // 未知 } + + @override + void onClose() { + // 取消按钮事件流订阅 + _mediaButtonSubscription?.cancel(); + + super.onClose(); + } } diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 93e4d5b9f..12f90875b 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -102,6 +102,8 @@ class HomeView extends GetView { _buildSectionTitle(isDarkMode), SizedBox(height: 16.h), _buildPremiumAILayout(), + + ], ), ), @@ -667,14 +669,16 @@ class HomeView extends GetView { overflow: TextOverflow.ellipsis, ), SizedBox(height: 4.h), - Text( - description, - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - fontSize: 12.sp, + Flexible( + child: Text( + description, + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontSize: 12.sp, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), ], ), @@ -698,14 +702,16 @@ class HomeView extends GetView { overflow: TextOverflow.ellipsis, ), SizedBox(height: 2.h), - Text( - description, - style: TextStyle( - color: isDarkMode ? Colors.grey[400] : Colors.grey[600], - fontSize: 10.sp, + Flexible( + child: Text( + description, + style: TextStyle( + color: isDarkMode ? Colors.grey[400] : Colors.grey[600], + fontSize: 10.sp, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, ), ], ), diff --git a/lib/modules/pairing/controllers/pairing_controller.dart b/lib/modules/pairing/controllers/pairing_controller.dart index 3ca16f47f..b1f07dfdb 100644 --- a/lib/modules/pairing/controllers/pairing_controller.dart +++ b/lib/modules/pairing/controllers/pairing_controller.dart @@ -1,18 +1,23 @@ -import 'package:deep_voice/data/services/voice_interaction_service.dart'; +import 'package:app_settings/app_settings.dart'; import 'package:get/get.dart'; import 'dart:async'; import '../../../routes/app_routes.dart'; -import '../../../data/services/classic_bluetooth_service.dart' as classic; +import '../../../data/services/classic_bluetooth_service.dart'; import '../../../core/utils/logger.dart'; -import 'package:flutter/material.dart'; -import 'package:permission_handler/permission_handler.dart'; +import 'dart:io'; +import '../../../data/services/voice_interaction_service.dart'; /// 简化的配对控制器,仅用于显示已连接经典蓝牙设备和处理基本配对流程 class PairingController extends GetxController { static PairingController get to => Get.find(); // 经典蓝牙服务 - late classic.ClassicBluetoothService _bluetoothService; + final ClassicBluetoothService _bluetoothService = + Get.find(); + + // 语音交互服务 + final VoiceInteractionService _voiceInteractionService = + Get.find(); // 是否开始搜索 final _isSearching = false.obs; @@ -27,21 +32,17 @@ class PairingController extends GetxController { bool get isBluetoothServiceAvailable => _isBluetoothServiceAvailable.value; // 当前连接设备 - Rx get currentDevice => - _bluetoothService.currentDevice; + Rx get currentDevice => _bluetoothService.currentDevice; // 连接设备列表 - RxList get connectedDevices => + RxList get connectedDevices => _bluetoothService.connectedDevices; - // 错误信息 - RxString get errorMessage => _bluetoothService.errorMessage; + // 蓝牙状态 + RxString get bluetoothState => _bluetoothService.bluetoothState; - // 连接设备列表(兼容旧代码) - List get connectedClassicDevices => - _bluetoothService.currentDevice.value != null - ? [_bluetoothService.currentDevice.value!] - : []; + // 错误信息 + final errorMessage = ''.obs; // 状态描述(用于UI显示) final statusDescription = 'initializing'.tr.obs; // 正在初始化... @@ -49,23 +50,70 @@ class PairingController extends GetxController { // 加载状态 final isLoading = false.obs; + // 是否正在跳转到主页 + bool _isNavigatingToHome = false; + @override void onInit() { super.onInit(); _initialize(); } - /// 完整的初始化流程 + /// 设置状态变化监听 + void _setupStatusListeners() { + if (!_isBluetoothServiceAvailable.value) return; + + // 监听蓝牙开关状态 + ever(_bluetoothService.bluetoothState, (state) { + _updateStatusDescription(); + }); + + // 监听当前设备状态 + ever(_bluetoothService.currentDevice, (device) { + _updateStatusDescription(); + + // 如果有设备连接,自动跳转到主页 + if (device != null) { + _autoNavigateToHome(); + } + }); + } + + /// 更新状态描述 + void _updateStatusDescription() { + if (_bluetoothService.bluetoothState.value == 'off') { + statusDescription.value = '蓝牙已关闭,请打开蓝牙'; + return; + } + + if (_bluetoothService.currentDevice.value != null) { + statusDescription.value = + '已连接到设备: ${_bluetoothService.currentDevice.value!.name}'; + return; + } + + if (_bluetoothService.connectedDevices.isNotEmpty) { + statusDescription.value = + '找到已连接的设备: ${_bluetoothService.connectedDevices.first.name}'; + return; + } + + if (isLoading.value) { + statusDescription.value = '正在搜索设备...'; + return; + } + + statusDescription.value = '未找到已连接的蓝牙设备'; + } + + /// 初始化流程 Future _initialize() async { try { - VoiceInteractionService.to.initialize(); - VoiceInteractionService.to.startService(); + // 初始化语音交互服务 + _voiceInteractionService.initialize(); isLoading.value = true; - statusDescription.value = 'initializing'.tr; // 正在初始化... - - // 1. 获取蓝牙服务 - _bluetoothService = Get.find(); + statusDescription.value = '正在初始化...'; // 确保蓝牙服务已初始化 if (!await _bluetoothService.initialize()) { @@ -76,33 +124,24 @@ class PairingController extends GetxController { isLoading.value = false; return; } - - // 设置连接监听器,确保在页面完全加载后设置,避免过早监听可能导致的问题 - _setupDeviceListeners(); - - // 2. 检查权限 - statusDescription.value = 'checkingPermissions'.tr; // 正在检查权限... - final hasPermission = - await _bluetoothService.checkAndRequestPermissions(); - if (!hasPermission) { - statusDescription.value = 'lackPermissions'.tr; // 缺少蓝牙权限 - isLoading.value = false; - return; + _setupStatusListeners(); + // 检查权限 + if (Platform.isAndroid) { + statusDescription.value = '正在检查权限...'; + final hasPermission = await _bluetoothService.checkPermissions(); + if (!hasPermission) { + statusDescription.value = '缺少蓝牙权限'; + isLoading.value = false; + return; + } } - // 3. 刷新设备列表 - statusDescription.value = 'searchingDevice'.tr; // 正在搜索设备... + // 刷新设备列表 + statusDescription.value = '正在搜索设备...'; await refreshConnectedDevices(); - // 4. 处理结果 - if (_bluetoothService.connectedDevices.isNotEmpty) { - statusDescription.value = 'findConnectedDevice'.tr; // 找到已连接的设备 - // 延迟跳转到主页 - _autoNavigateToHome(); - } else { - // 没有找到设备 - statusDescription.value = 'notFound'.tr; // 未找到已连接的蓝牙设备 - } + // 更新状态描述 + _updateStatusDescription(); } catch (e) { Logger.error('初始化配对控制器失败: $e'); statusDescription.value = 'initializationFailure'.tr; // 初始化失败 @@ -112,32 +151,6 @@ class PairingController extends GetxController { } } - /// 设置设备监听器 - void _setupDeviceListeners() { - try { - if (!_isBluetoothServiceAvailable.value) return; - - // 监听设备连接事件 - _bluetoothService.onDeviceConnected((device) { - Logger.info('检测到蓝牙设备连接: ${device.name}'); - - // 更新状态并跳转到主页 - statusDescription.value = - '${'connectedDevice'.tr} ${device.name}'; // 已连接到设备: - - // 立即跳转到主页 - _navigateToHome(); - }); - } catch (e) { - Logger.error('设置设备监听器失败: $e'); - } - } - - /// 立即跳转到主页 - void _navigateToHome() { - Get.offAllNamed(Routes.home); - } - /// 刷新设备 Future refreshConnectedDevices() async { try { @@ -146,14 +159,8 @@ class PairingController extends GetxController { final hasDevices = await _bluetoothService.refreshConnectedDevices(); - if (hasDevices) { - statusDescription.value = 'findConnectedDevice'.tr; // 找到已连接的设备 - // 延迟跳转到主页 - _autoNavigateToHome(); - } else { - // 没有找到设备 - statusDescription.value = 'notFound'.tr; // 未找到已连接的蓝牙设备 - } + // 更新状态描述 + _updateStatusDescription(); } catch (e) { Logger.error('刷新设备失败: $e'); statusDescription.value = 'failedRefreshDevice'.tr; // 刷新设备失败 @@ -164,7 +171,12 @@ class PairingController extends GetxController { /// 自动跳转到主页 void _autoNavigateToHome() { - if (_bluetoothService.connectedDevices.isEmpty) return; + // 如果已经在跳转或设备列表为空,则不执行 + if (_isNavigatingToHome || _bluetoothService.connectedDevices.isEmpty) + return; + + // 设置跳转标志 + _isNavigatingToHome = true; Logger.info('已找到连接的设备,自动跳转到主页'); @@ -175,7 +187,12 @@ class PairingController extends GetxController { /// 打开系统蓝牙设置 void openBluetoothSettings() { - _bluetoothService.openBluetoothSettings(); + try { + // 使用app_settings插件打开蓝牙设置 + AppSettings.openAppSettings(type: AppSettingsType.bluetooth); + } catch (e) { + Logger.error('打开蓝牙设置失败: $e'); + } } /// 刷新设备(兼容旧代码) @@ -185,6 +202,8 @@ class PairingController extends GetxController { /// 跳过连接 void skipConnecting() { + // 设置跳转标志防止自动跳转再次触发 + _isNavigatingToHome = true; Get.offAllNamed(Routes.home); } } diff --git a/lib/modules/pairing/views/pairing_view.dart b/lib/modules/pairing/views/pairing_view.dart index 5775f379f..45facd71b 100644 --- a/lib/modules/pairing/views/pairing_view.dart +++ b/lib/modules/pairing/views/pairing_view.dart @@ -200,7 +200,7 @@ class PairingView extends GetView { ), // 设备列表(有设备时才显示) - if (controller.connectedClassicDevices.isNotEmpty) ...[ + if (controller.connectedDevices.isNotEmpty) ...[ SizedBox(height: 24.h), _buildConnectedDevicesList(isDarkMode), ], @@ -212,7 +212,7 @@ class PairingView extends GetView { // 已连接设备列表 Widget _buildConnectedDevicesList(bool isDarkMode) { return Obx(() { - final devices = controller.connectedClassicDevices; + final devices = controller.connectedDevices; if (devices.isEmpty) { return const SizedBox.shrink(); diff --git a/lib/modules/settings/controllers/settings_controller.dart b/lib/modules/settings/controllers/settings_controller.dart index a34b590f3..a1f1890b1 100644 --- a/lib/modules/settings/controllers/settings_controller.dart +++ b/lib/modules/settings/controllers/settings_controller.dart @@ -59,6 +59,8 @@ class SettingsController extends GetxController { }, ]; + RxString timbreName = '默认音色'.obs; + //用户信息 static const String userKey = 'user_info'; //final _storage=GetStorage(); @@ -72,6 +74,8 @@ class SettingsController extends GetxController { // 获取系统语言 _getLanguage(); + _getTimbre(); + // 从存储中加载上次保存的主题设置 final savedDarkMode = _themeService.isSavedDarkMode(); @@ -144,6 +148,21 @@ class SettingsController extends GetxController { language.value = data; } + // 获取音色 + void _getTimbre() { + List? timbreList = _storage.read('timbreList'); + String? timbre = _storage.read('timbre'); + if (timbreList != null && timbre != null) { + Map data = timbreList.firstWhere( + (element) => element['type'] == timbre, + orElse: () => {}, + ); + if (data.isNotEmpty) { + timbreName.value = data['name']; + } + } + } + void setLanguage(LanguageModel data) { if (data.value != 'system') { List languageList = data.value.split('_'); diff --git a/lib/modules/settings/views/settings_view.dart b/lib/modules/settings/views/settings_view.dart index 53864d00c..01c8e0243 100644 --- a/lib/modules/settings/views/settings_view.dart +++ b/lib/modules/settings/views/settings_view.dart @@ -339,6 +339,32 @@ class SettingsView extends GetView { // 语音服务选择 _buildSpeechServiceSetting(isDarkMode), + Divider( + height: 1, + color: isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[200]), + Obx( + () => _buildNavigationSetting( + title: '音色服务', + subtitle: '选择音色语音合成服务', + icon: Icons.photo_camera_front_outlined, + iconBgColor: isDarkMode + ? Colors.cyan[900]!.withOpacity(0.3) + : Colors.cyan[100]!, + iconColor: + isDarkMode ? Colors.cyan[300]! : Colors.cyan[600]!, + value: controller.timbreName.value, + valueStyle: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.grey[500], + ), + onTap: () { + Get.toNamed(Routes.timbre); + }, + isDarkMode: isDarkMode, + ), + ), Divider( height: 1, color: isDarkMode diff --git a/lib/modules/timbre/controllers/timbre_controller.dart b/lib/modules/timbre/controllers/timbre_controller.dart index 3fd12cf53..49ff05d10 100644 --- a/lib/modules/timbre/controllers/timbre_controller.dart +++ b/lib/modules/timbre/controllers/timbre_controller.dart @@ -1,14 +1,303 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; import 'package:get/get.dart'; +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +import '../../../data/services/speech_factory.dart'; +import '../../settings/controllers/settings_controller.dart'; class TimbreController extends GetxController { - RxList dataList = [ - {'type': '1', 'name': '默认音色', 'description': '微软云端音色服务'}, - {'type': '2', 'name': '声音复刻', 'description': '复刻本人音色服务'} - ].obs; + final SettingsController settingsController = Get.find(); + + final GetStorage _storage = GetStorage(); + final _record = AudioRecorder(); + + final String _appId = "43d87d22"; + final String _appKey = "2bd01ce06ac35957fc0c082f9b6284ec"; + + RxList dataList = [].obs; RxString type = '1'.obs; + RxString text = ''.obs; + + String _token = ''; + + int _textId = 0; + int _segId = 0; + String _taskId = ''; + @override void onInit() { super.onInit(); + _getDataList(); + } + + @override + void onClose() { + _record.dispose(); + super.onClose(); + } + + void _getDataList() { + List? timbreList = _storage.read('timbreList'); + String? timbre = _storage.read('timbre'); + if (timbreList != null) { + dataList.value = timbreList; + } else { + dataList.add({ + 'type': '1', + 'name': '默认音色', + 'description': '微软云端音色服务', + 'isUsable': true + }); + _storage.write('timbreList', [ + { + 'type': '1', + 'name': '默认音色', + 'description': '微软云端音色服务', + 'isUsable': true + } + ]); + } + if (timbre != null) { + type.value = timbre; + } + } + + // 录制声音 + void startRecorder() async { + if (_token.isEmpty) { + await _getToken(); + } + await _getTraintext(); + await _addTask(); + final directory = await getApplicationDocumentsDirectory(); + final path = + '${directory.path}/recording_${DateTime.now().millisecondsSinceEpoch}.wav'; + await _record.start( + const RecordConfig( + encoder: AudioEncoder.wav, + sampleRate: 24000, + numChannels: 1, + ), + path: path, + ); + } + + // 获取token + Future _getToken() async { + int timeStamp = DateTime.now().millisecondsSinceEpoch; + String data = + '{"base":{"appid":"$_appId","version":"v1","timestamp":"$timeStamp"},"model":"remote"}'; + String keySignInput = _appKey + timeStamp.toString(); + List keySignBytes = utf8.encode(keySignInput); + Digest keySignDigest = md5.convert(keySignBytes); + String keySign = keySignDigest.toString(); + String signInput = keySign + data; + List signBytes = utf8.encode(signInput); + Digest signDigest = md5.convert(signBytes); + String sign = signDigest.toString(); + + final response = await http.post( + Uri.parse('http://avatar-hci.xfyousheng.com/aiauth/v1/token'), + headers: { + 'Content-Type': 'application/json', + 'Authorization': sign, + }, + body: data, + ); + if (response.statusCode == 200) { + final body = json.decode(response.body); + if (body['retcode'] == '000000') { + _token = body['accesstoken']; + } + } + } + + // 获取训练文本 + Future _getTraintext() async { + String data = '{"textId":"5001"}'; + Map sign = _sign(data); + + final response = await http.post( + Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/traintext'), + headers: { + 'Content-Type': 'application/json', + 'X-Sign': sign['sign'], + 'X-Token': _token, + 'X-AppId': _appId, + 'X-Time': sign['timeStamp'], + }, + body: data, + ); + if (response.statusCode == 200) { + final body = json.decode(response.body); + if (body['code'] == 0) { + _textId = body['data']['textId']; + Map textSegs = body['data']['textSegs'].first; + _segId = textSegs['segId']; + text.value = _decodeCorruptedText(textSegs['segText']); + } + } + } + + // 创建训练任务 + Future _addTask() async { + String data = '{"resourceType":"12"}'; + Map sign = _sign(data); + + final response = await http.post( + Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/add'), + headers: { + 'Content-Type': 'application/json', + 'X-Sign': sign['sign'], + 'X-Token': _token, + 'X-AppId': _appId, + 'X-Time': sign['timeStamp'], + }, + body: data, + ); + if (response.statusCode == 200) { + final body = json.decode(response.body); + if (body['code'] == 0) { + _taskId = body['data']; + } + } + } + + Map _sign(String data) { + int timeStamp = DateTime.now().millisecondsSinceEpoch; + String keySignInput = _appKey + timeStamp.toString(); + List keySignBytes = utf8.encode(keySignInput); + Digest keySignDigest = md5.convert(keySignBytes); + String keySign = keySignDigest.toString(); + String signInput = keySign + data; + List signBytes = utf8.encode(signInput); + Digest signDigest = md5.convert(signBytes); + String sign = signDigest.toString(); + return {'sign': sign, 'timeStamp': timeStamp.toString()}; + } + + String _decodeCorruptedText(String corruptedText) { + List latin1Bytes = + corruptedText.codeUnits.map((ch) => ch & 0xFF).toList(); + return utf8.decode(latin1Bytes); + } + + // 完成录制 + void completeRecorder() async { + final audioPath = await _record.stop(); + String data = + '{"taskId":"$_taskId","textId":"$_textId","textSegId":"$_segId"}'; + Map sign = _sign(data); + + final uri = Uri.parse( + 'http://opentrain.xfyousheng.com/voice_train/task/submitWithAudio'); + final request = http.MultipartRequest('POST', uri); + request.files.add(await http.MultipartFile.fromPath( + 'file', + audioPath!, + filename: path.basename(audioPath), + )); + request.fields.addAll({ + 'taskId': _taskId, + 'textId': '$_textId', + 'textSegId': '$_segId', + }); + request.headers.addAll({ + "X-Sign": sign['sign'], + "X-Token": _token, + "X-AppId": _appId, + "X-Time": sign['timeStamp'], + }); + final response = await request.send(); + if (response.statusCode == 200) { + final responseBody = await response.stream.bytesToString(); + final body = json.decode(responseBody); + if (body['code'] == 0) { + text.value = ''; + dataList.add({ + 'type': _taskId, + 'name': '声音复刻${dataList.length}', + 'description': '复刻本人音色服务', + 'isUsable': false + }); + await _getTaskResult(_taskId); + } + } + } + + // 查询训练任务状态 + Future _getTaskResult(String id) async { + String data = '{"taskId":"$id"}'; + Map sign = _sign(data); + + final response = await http.post( + Uri.parse('http://opentrain.xfyousheng.com/voice_train/task/result'), + headers: { + 'Content-Type': 'application/json', + 'X-Sign': sign['sign'], + 'X-Token': _token, + 'X-AppId': _appId, + 'X-Time': sign['timeStamp'], + }, + body: data, + ); + if (response.statusCode == 200) { + final body = json.decode(response.body); + if (body['code'] == 0) { + if (body['data']['trainStatus'] == -1) { + await _getTaskResult(id); + } + if (body['data']['trainStatus'] == 1) { + Map? dataMap = + dataList.firstWhereOrNull((item) => item['type'] == id); + if (dataMap != null) { + dataMap['type'] = body['data']['assetId']; + dataMap['isUsable'] = true; + dataList.refresh(); + _storage.write('timbreList', dataList); + } + } + if (body['data']['trainStatus'] == 0) { + Get.snackbar( + 'error'.tr, // 错误 + _decodeCorruptedText(body['data']['failedDesc']), + snackPosition: SnackPosition.TOP, + ); + } + } + } + } + + // 取消 + void cancel() { + text.value = ''; + _record.stop(); + Get.back(); + } + + // 选择音色 + void selectTone(Map data) { + if (!data['isUsable'] || type.value == data['type']) return; + type.value = data['type']; + settingsController.timbreName.value = data['name']; + _storage.write('timbre', data['type']); + setSpeechServiceType(); + } + + // 设置语音音色服务类型 + void setSpeechServiceType() { + try { + final factory = Get.find(); + factory.setTimbreServiceType(); + } catch (e) { + // 处理错误 + } } } diff --git a/lib/modules/timbre/views/timbre_view.dart b/lib/modules/timbre/views/timbre_view.dart index 37bdd8d45..dd82c8d55 100644 --- a/lib/modules/timbre/views/timbre_view.dart +++ b/lib/modules/timbre/views/timbre_view.dart @@ -60,7 +60,69 @@ class TimbreView extends GetView { ), ), GestureDetector( - onTap: () {}, + onTap: () { + controller.startRecorder(); + Get.dialog( + barrierDismissible: false, + AlertDialog( + backgroundColor: isDarkMode ? Colors.grey[900] : Colors.white, + title: Text( + '请朗读', + style: TextStyle( + color: isDarkMode ? Colors.white : Colors.black87, + ), + ), + content: Obx( + () => controller.text.value.isNotEmpty + ? Text( + controller.text.value, + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ) + : Container( + width: 40.w, + height: 40.w, + alignment: Alignment.center, + child: CircularProgressIndicator( + strokeWidth: 2.w, + valueColor: const AlwaysStoppedAnimation( + Colors.blue), + ), + ), + ), + actions: [ + TextButton( + onPressed: () { + controller.cancel(); + }, + child: Text( + 'cancel'.tr, // 取消 + style: TextStyle( + color: isDarkMode ? Colors.white70 : Colors.black54, + ), + ), + ), + Obx(() => controller.text.value.isNotEmpty + ? TextButton( + onPressed: () { + Get.back(); + controller.completeRecorder(); + }, + child: Text( + '完成录制', + style: TextStyle( + color: isDarkMode + ? Colors.red[300] + : Colors.red[600], + ), + ), + ) + : const SizedBox()), + ], + ), + ); + }, child: Container( width: 130.w, height: 48.w, @@ -89,7 +151,7 @@ class TimbreView extends GetView { Widget _item(bool isDarkMode, Map data) { return InkWell( onTap: () { - controller.type.value = data['type']; + controller.selectTone(data); }, child: Obx( () => Container( @@ -139,26 +201,36 @@ class TimbreView extends GetView { ], ), ), - Container( - width: 24.w, - height: 24.w, - margin: EdgeInsets.only(left: 12.w), - decoration: BoxDecoration( - color: controller.type.value == data['type'] - ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) - : (isDarkMode - ? Colors.white.withOpacity(0.1) - : Colors.grey[300]), - shape: BoxShape.circle, - ), - child: controller.type.value == data['type'] - ? Icon( - Icons.check, - size: 16.w, - color: Colors.white, - ) - : null, - ), + data['isUsable'] + ? Container( + width: 24.w, + height: 24.w, + margin: EdgeInsets.only(left: 12.w), + decoration: BoxDecoration( + color: controller.type.value == data['type'] + ? (isDarkMode ? Colors.teal[300] : Colors.teal[600]) + : (isDarkMode + ? Colors.white.withOpacity(0.1) + : Colors.grey[300]), + shape: BoxShape.circle, + ), + child: controller.type.value == data['type'] + ? Icon( + Icons.check, + size: 16.w, + color: Colors.white, + ) + : null, + ) + : SizedBox( + width: 24.w, + height: 24.w, + child: CircularProgressIndicator( + strokeWidth: 2.w, + valueColor: + const AlwaysStoppedAnimation(Colors.blue), + ), + ), ], ), ), diff --git a/lib/modules/translation/bindings/translation_binding.dart b/lib/modules/translation/bindings/translation_binding.dart index a31ed7a5b..b9071e619 100644 --- a/lib/modules/translation/bindings/translation_binding.dart +++ b/lib/modules/translation/bindings/translation_binding.dart @@ -1,19 +1,10 @@ import 'package:get/get.dart'; import '../controllers/translation_controller.dart'; -import '../../../data/services/speech_impl/azure_asr_service.dart'; -import '../../../data/services/speech_impl/azure_tts_service.dart'; -import '../../../data/services/asr_service.dart'; -import '../../../data/services/tts_service.dart'; -import '../../../data/services/volcano_translation_service.dart'; -import '../../../data/services/language_manager.dart'; + class TranslationBinding implements Bindings { @override void dependencies() { - Get.put(LanguageManager(), permanent: true); - Get.lazyPut(() => AzureAsrService(), fenix: true); - Get.lazyPut(() => AzureTtsService(), fenix: true); - Get.put(VolcanoTranslationService(), permanent: true); Get.put(TranslationController()); } } \ No newline at end of file diff --git a/lib/modules/translation/controllers/translation_controller.dart b/lib/modules/translation/controllers/translation_controller.dart index fd93019bf..59822e45f 100644 --- a/lib/modules/translation/controllers/translation_controller.dart +++ b/lib/modules/translation/controllers/translation_controller.dart @@ -7,6 +7,7 @@ import '../../../data/services/volcano_translation_service.dart'; import '../../../data/services/tts_service.dart'; import '../../../data/services/language_manager.dart'; import '../../../core/utils/logger.dart'; +import '../../../data/models/events/voice_interaction_event.dart'; import '../../../data/services/voice_interaction_service.dart'; import '../../../data/services/asr_service.dart'; class TranslationController extends GetxController { @@ -22,7 +23,7 @@ class TranslationController extends GetxController { static const String _historyKey = 'translation_history'; static const String _sourceLanguageKey = 'translation_source_language'; static const String _targetLanguageKey = 'translation_target_language'; - static const int _maxHistoryItems = 100; + static const int _maxHistoryItems = 10; // 状态变量 final isRecognizing = false.obs; @@ -38,9 +39,16 @@ class TranslationController extends GetxController { // 翻译历史和当前项 final translationHistory = [].obs; - final Rx currentItem = Rx(null); String? currentSessionId; + // 兼容旧版视图的计算属性 + Rx get currentItem { + if (translationHistory.isNotEmpty && translationHistory.last.isIntermediate) { + return Rx(translationHistory.last); + } + return Rx(null); + } + // UI相关 final ScrollController scrollController = ScrollController(); @@ -70,8 +78,8 @@ class TranslationController extends GetxController { targetLanguageCode.value = targetAsrCode; } - // 订阅VoiceInteractionService事件 - _subscribeToVoiceInteractionEvents(); + // 订阅原生服务事件 + _subscribeToVoiceInteractionService(); // 加载历史记录 loadTranslationHistory(); @@ -82,22 +90,17 @@ class TranslationController extends GetxController { // 使用微任务在布局完成后自动滚动到底部 WidgetsBinding.instance.addPostFrameCallback((_) { _scrollToBottom(); - - // 额外的延迟滚动,确保在复杂视图中也能正确滚动 - Future.delayed(const Duration(milliseconds: 500), () { - _scrollToBottom(); - }); + }); } - /// 订阅VoiceInteractionService事件 - void _subscribeToVoiceInteractionEvents() { + /// 订阅原生服务事件 + void _subscribeToVoiceInteractionService() { if (_voiceInteractionService.eventStream != null) { _voiceInteractionSubscription = _voiceInteractionService.eventStream!.listen((event) { if (event is RecognitionStartedEvent) { - // 收到recognitionStarted事件时,停止当前的AI交互、录音和TTS - stopAll(); - } + _handleVoiceInteractionStarted(); + } }); } } @@ -126,7 +129,6 @@ class TranslationController extends GetxController { Future _initServices() async { try { await _translationService.initialize(); - await _ttsService.initialize(); // 初始化 ASR 服务时,明确指定需要支持的语言 final List asrSupportedLanguages = [sourceLanguageCode.value, targetLanguageCode.value]; @@ -173,16 +175,20 @@ class TranslationController extends GetxController { currentSessionId = DateTime.now().millisecondsSinceEpoch.toString(); - // 初始化新的翻译项 - currentItem.value = TranslationItem( - sourceText: '', - translatedText: '', - sourceLanguageCode: sourceLanguageCode.value, - targetLanguageCode: targetLanguageCode.value, - timestamp: DateTime.now(), - sessionId: currentSessionId!, - isFirstInSession: true, - ); + // // 初始化新的翻译项 + // final newItem = TranslationItem( + // sourceText: '', + // translatedText: '', + // sourceLanguageCode: sourceLanguageCode.value, + // targetLanguageCode: targetLanguageCode.value, + // timestamp: DateTime.now(), + // sessionId: currentSessionId!, + // isFirstInSession: true, + // isIntermediate: true, + // ); + + // // 添加到历史记录 + // translationHistory.add(newItem); // 开始连续语音识别 final recognitionStream = await _asrService.startContinuousRecognition(); @@ -227,21 +233,20 @@ class TranslationController extends GetxController { isRecognizing.value = false; // 如果当前有中间结果,将其作为最终结果处理 - if (currentItem.value != null && currentItem.value!.sourceText.isNotEmpty) { + if (translationHistory.isNotEmpty && translationHistory.last.isIntermediate && translationHistory.last.sourceText.isNotEmpty) { // 标记为最终结果 - currentItem.value!.isIntermediate = false; + translationHistory.last.isIntermediate = false; + translationHistory.refresh(); // 如果还没有翻译结果,尝试翻译 - if (currentItem.value!.translatedText.isEmpty) { - await translateText(currentItem.value!.sourceText); + if (translationHistory.last.translatedText.isEmpty) { + await translateText(translationHistory.last.sourceText, translationHistory.last.timestamp, isFinal: true); } - translationHistory.add(currentItem.value!); saveTranslationHistory(); } currentSessionId = null; - currentItem.value = null; } catch (e) { Logger.error('停止语音识别失败: $e'); } @@ -251,9 +256,9 @@ class TranslationController extends GetxController { void handleIntermediateResult(String text) { if (text.isEmpty) return; - if (currentItem.value == null) { + if (translationHistory.isEmpty || !translationHistory.last.isIntermediate) { currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); - currentItem.value = TranslationItem( + final newItem = TranslationItem( sourceText: text, translatedText: '', sourceLanguageCode: sourceLanguageCode.value, @@ -263,13 +268,14 @@ class TranslationController extends GetxController { isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: true, ); + + translationHistory.add(newItem); // 添加新项后滚动到底部 _scrollToBottom(); } else { // 更新当前项 - currentItem.value!.sourceText = text; - currentItem.value!.isIntermediate = true; - currentItem.refresh(); + translationHistory.last.sourceText = text; + translationHistory.refresh(); // 更新后滚动到底部 _scrollToBottom(); } @@ -277,45 +283,53 @@ class TranslationController extends GetxController { // 防抖处理翻译请求 _translationDebounceTimer?.cancel(); _translationDebounceTimer = Timer(const Duration(milliseconds: 300), () { - if (currentItem.value != null && currentItem.value!.sourceText.isNotEmpty) { - translateText(currentItem.value!.sourceText); + if (translationHistory.isNotEmpty && translationHistory.last.isIntermediate && translationHistory.last.sourceText.isNotEmpty) { + translateText(translationHistory.last.sourceText, translationHistory.last.timestamp, isFinal: false); } }); } // 处理最终识别结果 Future handleFinalResult(String text) async { - if (text.isEmpty || currentItem.value == null) return; + if (text.isEmpty || translationHistory.isEmpty) return; - // 更新当前项,但不立即添加到历史记录 - currentItem.value!.sourceText = text; - currentItem.value!.isIntermediate = false; - currentItem.refresh(); - // 更新后滚动到底部 - _scrollToBottom(); - - // 先翻译,保持UI稳定 - final translationResult = await translateText(text); - - // 翻译完成后再添加到历史记录 - if (currentItem.value != null) { - translationHistory.add(currentItem.value!); - saveTranslationHistory(); - // 添加到历史记录后滚动到底部 + // 更新当前项 + if (translationHistory.last.isIntermediate) { + translationHistory.last.sourceText = text; + translationHistory.last.isIntermediate = false; + translationHistory.refresh(); + // 更新后滚动到底部 _scrollToBottom(); + saveTranslationHistory(); - // 播放TTS - if (translationResult != null && isTtsEnabled.value) { - await playTranslatedText(translationResult); - } + // 使用项目的时间戳进行翻译 + translateText(text, translationHistory.last.timestamp, isFinal: true); + } else { + // 如果最后一项不是中间结果,创建新项 + final newItem = TranslationItem( + sourceText: text, + translatedText: '', + sourceLanguageCode: sourceLanguageCode.value, + targetLanguageCode: targetLanguageCode.value, + timestamp: DateTime.now(), + sessionId: currentSessionId ?? DateTime.now().millisecondsSinceEpoch.toString(), + isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, + isIntermediate: false, + ); - // 重置当前项,准备下一次输入 - currentItem.value = null; + translationHistory.add(newItem); + translationHistory.refresh(); + _scrollToBottom(); + saveTranslationHistory(); + + + // 使用新项目的时间戳进行翻译 + translateText(text, newItem.timestamp, isFinal: true); } } // 翻译文本 - Future translateText(String sourceText) async { + Future translateText(String sourceText, DateTime timestamp, {bool isFinal = false}) async { if (sourceText.isEmpty) return null; isTranslating.value = true; @@ -335,27 +349,28 @@ class TranslationController extends GetxController { ); if (translationResult != null) { - // 更新翻译结果 - if (currentItem.value != null && currentItem.value!.sourceText.contains(sourceText)) { - // 更新当前项 - currentItem.value!.translatedText = translationResult; - currentItem.value!.sourceLanguageCode = detectedSourceLanguageCode; - currentItem.value!.targetLanguageCode = detectedTargetLanguageCode; - currentItem.refresh(); + // 查找匹配的项目 - 优先使用timestamp查找 + int index = -1; + + index = translationHistory.indexWhere((item) => + item.timestamp.isAtSameMomentAs(timestamp)); + if (index != -1) { + // 更新历史项 + translationHistory[index].translatedText = translationResult; + translationHistory[index].sourceLanguageCode = detectedSourceLanguageCode; + translationHistory[index].targetLanguageCode = detectedTargetLanguageCode; + translationHistory.refresh(); + saveTranslationHistory(); // 更新后滚动到底部 _scrollToBottom(); - } else { - // 更新历史记录 - final index = translationHistory.indexWhere((item) => item.sourceText == sourceText); - if (index != -1) { - translationHistory[index].translatedText = translationResult; - translationHistory[index].sourceLanguageCode = detectedSourceLanguageCode; - translationHistory[index].targetLanguageCode = detectedTargetLanguageCode; - translationHistory.refresh(); - saveTranslationHistory(); - // 更新后滚动到底部 - _scrollToBottom(); + + // 播放TTS - 只在最终结果时播放 + if (isTtsEnabled.value && isFinal) { + // Logger.info('播放翻译文本: ${translationResult}'); + await playTranslatedText(translationResult); } + } else { + Logger.info('未找到匹配的历史项 ${sourceText} ${timestamp.toIso8601String() ?? "无时间戳"}'); } } } catch (e) { @@ -428,7 +443,7 @@ class TranslationController extends GetxController { // 滚动到底部 void _scrollToBottom() { - if (scrollController.hasClients && (translationHistory.isNotEmpty || currentItem.value != null)) { + if (scrollController.hasClients && (translationHistory.isNotEmpty || currentSessionId != null)) { try { scrollController.animateTo( scrollController.position.maxScrollExtent, @@ -541,7 +556,14 @@ class TranslationController extends GetxController { if (historyJson != null) { try { final List historyList = jsonDecode(historyJson); - translationHistory.value = historyList.map((item) => TranslationItem.fromJson(item)).toList(); + final List allHistory = historyList.map((item) => TranslationItem.fromJson(item)).toList(); + + // 只保留最新的10条记录 + if (allHistory.length > _maxHistoryItems) { + translationHistory.value = allHistory.sublist(allHistory.length - _maxHistoryItems); + } else { + translationHistory.value = allHistory; + } // 使用微任务确保在视图完全渲染后滚动到底部 WidgetsBinding.instance.addPostFrameCallback((_) { @@ -578,6 +600,12 @@ class TranslationController extends GetxController { void scrollToBottom({bool animate = true}) { _scrollToBottom(); } + + /// 处理原生语音交互开始事件 + void _handleVoiceInteractionStarted() { + // 收到recognitionStarted事件时,停止当前的AI交互、录音和TTS + stopAll(); + } } // 翻译项模型 diff --git a/lib/modules/translation/views/translation_view.dart b/lib/modules/translation/views/translation_view.dart index 5e209d453..8c9d404ec 100644 --- a/lib/modules/translation/views/translation_view.dart +++ b/lib/modules/translation/views/translation_view.dart @@ -167,128 +167,96 @@ class TranslationView extends GetView { return Obx(() { final itemCount = controller.translationHistory.length; - // 使用 currentItem 来判断是否显示当前项 - final hasCurrentItem = controller.currentItem.value != null && - controller - .currentItem.value!.sourceText.isNotEmpty; // 只有当有实际内容时才显示当前项 - - // 计算底部内边距,确保内容不会被底部框遮住 - final bottomPadding = controller.bottomBarHeight + 20.h; // 使用控制栏高度加上额外间距 - return ListView.builder( controller: controller.scrollController, padding: EdgeInsets.only( - left: 16.w, right: 16.w, top: 16.h, bottom: bottomPadding), - itemCount: itemCount + (hasCurrentItem ? 1 : 0), + left: 16.w, + right: 16.w, + top: 16.h, + bottom: 16.h, + ), + itemCount: itemCount, itemBuilder: (context, index) { - // Current item (being translated) - if (hasCurrentItem && index == itemCount) { - return _buildTranslationItemWidget( - controller.currentItem.value!.sourceText, - controller.currentItem.value!.translatedText, - true, - isIntermediate: controller.currentItem.value!.isIntermediate, - isDarkMode: isDarkMode, - ); - } - - // History items + // 获取当前项 final historyItem = controller.translationHistory[index]; - return _buildTranslationItemWidget( - historyItem.sourceText, - historyItem.translatedText, - false, - item: historyItem, - isIntermediate: false, // 历史项总是显示为最终结果 - isDarkMode: isDarkMode, - ); + + // 构建项目widget + return _buildTranslationItemWidget(context, historyItem); }, ); }); } Widget _buildTranslationItemWidget( - String sourceText, - String translatedText, - bool isCurrent, { - TranslationItem? item, - bool isIntermediate = false, - required bool isDarkMode, - }) { - // 使用AnimatedContainer实现平滑过渡,但恢复原来的视觉样式 - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - margin: EdgeInsets.only(bottom: 24.h), + BuildContext context, TranslationItem item) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: isDarkMode ? AppColors.cardBackground : Colors.grey[100], - borderRadius: BorderRadius.circular(12.r), + color: item.isIntermediate + ? Colors.grey.withOpacity(0.1) // 中间结果使用浅灰色底色 + : (item.sourceLanguageCode == controller.sourceLanguageCode.value + ? Theme.of(context) + .colorScheme + .primary + .withOpacity(0.08) // 源语言匹配时使用主题色底色 + : Theme.of(context) + .colorScheme + .secondary + .withOpacity(0.08)), // 否则使用次要色底色 + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: item.isIntermediate + ? Colors.grey.withOpacity(0.2) + : Colors.transparent, + width: 1, + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Source text - AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: double.infinity, - padding: EdgeInsets.all(16.r), - child: Text( - sourceText.isEmpty && isCurrent ? '等待语音输入...' : sourceText, + // 源文本 + Text( + item.sourceText, + style: TextStyle( + fontSize: 16, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ), + if (item.translatedText.isNotEmpty) ...[ + const SizedBox(height: 8), + // 翻译文本 + Text( + item.translatedText, style: TextStyle( - fontSize: 16.sp, - color: sourceText.isEmpty && isCurrent - ? (isDarkMode ? Colors.grey[400] : Colors.grey) - : (isDarkMode ? Colors.white : Colors.black), - fontWeight: FontWeight.normal, + fontSize: 16, + color: Theme.of(context).textTheme.bodyMedium?.color, + fontWeight: FontWeight.w500, ), ), - ), - - // Divider - Divider( - height: 1, - thickness: 1, - color: isDarkMode ? Colors.grey[800] : Colors.grey[200]), - - // Translated text - AnimatedContainer( - duration: const Duration(milliseconds: 300), - width: double.infinity, - padding: EdgeInsets.all(16.r), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Text( - translatedText.isEmpty && isCurrent - ? '翻译结果将显示在这里...' - : translatedText, - style: TextStyle( - fontSize: 16.sp, - color: translatedText.isEmpty && isCurrent - ? (isDarkMode ? Colors.grey[400] : Colors.grey) - : (isDarkMode ? Colors.white : Colors.black), - fontStyle: FontStyle.normal, - ), - ), + ], + // 语言信息和时间戳 + const SizedBox(height: 4), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${item.sourceLanguageName} → ${item.targetLanguageName}', + style: TextStyle( + fontSize: 12, + color: Theme.of(context).textTheme.bodySmall?.color, ), - if (translatedText.isNotEmpty) - IconButton( - icon: Icon( - Icons.volume_up, - color: isDarkMode ? AppColors.primary : Colors.blue, - size: 20.sp, - ), - onPressed: () { - if (isCurrent) { - controller.playTranslatedText(translatedText); - } else if (item != null) { - controller.playTranslationItem(item); - } - }, + ), + if (!item.isIntermediate) + GestureDetector( + onTap: () => controller.playTranslationItem(item), + child: Icon( + Icons.volume_up, + size: 18, + color: Theme.of(context).colorScheme.primary, ), - ], - ), + ), + ], ), ], ), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index da6276a5d..000a98608 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -1,7 +1,4 @@ PODS: - - audio_service (0.0.1): - - Flutter - - FlutterMacOS - audio_session (0.0.1): - FlutterMacOS - device_info_plus (0.0.1): @@ -26,14 +23,15 @@ PODS: - FlutterMacOS - share_plus (0.0.1): - FlutterMacOS - - sqflite_darwin (0.0.4): + - speech_to_text (0.0.1): - Flutter - FlutterMacOS + - Try + - Try (2.1.1) - window_size (0.0.2): - FlutterMacOS DEPENDENCIES: - - audio_service (from `Flutter/ephemeral/.symlinks/plugins/audio_service/darwin`) - audio_session (from `Flutter/ephemeral/.symlinks/plugins/audio_session/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) @@ -45,12 +43,14 @@ DEPENDENCIES: - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) - record_darwin (from `Flutter/ephemeral/.symlinks/plugins/record_darwin/macos`) - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) - - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - speech_to_text (from `Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin`) - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) +SPEC REPOS: + trunk: + - Try + EXTERNAL SOURCES: - audio_service: - :path: Flutter/ephemeral/.symlinks/plugins/audio_service/darwin audio_session: :path: Flutter/ephemeral/.symlinks/plugins/audio_session/macos device_info_plus: @@ -73,13 +73,12 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/record_darwin/macos share_plus: :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos - sqflite_darwin: - :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + speech_to_text: + :path: Flutter/ephemeral/.symlinks/plugins/speech_to_text/darwin window_size: :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos SPEC CHECKSUMS: - audio_service: aa99a6ba2ae7565996015322b0bb024e1d25c6fd audio_session: eaca2512cf2b39212d724f35d11f46180ad3a33e device_info_plus: a56e6e74dbbd2bb92f2da12c64ddd4f67a749041 file_selector_macos: 6280b52b459ae6c590af5d78fc35c7267a3c4b31 @@ -91,7 +90,8 @@ SPEC CHECKSUMS: path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564 record_darwin: 30509266ae213af8afdb09a8ae7467cb64c1377e share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc - sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 + speech_to_text: 9dc43a5df3cbc2813f8c7cc9bd0fbf94268ed7ac + Try: 5ef669ae832617b3cee58cb2c6f99fb767a4ff96 window_size: 4bd15034e6e3d0720fd77928a7c42e5492cfece9 PODFILE CHECKSUM: 8c4442624fc136b0afb378a2502958e01c05d073 diff --git a/pubspec.yaml b/pubspec.yaml index be5e1da8c..3b17bb70d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,14 +16,11 @@ dependencies: get_storage: ^2.1.1 http: ^1.3.0 flutter_screenutil: ^5.9.0 - just_audio: ^0.9.36 - flutter_azure_tts: ^0.2.3 path_provider: ^2.1.5 audio_session: ^0.1.18 flutter_markdown: ^0.6.18+2 markdown: ^7.1.1 permission_handler: ^11.4.0 - audio_service: ^0.18.12 web_socket_channel: ^3.0.2 flutter_dotenv: ^5.1.0 synchronized: ^3.1.0 @@ -32,8 +29,9 @@ dependencies: flutter_tts: ^4.2.2 logger: ^2.0.2+1 flutter_blue_plus: ^1.31.16 - # shared_preferences: ^2.2.2 # 移除,使用 get_storage 代替 - # speech_to_text: ^7.0.0 # 移除,使用自定义的 ASR 服务 + just_audio: ^0.9.36 + + window_size: git: url: https://github.com/google/flutter-desktop-embedding.git diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index 11ee9670c..000000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,30 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility in the flutter_test package. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:deep_voice/main.dart'; - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MainApp()); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -}