import 'package:get/get.dart'; import '../../core/utils/logger.dart'; import '../models/user_Info.dart'; import '../services/speech_factory.dart'; import 'language_configs/azure_language_config.dart'; import 'language_configs/google_language_config.dart'; import 'language_configs/iflytek_language_config.dart'; import 'language_configs/volcano_language_config.dart'; import 'language_configs/alibaba_language_config.dart'; import 'language_configs/alibaba35_language_config.dart'; import 'language_configs/ast_config.dart'; /// 语言信息类,包含语言的各种表示形式 class LanguageInfo { /// 短代码,如 'zh', 'en' final String shortCode; /// 中文名称 final String chineseName; /// 英文名称 final String englishName; /// Azure ASR 语言代码,如 'zh-CN', 'en-US' final String asrCode; /// Azure TTS 语音名称(女声) final String ttsVoiceName; /// Azure TTS 语音名称(男声) final String ttsVoiceNameMale; /// 备注信息 final String? note; LanguageInfo({ required this.shortCode, required this.chineseName, required this.englishName, required this.asrCode, required this.ttsVoiceName, required this.ttsVoiceNameMale, this.note, }); } /// 语言类型管理类,提供语言信息的查询和转换功能 class LanguageManager extends GetxService { // 单例实例 static LanguageManager? _instance; // 单例工厂方法 factory LanguageManager() { _instance ??= LanguageManager._internal(); return _instance!; } // 私有构造函数 LanguageManager._internal() { _initLanguageMap(); } // 语言信息映射表,以 ASR 代码为键 final Map _languageMap = {}; // 中文名称到 ASR 代码的映射 final Map _chineseNameToAsrCodeMap = {}; // 英文名称到 ASR 代码的映射 final Map _englishNameToAsrCodeMap = {}; // 短代码到 ASR 代码的映射 final Map _shortCodeToAsrCodeMap = {}; // 按短码 / 中文名 / 英文名各存一份。粤语和香港繁体的识别码都是 zh-HK, // 不能再用识别码当唯一键,否则后加的那条会把前一条盖掉。 final Map _byShortCode = {}; final Map _byChineseName = {}; final Map _byEnglishName = {}; /// 这 40 个语种只在微软识别 / 翻译 / 合成三张表里完整,界面此前没开放。 /// 其中菲律宾语、希伯来语、冰岛语也在阿里 3.5 音频档,粤语在阿里旧版。 /// 通话只要一边是这些语种,就整对走微软端到端,不让阿里把这一对抢走。 static const Set azureCallOnlyShortCodes = { 'yue', 'cy', 'bg', 'am', 'as', 'af', 'az', 'bs', 'kn', 'et', 'eu', 'fil', 'ga', 'gl', 'gu', 'he', 'hy', 'is', 'jv', 'ka', 'kk', 'km', 'lo', 'lt', 'lv', 'mk', 'ml', 'mn', 'mt', 'my', 'ne', 'ps', 'si', 'sk', 'sl', 'so', 'sq', 'sr', 'uz', 'zu', }; /// 初始化语言映射表 void _initLanguageMap() { // 中文相关 _addLanguage( shortCode: 'zh', chineseName: '中文(简体)', englishName: 'Chinese (simplified)', asrCode: 'zh-CN', ttsVoiceName: 'zh-CN-XiaoxiaoNeural', ttsVoiceNameMale: 'zh-CN-YunxiNeural', ); _addLanguage( shortCode: 'zh-Hant', chineseName: '中文(繁体)', englishName: 'Chinese (traditional)', asrCode: 'zh-TW', ttsVoiceName: 'zh-TW-HsiaoChenNeural', ttsVoiceNameMale: 'zh-TW-YunJheNeural', ); _addLanguage( shortCode: 'zh-Hant-hk', chineseName: '中文(香港繁体)', englishName: 'Chinese (Hongkong traditional)', asrCode: 'zh-HK', ttsVoiceName: 'zh-HK-HiuMaanNeural', ttsVoiceNameMale: 'zh-HK-WanLungNeural', ); // 英语相关 _addLanguage( shortCode: 'en', chineseName: '英语', englishName: 'English', asrCode: 'en-US', ttsVoiceName: 'en-US-AriaNeural', ttsVoiceNameMale: 'en-US-GuyNeural', ); // 日语相关 _addLanguage( shortCode: 'ja', chineseName: '日语', englishName: 'Japanese', asrCode: 'ja-JP', ttsVoiceName: 'ja-JP-NanamiNeural', ttsVoiceNameMale: 'ja-JP-KeitaNeural', ); // 韩语相关 _addLanguage( shortCode: 'ko', chineseName: '韩语', englishName: 'Korean', asrCode: 'ko-KR', ttsVoiceName: 'ko-KR-SunHiNeural', ttsVoiceNameMale: 'ko-KR-InJoonNeural', ); // 欧洲语言 _addLanguage( shortCode: 'fr', chineseName: '法语', englishName: 'French', asrCode: 'fr-FR', ttsVoiceName: 'fr-FR-DeniseNeural', ttsVoiceNameMale: 'fr-FR-HenriNeural', ); _addLanguage( shortCode: 'es', chineseName: '西班牙语', englishName: 'Spanish', asrCode: 'es-ES', ttsVoiceName: 'es-ES-ElviraNeural', ttsVoiceNameMale: 'es-ES-AlvaroNeural', ); _addLanguage( shortCode: 'pt', chineseName: '葡萄牙语', englishName: 'Portuguese', asrCode: 'pt-BR', ttsVoiceName: 'pt-BR-FranciscaNeural', ttsVoiceNameMale: 'pt-BR-AntonioNeural', ); _addLanguage( shortCode: 'it', chineseName: '意大利语', englishName: 'Italian', asrCode: 'it-IT', ttsVoiceName: 'it-IT-ElsaNeural', ttsVoiceNameMale: 'it-IT-DiegoNeural', ); _addLanguage( shortCode: 'de', chineseName: '德语', englishName: 'German', asrCode: 'de-DE', ttsVoiceName: 'de-DE-KatjaNeural', ttsVoiceNameMale: 'de-DE-ConradNeural', ); _addLanguage( shortCode: 'ru', chineseName: '俄语', englishName: 'Russian', asrCode: 'ru-RU', ttsVoiceName: 'ru-RU-SvetlanaNeural', ttsVoiceNameMale: 'ru-RU-DmitryNeural', ); _addLanguage( shortCode: 'pl', chineseName: '波兰语', englishName: 'Polish', asrCode: 'pl-PL', ttsVoiceName: 'pl-PL-AgnieszkaNeural', ttsVoiceNameMale: 'pl-PL-MarekNeural', ); _addLanguage( shortCode: 'nl', chineseName: '荷兰语', englishName: 'Dutch', asrCode: 'nl-NL', ttsVoiceName: 'nl-NL-ColetteNeural', ttsVoiceNameMale: 'nl-NL-MaartenNeural', ); _addLanguage( shortCode: 'sv', chineseName: '瑞典语', englishName: 'Swedish', asrCode: 'sv-SE', ttsVoiceName: 'sv-SE-SofieNeural', ttsVoiceNameMale: 'sv-SE-MattiasNeural', ); _addLanguage( shortCode: 'cs', chineseName: '捷克语', englishName: 'Czech', asrCode: 'cs-CZ', ttsVoiceName: 'cs-CZ-VlastaNeural', ttsVoiceNameMale: 'cs-CZ-AntoninNeural', ); _addLanguage( shortCode: 'el', chineseName: '现代希腊语', englishName: 'Modern Greek', asrCode: 'el-GR', ttsVoiceName: 'el-GR-AthinaNeural', ttsVoiceNameMale: 'el-GR-NestorasNeural', ); _addLanguage( shortCode: 'ro', chineseName: '罗马尼亚语', englishName: 'Romanian', asrCode: 'ro-RO', ttsVoiceName: 'ro-RO-AlinaNeural', ttsVoiceNameMale: 'ro-RO-EmilNeural', ); _addLanguage( shortCode: 'hu', chineseName: '匈牙利语', englishName: 'Hungarian', asrCode: 'hu-HU', ttsVoiceName: 'hu-HU-NoemiNeural', ttsVoiceNameMale: 'hu-HU-TamasNeural', ); _addLanguage( shortCode: 'uk', chineseName: '乌克兰语', englishName: 'Ukrainian', asrCode: 'uk-UA', ttsVoiceName: 'uk-UA-PolinaNeural', ttsVoiceNameMale: 'uk-UA-OstapNeural', ); _addLanguage( shortCode: 'da', chineseName: '丹麦语', englishName: 'Danish', asrCode: 'da-DK', ttsVoiceName: 'da-DK-ChristelNeural', ttsVoiceNameMale: 'da-DK-JeppeNeural', ); _addLanguage( shortCode: 'fi', chineseName: '芬兰语', englishName: 'Finnish', asrCode: 'fi-FI', ttsVoiceName: 'fi-FI-NooraNeural', ttsVoiceNameMale: 'fi-FI-HarriNeural', ); _addLanguage( shortCode: 'no', chineseName: '挪威语', englishName: 'Norwegian', asrCode: 'nb-NO', ttsVoiceName: 'nb-NO-IselinNeural', ttsVoiceNameMale: 'nb-NO-FinnNeural', ); _addLanguage( shortCode: 'hr', chineseName: '克罗地亚语', englishName: 'Croatian', asrCode: 'hr-HR', ttsVoiceName: 'hr-HR-GabrijelaNeural', ttsVoiceNameMale: 'hr-HR-SreckoNeural', ); _addLanguage( shortCode: 'ca', chineseName: '加泰隆语', englishName: 'Catalan', asrCode: 'ca-ES', ttsVoiceName: 'ca-ES-JoanaNeural', ttsVoiceNameMale: 'ca-ES-EnricNeural', ); // 亚洲语言 _addLanguage( shortCode: 'ar', chineseName: '阿拉伯语', englishName: 'Arabic', asrCode: 'ar-EG', ttsVoiceName: 'ar-EG-SalmaNeural', ttsVoiceNameMale: 'ar-EG-ShakirNeural', ); _addLanguage( shortCode: 'th', chineseName: '泰语', englishName: 'Thai', asrCode: 'th-TH', ttsVoiceName: 'th-TH-PremwadeeNeural', ttsVoiceNameMale: 'th-TH-NiwatNeural', ); _addLanguage( shortCode: 'vi', chineseName: '越南语', englishName: 'Vietnamese', asrCode: 'vi-VN', ttsVoiceName: 'vi-VN-HoaiMyNeural', ttsVoiceNameMale: 'vi-VN-NamMinhNeural', ); _addLanguage( shortCode: 'id', chineseName: '印尼语', englishName: 'Indonesian', asrCode: 'id-ID', ttsVoiceName: 'id-ID-GadisNeural', ttsVoiceNameMale: 'id-ID-ArdiNeural', ); _addLanguage( shortCode: 'hi', chineseName: '印地语', englishName: 'Hindi', asrCode: 'hi-IN', ttsVoiceName: 'hi-IN-SwaraNeural', ttsVoiceNameMale: 'hi-IN-MadhurNeural', ); _addLanguage( shortCode: 'tr', chineseName: '土耳其语', englishName: 'Turkish', asrCode: 'tr-TR', ttsVoiceName: 'tr-TR-EmelNeural', ttsVoiceNameMale: 'tr-TR-AhmetNeural', ); _addLanguage( shortCode: 'ms', chineseName: '马来语', englishName: 'Malay', asrCode: 'ms-MY', ttsVoiceName: 'ms-MY-YasminNeural', ttsVoiceNameMale: 'ms-MY-OsmanNeural', ); _addLanguage( shortCode: 'bn', chineseName: '孟加拉语', englishName: 'Bengali', asrCode: 'bn-IN', ttsVoiceName: 'bn-IN-TanishaaNeural', ttsVoiceNameMale: 'bn-IN-BashkarNeural', ); _addLanguage( shortCode: 'ta', chineseName: '泰米尔语', englishName: 'Tamil', asrCode: 'ta-IN', ttsVoiceName: 'ta-IN-PallaviNeural', ttsVoiceNameMale: 'ta-IN-ValluvarNeural', ); _addLanguage( shortCode: 'te', chineseName: '泰卢固语', englishName: 'Telugu', asrCode: 'te-IN', ttsVoiceName: 'te-IN-ShrutiNeural', ttsVoiceNameMale: 'te-IN-MohanNeural', ); _addLanguage( shortCode: 'mr', chineseName: '马拉提语', englishName: 'Marathi', asrCode: 'mr-IN', ttsVoiceName: 'mr-IN-AarohiNeural', ttsVoiceNameMale: 'mr-IN-ManoharNeural', ); _addLanguage( shortCode: 'ur', chineseName: '乌尔都语', englishName: 'Urdu', asrCode: 'ur-IN', ttsVoiceName: 'ur-IN-GulNeural', ttsVoiceNameMale: 'ur-IN-SalmanNeural', ); _addLanguage( shortCode: 'fa', chineseName: '波斯语', englishName: 'Persian', asrCode: 'fa-IR', ttsVoiceName: 'fa-IR-DilaraNeural', ttsVoiceNameMale: 'fa-IR-FaridNeural', ); // 非洲语言 _addLanguage( shortCode: 'sw', chineseName: '斯瓦希里语', englishName: 'Swahili', asrCode: 'sw-KE', ttsVoiceName: 'sw-KE-ZuriNeural', ttsVoiceNameMale: 'sw-KE-RafikiNeural', ); // 微软三张表里有、界面原先没放出来的 40 种。男声没有单独列在音色表里的, // 先用同一条女声,避免把不存在的音色名传给微软合成。 _addAzureExtendedLanguages(); Logger.info('语言管理器初始化完成,共加载 ${_byChineseName.length} 种语言'); } void _addAzureExtendedLanguages() { const rows = >[ ['yue', '粤语', 'Cantonese', 'zh-HK', 'yue-CN-XiaoMinNeural'], ['cy', '威尔士语', 'Welsh', 'cy-GB', 'cy-GB-NiaNeural'], ['bg', '保加利亚语', 'Bulgarian', 'bg-BG', 'bg-BG-KalinaNeural'], ['am', '阿姆哈拉语', 'Amharic', 'am-ET', 'am-ET-MekdesNeural'], ['as', '阿萨姆语', 'Assamese', 'as-IN', 'as-IN-YashicaNeural'], ['af', '南非荷兰语', 'Afrikaans', 'af-ZA', 'af-ZA-AdriNeural'], ['az', '阿塞拜疆语', 'Azerbaijani', 'az-AZ', 'az-AZ-BanuNeural'], ['bs', '波斯尼亚语', 'Bosnian', 'bs-BA', 'bs-BA-VesnaNeural'], ['kn', '卡纳达语', 'Kannada', 'kn-IN', 'kn-IN-SapnaNeural'], ['et', '爱沙尼亚语', 'Estonian', 'et-EE', 'et-EE-AnuNeural'], ['eu', '巴斯克语', 'Basque', 'eu-ES', 'eu-ES-AinhoaNeural'], ['fil', '菲律宾语', 'Filipino', 'fil-PH', 'fil-PH-BlessicaNeural'], ['ga', '爱尔兰语', 'Irish', 'ga-IE', 'ga-IE-OrlaNeural'], ['gl', '加利西亚语', 'Galician', 'gl-ES', 'gl-ES-SabelaNeural'], ['gu', '古吉拉特语', 'Gujarati', 'gu-IN', 'gu-IN-DhwaniNeural'], ['he', '希伯来语', 'Hebrew', 'he-IL', 'he-IL-HilaNeural'], ['hy', '亚美尼亚语', 'Armenian', 'hy-AM', 'hy-AM-AnahitNeural'], ['is', '冰岛语', 'Icelandic', 'is-IS', 'is-IS-GudrunNeural'], ['jv', '爪哇语', 'Javanese', 'jv-ID', 'jv-ID-SitiNeural'], ['ka', '格鲁吉亚语', 'Georgian', 'ka-GE', 'ka-GE-EkaNeural'], ['kk', '哈萨克语', 'Kazakh', 'kk-KZ', 'kk-KZ-AigulNeural'], ['km', '高棉语', 'Khmer', 'km-KH', 'km-KH-SreymomNeural'], ['lo', '老挝语', 'Lao', 'lo-LA', 'lo-LA-KeomanyNeural'], ['lt', '立陶宛语', 'Lithuanian', 'lt-LT', 'lt-LT-OnaNeural'], ['lv', '拉脱维亚语', 'Latvian', 'lv-LV', 'lv-LV-EveritaNeural'], ['mk', '马其顿语', 'Macedonian', 'mk-MK', 'mk-MK-MarijaNeural'], ['ml', '马拉雅拉姆语', 'Malayalam', 'ml-IN', 'ml-IN-SobhanaNeural'], ['mn', '蒙古语', 'Mongolian', 'mn-MN', 'mn-MN-YesuiNeural'], ['mt', '马耳他语', 'Maltese', 'mt-MT', 'mt-MT-GraceNeural'], ['my', '缅甸语', 'Burmese', 'my-MM', 'my-MM-NilarNeural'], ['ne', '尼泊尔语', 'Nepali', 'ne-NP', 'ne-NP-HemkalaNeural'], ['ps', '普什图语', 'Pashto', 'ps-AF', 'ps-AF-LatifaNeural'], ['si', '僧伽罗语', 'Sinhala', 'si-LK', 'si-LK-ThiliniNeural'], ['sk', '斯洛伐克语', 'Slovak', 'sk-SK', 'sk-SK-ViktoriaNeural'], ['sl', '斯洛文尼亚语', 'Slovenian', 'sl-SI', 'sl-SI-PetraNeural'], ['so', '索马里语', 'Somali', 'so-SO', 'so-SO-UbaxNeural'], ['sq', '阿尔巴尼亚语', 'Albanian', 'sq-AL', 'sq-AL-AnilaNeural'], ['sr', '塞尔维亚语', 'Serbian', 'sr-RS', 'sr-Latn-RS-SophieNeural'], ['uz', '乌兹别克语', 'Uzbek', 'uz-UZ', 'uz-UZ-MadinaNeural'], ['zu', '祖鲁语', 'Zulu', 'zu-ZA', 'zu-ZA-ThandoNeural'], ]; for (final row in rows) { _addLanguage( shortCode: row[0], chineseName: row[1], englishName: row[2], asrCode: row[3], ttsVoiceName: row[4], ttsVoiceNameMale: row[4], ); } } /// 添加语言信息到映射表 void _addLanguage({ required String shortCode, required String chineseName, required String englishName, required String asrCode, required String ttsVoiceName, required String ttsVoiceNameMale, String? note, }) { final languageInfo = LanguageInfo( shortCode: shortCode, chineseName: chineseName, englishName: englishName, asrCode: asrCode, ttsVoiceName: ttsVoiceName, ttsVoiceNameMale: ttsVoiceNameMale, note: note, ); _byShortCode[shortCode] = languageInfo; _byChineseName[chineseName] = languageInfo; _byEnglishName[englishName] = languageInfo; // 识别码冲突时保留先登记的那条(香港繁体先于粤语)。按名字查不受影响。 _languageMap.putIfAbsent(asrCode, () => languageInfo); _chineseNameToAsrCodeMap[chineseName] = asrCode; _englishNameToAsrCodeMap[englishName] = asrCode; _shortCodeToAsrCodeMap[shortCode] = asrCode; } /// 获取所有支持的语言信息 Map getAllLanguages() { return Map.from(_languageMap); } /// 获取所有支持的语言的中文名称 List getAllChineseNames() { return _chineseNameToAsrCodeMap.keys.toList(); } /// 获取所有支持的语言的英文名称 List getAllEnglishNames() { return _englishNameToAsrCodeMap.keys.toList(); } /// 根据 ASR 代码获取语言信息 LanguageInfo? getLanguageByAsrCode(String asrCode) { return _languageMap[asrCode]; } /// 根据中文名称获取语言信息 LanguageInfo? getLanguageByChineseName(String chineseName) { return _byChineseName[chineseName]; } /// 根据英文名称获取语言信息 LanguageInfo? getLanguageByEnglishName(String englishName) { return _byEnglishName[englishName]; } /// 根据短代码获取语言信息 LanguageInfo? getLanguageByShortCode(String shortCode) { return _byShortCode[shortCode]; } /// 根据中文名称获取 ASR 代码 String? getAsrCodeByChineseName(String chineseName) { return _chineseNameToAsrCodeMap[chineseName]; } /// 根据英文名称获取 ASR 代码 String? getAsrCodeByEnglishName(String englishName) { return _englishNameToAsrCodeMap[englishName]; } /// 根据短代码获取 ASR 代码 String? getAsrCodeByShortCode(String shortCode) { return _shortCodeToAsrCodeMap[shortCode]; } /// 根据 ASR 代码获取中文名称 String? getChineseNameByAsrCode(String asrCode) { final languageInfo = _languageMap[asrCode]; return languageInfo?.chineseName; } /// 根据 ASR 代码获取英文名称 String? getEnglishNameByAsrCode(String asrCode) { final languageInfo = _languageMap[asrCode]; return languageInfo?.englishName; } /// 根据 ASR 代码获取短代码 String? getShortCodeByAsrCode(String asrCode) { final languageInfo = _languageMap[asrCode]; return languageInfo?.shortCode; } /// 根据 shortCode 获取性别适配的 TTS 音色(用于 findBestMatchingProvider) String? _getGenderAwareTtsCode(String shortCode) { return _getVoiceByGender(_byShortCode[shortCode]); } /// 根据用户性别获取对应的 TTS 语音名称 /// gender: 0=未设置(默认女声), 1=男, 2=女 String? _getVoiceByGender(LanguageInfo? info) { if (info == null) return null; try { final gender = User.instance.gender; final voice = gender == 1 ? info.ttsVoiceNameMale : info.ttsVoiceName; Logger.debug('TTS音色选择: gender=$gender, voice=$voice'); return voice; } catch (_) { Logger.debug('TTS音色选择: 用户未初始化,使用默认女声'); return info.ttsVoiceName; } } /// 根据 ASR 代码获取 TTS 语音名称(根据用户性别自动选择) String? getTtsVoiceNameByAsrCode(String asrCode) { return _getVoiceByGender(_languageMap[asrCode]); } /// 根据中文名称获取 TTS 语音名称(根据用户性别自动选择) String? getTtsVoiceNameByChineseName(String chineseName) { return _getVoiceByGender(_byChineseName[chineseName]); } /// 根据短代码获取中文名称 String? getChineseNameByShortCode(String shortCode) { return _byShortCode[shortCode]?.chineseName; } /// 根据短代码获取英文名称 String? getEnglishNameByShortCode(String shortCode) { return _byShortCode[shortCode]?.englishName; } /// 根据短代码获取 TTS 语音名称(根据用户性别自动选择) String? getTtsVoiceNameByShortCode(String shortCode) { return _getVoiceByGender(_byShortCode[shortCode]); } /// 微软文本翻译要用的语言码。表里没有的(繁体、挪威语)按微软翻译的写法补。 String microsoftTranslateCode(String shortCode) { for (final spec in getAzureTranslationLanguageSpecs()) { final code = spec['translationCode']; if (spec['shortCode'] == shortCode && code != null && code.isNotEmpty) { return code; } } switch (shortCode) { case 'zh-Hant': case 'zh-Hant-hk': return 'zh-Hant'; case 'no': return 'nb'; default: return shortCode; } } /// 同声 / 面对面 / 音视频里,一边是扩展 40 语就整句走微软文本翻译。 /// 通话不走这里,通话有自己的端到端选路。 static bool microsoftTextPipeline( String mode, String sourceShort, String targetShort) { if (mode != 'simultaneous' && mode != 'faceToFace' && mode != 'audioVideo') { return false; } return azureCallOnlyShortCodes.contains(sourceShort) || azureCallOnlyShortCodes.contains(targetShort); } /// 获取中文名称到 ASR 代码的映射 Map getChineseNameToAsrCodeMap() { return Map.from(_chineseNameToAsrCodeMap); } /// 获取英文名称到 ASR 代码的映射 Map getEnglishNameToAsrCodeMap() { return Map.from(_englishNameToAsrCodeMap); } /// 获取短代码到 ASR 代码的映射 Map getShortCodeToAsrCodeMap() { return Map.from(_shortCodeToAsrCodeMap); } /// 返回当前所有的AST语言配置(中文名称) List getAllAstLanguageNames() { return getAstLanguageConfigs() .map((e) => e['chineseName'] ?? '') .where((e) => e.isNotEmpty) .toList(); } /// 查找同时支持源语言和目标语言的最佳提供商(通过 shortCode 匹配) /// /// ⚠️ **通话翻译不再走豆包(火山)端到端**(2026-08-31 改): /// 原来 zh/en/ja/ko 这几对语言优先命中豆包,现统一改走阿里 /// (通义 qwen3-livetranslate 端到端)。豆包覆盖的 {zh,en,ja,ko} 是阿里 /// 翻译表(zh/en/ja/ko/fr/de/es/it/pt/ru)的真子集,所以摘掉豆包不会 /// 留下任何没人接的语言对。**别把 `SpeechServiceType.volcano` 加回 /// providers 列表**——加回去它排在前面就会又把中英通话翻译抢走。 /// (原生侧 DoubaoE2ETranslateHelper 仍在,只是不再被这里选中; /// 本函数是通话翻译选 provider 的唯一入口,见 /// TranslationController._initializeCallModeTranslationService。) /// /// 路由策略(2026-09-01 起,四档降级): /// 1. **阿里 3.5**(qwen3.5-livetranslate-flash-realtime,带实时声音复刻): /// 源/目标都在它的「音频+文本输出」29 语内。这一档译文用**说话人本人 /// 的声音**,男女音色天然对得上,不需要猜性别。 /// 2. **阿里旧版**(qwen3-livetranslate-flash-realtime):3.5 出不了音频、 /// 又不在扩展 40 语里的语种落这里。界面上实际只有 **el(希腊语)** /// 会走到。粤语在旧版表里,但通话选路会跳过阿里。 /// 3. **微软**:上面两档都不覆盖的语种,以及 [azureCallOnlyShortCodes] /// 里的 40 个扩展语种。一边命中就整对走微软,不再让阿里 3.5 抢走 /// 菲律宾语 / 希伯来语 / 冰岛语。 /// 4. **讯飞**:兜底。 /// /// ⚠️ 「3.5 支持 60 语」这个宣传数字**不能直接拿来当路由依据**:其中 31 语 /// 是仅文本输出。通话翻译必须拿到 TTS PCM 回灌耳机,没音频等于功能不成立, /// 所以第 1 档卡的是 29 语的音频档,不是 60。 BestProviderResult? findBestMatchingProvider( String sourceShortCode, String targetShortCode) { final providers = [ SpeechServiceType.alibaba35, SpeechServiceType.alibaba, SpeechServiceType.azure, SpeechServiceType.iflytek, ]; Logger.info( '[TRANS-INIT] 开始匹配提供商: $sourceShortCode -> $targetShortCode, 策略=阿里3.5(声音复刻)>阿里旧版>微软>讯飞(豆包已停用)'); final forceAzure = azureCallOnlyShortCodes.contains(sourceShortCode) || azureCallOnlyShortCodes.contains(targetShortCode); for (final provider in providers) { if (forceAzure && _isAlibaba(provider)) { Logger.info( '[TRANS-INIT] 跳过 ${_providerStringFromType(provider)}: $sourceShortCode -> $targetShortCode 含扩展语种,通话走微软'); continue; } final providerName = _providerStringFromType(provider); final transSpecs = _getTranslationSpecs(provider); Map? sourceTransSpec; Map? targetTransSpec; for (final spec in transSpecs) { if (spec['shortCode'] == sourceShortCode) { sourceTransSpec ??= {}; sourceTransSpec["translationCode"] = spec["translationCode"]!; } if (spec['shortCode'] == targetShortCode) { targetTransSpec ??= {}; targetTransSpec["translationCode"] = spec["translationCode"]!; } } if (sourceTransSpec == null || targetTransSpec == null) { Logger.info( '[TRANS-INIT] 跳过 ${providerDisplayName(providerName)}: 翻译表缺少${sourceTransSpec == null ? " 源=$sourceShortCode" : ""}${targetTransSpec == null ? " 目标=$targetShortCode" : ""}'); continue; } // 阿里端到端 (qwen3-livetranslate): 覆盖主流 10 语(zh/en/ja/ko/fr/de/es/it/pt/ru), // 两端都在阿里翻译表内即走阿里;稀有语种交给微软兜底。 // 这里不加额外白名单——上面 transSpecs 的匹配检查已经做了筛选。 // 对于非端到端服务(Azure, Iflytek),还需要检查 ASR 和 TTS 支持 if (!_isAlibaba(provider)) { final asrSpecs = _getAsrSpecs(provider); final ttsSpecs = _getTtsSpecs(provider); for (final spec in asrSpecs) { if (spec['shortCode'] == sourceShortCode) { sourceTransSpec["asrCode"] = spec["asrCode"]!; } if (spec['shortCode'] == targetShortCode) { targetTransSpec["asrCode"] = spec["asrCode"]!; } } for (final spec in ttsSpecs) { if (spec['shortCode'] == sourceShortCode) { sourceTransSpec["ttsCode"] = _getGenderAwareTtsCode(spec['shortCode']!) ?? spec["ttsVoiceName"]!; } if (spec['shortCode'] == targetShortCode) { targetTransSpec["ttsCode"] = _getGenderAwareTtsCode(spec['shortCode']!) ?? spec["ttsVoiceName"]!; } } } else if (_isAlibaba(provider)) { // 阿里端到端的音色是**按路**定的,两路含义完全不同,别给成同一个值: // // A 路 = 己方麦克风 → 译成对方语言 → **对方听见的"我"** // 原生读 supportedLanguages[5],即这里的 targetSpec['ttsCode'] // B 路 = 对端通话音 → 译成己方语言 → **我听见的"对方"** // 原生读 supportedLanguages[4],即这里的 sourceSpec['ttsCode'] // // 映射见 AzureSpeechPlugin(两端一致): // bailianConfigA.voice = ttsLang1 / bailianConfigB.voice = ttsLang0。 // 改这两行前先回去核一眼,写反了就变成「对方说话用我的性别、 // 我说话用默认女声」,且不会报任何错。 // // 对方是男是女**无从得知**:qwen3-livetranslate 的 voice 是 session 级 // 参数,握手时定死且中途改不了,模型也不做说话人性别识别。 // 所以 B 路一律女声;A 路代表用户本人,跟随用户设置的性别 // (未设置同样落女声,见 getAlibabaVoiceForUser)。 // 每一路的音色跟的是**这一路要播出来的语言**: // A 路播 targetShortCode,B 路播 sourceShortCode。 final String userVoice = getAlibabaVoiceForUser(targetShortCode); final String peerVoice = getAlibabaVoiceForPeer(sourceShortCode); final ttsSpecs = _getTtsSpecs(provider); for (final spec in ttsSpecs) { // 仍然按 shortCode 命中才填:填不上会退到 translation_controller 里 // getTtsVoiceNameByAsrCode 的 Azure 音色名(zh-CN-XiaoxiaoNeural), // 那对阿里是非法值。上面的 transSpecs 校验保证了这里一定命中。 if (spec['shortCode'] == sourceShortCode) { sourceTransSpec["ttsCode"] = peerVoice; } if (spec['shortCode'] == targetShortCode) { targetTransSpec["ttsCode"] = userVoice; } } // ⚠️ 日志里别直接读 User.instance:未初始化时它是 throw 而不是返回 null, // 异常会被上层 _initializeCallModeTranslationService 的 catch 吞成 // 「通话翻译初始化失败」,一句日志把整个功能带塌。 int? genderForLog; try { genderForLog = User.instance.gender; } catch (_) { genderForLog = null; } if (provider == SpeechServiceType.alibaba35) { Logger.info( '[TRANS-INIT] 阿里3.5 开启声音复刻:两路音色由原生强制写成 default,' '译文用说话人本人的声音,不按性别选音色(gender=${genderForLog ?? "未初始化"} 仅记录)'); } else { Logger.info( '[TRANS-INIT] 阿里旧版音色: A路(我说话/对方听)=$userVoice, B路(对方说话/我听)=$peerVoice, gender=${genderForLog ?? "未初始化"}'); } } final providerString = _providerStringFromType(provider); final isEndToEnd = _isAlibaba(provider); // warn 级:这一行决定了「有没有声音复刻」——只有 alibaba35 那一档才有。 // 客户反馈「没被复刻」时,第一件事就是看这里命中的是哪一档。 Logger.warning( '[TRANS-INIT] 命中 ${providerDisplayName(providerString)}[$providerString] for $sourceShortCode -> $targetShortCode (端到端=$isEndToEnd), transSrc=${sourceTransSpec["translationCode"]}, transTgt=${targetTransSpec["translationCode"]}, asrSrc=${sourceTransSpec["asrCode"]}, asrTgt=${targetTransSpec["asrCode"]}, ttsSrc=${sourceTransSpec["ttsCode"]}, ttsTgt=${targetTransSpec["ttsCode"]}'); return BestProviderResult( providerString, sourceTransSpec, targetTransSpec); } Logger.warning( '[TRANS-INIT] 所有提供商都不匹配 $sourceShortCode -> $targetShortCode,返回 null'); return null; } /// 两档阿里共用同一套端到端流水线(同一个原生 helper、同一套 WS 协议), /// 差别只在 model 名与是否开声音复刻,所以凡是「是不是端到端阿里」的判断 /// 都走这里,别再逐个写 `== SpeechServiceType.alibaba`——漏掉 3.5 会让它 /// 掉进 Azure 那条「还要查 ASR/TTS 表」的分支里,直接匹配失败。 static bool _isAlibaba(SpeechServiceType t) => t == SpeechServiceType.alibaba || t == SpeechServiceType.alibaba35; String _providerStringFromType(SpeechServiceType type) { switch (type) { case SpeechServiceType.iflytek: return 'iflytek'; case SpeechServiceType.azure: return 'azure'; case SpeechServiceType.google: return 'google'; case SpeechServiceType.alibaba: return 'alibaba'; case SpeechServiceType.alibaba35: return 'alibaba35'; case SpeechServiceType.volcano: return 'volcano'; case SpeechServiceType.flutter: return 'flutter'; } } /// 将 provider 字符串映射为中文厂商名,用于日志可读性 static String providerDisplayName(String provider) { switch (provider) { case 'volcano': return '豆包(火山)'; case 'alibaba': return '阿里(通义·旧版)'; case 'alibaba35': return '阿里(通义3.5·声音复刻)'; case 'azure': return '微软(Azure)'; case 'iflytek': return '讯飞'; case 'google': return '谷歌'; case 'flutter': return '本地(Flutter)'; default: return provider; } } // ===== 私有工具方法:加载不同提供商的语言规格 ===== List> _getAsrSpecs(SpeechServiceType provider) { switch (provider) { case SpeechServiceType.azure: return getAzureASRLanguageSpecs(); case SpeechServiceType.google: return getGoogleASRLanguageSpecs(); case SpeechServiceType.iflytek: return getIflytekASRLanguageSpecs(); default: return getAzureASRLanguageSpecs(); } } List> _getTranslationSpecs(SpeechServiceType provider) { switch (provider) { case SpeechServiceType.azure: return getAzureTranslationLanguageSpecs(); case SpeechServiceType.google: return getGoogleTranslationLanguageSpecs(); case SpeechServiceType.iflytek: return getIflytekTranslationLanguageSpecs(); case SpeechServiceType.volcano: return getVolcanoTranslationLanguageSpecs(); case SpeechServiceType.alibaba: return getAlibabaTranslationLanguageSpecs(); case SpeechServiceType.alibaba35: return getAlibaba35TranslationLanguageSpecs(); default: return getAzureTranslationLanguageSpecs(); } } List> _getTtsSpecs(SpeechServiceType provider) { switch (provider) { case SpeechServiceType.azure: return getAzureTTSLanguageSpecs(); case SpeechServiceType.google: return getGoogleTTSLanguageSpecs(); case SpeechServiceType.iflytek: return getIflytekTTSLanguageSpecs(); case SpeechServiceType.alibaba: return getAlibabaTranslationLanguageSpecs(); case SpeechServiceType.alibaba35: return getAlibaba35TranslationLanguageSpecs(); default: return getAzureTTSLanguageSpecs(); } } } class BestProviderResult { final String provider; final Map sourceSpec; final Map targetSpec; BestProviderResult(this.provider, this.sourceSpec, this.targetSpec); }