Skip to content

feat(wake): client-capture wake word for remote desktop - #75298

Closed
appletechie wants to merge 3 commits into
NousResearch:mainfrom
appletechie:feat/client-capture-wake-word
Closed

feat(wake): client-capture wake word for remote desktop#75298
appletechie wants to merge 3 commits into
NousResearch:mainfrom
appletechie:feat/client-capture-wake-word

Conversation

@appletechie

Copy link
Copy Markdown

What does this PR do?

Remote Hermes backends (Docker / headless VM / machine in another room) often have no microphone. Today wake word opens PortAudio on the server, so the desktop ear fails with “Failed to open the wake-word microphone.”

This PR keeps detection on the backend (openWakeWord / sherpa / porcupine unchanged) and adds client capture: the desktop streams 16 kHz mono int16 PCM over the existing authenticated WebSocket via a new wake.feed RPC. That enables remote agent + Mac mic hands-free.

Related Issue

N/A (user-reported remote-desktop wake failure on headless hosts)

Type of Change

  • 🐛 Bug fix (non-breaking change that fixes an issue)
  • ✨ New feature (non-breaking change that adds functionality)
  • 🔒 Security fix
  • 📝 Documentation update
  • ✅ Tests (adding or improving test coverage)
  • ♻️ Refactor (no behavior change)
  • 🎯 New skill (bundled or hub)

Changes Made

  • Add wake_word.capture: auto | local | client
  • Backend: external PCM queue on WakeWordDetector, feed_audio(), resolve_capture_mode()
  • Gateway: wake.feed RPC; wake.start / wake.status report capture, frame_length, sample_rate
  • Desktop: getUserMedia feeder (wake-client-capture.ts); GUI passes client_capture: true
  • Stop client feed on wake.detected so voice can take the mic; re-arm after voice
  • Docs: remote desktop (client capture) section in wake-word guide
  • Tests for capture mode, requirements without local mic, and feed → fire path

How to Test

  1. pytest tests/tools/test_wake_word.py -q (26 passed locally)
  2. Desktop connected to a headless remote backend (no /dev/snd):
    • Toggle ear on → should not show “Failed to open the wake-word microphone”
    • Allow Mac mic permission when prompted
    • Say “hey hermes” → voice session starts
  3. After a voice turn ends, ear re-arms without a manual toggle
  4. Local backend with a real mic still works (capture: local / auto without client prefer)
  5. CLI /wake on still uses local PortAudio (no client_capture)

Checklist

Code

  • I've read the Contributing Guide
  • My commit messages follow Conventional Commits (feat(wake): …)
  • I searched for existing PRs to make sure this isn't a duplicate
  • My PR contains only changes related to this fix/feature (no unrelated commits)
  • I've run pytest tests/tools/test_wake_word.py -q and tests pass
  • I've added tests for my changes
  • I've tested on my platform: Linux backend + needs desktop remote smoke on macOS (reviewer / follow-up)

Documentation & Housekeeping

  • I've updated relevant documentation (wake-word user guide)
  • I've updated cli-config.yaml.example if I added/changed config keys — config default is in hermes_cli/config_defaults.py (wake_word.capture)
  • I've updated CONTRIBUTING.md or AGENTS.md if I changed architecture or workflows — N/A
  • I've considered cross-platform impact (Windows, macOS) — client capture for desktop remote; CLI/TUI stay local PortAudio
  • I've updated tool descriptions/schemas if I changed tool behavior — N/A (gateway RPC, not model tools)

For New Skills

  • N/A

Andrew added 2 commits July 30, 2026 23:10
Remote headless backends have no PortAudio mic, so "hey hermes" fails even
when openWakeWord is installed. Let the desktop stream 16 kHz int16 PCM via
wake.feed while detection stays server-side.

- wake_word.capture: auto|local|client (+ GUI client_capture prefer)
- WakeWordDetector external_audio queue + feed_audio API
- wake.feed RPC; wake.start/status report capture + frame_length
- Desktop getUserMedia feeder; stop on wake.detected, re-arm after voice
- Docs + unit tests (26 pass in tests/tools/test_wake_word.py)

@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 addressing a real remote-backend gap: current main still opens the wake listener through backend sounddevice.InputStream in tools/wake_word.py:953-983.

