use super::truncate;
use super::MediaError;
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
pub struct VoiceOption {
pub id: String,
pub label: String,
pub gender: String,
}
pub fn voice_catalog(model: &str) -> Vec<VoiceOption> {
let m = model.to_ascii_lowercase();
if m.contains("volcano") || m.contains("volc") {
return volc_voice_catalog();
}
if m.contains("glm") {
return glm_voice_catalog();
}
if m.is_empty() || m.contains("cosyvoice") || m.contains("fish") {
return cosyvoice_voice_catalog();
}
Vec::new()
}
pub fn voice_timbre_hint(id: &str) -> &'static str {
match id {
"zh_female_meilinvyou_moon_bigtts" => "青年女声·甜美亲昵·温柔",
"zh_male_beijingxiaoye_moon_bigtts" => "青年男声·京腔·爽朗明亮",
"zh_female_wanwanxiaohe_moon_bigtts" => "青年女声·台湾腔·软糯活泼",
"zh_male_M392_conversation_wvae_bigtts" => "青年男声·自然口语·亲切",
"zh_female_shuangkuaisisi_moon_bigtts" => "青年女声·爽快利落·明快",
"zh_male_wennuanahu_moon_bigtts" => "中年男声·低沉沉稳·温厚",
"zh_female_tianmeixiaoyuan_moon_bigtts" => "少女声·甜美清脆·元气",
"zh_male_jingqiangkanye_moon_bigtts" => "中年男声·京腔·豪爽侃气",
"BV700_streaming" => "青年女声·多情感·百搭",
"BV701_streaming" => "中年男声·多情感·浑厚(适合旁白/长者)",
"BV001_streaming" => "青年女声·通用",
"BV002_streaming" => "青年男声·通用",
"alex" => "中年男声·沉稳",
"benjamin" => "中年男声·低沉",
"charles" => "青年男声·磁性",
"david" => "青年男声·欢快明亮",
"anna" => "中年女声·沉稳",
"bella" => "青年女声·激情有力",
"claire" => "青年女声·温柔",
"diana" => "青年女声·欢快",
"tongtong" => "少女声·甜美童真·清亮",
"jieyu" => "青年女声·知性温婉",
"tianmeng_shaonv" => "少女声·甜萌·元气",
"nuanyang_nvsheng" => "青年女声·温暖柔和",
"jieshuo_nansheng" => "中年男声·解说旁白·沉稳",
"jingdian_yueyu" => "粤语女声·经典",
_ => "",
}
}
fn to_voices(rows: &[(&str, &str, &str)]) -> Vec<VoiceOption> {
rows.iter()
.map(|(id, label, gender)| VoiceOption {
id: (*id).to_string(),
label: (*label).to_string(),
gender: (*gender).to_string(),
})
.collect()
}
fn cosyvoice_voice_catalog() -> Vec<VoiceOption> {
to_voices(&[
("alex", "沉稳男声", "male"),
("benjamin", "低沉男声", "male"),
("charles", "磁性男声", "male"),
("david", "欢快男声", "male"),
("anna", "沉稳女声", "female"),
("bella", "激情女声", "female"),
("claire", "温柔女声", "female"),
("diana", "欢快女声", "female"),
])
}
fn glm_voice_catalog() -> Vec<VoiceOption> {
to_voices(&[
("tongtong", "童童·甜美", "female"),
("jieyu", "婕语·知性", "female"),
("tianmeng_shaonv", "甜萌少女", "female"),
("nuanyang_nvsheng", "暖阳女声", "female"),
("jieshuo_nansheng", "解说男声", "male"),
("jingdian_yueyu", "经典粤语", "female"),
])
}
fn volc_voice_catalog() -> Vec<VoiceOption> {
to_voices(&[
(
"zh_female_meilinvyou_moon_bigtts",
"魅力女友·大模型",
"female",
),
(
"zh_male_beijingxiaoye_moon_bigtts",
"北京小爷·大模型",
"male",
),
(
"zh_female_wanwanxiaohe_moon_bigtts",
"湾湾小何·大模型",
"female",
),
(
"zh_male_M392_conversation_wvae_bigtts",
"对话男声·大模型",
"male",
),
(
"zh_female_shuangkuaisisi_moon_bigtts",
"爽快思思·大模型",
"female",
),
("zh_male_wennuanahu_moon_bigtts", "温暖阿虎·大模型", "male"),
(
"zh_female_tianmeixiaoyuan_moon_bigtts",
"甜美小源·大模型",
"female",
),
(
"zh_male_jingqiangkanye_moon_bigtts",
"京腔侃爷·大模型",
"male",
),
("BV700_streaming", "灿灿·多情感女声(经典)", "female"),
("BV701_streaming", "擎苍·多情感男声(经典)", "male"),
("BV001_streaming", "通用女声(经典)", "female"),
("BV002_streaming", "通用男声(经典)", "male"),
])
}
#[derive(Debug, Clone)]
pub struct TtsConfig {
pub endpoint: String,
pub model: String,
pub api_key: String,
}
#[derive(Debug, Clone)]
pub struct TtsParams {
pub text: String,
pub voice: String,
pub format: String,
pub emotion: String,
pub style: String,
}
impl Default for TtsParams {
fn default() -> Self {
Self {
text: String::new(),
voice: String::new(),
format: "mp3".to_string(),
emotion: String::new(),
style: String::new(),
}
}
}
#[allow(async_fn_in_trait)]
pub trait TtsProvider {
async fn synthesize(&self, params: &TtsParams) -> Result<Vec<u8>, MediaError>;
}
pub struct SiliconFlowTtsProvider {
client: super::http::MediaClient,
config: TtsConfig,
}
impl SiliconFlowTtsProvider {
pub fn new(config: TtsConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: TtsConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
fn speech_url(&self) -> String {
let e = self.config.endpoint.trim_end_matches('/');
if e.ends_with("/audio/speech") {
e.to_string()
} else {
format!("{e}/audio/speech")
}
}
}
impl TtsProvider for SiliconFlowTtsProvider {
async fn synthesize(&self, params: &TtsParams) -> Result<Vec<u8>, MediaError> {
if params.text.trim().is_empty() {
return Err(MediaError::InvalidInput("配音文本为空".into()));
}
let is_glm = self.config.model.to_ascii_lowercase().contains("glm");
let fmt = if is_glm {
"wav" } else if params.format.trim().is_empty() {
"mp3"
} else {
params.format.as_str()
};
let voice = if is_glm {
let v = params.voice.trim();
if v.is_empty() {
"tongtong".to_string()
} else if is_glm_voice(v) {
v.to_string()
} else {
log::warn!(
"glm-tts 配音收到非 glm 音色 '{v}'(疑似切换供应商后残留),已回退 tongtong;请在 设置→配音 改用 glm 音色"
);
"tongtong".to_string()
}
} else {
let v = params.voice.trim();
if is_volc_voice(v) {
log::warn!(
"CosyVoice 配音收到火山音色 '{v}'(疑似切换供应商后旁白/角色音色残留),已回退 alex;请在 设置→配音 改用 CosyVoice 音色"
);
format!("{}:alex", self.config.model.trim())
} else if v.is_empty() {
format!("{}:alex", self.config.model.trim())
} else if v.contains(':') {
v.to_string()
} else {
format!("{}:{}", self.config.model.trim(), v)
}
};
let dir = VoiceDirection::new(¶ms.emotion, ¶ms.style);
let input = match (is_glm, dir.cosyvoice_instruction()) {
(false, Some(instr)) => format!("{instr}<|endofprompt|>{}", params.text),
_ => params.text.clone(),
};
let mut body = serde_json::json!({
"model": self.config.model,
"input": input,
"voice": voice,
"response_format": fmt,
});
if !is_glm && dir.cosyvoice_emotion_word().is_some() {
body["speed"] = serde_json::json!(dir.cosyvoice_speed());
}
let resp = self
.client
.get()?
.post(self.speech_url())
.bearer_auth(&self.config.api_key)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交配音合成失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"配音合成 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let bytes = resp
.bytes()
.await
.map_err(|e| MediaError::Failed(format!("读取配音音频字节失败: {e}")))?;
if bytes.is_empty() {
return Err(MediaError::Failed("配音合成返回空音频".into()));
}
Ok(bytes.to_vec())
}
}
#[derive(Debug, Clone)]
pub struct VoiceDirection {
emotion: String,
style: String,
}
impl VoiceDirection {
pub fn new(emotion: &str, style: &str) -> Self {
Self {
emotion: emotion.trim().to_string(),
style: style.trim().to_string(),
}
}
pub fn volc_emotion(&self) -> Option<&'static str> {
let l = self.emotion.as_str();
if l.is_empty() {
return None;
}
if l.contains("开心") || l.contains("高兴") || l.contains("喜") || l.contains("快乐")
{
Some("happy")
} else if l.contains("悲") || l.contains("难过") || l.contains("伤心") || l.contains("哭")
{
Some("sad")
} else if l.contains("怒") || l.contains("愤") || l.contains("生气") {
Some("angry")
} else if l.contains("惊讶") || l.contains("震惊") || l.contains("吃惊") {
Some("surprise")
} else if l.contains("恐") || l.contains("惧") || l.contains("害怕") || l.contains("紧张")
{
Some("fear")
} else if l.contains("厌") || l.contains("恶") || l.contains("嫌") {
Some("hate")
} else {
None }
}
pub fn prosody(&self) -> (f64, f64, i64) {
let l = self.emotion.as_str();
if l.contains("怒") || l.contains("愤") || l.contains("生气") {
(1.1, 1.3, 5) } else if l.contains("悲") || l.contains("难过") || l.contains("伤") || l.contains("哭")
{
(0.9, 0.85, 4) } else if l.contains("恐") || l.contains("惧") || l.contains("害怕") || l.contains("紧张")
{
(1.1, 0.9, 4) } else if l.contains("惊讶") || l.contains("震惊") || l.contains("吃惊") {
(1.1, 1.15, 4) } else if l.contains("开心") || l.contains("高兴") || l.contains("喜") || l.contains("快乐")
{
(1.05, 1.1, 4) } else {
(1.0, 1.0, 4) }
}
pub fn cosyvoice_emotion_word(&self) -> Option<&'static str> {
match self.volc_emotion() {
Some("happy") => Some("高兴"),
Some("sad") => Some("悲伤"),
Some("angry") => Some("愤怒"),
Some("surprise") => Some("惊讶"),
Some("fear") => Some("恐惧"),
Some("hate") => Some("厌恶"),
_ => None,
}
}
pub fn cosyvoice_speed(&self) -> f64 {
self.prosody().0
}
pub fn cosyvoice_instruction(&self) -> Option<String> {
let style = self.style.trim();
match (style.is_empty(), self.cosyvoice_emotion_word()) {
(true, None) => None,
(false, None) => Some(format!("请用{style}的语气说。")),
(true, Some(emo)) => Some(format!("请用{emo}的语气说。")),
(false, Some(emo)) => Some(format!("请用{style}、{emo}的语气说。")),
}
}
}
fn is_volc_emotion_voice(voice: &str) -> bool {
let v = voice.to_ascii_lowercase();
v.ends_with("_bigtts") || v.contains("bv700") || v.contains("bv701")
}
fn is_glm_voice(voice: &str) -> bool {
matches!(
voice.trim(),
"tongtong"
| "jieyu"
| "tianmeng_shaonv"
| "nuanyang_nvsheng"
| "jieshuo_nansheng"
| "jingdian_yueyu"
)
}
fn is_cosyvoice_voice(voice: &str) -> bool {
matches!(
voice.trim().to_ascii_lowercase().as_str(),
"alex" | "benjamin" | "charles" | "david" | "anna" | "bella" | "claire" | "diana"
)
}
fn is_volc_voice(voice: &str) -> bool {
let v = voice.trim().to_ascii_lowercase();
v.starts_with("bv") || v.ends_with("_streaming") || v.ends_with("_bigtts")
}
#[derive(Debug, Clone)]
pub struct VolcTtsConfig {
pub endpoint: String,
pub appid: String,
pub cluster: String,
pub access_token: String,
}
pub struct VolcTtsProvider {
client: super::http::MediaClient,
config: VolcTtsConfig,
}
impl VolcTtsProvider {
pub fn new(config: VolcTtsConfig) -> Self {
Self::with_http(config, &super::MediaHttp::default())
}
pub fn with_http(config: VolcTtsConfig, http: &super::MediaHttp) -> Self {
Self {
client: super::http::default_client(http),
config,
}
}
}
#[derive(serde::Deserialize)]
struct VolcTtsResponse {
#[serde(default)]
code: i64,
#[serde(default)]
message: Option<String>,
#[serde(default)]
data: Option<String>,
}
impl TtsProvider for VolcTtsProvider {
async fn synthesize(&self, params: &TtsParams) -> Result<Vec<u8>, MediaError> {
if params.text.trim().is_empty() {
return Err(MediaError::InvalidInput("配音文本为空".into()));
}
if self.config.appid.trim().is_empty() {
return Err(MediaError::InvalidInput(
"字节豆包配音需配置 App ID(设置 → 配音 → 添加时填写)".into(),
));
}
let voice = if params.voice.trim().is_empty() {
"zh_female_meilinvyou_moon_bigtts".to_string()
} else if is_cosyvoice_voice(params.voice.trim()) {
log::warn!(
"火山配音收到 CosyVoice 音色 '{}'(疑似切换供应商后旁白/角色音色残留),已回退大模型拟真音色;请在 设置→配音 改用火山音色",
params.voice.trim()
);
"zh_female_meilinvyou_moon_bigtts".to_string()
} else {
params.voice.trim().to_string()
};
let cluster = if self.config.cluster.trim().is_empty() {
"volcano_tts"
} else {
self.config.cluster.trim()
};
let reqid = format!(
"sl-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
);
let dir = VoiceDirection::new(¶ms.emotion, ¶ms.style);
let (speed, loudness, scale) = dir.prosody();
let mut audio = serde_json::json!({
"voice_type": voice,
"encoding": "mp3",
"speed_ratio": speed,
"loudness_ratio": loudness,
});
if is_volc_emotion_voice(&voice) {
if let Some(emo) = dir.volc_emotion() {
audio["enable_emotion"] = serde_json::Value::Bool(true);
audio["emotion"] = serde_json::Value::String(emo.to_string());
audio["emotion_scale"] = serde_json::json!(scale);
}
}
let body = serde_json::json!({
"app": { "appid": self.config.appid, "token": self.config.access_token, "cluster": cluster },
"user": { "uid": "storyloom" },
"audio": audio,
"request": { "reqid": reqid, "text": params.text, "operation": "query" },
});
let resp = self
.client
.get()?
.post(self.config.endpoint.trim())
.header(
"Authorization",
format!("Bearer;{}", self.config.access_token),
)
.json(&body)
.send()
.await
.map_err(|e| {
MediaError::Failed(format!(
"提交配音合成失败: {}",
super::http::describe_reqwest_error(&e)
))
})?;
let status = resp.status();
if !status.is_success() {
let text = resp.text().await.unwrap_or_default();
return Err(MediaError::Failed(format!(
"配音合成 HTTP {}: {}",
status.as_u16(),
truncate(&text, 300)
)));
}
let parsed: VolcTtsResponse = resp
.json()
.await
.map_err(|e| MediaError::Failed(format!("解析配音响应失败: {e}")))?;
if parsed.code != 3000 {
return Err(MediaError::Failed(format!(
"配音合成失败(code {}): {}",
parsed.code,
parsed.message.unwrap_or_default()
)));
}
let b64 = parsed
.data
.filter(|s| !s.is_empty())
.ok_or_else(|| MediaError::Failed("配音响应未返回音频数据".into()))?;
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(b64.as_bytes())
.map_err(|e| MediaError::Failed(format!("配音音频 base64 解码失败: {e}")))?;
if bytes.is_empty() {
return Err(MediaError::Failed("配音合成返回空音频".into()));
}
Ok(bytes)
}
}
#[cfg(test)]
mod tests {
use super::VoiceDirection;
#[test]
fn neutral_emotion_emits_no_control() {
for label in ["", " ", "平静", "中性", "未知情绪"] {
let d = VoiceDirection::new(label, "");
assert_eq!(d.volc_emotion(), None, "{label}: 火山不应下发情感枚举");
assert_eq!(
d.cosyvoice_emotion_word(),
None,
"{label}: CosyVoice 不应有情感词"
);
assert_eq!(
d.cosyvoice_instruction(),
None,
"{label}: 无风格无情感不应注入指令"
);
assert_eq!(d.prosody(), (1.0, 1.0, 4), "{label}: 语气曲线应为自然值");
assert_eq!(d.cosyvoice_speed(), 1.0, "{label}: 语速应保持默认 1.0");
}
}
#[test]
fn emotion_maps_to_volc_enum_and_cosyvoice_word() {
let cases = [
("愤怒", "angry", "愤怒"),
("角色很生气", "angry", "愤怒"),
("悲伤", "sad", "悲伤"),
("开心", "happy", "高兴"),
("惊讶", "surprise", "惊讶"),
("恐惧", "fear", "恐惧"),
];
for (label, volc, cosy) in cases {
let d = VoiceDirection::new(label, "");
assert_eq!(d.volc_emotion(), Some(volc), "{label}: 火山枚举不符");
assert_eq!(
d.cosyvoice_emotion_word(),
Some(cosy),
"{label}: CosyVoice 指令词不符"
);
}
}
#[test]
fn speed_follows_emotion() {
assert_eq!(VoiceDirection::new("愤怒", "").cosyvoice_speed(), 1.1);
assert_eq!(VoiceDirection::new("悲伤", "").cosyvoice_speed(), 0.9);
}
#[test]
fn cosyvoice_instruction_combines_style_and_emotion() {
assert_eq!(
VoiceDirection::new("", "傲娇").cosyvoice_instruction(),
Some("请用傲娇的语气说。".to_string())
);
assert_eq!(
VoiceDirection::new("愤怒", "").cosyvoice_instruction(),
Some("请用愤怒的语气说。".to_string())
);
assert_eq!(
VoiceDirection::new("愤怒", "傲娇").cosyvoice_instruction(),
Some("请用傲娇、愤怒的语气说。".to_string())
);
assert_eq!(VoiceDirection::new("", "").cosyvoice_instruction(), None);
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum TtsProtocol {
Volc,
ArkMisconfigured,
OpenAi,
}
impl TtsProtocol {
pub fn detect(endpoint: &str) -> Self {
let e = endpoint.to_ascii_lowercase();
if e.contains("openspeech") {
Self::Volc
} else if e.contains("volces.com") || e.contains("/ark") {
Self::ArkMisconfigured
} else {
Self::OpenAi
}
}
}
pub fn audio_format_for(model: &str) -> &'static str {
if model.to_ascii_lowercase().contains("glm") {
"wav"
} else {
"mp3"
}
}
pub fn parse_volc_extra(extra: &str) -> (String, String) {
let v: serde_json::Value = serde_json::from_str(extra).unwrap_or(serde_json::Value::Null);
let appid = v
.get("appid")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let cluster = v
.get("cluster")
.and_then(|x| x.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("volcano_tts")
.to_string();
(appid, cluster)
}
pub async fn synthesize(
endpoint: &str,
model: &str,
extra: &str,
api_key: String,
params: &TtsParams,
) -> Result<Vec<u8>, MediaError> {
synthesize_with(
endpoint,
model,
extra,
api_key,
params,
&super::MediaHttp::default(),
)
.await
}
pub async fn synthesize_with(
endpoint: &str,
model: &str,
extra: &str,
api_key: String,
params: &TtsParams,
http: &super::MediaHttp,
) -> Result<Vec<u8>, MediaError> {
match TtsProtocol::detect(endpoint) {
TtsProtocol::Volc => {
let (appid, cluster) = parse_volc_extra(extra);
VolcTtsProvider::with_http(VolcTtsConfig {
endpoint: endpoint.to_string(),
appid,
cluster,
access_token: api_key,
}, http)
.synthesize(params)
.await
}
TtsProtocol::ArkMisconfigured => Err(MediaError::InvalidInput(
"配音不能用火山方舟(ark)端点——方舟没有语音合成接口(TTS 属「火山语音」另一产品线)。请改用:\
① 字节豆包配音(火山语音):端点 https://openspeech.bytedance.com/api/v1/tts,需 appid/cluster/access_token(用「字节豆包配音」预置重新添加才有这些字段);\
② 或 硅基流动 CosyVoice:端点 https://api.siliconflow.cn/v1,模型 FunAudioLLM/CosyVoice2-0.5B(编辑当前供应商改这两项 + 填硅基流动 Key 即可)。"
.into(),
)),
TtsProtocol::OpenAi => {
SiliconFlowTtsProvider::with_http(TtsConfig {
endpoint: endpoint.to_string(),
model: model.to_string(),
api_key,
}, http)
.synthesize(params)
.await
}
}
}
#[cfg(test)]
mod dispatch_tests {
use super::*;
#[test]
fn detect_by_endpoint() {
assert_eq!(
TtsProtocol::detect("https://openspeech.bytedance.com/api/v1/tts"),
TtsProtocol::Volc
);
assert_eq!(
TtsProtocol::detect("https://ark.cn-beijing.volces.com/api/v3"),
TtsProtocol::ArkMisconfigured
);
assert_eq!(
TtsProtocol::detect("https://api.siliconflow.cn/v1"),
TtsProtocol::OpenAi
);
assert_eq!(
TtsProtocol::detect("https://relay.example.com/v1"),
TtsProtocol::OpenAi
);
}
#[test]
fn no_tts_preset_is_misconfigured() {
for p in crate::preset::presets_for(crate::Kind::Tts) {
let Some(url) = p.base_url else { continue };
assert_ne!(
TtsProtocol::detect(url),
TtsProtocol::ArkMisconfigured,
"{}",
p.key
);
}
let volc = crate::preset::preset_by_key("volc_tts").unwrap();
assert_eq!(
TtsProtocol::detect(volc.base_url.unwrap()),
TtsProtocol::Volc
);
}
#[test]
fn volc_extra_defaults_cluster() {
assert_eq!(
parse_volc_extra(r#"{"appid":"123"}"#),
("123".into(), "volcano_tts".into())
);
assert_eq!(
parse_volc_extra(r#"{"appid":"1","cluster":" c "}"#),
("1".into(), "c".into())
);
assert_eq!(
parse_volc_extra("not json"),
(String::new(), "volcano_tts".into())
);
}
#[test]
fn glm_outputs_wav() {
assert_eq!(audio_format_for("glm-tts"), "wav");
assert_eq!(audio_format_for("FunAudioLLM/CosyVoice2-0.5B"), "mp3");
}
#[tokio::test]
async fn ark_endpoint_is_rejected_before_any_request() {
let e = synthesize(
"https://ark.cn-beijing.volces.com/api/v3",
"m",
"",
"k".into(),
&TtsParams::default(),
)
.await
.unwrap_err();
assert!(matches!(e, MediaError::InvalidInput(_)));
}
}