Skip to main content

kothok_edge_tts/
edge_tts.rs

1//! `EdgeTts` - the concrete Edge Read-Aloud TTS engine.
2//!
3//! Implements [`Engine`] by opening a fresh WebSocket per synthesis request,
4//! sending the `speech.config` and `ssml` messages, then collecting streamed
5//! [`TtsEvent`]s until `turn.end`.
6
7use crate::auth::{current_date_string, random_hex};
8use crate::connection::Ws;
9use crate::error::TtsError;
10use crate::event::TtsEvent;
11use crate::protocol;
12use crate::protocol::LOG_BODY_MAX_CHARS;
13use crate::ssml;
14use crate::tts::Engine;
15use std::future::Future;
16use std::time::Duration;
17use tokio_tungstenite::tungstenite::Message;
18
19const OUTPUT_FORMAT: &str = "audio-24khz-48kbitrate-mono-mp3";
20const RECEIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
21const REQ_ID_BYTES: usize = 16;
22
23const PATH_TURN_END: &str = "turn.end";
24const PATH_AUDIO_METADATA: &str = "audio.metadata";
25const PATH_SPEECH_CONFIG: &str = "speech.config";
26const PATH_SSML: &str = "ssml";
27
28const MIME_JSON: &str = "application/json; charset=utf-8";
29const MIME_SSML: &str = "application/ssml+xml";
30
31const CFG_KEY_CONTEXT: &str = "context";
32const CFG_KEY_SYNTHESIS: &str = "synthesis";
33const CFG_KEY_AUDIO: &str = "audio";
34const CFG_KEY_METADATA_OPTIONS: &str = "metadataoptions";
35const CFG_KEY_SENTENCE_BOUNDARY_ENABLED: &str = "sentenceBoundaryEnabled";
36const CFG_KEY_WORD_BOUNDARY_ENABLED: &str = "wordBoundaryEnabled";
37const CFG_KEY_OUTPUT_FORMAT: &str = "outputFormat";
38const CFG_VAL_FALSE: &str = "false";
39const CFG_VAL_TRUE: &str = "true";
40
41struct SynthRequest<'a> {
42    text: &'a str,
43    voice: &'a str,
44    rate: &'a str,
45    lang: &'a str,
46}
47
48/// A stateless Edge-TTS engine.
49///
50/// Construct `EdgeTts` (a unit struct) and call [`Engine::synthesize`].
51/// Each call opens its own WebSocket; there is no connection reuse.
52///
53/// ```no_run
54/// # async fn run() -> Result<(), Box<dyn std::error::Error>> {
55/// use kothok_edge_tts::Engine;
56/// kothok_edge_tts::init_tls();
57/// let events = kothok_edge_tts::EdgeTts
58///     .synthesize("Hello world.", "en-US-EmmaMultilingualNeural", "+0%", "en-US")
59///     .await?;
60/// # Ok(()) }
61/// ```
62pub struct EdgeTts;
63
64impl Engine for EdgeTts {
65    fn synthesize(
66        &self,
67        text: &str,
68        voice: &str,
69        rate: &str,
70        lang: &str,
71    ) -> impl Future<Output = Result<Vec<TtsEvent>, TtsError>> + Send {
72        let text = text.to_string();
73        let voice = voice.to_string();
74        let rate = rate.to_string();
75        let lang = lang.to_string();
76        async move {
77            let req = SynthRequest {
78                text: &text,
79                voice: &voice,
80                rate: &rate,
81                lang: &lang,
82            };
83            Self::run_synthesis(&req).await
84        }
85    }
86}
87
88impl EdgeTts {
89    /// Full synthesis pipeline: connect -> configure -> request -> receive.
90    async fn run_synthesis(req: &SynthRequest<'_>) -> Result<Vec<TtsEvent>, TtsError> {
91        let mut ws = Ws::connect().await?;
92        Self::send_config(&mut ws).await?;
93        Self::send_ssml(&mut ws, req).await?;
94        Self::receive_events(&mut ws).await
95    }
96
97    async fn send_config(ws: &mut Ws) -> Result<(), TtsError> {
98        let timestamp = current_date_string();
99        let config = format!(
100            "X-Timestamp:{timestamp}\r\n\
101             Content-Type:{MIME_JSON}\r\n\
102             Path:{PATH_SPEECH_CONFIG}\r\n\r\n\
103             {{\"{CFG_KEY_CONTEXT}\":{{\"{CFG_KEY_SYNTHESIS}\":{{\"{CFG_KEY_AUDIO}\":{{\
104             \"{CFG_KEY_METADATA_OPTIONS}\":{{\
105             \"{CFG_KEY_SENTENCE_BOUNDARY_ENABLED}\":\"{CFG_VAL_FALSE}\",\
106             \"{CFG_KEY_WORD_BOUNDARY_ENABLED}\":\"{CFG_VAL_TRUE}\"}},\
107             \"{CFG_KEY_OUTPUT_FORMAT}\":\"{OUTPUT_FORMAT}\"}}}}}}}}\r\n"
108        );
109        ws.send_text(config).await
110    }
111
112    async fn send_ssml(ws: &mut Ws, req: &SynthRequest<'_>) -> Result<(), TtsError> {
113        let request_id = random_hex(REQ_ID_BYTES);
114        let timestamp = current_date_string();
115        let ssml = ssml::build_ssml(req.text, req.voice, req.rate, req.lang);
116
117        let message = format!(
118            "X-RequestId:{request_id}\r\n\
119             Content-Type:{MIME_SSML}\r\n\
120             X-Timestamp:{timestamp}Z\r\n\
121             Path:{PATH_SSML}\r\n\r\n{ssml}"
122        );
123        ws.send_text(message).await
124    }
125
126    /// Receive messages until `turn.end` or stream close, collecting events.
127    /// Returns [`TtsError::NoAudio`] if the turn ends without any audio frames.
128    async fn receive_events(ws: &mut Ws) -> Result<Vec<TtsEvent>, TtsError> {
129        let mut events = Vec::new();
130        let mut got_audio = false;
131
132        while let Some(msg) = ws.recv_timeout(RECEIVE_IDLE_TIMEOUT).await? {
133            match msg {
134                Message::Binary(bin) => {
135                    log::debug!("edge-tts BIN len={}", bin.len());
136                    if let Some(audio) = protocol::parse_binary_audio(&bin) {
137                        got_audio = true;
138                        events.push(TtsEvent::Audio(audio));
139                    }
140                }
141                Message::Text(text) => {
142                    if Self::handle_text_message(&text, &mut events) {
143                        break;
144                    }
145                }
146                Message::Close(_) => break,
147                Message::Ping(data) => ws.send_pong(data).await,
148                ref other => log::debug!("edge-tts OTHER {other:?}"),
149            }
150        }
151
152        if !got_audio {
153            return Err(TtsError::NoAudio);
154        }
155        Ok(events)
156    }
157
158    /// Process one text message.  Returns `true` if the caller should stop
159    /// receiving (i.e. `turn.end` was received).
160    fn handle_text_message(text: &str, events: &mut Vec<TtsEvent>) -> bool {
161        let (headers, body) = protocol::split_msg(text);
162        let path = headers.get("Path").map(String::as_str).unwrap_or("");
163
164        log::debug!(
165            "edge-tts TXT path={path} body[..{LOG_BODY_MAX_CHARS}]={}",
166            body.chars().take(LOG_BODY_MAX_CHARS).collect::<String>()
167        );
168
169        match path {
170            PATH_TURN_END => {
171                events.push(TtsEvent::TurnEnd);
172                true
173            }
174            PATH_AUDIO_METADATA => {
175                events.extend(protocol::parse_word_boundaries(body));
176                false
177            }
178            _ => false,
179        }
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn handle_turn_end_stops_and_pushes_event() {
189        let mut events = Vec::new();
190        let msg = "Path:turn.end\r\n\r\n";
191        let stop = EdgeTts::handle_text_message(msg, &mut events);
192        assert!(stop);
193        assert_eq!(events.len(), 1);
194        assert!(matches!(events[0], TtsEvent::TurnEnd));
195    }
196
197    #[test]
198    fn handle_audio_metadata_extracts_word_boundaries() {
199        let mut events = Vec::new();
200        let msg = "Path:audio.metadata\r\n\r\n\
201            {\"Metadata\":[{\"Type\":\"WordBoundary\",\"Data\":\
202            {\"Offset\":500,\"Duration\":200,\"text\":{\"Text\":\"hi\"}}}]}";
203        let stop = EdgeTts::handle_text_message(msg, &mut events);
204        assert!(!stop);
205        assert_eq!(events.len(), 1);
206    }
207
208    #[test]
209    fn handle_unknown_path_continues() {
210        let mut events = Vec::new();
211        let msg = "Path:turn.start\r\n\r\n{}";
212        let stop = EdgeTts::handle_text_message(msg, &mut events);
213        assert!(!stop);
214        assert!(events.is_empty());
215    }
216
217    #[test]
218    fn handle_missing_path_header_continues() {
219        let mut events = Vec::new();
220        let msg = "Content-Type:text\r\n\r\nbody";
221        let stop = EdgeTts::handle_text_message(msg, &mut events);
222        assert!(!stop);
223        assert!(events.is_empty());
224    }
225}