@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,423 @@
1
+ import recommendationData from "../catalog/recommendations.json" with { type: "json" };
2
+ import {
3
+ languageIdentity as recommendationLanguage,
4
+ modelMatchesLanguage as modelMatchesRecommendationLanguage,
5
+ displayLanguage,
6
+ rankCatalogModels,
7
+ type CatalogModel,
8
+ } from "./catalog.js";
9
+
10
+ export type MachineTier = "accelerated" | "cpu";
11
+
12
+ export type RecommendationRole = "best" | "fast" | "accurate";
13
+
14
+ export type RecommendationStatus =
15
+ | "eligible"
16
+ | "experimental"
17
+ | "unsupported"
18
+ | "unbenchmarked";
19
+
20
+ export type ModelRecommendation = {
21
+ model: CatalogModel;
22
+ roles: RecommendationRole[];
23
+ status: RecommendationStatus;
24
+ /** Geometric mean of the per-language error, in percent. */
25
+ error?: number;
26
+ /** Seconds after the dictation on the benchmark GPU. */
27
+ waitSeconds?: number;
28
+ /** Whether this model meets the Fast role's wait target on the benchmark CPU. */
29
+ withinFastWaitTarget?: boolean;
30
+ /** The chosen language this model handles worst. */
31
+ worstLanguage?: string;
32
+ /** Its measured word or character error, in percent. */
33
+ worstError?: number;
34
+ /** True when even the worst language is over the usability floor. */
35
+ overFloor?: boolean;
36
+ /** Usable, but close enough to the floor that the pane says so. */
37
+ nearFloor?: boolean;
38
+ };
39
+
40
+ type AccuracyCell = {
41
+ error: number;
42
+ ciLower?: number;
43
+ };
44
+ type RecommendationModelData = {
45
+ accuracy: Record<string, AccuracyCell>;
46
+ performance: Partial<Record<MachineTier, number>>;
47
+ };
48
+ /**
49
+ * The picks in plain words. Wait is the seconds after a dictation of
50
+ * `dictationSeconds`, on the benchmark GPU unless said otherwise. When
51
+ * several languages were chosen, automatic switching is preferred, but a
52
+ * usable manual model beats an automatic model over the error floor. Quality
53
+ * is a hard constraint whenever any model stays under
54
+ * `maxLanguageErrorPercent` on every chosen language. The overall pick has
55
+ * the lowest error plus wait penalty, where the first `freeWaitSeconds` cost
56
+ * nothing and each second beyond costs `waitWeight` points; its
57
+ * `overallMaxWaitSeconds` limit relaxes before quality does. The fast pick is
58
+ * judged on the benchmark CPU: the most accurate usable model within
59
+ * `fastCpuMaxWaitSeconds`, else the quickest usable model. The most accurate
60
+ * pick has the lowest error among usable models within
61
+ * `accurateMaxWaitSeconds`, else the most accurate usable model. When no
62
+ * usable model exists, the closest model carries every role. It remains
63
+ * experimental below `experimentalMaxErrorPercent`, or when its lower
64
+ * confidence bound reaches that cutoff; otherwise it is unsupported. A pick
65
+ * that is usable but reaches `noteMinErrorPercent` on some chosen language is
66
+ * still recommended, with the shortfall named rather than left to be
67
+ * discovered: the usable band spans most of an order of magnitude, so silence
68
+ * across all of it would say the same thing about very different models.
69
+ */
70
+ type Methodology = {
71
+ dictationSeconds: number;
72
+ freeWaitSeconds: number;
73
+ waitWeight: number;
74
+ overallMaxWaitSeconds: number;
75
+ maxLanguageErrorPercent: number;
76
+ noteMinErrorPercent: number;
77
+ experimentalMaxErrorPercent: number;
78
+ fastCpuMaxWaitSeconds: number;
79
+ accurateMaxWaitSeconds: number;
80
+ };
81
+ type RecommendationData = {
82
+ methodology: Methodology;
83
+ models: Record<string, RecommendationModelData>;
84
+ };
85
+
86
+ const DATA: RecommendationData = recommendationData;
87
+
88
+ export const EXPERIMENTAL_MAX_ERROR_PERCENT = DATA.methodology.experimentalMaxErrorPercent;
89
+
90
+ export type AccuracyLetter = "A" | "B" | "C" | "D" | "F";
91
+
92
+ export type AccuracyGrade = {
93
+ letter: AccuracyLetter;
94
+ /** The letter with its modifier, e.g. "A-" or "B+"; D and F carry none. */
95
+ label: string;
96
+ };
97
+
98
+ /** Display bands are independent of recommendation eligibility policy. */
99
+ const ACCURACY_CEILINGS = { A: 5, B: 10, C: 20, D: 30 } as const;
100
+ /** Each sub-grade step multiplies the measured error by this. */
101
+ const SUB_GRADE_STEP = Math.cbrt(2);
102
+
103
+ /**
104
+ * A compact benchmark comparison, not a prediction of editing effort or
105
+ * whether the coding assistant will understand a request. A–C modifiers
106
+ * use proportional changes in error. Keep model-ratings-help.md in sync
107
+ * when changing display bands.
108
+ */
109
+ export function accuracyGrade(errorPercent: number): AccuracyGrade {
110
+ if (errorPercent >= ACCURACY_CEILINGS.D) return { letter: "F", label: "F" };
111
+ if (errorPercent >= ACCURACY_CEILINGS.C) return { letter: "D", label: "D" };
112
+ const letters: [AccuracyLetter, number][] = [
113
+ ["A", ACCURACY_CEILINGS.A],
114
+ ["B", ACCURACY_CEILINGS.B],
115
+ ["C", ACCURACY_CEILINGS.C],
116
+ ];
117
+ const [letter, ceiling] = letters.find(([, limit]) => errorPercent < limit)!;
118
+ const modifier = errorPercent < ceiling / SUB_GRADE_STEP ** 2
119
+ ? "+"
120
+ : errorPercent < ceiling / SUB_GRADE_STEP
121
+ ? ""
122
+ : "-";
123
+ return { letter, label: `${letter}${modifier}` };
124
+ }
125
+
126
+ /** Processing time for the display's reference recording on the benchmark tier. */
127
+ export function modelWaitSeconds(
128
+ model: CatalogModel,
129
+ tier: MachineTier = "accelerated",
130
+ ): number | undefined {
131
+ const xrt = DATA.models[model.id]?.performance[tier];
132
+ return xrt ? BENCHMARK_DICTATION_SECONDS / xrt : undefined;
133
+ }
134
+
135
+ // Display-only reference and bands; changing recommendation wait budgets must
136
+ // not silently redefine the meter. See model-ratings-help.md for the explanation.
137
+ export const BENCHMARK_DICTATION_SECONDS = 30;
138
+ const SPEED_CUTOFF_SECONDS = [1.5, 3, 5, 10, 20] as const;
139
+ export const SPEED_METER_STEPS = SPEED_CUTOFF_SECONDS.length;
140
+ export function speedMeterLevel(processingSeconds: number): number {
141
+ return SPEED_CUTOFF_SECONDS.filter((cutoff) => processingSeconds < cutoff).length;
142
+ }
143
+
144
+ /**
145
+ * The model's grade for one language, or undefined when the benchmark has
146
+ * no measurement for it (whether or not the model claims the language).
147
+ */
148
+ export function languageAccuracyGrade(
149
+ model: CatalogModel,
150
+ language: string,
151
+ ): AccuracyGrade | undefined {
152
+ const cell = DATA.models[model.id]?.accuracy[recommendationLanguage(language)];
153
+ return cell ? accuracyGrade(cell.error) : undefined;
154
+ }
155
+
156
+ /**
157
+ * The speed the overall pick was chosen for: a dictation of
158
+ * `dictationSeconds` back within `overallMaxWaitSeconds`. A model measured
159
+ * below this on someone's machine misses the budget it was recommended by.
160
+ */
161
+ export const COMFORTABLE_REAL_TIME_FACTOR =
162
+ DATA.methodology.dictationSeconds / DATA.methodology.overallMaxWaitSeconds;
163
+
164
+ type ScoredModel = {
165
+ model: CatalogModel;
166
+ /** Geometric mean of the per-language error, in percent. */
167
+ error: number;
168
+ /** The chosen language this model handles worst, and its error in percent. */
169
+ worstLanguage: string;
170
+ worstError: number;
171
+ /** Lower confidence bound for that error, when the benchmark provides one. */
172
+ worstCiLower: number | undefined;
173
+ /** Seconds after the dictation on the benchmark GPU. */
174
+ waitSeconds: number;
175
+ /** The same on the benchmark CPU, when measured. */
176
+ cpuWaitSeconds: number | undefined;
177
+ /** Needs the language set by hand for this set of languages. */
178
+ manual: boolean;
179
+ score: number;
180
+ };
181
+
182
+ function scoreModels(
183
+ models: readonly CatalogModel[],
184
+ languages: readonly string[],
185
+ ): ScoredModel[] {
186
+ const wanted = [...new Set(languages.map(recommendationLanguage))];
187
+ const method = DATA.methodology;
188
+ const scored: ScoredModel[] = [];
189
+
190
+ for (const model of models) {
191
+ if (!wanted.every((language) => modelMatchesRecommendationLanguage(model, language))) continue;
192
+ const data = DATA.models[model.id];
193
+ const cells = wanted.map((language) => data?.accuracy[language]);
194
+ const xrt = data?.performance.accelerated;
195
+ if (!data || !xrt || cells.some((cell) => cell === undefined)) continue;
196
+ const accuracyCells = cells as AccuracyCell[];
197
+ const numericErrors = accuracyCells.map((cell) => cell.error);
198
+ const error = Math.exp(
199
+ numericErrors.reduce((sum, value) => sum + Math.log(value), 0) /
200
+ numericErrors.length,
201
+ );
202
+ const waitSeconds = method.dictationSeconds / xrt;
203
+ const cpuXrt = data.performance.cpu;
204
+ const worstError = Math.max(...numericErrors);
205
+ const worstIndex = numericErrors.indexOf(worstError);
206
+ scored.push({
207
+ model,
208
+ error,
209
+ worstLanguage: wanted[worstIndex]!,
210
+ worstError,
211
+ worstCiLower: accuracyCells[worstIndex]!.ciLower,
212
+ waitSeconds,
213
+ cpuWaitSeconds: cpuXrt ? method.dictationSeconds / cpuXrt : undefined,
214
+ manual: wanted.length > 1 && !model.capabilities.languageDetection,
215
+ score: error + method.waitWeight * Math.max(0, waitSeconds - method.freeWaitSeconds),
216
+ });
217
+ }
218
+ return scored;
219
+ }
220
+
221
+ /** A model's benchmark against a set of chosen languages, for the picker. */
222
+ export type ModelBenchmark = {
223
+ /** Geometric mean of the per-language error, in percent. */
224
+ error: number;
225
+ /** Seconds after the benchmark dictation on the benchmark GPU. */
226
+ waitSeconds: number;
227
+ /** Every chosen language is under the usability floor. */
228
+ usable: boolean;
229
+ /** Needs the language set by hand for this set of languages. */
230
+ manual: boolean;
231
+ };
232
+
233
+ /**
234
+ * Benchmarks for every model measured on all the chosen languages, keyed by
235
+ * model id. Models that lack a language or a measurement are absent.
236
+ */
237
+ export function benchmarkModels(
238
+ models: readonly CatalogModel[],
239
+ languages: readonly string[],
240
+ ): Map<string, ModelBenchmark> {
241
+ const floor = DATA.methodology.maxLanguageErrorPercent;
242
+ return new Map(
243
+ scoreModels(models, languages).map((candidate) => [
244
+ candidate.model.id,
245
+ {
246
+ error: candidate.error,
247
+ waitSeconds: candidate.waitSeconds,
248
+ usable: candidate.worstError < floor,
249
+ manual: candidate.manual,
250
+ },
251
+ ]),
252
+ );
253
+ }
254
+
255
+ /**
256
+ * The usable models on the speed/accuracy frontier: nothing usable is both
257
+ * quicker and more accurate. Equal models both stay, so a tie never hides one.
258
+ */
259
+ export function frontierModelIds(
260
+ benchmarks: ReadonlyMap<string, ModelBenchmark>,
261
+ ): Set<string> {
262
+ const usable = [...benchmarks]
263
+ .filter(([, benchmark]) => benchmark.usable)
264
+ .map(([id, benchmark]) => ({ id, error: benchmark.error, wait: benchmark.waitSeconds }));
265
+ return new Set(
266
+ usable
267
+ .filter((candidate) =>
268
+ !usable.some((other) =>
269
+ other.error <= candidate.error &&
270
+ other.wait <= candidate.wait &&
271
+ (other.error < candidate.error || other.wait < candidate.wait),
272
+ ),
273
+ )
274
+ .map((candidate) => candidate.id),
275
+ );
276
+ }
277
+
278
+ function fallbackRecommendation(
279
+ models: readonly CatalogModel[],
280
+ languages: readonly string[],
281
+ ): ModelRecommendation[] {
282
+ const wanted = [...new Set(languages.map(recommendationLanguage))];
283
+ const compatible = models.filter(
284
+ (model) =>
285
+ wanted.every((language) =>
286
+ modelMatchesRecommendationLanguage(model, language),
287
+ ) && (wanted.length === 1 || model.capabilities.languageDetection),
288
+ );
289
+ const model = rankCatalogModels(compatible.length ? compatible : models, wanted)[0];
290
+ return model
291
+ ? [{ model, roles: ["best", "fast", "accurate"], status: "unbenchmarked" }]
292
+ : [];
293
+ }
294
+
295
+ function lowest<T>(items: readonly T[], key: (item: T) => number): T | undefined {
296
+ let best: T | undefined;
297
+ for (const item of items) {
298
+ if (best === undefined || key(item) < key(best)) best = item;
299
+ }
300
+ return best;
301
+ }
302
+
303
+ /**
304
+ * Overall, fast, and accurate picks. Every role is always assigned: when no
305
+ * other model earns the fast or accurate role, the overall pick carries it,
306
+ * so callers can rely on all three without special cases. The picks do not
307
+ * depend on the machine: they are judged on the benchmark rig, and the Try
308
+ * it step measures the real wait.
309
+ */
310
+ export function recommendModels(
311
+ models: readonly CatalogModel[],
312
+ languages: readonly string[],
313
+ ): ModelRecommendation[] {
314
+ const scored = scoreModels(models, languages);
315
+ if (scored.length === 0) return fallbackRecommendation(models, languages);
316
+ const method = DATA.methodology;
317
+ const byError = (candidate: ScoredModel) => candidate.error;
318
+
319
+ // Quality outranks both latency and automatic switching. Prefer an
320
+ // automatic model when one clears the floor, then a usable manual model.
321
+ // Only compare over-floor models when no usable candidate exists at all.
322
+ const withinFloor = scored.filter(
323
+ (candidate) => candidate.worstError < method.maxLanguageErrorPercent,
324
+ );
325
+ const detectingWithinFloor = withinFloor.filter((candidate) => !candidate.manual);
326
+ const hasUsableModel = withinFloor.length > 0;
327
+ const pool = detectingWithinFloor.length
328
+ ? detectingWithinFloor
329
+ : hasUsableModel
330
+ ? withinFloor
331
+ : scored;
332
+
333
+ const withinOverallBudget = pool.filter(
334
+ (candidate) => candidate.waitSeconds <= method.overallMaxWaitSeconds,
335
+ );
336
+ const overall = hasUsableModel
337
+ ? lowest(
338
+ withinOverallBudget.length ? withinOverallBudget : pool,
339
+ (candidate) => candidate.score,
340
+ )!
341
+ : lowest(pool, (candidate) => candidate.worstError + candidate.error / 1000)!;
342
+
343
+ // Fast means the best quality inside the CPU target. If every usable model
344
+ // misses that target, prefer the quickest usable model rather than a fast
345
+ // model whose transcript is not useful.
346
+ const onCpu = pool.filter(
347
+ (candidate): candidate is ScoredModel & { cpuWaitSeconds: number } =>
348
+ candidate.cpuWaitSeconds !== undefined,
349
+ );
350
+ const withinCpuBudget = onCpu.filter(
351
+ (candidate) => candidate.cpuWaitSeconds <= method.fastCpuMaxWaitSeconds,
352
+ );
353
+ const fast = hasUsableModel
354
+ ? lowest(withinCpuBudget, byError) ??
355
+ lowest(onCpu, (candidate) => candidate.cpuWaitSeconds + candidate.error / 1000) ??
356
+ overall
357
+ : overall;
358
+
359
+ const accurate = hasUsableModel
360
+ ? lowest(
361
+ pool.filter((candidate) => candidate.waitSeconds <= method.accurateMaxWaitSeconds),
362
+ byError,
363
+ ) ?? lowest(pool, byError) ?? overall
364
+ : overall;
365
+
366
+ const byId = new Map<string, ModelRecommendation>();
367
+ for (const [candidate, role] of [
368
+ [overall, "best"],
369
+ [fast, "fast"],
370
+ [accurate, "accurate"],
371
+ ] as const) {
372
+ const current = byId.get(candidate.model.id);
373
+ if (current) current.roles.push(role);
374
+ else {
375
+ byId.set(candidate.model.id, {
376
+ model: candidate.model,
377
+ roles: [role],
378
+ status: hasUsableModel
379
+ ? "eligible"
380
+ : candidate.worstError < method.experimentalMaxErrorPercent ||
381
+ (candidate.worstCiLower !== undefined && candidate.worstCiLower <= method.experimentalMaxErrorPercent)
382
+ ? "experimental"
383
+ : "unsupported",
384
+ error: candidate.error,
385
+ waitSeconds: candidate.waitSeconds,
386
+ withinFastWaitTarget:
387
+ candidate.cpuWaitSeconds !== undefined &&
388
+ candidate.cpuWaitSeconds <= method.fastCpuMaxWaitSeconds,
389
+ worstLanguage: candidate.worstLanguage,
390
+ worstError: candidate.worstError,
391
+ overFloor: candidate.worstError >= method.maxLanguageErrorPercent,
392
+ nearFloor:
393
+ candidate.worstError >= method.noteMinErrorPercent &&
394
+ candidate.worstError < method.maxLanguageErrorPercent,
395
+ });
396
+ }
397
+ }
398
+ return [...byId.values()];
399
+ }
400
+
401
+ /**
402
+ * Languages suitable for the recommendation-oriented preferred-language
403
+ * picker. Experimental languages remain discoverable; unsupported and
404
+ * unbenchmarked model-card claims stay available only in model-specific
405
+ * language controls.
406
+ */
407
+ export function getPreferredRecommendationLanguages(
408
+ models: readonly CatalogModel[],
409
+ ): string[] {
410
+ const languages = new Set(
411
+ models.flatMap((model) => model.languages.map(recommendationLanguage)),
412
+ );
413
+ return [...languages]
414
+ .filter((language) => {
415
+ const status = recommendModels(models, [language])[0]?.status;
416
+ return status === "eligible" || status === "experimental";
417
+ })
418
+ .sort((left, right) => {
419
+ if (left === "en") return -1;
420
+ if (right === "en") return 1;
421
+ return displayLanguage(left).localeCompare(displayLanguage(right));
422
+ });
423
+ }