msedge-tts 0.4.0

This library is a wrapper of MSEdge Read aloud function API. You can use it to synthesize text to speech with many voices MS provided.
Documentation
//! TTS Client and Stream, SpeechConfig, Response Type.

use crate::error::{Error, Result};

/// Synthesis Config
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
pub struct SpeechConfig {
    pub voice_name: String,
    pub audio_format: String,
    pub pitch: i32,
    pub rate: i32,
    pub volume: i32,
}

impl From<&crate::voice::Voice> for SpeechConfig {
    fn from(voice: &crate::voice::Voice) -> Self {
        let audio_format = if let Some(ref audio_format) = voice.suggested_codec {
            audio_format.clone()
        } else {
            "audio-24khz-48kbitrate-mono-mp3".to_owned()
        };
        Self {
            voice_name: voice.name.clone(),
            audio_format,
            pitch: 0,
            rate: 0,
            volume: 0,
        }
    }
}

/// Audio Metadata
#[derive(Debug)]
pub struct AudioMetadata {
    pub metadata_type: Option<String>,
    pub offset: u64,
    pub duration: u64,
    pub text: Option<String>,
    pub length: u64,
    pub boundary_type: Option<String>,
}

impl AudioMetadata {
    fn from_str(text: &str) -> Result<Vec<Self>> {
        let value: serde_json::Value = serde_json::from_str(text)?;
        if let Some(items) = value["Metadata"].as_array() {
            let mut audio_metadata = Vec::new();
            for item in items {
                let metadata_type = item["Type"].as_str().map(|x| x.to_owned());
                let offset = item["Data"]["Offset"].as_u64().unwrap_or(0);
                let duration = item["Data"]["Duration"].as_u64().unwrap_or(0);
                let text = item["Data"]["text"]["Text"].as_str().map(|x| x.to_owned());
                let length = item["Data"]["text"]["Length"].as_u64().unwrap_or(0);
                let boundary_type = item["Data"]["text"]["BoundaryType"]
                    .as_str()
                    .map(|x| x.to_owned());
                audio_metadata.push(AudioMetadata {
                    metadata_type,
                    offset,
                    duration,
                    text,
                    length,
                    boundary_type,
                });
            }
            Ok(audio_metadata)
        } else {
            Err(Error::UnexpectedMessage(format!(
                "unexpected json text: {}",
                text
            )))
        }
    }
}

pub mod client;
pub mod stream;

enum Payload {
    AudioBytes((tungstenite::Bytes, usize)),
    AudioMetadata(Vec<AudioMetadata>),
}

impl Payload {
    fn process(
        message: tungstenite::Message,
        turn_start: &mut bool,
        response: &mut bool,
        turn_end: &mut bool,
    ) -> Result<Option<Payload>> {
        match message {
            tungstenite::Message::Text(text) => {
                if text.contains("audio.metadata") {
                    if let Some(index) = text.find("\r\n\r\n") {
                        let metadata = AudioMetadata::from_str(&text[index + 4..])?;
                        Ok(Some(Payload::AudioMetadata(metadata)))
                    } else {
                        Ok(None)
                    }
                } else if text.contains("turn.start") {
                    *turn_start = true;
                    Ok(None)
                } else if text.contains("response") {
                    *response = true;
                    Ok(None)
                } else if text.contains("turn.end") {
                    *turn_end = true;
                    Ok(None)
                } else {
                    Err(Error::UnexpectedMessage(format!(
                        "unexpected text message: {}",
                        text
                    )))
                }
            }
            tungstenite::Message::Binary(bytes) => {
                if *turn_start || *response {
                    let header_len = u16::from_be_bytes([bytes[0], bytes[1]]) as usize;
                    Ok(Some(Payload::AudioBytes((bytes, header_len + 2))))
                } else {
                    Ok(None)
                }
            }
            tungstenite::Message::Close(_) => {
                *turn_end = true;
                Ok(None)
            }
            _ => Err(Error::UnexpectedMessage(format!(
                "unexpected message: {}",
                message
            ))),
        }
    }
}

fn build_config_message(config: &SpeechConfig) -> tungstenite::Message {
    static SPEECH_CONFIG_HEAD: &str = r#"{"context":{"synthesis":{"audio":{"metadataoptions":{"sentenceBoundaryEnabled":"false","wordBoundaryEnabled":"true"},"outputFormat":""#;
    static SPEECH_CONFIG_TAIL: &str = r#""}}}}"#;
    let speech_config_message = format!(
        "X-Timestamp:{}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n{}{}{}",
        chrono::Local::now().to_rfc2822(),
        SPEECH_CONFIG_HEAD,
        config.audio_format,
        SPEECH_CONFIG_TAIL
    );
    tungstenite::Message::Text(speech_config_message.into())
}

