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
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)
|
|
}
|
|
|