From 2147483ecfab7d9df90ccca954bdd4dccccbf887 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:11:59 +0100 Subject: [PATCH 1/8] feat(parser): recognize expressive audio tags ([laugh], [sigh], [applause]) Mirrors the speechmarkdown-js reference grammar: a fixed keyword list parsed into a new NodeType::Expressive AST node. Text and SSML base formatters keep the tag verbatim, matching the JS TextFormatter and W3cSsmlFormatter behavior. Unknown bracketed content continues to pass through as plain text, which preserves Eleven v3's open-ended natural-language audio tags. --- src/ast.rs | 4 ++ src/formatters/ssml/base.rs | 8 +-- src/formatters/text.rs | 7 ++- src/parser/parser.rs | 101 +++++++++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/ast.rs b/src/ast.rs index 6134637..a875e81 100644 --- a/src/ast.rs +++ b/src/ast.rs @@ -162,6 +162,10 @@ pub enum NodeType { /// Mark tag Mark, + /// Expressive audio tag [laugh], [sigh], [applause], etc. + /// Kept verbatim by formatters as a bracketed audio tag. + Expressive, + // Modifier types (for text modifiers and sections) /// Emphasis modifier Emphasis, diff --git a/src/formatters/ssml/base.rs b/src/formatters/ssml/base.rs index 5fd4496..ec8711a 100644 --- a/src/formatters/ssml/base.rs +++ b/src/formatters/ssml/base.rs @@ -232,6 +232,7 @@ impl SsmlFormatterBase { NodeType::ShortIpa => self.format_ipa(node), NodeType::BareIpa => self.format_bare_ipa(node), NodeType::ShortSub => self.format_short_sub(node), + NodeType::Expressive => Ok(format!("[{}]", node.text)), _ => Ok(node.text.clone()), } } @@ -855,12 +856,7 @@ mod phonetic_alphabet_tests { let (tag, attrs) = fmt().attribute_to_tag(key, src).unwrap(); assert_eq!(tag, "phoneme", "key {}", key); assert_eq!(attrs_get(&attrs, "alphabet"), Some("ipa"), "key {}", key); - assert_eq!( - attrs_get(&attrs, "ph"), - Some(expected_ipa), - "key {}", - key - ); + assert_eq!(attrs_get(&attrs, "ph"), Some(expected_ipa), "key {}", key); } } diff --git a/src/formatters/text.rs b/src/formatters/text.rs index 6a525a5..ed27d5f 100644 --- a/src/formatters/text.rs +++ b/src/formatters/text.rs @@ -113,7 +113,12 @@ impl TextFormatter { // Mark tags - no output NodeType::Mark => { - // Mark tags produce no output + // Mark tags produce no text output + } + + // Expressive audio tags - kept verbatim ([laugh], [sigh], …) + NodeType::Expressive => { + result.push(format!("[{}]", node.text)); } // Modifiers are handled as part of text modifiers diff --git a/src/parser/parser.rs b/src/parser/parser.rs index 4e05359..ca55324 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -7,6 +7,66 @@ use crate::ssml_to_smd; pub struct SpeechMarkdownParser; +/// Expressive audio tags recognized by the grammar, mirroring the +/// speechmarkdown-js reference implementation. Eleven v3 treats any +/// bracketed natural-language cue as an audio tag; this list covers the +/// fixed vocabulary the JS parser formalizes. Anything else bracketed +/// falls through as plain text (and therefore also passes through to +/// Eleven v3 verbatim). +const EXPRESSIVE_TAGS: &[&str] = &[ + "laugh", + "laughter", + "sigh", + "cough", + "cheer", + "cheering", + "cry", + "crying", + "gasp", + "groan", + "groaning", + "hum", + "hmm", + "mm-hmm", + "oh", + "sniff", + "whew", + "wow", + "yawn", + "yeah", + "huh", + "tsk", + "uh-huh", + "mmm", + "mhm", + "ahem", + "applause", + "boo", + "giggle", + "hiccup", + "hurray", + "moan", + "pant", + "scream", + "shush", + "sneeze", + "throat-clear", + "wheeze", + "whimper", + "yay", + "bleh", + "eek", + "meh", + "ooh", + "pfft", + "phew", + "psst", + "shh", + "tsk-tsk", + "uh-oh", + "umph", +]; + impl SpeechMarkdownParser { /// Parse SpeechMarkdown text into an AST pub fn parse(input: &str) -> Result { @@ -124,6 +184,9 @@ impl SpeechMarkdownParser { NodeType::ShortBreak, format!("[{}]", bracket_content), )); + } else if Self::is_expressive_tag(&bracket_content) { + document = document + .add_child(AstNode::new(NodeType::Expressive, bracket_content)); } else { current_text.push('['); current_text.push_str(&bracket_content); @@ -500,6 +563,10 @@ impl SpeechMarkdownParser { s.ends_with("s") || s.ends_with("ms") } + fn is_expressive_tag(s: &str) -> bool { + EXPRESSIVE_TAGS.contains(&s) + } + fn read_until(chars: &mut std::iter::Peekable, end: char) -> (String, bool) { let mut content = String::new(); let mut found = false; @@ -578,11 +645,43 @@ mod tests { println!("=========================="); } + #[test] + fn test_parse_expressive_tag() { + let ast = SpeechMarkdownParser::parse("He [laugh] and then [applause] left").unwrap(); + let tags: Vec<&AstNode> = ast + .children + .iter() + .filter(|c| c.node_type == NodeType::Expressive) + .collect(); + assert_eq!(tags.len(), 2); + assert_eq!(tags[0].text, "laugh"); + assert_eq!(tags[1].text, "applause"); + } + + #[test] + fn test_parse_expressive_untouched_when_not_keyword() { + // Unknown bracketed content stays plain text (passthrough for + // Eleven v3's open-ended natural-language audio tags). + let ast = SpeechMarkdownParser::parse("This [not-a-known-tag] stays").unwrap(); + assert!(ast + .children + .iter() + .all(|c| c.node_type == NodeType::PlainText)); + } + + #[test] + fn test_expressive_text_output_keeps_tag() { + let out = SpeechMarkdownParser::to_text("He said [boo] and left").unwrap(); + assert_eq!(out, "He said [boo] and left"); + } + #[test] fn test_is_speech_markdown() { assert!(!SpeechMarkdownParser::is_speech_markdown("Hello world")); assert!(!SpeechMarkdownParser::is_speech_markdown("")); - assert!(SpeechMarkdownParser::is_speech_markdown("Hello (world)[emphasis:\"strong\"]")); + assert!(SpeechMarkdownParser::is_speech_markdown( + "Hello (world)[emphasis:\"strong\"]" + )); assert!(SpeechMarkdownParser::is_speech_markdown("Sample [2s] text")); assert!(SpeechMarkdownParser::is_speech_markdown("++strong++")); assert!(SpeechMarkdownParser::is_speech_markdown("~word~")); From bacda381dfc21b6369396882911566ef78b44359 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:14:37 +0100 Subject: [PATCH 2/8] feat(elevenlabs): pre-v3 prompt-markup formatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the speechmarkdown-js ElevenLabsFormatter: for pauses (strength mapped to approximate durations), for IPA modifiers, expressive audio tags verbatim, everything else degrades to plain text. No wrapper, no XML escaping — a bare prompt, as the shared corpus .elevenlabs.ssml fixtures specify. Also fixes a parser bug: is_time_break accepted any token ending in 's'/'ms', so plain-text words like [apps] were parsed as breaks; it now requires a numeric body. The corpus runner now enforces the .elevenlabs.ssml fixtures (9 cases). Model notes: works on multilingual_v2 / flash_v2_5 / flash_v2 (max 3s); only on flash_v2 / turbo_v2, English only; eleven_v3 parses neither — it needs the audio-tag dialect (next). --- src/formatters/base.rs | 5 + src/formatters/elevenlabs.rs | 287 +++++++++++++++++++++++++++++++++++ src/formatters/mod.rs | 1 + src/parser/parser.rs | 7 +- tests/integration_test.rs | 17 +++ 5 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 src/formatters/elevenlabs.rs diff --git a/src/formatters/base.rs b/src/formatters/base.rs index 10b5a40..13394e7 100644 --- a/src/formatters/base.rs +++ b/src/formatters/base.rs @@ -105,6 +105,11 @@ pub fn create_formatter(platform: Platform, options: FormatterOptions) -> Box { + // Pre-v3 prompt markup: tags (and flash-only ), + // no SSML document, no escaping. + Box::new(super::elevenlabs::ElevenLabsFormatter::new(options)) + } _ => Box::new(super::TextFormatter::new()), } } diff --git a/src/formatters/elevenlabs.rs b/src/formatters/elevenlabs.rs new file mode 100644 index 0000000..52b49a6 --- /dev/null +++ b/src/formatters/elevenlabs.rs @@ -0,0 +1,287 @@ +use crate::ast::{AstNode, NodeType}; +use crate::error::Result; +use crate::formatters::base::{Formatter, FormatterOptions}; + +/// Break strength → approximate duration, matching the speechmarkdown-js +/// ElevenLabsFormatter reference. ElevenLabs only accepts `time` (no +/// `strength` attribute), up to 3 seconds. +const BREAK_STRENGTH_TO_DURATION: &[(&str, &str)] = &[ + ("none", "0s"), + ("x-weak", "0.2s"), + ("weak", "0.35s"), + ("medium", "0.5s"), + ("strong", "0.8s"), + ("x-strong", "1.2s"), +]; + +const DEFAULT_BREAK_DURATION: &str = "0.5s"; + +/// ElevenLabs prompt markup for the pre-v3 model family +/// (`eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_flash_v2`, +/// `eleven_turbo_v2`). +/// +/// These models do not parse SSML documents, but they do understand two +/// inline XML-style tags: +/// +/// - `` — exact pause, up to 3 seconds, seconds format +/// - `` — pronunciation control, +/// `eleven_flash_v2` / `eleven_turbo_v2` only, English only +/// +/// Everything else in SpeechMarkdown degrades to plain text. Output is a +/// bare prompt: no `` wrapper and no XML escaping (matching the +/// speechmarkdown-js reference formatter and the shared test corpus). +/// +/// `eleven_v3` models must use the audio-tag dialect instead +/// (`Platform::ElevenLabsV3`): they do not parse `` at all. +pub struct ElevenLabsFormatter { + #[allow(dead_code)] + preserve_empty_lines: bool, +} + +impl ElevenLabsFormatter { + pub fn new(_options: FormatterOptions) -> Self { + Self { + preserve_empty_lines: true, + } + } + + fn map_strength_to_time(strength: &str) -> String { + let normalized = strength.trim().to_lowercase(); + BREAK_STRENGTH_TO_DURATION + .iter() + .find(|(k, _)| *k == normalized) + .map(|(_, v)| (*v).to_string()) + .unwrap_or_else(|| DEFAULT_BREAK_DURATION.to_string()) + } + + /// Strip the `[` / `]` the parser keeps in `ShortBreak::text` + /// (e.g. "[2s]" → "2s"). + fn break_time_from_text(text: &str) -> &str { + text.trim_start_matches('[').trim_end_matches(']') + } + + fn format_node_internal(&self, node: &AstNode, out: &mut String) -> Result<()> { + match node.node_type { + NodeType::Document => { + for child in &node.children { + self.format_node_internal(child, out)?; + } + } + + // Sections have no pre-v3 equivalent: the marker itself is + // dropped and the section's content flows as plain text. + NodeType::Section => {} + + NodeType::PlainText + | NodeType::PlainTextSpecialChars + | NodeType::PlainTextEmphasis + | NodeType::SimpleLine + | NodeType::Paragraph => { + out.push_str(&node.text); + for child in &node.children { + self.format_node_internal(child, out)?; + } + } + + NodeType::EmptyLine => { + if self.preserve_empty_lines { + out.push('\n'); + } + } + + NodeType::ShortBreak => { + let time = Self::break_time_from_text(&node.text); + out.push_str(&format!("", time)); + } + + NodeType::Break => { + let strength = node + .attributes + .get("strength") + .unwrap_or(&node.text) + .clone(); + let time = Self::map_strength_to_time(&strength); + out.push_str(&format!("", time)); + } + + NodeType::ShortEmphasisModerate + | NodeType::ShortEmphasisStrong + | NodeType::ShortEmphasisNone + | NodeType::ShortEmphasisReduced => { + out.push_str(&node.text); + } + + NodeType::TextModifier => { + let phoneme = node.attributes.get("ipa"); + if let Some(ph) = phoneme.filter(|ph| !ph.is_empty()) { + out.push_str(&format!( + "{}", + ph, node.text + )); + } else { + out.push_str(&node.text); + } + } + + NodeType::ShortIpa => { + let phoneme = node.attributes.get("phoneme"); + if let Some(ph) = phoneme.filter(|ph| !ph.is_empty()) { + out.push_str(&format!( + "{}", + ph, node.text + )); + } else { + out.push_str(&node.text); + } + } + + NodeType::BareIpa => { + // A bare `/ipa/` has no display word; keep the phoneme + // characters as text (reference-formatter behavior). + if let Some(ph) = node.attributes.get("ph") { + out.push_str(ph); + } else { + out.push_str(&node.text); + } + } + + NodeType::ShortSub => { + out.push_str(&node.text); + } + + // No pre-v3 equivalent: drop rather than speak a URL or + // emit markup the model would read aloud. + NodeType::Audio | NodeType::Mark => {} + + NodeType::Expressive => { + out.push_str(&format!("[{}]", node.text)); + } + + // Modifier node types never appear standalone from the parser. + _ => {} + } + Ok(()) + } +} + +impl Formatter for ElevenLabsFormatter { + fn format(&self, ast: &AstNode) -> Result { + let mut out = String::new(); + self.format_node_internal(ast, &mut out)?; + Ok(out) + } + + fn format_node(&self, node: &AstNode) -> Result { + let mut out = String::new(); + self.format_node_internal(node, &mut out)?; + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::formatters::base::Platform; + use crate::parser::SpeechMarkdownParser; + + fn to_elevenlabs(input: &str) -> String { + SpeechMarkdownParser::to_ssml(input, Platform::ElevenLabs).unwrap() + } + + #[test] + fn short_break_becomes_break_tag() { + assert_eq!( + to_elevenlabs("Sample [3s] speech [250ms] markdown"), + "Sample speech markdown" + ); + } + + #[test] + fn quoted_break_time_becomes_break_tag() { + assert_eq!( + to_elevenlabs("Sample [break:\"3s\"] speech [break:'250ms'] markdown"), + "Sample speech markdown" + ); + } + + #[test] + fn break_strength_maps_to_duration() { + assert_eq!( + to_elevenlabs("[break:\"medium\"]"), + "" + ); + assert_eq!( + to_elevenlabs("[break:\"x-strong\"]"), + "" + ); + assert_eq!(to_elevenlabs("[break:\"none\"]"), ""); + // Unknown strength falls back to medium duration. + assert_eq!(to_elevenlabs("[break:\"bogus\"]"), ""); + } + + #[test] + fn no_speak_wrapper_and_no_escaping() { + assert_eq!( + to_elevenlabs("1 < 2 & 3 > 0 \"yes\" 'no'"), + "1 < 2 & 3 > 0 \"yes\" 'no'" + ); + } + + #[test] + fn empty_modifier_list_strips_to_text() { + assert_eq!(to_elevenlabs("Some (text)[]"), "Some text"); + } + + #[test] + fn unsupported_modifiers_degrade_to_text() { + assert_eq!( + to_elevenlabs("(read this)[rate:\"fast\";volume:\"loud\"]"), + "read this" + ); + assert_eq!(to_elevenlabs("++important++"), "important"); + assert_eq!(to_elevenlabs("(hello)[voice:\"Brian\"]"), "hello"); + } + + #[test] + fn ipa_modifier_emits_phoneme_tag() { + assert_eq!( + to_elevenlabs("(piccolo)[ipa:\"pɪkəloʊ\"]"), + "piccolo" + ); + } + + #[test] + fn short_ipa_emits_phoneme_tag() { + assert_eq!( + to_elevenlabs("(speech)/spitʃ/"), + "speech" + ); + } + + #[test] + fn expressive_tags_pass_through() { + assert_eq!( + to_elevenlabs("He [laugh] and then [applause] left"), + "He [laugh] and then [applause] left" + ); + } + + #[test] + fn sections_are_dropped_content_flows() { + assert_eq!(to_elevenlabs("#[excited] Hello world"), " Hello world"); + } + + #[test] + fn sub_keeps_display_text() { + assert_eq!(to_elevenlabs("{AL}aluminum"), "AL"); + } + + #[test] + fn audio_and_mark_are_dropped() { + // Dropped nodes leave the surrounding spacing in place. + assert_eq!( + to_elevenlabs("Hello [mark:chapter1] ![sfx](\"https://x/y.mp3\") world"), + "Hello world" + ); + } +} diff --git a/src/formatters/mod.rs b/src/formatters/mod.rs index 2017083..864d3a5 100644 --- a/src/formatters/mod.rs +++ b/src/formatters/mod.rs @@ -1,4 +1,5 @@ pub mod base; +pub mod elevenlabs; pub mod ssml; pub mod text; diff --git a/src/parser/parser.rs b/src/parser/parser.rs index ca55324..1b3f181 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -560,7 +560,12 @@ impl SpeechMarkdownParser { } fn is_time_break(s: &str) -> bool { - s.ends_with("s") || s.ends_with("ms") + let Some(body) = s.strip_suffix("ms").or_else(|| s.strip_suffix('s')) else { + return false; + }; + !body.is_empty() + && body.chars().any(|c| c.is_ascii_digit()) + && body.chars().all(|c| c.is_ascii_digit() || c == '.') } fn is_expressive_tag(s: &str) -> bool { diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 414a3c4..7b3866c 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -116,6 +116,23 @@ fn test_all_test_cases() { } } + // Test prompt markup output for ElevenLabs (pre-v3 dialect) + let elevenlabs_file = test_dir.join(format!("{}.elevenlabs.ssml", test_name)); + if elevenlabs_file.exists() { + let expected = fs::read_to_string(&elevenlabs_file).unwrap_or_else(|_| { + panic!("Failed to read ElevenLabs file: {:?}", elevenlabs_file) + }); + + let result = SpeechMarkdownParser::to_ssml(&input, Platform::ElevenLabs); + if result.is_err() { + all_checks_passed = false; + } else if let Ok(actual) = result { + if actual.trim() != normalize_line_endings(expected.trim()) { + all_checks_passed = false; + } + } + } + all_checks_passed } Err(_e) => false, From 5e9e4d8ee881b2dd2fd72ccb6a506aaf1bd49f1a Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:18:19 +0100 Subject: [PATCH 3/8] feat(elevenlabs): v3 audio-tag dialect New Platform::ElevenLabsV3 ("elevenlabs-v3") formatter for eleven_v3 / eleven_v3_conversational, which parse no SSML at all. SpeechMarkdown constructs map to Eleven's native direction mechanisms: - breaks -> ... / [short pause] / [pause] / [long pause] (three approximate steps; v3 has no exact durations) - emphasis -> [emphasized] / [stress on next word] / [understated] prefix tags (never CAPS-mutates user text) - whisper/excited/disappointed and rate/volume modifiers -> [whispers]/[excited]/[rushed]/[drawn out]/[softly]/[loudly] - IPA -> native " slash form; sub speaks the alias - #[style] sections -> verbatim prefix tags (v3 tags are open-ended natural language); audio/mark dropped capabilities.rs rewritten for both dialects to match the documented per-model truth: pre-v3 = break (<=3s) + flash-only phoneme; v3 = audio tags + inline IPA, everything else unsupported with pointers to API-level alternatives (voice_id, voice_settings.speed). --- src/capabilities.rs | 197 +++++++++++++--- src/formatters/base.rs | 10 + src/formatters/elevenlabs_v3.rs | 401 ++++++++++++++++++++++++++++++++ src/formatters/mod.rs | 1 + 4 files changed, 572 insertions(+), 37 deletions(-) create mode 100644 src/formatters/elevenlabs_v3.rs diff --git a/src/capabilities.rs b/src/capabilities.rs index b3bdbd2..6e5751d 100644 --- a/src/capabilities.rs +++ b/src/capabilities.rs @@ -26,6 +26,7 @@ pub fn get_supported_ssml(platform: Platform) -> PlatformCapabilities { Platform::W3c => w3c_capabilities(), Platform::SamsungBixby => samsung_bixby_capabilities(), Platform::ElevenLabs => elevenlabs_capabilities(), + Platform::ElevenLabsV3 => elevenlabs_v3_capabilities(), Platform::IbmWatson => ibm_watson_capabilities(), } } @@ -396,24 +397,127 @@ fn samsung_bixby_capabilities() -> PlatformCapabilities { } fn elevenlabs_capabilities() -> PlatformCapabilities { + // Pre-v3 prompt markup (eleven_multilingual_v2 / flash_v2_5 / + // flash_v2 / turbo_v2): no SSML document, but two inline tags are + // parsed. Everything else degrades to plain text. PlatformCapabilities { platform: "elevenlabs".into(), ssml_elements: vec![ - break_element(), - prosody_element(), - audio_element(), - phoneme_element(), - mark_element(), - say_as_element("characters", "characters"), - say_as_element("number", "number"), - say_as_element("date", "date"), - say_as_element("time", "time"), + SsmlCapability { + element: "break".into(), + description: "Insert a pause (pre-v3 models only; max 3s; \ + eleven_v3 does not parse break tags)" + .into(), + attributes: vec!["time".into()], + speech_markdown_syntax: vec![ + "[2s]".into(), + "[500ms]".into(), + "[break:strong]".into(), + ], + example: "Hello [2s] world".into(), + }, + SsmlCapability { + element: "phoneme".into(), + description: "Custom pronunciation (eleven_flash_v2 / \ + eleven_turbo_v2 only, English only)" + .into(), + attributes: vec!["alphabet".into(), "ph".into()], + speech_markdown_syntax: vec![ + "(text)[ipa:\"pɪkəloʊ\"]".into(), + "(text)/pɪkəloʊ/".into(), + ], + example: "(piccolo)/pɪkəloʊ/".into(), + }, ], unsupported: vec![ - "emphasis".into(), - "voice".into(), - "lang".into(), - "sub".into(), + "emphasis (no equivalent; degrades to text)".into(), + "prosody (rate maps to the API voice_settings.speed; use the \ + elevenlabs-v3 dialect for tag-based control)" + .into(), + "say-as (text normalization is built in)".into(), + "sub (use pronunciation dictionaries or phonetic spelling)".into(), + "audio (no equivalent)".into(), + "mark (no equivalent)".into(), + "voice (switch via the API voice_id parameter)".into(), + "lang (no equivalent)".into(), + "audio tags (eleven_v3 only)".into(), + "amazon:effect".into(), + "amazon:emotion".into(), + "amazon:domain".into(), + "mstts:express-as".into(), + "google:style".into(), + ], + } +} + +fn elevenlabs_v3_capabilities() -> PlatformCapabilities { + // Eleven v3 audio-tag dialect: no SSML at all. Bracketed + // natural-language tags are prompts interpreted by the model — + // best-effort, voice-dependent, and directional from their insertion + // point (no span semantics). + PlatformCapabilities { + platform: "elevenlabs-v3".into(), + ssml_elements: vec![ + SsmlCapability { + element: "[audio tag]".into(), + description: "Natural-language performance direction in \ + brackets; open-ended (emotions, delivery, \ + reactions, sound effects, accents)" + .into(), + attributes: vec![], + speech_markdown_syntax: vec![ + "[laugh]".into(), + "#[excited] text".into(), + "(text)[whisper]".into(), + ], + example: "[whispers] It's a secret".into(), + }, + SsmlCapability { + element: "[pause] family".into(), + description: "Pauses via [short pause] / [pause] / [long \ + pause] or ellipses; approximate — no exact \ + durations on v3" + .into(), + attributes: vec![], + speech_markdown_syntax: vec![ + "[500ms]".into(), + "[2s]".into(), + "[break:strong]".into(), + ], + example: "Hello [2s] world".into(), + }, + SsmlCapability { + element: "inline IPA".into(), + description: "Native IPA wrapped in quotes and slashes".into(), + attributes: vec![], + speech_markdown_syntax: vec!["(speech)/spitʃ/".into()], + example: "(speech)/spitʃ/".into(), + }, + SsmlCapability { + element: "emphasis tags".into(), + description: "Emphasis via [emphasized] / [stress on next \ + word] / [understated] (capitalization also \ + works but mutates the text)" + .into(), + attributes: vec![], + speech_markdown_syntax: vec!["++word++".into(), "+word+".into()], + example: "++important++".into(), + }, + ], + unsupported: vec![ + "break (eleven_v3 does not parse SSML break tags)".into(), + "phoneme (use inline IPA)".into(), + "prosody (rate maps to the API voice_settings.speed; \ + tempo/volume map to [rushed]/[drawn out]/[softly]/[loudly] tags)" + .into(), + "say-as (text normalization is built in)".into(), + "sub (alias is spoken instead of the text)".into(), + "audio (no equivalent)".into(), + "mark (no equivalent)".into(), + "voice (switch via the API voice_id parameter or Text to \ + Dialogue)" + .into(), + "lang (no equivalent)".into(), "amazon:effect".into(), "amazon:emotion".into(), "amazon:domain".into(), @@ -466,24 +570,64 @@ mod tests { Platform::W3c, Platform::SamsungBixby, Platform::ElevenLabs, + Platform::ElevenLabsV3, Platform::IbmWatson, ] { let caps = get_supported_ssml(platform); - assert!(!caps.ssml_elements.is_empty(), "{:?} has no elements", platform); + assert!( + !caps.ssml_elements.is_empty(), + "{:?} has no elements", + platform + ); assert!(!caps.platform.is_empty()); } } + #[test] + fn test_all_platforms_have_break() { + for platform in [ + Platform::AmazonAlexa, + Platform::GoogleAssistant, + Platform::MicrosoftAzure, + Platform::Apple, + Platform::W3c, + Platform::SamsungBixby, + Platform::ElevenLabs, + Platform::IbmWatson, + ] { + let caps = get_supported_ssml(platform); + assert!( + caps.ssml_elements.iter().any(|e| e.element == "break"), + "{:?} missing break", + platform + ); + } + + // Eleven v3 does not parse at all; pauses are audio tags. + let v3 = get_supported_ssml(Platform::ElevenLabsV3); + assert!(v3 + .ssml_elements + .iter() + .any(|e| e.element == "[pause] family")); + assert!(v3.unsupported.iter().any(|u| u.starts_with("break"))); + } + #[test] fn test_alexa_has_emotion() { let caps = get_supported_ssml(Platform::AmazonAlexa); - assert!(caps.ssml_elements.iter().any(|e| e.element == "amazon:emotion")); + assert!(caps + .ssml_elements + .iter() + .any(|e| e.element == "amazon:emotion")); } #[test] fn test_azure_has_express_as() { let caps = get_supported_ssml(Platform::MicrosoftAzure); - assert!(caps.ssml_elements.iter().any(|e| e.element == "mstts:express-as")); + assert!(caps + .ssml_elements + .iter() + .any(|e| e.element == "mstts:express-as")); } #[test] @@ -506,25 +650,4 @@ mod tests { let deserialized: PlatformCapabilities = serde_json::from_str(&json).unwrap(); assert_eq!(caps, deserialized); } - - #[test] - fn test_all_platforms_have_break() { - for platform in [ - Platform::AmazonAlexa, - Platform::GoogleAssistant, - Platform::MicrosoftAzure, - Platform::Apple, - Platform::W3c, - Platform::SamsungBixby, - Platform::ElevenLabs, - Platform::IbmWatson, - ] { - let caps = get_supported_ssml(platform); - assert!( - caps.ssml_elements.iter().any(|e| e.element == "break"), - "{:?} missing break", - platform - ); - } - } } diff --git a/src/formatters/base.rs b/src/formatters/base.rs index 13394e7..a50cf5c 100644 --- a/src/formatters/base.rs +++ b/src/formatters/base.rs @@ -12,6 +12,9 @@ pub enum Platform { W3c, SamsungBixby, ElevenLabs, + /// Eleven v3 audio-tag dialect (eleven_v3 / eleven_v3_conversational): + /// bracketed natural-language tags instead of SSML. + ElevenLabsV3, IbmWatson, } @@ -26,6 +29,7 @@ impl Platform { "w3c" => Some(Platform::W3c), "samsung-bixby" | "bixby" => Some(Platform::SamsungBixby), "elevenlabs" => Some(Platform::ElevenLabs), + "elevenlabs-v3" | "elevenlabs_v3" | "eleven-v3" => Some(Platform::ElevenLabsV3), "ibm-watson" | "watson" => Some(Platform::IbmWatson), _ => None, } @@ -41,6 +45,7 @@ impl Platform { Platform::W3c => "w3c", Platform::SamsungBixby => "samsung-bixby", Platform::ElevenLabs => "elevenlabs", + Platform::ElevenLabsV3 => "elevenlabs-v3", Platform::IbmWatson => "ibm-watson", } } @@ -110,6 +115,11 @@ pub fn create_formatter(platform: Platform, options: FormatterOptions) -> Box { + // Eleven v3 audio-tag dialect: no SSML at all — bracketed + // natural-language tags, punctuation pauses, native slash IPA. + Box::new(super::elevenlabs_v3::ElevenLabsV3Formatter::new(options)) + } _ => Box::new(super::TextFormatter::new()), } } diff --git a/src/formatters/elevenlabs_v3.rs b/src/formatters/elevenlabs_v3.rs new file mode 100644 index 0000000..b42115f --- /dev/null +++ b/src/formatters/elevenlabs_v3.rs @@ -0,0 +1,401 @@ +use crate::ast::{AstNode, NodeType}; +use crate::error::Result; +use crate::formatters::base::{Formatter, FormatterOptions}; + +/// Eleven v3 audio-tag dialect for `eleven_v3` / `eleven_v3_conversational`. +/// +/// Eleven v3 does not parse SSML — no ``, no ``. Delivery is +/// directed with bracketed natural-language audio tags (`[whispers]`, +/// `[laughs]`, `[pause]`), punctuation (`...`, em-dash) and capitalization. +/// Tags are open-ended prompts interpreted by the model, not an enum: they +/// are best-effort and voice-dependent, and they directionally apply from +/// their insertion point onward (there is no guaranteed "end tag" span). +/// +/// Mapping notes: +/// - Breaks lose temporal precision: three steps plus punctuation +/// (`...` / `[short pause]` / `[pause]` / `[long pause]`). +/// - Emphasis avoids mutating the user's words (no CAPS by default). +/// - IPA is emitted in v3's native `"/…/"` slash form. +/// - `#[style]` sections become prefix tags; unknown styles pass through +/// verbatim (v3 treats any bracketed cue as direction). +pub struct ElevenLabsV3Formatter { + #[allow(dead_code)] + preserve_empty_lines: bool, +} + +/// Break strength → v3 pause tag. No temporal precision available. +const BREAK_STRENGTH_TO_TAG: &[(&str, &str)] = &[ + ("none", ""), + ("x-weak", "..."), + ("weak", "[short pause]"), + ("medium", "[pause]"), + ("strong", "[long pause]"), + ("x-strong", "[long pause]"), +]; + +const DEFAULT_PAUSE_TAG: &str = "[pause]"; + +impl ElevenLabsV3Formatter { + pub fn new(_options: FormatterOptions) -> Self { + Self { + preserve_empty_lines: true, + } + } + + /// Parse "2", "0.25", "250ms", "1.5s", … into seconds. + fn parse_seconds(text: &str) -> Option { + let body = text + .strip_suffix("ms") + .or_else(|| text.strip_suffix('s')) + .unwrap_or(text); + let value: f64 = body.parse().ok()?; + let scale = if text.ends_with("ms") { 0.001 } else { 1.0 }; + Some(value * scale) + } + + fn pause_tag_for_seconds(secs: f64) -> &'static str { + if secs < 0.4 { + "..." + } else if secs <= 1.0 { + "[pause]" + } else { + "[long pause]" + } + } + + fn strength_to_tag(strength: &str) -> &'static str { + let normalized = strength.trim().to_lowercase(); + BREAK_STRENGTH_TO_TAG + .iter() + .find(|(k, _)| *k == normalized) + .map(|(_, v)| *v) + .unwrap_or(DEFAULT_PAUSE_TAG) + } + + fn break_time_from_text(text: &str) -> &str { + text.trim_start_matches('[').trim_end_matches(']') + } + + /// Map a modifier key/value pair to a prefix audio tag (or None to + /// degrade to plain text). IPA is handled separately by the caller + /// because it replaces the text instead of prefixing it. + fn modifier_to_tag(key: &str, value: &str) -> Option { + let value = value.trim().to_lowercase(); + match key.to_lowercase().as_str() { + "whisper" => Some("[whispers]".to_string()), + "excited" => Some("[excited]".to_string()), + "disappointed" => Some("[disappointed]".to_string()), + "rate" => match value.as_str() { + "x-slow" | "slow" => Some("[drawn out]".to_string()), + "fast" | "x-fast" => Some("[rushed]".to_string()), + _ => None, + }, + "volume" | "vol" => match value.as_str() { + "x-soft" | "soft" | "quiet" => Some("[softly]".to_string()), + "x-loud" | "loud" => Some("[loudly]".to_string()), + _ => None, + }, + "emphasis" => match value.as_str() { + "strong" => Some("[emphasized]".to_string()), + "moderate" => Some("[stress on next word]".to_string()), + "reduced" => Some("[understated]".to_string()), + _ => None, + }, + _ => None, + } + } + + fn format_node_internal(&self, node: &AstNode, out: &mut String) -> Result<()> { + match node.node_type { + NodeType::Document => { + // Sections prefix the content that follows them (until the + // next section); there is no closing form on v3. + let mut iter = node.children.iter().peekable(); + while let Some(child) = iter.next() { + if child.node_type == NodeType::Section { + let mut section_content = String::new(); + while let Some(next) = iter.peek() { + if next.node_type == NodeType::Section { + break; + } + let next = iter.next().unwrap(); + self.format_node_internal(next, &mut section_content)?; + } + out.push_str(&self.section_prefix(child)); + out.push_str(§ion_content); + } else { + self.format_node_internal(child, out)?; + } + } + } + + NodeType::PlainText + | NodeType::PlainTextSpecialChars + | NodeType::PlainTextEmphasis + | NodeType::SimpleLine + | NodeType::Paragraph => { + out.push_str(&node.text); + for child in &node.children { + self.format_node_internal(child, out)?; + } + } + + NodeType::EmptyLine => { + if self.preserve_empty_lines { + out.push('\n'); + } + } + + NodeType::ShortBreak => { + let time = Self::break_time_from_text(&node.text); + let tag = Self::parse_seconds(time) + .map(Self::pause_tag_for_seconds) + .unwrap_or(DEFAULT_PAUSE_TAG); + out.push_str(tag); + } + + NodeType::Break => { + let strength = node + .attributes + .get("strength") + .unwrap_or(&node.text) + .clone(); + out.push_str(Self::strength_to_tag(&strength)); + } + + NodeType::ShortEmphasisStrong => { + out.push_str("[emphasized] "); + out.push_str(&node.text); + } + NodeType::ShortEmphasisModerate => { + out.push_str("[stress on next word] "); + out.push_str(&node.text); + } + NodeType::ShortEmphasisReduced => { + out.push_str("[understated] "); + out.push_str(&node.text); + } + NodeType::ShortEmphasisNone => { + out.push_str(&node.text); + } + + NodeType::TextModifier => { + // IPA replaces the text; other recognized modifiers + // become prefix tags in declaration order. + if let Some(ph) = node.attributes.get("ipa").filter(|v| !v.is_empty()) { + out.push_str(&format!("\"/{}/\"", ph)); + } else if let Some(alias) = node.attributes.get("sub").filter(|v| !v.is_empty()) { + // Substitution: speak the alias instead of the text. + out.push_str(alias); + } else { + let mut tags: Vec = Vec::new(); + for key in &node.attribute_keys { + let value = node.attributes.get(key).map(String::as_str).unwrap_or(""); + if let Some(tag) = Self::modifier_to_tag(key, value) { + tags.push(tag); + } + } + for tag in &tags { + out.push_str(tag); + out.push(' '); + } + out.push_str(&node.text); + } + } + + NodeType::ShortIpa => { + // v3 native IPA replaces the word with "/phoneme/". + if let Some(ph) = node.attributes.get("phoneme").filter(|v| !v.is_empty()) { + out.push_str(&format!("\"/{}/\"", ph)); + } else { + out.push_str(&node.text); + } + } + + NodeType::BareIpa => { + if let Some(ph) = node.attributes.get("ph") { + out.push_str(&format!("\"/{}/\"", ph)); + } else { + out.push_str(&node.text); + } + } + + NodeType::ShortSub => { + // Speak the alias (the intended spoken form) when present. + if let Some(alias) = node.attributes.get("alias").filter(|v| !v.is_empty()) { + out.push_str(alias); + } else { + out.push_str(&node.text); + } + } + + NodeType::Audio | NodeType::Mark => {} + + NodeType::Expressive => { + out.push_str(&format!("[{}]", node.text)); + } + + NodeType::Section => { + // Handled by the document walk; standalone formatting of a + // section still emits its prefix tags. + out.push_str(&self.section_prefix(node)); + } + + // Modifier node types never appear standalone from the parser. + _ => {} + } + Ok(()) + } + + /// Prefix tags for a `#[…]` section: the bare style passes through as a + /// natural-language tag; recognized modifier keys map to tempo/volume + /// tags; everything else is dropped (no v3 equivalent). + fn section_prefix(&self, node: &AstNode) -> String { + let mut tags: Vec = Vec::new(); + + if let Some(style) = node.attributes.get("style") { + if style != "defaults" && !style.is_empty() { + tags.push(format!("[{}]", style)); + } + } + + for key in &node.attribute_keys { + if key == "style" { + continue; + } + let value = node.attributes.get(key).map(String::as_str).unwrap_or(""); + if let Some(tag) = Self::modifier_to_tag(key, value) { + tags.push(tag); + } + } + + if tags.is_empty() { + String::new() + } else { + format!("{} ", tags.join(" ")) + } + } +} + +impl Formatter for ElevenLabsV3Formatter { + fn format(&self, ast: &AstNode) -> Result { + let mut out = String::new(); + self.format_node_internal(ast, &mut out)?; + Ok(out) + } + + fn format_node(&self, node: &AstNode) -> Result { + let mut out = String::new(); + self.format_node_internal(node, &mut out)?; + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::formatters::base::Platform; + use crate::parser::SpeechMarkdownParser; + + fn to_v3(input: &str) -> String { + SpeechMarkdownParser::to_ssml(input, Platform::ElevenLabsV3).unwrap() + } + + #[test] + fn short_breaks_map_to_pause_steps() { + assert_eq!(to_v3("Sample [250ms] speech"), "Sample ... speech"); + assert_eq!(to_v3("Sample [0.5s] speech"), "Sample [pause] speech"); + assert_eq!(to_v3("Sample [2s] speech"), "Sample [long pause] speech"); + } + + #[test] + fn break_strengths_map_to_pause_tags() { + assert_eq!(to_v3("[break:\"none\"]"), ""); + assert_eq!(to_v3("[break:\"x-weak\"]"), "..."); + assert_eq!(to_v3("[break:\"weak\"]"), "[short pause]"); + assert_eq!(to_v3("[break:\"medium\"]"), "[pause]"); + assert_eq!(to_v3("[break:\"strong\"]"), "[long pause]"); + assert_eq!(to_v3("[break:\"x-strong\"]"), "[long pause]"); + assert_eq!(to_v3("[break:\"bogus\"]"), "[pause]"); + } + + #[test] + fn emphasis_maps_to_tags_not_caps() { + assert_eq!(to_v3("very ++important++"), "very [emphasized] important"); + assert_eq!( + to_v3("a +little+ bit"), + "a [stress on next word] little bit" + ); + assert_eq!(to_v3("a -little- bit"), "a [understated] little bit"); + assert_eq!(to_v3("~whatever~"), "whatever"); + } + + #[test] + fn whisper_and_emotion_modifiers_prefix_tags() { + assert_eq!( + to_v3("(it's a secret)[whisper]"), + "[whispers] it's a secret" + ); + assert_eq!(to_v3("(great news)[excited]"), "[excited] great news"); + } + + #[test] + fn rate_and_volume_map_to_tempo_tags() { + assert_eq!( + to_v3("(read this)[rate:\"fast\";volume:\"loud\"]"), + "[rushed] [loudly] read this" + ); + assert_eq!(to_v3("(slowly)[rate:\"slow\"]"), "[drawn out] slowly"); + } + + #[test] + fn unsupported_modifiers_degrade_to_text() { + assert_eq!(to_v3("(hello)[voice:\"Brian\"]"), "hello"); + assert_eq!(to_v3("(bonjour)[lang:\"fr-FR\"]"), "bonjour"); + assert_eq!(to_v3("(42)[number]"), "42"); + } + + #[test] + fn ipa_becomes_native_slash_form() { + assert_eq!(to_v3("(speech)/spitʃ/"), "\"/spitʃ/\""); + assert_eq!(to_v3("(word)[ipa:\"wɜːd\"]"), "\"/wɜːd/\""); + } + + #[test] + fn sub_speaks_alias() { + assert_eq!(to_v3("{AL}aluminum"), "aluminum"); + } + + #[test] + fn sections_become_prefix_tags() { + assert_eq!(to_v3("#[excited] Hello world"), "[excited] Hello world"); + // Unknown styles pass through as natural-language direction. + assert_eq!(to_v3("#[sarcastic] nice"), "[sarcastic] nice"); + // Recognized section modifiers map like inline ones. + assert_eq!(to_v3("#[rate:\"slow\"] steady"), "[drawn out] steady"); + // defaults produces nothing. + assert_eq!(to_v3("#[defaults] plain"), " plain"); + } + + #[test] + fn expressive_tags_pass_through() { + assert_eq!( + to_v3("He [laugh] and then [applause] left"), + "He [laugh] and then [applause] left" + ); + } + + #[test] + fn audio_and_mark_are_dropped() { + // Dropped nodes leave the surrounding spacing in place. + assert_eq!( + to_v3("Hello [mark:chapter1] ![sfx](\"https://x/y.mp3\") world"), + "Hello world" + ); + } + + #[test] + fn no_speak_wrapper_and_no_escaping() { + assert_eq!(to_v3("1 < 2 & 3 > 0"), "1 < 2 & 3 > 0"); + } +} diff --git a/src/formatters/mod.rs b/src/formatters/mod.rs index 864d3a5..e35ec69 100644 --- a/src/formatters/mod.rs +++ b/src/formatters/mod.rs @@ -1,5 +1,6 @@ pub mod base; pub mod elevenlabs; +pub mod elevenlabs_v3; pub mod ssml; pub mod text; From 7bb3eb6ea5bcd99d1832915c1613b6e62877ba1b Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:18:30 +0100 Subject: [PATCH 4/8] docs: document the two ElevenLabs dialects and their model requirements --- README.md | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f5a185d..730548c 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,24 @@ You can safely ignore Swift-specific files. Your respective package managers (Ca | Apple | `"apple"` | | W3C | `"w3c"` | | Samsung Bixby | `"samsung-bixby"` or `"bixby"` | -| ElevenLabs | `"elevenlabs"` | +| ElevenLabs (pre-v3) | `"elevenlabs"` | +| ElevenLabs v3 | `"elevenlabs-v3"` | | IBM Watson | `"ibm-watson"` or `"watson"` | +### ElevenLabs note + +ElevenLabs does not parse SSML documents, so both ElevenLabs platforms emit +prompt markup rather than SSML, and the correct dialect depends on the model: + +- `"elevenlabs"` — pre-v3 models (`eleven_multilingual_v2`, `eleven_flash_v2_5`, + `eleven_flash_v2`, `eleven_turbo_v2`): `` pauses (max 3s) + and `` for IPA (flash/turbo, English only). No `` wrapper. +- `"elevenlabs-v3"` — `eleven_v3` / `eleven_v3_conversational`: bracketed + natural-language audio tags (`[whispers]`, `[excited]`, `[pause]`, + `[long pause]`, `[laughs]`), punctuation pauses, and native `"/IPA/"`. + Audio tags are model-interpreted direction — best-effort, voice-dependent — + and are not understood by pre-v3 models. + ## API All bindings expose the same core methods: From b93a222abc879858325916fbf5bab068bb13a4e1 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:26:44 +0100 Subject: [PATCH 5/8] fix(bindings): dotnet ElevenLabs platform string never parsed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The constant said "eleven-labs" but Platform::from_platform_str only accepts "elevenlabs" — every dotnet ElevenLabs conversion failed as unsupported. Also adds the ElevenLabsV3 ("elevenlabs-v3") constant. --- bindings/dotnet/SpeechMarkdown.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bindings/dotnet/SpeechMarkdown.cs b/bindings/dotnet/SpeechMarkdown.cs index d1c5840..c149fa6 100644 --- a/bindings/dotnet/SpeechMarkdown.cs +++ b/bindings/dotnet/SpeechMarkdown.cs @@ -274,7 +274,9 @@ public static class Platform public const string Apple = "apple"; public const string W3c = "w3c"; public const string SamsungBixby = "samsung-bixby"; - public const string ElevenLabs = "eleven-labs"; + // Note: the core library parses "elevenlabs" (no hyphen). + public const string ElevenLabs = "elevenlabs"; + public const string ElevenLabsV3 = "elevenlabs-v3"; public const string IbmWatson = "ibm-watson"; } } From 791e05a431fe75a025d89cd0fc1921df57176655 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:26:51 +0100 Subject: [PATCH 6/8] docs(bindings): list elevenlabs-v3 in platform error hints (fix swift comment's eleven-labs) --- bindings/nodejs/src/lib.rs | 2 +- bindings/python/src/lib.rs | 2 +- bindings/swift/Sources/CSpeechMarkdown/include/speechmarkdown.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/bindings/nodejs/src/lib.rs b/bindings/nodejs/src/lib.rs index fbbaed3..3def523 100644 --- a/bindings/nodejs/src/lib.rs +++ b/bindings/nodejs/src/lib.rs @@ -5,7 +5,7 @@ use speechmarkdown_rust::{Platform, SpeechMarkdownParser}; fn parse_platform(platform: &str) -> Result { Platform::from_platform_str(platform).ok_or_else(|| { Error::from_reason(format!( - "unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, ibm-watson", + "unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, elevenlabs-v3, ibm-watson", platform )) }) diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 79172f4..4763e60 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -4,7 +4,7 @@ use speechmarkdown_rust::{Platform, SpeechMarkdownParser}; fn parse_platform(platform: &str) -> PyResult { Platform::from_platform_str(platform).ok_or_else(|| { pyo3::exceptions::PyValueError::new_err(format!( - "unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, ibm-watson", + "unsupported platform: '{}'. Use one of: amazon-alexa, google-assistant, microsoft-azure, apple, w3c, samsung-bixby, elevenlabs, elevenlabs-v3, ibm-watson", platform )) }) diff --git a/bindings/swift/Sources/CSpeechMarkdown/include/speechmarkdown.h b/bindings/swift/Sources/CSpeechMarkdown/include/speechmarkdown.h index d2e1da1..d84bee6 100644 --- a/bindings/swift/Sources/CSpeechMarkdown/include/speechmarkdown.h +++ b/bindings/swift/Sources/CSpeechMarkdown/include/speechmarkdown.h @@ -10,7 +10,7 @@ extern "C" { // Convert SpeechMarkdown input to SSML for the given platform. // Platforms: "amazon-alexa", "google-assistant", "microsoft-azure", -// "apple", "w3c", "samsung-bixby", "eleven-labs", "ibm-watson" +// "apple", "w3c", "samsung-bixby", "elevenlabs", "elevenlabs-v3", "ibm-watson" // Returns: allocated string with SSML, or NULL on error. // Caller must free with speechmarkdown_free(). const char* speechmarkdown_to_ssml(const char* input, const char* platform); From 413925f10e6715644e6d9052f0251f70c839ba62 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:49:58 +0100 Subject: [PATCH 7/8] =?UTF-8?q?fix:=20review=20feedback=20=E2=80=94=20clam?= =?UTF-8?q?p=20breaks=20to=203s,=20strict=20time=20parsing,=20zero-toleran?= =?UTF-8?q?ce=20corpus=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pre-v3 formatter clamps values above ElevenLabs' documented 3s limit (in-limit values keep the caller's unit formatting, matching the corpus fixtures' 3s/250ms) - is_time_break now rejects malformed numbers ([1.2.3s], [1..5s]) — at most one decimal point — closing the gap the previous fix left - phoneme attribute values escape double quotes (tag integrity; the prompt body stays unescaped by design) - corpus runner panics on ANY failure instead of a >50% threshold — the enforced fixture families are the regression net - both ElevenLabs formatters honor FormatterOptions.preserve_empty_lines (was hardcoded true behind a wrong #[allow(dead_code)]) --- src/formatters/elevenlabs.rs | 84 ++++++++++++++++++++++++++++++--- src/formatters/elevenlabs_v3.rs | 5 +- src/parser/parser.rs | 5 ++ tests/integration_test.rs | 14 +++--- 4 files changed, 93 insertions(+), 15 deletions(-) diff --git a/src/formatters/elevenlabs.rs b/src/formatters/elevenlabs.rs index 52b49a6..2da379b 100644 --- a/src/formatters/elevenlabs.rs +++ b/src/formatters/elevenlabs.rs @@ -16,6 +16,11 @@ const BREAK_STRENGTH_TO_DURATION: &[(&str, &str)] = &[ const DEFAULT_BREAK_DURATION: &str = "0.5s"; +/// ElevenLabs pre-v3 models accept pauses up to 3 seconds; longer +/// requested breaks are clamped (values beyond that are rejected or +/// destabilize the generation). +const MAX_BREAK_SECONDS: f64 = 3.0; + /// ElevenLabs prompt markup for the pre-v3 model family /// (`eleven_multilingual_v2`, `eleven_flash_v2_5`, `eleven_flash_v2`, /// `eleven_turbo_v2`). @@ -34,14 +39,34 @@ const DEFAULT_BREAK_DURATION: &str = "0.5s"; /// `eleven_v3` models must use the audio-tag dialect instead /// (`Platform::ElevenLabsV3`): they do not parse `` at all. pub struct ElevenLabsFormatter { - #[allow(dead_code)] preserve_empty_lines: bool, } impl ElevenLabsFormatter { - pub fn new(_options: FormatterOptions) -> Self { + pub fn new(options: FormatterOptions) -> Self { Self { - preserve_empty_lines: true, + preserve_empty_lines: options.preserve_empty_lines, + } + } + + /// Parse a duration like "2", "0.25", "250ms", "1.5s" into seconds. + fn parse_seconds(text: &str) -> Option { + let body = text + .strip_suffix("ms") + .or_else(|| text.strip_suffix('s')) + .unwrap_or(text); + let value: f64 = body.parse().ok()?; + let scale = if text.ends_with("ms") { 0.001 } else { 1.0 }; + Some(value * scale) + } + + /// Break time verbatim when within the documented 3s limit; clamped + /// to 3s beyond it (keeping the caller's unit formatting otherwise, + /// matching the shared corpus fixtures which use both "3s" and "250ms"). + fn clamp_break_time(time: &str) -> String { + match Self::parse_seconds(time) { + Some(secs) if secs > MAX_BREAK_SECONDS => format!("{MAX_BREAK_SECONDS}s"), + _ => time.to_string(), } } @@ -60,6 +85,13 @@ impl ElevenLabsFormatter { text.trim_start_matches('[').trim_end_matches(']') } + /// Escape a double-quoted attribute value. The prompt body stays + /// unescaped (ElevenLabs is not an XML document), but attribute + /// quotes would break the tag itself. + fn escape_attr(value: &str) -> String { + value.replace('"', """) + } + fn format_node_internal(&self, node: &AstNode, out: &mut String) -> Result<()> { match node.node_type { NodeType::Document => { @@ -90,7 +122,7 @@ impl ElevenLabsFormatter { } NodeType::ShortBreak => { - let time = Self::break_time_from_text(&node.text); + let time = Self::clamp_break_time(Self::break_time_from_text(&node.text)); out.push_str(&format!("", time)); } @@ -116,7 +148,8 @@ impl ElevenLabsFormatter { if let Some(ph) = phoneme.filter(|ph| !ph.is_empty()) { out.push_str(&format!( "{}", - ph, node.text + Self::escape_attr(ph), + node.text )); } else { out.push_str(&node.text); @@ -128,7 +161,8 @@ impl ElevenLabsFormatter { if let Some(ph) = phoneme.filter(|ph| !ph.is_empty()) { out.push_str(&format!( "{}", - ph, node.text + Self::escape_attr(ph), + node.text )); } else { out.push_str(&node.text); @@ -219,6 +253,44 @@ mod tests { assert_eq!(to_elevenlabs("[break:\"bogus\"]"), ""); } + #[test] + fn breaks_clamp_to_three_seconds() { + // Pre-v3 models accept at most 3s; longer values are rejected + // or destabilize the generation. + assert_eq!( + to_elevenlabs("Wait [10s] now"), + "Wait now" + ); + assert_eq!( + to_elevenlabs("Wait [3500ms] now"), + "Wait now" + ); + // Values within the limit keep the caller's unit formatting + // (the corpus fixtures pin both "3s" and "250ms"). + assert_eq!( + to_elevenlabs("Wait [250ms] now"), + "Wait now" + ); + } + + #[test] + fn malformed_break_numbers_are_not_breaks() { + // Not valid durations: plain text passthrough (matches the + // speechmarkdown-js grammar, which only accepts \d+(\.\d+)?(s|ms)). + for word in ["1.2.3s", "1..5s", "apps", "infs", "s"] { + let out = to_elevenlabs(&format!("x [{word}] y")); + assert_eq!(out, format!("x [{word}] y"), "word {word}"); + } + } + + #[test] + fn phoneme_attribute_quotes_are_escaped() { + assert_eq!( + to_elevenlabs("(x)[ipa:\"a\"b\"]"), + "x" + ); + } + #[test] fn no_speak_wrapper_and_no_escaping() { assert_eq!( diff --git a/src/formatters/elevenlabs_v3.rs b/src/formatters/elevenlabs_v3.rs index b42115f..df6743f 100644 --- a/src/formatters/elevenlabs_v3.rs +++ b/src/formatters/elevenlabs_v3.rs @@ -19,7 +19,6 @@ use crate::formatters::base::{Formatter, FormatterOptions}; /// - `#[style]` sections become prefix tags; unknown styles pass through /// verbatim (v3 treats any bracketed cue as direction). pub struct ElevenLabsV3Formatter { - #[allow(dead_code)] preserve_empty_lines: bool, } @@ -36,9 +35,9 @@ const BREAK_STRENGTH_TO_TAG: &[(&str, &str)] = &[ const DEFAULT_PAUSE_TAG: &str = "[pause]"; impl ElevenLabsV3Formatter { - pub fn new(_options: FormatterOptions) -> Self { + pub fn new(options: FormatterOptions) -> Self { Self { - preserve_empty_lines: true, + preserve_empty_lines: options.preserve_empty_lines, } } diff --git a/src/parser/parser.rs b/src/parser/parser.rs index 1b3f181..492e7e5 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -563,7 +563,12 @@ impl SpeechMarkdownParser { let Some(body) = s.strip_suffix("ms").or_else(|| s.strip_suffix('s')) else { return false; }; + // Strict numeric body: digits with at most one decimal point. + // Rejects words ending in 's' ("apps"), malformed numbers + // ("1.2.3s"), and non-finite spellings ("nan"/"inf" fail the + // character filter). !body.is_empty() + && body.chars().filter(|&c| c == '.').count() <= 1 && body.chars().any(|c| c.is_ascii_digit()) && body.chars().all(|c| c.is_ascii_digit() || c == '.') } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 7b3866c..353b799 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -160,14 +160,16 @@ fn test_all_test_cases() { for test in &failed_tests { println!(" - {}", test); } - } - - // Only panic if we have significant failures (>50% fail rate) - if failed > 0 && (failed as f64 / (passed + failed) as f64) > 0.5 { + // Zero tolerance: this suite is the regression net for the + // enforced fixture families (.txt, .alexa.ssml, .google.ssml, + // .elevenlabs.ssml). Any failure should fail CI — a lenient + // threshold would let a broken formatter slip through while + // enough unrelated cases still pass. panic!( - "Too many test failures: {}/{} failed", + "{}/{} corpus tests failed: {:?}", failed, - passed + failed + passed + failed, + failed_tests ); } } From a9c3a5e99fc314be3ad034a8829261486f3b7bf5 Mon Sep 17 00:00:00 2001 From: will wade Date: Fri, 4 Sep 2026 14:55:00 +0100 Subject: [PATCH 8/8] fix: is_time_break enforces the \d+(\.\d+)? grammar exactly Loop-2 review caught leading/trailing-dot forms ([5.s], [.5s], [0.s]) still parsing as breaks. The dot must now be surrounded by digits, matching the speechmarkdown-js grammar the code cites. Also drops two unused use-super imports flagged by clippy --all-targets. --- src/formatters/elevenlabs.rs | 15 +++++++++++++-- src/formatters/elevenlabs_v3.rs | 1 - src/parser/parser.rs | 26 ++++++++++++++++++-------- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/src/formatters/elevenlabs.rs b/src/formatters/elevenlabs.rs index 2da379b..53fea1e 100644 --- a/src/formatters/elevenlabs.rs +++ b/src/formatters/elevenlabs.rs @@ -214,7 +214,6 @@ impl Formatter for ElevenLabsFormatter { #[cfg(test)] mod tests { - use super::*; use crate::formatters::base::Platform; use crate::parser::SpeechMarkdownParser; @@ -277,7 +276,19 @@ mod tests { fn malformed_break_numbers_are_not_breaks() { // Not valid durations: plain text passthrough (matches the // speechmarkdown-js grammar, which only accepts \d+(\.\d+)?(s|ms)). - for word in ["1.2.3s", "1..5s", "apps", "infs", "s"] { + for word in [ + "1.2.3s", + "1..5s", + "apps", + "infs", + "s", + "5.s", + ".5s", + "0.s", + "-2s", + "+2s", + "1e3s", + ] { let out = to_elevenlabs(&format!("x [{word}] y")); assert_eq!(out, format!("x [{word}] y"), "word {word}"); } diff --git a/src/formatters/elevenlabs_v3.rs b/src/formatters/elevenlabs_v3.rs index df6743f..800965c 100644 --- a/src/formatters/elevenlabs_v3.rs +++ b/src/formatters/elevenlabs_v3.rs @@ -292,7 +292,6 @@ impl Formatter for ElevenLabsV3Formatter { #[cfg(test)] mod tests { - use super::*; use crate::formatters::base::Platform; use crate::parser::SpeechMarkdownParser; diff --git a/src/parser/parser.rs b/src/parser/parser.rs index 492e7e5..1e85678 100644 --- a/src/parser/parser.rs +++ b/src/parser/parser.rs @@ -563,14 +563,24 @@ impl SpeechMarkdownParser { let Some(body) = s.strip_suffix("ms").or_else(|| s.strip_suffix('s')) else { return false; }; - // Strict numeric body: digits with at most one decimal point. - // Rejects words ending in 's' ("apps"), malformed numbers - // ("1.2.3s"), and non-finite spellings ("nan"/"inf" fail the - // character filter). - !body.is_empty() - && body.chars().filter(|&c| c == '.').count() <= 1 - && body.chars().any(|c| c.is_ascii_digit()) - && body.chars().all(|c| c.is_ascii_digit() || c == '.') + // Strict numeric body matching the speechmarkdown-js grammar + // (\d+(\.\d+)?): digits, with an optional decimal point that must + // be surrounded by digits. Rejects words ending in 's' ("apps"), + // malformed numbers ("1.2.3s", "1..5s"), sign/exponent spellings + // ("+2s", "1e3s"), and bare/edge dots (".5s", "5.s", "0.s"). + let mut parts = body.split('.'); + match (parts.next(), parts.next(), parts.next()) { + (Some(int_part), None, None) => { + !int_part.is_empty() && int_part.bytes().all(|b| b.is_ascii_digit()) + } + (Some(int_part), Some(frac_part), None) => { + !int_part.is_empty() + && int_part.bytes().all(|b| b.is_ascii_digit()) + && !frac_part.is_empty() + && frac_part.bytes().all(|b| b.is_ascii_digit()) + } + _ => false, + } } fn is_expressive_tag(s: &str) -> bool {