@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,432 @@
|
|
|
1
|
+
import { DynamicBorder, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
Box,
|
|
4
|
+
type Component,
|
|
5
|
+
Container,
|
|
6
|
+
type Focusable,
|
|
7
|
+
fuzzyFilter,
|
|
8
|
+
Input,
|
|
9
|
+
Loader,
|
|
10
|
+
Spacer,
|
|
11
|
+
Text,
|
|
12
|
+
truncateToWidth,
|
|
13
|
+
visibleWidth,
|
|
14
|
+
type KeybindingsManager,
|
|
15
|
+
type TUI,
|
|
16
|
+
} from "@earendil-works/pi-tui";
|
|
17
|
+
import { formatBinarySize } from "./catalog.js";
|
|
18
|
+
import type { DownloadState } from "./model-selection-controller.js";
|
|
19
|
+
import { VoiceKeys } from "./keybindings.js";
|
|
20
|
+
|
|
21
|
+
type UiTheme = ExtensionContext["ui"]["theme"];
|
|
22
|
+
|
|
23
|
+
export const PANEL_PADDING = 1;
|
|
24
|
+
export const LIST_PADDING = 1;
|
|
25
|
+
|
|
26
|
+
export function panelBorder(theme: UiTheme): DynamicBorder {
|
|
27
|
+
return new DynamicBorder((text: string) => theme.fg("border", text));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The rule Pi's editor draws above and below its text. */
|
|
31
|
+
export function editorBorder(theme: UiTheme): DynamicBorder {
|
|
32
|
+
return new DynamicBorder((text: string) => theme.fg("borderMuted", text));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Page title on the left; quieter setup context and progress on the right. */
|
|
36
|
+
export function onboardingHeader(
|
|
37
|
+
theme: UiTheme,
|
|
38
|
+
title: string,
|
|
39
|
+
step: number,
|
|
40
|
+
total = 3,
|
|
41
|
+
): Component {
|
|
42
|
+
const context = `Pi Voice setup · ${step} of ${total}`;
|
|
43
|
+
const compactContext = `${step} of ${total}`;
|
|
44
|
+
return {
|
|
45
|
+
invalidate() {},
|
|
46
|
+
render(width: number): string[] {
|
|
47
|
+
const innerWidth = Math.max(1, width - PANEL_PADDING * 2);
|
|
48
|
+
const titleWidth = visibleWidth(title);
|
|
49
|
+
const contextWidth = visibleWidth(context);
|
|
50
|
+
const left = theme.fg("accent", theme.bold(title));
|
|
51
|
+
let content: string;
|
|
52
|
+
if (titleWidth + contextWidth + 2 <= innerWidth) {
|
|
53
|
+
content = `${left}${" ".repeat(innerWidth - titleWidth - contextWidth)}${theme.fg("dim", context)}`;
|
|
54
|
+
} else {
|
|
55
|
+
const suffix = ` · ${compactContext}`;
|
|
56
|
+
const titleRoom = Math.max(1, innerWidth - visibleWidth(suffix));
|
|
57
|
+
content = `${truncateToWidth(left, titleRoom, "…")}${theme.fg("dim", suffix)}`;
|
|
58
|
+
}
|
|
59
|
+
return [truncateToWidth(`${" ".repeat(PANEL_PADDING)}${content}`, width, "")];
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export type SingleSelectChoice<T extends string> = {
|
|
65
|
+
value: T;
|
|
66
|
+
label: string;
|
|
67
|
+
description?: string;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export function selectedWindow<T>(
|
|
71
|
+
items: readonly T[],
|
|
72
|
+
selected: number,
|
|
73
|
+
maximum: number,
|
|
74
|
+
): [number, number] {
|
|
75
|
+
const start = Math.max(
|
|
76
|
+
0,
|
|
77
|
+
Math.min(selected - Math.floor(maximum / 2), items.length - maximum),
|
|
78
|
+
);
|
|
79
|
+
return [start, Math.min(start + maximum, items.length)];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Rows the host renders below an editor-mounted pane (its two footer lines). */
|
|
83
|
+
const RESERVED_HOST_ROWS = 2;
|
|
84
|
+
/** Below this a list stops shrinking and the pane is left to overflow. */
|
|
85
|
+
export const MIN_VISIBLE_ROWS = 3;
|
|
86
|
+
|
|
87
|
+
/** Rows available to a pane, or undefined when the terminal size is unknown. */
|
|
88
|
+
export function paneRowBudget(tui: TUI): number | undefined {
|
|
89
|
+
const rows = (tui as Partial<TUI>).terminal?.rows;
|
|
90
|
+
return typeof rows === "number" && rows > 0
|
|
91
|
+
? rows - RESERVED_HOST_ROWS
|
|
92
|
+
: undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Window size that fits a list into a budget of rows. */
|
|
96
|
+
export function windowSizeForBudget(
|
|
97
|
+
budget: number,
|
|
98
|
+
maximum: number,
|
|
99
|
+
minimum = MIN_VISIBLE_ROWS,
|
|
100
|
+
): number {
|
|
101
|
+
return Math.max(minimum, Math.min(maximum, budget));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Compute a list window while reserving the pane's non-list content. */
|
|
105
|
+
export function paneListWindow(
|
|
106
|
+
tui: TUI,
|
|
107
|
+
renderedRows: number,
|
|
108
|
+
listRows: number,
|
|
109
|
+
detailRows: number,
|
|
110
|
+
reservedDetailRows: number,
|
|
111
|
+
maximum: number,
|
|
112
|
+
): number | undefined {
|
|
113
|
+
const budget = paneRowBudget(tui);
|
|
114
|
+
if (budget === undefined) return undefined;
|
|
115
|
+
const chrome = renderedRows - listRows - detailRows + reservedDetailRows;
|
|
116
|
+
return windowSizeForBudget(budget - chrome, maximum);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function padToWidth(value: string, width: number): string {
|
|
120
|
+
const truncated = truncateToWidth(value, width, "…");
|
|
121
|
+
return `${truncated}${" ".repeat(Math.max(0, width - visibleWidth(truncated)))}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Shared fixed-width marker: the arrow is focus, while ✓ is selected/current. */
|
|
125
|
+
export function selectionMarker(theme: UiTheme, selected: boolean): string {
|
|
126
|
+
return selected ? theme.fg("accent", "✓") : " ";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Shared download presentation; the picker owns and disposes its spinner. */
|
|
130
|
+
export class DownloadPanel {
|
|
131
|
+
private readonly spinner: Loader;
|
|
132
|
+
private state: DownloadState;
|
|
133
|
+
private stats: string | undefined;
|
|
134
|
+
|
|
135
|
+
constructor(
|
|
136
|
+
tui: TUI,
|
|
137
|
+
private readonly theme: UiTheme,
|
|
138
|
+
private readonly keys: VoiceKeys,
|
|
139
|
+
state: DownloadState,
|
|
140
|
+
) {
|
|
141
|
+
this.state = state;
|
|
142
|
+
this.spinner = new Loader(tui, (text) => theme.fg("accent", text), (text) => theme.fg("muted", text), state.message);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
update(state: DownloadState, stats?: string): void {
|
|
146
|
+
this.state = state;
|
|
147
|
+
this.stats = stats;
|
|
148
|
+
this.spinner.setMessage(state.message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
render(width: number, maxRows = Infinity): string[] {
|
|
152
|
+
const { model, downloaded, total } = this.state;
|
|
153
|
+
const text = (value: string) => new Text(value, PANEL_PADDING, 0).render(width);
|
|
154
|
+
const ratio = total > 0 ? Math.max(0, Math.min(1, downloaded / total)) : 0;
|
|
155
|
+
const barWidth = Math.max(1, Math.min(36, width - PANEL_PADDING * 2 - 5));
|
|
156
|
+
const filled = Math.round(ratio * barWidth);
|
|
157
|
+
const bar = this.theme.fg("accent", "█".repeat(filled)) + this.theme.fg("dim", "─".repeat(barWidth - filled));
|
|
158
|
+
const title = this.theme.fg("accent", this.theme.bold(`Downloading ${model.name}`));
|
|
159
|
+
const progress = `${bar}${total > 0 ? this.theme.fg("dim", ` ${Math.floor(ratio * 100)}%`) : ""}`;
|
|
160
|
+
const stats = this.theme.fg("muted", this.stats ?? (total > 0
|
|
161
|
+
? `${formatBinarySize(downloaded)} / ${formatBinarySize(total)}` : "Preparing download…"));
|
|
162
|
+
const privacy = this.theme.fg("dim", "Models run locally — audio never leaves this machine.");
|
|
163
|
+
const hint = this.keys.hint("tui.select.cancel", "stop (keeps progress)");
|
|
164
|
+
const activity = this.spinner.render(width);
|
|
165
|
+
const lines = ["", ...text(title), ...activity, "", ...text(progress), "", ...text(stats), ...text(privacy), "", ...text(hint)];
|
|
166
|
+
if (lines.length <= maxRows) return lines;
|
|
167
|
+
// Small terminals keep the current operation and cancel key visible.
|
|
168
|
+
const compact = [title, activity[1]?.trim() ?? this.state.message, progress, stats, privacy]
|
|
169
|
+
.slice(0, Math.max(0, maxRows - 1));
|
|
170
|
+
return [...compact, this.keys.hint("tui.select.cancel", "stop")].map((line) => truncateToWidth(` ${line}`, width));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
invalidate(): void { this.spinner.invalidate(); }
|
|
174
|
+
dispose(): void { this.spinner.stop(); }
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Pi-native single-choice picker with optional fuzzy search and current-value marker. */
|
|
178
|
+
export class SingleSelectPicker<T extends string> extends Container implements Focusable {
|
|
179
|
+
private readonly search = new Input();
|
|
180
|
+
private readonly titleText: Text;
|
|
181
|
+
private readonly subtitleText: Text | undefined;
|
|
182
|
+
private readonly list = new Container();
|
|
183
|
+
private readonly detail = new Text("", PANEL_PADDING, 0);
|
|
184
|
+
private readonly footer = new Text("", PANEL_PADDING, 0);
|
|
185
|
+
private filtered: SingleSelectChoice<T>[];
|
|
186
|
+
private selectedIndex: number;
|
|
187
|
+
/** Rows the list window may use; shrinks to fit short terminals. */
|
|
188
|
+
private visibleLimit: number;
|
|
189
|
+
/** Width of the last render; row labels are laid out against it. */
|
|
190
|
+
private renderWidth = 80;
|
|
191
|
+
/** Lines of the longest description at the cached width. */
|
|
192
|
+
private detailReserve = 0;
|
|
193
|
+
private detailReserveWidth = -1;
|
|
194
|
+
private readonly hasDescriptions: boolean;
|
|
195
|
+
private _focused = false;
|
|
196
|
+
|
|
197
|
+
get focused(): boolean {
|
|
198
|
+
return this._focused;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
set focused(value: boolean) {
|
|
202
|
+
this._focused = value;
|
|
203
|
+
this.search.focused = value && Boolean(this.options.searchable);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private readonly keys: VoiceKeys;
|
|
207
|
+
|
|
208
|
+
constructor(
|
|
209
|
+
private readonly tui: TUI,
|
|
210
|
+
private readonly theme: UiTheme,
|
|
211
|
+
keybindings: KeybindingsManager,
|
|
212
|
+
private readonly choices: readonly SingleSelectChoice<T>[],
|
|
213
|
+
private readonly current: T | undefined,
|
|
214
|
+
private readonly options: {
|
|
215
|
+
title: string;
|
|
216
|
+
subtitle?: string;
|
|
217
|
+
searchable?: boolean;
|
|
218
|
+
maximumVisible?: number;
|
|
219
|
+
cancelLabel?: string;
|
|
220
|
+
/** Extra footer legend, appended after the ✓ current marker. */
|
|
221
|
+
legend?: string;
|
|
222
|
+
/** Custom row body after the cursor and ✓ markers; handles its own active styling. */
|
|
223
|
+
renderLabel?: (choice: SingleSelectChoice<T>, active: boolean, width: number) => string;
|
|
224
|
+
},
|
|
225
|
+
private readonly done: (value: T | undefined) => void,
|
|
226
|
+
) {
|
|
227
|
+
super();
|
|
228
|
+
this.keys = new VoiceKeys(keybindings);
|
|
229
|
+
this.visibleLimit = options.maximumVisible ?? 10;
|
|
230
|
+
this.hasDescriptions = choices.some((choice) => choice.description);
|
|
231
|
+
this.filtered = [...choices];
|
|
232
|
+
this.selectedIndex = Math.max(
|
|
233
|
+
0,
|
|
234
|
+
this.filtered.findIndex((choice) => choice.value === current),
|
|
235
|
+
);
|
|
236
|
+
|
|
237
|
+
this.titleText = new Text(
|
|
238
|
+
theme.fg("accent", theme.bold(options.title)),
|
|
239
|
+
PANEL_PADDING,
|
|
240
|
+
0,
|
|
241
|
+
);
|
|
242
|
+
this.subtitleText = options.subtitle
|
|
243
|
+
? new Text(theme.fg("muted", options.subtitle), PANEL_PADDING, 0)
|
|
244
|
+
: undefined;
|
|
245
|
+
|
|
246
|
+
this.addChild(panelBorder(theme));
|
|
247
|
+
this.addChild(new Spacer(1));
|
|
248
|
+
this.addChild(this.titleText);
|
|
249
|
+
if (this.subtitleText) this.addChild(this.subtitleText);
|
|
250
|
+
this.addChild(new Spacer(1));
|
|
251
|
+
if (options.searchable) {
|
|
252
|
+
const searchBox = new Box(LIST_PADDING, 0);
|
|
253
|
+
searchBox.addChild(this.search);
|
|
254
|
+
this.addChild(searchBox);
|
|
255
|
+
this.addChild(new Spacer(1));
|
|
256
|
+
}
|
|
257
|
+
this.addChild(this.list);
|
|
258
|
+
this.addChild(new Spacer(1));
|
|
259
|
+
if (this.hasDescriptions) {
|
|
260
|
+
this.addChild(this.detail);
|
|
261
|
+
this.addChild(new Spacer(1));
|
|
262
|
+
}
|
|
263
|
+
this.addChild(this.footer);
|
|
264
|
+
this.addChild(new Spacer(1));
|
|
265
|
+
this.addChild(panelBorder(theme));
|
|
266
|
+
this.refresh();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
private refresh(): void {
|
|
270
|
+
const query = this.search.getValue().trim();
|
|
271
|
+
this.filtered = query
|
|
272
|
+
? fuzzyFilter([...this.choices], query, (choice) =>
|
|
273
|
+
`${choice.label} ${choice.value} ${choice.description ?? ""}`,
|
|
274
|
+
)
|
|
275
|
+
: [...this.choices];
|
|
276
|
+
this.selectedIndex = Math.min(
|
|
277
|
+
this.selectedIndex,
|
|
278
|
+
Math.max(0, this.filtered.length - 1),
|
|
279
|
+
);
|
|
280
|
+
this.list.clear();
|
|
281
|
+
|
|
282
|
+
if (this.filtered.length === 0) {
|
|
283
|
+
this.list.addChild(
|
|
284
|
+
new Text(this.theme.fg("muted", " No matching choices"), LIST_PADDING, 0),
|
|
285
|
+
);
|
|
286
|
+
this.detail.setText("");
|
|
287
|
+
} else {
|
|
288
|
+
const maximum = this.visibleLimit;
|
|
289
|
+
const [start, end] = selectedWindow(
|
|
290
|
+
this.filtered,
|
|
291
|
+
this.selectedIndex,
|
|
292
|
+
maximum,
|
|
293
|
+
);
|
|
294
|
+
for (let index = start; index < end; index += 1) {
|
|
295
|
+
const choice = this.filtered[index]!;
|
|
296
|
+
const active = index === this.selectedIndex;
|
|
297
|
+
const prefix = active ? this.theme.fg("accent", "→ ") : " ";
|
|
298
|
+
const current = this.current === undefined
|
|
299
|
+
? ""
|
|
300
|
+
: `${selectionMarker(this.theme, choice.value === this.current)} `;
|
|
301
|
+
const label = this.options.renderLabel
|
|
302
|
+
? this.options.renderLabel(choice, active, this.renderWidth)
|
|
303
|
+
: active
|
|
304
|
+
? this.theme.fg("accent", choice.label)
|
|
305
|
+
: choice.label;
|
|
306
|
+
this.list.addChild(new Text(`${prefix}${current}${label}`, LIST_PADDING, 0));
|
|
307
|
+
}
|
|
308
|
+
this.detail.setText(
|
|
309
|
+
this.filtered[this.selectedIndex]?.description
|
|
310
|
+
? this.theme.fg("dim", this.filtered[this.selectedIndex]!.description!)
|
|
311
|
+
: "",
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// When the list is clipped the scroll position lives in this count, so
|
|
316
|
+
// the list itself never spends a row on an indicator.
|
|
317
|
+
const clipped = this.filtered.length > this.visibleLimit;
|
|
318
|
+
const shown = query
|
|
319
|
+
? clipped
|
|
320
|
+
? `${this.selectedIndex + 1}/${this.filtered.length} matching choices`
|
|
321
|
+
: `${this.filtered.length}/${this.choices.length} matching choices`
|
|
322
|
+
: clipped
|
|
323
|
+
? `${this.selectedIndex + 1}/${this.choices.length} choices`
|
|
324
|
+
: `${this.choices.length} choices`;
|
|
325
|
+
const legend = [
|
|
326
|
+
this.current === undefined
|
|
327
|
+
? undefined
|
|
328
|
+
: `${selectionMarker(this.theme, true)} ${this.theme.fg("dim", "current")}`,
|
|
329
|
+
this.options.legend,
|
|
330
|
+
]
|
|
331
|
+
.filter((value): value is string => Boolean(value))
|
|
332
|
+
.join(" ");
|
|
333
|
+
this.footer.setText(
|
|
334
|
+
`${this.theme.fg("dim", shown)}${legend ? ` ${legend}` : ""}\n${this.keys.navHint("navigate")} ${this.keys.hint("tui.select.confirm", "select")} ${this.keys.hint("tui.select.cancel", query ? "clear search" : (this.options.cancelLabel ?? "back"))}`,
|
|
335
|
+
);
|
|
336
|
+
this.tui.requestRender();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
override invalidate(): void {
|
|
340
|
+
super.invalidate();
|
|
341
|
+
this.titleText.setText(
|
|
342
|
+
this.theme.fg("accent", this.theme.bold(this.options.title)),
|
|
343
|
+
);
|
|
344
|
+
if (this.subtitleText && this.options.subtitle) {
|
|
345
|
+
this.subtitleText.setText(this.theme.fg("muted", this.options.subtitle));
|
|
346
|
+
}
|
|
347
|
+
this.refresh();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// The pane replaces the host editor and cannot scroll: when the terminal
|
|
351
|
+
// is short, shrink the list window so the title and footer stay on screen.
|
|
352
|
+
override render(width: number): string[] {
|
|
353
|
+
if (width !== this.renderWidth) {
|
|
354
|
+
this.renderWidth = width;
|
|
355
|
+
this.refresh();
|
|
356
|
+
}
|
|
357
|
+
const limit = paneListWindow(
|
|
358
|
+
this.tui,
|
|
359
|
+
super.render(width).length,
|
|
360
|
+
this.list.render(width).length,
|
|
361
|
+
this.detail.render(width).length,
|
|
362
|
+
this.maxDetailLines(width),
|
|
363
|
+
this.options.maximumVisible ?? 10,
|
|
364
|
+
);
|
|
365
|
+
if (limit !== undefined && limit !== this.visibleLimit) {
|
|
366
|
+
this.visibleLimit = limit;
|
|
367
|
+
this.refresh();
|
|
368
|
+
}
|
|
369
|
+
return super.render(width);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// Sizing against the longest description keeps the window height steady
|
|
373
|
+
// while the highlight moves across short and wrapping descriptions.
|
|
374
|
+
private maxDetailLines(width: number): number {
|
|
375
|
+
if (!this.hasDescriptions) return 0;
|
|
376
|
+
if (this.detailReserveWidth !== width) {
|
|
377
|
+
this.detailReserveWidth = width;
|
|
378
|
+
const probe = new Text("", PANEL_PADDING, 0);
|
|
379
|
+
this.detailReserve = Math.max(
|
|
380
|
+
...this.choices.map((choice) => {
|
|
381
|
+
if (!choice.description) return 1;
|
|
382
|
+
probe.setText(choice.description);
|
|
383
|
+
return probe.render(width).length;
|
|
384
|
+
}),
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
return this.detailReserve;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
handleInput(data: string): void {
|
|
391
|
+
if (this.keys.matches(data, "tui.select.up")) {
|
|
392
|
+
if (this.filtered.length > 0) {
|
|
393
|
+
this.selectedIndex =
|
|
394
|
+
this.selectedIndex === 0 ? this.filtered.length - 1 : this.selectedIndex - 1;
|
|
395
|
+
this.refresh();
|
|
396
|
+
}
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (this.keys.matches(data, "tui.select.down")) {
|
|
400
|
+
if (this.filtered.length > 0) {
|
|
401
|
+
this.selectedIndex =
|
|
402
|
+
this.selectedIndex === this.filtered.length - 1 ? 0 : this.selectedIndex + 1;
|
|
403
|
+
this.refresh();
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
if (this.keys.matches(data, "tui.select.confirm")) {
|
|
408
|
+
const selected = this.filtered[this.selectedIndex];
|
|
409
|
+
if (selected) this.done(selected.value);
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
if (this.keys.matches(data, "tui.select.cancel")) {
|
|
413
|
+
if (this.search.getValue()) {
|
|
414
|
+
this.search.setValue("");
|
|
415
|
+
this.selectedIndex = Math.max(
|
|
416
|
+
0,
|
|
417
|
+
this.choices.findIndex((choice) => choice.value === this.current),
|
|
418
|
+
);
|
|
419
|
+
this.refresh();
|
|
420
|
+
} else {
|
|
421
|
+
this.done(undefined);
|
|
422
|
+
}
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (this.options.searchable) {
|
|
427
|
+
this.search.handleInput(data);
|
|
428
|
+
this.selectedIndex = 0;
|
|
429
|
+
this.refresh();
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
3
|
+
import { STATUS_WIDGET_KEY } from "./shortcut-core.js";
|
|
4
|
+
|
|
5
|
+
type UiTheme = ExtensionContext["ui"]["theme"];
|
|
6
|
+
|
|
7
|
+
const WIDGET_KEY = STATUS_WIDGET_KEY;
|
|
8
|
+
/** Repaint interval shared by every surface that draws the meter. */
|
|
9
|
+
export const METER_UPDATE_MS = 50;
|
|
10
|
+
const LEVEL_GAIN = 36;
|
|
11
|
+
const DECAY = 0.65;
|
|
12
|
+
const BLOCKS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
|
|
13
|
+
const BAND_EDGES_HZ = [80, 160, 250, 400, 650, 1000, 1600, 2500, 4000, 6000] as const;
|
|
14
|
+
|
|
15
|
+
function floorPowerOfTwo(value: number): number {
|
|
16
|
+
return 2 ** Math.floor(Math.log2(value));
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function radix2Fft(re: Float64Array, im: Float64Array): void {
|
|
20
|
+
const n = re.length;
|
|
21
|
+
for (let index = 1, reversed = 0; index < n; index += 1) {
|
|
22
|
+
let bit = n >> 1;
|
|
23
|
+
while ((reversed & bit) !== 0) {
|
|
24
|
+
reversed ^= bit;
|
|
25
|
+
bit >>= 1;
|
|
26
|
+
}
|
|
27
|
+
reversed ^= bit;
|
|
28
|
+
if (index < reversed) {
|
|
29
|
+
const reTmp = re[index] ?? 0;
|
|
30
|
+
re[index] = re[reversed] ?? 0;
|
|
31
|
+
re[reversed] = reTmp;
|
|
32
|
+
const imTmp = im[index] ?? 0;
|
|
33
|
+
im[index] = im[reversed] ?? 0;
|
|
34
|
+
im[reversed] = imTmp;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (let length = 2; length <= n; length <<= 1) {
|
|
39
|
+
const angle = (-2 * Math.PI) / length;
|
|
40
|
+
const stepRe = Math.cos(angle);
|
|
41
|
+
const stepIm = Math.sin(angle);
|
|
42
|
+
const half = length >> 1;
|
|
43
|
+
for (let start = 0; start < n; start += length) {
|
|
44
|
+
let twiddleRe = 1;
|
|
45
|
+
let twiddleIm = 0;
|
|
46
|
+
for (let offset = 0; offset < half; offset += 1) {
|
|
47
|
+
const even = start + offset;
|
|
48
|
+
const odd = even + half;
|
|
49
|
+
const oddRe = (re[odd] ?? 0) * twiddleRe - (im[odd] ?? 0) * twiddleIm;
|
|
50
|
+
const oddIm = (re[odd] ?? 0) * twiddleIm + (im[odd] ?? 0) * twiddleRe;
|
|
51
|
+
const evenRe = re[even] ?? 0;
|
|
52
|
+
const evenIm = im[even] ?? 0;
|
|
53
|
+
re[even] = evenRe + oddRe;
|
|
54
|
+
im[even] = evenIm + oddIm;
|
|
55
|
+
re[odd] = evenRe - oddRe;
|
|
56
|
+
im[odd] = evenIm - oddIm;
|
|
57
|
+
const nextRe = twiddleRe * stepRe - twiddleIm * stepIm;
|
|
58
|
+
twiddleIm = twiddleRe * stepIm + twiddleIm * stepRe;
|
|
59
|
+
twiddleRe = nextRe;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function bandEnergies(frame: Int16Array, re: Float64Array, im: Float64Array): number[] {
|
|
66
|
+
const n = re.length;
|
|
67
|
+
if (n < 2) return BAND_EDGES_HZ.slice(0, -1).map(() => 0);
|
|
68
|
+
|
|
69
|
+
re.fill(0);
|
|
70
|
+
im.fill(0);
|
|
71
|
+
for (let index = 0; index < n; index += 1) {
|
|
72
|
+
const window = 0.5 - 0.5 * Math.cos((2 * Math.PI * index) / (n - 1));
|
|
73
|
+
re[index] = ((frame[index] ?? 0) / 32_768) * window;
|
|
74
|
+
}
|
|
75
|
+
radix2Fft(re, im);
|
|
76
|
+
|
|
77
|
+
const bands: number[] = [];
|
|
78
|
+
for (let band = 0; band < BAND_EDGES_HZ.length - 1; band += 1) {
|
|
79
|
+
const start = Math.max(1, Math.floor((BAND_EDGES_HZ[band]! * n) / CAPTURE_SAMPLE_RATE));
|
|
80
|
+
const end = Math.min(
|
|
81
|
+
n / 2,
|
|
82
|
+
Math.ceil((BAND_EDGES_HZ[band + 1]! * n) / CAPTURE_SAMPLE_RATE),
|
|
83
|
+
);
|
|
84
|
+
let peak = 0;
|
|
85
|
+
for (let bin = start; bin < end; bin += 1) {
|
|
86
|
+
peak = Math.max(peak, Math.hypot(re[bin] ?? 0, im[bin] ?? 0));
|
|
87
|
+
}
|
|
88
|
+
bands.push(peak / n);
|
|
89
|
+
}
|
|
90
|
+
return bands;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function blockForLevel(level: number): string {
|
|
94
|
+
const normalized = Math.min(1, Math.max(0, level) * LEVEL_GAIN);
|
|
95
|
+
const index = Math.min(
|
|
96
|
+
BLOCKS.length - 1,
|
|
97
|
+
Math.round(Math.sqrt(normalized) * (BLOCKS.length - 1)),
|
|
98
|
+
);
|
|
99
|
+
return BLOCKS[index] ?? "▁";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function formatElapsed(ms: number): string {
|
|
103
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
104
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
105
|
+
const seconds = totalSeconds % 60;
|
|
106
|
+
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Shared completion summary for regular dictation and the onboarding test. */
|
|
110
|
+
export function formatTranscriptionSummary(
|
|
111
|
+
audioSeconds: number,
|
|
112
|
+
transcribeSeconds: number,
|
|
113
|
+
): string {
|
|
114
|
+
return `Transcribed ${audioSeconds.toFixed(1)}s of audio in ${transcribeSeconds.toFixed(1)}s`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function showTranscribeStatus(
|
|
118
|
+
ctx: ExtensionContext,
|
|
119
|
+
text: string,
|
|
120
|
+
options?: { cancelKeys?: string },
|
|
121
|
+
): void {
|
|
122
|
+
if (!ctx.hasUI) return;
|
|
123
|
+
const theme = ctx.ui.theme;
|
|
124
|
+
const hint = options?.cancelKeys ? ` ${theme.fg("dim", `${options.cancelKeys} to cancel`)}` : "";
|
|
125
|
+
ctx.ui.setWidget(WIDGET_KEY, [`${theme.fg("muted", text)}${hint}`]);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function clearTranscribeWidget(ctx: ExtensionContext): void {
|
|
129
|
+
if (!ctx.hasUI) return;
|
|
130
|
+
ctx.ui.setWidget(WIDGET_KEY, undefined);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Ready line shown when setup finishes: a green check, then the body in the
|
|
135
|
+
* terminal's default foreground so it matches what the user types. The help
|
|
136
|
+
* command is accented and its description muted. The caller decides how long it stays.
|
|
137
|
+
*/
|
|
138
|
+
export function showReadyStatus(
|
|
139
|
+
ctx: ExtensionContext,
|
|
140
|
+
options: {
|
|
141
|
+
talk: string;
|
|
142
|
+
help: { command: string; description: string };
|
|
143
|
+
},
|
|
144
|
+
): void {
|
|
145
|
+
if (!ctx.hasUI) return;
|
|
146
|
+
const theme = ctx.ui.theme;
|
|
147
|
+
ctx.ui.setWidget(WIDGET_KEY, [
|
|
148
|
+
`${theme.fg("success", "✓")} Pi Voice ready · ${options.talk}`,
|
|
149
|
+
`${theme.fg("accent", options.help.command)} ${theme.fg("muted", options.help.description)}`,
|
|
150
|
+
]);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type MeterModelState = "loading" | "ready" | "failed";
|
|
154
|
+
|
|
155
|
+
/** Peak-hold band levels computed from capture frames. */
|
|
156
|
+
export class SpectrumAnalyzer {
|
|
157
|
+
readonly bands: number[] = Array.from({ length: BAND_EDGES_HZ.length - 1 }, () => 0);
|
|
158
|
+
private re: Float64Array | undefined;
|
|
159
|
+
private im: Float64Array | undefined;
|
|
160
|
+
|
|
161
|
+
reset(): void {
|
|
162
|
+
this.bands.fill(0);
|
|
163
|
+
this.re = undefined;
|
|
164
|
+
this.im = undefined;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
push(frame: Int16Array): void {
|
|
168
|
+
const n = floorPowerOfTwo(frame.length);
|
|
169
|
+
if (!this.re || !this.im || this.re.length !== n) {
|
|
170
|
+
this.re = new Float64Array(n);
|
|
171
|
+
this.im = new Float64Array(n);
|
|
172
|
+
}
|
|
173
|
+
const energies = bandEnergies(frame, this.re, this.im);
|
|
174
|
+
for (let index = 0; index < this.bands.length; index += 1) {
|
|
175
|
+
this.bands[index] = Math.max(energies[index] ?? 0, (this.bands[index] ?? 0) * DECAY);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The one-line recording meter: band blocks, elapsed time, model state, and
|
|
182
|
+
* an optional trailing hint. Every surface that shows a recording renders
|
|
183
|
+
* through here so the editor widget and the setup pane look the same.
|
|
184
|
+
*/
|
|
185
|
+
export function renderMeterLine(
|
|
186
|
+
theme: UiTheme,
|
|
187
|
+
options: {
|
|
188
|
+
bands: readonly number[];
|
|
189
|
+
elapsedMs: number;
|
|
190
|
+
modelState: MeterModelState;
|
|
191
|
+
actionHint?: string;
|
|
192
|
+
discardHint?: string;
|
|
193
|
+
},
|
|
194
|
+
): string {
|
|
195
|
+
const parts = [
|
|
196
|
+
theme.fg("accent", options.bands.map(blockForLevel).join("")),
|
|
197
|
+
theme.fg("muted", formatElapsed(options.elapsedMs)),
|
|
198
|
+
];
|
|
199
|
+
if (options.modelState === "loading") parts.push(theme.fg("dim", "loading model"));
|
|
200
|
+
if (options.modelState === "failed") parts.push(theme.fg("warning", "model load failed"));
|
|
201
|
+
if (options.actionHint) parts.push(theme.fg("muted", options.actionHint));
|
|
202
|
+
if (options.discardHint) parts.push(theme.fg("dim", options.discardHint));
|
|
203
|
+
return parts.join(" ");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Left-to-right FFT meter shown above the editor while recording. */
|
|
207
|
+
export class RecordingMeter {
|
|
208
|
+
private readonly analyzer = new SpectrumAnalyzer();
|
|
209
|
+
private startedAt = 0;
|
|
210
|
+
private nextPaintAt = 0;
|
|
211
|
+
private lastLine: string | undefined;
|
|
212
|
+
private ctx: ExtensionContext | undefined;
|
|
213
|
+
private modelState: MeterModelState = "loading";
|
|
214
|
+
private hints: { action: string; discard: string } | undefined;
|
|
215
|
+
|
|
216
|
+
start(
|
|
217
|
+
ctx: ExtensionContext,
|
|
218
|
+
hints: { action: string; discard: string },
|
|
219
|
+
): void {
|
|
220
|
+
if (!ctx.hasUI) return;
|
|
221
|
+
this.ctx = ctx;
|
|
222
|
+
this.hints = hints;
|
|
223
|
+
this.startedAt = Date.now();
|
|
224
|
+
this.analyzer.reset();
|
|
225
|
+
this.nextPaintAt = 0;
|
|
226
|
+
this.lastLine = undefined;
|
|
227
|
+
this.modelState = "loading";
|
|
228
|
+
this.paint();
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
setModelState(state: MeterModelState): void {
|
|
232
|
+
this.modelState = state;
|
|
233
|
+
this.paint();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
push(frame: Int16Array): void {
|
|
237
|
+
if (!this.ctx) return;
|
|
238
|
+
this.analyzer.push(frame);
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
if (now < this.nextPaintAt) return;
|
|
241
|
+
this.nextPaintAt = now + METER_UPDATE_MS;
|
|
242
|
+
this.paint();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
stop(options?: { clearWidget?: boolean }): void {
|
|
246
|
+
// Callers transitioning to another status on the same slot pass
|
|
247
|
+
// clearWidget: false so the widget area never collapses between states.
|
|
248
|
+
if (options?.clearWidget !== false) this.ctx?.ui.setWidget(WIDGET_KEY, undefined);
|
|
249
|
+
this.ctx = undefined;
|
|
250
|
+
this.lastLine = undefined;
|
|
251
|
+
this.hints = undefined;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private paint(): void {
|
|
255
|
+
const ctx = this.ctx;
|
|
256
|
+
if (!ctx) return;
|
|
257
|
+
|
|
258
|
+
const line = renderMeterLine(ctx.ui.theme, {
|
|
259
|
+
bands: this.analyzer.bands,
|
|
260
|
+
elapsedMs: Date.now() - this.startedAt,
|
|
261
|
+
modelState: this.modelState,
|
|
262
|
+
actionHint: this.hints?.action,
|
|
263
|
+
discardHint: this.hints?.discard,
|
|
264
|
+
});
|
|
265
|
+
if (line === this.lastLine) return;
|
|
266
|
+
this.lastLine = line;
|
|
267
|
+
ctx.ui.setWidget(WIDGET_KEY, [line]);
|
|
268
|
+
}
|
|
269
|
+
}
|