From 20873dd1b0bcd1e5f574b985c7e5da99499f6127 Mon Sep 17 00:00:00 2001 From: will wade Date: Tue, 18 Aug 2026 09:24:07 +0000 Subject: [PATCH] =?UTF-8?q?fix(cloud):=20Edge=20SSML=20dialect=20=E2=80=94?= =?UTF-8?q?=20strip=20,=20route=20SpeechMarkdown=20to=20the=20Az?= =?UTF-8?q?ure=20platform?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #23. Azure documents its own position element, but live testing shows the free Edge endpoint synthesises zero audio for it (same silent failure class as ), so the Edge WS path now strips both; the Azure REST path keeps since Azure accepts it. Also: SpeechMarkdown preprocessing mapped every engine except azure/google to the AmazonAlexa platform — including Edge, which speaks the Azure dialect (same Speech platform). Edge now maps to MicrosoftAzure, so SpeechMarkdown output for Edge gets a complete envelope and Azure-family elements instead of Alexa ones. Verified live against Edge: plain text, emphasis, and [mark:] Speech- Markdown all synthesise (examples/edge-speechmarkdown.rs). --- examples/edge-speechmarkdown.rs | 30 ++++++++++++++ src/cloud_engine.rs | 73 ++++++++++++++++++++++++--------- src/engine.rs | 5 ++- 3 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 examples/edge-speechmarkdown.rs 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, };