Skip to main content

edge_tts_rust/
lib.rs

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}