Repository navigation
Releases
dsh-tts 0.4.16-local.5 - speak inline code instead of deleting it
dsh-tts 0.4.16-local.5 — speak inline code instead of deleting it
Replies were spoken with holes in them. stripForSpeech replaced every inline
`span` with a single space, so identifiers vanished from the audio while the
words around them survived. Heard live, from a real reply:
Everything the restart needed to pick up is live: now reads True (it was false
before), the installed plugin is , and the Akeno voice re-registered.
Three things were missing from that sentence — a setting name, a version, and the words
that connected them — and the result still sounded like a sentence, which is why it read
as a glitch rather than a bug.
The fix
Inline spans are unwrapped (`x` → x) instead of deleted. Deleting a fenced
block is right — nobody wants a listing read aloud — but an inline span is a single
identifier inside prose, and reading it literally is what a person does when reading the
sentence aloud. Fenced blocks still become "code block, N lines", and skipCode: false
still leaves all markup untouched.
One rule was added to protectAbbreviations: a dot preceded by a letter or digit and
followed by a digit. The existing rule only covered digits-dot-digits, so it stopped at
0.4.16 and left the final dot of 0.4.16-local.4 — the sentence cutter then broke the
identifier in two and spoke "0.4.16-local. 4". A sentence boundary never has a digit
immediately after the dot without a space, so this cannot swallow one.
Both changes sit on the shared cleanText path, so live sentence flush and the durable
settlement cannot disagree about what a sentence says.
Verified artifact
| field | value |
|---|---|
| commit | 3cd846a (master) |
| tag | v0.4.16-local.5 |
| artifact | goodandready-dsh-tts-0.4.16-local.5.tgz |
| artifact SHA256 | F74C5DB607AD699BA9F22C3B1C9FADC807FC62D587FD19B1B7FF23D7B592A793 |
| artifact size | 114 309 bytes, 37 files |
lib/text.js SHA256 |
9CC43E5348AF91A215964FF8C17705680EF65F99E178C5AE138AC68FE52D758E |
| tests | 93 pass / 0 fail |
| parity | all production patches intact |
lib/client.js is byte-identical to local.4 in this release — the browser half is
untouched, so the recorded client hash stays valid across both versions and only the host
half changes. A restart is still required: lib/text.js is loaded at boot.
Regression coverage
test/spoken-code.test.mjs (8 tests) asserts the invariant that matters — no hole in the
sentence (!/is\s+,/) — rather than only the presence of the missing text, and it uses
the real sentence that was heard garbled. It also pins the fenced-block notice line count,
skipCode: false, several spans in one sentence, spans at sentence edges, and that an
unwrapped version survives sentence splitting as one piece.
Negative-controlled against git show HEAD:lib/text.js: the pre-patch module speaks the
garbled sentence above and fails three of four checks; the patched module passes all four.
dsh-tts 0.4.16-local.4 - barge-in silences the rest of the turn
dsh-tts 0.4.16-local.4 — barge-in silences the rest of the turn
Speaking over the assistant by pressing the microphone stopped it sometimes:
reliably for a reply that had already finished streaming, and not for a longer one
still being generated.
The defect
Aborting in-flight synthesis and dropping the queued slots stops speech now. It
does not stop speech resuming — the agent loop keeps emitting frames for the same
turn after the interruption, and every sentence completed afterwards was flushed,
claimed and synthesized as usual. The reply picked up again a moment later,
mid-thought.
Three separate holes fed the same symptom:
| hole | effect |
|---|---|
abortAll aborted controllers and dropped the queue but left the ownership cursor alive |
the next completed sentence of the same turn was spoken |
nothing ever dispatched dsh:tts:start / dsh:tts:stop |
dsh-voice listened for both and nothing sent them, so its isTtsSpeaking was permanently false and its own barge-in path was unreachable |
the host barge-in POST was gated on player.liveEnabled |
with live sentence streaming off, a piece still synthesizing settled into the queue and was collected by the next poll |
The fix
lib/live.js — per-turn silence. abortAll(sid) now also records the turn in a
mutedTurns map, taken from every live cursor and from the turn currently
streaming, so a barge-in that lands before the first flushed sentence — or between
two steps of a multi-step turn — still counts. speak() returns early for a muted
turn, which is safe precisely because drainPieces claims raw before returning
pieces: the silenced text stays owned by the step, so the settlement reconcile can
never read it as unsaid and speak it from the durable path afterwards. The claim
advances; only the audio is suppressed.
onSettledMessage() checks the mute first. The cursor can hold zero pieces when
the barge-in beat the first sentence, and the existing pieces === 0 fallback would
have handed the entire message back to be spoken — the loudest possible version of
the talk-over. Mutes are deliberately not pruned by turn order, because a settlement
can arrive after the next turn has already started streaming; they are bounded by a
cap instead, and the muted turn consumes its own entry when it settles.
lib/client-src/20-player.js — one hard-stop path. announceSpeech() dispatches
dsh:tts:start / dsh:tts:stop on edges only, so isTtsSpeaking is now truthful.
bargeInNow() holds the single implementation and no longer gates the host POST on
live mode, because the durable path has a pending queue too. dsh:tts:cancel is now
listened for as well as dispatched, and is authoritative — the voice plugin has
already decided that the user talking wins.
Deployment requirement
isTtsSpeaking being truthful activates dsh-voice's turn-taking gate. With the plugin
defaults (bargeIn: false, gatedTurnTaking: true) pressing the microphone during
speech now refuses with Assistant speaking (gated mode) instead of barging in. The
profile therefore sets bargeIn: true, pairing with the bargeIn: true this plugin
already reads from settings.yaml.
Verified artifact
| field | value |
|---|---|
| commit | 0d4ca887f4fb7680b8f426eb36dd99532e6c2d4b (master) |
| tag | v0.4.16-local.4 → target 0d4ca887… |
| artifact | goodandready-dsh-tts-0.4.16-local.4.tgz |
| artifact SHA256 | EFD257D4D39F1CFB879AE4B89143CBC6FBDB46B124D4FD02EBFD074A2B4F7FB9 |
| artifact size | 113 880 bytes, 37 files |
lib/live.js SHA256 |
EF3D5145A7B741AD2E9BC6657CFD1DD439C169A57566297F37B90E18AB829897 |
lib/client.js SHA256 |
8827A7399EDF227B798153E65BF68A191E8D5045A73BE930C69F12660C6344D3 |
| tests | 85 pass / 0 fail |
| parity | all production patches intact (scripts/verify-parity.mjs) |
The installed plugin's lib/live.js and lib/client.js hashes must equal the fork's;
the -local.4 tarball was installed with pnpm and the whole profile was
snapshot-diffed before and after, with only @goodandready/dsh-tts changing.
Regression coverage
test/bargein.test.mjs (12 tests) drives the engine with the exact window this defect
lived in — text arriving after the barge-in — and asserts that the piece already
audible stays audible, that later text is claimed but never spoken in both the
pieces > 0 and pieces === 0 shapes, that later steps and later frames of the
interrupted turn stay silent, that a duplicate settlement cannot resurrect it, that a
following turn speaks again, and that the mute map stays capped.
test/bargein-wiring.test.mjs (4 tests) reads the built lib/client.js for the
browser half so a rebase cannot quietly drop it. Every assertion was
negative-controlled against the pre-patch bundle: 6 of 7 checks fail there, 7 of 7
pass here.
Known boundary
A barge-in that arrives before the interrupted turn has emitted its first stream frame
— a sub-second window between sending a message and the model starting to generate —
has nothing to silence, and that reply speaks normally. Everything from the first
frame onward is covered.
dsh-tts-live v0.4.16-live.1 — Live Sentence TTS
Pre-release. This build is production-usable and has been validated on a real
local DSH voice stack, but it will keep being exercised through normal daily use
before it is declared fully stable.
This is a fork of GooDAnDReaDY/dsh-tts (MIT),
based on upstream 0.4.16 (npm tarball shasum 0516c0c9613efe5ca666738179ba34570802b92e).
It does not replace or renumber upstream releases: the package version stays
0.4.16-local.3 and v0.4.16-live.1 is this fork's own tag series. This is not
upstream 0.4.17.
Highlights
- Live sentence-level speech — driven by the process-local
agent/assistant-stream
event, the only genuinely token-level hook in DeepSeek Harness. - First-sentence early flush — the first sentence can be synthesized before
assistant/messagesettles, so audio starts while the model is still writing. - Ordered synthesis — one piece per sentence, published in order with stable ids, so
the client cannot reorder or double-play audio. - Tool / multi-step ownership — explicit session/turn/step ownership on every queued
and settled piece; reasoning text and tool-call JSON are never spoken. - Barge-in cancellation — host-side
POST /dsh-tts/bargeindrops pending audio and
aborts in-flight synthesis; a late re-settlement cannot resurrect cancelled audio. - Durable-message reconciliation — when the settled message arrives, already spoken
text is matched and only the unsaid tail is published, so nothing is said twice.
Fixes
- Duplicate-tail publication — ownership now claims an exact raw text range
(claimedEnd) instead of a re-rendered "pieces joined by one space" string, so a
single synthesis can never be published twice. - WAV / custom TTS support — the
customandopenaiproviders request
response_format: 'wav'and reportaudio/wav, matching local OpenAI-compatible
TTS servers. - Abort propagation — a bare
abortidentifier meant the cancellation listener was
never attached, so caller cancellation never reached in-flight synthesis. - Browser
SpeechSynthesisfallback suppressed — the OS-voice fallback used to speak
provider failures out loud in the wrong voice, masking real errors. - Provider timeout raised from 10 s to 120 s for slow local cold-start synthesis.
Configuration
Live mode is opt-in and off by default. All keys live under dsh-tts: in
settings.yaml:
dsh-tts:
liveSentenceStreaming: true # master switch
liveMinCharsFirst: 12 # minimum characters before the first flush
liveMinChars: 48 # minimum characters for later flushes
liveMaxChars: 0 # hard cap per piece; 0 -> falls back to sentenceChars
livePollMs: 150 # pending-queue poll interval while speaking
liveFlushOnToolCall: true # flush the remainder before a tool call
streamingEnabled: false # independent of live mode; kept false in the validated setupValidation
- 69/69 plugin tests (
npm test), 208/208 benchmark-harness selftests - cache-clean CONTROL/LIVE validation — live speech begins before message settlement
on 5/5 measured turns (median −1220 ms; the control arm never did), and the
first-flushable-sentence → first-synthesis queue wait improved from 1175 ms to 15 ms - targeted 30-turn multi-step validation — zero duplicate speech, zero out-of-order
pieces, zero unowned pieces, full sentence coverage - successful production promotion, then re-verified in production: every published
piece owned by the expected turn/step, all WAV, no duplicates, nothing left reserved,
and a real barge-in that left nothing to resurrect
Known limitations
- Shared-GPU contention between the LLM and the local TTS engine. Live streaming
improves scheduling — speech starts earlier — but it does not make the model or the
synthesizer faster. - It does not solve model TTFT. It removes the wait after the first sentence exists.
- Inline-code speech is cosmetic. With
skipCode: trueinline code spans are deleted
on both the live and the settled path, so a code-heavy sentence can be spoken with gaps.
Pre-existing upstream behaviour; live mode simply speaks more sentences, so it is heard
more often. - Performance varies with the local model, TTS engine and GPU configuration. The
figures above come from one specific local setup and are not a general benchmark. - Continued daily-use testing — hence the pre-release flag.
Install
The runtime in this tag is byte-identical to the validated commit
9c4dd72f97d50dd9c561dde5c122bda983cc30c3; package version 0.4.16-local.3.
git clone https://github.com/YevheniiMiachyn/dsh-tts-live.git
npm pack # inside the clone, to produce a frozen tarball
dsh plugin --profile web add file:/path/to/dsh-tts-0.4.16-local.3.tgzPrefer a file: tarball for production installs. link:/junction installs are fine for
scratch profiles but are not what this release was validated against.
Release artifact
The validated production tarball is intentionally not attached. Its SHA256 does match
the recorded value (464429BC2E0A0ACC8C8AF91B8C47E8D9D39E10CFF51CBF0C320E07947E1600A7)
and it contains no credentials, but its bundled scripts/verify-parity.mjs still carries
two lines containing a local machine path, so it fails this fork's own publication rule.
Use the automatically generated source archive instead: the runtime (lib/) in it is
byte-identical, and npm pack at this tag reproduces a tarball — the only differences
being the genericized default path in that one dev script and the README.
License and attribution
MIT. Upstream copyright Copyright (c) 2026 GooDAnDReaDY is preserved verbatim in
LICENSE. Not affiliated with or endorsed by the upstream
author. See PATCHES.md for the per-patch ledger and
LIVE-SENTENCE.md for the design notes and defect log.