use crate::auth::{self, random_hex, SEC_MS_GEC_VERSION, TRUSTED_CLIENT_TOKEN};
use crate::error::TtsError;
use futures_util::{SinkExt, StreamExt};
use std::time::Duration;
use tokio_tungstenite::{
connect_async,
tungstenite::{client::IntoClientRequest, http::HeaderValue, Message},
};
const ENDPOINT_BASE: &str = "speech.platform.bing.com/consumer/speech/synthesize/readaloud";
const ORIGIN: &str = "chrome-extension://jdiccldimpdaibmpdkjnbmckianbfold";
const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0";
const NO_CACHE: &str = "no-cache";
const MAX_CONNECT_ATTEMPTS: u8 = 3;
const SKEW_NUDGE_SECS: i64 = 60;
const MUID_BYTES: usize = 16;
const CONN_ID_BYTES: usize = 16;
type WsStream =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
pub(crate) struct Ws {
inner: WsStream,
}
impl Ws {
pub(crate) async fn connect() -> Result<Self, TtsError> {
let mut skew = 0i64;
let mut last_err = String::new();
for attempt in 0..MAX_CONNECT_ATTEMPTS {
match Self::try_connect(skew).await {
Ok(ws) => return Ok(ws),
Err(TtsError::Ws(e)) => {
let msg = e.to_string();
last_err = msg.clone();
if msg.contains("403") || msg.contains("401") {
skew += SKEW_NUDGE_SECS;
}
log::warn!("edge-tts connect attempt {attempt} failed: {msg} (skew={skew})");
}
Err(e) => return Err(e),
}
}
Err(TtsError::Connect(last_err))
}
async fn try_connect(skew: i64) -> Result<Self, TtsError> {
let gec = auth::sec_ms_gec(skew);
let muid = random_hex(MUID_BYTES);
let conn_id = random_hex(CONN_ID_BYTES);
let url = format!(
"wss://{ENDPOINT_BASE}/edge/v1\
?TrustedClientToken={TRUSTED_CLIENT_TOKEN}\
&ConnectionId={conn_id}\
&Sec-MS-GEC={gec}\
&Sec-MS-GEC-Version={SEC_MS_GEC_VERSION}"
);
let mut req = url.into_client_request()?;
let headers = req.headers_mut();
headers.insert("origin", HeaderValue::from_static(ORIGIN));
headers.insert("user-agent", HeaderValue::from_static(USER_AGENT));
headers.insert(
"cookie",
HeaderValue::from_str(&format!("muid={muid};"))
.map_err(|e| TtsError::Connect(format!("bad cookie header: {e}")))?,
);
headers.insert("pragma", HeaderValue::from_static(NO_CACHE));
headers.insert("cache-control", HeaderValue::from_static(NO_CACHE));
headers.insert(
"accept-language",
HeaderValue::from_static("en-US,en;q=0.9"),
);
headers.insert(
"accept-encoding",
HeaderValue::from_static("gzip, deflate, br, zstd"),
);
let (inner, resp) = connect_async(req).await?;
log::debug!("edge-tts WS connected: HTTP {}", resp.status());
Ok(Ws { inner })
}
pub(crate) async fn send_text(&mut self, msg: String) -> Result<(), TtsError> {
self.inner.send(Message::Text(msg)).await?;
Ok(())
}
pub(crate) async fn send_pong(&mut self, data: Vec<u8>) {
let _ = self.inner.send(Message::Pong(data)).await;
}
pub(crate) async fn recv_timeout(
&mut self,
timeout: Duration,
) -> Result<Option<Message>, TtsError> {
match tokio::time::timeout(timeout, self.inner.next()).await {
Ok(Some(msg)) => Ok(Some(msg?)),
Ok(None) => Ok(None),
Err(_) => Err(TtsError::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"edge-tts receive idle timeout exceeded",
))),
}
}
}