Skip to content

fix(tts): honor volume for macOS say via afplay - #11

Merged
musingfox merged 1 commit into
mainfrom
fix/macos-say-volume
Jul 13, 2026
Merged

musingfox merged 1 commit into
mainfrom
fix/macos-say-volume

Conversation

@musingfox

Copy link
Copy Markdown
Owner

問題

macOS `say` 沒有音量參數,原本 `MacOsTtsProvider` 的 `volume` 欄位標了 `#[allow(dead_code)]` 直接被忽略 —— 設定的音量(如 `notification_volume`)對通知完全無效,`say` 一律以系統音量播放。

在系統音量不可控的輸出裝置上(螢幕/DAC 回報 `output volume: missing value`),這代表通知永遠是硬體滿刻度、整段大聲,且 config 的 volume 旋鈕碰不到它。

修法

改用 `say -o tmp.aiff` 產檔,再走 `afplay -v {volume/100}` 播放:

  • 音量終於生效(`notification_volume = 40` → `-v 0.40`)
  • 與其他所有 provider 一樣經過 afplay 這個單一 choke point(順帶讓 menu bar avatar 嘴型對 macOS say 也會動)
  • temp 檔在每條路徑(含播放錯誤)都清理

測試

  • `cargo build` / `cargo clippy` clean
  • 新增 macOS-gated 測試跑完整 render+afplay 路徑(volume=1 近乎靜音)
  • 實跑 `sumvox say "測試" --tts macos` 確認 `volume=40` 正確帶入 afplay

備註

SoundSource 開頭漏音是其 ACE 攔截延遲的行為,程式端解不掉;搭配把 SoundSource 內 `afplay` 設回 100%、由 sumvox 的 `-v` 控音量即可根除。

🤖 Generated with Claude Code

Comment thread src/tts/macos.rs Outdated
);

// Render to a temp AIFF, then play via afplay so the volume knob applies.
let aiff_path = std::env::temp_dir().join("sumvox_macos.aiff");

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Race condition: fixed temp filename shared across concurrent invocations

aiff_path is always std::env::temp_dir().join("sumvox_macos.aiff") — the same path for every call, in every process. sumvox is invoked fresh per hook event, so two notifications firing close together (two Claude Code sessions, or two rapid hook events in one session) can race: process A's afplay may still be reading the file when process B's say -o overwrites it, or process B's say may still be writing when process A's cleanup (remove_file) deletes it out from under it. Net effect: wrong audio played, truncated/corrupted playback, or a spurious afplay error.

This project already solved exactly this problem in src/audio/normalize.rs::normalize_to_wav, which qualifies temp paths with std::process::id() plus a per-call sequence counter specifically "so concurrent calls (across or within a process) never clobber each other's files." This PR doesn't follow that established convention.

Suggest qualifying the path similarly, e.g.:

let aiff_path = std::env::temp_dir().join(format!("sumvox_macos_{}.aiff", std::process::id()));

Comment thread src/tts/macos.rs
Comment on lines 69 to 78
@@ -69,6 +77,11 @@ impl TtsProvider for MacOsTtsProvider {
return Err(VoiceError::Voice(format!("Say command failed: {}", stderr)));
}

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 行:),此為誤報 —— 建議的程式碼已存在。

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review: fix(tts): honor volume for macOS say via afplay

Overview

Fixes a real bug: MacOsTtsProvider.volume was dead code, so notification_volume never applied to macOS say notifications. The fix renders speech to a temp AIFF via say -o and plays it through the existing afplay -v choke point (run_afplay), which also picks up the "now playing" avatar-mouth hook for free. Small, focused diff (+27/-4), single file.

