From 2e9bba8a1f3773266d1c61d26e348a146ef1545d Mon Sep 17 00:00:00 2001 From: will wade Date: Wed, 9 Sep 2026 07:26:52 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20SSML=E2=86=92SpeechMarkdown=20gains=20a?= =?UTF-8?q?mazon:effect=20whisper=20+=20mstts:express-as;=20v3=20style=20p?= =?UTF-8?q?assthrough?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit to_smd now translates to the whisper modifier and to the generic style modifier; the ElevenLabs v3 dialect passes style values through as audio tags (v3 tags are open-ended natural language). Together these unblock tts_speak_ssml on ElevenLabs: W3C/Alexa/Azure SSML can round-trip into the model-matched dialect instead of being stripped — pinned by a round-trip test (break → pause tier, prosody → tempo tag, whisper). --- src/formatters/elevenlabs_v3.rs | 10 +++++ src/ssml_to_smd.rs | 75 +++++++++++++++++++++++++++------ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/src/formatters/elevenlabs_v3.rs b/src/formatters/elevenlabs_v3.rs index 800965c..cef9143 100644 --- a/src/formatters/elevenlabs_v3.rs +++ b/src/formatters/elevenlabs_v3.rs @@ -100,6 +100,16 @@ impl ElevenLabsV3Formatter { "reduced" => Some("[understated]".to_string()), _ => None, }, + // Generic style modifier (e.g. translated from Azure's + // mstts:express-as): v3 tags are open-ended natural language, + // so the style value passes through as the tag. + "style" => { + if value.is_empty() { + None + } else { + Some(format!("[{value}]")) + } + } _ => None, } } diff --git a/src/ssml_to_smd.rs b/src/ssml_to_smd.rs index 82c3e64..4352b7f 100644 --- a/src/ssml_to_smd.rs +++ b/src/ssml_to_smd.rs @@ -135,12 +135,7 @@ fn handle_self_closing(name: &str, attrs: &[(String, String)], result: &mut Stri } } -fn handle_closing( - name: &str, - attrs: &[(String, String)], - text_start: usize, - result: &mut String, -) { +fn handle_closing(name: &str, attrs: &[(String, String)], text_start: usize, result: &mut String) { match name { "emphasis" => { if let Some(level) = get_attr(attrs, "level") { @@ -206,6 +201,28 @@ fn handle_closing( result.push_str(&format!("({})/{}", inner, ph)); } } + // Amazon's whispered effect (the local name quick-xml reports for + // ). Round-trips into the ElevenLabs dialects via + // the whisper modifier. + "effect" => { + if get_attr(attrs, "name").is_some_and(|name| name.eq_ignore_ascii_case("whispered")) { + let inner = extract_inner(text_start, result); + if !inner.is_empty() { + result.push_str(&format!("({})[whisper]", inner)); + } + } + } + // Azure's express-as () → the generic + // style modifier, which the ElevenLabs v3 dialect renders as an + // audio tag and other platforms drop. + "express-as" => { + if let Some(style) = get_attr(attrs, "style") { + let inner = extract_inner(text_start, result); + if !inner.is_empty() { + result.push_str(&format!("({})[style:\"{}\"]", inner, style)); + } + } + } "sub" => { if let Some(alias) = get_attr(attrs, "alias") { let inner = extract_inner(text_start, result); @@ -274,10 +291,7 @@ fn extract_inner(text_start: usize, result: &mut String) -> String { } fn get_attr(attrs: &[(String, String)], key: &str) -> Option { - attrs - .iter() - .find(|(k, _)| k == key) - .map(|(_, v)| v.clone()) + attrs.iter().find(|(k, _)| k == key).map(|(_, v)| v.clone()) } #[cfg(test)] @@ -406,8 +420,7 @@ mod tests { #[test] fn test_say_as_date_with_format() { - let ssml = - r#"01/02/2024"#; + let ssml = r#"01/02/2024"#; let result = ssml_to_smd(ssml).unwrap(); assert_eq!(result, r#"(01/02/2024)[date:"mdy"]"#); } @@ -431,4 +444,42 @@ mod tests { let result = ssml_to_smd(ssml).unwrap(); assert_eq!(result, "A & B < C > D"); } + + #[test] + fn test_amazon_whispered_effect() { + let ssml = + r#"it's a secret"#; + let result = ssml_to_smd(ssml).unwrap(); + assert_eq!(result, "(it's a secret)[whisper]"); + } + + #[test] + fn test_amazon_other_effect_dropped() { + let ssml = r#"plain"#; + let result = ssml_to_smd(ssml).unwrap(); + assert_eq!(result, "plain"); + } + + #[test] + fn test_mstts_express_as_style() { + let ssml = + r#"hello there"#; + let result = ssml_to_smd(ssml).unwrap(); + assert_eq!(result, r#"(hello there)[style:"cheerful"]"#); + } + + #[test] + fn elevenlabs_round_trip_ssml_to_v3_dialect() { + // The chain tts_speak_ssml uses: W3C SSML → SpeechMarkdown → + // v3 audio-tag dialect. + use crate::formatters::base::Platform; + use crate::parser::SpeechMarkdownParser; + let ssml = r#"Hello world quietly"#; + let smd = ssml_to_smd(ssml).unwrap(); + let dialect = SpeechMarkdownParser::to_ssml(&smd, Platform::ElevenLabsV3).unwrap(); + assert_eq!( + dialect, + "Hello [long pause] [drawn out] world [whispers] quietly" + ); + } }