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.
876 lines
28 KiB
876 lines
28 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 = {};
|
|
|
|
/// 初始化语言映射表
|
|
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',
|
|
);
|
|
|
|
Logger.info('语言管理器初始化完成,共加载 ${_languageMap.length} 种语言');
|
|
}
|
|
|
|
/// 添加语言信息到映射表
|
|
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,
|
|
);
|
|
|
|
_languageMap[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) {
|
|
final asrCode = _chineseNameToAsrCodeMap[chineseName];
|
|
if (asrCode == null) return null;
|
|
return _languageMap[asrCode];
|
|
}
|
|
|
|
/// 根据英文名称获取语言信息
|
|
LanguageInfo? getLanguageByEnglishName(String englishName) {
|
|
final asrCode = _englishNameToAsrCodeMap[englishName];
|
|
if (asrCode == null) return null;
|
|
return _languageMap[asrCode];
|
|
}
|
|
|
|
/// 根据短代码获取语言信息
|
|
LanguageInfo? getLanguageByShortCode(String shortCode) {
|
|
final asrCode = _shortCodeToAsrCodeMap[shortCode];
|
|
if (asrCode == null) return null;
|
|
return _languageMap[asrCode];
|
|
}
|
|
|
|
/// 根据中文名称获取 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) {
|
|
final asrCode = _shortCodeToAsrCodeMap[shortCode];
|
|
if (asrCode == null) return null;
|
|
return _getVoiceByGender(_languageMap[asrCode]);
|
|
}
|
|
|
|
/// 根据用户性别获取对应的 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) {
|
|
final asrCode = _chineseNameToAsrCodeMap[chineseName];
|
|
if (asrCode == null) return null;
|
|
return _getVoiceByGender(_languageMap[asrCode]);
|
|
}
|
|
|
|
/// 根据短代码获取中文名称
|
|
String? getChineseNameByShortCode(String shortCode) {
|
|
final asrCode = _shortCodeToAsrCodeMap[shortCode];
|
|
if (asrCode == null) return null;
|
|
return _languageMap[asrCode]?.chineseName;
|
|
}
|
|
|
|
/// 根据短代码获取英文名称
|
|
String? getEnglishNameByShortCode(String shortCode) {
|
|
final asrCode = _shortCodeToAsrCodeMap[shortCode];
|
|
if (asrCode == null) return null;
|
|
return _languageMap[asrCode]?.englishName;
|
|
}
|
|
|
|
/// 根据短代码获取 TTS 语音名称(根据用户性别自动选择)
|
|
String? getTtsVoiceNameByShortCode(String shortCode) {
|
|
final asrCode = _shortCodeToAsrCodeMap[shortCode];
|
|
if (asrCode == null) return null;
|
|
return _getVoiceByGender(_languageMap[asrCode]);
|
|
}
|
|
|
|
/// 获取中文名称到 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 出不了音频的
|
|
/// 语种落这里。旧版 18 语里有 16 语被 3.5 覆盖,所以实际只有
|
|
/// **yue(粤语)/ el(希腊语)** 会走到——它俩在 3.5 里是「仅文本输出」。
|
|
/// 3. **微软**:上面两档都不覆盖的语种(app 一共开放 80 个语种,
|
|
/// 阿里两档合起来 31 个,剩下约 50 个都靠微软)。
|
|
/// 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(声音复刻)>阿里旧版>微软>讯飞(豆包已停用)');
|
|
|
|
for (final provider in providers) {
|
|
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);
|
|
Logger.info(
|
|
'[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);
|
|
}
|
|
|