@maddeye/pi-voice 1.0.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 maddeye
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 ADDED
@@ -0,0 +1,80 @@
1
+ # pi voice
2
+
3
+ Private voice prompt input for [pi](https://github.com/earendil-works/pi-mono), transcribed locally with Whisper. Recorded audio is kept in memory and never uploaded or written to disk.
4
+
5
+ ## Install
6
+
7
+ After the npm release:
8
+
9
+ ```bash
10
+ pi install npm:@maddeye/pi-voice
11
+ ```
12
+
13
+ From a local checkout:
14
+
15
+ ```bash
16
+ pi install /absolute/path/to/pi-voice
17
+ ```
18
+
19
+ Run `/voice-settings` once, then press **Alt+M** or run `/voice`. Press **Enter** to stop recording or **Esc** to cancel. The transcription is placed in the prompt editor for review; it is never submitted automatically.
20
+
21
+ ## Configuration
22
+
23
+ `/voice-settings` configures the microphone, language, maximum recording length, and local model entirely inside the pi TUI. Defaults are the system microphone, English, 120 seconds, and Whisper Base.
24
+
25
+ The shortcut uses pi's normal `~/.pi/agent/keybindings.json`. Change it and run `/reload`:
26
+
27
+ ```json
28
+ {
29
+ "pi-voice.record": "alt+r"
30
+ }
31
+ ```
32
+
33
+ Use an array for multiple shortcuts or `[]` to disable the shortcut.
34
+
35
+ Settings are stored in `~/.pi/agent/pi-voice.json` (or the configured pi agent directory). Models are downloaded from Hugging Face on first use and cached in `pi-voice-models/`; after that, transcription works offline. Tiny is fastest, Base is the default, and Small favors accuracy.
36
+
37
+ > **Disk usage:** Runtime dependencies use about **538 MB**, mostly ONNX Runtime’s cross-platform binaries. The default quantized Whisper Base model adds about **77 MB**, for roughly **615 MB total**. Other models and package versions may vary; keep at least **1 GB free**.
38
+
39
+ ## Privacy
40
+
41
+ - Microphone samples remain in process memory only.
42
+ - Audio is sent only to the local Whisper model.
43
+ - The first use of each model downloads model files from Hugging Face.
44
+ - No API key is required.
45
+
46
+ ## Compatibility
47
+
48
+ - Linux x86_64
49
+ - macOS x86_64 and arm64
50
+ - Windows x86_64 and arm64
51
+ - Node.js 22.19 or newer
52
+
53
+ The terminal running pi needs microphone permission. Native audio and ONNX binaries are included by the runtime dependencies.
54
+
55
+ ## Troubleshooting
56
+
57
+ - **No microphone / permission denied:** grant microphone access to your terminal in macOS Privacy & Security or Windows Privacy & security. On Linux, verify PipeWire/PulseAudio can see the device.
58
+ - **Configured microphone disappeared:** rerun `/voice-settings`; recording falls back to the system default.
59
+ - **Model download failed:** check network access to `huggingface.co`, then retry `/voice`.
60
+ - **Native module unsupported:** verify the OS/architecture and Node version listed above.
61
+ - **Slow transcription:** select Tiny in `/voice-settings`.
62
+
63
+ ## Upgrade and removal
64
+
65
+ ```bash
66
+ pi update npm:@maddeye/pi-voice
67
+ pi remove npm:@maddeye/pi-voice
68
+ ```
69
+
70
+ Removing the package does not remove downloaded models. Delete `~/.pi/agent/pi-voice-models/` and `~/.pi/agent/pi-voice.json` to remove cached data and settings.
71
+
72
+ ## Development
73
+
74
+ ```bash
75
+ npm ci
76
+ npm run check
77
+ pi -e ./extensions/voice.ts
78
+ ```
79
+
80
+ Before release, verify one real recording on every supported OS/architecture.
@@ -0,0 +1,300 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { mkdir, readFile, writeFile, chmod } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { PvRecorder } from "@picovoice/pvrecorder-node";
5
+ import {
6
+ BorderedLoader,
7
+ getAgentDir,
8
+ type ExtensionAPI,
9
+ type ExtensionContext,
10
+ } from "@earendil-works/pi-coding-agent";
11
+ import { Key, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
12
+
13
+ const WHISPER_MODELS = {
14
+ "Tiny (fastest)": "Xenova/whisper-tiny",
15
+ "Base (balanced)": "Xenova/whisper-base",
16
+ "Small (best accuracy)": "Xenova/whisper-small",
17
+ } as const;
18
+ type WhisperModel = (typeof WHISPER_MODELS)[keyof typeof WHISPER_MODELS];
19
+
20
+ interface VoiceConfig {
21
+ deviceName?: string;
22
+ language?: string;
23
+ maxSeconds?: number;
24
+ model?: WhisperModel;
25
+ }
26
+
27
+ const CONFIG_PATH = join(getAgentDir(), "pi-voice.json");
28
+ const MODEL_CACHE = join(getAgentDir(), "pi-voice-models");
29
+ const DEFAULT_MAX_SECONDS = 120;
30
+ const DEFAULT_MODEL: WhisperModel = "Xenova/whisper-base";
31
+ const DEFAULT_SHORTCUT = "alt+m";
32
+ const SHORTCUT_SETTING = "pi-voice.record";
33
+
34
+ export function parseVoiceShortcuts(value: unknown): string[] {
35
+ if (!value || typeof value !== "object" || !(SHORTCUT_SETTING in value)) return [DEFAULT_SHORTCUT];
36
+ const configured = (value as Record<string, unknown>)[SHORTCUT_SETTING];
37
+ if (Array.isArray(configured) && configured.length === 0) return [];
38
+ const shortcuts = Array.isArray(configured) ? configured : [configured];
39
+ const valid = [...new Set(shortcuts.filter((key): key is string => typeof key === "string" && key.trim().length > 0).map((key) => key.trim()))];
40
+ return valid.length ? valid : [DEFAULT_SHORTCUT];
41
+ }
42
+
43
+ function loadVoiceShortcuts(): string[] {
44
+ try {
45
+ return parseVoiceShortcuts(JSON.parse(readFileSync(join(getAgentDir(), "keybindings.json"), "utf8")));
46
+ } catch {
47
+ return [DEFAULT_SHORTCUT];
48
+ }
49
+ }
50
+
51
+ type LocalTranscriber = ((audio: Float32Array, options: Record<string, unknown>) => Promise<{ text: string }>) & {
52
+ dispose(): void | Promise<void>;
53
+ };
54
+ let transcriber: LocalTranscriber | undefined;
55
+ let loadedModel: WhisperModel | undefined;
56
+
57
+ async function getTranscriber(model: WhisperModel): Promise<LocalTranscriber> {
58
+ if (transcriber && loadedModel === model) return transcriber;
59
+ const previous = transcriber;
60
+ transcriber = undefined;
61
+ loadedModel = undefined;
62
+ await previous?.dispose();
63
+ await mkdir(MODEL_CACHE, { recursive: true });
64
+ const { env, pipeline } = await import("@huggingface/transformers");
65
+ env.cacheDir = MODEL_CACHE;
66
+ const loaded = await pipeline("automatic-speech-recognition", model, { dtype: "q8" }) as unknown as LocalTranscriber;
67
+ transcriber = loaded;
68
+ loadedModel = model;
69
+ return loaded;
70
+ }
71
+
72
+ export function parseConfig(value: unknown): VoiceConfig {
73
+ if (!value || typeof value !== "object") return {};
74
+ const input = value as Record<string, unknown>;
75
+ const config: VoiceConfig = {};
76
+ if (typeof input.deviceName === "string" && input.deviceName.length <= 512) config.deviceName = input.deviceName;
77
+ if (typeof input.language === "string" && /^[a-z]{2}$/i.test(input.language)) config.language = input.language.toLowerCase();
78
+ if (Number.isInteger(input.maxSeconds) && Number(input.maxSeconds) >= 5 && Number(input.maxSeconds) <= 600) {
79
+ config.maxSeconds = Number(input.maxSeconds);
80
+ }
81
+ if (typeof input.model === "string" && Object.values(WHISPER_MODELS).includes(input.model as WhisperModel)) {
82
+ config.model = input.model as WhisperModel;
83
+ }
84
+ return config;
85
+ }
86
+
87
+ export function pcmToFloat32(frames: Int16Array[]): Float32Array {
88
+ const audio = new Float32Array(frames.reduce((count, frame) => count + frame.length, 0));
89
+ let offset = 0;
90
+ for (const frame of frames) {
91
+ for (const sample of frame) audio[offset++] = sample / 32768;
92
+ }
93
+ return audio;
94
+ }
95
+
96
+ async function loadConfig(): Promise<VoiceConfig> {
97
+ try {
98
+ const raw = JSON.parse(await readFile(CONFIG_PATH, "utf8"));
99
+ const config = parseConfig(raw);
100
+ if (raw && typeof raw === "object" && "apiKey" in raw) await saveConfig(config);
101
+ return config;
102
+ } catch {
103
+ return {};
104
+ }
105
+ }
106
+
107
+ async function saveConfig(config: VoiceConfig): Promise<void> {
108
+ await mkdir(getAgentDir(), { recursive: true });
109
+ await writeFile(CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
110
+ if (process.platform !== "win32") await chmod(CONFIG_PATH, 0o600);
111
+ }
112
+
113
+ function availableDevices(): string[] {
114
+ return PvRecorder.getAvailableDevices();
115
+ }
116
+
117
+ async function configure(ctx: ExtensionContext): Promise<void> {
118
+ if (ctx.mode !== "tui") {
119
+ ctx.ui.notify("/voice-settings requires TUI mode", "error");
120
+ return;
121
+ }
122
+
123
+ const old = await loadConfig();
124
+ const devices = availableDevices();
125
+ const selectedDevice = await ctx.ui.select("Voice microphone", ["System default", ...devices]);
126
+ if (!selectedDevice) return;
127
+
128
+ const languageChoice = await ctx.ui.select("Whisper language", ["en", "de", "es", "fr", "Other ISO-639-1 code"]);
129
+ if (!languageChoice) return;
130
+ let language = languageChoice;
131
+ if (languageChoice === "Other ISO-639-1 code") {
132
+ const input = await ctx.ui.input("Two-letter language code", old.language ?? "en");
133
+ if (input === undefined) return;
134
+ language = input.trim().toLowerCase();
135
+ if (!/^[a-z]{2}$/.test(language)) {
136
+ ctx.ui.notify("Language must be a two-letter ISO-639-1 code", "error");
137
+ return;
138
+ }
139
+ }
140
+
141
+ const duration = await ctx.ui.select("Maximum recording length", ["30 seconds", "60 seconds", "120 seconds", "300 seconds", "600 seconds"]);
142
+ if (!duration) return;
143
+
144
+ const modelLabel = await ctx.ui.select("Local Whisper model", Object.keys(WHISPER_MODELS));
145
+ if (!modelLabel) return;
146
+
147
+ await saveConfig({
148
+ deviceName: selectedDevice === "System default" ? undefined : selectedDevice,
149
+ language,
150
+ maxSeconds: Number.parseInt(duration, 10),
151
+ model: WHISPER_MODELS[modelLabel as keyof typeof WHISPER_MODELS],
152
+ });
153
+ ctx.ui.notify(`Voice settings saved to ${CONFIG_PATH}`, "info");
154
+ }
155
+
156
+ async function record(ctx: ExtensionContext, config: VoiceConfig): Promise<Float32Array | null> {
157
+ const devices = availableDevices();
158
+ const deviceIndex = config.deviceName ? devices.indexOf(config.deviceName) : -1;
159
+ if (config.deviceName && deviceIndex < 0) ctx.ui.notify(`Microphone “${config.deviceName}” not found; using system default`, "warning");
160
+
161
+ const recorder = new PvRecorder(512, deviceIndex);
162
+ const frames: Int16Array[] = [];
163
+ let capturing = true;
164
+ let captureFailure: unknown;
165
+ let cleanupFailure: unknown;
166
+ let finish = (_save: boolean) => {};
167
+ let capture = Promise.resolve();
168
+ let timer: NodeJS.Timeout | undefined;
169
+ let save = false;
170
+
171
+ try {
172
+ if (recorder.sampleRate !== 16_000) {
173
+ throw new Error(`Local Whisper requires 16 kHz audio; recorder returned ${recorder.sampleRate} Hz`);
174
+ }
175
+ const maxSamples = recorder.sampleRate * (config.maxSeconds ?? DEFAULT_MAX_SECONDS);
176
+ recorder.start();
177
+ capture = (async () => {
178
+ try {
179
+ while (capturing && frames.length * recorder.frameLength < maxSamples) frames.push(await recorder.read());
180
+ if (capturing) finish(true);
181
+ } catch (error) {
182
+ if (capturing) {
183
+ captureFailure = error;
184
+ finish(false);
185
+ }
186
+ }
187
+ })();
188
+
189
+ const started = Date.now();
190
+ save = await ctx.ui.custom<boolean>((tui, theme, _keybindings, done) => {
191
+ finish = done;
192
+ timer = setInterval(() => tui.requestRender(), 250);
193
+ return {
194
+ render(width: number) {
195
+ const seconds = ((Date.now() - started) / 1000).toFixed(1);
196
+ return [
197
+ truncateToWidth(theme.fg("error", theme.bold(`● Recording ${seconds}s`)), width),
198
+ truncateToWidth(theme.fg("dim", "Enter stop and transcribe • Esc cancel"), width),
199
+ ];
200
+ },
201
+ invalidate() {},
202
+ handleInput(data: string) {
203
+ if (matchesKey(data, Key.enter)) done(true);
204
+ else if (matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) done(false);
205
+ },
206
+ };
207
+ });
208
+ } finally {
209
+ if (timer) clearInterval(timer);
210
+ capturing = false;
211
+ try {
212
+ if (recorder.isRecording) recorder.stop();
213
+ } catch (error) {
214
+ cleanupFailure = error;
215
+ }
216
+ await capture;
217
+ try {
218
+ recorder.release();
219
+ } catch (error) {
220
+ cleanupFailure ??= error;
221
+ }
222
+ }
223
+
224
+ if (captureFailure) throw captureFailure;
225
+ if (cleanupFailure) throw cleanupFailure;
226
+ return save && frames.length ? pcmToFloat32(frames) : null;
227
+ }
228
+
229
+ async function transcribe(ctx: ExtensionContext, audio: Float32Array, config: VoiceConfig): Promise<string | null> {
230
+ let failure: unknown;
231
+ const result = await ctx.ui.custom<string | null>((tui, theme, _keybindings, done) => {
232
+ const model = config.model ?? DEFAULT_MODEL;
233
+ const loader = new BorderedLoader(tui, theme, `Running ${model} locally (first use downloads the model)...`, { cancellable: false });
234
+ getTranscriber(model)
235
+ .then((pipe) => pipe(audio, {
236
+ ...(config.language ? { language: config.language } : {}),
237
+ task: "transcribe",
238
+ chunk_length_s: 30,
239
+ stride_length_s: 5,
240
+ }))
241
+ .then((output) => done(output.text.trim()))
242
+ .catch((error) => {
243
+ failure = error;
244
+ done(null);
245
+ });
246
+ return loader;
247
+ });
248
+ if (failure) throw failure;
249
+ return result;
250
+ }
251
+
252
+ export default function voiceExtension(pi: ExtensionAPI) {
253
+ let busy = false;
254
+
255
+ const run = async (ctx: ExtensionContext) => {
256
+ if (ctx.mode !== "tui") return ctx.ui.notify("Voice input requires TUI mode", "error");
257
+ if (!ctx.isIdle()) return ctx.ui.notify("Wait for pi to finish before recording", "warning");
258
+ if (busy) return ctx.ui.notify("Voice input is already active", "warning");
259
+ busy = true;
260
+ try {
261
+ const config = await loadConfig();
262
+ const audio = await record(ctx, config);
263
+ if (!audio) return ctx.ui.notify("Voice recording cancelled", "info");
264
+ const text = await transcribe(ctx, audio, config);
265
+ if (!text) return ctx.ui.notify("No speech detected", "warning");
266
+ const current = ctx.ui.getEditorText().trimEnd();
267
+ ctx.ui.setEditorText(current ? `${current} ${text}` : text);
268
+ ctx.ui.notify("Transcription added to the prompt", "info");
269
+ } catch (error) {
270
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
271
+ } finally {
272
+ busy = false;
273
+ }
274
+ };
275
+
276
+ pi.registerCommand("voice", { description: "Record and transcribe voice into the prompt", handler: (_args, ctx) => run(ctx) });
277
+ pi.registerCommand("voice-settings", {
278
+ description: "Configure voice input",
279
+ handler: async (_args, ctx) => {
280
+ try {
281
+ await configure(ctx);
282
+ } catch (error) {
283
+ ctx.ui.notify(error instanceof Error ? error.message : String(error), "error");
284
+ }
285
+ },
286
+ });
287
+ const shortcuts = loadVoiceShortcuts();
288
+ for (const shortcut of shortcuts) {
289
+ pi.registerShortcut(shortcut as Parameters<typeof pi.registerShortcut>[0], { description: "Record a voice prompt", handler: run });
290
+ }
291
+ pi.on("session_start", (_event, ctx) => {
292
+ if (ctx.mode === "tui") ctx.ui.setStatus("pi-voice", shortcuts.length ? `voice: ${shortcuts.join(", ")}` : "voice: /voice");
293
+ });
294
+ pi.on("session_shutdown", async () => {
295
+ const loaded = transcriber;
296
+ transcriber = undefined;
297
+ loadedModel = undefined;
298
+ await loaded?.dispose();
299
+ });
300
+ }
package/package.json ADDED
@@ -0,0 +1,57 @@
1
+ {
2
+ "name": "@maddeye/pi-voice",
3
+ "version": "1.0.0",
4
+ "description": "Private, cross-platform voice prompt input for pi using local Whisper",
5
+ "license": "MIT",
6
+ "author": "maddeye",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/maddeye/pi-voice.git"
10
+ },
11
+ "homepage": "https://github.com/maddeye/pi-voice#readme",
12
+ "bugs": {
13
+ "url": "https://github.com/maddeye/pi-voice/issues"
14
+ },
15
+ "keywords": [
16
+ "pi-package",
17
+ "voice",
18
+ "whisper",
19
+ "speech-to-text",
20
+ "local-first"
21
+ ],
22
+ "type": "module",
23
+ "files": [
24
+ "extensions",
25
+ "README.md",
26
+ "LICENSE"
27
+ ],
28
+ "engines": {
29
+ "node": ">=22.19.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/voice.ts"
37
+ ]
38
+ },
39
+ "dependencies": {
40
+ "@huggingface/transformers": "4.2.0",
41
+ "@picovoice/pvrecorder-node": "1.2.9"
42
+ },
43
+ "peerDependencies": {
44
+ "@earendil-works/pi-coding-agent": ">=0.80.6",
45
+ "@earendil-works/pi-tui": ">=0.80.6"
46
+ },
47
+ "scripts": {
48
+ "test": "node --test tests/*.test.ts",
49
+ "typecheck": "tsc --noEmit",
50
+ "check": "npm run test && npm run typecheck",
51
+ "prepack": "npm run check"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^24.0.0",
55
+ "typescript": "^5.9.0"
56
+ }
57
+ }