rpi-voice 0.2.1

Voice conversation extension for rpi — offline STT + auto-TTS replies
//! Microsoft Edge TTS — free, no API key required.
//!
//! Implements the WebSocket protocol used by Microsoft Edge's read-aloud feature.
//! Returns MP3 audio bytes (audio-24khz-48kbitrate-mono-mp3).

use futures_util::{SinkExt, StreamExt};
use sha2::{Digest, Sha256};
use std::time::{SystemTime, UNIX_EPOCH};
use tokio_tungstenite::{connect_async, tungstenite::Message};

// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------

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;

// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------

/// Synthesize text to speech. Returns MP3 audio bytes.
///
/// This is a synchronous wrapper — creates a tokio runtime internally.
pub fn synthesize(
    text: &str,
    voice: &str,
    rate: &str,
    pitch: &str,
    volume: &str,
) -> Result<Vec<u8>, String> {
    let rt = tokio::runtime::Runtime::new().map_err(|e| format!("Tokio runtime error: {e}"))?;
    rt.block_on(synthesize_async(text, voice, rate, pitch, volume))
}

// ---------------------------------------------------------------------------
// Async implementation
// ---------------------------------------------------------------------------

async fn synthesize_async(
    text: &str,
    voice: &str,
    rate: &str,
    pitch: &str,
    volume: &str,
) -> Result<Vec<u8>, 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}"
    );

    // Build from the URI so tungstenite generates the handshake headers
    // (Sec-WebSocket-Key/Version, Host, Connection/Upgrade). Hand-building a
    // `http::Request` skips that and the server rejects the upgrade.
    use tokio_tungstenite::tungstenite::client::IntoClientRequest;
    use tokio_tungstenite::tungstenite::http::HeaderValue;

    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, _) = connect_async(request)
        .await
        .map_err(|e| format!("WebSocket connect failed: {e}"))?;

    // Send speech.config — text frames are `headers + "\r\n\r\n" + body`
    // (the `--` delimiter belongs to binary audio frames only).
    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}"))?;

    // Send SSML
    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}"))?;

    // Collect audio chunks
    let mut audio_data = Vec::new();

    while let Some(msg) = ws.next().await {
        match msg {
            Ok(Message::Text(text)) => {
                if text.contains("Path:turn.end") {
                    break;
                }
            }
            Ok(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() {
                        audio_data.extend_from_slice(&data[audio_start..]);
                    }
                }
            }
            Ok(Message::Close(_)) => break,
            Err(e) => return Err(format!("WebSocket error: {e}")),
            _ => {}
        }
    }

    if audio_data.is_empty() {
        return Err("No audio data received from Edge TTS".into());
    }

    Ok(audio_data)
}

// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

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 {
    // JavaScript-compatible UTC date string, e.g.
    // `Sat Sep 27 2026 01:45:00 GMT+0000 (Coordinated Universal Time)`.
    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);

    // Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
    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('&', "&amp;")
        .replace('<', "&lt;")
        .replace('>', "&gt;")
        .replace('\'', "&apos;")
        .replace('"', "&quot;");

    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>"
    )
}