Skip to content

fix: wake word mic-busy retry with actionable error messages - #74996

Open
tomekdot wants to merge 3 commits into
NousResearch:mainfrom
tomekdot:fix/wake-word-mic-busy-retry
Open

fix: wake word mic-busy retry with actionable error messages#74996
tomekdot wants to merge 3 commits into
NousResearch:mainfrom
tomekdot:fix/wake-word-mic-busy-retry

Conversation

@tomekdot

Copy link
Copy Markdown

Problem

When the microphone is already held by another process or Hermes instance, wake word initialization fails immediately with the opaque message:

Wake-word microphone is already owned.

This gives users no indication of what to do next.

Solution

This PR adds:

1. Auto-retry with configurable backoff

When the mic is busy, now retries every 10s (configurable) instead of failing immediately. This handles the common case where Desktop GUI holds the mic briefly at startup.

Config options in wake_word:

  • retry_on_busy (default: true)
  • retry_interval (default: 10 seconds)
  • retry_max_attempts (default: 0 = unlimited)

2. Actionable error messages

When retry is exhausted, the error message now:

  • Names likely culprits (Teams, Discord, Zoom, other Hermes instances)
  • Suggests hermes config set wake_word.enabled false as a workaround
  • Differentiates between cross-process and in-process ownership

Testing

  • All 15 non-audio unit tests pass
  • Syntax verified on both modified files
  • 7 audio-device tests skipped (pre-existing: no mic in CI environment)

Files changed

  • tools/wake_word.py — retry logic + better error messages
  • hermes_cli/config_defaults.py — new retry config defaults

Copilot AI review requested due to automatic review settings July 30, 2026 19:54
@alt-glitch alt-glitch added type/bug Something isn't working comp/cli CLI entry point, hermes_cli/, setup wizard tool/tts Text-to-speech and transcription area/config Config system, migrations, profiles P3 Low — cosmetic, nice to have sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades labels Jul 30, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR aims to improve wake-word startup reliability and UX by adding configurable retry/backoff behavior when the microphone is busy, and by replacing the previous opaque “already owned” errors with more actionable guidance. This fits into Hermes’s voice/wake-word subsystem (tools/wake_word.py) and its default configuration surface (hermes_cli/config_defaults.py).

Changes:

  • Adds wake-word mic-busy retry configuration (retry_on_busy, retry_interval, retry_max_attempts) and retry loop behavior in start_listening().
  • Expands the cross-process mic lock error message to suggest likely culprits and a config-based workaround.
  • Extends default config values for wake_word with the new retry keys.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
tools/wake_word.py Adds retry config + retry loop and updates mic ownership error messaging.
hermes_cli/config_defaults.py Adds default values for the new wake-word retry configuration keys.
Comments suppressed due to low confidence (1)

tools/wake_word.py:1203

  • Auto-retry doesn’t apply to the most common mic-busy case: _acquire_machine_lock() raises WakeWordInUse before lock_handle is assigned, and that exception currently bypasses the retry loop. As a result, cross-process ownership still fails immediately instead of backing off.
        # No retry needed — proceed to acquire lock and build detector
        # Acquire lock and build detector (inside the retry loop)
        lock_handle = _acquire_machine_lock()
        try:

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tools/wake_word.py
Comment on lines +1190 to +1199
# Sleep outside the detector_lock so other threads can proceed
if retry_on and (_detector_owner is not owner):
if retry_max and attempt > retry_max:
raise WakeWordInUse(
"Wake-word microphone is already owned and retry limit "
f"({retry_max}) reached. Run: hermes config set "
"wake_word.enabled false"
)
time.sleep(retry_interval)
continue
Comment thread tools/wake_word.py
Comment on lines +1161 to +1165
# Resolve retry config
_cfg = config if config is not None else load_wake_word_config()
retry_on = bool(_cfg.get("retry_on_busy", _RETRY_ON_BUSY_DEFAULT))
retry_interval = max(1, int(_cfg.get("retry_interval", _RETRY_INTERVAL_SECONDS)))
retry_max = max(0, int(_cfg.get("retry_max_attempts", _RETRY_MAX_ATTEMPTS_DEFAULT)))
@tomekdot
tomekdot force-pushed the fix/wake-word-mic-busy-retry branch from 434991d to e78f0c2 Compare July 30, 2026 20:32

