Jesse-njx
dsh-voice
Voice notes in, spoken answers out — dictate audio that becomes user messages (transcribe), have the agent read replies aloud (speak), and leave walk-away narration on long headless runs. Local-first: plain audio files under ~/.dsh/voice/.
- Stars
- 0
- Language
- TypeScript
- Created
- Aug 13, 2026
- Updated
- Aug 13, 2026
Introduction
dsh-voice
Voice notes in, spoken answers out. Dictate audio that becomes user messages, and have the agent read replies aloud. A hands-free terminal for DSH.
dsh-voice is a DeepSeek Harness bundle. Two tools, one durable event, one toggle:
transcribe({ source })— speech-to-text. Pass{ file }(an existing audio file) or{ record }(record from the mic for a few seconds). The transcript becomes a user message the agent responds to — never tool output — and the chat shows a compact audio card with play/pause, duration, backend badge, and the transcript as caption.speak({ text, voice?, rate? })— text-to-speech on a background job. The tool returns{ jobId, audioRef }immediately and never blocks the turn; playback happens async and a failure surfaces as an injected note.speakdoubles as walk-away narration for long builds and headless runs ("build finished, 0 failures").readReplies+/voice— a per-session toggle that auto-narrates the assistant's reply text. Off by default; flip it live with/voice on.
The design center is local-first: audio is plain files under ~/.dsh/voice/ (inspect them, rm them), nothing leaves the machine unless you explicitly configure a cloud backend, and nothing audio-related ever auto-runs — the model must call a tool.
Why this shape
A terminal agent handles two everyday moments badly: you're away from the keyboard and want to leave an instruction (dictation), and you're mid-task and don't want to read a wall of output (narration). dsh-voice is a thin layer over things DSH already exposes — ctx.shell, ctx.jobs, ctx.settings, ctx.attachments, ctx.conversationEvents — so it stays useful without owning any audio pipeline itself. Audio is plain files, the session log holds only refs + transcripts (the attachment/image-ref pattern), and replay reproduces the audio card without re-reading audio.
Install
dsh plugin --profile web add @dsh-voice/bundle
The bundle installs the dsh-voice entry (tools + /voice command + the web audio cards). Nothing runs until the model calls a tool.
Config
All fields optional (profile patch or cordis.patch.yml):
plugins:
dsh-voice:
stt:
backend: whisper-local | openai | macos | fake # absent = auto (whisper-local → macos)
model: whisper-1 # STT model
whisperLocal: { bin: whisper-cli, model: tiny } # whisper.cpp binary + model
openai: { baseUrl: https://api.openai.com/v1, apiKeyEnv: OPENAI_API_KEY }
tts:
backend: say | piper | edge-tts | fake # absent = auto (say → piper)
voice: Samantha # default voice
rate: 180 # say words per minute
piper: { bin: piper, model: /path/to/model.onnx }
edgeTts: { voice: en-US-GuyNeural }
readReplies: false # narrate replies when on
audioDir: ~/.dsh/voice # artifact root
Defaults: stt.backend auto-selected offline (whisper-local → macos), tts.backend: say, readReplies: false, audioDir: ~/.dsh/voice. Cloud backends are never auto-selected — openai and edge-tts are reachable only when you pin them. The openai backend reads its key through the standard credential seam (OPENAI_API_KEY, the same convention a polyglot preset would use), falling back to the launching environment.
Tools
transcribe({ source, to? })
source is exactly one of:
{ file: <path> }— transcribe an existing audio file.{ record: { seconds? } }— record from the microphone (default 5s), gated on a recording path being available (ffmpeg or the bundled swift shim on macOS).
The transcript is inserted as a user message, not returned as tool output: a voice/note session event renders the audio card as a user-authored turn, and the text is delivered to the agent as user input. The canonical return is a compact handle — { transcript, audioRef, backend, durationMs } — so Code Mode callers get structured data.
With dsh-crosstalk installed, transcribe({ source, to: <peer> }) delivers the note to another local session as a labeled peer message with the audio path attached (crosstalk owns provenance framing; the option simply isn't offered without it).
speak({ text, voice?, rate? })
Synthesizes + plays on a background job (ctx.jobs, kind voice-speak), returns { jobId, audioRef } immediately. Every backend writes a durable file under audioDir first (the unit-testable seam), then plays it as a separate best-effort step. A job failure is injected as a note, never a thrown turn.
Because it's a plain tool over ctx.jobs, speak is callable from routines and headless runs — narration is speak called from a job context. No new surface.
Voice notes in chat
Audio never enters the session log. The file lands under audioDir; the log holds one durable event:
| Event | Role | Required durable facts |
|---|---|---|
voice/note | unique start | noteId, turn/step coords, audioRef (path + mime + durationMs), transcript, direction: 'in' | 'out', backend |
Single-event business in v0.1 — noteId is the stable id, no update events. The web client renders voice-note cards: inbound notes (STT) read as user turns, outbound (speak) as agent-side cards. A missing or deleted file degrades to a transcript-only card — you're free to rm audio.
/voice
/voice on # narrate the assistant's replies aloud
/voice off # stop
/voice status # current state + backend + audioDir
/voice speak <text> # speak a line directly from the composer
readReplies defaults follow config; the toggle is per-session and live.
Backends
Speech-to-text (dsh-voice-backends module owns selection + the fake):
whisper-local— a whisper.cpp binary on PATH (or configured), invoked viactx.shell. Fully offline.openai— an OpenAI-compatiblewhisper-1endpoint via the standard credential seam. The only STT path that sends audio off-machine; only when configured.macos— built-inSFSpeechRecognizervia a tiny bundled swift shim throughctx.shell. No install, no network setup.fake— text-to-text fixture mapping (a file whose content is{"transcript": "…"}— or whose basename isfixture-<text>.m4a— transcribes to that text). Runs the whole tool path with no mic and no network; the CI default.
Text-to-speech:
say(default) — macOSsay -o <file> --file-format=m4af --data-format=aac, thenafplay. Zero install; writes Chrome/Safari-playable m4a.piper— local Piper binary, offline neural TTS.edge-tts— cloud; only when explicitly configured.fake— writes{"transcript": "<text>"}so speak output round-trips through the fake STT exactly.
Selection is pure and unit-tested: configured backend always wins; otherwise offline fallback order (whisper-local → macos, say → piper); cloud never auto-selected; no offline backend → a clear error telling you what to configure.
Safety / privacy defaults
- Local-first — audio never leaves the machine unless you explicitly set
stt.backend: openaiortts.backend: edge-tts. - Plain files — every artifact is a file under
audioDiryou can inspect orrm; the session log holds only refs + transcripts. - No auto-run — recording and playback happen only on an explicit tool call.
readRepliesnarrates existing replies; it never records, and it's off unless configured.
Non-goals (v0.1)
Real-time streaming conversation; outbound synthesized voice calls; audio in group WeChat contexts; speaker diarization; music/effects; storing raw audio in the session log; wake-word / always-listening capture.
Testing
pnpm install
pnpm typecheck # host + client tsconfigs
pnpm test # node --test (46 tests)
pnpm build # tsc host + client declarations + the web client bundle
pnpm pack # publishable tarball
The suite covers the spec's testing goals: arg-schema units (the exact-one {file|record} union, speak's optional voice/rate), backend selection with faked probes, the fake text-to-text backend end-to-end through both tool pipelines, the voice-note renderer (expected node.data from a logged event, transcript-only degradation, replay purity), and a macOS say integration test (synthesizes a non-empty m4a under audioDir).
The client bundle (lib/client.js) is built by scripts/build-client.mjs into the web client's lazy-CJS handoff format and served at /plugins/@dsh-voice/bundle/client.js when the bundle is installed in a web profile.
Development
The repo mirrors the sibling plugin layout: src/backends/ is the dsh-voice-backends module (interfaces, pure selection, probes, the fake, and every concrete backend); src/tools/ holds the transcribe/speak pipelines with injected deps so tests run with fakes; src/client/ is the web half (pure Definition + React audio card); shims/ are the bundled swift scripts for macOS STT and mic recording.
License
MIT