@tychenjiajun/pi-voice 0.0.0-stage → 0.2.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +134 -2
  3. package/catalog/recommendations.json +710 -0
  4. package/index.ts +1 -0
  5. package/package.json +68 -4
  6. package/src/async-limiter.ts +77 -0
  7. package/src/audio-constants.ts +1 -0
  8. package/src/audio-errors.ts +14 -0
  9. package/src/audio.ts +109 -0
  10. package/src/catalog.generated.ts +1305 -0
  11. package/src/catalog.ts +89 -0
  12. package/src/chinese.ts +52 -0
  13. package/src/deferred.ts +30 -0
  14. package/src/dictation-controller.ts +315 -0
  15. package/src/file-audio.ts +164 -0
  16. package/src/file-transcription.ts +212 -0
  17. package/src/index.ts +135 -0
  18. package/src/install-migration.ts +47 -0
  19. package/src/keybindings.ts +119 -0
  20. package/src/languages.ts +22 -0
  21. package/src/log.ts +171 -0
  22. package/src/microphone-picker.ts +95 -0
  23. package/src/model-activation.ts +74 -0
  24. package/src/model-cells.ts +218 -0
  25. package/src/model-picker.ts +1026 -0
  26. package/src/model-ratings-help.md +41 -0
  27. package/src/model-ratings-help.ts +146 -0
  28. package/src/model-selection-controller.ts +185 -0
  29. package/src/models.ts +263 -0
  30. package/src/onboarding.ts +319 -0
  31. package/src/openai-asr.ts +247 -0
  32. package/src/pcm-chunker.ts +46 -0
  33. package/src/recommendation-picker.ts +512 -0
  34. package/src/recommendations.ts +423 -0
  35. package/src/runtime.ts +524 -0
  36. package/src/settings-menu.ts +638 -0
  37. package/src/settings-path.ts +19 -0
  38. package/src/settings.ts +273 -0
  39. package/src/shortcut-core.ts +85 -0
  40. package/src/shortcuts.ts +167 -0
  41. package/src/startup-shortcut.ts +24 -0
  42. package/src/transcript-preview.ts +52 -0
  43. package/src/transcription-service.ts +570 -0
  44. package/src/transcription.ts +203 -0
  45. package/src/try-it.ts +318 -0
  46. package/src/ui-components.ts +432 -0
  47. package/src/visualizer.ts +271 -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 transcription backend (local model, or the configured remote ASR endpoint with the local model as fallback). 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",
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,135 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { existsSync } from "node:fs";
7
+ import { registerFileTranscriptionTool } from "./file-transcription.js";
8
+ import {
9
+ claimLegacyGitNotice,
10
+ findLegacyGitInstall,
11
+ legacyGitMigrationMessage,
12
+ } from "./install-migration.js";
13
+ import type { PiVoiceRuntime } from "./runtime.js";
14
+ import { displayShortcut, STATUS_WIDGET_KEY } from "./shortcut-core.js";
15
+ import { initLog, log, logStep, markKeyPress, watchEventLoop } from "./log.js";
16
+ import { legacySettingsPath, logPath, settingsPath } from "./settings-path.js";
17
+ import { readShortcutForRegistration } from "./startup-shortcut.js";
18
+
19
+ // Pi awaits extension module evaluation before continuing startup. Keep this
20
+ // entry point registration-only and load feature implementations on first use.
21
+ export default function piVoice(pi: ExtensionAPI): void {
22
+ // Opens nothing yet: the first line written creates the file.
23
+ initLog({ path: logPath(), level: process.env.PI_VOICE_DEBUG === "1" ? "debug" : "info" });
24
+ const registeredShortcut = readShortcutForRegistration();
25
+ let runtimePromise: Promise<PiVoiceRuntime> | undefined;
26
+ let shuttingDown = false;
27
+
28
+ function loadRuntime(): Promise<PiVoiceRuntime> {
29
+ if (shuttingDown) return Promise.reject(new Error("Pi Voice is shutting down"));
30
+ if (runtimePromise) return runtimePromise;
31
+
32
+ const loadStarted = performance.now();
33
+ logStep("loading runtime");
34
+ const loading = import("./runtime.js").then(({ createPiVoiceRuntime }) => {
35
+ log.debug(`runtime loaded in ${Math.round(performance.now() - loadStarted)} ms`);
36
+ return createPiVoiceRuntime(pi, registeredShortcut);
37
+ });
38
+ runtimePromise = loading;
39
+ void loading.catch(() => {
40
+ if (runtimePromise === loading) runtimePromise = undefined;
41
+ });
42
+ return loading;
43
+ }
44
+
45
+ pi.on("session_start", async (_event, ctx) => {
46
+ let showedMigrationNotice = false;
47
+ if (ctx.mode === "tui") {
48
+ const legacyInstall = findLegacyGitInstall(pi.getCommands());
49
+ if (legacyInstall && await claimLegacyGitNotice()) {
50
+ ctx.ui.notify(legacyGitMigrationMessage(legacyInstall), "warning");
51
+ showedMigrationNotice = true;
52
+ }
53
+ }
54
+
55
+ if (
56
+ !showedMigrationNotice &&
57
+ !existsSync(settingsPath()) &&
58
+ !existsSync(legacySettingsPath())
59
+ ) {
60
+ ctx.ui.notify(
61
+ `Pi Voice installed · press ${displayShortcut(registeredShortcut)} or run /voice-settings to set up`,
62
+ "info",
63
+ );
64
+ }
65
+ });
66
+
67
+ const fileTranscription = registerFileTranscriptionTool(pi, {
68
+ getSettings: async () => (await loadRuntime()).requireConfiguredSettingsForTool(),
69
+ getService: async () => (await loadRuntime()).service,
70
+ });
71
+
72
+ async function toggleCapture(ctx: ExtensionContext): Promise<void> {
73
+ markKeyPress();
74
+ const release = watchEventLoop();
75
+ // The first press pays deferred module loading before the runtime can
76
+ // show anything; paint feedback synchronously. Later presses reach the
77
+ // memoized runtime in a microtask and it paints its own status.
78
+ if (!runtimePromise && ctx.hasUI) {
79
+ ctx.ui.setWidget(STATUS_WIDGET_KEY, [
80
+ ctx.ui.theme.fg("muted", "Starting microphone…"),
81
+ ]);
82
+ }
83
+ try {
84
+ await (await loadRuntime()).toggleCapture(ctx);
85
+ } catch (error) {
86
+ if (ctx.hasUI) ctx.ui.setWidget(STATUS_WIDGET_KEY, undefined);
87
+ throw error;
88
+ } finally {
89
+ release();
90
+ }
91
+ }
92
+
93
+ pi.registerShortcut(
94
+ registeredShortcut as Parameters<ExtensionAPI["registerShortcut"]>[0],
95
+ {
96
+ description: "Toggle microphone transcription",
97
+ handler: toggleCapture,
98
+ },
99
+ );
100
+
101
+ pi.registerCommand("voice", {
102
+ description: "Start or stop microphone transcription",
103
+ handler: (_args, ctx) => toggleCapture(ctx),
104
+ });
105
+
106
+ const openSettings = async (
107
+ _args: string,
108
+ ctx: ExtensionCommandContext,
109
+ ): Promise<void> => (await loadRuntime()).showSettings(ctx);
110
+
111
+ pi.registerCommand("voice-settings", {
112
+ description: "Open Pi Voice settings",
113
+ handler: openSettings,
114
+ });
115
+ pi.registerCommand("transcribe", {
116
+ description: "Open Pi Voice settings (alias for /voice-settings)",
117
+ handler: openSettings,
118
+ });
119
+
120
+ if (process.env.PI_VOICE_DEBUG === "1") {
121
+ pi.registerCommand("voice-onboarding", {
122
+ description: "Replay Pi Voice onboarding (debug)",
123
+ handler: async (_args, ctx) => (await loadRuntime()).replayOnboarding(ctx),
124
+ });
125
+ }
126
+
127
+ pi.on("session_shutdown", async (_event, ctx) => {
128
+ shuttingDown = true;
129
+ await fileTranscription.shutdown().catch(() => undefined);
130
+ const loading = runtimePromise;
131
+ if (!loading) return;
132
+ const runtime = await loading.catch(() => undefined);
133
+ await runtime?.shutdown(ctx).catch(() => undefined);
134
+ });
135
+ }
@@ -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:@tychenjiajun/pi-voice`,
46
+ ].join("\n");
47
+ }
@@ -0,0 +1,119 @@
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
+ "voice.dictation.stop": { defaultKeys: "space", description: "Stop recording and transcribe" },
37
+ } as const satisfies KeybindingDefinitions;
38
+
39
+ export type VoiceKeybinding = keyof typeof VOICE_KEYBINDINGS;
40
+ /** A pi `tui.*` id or one of ours; callers never need to know which. */
41
+ export type KeyAction = Keybinding | VoiceKeybinding;
42
+
43
+ export function isVoiceKeybinding(id: string): id is VoiceKeybinding {
44
+ return Object.hasOwn(VOICE_KEYBINDINGS, id);
45
+ }
46
+
47
+ // Our ids are not declaration-merged into pi's `Keybindings`, so pi's manager
48
+ // would silently accept them and never match. Route by table membership instead.
49
+ const asTuiId = (id: VoiceKeybinding): Keybinding => id as unknown as Keybinding;
50
+
51
+ /**
52
+ * Before the Pi Voice rename our ids were `transcribe.*`. A user binding under
53
+ * the old id still applies unless the matching `voice.*` id is also set.
54
+ */
55
+ function withLegacyBindings(user: KeybindingsConfig): KeybindingsConfig {
56
+ const bindings = { ...user };
57
+ for (const id of Object.keys(VOICE_KEYBINDINGS)) {
58
+ const legacy = user[id.replace(/^voice\./, "transcribe.")];
59
+ if (bindings[id] === undefined && legacy !== undefined) bindings[id] = legacy;
60
+ }
61
+ return bindings;
62
+ }
63
+
64
+ function matchesLocalKey(data: string, key: KeyId): boolean {
65
+ if (matchesKey(data, key)) return true;
66
+ // Single printable keys also accept the shifted or caps-lock form and Kitty's
67
+ // CSI-u report of the typed character, such as `?` arriving as shift+/.
68
+ if (key.length !== 1) return false;
69
+ const typed = data.length === 1 ? data : decodeKittyPrintable(data);
70
+ return typed?.toLowerCase() === key;
71
+ }
72
+
73
+ /** The user's dictation shortcut is a runtime setting, not a table entry. */
74
+ export function matchesShortcut(data: string, shortcut: string): boolean {
75
+ return matchesKey(data, shortcut as KeyId);
76
+ }
77
+
78
+ /**
79
+ * One matcher and one hint formatter over pi's manager and our table.
80
+ * Built per pane from the manager pi injects, so the user's bindings for both
81
+ * apply. Construct it fresh rather than caching: pi's /reload swaps the user
82
+ * bindings on its manager and the local copy is a snapshot.
83
+ */
84
+ export class VoiceKeys {
85
+ private readonly local: KeybindingsManager;
86
+
87
+ constructor(readonly host: KeybindingsManager) {
88
+ this.local = new KeybindingsManager(VOICE_KEYBINDINGS, withLegacyBindings(host.getUserBindings()));
89
+ }
90
+
91
+ matches(data: string, id: KeyAction): boolean {
92
+ if (!isVoiceKeybinding(id)) return this.host.matches(data, id);
93
+ return this.local.getKeys(asTuiId(id)).some((key) => matchesLocalKey(data, key));
94
+ }
95
+
96
+ keys(id: KeyAction): KeyId[] {
97
+ return isVoiceKeybinding(id) ? this.local.getKeys(asTuiId(id)) : this.host.getKeys(id);
98
+ }
99
+
100
+ keyText(id: KeyAction | readonly KeyAction[]): string {
101
+ const ids = Array.isArray(id) ? (id as readonly KeyAction[]) : [id as KeyAction];
102
+ return ids.flatMap((each) => this.keys(each)).join("/");
103
+ }
104
+
105
+ hint(id: KeyAction | readonly KeyAction[], description: string): string {
106
+ return rawKeyHint(this.keyText(id), description);
107
+ }
108
+
109
+ navLabel(): string {
110
+ const up = this.keys("tui.select.up");
111
+ const down = this.keys("tui.select.down");
112
+ const arrows = up.includes("up") && down.includes("down");
113
+ return arrows ? "↑↓" : `${up.join("/")}/${down.join("/")}`;
114
+ }
115
+
116
+ navHint(description: string): string {
117
+ return rawKeyHint(this.navLabel(), description);
118
+ }
119
+ }
@@ -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
+ }