1mod client;
2mod constants;
3mod error;
4mod options;
5mod protocol;
6mod subtitles;
7mod types;
8
9pub use client::{EdgeTtsClient, EdgeTtsClientBuilder, EventStream, subtitles};
10pub use error::{Error, Result};
11pub use options::{SpeakOptions, normalize_voice};
12pub use protocol::{build_ssml, parse_binary_headers, parse_headers, parse_metadata, split_text};
13pub use subtitles::to_srt;
14pub use types::{Boundary, BoundaryEvent, SynthesisEvent, SynthesisResult, Voice, VoiceTag};
15
16#[cfg(test)]
17mod tests {
18 use super::*;
19 use crate::protocol::{
20 build_ssml, offset_from_audio_bytes, parse_headers, parse_metadata, split_text,
21 };
22
23 #[test]
24 fn normalizes_short_voice_name() {
25 let normalized = normalize_voice("en-US-EmmaMultilingualNeural").unwrap();
26 assert_eq!(
27 normalized,
28 "Microsoft Server Speech Text to Speech Voice (en-US, EmmaMultilingualNeural)"
29 );
30 }
31
32 #[test]
33 fn preserves_extended_region_voice_name() {
34 let normalized = normalize_voice("zh-CN-liaoning-XiaobeiNeural").unwrap();
35 assert_eq!(
36 normalized,
37 "Microsoft Server Speech Text to Speech Voice (zh-CN-liaoning, XiaobeiNeural)"
38 );
39 }
40
41 #[test]
42 fn rejects_invalid_pitch() {
43 let options = SpeakOptions {
44 pitch: "fast".to_owned(),
45 ..SpeakOptions::default()
46 };
47 assert!(matches!(options.validate(), Err(Error::InvalidPitch(_))));
48 }
49
50 #[test]
51 fn splits_text_without_breaking_entities() {
52 let chunks = split_text("hello & goodbye across the entity boundary", 15).unwrap();
53 assert!(chunks.iter().all(|chunk| !chunk.ends_with('&')));
54 }
55
56 #[test]
57 fn builds_valid_ssml() {
58 let ssml = build_ssml(
59 "Microsoft Server Speech Text to Speech Voice (en-US, EmmaMultilingualNeural)",
60 "+0%",
61 "+0%",
62 "+0Hz",
63 "Fish & Chips",
64 );
65 assert!(ssml.contains("Fish & Chips"));
66 }
67
68 #[test]
69 fn parses_text_headers() {
70 let frame = b"Path:audio.metadata\r\nX-RequestId:abc\r\n\r\n{\"Metadata\":[]}";
71 let header_end = frame
72 .windows(4)
73 .position(|window| window == b"\r\n\r\n")
74 .unwrap();
75 let (headers, payload) = parse_headers(frame, header_end).unwrap();
76 assert_eq!(headers.get("Path").unwrap(), "audio.metadata");
77 assert_eq!(payload, br#"{"Metadata":[]}"#);
78 }
79
80 #[test]
81 fn parses_metadata_events() {
82 let payload = br#"{"Metadata":[{"Type":"WordBoundary","Data":{"Offset":100,"Duration":200,"text":{"Text":"hello"}}}]}"#;
83 let events = parse_metadata(payload, 50).unwrap();
84 assert_eq!(
85 events,
86 vec![SynthesisEvent::Boundary(BoundaryEvent {
87 kind: Boundary::Word,
88 offset_ticks: 150,
89 duration_ticks: 200,
90 text: "hello".to_owned(),
91 })]
92 );
93 }
94
95 #[test]
96 fn compensates_offsets_from_audio_length() {
97 assert_eq!(offset_from_audio_bytes(6_000), 10_000_000);
98 }
99
100 #[test]
101 fn renders_srt() {
102 let srt = to_srt(&[BoundaryEvent {
103 kind: Boundary::Sentence,
104 offset_ticks: 0,
105 duration_ticks: 15_000_000,
106 text: "hello".to_owned(),
107 }]);
108 assert!(srt.contains("00:00:00,000 --> 00:00:01,500"));
109 }
110}