diff --git a/examples/edge-speechmarkdown.rs b/examples/edge-speechmarkdown.rs new file mode 100644 index 0000000..bc5a0d7 --- /dev/null +++ b/examples/edge-speechmarkdown.rs @@ -0,0 +1,30 @@ +//! Live check: `SpeechMarkdown` through Edge, including [mark:] (whose Azure- +//! dialect output is , zero-audio on the free Edge endpoint unless +//! stripped) and emphasis. cargo run --no-default-features --features cloud +//! --example edge-speechmarkdown +use rust_tts_wrapper::factory::create_engine; + +fn main() { + let engine = create_engine("edge", "{}").expect("edge engine"); + for text in [ + "Plain text reference.", + "This is (very)[emphasis:\"strong\"] emphasised.", + "A (mark)[mark:\"m1\"] in speech markdown.", + ] { + let mut bytes = 0usize; + engine + .speak( + text, + Some("en-GB-SoniaNeural"), + 1.0, + 1.0, + 1.0, + Some(&mut |chunk: &[u8]| bytes += chunk.len()), + None, + ) + .unwrap_or_else(|e| panic!("{text}: {e}")); + println!("{text:?}: {bytes} bytes"); + assert!(bytes > 0, "{text}: no audio"); + } + println!("PASS"); +} diff --git a/src/cloud_engine.rs b/src/cloud_engine.rs index 1b09e1e..c0890e1 100644 --- a/src/cloud_engine.rs +++ b/src/cloud_engine.rs @@ -1193,18 +1193,25 @@ fn normalize_ssml_envelope(ssml: &str, voice: &str) -> String { format!("{lead}{open}{}", &trimmed[tag_end + 1..]) } -/// Remove `` elements from an SSML document for Azure/Edge. +/// Remove unsupported position-marking elements from an SSML document +/// for Azure/Edge. /// -/// Azure/Edge do not support the SSML `` element — an utterance +/// Neither service supports the W3C SSML `` element — an utterance /// containing one synthesises **zero audio**, again with no error from -/// the service. `` is an empty element (it only names a position), -/// so dropping it changes no spoken content; consumers that need the +/// the service. Azure proper documents its own `` +/// replacement element, but the **free Edge endpoint zero-audios on +/// `` too** (verified live), so Edge strips both while Azure +/// keeps bookmarks. Both are empty elements (they only name a position), +/// so dropping them changes no spoken content; consumers that need the /// positions should use word-boundary events. speech-dispatcher's /// wrapper injects `` around every pause, so /// pass-through SSML from SSIP clients hits this constantly. #[cfg(feature = "cloud")] -fn strip_unsupported_marks(ssml: &str) -> String { - if !ssml.contains(" String { + let has_marks = ssml.contains(" String { s.strip_prefix(prefix) .is_some_and(|tail| tail.starts_with([' ', '\t', '\r', '\n', '/', '>'])) }; - if name_done(after, "') { out.push_str(&rest[..pos]); rest = &after[end + 1..]; @@ -2003,11 +2014,16 @@ impl TtsEngine for CloudEngine { // XML-escape the tags). If the SSML lacks a tag but the // caller set one via tts_set_voice, inject it so the voice takes // effect. The envelope is completed first (a bare is - // accepted but synthesises zero audio) and unsupported - // elements are dropped (same silent zero-audio failure). + // accepted but synthesises zero audio) and unsupported position + // elements are dropped ( on both; — Azure's own + // documented element — on Edge only, where it also zeroes audio). + let is_edge = self.config.provider_id == "edge"; let ssml = if is_ssml { inject_voice_if_missing( - &strip_unsupported_marks(&normalize_ssml_envelope(&text, &voice_to_use)), + &strip_unsupported_marks( + &normalize_ssml_envelope(&text, &voice_to_use), + is_edge, + ), &voice_to_use, ) } else { @@ -2270,10 +2286,11 @@ impl TtsEngine for CloudEngine { // already SSML — send it directly (don't escape/wrap with // build_azure_ssml). Inject voice if the SSML lacks a // tag, after completing the envelope and dropping unsupported - // elements (both make Azure synthesise zero audio). + // elements (Azure accepts its documented + // here, so bookmarks are kept on this path). let ssml = if is_ssml { inject_voice_if_missing( - &strip_unsupported_marks(&normalize_ssml_envelope(&text, &voice_to_use)), + &strip_unsupported_marks(&normalize_ssml_envelope(&text, &voice_to_use), false), &voice_to_use, ) } else { @@ -3555,11 +3572,11 @@ mod tests { fn test_strip_marks_removes_self_closing_and_paired() { // The exact shape speech-dispatcher wraps around pauses. assert_eq!( - strip_unsupported_marks("A B"), + strip_unsupported_marks("A B", false), "A B" ); assert_eq!( - strip_unsupported_marks("A B"), + strip_unsupported_marks("A B", false), "A B" ); } @@ -3567,22 +3584,40 @@ mod tests { #[test] fn test_strip_marks_leaves_similar_names_and_text_alone() { assert_eq!( - strip_unsupported_marks(""), + strip_unsupported_marks("", false), "" ); - assert_eq!(strip_unsupported_marks("a < b"), "a < b"); - assert_eq!(strip_unsupported_marks(" world"; - let stripped = strip_unsupported_marks(ssml); + let stripped = strip_unsupported_marks(ssml, false); assert!(!stripped.contains("mark")); assert_eq!(stripped, "Hello world"); } + #[test] + fn test_bookmark_kept_for_azure_stripped_for_edge() { + // Azure documents and accepts it … + assert_eq!( + strip_unsupported_marks("roses and", false), + "roses and" + ); + // … but the free Edge endpoint synthesises zero audio for it, so + // the Edge path strips it too (verified live). + assert_eq!( + strip_unsupported_marks("roses and", true), + "roses and" + ); + } + // ===== inject_voice_if_missing ===== #[test] diff --git a/src/engine.rs b/src/engine.rs index 73fa2f1..b704bd6 100644 --- a/src/engine.rs +++ b/src/engine.rs @@ -48,7 +48,10 @@ pub fn preprocess_speech_markdown(text: &str, platform: &str) -> (String, bool) } let platform = match platform { - "azure" => Platform::MicrosoftAzure, + // Edge speaks the Azure SSML dialect (same Speech platform; it is + // not an Alexa-family endpoint) — its free endpoint just lacks a + // few elements, which the engine boundary strips. + "azure" | "edge" => Platform::MicrosoftAzure, "google" => Platform::GoogleAssistant, _ => Platform::AmazonAlexa, };