diff --git a/src/tts/macos.rs b/src/tts/macos.rs index b6e90f3..724a984 100644 --- a/src/tts/macos.rs +++ b/src/tts/macos.rs @@ -1,16 +1,25 @@ // macOS say command TTS provider +use std::sync::atomic::{AtomicU64, Ordering}; + use async_trait::async_trait; use tokio::process::Command; use super::TtsProvider; use crate::error::{Result, VoiceError}; +// Per-call counter so the temp path is unique even for concurrent calls that +// share a PID — same collision-safety scheme as audio/normalize.rs. +static CALL_SEQ: AtomicU64 = AtomicU64::new(0); + /// macOS TTS provider using the built-in `say` command pub struct MacOsTtsProvider { voice_name: Option, rate: u32, - #[allow(dead_code)] // macOS say doesn't support volume control + // `say` itself has no volume flag, so we render to a file and let afplay + // apply `-v {volume/100}` on playback. This also routes macOS TTS through + // the same afplay choke point as every other provider (honors the volume + // knob on output devices with no software system volume, drives the avatar). volume: u32, } @@ -42,12 +51,24 @@ impl TtsProvider for MacOsTtsProvider { } tracing::info!( - "Speaking with macOS say: voice={:?}, rate={}", + "Speaking with macOS say: voice={:?}, rate={}, volume={}", self.voice_name, - self.rate + self.rate, + self.volume ); + // Render to a temp AIFF, then play via afplay so the volume knob applies. + // Qualify by PID + per-call counter so concurrent invocations (rapid + // `sumvox say` calls that don't hold the hook queue lock, or across + // processes) never clobber each other's file. + let aiff_path = std::env::temp_dir().join(format!( + "sumvox_macos_{}_{}.aiff", + std::process::id(), + CALL_SEQ.fetch_add(1, Ordering::Relaxed) + )); + let mut cmd = Command::new("say"); + cmd.arg("-o").arg(&aiff_path); // Only add -v argument if voice is specified and not empty if let Some(ref voice) = self.voice_name { @@ -58,17 +79,24 @@ impl TtsProvider for MacOsTtsProvider { cmd.arg("-r").arg(self.rate.to_string()).arg(text); - // Blocking: wait for completion + // Blocking: wait for synthesis to finish let output = cmd .output() .await .map_err(|e| VoiceError::Voice(format!("Say command failed: {}", e)))?; if !output.status.success() { + // `say` may have left a partial file behind before failing. + let _ = std::fs::remove_file(&aiff_path); let stderr = String::from_utf8_lossy(&output.stderr); return Err(VoiceError::Voice(format!("Say command failed: {}", stderr))); } + // Play with afplay -v; clean up on every path (including playback error). + let result = crate::audio::afplay::run_afplay(&aiff_path, self.volume); + let _ = std::fs::remove_file(&aiff_path); + result?; + tracing::debug!("Voice playback completed (blocking)"); Ok(true) @@ -120,4 +148,14 @@ mod tests { let result = provider.speak(" ").await.unwrap(); assert!(!result); } + + // Exercises the full render-to-file + afplay path at a low volume; fails if + // `say -o` or the afplay handoff breaks. Uses volume 1 to stay near-silent. + #[cfg(target_os = "macos")] + #[tokio::test] + async fn test_speak_renders_and_plays() { + let provider = MacOsTtsProvider::new(None, 300, 1); + let result = provider.speak("test").await.unwrap(); + assert!(result); + } }