@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +69 -0
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio.ts +193 -0
- package/src/catalog.generated.ts +1305 -0
- package/src/catalog.ts +89 -0
- package/src/chinese.ts +52 -0
- package/src/deferred.ts +30 -0
- package/src/dictation-controller.ts +204 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +114 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +118 -0
- package/src/languages.ts +22 -0
- package/src/microphone-picker.ts +99 -0
- package/src/model-activation.ts +74 -0
- package/src/model-cells.ts +218 -0
- package/src/model-picker.ts +1026 -0
- package/src/model-ratings-help.md +41 -0
- package/src/model-ratings-help.ts +146 -0
- package/src/model-selection-controller.ts +185 -0
- package/src/models.ts +263 -0
- package/src/onboarding.ts +314 -0
- package/src/pcm-chunker.ts +42 -0
- package/src/pcm.ts +19 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +501 -0
- package/src/settings-menu.ts +410 -0
- package/src/settings-path.ts +13 -0
- package/src/settings.ts +235 -0
- package/src/shortcut-core.ts +85 -0
- package/src/shortcuts.ts +167 -0
- package/src/startup-shortcut.ts +24 -0
- package/src/transcript-preview.ts +52 -0
- package/src/transcription-service.ts +548 -0
- package/src/transcription.ts +186 -0
- package/src/try-it.ts +327 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +269 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
chooseCatalogModel,
|
|
4
|
+
chooseLanguages,
|
|
5
|
+
defaultSpokenLanguages,
|
|
6
|
+
type CatalogModelPostActivation,
|
|
7
|
+
} from "./model-picker.js";
|
|
8
|
+
import { createModelActivation } from "./model-activation.js";
|
|
9
|
+
import { testMicrophonePermission } from "./audio.js";
|
|
10
|
+
import { chooseMicrophone, microphonesEqual } from "./microphone-picker.js";
|
|
11
|
+
import {
|
|
12
|
+
chooseRecommendedModel,
|
|
13
|
+
hasRecommendedAlternatives,
|
|
14
|
+
} from "./recommendation-picker.js";
|
|
15
|
+
import { CATALOG_MODELS } from "./catalog.js";
|
|
16
|
+
import { recommendModels } from "./recommendations.js";
|
|
17
|
+
import { createShortcutPicker } from "./shortcuts.js";
|
|
18
|
+
import { tryVoice } from "./try-it.js";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_MICROPHONE,
|
|
21
|
+
settingsForModel,
|
|
22
|
+
writeSettings,
|
|
23
|
+
type ChineseOutput,
|
|
24
|
+
type MicrophoneSetting,
|
|
25
|
+
type TranscribeSettings,
|
|
26
|
+
type TranscriptionLanguage,
|
|
27
|
+
} from "./settings.js";
|
|
28
|
+
import { DEFAULT_SHORTCUT } from "./shortcut-core.js";
|
|
29
|
+
|
|
30
|
+
function requireTui(ctx: ExtensionContext): boolean {
|
|
31
|
+
if (ctx.mode === "tui") return true;
|
|
32
|
+
ctx.ui.notify("Pi Voice configuration requires the interactive TUI", "error");
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type SettingsOptions = NonNullable<Parameters<typeof settingsForModel>[2]>;
|
|
37
|
+
|
|
38
|
+
/** Build the shared download-and-save pipeline from the flow's live settings. */
|
|
39
|
+
function createSettingsActivation(
|
|
40
|
+
options: () => SettingsOptions,
|
|
41
|
+
onCommitted: (settings: TranscribeSettings) => void,
|
|
42
|
+
) {
|
|
43
|
+
return createModelActivation({
|
|
44
|
+
buildSettings: (model, path) => settingsForModel(model.id, path, options()),
|
|
45
|
+
onCommitted,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type ModelSelectionOptions = {
|
|
50
|
+
shortcut?: string;
|
|
51
|
+
preferredLanguages?: readonly string[];
|
|
52
|
+
transcriptionLanguage?: TranscriptionLanguage;
|
|
53
|
+
chineseOutput?: ChineseOutput;
|
|
54
|
+
currentModelId?: string;
|
|
55
|
+
microphone?: MicrophoneSetting;
|
|
56
|
+
/** Persists language changes made before this flow activates a model. */
|
|
57
|
+
onPreferredLanguagesChange?: (languages: string[]) => Promise<void>;
|
|
58
|
+
postActivation?: CatalogModelPostActivation;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export async function runModelSelection(
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
options: ModelSelectionOptions = {},
|
|
64
|
+
): Promise<TranscribeSettings | undefined> {
|
|
65
|
+
if (!requireTui(ctx)) return undefined;
|
|
66
|
+
|
|
67
|
+
let preferredLanguages = [
|
|
68
|
+
...(options.preferredLanguages ?? defaultSpokenLanguages()),
|
|
69
|
+
];
|
|
70
|
+
let currentModelId = options.currentModelId;
|
|
71
|
+
let configured: TranscribeSettings | undefined;
|
|
72
|
+
const { activate, waitForCommits } = createSettingsActivation(
|
|
73
|
+
() => ({
|
|
74
|
+
shortcut: configured?.shortcut ?? options.shortcut ?? DEFAULT_SHORTCUT,
|
|
75
|
+
preferredLanguages,
|
|
76
|
+
transcriptionLanguage:
|
|
77
|
+
configured?.transcriptionLanguage ?? options.transcriptionLanguage,
|
|
78
|
+
chineseOutput: configured?.chineseOutput ?? options.chineseOutput,
|
|
79
|
+
microphone: configured?.microphone ?? options.microphone ?? DEFAULT_MICROPHONE,
|
|
80
|
+
}),
|
|
81
|
+
(settings) => {
|
|
82
|
+
configured = settings;
|
|
83
|
+
currentModelId = settings.model.id;
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// One pane switches between the models on disk and downloads new ones.
|
|
88
|
+
while (true) {
|
|
89
|
+
const selection = await chooseCatalogModel(ctx, preferredLanguages, currentModelId, {
|
|
90
|
+
postActivation: options.postActivation,
|
|
91
|
+
// Keep the post-selection state when the picker reopens after a
|
|
92
|
+
// round-trip through the language step.
|
|
93
|
+
activatedInFlow: configured !== undefined,
|
|
94
|
+
onActivate: activate,
|
|
95
|
+
cancelLabel: "close",
|
|
96
|
+
});
|
|
97
|
+
// The picker can close while its last commit is still in flight; wait so
|
|
98
|
+
// configured reflects every selection that will land on disk.
|
|
99
|
+
await waitForCommits();
|
|
100
|
+
|
|
101
|
+
if (!selection || selection.type === "complete") return configured;
|
|
102
|
+
|
|
103
|
+
// Esc and Continue both keep the selection here; the picker edits live
|
|
104
|
+
// state rather than gating it behind a confirm.
|
|
105
|
+
const changed = await chooseLanguages(ctx, preferredLanguages);
|
|
106
|
+
if (changed) {
|
|
107
|
+
try {
|
|
108
|
+
if (configured) {
|
|
109
|
+
const updated = { ...configured, preferredLanguages: changed.languages };
|
|
110
|
+
await writeSettings(updated);
|
|
111
|
+
configured = updated;
|
|
112
|
+
} else {
|
|
113
|
+
await options.onPreferredLanguagesChange?.(changed.languages);
|
|
114
|
+
}
|
|
115
|
+
preferredLanguages = changed.languages;
|
|
116
|
+
} catch (error) {
|
|
117
|
+
ctx.ui.notify(
|
|
118
|
+
`Could not save preferred languages: ${error instanceof Error ? error.message : String(error)}`,
|
|
119
|
+
"error",
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Reaching the language pane after a commit takes a Tab that landed while
|
|
125
|
+
// the save was in flight. Esc there closes the flow instead of bouncing
|
|
126
|
+
// back to a model pane that no longer offers the language step.
|
|
127
|
+
if (!changed?.confirmed && configured) return configured;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function chooseOnboardingShortcut(
|
|
132
|
+
ctx: ExtensionContext,
|
|
133
|
+
current: string,
|
|
134
|
+
): Promise<string | undefined> {
|
|
135
|
+
return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
136
|
+
createShortcutPicker(tui, theme, keybindings, current, done),
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function saveOnboardingSettings(
|
|
141
|
+
ctx: ExtensionContext,
|
|
142
|
+
settings: TranscribeSettings,
|
|
143
|
+
): Promise<boolean> {
|
|
144
|
+
try {
|
|
145
|
+
await writeSettings(settings);
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
ctx.ui.notify(
|
|
149
|
+
`Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
150
|
+
"error",
|
|
151
|
+
);
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The Try it step, with its shortcut, microphone, and model adjustments looping back into it. */
|
|
157
|
+
async function finishOnboarding(
|
|
158
|
+
ctx: ExtensionContext,
|
|
159
|
+
initial: TranscribeSettings,
|
|
160
|
+
changeModel: (current: TranscribeSettings) => Promise<TranscribeSettings | undefined>,
|
|
161
|
+
): Promise<TranscribeSettings> {
|
|
162
|
+
let configured = initial;
|
|
163
|
+
while (true) {
|
|
164
|
+
const result = await tryVoice(ctx, configured);
|
|
165
|
+
if (result?.action === "model") {
|
|
166
|
+
configured = (await changeModel(configured)) ?? configured;
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
if (result?.action === "shortcut") {
|
|
170
|
+
const shortcut = await chooseOnboardingShortcut(ctx, configured.shortcut);
|
|
171
|
+
if (!shortcut || shortcut === configured.shortcut) continue;
|
|
172
|
+
const updated = { ...configured, shortcut };
|
|
173
|
+
if (await saveOnboardingSettings(ctx, updated)) configured = updated;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (result?.action === "microphone") {
|
|
177
|
+
const permission = await testMicrophonePermission();
|
|
178
|
+
const microphone = await chooseMicrophone(ctx, configured.microphone, permission);
|
|
179
|
+
if (!microphone || microphonesEqual(microphone, configured.microphone)) continue;
|
|
180
|
+
const updated = { ...configured, microphone };
|
|
181
|
+
if (await saveOnboardingSettings(ctx, updated)) configured = updated;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
return configured;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Change the model from Try it, including a round trip through languages. */
|
|
189
|
+
export async function changeOnboardingModel(
|
|
190
|
+
ctx: ExtensionContext,
|
|
191
|
+
current: TranscribeSettings,
|
|
192
|
+
): Promise<TranscribeSettings | undefined> {
|
|
193
|
+
let languages = [...current.preferredLanguages];
|
|
194
|
+
let picks = recommendModels(CATALOG_MODELS, languages);
|
|
195
|
+
// With nothing to recommend besides the current model, the catalog is the
|
|
196
|
+
// whole flow. Otherwise the recommendation pane fronts it, and Esc from
|
|
197
|
+
// the catalog returns there rather than to Try it.
|
|
198
|
+
let recommending = hasRecommendedAlternatives(picks);
|
|
199
|
+
let pane: "recommended" | "browse" = recommending ? "recommended" : "browse";
|
|
200
|
+
let chosen: TranscribeSettings | undefined;
|
|
201
|
+
const activation = createSettingsActivation(
|
|
202
|
+
() => ({
|
|
203
|
+
shortcut: current.shortcut,
|
|
204
|
+
preferredLanguages: languages,
|
|
205
|
+
microphone: current.microphone,
|
|
206
|
+
chineseOutput: current.chineseOutput,
|
|
207
|
+
}),
|
|
208
|
+
(settings) => {
|
|
209
|
+
chosen = settings;
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
while (true) {
|
|
214
|
+
const result = pane === "recommended"
|
|
215
|
+
? await chooseRecommendedModel(ctx, languages, picks, activation.activate, {
|
|
216
|
+
title: "Change model",
|
|
217
|
+
expanded: true,
|
|
218
|
+
onboardingStep: 3,
|
|
219
|
+
})
|
|
220
|
+
: await chooseCatalogModel(ctx, languages, chosen?.model.id ?? current.model.id, {
|
|
221
|
+
postActivation: "advance",
|
|
222
|
+
onActivate: activation.activate,
|
|
223
|
+
cancelLabel: "back",
|
|
224
|
+
onboardingStep: 3,
|
|
225
|
+
title: "Browse all models",
|
|
226
|
+
});
|
|
227
|
+
await activation.waitForCommits();
|
|
228
|
+
if (!result && pane === "browse" && recommending) {
|
|
229
|
+
pane = "recommended";
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (!result || result.type === "complete" || result.type === "back") return chosen;
|
|
233
|
+
if (result.type === "other-models") {
|
|
234
|
+
pane = "browse";
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const changed = await chooseLanguages(ctx, languages, {
|
|
239
|
+
cancelLabel: "back",
|
|
240
|
+
onboardingStep: 3,
|
|
241
|
+
});
|
|
242
|
+
if (!changed?.confirmed) continue;
|
|
243
|
+
// Language edits remain a draft until a model is selected. Esc back to
|
|
244
|
+
// Try it must not silently alter the settings it is about to test.
|
|
245
|
+
languages = changed.languages;
|
|
246
|
+
picks = recommendModels(CATALOG_MODELS, languages);
|
|
247
|
+
// Even a single pick deserves its recommendation after languages change.
|
|
248
|
+
recommending = true;
|
|
249
|
+
pane = "recommended";
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function runOnboarding(
|
|
254
|
+
ctx: ExtensionContext,
|
|
255
|
+
shortcut = DEFAULT_SHORTCUT,
|
|
256
|
+
): Promise<TranscribeSettings | undefined> {
|
|
257
|
+
if (!requireTui(ctx)) return undefined;
|
|
258
|
+
|
|
259
|
+
let languages = defaultSpokenLanguages();
|
|
260
|
+
// Navigation cannot undo a completed settings commit. Keep it across a
|
|
261
|
+
// return to languages, even if the user then exits without another choice.
|
|
262
|
+
let configured: TranscribeSettings | undefined;
|
|
263
|
+
while (true) {
|
|
264
|
+
const chosen = await chooseLanguages(ctx, languages, {
|
|
265
|
+
cancelLabel: "exit",
|
|
266
|
+
onboardingStep: 1,
|
|
267
|
+
});
|
|
268
|
+
if (!chosen?.confirmed) return configured;
|
|
269
|
+
languages = chosen.languages;
|
|
270
|
+
// The picks are judged on the benchmark rig; the Try it step measures
|
|
271
|
+
// the real wait on this machine.
|
|
272
|
+
const picks = recommendModels(CATALOG_MODELS, languages);
|
|
273
|
+
const { activate, waitForCommits } = createSettingsActivation(
|
|
274
|
+
() => ({ shortcut, preferredLanguages: languages }),
|
|
275
|
+
(settings) => {
|
|
276
|
+
configured = settings;
|
|
277
|
+
},
|
|
278
|
+
);
|
|
279
|
+
|
|
280
|
+
let changeLanguages = false;
|
|
281
|
+
while (!changeLanguages) {
|
|
282
|
+
const recommendation = await chooseRecommendedModel(
|
|
283
|
+
ctx,
|
|
284
|
+
languages,
|
|
285
|
+
picks,
|
|
286
|
+
activate,
|
|
287
|
+
{ onboardingStep: 2 },
|
|
288
|
+
);
|
|
289
|
+
await waitForCommits();
|
|
290
|
+
if (!recommendation) return configured;
|
|
291
|
+
if (recommendation.type === "complete" && configured) {
|
|
292
|
+
return finishOnboarding(ctx, configured, (current) => changeOnboardingModel(ctx, current));
|
|
293
|
+
}
|
|
294
|
+
if (recommendation.type === "change-languages" || recommendation.type === "back") {
|
|
295
|
+
changeLanguages = true;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const selection = await chooseCatalogModel(ctx, languages, configured?.model.id, {
|
|
300
|
+
postActivation: "advance",
|
|
301
|
+
onActivate: activate,
|
|
302
|
+
cancelLabel: "back",
|
|
303
|
+
onboardingStep: 2,
|
|
304
|
+
title: "Browse all models",
|
|
305
|
+
});
|
|
306
|
+
await waitForCommits();
|
|
307
|
+
if (selection?.type === "complete" && configured) {
|
|
308
|
+
return finishOnboarding(ctx, configured, (current) => changeOnboardingModel(ctx, current));
|
|
309
|
+
}
|
|
310
|
+
if (selection?.type === "change-languages") changeLanguages = true;
|
|
311
|
+
// Esc from the complete selector returns to the recommendation pane.
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
2
|
+
import { convertFrames } from "./pcm.js";
|
|
3
|
+
|
|
4
|
+
const STREAM_CHUNK_SAMPLES = CAPTURE_SAMPLE_RATE / 2;
|
|
5
|
+
|
|
6
|
+
/** Coalesces recorder frames into fresh Float32 PCM chunks without timers. */
|
|
7
|
+
export class PcmChunker {
|
|
8
|
+
private frames: Int16Array[] = [];
|
|
9
|
+
private sampleCount = 0;
|
|
10
|
+
|
|
11
|
+
constructor(
|
|
12
|
+
private readonly onChunk: (chunk: Float32Array) => void,
|
|
13
|
+
private readonly targetSamples = STREAM_CHUNK_SAMPLES,
|
|
14
|
+
) {
|
|
15
|
+
if (!Number.isInteger(targetSamples) || targetSamples <= 0) {
|
|
16
|
+
throw new Error("PCM chunk size must be a positive integer");
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
push(frame: Int16Array): void {
|
|
21
|
+
if (frame.length === 0) return;
|
|
22
|
+
this.frames.push(frame);
|
|
23
|
+
this.sampleCount += frame.length;
|
|
24
|
+
if (this.sampleCount >= this.targetSamples) this.emit();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
flush(): void {
|
|
28
|
+
if (this.sampleCount > 0) this.emit();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
discard(): void {
|
|
32
|
+
this.frames = [];
|
|
33
|
+
this.sampleCount = 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
private emit(): void {
|
|
37
|
+
const pcm = convertFrames(this.frames);
|
|
38
|
+
this.frames = [];
|
|
39
|
+
this.sampleCount = 0;
|
|
40
|
+
this.onChunk(pcm);
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/pcm.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Coalesces Int16 recorder frames into one Float32 PCM buffer. Kept free of
|
|
3
|
+
* imports so eagerly loaded modules can share it without pulling in the native
|
|
4
|
+
* recorder that audio.ts loads.
|
|
5
|
+
*/
|
|
6
|
+
export function convertFrames(frames: readonly Int16Array[]): Float32Array {
|
|
7
|
+
const sampleCount = frames.reduce((total, frame) => total + frame.length, 0);
|
|
8
|
+
const pcm = new Float32Array(sampleCount);
|
|
9
|
+
let offset = 0;
|
|
10
|
+
|
|
11
|
+
for (const frame of frames) {
|
|
12
|
+
for (let index = 0; index < frame.length; index += 1) {
|
|
13
|
+
pcm[offset + index] = (frame[index] ?? 0) / 32_768;
|
|
14
|
+
}
|
|
15
|
+
offset += frame.length;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return pcm;
|
|
19
|
+
}
|