#![allow(clippy::wildcard_imports)]
#[must_use]
pub fn base64_encode(data: &str) -> String {
use base64::Engine;
base64::engine::general_purpose::STANDARD.encode(data.as_bytes())
}
#[cfg(feature = "cloud")]
#[must_use]
pub fn inject_voice_if_missing(ssml: &str, voice: &str) -> String {
if ssml.contains("<voice") || voice.is_empty() {
return ssml.to_string();
}
let voice_tag = format!("<voice name='{voice}'>");
if let Some(close_idx) = ssml.find('>') {
let tag_start = &ssml[..=close_idx];
if tag_start
.trim_start()
.to_ascii_lowercase()
.starts_with("<speak")
{
let insert_at = close_idx + 1;
let (before, after) = ssml.split_at(insert_at);
let after_with_close = if let Some(pos) = after.rfind("</speak") {
let (content, close) = after.split_at(pos);
format!("{content}</voice>{close}")
} else {
format!("{after}</voice>")
};
return format!("{before}{voice_tag}{after_with_close}");
}
}
ssml.to_string()
}
pub(crate) const SSML_XMLNS: &str = "http://www.w3.org/2001/10/synthesis";
#[must_use]
pub fn voice_lang(voice: &str) -> String {
let head: String = voice.chars().take(5).collect();
let chars: Vec<char> = head.chars().collect();
let locale_like = chars.len() == 5
&& chars[2] == '-'
&& [0, 1, 3, 4].iter().all(|&i| chars[i].is_ascii_alphabetic());
if locale_like {
head
} else {
"en-US".to_string()
}
}
#[cfg(feature = "cloud")]
pub(crate) fn normalize_ssml_envelope(ssml: &str, voice: &str) -> String {
let trimmed = ssml.trim_start();
if !trimmed.to_ascii_lowercase().starts_with("<speak") {
return ssml.to_string();
}
let Some(tag_end) = trimmed.find('>') else {
return ssml.to_string(); };
let inner = trimmed[..=tag_end]
.strip_prefix('<')
.and_then(|t| t.strip_suffix('>'))
.unwrap_or("");
let mut has_version = false;
let mut has_xmlns = false;
let mut has_lang = false;
for attr in inner.split_whitespace().skip(1) {
match attr
.split('=')
.next()
.unwrap_or("")
.to_ascii_lowercase()
.as_str()
{
"version" => has_version = true,
"xmlns" | "xmlns:xmlns" => has_xmlns = true,
"xml:lang" => has_lang = true,
_ => {}
}
}
if has_version && has_xmlns && has_lang {
return ssml.to_string(); }
let mut words = inner.split_whitespace();
let first = words.next().unwrap_or("speak");
let self_closing = first.ends_with('/');
let name = first.trim_end_matches('/');
let mut open = format!("<{name}");
let attrs = words.collect::<Vec<_>>().join(" ");
let self_closing = self_closing || attrs.ends_with('/');
let attrs = attrs.trim_end_matches('/');
if !attrs.is_empty() {
open.push(' ');
open.push_str(attrs);
}
if !has_version {
open.push_str(" version=\"1.0\"");
}
if !has_xmlns {
open.push_str(" xmlns=\"");
open.push_str(SSML_XMLNS);
open.push('"');
}
if !has_lang {
open.push_str(" xml:lang=\"");
open.push_str(&voice_lang(voice));
open.push('"');
}
if self_closing {
open.push('/');
}
open.push('>');
let lead = &ssml[..ssml.len() - trimmed.len()];
format!("{lead}{open}{}", &trimmed[tag_end + 1..])
}
#[cfg(feature = "cloud")]
pub(crate) fn strip_unsupported_marks(ssml: &str, is_edge: bool) -> String {
let has_marks = ssml.contains("<mark") || ssml.contains("</mark");
let has_edge_extras = is_edge
&& (ssml.contains("<bookmark")
|| ssml.contains("</bookmark")
|| ssml.contains("<mstts:express-as")
|| ssml.contains("</mstts:express-as"));
if !has_marks && !has_edge_extras {
return ssml.to_string();
}
let mut out = String::with_capacity(ssml.len());
let mut rest = ssml;
while let Some(pos) = rest.find('<') {
let after = &rest[pos..];
let name_done = |s: &str, prefix: &str| {
s.strip_prefix(prefix)
.is_some_and(|tail| tail.starts_with([' ', '\t', '\r', '\n', '/', '>']))
};
let drop = name_done(after, "<mark")
|| name_done(after, "</mark")
|| (is_edge
&& (name_done(after, "<bookmark")
|| name_done(after, "</bookmark")
|| name_done(after, "<mstts:express-as")
|| name_done(after, "</mstts:express-as")));
if drop {
if let Some(end) = after.find('>') {
out.push_str(&rest[..pos]);
rest = &after[end + 1..];
continue;
}
}
out.push_str(&rest[..=pos]);
rest = &rest[pos + 1..];
}
out.push_str(rest);
out
}
pub(crate) fn build_azure_ssml(
text: &str,
voice: &str,
rate: f32,
pitch: f32,
volume: f32,
) -> String {
let lang = voice.chars().take(5).collect::<String>();
let escaped = text
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
let voice_escaped = voice
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('\'', "'")
.replace('"', """);
let mut prosody_attrs = Vec::new();
if (rate - 1.0).abs() > f32::EPSILON {
let pct = ((rate - 1.0) * 100.0).round() as i32;
let sign = if pct >= 0 { "+" } else { "" };
prosody_attrs.push(format!("rate=\"{sign}{pct}%\""));
}
if (pitch - 1.0).abs() > f32::EPSILON {
let pct = ((pitch - 1.0) * 50.0).round() as i32;
let sign = if pct >= 0 { "+" } else { "" };
prosody_attrs.push(format!("pitch=\"{sign}{pct}%\""));
}
if (volume - 1.0).abs() > f32::EPSILON {
let pct = ((volume - 1.0) * 100.0).round() as i32;
let sign = if pct >= 0 { "+" } else { "" };
prosody_attrs.push(format!("volume=\"{sign}{pct}%\""));
}
let inner = if prosody_attrs.is_empty() {
escaped
} else {
format!("<prosody {}>{escaped}</prosody>", prosody_attrs.join(" "))
};
if let Some((base_model, profile)) = voice.split_once('/') {
if !profile.is_empty()
&& base_model.chars().next().is_some_and(char::is_uppercase)
&& profile
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
let esc = |v: &str| {
v.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('\'', "'")
};
let model_escaped = esc(base_model);
let profile_escaped = esc(profile);
return format!(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' \
xmlns:mstts='https://www.w3.org/2001/mstts' xml:lang='en-US'>\
<voice name='{model_escaped}'>\
<mstts:ttsembedding speakerProfileId='{profile_escaped}'>{inner}\
</mstts:ttsembedding></voice></speak>"
);
}
}
format!(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='{lang}'>\
<voice name='{voice_escaped}'>{inner}</voice></speak>"
)
}