diff --git a/lib/core/widgets/intro_overlay_builder.dart b/lib/core/widgets/intro_overlay_builder.dart index fec06693e..6b503bb69 100644 --- a/lib/core/widgets/intro_overlay_builder.dart +++ b/lib/core/widgets/intro_overlay_builder.dart @@ -4,6 +4,18 @@ import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:lottie/lottie.dart'; class IntroOverlayBuilder { + static OverlayPosition overlayPosition({ + required Offset offset, + required Size screenSize, + required Size size, + }) { + return OverlayPosition( + top: ScreenUtil().statusBarHeight, + width: screenSize.width, + crossAxisAlignment: CrossAxisAlignment.center, + ); + } + static Widget homeBuilder( StepWidgetParams params, bool isWakeup, { @@ -12,6 +24,18 @@ class IntroOverlayBuilder { }) { return Column( children: [ + Container( + padding: EdgeInsetsGeometry.all(12.w), + alignment: Alignment.centerRight, + child: _button( + '跳过', + () { + params.onFinish(); + }, + isSkip: true, + ), + ), + 100.verticalSpace, isWakeup ? _AnimatedLottieWidget() : SizedBox( @@ -31,27 +55,12 @@ class IntroOverlayBuilder { ), ), if (onNext != null) - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: EdgeInsets.only(right: 50.w), - child: _button( - '下一步', - () { - params.onNext!(); - onNext(); - }, - ), - ), - _button( - '跳过', - () { - params.onFinish(); - }, - isSkip: true, - ), - ], + _button( + '下一步', + () { + params.onNext!(); + onNext(); + }, ), ], ); diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index dc39fe3c8..92586db1f 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -196,17 +196,7 @@ class _AgentViewState extends State with WidgetsBindingObserver { IntroStepBuilder( group: 'ai', order: 3, - getOverlayPosition: ({ - required Offset offset, - required Size screenSize, - required Size size, - }) { - return OverlayPosition( - top: 200.h, - width: screenSize.width, - crossAxisAlignment: CrossAxisAlignment.end, - ); - }, + getOverlayPosition: IntroOverlayBuilder.overlayPosition, overlayBuilder: (params) { return Obx( () => IntroOverlayBuilder.homeBuilder( diff --git a/lib/modules/home/views/home_view.dart b/lib/modules/home/views/home_view.dart index 8506a7337..bbcd78dae 100644 --- a/lib/modules/home/views/home_view.dart +++ b/lib/modules/home/views/home_view.dart @@ -72,6 +72,11 @@ class HomeView extends GetView { @override Widget build(BuildContext context) { + // 预缓存图片 + precacheImage( + const AssetImage('assets/images/headphone_new3.png'), context); + precacheImage( + const AssetImage('assets/images/headphone_new4.png'), context); final bool isDarkMode = Theme.of(context).brightness == Brightness.dark; return Scaffold( backgroundColor: isDarkMode ? Colors.black : const Color(0xFFF9FAFC), @@ -83,77 +88,69 @@ class HomeView extends GetView { children: [ // 顶部标题栏 _buildAppBar(isDarkMode), - - // 可变高度的顶部图片区域 - Obx(() => _buildAdaptiveEarbudsImage()), - - SizedBox(height: 20.h), - - // 设备状态卡片 - 使用 AnimatedContainer 实现平滑过渡 - IntroStepBuilder( - group: 'home', - order: 1, - onWidgetLoad: () { - controller.noviceGuideService.startHomeGuide(); - }, - overlayBuilder: (params) { - return IntroOverlayBuilder.aiBuilder( - params, - '欢迎使用Voitrans!首先,让我们唤醒你的专属 AI—— 小言', - onNext: () { - controller.noviceGuideService.startWakeupListen(); - }, - ); - }, - builder: (BuildContext context, GlobalKey key) => SizedBox( - key: key, - child: _buildAnimatedDeviceStatusCard(isDarkMode), - ), - ), - - SizedBox(height: 20.h), - - IntroStepBuilder( - group: 'home', - order: 2, - getOverlayPosition: ({ - required Offset offset, - required Size screenSize, - required Size size, - }) { - return OverlayPosition( - top: 200.h, - width: screenSize.width, - crossAxisAlignment: CrossAxisAlignment.end, - ); - }, - overlayBuilder: (params) { - return Obx( - () => IntroOverlayBuilder.homeBuilder( - params, - controller.noviceGuideService.isWakeup.value, - onNext: () { - controller.noviceGuideService.closeWakeupListen(); - }, - ), - ); - }, - builder: (BuildContext context, GlobalKey key) => SizedBox( - key: key, - ), - ), - - // 热门AI体验 - 使用 Expanded 确保始终在可视范围内 + // 可变高度的顶部图片区域 - 使用Expanded让它占用剩余空间 Expanded( child: Column( - crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildSectionTitle(isDarkMode), - SizedBox(height: 16.h), - Expanded(child: _buildPremiumAILayout()), + Obx(() => _buildAdaptiveEarbudsImage()), + SizedBox(height: 20.h), + // 设备状态卡片 + IntroStepBuilder( + group: 'home', + order: 1, + onWidgetLoad: () { + controller.noviceGuideService.startHomeGuide(); + }, + overlayBuilder: (params) { + return IntroOverlayBuilder.aiBuilder( + params, + '欢迎使用Voitrans!首先,让我们唤醒你的专属 AI—— 小言', + onNext: () { + controller.noviceGuideService.startWakeupListen(); + }, + ); + }, + builder: (BuildContext context, GlobalKey key) => + SizedBox( + key: key, + child: _buildAnimatedDeviceStatusCard(isDarkMode), + ), + ), + SizedBox(height: 20.h), + IntroStepBuilder( + group: 'home', + order: 2, + getOverlayPosition: IntroOverlayBuilder.overlayPosition, + overlayBuilder: (params) { + return Obx( + () => IntroOverlayBuilder.homeBuilder( + params, + controller.noviceGuideService.isWakeup.value, + onNext: () { + controller.noviceGuideService.closeWakeupListen(); + }, + ), + ); + }, + builder: (BuildContext context, GlobalKey key) => + SizedBox( + key: key, + ), + ), ], ), ), + // 热门AI体验 - 固定在底部,基于内容的固定高度 + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, // 重要:让高度基于内容 + children: [ + _buildSectionTitle(isDarkMode), + SizedBox(height: 16.h), + // 给AI布局一个固定的高度,或者让它基于内容自适应 + _buildPremiumAILayout(), + ], + ), ], ), ), @@ -203,40 +200,39 @@ class HomeView extends GetView { ); } - - // 自适应高度的耳机图片 - 根据设备连接状态动态调整高度 + // 在自适应图片组件中添加RepaintBoundary Widget _buildAdaptiveEarbudsImage() { final bool isDarkMode = Get.isDarkMode; - // 根据设备连接状态调整图片高度 - // 已连接时高度较小,为设备信息让出空间;未连接时高度较大 - final double imageHeight = controller.isFullyConnectedRx.value ? 160.h : 220.h; - - return AnimatedContainer( - duration: const Duration(milliseconds: 500), // 平滑过渡动画 - curve: Curves.easeInOut, - height: imageHeight, - child: PageView( - children: [ - Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(16.r), - child: Image.asset( - isDarkMode - ? 'assets/images/headphone_new3.png' - : 'assets/images/headphone_new4.png', - height: imageHeight, - fit: BoxFit.contain, + final double imageHeight = + controller.isFullyConnectedRx.value ? 160.h : 220.h; + + return RepaintBoundary( + // 添加重绘边界,优化动画性能 + child: AnimatedContainer( + duration: const Duration(milliseconds: 500), + curve: Curves.easeInOut, + height: imageHeight, + child: PageView( + children: [ + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(16.r), + child: Image.asset( + isDarkMode + ? 'assets/images/headphone_new3.png' + : 'assets/images/headphone_new4.png', + height: imageHeight, + fit: BoxFit.contain, + ), ), ), - ), - ], + ], + ), ), ); } - - // 带动画的设备状态卡片 - 实现平滑的高度变化过渡 Widget _buildAnimatedDeviceStatusCard(bool isDarkMode) { return Obx(() { diff --git a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift index af51264a9..128a4852b 100644 --- a/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift +++ b/local_plugins/agent_service/ios/agent_service/Sources/agent_service/AgentServiceImpl.swift @@ -1727,7 +1727,7 @@ extension AgentServiceImpl: AzureAsrHelper.ContinuousRecognizeCallback { stopIdleCheck() } - func onError(_ error: String) { + func onError(_ errorCode: Int, _ error: String) { let data: [String: Any] = ["message": error.isEmpty ? "未知错误" : error] sendEvent(name: "error", data: data) isRecognizing = false diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt index 0f783813a..c9a229720 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrHelper.kt @@ -2,31 +2,17 @@ package com.yunqiinnovation.azure_speech import android.content.Context -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.util.Log import com.microsoft.cognitiveservices.speech.* import com.microsoft.cognitiveservices.speech.util.EventHandler import com.yunqiinnovation.azure_speech.tools.SimpleAudioReceiver -import android.media.AudioManager -import android.media.AudioAttributes -import android.media.AudioFocusRequest import android.os.Handler import android.os.Looper -import com.microsoft.cognitiveservices.speech.audio.PullAudioInputStreamCallback -import com.microsoft.cognitiveservices.speech.audio.AudioStreamFormat -import java.util.concurrent.BlockingQueue -import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit import com.yunqiinnovation.azure_speech.tools.RecordFile import com.microsoft.cognitiveservices.speech.audio.AudioConfig -import com.microsoft.cognitiveservices.speech.audio.AudioInputStream -import com.microsoft.cognitiveservices.speech.audio.PushAudioInputStream -import java.util.concurrent.atomic.AtomicBoolean + /** @@ -35,11 +21,6 @@ import java.util.concurrent.atomic.AtomicBoolean * 针对回音消除的场景,使用拉流模式 (PullAudioInputStreamCallback) 实现,避免复杂的协程处理 * 通过检测扬声器和耳机状态,自动决定是否启用回音消除 */ -import android.net.ConnectivityManager -import android.net.Network -import android.net.NetworkCapabilities -import android.net.NetworkRequest -import android.os.Build class AzureAsrHelper(private val context: Context) { private val tag = "AzureAsrHelper" @@ -66,10 +47,8 @@ class AzureAsrHelper(private val context: Context) { // 音频处理 var audioStream: SimpleAudioReceiver? = null - // 网络状态监听相关 - private var connectivityManager: ConnectivityManager? = null - private var networkCallback: ConnectivityManager.NetworkCallback? = null - private var isNetworkAvailable = true + // 网络状态监听 + private var networkMonitor: NetworkStateMonitor = NetworkStateMonitor(context) private var isNetworkRecovering = false @@ -150,10 +129,16 @@ class AzureAsrHelper(private val context: Context) { // 设置分段策略为时间模式 setProperty("Speech_SegmentationStrategy", "Time") } - - - // 初始化网络监听 - initNetworkMonitoring() + // 初始化网络监听 + networkMonitor.initialize(object : NetworkStateMonitor.NetworkStateListener { + override fun onNetworkAvailable() { + handleNetworkAvailable() + } + + override fun onNetworkLost() { + handleNetworkLost() + } + }) return true } catch (e: Exception) { @@ -289,30 +274,7 @@ class AzureAsrHelper(private val context: Context) { } } - /** - * 检查当前网络状态 - * @return true表示网络可用,false表示网络不可用 - */ - private fun checkNetworkStatus(): Boolean { - try { - val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - val network = connectivityManager.activeNetwork ?: return false - val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - - return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && - networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) - } else { - @Suppress("DEPRECATION") - val networkInfo = connectivityManager.activeNetworkInfo - return networkInfo?.isConnectedOrConnecting == true - } - } catch (e: Exception) { - Log.e(tag, "检查网络状态失败: ${e.message}") - return false - } - } + /** * 开始连续语音识别 @@ -544,13 +506,8 @@ class AzureAsrHelper(private val context: Context) { // recognizer?.stopContinuousRecognitionAsync()?.get(1000, TimeUnit.MILLISECONDS) // 停止音频处理 stopAudioProcessing() - - // 清理网络监听 - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && networkCallback != null) { - connectivityManager?.unregisterNetworkCallback(networkCallback!!) - networkCallback = null - } - connectivityManager = null + // 清理网络监听 + networkMonitor.dispose() // 停止录音 if (audioStream!!.recordfile != null) { @@ -766,80 +723,34 @@ class AzureAsrHelper(private val context: Context) { */ fun onError(code: Int,error: String) } + + + /** + * 检查当前网络状态 + * @return true表示网络可用,false表示网络不可用 + */ + private fun checkNetworkStatus(): Boolean { + return networkMonitor.checkNetworkStatus() + } /** * 检查是否是网络相关错误 */ private fun isNetworkRelatedError(reason: String, errorDetails: String): Boolean { - val networkErrorKeywords = arrayOf( - "network", "connection", "timeout", "unreachable", - "dns", "socket", "ssl", "tls", "certificate", - "网络", "连接", "超时", "不可达" - ) - - val combinedText = "$reason $errorDetails".lowercase() - return networkErrorKeywords.any { keyword -> - combinedText.contains(keyword.lowercase()) - } + return networkMonitor.isNetworkRelatedError(reason, errorDetails) } - /** - * 初始化网络状态监听 - */ - private fun initNetworkMonitoring() { - try { - connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - networkCallback = object : ConnectivityManager.NetworkCallback() { - override fun onAvailable(network: Network) { - super.onAvailable(network) - Log.d(tag, "网络连接可用") - handleNetworkAvailable() - } - - override fun onLost(network: Network) { - super.onLost(network) - Log.d(tag, "网络连接丢失") - handleNetworkLost() - } - - override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { - super.onCapabilitiesChanged(network, networkCapabilities) - val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - Log.d(tag, "网络能力变化,是否有网络: $hasInternet") - if (hasInternet && !isNetworkAvailable) { - handleNetworkAvailable() - } else if (!hasInternet && isNetworkAvailable) { - handleNetworkLost() - } - } - } - - val networkRequest = NetworkRequest.Builder() - .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - .build() - - connectivityManager?.registerNetworkCallback(networkRequest, networkCallback!!) - } - } catch (e: Exception) { - Log.e(tag, "初始化网络监听失败: ${e.message}") - } - } - + /** * 处理网络连接可用 */ private fun handleNetworkAvailable() { - if (!isNetworkAvailable) { - isNetworkAvailable = true + if (!isNetworkRecovering) { isNetworkRecovering = true Log.d(tag, "网络恢复,准备重新启动识别") // 如果之前在进行连续识别,重新启动 - - Handler(Looper.getMainLooper()).postDelayed({ - restartContinuousRecognition() - }, 1000) // 延迟1秒确保网络稳定 - + Handler(Looper.getMainLooper()).postDelayed({ + restartContinuousRecognition() + }, 1000) // 延迟1秒确保网络稳定 } } @@ -847,14 +758,8 @@ class AzureAsrHelper(private val context: Context) { * 处理网络连接丢失 */ private fun handleNetworkLost() { - if (isNetworkAvailable) { - isNetworkAvailable = false - Log.d(tag, "网络断开,停止音频处理") - - - stopAudioProcessingImmediately() - - } + Log.d(tag, "网络断开,停止音频处理") + stopAudioProcessingImmediately() } /** @@ -863,7 +768,7 @@ class AzureAsrHelper(private val context: Context) { private fun stopAudioProcessingImmediately() { try { Log.d(tag, "立即停止音频处理") - stopContinuousRecognition() + stopContinuousRecognition() // 不改变isContinuousRecognitionActive状态,保持识别意图 } catch (e: Exception) { Log.e(tag, "立即停止音频处理失败: ${e.message}") @@ -874,23 +779,18 @@ class AzureAsrHelper(private val context: Context) { * 重新启动连续识别(网络恢复时使用) */ private fun restartContinuousRecognition() { - try { + try { Log.d(tag, "重新启动连续识别") - // 检查continuousCallback是否为空 - val callback = continuousCallback - if (callback == null) { - Log.e(tag, "无法重新启动连续识别:回调为空") - return - } - - setupEventListeners(callback) - + // 检查continuousCallback是否为空 + val callback = continuousCallback + if (callback == null) { + Log.e(tag, "无法重新启动连续识别:回调为空") + return + } + + setupEventListeners(callback) isNetworkRecovering = false Log.d(tag, "连续识别重新启动成功") - - // 通知上层网络已恢复 - //continuousCallback?.onError("网络已恢复") - } catch (e: Exception) { Log.e(tag, "重新启动连续识别失败: ${e.message}") isNetworkRecovering = false diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt index fc92edaad..6aef2dc35 100644 --- a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureAsrToAsr.kt @@ -644,19 +644,54 @@ class IntegratedSpeechTranslationService( private fun getVoiceForLanguage(language: String): String { return voiceCache.getOrPut(language) { when (language) { - "zh-CN" -> "zh-CN-XiaoxiaoNeural" - "zh-TW" -> "zh-TW-HsiaoChenNeural" - "en-US" -> "en-US-AriaNeural" - "en-GB" -> "en-GB-SoniaNeural" - "ja-JP" -> "ja-JP-NanamiNeural" - "ko-KR" -> "ko-KR-SunHiNeural" - "fr-FR" -> "fr-FR-DeniseNeural" - "de-DE" -> "de-DE-KatjaNeural" - "es-ES" -> "es-ES-ElviraNeural" - "ru-RU" -> "ru-RU-SvetlanaNeural" - "ar-SA" -> "ar-SA-ZariyahNeural" - "pt-BR" -> "pt-BR-FranciscaNeural" - "it-IT" -> "it-IT-ElsaNeural" + // 中文相关 + "zh-CN" -> "zh-CN-XiaoxiaoNeural" + "zh-TW" -> "zh-TW-HsiaoChenNeural" + "zh-HK" -> "zh-HK-HiuMaanNeural" + + // 英语相关 + "en-US" -> "en-US-AriaNeural" + + // 亚洲语言 + "ja-JP" -> "ja-JP-NanamiNeural" + "ko-KR" -> "ko-KR-SunHiNeural" + "th-TH" -> "th-TH-PremwadeeNeural" + "vi-VN" -> "vi-VN-HoaiMyNeural" + "id-ID" -> "id-ID-GadisNeural" + "hi-IN" -> "hi-IN-SwaraNeural" + "ms-MY" -> "ms-MY-YasminNeural" + "bn-IN" -> "bn-IN-TanishaaNeural" + "ta-IN" -> "ta-IN-PallaviNeural" + "te-IN" -> "te-IN-ShrutiNeural" + "mr-IN" -> "mr-IN-AarohiNeural" + "ur-IN" -> "ur-IN-GulNeural" + "tr-TR" -> "tr-TR-EmelNeural" + "fa-IR" -> "fa-IR-DilaraNeural" + + // 欧洲语言 + "fr-FR" -> "fr-FR-DeniseNeural" + "es-ES" -> "es-ES-ElviraNeural" + "pt-BR" -> "pt-BR-FranciscaNeural" + "it-IT" -> "it-IT-ElsaNeural" + "de-DE" -> "de-DE-KatjaNeural" + "ru-RU" -> "ru-RU-SvetlanaNeural" + "pl-PL" -> "pl-PL-AgnieszkaNeural" + "nl-NL" -> "nl-NL-ColetteNeural" + "sv-SE" -> "sv-SE-SofieNeural" + "cs-CZ" -> "cs-CZ-VlastaNeural" + "el-GR" -> "el-GR-AthinaNeural" + "ro-RO" -> "ro-RO-AlinaNeural" + "hu-HU" -> "hu-HU-NoemiNeural" + "uk-UA" -> "uk-UA-PolinaNeural" + "da-DK" -> "da-DK-ChristelNeural" + "fi-FI" -> "fi-FI-NooraNeural" + "nb-NO" -> "nb-NO-IselinNeural" + "hr-HR" -> "hr-HR-GabrijelaNeural" + "ca-ES" -> "ca-ES-JoanaNeural" + + // 阿拉伯语和非洲语言 + "ar-EG" -> "ar-EG-SalmaNeural" + "sw-KE" -> "sw-KE-ZuriNeural" else -> "en-US-AriaNeural" }.also { serviceConfig.currentVoice = it @@ -830,13 +865,45 @@ fun setAudioOutputDevice(device: com.deep_voice.speech.tts.AudioOutputDevice) { /** * 获取当前支持的语言列表 */ - fun getSupportedLanguages(): List { - return listOf( - "zh-CN", "zh-TW", "en-US", "en-GB", "ja-JP", "ko-KR", - "fr-FR", "de-DE", "es-ES", "ru-RU", "ar-SA", "pt-BR", - "it-IT", "th-TH", "vi-VN", "hi-IN" - ) - } + +fun getSupportedLanguages(): List { + return listOf( + // 亚洲语言 + "zh-CN", "zh-TW", "zh-HK", "ja-JP", "ko-KR", + "hi-IN", "ta-IN", "te-IN", "bn-IN", "pa-IN", + "ur-PK", "th-TH", "vi-VN", "ms-MY", "id-ID", + "fil-PH", "km-KH", "my-MM", "lo-LA", "ne-NP", + + // 欧洲语言 + "en-US", "en-GB", "en-CA", "en-AU", "fr-FR", + "fr-CA", "de-DE", "es-ES", "es-MX", "it-IT", + "nl-NL", "pl-PL", "ru-RU", "tr-TR", "uk-UA", + "cs-CZ", "hu-HU", "sv-SE", "fi-FI", "da-DK", + "no-NO", "ro-RO", "el-GR", "bg-BG", "hr-HR", + "sr-RS", "sk-SK", "sl-SI", "lt-LT", "lv-LV", + "et-EE", "is-IS", "ga-IE", + + // 中东/非洲语言 + "ar-SA", "ar-EG", "he-IL", "fa-IR", "ps-AF", + "ku-TR", "sw-KE", "am-ET", "yo-NG", "zu-ZA", + "xh-ZA", "af-ZA", "st-ZA", "tn-ZA", "ha-NG", + "ig-NG", "mg-MG", "rw-RW", "so-SO", "ti-ER", + + // 美洲/大洋洲 + "pt-BR", "pt-PT", "qu-PE", "gn-PY", "ay-BO", + "mi-NZ", "haw-US", "sm-WS", "to-TO", "fj-FJ", + + // 其他重要语言 + "as-IN", "or-IN", "kn-IN", "ml-IN", "gu-IN", + "mr-IN", "sa-IN", "sd-IN", "bo-CN", "ug-CN", + "ii-CN", "mn-MN", "jv-ID", "su-ID", "ceb-PH", + "gl-ES", "eu-ES", "ca-ES", "gd-GB", "cy-GB", + "br-FR", "fy-NL", "lb-LU", "mt-MT", "sq-AL", + "hy-AM", "ka-GE", "be-BY", "kk-KZ", "uz-UZ", + "ky-KG", "tg-TJ", "tk-TM", "tt-RU", "cv-RU" + ) +} + /** * 设置识别参数 @@ -1219,24 +1286,57 @@ class VolcanoTranslationServiceImpl : .build() // 语言代码映射 - private val languageCodeMap = mapOf( - "zh-CN" to "zh", - "zh-TW" to "zh-Hant", - "en-US" to "en", - "en-GB" to "en", - "ja-JP" to "ja", - "ko-KR" to "ko", - "fr-FR" to "fr", - "de-DE" to "de", - "es-ES" to "es", - "ru-RU" to "ru", - "ar-SA" to "ar", - "pt-BR" to "pt", - "it-IT" to "it", - "th-TH" to "th", - "vi-VN" to "vi", - "hi-IN" to "hi" - ) + private val languageCodeMap = mapOf( + // 亚洲 + "zh-CN" to "zh", // 简体中文(中国大陆) + "zh-TW" to "zh-Hant", // 繁体中文(台湾) + "zh-HK" to "zh-Hant", // 繁体中文(香港) + "ja-JP" to "ja", // 日语(日本) + "ko-KR" to "ko", // 韩语(韩国) + "hi-IN" to "hi", // 印地语(印度) + "ta-IN" to "ta", // 泰米尔语(印度) + "te-IN" to "te", // 泰卢固语(印度) + "bn-IN" to "bn", // 孟加拉语(印度) + "pa-IN" to "pa", // 旁遮普语(印度) + "ur-PK" to "ur", // 乌尔都语(巴基斯坦) + "th-TH" to "th", // 泰语(泰国) + "vi-VN" to "vi", // 越南语(越南) + "ms-MY" to "ms", // 马来语(马来西亚) + "id-ID" to "id", // 印尼语(印尼) + "fil-PH" to "tl", // 菲律宾语(菲律宾) + + // 欧洲 + "en-US" to "en", // 英语(美国) + "en-GB" to "en", // 英语(英国) + "fr-FR" to "fr", // 法语(法国) + "fr-CA" to "fr", // 法语(加拿大) + "de-DE" to "de", // 德语(德国) + "es-ES" to "es", // 西班牙语(西班牙) + "es-MX" to "es", // 西班牙语(墨西哥) + "it-IT" to "it", // 意大利语(意大利) + "nl-NL" to "nl", // 荷兰语(荷兰) + "pl-PL" to "pl", // 波兰语(波兰) + "ru-RU" to "ru", // 俄语(俄罗斯) + "tr-TR" to "tr", // 土耳其语(土耳其) + + // 中东 + "ar-SA" to "ar", // 阿拉伯语(沙特) + "ar-EG" to "ar", // 阿拉伯语(埃及) + "he-IL" to "he", // 希伯来语(以色列) + + // 非洲 + "sw-KE" to "sw", // 斯瓦希里语(肯尼亚) + "am-ET" to "am", // 阿姆哈拉语(埃塞俄比亚) + "yo-NG" to "yo", // 约鲁巴语(尼日利亚) + + // 其他 + "pt-BR" to "pt", // 葡萄牙语(巴西) + "pt-PT" to "pt", // 葡萄牙语(葡萄牙) + "fa-IR" to "fa", // 波斯语(伊朗) + "el-GR" to "el", // 希腊语(希腊) + "cs-CZ" to "cs", // 捷克语(捷克) + "uk-UA" to "uk", // 乌克兰语(乌克兰) +) override suspend fun initialize(config: Map): Boolean { return try { diff --git a/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/NetworkStateMonitor.kt b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/NetworkStateMonitor.kt new file mode 100644 index 000000000..ae2f759be --- /dev/null +++ b/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/NetworkStateMonitor.kt @@ -0,0 +1,139 @@ +// NetworkStateMonitor.kt +package com.yunqiinnovation.azure_speech + +import android.content.Context +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import android.net.NetworkRequest +import android.os.Build +import android.util.Log + +/** + * 网络状态监听器,负责监控网络连接状态变化 + */ +class NetworkStateMonitor(private val context: Context) { + private val tag = "NetworkStateMonitor" + + private var connectivityManager: ConnectivityManager? = null + private var networkCallback: ConnectivityManager.NetworkCallback? = null + private var isNetworkAvailable = true + private var networkStateListener: NetworkStateListener? = null + + /** + * 网络状态监听接口 + */ + interface NetworkStateListener { + fun onNetworkAvailable() + fun onNetworkLost() + } + + /** + * 初始化网络状态监听 + */ + fun initialize(listener: NetworkStateListener? = null) { + networkStateListener = listener + initNetworkMonitoring() + } + + /** + * 检查当前网络状态 + * @return true表示网络可用,false表示网络不可用 + */ + fun checkNetworkStatus(): Boolean { + try { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val networkCapabilities = connectivityManager.getNetworkCapabilities(network) ?: return false + + return networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo + return networkInfo?.isConnectedOrConnecting == true + } + } catch (e: Exception) { + Log.e(tag, "检查网络状态失败: ${e.message}") + return false + } + } + + /** + * 初始化网络状态监听 + */ + private fun initNetworkMonitoring() { + try { + connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + networkCallback = object : ConnectivityManager.NetworkCallback() { + override fun onAvailable(network: Network) { + super.onAvailable(network) + Log.d(tag, "网络连接可用") + isNetworkAvailable = true + networkStateListener?.onNetworkAvailable() + } + + override fun onLost(network: Network) { + super.onLost(network) + Log.d(tag, "网络连接丢失") + isNetworkAvailable = false + networkStateListener?.onNetworkLost() + } + + override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) { + super.onCapabilitiesChanged(network, networkCapabilities) + val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + Log.d(tag, "网络能力变化,是否有网络: $hasInternet") + if (hasInternet && !isNetworkAvailable) { + isNetworkAvailable = true + networkStateListener?.onNetworkAvailable() + } else if (!hasInternet && isNetworkAvailable) { + isNetworkAvailable = false + networkStateListener?.onNetworkLost() + } + } + } + + val networkRequest = NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build() + + connectivityManager?.registerNetworkCallback(networkRequest, networkCallback!!) + } + } catch (e: Exception) { + Log.e(tag, "初始化网络监听失败: ${e.message}") + } + } + + /** + * 释放资源 + */ + fun dispose() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && networkCallback != null) { + connectivityManager?.unregisterNetworkCallback(networkCallback!!) + networkCallback = null + } + connectivityManager = null + networkStateListener = null + } + + /** + * 检查是否是网络相关错误 + */ + fun isNetworkRelatedError(reason: String, errorDetails: String): Boolean { + val networkErrorKeywords = arrayOf( + "network", "connection", "timeout", "unreachable", + "dns", "socket", "ssl", "tls", "certificate", + "网络", "连接", "超时", "不可达" + ) + + val combinedText = "$reason $errorDetails".lowercase() + return networkErrorKeywords.any { keyword -> + combinedText.contains(keyword.lowercase()) + } + } +} \ No newline at end of file diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index fa4f5d684..3000c5ee7 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift @@ -3,7 +3,8 @@ import AVFoundation import MicrosoftCognitiveServicesSpeech import speech import os.log -import Network +// 移除 Network 导入,因为我们将使用独立的 NetworkStateMonitor +// import Network // MARK: - 基于微软Azure语音服务的ASR实现 /** @@ -22,12 +23,10 @@ public class AzureAsrHelper: NSObject { private var recognizer: SPXSpeechRecognizer? private var audioConfig: SPXAudioConfiguration? - // 网络监听相关 - private var networkMonitor: NWPathMonitor? - private var networkQueue: DispatchQueue? - private var isNetworkAvailable = true + // 网络监听相关 - 使用独立的NetworkStateMonitor + private var networkMonitor: NetworkStateMonitor = NetworkStateMonitor() private var continuousCallback: ContinuousRecognizeCallback? - + private var isNetworkRecovering = false // 状态管理 private var _isContinuousRecognitionActive = false @@ -117,7 +116,7 @@ public class AzureAsrHelper: NSObject { // 预初始化音频组件 preInitializeAudioComponents() - // 初始化网络监听 + // 初始化网络监听 - 使用独立的NetworkStateMonitor initNetworkMonitoring() return true @@ -126,77 +125,77 @@ public class AzureAsrHelper: NSObject { return false } } - /** - * 初始化网络监听 - */ - private func initNetworkMonitoring() { - networkQueue = DispatchQueue(label: "NetworkMonitor") - networkMonitor = NWPathMonitor() - - networkMonitor?.pathUpdateHandler = { [weak self] path in - guard let self = self else { return } - - let wasAvailable = self.isNetworkAvailable - self.isNetworkAvailable = (path.status == .satisfied) - - if wasAvailable && !self.isNetworkAvailable { - // 网络断开 - self.handleNetworkLost() - } else if !wasAvailable && self.isNetworkAvailable { - // 网络恢复 - self.handleNetworkAvailable() - } - } - - if let queue = networkQueue { - networkMonitor?.start(queue: queue) - } - - os_log("网络监听已初始化", log: log, type: .info) - } - - /** - * 处理网络可用事件 - */ - private func handleNetworkAvailable() { - os_log("网络已恢复", log: log, type: .info) - - DispatchQueue.main.async { [weak self] in - // self?.continuousCallback?.onNetworkRestored() - } - } - - /** - * 处理网络丢失事件 - */ - private func handleNetworkLost() { - os_log("网络连接丢失", log: log, type: .error) - - // 立即停止音频处理 - stopAudioProcessingImmediately() + + /** + * 初始化网络监听 + * 使用独立的NetworkStateMonitor类 + */ + private func initNetworkMonitoring() { + networkMonitor.initialize(listener: self) + os_log("网络监听已初始化", log: log, type: .info) + } + + /** + * 处理网络可用事件 + */ + private func handleNetworkAvailable() { + os_log("网络已恢复", log: log, type: .info) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + if !self.isNetworkRecovering { + self.isNetworkRecovering = true + os_log("网络恢复,准备重新启动识别", log: self.log, type: .info) - DispatchQueue.main.async { [weak self] in - // self?.continuousCallback?.onNetworkError("网络连接已断开") + // 如果之前在进行连续识别,重新启动 + DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { + self.restartContinuousRecognition() } } - - /** - * 立即停止音频处理 - */ - private func stopAudioProcessingImmediately() { - audioStream?.stopMicrophoneCapture() - - if _isContinuousRecognitionActive { - do { - try recognizer?.stopContinuousRecognition() - _isContinuousRecognitionActive = false - os_log("因网络问题强制停止识别", log: log, type: .info) - } catch { - _isContinuousRecognitionActive = false - os_log("强制停止识别失败: %@", log: log, type: .error, error.localizedDescription) - } - } + } + } + + /** + * 重新启动连续识别(网络恢复时使用) + */ + private func restartContinuousRecognition() { + os_log("重新启动连续识别", log: log, type: .info) + + // 检查continuousCallback是否为空 + guard let callback = continuousCallback else { + os_log("连续识别回调为空,无法重新启动", log: log, type: .info) + return + } + + do { + _ = setupEventListeners(callback: callback) + isNetworkRecovering = false + os_log("连续识别重新启动成功", log: log, type: .info) + } catch { + os_log("重新启动连续识别失败: %@", log: log, type: .error, error.localizedDescription) + isNetworkRecovering = false + } + } + + + /** + * 立即停止音频处理 + */ + private func stopAudioProcessingImmediately() { + audioStream?.stopMicrophoneCapture() + + if _isContinuousRecognitionActive { + do { + try recognizer?.stopContinuousRecognition() + _isContinuousRecognitionActive = false + os_log("因网络问题强制停止识别", log: log, type: .info) + } catch { + _isContinuousRecognitionActive = false + os_log("强制停止识别失败: %@", log: log, type: .error, error.localizedDescription) } + } + } + /** * 预初始化音频组件 * 在初始化阶段就准备好音频流和相关组件,减少首次启动延迟 @@ -352,6 +351,8 @@ public class AzureAsrHelper: NSObject { // 停止音频处理 audioStream?.releaseAudioResources() + // 释放网络监听资源 + networkMonitor.dispose() // 释放资源 recognizer = nil speechConfig = nil @@ -614,7 +615,7 @@ public class AzureAsrHelper: NSObject { } // 取消事件 - recognizer.addCanceledEventHandler { [weak self] (sender, event) in + recognizer.addCanceledEventHandler { [weak self] (_, event) in guard let self = self else { return } print("取消事件:") let errorDetails = event.errorDetails ?? "未知错误" @@ -624,7 +625,13 @@ public class AzureAsrHelper: NSObject { // 检查是否为网络相关错误 if self.isNetworkRelatedError(errorDetails) { - callback.onError("网络连接问题: \(errorDetails)") + os_log("检测到网络相关错误,停止音频处理", log: self.log, type: .info) + self.stopAudioProcessingImmediately() + + // 如果不是正在恢复网络,通知上层网络错误 + if !self.isNetworkRecovering { + callback.onError(1000,"启动连续识别失败,网络连接已断开") + } } else { callback.onCanceled(reason, errorDetails) } @@ -795,24 +802,19 @@ public class AzureAsrHelper: NSObject { * * @param error 错误信息 */ - func onError(_ error: String) + func onError(_ code: Int,_ error: String) } /** * 检查网络状态 * @return 网络是否可用 */ private func checkNetworkStatus() -> Bool { - guard let monitor = networkMonitor else { - os_log("网络监听器未初始化,假设网络可用", log: log) - return true - } - - let currentPath = monitor.currentPath - let isAvailable = currentPath.status == .satisfied + // 直接使用 NetworkStateMonitor 的 checkNetworkStatus 方法 + let isAvailable = networkMonitor.checkNetworkStatus() if !isAvailable { os_log("网络不可用,无法启动语音识别", log: log, type: .error) - continuousCallback?.onError("网络连接不可用,请检查网络设置") + continuousCallback?.onError(1000,"网络连接不可用,请检查网络设置") } return isAvailable @@ -835,7 +837,35 @@ private func isNetworkRelatedError(_ errorDetails: String) -> Bool { } } - +// MARK: - NetworkStateListener实现 +extension AzureAsrHelper: NetworkStateMonitor.NetworkStateListener { + /** + * 处理网络可用事件 + */ + func onNetworkAvailable() { + os_log("网络已恢复", log: log, type: .info) + + DispatchQueue.main.async { [weak self] in + self?.handleNetworkAvailable() + } + } + + /** + * 处理网络丢失事件 + */ + func onNetworkLost() { + os_log("网络连接丢失", log: log, type: .error) + + // 立即停止音频处理 + stopAudioProcessingImmediately() + + DispatchQueue.main.async { [weak self] in + self?.continuousCallback?.onError(1000,"网络连接已断开") + } + } + + +} // //ios 语言检测 // /** // * 检测输入文本的语言,从支持的语言列表中选择最匹配的 diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 0f5c93eab..7633701aa 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -1057,12 +1057,14 @@ private class AsrCallbackWrapper: AzureAsrHelper.ContinuousRecognizeCallback { /** * 错误回调 + * @param errorCode 错误码 * @param error 错误信息 */ - func onError(_ error: String) { + func onError(_ errorCode: Int, _ error: String) { plugin?.sendAsrEvent([ "type": "error", - "error": error + "code": errorCode, + "message": error ]) } } diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift index 1cf58606a..538a5b66e 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/IntegratedSpeechTranslationService.swift @@ -212,7 +212,12 @@ import os.log serviceConfig: ServiceConfiguration? = nil, callback: ServiceEventCallback ) async -> Bool { - + + // 重新初始化前先清理 + if (serviceState.isInitialized) { + os_log("检测到服务已初始化,先进行清理", log: log, type: .info) + dispose() + } do { self.eventCallback = callback @@ -285,19 +290,10 @@ import os.log config.speechRecognitionLanguage = serviceConfig.sourceLanguage config.speechSynthesisVoiceName = getVoiceForLanguage(serviceConfig.targetLanguage) config.setSpeechSynthesisOutputFormat(.riff16Khz16BitMonoPcm) - - // 优化配置 - config.setPropertyTo(Self.END_SILENCE_TIMEOUT, byName: "SpeechServiceConnection_EndSilenceTimeoutMs") - config.setPropertyTo(Self.SEGMENTATION_SILENCE_TIMEOUT, byName: "Speech_SegmentationSilenceTimeoutMs") - config.setPropertyTo(Self.INITIAL_SILENCE_TIMEOUT, byName: "SpeechServiceConnection_InitialSilenceTimeoutMs") - config.setPropertyTo("INTERACTIVE", byName: "SpeechServiceConnection_RecoMode") - - // 启用详细结果 - config.setPropertyTo("true", byName: "SpeechServiceResponse_RequestDetailedResultTrueFalse") - + // 自动语言检测 if serviceConfig.enableAutoLanguageDetection { - config.setPropertyTo("Continuous", byName: "SpeechServiceConnection_LanguageIdMode") + config.setPropertyTo("Continuous", by: SPXPropertyId.speechServiceConnectionLanguageIdMode) } os_log("Azure语音服务配置完成", log: log, type: .info) @@ -767,24 +763,73 @@ import os.log } let voice: String - switch language { - case "zh-CN": - voice = "zh-CN-XiaoxiaoNeural" - case "en-US": - voice = "en-US-AriaNeural" - case "ja-JP": - voice = "ja-JP-NanamiNeural" - case "ko-KR": - voice = "ko-KR-SunHiNeural" - case "fr-FR": - voice = "fr-FR-DeniseNeural" - case "de-DE": - voice = "de-DE-KatjaNeural" - case "es-ES": - voice = "es-ES-ElviraNeural" - default: - voice = "en-US-AriaNeural" - } + switch language { + // 亚洲地区 + case "zh-CN": + voice = "zh-CN-XiaoxiaoNeural" + case "zh-TW": + voice = "zh-TW-HsiaoNeural" + case "zh-HK": + voice = "zh-HK-HiuGaaiNeural" + case "ja-JP": + voice = "ja-JP-NanamiNeural" + case "ko-KR": + voice = "ko-KR-SunHiNeural" + case "hi-IN": + voice = "hi-IN-KalpanaNeural" + case "ta-IN": + voice = "ta-IN-SundarNeural" + case "th-TH": + voice = "th-TH-PattaraNeural" + case "vi-VN": + voice = "vi-VN-AnhNeural" + + // 欧洲地区 + case "en-US": + voice = "en-US-AriaNeural" + case "en-GB": + voice = "en-GB-SusanNeural" + case "fr-FR": + voice = "fr-FR-DeniseNeural" + case "de-DE": + voice = "de-DE-KatjaNeural" + case "es-ES": + voice = "es-ES-ElviraNeural" + case "it-IT": + voice = "it-IT-DonatellaNeural" + case "pl-PL": + voice = "pl-PL-JozefinaNeural" + case "ru-RU": + voice = "ru-RU-DariaNeural" + + // 中东/非洲 + case "ar-SA": + voice = "ar-SA-MajdNeural" + case "he-IL": + voice = "he-IL-NoaNeural" + case "sw-KE": + voice = "sw-KE-SabinaNeural" + + // 美洲/大洋洲 + case "pt-BR": + voice = "pt-BR-ManuelaNeural" + case "es-MX": + voice = "es-MX-CarlaNeural" + + // 其他重要语言 + case "tr-TR": + voice = "tr-TR-SerifeNeural" + case "uk-UA": + voice = "uk-UA-OksanaNeural" + case "fa-IR": + voice = "fa-IR-SaraNeural" + case "nl-NL": + voice = "nl-NL-LotteNeural" + + default: + voice = "en-US-AriaNeural" // 默认回退英语 +} + voiceCache[language] = voice return voice diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/VolcanoTranslationServiceImpl.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/VolcanoTranslationServiceImpl.swift index d07ac6b9e..7ef63cdeb 100644 --- a/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/VolcanoTranslationServiceImpl.swift +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/VolcanoTranslationServiceImpl.swift @@ -34,24 +34,58 @@ public class VolcanoTranslationServiceImpl: IntegratedSpeechTranslationService.T }() // 语言代码映射 - private let languageCodeMap: [String: String] = [ - "zh-CN": "zh", - "zh-TW": "zh-Hant", - "en-US": "en", - "en-GB": "en", - "ja-JP": "ja", - "ko-KR": "ko", - "fr-FR": "fr", - "de-DE": "de", - "es-ES": "es", - "ru-RU": "ru", - "ar-SA": "ar", - "pt-BR": "pt", - "it-IT": "it", - "th-TH": "th", - "vi-VN": "vi", - "hi-IN": "hi" - ] + private let languageCodeMap: [String: String] = [ + // 亚洲 + "zh-CN": "zh", // 简体中文(中国大陆) + "zh-TW": "zh-Hant", // 繁体中文(台湾) + "zh-HK": "zh-Hant", // 繁体中文(香港) + "ja-JP": "ja", // 日语(日本) + "ko-KR": "ko", // 韩语(韩国) + "hi-IN": "hi", // 印地语(印度) + "ta-IN": "ta", // 泰米尔语(印度) + "te-IN": "te", // 泰卢固语(印度) + "bn-IN": "bn", // 孟加拉语(印度) + "pa-IN": "pa", // 旁遮普语(印度) + "ur-PK": "ur", // 乌尔都语(巴基斯坦) + "th-TH": "th", // 泰语(泰国) + "vi-VN": "vi", // 越南语(越南) + "ms-MY": "ms", // 马来语(马来西亚) + "id-ID": "id", // 印尼语(印尼) + "fil-PH": "tl", // 菲律宾语(菲律宾) + + // 欧洲 + "en-US": "en", // 英语(美国) + "en-GB": "en", // 英语(英国) + "fr-FR": "fr", // 法语(法国) + "fr-CA": "fr", // 法语(加拿大) + "de-DE": "de", // 德语(德国) + "es-ES": "es", // 西班牙语(西班牙) + "es-MX": "es", // 西班牙语(墨西哥) + "it-IT": "it", // 意大利语(意大利) + "nl-NL": "nl", // 荷兰语(荷兰) + "pl-PL": "pl", // 波兰语(波兰) + "ru-RU": "ru", // 俄语(俄罗斯) + "tr-TR": "tr", // 土耳其语(土耳其) + + // 中东 + "ar-SA": "ar", // 阿拉伯语(沙特) + "ar-EG": "ar", // 阿拉伯语(埃及) + "he-IL": "he", // 希伯来语(以色列) + + // 非洲 + "sw-KE": "sw", // 斯瓦希里语(肯尼亚) + "am-ET": "am", // 阿姆哈拉语(埃塞俄比亚) + "yo-NG": "yo", // 约鲁巴语(尼日利亚) + + // 其他 + "pt-BR": "pt", // 葡萄牙语(巴西) + "pt-PT": "pt", // 葡萄牙语(葡萄牙) + "fa-IR": "fa", // 波斯语(伊朗) + "el-GR": "el", // 希腊语(希腊) + "cs-CZ": "cs", // 捷克语(捷克) + "uk-UA": "uk", // 乌克兰语(乌克兰) +] + // MARK: - TranslationServiceInterface Implementation diff --git a/local_plugins/azure_speech/ios/azure_speech/Sources/tools/NetworkStateMonitor.swift b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/NetworkStateMonitor.swift new file mode 100644 index 000000000..5ec663f82 --- /dev/null +++ b/local_plugins/azure_speech/ios/azure_speech/Sources/tools/NetworkStateMonitor.swift @@ -0,0 +1,177 @@ +import Foundation +import SystemConfiguration +import Network + +/// 网络状态监听器(iOS) +final class NetworkStateMonitor { + private let tag = "NetworkStateMonitor" + + // iOS 12+ + private var monitor: NWPathMonitor? + private let monitorQueue = DispatchQueue(label: "NetworkStateMonitor.queue") + + // iOS 10–11 回退 + private var reachability: SCNetworkReachability? + private var reachabilityQueue: DispatchQueue? + + private(set) var isNetworkAvailable: Bool = true + private weak var networkStateListener: NetworkStateListener? + + /// 网络状态监听接口 + protocol NetworkStateListener: AnyObject { + func onNetworkAvailable() + func onNetworkLost() + } + + /// 初始化网络状态监听 + func initialize(listener: NetworkStateListener? = nil) { + networkStateListener = listener + initNetworkMonitoring() + } + + /// 检查当前网络状态 + /// - Returns: true 表示网络可用 + func checkNetworkStatus() -> Bool { + if #available(iOS 12.0, *) { + // NWPathMonitor 只在回调里更新状态,因此这里返回缓存的 isNetworkAvailable + return isNetworkAvailable + } else { + // 使用 Reachability 即时检测 + var zeroAddress = sockaddr_in() + zeroAddress.sin_len = UInt8(MemoryLayout.size) + zeroAddress.sin_family = sa_family_t(AF_INET) + let ref = withUnsafePointer(to: &zeroAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + SCNetworkReachabilityCreateWithAddress(nil, $0) + } + } + guard let reachRef = ref else { return false } + var flags = SCNetworkReachabilityFlags() + if !SCNetworkReachabilityGetFlags(reachRef, &flags) { return false } + return Self.isReachable(flags: flags) + } + } + + /// 释放资源 + func dispose() { + if #available(iOS 12.0, *) { + monitor?.cancel() + monitor = nil + } else { + if let reachability = reachability { + SCNetworkReachabilitySetDispatchQueue(reachability, nil) + SCNetworkReachabilitySetCallback(reachability, nil, nil) + } + reachability = nil + reachabilityQueue = nil + } + networkStateListener = nil + } + + /// 判断是否为网络相关错误(关键字简单匹配) + func isNetworkRelatedError(reason: String, errorDetails: String) -> Bool { + let networkErrorKeywords = [ + "network","connection","timeout","unreachable", + "dns","socket","ssl","tls","certificate", + "网络","连接","超时","不可达" + ] + let combined = (reason + " " + errorDetails).lowercased() + return networkErrorKeywords.contains { combined.contains($0.lowercased()) } + } +} + +// MARK: - Private +private extension NetworkStateMonitor { + func initNetworkMonitoring() { + if #available(iOS 12.0, *) { + let monitor = NWPathMonitor() + self.monitor = monitor + monitor.pathUpdateHandler = { [weak self] path in + guard let self = self else { return } + let hasInternet = (path.status == .satisfied) + // 只有状态变化时才回调 + if hasInternet && !self.isNetworkAvailable { + self.isNetworkAvailable = true + self.networkStateListener?.onNetworkAvailable() + Self.log(self.tag, "网络连接可用") + } else if !hasInternet && self.isNetworkAvailable { + self.isNetworkAvailable = false + self.networkStateListener?.onNetworkLost() + Self.log(self.tag, "网络连接丢失") + } else { + Self.log(self.tag, "网络能力变化,是否有网络: \(hasInternet)") + } + } + monitor.start(queue: monitorQueue) + } else { + // iOS 10–11 使用 Reachability 回退 + var zeroAddress = sockaddr_in() + zeroAddress.sin_len = UInt8(MemoryLayout.size) + zeroAddress.sin_family = sa_family_t(AF_INET) + reachability = withUnsafePointer(to: &zeroAddress) { + $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { + SCNetworkReachabilityCreateWithAddress(nil, $0) + } + } + guard let reachability = reachability else { + Self.log(tag, "初始化网络监听失败: Reachability 创建失败") + return + } + let queue = DispatchQueue(label: "NetworkStateMonitor.reachability") + reachabilityQueue = queue + + var context = SCNetworkReachabilityContext( + version: 0, + info: UnsafeMutableRawPointer(Unmanaged.passUnretained(self).toOpaque()), + retain: nil, + release: nil, + copyDescription: nil + ) + let callback: SCNetworkReachabilityCallBack = { (_, flags, info) in + guard let info = info else { return } + let monitor = Unmanaged.fromOpaque(info).takeUnretainedValue() + let hasInternet = NetworkStateMonitor.isReachable(flags: flags) + if hasInternet && !monitor.isNetworkAvailable { + monitor.isNetworkAvailable = true + monitor.networkStateListener?.onNetworkAvailable() + NetworkStateMonitor.log(monitor.tag, "网络连接可用") + } else if !hasInternet && monitor.isNetworkAvailable { + monitor.isNetworkAvailable = false + monitor.networkStateListener?.onNetworkLost() + NetworkStateMonitor.log(monitor.tag, "网络连接丢失") + } else { + NetworkStateMonitor.log(monitor.tag, "网络能力变化,是否有网络: \(hasInternet)") + } + } + + if SCNetworkReachabilitySetCallback(reachability, callback, &context), + SCNetworkReachabilitySetDispatchQueue(reachability, queue) { + // 触发一次初始查询 + var flags = SCNetworkReachabilityFlags() + if SCNetworkReachabilityGetFlags(reachability, &flags) { + self.isNetworkAvailable = Self.isReachable(flags: flags) + } + } else { + Self.log(tag, "初始化网络监听失败: 设置回调/队列失败") + SCNetworkReachabilitySetCallback(reachability, nil, nil) + SCNetworkReachabilitySetDispatchQueue(reachability, nil) + self.reachability = nil + } + } + } + + static func isReachable(flags: SCNetworkReachabilityFlags) -> Bool { + // 经典 Reachability 判定 + let reachable = flags.contains(.reachable) + let requiresConnection = flags.contains(.connectionRequired) + let canConnectAutomatically = flags.contains(.connectionOnTraffic) || flags.contains(.connectionOnDemand) + let canConnectWithoutUser = canConnectAutomatically && !flags.contains(.interventionRequired) + return reachable && (!requiresConnection || canConnectWithoutUser) + } + + static func log(_ tag: String, _ message: String) { + #if DEBUG + print("[\(tag)] \(message)") + #endif + } +}