fn build_ssml_message(text: &str, config: &SpeechConfig) -> tungstenite::Message {
    let ssml = format!(
        "<speak version='1.0' xmlns='http://www.w3.org/2001/10/synthesis' xml:lang='en-US'><voice name='{}'><prosody pitch='{:+}Hz' rate='{:+}%' volume='{:+}%'>{}</prosody></voice></speak>",
        config.voice_name, config.pitch, config.rate, config.volume, text,
    );
    let ssml_message = format!(
        "X-RequestId:{}\r\nContent-Type:application/ssml+xml\r\nX-Timestamp:{}\r\nPath:ssml\r\n\r\n{}",
        uuid::Uuid::new_v4().simple(),
        chrono::Local::now().to_rfc2822(),
        ssml,
    );
    tungstenite::Message::Text(ssml_message.into())
}

// try to fix china mainland 403 forbidden issue
// solution from:
// https://github.com/rany2/edge-tts/issues/290#issuecomment-2464956570
fn gen_sec_ms_gec() -> String {
    use sha2::Digest;

    // UTC time from 1601-01-01
    let duration = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        + std::time::Duration::from_secs(11644473600);
    let ticks = duration.as_nanos() / 100;
    let ticks = ticks - ticks % 3_000_000_000;

    let mut hasher = sha2::Sha256::new();
    hasher.update(format!("{ticks}6A5AA1D4EAFF4E9FB37E23D68491D6F4"));
    let hash_code = hasher.finalize();
    let mut hex_str = String::new();
    for &byte in hash_code.iter() {
        hex_str.push_str(&format!("{:02X}", byte));
    }
    hex_str
}

fn build_websocket_request() -> tungstenite::Result<tungstenite::handshake::client::Request> {
    use crate::constants;
    use tungstenite::client::IntoClientRequest;
    use tungstenite::http::header;

    let uuid = uuid::Uuid::new_v4().simple().to_string();
    let sec_ms_gec = gen_sec_ms_gec();
    let sec_ms_gec_version = "1-130.0.2849.68";
    let mut request = format!(
        "{}{}&Sec-MS-GEC={}&Sec-MS-GEC-Version={}",
        constants::WSS_URL,
        uuid,
        sec_ms_gec,
        sec_ms_gec_version
    )
    .into_client_request()?;
    let headers = request.headers_mut();
    headers.insert(header::PRAGMA, "no-cache".parse().unwrap());
    headers.insert(header::CACHE_CONTROL, "no-cache".parse().unwrap());
    headers.insert(header::USER_AGENT, constants::USER_AGENT.parse().unwrap());
    headers.insert(header::ORIGIN, constants::ORIGIN.parse().unwrap());
    Ok(request)
}

// we sure that target websocket server is TLS, so we can use rustls::StreamOwned directly
#[cfg(feature = "blocking")]
type RustlsStream<T> = rustls::StreamOwned<rustls::ClientConnection, T>;

#[cfg(feature = "blocking")]
fn websocket_connect() -> Result<tungstenite::WebSocket<RustlsStream<std::net::TcpStream>>> {
    use rustls::pki_types::ServerName;
    use rustls::{ClientConfig, ClientConnection, StreamOwned};
    use rustls_platform_verifier::ConfigVerifierExt;
    use std::sync::Arc;
    use tungstenite::{ClientHandshake, Error, HandshakeError, error::*};

    let request = build_websocket_request()?;
    let host = request
        .uri()
        .host()
        .ok_or(Error::Url(UrlError::NoHostName))?
        .to_owned();

    let stream = std::net::TcpStream::connect((host.as_str(), 443)).map_err(Error::Io)?;
    stream.set_nodelay(true).map_err(Error::Io)?;

    let config = ClientConfig::with_platform_verifier()
        .map_err(|e| Error::Tls(TlsError::Rustls(Box::new(e))))?;
    let name = ServerName::try_from(host).map_err(|_| Error::Tls(TlsError::InvalidDnsName))?;
    let client = ClientConnection::new(Arc::new(config), name)
        .map_err(|e| Error::Tls(TlsError::Rustls(Box::new(e))))?;

    let stream = StreamOwned::new(client, stream);
    let (websocket, _) = ClientHandshake::start(stream, request, None)?
        .handshake()
        .map_err(|e| match e {
            HandshakeError::Failure(e) => e,
            HandshakeError::Interrupted(_) => {
                panic!("Bug: blocking handshake not blocked")
            }
        })?;
    Ok(websocket)
}

#[cfg(feature = "smol-runtime")]
async fn websocket_connect_smol_async() -> Result<
    async_tungstenite::WebSocketStream<async_tungstenite::smol::ClientStream<smol::net::TcpStream>>,
> {
    let request = build_websocket_request()?;
    let (websocket, _) = async_tungstenite::smol::connect_async(request).await?;
    Ok(websocket)
}

#[cfg(feature = "tokio-runtime")]
async fn websocket_connect_tokio_async() -> Result<
    async_tungstenite::WebSocketStream<
        async_tungstenite::tokio::ClientStream<tokio::net::TcpStream>,
    >,
> {
    let request = build_websocket_request()?;
    let (websocket, _) = async_tungstenite::tokio::connect_async(request).await?;
    Ok(websocket)
}

#[cfg(feature = "proxy")]
mod proxy;