use futures_util::{SinkExt, StreamExt};
use std::sync::OnceLock;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::HeaderValue;
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio_tungstenite::{connect_async, tungstenite::Message};
const TRUSTED_CLIENT_TOKEN: &str = "6A5AA1D4EAFF4E9FB37E23D68491D6F4";
const WSS_URL: &str = "wss://speech.platform.bing.com/consumer/speech/synthesize/readaloud/edge/v1";
const CHROMIUM_MAJOR: &str = "143";
const SEC_MS_GEC_VERSION: &str = "1-143.0.3650.75";
const OUTPUT_FORMAT: &str = "audio-24khz-48kbitrate-mono-mp3";
const WIN_EPOCH_SECS: f64 = 11_644_473_600.0;
fn tls_connector() -> Option<tokio_tungstenite::Connector> {
static CONNECTOR: OnceLock<Option<tokio_tungstenite::Connector>> = OnceLock::new();
CONNECTOR
.get_or_init(|| {
let connector = native_tls::TlsConnector::new().ok()?;
Some(tokio_tungstenite::Connector::NativeTls(connector))
})
.clone()
}
fn debug_connector_once() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
crate::debug_log(&format!(
"edge tts: cached TLS connector = {}",
tls_connector().is_some()
));
});
}
#[allow(dead_code)] pub fn has_cached_tls_connector() -> bool {
tls_connector().is_some()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamEnd {
Complete,
Cancelled,
}
pub fn synthesize_stream(
text: &str,
voice: &str,
rate: &str,
pitch: &str,
volume: &str,
should_stop: &dyn Fn() -> bool,
mut on_chunk: impl FnMut(&[u8]),
) -> Result<(StreamEnd, usize), String> {
let rt = tokio::runtime::Runtime::new().map_err(|e| format!("Tokio runtime error: {e}"))?;
rt.block_on(synthesize_stream_async(
text,
voice,
rate,
pitch,
volume,
should_stop,
&mut on_chunk,
))
}
async fn synthesize_stream_async(
text: &str,
voice: &str,
rate: &str,
pitch: &str,
volume: &str,
should_stop: &dyn Fn() -> bool,
on_chunk: &mut dyn FnMut(&[u8]),
) -> Result<(StreamEnd, usize), String> {
let drm_token = generate_sec_ms_gec();
let muid = generate_muid();
let conn_id = uuid::Uuid::new_v4().simple();
let ws_url = format!(
"{WSS_URL}?TrustedClientToken={TRUSTED_CLIENT_TOKEN}\
&Sec-MS-GEC={drm_token}\
&Sec-MS-GEC-Version={SEC_MS_GEC_VERSION}\
&ConnectionId={conn_id}"
);
let mut request = ws_url
.as_str()
.into_client_request()
.map_err(|e| format!("Request build error: {e}"))?;
{
let headers = request.headers_mut();
headers.insert("Pragma", HeaderValue::from_static("no-cache"));
headers.insert("Cache-Control", HeaderValue::from_static("no-cache"));
headers.insert(
"Origin",
HeaderValue::from_static("chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold"),
);
headers.insert(
"User-Agent",
HeaderValue::from_str(&format!(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/{CHROMIUM_MAJOR}.0.0.0 Safari/537.36 \
Edg/{CHROMIUM_MAJOR}.0.0.0"
))
.map_err(|e| format!("User-Agent error: {e}"))?,
);
headers.insert(
"Cookie",
HeaderValue::from_str(&format!("muid={muid};"))
.map_err(|e| format!("Cookie error: {e}"))?,
);
}
let (mut ws, _) = if let Some(connector) = tls_connector() {
tokio_tungstenite::connect_async_tls_with_config(request, None, true, Some(connector))
.await
.map_err(|e| format!("WebSocket connect failed: {e}"))?
} else {
connect_async(request)
.await
.map_err(|e| format!("WebSocket connect failed: {e}"))?
};
let ts = date_to_string();
let config_msg = format!(
"X-Timestamp:{ts}\r\n\
Content-Type:application/json; charset=utf-8\r\n\
Path:speech.config\r\n\
\r\n\
{{\"context\":{{\"synthesis\":{{\"audio\":{{\"metadataoptions\":{{\
\"sentenceBoundaryEnabled\":\"true\",\n \"wordBoundaryEnabled\":\"true\"\n }},\"outputFormat\":\"{OUTPUT_FORMAT}\"}}}}}}}}"
);
ws.send(Message::Text(config_msg))
.await
.map_err(|e| format!("Send config error: {e}"))?;
let ssml = build_ssml(text, voice, rate, pitch, volume);
let req_id = uuid::Uuid::new_v4().simple();
let ssml_msg = format!(
"X-RequestId:{req_id}\r\n\
Content-Type:application/ssml+xml\r\n\
X-Timestamp:{ts}Z\r\n\
Path:ssml\r\n\
\r\n\
{ssml}"
);
ws.send(Message::Text(ssml_msg))
.await
.map_err(|e| format!("Send SSML error: {e}"))?;
debug_connector_once();
let mut total = 0usize;
let mut ended = StreamEnd::Complete;
loop {
if should_stop() {
ended = StreamEnd::Cancelled;
break;
}
let next = tokio::time::timeout(std::time::Duration::from_millis(100), ws.next()).await;
let msg = match next {
Err(_elapsed) => continue,
Ok(None) => break,
Ok(Some(Ok(msg))) => msg,
Ok(Some(Err(e))) => return Err(format!("WebSocket error: {e}")),
};
match msg {
Message::Text(text) => {
if text.contains("Path:turn.end") {
break;
}
}
Message::Binary(data) => {
if data.len() < 2 {
continue;
}
let header_len = u16::from_be_bytes([data[0], data[1]]) as usize;
if data.len() < 2 + header_len {
continue;
}
let header_bytes = &data[2..2 + header_len];
let header_str = String::from_utf8_lossy(header_bytes);
if header_str.contains("Path:audio") {
let audio_start = 2 + header_len;
if audio_start < data.len() {
let chunk = &data[audio_start..];
on_chunk(chunk);
total += chunk.len();
}
}
}
Message::Close(_) => break,
_ => {}
}
}
if total == 0 {
return Err("No audio data received from Edge TTS".into());
}
Ok((ended, total))
}
fn generate_sec_ms_gec() -> String {
let unix_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs_f64();
let mut ticks = unix_secs + WIN_EPOCH_SECS;
ticks -= ticks % 300.0;
let ticks_100ns = (ticks * 10_000_000.0) as u64;
let hash_input = format!("{ticks_100ns}{TRUSTED_CLIENT_TOKEN}");
let hash = Sha256::digest(hash_input.as_bytes());
hash.iter().map(|b| format!("{b:02X}")).collect()
}
fn generate_muid() -> String {
uuid::Uuid::new_v4().simple().to_string().to_uppercase()
}
fn date_to_string() -> String {
const DAYS: [&str; 7] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
const MONTHS: [&str; 12] = [
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
];
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0);
let days = secs.div_euclid(86_400);
let tod = secs.rem_euclid(86_400);
let (hh, mm, ss) = (tod / 3600, (tod % 3600) / 60, tod % 60);
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = if m <= 2 { y + 1 } else { y };
let weekday = (days + 4).rem_euclid(7) as usize;
format!(
"{} {} {:02} {} {:02}:{:02}:{:02} GMT+0000 (Coordinated Universal Time)",
DAYS[weekday],
MONTHS[(m - 1) as usize],
d,
y,
hh,
mm,
ss
)
}
fn build_ssml(text: &str, voice: &str, rate: &str, pitch: &str, volume: &str) -> String {
let escaped = text
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('\'', "'")
.replace('"', """);
format!(
"<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'>\
<voice name='{voice}'>\
<prosody pitch='{pitch}' rate='{rate}' volume='{volume}'>\
{escaped}\
</prosody>\
</voice>\
</speak>"
)
}