pi-push-to-talk 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 sqwxl
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,76 @@
1
- # Temporary Holding Version
1
+ # pi-push-to-talk
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Hold space in pi's prompt to dictate. Your words appear where the cursor is, and speech recognition runs on your machine.
4
+
5
+ ```
6
+ hold space → speak → release
7
+ ```
8
+
9
+ No overlay, no separate window, no API key. The transcript is ordinary text in the editor, so you can keep typing, move the cursor, and dictate again wherever you are.
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pi install npm:pi-push-to-talk
15
+ ```
16
+
17
+ Remove any other voice extension first. Several of them also bind space or replace the editor, and two handlers on one key produce dropped spaces.
18
+
19
+ ## Use
20
+
21
+ | Action | Result |
22
+ |---|---|
23
+ | Hold space (> 350 ms) | Recording starts; text appears at the cursor every second and updates in place |
24
+ | Release | That phrase is transcribed and committed |
25
+ | Pause 2.5 s while holding | The open phrase is committed and dictation continues |
26
+ | Tap space | An ordinary space, as usual |
27
+
28
+ While you speak, the open phrase is drawn dim; committed text is normal.
29
+
30
+ ## First run
31
+
32
+ Whisper base is downloaded once, about 198 MB, into `~/.pi/models/whisper-base/`. Only the int8 files are kept. After that, dictation is fully offline.
33
+
34
+ Extraction uses the system `tar` with bzip2 support, which is present on macOS, Linux, and Windows 10+.
35
+
36
+ ## How it works
37
+
38
+ - Capture: `decibri` reads the microphone in process, resampling to 16 kHz mono.
39
+ - Recognition: `sherpa-onnx-node` runs Whisper base int8 on the CPU, four threads.
40
+ - Segmentation: a phrase closes after 2.5 s of silence or 12 s of unbroken speech; the cut lands in the quietest part of the last 800 ms, not mid-syllable.
41
+ - Editor: the extension owns the prompt editor, but only to write text. Keys are handled on the raw terminal path and only while the main editor has focus, so dialogs and nested editors keep working.
42
+
43
+ ## Requirements
44
+
45
+ - pi with extension support
46
+ - A microphone the terminal may use
47
+ - macOS, Linux, or Windows, x64 or arm64
48
+
49
+ ## Known limits
50
+
51
+ - Undo history records each preview rewrite; `Ctrl+Z` after a dictation steps back through them. The editor API offers no way to write without an undo entry.
52
+ - English only.
53
+ - A true sentence end followed by a pause longer than 2.5 s loses its full stop, because a mid-session commit drops Whisper's trailing period.
54
+ - Brackets are stripped from transcripts, since Whisper emits them as sound captions. Dictating code with brackets is not supported.
55
+ - The cursor is restored with simulated left-arrow presses, capped at 4,000 characters. Inserting near the start of a very long prompt can leave the cursor short of the dictation point.
56
+ - Nested editors (dialogs) do not accept dictation, only normal typing.
57
+
58
+ ## Diagnostics
59
+
60
+ Both are off unless the environment variable is set; both write only to `/tmp`.
61
+
62
+ | Variable | File | Content |
63
+ |---|---|---|
64
+ | `PI_DICTATION_DEBUG=1` | `/tmp/pi-dictation.log` | Space events, phrase decodes, recognizer errors |
65
+ | `PI_DICTATION_PROBE=1` | `/tmp/pi-dictation-probe.log` | Every terminal key sequence, for diagnosing key encoding. Dictation is disabled in this mode. |
66
+
67
+ ## Uninstall
68
+
69
+ ```bash
70
+ pi remove npm:pi-push-to-talk
71
+ rm -rf ~/.pi/models/whisper-base # optional, frees 154 MB
72
+ ```
73
+
74
+ ## License
75
+
76
+ MIT
package/index.ts ADDED
@@ -0,0 +1,73 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { DictationEditor } from "./src/editor.ts";
3
+ import { SpaceHold } from "./src/hold.ts";
4
+ import { ProbeEditor } from "./src/probe.ts";
5
+ import { Stt } from "./src/stt.ts";
6
+
7
+ type Ui = {
8
+ notify: (message: string, level: "info" | "warning" | "error") => void;
9
+ setStatus: (key: string, text: string | undefined) => void;
10
+ onTerminalInput: (handler: (data: string) => { consume?: boolean } | undefined) => () => void;
11
+ };
12
+
13
+ export default function dictationExtension(pi: ExtensionAPI): void {
14
+ let ui: Ui | undefined;
15
+ let editor: DictationEditor | undefined;
16
+ let sttPromise: Promise<Stt> | undefined;
17
+ let unsubscribe: (() => void) | undefined;
18
+
19
+ const getStt = (): Promise<Stt> => {
20
+ if (!sttPromise) {
21
+ sttPromise = Stt.create(undefined, (message) => ui?.setStatus("dictation", message)).then(
22
+ (stt) => {
23
+ ui?.setStatus("dictation", undefined);
24
+ return stt;
25
+ },
26
+ (error: unknown) => {
27
+ // Do not cache a failure: the next hold retries.
28
+ sttPromise = undefined;
29
+ ui?.setStatus("dictation", undefined);
30
+ throw error;
31
+ },
32
+ );
33
+ }
34
+ return sttPromise;
35
+ };
36
+
37
+ const hold = new SpaceHold({
38
+ getEditor: () => editor,
39
+ getStt,
40
+ notify: (message, level) => ui?.notify(message, level),
41
+ status: (text) => ui?.setStatus("dictation", text),
42
+ });
43
+
44
+ pi.on("session_start", (_event, ctx) => {
45
+ if (!ctx.hasUI) return;
46
+ ui = ctx.ui;
47
+
48
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
49
+ if (process.env.PI_DICTATION_PROBE) return new ProbeEditor(tui, theme, keybindings);
50
+ editor = new DictationEditor(tui, theme, keybindings);
51
+ return editor;
52
+ });
53
+
54
+ unsubscribe?.();
55
+ // Probe mode replaces the editor purely to log keys; it must not consume
56
+ // space or start sessions.
57
+ unsubscribe = process.env.PI_DICTATION_PROBE
58
+ ? undefined
59
+ : ctx.ui.onTerminalInput((data) => (hold.handle(data) ? { consume: true } : undefined));
60
+ });
61
+
62
+ // A submitted prompt clears the editor; an open session must not refill it
63
+ // with a transcript written against the old buffer.
64
+ pi.on("input", () => {
65
+ hold.cancel();
66
+ });
67
+
68
+ pi.on("session_shutdown", () => {
69
+ hold.cancel();
70
+ unsubscribe?.();
71
+ unsubscribe = undefined;
72
+ });
73
+ }
package/package.json CHANGED
@@ -1,6 +1,34 @@
1
1
  {
2
2
  "name": "pi-push-to-talk",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Hold space to dictate into pi's prompt, transcribed locally with Whisper.",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi-extension",
9
+ "dictation",
10
+ "push-to-talk",
11
+ "speech-to-text",
12
+ "whisper",
13
+ "local"
14
+ ],
15
+ "license": "MIT",
16
+ "author": "sqwxl",
17
+ "engines": {
18
+ "node": ">=20"
19
+ },
20
+ "pi": {
21
+ "extensions": ["./index.ts"]
22
+ },
23
+ "files": ["index.ts", "src/**/*.ts", "!src/**/*.test.ts", "README.md", "LICENSE"],
24
+ "dependencies": {
25
+ "decibri": "^3.4.2",
26
+ "sherpa-onnx-node": "^1.13.8"
27
+ },
28
+ "peerDependencies": {
29
+ "@earendil-works/pi-coding-agent": "*"
30
+ },
31
+ "peerDependenciesMeta": {
32
+ "@earendil-works/pi-coding-agent": { "optional": true }
33
+ }
34
+ }
package/src/capture.ts ADDED
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Microphone capture.
3
+ *
4
+ * Asks the device for 16 kHz mono directly; falls back to a native rate with a
5
+ * linear resampler when the device refuses, which is what built-in mics do on
6
+ * some Linux setups.
7
+ */
8
+ import { createRequire } from "node:module";
9
+ import { SAMPLE_RATE, msToSamples } from "./config.ts";
10
+
11
+ const require = createRequire(import.meta.url);
12
+
13
+ export interface Microphone {
14
+ stop(): void;
15
+ }
16
+
17
+ const FALLBACK_RATES = [SAMPLE_RATE, 48000, 44100];
18
+
19
+ function toFloat32(chunk: Buffer): Float32Array {
20
+ const count = Math.floor(chunk.length / 2);
21
+ const out = new Float32Array(count);
22
+ for (let i = 0; i < count; i++) out[i] = chunk.readInt16LE(i * 2) / 32768;
23
+ return out;
24
+ }
25
+
26
+ /** Linear resampler with phase carried across chunks. */
27
+ function makeResampler(fromRate: number): (input: Float32Array) => Float32Array {
28
+ if (fromRate === SAMPLE_RATE) return (input) => input;
29
+ const step = fromRate / SAMPLE_RATE;
30
+ let position = 0;
31
+ let last = 0;
32
+
33
+ return (input) => {
34
+ const out: number[] = [];
35
+ for (;;) {
36
+ const index = Math.floor(position);
37
+ if (index + 1 >= input.length) break;
38
+ const frac = position - index;
39
+ const a = index < 0 ? last : (input[index] ?? 0);
40
+ const b = input[index + 1] ?? 0;
41
+ out.push(a + (b - a) * frac);
42
+ position += step;
43
+ }
44
+ position -= input.length;
45
+ last = input[input.length - 1] ?? last;
46
+ return Float32Array.from(out);
47
+ };
48
+ }
49
+
50
+ export async function openMicrophone(
51
+ onSamples: (samples: Float32Array) => void,
52
+ onError: (error: Error) => void,
53
+ ): Promise<Microphone> {
54
+ const Decibri = require("decibri");
55
+ let failure: Error = new Error("no capture device");
56
+
57
+ for (const rate of FALLBACK_RATES) {
58
+ try {
59
+ const mic = new Decibri({
60
+ sampleRate: rate,
61
+ channels: 1,
62
+ framesPerBuffer: Math.round((msToSamples(100) * rate) / SAMPLE_RATE),
63
+ format: "int16",
64
+ });
65
+ const resample = makeResampler(rate);
66
+ let stopped = false;
67
+ mic.on("data", (chunk: Buffer) => {
68
+ const samples = resample(toFloat32(chunk));
69
+ if (samples.length > 0) onSamples(samples);
70
+ });
71
+ mic.on("error", (error: Error) => {
72
+ if (!stopped) onError(error);
73
+ });
74
+ // A device that disappears mid-recording must not leave a silent session.
75
+ for (const event of ["end", "close"] as const) {
76
+ mic.once(event, () => {
77
+ if (!stopped) onError(new Error("microphone stream ended"));
78
+ });
79
+ }
80
+ return {
81
+ stop: () => {
82
+ stopped = true;
83
+ mic.stop();
84
+ },
85
+ };
86
+ } catch (error) {
87
+ failure = error instanceof Error ? error : new Error(String(error));
88
+ }
89
+ }
90
+
91
+ throw failure;
92
+ }
package/src/config.ts ADDED
@@ -0,0 +1,41 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+
4
+ export const SAMPLE_RATE = 16000;
5
+
6
+ /** How often the still-open phrase is re-decoded for the live preview. */
7
+ export const PARTIAL_INTERVAL_MS = 1000;
8
+
9
+ /**
10
+ * Silence that closes a phrase.
11
+ *
12
+ * Long on purpose: a pause mid-sentence is the speaker thinking, not the end of
13
+ * a thought. Cutting there splits words across two decodes, which is where lost
14
+ * and reordered words come from. The 12 s cap still bounds a non-stop stretch.
15
+ */
16
+ export const SILENCE_COMMIT_MS = 2500;
17
+
18
+ /** Trailing silence kept with a phrase so its last word is not clipped. */
19
+ export const TAIL_PAD_MS = 300;
20
+
21
+ /** Shorter buffers are not worth a decode. */
22
+ export const MIN_PHRASE_MS = 400;
23
+
24
+ /** Longest phrase sent to the recognizer in one piece. */
25
+ export const MAX_PHRASE_MS = 12000;
26
+
27
+ /** Window scanned for the quietest point when the cap fires. */
28
+ export const CAP_CUT_SCAN_MS = 800;
29
+
30
+ /** Mean level below which a buffer counts as silence, not speech. */
31
+ export const MIN_PHRASE_RMS = 0.005;
32
+
33
+ /** SPACE held this long starts dictating. */
34
+ export const HOLD_MS = 350;
35
+
36
+ /** A space this soon after typing stays a space (pi-voicekit's value). */
37
+ export const TYPING_COOLDOWN_MS = 400;
38
+
39
+ export const MODEL_DIR = path.join(os.homedir(), ".pi", "models", "whisper-base");
40
+
41
+ export const msToSamples = (ms: number): number => Math.round((SAMPLE_RATE * ms) / 1000);
package/src/debug.ts ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Opt-in trace for the space state machine. `PI_DICTATION_DEBUG=1 pi` writes
3
+ * one line per space event to /tmp/pi-dictation.log — the only way to tell a
4
+ * missing release from an unmatched one.
5
+ */
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+
10
+ const ENABLED = !!process.env.PI_DICTATION_DEBUG;
11
+ export const LOG_FILE = path.join(os.tmpdir(), "pi-dictation.log");
12
+
13
+ export function trace(message: string): void {
14
+ if (!ENABLED) return;
15
+ try {
16
+ fs.appendFileSync(LOG_FILE, `${new Date().toISOString()} ${message}\n`);
17
+ } catch {
18
+ // Diagnostics must never break input handling.
19
+ }
20
+ }
package/src/dim.ts ADDED
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Dim the uncommitted transcript: the N characters just before the cursor.
3
+ *
4
+ * The editor renders the cursor as zero-width APC markers around one character,
5
+ * so the end of the open text is unambiguous. Everything else here is ANSI
6
+ * bookkeeping: escape sequences are zero-width, so they must not be counted.
7
+ */
8
+ const FAKE_CURSOR_START = "\x1b_pi:fc\x07";
9
+ const ANSI = /\x1b(?:\[[0-9;?]*[A-Za-z]|_pi:[^\x07]*\x07)/y;
10
+
11
+ const DIM_ON = "\x1b[2m";
12
+ const DIM_OFF = "\x1b[22m";
13
+
14
+ /** Visible characters of a line, with the string index where each starts. */
15
+ function visibleChars(line: string): Array<{ text: string; at: number }> {
16
+ const out: Array<{ text: string; at: number }> = [];
17
+ let at = 0;
18
+ while (at < line.length) {
19
+ ANSI.lastIndex = at;
20
+ const escape = ANSI.exec(line);
21
+ if (escape) {
22
+ at += escape[0].length;
23
+ continue;
24
+ }
25
+ const char = String.fromCodePoint(line.codePointAt(at) ?? 0);
26
+ out.push({ text: char, at });
27
+ at += char.length;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /**
33
+ * Wrap the `count` visible characters before the cursor in dim, per line.
34
+ * Returns the input untouched when the cursor cannot be found or `count` is 0.
35
+ */
36
+ export function dimBeforeCursor(lines: readonly string[], count: number): string[] {
37
+ if (count <= 0) return lines as string[];
38
+
39
+ const cursorLine = lines.findIndex((line) => line.includes(FAKE_CURSOR_START));
40
+ if (cursorLine === -1) return lines as string[];
41
+
42
+ // Characters before the cursor, walking back from the cursor line.
43
+ const targets: Array<{ line: number; at: number; length: number }> = [];
44
+ let remaining = count;
45
+
46
+ for (let lineIndex = cursorLine; lineIndex >= 0 && remaining > 0; lineIndex--) {
47
+ const chars = visibleChars(lines[lineIndex] ?? "");
48
+ let limit = chars.length;
49
+ if (lineIndex === cursorLine) {
50
+ const markerAt = (lines[lineIndex] ?? "").indexOf(FAKE_CURSOR_START);
51
+ limit = chars.filter((char) => char.at < markerAt).length;
52
+ }
53
+ for (let i = limit - 1; i >= 0 && remaining > 0; i--) {
54
+ const char = chars[i];
55
+ if (!char) continue;
56
+ targets.push({ line: lineIndex, at: char.at, length: char.text.length });
57
+ remaining--;
58
+ }
59
+ }
60
+ if (targets.length === 0) return [...lines];
61
+
62
+ const first = targets[targets.length - 1];
63
+ const last = targets[0];
64
+ if (!first || !last) return [...lines];
65
+
66
+ const byLine = new Map<number, Array<{ at: number; text: string }>>();
67
+ const push = (line: number, at: number, text: string) => {
68
+ const list = byLine.get(line) ?? [];
69
+ list.push({ at, text });
70
+ byLine.set(line, list);
71
+ };
72
+ push(first.line, first.at, DIM_ON);
73
+ push(last.line, last.at + last.length, DIM_OFF);
74
+
75
+ const out = [...lines];
76
+ for (const [lineIndex, insertions] of byLine) {
77
+ let line = out[lineIndex] ?? "";
78
+ // Right to left so earlier indices stay valid.
79
+ for (const insertion of [...insertions].sort((a, b) => b.at - a.at)) {
80
+ line = line.slice(0, insertion.at) + insertion.text + line.slice(insertion.at);
81
+ }
82
+ out[lineIndex] = line;
83
+ }
84
+ return out;
85
+ }
package/src/editor.ts ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The prompt editor, extended with cursor-preserving writes and dim rendering
3
+ * of the uncommitted transcript.
4
+ *
5
+ * Key handling lives in SpaceHold, which sits on the raw terminal input path.
6
+ * Nothing here can swallow a keystroke.
7
+ */
8
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
9
+ import { dimBeforeCursor } from "./dim.ts";
10
+
11
+ /** Guard against pathological cursor rewinds on very long buffers. */
12
+ const MAX_CURSOR_REWIND = 4000;
13
+
14
+ export class DictationEditor extends CustomEditor {
15
+ /** Characters before the cursor that are not committed yet. */
16
+ private partialLength = 0;
17
+ /** Last buffer state written, so identical rewrites are skipped. */
18
+ private lastWrite = "";
19
+
20
+ override render(width: number): string[] {
21
+ const lines = super.render(width);
22
+ return dimBeforeCursor(lines, this.partialLength);
23
+ }
24
+
25
+ /** Absolute offset of the cursor in the buffer. */
26
+ insertionOffset(): number {
27
+ const { line, col } = this.getCursor();
28
+ const lines = this.getLines();
29
+ let offset = 0;
30
+ for (let i = 0; i < line && i < lines.length; i++) offset += (lines[i] ?? "").length + 1;
31
+ return offset + col;
32
+ }
33
+
34
+ /** Replace the buffer, keep the cursor at `cursorOffset`, dim `partialLength` before it. */
35
+ writeAtCursor(text: string, cursorOffset: number, partialLength: number): void {
36
+ const state = `${cursorOffset}:${partialLength}:${text}`;
37
+ if (state === this.lastWrite) return;
38
+ this.lastWrite = state;
39
+
40
+ this.setText(text);
41
+ let rewind = Math.min(MAX_CURSOR_REWIND, Math.max(0, text.length - cursorOffset));
42
+ while (rewind > 0) {
43
+ super.handleInput("\x1b[D");
44
+ rewind--;
45
+ }
46
+ // UTF-16 length; identical to the visible count for the scripts this is
47
+ // used with, and an off-by-one only trims one cell from the dim span.
48
+ this.partialLength = Math.max(0, partialLength);
49
+ // setText does not repaint on its own, so timer-driven writes would stay
50
+ // invisible until the next keystroke.
51
+ this.tui.requestRender(true);
52
+ }
53
+
54
+ /** Insert a literal space at the cursor. */
55
+ typeSpace(): void {
56
+ super.handleInput(" ");
57
+ this.tui.requestRender(true);
58
+ }
59
+
60
+ /** Remove one character before the cursor (the space typed by the key press). */
61
+ deleteBackward(): void {
62
+ super.handleInput("\x7f");
63
+ this.tui.requestRender(true);
64
+ }
65
+ }
package/src/hold.ts ADDED
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Hold SPACE to dictate.
3
+ *
4
+ * Hooks `ctx.ui.onTerminalInput`, the raw terminal path, because that is where
5
+ * kitty key releases arrive. The press is left to the editor, so typing a space
6
+ * never depends on this state machine: if the key stays down past HOLD_MS the
7
+ * space the editor just typed is taken back and dictation starts.
8
+ */
9
+ import { isKeyRelease, isKeyRepeat, matchesKey } from "@earendil-works/pi-tui";
10
+ import { HOLD_MS, TYPING_COOLDOWN_MS } from "./config.ts";
11
+ import { trace } from "./debug.ts";
12
+ import type { DictationEditor } from "./editor.ts";
13
+ import { createRegion } from "./region.ts";
14
+ import { DictationSession, type SessionUi } from "./session.ts";
15
+ import type { Stt } from "./stt.ts";
16
+
17
+ export type HoldDeps = {
18
+ getEditor: () => DictationEditor | undefined;
19
+ getStt: () => Promise<Stt>;
20
+ notify: (message: string, level: "info" | "warning" | "error") => void;
21
+ status: (text: string | undefined) => void;
22
+ /** Test seam; production uses DictationSession. */
23
+ createSession?: (stt: Stt, ui: SessionUi) => DictationSession;
24
+ };
25
+
26
+ /** A hold with no release event for this long is treated as stale. */
27
+ const STALE_HOLD_MS = 5000;
28
+
29
+ export class SpaceHold {
30
+ private readonly deps: HoldDeps;
31
+ private session: DictationSession | undefined;
32
+ private holdTimer: ReturnType<typeof setTimeout> | undefined;
33
+ private holding = false;
34
+ private holdingSince = 0;
35
+ private lastTypingAt = 0;
36
+ private starting = false;
37
+ /** Resolves when the previous session finished its final decode. */
38
+ private stopping: Promise<unknown> = Promise.resolve();
39
+
40
+ constructor(deps: HoldDeps) {
41
+ this.deps = deps;
42
+ }
43
+
44
+ /** Returns true when the event belongs to dictation and must be consumed. */
45
+ handle(data: string): boolean {
46
+ const editor = this.deps.getEditor();
47
+
48
+ // Raw terminal input is global: dialogs and nested editors receive it too.
49
+ // Without this guard a space typed into a permission prompt would start
50
+ // dictation instead of reaching that editor.
51
+ if (editor && editor.focused === false) {
52
+ if (this.session?.isActive()) this.cancel();
53
+ return false;
54
+ }
55
+
56
+ if (matchesKey(data, "space")) return this.handleSpace(isKeyRelease(data), isKeyRepeat(data));
57
+
58
+ if (!isKeyRelease(data) && !isKeyRepeat(data) && data.length === 1 && data.charCodeAt(0) >= 32) {
59
+ this.lastTypingAt = Date.now();
60
+ }
61
+ return false;
62
+ }
63
+
64
+ /** Abandon any open session without committing, e.g. when the prompt is submitted. */
65
+ cancel(): void {
66
+ this.holding = false;
67
+ this.clearHoldTimer();
68
+ this.session?.cancel();
69
+ this.session = undefined;
70
+ }
71
+
72
+ private handleSpace(released: boolean, repeated: boolean): boolean {
73
+ trace(
74
+ `space release=${String(released)} repeat=${String(repeated)} holding=${String(this.holding)} active=${String(this.session?.isActive())}`,
75
+ );
76
+
77
+ if (this.session?.isActive()) {
78
+ if (released) this.finish();
79
+ return true; // space events are meaningless while dictating
80
+ }
81
+
82
+ if (released) {
83
+ this.holding = false;
84
+ this.clearHoldTimer();
85
+ return false; // the space the editor typed stays
86
+ }
87
+
88
+ // Repeats of a held key must not type more spaces; the first one is the
89
+ // space we take back when the hold threshold is reached.
90
+ if (this.holding) {
91
+ if (Date.now() - this.holdingSince > STALE_HOLD_MS) {
92
+ this.holding = false; // release was lost; recover typing
93
+ return false;
94
+ }
95
+ return true;
96
+ }
97
+
98
+ if (repeated) return false;
99
+ if (Date.now() - this.lastTypingAt < TYPING_COOLDOWN_MS) return false;
100
+
101
+ this.holding = true;
102
+ this.holdingSince = Date.now();
103
+ this.holdTimer = setTimeout(() => {
104
+ this.holdTimer = undefined;
105
+ void this.begin();
106
+ }, HOLD_MS);
107
+ return false;
108
+ }
109
+
110
+ private finish(): void {
111
+ this.holding = false;
112
+ this.clearHoldTimer();
113
+ if (this.session) this.stopping = this.session.stop();
114
+ void this.stopping;
115
+ }
116
+
117
+ private async begin(): Promise<void> {
118
+ if (this.starting) return;
119
+ this.starting = true;
120
+ try {
121
+ const editor = this.deps.getEditor();
122
+ if (!editor) return;
123
+
124
+ // Take back the space the editor typed when the key went down.
125
+ editor.deleteBackward();
126
+
127
+ // A previous stop may still be decoding its tail; its write would land
128
+ // on top of the text this session is about to produce.
129
+ await this.stopping;
130
+
131
+ // Released before capture began: the tap must not vanish.
132
+ if (!this.holding) {
133
+ editor.typeSpace();
134
+ return;
135
+ }
136
+
137
+ const stt = await this.deps.getStt();
138
+ if (!this.holding) {
139
+ editor.typeSpace();
140
+ return;
141
+ }
142
+
143
+ const ui: SessionUi = {
144
+ write: (text, cursorOffset, partialLength) => editor.writeAtCursor(text, cursorOffset, partialLength),
145
+ status: (text) => this.deps.status(text),
146
+ notify: (message, level) => this.deps.notify(message, level),
147
+ };
148
+ const create = this.deps.createSession ?? ((engine, sessionUi) => new DictationSession(engine, sessionUi));
149
+ const session = create(stt, ui);
150
+ this.session = session;
151
+ await session.start(createRegion(editor.getText(), editor.insertionOffset()));
152
+
153
+ // Released while the capture device was opening: close it, and give the
154
+ // space back if nothing was transcribed.
155
+ if (!this.holding) {
156
+ const committed = await session.stop();
157
+ if (!committed) editor.typeSpace();
158
+ }
159
+ } catch (error) {
160
+ this.deps.notify(`Dictation: ${String(error)}`, "error");
161
+ // Nothing was transcribed, so the deleted space comes back.
162
+ if (!this.session?.isActive()) this.deps.getEditor()?.typeSpace();
163
+ } finally {
164
+ this.starting = false;
165
+ }
166
+ }
167
+
168
+ private clearHoldTimer(): void {
169
+ if (this.holdTimer) {
170
+ clearTimeout(this.holdTimer);
171
+ this.holdTimer = undefined;
172
+ }
173
+ }
174
+ }
package/src/model.ts ADDED
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Model bootstrap.
3
+ *
4
+ * The recognizer needs whisper-base int8 on disk. Fetching it is part of first
5
+ * use, not an install step, so a fresh `pi install` works without instructions.
6
+ * System `tar` does the extraction because the archive is bzip2.
7
+ */
8
+ import { execFile } from "node:child_process";
9
+ import fs from "node:fs";
10
+ import os from "node:os";
11
+ import path from "node:path";
12
+ import { Readable } from "node:stream";
13
+ import { pipeline } from "node:stream/promises";
14
+ import { promisify } from "node:util";
15
+ import { MODEL_DIR } from "./config.ts";
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ const ARCHIVE_URL =
20
+ "https://github.com/k2-fsa/sherpa-onnx/releases/download/asr-models/sherpa-onnx-whisper-base.tar.bz2";
21
+
22
+ /** Files the recognizer loads, all int8. */
23
+ export const REQUIRED_FILES = ["base-encoder.int8.onnx", "base-decoder.int8.onnx", "base-tokens.txt"] as const;
24
+
25
+ /** Full-precision duplicates in the archive; not used on CPU. */
26
+ const FP32_FILES = ["base-encoder.onnx", "base-decoder.onnx"];
27
+
28
+ export type Progress = (message: string) => void;
29
+
30
+ export function modelReady(dir: string = MODEL_DIR): boolean {
31
+ return REQUIRED_FILES.every((file) => fs.existsSync(path.join(dir, file)));
32
+ }
33
+
34
+ async function download(url: string, destination: string, onProgress?: Progress): Promise<void> {
35
+ const response = await fetch(url, { redirect: "follow" });
36
+ if (!response.ok || !response.body) {
37
+ throw new Error(`model download failed: HTTP ${response.status}`);
38
+ }
39
+
40
+ const total = Number(response.headers.get("content-length") ?? 0);
41
+ let received = 0;
42
+ let reported = -1;
43
+
44
+ const source = Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]);
45
+ source.on("data", (chunk: Buffer) => {
46
+ received += chunk.length;
47
+ if (!onProgress || total === 0) return;
48
+ const percent = Math.floor((received / total) * 100);
49
+ if (percent >= reported + 10) {
50
+ reported = percent;
51
+ onProgress(`Downloading Whisper base ${percent}%`);
52
+ }
53
+ });
54
+
55
+ await pipeline(source, fs.createWriteStream(destination));
56
+ }
57
+
58
+ /** Ensure the model directory holds every required file; download it once if not. */
59
+ export async function ensureModel(dir: string = MODEL_DIR, onProgress?: Progress): Promise<string> {
60
+ if (modelReady(dir)) return dir;
61
+
62
+ fs.mkdirSync(dir, { recursive: true });
63
+ const archive = path.join(os.tmpdir(), `pi-dictation-${process.pid}.tar.bz2`);
64
+
65
+ try {
66
+ onProgress?.("Downloading Whisper base (198 MB, once)");
67
+ await download(ARCHIVE_URL, archive, onProgress);
68
+
69
+ onProgress?.("Extracting model files");
70
+ // --strip-components drops the archive's wrapper directory.
71
+ await execFileAsync("tar", ["-xjf", archive, "--strip-components=1", "-C", dir]);
72
+
73
+ for (const file of FP32_FILES) fs.rmSync(path.join(dir, file), { force: true });
74
+ // Sample clips from the archive; not used by the recognizer.
75
+ fs.rmSync(path.join(dir, "test_wavs"), { recursive: true, force: true });
76
+ } finally {
77
+ fs.rmSync(archive, { force: true });
78
+ }
79
+
80
+ if (!modelReady(dir)) {
81
+ throw new Error(`model files missing after extraction in ${dir}`);
82
+ }
83
+ return dir;
84
+ }
package/src/probe.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Temporary key probe.
3
+ *
4
+ * Hold-to-talk needs a key whose press *and* release both reach the app. This
5
+ * editor logs every sequence the terminal sends, so the binding can be chosen
6
+ * from evidence instead of guessed. Enabled with PI_DICTATION_PROBE=1.
7
+ */
8
+ import fs from "node:fs";
9
+ import os from "node:os";
10
+ import path from "node:path";
11
+ import { CustomEditor } from "@earendil-works/pi-coding-agent";
12
+
13
+ export const PROBE_LOG = path.join(os.tmpdir(), "pi-dictation-probe.log");
14
+
15
+ export class ProbeEditor extends CustomEditor {
16
+ override handleInput(data: string): void {
17
+ try {
18
+ const hex = Buffer.from(data, "utf8").toString("hex");
19
+ const printable = /^[\x20-\x7e]+$/.test(data) ? JSON.stringify(data) : "-";
20
+ fs.appendFileSync(PROBE_LOG, `${new Date().toISOString()} hex=${hex} text=${printable}\n`);
21
+ } catch {
22
+ // A diagnostic must never break typing.
23
+ }
24
+ super.handleInput(data);
25
+ }
26
+ }
package/src/region.ts ADDED
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The text region the current dictation owns.
3
+ *
4
+ * `before` and `after` are frozen when the key is pressed, so the rest of the
5
+ * buffer is never rewritten. Only `committed` + `partial` change while the key
6
+ * is held.
7
+ */
8
+ export type Region = {
9
+ before: string;
10
+ committed: string;
11
+ partial: string;
12
+ after: string;
13
+ };
14
+
15
+ /** True when two pieces would glue together without a space. */
16
+ const needsSpace = (left: string, right: string): boolean =>
17
+ left.length > 0 && right.length > 0 && !/\s$/.test(left) && !/^\s/.test(right);
18
+
19
+ /** Join two pieces with a space unless the seam is already whitespace. */
20
+ export function joinText(left: string, right: string): string {
21
+ return needsSpace(left, right) ? `${left} ${right}` : left + right;
22
+ }
23
+
24
+ export function createRegion(text: string, offset: number): Region {
25
+ const at = Math.max(0, Math.min(offset, text.length));
26
+ return { before: text.slice(0, at), committed: "", partial: "", after: text.slice(at) };
27
+ }
28
+
29
+ export function withPartial(region: Region, partial: string): Region {
30
+ return { ...region, partial };
31
+ }
32
+
33
+ export function commitPartial(region: Region): Region {
34
+ if (region.partial.trim().length === 0) return { ...region, partial: "" };
35
+ return { ...region, committed: joinText(region.committed, region.partial.trim()), partial: "" };
36
+ }
37
+
38
+ const renderHead = (region: Region): string => joinText(joinText(region.before, region.committed), region.partial);
39
+
40
+ export function renderRegion(region: Region): string {
41
+ return joinText(renderHead(region), region.after);
42
+ }
43
+
44
+ /** Where the cursor belongs: right after the text this session owns. */
45
+ export function regionCursorOffset(region: Region): number {
46
+ return renderHead(region).length;
47
+ }
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Phrase boundaries.
3
+ *
4
+ * A phrase closes on SILENCE_COMMIT_MS of quiet, or at MAX_PHRASE_MS. The cap
5
+ * cuts at the quietest point of the trailing CAP_CUT_SCAN_MS so it lands in a
6
+ * breath rather than mid-syllable; the tail carries into the next phrase.
7
+ */
8
+ import {
9
+ CAP_CUT_SCAN_MS,
10
+ MAX_PHRASE_MS,
11
+ MIN_PHRASE_RMS,
12
+ SILENCE_COMMIT_MS,
13
+ TAIL_PAD_MS,
14
+ msToSamples,
15
+ } from "./config.ts";
16
+
17
+ export type ClosedPhrase = {
18
+ samples: Float32Array;
19
+ reason: "silence" | "cap";
20
+ };
21
+
22
+ /**
23
+ * Level of a buffer, with its mean removed.
24
+ *
25
+ * Centring matters: a capture stream with a constant offset reads as loud even
26
+ * in silence, which would defeat the silence gate entirely.
27
+ */
28
+ export function rmsOf(samples: Float32Array): number {
29
+ if (samples.length === 0) return 0;
30
+ let sum = 0;
31
+ for (let i = 0; i < samples.length; i++) sum += samples[i] ?? 0;
32
+ const mean = sum / samples.length;
33
+
34
+ let energy = 0;
35
+ for (let i = 0; i < samples.length; i++) {
36
+ const centred = (samples[i] ?? 0) - mean;
37
+ energy += centred * centred;
38
+ }
39
+ return Math.sqrt(energy / samples.length);
40
+ }
41
+
42
+ function concat(chunks: readonly Float32Array[]): Float32Array {
43
+ const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
44
+ const out = new Float32Array(total);
45
+ let at = 0;
46
+ for (const chunk of chunks) {
47
+ out.set(chunk, at);
48
+ at += chunk.length;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export class Segmenter {
54
+ private chunks: Float32Array[] = [];
55
+ private total = 0;
56
+ private silent = 0;
57
+ private hasSpeech = false;
58
+
59
+ /** Feed captured audio; returns a phrase when one closes. */
60
+ add(samples: Float32Array): ClosedPhrase | undefined {
61
+ if (samples.length === 0) return undefined;
62
+ this.chunks.push(samples);
63
+ this.total += samples.length;
64
+
65
+ const quiet = rmsOf(samples) < MIN_PHRASE_RMS;
66
+ if (quiet) {
67
+ this.silent += samples.length;
68
+ } else {
69
+ this.silent = 0;
70
+ this.hasSpeech = true;
71
+ }
72
+
73
+ const silenceLimit = msToSamples(SILENCE_COMMIT_MS);
74
+ if (this.silent >= silenceLimit) {
75
+ // Nothing was ever said: drop the buffer instead of closing on it,
76
+ // otherwise every quiet stretch would ask the recognizer to caption
77
+ // silence.
78
+ if (!this.hasSpeech) {
79
+ this.reset();
80
+ return undefined;
81
+ }
82
+ return this.closeAt(Math.min(this.total, Math.max(0, this.total - this.silent + msToSamples(TAIL_PAD_MS))), "silence");
83
+ }
84
+
85
+ if (this.total >= msToSamples(MAX_PHRASE_MS)) {
86
+ return this.closeAt(this.quietestCut(), "cap");
87
+ }
88
+
89
+ return undefined;
90
+ }
91
+
92
+ /** The still-open buffer, for the live preview decode. */
93
+ open(): Float32Array {
94
+ return concat(this.chunks);
95
+ }
96
+
97
+ /** Drop everything buffered, e.g. on cancel. */
98
+ reset(): void {
99
+ this.chunks = [];
100
+ this.total = 0;
101
+ this.silent = 0;
102
+ this.hasSpeech = false;
103
+ }
104
+
105
+ private closeAt(cut: number, reason: ClosedPhrase["reason"]): ClosedPhrase {
106
+ const all = concat(this.chunks);
107
+ const head = all.slice(0, cut);
108
+ // Silence that closed a phrase is not carried into the next one; only a
109
+ // cap cut keeps its tail, because that tail is still speech.
110
+ const tail = reason === "cap" ? all.slice(cut) : new Float32Array(0);
111
+ this.chunks = tail.length > 0 ? [tail] : [];
112
+ this.total = tail.length;
113
+ this.silent = 0;
114
+ // A cap cut keeps speech in the tail; a tail of pure silence must not
115
+ // look like speech, or the next phrase would decode silence.
116
+ this.hasSpeech = tail.length > 0 && rmsOf(tail) >= MIN_PHRASE_RMS;
117
+ return { samples: head, reason };
118
+ }
119
+
120
+ /** Start of the quietest 10 ms window in the trailing scan range. */
121
+ private quietestCut(): number {
122
+ const all = concat(this.chunks);
123
+ const scan = Math.min(all.length, msToSamples(CAP_CUT_SCAN_MS));
124
+ const step = msToSamples(10);
125
+ const from = Math.max(0, all.length - scan);
126
+ let bestAt = from;
127
+ let bestRms = Number.POSITIVE_INFINITY;
128
+ for (let at = from; at < all.length; at += step) {
129
+ const rms = rmsOf(all.subarray(at, Math.min(at + step, all.length)));
130
+ if (rms < bestRms) {
131
+ bestRms = rms;
132
+ bestAt = at;
133
+ }
134
+ }
135
+ return bestAt;
136
+ }
137
+ }
package/src/session.ts ADDED
@@ -0,0 +1,190 @@
1
+ /**
2
+ * One dictation stretch: mic -> segmenter -> recognizer -> editor region.
3
+ *
4
+ * The region is created on key press and frozen on release. Nothing outside it
5
+ * is ever rewritten, so the rest of the buffer stays the user's.
6
+ */
7
+ import { openMicrophone, type Microphone } from "./capture.ts";
8
+ import { MIN_PHRASE_MS, MIN_PHRASE_RMS, PARTIAL_INTERVAL_MS, msToSamples } from "./config.ts";
9
+ import { trace } from "./debug.ts";
10
+ import { commitPartial, joinText, regionCursorOffset, renderRegion, withPartial, type Region } from "./region.ts";
11
+ import { Segmenter, rmsOf } from "./segmenter.ts";
12
+ import type { Stt } from "./stt.ts";
13
+ import { cleanTranscript } from "./transcript.ts";
14
+
15
+ export type SessionUi = {
16
+ /** Replace the editor text, place the cursor at `cursorOffset`, dim `partialLength` before it. */
17
+ write(text: string, cursorOffset: number, partialLength: number): void;
18
+ status(text: string | undefined): void;
19
+ notify(message: string, level: "info" | "warning" | "error"): void;
20
+ };
21
+
22
+ const MIN_PHRASE_SAMPLES = msToSamples(MIN_PHRASE_MS);
23
+
24
+ /** Whisper ends a decode with a full stop; mid-session that is not the sentence end. */
25
+ const TRAILING_PERIOD = /\.\s*$/;
26
+
27
+ export class DictationSession {
28
+ private readonly stt: Stt;
29
+ private readonly ui: SessionUi;
30
+ private readonly openMic: typeof openMicrophone;
31
+ private region: Region | undefined;
32
+ private mic: Microphone | undefined;
33
+ private segmenter = new Segmenter();
34
+ private timer: ReturnType<typeof setInterval> | undefined;
35
+ private pending = 0;
36
+ private stopped = true;
37
+ /** Characters committed during this session, to tell a real result from silence. */
38
+ private committedChars = 0;
39
+
40
+ constructor(stt: Stt, ui: SessionUi, openMic: typeof openMicrophone = openMicrophone) {
41
+ this.stt = stt;
42
+ this.ui = ui;
43
+ this.openMic = openMic;
44
+ }
45
+
46
+ async start(region: Region): Promise<void> {
47
+ if (!this.stopped) return;
48
+ this.stopped = false;
49
+ this.region = region;
50
+ this.segmenter = new Segmenter();
51
+ this.committedChars = 0;
52
+ this.ui.status("MIC listening");
53
+
54
+ try {
55
+ this.mic = await this.openMic(
56
+ (samples) => this.onSamples(samples),
57
+ (error) => this.fail(error),
58
+ );
59
+ } catch (error) {
60
+ this.stopped = true;
61
+ this.ui.status(undefined);
62
+ this.ui.notify(`Dictation: microphone unavailable (${String(error)})`, "error");
63
+ return;
64
+ }
65
+
66
+ // Released while the device was still opening.
67
+ if (this.stopped) {
68
+ this.mic.stop();
69
+ this.mic = undefined;
70
+ return;
71
+ }
72
+
73
+ this.timer = setInterval(() => void this.preview(), PARTIAL_INTERVAL_MS);
74
+ }
75
+
76
+ isActive(): boolean {
77
+ return !this.stopped;
78
+ }
79
+
80
+ /** Decode what is still open, commit it, and close the session. Returns whether anything was transcribed. */
81
+ async stop(): Promise<boolean> {
82
+ if (this.stopped) return false;
83
+ this.stopped = true;
84
+ this.clearTimer();
85
+ this.mic?.stop();
86
+ this.mic = undefined;
87
+
88
+ const tail = this.segmenter.open();
89
+ this.segmenter.reset();
90
+ if (tail.length >= MIN_PHRASE_SAMPLES && rmsOf(tail) >= MIN_PHRASE_RMS) {
91
+ await this.decodePhrase(tail, true);
92
+ }
93
+
94
+ // A preview of audio that was never committed must not stay in the buffer.
95
+ if (this.region && this.region.partial.length > 0) {
96
+ this.region = { ...this.region, partial: "" };
97
+ this.write();
98
+ }
99
+
100
+ const committed = this.committedChars > 0;
101
+ this.ui.status(undefined);
102
+ this.region = undefined;
103
+ return committed;
104
+ }
105
+
106
+ /** Abandon the session and remove whatever it wrote. */
107
+ cancel(): void {
108
+ if (this.stopped) return;
109
+ this.stopped = true;
110
+ this.clearTimer();
111
+ this.mic?.stop();
112
+ this.mic = undefined;
113
+ this.segmenter.reset();
114
+
115
+ if (this.region && (this.region.committed.length > 0 || this.region.partial.length > 0)) {
116
+ const cleaned = joinText(this.region.before, this.region.after);
117
+ this.ui.write(cleaned, this.region.before.length, 0);
118
+ }
119
+ this.ui.status(undefined);
120
+ this.region = undefined;
121
+ }
122
+
123
+ private onSamples(samples: Float32Array): void {
124
+ if (this.stopped) return;
125
+ const closed = this.segmenter.add(samples);
126
+ if (!closed) return;
127
+ if (rmsOf(closed.samples) < MIN_PHRASE_RMS) return;
128
+ // A phrase closing while the session is open is not the end of the thought:
129
+ // its trailing full stop is Whisper's, not the speaker's.
130
+ void this.decodePhrase(closed.samples, false);
131
+ }
132
+
133
+ private async decodePhrase(samples: Float32Array, final: boolean): Promise<void> {
134
+ this.pending++;
135
+ try {
136
+ const raw = await this.stt.recognize(samples);
137
+ trace(`phrase ${samples.length} samples final=${String(final)} -> ${JSON.stringify(raw)}`);
138
+ const text = cleanTranscript(raw);
139
+ this.commitText(final ? text : text.replace(TRAILING_PERIOD, ""));
140
+ } catch (error) {
141
+ this.fail(error instanceof Error ? error : new Error(String(error)));
142
+ } finally {
143
+ this.pending--;
144
+ }
145
+ }
146
+
147
+ private async preview(): Promise<void> {
148
+ // Let queued phrases land first so the preview never rewrites newer text.
149
+ if (this.pending > 0 || this.stopped || !this.region) return;
150
+ const open = this.segmenter.open();
151
+ if (open.length < MIN_PHRASE_SAMPLES || rmsOf(open) < MIN_PHRASE_RMS) return;
152
+
153
+ this.pending++;
154
+ try {
155
+ const text = cleanTranscript(await this.stt.recognize(open));
156
+ if (this.stopped || !this.region) return;
157
+ this.region = withPartial(this.region, text);
158
+ this.write();
159
+ } catch (error) {
160
+ this.fail(error instanceof Error ? error : new Error(String(error)));
161
+ } finally {
162
+ this.pending--;
163
+ }
164
+ }
165
+
166
+ private commitText(text: string): void {
167
+ const clean = text.trim();
168
+ if (!this.region || clean.length === 0) return;
169
+ this.region = commitPartial(withPartial(this.region, clean));
170
+ this.committedChars += clean.length;
171
+ this.write();
172
+ }
173
+
174
+ private write(): void {
175
+ if (!this.region) return;
176
+ this.ui.write(renderRegion(this.region), regionCursorOffset(this.region), this.region.partial.length);
177
+ }
178
+
179
+ private fail(error: Error): void {
180
+ this.ui.notify(`Dictation: ${error.message}`, "error");
181
+ this.cancel();
182
+ }
183
+
184
+ private clearTimer(): void {
185
+ if (this.timer) {
186
+ clearInterval(this.timer);
187
+ this.timer = undefined;
188
+ }
189
+ }
190
+ }
package/src/stt.ts ADDED
@@ -0,0 +1,98 @@
1
+ /**
2
+ * whisper-base int8 through sherpa-onnx-node.
3
+ *
4
+ * Decodes are FIFO-queued: the native handle tolerates one decode at a time and
5
+ * the preview can overlap a phrase commit.
6
+ */
7
+ import { createRequire } from "node:module";
8
+ import os from "node:os";
9
+ import path from "node:path";
10
+ import { MODEL_DIR, SAMPLE_RATE } from "./config.ts";
11
+ import { trace } from "./debug.ts";
12
+ import { ensureModel } from "./model.ts";
13
+
14
+ const require = createRequire(import.meta.url);
15
+
16
+ type Recognizer = {
17
+ createStream(): unknown;
18
+ getResult(stream: unknown): { text?: string } | undefined;
19
+ decodeAsync?(stream: unknown): Promise<void>;
20
+ decode?(stream: unknown): void;
21
+ };
22
+
23
+ type RecognizerCtor = {
24
+ new (config: unknown): Recognizer;
25
+ createAsync?(config: unknown): Promise<Recognizer>;
26
+ };
27
+
28
+ /** Four threads is the sweet spot for these int8 models; do not starve the app. */
29
+ const THREADS = Math.max(1, Math.min(4, os.cpus().length - 1));
30
+
31
+ function recognizerConfig(modelDir: string): unknown {
32
+ return {
33
+ featConfig: { sampleRate: SAMPLE_RATE, featureDim: 80 },
34
+ modelConfig: {
35
+ whisper: {
36
+ encoder: path.join(modelDir, "base-encoder.int8.onnx"),
37
+ decoder: path.join(modelDir, "base-decoder.int8.onnx"),
38
+ language: "en",
39
+ task: "transcribe",
40
+ // Trailing padding keeps Whisper from missing EOS and inventing a
41
+ // closing phrase on short buffers (sherpa-onnx#2787).
42
+ tailPaddings: 1000,
43
+ },
44
+ tokens: path.join(modelDir, "base-tokens.txt"),
45
+ numThreads: THREADS,
46
+ provider: "cpu",
47
+ },
48
+ };
49
+ }
50
+
51
+ export class Stt {
52
+ private readonly recognizer: Recognizer;
53
+ private queue: Promise<unknown> = Promise.resolve();
54
+
55
+ private constructor(recognizer: Recognizer) {
56
+ this.recognizer = recognizer;
57
+ }
58
+
59
+ /** Async so loading the model does not block the UI on first dictation. */
60
+ static async create(modelDir: string = MODEL_DIR, onProgress?: (message: string) => void): Promise<Stt> {
61
+ await ensureModel(modelDir, onProgress);
62
+ const sherpa = require("sherpa-onnx-node");
63
+ const RecognizerClass = sherpa.OfflineRecognizer as RecognizerCtor;
64
+ const config = recognizerConfig(modelDir);
65
+ const recognizer =
66
+ typeof RecognizerClass.createAsync === "function"
67
+ ? await RecognizerClass.createAsync(config)
68
+ : new RecognizerClass(config);
69
+ return new Stt(recognizer);
70
+ }
71
+
72
+ /** Transcribe 16 kHz mono samples. Never rejects; returns "" on failure. */
73
+ recognize(samples: Float32Array): Promise<string> {
74
+ const run = this.queue
75
+ .then(() => this.decode(samples))
76
+ .catch((error: unknown) => {
77
+ trace(`recognize failed: ${String(error)}`);
78
+ return "";
79
+ });
80
+ this.queue = run;
81
+ return run;
82
+ }
83
+
84
+ private async decode(samples: Float32Array): Promise<string> {
85
+ if (samples.length === 0) return "";
86
+ const stream = this.recognizer.createStream();
87
+ (stream as { acceptWaveform(input: { sampleRate: number; samples: Float32Array }): void }).acceptWaveform({
88
+ sampleRate: SAMPLE_RATE,
89
+ samples,
90
+ });
91
+ if (typeof this.recognizer.decodeAsync === "function") {
92
+ await this.recognizer.decodeAsync(stream);
93
+ } else if (typeof this.recognizer.decode === "function") {
94
+ this.recognizer.decode(stream);
95
+ }
96
+ return String(this.recognizer.getResult(stream)?.text ?? "").trim();
97
+ }
98
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Transcript cleanup.
3
+ *
4
+ * Whisper captions non-speech instead of returning nothing: `[Music]`,
5
+ * `(footsteps)`, `♪`. Those come from near-silent audio, which the level gate
6
+ * is supposed to reject — it cannot on a stream with a DC offset, so the
7
+ * artifacts are removed after decoding as well.
8
+ */
9
+
10
+ /** Bracketed annotations, musical notes, and repeated punctuation. */
11
+ const CAPTION = /[\[(][^\])]*[\])]/g;
12
+ const MUSIC = /[♪♫♬♩]/g;
13
+ /** Stray brackets left behind by a caption or by decoded noise. */
14
+ const STRAY_BRACKET = /[\[\]]/g;
15
+
16
+ export function cleanTranscript(text: string): string {
17
+ return text
18
+ .replace(CAPTION, " ")
19
+ .replace(MUSIC, " ")
20
+ .replace(STRAY_BRACKET, " ")
21
+ .replace(/\s+/g, " ")
22
+ .replace(/\s+([,.!?;:])/g, "$1")
23
+ .trim();
24
+ }