Skip to content

feat(daemon): refresh audio only when default output device changes#13

Merged
StuBehan merged 1 commit into
mainfrom
feat/macos-device-watcher
Apr 29, 2026
Merged

feat(daemon): refresh audio only when default output device changes#13
StuBehan merged 1 commit into
mainfrom
feat/macos-device-watcher

Conversation

@StuBehan
Copy link
Copy Markdown
Collaborator

@StuBehan StuBehan commented Apr 29, 2026

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

  • `_audio_dirty` (`threading.Event`, initially set) is the single coordination point. The worker reads-and-clears it before each playback; the listener 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.
  • The listener filters spurious notifications. macOS fires the property listener for volume changes, playback start, and other side effects too — we read the actual default-output device ID and only mark dirty on a real change.
  • Cross-platform safe. `_start_device_watcher()` no-ops on non-macOS; the dirty flag's initial-set state still gives the first playback a refresh, and the worker's failure-retry path catches subsequent device changes there.
  • Failure-retry path. If `tts.speak` raises, the worker re-sets the dirty flag — a stale audio context recovers on the next request without manual intervention.

Tests

Existing test rewritten to verify the new contract:

  • Initial-dirty state at startup → first playback refreshes.
  • Second playback with no device change → no extra refresh.
  • Re-marking dirty (simulating a device change) → next playback refreshes again.

`pytest`: 17/17 locally. `ruff check`, `ruff format --check`, `mypy` clean.

Follow-ups (separate commits on this branch)

  • Log non-zero `AudioObjectAddPropertyListener` status so a silent registration failure is observable.
  • Tighten the `_ca_refs` type annotation to `list[Any]` for future strict-mypy.

Test plan

  • CI passes (lint, format, mypy, tests 3.10–3.13 Ubuntu, test-macos py3.12, PR-title, commit lint).
  • Manual: start the daemon, swap from speakers to Bluetooth mid-session, send `stackvox say "..."`. Audio follows the new device on the very next request.
  • Manual: the first request after `stackvox serve` includes the one-shot reinit (matches today's behavior).

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.

  • New Features
    • Added a macOS device watcher using CoreAudio’s default-output property listener via ctypes (runs a CFRunLoop on a daemon thread, filters spurious events by comparing device IDs). No new deps.
    • Introduced _audio_dirty to gate refresh: set at start, cleared after refresh; re-set by the watcher and on playback errors so the next request reinitializes safely.
    • Cross-platform safe: watcher no-ops off macOS; first playback still refreshes; failure path handles later device changes. Tests updated to assert one refresh per dirty cycle.

Written for commit a4adef0. Summary will update on new commits. Review in cubic

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>
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

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

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 thread stackvox/daemon.py
Comment on lines +169 to +171
if _audio_dirty.is_set():
_refresh_audio_devices()
_audio_dirty.clear()
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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()

@StuBehan StuBehan merged commit dd68606 into main Apr 29, 2026
11 checks passed
This was referenced Apr 29, 2026
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