package comm import "strings" // TTS 音色表:各 TTS 服务商「支持哪些语言、每种语言有哪些男/女音色」的常量清单。 // // 为什么放代码里而不是后台配置:音色 id 是服务商固定的枚举(改了就调用失败), // 不是运营该逐个录入的数据。后台 agent 只做「选哪一个」,选项由本表提供; // agent 的 voice_map 里存的就是这里的 VoiceId。 // // 运行时用法:agent 说话前按「当前语言 + agent 性别」查音色—— // 1. 先查 agent 的 voice_map(运营在后台显式选过的,优先); // 2. 没配就用 PickVoice 取本表中该语言+性别的第一个(列表顺序即优先级)。 // 这样 agent 切语言时音色自动跟着换,不必给每种语言都手配。 // // ⚠️ 维护提醒:音色 id 以服务商控制台/文档为准,服务商会新增或下线音色。 // Azure 音色为标准 Neural 系列(命名规则 -Neural); // 阿里为智能语音交互的标准音色 id。改动只影响「后台能选到什么」, // 已保存到 agent voice_map 里的历史值不会被本表改动动到。 // TTS 服务商标识(与 ThirdSvcConfig.Provider 一致)。 const ( TTSProviderAzure = "azure" TTSProviderAlibaba = "alibaba" ) // 性别取值(与 AgentConfig.Gender / AgentVoice.Gender 一致)。 const ( GenderUnset int32 = 0 GenderMale int32 = 1 GenderFemale int32 = 2 ) // TTSVoice 一个可选音色。 type TTSVoice struct { Provider string `json:"provider"` // azure / alibaba Language string `json:"language"` // BCP-47,如 zh-CN Gender int32 `json:"gender"` // 1 男 2 女 VoiceId string `json:"voice_id"` // 传给服务商的音色 id Name string `json:"name"` // 展示名(后台下拉用) } // v 构造音色项,省掉一堆字段名。 func v(lang string, gender int32, id, name string) TTSVoice { return TTSVoice{Language: lang, Gender: gender, VoiceId: id, Name: name} } // azureVoices 微软 Azure TTS 音色。同一(语言,性别)下按推荐度排序,第一个是默认。 var azureVoices = []TTSVoice{ // 中文 v("zh-CN", GenderFemale, "zh-CN-XiaoxiaoNeural", "晓晓 · 温柔"), v("zh-CN", GenderFemale, "zh-CN-XiaoyiNeural", "晓伊 · 活泼"), v("zh-CN", GenderMale, "zh-CN-YunxiNeural", "云希 · 阳光"), v("zh-CN", GenderMale, "zh-CN-YunjianNeural", "云健 · 浑厚"), v("zh-TW", GenderFemale, "zh-TW-HsiaoChenNeural", "曉臻"), v("zh-TW", GenderMale, "zh-TW-YunJheNeural", "雲哲"), v("zh-HK", GenderFemale, "zh-HK-HiuMaanNeural", "曉曼(粤)"), v("zh-HK", GenderMale, "zh-HK-WanLungNeural", "雲龍(粤)"), v("yue-HK", GenderFemale, "zh-HK-HiuGaaiNeural", "曉佳(粤)"), v("yue-HK", GenderMale, "zh-HK-WanLungNeural", "雲龍(粤)"), // 英语 v("en-US", GenderFemale, "en-US-JennyNeural", "Jenny"), v("en-US", GenderFemale, "en-US-AriaNeural", "Aria"), v("en-US", GenderMale, "en-US-GuyNeural", "Guy"), v("en-US", GenderMale, "en-US-AndrewNeural", "Andrew"), v("en-GB", GenderFemale, "en-GB-SoniaNeural", "Sonia"), v("en-GB", GenderMale, "en-GB-RyanNeural", "Ryan"), // 东亚 v("ja-JP", GenderFemale, "ja-JP-NanamiNeural", "Nanami"), v("ja-JP", GenderMale, "ja-JP-KeitaNeural", "Keita"), v("ko-KR", GenderFemale, "ko-KR-SunHiNeural", "SunHi"), v("ko-KR", GenderMale, "ko-KR-InJoonNeural", "InJoon"), // 东南亚 / 南亚 v("id-ID", GenderFemale, "id-ID-GadisNeural", "Gadis"), v("id-ID", GenderMale, "id-ID-ArdiNeural", "Ardi"), v("th-TH", GenderFemale, "th-TH-PremwadeeNeural", "Premwadee"), v("th-TH", GenderMale, "th-TH-NiwatNeural", "Niwat"), v("vi-VN", GenderFemale, "vi-VN-HoaiMyNeural", "HoaiMy"), v("vi-VN", GenderMale, "vi-VN-NamMinhNeural", "NamMinh"), v("ms-MY", GenderFemale, "ms-MY-YasminNeural", "Yasmin"), v("ms-MY", GenderMale, "ms-MY-OsmanNeural", "Osman"), v("fil-PH", GenderFemale, "fil-PH-BlessicaNeural", "Blessica"), v("fil-PH", GenderMale, "fil-PH-AngeloNeural", "Angelo"), // 欧洲 v("fr-FR", GenderFemale, "fr-FR-DeniseNeural", "Denise"), v("fr-FR", GenderMale, "fr-FR-HenriNeural", "Henri"), v("de-DE", GenderFemale, "de-DE-KatjaNeural", "Katja"), v("de-DE", GenderMale, "de-DE-ConradNeural", "Conrad"), v("es-ES", GenderFemale, "es-ES-ElviraNeural", "Elvira"), v("es-ES", GenderMale, "es-ES-AlvaroNeural", "Alvaro"), v("es-MX", GenderFemale, "es-MX-DaliaNeural", "Dalia"), v("es-MX", GenderMale, "es-MX-JorgeNeural", "Jorge"), v("pt-BR", GenderFemale, "pt-BR-FranciscaNeural", "Francisca"), v("pt-BR", GenderMale, "pt-BR-AntonioNeural", "Antonio"), v("pt-PT", GenderFemale, "pt-PT-RaquelNeural", "Raquel"), v("pt-PT", GenderMale, "pt-PT-DuarteNeural", "Duarte"), v("it-IT", GenderFemale, "it-IT-ElsaNeural", "Elsa"), v("it-IT", GenderMale, "it-IT-DiegoNeural", "Diego"), v("nl-NL", GenderFemale, "nl-NL-ColetteNeural", "Colette"), v("nl-NL", GenderMale, "nl-NL-MaartenNeural", "Maarten"), v("pl-PL", GenderFemale, "pl-PL-ZofiaNeural", "Zofia"), v("pl-PL", GenderMale, "pl-PL-MarekNeural", "Marek"), v("ru-RU", GenderFemale, "ru-RU-SvetlanaNeural", "Svetlana"), v("ru-RU", GenderMale, "ru-RU-DmitryNeural", "Dmitry"), v("uk-UA", GenderFemale, "uk-UA-PolinaNeural", "Polina"), v("uk-UA", GenderMale, "uk-UA-OstapNeural", "Ostap"), v("tr-TR", GenderFemale, "tr-TR-EmelNeural", "Emel"), v("tr-TR", GenderMale, "tr-TR-AhmetNeural", "Ahmet"), // 中东 v("ar-SA", GenderFemale, "ar-SA-ZariyahNeural", "Zariyah"), v("ar-SA", GenderMale, "ar-SA-HamedNeural", "Hamed"), } // alibabaVoices 阿里云智能语音交互 TTS 音色。 // 阿里的音色不像 Azure 那样每个语种都有一大把:中文/中英混合最全, // 小语种主要靠多语言音色兜底,所以这里同一个 id 会挂在多个语言下。 var alibabaVoices = []TTSVoice{ // 中文(含中英混合) v("zh-CN", GenderFemale, "zhixiaobai", "知小白 · 通用"), v("zh-CN", GenderFemale, "xiaoyun", "小云 · 标准"), v("zh-CN", GenderFemale, "siqi", "思琪 · 温柔"), v("zh-CN", GenderMale, "zhigui", "知贵 · 沉稳"), v("zh-CN", GenderMale, "xiaogang", "小刚 · 标准"), v("zh-CN", GenderMale, "sicheng", "思诚 · 浑厚"), v("zh-TW", GenderFemale, "xiaoyun", "小云"), v("zh-TW", GenderMale, "xiaogang", "小刚"), v("zh-HK", GenderFemale, "jiajia", "佳佳(粤)"), v("yue-HK", GenderFemale, "jiajia", "佳佳(粤)"), v("yue-HK", GenderFemale, "taozi", "桃子(粤)"), // 英语 v("en-US", GenderFemale, "luna", "Luna"), v("en-US", GenderMale, "luca", "Luca"), v("en-GB", GenderFemale, "luna", "Luna"), v("en-GB", GenderMale, "luca", "Luca"), // 日语 / 韩语 v("ja-JP", GenderFemale, "tomoka", "智香"), v("ja-JP", GenderMale, "tomoya", "智也"), v("ko-KR", GenderFemale, "kyong", "京"), // 东南亚 v("id-ID", GenderFemale, "indah", "Indah"), v("ms-MY", GenderFemale, "farah", "Farah"), v("fil-PH", GenderFemale, "tala", "Tala"), } // TTSVoices 返回某服务商的全部音色(按上表顺序)。provider 未知返回空切片。 // 返回的是副本,调用方改不动常量表。 func TTSVoices(provider string) []TTSVoice { var src []TTSVoice switch strings.ToLower(strings.TrimSpace(provider)) { case TTSProviderAzure: src = azureVoices case TTSProviderAlibaba: src = alibabaVoices default: return []TTSVoice{} } out := make([]TTSVoice, 0, len(src)) for _, x := range src { x.Provider = provider out = append(out, x) } return out } // TTSLanguages 返回某服务商音色表覆盖的语言列表(按表内首次出现顺序去重)。 // // 这就是「该 TTS 服务实际能说哪些语言」——音色表里有音色的语言才说得出来。 // 用途有二:seed 时预填服务模板的 languages 字段(省得运营手抄一遍还抄错); // 通话翻译 / AI 助手做「这条链路的语言有没有服务支持」排查时的依据。 // 未覆盖的服务商返回空切片,调用方应理解为「不知道,别据此判定不支持」。 func TTSLanguages(provider string) []string { all := TTSVoices(provider) out := make([]string, 0, 16) seen := map[string]bool{} for _, v := range all { k := strings.ToLower(v.Language) if seen[k] { continue } seen[k] = true out = append(out, v.Language) } return out } // TTSProviders 返回内置音色表覆盖的服务商列表。 func TTSProviders() []string { return []string{TTSProviderAzure, TTSProviderAlibaba} } // VoicesOf 取某服务商在指定语言+性别下的候选音色(顺序即优先级,第一个是默认)。 // gender=0 表示不限性别,返回该语言下全部音色。 // 语言匹配:先精确匹配 BCP-47,命中不到再退到主语言子标签(zh-CN ↔ zh-TW 不互通, // 但 zh ↔ zh-CN 能对上),绝不跨语言乱兜底——宁可没有音色让调用方用服务商默认, // 也不能把日语文本丢给中文音色念。 func VoicesOf(provider, language string, gender int32) []TTSVoice { all := TTSVoices(provider) want := strings.TrimSpace(language) match := func(cmp func(a, b string) bool) []TTSVoice { out := make([]TTSVoice, 0, 4) for _, x := range all { if gender != GenderUnset && x.Gender != gender { continue } if cmp(x.Language, want) { out = append(out, x) } } return out } if hit := match(strings.EqualFold); len(hit) > 0 { return hit } wantBase, _, _ := strings.Cut(want, "-") return match(func(a, b string) bool { aBase, _, _ := strings.Cut(a, "-") return strings.EqualFold(aBase, wantBase) }) } // PickVoice 选出某服务商在指定语言+性别下应使用的音色 id(同条件多个时取第一个)。 // 找不到返回空串——调用方应据此走服务商默认音色,而不是硬塞一个别的语言的音色。 func PickVoice(provider, language string, gender int32) string { if hit := VoicesOf(provider, language, gender); len(hit) > 0 { return hit[0].VoiceId } // 指定性别没有,就放开性别再找一次:宁可性别不符也要说对语言。 if gender != GenderUnset { if hit := VoicesOf(provider, language, GenderUnset); len(hit) > 0 { return hit[0].VoiceId } } return "" } // ResolveAgentVoice 解析 agent 在某语言下最终该用的音色 id: // 运营在后台显式配过(voice_map 命中语言+性别)就用它,否则按音色表取默认。 // 这是「agent 切语言自动换音色」的唯一入口,客户端与服务端都走它,避免两套口径。 func ResolveAgentVoice(voiceMap []AgentVoice, provider, language string, gender int32) string { want := strings.TrimSpace(language) wantBase, _, _ := strings.Cut(want, "-") // 先精确(语言+性别),再放宽到主语言,最后不限性别——都在 voice_map 内部找。 for _, pass := range []int{0, 1, 2} { for _, m := range voiceMap { if strings.TrimSpace(m.Voice) == "" { continue } if pass < 2 && gender != GenderUnset && m.Gender != GenderUnset && m.Gender != gender { continue } switch pass { case 0: if strings.EqualFold(m.Language, want) { return m.Voice } default: mBase, _, _ := strings.Cut(m.Language, "-") if strings.EqualFold(mBase, wantBase) { return m.Voice } } } } return PickVoice(provider, language, gender) }