You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
982 lines
34 KiB
982 lines
34 KiB
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<String, LanguageInfo> _languageMap = {};
|
|
|
|
// 中文名称到 ASR 代码的映射
|
|
final Map<String, String> _chineseNameToAsrCodeMap = {};
|
|
|
|
// 英文名称到 ASR 代码的映射
|
|
final Map<String, String> _englishNameToAsrCodeMap = {};
|
|
|
|
// 短代码到 ASR 代码的映射
|
|
final Map<String, String> _shortCodeToAsrCodeMap = {};
|
|
|
|
// 按短码 / 中文名 / 英文名各存一份。粤语和香港繁体的识别码都是 zh-HK,
|
|
// 不能再用识别码当唯一键,否则后加的那条会把前一条盖掉。
|
|
final Map<String, LanguageInfo> _byShortCode = {};
|
|
final Map<String, LanguageInfo> _byChineseName = {};
|
|
final Map<String, LanguageInfo> _byEnglishName = {};
|
|
|
|
/// 这 40 个语种只在微软识别 / 翻译 / 合成三张表里完整,界面此前没开放。
|
|
/// 其中菲律宾语、希伯来语、冰岛语也在阿里 3.5 音频档,粤语在阿里旧版。
|
|
/// 通话只要一边是这些语种,就整对走微软端到端,不让阿里把这一对抢走。
|
|
static const Set<String> 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 = <List<String>>[
|
|
['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<String, LanguageInfo> getAllLanguages() {
|
|
return Map.from(_languageMap);
|
|
}
|
|
|
|
/// 获取所有支持的语言的中文名称
|
|
List<String> getAllChineseNames() {
|
|
return _chineseNameToAsrCodeMap.keys.toList();
|
|
}
|
|
|
|
/// 获取所有支持的语言的英文名称
|
|
List<String> 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<String, String> getChineseNameToAsrCodeMap() {
|
|
return Map.from(_chineseNameToAsrCodeMap);
|
|
}
|
|
|
|
/// 获取英文名称到 ASR 代码的映射
|
|
Map<String, String> getEnglishNameToAsrCodeMap() {
|
|
return Map.from(_englishNameToAsrCodeMap);
|
|
}
|
|
|
|
/// 获取短代码到 ASR 代码的映射
|
|
Map<String, String> getShortCodeToAsrCodeMap() {
|
|
return Map.from(_shortCodeToAsrCodeMap);
|
|
}
|
|
|
|
/// 返回当前所有的AST语言配置(中文名称)
|
|
List<String> 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<String, String>? sourceTransSpec;
|
|
Map<String, String>? 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<Map<String, String>> _getAsrSpecs(SpeechServiceType provider) {
|
|
switch (provider) {
|
|
case SpeechServiceType.azure:
|
|
return getAzureASRLanguageSpecs();
|
|
case SpeechServiceType.google:
|
|
return getGoogleASRLanguageSpecs();
|
|
case SpeechServiceType.iflytek:
|
|
return getIflytekASRLanguageSpecs();
|
|
default:
|
|
return getAzureASRLanguageSpecs();
|
|
}
|
|
}
|
|
|
|
List<Map<String, String>> _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<Map<String, String>> _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<String, String> sourceSpec;
|
|
final Map<String, String> targetSpec;
|
|
|
|
BestProviderResult(this.provider, this.sourceSpec, this.targetSpec);
|
|
}
|
|
|