# dsh-voice-hotkey [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) > Hold `F9` to talk, release to transcribe, and the text lands in whichever input currently has focus. A voice-input plugin for DeepSeek Harness (Host + Client halves). Hold **F9** to speak; on release the local SenseVoice recognizer transcribes it and inserts the text into the **currently focused input** — the conversation composer, a question card's free-text answer box, or any `input` / `textarea` / `contenteditable` on the page. > 中文: [README.md](README.md) ## Why not the built-in voice input The built-in plugin `@deepseek-ai/dsh-experimental-client-ui-voice-input` mounts its microphone button in the `conversation.input.activity` slot. But when an `ask_user_question` card appears it **takes over the whole composer** (the `conversation.composer` slot is replaced by `dsh-client-ui-user-questions`), so the microphone button disappears and the free-text answer box has no voice input. This plugin **occupies no slot at all**. It listens for the hotkey on `window` in the capture phase — whichever input you are in, that is where the text goes. ## Requirements It depends on DSH's built-in voice bundle for the recognizer: - `@deepseek-ai/dsh-experimental-voice-input-bundle` must be in the profile's `dsh.profile.bundles`; - the **SenseVoiceSmall INT8** model (about 230 MB) must be downloaded under `$DSH_HOME/speech-to-text` (it is fetched automatically the first time the voice bundle is enabled). Without the voice bundle the plugin still loads fine; `/status` reports the service as unavailable and `/transcribe` returns 503. ## Install ```sh # from GitHub dsh plugin --profile desktop add github:/dsh-voice-hotkey # or from a local checkout dsh plugin --profile desktop add link:/path/to/dsh-voice-hotkey ``` Then: 1. The host half takes effect immediately via the profile's `patchReload: live`; 2. The **browser half needs one page refresh** to load. ## Configuration DSH's loader passes client plugins only `{ name: id }` — **profile config is not available** — so configuration is read from `localStorage['dsh-voice-hotkey']`: ```js localStorage.setItem('dsh-voice-hotkey', JSON.stringify({ key: 'F9', // KeyboardEvent.code modifiers: [], // e.g. ['ctrl','shift'] holdToTalk: true, // false = press once to start, again to stop maxSeconds: 120, showIndicator: true })) ``` Those are the defaults. If F9 is globally captured by another app, change `key` (named by `event.code`, e.g. `F8`, `KeyG`). ## HTTP routes (host half) | Method | Path | Description | |---|---|---| | GET | `/api/dsh-voice-hotkey/status` | Recognizer status, for diagnostics | | POST | `/api/dsh-voice-hotkey/transcribe` | body `{audioBase64, providerId?, language?}` → `{ok, text}` | Both routes **accept loopback addresses only** (`req.socket.remoteAddress` must be 127/8 or `::1`, including `::ffff:` mapped forms, and the `Host` header must also resolve to localhost/127.x/[::1]); otherwise 403. > On auth: `dsh-client-connection` occupies `/api` with a **prefix** route for browser cookie auth, while `webServer` matching checks the exact table first and only falls back to prefixes. This plugin registers exact routes, which win and therefore **bypass browser auth** — which is exactly why the loopback check has to be done here. `/transcribe` validates the audio structure **before** handing it to the recognizer: it parses the RIFF/WAVE chunks and requires `fmt` (PCM / 16-bit / 16 kHz / mono), a non-empty `data` chunk, exactly a canonical 44-byte header, and computes the duration; otherwise it returns an explicit 400/413. Limits: 6 MB body / 120 seconds. The recognizer is obtained lazily via `ctx.get('speechToText')`: if the voice bundle is disabled the plugin still loads normally, `/status` just reports the service unavailable and `/transcribe` returns 503. ## Three pitfalls that bite (each handled in the implementation) 1. **The WAV must have a canonical 44-byte header.** WAV files produced by Windows TTS use an **18-byte fmt chunk** (confirmed by measurement), but the provider's `validateWave` requires exactly 16 and rejects them outright. The browser side resamples to 16 kHz mono with `OfflineAudioContext(1, frames, 16000)` and then hand-encodes a canonical 44-byte-header PCM16 WAV. 2. **Lexical's `execCommand` commits asynchronously.** The conversation composer is Lexical. `execCommand('insertText', false, text)` dispatches `beforeinput`; Lexical intercepts it, calls `preventDefault()`, and then commits the DOM update itself **in a microtask**. So: ``` execCommand(...) -> returns true element.textContent -> looks "unchanged" synchronously element.textContent -> the text appears one tick later ``` A naive "synchronously unchanged → assume failure → fall back to manual insert" makes the editor's own commit and the fallback insert land together, **inserting the same sentence twice**. The correct strategy is "**if the editor moved at all, never insert again**": call `execCommand` exactly once → the moment `textContent` changes in any way (sync or async) stop immediately → only after **3 macrotasks** of grace with still no change, add one plain DOM insert (deliberately **without** dispatching an `input` event). Judge by "did anything change", not "does it contain this text" — the editor may normalize text (merging/splitting text nodes), and a "contains" check would misjudge failure and insert twice. 3. **Configuration can only live in `localStorage`**, because the loader does not pass config to client plugins. ## Other behavior - **Insertion target priority**: the `document.activeElement` at the moment the hotkey was pressed (if still connected) → the current `document.activeElement` → the question answer box `[data-question-key] textarea` → the conversation composer `[data-composer-input]`. - `` / `