car-voice 0.47.0

Voice I/O capability for CAR — mic capture, VAD, listener/speaker traits
//! [`Speaker`] via `Windows.Media.SpeechSynthesis` (WinRT) — Windows' built-in
//! OS text-to-speech, the parity analog of the macOS `AppleSpeech`
//! (AVSpeechSynthesizer) path: free, on-device, no model download, no MLX.
//! Windows-only; other targets reject `TtsProvider::WindowsSpeech` in
//! `provider::build_tts_speaker`.
//!
//! `SynthesizeTextToStreamAsync` yields a WAV (PCM) stream, which we read into
//! bytes. WinRT's async ops are driven synchronously here (`IAsyncOperation::get`),
//! so synthesis runs on a blocking pool.

use async_trait::async_trait;
use windows::core::HSTRING;
use windows::Media::SpeechSynthesis::SpeechSynthesizer;
use windows::Storage::Streams::DataReader;

use crate::tts::{AudioFormat, Speaker, SynthesizedAudio};
use crate::{Result, VoiceConfig, VoiceError};

/// In-process Windows OS TTS.
#[derive(Debug, Clone)]
pub struct WindowsSpeechSpeaker {
    /// Optional voice name (matched against `VoiceInformation::DisplayName` by
    /// case-insensitive substring). Empty → the system default voice.
    voice: String,
    /// WinRT speaking rate (`SpeechSynthesizerOptions::SpeakingRate`): 0.5..6.0,
    /// 1.0 = normal. Other providers treat `local_tts_speed` as a 1.0 = normal
    /// multiplier, which maps directly.
    rate: f64,
}

impl WindowsSpeechSpeaker {
    /// Construct from a [`VoiceConfig`].
    pub fn from_config(config: &VoiceConfig) -> Self {
        Self {
            voice: config.local_tts_voice.clone(),
            rate: (config.local_tts_speed as f64).clamp(0.5, 6.0),
        }
    }

    fn synth_blocking(voice: String, rate: f64, text: String) -> Result<Vec<u8>> {
        let synth = SpeechSynthesizer::new()
            .map_err(|e| VoiceError::Tts(format!("SpeechSynthesizer::new: {e}")))?;

        // Speaking rate — best-effort (older Windows may not expose it).
        if let Ok(opts) = synth.Options() {
            let _ = opts.SetSpeakingRate(rate);
        }

        // Voice by DisplayName substring, if the user named one and it exists.
        if !voice.is_empty() {
            if let Ok(all) = SpeechSynthesizer::AllVoices() {
                let want = voice.to_lowercase();
                let count = all.Size().unwrap_or(0);
                for i in 0..count {
                    if let Ok(info) = all.GetAt(i) {
                        if let Ok(name) = info.DisplayName() {
                            if name.to_string_lossy().to_lowercase().contains(&want) {
                                let _ = synth.SetVoice(&info);
                                break;
                            }
                        }
                    }
                }
            }
        }

        let stream = synth
            .SynthesizeTextToStreamAsync(&HSTRING::from(text))
            .map_err(|e| VoiceError::Tts(format!("SynthesizeTextToStreamAsync: {e}")))?
            .get()
            .map_err(|e| VoiceError::Tts(format!("synthesize await: {e}")))?;

        let size = stream
            .Size()
            .map_err(|e| VoiceError::Tts(format!("stream size: {e}")))?;
        let input = stream
            .GetInputStreamAt(0)
            .map_err(|e| VoiceError::Tts(format!("input stream: {e}")))?;
        let reader = DataReader::CreateDataReader(&input)
            .map_err(|e| VoiceError::Tts(format!("data reader: {e}")))?;
        reader
            .LoadAsync(size as u32)
            .map_err(|e| VoiceError::Tts(format!("load async: {e}")))?
            .get()
            .map_err(|e| VoiceError::Tts(format!("load await: {e}")))?;
        let mut buf = vec![0u8; size as usize];
        reader
            .ReadBytes(&mut buf)
            .map_err(|e| VoiceError::Tts(format!("read bytes: {e}")))?;
        Ok(buf)
    }
}

#[async_trait]
impl Speaker for WindowsSpeechSpeaker {
    async fn synth(&self, text: &str) -> Result<SynthesizedAudio> {
        let voice = self.voice.clone();
        let rate = self.rate;
        let text = text.to_string();
        let bytes = tokio::task::spawn_blocking(move || Self::synth_blocking(voice, rate, text))
            .await
            .map_err(|e| VoiceError::Tts(format!("winrt tts join: {e}")))??;
        // SynthesizeTextToStreamAsync produces a WAV (PCM) container.
        Ok(SynthesizedAudio {
            bytes,
            format: AudioFormat::Wav,
        })
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // End-to-end against the real WinRT synthesizer (on-device, no network).
    #[tokio::test]
    async fn synthesizes_valid_wav() {
        let speaker = WindowsSpeechSpeaker {
            voice: String::new(),
            rate: 1.0,
        };
        let audio = speaker
            .synth("Testing CAR Windows speech synthesis.")
            .await
            .expect("synthesis should succeed on Windows");
        assert!(matches!(audio.format, AudioFormat::Wav));
        assert!(
            audio.bytes.len() > 1000,
            "expected real audio, got {} bytes",
            audio.bytes.len()
        );
        // A WAV container: "RIFF"...."WAVE".
        assert_eq!(&audio.bytes[0..4], b"RIFF", "expected RIFF header");
        assert_eq!(&audio.bytes[8..12], b"WAVE", "expected WAVE tag");
    }
}