kothok_edge_tts/
edge_tts.rs1use 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
48pub 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 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 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 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}