Repository navigation
fix(tts): honor volume for macOS say via afplay #11
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String>, | ||
| 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))); | ||
| } | ||
|
Comment on lines
82
to
93
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: temp file isn't cleaned up when Cleanup (
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let _ = std::fs::remove_file(&aiff_path);
return Err(VoiceError::Voice(format!("Say command failed: {}", stderr)));
}
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 已在錯誤返回前補上 best-effort 清理(第 79–84 行:),此為誤報 —— 建議的程式碼已存在。 |
||
|
|
||
| // 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?; | ||
|
Comment on lines
+95
to
+98
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Minor: neither the Not a regression in
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 超出本 PR 範圍。如你所述,無 timeout 是所有 provider 經 run_afplay 的既有性質、非本 PR 引入;若要加應對所有 provider 統一處理,屬另一個變更。本 PR 只做 macOS say 音量修正,不夾帶。 |
||
|
|
||
| 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); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Unlike the other providers'
play_with_afplay, which reuse a fixed temp filename per provider (e.g.sumvox_google.wav, overwritten on every call), this path mints a brand-new, never-repeated filename per invocation (pid+ monotonically increasingCALL_SEQ). That's fine whilespeak()runs to completion, but if the process is ever killed before the cleanup lines run — e.g. an external hook timeout, Ctrl-C, or a crash mid-playback — the file is orphaned intemp_dir()permanently, since that exact(pid, seq)pair will never occur again to get overwritten.This path is now more exposed to that risk than before: previously
speak()made one blocking subprocess call (say); now it chains two (saythenafplay), roughly doubling the window during which an external kill can land mid-flight. Since this is a Claude Code hook (typically time-boxed) that fires on every notification, repeated timeouts/kills could leak asumvox_macos_*.aifffile per occurrence with nothing ever sweeping them up.Worth considering: reuse a fixed filename (e.g.
sumvox_macos.aiff) like the other providers — since calls here appear to already be serialized through the fallback loop — or add a best-effort sweep of stalesumvox_macos_*.aifffiles on startup.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
不改。呼叫已序列化(hook queue lock + 序列 fallback 迴圈),孤兒檔僅在 process 被 kill 於兩次 subprocess 之間才會殘留,且是 temp_dir 裡數十 KB 的 AIFF、由 OS 週期清理。加啟動掃除是替非問題造新機制;而改回固定檔名又與上一輪要求的 CALL_SEQ 相衝。維持現狀。