Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 42 additions & 4 deletions src/tts/macos.rs
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,
}

Expand Down Expand Up @@ -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)
));
Comment on lines +64 to +68

Copy link
Copy Markdown

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 increasing CALL_SEQ). That's fine while speak() 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 in temp_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 (say then afplay), 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 a sumvox_macos_*.aiff file 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 stale sumvox_macos_*.aiff files on startup.

Copy link
Copy Markdown
Owner Author

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 相衝。維持現狀。


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 {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: temp file isn't cleaned up when say itself fails

Cleanup (std::fs::remove_file(&aiff_path)) only runs after a successful say invocation, on the line after this block. If say -o aiff_path ... exits non-zero after having already created or partially written the output file (e.g. a mid-synthesis error), this early return Err(...) leaves that file behind in the temp dir indefinitely.

normalize_to_wav in src/audio/normalize.rs handles the analogous case explicitly ("create() may have left a partial file behind before write_all failed" → best-effort remove_file). Worth mirroring that here with a best-effort cleanup before returning the error, e.g.:

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)));
}

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: neither the say -o ... synthesis (line 83) nor this afplay call has a timeout. src/audio/normalize.rs explicitly guards its ffmpeg subprocess with FFMPEG_TIMEOUT for exactly this reason — quoting its comment: "A stalled ffmpeg would otherwise block the calling thread forever (worst case for a notification tool)." This PR chains two sequential blocking subprocess calls (say then afplay) into the macOS path with no cap on either, so a wedged audio device or a stuck say synthesis would now hang the whole hook indefinitely with no way out.

Not a regression in run_afplay itself (pre-existing for the other providers too), but since this PR doubles the blocking-call surface for macOS specifically, it might be worth applying the same timeout+kill pattern here for consistency.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The 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)
Expand Down Expand Up @@ -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);
}
}
Loading