use crate::auth::{current_date_string, random_hex};
use crate::connection::Ws;
use crate::error::TtsError;
use crate::event::TtsEvent;
use crate::protocol;
use crate::protocol::LOG_BODY_MAX_CHARS;
use crate::ssml;
use crate::tts::Engine;
use std::future::Future;
use std::time::Duration;
use tokio_tungstenite::tungstenite::Message;
const OUTPUT_FORMAT: &str = "audio-24khz-48kbitrate-mono-mp3";
const RECEIVE_IDLE_TIMEOUT: Duration = Duration::from_secs(30);
const REQ_ID_BYTES: usize = 16;
const PATH_TURN_END: &str = "turn.end";
const PATH_AUDIO_METADATA: &str = "audio.metadata";
const PATH_SPEECH_CONFIG: &str = "speech.config";
const PATH_SSML: &str = "ssml";
const MIME_JSON: &str = "application/json; charset=utf-8";
const MIME_SSML: &str = "application/ssml+xml";
const CFG_KEY_CONTEXT: &str = "context";
const CFG_KEY_SYNTHESIS: &str = "synthesis";
const CFG_KEY_AUDIO: &str = "audio";
const CFG_KEY_METADATA_OPTIONS: &str = "metadataoptions";
const CFG_KEY_SENTENCE_BOUNDARY_ENABLED: &str = "sentenceBoundaryEnabled";
const CFG_KEY_WORD_BOUNDARY_ENABLED: &str = "wordBoundaryEnabled";
const CFG_KEY_OUTPUT_FORMAT: &str = "outputFormat";
const CFG_VAL_FALSE: &str = "false";
const CFG_VAL_TRUE: &str = "true";
struct SynthRequest<'a> {
text: &'a str,
voice: &'a str,
rate: &'a str,
lang: &'a str,
}
pub struct EdgeTts;
impl Engine for EdgeTts {
fn synthesize(
&self,
text: &str,
voice: &str,
rate: &str,
lang: &str,
) -> impl Future<Output = Result<Vec<TtsEvent>, TtsError>> + Send {
let text = text.to_string();
let voice = voice.to_string();
let rate = rate.to_string();
let lang = lang.to_string();
async move {
let req = SynthRequest {
text: &text,
voice: &voice,
rate: &rate,
lang: &lang,
};
Self::run_synthesis(&req).await
}
}
}
impl EdgeTts {
async fn run_synthesis(req: &SynthRequest<'_>) -> Result<Vec<TtsEvent>, TtsError> {
let mut ws = Ws::connect().await?;
Self::send_config(&mut ws).await?;
Self::send_ssml(&mut ws, req).await?;
Self::receive_events(&mut ws).await
}
async fn send_config(ws: &mut Ws) -> Result<(), TtsError> {
let timestamp = current_date_string();
let config = format!(
"X-Timestamp:{timestamp}\r\n\
Content-Type:{MIME_JSON}\r\n\
Path:{PATH_SPEECH_CONFIG}\r\n\r\n\
{{\"{CFG_KEY_CONTEXT}\":{{\"{CFG_KEY_SYNTHESIS}\":{{\"{CFG_KEY_AUDIO}\":{{\
\"{CFG_KEY_METADATA_OPTIONS}\":{{\
\"{CFG_KEY_SENTENCE_BOUNDARY_ENABLED}\":\"{CFG_VAL_FALSE}\",\
\"{CFG_KEY_WORD_BOUNDARY_ENABLED}\":\"{CFG_VAL_TRUE}\"}},\
\"{CFG_KEY_OUTPUT_FORMAT}\":\"{OUTPUT_FORMAT}\"}}}}}}}}\r\n"
);
ws.send_text(config).await
}
async fn send_ssml(ws: &mut Ws, req: &SynthRequest<'_>) -> Result<(), TtsError> {
let request_id = random_hex(REQ_ID_BYTES);
let timestamp = current_date_string();
let ssml = ssml::build_ssml(req.text, req.voice, req.rate, req.lang);
let message = format!(
"X-RequestId:{request_id}\r\n\
Content-Type:{MIME_SSML}\r\n\
X-Timestamp:{timestamp}Z\r\n\
Path:{PATH_SSML}\r\n\r\n{ssml}"
);
ws.send_text(message).await
}
async fn receive_events(ws: &mut Ws) -> Result<Vec<TtsEvent>, TtsError> {
let mut events = Vec::new();
let mut got_audio = false;
while let Some(msg) = ws.recv_timeout(RECEIVE_IDLE_TIMEOUT).await? {
match msg {
Message::Binary(bin) => {
log::debug!("edge-tts BIN len={}", bin.len());
if let Some(audio) = protocol::parse_binary_audio(&bin) {
got_audio = true;
events.push(TtsEvent::Audio(audio));
}
}
Message::Text(text) => {
if Self::handle_text_message(&text, &mut events) {
break;
}
}
Message::Close(_) => break,
Message::Ping(data) => ws.send_pong(data).await,
ref other => log::debug!("edge-tts OTHER {other:?}"),
}
}
if !got_audio {
return Err(TtsError::NoAudio);
}
Ok(events)
}
fn handle_text_message(text: &str, events: &mut Vec<TtsEvent>) -> bool {
let (headers, body) = protocol::split_msg(text);
let path = headers.get("Path").map(String::as_str).unwrap_or("");
log::debug!(
"edge-tts TXT path={path} body[..{LOG_BODY_MAX_CHARS}]={}",
body.chars().take(LOG_BODY_MAX_CHARS).collect::<String>()
);
match path {
PATH_TURN_END => {
events.push(TtsEvent::TurnEnd);
true
}
PATH_AUDIO_METADATA => {
events.extend(protocol::parse_word_boundaries(body));
false
}
_ => false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn handle_turn_end_stops_and_pushes_event() {
let mut events = Vec::new();
let msg = "Path:turn.end\r\n\r\n";
let stop = EdgeTts::handle_text_message(msg, &mut events);
assert!(stop);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], TtsEvent::TurnEnd));
}
#[test]
fn handle_audio_metadata_extracts_word_boundaries() {
let mut events = Vec::new();
let msg = "Path:audio.metadata\r\n\r\n\
{\"Metadata\":[{\"Type\":\"WordBoundary\",\"Data\":\
{\"Offset\":500,\"Duration\":200,\"text\":{\"Text\":\"hi\"}}}]}";
let stop = EdgeTts::handle_text_message(msg, &mut events);
assert!(!stop);
assert_eq!(events.len(), 1);
}
#[test]
fn handle_unknown_path_continues() {
let mut events = Vec::new();
let msg = "Path:turn.start\r\n\r\n{}";
let stop = EdgeTts::handle_text_message(msg, &mut events);
assert!(!stop);
assert!(events.is_empty());
}
#[test]
fn handle_missing_path_header_continues() {
let mut events = Vec::new();
let msg = "Content-Type:text\r\n\r\nbody";
let stop = EdgeTts::handle_text_message(msg, &mut events);
assert!(!stop);
assert!(events.is_empty());
}
}