feat(daemon): refresh audio only when default output device changes#13
Merged
Conversation
Replaces the per-play `sd._terminate(); sd._initialize()` (10-50ms on every utterance) with a dirty-flag pattern driven by a macOS CoreAudio property listener. The worker reinitialises PortAudio only when the flag is set; the listener flips it on real default-output-device changes (filtering out volume changes, playback start, and other spurious notifications by comparing the actual device ID). - `_audio_dirty` (threading.Event, initially set) is the single coordination point. The worker reads-and-clears before each playback; the watcher and the failure path re-set it. - `_start_device_watcher()` registers an AudioObjectAddPropertyListener for kAudioHardwarePropertyDefaultOutputDevice via ctypes — no new dependencies. Runs CFRunLoopRun on a daemon thread. No-ops on non-macOS platforms; the dirty flag's initial state means the first playback still refreshes correctly there. - On playback failure the worker re-sets the dirty flag — if the exception was a stale audio context, the next request retries with a fresh one. Belt-and-suspenders for non-macOS where there's no listener. - Test rewritten to verify the new contract: initial-dirty refreshes once, clean state doesn't refresh again, re-dirtied state refreshes on the next playback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
1 issue found across 2 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="stackvox/daemon.py">
<violation number="1" location="stackvox/daemon.py:169">
P1: Race condition: clear the dirty flag *before* refreshing, not after. If a device change fires during `_refresh_audio_devices()` (10-50 ms window), the subsequent `clear()` swallows it and the next playback targets the wrong device.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
Comment on lines
+169
to
+171
| if _audio_dirty.is_set(): | ||
| _refresh_audio_devices() | ||
| _audio_dirty.clear() |
There was a problem hiding this comment.
P1: Race condition: clear the dirty flag before refreshing, not after. If a device change fires during _refresh_audio_devices() (10-50 ms window), the subsequent clear() swallows it and the next playback targets the wrong device.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At stackvox/daemon.py, line 169:
<comment>Race condition: clear the dirty flag *before* refreshing, not after. If a device change fires during `_refresh_audio_devices()` (10-50 ms window), the subsequent `clear()` swallows it and the next playback targets the wrong device.</comment>
<file context>
@@ -54,21 +69,106 @@ def _refresh_audio_devices() -> None:
except queue.Empty:
continue
- _refresh_audio_devices()
+ if _audio_dirty.is_set():
+ _refresh_audio_devices()
+ _audio_dirty.clear()
</file context>
Suggested change
| if _audio_dirty.is_set(): | |
| _refresh_audio_devices() | |
| _audio_dirty.clear() | |
| if _audio_dirty.is_set(): | |
| _audio_dirty.clear() | |
| _refresh_audio_devices() |
This was referenced Apr 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Replaces the per-play `sd._terminate(); sd._initialize()` (10–50ms on every utterance) with a dirty-flag pattern driven by a macOS CoreAudio property listener. PortAudio reinitialises only when the system default output device actually changes.
Why
The previous fix (in v0.2.0) reset PortAudio before every playback so device switches like swapping to Bluetooth headphones picked up automatically. Correct, but paid 10–50ms of latency per utterance even when nothing had changed. With the listener in place we keep the same device-switch correctness and reclaim that latency on the common path.
How
Tests
Existing test rewritten to verify the new contract:
`pytest`: 17/17 locally. `ruff check`, `ruff format --check`, `mypy` clean.
Follow-ups (separate commits on this branch)
Test plan
Summary by cubic
Refresh PortAudio only when the system default output device changes, instead of before every playback. This removes 10–50 ms of per-utterance latency while keeping instant device-switch behavior on macOS.
ctypes(runs a CFRunLoop on a daemon thread, filters spurious events by comparing device IDs). No new deps._audio_dirtyto gate refresh: set at start, cleared after refresh; re-set by the watcher and on playback errors so the next request reinitializes safely.Written for commit a4adef0. Summary will update on new commits. Review in cubic