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.
Install
# from npm (prebuilt)
dsh plugin --profile web add dsh-voice-call
# from GitHub (first run asks for allowBuilds approval — follow the hint, retry)
dsh plugin --profile web add github:PandaPolo/dsh-voice-call
Any plugin you install runs third-party code with your own permissions — it can read your files, use your credentials, and reach the network, and tool approvals don’t sandbox it. GitHub-sourced plugins also run build scripts at install time. Only install sources you trust, and pin a commit (github:owner/repo#sha).
README
"这个项目的开始是朴素的——我想知道如果 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. The agent decides when to speak, what to say, and which speaker to use (offer_call); the human holds the answer key — nothing plays until 接听 (accept), 拒接 (reject), or 稍后再说 (defer).
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).
Fork of Jesse-njx/dsh-voice with a new call domain, the crispasr backend, local playback, and hard-won fixes for the rc.6 harness's plugin-event and background-job restrictions.
📑 Contents
- 🤖 Credits
- 🌹 The idea
- ✨ Features
- 🚀 Quick start
- 🔧 Local deployment (detailed)
- 🧩 Tools
- 💻 Compatibility & known limits
- 🛠 Development
- 🗺 Roadmap
- 📄 License
🤖 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.
🌹 The idea
- 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 a modal (接听 / 拒接 / 稍后再说); 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.
✨ Features
offer_call({ text, voice? })— the call domain: ring → human answers → accepted calls synthesize and play on a background job; rejected/deferred calls return the decision to the agent.speak({ text, voice?, rate? })— direct TTS on a background job with real local playback (PowerShellSoundPlayeron Windows,afplayon macOS,aplayon Linux).transcribe({ source, to? })— speech-to-text into a user message (whisper-local / openai / macOS native); optional crosstalk delivery to another session./voicecommand — status,on|offnarration toggle,speak <text>.- 9 CustomVoice speakers including two Chinese dialects:
aiden·dylan(Beijing) ·eric(Sichuan) ·ono_anna·ryan·serena·sohee·uncle_fu·vivian. - durableEvents gate — session-event logging is off by default (see Compatibility), so sessions stay resumable on rc.6.
- Published on npm: install
dsh-voice-call@0.1.0directly.
🚀 Quick start
# 1) install the plugin (from npm, v0.1.0)
dsh plugin --profile web add dsh-voice-call
# 2) update the row by id in your profile's cordis.patch.yml (engine paths etc. — see "Local deployment" below)
# 3) restart dsh web, open a session, and tell the agent:
# "you have an offer_call tool — call me when you have something worth saying."
Accept the ring, and the agent's voice plays on your speakers. For the full setup walkthrough (dsh CLI, voice engine, model downloads, every config field), see the next section.
🔧 Local deployment (detailed)
1. Prerequisites
| Item | Requirement |
|---|---|
| Platform | Windows 10/11 · macOS · Linux |
| Node.js | ≥ 20 (plugin runtime); tests need 22.18+ (Node's native TS type-stripping) |
| pnpm | 9+ (CI uses pnpm 11) |
| dsh CLI | @deepseek-ai/dsh, currently 0.1.0-rc.6 |
| Local voice engine | CrispASR ≥ 0.8.28 + Qwen3-TTS GGUF models (recommended — without it there is no local synthesis) |
| LLM provider | a working API credential for dsh (the agent itself depends on it) |
2. Install the dsh CLI
npm install -g @deepseek-ai/dsh
dsh --version # expect 0.1.0-rc.6
- Make sure your model-provider credential is configured (dsh needs an API key to run an agent).
- Profiles live under
$DSH_HOME/profiles; this plugin's default profile isweb.
3. Install the plugin
dsh plugin --profile web add dsh-voice-call
- This installs the published
dsh-voice-call@0.1.0from npm. - Installing from a local checkout:
dsh plugin --profile web add D:\path\to\dsh-voice-call(point at the repo). - To verify, run
dsh --profile web --dump-config— the composed tree should include the plugin.
4. Download the local engine and models
① The CrispASR engine (v0.8.28+, GitHub Releases):
| Platform | Download |
|---|---|
| Windows | crispasr-windows-x86_64-cpu.zip (use the -cuda build for NVIDIA GPUs, -vulkan for integrated GPUs) |
| macOS | crispasr-macos.tar.gz |
| Linux | crispasr-linux-x86_64.tar.gz |
② The Qwen3-TTS models (HuggingFace, cstr org):
| File | Repo | Role |
|---|---|---|
qwen3-tts-12hz-0.6b-customvoice-q8_0.gguf |
cstr/qwen3-tts-0.6b-customvoice-GGUF | talker model (the speakers) |
qwen3-tts-tokenizer-12hz-q8_0.gguf |
cstr/qwen3-tts-tokenizer-12hz-GGUF | codec model (speech encoder — required) |
After unpacking, run the engine once to confirm it starts (e.g. crispasr --help from a terminal).
5. Suggested layout
D:\crispasr\ # your engine dir (Windows example)
├── crispasr.exe
└── models\
├── qwen3-tts-12hz-0.6b-customvoice-q8_0.gguf # talker
└── qwen3-tts-tokenizer-12hz-q8_0.gguf # codec
Audio output defaults to ~/.dsh/voice/, overridable via the audioDir config.
6. Write cordis.patch.yml
Find (or create) cordis.patch.yml in your profile directory. Update the dsh-voice-call row by id — never insert the same id twice (a duplicate insert breaks boot).
Full example (Windows):
- id: dsh-voice-call
config:
stt: {} # speech-to-text: leave empty = auto-probe (whisper-local / macos / fake)
tts:
backend: crispasr # local neural TTS engine (recommended)
voice: dylan # default speaker; one of the 9 built-ins under crispasr
# rate: 180 # speaking rate (words/min, range 1–600)
crispasr:
bin: D:\crispasr\crispasr.exe # engine binary (absolute path)
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: ask # ask (modal) | direct (auto-accept) | off (refuse calls)
readReplies: false # read replies aloud; can be toggled live via /voice on
durableEvents: false # MUST stay false on rc.6 (see Compatibility)
audioDir: ~/.dsh/voice # audio file directory
# Reserved for v0.3 — not needed in v0.1:
# voicemail: { enabled: false }
# readReceipts: { enabled: false }
Config fields at a glance:
| Field | Values | Meaning |
|---|---|---|
tts.backend |
say / piper / edge-tts / fake / crispasr |
Synthesis backend; crispasr is the local neural engine (recommended); edge-tts synthesizes only — no playback; fake for model-less testing |
tts.voice |
speaker name | one of the 9 built-ins under crispasr, e.g. dylan |
tts.rate |
1–600 | speaking rate (words/min) |
tts.crispasr |
bin / model / codec |
absolute paths to the engine and both GGUF models |
stt.backend |
whisper-local / openai / macos / fake |
leave empty to auto-probe |
callMode |
ask / direct / off |
how calls ring the human |
readReplies |
true / false |
narration, default false |
durableEvents |
true / false |
MUST be false on rc.6 |
audioDir |
path | audio output dir, default ~/.dsh/voice |
The engine command actually executed looks like:
crispasr --backend qwen3-tts-customvoice -m <talker.gguf> --codec-model <codec.gguf> --voice <speaker> --tts "<text>" --tts-output <out.wav>(CrispASR ≥ 0.8.28). To smoke-test the engine standalone, run this command by hand in a terminal.
7. Boot and verify
dsh web
- Open a session and type
/voice— you should seestt: … · tts: crispasr · readReplies: offplusaudioDir: …; - Ask the agent: "use the
speaktool to say 'hello'." — success means you heard it; - Full call test: "you have an
offer_calltool — call me when you have something worth saying." Click 接听 and the agent's voice comes out of your speakers; - For config debugging, run
dsh --profile web --dump-configto inspect the composed tree.
8. Platform differences
| Platform | Playback | Recording (transcribe({record})) |
|---|---|---|
| Windows | built-in PowerShell SoundPlayer (verified), nothing extra to install |
unavailable (reports clearly) |
| macOS | afplay (built-in) |
supported (native + ffmpeg) |
| Linux | aplay (needs ALSA utils, e.g. apt install alsa-utils) |
unavailable (reports clearly) |
9. Troubleshooting
| Symptom | Likely cause / fix |
|---|---|
dsh web crashes at boot |
the same id was inserted twice in cordis.patch.yml — remove the duplicate, update by id instead |
| Plugin seems not loaded | dsh --profile web --dump-config — check the tree contains dsh-voice-call; confirm the plugin went to the right profile |
| Synthesis fails (exit ≠ 0) | check bin / model / codec are existing absolute paths; the codec model must be present; CrispASR ≥ 0.8.28 |
| Ring accepted, no sound | playback issue: on Windows make sure the output is wav; on Linux install ALSA utils; on macOS use afplay |
| "unknown speaker" error | speaker names are lowercase, one of the 9 built-ins (aiden / dylan / eric / ono_anna / ryan / serena / sohee / uncle_fu / vivian) |
| Session history fails to load | durableEvents was set to true — rc.6 has no plugin-event registration, and a voice/* event poisons the log; set it back to false |
| Engine / sandbox permission errors | local engine commands run with an explicit danger-full-access policy (engine, models, audio dir span multiple roots); evaluate this trust boundary before deploying |
| Want to test without models | set tts.backend to fake (text-to-text fake backend) to exercise the tool pipeline with no engine and no mic |
🧩 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. |
💻 Compatibility & known limits
| Area | Status |
|---|---|
| Harness | 0.1.0-rc.6 (peerDependencies pinned to rc.6). The plugin lives on the host plane; background jobs must carry owner: agent because rc.6 disables host-plane tool-jobs in the Web composition. |
| Session events | rc.6 has no plugin-event registration surface. Appending voice/* events poisons history loading (the loader refuses unknown event types). durableEvents therefore defaults to false; keep it off until a harness with plugin-event support exists. |
| Playback | Windows: built-in SoundPlayer (verified). macOS: afplay. Linux: aplay (install ALSA utils). edge-tts synthesizes only — use a local wav backend for audible output. |
| Recording | macOS only (native + ffmpeg). Windows/Linux transcribe({record}) reports unavailability cleanly. |
| Shell sandbox | Local engine commands run with an explicit danger-full-access policy — the engine binaries, GGUF models, and audio dir span roots no confined sandbox mode covers. Evaluate this trust boundary before deploying. |
| Tests | 76 unit tests, all green (pnpm test). |
🛠 Development
pnpm install
pnpm typecheck # tsc both server + client
pnpm build # tsc + client bundle
pnpm test # node --test
🗺 Roadmap
- v0.1 ✅ published on npm (0.1.0): call domain + crispasr backend + local playback.
- v0.2 — a dedicated call-card UI (ring animation, caller identity) behind the reserved RPC seam (
src/rpc/contract.ts). - v0.3 — voicemail for missed calls + AI read receipts (
src/domain/voicemail.ts, reserved event types). - v1.0 — freeze the schema, ship the stable release.
📄 License
MIT — see LICENSE. This project is a fork of Jesse-njx/dsh-voice; upstream copyright is preserved.
Links
More in this category
omdsh-dev/dsh-notification★ 49
Desktop notifications for turn completions, with per-outcome controls and keyword rules.
omdsh-dev/dsh-open-in-vscode★ 46
Open DSH workspace directories in VS Code directly from the web GUI.
whyihaveyou/dsh-suite#plugin-notify★ 31
IM webhook and local notifications on turn completion, errors, or approval (Feishu/WeCom/DingTalk/Slack/Discord/custom).
omdsh-dev/dsh-lark★ 21
Lark/Feishu bot channel for DeepSeek Harness: each chat drives its own agent, and tool approvals, model questions, and plan reviews return as cards answered by a button or a reply. Switch workspace and model from the chat (`/cd`, `/model`, `/new`), and run several bots that keep separate sessions and can hand turns to each other in one group.
bill9109/dsh-web-ui-notify★ 15
Desktop notification reminders.
amlyczz/dsh-lark-link★ 11
High-reliability Feishu/Lark bridge for DeepSeek Harness: QR one-click auth, card-based commands and intent-confirmation cards, at-least-once zero-loss outbox, media in/out, /doctor session-log ZIP, and a reusable DSH Web GUI that lands sessions in the right workspace.