Problems

  • tui_gateway/server.py:12814 derives wake.status.capture from raw config, not the active listener. With capture: auto, GUI start selects client capture (:12616-12618), but status reports local. After wake.detected stops the feeder (apps/desktop/src/app/contrib/wiring.tsx:688-690), resumeWakeAfterVoice() only reattaches it for status.capture === 'client' (apps/desktop/src/store/wake-word.ts:342-353). The resumed listener has no client PCM.
  • tui_gateway/server.py:12700 now passes external_audio, but current gateway test stubs at tests/test_tui_gateway_server.py:1459 and :1656 do not accept that keyword. Update those fixtures and add gateway RPC coverage.
  • Desktop /wake on still omits client_capture at apps/desktop/src/app/session/hooks/use-prompt-actions/slash.ts:677-684.
  • apps/desktop/src/lib/wake-client-capture.ts:113-127 drops any frame generated while a prior RPC awaits; with 80 ms frames (:11), remote latency can create detector gaps.

Suggested changes

  • Report the effective active capture mode/frame size, cover wake → voice → re-arm, pass client preference through Desktop slash handling, and use bounded queued PCM delivery.

Automated hermes-sweeper review.

Comment thread tui_gateway/server.py Outdated
hint = f"Wake-word input device could not be resolved: {input_device['error']}"
if silent and not hint:
hint = silent_audio_hint(input_device)
capture = reqs.get("capture") or str(cfg.get("capture") or "auto")

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.

For capture: auto, GUI wake.start selects client mode via prefer_client, but this fresh status probe has no such preference and reports local. After detection stops the feeder, the Desktop uses status.capture to decide whether to reattach it, so the resumed external listener receives no PCM. Return the active detector's effective mode (and frame metadata), or thread the caller capture preference through wake.status.

Comment thread tui_gateway/server.py
_on_detect,
owner=transport,
config=cfg,
external_audio=external_audio,

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.

Please update the existing gateway test doubles as part of this signature change: tests/test_tui_gateway_server.py:1459 and :1656 only accept callback, owner, and config, so this keyword makes those relevant tests fail before their assertions run.

let inflight = false

const pushFrame = async (frame: Float32Array) => {
if (stopped || inflight) {

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 drops every 80 ms audio frame while a previous wake.feed RPC is awaiting its response. That is especially risky for the remote/high-latency case this feature targets; use a bounded ordered queue or explicit backpressure that preserves contiguous PCM instead of discarding it.

- wake.status reports effective capture from the armed detector (client vs
  local), plus frame_length/sample_rate; GUI status probes prefer client
- Gateway test doubles accept external_audio on start_listening
- Desktop PCM feeder uses a bounded ordered queue instead of dropping frames
  while a wake.feed RPC is in flight
- /wake on and status/re-arm paths pass client_capture so remote reattach works
@appletechie

Copy link
Copy Markdown
Author

Addressed in 0d39c11:

  1. wake.status capture — reports effective mode from the armed detector (external_audioclient), plus frame_length/sample_rate. GUI status/re-arm probes pass client_capture / surface: gui.
  2. Gateway test doublesstart_listening stubs accept external_audio=False.
  3. PCM feed queue — bounded ordered queue (24 frames, ~1.9s) with serial drain; no discard-while-inflight gaps.
  4. Slash /wake on — passes client_capture: true (and status probes do too).

pytest tests/tools/test_wake_word.py 26 passed; gateway wake tests 3 passed.

@teknium1 teknium1 added sweeper:risk-compatibility Sweeper risk: may break existing users, config, migrations, defaults, or upgrades sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data sweeper:blast-moderate Sweeper blast radius: moderate — a subsystem or single platform labels Jul 31, 2026
@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) tool/tts Text-to-speech and transcription area/config Config system, migrations, profiles labels Jul 31, 2026
@appletechie

Copy link
Copy Markdown
Author

@teknium1 Anything else i need to do to get this rolling?

@OutThisLife

Copy link
Copy Markdown
Collaborator

Superseded by #79491, which carries your commits with authorship intact and adds one behavior fix on top: with capture: auto the desktop always preferred client streaming, so local desktops with a working backend mic lost PortAudio + wake_word.input_device selection (#74363). Auto now keeps the backend mic when one exists and streams from the desktop only on mic-less backends. Also batched the feed RPC and documented wake_word.capture in cli-config.yaml.example. Thanks for the solid design and the fast review turnaround — detection-on-backend with PCM transport over the existing WS was the right call.

@OutThisLife OutThisLife closed this Aug 5, 2026
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/desktop Electron desktop app (apps/desktop/*) comp/tui Terminal UI (ui-tui/ + tui_gateway/) 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 sweeper:risk-security-boundary Sweeper risk: may affect sandboxing, auth, credentials, or sensitive data tool/tts Text-to-speech and transcription type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants