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