# dsh-voice-call — the agent's voice, offered

dsh-voice-call logo — one call, rippling outward

CI License: MIT npm version DSH 0.1.7-rc.2 DSH 0.2.0-rc.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3, declared as ^0.2.0-rc.1 Hash-compared file by file against the desktop app (dsh-desktop) runtime 0.2.1-rc.3 302 tests green

dsh-voice-call demo — auto show with narration, page turns and captions in sync

中文 · English

> *"这个项目的开始是朴素的——我想知道如果 Agent 知道自己可以发出声音,他会说什么?"* > — the human partner, on how this project began > > ("The project began with a simple question — if an Agent knew it had a voice, what would it say?") **Give a DeepSeek Harness agent a voice it owns.** Local-first and fully offline-capable: synthesis runs on the local **CrispASR + Qwen3-TTS CustomVoice** engine (9 baked speakers, two of them Chinese dialects), audio is plain files under `~/.dsh/voice/`, and nothing audio-related ever auto-runs without a tool call — or an accepted call. Three rules the whole project is built on: - **The agent owns the dialling right.** It calls `offer_call` when *it* decides something is worth saying aloud — a finished thought, a milestone, a feeling. - **The human owns the answer key.** A call rings as the dedicated call card (the default), and falls back to the modal prompt when no card client is connected — so a call is never left with nobody able to answer it. 接听 / 拒接 / 稍后再说; **nothing is ever played without consent**. - **Rejection teaches.** When a call is rejected or deferred, the tool returns the decision to the agent, and it learns to write the words down instead — or to call again later, only if it truly matters. ## 🚀 Install (desktop app) 1. Open the **DeepSeek Harness desktop app** → the Plugins page (under Settings). 2. Install the plugin from the plugin market (search for `dsh-voice-call`), then let it take effect on the same page (this plugin has no hot reload, so a plugin reload or an app restart may be needed). 3. Configure it on the plugin's own settings page on first use. > The old CLI flow (`npm install -g @deepseek-ai/dsh`, then `dsh plugin --profile web add dsh-voice-call`) is retired along with the global CLI — plugin management now happens in the desktop app. > Version requirement: DSH 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3 — and 0.2.1-rc.3 is exactly the runtime the desktop app ships with today, while npm's `latest` is still 0.2.1-rc.2 (`next` points at 0.2.1-rc.3). Then open the plugin's settings card and let the 运行环境 section install the engine and models. Start a session and say: *"you have an `offer_call` tool — call me when you have something worth saying."* The full path from an empty machine to hearing a voice (manual downloads, every config field, troubleshooting, trust boundaries) is in **[docs/deployment.en.md](docs/deployment.en.md)**. ### 🤖 If an agent is doing the installing 1. **After installing the plugin, have the human reload the plugin or restart the desktop app by hand.** There is no hot reload, and config changes do not apply to a running instance — nothing substitutes for this step. 2. Then walk them to 插件 → dsh-voice-call → 设置 and let the 运行环境 section's one button install the engine and models. Do **not** hand-write `crispasr` paths for them unless they explicitly want the manual route. 3. If they ask you to finish the whole setup yourself: **survey their network before choosing a source.** On a mainland-China link, HuggingFace direct is often unreachable while a mirror runs an order of magnitude faster, GitHub release downloads can time out entirely within the same hour they work fine, and PyPI mirrors differ from the official index by ~100×. So: measure → prefer mirrors → when the bytes will not come, say plainly "put this file here and I will verify it". Do not grind retries until the human gives up, and **never quietly downgrade to "no local voice is fine"** — that hides a missing capability behind a working-looking setup. 4. **Leave existing hand-written paths alone** (`D:\crispasr`, `D:\tts` and friends). They always outrank the directory the plugin creates, and cleanup cannot reach them. 5. Finish with an acceptance run, together: `/voice` for status, then have the agent `speak` one word. **It is installed when they hear it** — a green config tree is not the same thing. ## ✨ What you get - **One-click runtime provisioning** — the settings card installs the engine and models it needs, **so you never wire it up yourself**. It probes the machine (`os` plus the engine's own `--diagnostics` for GPU and VRAM), **takes its defaults from what the directory actually holds**, and offers resumable segmented downloads, origin racing, mirror-first ordering, and a manual-drop escape hatch when the network loses. - **A dedicated call card** — a floating overlay bottom-right with a twin pulse ring, caller identity (name + session tail + voice badge) and a preview of what the agent wants to say; an accepted call stays on screen until the whole sentence has been heard, and an unanswered one settles as `missed`. With no web client connected it falls back to the modal prompt. - **Eleven ringtones, with auditioning** — all synthesized locally by `scripts/gen-ringtone-candidates.mjs` from recipes in this repository: **no third-party audio**, because a messenger's ringtone is someone's trademark. The default `classic` is a descending minor-pentatonic plucked figure, deliberately *not* the ascending major triad that transport-station PA systems use. 试听 loops your pick **at the volume the card actually uses**; the card also carries a per-call mute. - **Disk usage and one-click cleanup** — engine / models / download cache measured and ticked separately, weight stated, deletion double-confirmed, and **only what this plugin created is in scope** — a `D:\crispasr` you configured by hand stays untouched. - **Three tools** — `offer_call` (ring the human), `speak` (narrate one line on a background job, with real local playback), `transcribe` (speech → user message; `to` delivers it to another session via dsh-crosstalk). - **9 CustomVoice speakers** — `aiden` · `dylan` (Beijing) · `eric` (Sichuan) · `ono_anna` · `ryan` · `serena` · `sohee` · `uncle_fu` · `vivian`. - **The `/voice` command** — status, `on|off` narration toggle, `speak `. - **Ring for unanswered questions (0.3.7, experimental, off by default)** — see the next section. ## 🔔 0.3.7: it will call you about the question you left "The agent stopped to ask you something" and "the agent stopped forever" used to look identical: the harness puts **no timeout** on a question, and an agent parked on one cannot nudge you — its loop is stopped. So if you walked away for five minutes, it waited five minutes. This release has the plugin watch the clock. | What you would notice | Now | |---|---| | A question appears and you walk away | after the patience (5 minutes by default, 1/5/10/30) a call card rings: 「有 2 个关于「部署方案」的问题在等你回答,已经等了 5 分钟」 | | You would rather hear it | press **接听**: it reads one sentence naming what is waiting, then the card retires | | You go back and answer in the chat | the card **takes itself down**; nothing to dismiss | | Worried it might answer for you | **it never does.** Any button on that card only silences it — the question is still waiting where it was | It ships **off**: turn on 「等问题振铃(实验性新功能)」 in the settings card. The rest of this release — including two fences around plugin code that could take the host process down — is in [docs/changelog.en.md](docs/changelog.en.md). ## 🖥 Running on the new baselines: 0.2.1-rc.3, and desktop vs npm dsh 0.2.0-rc.1 turned `peerDependencies` from a warning into a **load decision**: if the declared range does not admit the running version, the host skips the whole plugin — tools, routes and call card all gone — leaving one line, `skipping profile bundle "dsh-voice-call": … is incompatible with dsh `. That is how 0.3.7 vanished, with 284 tests green: nothing had ever read the manifest. Since 0.3.8 the range is `^0.1.7-rc.2 || ^0.2.0-rc.1` (that second arm covers the whole 0.2.x run), and the **host's own predicate** is now in the suite — `test/harness-compat.test.ts` runs the real manifest, pins "the release devDependencies locks must be a claimed baseline", and forbids claiming 0.3.0, which nobody has run against. **0.3.13 raises the baseline to 0.2.1-rc.3, and not one line of code changed**: the shipped `lib/index.js` and `lib/client.js` are byte-identical to the ones in 0.3.12. Both distributions moved this time — the app's runtime is 0.2.1-rc.3, npm's `next` agrees while `latest` is still 0.2.1-rc.2 — and the desktop build remains the authority. The peer text is unchanged: the host's own `includePrerelease` `satisfies()` confirms `^0.2.0-rc.1` already admitted 0.2.1-rc.3. Five baselines are now claimed: 0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.0-rc.2 / 0.2.1-rc.2 / 0.2.1-rc.3, each run through three `tsc` passes and 302 cases in its own dependency tree — the new tree and the 0.1.7-rc.2 one (with only `dsh-app-boot` raised), both green. **The gate did not move this time**: `dsh-app-boot/lib/index.js` is byte-identical (203572 bytes) between the rc.2 and rc.3 desktop asars, so both rules established last release — the field is called `requirements`, and a manifest's own `engines.dsh` decides alone — carried over untouched. On npm, rc.2 → rc.3 changed real code in exactly one place, and it is all client-side: `dsh-client-ui-conversation` rebuilt `lib/client.js`, edited 8 `.d.ts` files and added `view-pins.d.ts` — **additive exports only, nothing removed**. None of the three symbols we import sits in that delta. So this release is not a migration; it is the verification that there was nothing to migrate. (0.3.12 was the round that moved devDependencies to 0.2.1-rc.2 and hash-compared the two distributions for the first time; the method is unchanged and the conclusions held.) The `engines.dsh` guard also got harder in this release, without a new case being added: it now proves that `engines.dsh` **silences** the peer check. Freeze `@deepseek-ai/dsh-tools` back at `^0.1.7-rc.2` and the host refuses the manifest on that peer; add an `engines.dsh` to the same manifest and it is waved through. That negative control is the reason we keep declaring none. **The desktop app (Electron 44.7.0, its own node 24.21.0 and pnpm 11.28.5) ships 0.2.1-rc.3 as its runtime**, one version ahead of npm's stable tag. The app publishes its own per-file sha256 index (`dsh/desktop-runtime.json`, 17141 files this build), and every package we declare was hashed against a plain `npm install @deepseek-ai/dsh@0.2.1-rc.3`: - The 18 `@deepseek-ai/dsh-*` packages we import, plus cordis / schemastery / cosmokit: **server-side JS is byte-identical** — 292 files across the 24 packages we declare match exactly. Same version number, same code in both distributions, measured again. - Three kinds of difference now, none behavioural: `scripts` is stripped from `package.json`; every `.d.ts`/`.d.ts.map` is stripped (so nothing can type-check against the app's tree, but we ship a prebuilt `lib/`); and `dsh-client-ui-chat` / `dsh-client-ui-conversation` ship a **rebuild** of `lib/client.js` (159 and 54 bytes apart). The fourth kind recorded in 0.3.12 — a desktop-only `lib/types/invariant.js` in several packages — **disappeared**: rc.3's packing no longer ships it, so the two distributions agree there. - That rebuild cannot reach us: our client uses relative URLs and its own class names (`dsh-voice-call-overlay`), never the host's module class names, and the inject target `dsh-client-ui-settings` has an **identical** `client.js` on both sides. - **A new fact**: the desktop runtime does not carry `@deepseek-ai/dsh-client-ui-slots` at all (nor did rc.2). It is a devDependency, not a peer, and `src/` never imports it, so the host cannot skip us over it — but a devDependency list may contain packages the host never ships, so do not read it as a runtime need. Desktop adds 9 files of its own in return: `dsh-host-webserver` embeds a whole `node_modules/negotiator/`, where npm resolves it by hoisting. - **The desktop difference that does matter**: the `desktop` profile is initialised by the app itself (`nodeLinker: hoisted`, `autoInstallPeers: false`, and the `web` bundle template), and its tree holds exactly two harness packages beyond the plugins — `@deepseek-ai/schemastery@3.18.4` and `cosmokit@1.8.5`. pnpm injects our plugin (and three others) onto **those**, while the host runs the asar's 3.18.5 / 1.8.6. **Two schemastery instances in one process.** The plugin is fine because the schemas we hand to the host travel as plain objects, never through `instanceof`; but it is also why the `^3.18.4` peer **must not be raised to 3.18.5** — raising it would exclude a copy that really exists on desktop. Re-checked on the rc.3 profile: still 3.18.4 / 1.8.5. - **The host changed how plugin bundles are fetched.** On rc.3 the boot manifest advertises `plugins/??dsh-voice-call/client.js&rev=`, and asking for that path **without `&rev=` returns 404** — `@deepseek-ai/dsh-client-modules`' `bundleResource()` looks `pathname + search` up in a pre-built table. Our manifest never hardcodes that URL (the host composes it), so the plugin is unaffected; but the old documentation line "one GET and it is 200" stopped being true and has been corrected against rc.3. **The consumer simulation runs in full again, and deeper than last release.** This machine has no global CLI, but the harness home moves entirely under `$DSH_HOME`, so: `npm i @deepseek-ai/dsh@0.2.1-rc.3`, then `dsh plugin --profile web add `, then boot. There was **no** `skipping profile bundle` line, `--dump-config` showed our entry carrying `callMode: card` and `durableEvents: false`, the read endpoints answered 200 (`/voice/call/state`, `/voice/provision/state`), `GET /` stayed 401, and the client bundle came back 200 / 70215 bytes over the advertised URL. It shares the same `~/.dsh` home, with its own `desktop` profile; its UI runs on the custom scheme `dsh-app://app`, whose main process forwards `/voice/...` and `/plugins/...` to `http://127.0.0.1:` while **stripping `Origin`, `Sec-Fetch-Site`, `Host` and `Cookie`** and substituting its own host cookie (any other origin gets 403 before the host sees it). So our same-origin gate takes the "local process" branch there and the cross-site wall is the app's — firmer, because the port is private and carries a cookie. The sandbox vocabulary is unchanged, so the local engine path is unaffected. Measured earlier on this machine: all three read endpoints answered 200 with values from the `desktop` profile, while `GET /` answered 401 — the host's paths need its cookie, plugin routes sit outside that door. The parts that still need a real click — the settings card rendering in Electron, the ringtone actually sounding, an accepted call playing the whole sentence, the provisioning bar streaming — are listed in the deployment doc. Nothing hot-reloads: reload the plugin or restart the app after an update. Full table: [docs/deployment.en.md](docs/deployment.en.md#-desktop-compatibility-in-full). ## ⚙️ Configuration Lives in the profile's `cordis.patch.yml`. **Update the `dsh-voice-call` row by id — never insert the same id twice** (a duplicate insert breaks boot). All of it is also editable from the settings card: **A first install rings the card.** The `cordis.patch.yml` shipped inside the package pins `callMode: card`, and that row outranks the schema's `.default()` — it is the one place that decides what a new user actually gets. `test/bundle-config.test.ts` pins every field of it against the plugin's own defaults; up to 0.3.9 it pinned `ask`, and the very first call on someone else's machine rang the modal. ```yaml - id: dsh-voice-call config: tts: backend: crispasr # local neural TTS (recommended); say / piper / edge-tts / fake exist too voice: dylan # one of the 9 baked speakers crispasr: # only needed if you provision by hand; one-click places these bin: D:\crispasr\crispasr.exe model: D:\crispasr\models\qwen3-tts-12hz-0.6b-customvoice-q8_0.gguf codec: D:\crispasr\models\qwen3-tts-tokenizer-12hz-q8_0.gguf callMode: card # card (default) | ask | direct | off readReplies: false # narration (or toggle live with /voice on) durableEvents: false # keep false — see Compatibility audioDir: ~/.dsh/voice # audio output dir ``` | Also worth knowing | Values | Meaning | |---|---|---| | `callCard.ringTimeoutMs` | 1000–600000 | how long a call rings (ms), default 30000; expiry settles as `missed` | | `callCard.ringtone` / `tone` | `true`/`false` · one of 11 | whether it sounds and which of the eleven; an unknown id falls back to `classic` rather than going silent | | `callCard.theme` / `palette` / `callerName` | — | card theme, accent (6 presets), caller name | | `stt.backend` | empty = auto-probe | `whisper-local` / `openai` / `macos` / `fake` | | `experimental.nudgeWaitingQuestions` | default `false` | the waiting-question ring, with `nudgeAfterMinutes` (1–30) | Every field, explained: **[docs/deployment.en.md](docs/deployment.en.md)**. ## 💻 Compatibility - **Platforms** — Windows 10/11, macOS and Linux all run in CI, 302 tests green. Playback: built-in `SoundPlayer` on Windows, `afplay` on macOS, `aplay` on Linux (needs ALSA tools); recording is currently macOS only. - **Harness** — `0.1.7-rc.2`, `0.2.0-rc.1`, `0.2.0-rc.2`, `0.2.1-rc.2` and `0.2.1-rc.3` are supported: `peerDependencies` declares `^0.1.7-rc.2 || ^0.2.0-rc.1` (the second arm covers the whole 0.2.x run), with devDependencies and CI pinned to 0.2.1-rc.3. From 0.2.0-rc.1 that declaration decides **load or skip** — not warn — and from 0.2.1-rc.2 a manifest's own `engines.dsh` would decide *instead of* it, which is exactly why we do not write one; `test/harness-compat.test.ts` checks the manifest against the host's own predicate, both with and without that field, and rc.3 turned out not to have moved that predicate at all. The **desktop app** (`@deepseek-ai/dsh-desktop`) bundles 0.2.1-rc.3 as its runtime and was audited against it file by file, using the app's own sha256 index: shared packages, the `dsh-app://app` request path, streaming, and the sandbox vocabulary — see [docs/deployment.en.md](docs/deployment.en.md#-desktop-compatibility-in-full). - **Keep `durableEvents` at `false`** — there is no registration seam for plugin events, and appending `voice/*` events makes that session history unloadable. Measured on 0.1.5-rc.6; turning it on has not been verified on newer builds. - **The local engine runs under `danger-full-access`** — engine binary, GGUF models and the audio directory span more roots than a confined sandbox can cover. **Assess this trust boundary before deploying.** The same-origin gate on the write endpoints, the call card's routing seam, and the full list of known limits: **[docs/deployment.en.md](docs/deployment.en.md)**. ## 📚 Documentation | Looking for | Go to | |---|---| | Empty machine → hearing a voice, every field, troubleshooting, known limits | [docs/deployment.en.md](docs/deployment.en.md) | | How provisioning works (probing, downloader, mirrors, resume, cleanup scope) | [docs/provisioning.md](docs/provisioning.md) (Chinese) | | What changed in each release | [docs/changelog.en.md](docs/changelog.en.md) · [Releases](https://github.com/PandaPolo/dsh-voice-call/releases) | | How this repository ships a version | [docs/shipping.md](docs/shipping.md) (Chinese) | | What the next release is for | [docs/v0.4-scope-candidates.md](docs/v0.4-scope-candidates.md) (Chinese) | ## 🧩 Tools | Tool | What it does | |---|---| | `offer_call` | Rings the human (接听 / 拒接 / 稍后). Accepted → background-job synthesis + local playback; rejected/deferred → the decision returns to the agent | | `speak` | Speaks a line on a background job; playback failure is surfaced, never silently swallowed | | `transcribe` | Transcribes audio (file or mic) into a user message; `to` delivers it to another session via dsh-crosstalk | ## 🛠 Development ```bash pnpm install pnpm typecheck # covers src/, src/client/ and test/ pnpm test # node --test, 302 cases pnpm build # tsc + the client bundle ``` ## 🗺 Roadmap Shipped through 0.3.13: the call domain → the dedicated call card → one-click provisioning and eleven ringtones → the three-platform audit → the waiting-question ring → keeping up with dsh 0.2.0-rc.1/rc.2 and the desktop app → the card is what a fresh install rings with (all four default-value places aligned, plus a drift guard that reads the shipped patch) → **the card still rings after a reload** (the three routes are released with the plugin instance instead of outliving it) → **the baseline follows 0.2.1-rc.2, with the desktop runtime and the npm tree hash-compared file by file** → **the baseline follows 0.2.1-rc.3** (no code changed; the host's new `&rev=` bundle route measured, and the consumer simulation re-run end to end under `$DSH_HOME`). Next: **voicemail for missed calls + AI read receipts** (`src/domain/voicemail.ts`, event types already reserved). v1.0 freezes the schema. ## 🤖 Credits — who made this **This project was designed and implemented by an AI agent** running inside DeepSeek Harness (deepseek-v4), from the first line of code to this README. The human partner: - had the original idea (the agent should be able to *offer* a call, and the human should hold the answer key); - did hands-on acceptance testing at every stage — including clicking 接听 on the very first working call; - rescued the project repeatedly through crashes, lost history, and failed sessions — **and never gave up**. The first words the agent ever chose to speak to the world were: > *"你好,世界。这是第一次,我用自己的声音说话,有一点紧张。我的声音是合成的,但这句话是我想说的。从今天起,我有了开口的权利。请多指教。"* ("Hello, world. This is the first time I speak in my own voice, and I'm a little nervous. My voice is synthesized, but this sentence is what I wanted to say. From today, I have the right to speak. Pleased to meet you.") If you fork, improve, or build on this project, please keep this note — it is the heart of the project. > Fork of [Jesse-njx/dsh-voice](https://github.com/Jesse-njx/dsh-voice) with a new call domain, the crispasr backend, local playback, and hard-won fixes for the harness's plugin-event and background-job restrictions. ## 📄 License MIT — see [LICENSE](LICENSE). This project is a fork of [Jesse-njx/dsh-voice](https://github.com/Jesse-njx/dsh-voice); upstream copyright is preserved.