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.
 
 
 
 
 
 

263 lines
11 KiB

package comm
import "strings"
// TTS 音色表:各 TTS 服务商「支持哪些语言、每种语言有哪些男/女音色」的常量清单。
//
// 为什么放代码里而不是后台配置:音色 id 是服务商固定的枚举(改了就调用失败),
// 不是运营该逐个录入的数据。后台 agent 只做「选哪一个」,选项由本表提供;
// agent 的 voice_map 里存的就是这里的 VoiceId。
//
// 运行时用法:agent 说话前按「当前语言 + agent 性别」查音色——
// 1. 先查 agent 的 voice_map(运营在后台显式选过的,优先);
// 2. 没配就用 PickVoice 取本表中该语言+性别的第一个(列表顺序即优先级)。
// 这样 agent 切语言时音色自动跟着换,不必给每种语言都手配。
//
// ⚠️ 维护提醒:音色 id 以服务商控制台/文档为准,服务商会新增或下线音色。
// Azure 音色为标准 Neural 系列(命名规则 <locale>-<Name>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)
}