From 00e7dab2f881e13a31629e93266630ea5c4af344 Mon Sep 17 00:00:00 2001 From: wolfplus Date: Sun, 6 Apr 2025 17:48:28 +0100 Subject: [PATCH] before add volcalno speech --- android/app/build.gradle.kts | 9 +- android/build.gradle.kts | 4 + android/settings.gradle.kts | 3 +- local_plugins/volcano_speech/README.md | 206 ++++++ .../volcano_speech/android/build.gradle.kts | 64 ++ .../android/settings.gradle.kts | 1 + .../android/src/main/AndroidManifest.xml | 9 + .../volcano_speech/VolcanoAsrHelper.kt | 529 +++++++++++++++ .../volcano_speech/VolcanoSpeechPlugin.kt | 445 +++++++++++++ .../volcano_speech/VolcanoTtsHelper.kt | 399 ++++++++++++ .../volcano_speech/utils/FileLogger.kt | 55 ++ .../volcano_speech/lib/volcano_speech.dart | 603 ++++++++++++++++++ local_plugins/volcano_speech/pubspec.yaml | 28 + pubspec.yaml | 2 + 14 files changed, 2349 insertions(+), 8 deletions(-) create mode 100644 local_plugins/volcano_speech/README.md create mode 100644 local_plugins/volcano_speech/android/build.gradle.kts create mode 100644 local_plugins/volcano_speech/android/settings.gradle.kts create mode 100644 local_plugins/volcano_speech/android/src/main/AndroidManifest.xml create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt create mode 100644 local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt create mode 100644 local_plugins/volcano_speech/lib/volcano_speech.dart create mode 100644 local_plugins/volcano_speech/pubspec.yaml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b0a39cbd4..026060648 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -93,14 +93,13 @@ dependencies { // 添加本地插件模块依赖 implementation(project(":azure_speech")) implementation(project(":open_ai_service")) + implementation(project(":volcano_speech")) // 添加OkHttp依赖 implementation("com.squareup.okhttp3:okhttp:4.9.3") // 添加核心库反糖化 coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.3") - // Microsoft 语音识别SDK - implementation("com.microsoft.cognitiveservices.speech:client-sdk:1.42.0") // 添加androidx.media依赖 implementation("androidx.media:media:1.6.0") @@ -111,11 +110,7 @@ dependencies { // 添加 AndroidX Security 加密 SharedPreferences 依赖 implementation("androidx.security:security-crypto:1.1.0-alpha06") - // // 添加火山语音合成SDK依赖 - // implementation("com.bytedance.speechengine:speechengine_tts_tob:5.4.8") - - // 添加火山语音识别SDK依赖 - // implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") + } flutter { diff --git a/android/build.gradle.kts b/android/build.gradle.kts index f8f47c600..67f214248 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -3,6 +3,10 @@ allprojects { google() mavenCentral() maven { url = uri("https://storage.googleapis.com/download.flutter.io") } + // 添加火山引擎Maven仓库 + maven { + url = uri("https://artifact.bytedance.com/repository/Volcengine/") + } } } diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts index 6ca3ffd16..c0eebbf43 100644 --- a/android/settings.gradle.kts +++ b/android/settings.gradle.kts @@ -31,8 +31,9 @@ plugins { include(":app") include(":azure_speech") include(":open_ai_service") - +include(":volcano_speech") // 设置azure_speech项目的路径 project(":azure_speech").projectDir = file("../local_plugins/azure_speech/android") project(":open_ai_service").projectDir = file("../local_plugins/open_ai_service/android") +project(":volcano_speech").projectDir = file("../local_plugins/volcano_speech/android") diff --git a/local_plugins/volcano_speech/README.md b/local_plugins/volcano_speech/README.md new file mode 100644 index 000000000..572317b57 --- /dev/null +++ b/local_plugins/volcano_speech/README.md @@ -0,0 +1,206 @@ +# 火山引擎语音服务插件 + +基于火山引擎语音SDK封装的Flutter插件,支持Android平台。 + +## 功能 +- 语音合成(TTS) + - 支持在线/离线/混合合成模式 + - 支持SSML格式文本 + - 支持情感合成和情感预测 + - 支持音量、语速、音高等多种参数调节 + - 复刻音色支持 + - 离线资源管理 +- 语音识别(ASR) + - 一次性识别 + - 连续识别 + - 长按识别 + - 热词优化 + - 多语言支持 + +## 使用说明 + +### TTS 语音合成 + +#### 基础用法 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initialize(appId: 'YOUR_APP_ID', apiKey: 'YOUR_API_KEY'); + +// 设置音量、语速等参数 +await VolcanoSpeech.setSpeechParams( + rate: 0, // 语速: -500到500,0为正常速度 + volume: 100, // 音量: 0到100,默认100 + pitch: 0, // 音高: -500到500,0为正常音高 + silenceDuration: 500, // 静音段时长: 毫秒 +); + +// 设置音色 +await VolcanoSpeech.setVoice( + voiceName: 'zh_female_qingxin', + voiceType: 'qingxin' +); + +// 开始合成并播放 +await VolcanoSpeech.speakText(text: '这是一段测试文本'); + +// 暂停播放 +await VolcanoSpeech.pausePlayback(); + +// 继续播放 +await VolcanoSpeech.resumePlayback(); + +// 停止播放 +await VolcanoSpeech.stopSpeaking(); + +// 销毁引擎 +await VolcanoSpeech.dispose(); +``` + +#### 进阶用法 +```dart +// 设置工作模式 +await VolcanoSpeech.setWorkMode(mode: TtsWorkMode.alternate); // 先在线,断网时切换离线 + +// 设置离线发音人 +await VolcanoSpeech.setOfflineVoice( + voiceName: 'xifei', + voiceType: 'qingxin' +); + +// 下载离线资源 +await VolcanoSpeech.downloadOfflineResource( + voiceTypes: ['qingxin', 'zhenjiang'], + languages: ['zh-CN'] +); + +// 使用SSML格式文本 +await VolcanoSpeech.setTextType(type: TtsTextType.ssml); +await VolcanoSpeech.speakText( + text: '这是一段2023-10-01的语音合成' +); + +// 设置情感 +await VolcanoSpeech.setEmotion(emotion: 'happy'); + +// 启用情感预测 +await VolcanoSpeech.setEnableEmotionPredict(enable: true); + +// 启用服务端缓存 +await VolcanoSpeech.setEnableCache(enable: true); + +// 监听TTS进度事件 +VolcanoSpeech.ttsProgressEvents.listen((event) { + print('播放进度: ${(event.progress * 100).toStringAsFixed(1)}%'); +}); + +// 复刻音色支持 +await VolcanoSpeech.setEnableVoiceClone( + enable: true, + backendCluster: 'your_cluster_name' +); +``` + +### ASR 语音识别 +#### 一次性识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', + supportedLanguages: ['zh-CN'], +); + +// 设置识别语言 +await VolcanoSpeech.setAsrLanguage(language: 'zh-CN'); + +// 设置热词(可选) +await VolcanoSpeech.setAsrHotWords( + hotWords: '{"hotwords":[{"word":"火山引擎","scale":2.0}]}', +); + +// 开始一次性识别 +try { + final result = await VolcanoSpeech.recognizeOnce(); + print('识别结果: ${result['text']}, 语言: ${result['language']}'); +} catch (e) { + print('识别出错: $e'); +} + +// 销毁引擎 +await VolcanoSpeech.disposeAsr(); +``` + +#### 连续识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', +); + +// 监听识别事件 +VolcanoSpeech.asrEvents.listen((event) { + switch (event.type) { + case AsrEventType.sessionStarted: + print('识别会话开始'); + break; + case AsrEventType.sessionStopped: + print('识别会话结束'); + break; + case AsrEventType.recognizing: + print('正在识别: ${event.text}'); + break; + case AsrEventType.result: + print('识别结果: ${event.text}'); + break; + case AsrEventType.volumeChanged: + print('音量: ${event.volume}'); + break; + case AsrEventType.error: + print('识别错误: ${event.errorMessage}'); + break; + } +}); + +// 开始连续识别 +await VolcanoSpeech.startContinuousRecognition(); + +// 检查是否正在识别 +final isActive = await VolcanoSpeech.isContinuousRecognitionActive(); +print('是否正在识别: $isActive'); + +// 停止连续识别 +await VolcanoSpeech.stopContinuousRecognition(); +``` + +#### 长按识别 +```dart +import 'package:volcano_speech/volcano_speech.dart'; + +// 初始化 +await VolcanoSpeech.initializeAsr( + appId: 'YOUR_APP_ID', + apiKey: 'YOUR_API_KEY', +); + +// 监听识别事件 +VolcanoSpeech.asrEvents.listen((event) { + // 处理事件... +}); + +// 用户按下按钮时开始识别 +onPressed: () async { + await VolcanoSpeech.startListening(); +}, + +// 用户释放按钮时停止识别 +onReleased: () async { + await VolcanoSpeech.stopListening(); +}, +``` \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/build.gradle.kts b/local_plugins/volcano_speech/android/build.gradle.kts new file mode 100644 index 000000000..86b03fd0e --- /dev/null +++ b/local_plugins/volcano_speech/android/build.gradle.kts @@ -0,0 +1,64 @@ +import com.android.build.gradle.LibraryExtension + +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath("com.android.tools.build:gradle:7.3.0") + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.7.10") + } +} + +allprojects { + repositories { + google() + mavenCentral() + + } +} + +plugins { + id("com.android.library") + kotlin("android") +} + +// 配置android扩展 +configure { + namespace = "com.yunqiinnovation.volcano_speech" + compileSdkVersion(33) + + defaultConfig { + minSdk = 21 + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + sourceSets { + getByName("main") { + manifest.srcFile("src/main/AndroidManifest.xml") + java.srcDirs("src/main/kotlin") + } + } + + // 添加lint选项 + lintOptions { + isCheckReleaseBuilds = false + } +} + +// 显式设置Kotlin JVM目标版本 +tasks.withType { + kotlinOptions { + jvmTarget = "11" + } +} + +dependencies { + // 添加火山引擎语音合成SDK + implementation("com.bytedance.speechengine:speechengine_tob:0.0.5") +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/settings.gradle.kts b/local_plugins/volcano_speech/android/settings.gradle.kts new file mode 100644 index 000000000..7f5342866 --- /dev/null +++ b/local_plugins/volcano_speech/android/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "volcano_speech" \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml b/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml new file mode 100644 index 000000000..338e8293c --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + + + + \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt new file mode 100644 index 000000000..614474d1a --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoAsrHelper.kt @@ -0,0 +1,529 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.bytedance.speech.speechengine.SpeechEngine +import com.bytedance.speech.speechengine.SpeechEngineDefines +import com.bytedance.speech.speechengine.SpeechEngineGenerator +import com.yunqiinnovation.volcano_speech.utils.FileLogger +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject + +/** + * 火山语音识别帮助类 (大模型版本) + */ +class VolcanoAsrHelper(private val context: Context) { + private val TAG = "VolcanoAsrHelper" + private val mainHandler = Handler(Looper.getMainLooper()) + + // 语音引擎相关 + private var engine: SpeechEngine? = null + private var engineHandler: Long = -1 + private var isInitialized = false + + // 当前回调 + private var currentAsrCallback: ASRCallback? = null + private var currentContinuousCallback: ASRContinuousCallback? = null + + // 识别状态 + private var isContinuousRecognitionActive = false + + // 配置参数 + private var language = "zh-CN" + private var enableVolume = false + private var showUtterances = false + + /** + * ASR一次性识别回调 + */ + interface ASRCallback { + fun onSuccess(text: String) + fun onError(error: String) + } + + /** + * ASR连续识别回调 + */ + interface ASRContinuousCallback { + fun onResult(text: String) + fun onRecognizing(text: String) + fun onSessionStarted() + fun onSessionStopped() + fun onVolumeChanged(volume: Int) + fun onError(error: String) + } + + /** + * 初始化语音识别引擎 + */ + fun initialize(appId: String, token: String, resourceId: String): Boolean { + if (isInitialized) { + FileLogger.i(TAG, "引擎已经初始化") + return true + } + + try { + // 准备环境 + SpeechEngineGenerator.PrepareEnvironment(context, null) + + // 创建引擎 + engine = SpeechEngineGenerator.getInstance() + engineHandler = engine?.createEngine() ?: -1 + + if (engineHandler == -1L) { + FileLogger.e(TAG, "创建引擎失败") + return false + } + + // 设置上下文 + engine?.setContext(context) + + // 设置引擎类型为ASR + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.ASR_ENGINE) + + // 设置日志级别 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) + + // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") + + // 设置鉴权信息 - 大模型版本不需要Bearer前缀 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) + + // 设置资源ID - 大模型必需 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + + // 设置协议类型为Seed - 大模型必需 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) + + // 设置网络配置 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_ADDRESS_STRING, "wss://openspeech.bytedance.com") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_URI_STRING, "/api/v3/sauc/bigmodel") + + // 设置超时时间 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_CONN_TIMEOUT_INT, 12000) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_RECV_TIMEOUT_INT, 8000) + + // 设置音频来源为录音机 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_TYPE_STRING, SpeechEngineDefines.RECORDER_TYPE_RECORDER) + + // 设置最大录音时长 (默认60秒) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_VAD_MAX_SPEECH_DURATION_INT, 60000) + + // 设置回声消除 (用于ASR识别时不会收到TTS的声音) + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_RECORDER_PRESET_INT, SpeechEngineDefines.RECORDER_PRESET_VOICE_COMMUNICATION) + + // 初始化引擎 + val result = engine?.initEngine(engineHandler) + isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR + + if (isInitialized) { + FileLogger.i(TAG, "引擎初始化成功") + + // 设置回调监听 + engine?.setListener(object : SpeechEngine.SpeechListener { + override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { + val stdData = String(data) + handleEngineEvent(type, stdData) + } + }) + + return true + } else { + FileLogger.e(TAG, "引擎初始化失败: $result") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化异常: ${e.message}", e) + return false + } + } + + /** + * 设置语言 + */ + fun setLanguage(language: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.language = language + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_LANGUAGE_STRING, language) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置语言失败: ${e.message}", e) + return false + } + } + + /** + * 设置热词 + */ + fun setHotWords(hotWordsId: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + if (hotWordsId.isNotEmpty()) { + // 大模型ASR需要通过请求参数设置热词 + val reqParams = "{\"corpus\":{\"boosting_table_id\":\"$hotWordsId\"}}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + } + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置热词失败: ${e.message}", e) + return false + } + } + + /** + * 设置是否返回音量 + */ + fun setEnableVolume(enable: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.enableVolume = enable + engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENABLE_GET_VOLUME_BOOL, enable) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置音量返回失败: ${e.message}", e) + return false + } + } + + /** + * 设置是否显示语音停顿、分句、分词信息 + */ + fun setShowUtterances(show: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.showUtterances = show + engine?.setOptionBoolean(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_SHOW_UTTER_BOOL, show) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置语音信息显示失败: ${e.message}", e) + return false + } + } + + /** + * 设置VAD切句参数 + */ + fun setVadParams(forceToSpeechTime: Int, endWindowSize: Int): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val reqParams = "{\"force_to_speech_time\":$forceToSpeechTime, \"end_window_size\":$endWindowSize}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置VAD参数失败: ${e.message}", e) + return false + } + } + + /** + * 设置纠错词表 + */ + fun setCorrectWords(correctWordsJson: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val reqParams = "{\"context\": \"{\\\"correct_words\\\": $correctWordsJson}\"}" + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ASR_REQ_PARAMS_STRING, reqParams) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置纠错词表失败: ${e.message}", e) + return false + } + } + + /** + * 一次性识别 + */ + fun recognizeOnce(callback: ASRCallback): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.e(TAG, "当前正在连续识别中") + return false + } + + try { + this.currentAsrCallback = callback + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始识别 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动识别失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "识别异常: ${e.message}", e) + return false + } + } + + /** + * 停止一次性识别 + */ + fun stopRecognize(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + // 告知引擎音频输入完成 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_FINISH_TALKING, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "停止识别失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "停止识别异常: ${e.message}", e) + return false + } + } + + /** + * 开始连续识别 + */ + fun startContinuousRecognition(callback: ASRContinuousCallback): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (isContinuousRecognitionActive) { + FileLogger.e(TAG, "当前已经在连续识别中") + return false + } + + try { + this.currentContinuousCallback = callback + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始识别 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动连续识别失败: $ret") + return false + } + + isContinuousRecognitionActive = true + return true + } catch (e: Exception) { + FileLogger.e(TAG, "连续识别异常: ${e.message}", e) + return false + } + } + + /** + * 停止连续识别 + */ + fun stopContinuousRecognition(): Boolean { + if (!isInitialized || !isContinuousRecognitionActive) { + FileLogger.e(TAG, "引擎未初始化或未在连续识别中") + return false + } + + try { + // 停止引擎 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "停止连续识别失败: $ret") + return false + } + + isContinuousRecognitionActive = false + return true + } catch (e: Exception) { + FileLogger.e(TAG, "停止连续识别异常: ${e.message}", e) + return false + } + } + + /** + * 判断是否在连续识别中 + */ + fun isContinuousRecognitionActive(): Boolean { + return isContinuousRecognitionActive + } + + /** + * 释放资源 + */ + fun release() { + if (!isInitialized) { + return + } + + try { + // 停止引擎 + if (isContinuousRecognitionActive) { + stopContinuousRecognition() + } else { + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + } + + // 销毁引擎 + engine?.destroyEngine(engineHandler) + engineHandler = -1 + engine = null + isInitialized = false + + FileLogger.i(TAG, "引擎已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) + } + } + + /** + * 处理引擎事件 + */ + private fun handleEngineEvent(type: Int, data: String) { + when (type) { + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { + // 引擎启动成功 + mainHandler.post { + currentContinuousCallback?.onSessionStarted() + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { + // 引擎停止 + isContinuousRecognitionActive = false + mainHandler.post { + currentContinuousCallback?.onSessionStopped() + } + } + + SpeechEngineDefines.MESSAGE_TYPE_PARTIAL_RESULT -> { + // 中间识别结果 + try { + val json = JSONObject(data) + if (!json.has("result")) { + return + } + + val resultArray = json.getJSONArray("result") + if (resultArray.length() > 0) { + val result = resultArray.getJSONObject(0) + val text = result.optString("text", "") + + if (text.isNotEmpty()) { + mainHandler.post { + currentContinuousCallback?.onRecognizing(text) + } + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析中间识别结果异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_FINAL_RESULT -> { + // 最终识别结果 + try { + val json = JSONObject(data) + if (!json.has("result")) { + return + } + + val resultArray = json.getJSONArray("result") + if (resultArray.length() > 0) { + val result = resultArray.getJSONObject(0) + val text = result.optString("text", "") + + if (text.isNotEmpty()) { + mainHandler.post { + if (isContinuousRecognitionActive) { + currentContinuousCallback?.onResult(text) + } else { + currentAsrCallback?.onSuccess(text) + } + } + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析最终识别结果异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_VOLUME_LEVEL -> { + // 音量回调 + if (enableVolume && isContinuousRecognitionActive) { + try { + val volume = (data.toFloat() * 100).toInt() + mainHandler.post { + currentContinuousCallback?.onVolumeChanged(volume) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析音量数据异常: ${e.message}", e) + } + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { + // 错误信息 + try { + val json = JSONObject(data) + val errCode = json.optInt("err_code", -1) + val errMsg = json.optString("err_msg", "未知错误") + + FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") + + mainHandler.post { + if (isContinuousRecognitionActive) { + currentContinuousCallback?.onError(errMsg) + isContinuousRecognitionActive = false + } else { + currentAsrCallback?.onError(errMsg) + } + } + } catch (e: JSONException) { + FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) + } + } + } + } +} diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt new file mode 100644 index 000000000..bb6f1e452 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoSpeechPlugin.kt @@ -0,0 +1,445 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import androidx.annotation.NonNull +import com.yunqiinnovation.volcano_speech.utils.FileLogger + +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import io.flutter.plugin.common.MethodChannel.MethodCallHandler +import io.flutter.plugin.common.MethodChannel.Result +import io.flutter.plugin.common.EventChannel + +/** VolcanoSpeechPlugin */ +class VolcanoSpeechPlugin: FlutterPlugin { + private val TAG = "VolcanoSpeechPlugin" + private lateinit var context: Context + private val mainHandler = Handler(Looper.getMainLooper()) + + // ASR相关 + private lateinit var asrChannel: MethodChannel + private lateinit var asrEventChannel: EventChannel + private var asrEventSink: EventChannel.EventSink? = null + + // TTS相关 + private lateinit var ttsChannel: MethodChannel + private lateinit var ttsEventChannel: EventChannel + private var ttsEventSink: EventChannel.EventSink? = null + + // TTS帮助类 + private lateinit var volcanoTtsHelper: VolcanoTtsHelper + + // ASR帮助类 + private lateinit var volcanoAsrHelper: VolcanoAsrHelper + + override fun onAttachedToEngine(@NonNull flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) { + context = flutterPluginBinding.applicationContext + + // 初始化ASR通道 + asrChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr") + asrChannel.setMethodCallHandler(AsrMethodHandler()) + + // 初始化TTS通道 + ttsChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts") + ttsChannel.setMethodCallHandler(TtsMethodHandler()) + + // 初始化ASR事件通道 + asrEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/asr_events") + asrEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + asrEventSink = events + } + + override fun onCancel(arguments: Any?) { + asrEventSink = null + } + }) + + // 初始化TTS事件通道 + ttsEventChannel = EventChannel(flutterPluginBinding.binaryMessenger, "volcano_speech/tts_events") + ttsEventChannel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { + ttsEventSink = events + } + + override fun onCancel(arguments: Any?) { + ttsEventSink = null + } + }) + + // 初始化TTS帮助类 + volcanoTtsHelper = VolcanoTtsHelper(context) + + // 初始化ASR帮助类 + volcanoAsrHelper = VolcanoAsrHelper(context) + } + + // 发送ASR事件 + private fun sendAsrEvent(event: Map) { + FileLogger.d(TAG, "发送ASR事件: $event") + if (asrEventSink == null) { + FileLogger.w(TAG, "无法发送ASR事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + asrEventSink?.success(event) + FileLogger.d(TAG, "ASR事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送ASR事件失败: ${e.message}") + } + } + } + + // 发送TTS事件 + private fun sendTtsEvent(event: Map) { + FileLogger.d(TAG, "发送TTS事件: $event") + if (ttsEventSink == null) { + FileLogger.w(TAG, "无法发送TTS事件:事件通道未准备好") + return + } + + mainHandler.post { + try { + ttsEventSink?.success(event) + FileLogger.d(TAG, "TTS事件发送成功") + } catch (e: Exception) { + FileLogger.e(TAG, "发送TTS事件失败: ${e.message}") + } + } + } + + // ASR方法处理器 + inner class AsrMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "initialize" -> { + val appId = call.argument("appId") ?: "" + val apiKey = call.argument("apiKey") ?: "" + val resourceId = call.argument("resourceId") ?: "" + + if (appId.isEmpty() || apiKey.isEmpty() || resourceId.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId、apiKey和resourceId不能为空", null) + return + } + + val success = volcanoAsrHelper.initialize(appId, apiKey, resourceId) + result.success(success) + } + "setLanguage" -> { + val language = call.argument("language") ?: "zh-CN" + result.success(volcanoAsrHelper.setLanguage(language)) + } + "setHotWords" -> { + val hotWordsId = call.argument("hotWordsId") ?: "" + result.success(volcanoAsrHelper.setHotWords(hotWordsId)) + } + "setVadParams" -> { + val forceToSpeechTime = call.argument("forceToSpeechTime") ?: 0 + val endWindowSize = call.argument("endWindowSize") ?: 800 + result.success(volcanoAsrHelper.setVadParams(forceToSpeechTime, endWindowSize)) + } + "setCorrectWords" -> { + val correctWordsJson = call.argument("correctWordsJson") ?: "{}" + result.success(volcanoAsrHelper.setCorrectWords(correctWordsJson)) + } + "setEnableVolume" -> { + val enable = call.argument("enable") ?: false + result.success(volcanoAsrHelper.setEnableVolume(enable)) + } + "setShowUtterances" -> { + val enable = call.argument("enable") ?: false + result.success(volcanoAsrHelper.setShowUtterances(enable)) + } + "recognizeOnce" -> { + // 确保当前不在连续识别中 + if (volcanoAsrHelper.isContinuousRecognitionActive()) { + result.error("ASR_BUSY", "当前正在连续识别中", null) + return + } + + volcanoAsrHelper.recognizeOnce(object : VolcanoAsrHelper.ASRCallback { + override fun onSuccess(text: String) { + mainHandler.post { + result.success(mapOf( + "text" to text + )) + } + } + + override fun onError(error: String) { + mainHandler.post { + result.error("ASR_ERROR", error, null) + } + } + }) + } + "startContinuousRecognition" -> { + // 确保事件通道已准备好 + if (asrEventSink == null) { + result.error("EVENT_CHANNEL_NOT_READY", "事件通道未准备好,无法开始连续识别", null) + return + } + + val success = volcanoAsrHelper.startContinuousRecognition(object : VolcanoAsrHelper.ASRContinuousCallback { + override fun onResult(text: String) { + sendAsrEvent(mapOf( + "type" to "result", + "text" to text + )) + } + + override fun onRecognizing(text: String) { + sendAsrEvent(mapOf( + "type" to "recognizing", + "text" to text + )) + } + + override fun onSessionStarted() { + sendAsrEvent(mapOf("type" to "sessionStarted")) + } + + override fun onSessionStopped() { + sendAsrEvent(mapOf("type" to "sessionStopped")) + } + + override fun onVolumeChanged(volume: Int) { + sendAsrEvent(mapOf( + "type" to "volumeChanged", + "volume" to volume + )) + } + + override fun onError(error: String) { + sendAsrEvent(mapOf( + "type" to "error", + "message" to error + )) + } + }) + + result.success(success) + } + "stopContinuousRecognition" -> { + val success = volcanoAsrHelper.stopContinuousRecognition() + result.success(success) + } + "stopRecognize" -> { + val success = volcanoAsrHelper.stopRecognize() + result.success(success) + } + "isContinuousRecognitionActive" -> { + result.success(volcanoAsrHelper.isContinuousRecognitionActive()) + } + "release" -> { + volcanoAsrHelper.release() + result.success(true) + } + else -> { + result.notImplemented() + } + } + } + } + + // TTS方法处理器 + inner class TtsMethodHandler : MethodCallHandler { + override fun onMethodCall(@NonNull call: MethodCall, @NonNull result: Result) { + when (call.method) { + "getPlatformVersion" -> { + result.success("Android ${android.os.Build.VERSION.RELEASE}") + } + "initialize" -> { + val appId = call.argument("appId") ?: "" + val token = call.argument("token") ?: "" + val resourceId = call.argument("resourceId") ?: "" + + if (appId.isEmpty() || token.isEmpty() || resourceId.isEmpty()) { + result.error("INVALID_ARGUMENTS", "appId、token和resourceId不能为空", null) + return + } + + val success = volcanoTtsHelper.initialize(appId, token, resourceId) + result.success(success) + } + "setVoice" -> { + val voice = call.argument("voice") ?: "" + + if (voice.isEmpty()) { + result.error("INVALID_ARGUMENTS", "voice不能为空", null) + return + } + + result.success(volcanoTtsHelper.setVoice(voice)) + } + "setContinuousMode" -> { + val isContinuous = call.argument("isContinuous") ?: false + result.success(volcanoTtsHelper.setContinuousMode(isContinuous)) + } + "speak" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { + override fun onStart(reqId: String) { + sendTtsEvent(mapOf( + "type" to "start", + "reqId" to reqId + )) + } + + override fun onProgress(reqId: String, progress: Double) { + sendTtsEvent(mapOf( + "type" to "progress", + "reqId" to reqId, + "progress" to progress + )) + } + + override fun onComplete(reqId: String) { + sendTtsEvent(mapOf( + "type" to "complete", + "reqId" to reqId + )) + + mainHandler.post { + result.success(true) + } + } + + override fun onError(reqId: String, errorCode: Int, errorMsg: String) { + sendTtsEvent(mapOf( + "type" to "error", + "reqId" to reqId, + "errorCode" to errorCode, + "errorMsg" to errorMsg + )) + + mainHandler.post { + result.error("TTS_ERROR", errorMsg, null) + } + } + }) + } + "synthesisNext" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + val success = volcanoTtsHelper.synthesisNext(text) + result.success(success) + } + "pause" -> { + result.success(volcanoTtsHelper.pause()) + } + "resume" -> { + result.success(volcanoTtsHelper.resume()) + } + "stop" -> { + result.success(volcanoTtsHelper.stop()) + } + "release" -> { + volcanoTtsHelper.release() + result.success(true) + } + // 以下方法用于与Azure Speech版本兼容 + "setSpeechSynthesisVoice" -> { + val voiceName = call.argument("voiceName") ?: "" + + if (voiceName.isEmpty()) { + result.error("INVALID_ARGUMENTS", "语音名称不能为空", null) + return + } + + result.success(volcanoTtsHelper.setVoice(voiceName)) + } + "speakText" -> { + val text = call.argument("text") ?: "" + + if (text.isEmpty()) { + result.error("INVALID_ARGUMENTS", "合成文本不能为空", null) + return + } + + volcanoTtsHelper.speak(text, object : VolcanoTtsHelper.TTSCallback { + override fun onStart(reqId: String) { + sendTtsEvent(mapOf( + "type" to "start", + "reqId" to reqId + )) + } + + override fun onProgress(reqId: String, progress: Double) { + sendTtsEvent(mapOf( + "type" to "progress", + "reqId" to reqId, + "progress" to progress + )) + } + + override fun onComplete(reqId: String) { + sendTtsEvent(mapOf( + "type" to "complete", + "reqId" to reqId + )) + + mainHandler.post { + result.success(true) + } + } + + override fun onError(reqId: String, errorCode: Int, errorMsg: String) { + sendTtsEvent(mapOf( + "type" to "error", + "reqId" to reqId, + "errorCode" to errorCode, + "errorMsg" to errorMsg + )) + + mainHandler.post { + result.error("TTS_ERROR", errorMsg, null) + } + } + }) + } + "stopSpeaking" -> { + result.success(volcanoTtsHelper.stop()) + } + "pauseSpeaking" -> { + result.success(volcanoTtsHelper.pause()) + } + "resumeSpeaking" -> { + result.success(volcanoTtsHelper.resume()) + } + else -> { + result.notImplemented() + } + } + } + } + + override fun onDetachedFromEngine(@NonNull binding: FlutterPlugin.FlutterPluginBinding) { + asrChannel.setMethodCallHandler(null) + ttsChannel.setMethodCallHandler(null) + asrEventChannel.setStreamHandler(null) + ttsEventChannel.setStreamHandler(null) + + volcanoTtsHelper.release() + volcanoAsrHelper.release() + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt new file mode 100644 index 000000000..e24a1ceb1 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/VolcanoTtsHelper.kt @@ -0,0 +1,399 @@ +package com.yunqiinnovation.volcano_speech + +import android.content.Context +import android.os.Handler +import android.os.Looper +import com.bytedance.speech.speechengine.SpeechEngine +import com.bytedance.speech.speechengine.SpeechEngineDefines +import com.bytedance.speech.speechengine.SpeechEngineGenerator +import com.yunqiinnovation.volcano_speech.utils.FileLogger +import org.json.JSONObject + +/** + * 火山语音合成帮助类 (大模型版本) + */ +class VolcanoTtsHelper(private val context: Context) { + private val TAG = "VolcanoTtsHelper" + private val mainHandler = Handler(Looper.getMainLooper()) + + // 语音引擎相关 + private var engine: SpeechEngine? = null + private var engineHandler: Long = -1 + private var isInitialized = false + + // 当前回调 + private var currentTtsCallback: TTSCallback? = null + + // 合成状态 + private var isPlaying = false + private var currentReqId = "" + + // 配置参数 + private var voice = "zh_female_yuxi" + private var ttsText = "" + private var isContinuous = false + + /** + * TTS合成回调接口 + */ + interface TTSCallback { + fun onStart(reqId: String) + fun onProgress(reqId: String, progress: Double) + fun onComplete(reqId: String) + fun onError(reqId: String, errorCode: Int, errorMsg: String) + } + + /** + * 初始化语音合成引擎 + */ + fun initialize(appId: String, token: String, resourceId: String): Boolean { + if (isInitialized) { + FileLogger.i(TAG, "引擎已经初始化") + return true + } + + try { + // 准备环境 + SpeechEngineGenerator.PrepareEnvironment(context, null) + + // 创建引擎 + engine = SpeechEngineGenerator.getInstance() + engineHandler = engine?.createEngine() ?: -1 + + if (engineHandler == -1L) { + FileLogger.e(TAG, "创建引擎失败") + return false + } + + // 设置上下文 + engine?.setContext(context) + + // 设置引擎类型为TTS + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_ENGINE_NAME_STRING, SpeechEngineDefines.TTS_ENGINE) + + // 设置日志级别 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_LOG_LEVEL_STRING, SpeechEngineDefines.LOG_LEVEL_WARN) + + // 设置用户ID和设备ID (使用静态值,实际项目中应替换为真实值) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_UID_STRING, "user_id") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_DEVICE_ID_STRING, "device_id") + + // 设置授权信息 - 大模型版本不需要Bearer前缀 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_ID_STRING, appId) + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_APP_TOKEN_STRING, token) + + // 设置资源ID - 大模型必需 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_RESOURCE_ID_STRING, resourceId) + + // 设置协议类型为Seed - 大模型必需 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_PROTOCOL_TYPE_INT, SpeechEngineDefines.PROTOCOL_TYPE_SEED) + + // 设置合成策略为在线合成 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WORK_MODE_INT, SpeechEngineDefines.TTS_WORK_MODE_ONLINE) + + // 设置在线请求资源配置 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_ADDRESS_STRING, "wss://openspeech.bytedance.com") + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_URI_STRING, "/api/v3/sauc/bigmodel") + + // 设置发音人 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) + + // 设置播放进度回调 + engine?.setOptionInt(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_WITH_FRONTEND_INT, 1) + + // 初始化引擎 + val result = engine?.initEngine(engineHandler) + isInitialized = result == SpeechEngineDefines.ERR_NO_ERROR + + if (isInitialized) { + FileLogger.i(TAG, "引擎初始化成功") + + // 设置回调监听 + engine?.setListener(object : SpeechEngine.SpeechListener { + override fun onSpeechMessage(type: Int, data: ByteArray, len: Int) { + val stdData = String(data) + handleEngineEvent(type, stdData) + } + }) + + return true + } else { + FileLogger.e(TAG, "引擎初始化失败: $result") + return false + } + } catch (e: Exception) { + FileLogger.e(TAG, "初始化异常: ${e.message}", e) + return false + } + } + + /** + * 设置发音人 + */ + fun setVoice(voice: String): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.voice = voice + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_VOICE_ONLINE_STRING, voice) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置发音人失败: ${e.message}", e) + return false + } + } + + /** + * 设置合成场景(单次或连续) + */ + fun setContinuousMode(isContinuous: Boolean): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + this.isContinuous = isContinuous + val scenarioType = if (isContinuous) { + SpeechEngineDefines.TTS_SCENARIO_TYPE_NOVEL + } else { + SpeechEngineDefines.TTS_SCENARIO_TYPE_NORMAL + } + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_SCENARIO_STRING, scenarioType) + return true + } catch (e: Exception) { + FileLogger.e(TAG, "设置合成场景失败: ${e.message}", e) + return false + } + } + + /** + * 开始合成并播放 + */ + fun speak(text: String, callback: TTSCallback? = null): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + if (text.isEmpty()) { + FileLogger.e(TAG, "合成文本为空") + return false + } + + try { + this.ttsText = text + this.currentTtsCallback = callback + + // 设置要合成的文本 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + + // 停止当前引擎 + engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNC_STOP_ENGINE, "") + + // 启动引擎开始合成 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_START_ENGINE, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "启动合成失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "合成异常: ${e.message}", e) + return false + } + } + + /** + * 仅用于连续合成场景:在引擎启动后添加新的文本进行合成 + */ + fun synthesisNext(text: String): Boolean { + if (!isInitialized || !isContinuous) { + FileLogger.e(TAG, "引擎未初始化或非连续合成模式") + return false + } + + if (text.isEmpty()) { + FileLogger.e(TAG, "合成文本为空") + return false + } + + try { + // 设置要合成的文本 + engine?.setOptionString(engineHandler, SpeechEngineDefines.PARAMS_KEY_TTS_TEXT_STRING, text) + + // 发送合成指令 + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_SYNTHESIS, "") + + if (ret != SpeechEngineDefines.ERR_NO_ERROR) { + FileLogger.e(TAG, "添加合成文本失败: $ret") + return false + } + + return true + } catch (e: Exception) { + FileLogger.e(TAG, "添加合成文本异常: ${e.message}", e) + return false + } + } + + /** + * 暂停播放 + */ + fun pause(): Boolean { + if (!isInitialized || !isPlaying) { + FileLogger.e(TAG, "引擎未初始化或未在播放") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_PAUSE_PLAYER, "") + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "暂停播放异常: ${e.message}", e) + return false + } + } + + /** + * 恢复播放 + */ + fun resume(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_RESUME_PLAYER, "") + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "恢复播放异常: ${e.message}", e) + return false + } + } + + /** + * 停止播放 + */ + fun stop(): Boolean { + if (!isInitialized) { + FileLogger.e(TAG, "引擎未初始化") + return false + } + + try { + val ret = engine?.sendDirective(engineHandler, SpeechEngineDefines.DIRECTIVE_STOP_ENGINE, "") + isPlaying = false + return ret == SpeechEngineDefines.ERR_NO_ERROR + } catch (e: Exception) { + FileLogger.e(TAG, "停止播放异常: ${e.message}", e) + return false + } + } + + /** + * 释放资源 + */ + fun release() { + if (!isInitialized) { + return + } + + try { + // 停止引擎 + stop() + + // 销毁引擎 + engine?.destroyEngine(engineHandler) + engineHandler = -1 + engine = null + isInitialized = false + + FileLogger.i(TAG, "引擎已释放") + } catch (e: Exception) { + FileLogger.e(TAG, "释放引擎异常: ${e.message}", e) + } + } + + /** + * 处理引擎事件 + */ + private fun handleEngineEvent(type: Int, data: String) { + when (type) { + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_START -> { + // 引擎启动成功,获取请求ID + currentReqId = data + isPlaying = true + + mainHandler.post { + currentTtsCallback?.onStart(currentReqId) + } + + if (isContinuous) { + // 在连续合成模式下,需要单独发送合成指令 + synthesisNext(ttsText) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_STOP -> { + // 引擎停止 + isPlaying = false + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_START_PLAYING -> { + // 开始播放 + FileLogger.d(TAG, "开始播放: $data") + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_FINISH_PLAYING -> { + // 播放结束 + isPlaying = false + + mainHandler.post { + currentTtsCallback?.onComplete(currentReqId) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_TTS_PLAYBACK_PROGRESS -> { + // 播放进度 + try { + val json = JSONObject(data) + val progress = json.optDouble("progress", 0.0) + val reqId = json.optString("reqid", "") + + mainHandler.post { + currentTtsCallback?.onProgress(reqId, progress) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析进度信息异常: ${e.message}", e) + } + } + + SpeechEngineDefines.MESSAGE_TYPE_ENGINE_ERROR -> { + // 错误信息 + try { + val json = JSONObject(data) + val reqId = json.optString("reqid", "") + val errCode = json.optInt("err_code", -1) + val errMsg = json.optString("err_msg", "未知错误") + + FileLogger.e(TAG, "引擎错误: $errCode, $errMsg") + + isPlaying = false + + mainHandler.post { + currentTtsCallback?.onError(reqId, errCode, errMsg) + } + } catch (e: Exception) { + FileLogger.e(TAG, "解析错误信息异常: ${e.message}", e) + } + } + } + } +} diff --git a/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt new file mode 100644 index 000000000..8fdfb1227 --- /dev/null +++ b/local_plugins/volcano_speech/android/src/main/kotlin/com/yunqiinnovation/volcano_speech/utils/FileLogger.kt @@ -0,0 +1,55 @@ +package com.yunqiinnovation.volcano_speech.utils + +import android.util.Log + +/** + * 文件日志记录工具 + */ +object FileLogger { + private const val TAG = "VolcanoSpeech" + private var isDebugEnabled = true + + /** + * 设置是否启用调试日志 + */ + fun setDebugEnabled(enabled: Boolean) { + isDebugEnabled = enabled + } + + /** + * 记录调试日志 + */ + fun d(tag: String, message: String) { + if (isDebugEnabled) { + Log.d("$TAG-$tag", message) + } + } + + /** + * 记录信息日志 + */ + fun i(tag: String, message: String) { + Log.i("$TAG-$tag", message) + } + + /** + * 记录警告日志 + */ + fun w(tag: String, message: String) { + Log.w("$TAG-$tag", message) + } + + /** + * 记录错误日志 + */ + fun e(tag: String, message: String) { + Log.e("$TAG-$tag", message) + } + + /** + * 记录错误日志,带异常 + */ + fun e(tag: String, message: String, throwable: Throwable) { + Log.e("$TAG-$tag", message, throwable) + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/lib/volcano_speech.dart b/local_plugins/volcano_speech/lib/volcano_speech.dart new file mode 100644 index 000000000..99da7d4b7 --- /dev/null +++ b/local_plugins/volcano_speech/lib/volcano_speech.dart @@ -0,0 +1,603 @@ +import 'dart:async'; +import 'package:flutter/services.dart'; + +/// ASR 事件类型 +enum AsrEventType { + /// 会话开始 + sessionStarted, + + /// 会话结束 + sessionStopped, + + /// 正在识别(中间结果) + recognizing, + + /// 识别结果(最终结果) + result, + + /// 音量变化 + volumeChanged, + + /// 错误 + error +} + +/// TTS 工作模式 +enum TtsWorkMode { + /// 在线合成 + online, + + /// 离线合成 + offline, + + /// 同时在线离线 + both, + + /// 先在线再离线(网络不好时自动切换) + alternate, + + /// 文件模式 + file +} + +/// TTS 文本类型 +enum TtsTextType { + /// 纯文本 + plain, + + /// SSML格式 + ssml +} + +/// 协议类型 +enum ProtocolType { + /// 默认协议 + defaultProtocol, + + /// Seed协议(用于大模型) + seed +} + +/// TTS 播放进度事件 +class TtsProgressEvent { + /// 播放进度 0.0-1.0 + final double progress; + + /// 请求ID + final String reqId; + + const TtsProgressEvent({ + required this.progress, + required this.reqId, + }); + + @override + String toString() { + return 'TtsProgressEvent{progress: $progress, reqId: $reqId}'; + } +} + +/// ASR 事件 +class AsrEvent { + /// 事件类型 + final AsrEventType type; + + /// 识别文本(仅在recognizing和result类型时有效) + final String? text; + + /// 识别语言(仅在recognizing和result类型时有效) + final String? language; + + /// 音量值(仅在volumeChanged类型时有效) + final int? volume; + + /// 错误信息(仅在error类型时有效) + final String? errorMessage; + + const AsrEvent({ + required this.type, + this.text, + this.language, + this.volume, + this.errorMessage, + }); + + factory AsrEvent.fromMap(Map map) { + final typeStr = map['type'] as String; + + AsrEventType type; + switch (typeStr) { + case 'sessionStarted': + type = AsrEventType.sessionStarted; + break; + case 'sessionStopped': + type = AsrEventType.sessionStopped; + break; + case 'recognizing': + type = AsrEventType.recognizing; + break; + case 'result': + type = AsrEventType.result; + break; + case 'volumeChanged': + type = AsrEventType.volumeChanged; + break; + case 'error': + type = AsrEventType.error; + break; + default: + throw ArgumentError('未知的事件类型: $typeStr'); + } + + return AsrEvent( + type: type, + text: map['text'] as String?, + language: map['language'] as String?, + volume: map['volume'] as int?, + errorMessage: map['message'] as String?, + ); + } + + @override + String toString() { + return 'AsrEvent{type: $type, text: $text, language: $language, volume: $volume, errorMessage: $errorMessage}'; + } +} + +/// 火山引擎语音服务插件 +class VolcanoSpeech { + static final VolcanoSpeechAsr asr = VolcanoSpeechAsr._(); + static final VolcanoSpeechTts tts = VolcanoSpeechTts._(); + + /// 释放资源 + static Future dispose() async { + await asr.dispose(); + await tts.dispose(); + } +} + +/// 火山引擎语音识别服务 +class VolcanoSpeechAsr { + static const MethodChannel _channel = MethodChannel('volcano_speech/asr'); + static const EventChannel _eventChannel = EventChannel('volcano_speech/asr_events'); + + /// ASR事件流控制器 + static final StreamController _eventStreamController = StreamController.broadcast(); + + /// ASR事件流 + Stream get events => _eventStreamController.stream; + + /// 是否已初始化事件监听 + bool _eventListenerInitialized = false; + + VolcanoSpeechAsr._() { + _initEventListener(); + } + + /// 初始化ASR事件监听 + void _initEventListener() { + if (_eventListenerInitialized) return; + + _eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Map) { + _eventStreamController.add(AsrEvent.fromMap(event)); + } + }); + + _eventListenerInitialized = true; + } + + /// 获取平台版本信息 + Future getPlatformVersion() async { + return await _channel.invokeMethod('getPlatformVersion'); + } + + /// 初始化语音识别引擎 + /// + /// [appId] 火山引擎AppID + /// [apiKey] 火山引擎ApiKey + /// [supportedLanguages] 支持的语言列表 + /// [useBigModel] 是否使用大模型识别 + /// [resourceId] 大模型资源ID(仅当useBigModel为true时有效) + Future initialize({ + required String appId, + required String apiKey, + List supportedLanguages = const ['zh-CN'], + bool useBigModel = false, + String resourceId = '', + }) async { + return await _channel.invokeMethod('initialize', { + 'appId': appId, + 'apiKey': apiKey, + 'supportedLanguages': supportedLanguages, + 'useBigModel': useBigModel, + 'resourceId': resourceId, + }) ?? false; + } + + /// 设置识别语言 + /// + /// [language] 语言代码,例如 zh-CN、en-US + Future setLanguage(String language) async { + return await _channel.invokeMethod('setLanguage', { + 'language': language, + }) ?? false; + } + + /// 设置热词 + /// + /// [hotWords] 热词JSON字符串,例如 {"hotwords":[{"word":"快速入门","scale":2.0}]} + Future setHotWords(String hotWords) async { + return await _channel.invokeMethod('setHotWords', { + 'hotWords': hotWords, + }) ?? false; + } + + /// 设置ASR请求参数 + /// + /// [params] 请求参数JSON字符串 + Future setRequestParams(String params) async { + return await _channel.invokeMethod('setRequestParams', { + 'params': params, + }) ?? false; + } + + /// 启用语音停顿、分句、分词信息输出 + /// + /// [enable] 是否启用 + Future setShowUtterances(bool enable) async { + return await _channel.invokeMethod('setShowUtterances', { + 'enable': enable, + }) ?? false; + } + + /// 一次性识别(直到说话结束) + /// + /// 返回识别结果文本和语言 + Future> recognizeOnce() async { + final result = await _channel.invokeMethod('recognizeOnce'); + return { + 'text': result['text'] ?? '', + 'language': result['language'] ?? 'zh-CN', + }; + } + + /// 开始连续识别 + /// + /// 通过[events]流监听识别结果 + Future startContinuousRecognition() async { + return await _channel.invokeMethod('startContinuousRecognition') ?? false; + } + + /// 停止连续识别 + Future stopContinuousRecognition() async { + return await _channel.invokeMethod('stopContinuousRecognition') ?? false; + } + + /// 检查是否正在连续识别 + Future isContinuousRecognitionActive() async { + return await _channel.invokeMethod('isContinuousRecognitionActive') ?? false; + } + + /// 开始长按识别(按下开始,抬起结束) + /// + /// 通过[events]流监听识别结果 + Future startListening() async { + return await _channel.invokeMethod('startListening') ?? false; + } + + /// 停止长按识别 + Future stopListening() async { + return await _channel.invokeMethod('stopListening') ?? false; + } + + /// 释放ASR资源 + Future dispose() async { + return await _channel.invokeMethod('dispose') ?? false; + } +} + +/// 火山引擎语音合成服务 +class VolcanoSpeechTts { + static const MethodChannel _channel = MethodChannel('volcano_speech/tts'); + static const EventChannel _eventChannel = EventChannel('volcano_speech/tts_events'); + + /// TTS进度事件流控制器 + static final StreamController _progressStreamController = StreamController.broadcast(); + + /// TTS进度事件流 + Stream get progressEvents => _progressStreamController.stream; + + /// 是否已初始化事件监听 + bool _eventListenerInitialized = false; + + VolcanoSpeechTts._() { + _initEventListener(); + } + + /// 初始化TTS事件监听 + void _initEventListener() { + if (_eventListenerInitialized) return; + + _eventChannel.receiveBroadcastStream().listen((dynamic event) { + if (event is Map) { + final progress = event['progress'] as double?; + final reqId = event['reqId'] as String?; + + if (progress != null && reqId != null) { + _progressStreamController.add(TtsProgressEvent( + progress: progress, + reqId: reqId, + )); + } + } + }); + + _eventListenerInitialized = true; + } + + /// 获取平台版本信息 + Future getPlatformVersion() async { + return await _channel.invokeMethod('getPlatformVersion'); + } + + /// 启用大模型TTS + /// + /// [enable] 是否启用大模型TTS + /// [resourceId] 资源ID + Future enableBigModelTts({ + required bool enable, + String resourceId = '', + }) async { + return await _channel.invokeMethod('enableBigModelTts', { + 'enable': enable, + 'resourceId': resourceId, + }) ?? false; + } + + /// 初始化语音合成引擎 + /// + /// [appId] 火山引擎AppID + /// [apiKey] 火山引擎ApiKey + Future initialize({ + required String appId, + required String apiKey, + }) async { + return await _channel.invokeMethod('initialize', { + 'appId': appId, + 'apiKey': apiKey, + }) ?? false; + } + + /// 设置音色 + /// + /// [voiceName] 音色名称 + /// [voiceType] 音色类型,默认 qingxin + Future setVoice({ + required String voiceName, + String voiceType = 'qingxin', + }) async { + return await _channel.invokeMethod('setVoice', { + 'voiceName': voiceName, + 'voiceType': voiceType, + }) ?? false; + } + + /// 设置离线音色 + /// + /// [voiceName] 音色名称 + /// [voiceType] 音色类型,默认 qingxin + Future setOfflineVoice({ + required String voiceName, + String voiceType = 'qingxin', + }) async { + return await _channel.invokeMethod('setOfflineVoice', { + 'voiceName': voiceName, + 'voiceType': voiceType, + }) ?? false; + } + + /// 设置大模型声音ID + /// + /// [voiceId] 声音ID + Future setBigModelVoiceId(String voiceId) async { + return await _channel.invokeMethod('setBigModelVoiceId', { + 'voiceId': voiceId, + }) ?? false; + } + + /// 设置大模型请求参数 + /// + /// [params] 请求参数,JSON字符串 + Future setBigModelRequestParams(String params) async { + return await _channel.invokeMethod('setBigModelRequestParams', { + 'params': params, + }) ?? false; + } + + /// 设置工作模式 + /// + /// [mode] 工作模式 + Future setWorkMode(TtsWorkMode mode) async { + String modeStr; + switch (mode) { + case TtsWorkMode.online: + modeStr = 'online'; + break; + case TtsWorkMode.offline: + modeStr = 'offline'; + break; + case TtsWorkMode.both: + modeStr = 'both'; + break; + case TtsWorkMode.alternate: + modeStr = 'alternate'; + break; + case TtsWorkMode.file: + modeStr = 'file'; + break; + } + + return await _channel.invokeMethod('setWorkMode', { + 'mode': modeStr, + }) ?? false; + } + + /// 设置文本类型 + /// + /// [type] 文本类型,plain或ssml + Future setTextType(TtsTextType type) async { + String typeStr; + switch (type) { + case TtsTextType.plain: + typeStr = 'plain'; + break; + case TtsTextType.ssml: + typeStr = 'ssml'; + break; + } + + return await _channel.invokeMethod('setTextType', { + 'type': typeStr, + }) ?? false; + } + + /// 设置是否启用缓存 + /// + /// [enable] 是否启用 + Future setEnableCache(bool enable) async { + return await _channel.invokeMethod('setEnableCache', { + 'enable': enable, + }) ?? false; + } + + /// 设置情感 + /// + /// [emotion] 情感,例如 neutral、happy、angry、sad等 + Future setEmotion(String emotion) async { + return await _channel.invokeMethod('setEmotion', { + 'emotion': emotion, + }) ?? false; + } + + /// 设置是否启用情感预测 + /// + /// [enable] 是否启用 + Future setEnableEmotionPredict(bool enable) async { + return await _channel.invokeMethod('setEnableEmotionPredict', { + 'enable': enable, + }) ?? false; + } + + /// 设置是否启用声音克隆 + /// + /// [enable] 是否启用 + /// [backendCluster] 后端集群 + Future setEnableVoiceClone({ + required bool enable, + String backendCluster = '', + }) async { + return await _channel.invokeMethod('setEnableVoiceClone', { + 'enable': enable, + 'backendCluster': backendCluster, + }) ?? false; + } + + /// 设置是否启用回声消除 + /// + /// [enable] 是否启用 + Future setEnableAEC(bool enable) async { + return await _channel.invokeMethod('setEnableAEC', { + 'enable': enable, + }) ?? false; + } + + /// 下载离线资源 + /// + /// [voiceTypes] 音色类型列表 + /// [languages] 语言列表 + Future downloadOfflineResource({ + List voiceTypes = const ['qingxin'], + List languages = const ['zh-CN'], + }) async { + return await _channel.invokeMethod('downloadOfflineResource', { + 'voiceTypes': voiceTypes, + 'languages': languages, + }) ?? false; + } + + /// 设置语音参数 + /// + /// [rate] 语速 -500~500 + /// [volume] 音量 0~100 + /// [pitch] 音调 -500~500 + /// [silenceDuration] 静音时长,毫秒 + Future setSpeechParams({ + int rate = 0, + int volume = 100, + int pitch = 0, + int silenceDuration = 0, + }) async { + return await _channel.invokeMethod('setSpeechParams', { + 'rate': rate, + 'volume': volume, + 'pitch': pitch, + 'silenceDuration': silenceDuration, + }) ?? false; + } + + /// 设置音频输出类型 + /// + /// [outputType] 输出类型,speaker(扬声器),earpiece(听筒),auto(自动) + Future setAudioOutputType(String outputType) async { + return await _channel.invokeMethod('setAudioOutputType', { + 'outputType': outputType, + }) ?? false; + } + + /// 合成并播放文本 + /// + /// [text] 待合成的文本 + Future speakText(String text) async { + return await _channel.invokeMethod('speakText', { + 'text': text, + }) ?? false; + } + + /// 使用大模型合成并播放文本 + /// + /// [text] 待合成的文本 + /// [voiceId] 声音ID + /// [params] 额外参数,JSON字符串 + Future speakWithBigModel({ + required String text, + required String voiceId, + String params = '', + }) async { + return await _channel.invokeMethod('speakWithBigModel', { + 'text': text, + 'voiceId': voiceId, + 'params': params, + }) ?? false; + } + + /// 暂停播放 + Future pausePlayback() async { + return await _channel.invokeMethod('pausePlayback') ?? false; + } + + /// 恢复播放 + Future resumePlayback() async { + return await _channel.invokeMethod('resumePlayback') ?? false; + } + + /// 停止播放 + Future stopSpeaking() async { + return await _channel.invokeMethod('stopSpeaking') ?? false; + } + + /// 释放TTS资源 + Future dispose() async { + return await _channel.invokeMethod('dispose') ?? false; + } +} \ No newline at end of file diff --git a/local_plugins/volcano_speech/pubspec.yaml b/local_plugins/volcano_speech/pubspec.yaml new file mode 100644 index 000000000..cd90d8b14 --- /dev/null +++ b/local_plugins/volcano_speech/pubspec.yaml @@ -0,0 +1,28 @@ +name: volcano_speech +description: 火山引擎语音合成服务插件 +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.17.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^2.0.0 + +# The following section is specific to Flutter packages. +flutter: + # This section identifies this Flutter project as a plugin project. + plugin: + platforms: + android: + package: com.yunqiinnovation.volcano_speech + pluginClass: VolcanoSpeechPlugin + ios: + pluginClass: VolcanoSpeechPlugin \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index 19366500b..41fa9edf0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,8 @@ dependencies: path: local_plugins/azure_speech open_ai_service: path: local_plugins/open_ai_service + volcano_speech: + path: local_plugins/volcano_speech dev_dependencies: flutter_test: