@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,218 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
canonicalLanguage,
|
|
5
|
+
catalogModelSearchText,
|
|
6
|
+
displayLanguage,
|
|
7
|
+
formatBinarySize,
|
|
8
|
+
modelMatchesLanguage,
|
|
9
|
+
type CatalogModel,
|
|
10
|
+
} from "./catalog.js";
|
|
11
|
+
import {
|
|
12
|
+
languageAccuracyGrade,
|
|
13
|
+
modelWaitSeconds,
|
|
14
|
+
SPEED_METER_STEPS,
|
|
15
|
+
speedMeterLevel,
|
|
16
|
+
type AccuracyLetter,
|
|
17
|
+
type RecommendationRole,
|
|
18
|
+
} from "./recommendations.js";
|
|
19
|
+
import { LIST_PADDING, padToWidth, selectionMarker } from "./ui-components.js";
|
|
20
|
+
|
|
21
|
+
type UiTheme = ExtensionContext["ui"]["theme"];
|
|
22
|
+
|
|
23
|
+
// "A-" is the widest grade; language codes ("yue") can widen a column.
|
|
24
|
+
const MIN_GRADE_CELL_WIDTH = 2;
|
|
25
|
+
const MIN_MODEL_NAME_WIDTH = 12;
|
|
26
|
+
export const ROLE_LABELS: Record<RecommendationRole, string> = {
|
|
27
|
+
best: "Best",
|
|
28
|
+
fast: "Fast",
|
|
29
|
+
accurate: "Accurate",
|
|
30
|
+
};
|
|
31
|
+
export const MANUAL_LANGUAGE_TAG = "manual lang";
|
|
32
|
+
/** Replaces the size column for a model already in the cache. */
|
|
33
|
+
export const ON_DISK_LABEL = "on disk";
|
|
34
|
+
// "Best · Fast" is the widest role pairing that occurs.
|
|
35
|
+
export const TAG_WIDTH = 11;
|
|
36
|
+
|
|
37
|
+
/** Width of one grade cell for these language columns. */
|
|
38
|
+
export function gradeCellWidth(languages: readonly string[]): number {
|
|
39
|
+
return Math.max(MIN_GRADE_CELL_WIDTH, ...languages.map((language) => visibleWidth(language)));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Width of the whole grade block: the cells and the spaces between them. */
|
|
43
|
+
export function gradeColumnsWidth(languages: readonly string[]): number {
|
|
44
|
+
return languages.length * gradeCellWidth(languages) + Math.max(0, languages.length - 1);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Colour reinforces the letter rather than replacing it, one hue per grade so
|
|
48
|
+
// no single grade dominates: green A, blue B, gold C, red D and F. The blue
|
|
49
|
+
// and gold borrow the markdown link and heading tokens; the theme has no
|
|
50
|
+
// dedicated ones, and both shipped themes give them sensible values.
|
|
51
|
+
export function gradeStyle(theme: UiTheme, letter: AccuracyLetter, text: string): string {
|
|
52
|
+
switch (letter) {
|
|
53
|
+
case "A": return theme.fg("success", text);
|
|
54
|
+
case "B": return theme.fg("mdLink", text);
|
|
55
|
+
case "C": return theme.fg("mdHeading", text);
|
|
56
|
+
case "D":
|
|
57
|
+
case "F": return theme.fg("error", text);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// One cell per preferred language, in preference order, carrying the model's
|
|
62
|
+
// benchmark grade for it. A model that lacks the language shows a dash; one
|
|
63
|
+
// that claims it without a benchmark shows a question mark, since a claim on
|
|
64
|
+
// a model card is not a measurement.
|
|
65
|
+
export function gradeCells(
|
|
66
|
+
theme: UiTheme,
|
|
67
|
+
model: CatalogModel,
|
|
68
|
+
languages: readonly string[],
|
|
69
|
+
): string {
|
|
70
|
+
const cellWidth = gradeCellWidth(languages);
|
|
71
|
+
return languages
|
|
72
|
+
.map((language) => {
|
|
73
|
+
const grade = languageAccuracyGrade(model, language);
|
|
74
|
+
if (!grade) {
|
|
75
|
+
const mark = modelMatchesLanguage(model, language) ? "?" : "—";
|
|
76
|
+
return theme.fg("dim", padToWidth(mark, cellWidth));
|
|
77
|
+
}
|
|
78
|
+
return gradeStyle(theme, grade.letter, padToWidth(grade.label, cellWidth));
|
|
79
|
+
})
|
|
80
|
+
.join(" ");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The language codes over the grade cells, in the same widths. */
|
|
84
|
+
export function gradeHeader(languages: readonly string[]): string {
|
|
85
|
+
const cellWidth = gradeCellWidth(languages);
|
|
86
|
+
return languages.map((language) => padToWidth(language, cellWidth)).join(" ");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Benchmark processing speed, fuller the quicker (not end-of-speech latency).
|
|
90
|
+
// Unbenchmarked models leave the column empty rather than claim a speed.
|
|
91
|
+
export function speedCell(model: CatalogModel): string {
|
|
92
|
+
const wait = modelWaitSeconds(model);
|
|
93
|
+
if (wait === undefined) return " ".repeat(SPEED_METER_STEPS);
|
|
94
|
+
const level = speedMeterLevel(wait);
|
|
95
|
+
return "▰".repeat(level) + "▱".repeat(SPEED_METER_STEPS - level);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export type ModelTableLayout = {
|
|
99
|
+
nameWidth: number;
|
|
100
|
+
/**
|
|
101
|
+
* A section heading that doubles as the column header row: the label sits
|
|
102
|
+
* over the name column, the column names over theirs.
|
|
103
|
+
*/
|
|
104
|
+
header: (label: string) => string;
|
|
105
|
+
sizeWidth?: number;
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
/** Shared column geometry for catalog and downloaded-model tables. */
|
|
109
|
+
export function modelTableLayout(
|
|
110
|
+
theme: UiTheme,
|
|
111
|
+
width: number,
|
|
112
|
+
maximumNameWidth: number,
|
|
113
|
+
languages: readonly string[],
|
|
114
|
+
sizeWidth?: number,
|
|
115
|
+
): ModelTableLayout {
|
|
116
|
+
const languagesWidth = gradeColumnsWidth(languages);
|
|
117
|
+
const sizeOverhead = sizeWidth === undefined ? 0 : 2 + sizeWidth;
|
|
118
|
+
const overhead =
|
|
119
|
+
LIST_PADDING * 2 +
|
|
120
|
+
4 +
|
|
121
|
+
2 +
|
|
122
|
+
SPEED_METER_STEPS +
|
|
123
|
+
2 +
|
|
124
|
+
languagesWidth +
|
|
125
|
+
sizeOverhead +
|
|
126
|
+
2 +
|
|
127
|
+
TAG_WIDTH;
|
|
128
|
+
const nameWidth = Math.min(
|
|
129
|
+
maximumNameWidth,
|
|
130
|
+
Math.max(MIN_MODEL_NAME_WIDTH, width - overhead),
|
|
131
|
+
);
|
|
132
|
+
// Labels outdent two columns from the names, like a heading; the rest of
|
|
133
|
+
// the name column is theirs, and a long one truncates on narrow terminals.
|
|
134
|
+
const labelWidth = 2 + nameWidth;
|
|
135
|
+
const size = sizeWidth === undefined
|
|
136
|
+
? ""
|
|
137
|
+
: ` ${theme.fg("dim", "Size".padStart(sizeWidth))}`;
|
|
138
|
+
return {
|
|
139
|
+
nameWidth,
|
|
140
|
+
sizeWidth,
|
|
141
|
+
header: (label) =>
|
|
142
|
+
` ${theme.fg("muted", padToWidth(label, labelWidth))} ` +
|
|
143
|
+
`${theme.fg("dim", padToWidth("Speed", SPEED_METER_STEPS))} ` +
|
|
144
|
+
theme.fg("dim", gradeHeader(languages)) +
|
|
145
|
+
size,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Shared model row; callers provide only the pane-specific final tag. */
|
|
150
|
+
export function modelTableRow(
|
|
151
|
+
theme: UiTheme,
|
|
152
|
+
model: CatalogModel,
|
|
153
|
+
languages: readonly string[],
|
|
154
|
+
layout: ModelTableLayout,
|
|
155
|
+
options: {
|
|
156
|
+
active: boolean;
|
|
157
|
+
current: boolean;
|
|
158
|
+
tag?: string;
|
|
159
|
+
/** Already in the cache: the size column says so instead of the size. */
|
|
160
|
+
downloaded?: boolean;
|
|
161
|
+
},
|
|
162
|
+
): string {
|
|
163
|
+
const prefix = options.active ? theme.fg("accent", "→ ") : " ";
|
|
164
|
+
const current = selectionMarker(theme, options.current);
|
|
165
|
+
const nameText = padToWidth(model.name, layout.nameWidth);
|
|
166
|
+
const name = options.active ? theme.fg("accent", nameText) : nameText;
|
|
167
|
+
const sizeText = options.downloaded ? ON_DISK_LABEL : formatBinarySize(model.size);
|
|
168
|
+
const size = layout.sizeWidth === undefined
|
|
169
|
+
? ""
|
|
170
|
+
: ` ${theme.fg("dim", sizeText.padStart(layout.sizeWidth))}`;
|
|
171
|
+
return (
|
|
172
|
+
`${prefix}${current} ${name} ${speedCell(model)} ` +
|
|
173
|
+
`${gradeCells(theme, model, languages)}${size} ${options.tag ?? ""}`
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Shared description and capability lines below a model table. */
|
|
178
|
+
export function modelDetailText(
|
|
179
|
+
theme: UiTheme,
|
|
180
|
+
model: CatalogModel,
|
|
181
|
+
width: number,
|
|
182
|
+
padding: number,
|
|
183
|
+
feedback?: { type: "success" | "error" | "muted"; text: string },
|
|
184
|
+
showManualSelection = false,
|
|
185
|
+
): string {
|
|
186
|
+
const canonicalLanguages = [...new Set(model.languages.map(canonicalLanguage))];
|
|
187
|
+
const features = [
|
|
188
|
+
canonicalLanguages.length === 1
|
|
189
|
+
? `${displayLanguage(canonicalLanguages[0]!)} only`
|
|
190
|
+
: `${canonicalLanguages.length} languages`,
|
|
191
|
+
model.capabilities.languageDetection
|
|
192
|
+
? "auto language detection"
|
|
193
|
+
: showManualSelection
|
|
194
|
+
? "manual language selection"
|
|
195
|
+
: undefined,
|
|
196
|
+
].filter((value): value is string => Boolean(value));
|
|
197
|
+
const description = truncateToWidth(
|
|
198
|
+
model.description,
|
|
199
|
+
Math.max(24, width - padding * 2),
|
|
200
|
+
"…",
|
|
201
|
+
);
|
|
202
|
+
const feedbackText = feedback
|
|
203
|
+
? `\n${theme.fg(feedback.type, feedback.text)}`
|
|
204
|
+
: "";
|
|
205
|
+
return `${description}\n${theme.fg("dim", features.join(" · "))}${feedbackText}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* The catalog's search matching, shared by every page that lists models.
|
|
210
|
+
* Each word of the query must appear literally in the model's id, name,
|
|
211
|
+
* family or size, so typing a model name finds that model and little else.
|
|
212
|
+
*/
|
|
213
|
+
export function matchesCatalogSearch(model: CatalogModel, query: string): boolean {
|
|
214
|
+
const tokens = query.toLowerCase().split(/\s+/).filter(Boolean);
|
|
215
|
+
if (!tokens.length) return true;
|
|
216
|
+
const text = catalogModelSearchText(model);
|
|
217
|
+
return tokens.every((token) => text.includes(token));
|
|
218
|
+
}
|