1use std::fmt;
20use std::ops::Range;
21
22#[derive(Debug, Clone, Copy, PartialEq, Default)]
25pub struct Prosody {
26 pub rate: Option<f32>,
28 pub pitch: Option<f32>,
30 pub volume: Option<f32>,
32}
33
34impl Prosody {
35 fn overlay(self, other: Self) -> Self {
37 Self {
38 rate: other.rate.or(self.rate),
39 pitch: other.pitch.or(self.pitch),
40 volume: other.volume.or(self.volume),
41 }
42 }
43
44 #[must_use]
46 pub fn is_default(&self) -> bool {
47 self.rate.is_none() && self.pitch.is_none() && self.volume.is_none()
48 }
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum SayAs {
54 #[default]
56 None,
57 Characters,
59 Cardinal,
61 Ordinal,
63 Other,
65}
66
67#[derive(Debug, Clone, PartialEq)]
73pub struct WordSpan {
74 pub text: String,
76 pub spoken: String,
79 pub char_span: Range<usize>,
81 pub byte_span: Range<usize>,
83 pub phonemes: Option<Vec<String>>,
86 pub prosody: Prosody,
88 pub say_as: SayAs,
90 pub voice: Option<String>,
92}
93
94impl WordSpan {
95 #[must_use]
97 pub fn char_len(&self) -> usize {
98 self.char_span.end - self.char_span.start
99 }
100
101 #[must_use]
103 pub fn byte_len(&self) -> usize {
104 self.byte_span.end - self.byte_span.start
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Default)]
110pub struct SsmlDocument {
111 pub segments: Vec<Segment>,
113 pub warnings: Vec<String>,
116 pub lang: Option<String>,
120}
121
122impl SsmlDocument {
123 #[must_use]
125 pub fn words(&self) -> Vec<&WordSpan> {
126 self.segments
127 .iter()
128 .filter_map(|s| match s {
129 Segment::Words { words } => Some(words.iter()),
130 _ => None,
131 })
132 .flatten()
133 .collect()
134 }
135
136 #[must_use]
138 pub fn spoken_text(&self) -> String {
139 let mut parts: Vec<String> = Vec::new();
140 for seg in &self.segments {
141 if let Segment::Words { words } = seg {
142 parts.extend(words.iter().map(|w| w.spoken.clone()));
143 }
144 }
145 parts.join(" ")
146 }
147}
148
149#[derive(Debug, Clone, PartialEq)]
151pub enum Segment {
152 Words {
154 words: Vec<WordSpan>,
156 },
157 Break {
159 ms: u32,
161 char_pos: usize,
163 byte_pos: usize,
165 },
166 Mark {
169 name: String,
171 char_pos: usize,
173 byte_pos: usize,
175 },
176 SentenceEnd {
178 char_pos: usize,
180 byte_pos: usize,
182 },
183 ParagraphEnd {
185 char_pos: usize,
187 byte_pos: usize,
189 },
190}
191
192#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct ParseError(pub String);
195
196impl fmt::Display for ParseError {
197 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
198 write!(f, "SSML parse error: {}", self.0)
199 }
200}
201
202impl std::error::Error for ParseError {}
203
204#[allow(clippy::missing_errors_doc)]
210pub fn parse(input: &str) -> Result<SsmlDocument, ParseError> {
211 if input.contains('<') {
212 match parse_xml(input) {
213 ParseOutcome::Ok(doc) => return Ok(doc),
214 ParseOutcome::Fallback(mut bad) => {
215 let mut plain = SsmlDocument::default();
216 push_plain_words(&mut plain, input.as_bytes(), 0, &OffsetMap::identity(input));
217 bad.warnings.insert(
218 0,
219 "input looked like XML but did not parse cleanly; treated as plain text".into(),
220 );
221 plain.warnings = bad.warnings;
222 return Ok(plain);
223 }
224 }
225 }
226 let mut doc = SsmlDocument::default();
227 push_plain_words(&mut doc, input.as_bytes(), 0, &OffsetMap::identity(input));
228 Ok(doc)
229}
230
231enum ParseOutcome {
232 Ok(SsmlDocument),
233 Fallback(SsmlDocument),
234}
235
236struct OffsetMap {
238 bytes_to_chars: Vec<usize>,
240}
241
242impl OffsetMap {
243 fn identity(input: &str) -> Self {
244 Self::build(input)
245 }
246
247 fn build(input: &str) -> Self {
248 let bytes = input.as_bytes();
249 let mut map = Vec::with_capacity(bytes.len() + 1);
250 map.push(0usize);
251 let mut starts = 0usize;
252 for (b, byte) in bytes.iter().enumerate() {
253 if b == 0 || (byte & 0xC0) != 0x80 {
255 starts += 1;
256 }
257 map.push(starts);
258 }
259 Self {
260 bytes_to_chars: map,
261 }
262 }
263
264 fn char_at(&self, byte: usize) -> usize {
265 self.bytes_to_chars
266 .get(byte)
267 .copied()
268 .unwrap_or_else(|| self.bytes_to_chars.last().copied().unwrap_or(0))
269 }
270}
271
272struct Override {
273 is_phoneme: bool,
274 inner_start: usize,
276 inner_end: usize,
278 phonemes: Option<Vec<String>>,
279 alias: Option<String>,
280}
281
282struct ParserState {
283 prosody_stack: Vec<Prosody>,
284 say_as_stack: Vec<SayAs>,
285 voice_stack: Vec<String>,
286 override_active: Option<Override>,
287 pending: Vec<WordSpan>,
288 warnings: Vec<String>,
289 fatal: bool,
290 last_text_end: usize,
292 assembler: WordAssembler,
294}
295
296impl ParserState {
297 fn new() -> Self {
298 Self {
299 prosody_stack: vec![Prosody::default()],
300 say_as_stack: vec![SayAs::None],
301 voice_stack: Vec::new(),
302 override_active: None,
303 pending: Vec::new(),
304 warnings: Vec::new(),
305 fatal: false,
306 last_text_end: 0,
307 assembler: WordAssembler::default(),
308 }
309 }
310
311 fn prosody(&self) -> Prosody {
312 *self.prosody_stack.last().unwrap_or(&Prosody::default())
313 }
314
315 fn say_as(&self) -> SayAs {
316 self.say_as_stack.last().copied().unwrap_or_default()
317 }
318
319 fn voice(&self) -> Option<String> {
320 self.voice_stack.last().cloned()
321 }
322}
323
324fn parse_xml(input: &str) -> ParseOutcome {
325 use quick_xml::events::Event;
326
327 let mut doc = SsmlDocument::default();
328 let mut st = ParserState::new();
329 let offsets = OffsetMap::build(input);
330 let mut reader = quick_xml::Reader::from_str(input);
331 reader.config_mut().expand_empty_elements = false;
332 reader.config_mut().trim_text(false);
333
334 loop {
335 let event_start = usize::try_from(reader.buffer_position()).unwrap_or(usize::MAX);
337 let event = match reader.read_event() {
338 Ok(ev) => ev,
339 Err(e) => {
340 st.warnings
341 .push(format!("XML error near byte {event_start}: {e}"));
342 st.fatal = true;
343 break;
344 }
345 };
346 let event_end = usize::try_from(reader.buffer_position()).unwrap_or(usize::MAX);
347
348 match event {
349 Event::Start(tag) => {
350 let name = local_name(&tag);
351 handle_open(
352 &mut st,
353 &mut doc,
354 &tag,
355 &name,
356 event_start,
357 event_end,
358 false,
359 &offsets,
360 );
361 }
362 Event::Empty(tag) => {
363 let name = local_name(&tag);
364 handle_open(
365 &mut st,
366 &mut doc,
367 &tag,
368 &name,
369 event_start,
370 event_start,
371 true,
372 &offsets,
373 );
374 }
375 Event::End(tag) => {
376 let name = String::from_utf8_lossy(tag.name().as_ref()).into_owned();
377 handle_close(&mut st, &mut doc, &name, event_start, input, &offsets);
378 }
379 Event::Text(t) => {
380 let raw = &input[event_start..event_end.min(input.len())];
382 debug_assert_eq!(raw.as_bytes(), t.as_ref(), "quick-xml text slice mismatch");
383 st.on_text(raw.as_bytes(), input, &offsets);
384 }
385 Event::GeneralRef(g) => {
386 let raw = &input[event_start..event_end.min(input.len())];
391 debug_assert!(
392 raw.len() >= 2 && raw.as_bytes()[1..raw.len() - 1] == *g.as_ref(),
393 "quick-xml genref slice mismatch"
394 );
395 st.on_text(raw.as_bytes(), input, &offsets);
396 }
397 Event::CData(t) => {
398 let start = event_start + b"<![CDATA[".len();
401 let end = event_end.saturating_sub(b"]]>".len()).max(start);
402 let raw = &input[start..end.min(input.len())];
403 let _ = t;
404 st.on_text(raw.as_bytes(), input, &offsets);
405 }
406 Event::Comment(_) | Event::Decl(_) | Event::PI(_) | Event::DocType(_) => {}
407 Event::Eof => break,
408 }
409 }
410
411 st.flush(&mut doc, &offsets);
412 doc.warnings = st.warnings;
413 if st.fatal {
414 ParseOutcome::Fallback(doc)
415 } else {
416 ParseOutcome::Ok(doc)
417 }
418}
419
420impl ParserState {
421 fn on_text(&mut self, raw: &[u8], input: &str, _offsets: &OffsetMap) {
422 let run_start = locate_run(input, self.last_text_end, raw);
423 self.last_text_end = run_start + raw.len();
424 if let Some(ov) = self.override_active.as_mut() {
425 ov.inner_end = self.last_text_end;
426 return;
427 }
428 self.assembler.push_run(raw, run_start);
429 }
430
431 fn drain_text(&mut self, offsets: &OffsetMap) {
434 let words = self.assembler.finish();
435 for w in words {
436 self.pending.push(WordSpan {
437 char_span: offsets.char_at(w.byte_span.start)..offsets.char_at(w.byte_span.end),
438 byte_span: w.byte_span,
439 text: w.text.clone(),
440 spoken: w.text,
441 phonemes: None,
442 prosody: self.prosody(),
443 say_as: self.say_as(),
444 voice: self.voice(),
445 });
446 }
447 }
448
449 fn flush(&mut self, doc: &mut SsmlDocument, offsets: &OffsetMap) {
450 self.drain_text(offsets);
451 if !self.pending.is_empty() {
452 doc.segments.push(Segment::Words {
453 words: std::mem::take(&mut self.pending),
454 });
455 }
456 }
457}
458
459fn locate_run(input: &str, from: usize, raw: &[u8]) -> usize {
463 if raw.is_empty() {
464 return from;
465 }
466 let hay = input.as_bytes();
467 let start = from.min(hay.len());
468 if hay.len() >= raw.len() && hay[start..].starts_with(raw) {
469 return start;
470 }
471 memfind(hay, raw).unwrap_or(start)
473}
474
475fn memfind(hay: &[u8], needle: &[u8]) -> Option<usize> {
476 if needle.is_empty() || hay.len() < needle.len() {
477 return None;
478 }
479 hay.windows(needle.len()).position(|w| w == needle)
480}
481
482#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
484fn handle_open(
485 st: &mut ParserState,
486 doc: &mut SsmlDocument,
487 tag: &quick_xml::events::BytesStart<'_>,
488 name: &str,
489 tag_start: usize,
490 tag_end: usize,
491 is_empty_form: bool,
492 offsets: &OffsetMap,
493) {
494 match name {
496 "break" | "mark" | "prosody" | "emphasis" | "say-as" | "voice" | "phoneme" | "sub" => {
497 st.flush(doc, offsets);
498 }
499 _ => {}
500 }
501 match name {
502 "speak" => {
503 if let Some(lang) = attr(tag, "xml:lang") {
505 doc.lang = Some(lang);
506 }
507 }
508 "break" => {
509 let ms = attr(tag, "time")
510 .and_then(|t| parse_time(&t))
511 .or_else(|| attr(tag, "strength").and_then(|s| parse_break_strength(&s)))
512 .unwrap_or_else(|| {
513 st.warnings
514 .push("break without usable time/strength; using 0 ms".into());
515 0
516 });
517 doc.segments.push(Segment::Break {
518 ms,
519 char_pos: offsets.char_at(tag_start),
520 byte_pos: tag_start,
521 });
522 }
523 "mark" => {
524 let mark_name = attr(tag, "name").unwrap_or_default();
525 doc.segments.push(Segment::Mark {
526 name: mark_name,
527 char_pos: offsets.char_at(tag_start),
528 byte_pos: tag_start,
529 });
530 }
531 "prosody" => {
532 let mut p = Prosody::default();
533 if let Some(v) = attr(tag, "rate") {
534 match parse_rate(&v) {
535 Some(r) => p.rate = Some(r),
536 None => st.warnings.push(format!("ignored prosody rate {v:?}")),
537 }
538 }
539 if let Some(v) = attr(tag, "pitch") {
540 match parse_pitch(&v) {
541 Some(r) => p.pitch = Some(r),
542 None => st.warnings.push(format!("ignored prosody pitch {v:?}")),
543 }
544 }
545 if let Some(v) = attr(tag, "volume") {
546 match parse_volume(&v) {
547 Some(r) => p.volume = Some(r),
548 None => st.warnings.push(format!("ignored prosody volume {v:?}")),
549 }
550 }
551 st.prosody_stack.push(st.prosody().overlay(p));
552 }
553 "emphasis" => {
554 let level = attr(tag, "level").unwrap_or_else(|| "moderate".into());
555 let p = match level.as_str() {
556 "strong" => Prosody {
557 rate: Some(0.97),
558 pitch: Some(1.15),
559 volume: Some(1.2),
560 },
561 "moderate" => Prosody {
562 rate: Some(0.99),
563 pitch: Some(1.06),
564 volume: Some(1.1),
565 },
566 "reduced" => Prosody {
567 rate: Some(0.92),
568 pitch: Some(0.9),
569 volume: Some(0.8),
570 },
571 other => {
572 st.warnings
573 .push(format!("unknown emphasis level {other:?}; using default"));
574 Prosody::default()
575 }
576 };
577 st.prosody_stack.push(st.prosody().overlay(p));
578 }
579 "say-as" => {
580 let mode = match attr(tag, "interpret-as").as_deref() {
581 Some("characters" | "spell-out") => SayAs::Characters,
582 Some("cardinal" | "number") => SayAs::Cardinal,
583 Some("ordinal") => SayAs::Ordinal,
584 Some(_) | None => SayAs::Other,
585 };
586 st.say_as_stack.push(mode);
587 }
588 "voice" => {
589 st.voice_stack.push(attr(tag, "name").unwrap_or_default());
590 }
591 "phoneme" => {
592 let ph = attr(tag, "ph")
593 .map(|s| s.split_whitespace().map(str::to_owned).collect::<Vec<_>>());
594 match ph {
595 Some(phonemes) => {
596 st.override_active = Some(Override {
597 is_phoneme: true,
598 inner_start: tag_end,
599 inner_end: tag_end,
600 phonemes: Some(phonemes),
601 alias: None,
602 });
603 }
604 None => st
605 .warnings
606 .push("phoneme tag without ph attribute; override ignored".into()),
607 }
608 }
609 "sub" => match attr(tag, "alias") {
610 Some(alias) => {
611 st.override_active = Some(Override {
612 is_phoneme: false,
613 inner_start: tag_end,
614 inner_end: tag_end,
615 phonemes: None,
616 alias: Some(alias),
617 });
618 }
619 None => st
620 .warnings
621 .push("sub tag without alias attribute; override ignored".into()),
622 },
623 "audio" => {
624 st.warnings
625 .push("audio tag ignored (pre-recorded audio not supported)".into());
626 }
627 _ => {}
628 }
629 if is_empty_form {
631 match name {
632 "prosody" | "emphasis" => {
633 st.prosody_stack.pop();
634 }
635 "say-as" => {
636 st.say_as_stack.pop();
637 }
638 "voice" => {
639 st.voice_stack.pop();
640 }
641 _ => {}
642 }
643 }
644}
645
646fn handle_close(
648 st: &mut ParserState,
649 doc: &mut SsmlDocument,
650 name: &str,
651 tag_start: usize,
652 input: &str,
653 offsets: &OffsetMap,
654) {
655 match name {
656 "prosody" | "emphasis" => {
657 st.flush(doc, offsets);
658 st.prosody_stack.pop();
659 }
660 "say-as" => {
661 st.flush(doc, offsets);
662 st.say_as_stack.pop();
663 }
664 "voice" => {
665 st.flush(doc, offsets);
666 st.voice_stack.pop();
667 }
668 "phoneme" | "sub" => {
669 if let Some(ov) = st.override_active.take() {
670 let inner = &input[ov.inner_start..ov.inner_end.min(input.len())];
671 let decoded: Vec<String> = decode_words(inner.as_bytes(), ov.inner_start)
672 .into_iter()
673 .map(|w| w.text)
674 .collect();
675 if decoded.is_empty() {
676 st.warnings
677 .push(format!("{name} element with no inner text; ignored"));
678 } else {
679 let byte_span = ov.inner_start..ov.inner_end.min(input.len());
680 let joined = decoded.join(" ");
681 let spoken = if ov.is_phoneme {
682 joined.clone()
683 } else {
684 ov.alias.clone().unwrap_or(joined.clone())
685 };
686 st.pending.push(WordSpan {
687 text: joined,
688 spoken,
689 char_span: offsets.char_at(byte_span.start)..offsets.char_at(byte_span.end),
690 byte_span,
691 phonemes: ov.phonemes,
692 prosody: st.prosody(),
693 say_as: st.say_as(),
694 voice: st.voice(),
695 });
696 }
697 }
698 }
699 "s" => {
700 st.flush(doc, offsets);
701 doc.segments.push(Segment::SentenceEnd {
702 char_pos: offsets.char_at(tag_start),
703 byte_pos: tag_start,
704 });
705 }
706 "p" => {
707 st.flush(doc, offsets);
708 doc.segments.push(Segment::ParagraphEnd {
709 char_pos: offsets.char_at(tag_start),
710 byte_pos: tag_start,
711 });
712 }
713 _ => {}
714 }
715}
716
717fn local_name(tag: &quick_xml::events::BytesStart<'_>) -> String {
718 String::from_utf8_lossy(tag.name().as_ref()).into_owned()
719}
720
721fn attr(tag: &quick_xml::events::BytesStart<'_>, key: &str) -> Option<String> {
722 tag.attributes()
723 .flatten()
724 .find(|a| a.key.as_ref() == key.as_bytes())
725 .and_then(|a| a.unescape_value().ok().map(std::borrow::Cow::into_owned))
726}
727
728fn push_plain_words(doc: &mut SsmlDocument, raw: &[u8], start: usize, offsets: &OffsetMap) {
730 let mut words = Vec::new();
731 for w in decode_words(raw, start) {
732 words.push(WordSpan {
733 char_span: offsets.char_at(w.byte_span.start)..offsets.char_at(w.byte_span.end),
734 byte_span: w.byte_span,
735 text: w.text.clone(),
736 spoken: w.text,
737 phonemes: None,
738 prosody: Prosody::default(),
739 say_as: SayAs::None,
740 voice: None,
741 });
742 }
743 if !words.is_empty() {
744 doc.segments.push(Segment::Words { words });
745 }
746}
747
748struct DecodedWord {
750 text: String,
751 byte_span: Range<usize>,
752}
753
754#[derive(Default)]
757struct WordAssembler {
758 words: Vec<DecodedWord>,
759 text: String,
760 start: Option<usize>,
761 end: usize,
762}
763
764impl WordAssembler {
765 fn push_run(&mut self, raw: &[u8], run_start: usize) {
766 let mut i = 0;
767 while i < raw.len() {
768 let b = raw[i];
769 if b == b'&' {
770 if let Some(semi) = raw[i + 1..].iter().position(|&c| c == b';') {
771 let ent = &raw[i..i + semi + 2];
772 let decoded = decode_entity(ent);
773 for ch in decoded.chars() {
774 if ch.is_whitespace() {
775 self.flush_word(run_start + i);
776 } else {
777 if self.start.is_none() {
778 self.start = Some(run_start + i);
779 }
780 self.text.push(ch);
781 }
782 }
783 self.end = run_start + i + semi + 2;
784 i += semi + 2;
785 continue;
786 }
787 }
788 let len = utf8_len(b);
789 let chunk = &raw[i..(i + len).min(raw.len())];
790 let s = String::from_utf8_lossy(chunk).into_owned();
791 for ch in s.chars() {
792 if ch.is_whitespace() {
793 self.flush_word(run_start + i);
794 } else {
795 if self.start.is_none() {
796 self.start = Some(run_start + i);
797 }
798 self.text.push(ch);
799 }
800 }
801 self.end = run_start + i + len;
802 i += len;
803 }
804 }
805
806 fn flush_word(&mut self, pos: usize) {
807 if self.text.is_empty() {
808 self.start = None;
809 } else {
810 let start = self.start.take().unwrap_or(pos);
811 self.words.push(DecodedWord {
812 text: std::mem::take(&mut self.text),
813 byte_span: start..self.end.max(pos),
814 });
815 }
816 }
817
818 fn finish(&mut self) -> Vec<DecodedWord> {
820 if !self.text.is_empty() {
821 let start = self.start.take().unwrap_or(self.end);
822 let end = self.end;
823 self.words.push(DecodedWord {
824 text: std::mem::take(&mut self.text),
825 byte_span: start..end,
826 });
827 }
828 std::mem::take(&mut self.words)
829 }
830}
831
832fn decode_words(raw: &[u8], run_start: usize) -> Vec<DecodedWord> {
834 let mut a = WordAssembler::default();
835 a.push_run(raw, run_start);
836 a.finish()
837}
838
839fn utf8_len(b: u8) -> usize {
840 match b {
841 0x00..=0x7F => 1,
842 0xC0..=0xDF => 2,
843 0xE0..=0xEF => 3,
844 _ => 4,
845 }
846}
847
848fn decode_entity(ent: &[u8]) -> String {
850 if ent.len() < 3 {
851 return String::from_utf8_lossy(ent).into_owned();
852 }
853 let inner = &ent[1..ent.len() - 1];
854 match inner {
855 b"amp" => "&".into(),
856 b"lt" => "<".into(),
857 b"gt" => ">".into(),
858 b"quot" => "\"".into(),
859 b"apos" => "'".into(),
860 b"nbsp" => "\u{a0}".into(),
861 _ => {
862 if let Some(hex) = inner
863 .strip_prefix(b"#x")
864 .or_else(|| inner.strip_prefix(b"#X"))
865 {
866 u32::from_str_radix(&String::from_utf8_lossy(hex), 16)
867 .ok()
868 .and_then(char::from_u32)
869 .map(String::from)
870 .unwrap_or_default()
871 } else if let Some(dec) = inner.strip_prefix(b"#") {
872 String::from_utf8_lossy(dec)
873 .parse::<u32>()
874 .ok()
875 .and_then(char::from_u32)
876 .map(String::from)
877 .unwrap_or_default()
878 } else {
879 String::new()
880 }
881 }
882 }
883}
884
885#[must_use]
887#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
888pub fn parse_time(t: &str) -> Option<u32> {
889 let t = t.trim();
890 if let Some(v) = t.strip_suffix("ms") {
891 v.trim().parse::<f64>().ok().map(|f| f.max(0.0) as u32)
892 } else if let Some(v) = t.strip_suffix('s') {
893 v.trim()
894 .parse::<f64>()
895 .ok()
896 .map(|f| (f * 1000.0).max(0.0) as u32)
897 } else {
898 t.parse::<f64>().ok().map(|f| f.max(0.0) as u32)
899 }
900}
901
902#[must_use]
904pub fn parse_break_strength(s: &str) -> Option<u32> {
905 match s {
906 "none" => Some(0),
907 "x-weak" => Some(50),
908 "weak" => Some(100),
909 "medium" => Some(250),
910 "strong" => Some(500),
911 "x-strong" => Some(800),
912 _ => None,
913 }
914}
915
916#[must_use]
919pub fn parse_rate(r: &str) -> Option<f32> {
920 let r = r.trim();
921 let named = match r {
922 "x-slow" => Some(0.5),
923 "slow" => Some(0.75),
924 "medium" | "default" => Some(1.0),
925 "fast" => Some(1.25),
926 "x-fast" => Some(1.5),
927 _ => None,
928 };
929 named.or_else(|| parse_relative_percent(r)).or_else(|| {
930 let v = r.parse::<f32>().ok()?;
931 (0.1..=10.0).contains(&v).then_some(v)
932 })
933}
934
935#[must_use]
937pub fn parse_pitch(p: &str) -> Option<f32> {
938 let p = p.trim();
939 let named = match p {
940 "x-low" => Some(0.7),
941 "low" => Some(0.85),
942 "medium" | "default" => Some(1.0),
943 "high" => Some(1.15),
944 "x-high" => Some(1.3),
945 _ => None,
946 };
947 if let Some(v) = named {
948 return Some(v);
949 }
950 if let Some(st) = p.strip_suffix("st") {
951 return st
952 .trim()
953 .parse::<f32>()
954 .ok()
955 .map(|n| 2.0_f32.powf(n / 12.0));
956 }
957 parse_relative_percent(p)
958}
959
960#[must_use]
962pub fn parse_volume(v: &str) -> Option<f32> {
963 let v = v.trim();
964 let named = match v {
965 "silent" => Some(0.0),
966 "x-soft" => Some(0.3),
967 "soft" => Some(0.6),
968 "medium" | "default" => Some(1.0),
969 "loud" => Some(1.3),
970 "x-loud" => Some(1.6),
971 _ => None,
972 };
973 if let Some(x) = named {
974 return Some(x);
975 }
976 if v.ends_with('%') {
977 let n = v.strip_suffix('%')?.trim().parse::<f32>().ok()?;
978 return Some((n / 100.0).clamp(0.0, 2.0));
979 }
980 let n = v.parse::<f32>().ok()?;
981 Some((n / 100.0).clamp(0.0, 2.0))
982}
983
984fn parse_relative_percent(s: &str) -> Option<f32> {
986 let s = s.trim();
987 let pct = s.strip_suffix('%')?.trim();
988 if let Some(rel) = pct.strip_prefix('+') {
989 let r: f32 = rel.trim().parse().ok()?;
990 return Some((1.0 + r / 100.0).max(0.05));
991 }
992 if let Some(rel) = pct.strip_prefix('-') {
993 let r: f32 = rel.trim().parse().ok()?;
994 return Some((1.0 - r / 100.0).max(0.05));
995 }
996 let n: f32 = pct.parse().ok()?;
997 Some((n / 100.0).max(0.05))
998}
999
1000#[cfg(test)]
1001mod tests {
1002 use super::*;
1003
1004 #[test]
1005 fn speak_envelope_lang_is_recorded() {
1006 let doc = parse(r#"<speak xml:lang="de-DE">Guten Tag</speak>"#).unwrap();
1007 assert_eq!(doc.lang.as_deref(), Some("de-DE"));
1008 assert!(parse("<speak>hi</speak>").unwrap().lang.is_none());
1009 assert!(parse("plain").unwrap().lang.is_none());
1010 }
1011
1012 #[test]
1013 fn plain_text_words() {
1014 let doc = parse("Hello world").unwrap();
1015 let words = doc.words();
1016 assert_eq!(words.len(), 2);
1017 assert_eq!(words[0].text, "Hello");
1018 assert_eq!(words[0].char_span, 0..5);
1019 assert_eq!(words[1].char_span, 6..11);
1020 assert_eq!(words[1].byte_span, 6..11);
1021 }
1022
1023 #[test]
1024 fn speak_root_strips() {
1025 let doc = parse("<speak>Hello world</speak>").unwrap();
1026 assert_eq!(doc.words().len(), 2);
1027 assert_eq!(doc.spoken_text(), "Hello world");
1028 }
1029
1030 #[test]
1031 fn break_time_variants() {
1032 let doc = parse(
1033 "<speak>a<break time=\"500ms\"/>b<break time=\"2s\"/>c<break time=\"1.5s\"/>d</speak>",
1034 )
1035 .unwrap();
1036 let breaks: Vec<u32> = doc
1037 .segments
1038 .iter()
1039 .filter_map(|s| match s {
1040 Segment::Break { ms, .. } => Some(*ms),
1041 _ => None,
1042 })
1043 .collect();
1044 assert_eq!(breaks, vec![500, 2000, 1500]);
1045 }
1046
1047 #[test]
1048 fn break_strength() {
1049 let doc = parse("<speak>a<break strength=\"strong\"/>b</speak>").unwrap();
1050 assert!(doc
1051 .segments
1052 .iter()
1053 .any(|s| matches!(s, Segment::Break { ms: 500, .. })));
1054 }
1055
1056 #[test]
1057 fn mark_name_and_position() {
1058 let ssml = "<speak>Hi <mark name=\"m1\"/>there</speak>";
1059 let doc = parse(ssml).unwrap();
1060 match &doc.segments[1] {
1061 Segment::Mark {
1062 name,
1063 char_pos,
1064 byte_pos,
1065 } => {
1066 assert_eq!(name, "m1");
1067 assert_eq!(*byte_pos, 10);
1069 assert_eq!(*char_pos, 10);
1070 assert_eq!(&ssml[(*byte_pos)..=(*byte_pos)], "<");
1071 }
1072 other => panic!("expected mark, got {other:?}"),
1073 }
1074 assert_eq!(doc.words()[1].text, "there");
1076 }
1077
1078 #[test]
1079 fn prosody_resolution() {
1080 let doc = parse(
1081 "<speak><prosody rate=\"80%\">slow <prosody pitch=\"+5%\">both</prosody></prosody>normal</speak>",
1082 )
1083 .unwrap();
1084 let words = doc.words();
1085 assert_eq!(words[0].prosody.rate, Some(0.8));
1086 assert_eq!(words[0].prosody.pitch, None);
1087 assert_eq!(words[1].prosody.rate, Some(0.8));
1088 assert_eq!(words[1].prosody.pitch, Some(1.05));
1089 assert!(words[2].prosody.is_default());
1090 }
1091
1092 #[test]
1093 fn phoneme_override() {
1094 let doc =
1095 parse("<speak>Say <phoneme ph=\"f ə n ɛ t ɪ k s\">phonetics</phoneme> now</speak>")
1096 .unwrap();
1097 let words = doc.words();
1098 assert_eq!(words.len(), 3);
1099 assert_eq!(words[1].text, "phonetics");
1100 let ph = words[1].phonemes.as_deref().expect("override present");
1101 assert_eq!(ph, &["f", "ə", "n", "ɛ", "t", "ɪ", "k", "s"]);
1102 }
1103
1104 #[test]
1105 fn sub_alias() {
1106 let ssml = "<speak>Visit <sub alias=\"World Wide Web\">WWW</sub> today</speak>";
1107 let doc = parse(ssml).unwrap();
1108 let words = doc.words();
1109 assert_eq!(words.len(), 3);
1110 assert_eq!(words[0].text, "Visit");
1111 assert_eq!(words[1].text, "WWW");
1112 assert_eq!(words[1].spoken, "World Wide Web");
1113 let start = ssml.find("WWW").expect("WWW in source");
1115 assert_eq!(words[1].byte_span, start..start + 3);
1116 }
1117
1118 #[test]
1119 fn entities_preserve_spans() {
1120 let ssml = "<speak>AT&T rules</speak>";
1122 let doc = parse(ssml).unwrap();
1123 let words = doc.words();
1124 assert_eq!(words[0].text, "AT&T");
1125 assert_eq!(words[0].byte_len(), "AT&T".len());
1126 assert_eq!(words[0].char_len(), "AT&T".len()); let span = words[0].byte_span.clone();
1128 assert_eq!(&ssml[span], "AT&T");
1129 assert_eq!(words[1].text, "rules");
1130 }
1131
1132 #[test]
1133 fn numeric_entity() {
1134 let doc = parse("<speak>AB cd</speak>").unwrap();
1135 assert_eq!(doc.words()[0].text, "AB");
1136 }
1137
1138 #[test]
1139 fn unicode_char_vs_byte() {
1140 let ssml = "<speak>héllo wörld</speak>";
1142 let doc = parse(ssml).unwrap();
1143 let w = &doc.words()[0];
1144 assert_eq!(w.text, "héllo");
1145 assert_eq!(w.byte_span, 7..13); assert_eq!(w.char_span, 7..12); let w2 = &doc.words()[1];
1148 assert_eq!(w2.text, "wörld");
1149 assert_eq!(w2.byte_span, 14..20); assert_eq!(w2.char_span, 13..18);
1151 }
1152
1153 #[test]
1154 fn say_as_characters() {
1155 let doc =
1156 parse("<speak>code <say-as interpret-as=\"characters\">ABC</say-as></speak>").unwrap();
1157 assert_eq!(doc.words()[1].say_as, SayAs::Characters);
1158 }
1159
1160 #[test]
1161 fn malformed_falls_back_to_plain() {
1162 let doc = parse("<speak><unclosed malformed").unwrap();
1163 assert!(!doc.warnings.is_empty());
1164 assert!(doc.spoken_text().contains("unclosed"));
1165 }
1166
1167 #[test]
1168 fn unknown_tags_transparent() {
1169 let doc = parse("<speak>Hello <marvelous>brave</marvelous> world</speak>").unwrap();
1170 assert_eq!(doc.spoken_text(), "Hello brave world");
1171 assert!(doc.warnings.is_empty());
1172 }
1173
1174 #[test]
1175 fn sentence_and_paragraph_markers() {
1176 let doc = parse("<speak><p>One<s>two</s></p></speak>").unwrap();
1177 assert!(doc
1178 .segments
1179 .iter()
1180 .any(|s| matches!(s, Segment::SentenceEnd { .. })));
1181 assert!(doc
1182 .segments
1183 .iter()
1184 .any(|s| matches!(s, Segment::ParagraphEnd { .. })));
1185 }
1186
1187 #[test]
1188 fn empty_element_prosody_no_hang() {
1189 let doc = parse("<speak><prosody rate=\"slow\"/>word</speak>").unwrap();
1190 assert_eq!(doc.words().len(), 1);
1191 assert!(doc.words()[0].prosody.is_default());
1192 }
1193
1194 #[test]
1195 fn cdata_text_spoken() {
1196 let doc = parse("<speak><![CDATA[Hello there]]></speak>").unwrap();
1197 assert_eq!(doc.spoken_text(), "Hello there");
1198 }
1199
1200 #[test]
1201 fn time_value_parsing() {
1202 assert_eq!(parse_time("500ms"), Some(500));
1203 assert_eq!(parse_time("2s"), Some(2000));
1204 assert_eq!(parse_time("1.5s"), Some(1500));
1205 assert_eq!(parse_time("300"), Some(300));
1206 assert_eq!(parse_time("junk"), None);
1207 }
1208
1209 #[test]
1210 fn rate_parsing_variants() {
1211 assert_eq!(parse_rate("0.8"), Some(0.8));
1212 assert_eq!(parse_rate("80%"), Some(0.8));
1213 assert_eq!(parse_rate("+20%"), Some(1.2));
1214 assert_eq!(parse_rate("-20%"), Some(0.8));
1215 assert_eq!(parse_rate("slow"), Some(0.75));
1216 assert_eq!(parse_rate("wat"), None);
1217 }
1218
1219 #[test]
1220 fn pitch_semitones() {
1221 let p = parse_pitch("+3st").unwrap();
1222 assert!((p - 2.0_f32.powf(3.0 / 12.0)).abs() < 1e-6);
1223 assert_eq!(parse_pitch("x-high"), Some(1.3));
1224 }
1225}