Skip to main content

kothok_edge_tts/
edge_tts.rs

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