@teknium1 teknium1 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Thanks for improving a real wake-word startup failure path. The current implementation needs correction before the retry behavior can satisfy the PR's stated cross-process/device-busy goal.

Problems

  • tools/wake_word.py:1205 calls _acquire_machine_lock() before the try whose WakeWordInUse handler starts at line 1220. A held machine lock therefore raises out immediately; it never retries. This confirms the existing review observation.
  • WakeWordDetector.start() turns microphone-open failures into RuntimeError (tools/wake_word.py:923-928), but the new retry branch catches only WakeWordInUse (tools/wake_word.py:1220). Audio-backend busy failures therefore still fail immediately.
  • The new retry test at tests/tools/test_wake_word.py:648 covers only the in-process _detector path. The pre-existing cross-process lock fixture at lines 603-621 is not used to validate retry.

Suggested changes

  • Retry the machine-lock failure explicitly and add a real held-lock/release test.
  • Define and test the retryable device-open error boundary separately from non-retryable startup errors.
  • Reconcile waiting behavior with the sticky-ownership contract in website/docs/user-guide/features/wake-word.md:150-154, and restore the test file's original line endings.

Automated hermes-sweeper review.

Comment thread tools/wake_word.py Outdated
raise WakeWordInUse(
"Wake-word microphone is already owned by another "
"surface in this process (e.g. desktop app + CLI)."
)
lock_handle = _acquire_machine_lock()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_acquire_machine_lock() can itself raise WakeWordInUse, but it is evaluated before the try at line 1206. That bypasses the retry handler below, so the cross-process lock case still fails immediately. Move acquisition into retry handling and cover a held machine lock in a test.

Comment thread tools/wake_word.py Outdated
)
_detector = detector
_detector_owner = owner
_detector_file_lock = lock_handle
detector.start()
return detector
except WakeWordInUse:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This catches only WakeWordInUse, whereas WakeWordDetector.start() wraps microphone-open failures as RuntimeError (tools/wake_word.py:923-928). The actual audio-device-busy path therefore is not retried; classify and retry only the relevant startup failure.

assert isinstance(defaults, dict)


def test_start_listening_retries_when_detector_held(monkeypatch, tmp_path):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This covers only a pre-populated in-process _detector. Add a test using the existing cross-process machine-lock fixture so the retry path verifies the failure raised by _acquire_machine_lock().

@teknium1 teknium1 added the sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform label Jul 30, 2026
@tomekdot

Copy link
Copy Markdown
Author

Thanks @teknium1 for the detailed review.

You're right on both points:

  1. The real audio-device-busy path surfaces as RuntimeError from WakeWordDetector.start() (tools/wake_word.py:923-928), not WakeWordInUse, so the current except WakeWordInUse does not actually retry that case.
  2. The test only exercises the pre-populated in-process _detector path.

I'm not able to keep iterating on this PR right now, so I'll leave it as-is and let the maintainers pick it up — feel free to apply the fixes or close it if a better approach lands on main. Thanks again!

@tomekdot

tomekdot commented Aug 7, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed reviews — both points were real bugs. Pushed a fixup that addresses each:

Copilot / hermes-sweeper #1 — cross-process lock never retried
_acquire_machine_lock() was called before the retry try, so a held cross-process lease raised WakeWordInUse immediately and bypassed the backoff. Moved the lock acquisition inside the retryable region; the most common busy case now actually retries. Also added a dedicated actionable message on retry exhaustion (names the limit, unlike the generic "already owned" text).

