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 ACCEPT_LANGUAGE: &str = "en-US,en;q=0.9";
const ACCEPT_ENCODING: &str = "gzip, deflate, br, zstd";
const HTTP_FORBIDDEN: &str = "403";
const HTTP_UNAUTHORIZED: &str = "401";
const MAX_CONNECT_ATTEMPTS: u8 = 3;
const SKEW_NUDGE_SECS: i64 = 60;
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
const HEADER_ORIGIN: &str = "origin";
const HEADER_USER_AGENT: &str = "user-agent";
const HEADER_COOKIE: &str = "cookie";
const HEADER_PRAGMA: &str = "pragma";
const HEADER_CACHE_CONTROL: &str = "cache-control";
const HEADER_ACCEPT_LANGUAGE: &str = "accept-language";
const HEADER_ACCEPT_ENCODING: &str = "accept-encoding";
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(HTTP_FORBIDDEN) || msg.contains(HTTP_UNAUTHORIZED) {
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(HEADER_ORIGIN, HeaderValue::from_static(ORIGIN));
headers.insert(HEADER_USER_AGENT, HeaderValue::from_static(USER_AGENT));
headers.insert(
HEADER_COOKIE,
HeaderValue::from_str(&format!("muid={muid};"))
.map_err(|e| TtsError::Connect(format!("bad cookie header: {e}")))?,
);
headers.insert(HEADER_PRAGMA, HeaderValue::from_static(NO_CACHE));
headers.insert(HEADER_CACHE_CONTROL, HeaderValue::from_static(NO_CACHE));
headers.insert(
HEADER_ACCEPT_LANGUAGE,
HeaderValue::from_static(ACCEPT_LANGUAGE),
);
headers.insert(
HEADER_ACCEPT_ENCODING,
HeaderValue::from_static(ACCEPT_ENCODING),
);
let (inner, resp) = tokio::time::timeout(CONNECT_TIMEOUT, connect_async(req))
.await
.map_err(|_| {
TtsError::Connect(format!(
"WebSocket connect timed out after {CONNECT_TIMEOUT:?}"
))
})??;
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",
))),
}
}
}