@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,212 @@
1
+ import {
2
+ DEFAULT_MAX_BYTES,
3
+ DEFAULT_MAX_LINES,
4
+ formatSize,
5
+ truncateHead,
6
+ type ExtensionAPI,
7
+ type TruncationResult,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import { mkdtemp, stat, writeFile } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { basename, join, resolve } from "node:path";
12
+ import { Type } from "typebox";
13
+ import { AsyncLimiter } from "./async-limiter.js";
14
+ import type { TranscribeSettings } from "./settings.js";
15
+ import type { TranscriptionService } from "./transcription-service.js";
16
+
17
+ const CONTEXT_LINE_LENGTH = 1_000;
18
+ const MAX_FILE_OPERATIONS = 2;
19
+ const MAX_FILE_DECODERS = 1;
20
+
21
+ type FileTranscriptionDetails = {
22
+ inputPath: string;
23
+ modelId: string;
24
+ seconds: number;
25
+ truncation?: TruncationResult;
26
+ fullTranscriptPath?: string;
27
+ };
28
+
29
+ type FileTranscriptionOptions = {
30
+ getSettings: () => Promise<TranscribeSettings>;
31
+ getService: () => Promise<TranscriptionService>;
32
+ };
33
+
34
+ export type FileTranscriptionController = {
35
+ shutdown(): Promise<void>;
36
+ };
37
+
38
+ function normalizeToolPath(path: string): string {
39
+ return path.startsWith("@") ? path.slice(1) : path;
40
+ }
41
+
42
+ /** Wrap long model output lines so Pi's line-aware truncation can retain useful text. */
43
+ function wrapLongLines(text: string): string {
44
+ const output: string[] = [];
45
+ for (const originalLine of text.split("\n")) {
46
+ let line = originalLine;
47
+ while (line.length > CONTEXT_LINE_LENGTH) {
48
+ let split = line.lastIndexOf(" ", CONTEXT_LINE_LENGTH);
49
+ if (split <= 0) split = CONTEXT_LINE_LENGTH;
50
+ output.push(line.slice(0, split));
51
+ line = line.slice(split).trimStart();
52
+ }
53
+ output.push(line);
54
+ }
55
+ return output.join("\n");
56
+ }
57
+
58
+ async function saveFullTranscript(text: string): Promise<string> {
59
+ const directory = await mkdtemp(join(tmpdir(), "pi-voice-"));
60
+ const path = join(directory, "transcript.txt");
61
+ await writeFile(path, `${text}\n`, "utf8");
62
+ return path;
63
+ }
64
+
65
+ export function registerFileTranscriptionTool(
66
+ pi: ExtensionAPI,
67
+ options: FileTranscriptionOptions,
68
+ ): FileTranscriptionController {
69
+ let shuttingDown = false;
70
+ const operations = new Set<Promise<unknown>>();
71
+ const fileOperations = new AsyncLimiter(MAX_FILE_OPERATIONS);
72
+ const fileDecoders = new AsyncLimiter(MAX_FILE_DECODERS);
73
+ const shutdownController = new AbortController();
74
+
75
+ function track<T>(operation: Promise<T>): Promise<T> {
76
+ const tracked = operation.finally(() => {
77
+ operations.delete(tracked);
78
+ });
79
+ operations.add(tracked);
80
+ return tracked;
81
+ }
82
+
83
+ pi.registerTool({
84
+ name: "transcribe_file",
85
+ label: "Transcribe File",
86
+ description: `Transcribe speech from a local audio or video file using Pi Voice's configured local model. Requires the ffmpeg executable on PATH (or PI_VOICE_FFMPEG_PATH). Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; a complete transcript is saved to a temporary file when needed.`,
87
+ promptSnippet: "Transcribe speech from local audio or video files with a local model",
88
+ promptGuidelines: [
89
+ "Use transcribe_file when speech in a local audio or video file needs to be read, analyzed, or transcribed.",
90
+ "transcribe_file automatically queues model work, with interactive dictation taking priority over queued files.",
91
+ "If transcribe_file reports that FFmpeg is unavailable, explain the installation guidance and ask the user before running a system package-manager command.",
92
+ ],
93
+ parameters: Type.Object({
94
+ path: Type.String({
95
+ description: "Local media file path, absolute or relative to the current working directory",
96
+ }),
97
+ }),
98
+
99
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
100
+ if (shuttingDown) throw new Error("Pi Voice is shutting down");
101
+ const operationSignal = signal
102
+ ? AbortSignal.any([signal, shutdownController.signal])
103
+ : shutdownController.signal;
104
+
105
+ return track(
106
+ (async () => {
107
+ operationSignal.throwIfAborted();
108
+ const input = normalizeToolPath(params.path.trim());
109
+ if (!input) throw new Error("A media file path is required");
110
+ const inputPath = resolve(ctx.cwd, input);
111
+ const inputStat = await stat(inputPath).catch((error: NodeJS.ErrnoException) => {
112
+ if (error.code === "ENOENT") throw new Error(`Media file not found: ${inputPath}`);
113
+ throw error;
114
+ });
115
+ if (!inputStat.isFile()) throw new Error(`Media path is not a regular file: ${inputPath}`);
116
+ operationSignal.throwIfAborted();
117
+
118
+ const configured = await options.getSettings();
119
+ const service = await options.getService();
120
+ if (fileOperations.saturated) {
121
+ onUpdate?.({
122
+ content: [{ type: "text", text: "Waiting for file transcription capacity…" }],
123
+ details: { inputPath, modelId: configured.model.id, seconds: 0 },
124
+ });
125
+ }
126
+
127
+ return fileOperations.run(async () => {
128
+ if (fileDecoders.saturated) {
129
+ onUpdate?.({
130
+ content: [{ type: "text", text: `Waiting to decode ${basename(inputPath)}…` }],
131
+ details: { inputPath, modelId: configured.model.id, seconds: 0 },
132
+ });
133
+ }
134
+ const audio = await fileDecoders.run(async () => {
135
+ onUpdate?.({
136
+ content: [{ type: "text", text: `Decoding ${basename(inputPath)} with FFmpeg…` }],
137
+ details: { inputPath, modelId: configured.model.id, seconds: 0 },
138
+ });
139
+ const { decodeFileAudio } = await import("./file-audio.js");
140
+ return decodeFileAudio(inputPath, operationSignal);
141
+ }, operationSignal);
142
+
143
+ onUpdate?.({
144
+ content: [
145
+ {
146
+ type: "text",
147
+ text: `Queued ${audio.seconds.toFixed(1)}s from ${basename(inputPath)} for local transcription…`,
148
+ },
149
+ ],
150
+ details: {
151
+ inputPath,
152
+ modelId: configured.model.id,
153
+ seconds: audio.seconds,
154
+ },
155
+ });
156
+ const transcript = await service.transcribeFile(
157
+ configured,
158
+ audio.pcm,
159
+ operationSignal,
160
+ );
161
+ const details: FileTranscriptionDetails = {
162
+ inputPath,
163
+ modelId: configured.model.id,
164
+ seconds: audio.seconds,
165
+ };
166
+
167
+ if (!transcript) {
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text" as const,
172
+ text: `No speech detected in ${audio.seconds.toFixed(1)}s of audio from ${inputPath}`,
173
+ },
174
+ ],
175
+ details,
176
+ };
177
+ }
178
+
179
+ const contextTranscript =
180
+ Buffer.byteLength(transcript, "utf8") > DEFAULT_MAX_BYTES
181
+ ? wrapLongLines(transcript)
182
+ : transcript;
183
+ const truncation = truncateHead(contextTranscript, {
184
+ maxLines: DEFAULT_MAX_LINES,
185
+ maxBytes: DEFAULT_MAX_BYTES,
186
+ });
187
+ let resultText = truncation.content;
188
+ if (truncation.truncated) {
189
+ const fullTranscriptPath = await saveFullTranscript(transcript);
190
+ details.truncation = truncation;
191
+ details.fullTranscriptPath = fullTranscriptPath;
192
+ resultText += `\n\n[Transcript truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)}). Full transcript saved to: ${fullTranscriptPath}]`;
193
+ }
194
+
195
+ return {
196
+ content: [{ type: "text" as const, text: resultText }],
197
+ details,
198
+ };
199
+ }, operationSignal);
200
+ })(),
201
+ );
202
+ },
203
+ });
204
+
205
+ return {
206
+ async shutdown() {
207
+ shuttingDown = true;
208
+ shutdownController.abort(new Error("Pi Voice is shutting down"));
209
+ await Promise.allSettled([...operations]);
210
+ },
211
+ };
212
+ }
package/src/index.ts ADDED
@@ -0,0 +1,114 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { existsSync } from "node:fs";
3
+ import { registerFileTranscriptionTool } from "./file-transcription.js";
4
+ import {
5
+ claimLegacyGitNotice,
6
+ findLegacyGitInstall,
7
+ legacyGitMigrationMessage,
8
+ } from "./install-migration.js";
9
+ import type { PiVoiceRuntime } from "./runtime.js";
10
+ import { displayShortcut, STATUS_WIDGET_KEY } from "./shortcut-core.js";
11
+ import { legacySettingsPath, settingsPath } from "./settings-path.js";
12
+ import { readShortcutForRegistration } from "./startup-shortcut.js";
13
+
14
+ // Pi awaits extension module evaluation before continuing startup. Keep this
15
+ // entry point registration-only and load feature implementations on first use.
16
+ export default function piVoice(pi: ExtensionAPI): void {
17
+ const registeredShortcut = readShortcutForRegistration();
18
+ let runtimePromise: Promise<PiVoiceRuntime> | undefined;
19
+ let shuttingDown = false;
20
+
21
+ function loadRuntime(): Promise<PiVoiceRuntime> {
22
+ if (shuttingDown) return Promise.reject(new Error("Pi Voice is shutting down"));
23
+ if (runtimePromise) return runtimePromise;
24
+
25
+ const loading = import("./runtime.js").then(({ createPiVoiceRuntime }) =>
26
+ createPiVoiceRuntime(pi, registeredShortcut),
27
+ );
28
+ runtimePromise = loading;
29
+ void loading.catch(() => {
30
+ if (runtimePromise === loading) runtimePromise = undefined;
31
+ });
32
+ return loading;
33
+ }
34
+
35
+ pi.on("session_start", async (_event, ctx) => {
36
+ let showedMigrationNotice = false;
37
+ if (ctx.mode === "tui") {
38
+ const legacyInstall = findLegacyGitInstall(pi.getCommands());
39
+ if (legacyInstall && await claimLegacyGitNotice()) {
40
+ ctx.ui.notify(legacyGitMigrationMessage(legacyInstall), "warning");
41
+ showedMigrationNotice = true;
42
+ }
43
+ }
44
+
45
+ if (
46
+ !showedMigrationNotice &&
47
+ !existsSync(settingsPath()) &&
48
+ !existsSync(legacySettingsPath())
49
+ ) {
50
+ ctx.ui.notify(
51
+ `Pi Voice installed · press ${displayShortcut(registeredShortcut)} or run /voice-settings to set up`,
52
+ "info",
53
+ );
54
+ }
55
+ });
56
+
57
+ const fileTranscription = registerFileTranscriptionTool(pi, {
58
+ getSettings: async () => (await loadRuntime()).requireConfiguredSettingsForTool(),
59
+ getService: async () => (await loadRuntime()).service,
60
+ });
61
+
62
+ pi.registerShortcut(
63
+ registeredShortcut as Parameters<ExtensionAPI["registerShortcut"]>[0],
64
+ {
65
+ description: "Toggle microphone transcription",
66
+ handler: async (ctx) => {
67
+ // The first press pays deferred module loading before the runtime can
68
+ // show anything; paint feedback synchronously. Later presses reach the
69
+ // memoized runtime in a microtask and it paints its own status.
70
+ if (!runtimePromise && ctx.hasUI) {
71
+ ctx.ui.setWidget(STATUS_WIDGET_KEY, [
72
+ ctx.ui.theme.fg("muted", "Starting microphone…"),
73
+ ]);
74
+ }
75
+ try {
76
+ await (await loadRuntime()).toggleCapture(ctx);
77
+ } catch (error) {
78
+ if (ctx.hasUI) ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined);
79
+ throw error;
80
+ }
81
+ },
82
+ },
83
+ );
84
+
85
+ const openSettings = async (
86
+ _args: string,
87
+ ctx: ExtensionCommandContext,
88
+ ): Promise<void> => (await loadRuntime()).showSettings(ctx);
89
+
90
+ pi.registerCommand("voice-settings", {
91
+ description: "Open Pi Voice settings",
92
+ handler: openSettings,
93
+ });
94
+ pi.registerCommand("transcribe", {
95
+ description: "Open Pi Voice settings (alias for /voice-settings)",
96
+ handler: openSettings,
97
+ });
98
+
99
+ if (process.env.PI_VOICE_DEBUG === "1") {
100
+ pi.registerCommand("voice-onboarding", {
101
+ description: "Replay Pi Voice onboarding (debug)",
102
+ handler: async (_args, ctx) => (await loadRuntime()).replayOnboarding(ctx),
103
+ });
104
+ }
105
+
106
+ pi.on("session_shutdown", async (_event, ctx) => {
107
+ shuttingDown = true;
108
+ await fileTranscription.shutdown().catch(() => undefined);
109
+ const loading = runtimePromise;
110
+ if (!loading) return;
111
+ const runtime = await loading.catch(() => undefined);
112
+ await runtime?.shutdown(ctx).catch(() => undefined);
113
+ });
114
+ }
@@ -0,0 +1,47 @@
1
+ import type { SlashCommandInfo, SourceInfo } from "@earendil-works/pi-coding-agent";
2
+ import { writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ const NOTICE_MARKER_FILENAME = ".pi-voice-legacy-git-notice-v1";
7
+ const LEGACY_GIT_REPOSITORY =
8
+ /github\.com(?::|\/)earendil-works\/pi-transcribe(?:\.git)?(?:@.*)?\/?$/i;
9
+
10
+ /** Find a loaded package that still uses the pre-rename Git repository source. */
11
+ export function findLegacyGitInstall(
12
+ commands: readonly SlashCommandInfo[],
13
+ ): SourceInfo | undefined {
14
+ return commands.find(
15
+ (command) =>
16
+ command.source === "extension" &&
17
+ command.sourceInfo.origin === "package" &&
18
+ LEGACY_GIT_REPOSITORY.test(command.sourceInfo.source),
19
+ )?.sourceInfo;
20
+ }
21
+
22
+ /** Atomically claim the one-time notice across concurrently running Pi processes. */
23
+ export async function claimLegacyGitNotice(): Promise<boolean> {
24
+ try {
25
+ await writeFile(join(getAgentDir(), NOTICE_MARKER_FILENAME), "shown\n", {
26
+ encoding: "utf8",
27
+ flag: "wx",
28
+ mode: 0o600,
29
+ });
30
+ return true;
31
+ } catch (error) {
32
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
33
+ // A read-only config directory should not prevent the user from seeing the
34
+ // migration guidance, even though it means the notice may appear again.
35
+ return true;
36
+ }
37
+ }
38
+
39
+ export function legacyGitMigrationMessage(sourceInfo: SourceInfo): string {
40
+ const local = sourceInfo.scope === "project" ? " -l" : "";
41
+ return [
42
+ "Pi Voice is still installed from the old pi-transcribe Git repository.",
43
+ "For stable npm updates, replace it with the renamed package:",
44
+ `pi remove${local} ${sourceInfo.source}`,
45
+ `pi install${local} npm:@earendil-works/pi-voice`,
46
+ ].join("\n");
47
+ }
@@ -0,0 +1,118 @@
1
+ import { rawKeyHint } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ decodeKittyPrintable,
4
+ KeybindingsManager,
5
+ matchesKey,
6
+ type Keybinding,
7
+ type KeybindingDefinitions,
8
+ type KeybindingsConfig,
9
+ type KeyId,
10
+ } from "@earendil-works/pi-tui";
11
+
12
+ /**
13
+ * Every key Pi Voice binds itself, in pi's definition shape. Users
14
+ * override these with the same ids in pi's keybindings.json; pi keeps entries
15
+ * it does not recognise and hands them back through `getUserBindings()`.
16
+ *
17
+ * Navigation, confirm, and cancel come from pi's `tui.*` ids. Tab is ours: it
18
+ * continues the language step and browses from Your models. The model pickers
19
+ * in between swallow the Continue key so the setup sequence never reads Tab as
20
+ * "go back".
21
+ */
22
+ export const VOICE_KEYBINDINGS = {
23
+ "voice.languages.toggle": { defaultKeys: "space", description: "Toggle the highlighted language" },
24
+ "voice.languages.continue": { defaultKeys: "tab", description: "Continue with the selected languages" },
25
+ "voice.languages.change": { defaultKeys: "ctrl+l", description: "Change spoken languages" },
26
+ "voice.recommendations.browseAll": { defaultKeys: "o", description: "Browse all models" },
27
+ "voice.models.ratingsHelp": { defaultKeys: "?", description: "Open the rating guide" },
28
+ "voice.ratingsHelp.close": { defaultKeys: "q", description: "Close the rating guide" },
29
+ "voice.scroll.top": { defaultKeys: "home", description: "Scroll to the top" },
30
+ "voice.scroll.bottom": { defaultKeys: "end", description: "Scroll to the bottom" },
31
+ "voice.tryIt.shortcut": { defaultKeys: "s", description: "Change the dictation shortcut" },
32
+ "voice.tryIt.microphone": { defaultKeys: "m", description: "Change the microphone" },
33
+ "voice.tryIt.model": { defaultKeys: "c", description: "Change the model" },
34
+ "voice.shortcut.useDefault": { defaultKeys: "d", description: "Use the default shortcut" },
35
+ "voice.dictation.cancel": { defaultKeys: "escape", description: "Cancel recording or transcription" },
36
+ } as const satisfies KeybindingDefinitions;
37
+
38
+ export type VoiceKeybinding = keyof typeof VOICE_KEYBINDINGS;
39
+ /** A pi `tui.*` id or one of ours; callers never need to know which. */
40
+ export type KeyAction = Keybinding | VoiceKeybinding;
41
+
42
+ export function isVoiceKeybinding(id: string): id is VoiceKeybinding {
43
+ return Object.hasOwn(VOICE_KEYBINDINGS, id);
44
+ }
45
+
46
+ // Our ids are not declaration-merged into pi's `Keybindings`, so pi's manager
47
+ // would silently accept them and never match. Route by table membership instead.
48
+ const asTuiId = (id: VoiceKeybinding): Keybinding => id as unknown as Keybinding;
49
+
50
+ /**
51
+ * Before the Pi Voice rename our ids were `transcribe.*`. A user binding under
52
+ * the old id still applies unless the matching `voice.*` id is also set.
53
+ */
54
+ function withLegacyBindings(user: KeybindingsConfig): KeybindingsConfig {
55
+ const bindings = { ...user };
56
+ for (const id of Object.keys(VOICE_KEYBINDINGS)) {
57
+ const legacy = user[id.replace(/^voice\./, "transcribe.")];
58
+ if (bindings[id] === undefined && legacy !== undefined) bindings[id] = legacy;
59
+ }
60
+ return bindings;
61
+ }
62
+
63
+ function matchesLocalKey(data: string, key: KeyId): boolean {
64
+ if (matchesKey(data, key)) return true;
65
+ // Single printable keys also accept the shifted or caps-lock form and Kitty's
66
+ // CSI-u report of the typed character, such as `?` arriving as shift+/.
67
+ if (key.length !== 1) return false;
68
+ const typed = data.length === 1 ? data : decodeKittyPrintable(data);
69
+ return typed?.toLowerCase() === key;
70
+ }
71
+
72
+ /** The user's dictation shortcut is a runtime setting, not a table entry. */
73
+ export function matchesShortcut(data: string, shortcut: string): boolean {
74
+ return matchesKey(data, shortcut as KeyId);
75
+ }
76
+
77
+ /**
78
+ * One matcher and one hint formatter over pi's manager and our table.
79
+ * Built per pane from the manager pi injects, so the user's bindings for both
80
+ * apply. Construct it fresh rather than caching: pi's /reload swaps the user
81
+ * bindings on its manager and the local copy is a snapshot.
82
+ */
83
+ export class VoiceKeys {
84
+ private readonly local: KeybindingsManager;
85
+
86
+ constructor(readonly host: KeybindingsManager) {
87
+ this.local = new KeybindingsManager(VOICE_KEYBINDINGS, withLegacyBindings(host.getUserBindings()));
88
+ }
89
+
90
+ matches(data: string, id: KeyAction): boolean {
91
+ if (!isVoiceKeybinding(id)) return this.host.matches(data, id);
92
+ return this.local.getKeys(asTuiId(id)).some((key) => matchesLocalKey(data, key));
93
+ }
94
+
95
+ keys(id: KeyAction): KeyId[] {
96
+ return isVoiceKeybinding(id) ? this.local.getKeys(asTuiId(id)) : this.host.getKeys(id);
97
+ }
98
+
99
+ keyText(id: KeyAction | readonly KeyAction[]): string {
100
+ const ids = Array.isArray(id) ? (id as readonly KeyAction[]) : [id as KeyAction];
101
+ return ids.flatMap((each) => this.keys(each)).join("/");
102
+ }
103
+
104
+ hint(id: KeyAction | readonly KeyAction[], description: string): string {
105
+ return rawKeyHint(this.keyText(id), description);
106
+ }
107
+
108
+ navLabel(): string {
109
+ const up = this.keys("tui.select.up");
110
+ const down = this.keys("tui.select.down");
111
+ const arrows = up.includes("up") && down.includes("down");
112
+ return arrows ? "↑↓" : `${up.join("/")}/${down.join("/")}`;
113
+ }
114
+
115
+ navHint(description: string): string {
116
+ return rawKeyHint(this.navLabel(), description);
117
+ }
118
+ }
@@ -0,0 +1,22 @@
1
+ /** A base code, without changing the identity or the model's exact code. */
2
+ export function canonicalLanguage(language: string): string {
3
+ return language.trim().toLowerCase().split("-", 1)[0] ?? "";
4
+ }
5
+
6
+ // Model cards and benchmarks use different ISO codes for these languages.
7
+ // This identity is for matching, never a code to send directly to a backend.
8
+ const LANGUAGE_ALIASES: Readonly<Record<string, string>> = { tl: "fil", no: "nb" };
9
+
10
+ export function languageIdentity(language: string): string {
11
+ const base = canonicalLanguage(language);
12
+ return LANGUAGE_ALIASES[base] ?? base;
13
+ }
14
+
15
+ /** Return a model's exact code, preserving an explicitly chosen regional variant. */
16
+ export function resolveModelLanguage(
17
+ model: { readonly languages: readonly string[] },
18
+ language: string,
19
+ ): string | undefined {
20
+ const exact = model.languages.find((code) => code.toLowerCase() === language.trim().toLowerCase());
21
+ return exact ?? model.languages.find((code) => languageIdentity(code) === languageIdentity(language));
22
+ }
@@ -0,0 +1,99 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { getAvailableMicrophones, testMicrophonePermission } from "./audio.js";
3
+ import type { MicrophoneSetting } from "./settings.js";
4
+ import { SingleSelectPicker, type SingleSelectChoice } from "./ui-components.js";
5
+
6
+ export type MicrophonePermission = Awaited<ReturnType<typeof testMicrophonePermission>>;
7
+
8
+ export function microphoneSummary(microphone: MicrophoneSetting): string {
9
+ if (microphone.type === "system-default") return "System default";
10
+ const duplicate = microphone.occurrence > 0 ? ` · device ${microphone.occurrence + 1}` : "";
11
+ return `${microphone.name}${duplicate}`;
12
+ }
13
+
14
+ export function microphonesEqual(left: MicrophoneSetting, right: MicrophoneSetting): boolean {
15
+ return (
16
+ left.type === right.type &&
17
+ (left.type === "system-default" ||
18
+ (right.type === "device" &&
19
+ left.name === right.name &&
20
+ left.occurrence === right.occurrence))
21
+ );
22
+ }
23
+
24
+ function microphoneChoices(
25
+ devices: readonly string[],
26
+ current: MicrophoneSetting,
27
+ ): {
28
+ choices: SingleSelectChoice<string>[];
29
+ currentValue: string;
30
+ byValue: Map<string, MicrophoneSetting>;
31
+ } {
32
+ const totals = new Map<string, number>();
33
+ for (const name of devices) totals.set(name, (totals.get(name) ?? 0) + 1);
34
+ const seen = new Map<string, number>();
35
+ const byValue = new Map<string, MicrophoneSetting>();
36
+ byValue.set("system-default", { type: "system-default" });
37
+ const choices: SingleSelectChoice<string>[] = [
38
+ {
39
+ value: "system-default",
40
+ label: "System default",
41
+ description: "Follow the input device selected by the operating system",
42
+ },
43
+ ];
44
+ let currentValue = "system-default";
45
+ for (const [index, name] of devices.entries()) {
46
+ const occurrence = seen.get(name) ?? 0;
47
+ seen.set(name, occurrence + 1);
48
+ const microphone: MicrophoneSetting = { type: "device", name, occurrence };
49
+ const value = `device-${index}`;
50
+ const label = (totals.get(name) ?? 0) > 1 ? `${name} · device ${occurrence + 1}` : name;
51
+ choices.push({ value, label });
52
+ byValue.set(value, microphone);
53
+ if (microphonesEqual(current, microphone)) currentValue = value;
54
+ }
55
+ return { choices, currentValue, byValue };
56
+ }
57
+
58
+ export function microphonePermissionSummary(result: MicrophonePermission): string {
59
+ if (result.status === "granted") return "Microphone: ✓ Access granted";
60
+ if (result.status === "denied") return "Microphone: ✗ Access denied";
61
+ if (result.status === "not-determined") {
62
+ return "Microphone: ⚠ Not yet requested — first recording will prompt for access";
63
+ }
64
+ return `Microphone: ⚠ ${result.message}`;
65
+ }
66
+
67
+ export async function chooseMicrophone(
68
+ ctx: ExtensionContext,
69
+ current: MicrophoneSetting,
70
+ permission: MicrophonePermission,
71
+ ): Promise<MicrophoneSetting | undefined> {
72
+ let devices: string[] = [];
73
+ try {
74
+ devices = getAvailableMicrophones();
75
+ } catch (error) {
76
+ ctx.ui.notify(
77
+ `Could not list microphones: ${error instanceof Error ? error.message : String(error)}`,
78
+ "error",
79
+ );
80
+ }
81
+ const { choices, currentValue, byValue } = microphoneChoices(devices, current);
82
+ const selected = await ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
83
+ new SingleSelectPicker(
84
+ tui,
85
+ theme,
86
+ keybindings,
87
+ choices,
88
+ currentValue,
89
+ {
90
+ title: "Choose microphone input",
91
+ subtitle: microphonePermissionSummary(permission),
92
+ searchable: choices.length > 8,
93
+ cancelLabel: "back",
94
+ },
95
+ done,
96
+ ),
97
+ );
98
+ return selected ? byValue.get(selected) : undefined;
99
+ }
@@ -0,0 +1,74 @@
1
+ import type { CatalogModel } from "./catalog.js";
2
+ import { downloadCatalogModel, findCachedCatalogModel, type CachedCatalogModel } from "./models.js";
3
+ import { writeSettings, type TranscribeSettings } from "./settings.js";
4
+
5
+ export type CatalogModelActivation = (
6
+ model: CatalogModel,
7
+ options: {
8
+ cached: CachedCatalogModel | undefined;
9
+ signal: AbortSignal;
10
+ onProgress: (progress: { downloaded: number; total: number }) => void;
11
+ },
12
+ ) => Promise<{ path: string }>;
13
+
14
+ /**
15
+ * The download-then-save pipeline behind the model picker, shared by
16
+ * onboarding and the settings menu. Back-to-back selections can outrun their
17
+ * settings writes; commits run in selection order, so the file always ends on
18
+ * the user's last choice.
19
+ */
20
+ export function createModelActivation(options: {
21
+ buildSettings: (model: CatalogModel, path: string) => TranscribeSettings;
22
+ onCommitted: (settings: TranscribeSettings) => void;
23
+ }): {
24
+ activate: CatalogModelActivation;
25
+ /** Resolves once every commit enqueued so far has landed (or failed). */
26
+ waitForCommits: () => Promise<void>;
27
+ } {
28
+ let commitQueue: Promise<void> = Promise.resolve();
29
+
30
+ const activate: CatalogModelActivation = async (
31
+ model,
32
+ { cached, signal, onProgress },
33
+ ) => {
34
+ let path: string;
35
+ if (cached) {
36
+ // The picker listed the cache when it opened; re-check so settings
37
+ // never point at a file that has since been evicted. Integrity is
38
+ // covered by the download-time hash and the size check here.
39
+ const stillCached = findCachedCatalogModel(model);
40
+ if (!stillCached) {
41
+ throw new Error(
42
+ "the downloaded file is missing; select the model again to re-download it",
43
+ );
44
+ }
45
+ path = stillCached.path;
46
+ } else {
47
+ path = await downloadCatalogModel(model, { signal, onProgress });
48
+ }
49
+
50
+ const commit = commitQueue.then(async () => {
51
+ // Skip the write when this selection was superseded or its download
52
+ // was cancelled while the commit sat in the queue.
53
+ signal.throwIfAborted();
54
+ let settings: TranscribeSettings;
55
+ try {
56
+ settings = options.buildSettings(model, path);
57
+ await writeSettings(settings);
58
+ } catch (error) {
59
+ throw new Error(
60
+ `Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
61
+ );
62
+ }
63
+ options.onCommitted(settings);
64
+ });
65
+ commitQueue = commit.then(
66
+ () => undefined,
67
+ () => undefined,
68
+ );
69
+ await commit;
70
+ return { path };
71
+ };
72
+
73
+ return { activate, waitForCommits: () => commitQueue };
74
+ }