hermes-sweeper #2 — device-busy failures never retried
WakeWordDetector.start() turned a mic-open failure into RuntimeError, but the retry only caught WakeWordInUse. Now a transient device-busy failure raises a new private _MicBusy (retryable) while hard failures (no device, missing deps) stay a non-retryable RuntimeError — so we don't retry forever on a real error. The retryable/non-retryable boundary is defined explicitly in _is_mic_busy_error().

hermes-sweeper #3 — test coverage + sticky-ownership contract

  • Added real cross-process lock-busy tests (test_start_listening_retries_when_machine_lock_held, ..._gives_up_after_max_retries_machine_lock) using a held on-disk lock, not just the in-process _detector path.
  • Added test_mic_busy_error_is_retryable_boundary, test_start_listening_retries_on_device_busy, and test_start_listening_does_not_retry_on_hard_mic_failure covering the new error boundary.
  • Fixed a latent bug the new tests exposed: a half-built detector wasn't cleared on retry, so the next loop hit the idempotent early-return and never retried.
  • Documented the retry_* keys in wake-word.md and reconciled the retry with the sticky-ownership contract (retry only re-acquires; it never fails over to another surface).

Verified locally: all 9 retry tests + the pre-existing test_startup_failure_releases_owner_and_machine_lock pass. (Two tests fail in my sandbox only because the 632 MB repo's bundled tools/wakewords/*.onnx isn't in my sparse checkout — same failures on the unmodified branch.)

I couldn't see any CI runs on this PR (gh pr checks reports none) — if there's a required check I should watch, let me know.

hermes-seaeye Bot and others added 2 commits August 7, 2026 20:51
When the microphone is already held by another process or Hermes
instance, wake word initialization previously failed immediately with
the opaque message 'Wake-word microphone is already owned.'

This commit:
- Adds auto-retry with configurable interval and max attempts
  (wake_word.retry_on_busy / retry_interval / retry_max_attempts)
- Provides actionable error messages that name likely culprits
  (Teams, Discord, Zoom, other Hermes instances) and suggest
  hermes config set wake_word.enabled false as a workaround
- Logs retry attempts so users can see what is happening
- Adds unit tests for retry behavior (held-by-other, max retries,
  retry disabled)
…h non-retryable boundary

Address review feedback (Copilot + hermes-sweeper) on the mic-busy retry:

- Move _acquire_machine_lock() inside the retryable try so the
  cross-process mic lease (the most common busy case) actually retries
  instead of failing immediately outside the loop.
- Treat a transient device-busy mic-open failure as retryable via a new
  private _MicBusy error; hard failures (no device / missing deps) stay
  non-retryable RuntimeError so we never retry forever on a real error.
- Add _is_mic_busy_error() to classify the retryable/non-retryable error
  boundary explicitly, as the review requested.
- Clear half-built detector state on retry so the next loop re-acquires
  the lock rather than hitting the idempotent early-return.
- Document the retry keys and reconcile with the sticky-ownership contract.
- Add real cross-process lock-busy + device-busy retry tests.
@tomekdot
tomekdot marked this pull request as draft August 7, 2026 18:52
@tomekdot
tomekdot force-pushed the fix/wake-word-mic-busy-retry branch 2 times, most recently from 94c86df to f13b5f9 Compare August 7, 2026 18:54
@tomekdot

tomekdot commented Aug 7, 2026

Copy link
Copy Markdown
Author

Note for maintainers: the PR UI shows CONFLICTING, but this appears to be a stale mergeability cache on the fork PR. Verified locally:

  • git merge-tree --write-tree origin/main fix/wake-word-mic-busy-retry produces a clean tree with no conflict markers.
  • origin/main is already an ancestor of this branch (rebased onto current main), so the branch fully contains main.
  • No CRLF/.gitattributes divergence in the touched files (tools/wake_word.py, tests/tools/test_wake_word.py).

A force-push / re-review usually clears the cached state. If it still shows conflicting after a refresh, let me know and I'll dig further.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/config Config system, migrations, profiles comp/cli CLI entry point, hermes_cli/, setup wizard P3 Low — cosmetic, nice to have sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades tool/tts Text-to-speech and transcription type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants