@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,85 @@
1
+ export const DEFAULT_SHORTCUT = "ctrl+alt+z";
2
+
3
+ /** Widget slot shared by the startup status and the recording meter. */
4
+ export const STATUS_WIDGET_KEY = "pi-voice-meter";
5
+
6
+ const MODIFIER_ORDER = ["ctrl", "shift", "alt", "super"] as const;
7
+ const SPECIAL_KEYS = new Set([
8
+ "escape",
9
+ "enter",
10
+ "tab",
11
+ "space",
12
+ "backspace",
13
+ "delete",
14
+ "insert",
15
+ "home",
16
+ "end",
17
+ "pageup",
18
+ "pagedown",
19
+ "up",
20
+ "down",
21
+ "left",
22
+ "right",
23
+ ]);
24
+ const SYMBOL_KEYS = new Set(["`", "-", "=", "[", "]", "\\", ";", "'", ",", ".", "/"]);
25
+
26
+ export function normalizeShortcut(input: string): string | undefined {
27
+ const parts = input
28
+ .trim()
29
+ .toLowerCase()
30
+ .replaceAll("option", "alt")
31
+ .replaceAll("command", "super")
32
+ .replaceAll("cmd", "super")
33
+ .split("+")
34
+ .map((part) => part.trim())
35
+ .filter(Boolean);
36
+ if (parts.length === 0) return undefined;
37
+
38
+ const key = parts.at(-1)!;
39
+ const modifiers = parts.slice(0, -1);
40
+ if (new Set(modifiers).size !== modifiers.length) return undefined;
41
+ if (
42
+ modifiers.some(
43
+ (modifier) =>
44
+ !MODIFIER_ORDER.includes(modifier as (typeof MODIFIER_ORDER)[number]),
45
+ )
46
+ ) {
47
+ return undefined;
48
+ }
49
+
50
+ const isLetter = /^[a-z]$/.test(key);
51
+ const isDigit = /^\d$/.test(key);
52
+ const isFunction = /^f(?:[1-9]|1[0-2])$/.test(key);
53
+ const isSpecial = SPECIAL_KEYS.has(key);
54
+ const isSymbol = SYMBOL_KEYS.has(key);
55
+ if (!isLetter && !isDigit && !isFunction && !isSpecial && !isSymbol) return undefined;
56
+
57
+ // Bare printable keys would make normal editor input impossible.
58
+ if (modifiers.length === 0 && !isFunction) return undefined;
59
+ // Shift-only printable keys are also normal text input.
60
+ if (
61
+ modifiers.length === 1 &&
62
+ modifiers[0] === "shift" &&
63
+ (isLetter || isDigit || isSymbol)
64
+ ) {
65
+ return undefined;
66
+ }
67
+
68
+ const ordered = MODIFIER_ORDER.filter((modifier) => modifiers.includes(modifier));
69
+ return [
70
+ ...ordered,
71
+ key === "pageup" ? "pageUp" : key === "pagedown" ? "pageDown" : key,
72
+ ].join("+");
73
+ }
74
+
75
+ export function displayShortcut(shortcut: string): string {
76
+ return shortcut
77
+ .split("+")
78
+ .map((part) => {
79
+ if (process.platform === "darwin" && part.toLowerCase() === "alt") return "Option";
80
+ return part.length === 1
81
+ ? part.toUpperCase()
82
+ : `${part[0]?.toUpperCase()}${part.slice(1)}`;
83
+ })
84
+ .join("+");
85
+ }
@@ -0,0 +1,167 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ Container,
4
+ parseKey,
5
+ Spacer,
6
+ Text,
7
+ truncateToWidth,
8
+ type Component,
9
+ type KeybindingsManager,
10
+ type TUI,
11
+ } from "@earendil-works/pi-tui";
12
+ import {
13
+ DEFAULT_SHORTCUT,
14
+ displayShortcut,
15
+ normalizeShortcut,
16
+ } from "./shortcut-core.js";
17
+ import { VoiceKeys } from "./keybindings.js";
18
+ import { panelBorder } from "./ui-components.js";
19
+
20
+ type UiTheme = ExtensionContext["ui"]["theme"];
21
+ type Conflict = { description: string };
22
+ type Phase =
23
+ | { kind: "waiting" }
24
+ | { kind: "preview"; normalized: string; conflicts: Conflict[] }
25
+ | { kind: "error"; message: string };
26
+
27
+ // Pi exposes its resolved built-in bindings here, but not shortcuts owned by
28
+ // other extensions. Pi reports those collisions when it reloads.
29
+ function findConflicts(kb: KeybindingsManager, shortcut: string): Conflict[] {
30
+ const resolved = kb.getResolvedBindings();
31
+ const result: Conflict[] = [];
32
+ for (const [id, keys] of Object.entries(resolved)) {
33
+ if (keys === undefined) continue;
34
+ const keyList: string[] = Array.isArray(keys) ? keys : [keys];
35
+ if (keyList.includes(shortcut)) {
36
+ const def = kb.getDefinition(id as never);
37
+ if (def?.description) result.push({ description: def.description });
38
+ }
39
+ }
40
+ return result;
41
+ }
42
+
43
+ export function createShortcutPicker(
44
+ tui: TUI,
45
+ theme: UiTheme,
46
+ keybindings: KeybindingsManager,
47
+ current: string,
48
+ done: (shortcut: string | undefined) => void,
49
+ ): Component {
50
+ const keys = new VoiceKeys(keybindings);
51
+ let phase: Phase = { kind: "waiting" };
52
+ const container = new Container();
53
+
54
+ function rebuild(): void {
55
+ container.clear();
56
+ container.addChild(panelBorder(theme));
57
+ container.addChild(new Spacer(1));
58
+ container.addChild(
59
+ new Text(theme.fg("accent", theme.bold("Record keyboard shortcut")), 1, 0),
60
+ );
61
+ container.addChild(
62
+ new Text(theme.fg("muted", `Current: ${displayShortcut(current)}`), 1, 0),
63
+ );
64
+ container.addChild(new Spacer(1));
65
+
66
+ if (phase.kind === "preview") {
67
+ container.addChild(
68
+ new Text(theme.fg("accent", displayShortcut(phase.normalized)), 1, 0),
69
+ );
70
+ if (phase.conflicts.length > 0) {
71
+ container.addChild(
72
+ new Text(
73
+ theme.fg(
74
+ "warning",
75
+ `Built-in conflict: ${phase.conflicts.map((conflict) => conflict.description).join(", ")}. Pi may override or reject this shortcut.`,
76
+ ),
77
+ 1,
78
+ 0,
79
+ ),
80
+ );
81
+ }
82
+ container.addChild(new Spacer(1));
83
+ container.addChild(
84
+ new Text(
85
+ `${keys.hint("tui.select.confirm", "keep")} ${keys.hint("voice.shortcut.useDefault", "default")} ${theme.fg("dim", "press another shortcut to replace")} ${keys.hint("tui.select.cancel", "back")}`,
86
+ 1,
87
+ 0,
88
+ ),
89
+ );
90
+ } else {
91
+ if (phase.kind === "error") {
92
+ container.addChild(new Text(theme.fg("error", phase.message), 1, 0));
93
+ container.addChild(new Spacer(1));
94
+ }
95
+ container.addChild(
96
+ new Text(theme.fg("muted", "Press a key combination…"), 1, 0),
97
+ );
98
+ container.addChild(new Spacer(1));
99
+ container.addChild(
100
+ new Text(
101
+ `${keys.hint("voice.shortcut.useDefault", `default (${displayShortcut(DEFAULT_SHORTCUT)})`)} ${keys.hint("tui.select.cancel", "back")}`,
102
+ 1,
103
+ 0,
104
+ ),
105
+ );
106
+ }
107
+
108
+ container.addChild(new Spacer(1));
109
+ container.addChild(panelBorder(theme));
110
+ }
111
+
112
+ rebuild();
113
+
114
+ return {
115
+ wantsKeyRelease: false,
116
+
117
+ render(width: number): string[] {
118
+ return container.render(width).map((line) => truncateToWidth(line, width, ""));
119
+ },
120
+
121
+ handleInput(data: string): void {
122
+ if (keys.matches(data, "tui.select.cancel")) {
123
+ done(undefined);
124
+ return;
125
+ }
126
+
127
+ if (keys.matches(data, "tui.select.confirm")) {
128
+ if (phase.kind === "preview") done(phase.normalized);
129
+ return;
130
+ }
131
+
132
+ if (keys.matches(data, "voice.shortcut.useDefault")) {
133
+ phase = {
134
+ kind: "preview",
135
+ normalized: DEFAULT_SHORTCUT,
136
+ conflicts: findConflicts(keybindings, DEFAULT_SHORTCUT),
137
+ };
138
+ rebuild();
139
+ tui.requestRender();
140
+ return;
141
+ }
142
+
143
+ const parsed = parseKey(data);
144
+ if (!parsed) return;
145
+
146
+ const normalized = normalizeShortcut(parsed);
147
+ if (!normalized) {
148
+ phase = {
149
+ kind: "error",
150
+ message: `${displayShortcut(parsed)} is not valid. Use a modifier such as Ctrl or Alt, or a function key.`,
151
+ };
152
+ } else {
153
+ phase = {
154
+ kind: "preview",
155
+ normalized,
156
+ conflicts: findConflicts(keybindings, normalized),
157
+ };
158
+ }
159
+ rebuild();
160
+ tui.requestRender();
161
+ },
162
+
163
+ invalidate(): void {
164
+ rebuild();
165
+ },
166
+ };
167
+ }
@@ -0,0 +1,24 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { DEFAULT_SHORTCUT, normalizeShortcut } from "./shortcut-core.js";
3
+ import { legacySettingsPath, settingsPath } from "./settings-path.js";
4
+
5
+ function isObject(value: unknown): value is Record<string, unknown> {
6
+ return typeof value === "object" && value !== null && !Array.isArray(value);
7
+ }
8
+
9
+ /** Read only the setting Pi needs while synchronously registering the extension. */
10
+ export function readShortcutForRegistration(): string {
11
+ for (const path of [settingsPath(), legacySettingsPath()]) {
12
+ try {
13
+ const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
14
+ if (!isObject(parsed) || parsed.version !== 1 || typeof parsed.shortcut !== "string") {
15
+ return DEFAULT_SHORTCUT;
16
+ }
17
+ return normalizeShortcut(parsed.shortcut) ?? DEFAULT_SHORTCUT;
18
+ } catch (error) {
19
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
20
+ return DEFAULT_SHORTCUT;
21
+ }
22
+ }
23
+ return DEFAULT_SHORTCUT;
24
+ }
@@ -0,0 +1,52 @@
1
+ import { Text, truncateToWidth } from "@earendil-works/pi-tui";
2
+ import type { VoiceKeys } from "./keybindings.js";
3
+
4
+ /** A bounded read-only view. Clipping never changes the underlying transcript. */
5
+ export class TranscriptPreview {
6
+ private content = "";
7
+ private text = new Text("", 1, 0);
8
+ private offset = 0;
9
+ private lineCount = 1;
10
+ private pageSize = 1;
11
+
12
+ constructor(private readonly keys: VoiceKeys) {}
13
+
14
+ setText(content: string): void {
15
+ if (this.content === content) return;
16
+ this.content = content;
17
+ this.text.setText(content);
18
+ this.offset = 0;
19
+ }
20
+
21
+ invalidate(): void { this.text.invalidate(); }
22
+
23
+ render(width: number, height: number, muted: (text: string) => string): string[] {
24
+ const rows = Math.max(1, height);
25
+ const lines = this.content ? this.text.render(width) : [" ".repeat(width)];
26
+ this.lineCount = lines.length;
27
+ const clipped = lines.length > rows;
28
+ this.pageSize = Math.max(1, rows - (clipped && rows > 1 ? 1 : 0));
29
+ this.offset = Math.max(0, Math.min(this.offset, lines.length - this.pageSize));
30
+ const visible = lines.slice(this.offset, this.offset + this.pageSize);
31
+ if (clipped && rows > 1) {
32
+ visible.push(truncateToWidth(
33
+ muted(` ${this.offset + 1}–${this.offset + visible.length} / ${lines.length} lines · ${this.keys.navLabel()} / ${this.keys.keyText(["tui.select.pageUp", "tui.select.pageDown"])} scroll`), width,
34
+ ));
35
+ }
36
+ return visible;
37
+ }
38
+
39
+ handleInput(data: string): boolean {
40
+ if (this.lineCount <= this.pageSize) return false;
41
+ let offset = this.offset;
42
+ if (this.keys.matches(data, "tui.select.up")) offset--;
43
+ else if (this.keys.matches(data, "tui.select.down")) offset++;
44
+ else if (this.keys.matches(data, "tui.select.pageUp")) offset -= this.pageSize;
45
+ else if (this.keys.matches(data, "tui.select.pageDown")) offset += this.pageSize;
46
+ else if (this.keys.matches(data, "voice.scroll.top")) offset = 0;
47
+ else if (this.keys.matches(data, "voice.scroll.bottom")) offset = this.lineCount - this.pageSize;
48
+ else return false;
49
+ this.offset = Math.max(0, Math.min(offset, this.lineCount - this.pageSize));
50
+ return true;
51
+ }
52
+ }