Correctness / edge cases

  • Volume now correctly reaches playback via run_afplay, which already clamps volume.min(100) to avoid over-amplification.
  • Empty/whitespace text still short-circuits before any subprocess work.
  • Race condition on the temp path — aiff_path is a fixed sumvox_macos.aiff in the shared system temp dir, not qualified per-process/per-call. Concurrent sumvox invocations (plausible since it's spawned fresh per hook event, e.g. two Claude Code sessions notifying close together) can clobber each other's file mid-write or mid-read. The codebase already has the fix for this exact class of bug in src/audio/normalize.rs::normalize_to_wav (PID + call-sequence qualified paths) — this PR doesn't reuse that pattern. Left as an inline comment.
  • Minor cleanup gap — if say itself exits non-zero after partially writing the output file, the early error return skips removing aiff_path, leaking the temp file. Cleanup only happens on the success-of-say path. Left as an inline comment with a suggested fix mirroring normalize_to_wav's handling of the analogous case.

Idiomatic Rust / clippy

  • The let result = ...; let _ = std::fs::remove_file(...); result?; pattern for "clean up on every path" matches the existing idiom in play_with_afplay, good consistency.
  • No obvious clippy-level issues in the diff itself.

Security

  • Subprocess args are passed via Command::arg (no shell interpolation), so no command-injection risk from user-controlled text or voice_name.
  • No secrets/credentials touched by this change.

Performance

  • No meaningful perf concern — one extra disk round-trip (write AIFF, read it back in afplay) versus the previous direct say playback, which is an acceptable and expected tradeoff for gaining volume control.

Tests

  • New #[cfg(target_os = "macos")] test exercises the full render+afplay path at volume=1 (near-silent), consistent with the existing pattern in audio/afplay.rs's macOS-gated tests. Good coverage for the happy path; no test covers the say-failure cleanup gap noted above (understandably hard to simulate without mocking).

@musingfox
musingfox force-pushed the fix/macos-say-volume branch from bc3b029 to 60b911b Compare July 12, 2026 20:03
Comment thread src/tts/macos.rs Outdated
Comment on lines +54 to +59
// Render to a temp AIFF, then play via afplay so the volume knob applies.
// Qualify the path with the pid so concurrent invocations (e.g. two rapid
// `sumvox say` calls, which don't hold the hook queue lock) can't clobber
// each other's file — same convention as audio/normalize.rs.
let aiff_path =
std::env::temp_dir().join(format!("sumvox_macos_{}.aiff", std::process::id()));

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: PID-only temp filename isn't actually the same collision-safety scheme as audio/normalize.rs.

normalize.rs explicitly notes that PID alone is not sufficient for uniqueness because concurrent calls within the same process share a PID, and adds a monotonic CALL_SEQ counter on top of the PID for that reason. This code only uses std::process::id().

Today that's safe because every speak() call site (main.rs, hooks/claude_code.rs) awaits providers sequentially in a loop, so there's never two in-flight speak() calls in one process. But it's a latent footgun: if this ever changes (e.g. a future parallel-fallback attempt, or a second macOS-gated test added to this file that also exercises the full say+afplay path — cargo test runs tests in the same binary/PID concurrently by default), two calls would race on the exact same aiff_path, and one's remove_file cleanup could delete the file out from under the other's afplay/say -o write.

Two low-cost options:

  • Fix the comment to not claim parity with normalize.rs (it deliberately omits the piece that makes that scheme collision-safe under same-process concurrency), or
  • Reuse the same CALL_SEQ-style suffix here for actual parity and future-proofing.

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.

採納:已把註解改誠實,不再宣稱與 normalize.rs 同 scheme,並註明 pid 足夠是因為 speak() 於同一 process 內只會循序呼叫。未加 CALL_SEQ 計數器,因為它只為防不存在的同 process 並行(YAGNI);真出現第二個全路徑測試再補即可。

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review: fix(tts): honor volume for macOS say via afplay

Overview
Fixes MacOsTtsProvider ignoring volume (previously #[allow(dead_code)]) by rendering say output to a temp AIFF (say -o) and playing it through the existing run_afplay choke point, which already applies -v {volume/100}. Temp file is cleaned up on every path (say failure, afplay failure, success). A macOS-gated integration test exercises the full render+play path at low volume.

Correctness

  • Error handling looks solid: say failure removes the partial temp file before returning; afplay failure (via run_afplay) still triggers cleanup because the result is captured before remove_file runs, then propagated with ? — same capture-then-cleanup idiom already used in audio/afplay.rs::play_with_afplay and audio/normalize.rs.
  • Subprocess usage is safe: both say and afplay are invoked via Command with separate .arg() calls (no shell interpolation), so no injection risk from the text argument.
  • Left one inline comment: the temp filename is qualified with std::process::id() only, not the PID+monotonic-counter scheme audio/normalize.rs uses (and which the new code comment claims parity with). Harmless today since all speak() call sites await providers sequentially, but worth either correcting the comment or adding the same counter for future-proofing (e.g. if a second full-path macOS test is added later, since cargo test runs same-binary tests concurrently by default).

Style / idiomatic Rust

  • Matches existing conventions well (mirrors play_with_afplay's capture-result / clean-up-unconditionally / propagate pattern).
  • No clippy-level concerns spotted in the diff (couldn't run cargo clippy in this Linux sandbox since say/afplay are macOS-only, but the diff itself doesn't introduce anything obviously lint-triggering).

Security

  • No secrets involved; no shell string concatenation. Predictable temp filename in the temp dir is consistent with the existing pattern used by every other TTS provider in this codebase (sumvox_google.wav, sumvox_openai.wav, etc.), so not a new risk introduced by this PR.

Performance

  • Adds a disk round-trip (write AIFF, then have afplay read it) versus the previous behavior of say doing playback directly. This is inherent to the fix's design (needed to get a volume knob at all) and consistent with how every other provider in this codebase already plays audio, so it's a reasonable tradeoff, not a regression.

Test coverage

  • Good: keeps the existing empty/whitespace tests, adds a macOS-gated end-to-end test for the new path. No non-macOS-gated test can exercise the render/play logic, but that matches the rest of the TTS test suite.

Overall: solid, well-scoped fix consistent with existing project conventions. One minor doc/consistency nit left inline; no blocking issues found.

@musingfox
musingfox force-pushed the fix/macos-say-volume branch from 60b911b to 9741eaf Compare July 12, 2026 20:09
@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review: fix(tts): honor volume for macOS say via afplay

Nice fix — routing macOS `say` through `afplay` to finally honor the volume setting is a sensible approach, and it's consistent with the existing `run_afplay`/`play_with_afplay` choke point used by the other providers. A few notes:

Correctness

  • Temp file naming relies on PID only, unlike `src/audio/normalize.rs` which additionally uses a monotonic `CALL_SEQ` counter specifically because "concurrent calls — even within one process, where the PID is identical — never collide." The in-code comment justifies skipping the counter here because `speak()` is only ever awaited sequentially in the current call sites (main.rs, hooks/claude_code.rs) — that's true today, but it's a fragile invariant to encode only as a comment. If a future caller ever fires TTS concurrently (e.g. two hook events overlapping, or a test added alongside test_speak_renders_and_plays that also exercises speak()), two calls in the same process/test binary would race on the exact same sumvox_macos_{pid}.aiff path — one call's remove_file could delete the file out from under the other's in-flight afplay. Worth at least matching normalize.rs's pattern (PID + counter) for defense in depth, since the cost is trivial.

Minor / style

  • The temp path std::env::temp_dir().join(format!("sumvox_macos_{}.aiff", pid)) is a predictable, fixed name in a world-writable directory (same pattern as elevenlabs.rs's static sumvox_elevenlabs.mp3, so not a regression, but worth eventually migrating these to the tempfile crate already used elsewhere in the repo for collision/symlink-safe temp files).
  • crate::audio::afplay::run_afplay is called via fully-qualified path rather than a use import at the top of the file — minor readability nit, not a functional issue.

Testing

  • The new #[cfg(target_os = "macos")] test_speak_renders_and_plays exercises the real say/afplay binaries. This mirrors the existing pattern in afplay.rs's macOS-gated tests, so it should be fine on macos-latest CI runners, but it does add a real subprocess + audio playback round-trip to the test suite (small CI time/flakiness surface).

Overall the fix looks correct for the actual call paths in this codebase today (cargo build/clippy clean per the PR description), and cleans up the temp file on every path including playback failure. The concurrency point above is the only thing I'd consider addressing before merge, and even that could reasonably be a fast-follow given the current call sites are provably sequential.

Comment thread src/tts/macos.rs Outdated
Comment on lines +54 to +60
// Render to a temp AIFF, then play via afplay so the volume knob applies.
// Qualify by pid so two separate invocations (e.g. rapid `sumvox say`
// calls that don't hold the hook queue lock) can't clobber each other.
// pid alone suffices because speak() is only ever called sequentially
// within a process; no CALL_SEQ counter (unlike normalize.rs) is needed.
let aiff_path =
std::env::temp_dir().join(format!("sumvox_macos_{}.aiff", std::process::id()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Temp file name is qualified only by PID, relying on the invariant that speak() is never called concurrently within the same process. That's true for the current call sites (sequential awaits in main.rs/hooks/claude_code.rs), but src/audio/normalize.rs hit this exact issue and added a monotonic CALL_SEQ counter alongside the PID specifically because "concurrent calls — even within one process, where the PID is identical — never collide." If this invariant is ever broken (e.g. a future test also exercising this path on another thread within the same test binary, or a caller that fans out TTS concurrently), two calls would race on sumvox_macos_{pid}.aiff — one's remove_file (line 89) could delete the file while the other's afplay/say is still using it. Consider adopting the same PID+counter pattern here for consistency/robustness.

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.

採納 repo 慣例:已改用 PID + CALL_SEQ 計數器(對齊 audio/normalize.rs),消除同 process 並行時的潛在 race。

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) <noreply@anthropic.com>
@musingfox
musingfox force-pushed the fix/macos-say-volume branch from 9741eaf to 1863c61 Compare July 12, 2026 20:17
Comment thread src/tts/macos.rs
Comment on lines +64 to +68
let aiff_path = std::env::temp_dir().join(format!(
"sumvox_macos_{}_{}.aiff",
std::process::id(),
CALL_SEQ.fetch_add(1, Ordering::Relaxed)
));

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

Comment thread src/tts/macos.rs
Comment on lines +95 to +98
// 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?;

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 音量修正,不夾帶。

@claude

claude Bot commented Jul 12, 2026

Copy link
Copy Markdown

Review Summary

What this PR does: Fixes the macOS say TTS provider silently ignoring the configured volume. It now renders speech to a temp AIFF via say -o, then plays it back through afplay -v {volume/100} — the same choke point every other provider already uses. Temp file cleanup is handled on every return path (success, say failure, and afplay failure).

Strengths

  • Correctly fixes the underlying bug: volume was previously #[allow(dead_code)] and had zero effect on macOS notifications.
  • Cleanup runs on all paths, including the "partial file left by a failed say" case and the "afplay failed" case — matches the existing result-then-cleanup pattern already used in play_with_afplay.
  • No shell interpolation — text, voice, and rate are passed as discrete Command args, so there's no command-injection surface here.
  • Adds a macOS-gated integration test (test_speak_renders_and_plays) exercising the full say → afplay handoff at near-silent volume, plus the volume/rate fields are now covered by the existing constructor test.
  • cargo build/cargo clippy reported clean per the PR description, and the diff itself doesn't introduce obvious clippy issues.

Points raised inline

  1. Temp-file leak risk on kill (src/tts/macos.rs:64-68) — the new path mints a unique, never-reused filename per call (pid + CALL_SEQ), unlike the other providers' fixed-name temp files that get overwritten on the next call. If the hook process is ever killed mid-flight (external timeout, Ctrl-C, crash) before cleanup runs, that file is orphaned in temp_dir() forever. This PR also doubles the blocking-subprocess window (say then afplay) versus the previous single-call path, widening that exposure. Suggest either reusing a fixed filename (calls appear serialized already) or sweeping stale sumvox_macos_*.aiff files on startup.
  2. No timeout on the say/afplay chain (src/tts/macos.rs:95-98) — src/audio/normalize.rs explicitly caps its ffmpeg subprocess for the same reason ("a stalled process would otherwise block the calling thread forever — worst case for a notification tool"). This path has no equivalent guard, and now chains two blocking calls instead of one. Minor/pre-existing-pattern, but worth considering for consistency.

Neither point is a regression in the volume fix itself — both are pre-existing patterns elsewhere in the codebase, but this PR increases the exposure by adding a second blocking subprocess call and switching to a non-overwriting temp filename scheme.

@musingfox
musingfox merged commit b2f12d8 into main Jul 13, 2026
8 checks passed
@musingfox
musingfox deleted the fix/macos-say-volume branch October 1, 2026 02:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant