Skip to content
dsh.fish
Bundle

@dsh-voice/bundle

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/

Source
Jesse-njx
stars
3 stars
License
MIT
Updated
Updated 9 days ago

Readme

# 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. `speak` doubles 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

```sh
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`):

```yaml
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`

```sh
/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 via `ctx.shell`. Fully offline.
- **`openai`** — an OpenAI-compatible `whisper-1` endpoint via the standard credential seam. The only STT path that sends audio off-machine; only when configured.
- **`macos`** — built-in `SFSpeechRecognizer` via a tiny bundled swift shim through `ctx.shell`. No install, no network setup.
- **`fake`** — text-to-text fixture mapping (a file whose content is `{"transcript": "…"}` — or whose basename is `fixture-<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) — macOS `say -o <file> --file-format=m4af --data-format=aac`, then `afplay`. 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: openai` or `tts.backend: edge-tts`.
- **Plain files** — every artifact is a file under `audioDir` you can inspect or `rm`; the session log holds only refs + transcripts.
- **No auto-run** — recording and playback happen only on an explicit tool call. `readReplies` narrates 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

```sh
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

Install

dsh plugin --profile web add github:Jesse-njx/dsh-voice

Profile: web

  • This package builds from source on install. pnpm will ask you to allow its build script — that is permission to run the package’s code on your machine, outside the agent sandbox. Only allow sources you trust.
  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source