Skip to content

feat: voice dictation — Slack/Discord audio → text, supervised with the daemon - #111

Merged
kwliang1 merged 15 commits into
mainfrom
kevinliang/voice-dictation
Jul 11, 2026
Merged

feat: voice dictation — Slack/Discord audio → text, supervised with the daemon#111
kwliang1 merged 15 commits into
mainfrom
kevinliang/voice-dictation

Conversation

@kwliang1

@kwliang1 kwliang1 commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Voice dictation: inbound audio attachments (Discord voice notes, Slack voice clips / audio uploads) are transcribed to text via a self-hosted sidecar, merged into the message as [voice transcript] .... Audio stays local — Parakeet-MLX on macOS (Apple Silicon), Canary-Qwen 2.5B on Linux+GPU.
  • Supervised sidecar: hydra up starts the sidecar alongside the daemon, hydra down stops it (only when no other platform's daemon is running — shared session preserved), every watchdog tick revives it (skips the shell fork when already running). One shared tmux session (hydra-transcribe), park-on-crash (no model-load crash-loop), zero extra config after one-time ./transcribe-server/setup.sh.
  • On by default, invisible when not set up: transcription is attempted whenever a sidecar is reachable; when it isn't, audio passes through untranscribed (connection refused fails fast). Text-only messages skip the transcription call entirely. Devs without dictation see zero behavioral change.
  • Daemon .env parser fix: strips inline # comments, handles export prefix and quoted values — the old regex passed trailing comments as part of the value. Extracted to shared/env-parse.ts with 11 unit tests.
  • Async file I/O: transcribeFile uses stat/readFile from fs/promises — no event loop blocking for large audio files.
  • execFileSync timeout: sidecar autostart bounded to 15s; catch (err: unknown) with proper type guard.

What changed

  • daemon/transcription.ts — audio detection + transcript merge (pure, unit-tested) + async HTTP client; failures logged and skipped, never block delivery
  • daemon/router.ts — hook into buildNotificationPayload after attachment download; voice_transcript_count meta marker; guarded on downloadedFiles.length > 0
  • cli/lifecycle.ts, cli/helpers.ts — sidecar supervision in hydra up / down / watchdog; shared sidecar kill guarded (iterates known platforms); watchdog skips shell fork when sidecar already running
  • shared/env-parse.ts.env line parser (handles export, quotes, inline comments)
  • shared/constants.tsTRANSCRIBE_TMUX constant (cross-compilation-unit per convention)
  • daemon/config.ts — uses new parser
  • start-transcribe.sh--auto gate, shared session, park-on-crash, key-allowlisted .env extraction
  • start-daemon.sh, watchdog.sh — call the shared --auto path
  • transcribe-server/server_mlx.py (Parakeet-MLX), server.py (Canary-Qwen/NeMo), mock_server.py (stdlib stub), setup.sh, requirements, README
  • Tests: daemon/__tests__/transcription.test.ts, shared/__tests__/env-parse.test.ts
  • .env.example, README.md

Test plan

  • bun build clean on all 3 entry points (daemon.ts, cli/hydra.ts, bridge.ts)
  • bun test — 432/434 pass (2 pre-existing failures on origin/main, unrelated)
  • 3-lens independent review (engineering / ops / security) — 2 successive clean rounds
  • Manual: send a voice note on Slack/Discord with mock sidecar running, verify [voice transcript] appears
  • Manual: hydra up/hydra down/watchdog with sidecar configured — verify start/stop/revive
  • Manual: hydra down discord while slack daemon is running — verify sidecar stays alive

Origin

Rebased from kwliang1/hydra#1 onto upstream main. Improvements over the original PR: daemon .env parser fix, execFileSync timeout, async file I/O, shared-kill guard, TRANSCRIBE_TMUX constant, mergeTranscripts fallback fix, watchdog skip optimization, downloadedFiles.length guard, catch (err: unknown).

🤖 Generated with Claude Code

kwliang1 and others added 13 commits July 9, 2026 07:46
Transcribe inbound audio attachments (Discord voice notes, Slack audio
clips) to text so users can dictate prompts to Claude alongside text and
images. Claude has no native audio input, so the daemon transcribes first
and merges the result into the message as a [voice transcript] block; the
original audio file stays in downloaded_files.

- daemon/transcription.ts: audio detection, transcript merging (pure,
  unit-tested), and an HTTP client to a self-hosted STT sidecar. Failures
  are logged and skipped — dictation never blocks message delivery.
- daemon/router.ts: hook transcription into buildNotificationPayload after
  attachment download.
- transcribe-server/: self-hosted sidecar serving NVIDIA Canary-Qwen 2.5B
  via NeMo (top of the Open ASR leaderboard for English accuracy), plus a
  GPU-free mock_server.py for testing the wiring locally.
- Off by default; enable with HYDRA_TRANSCRIBE_ENABLED=1.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Make dictation work as a packaged default rather than a manual opt-in:

- Daemon transcription is now ON by default ("auto"): it's attempted
  whenever audio arrives and silently no-ops if no sidecar is reachable
  (the fetch fails fast). HYDRA_TRANSCRIBE_ENABLED=0 opts out.
- start-transcribe.sh: idempotent launcher for the sidecar in a tmux
  session. Accepts a backend arg (`./start-transcribe.sh mock`) for a
  zero-GPU end-to-end test; canary backend refuses cleanly until set up.
- watchdog.sh: revive the sidecar when HYDRA_TRANSCRIBE_AUTOSTART is set,
  reusing the same supervision pattern as the bot session.
- transcribe-server/setup.sh: one-time venv + NeMo install.
- Docs/env updated for the packaged-default flow.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pasting a doc line with an inline '# comment' into interactive zsh (which
does not strip '#') passed the comment as args, making the launcher try
backend '#'. Only accept mock|canary as the positional arg; ignore
anything else with a warning and fall back to env/default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Canary-Qwen runs via NeMo and needs a CUDA GPU, so it can't run on Mac.
Add a Parakeet-MLX backend (NVIDIA Parakeet TDT on Apple's MLX runtime):
native, ~50x realtime on M-series, ~6% English WER, no GPU.

- transcribe-server/server_mlx.py — FastAPI server, same /transcribe
  contract, parakeet-mlx + ffmpeg resample.
- transcribe-server/requirements-mlx.txt — light deps (no torch/NeMo).
- start-transcribe.sh — `parakeet` backend; default by platform
  (Darwin -> parakeet, else canary).
- setup.sh — installs the right requirements per platform / arg.
- Docs updated across README, transcribe-server/README, .env.example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MLX ships arm64-only wheels, and on Apple Silicon the shell frequently
runs under Rosetta (x86_64), where 'pip install mlx' fails with no
matching wheel. setup.sh now uses an arm64 Homebrew python@3.12 and
builds the venv via 'arch -arm64' for the parakeet backend; uv/system
python paths remain for canary. Also relax the parakeet-mlx pin.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
tmux does not reliably inherit env, so a PARAKEET_MODEL override in .env
never reached the server (it would fall back to the 0.6B default). Forward
model-selection vars explicitly, only when set. Enables pinning the smaller
110M Parakeet on constrained networks where the 2.4GB 0.6B is impractical.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…tests

The module-top-level stub leaked into every test file loaded after it,
swallowing bun test's per-test output and final summary for the rest of
the suite (bun runs all files in one process). The stub was unnecessary:
these tests only exercise pure helpers that never write to stderr.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Voice dictation now comes up with the daemon instead of needing a separate
manual step or an explicit opt-in flag:

- start-transcribe.sh gains an --auto mode used by every supervisor:
  explicit HYDRA_TRANSCRIBE_AUTOSTART wins in both directions; when unset it
  starts the sidecar iff the backend is ready (venv built, or mock chosen),
  and quietly no-ops otherwise so unconfigured machines don't log a failure
  every watchdog cycle. Honors HYDRA_TRANSCRIBE_ENABLED=0.
- hydra up starts it right after the daemon (model loads while the byte
  comes up); hydra watchdog revives it each tick; hydra down stops it.
- Legacy start-daemon.sh and watchdog.sh call the same --auto path; the
  watchdog's AUTOSTART=1 opt-in grep is replaced by the shared gate.
- mock backend: resolve python3 up front and fall back to /usr/bin/python3 —
  asdf's shim fails when no python version is pinned for the dir, and
  mock_server.py is pure stdlib.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Slack voice clips arrive as files with mimetype audio/mp4 (m4a), or
audio/webm;codecs=opus from browser recordings — assert detection for those
shapes plus the extension fallback. Add transcribeDownloads tests with a
stubbed fetch: only audio files are POSTed, sidecar failure skips the file
instead of throwing, and the disabled flag short-circuits before the network.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d 1)

Structural:
- ONE shared tmux session (hydra-transcribe) for all platform daemons —
  per-platform sessions raced for the same default port, and the loser
  reloaded the full model every watchdog tick. hydra down kills it; the
  other platform's watchdog revives it within a tick.
- A crashed server PARKS its session (error on screen + in log) instead of
  exiting, so supervisors' has-session check holds: broken configs fail
  once, not as a model-load crash-loop every 120s.
- The mock backend is excluded from --auto (manual or explicit
  AUTOSTART=1 only) — leftover test config must not keep canned
  transcripts flowing into real prompts. A remote HYDRA_TRANSCRIBE_URL
  also disables local autostart.

Environment/robustness:
- Extract only dictation keys from the state-dir .env instead of set -a
  sourcing the whole file — the model server has no business holding chat
  bot tokens (they leaked into the tmux server env when this script
  bootstrapped it).
- Forward PATH into the tmux pane (launchd-frozen server env lacks
  /opt/homebrew/bin, breaking the servers' ffmpeg lookup) and shell-quote
  every interpolated value (shq) so an embedded quote can't break out of
  the tmux command string.
- URL without an explicit port now binds the scheme default so a mismatch
  fails visibly instead of the sidecar silently serving a port the daemon
  never queries.
- start-daemon.sh: sidecar refusal no longer fails the whole script under
  set -e after a successful daemon start. Legacy watchdog runs the sidecar
  step before the daemon branches' early exits.

Daemon:
- isAudioFile: a definitive non-audio MIME (video/mp4 screen recording) is
  no longer re-classified as audio by its extension; only generic types
  fall back. Codec suffixes (audio/webm;codecs=opus) parsed correctly.
- transcribeFile checks size via statSync BEFORE reading — the cap now
  protects daemon memory, not just sidecar latency.
- Tests: env save/restore moved into beforeEach/afterEach (the old
  describe-body restore ran at collection time and leaked env into later
  test files); network tests set HYDRA_TRANSCRIBE_ENABLED explicitly; new
  cases for video-MIME rejection and the pre-network size cap.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- --auto with AUTOSTART unset now also requires BACKEND != mock — with a
  built venv, leftover BACKEND=mock in .env passed the venv-only gate and
  auto-supervised canned transcripts, the exact residue case the previous
  commit claimed to prevent.
- .env key extraction now parses like shell sourcing: optional 'export'
  prefix, quoted values kept verbatim (a # inside quotes is not a
  comment), unquoted values lose trailing inline comments/whitespace.
  The grep|cut version kept ' 0  # never' whole, silently defeating
  explicit opt-outs and erroring per watchdog tick on commented backends.
- Document that multi-platform machines must keep dictation config
  identical across platform .env files (shared session = first
  supervisor wins).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…port

The daemon's .env parser used a bare `^(\w+)=(.*)$` regex that passed
through inline `# comments` as part of the value — a malformed
`SPAWN_CWD=~/work # my dir` would set SPAWN_CWD to `~/work # my dir`
and break spawns. The sidecar's .env parser (start-transcribe.sh)
already handled this correctly; this brings the daemon in line.

Extracts `parseEnvLine` into `shared/env-parse.ts` (pure, no side
effects) so both the daemon config loader and tests can use it without
triggering config.ts's module-scope gateway/token side effects.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread cli/lifecycle.ts
Comment thread daemon/transcription.ts Outdated
Comment thread daemon/router.ts
Comment thread cli/helpers.ts Outdated
…ield naming

- Guard `tmuxKill(cfg.transcribeTmux)` in `hydra down`: only kill the
  shared `hydra-transcribe` session when the other platform's daemon
  isn't running, so `hydra down discord` doesn't break Slack's sidecar.
- Replace sync `statSync`/`readFileSync` with async `stat`/`readFile`
  from `fs/promises` in `transcribeFile` — avoids blocking the event
  loop for files near the 25MB cap on the inbound-message hot path.
- Rename `voice_transcript` metadata field to `voice_transcript_count`
  to match what it stores (a count, not the actual transcript text).
- Extract `TRANSCRIBE_TMUX` constant to `shared/constants.ts` per
  project convention (cross-compilation-unit constants go there).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Comment thread daemon/router.ts
Comment thread cli/lifecycle.ts Outdated
Comment thread cli/lifecycle.ts Outdated
Comment thread daemon/router.ts
Comment thread cli/lifecycle.ts Outdated
…chdog skip

- Pass computed `content` (with '(attachment)' fallback) to
  mergeTranscripts instead of raw `msg.content`, so the fallback
  survives when all transcripts are empty.
- Guard transcribeDownloads call on downloadedFiles.length > 0
  to skip the promise allocation for text-only messages.
- Replace two-platform assumption in sidecar kill guard with a
  general check against all known platforms.
- Guard watchdog's startTranscribeAuto with tmuxExists to skip
  the shell fork when the sidecar is already running.
- Use `catch (err: unknown)` with type guard instead of `any`.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@kwliang1
kwliang1 merged commit 785596f into main Jul 11, 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.

2 participants