From 1863c613f85082bf7676f99f4a37d5dd1799c9ed Mon Sep 17 00:00:00 2001 From: musingfox Date: Sun, 12 Jul 2026 22:03:04 +0800 Subject: [PATCH] fix(tts): honor volume for macOS say via afplay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit macOS `say` has no volume flag, so the configured volume (e.g. notification_volume) was silently ignored — notifications always played at system volume, which is uncontrollable on output devices reporting no software volume (missing value). Render to a temp AIFF and play through afplay -v {volume/100} instead, routing macOS TTS through the same afplay choke point as every other provider (also drives the avatar mouth-flap). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/tts/macos.rs | 46 ++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 42 insertions(+), 4 deletions(-) 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); + } }