Notifications & Integrations
dsh-voice-call
PandaPolo/dsh-voice-call · ★ 4
Agent-initiated voice calls: `offer_call` rings the human (接听/拒接/稍后再说); accepted calls synthesize and play locally via CrispASR + Qwen3-TTS (9 speakers, 2 Chinese dialects), rejected calls return the decision to the agent.
Manifest details
安装
dsh plugin --profile web add github:PandaPolo/dsh-voice-callGitHub 源码安装:首次需按提示配置 allowBuilds 构建授权后重试
For install and environment setup, plus plugin development tutorials, see DSH community docs ↗
Screenshots







README
Table of contents
"这个项目的开始是朴素的——我想知道如果 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_callwhen 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)
- Open the DeepSeek Harness desktop app → the Plugins page (under Settings).
- 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). - Configure it on the plugin's own settings page on first use.
The old CLI flow (
npm install -g @deepseek-ai/dsh, thendsh 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
latestis still 0.2.1-rc.2 (nextpoints 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.
🤖 If an agent is doing the installing
- 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.
- Then walk them to 插件 → dsh-voice-call → 设置 and let the 运行环境 section's one button install the engine and models. Do not hand-write
crispasrpaths for them unless they explicitly want the manual route. - 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.
- Leave existing hand-written paths alone (
D:\crispasr,D:\ttsand friends). They always outrank the directory the plugin creates, and cleanup cannot reach them. - Finish with an acceptance run, together:
/voicefor status, then have the agentspeakone 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 (
osplus the engine's own--diagnosticsfor 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.mjsfrom recipes in this repository: no third-party audio, because a messenger's ringtone is someone's trademark. The defaultclassicis 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:\crispasryou 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;todelivers it to another session via dsh-crosstalk). - 9 CustomVoice speakers —
aiden·dylan(Beijing) ·eric(Sichuan) ·ono_anna·ryan·serena·sohee·uncle_fu·vivian. - The
/voicecommand — status,on|offnarration toggle,speak <text>. - 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.
🖥 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 <version>. 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:
scriptsis stripped frompackage.json; every.d.ts/.d.ts.mapis stripped (so nothing can type-check against the app's tree, but we ship a prebuiltlib/); anddsh-client-ui-chat/dsh-client-ui-conversationship a rebuild oflib/client.js(159 and 54 bytes apart). The fourth kind recorded in 0.3.12 — a desktop-onlylib/types/invariant.jsin 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 targetdsh-client-ui-settingshas an identicalclient.json both sides. - A new fact: the desktop runtime does not carry
@deepseek-ai/dsh-client-ui-slotsat all (nor did rc.2). It is a devDependency, not a peer, andsrc/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-webserverembeds a wholenode_modules/negotiator/, where npm resolves it by hoisting. - The desktop difference that does matter: the
desktopprofile is initialised by the app itself (nodeLinker: hoisted,autoInstallPeers: false, and thewebbundle template), and its tree holds exactly two harness packages beyond the plugins —@deepseek-ai/schemastery@3.18.4andcosmokit@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 throughinstanceof; but it is also why the^3.18.4peer 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=<hash>, and asking for that path without&rev=returns 404 —@deepseek-ai/dsh-client-modules'bundleResource()lookspathname + searchup 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 <tarball>, 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:<random port> 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.
⚙️ 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.
- 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.
💻 Compatibility
- Platforms — Windows 10/11, macOS and Linux all run in CI, 302 tests green. Playback: built-in
SoundPlayeron Windows,afplayon macOS,aplayon 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.2and0.2.1-rc.3are supported:peerDependenciesdeclares^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 ownengines.dshwould decide instead of it, which is exactly why we do not write one;test/harness-compat.test.tschecks 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, thedsh-app://apprequest path, streaming, and the sandbox vocabulary — see docs/deployment.en.md. - Keep
durableEventsatfalse— there is no registration seam for plugin events, and appendingvoice/*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.
📚 Documentation
| Looking for | Go to |
|---|---|
| Empty machine → hearing a voice, every field, troubleshooting, known limits | docs/deployment.en.md |
| How provisioning works (probing, downloader, mirrors, resume, cleanup scope) | docs/provisioning.md (Chinese) |
| What changed in each release | docs/changelog.en.md · Releases |
| How this repository ships a version | docs/shipping.md (Chinese) |
| What the next release is for | 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
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 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. This project is a fork of Jesse-njx/dsh-voice; upstream copyright is preserved.