@earendil-works/pi-voice 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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +86 -0
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +69 -0
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio.ts +193 -0
  9. package/src/catalog.generated.ts +1305 -0
  10. package/src/catalog.ts +89 -0
  11. package/src/chinese.ts +52 -0
  12. package/src/deferred.ts +30 -0
  13. package/src/dictation-controller.ts +204 -0
  14. package/src/file-audio.ts +164 -0
  15. package/src/file-transcription.ts +212 -0
  16. package/src/index.ts +114 -0
  17. package/src/install-migration.ts +47 -0
  18. package/src/keybindings.ts +118 -0
  19. package/src/languages.ts +22 -0
  20. package/src/microphone-picker.ts +99 -0
  21. package/src/model-activation.ts +74 -0
  22. package/src/model-cells.ts +218 -0
  23. package/src/model-picker.ts +1026 -0
  24. package/src/model-ratings-help.md +41 -0
  25. package/src/model-ratings-help.ts +146 -0
  26. package/src/model-selection-controller.ts +185 -0
  27. package/src/models.ts +263 -0
  28. package/src/onboarding.ts +314 -0
  29. package/src/pcm-chunker.ts +42 -0
  30. package/src/pcm.ts +19 -0
  31. package/src/recommendation-picker.ts +512 -0
  32. package/src/recommendations.ts +423 -0
  33. package/src/runtime.ts +501 -0
  34. package/src/settings-menu.ts +410 -0
  35. package/src/settings-path.ts +13 -0
  36. package/src/settings.ts +235 -0
  37. package/src/shortcut-core.ts +85 -0
  38. package/src/shortcuts.ts +167 -0
  39. package/src/startup-shortcut.ts +24 -0
  40. package/src/transcript-preview.ts +52 -0
  41. package/src/transcription-service.ts +548 -0
  42. package/src/transcription.ts +186 -0
  43. package/src/try-it.ts +327 -0
  44. package/src/ui-components.ts +432 -0
  45. package/src/visualizer.ts +269 -0
@@ -0,0 +1,186 @@
1
+ import type {
2
+ Capabilities,
3
+ Session,
4
+ Stream,
5
+ TranscribeModel,
6
+ } from "transcribe-cpp";
7
+ import { convertChineseOutput, isChineseLanguage } from "./chinese.js";
8
+ import type { ChineseOutput } from "./settings.js";
9
+
10
+ export type TranscriptionOptions = {
11
+ signal?: AbortSignal;
12
+ language?: string;
13
+ chineseOutput?: ChineseOutput;
14
+ };
15
+
16
+ export type DictationStream = {
17
+ feed(chunk: Float32Array): Promise<void>;
18
+ finalize(): Promise<string>;
19
+ reset(): void;
20
+ };
21
+
22
+ function validateLanguage(
23
+ capabilities: Pick<Capabilities, "languages">,
24
+ language: string | undefined,
25
+ ): void {
26
+ if (language && !capabilities.languages.includes(language)) {
27
+ throw new Error(
28
+ `Configured language ${language} is not supported by this model. Open /voice-settings and choose another language.`,
29
+ );
30
+ }
31
+ }
32
+
33
+ /**
34
+ * Shared final step for batch and streaming output: trim, then apply the
35
+ * Chinese script preference when the detected (or configured) language is
36
+ * Chinese. Keeping this in one place keeps the two paths from drifting.
37
+ */
38
+ async function finishTranscript(
39
+ text: string,
40
+ detectedLanguage: string,
41
+ configuredLanguage: string | undefined,
42
+ chineseOutput: ChineseOutput,
43
+ ): Promise<string> {
44
+ const trimmed = text.trim();
45
+ return isChineseLanguage(detectedLanguage || configuredLanguage || "")
46
+ ? convertChineseOutput(trimmed, chineseOutput)
47
+ : trimmed;
48
+ }
49
+
50
+ class TranscribeCppDictationStream implements DictationStream {
51
+ private closed = false;
52
+
53
+ constructor(
54
+ private readonly session: Session,
55
+ private readonly stream: Stream,
56
+ private readonly language: string | undefined,
57
+ private readonly chineseOutput: ChineseOutput,
58
+ ) {}
59
+
60
+ async feed(chunk: Float32Array): Promise<void> {
61
+ if (this.closed) throw new Error("Dictation stream is closed");
62
+ await this.stream.feed(chunk);
63
+ }
64
+
65
+ async finalize(): Promise<string> {
66
+ if (this.closed) throw new Error("Dictation stream is closed");
67
+
68
+ let text: string;
69
+ let detectedLanguage: string;
70
+ try {
71
+ await this.stream.finalize();
72
+ if (this.closed) {
73
+ throw new Error("Dictation stream was reset while finalizing");
74
+ }
75
+ const snapshot = this.stream.snapshot;
76
+ text = snapshot.text;
77
+ detectedLanguage = snapshot.language;
78
+ } finally {
79
+ this.close();
80
+ }
81
+
82
+ return finishTranscript(text, detectedLanguage, this.language, this.chineseOutput);
83
+ }
84
+
85
+ reset(): void {
86
+ this.close();
87
+ }
88
+
89
+ private close(): void {
90
+ if (this.closed) return;
91
+ this.closed = true;
92
+ // reset() is synchronous at the binding boundary and queues native teardown
93
+ // behind any in-flight feed/finalize before the session is freed.
94
+ this.stream.reset();
95
+ this.session.dispose();
96
+ }
97
+ }
98
+
99
+ /** A reusable loaded transcribe.cpp model. Calls must be scheduled sequentially. */
100
+ export class TranscribeCppBackend {
101
+ private model: TranscribeModel | undefined;
102
+ private loading: Promise<TranscribeModel> | undefined;
103
+ private disposed = false;
104
+
105
+ constructor(private readonly modelPath: string) {}
106
+
107
+ async prepare(): Promise<void> {
108
+ if (this.model) return;
109
+ if (this.disposed) throw new Error("Transcription backend has been disposed");
110
+
111
+ if (!this.loading) {
112
+ this.loading = import("transcribe-cpp")
113
+ .then(({ TranscribeModel }) => TranscribeModel.load(this.modelPath))
114
+ .then((model) => {
115
+ if (this.disposed) {
116
+ model.dispose();
117
+ throw new Error("Transcription backend was disposed while loading");
118
+ }
119
+ this.model = model;
120
+ return model;
121
+ });
122
+ }
123
+
124
+ try {
125
+ await this.loading;
126
+ } finally {
127
+ this.loading = undefined;
128
+ }
129
+ }
130
+
131
+ async startStream(
132
+ options: TranscriptionOptions = {},
133
+ ): Promise<DictationStream | undefined> {
134
+ await this.prepare();
135
+ const model = this.model!;
136
+ const capabilities = model.capabilities;
137
+ validateLanguage(capabilities, options.language);
138
+ if (!capabilities.supportsStreaming) return undefined;
139
+
140
+ const session = model.createSession();
141
+ try {
142
+ const stream = await session.stream({
143
+ timestamps: "none",
144
+ ...(options.language ? { language: options.language } : {}),
145
+ });
146
+ return new TranscribeCppDictationStream(
147
+ session,
148
+ stream,
149
+ options.language,
150
+ options.chineseOutput ?? "simplified",
151
+ );
152
+ } catch (error) {
153
+ session.dispose();
154
+ throw error;
155
+ }
156
+ }
157
+
158
+ async transcribe(
159
+ pcm: Float32Array,
160
+ options: TranscriptionOptions = {},
161
+ ): Promise<string> {
162
+ if (pcm.length === 0) throw new Error("No audio samples were provided");
163
+ await this.prepare();
164
+ const model = this.model!;
165
+ validateLanguage(model.capabilities, options.language);
166
+
167
+ const result = await model.transcribe(pcm, {
168
+ signal: options.signal,
169
+ timestamps: "none",
170
+ ...(options.language ? { language: options.language } : {}),
171
+ });
172
+ return finishTranscript(
173
+ result.text,
174
+ result.language,
175
+ options.language,
176
+ options.chineseOutput ?? "simplified",
177
+ );
178
+ }
179
+
180
+ async dispose(): Promise<void> {
181
+ this.disposed = true;
182
+ await this.loading?.catch(() => undefined);
183
+ this.model?.dispose();
184
+ this.model = undefined;
185
+ }
186
+ }
package/src/try-it.ts ADDED
@@ -0,0 +1,327 @@
1
+ import { rawKeyHint, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Text,
5
+ truncateToWidth,
6
+ type KeybindingsManager,
7
+ type TUI,
8
+ } from "@earendil-works/pi-tui";
9
+ import { createMicrophoneCapture, testMicrophonePermission } from "./audio.js";
10
+ import { getCatalogModel } from "./catalog.js";
11
+ import { DictationController, type DictationControllerOptions } from "./dictation-controller.js";
12
+ import { microphoneSummary } from "./microphone-picker.js";
13
+ import { matchesShortcut, VoiceKeys } from "./keybindings.js";
14
+ import { COMFORTABLE_REAL_TIME_FACTOR } from "./recommendations.js";
15
+ import type { TranscribeSettings } from "./settings.js";
16
+ import { displayShortcut } from "./shortcut-core.js";
17
+ import { TranscriptionService } from "./transcription-service.js";
18
+ import { TranscriptPreview } from "./transcript-preview.js";
19
+ import { editorBorder, onboardingHeader, PANEL_PADDING, panelBorder, paneRowBudget } from "./ui-components.js";
20
+ import {
21
+ formatTranscriptionSummary,
22
+ METER_UPDATE_MS,
23
+ renderMeterLine,
24
+ SpectrumAnalyzer,
25
+ } from "./visualizer.js";
26
+
27
+ type UiTheme = ExtensionContext["ui"]["theme"];
28
+
29
+ type TryItPaneOptions = Pick<DictationControllerOptions, "createCapture" | "now"> & {
30
+ /** Shown only before the first recording attempt when macOS has not asked yet. */
31
+ showMacPermissionNote?: boolean;
32
+ /** Checked without holding the previous onboarding pane on screen. */
33
+ microphonePermission?: Promise<Awaited<ReturnType<typeof testMicrophonePermission>>>;
34
+ };
35
+
36
+ export type TryItResult =
37
+ | { action: "done" }
38
+ | { action: "skip" }
39
+ | { action: "shortcut" }
40
+ | { action: "microphone" }
41
+ | { action: "model" };
42
+
43
+ /** Shorter takes are dominated by fixed costs and say little about speed. */
44
+ const MIN_SPEECH_SECONDS_TO_JUDGE = 5;
45
+
46
+ export function realTimeFactor(speechSeconds: number, transcribeSeconds: number): number {
47
+ return speechSeconds / Math.max(transcribeSeconds, 0.05);
48
+ }
49
+
50
+ export function needsFasterModel(speechSeconds: number, transcribeSeconds: number): boolean {
51
+ return (
52
+ speechSeconds >= MIN_SPEECH_SECONDS_TO_JUDGE &&
53
+ realTimeFactor(speechSeconds, transcribeSeconds) < COMFORTABLE_REAL_TIME_FACTOR
54
+ );
55
+ }
56
+
57
+ /** Presentation and navigation only; native resources belong to the controller. */
58
+ export class TryItPane implements Component {
59
+ private readonly dictation: DictationController;
60
+ private readonly analyzer = new SpectrumAnalyzer();
61
+ private readonly preview: TranscriptPreview;
62
+ private readonly keys: VoiceKeys;
63
+ private nextPaintAt = 0;
64
+ private disposed = false;
65
+ private closed = false;
66
+ private showMacPermissionNote: boolean;
67
+ private recordingAttempted = false;
68
+ private modelPreparationScheduled = false;
69
+
70
+ constructor(
71
+ private readonly tui: TUI,
72
+ private readonly theme: UiTheme,
73
+ keybindings: KeybindingsManager,
74
+ private readonly settings: TranscribeSettings,
75
+ service: Pick<TranscriptionService, "reserveDictation">,
76
+ private readonly done: (result: TryItResult) => void,
77
+ options: TryItPaneOptions = { createCapture: createMicrophoneCapture },
78
+ ) {
79
+ this.keys = new VoiceKeys(keybindings);
80
+ this.preview = new TranscriptPreview(this.keys);
81
+ this.showMacPermissionNote = options.showMacPermissionNote ?? false;
82
+ this.dictation = new DictationController(service, {
83
+ createCapture: options.createCapture,
84
+ now: options.now,
85
+ onChange: () => this.refresh(),
86
+ onFrame: (frame) => {
87
+ this.analyzer.push(frame);
88
+ const now = Date.now();
89
+ if (now < this.nextPaintAt) return;
90
+ this.nextPaintAt = now + METER_UPDATE_MS;
91
+ this.refresh();
92
+ },
93
+ });
94
+ void options.microphonePermission?.then(
95
+ (permission) => {
96
+ if (this.disposed || this.closed || this.recordingAttempted) return;
97
+ const show = permission.status === "not-determined";
98
+ if (show === this.showMacPermissionNote) return;
99
+ this.showMacPermissionNote = show;
100
+ this.refresh();
101
+ },
102
+ () => undefined,
103
+ );
104
+ }
105
+
106
+ private refresh(): void {
107
+ if (!this.closed && !this.disposed) this.tui.requestRender();
108
+ }
109
+
110
+ invalidate(): void {
111
+ this.preview.invalidate();
112
+ this.refresh();
113
+ }
114
+
115
+ render(width: number): string[] {
116
+ // The native backend performs some synchronous first-use initialization.
117
+ // Start it only after this first render has put Try It on screen, so the
118
+ // model picker never looks stuck while the next model is being prepared.
119
+ if (!this.modelPreparationScheduled) {
120
+ this.modelPreparationScheduled = true;
121
+ setImmediate(() => {
122
+ if (!this.closed && !this.disposed) this.dictation.prepare(this.settings);
123
+ });
124
+ }
125
+
126
+ const state = this.dictation.state;
127
+ const shortcut = displayShortcut(this.settings.shortcut);
128
+ const modelName = getCatalogModel(this.settings.model.id)?.name ?? this.settings.model.id;
129
+ const title = "Try it";
130
+ const fg = (color: Parameters<UiTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
131
+ const text = (value: string) => new Text(value, PANEL_PADDING, 0).render(width);
132
+ const line = (value: string) => truncateToWidth(` ${value}`, width);
133
+ let activity = "";
134
+ let content = fg("dim", "Your transcript will appear here.");
135
+ let details = "";
136
+ if (state.phase === "listening") {
137
+ activity = renderMeterLine(this.theme, {
138
+ bands: this.analyzer.bands, elapsedMs: this.dictation.elapsedMs,
139
+ modelState: this.dictation.modelState,
140
+ });
141
+ } else if (
142
+ (state.phase === "idle" || state.phase === "ready") &&
143
+ this.dictation.modelState === "loading"
144
+ ) {
145
+ activity = fg("muted", `Loading ${modelName}… You can start recording now.`);
146
+ } else if (state.phase === "transcribing") {
147
+ activity = fg("accent", "Transcribing…");
148
+ } else if (state.phase === "starting") {
149
+ activity = fg("muted", "Starting microphone…");
150
+ } else if (state.phase === "cancelling") {
151
+ activity = fg("muted", "Cancelling…");
152
+ } else if (state.phase === "result") {
153
+ const { text: transcript, speechSeconds, transcribeSeconds } = state.result;
154
+ content = transcript || fg("muted", "No speech detected");
155
+ activity = fg("muted", formatTranscriptionSummary(speechSeconds, transcribeSeconds));
156
+ if (needsFasterModel(speechSeconds, transcribeSeconds)) {
157
+ details = fg("warning", `Slow on this machine? Press ${this.keys.keyText("voice.tryIt.model")} to try another model.`);
158
+ }
159
+ } else if (state.phase === "error") {
160
+ activity = fg("error", state.stage === "model" ? "Could not load the model" : state.stage === "capture" ? "Microphone capture failed" : "Transcription failed");
161
+ const message = state.cause instanceof Error ? state.cause.message : String(state.cause);
162
+ details = fg("error", message);
163
+ if (state.stage === "capture" && process.platform === "darwin") {
164
+ details += "\nCheck System Settings → Privacy & Security → Microphone for your terminal app.";
165
+ }
166
+ }
167
+ this.preview.setText(content);
168
+
169
+ let hints: string;
170
+ if (state.phase === "listening") {
171
+ hints = `${rawKeyHint(shortcut, "to transcribe")} ${this.keys.hint("tui.select.cancel", "to discard")}`;
172
+ } else if (
173
+ state.phase === "transcribing" ||
174
+ state.phase === "starting" ||
175
+ state.phase === "cancelling"
176
+ ) {
177
+ hints = this.keys.hint("tui.select.cancel", "cancel");
178
+ } else if (state.phase === "result") {
179
+ hints = `${this.keys.hint("tui.select.confirm", "looks good")} ${this.keys.hint("tui.select.cancel", "done")} ${rawKeyHint(shortcut, "try again")}`;
180
+ } else {
181
+ hints = `${rawKeyHint(shortcut, state.phase === "error" ? "try again" : "record")} ${this.keys.hint("tui.select.cancel", "skip")}`;
182
+ }
183
+
184
+ const setting = (label: string, value: string, key: string, compact: boolean) => {
185
+ const suffix = ` (${key} to change)`;
186
+ const labelColumn = compact ? `${label}: ` : `${label}:`.padEnd(12);
187
+ const body = compact
188
+ ? truncateToWidth(`${labelColumn}${value}`, Math.max(1, width - 2 - suffix.length))
189
+ : `${labelColumn}${value}`;
190
+ return fg("muted", body) + fg("dim", suffix);
191
+ };
192
+ const instructions = (compact: boolean) => compact
193
+ ? `${shortcut} starts/stops recording`
194
+ : `Press ${shortcut} to record, start speaking, then press again to transcribe.`;
195
+ const topChrome = (compact: boolean): string[] => [
196
+ ...panelBorder(this.theme).render(width),
197
+ ...(compact ? [] : [""]),
198
+ ...onboardingHeader(this.theme, title, 3).render(width),
199
+ ...(compact ? [] : [""]),
200
+ ...(compact ? [line(instructions(true))] : text(instructions(false))),
201
+ ...(compact ? [] : [""]),
202
+ ...(activity ? (compact ? [line(activity)] : text(activity)) : []),
203
+ ];
204
+ const bottomChrome = (compact: boolean): string[] => {
205
+ const render = compact ? (value: string) => [line(value)] : text;
206
+ const permission = !compact && this.showMacPermissionNote
207
+ ? [
208
+ ...text(fg("muted", "macOS will ask for microphone access the first time. Your terminal may need to be restarted.")),
209
+ "",
210
+ ]
211
+ : compact || details ? [] : [""];
212
+ return [
213
+ ...(details ? [...text(details), ...(compact ? [] : [""])] : []),
214
+ ...permission,
215
+ ...render(setting("Shortcut", shortcut, this.keys.keyText("voice.tryIt.shortcut"), compact)),
216
+ ...render(setting("Microphone", microphoneSummary(this.settings.microphone), this.keys.keyText("voice.tryIt.microphone"), compact)),
217
+ ...render(setting("Model", modelName, this.keys.keyText("voice.tryIt.model"), compact)),
218
+ ...(compact ? [] : [""]),
219
+ ...text(hints),
220
+ ...(compact ? [] : [""]),
221
+ ...panelBorder(this.theme).render(width),
222
+ ];
223
+ };
224
+
225
+ const budget = Math.max(1, paneRowBudget(this.tui) ?? 32);
226
+ const frame = budget >= 6 ? editorBorder(this.theme).render(width) : [];
227
+ const frameRows = frame.length * 2;
228
+ let top = topChrome(false);
229
+ let bottom = bottomChrome(false);
230
+ // Reserve useful room for a transcript or error before switching to the compact chrome.
231
+ const previewReserve = state.phase === "result" || state.phase === "error" ? 5 : 3;
232
+ if (top.length + bottom.length + frameRows + previewReserve > budget) {
233
+ top = topChrome(true);
234
+ bottom = bottomChrome(true);
235
+ }
236
+ if (top.length + bottom.length + frameRows + 1 > budget) {
237
+ // Tiny terminals: drop settings, but keep the task and current actions visible.
238
+ top = [
239
+ ...onboardingHeader(this.theme, title, 3).render(width),
240
+ line(instructions(true)),
241
+ ];
242
+ bottom = [...text(hints), ...panelBorder(this.theme).render(width)];
243
+ }
244
+ bottom = bottom.slice(0, Math.max(0, budget - frameRows - 1));
245
+ top = top.slice(0, Math.max(0, budget - bottom.length - frameRows - 1));
246
+ const available = Math.max(1, budget - top.length - bottom.length - frameRows);
247
+ const preview = this.preview.render(width, available, (value) => fg("dim", value));
248
+ return [...top, ...frame, ...preview, ...frame, ...bottom];
249
+ }
250
+
251
+ private start(): void {
252
+ this.recordingAttempted = true;
253
+ this.showMacPermissionNote = false;
254
+ this.preview.setText("");
255
+ this.analyzer.reset();
256
+ this.nextPaintAt = 0;
257
+ void this.dictation.start(this.settings);
258
+ }
259
+
260
+ private leave(result: TryItResult): void {
261
+ if (this.closed || this.disposed) return;
262
+ this.closed = true;
263
+ void this.dictation.dispose();
264
+ this.done(result);
265
+ }
266
+ handleInput(data: string): void {
267
+ if (this.closed || this.disposed) return;
268
+ const phase = this.dictation.state.phase;
269
+ if (matchesShortcut(data, this.settings.shortcut)) {
270
+ if (phase === "listening") {
271
+ void this.dictation.stop();
272
+ } else if (["idle", "ready", "result", "error"].includes(phase)) {
273
+ this.start();
274
+ }
275
+ return;
276
+ }
277
+ if (this.keys.matches(data, "tui.select.cancel")) {
278
+ if (["starting", "listening", "transcribing", "cancelling"].includes(phase)) {
279
+ void this.dictation.cancel();
280
+ } else {
281
+ this.leave({ action: phase === "result" ? "done" : "skip" });
282
+ }
283
+ return;
284
+ }
285
+ if (!["idle", "ready", "result", "error"].includes(phase)) return;
286
+ if ((phase === "result" || phase === "error") && this.preview.handleInput(data)) {
287
+ this.refresh();
288
+ return;
289
+ }
290
+ if (this.keys.matches(data, "tui.select.confirm")) {
291
+ if (phase === "result") this.leave({ action: "done" });
292
+ return;
293
+ }
294
+ if (this.keys.matches(data, "voice.tryIt.microphone")) {
295
+ this.leave({ action: "microphone" });
296
+ } else if (this.keys.matches(data, "voice.tryIt.shortcut")) {
297
+ this.leave({ action: "shortcut" });
298
+ } else if (this.keys.matches(data, "voice.tryIt.model")) {
299
+ this.leave({ action: "model" });
300
+ }
301
+ }
302
+
303
+ dispose(): Promise<void> {
304
+ this.disposed = true;
305
+ return this.dictation.dispose();
306
+ }
307
+ }
308
+
309
+ export async function tryVoice(ctx: ExtensionContext, settings: TranscribeSettings): Promise<TryItResult | undefined> {
310
+ // This can take up to its subprocess timeout on macOS. Let it finish after
311
+ // the Try It pane has replaced the model picker instead of blocking between
312
+ // the two panes.
313
+ const microphonePermission = testMicrophonePermission();
314
+ const service = new TranscriptionService();
315
+ let pane: TryItPane | undefined;
316
+ try {
317
+ return await ctx.ui.custom<TryItResult>((tui, theme, keybindings, done) =>
318
+ (pane = new TryItPane(tui, theme, keybindings, settings, service, done, {
319
+ createCapture: createMicrophoneCapture,
320
+ microphonePermission,
321
+ })),
322
+ );
323
+ } finally {
324
+ await pane?.dispose();
325
+ await service.shutdown();
326
+ }
327
+ }