@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,638 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { Box, Container, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { microphonePermission, type Permission } from "./audio.js";
7
+ import { displayLanguage, getCatalogModel } from "./catalog.js";
8
+ import { chineseOutputSummary, isChineseLanguage } from "./chinese.js";
9
+ import { VoiceKeys } from "./keybindings.js";
10
+ import {
11
+ chooseLanguages,
12
+ createTranscriptionLanguagePicker,
13
+ transcriptionLanguageSummary,
14
+ } from "./model-picker.js";
15
+ import {
16
+ chooseMicrophone,
17
+ microphonesEqual,
18
+ microphoneSummary,
19
+ } from "./microphone-picker.js";
20
+ import { runModelSelection } from "./onboarding.js";
21
+ import {
22
+ writeSettings,
23
+ type BackendSettings,
24
+ type ChineseOutput,
25
+ type MicrophoneSetting,
26
+ type TranscribeSettings,
27
+ type TranscriptionLanguage,
28
+ } from "./settings.js";
29
+ import { displayShortcut } from "./shortcut-core.js";
30
+ import { createShortcutPicker } from "./shortcuts.js";
31
+ import {
32
+ LIST_PADDING,
33
+ padToWidth,
34
+ panelBorder,
35
+ SingleSelectPicker,
36
+ type SingleSelectChoice,
37
+ } from "./ui-components.js";
38
+
39
+ const MACOS_MICROPHONE_SETTINGS_URL =
40
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone";
41
+ const SETTINGS_LABEL_WIDTH = 25;
42
+ // Text padding (2), cursor gutter (2), label column, and its gap (2).
43
+ const SETTINGS_ROW_OVERHEAD = SETTINGS_LABEL_WIDTH + 6;
44
+
45
+ type SettingsAction =
46
+ | "preferred-languages"
47
+ | "model"
48
+ | "backend"
49
+ | "transcription-language"
50
+ | "chinese-output"
51
+ | "microphone"
52
+ | "shortcut";
53
+
54
+ type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
55
+ summary: string;
56
+ /** Render the summary in the error color. */
57
+ alert?: boolean;
58
+ };
59
+
60
+ function preferredLanguagesSummary(languages: readonly string[]): string {
61
+ const names = languages.map(displayLanguage);
62
+ const visible = names.slice(0, 3).join(", ");
63
+ return names.length > 3 ? `${visible} +${names.length - 3}` : visible;
64
+ }
65
+
66
+ function languagesEqual(left: readonly string[], right: readonly string[]): boolean {
67
+ return left.join("\0") === right.join("\0");
68
+ }
69
+
70
+ async function saveUpdatedSettings(
71
+ ctx: ExtensionContext,
72
+ configured: TranscribeSettings,
73
+ updated: TranscribeSettings,
74
+ successMessage?: string,
75
+ ): Promise<boolean> {
76
+ try {
77
+ await writeSettings(updated);
78
+ Object.assign(configured, updated);
79
+ if (successMessage) ctx.ui.notify(successMessage, "info");
80
+ return true;
81
+ } catch (error) {
82
+ ctx.ui.notify(
83
+ `Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
84
+ "error",
85
+ );
86
+ return false;
87
+ }
88
+ }
89
+
90
+ function backendSummary(backend: BackendSettings): string {
91
+ return backend.type === "openai-asr" ? `Remote · ${backend.model}` : "Local model";
92
+ }
93
+
94
+ function settingsHomeChoices(
95
+ configured: TranscribeSettings,
96
+ permission: Permission,
97
+ ): SettingsHomeChoice[] {
98
+ const model = getCatalogModel(configured.model.id)!;
99
+ const choices: SettingsHomeChoice[] = [
100
+ {
101
+ value: "preferred-languages",
102
+ label: "Preferred languages",
103
+ summary: preferredLanguagesSummary(configured.preferredLanguages),
104
+ description: "Languages you speak, used to rank and recommend transcription models",
105
+ },
106
+ {
107
+ value: "model",
108
+ label: "Model",
109
+ summary: model.name,
110
+ description: "Switch between downloaded models, or download a new one",
111
+ },
112
+ {
113
+ value: "backend",
114
+ label: "Transcription backend",
115
+ summary: backendSummary(configured.backend),
116
+ description:
117
+ "Local model, or an OpenAI-compatible remote ASR endpoint (local model stays as fallback)",
118
+ },
119
+ {
120
+ value: "transcription-language",
121
+ label: "Transcription language",
122
+ summary: transcriptionLanguageSummary(configured.transcriptionLanguage, model),
123
+ description: "Language expected in recordings, or automatic detection when supported",
124
+ },
125
+ ];
126
+
127
+ if (
128
+ isChineseLanguage(configured.transcriptionLanguage) ||
129
+ configured.preferredLanguages.some(isChineseLanguage)
130
+ ) {
131
+ choices.push({
132
+ value: "chinese-output",
133
+ label: "Chinese output",
134
+ summary: chineseOutputSummary(configured.chineseOutput),
135
+ description: "Character style used for Chinese transcripts",
136
+ });
137
+ }
138
+
139
+ choices.push(
140
+ {
141
+ value: "microphone",
142
+ label: "Microphone",
143
+ // A permission problem replaces the device summary so it is visible
144
+ // from the home screen; selecting the row then goes straight to the
145
+ // System Settings fix.
146
+ ...(permission === "denied" && process.platform === "darwin"
147
+ ? {
148
+ summary: "✗ Access denied",
149
+ alert: true,
150
+ description: "Grant microphone access to the terminal application running Pi",
151
+ }
152
+ : {
153
+ summary: microphoneSummary(configured.microphone),
154
+ description: "Input device used for dictation",
155
+ }),
156
+ },
157
+ {
158
+ value: "shortcut",
159
+ label: "Shortcut",
160
+ summary: displayShortcut(configured.shortcut),
161
+ description: "Terminal shortcut that starts and stops microphone dictation",
162
+ },
163
+ );
164
+
165
+ return choices;
166
+ }
167
+
168
+ async function showSettingsHome(
169
+ ctx: ExtensionContext,
170
+ configured: TranscribeSettings,
171
+ permission: Permission,
172
+ ): Promise<SettingsAction | undefined> {
173
+ return ctx.ui.custom<SettingsAction | undefined>((tui, theme, keybindings, done) => {
174
+ const choices = settingsHomeChoices(configured, permission);
175
+ const rows = new Map(choices.map((choice) => [choice.value, choice]));
176
+ return new SingleSelectPicker(
177
+ tui,
178
+ theme,
179
+ keybindings,
180
+ choices,
181
+ undefined,
182
+ {
183
+ title: "Pi Voice settings",
184
+ cancelLabel: "close",
185
+ renderLabel: (choice, active, width) => {
186
+ const row = rows.get(choice.value);
187
+ const labelText = padToWidth(choice.label, SETTINGS_LABEL_WIDTH);
188
+ const label = active ? theme.fg("accent", labelText) : labelText;
189
+ // Long summaries (language lists, microphone names) truncate so
190
+ // they never wrap the row and break the column layout.
191
+ const summary = truncateToWidth(
192
+ row?.summary ?? "",
193
+ Math.max(12, width - SETTINGS_ROW_OVERHEAD),
194
+ "…",
195
+ );
196
+ const value = row?.alert
197
+ ? theme.fg("error", summary)
198
+ : theme.fg("dim", summary);
199
+ return `${label} ${value}`;
200
+ },
201
+ },
202
+ done,
203
+ );
204
+ });
205
+ }
206
+
207
+ async function chooseChineseOutput(
208
+ ctx: ExtensionContext,
209
+ current: ChineseOutput,
210
+ ): Promise<ChineseOutput | undefined> {
211
+ const choices: SingleSelectChoice<ChineseOutput>[] = [
212
+ {
213
+ value: "simplified",
214
+ label: "Simplified",
215
+ description: "Convert Chinese transcripts to simplified characters",
216
+ },
217
+ {
218
+ value: "traditional-taiwan",
219
+ label: "Traditional (Taiwan)",
220
+ description: "Use traditional characters and Taiwan conventions",
221
+ },
222
+ {
223
+ value: "traditional-hong-kong",
224
+ label: "Traditional (Hong Kong)",
225
+ description: "Use traditional characters and Hong Kong conventions",
226
+ },
227
+ ];
228
+ return ctx.ui.custom<ChineseOutput | undefined>((tui, theme, keybindings, done) =>
229
+ new SingleSelectPicker(
230
+ tui,
231
+ theme,
232
+ keybindings,
233
+ choices,
234
+ current,
235
+ { title: "Choose Chinese output", cancelLabel: "back" },
236
+ done,
237
+ ),
238
+ );
239
+ }
240
+
241
+ async function chooseTranscriptionLanguage(
242
+ ctx: ExtensionContext,
243
+ configured: TranscribeSettings,
244
+ ): Promise<TranscriptionLanguage | undefined> {
245
+ const model = getCatalogModel(configured.model.id)!;
246
+ return ctx.ui.custom<TranscriptionLanguage | undefined>(
247
+ (tui, theme, keybindings, done) =>
248
+ createTranscriptionLanguagePicker(
249
+ tui,
250
+ theme,
251
+ keybindings,
252
+ model,
253
+ configured.transcriptionLanguage,
254
+ configured.preferredLanguages,
255
+ done,
256
+ ),
257
+ );
258
+ }
259
+
260
+ async function chooseShortcut(
261
+ ctx: ExtensionContext,
262
+ current: string,
263
+ ): Promise<string | undefined> {
264
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
265
+ createShortcutPicker(tui, theme, keybindings, current, done),
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Single-field text prompt built on pi-tui's Input. Empty input may be legal
271
+ * (the caller decides via validate); Escape backs out of the whole flow.
272
+ */
273
+ function promptText(
274
+ ctx: ExtensionContext,
275
+ options: {
276
+ title: string;
277
+ description?: string;
278
+ value: string;
279
+ placeholder?: string;
280
+ validate?: (value: string) => string | undefined;
281
+ },
282
+ ): Promise<string | undefined> {
283
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
284
+ const keys = new VoiceKeys(keybindings);
285
+ const input = new Input();
286
+ input.setValue(options.value);
287
+ let error: string | undefined;
288
+ const container = new Container();
289
+
290
+ const rebuild = (): void => {
291
+ container.clear();
292
+ container.addChild(panelBorder(theme));
293
+ container.addChild(new Spacer(1));
294
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
295
+ if (options.description) {
296
+ container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
297
+ }
298
+ container.addChild(new Spacer(1));
299
+ if (error) {
300
+ container.addChild(new Text(theme.fg("error", error), 1, 0));
301
+ container.addChild(new Spacer(1));
302
+ }
303
+ if (!input.getValue() && options.placeholder) {
304
+ container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
305
+ }
306
+ const inputBox = new Box(LIST_PADDING, 0);
307
+ inputBox.addChild(input);
308
+ container.addChild(inputBox);
309
+ container.addChild(new Spacer(1));
310
+ container.addChild(
311
+ new Text(
312
+ `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
313
+ 1,
314
+ 0,
315
+ ),
316
+ );
317
+ container.addChild(new Spacer(1));
318
+ container.addChild(panelBorder(theme));
319
+ };
320
+ rebuild();
321
+
322
+ input.onSubmit = (value) => {
323
+ const problem = options.validate?.(value.trim());
324
+ if (problem) {
325
+ error = problem;
326
+ rebuild();
327
+ tui.requestRender();
328
+ return;
329
+ }
330
+ done(value.trim());
331
+ };
332
+ input.onEscape = () => done(undefined);
333
+
334
+ return {
335
+ get focused() {
336
+ return input.focused;
337
+ },
338
+ set focused(value: boolean) {
339
+ input.focused = value;
340
+ },
341
+ render(width: number): string[] {
342
+ return container
343
+ .render(width)
344
+ .map((line) => truncateToWidth(line, width, ""));
345
+ },
346
+ handleInput(data: string): void {
347
+ input.handleInput(data);
348
+ },
349
+ invalidate(): void {
350
+ container.invalidate();
351
+ },
352
+ };
353
+ });
354
+ }
355
+
356
+ async function chooseBackendType(
357
+ ctx: ExtensionContext,
358
+ current: BackendSettings,
359
+ ): Promise<"transcribe-cpp" | "openai-asr" | undefined> {
360
+ const choices: SingleSelectChoice<"transcribe-cpp" | "openai-asr">[] = [
361
+ {
362
+ value: "transcribe-cpp",
363
+ label: "Local model",
364
+ description: "Transcribe fully on-device with the downloaded model; works offline",
365
+ },
366
+ {
367
+ value: "openai-asr",
368
+ label: "Remote ASR (OpenAI-compatible)",
369
+ description:
370
+ "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
371
+ },
372
+ ];
373
+ return ctx.ui.custom<"transcribe-cpp" | "openai-asr" | undefined>(
374
+ (tui, theme, keybindings, done) =>
375
+ new SingleSelectPicker(
376
+ tui,
377
+ theme,
378
+ keybindings,
379
+ choices,
380
+ current.type,
381
+ { title: "Transcription backend", cancelLabel: "back" },
382
+ done,
383
+ ),
384
+ );
385
+ }
386
+
387
+ function validateBaseUrl(value: string): string | undefined {
388
+ if (!value) return "Base URL is required";
389
+ try {
390
+ const url = new URL(value);
391
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
392
+ return "Base URL must start with http:// or https://";
393
+ }
394
+ } catch {
395
+ return "Base URL must be a valid URL";
396
+ }
397
+ return undefined;
398
+ }
399
+
400
+ function validateEnvName(value: string): string | undefined {
401
+ if (!value) return "Environment variable name is required";
402
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
403
+ return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
404
+ }
405
+ return undefined;
406
+ }
407
+
408
+ /** Backend row flow: pick the type, then collect remote settings step by step. */
409
+ async function editBackend(
410
+ ctx: ExtensionContext,
411
+ configured: TranscribeSettings,
412
+ ): Promise<boolean> {
413
+ const type = await chooseBackendType(ctx, configured.backend);
414
+ if (!type) return false;
415
+ if (type === "transcribe-cpp") {
416
+ if (configured.backend.type === "transcribe-cpp") return false;
417
+ return saveUpdatedSettings(
418
+ ctx,
419
+ configured,
420
+ { ...configured, backend: { type: "transcribe-cpp" } },
421
+ "Using the local model",
422
+ );
423
+ }
424
+
425
+ const current =
426
+ configured.backend.type === "openai-asr" ? configured.backend : undefined;
427
+ const baseUrl = await promptText(ctx, {
428
+ title: "ASR base URL",
429
+ description: "OpenAI-compatible endpoint root, without the /chat/completions path",
430
+ value: current?.baseUrl ?? "",
431
+ placeholder: "https://api.example.com/v1",
432
+ validate: validateBaseUrl,
433
+ });
434
+ if (baseUrl === undefined) return false;
435
+
436
+ const model = await promptText(ctx, {
437
+ title: "ASR model",
438
+ value: current?.model ?? "",
439
+ placeholder: "whisper-1",
440
+ validate: (value) => (value ? undefined : "Model name is required"),
441
+ });
442
+ if (model === undefined) return false;
443
+
444
+ const apiKeyEnv = await promptText(ctx, {
445
+ title: "API key environment variable",
446
+ description: "The key itself is read from this variable at runtime; it is never stored in settings",
447
+ value: current?.apiKeyEnv ?? "",
448
+ placeholder: "OPENAI_API_KEY",
449
+ validate: validateEnvName,
450
+ });
451
+ if (apiKeyEnv === undefined) return false;
452
+
453
+ const backend: BackendSettings = { type: "openai-asr", baseUrl, model, apiKeyEnv };
454
+ if (
455
+ current &&
456
+ current.baseUrl === baseUrl &&
457
+ current.model === model &&
458
+ current.apiKeyEnv === apiKeyEnv
459
+ ) {
460
+ return false;
461
+ }
462
+ const saved = await saveUpdatedSettings(
463
+ ctx,
464
+ configured,
465
+ { ...configured, backend },
466
+ `Remote ASR configured (${model})`,
467
+ );
468
+ if (saved && !process.env[apiKeyEnv]) {
469
+ ctx.ui.notify(
470
+ `${apiKeyEnv} is not set in Pi's environment; remote transcription will fall back to the local model until it is set`,
471
+ "warning",
472
+ );
473
+ }
474
+ return saved;
475
+ }
476
+
477
+ export async function openMacOSMicrophoneSettings(
478
+ pi: ExtensionAPI,
479
+ ctx: ExtensionContext,
480
+ ): Promise<void> {
481
+ if (process.platform !== "darwin") return;
482
+ const result = await pi.exec("open", [MACOS_MICROPHONE_SETTINGS_URL]);
483
+ if (result.code === 0) {
484
+ ctx.ui.notify(
485
+ "Enable microphone access for your terminal app, then return to Pi and try recording. A terminal restart may be required.",
486
+ "info",
487
+ );
488
+ } else {
489
+ ctx.ui.notify(
490
+ "Could not open System Settings. Open Privacy & Security → Microphone manually.",
491
+ "error",
492
+ );
493
+ }
494
+ }
495
+
496
+ export async function offerMacOSPermissionHelp(
497
+ pi: ExtensionAPI,
498
+ ctx: ExtensionContext,
499
+ ): Promise<void> {
500
+ if (process.platform !== "darwin") return;
501
+ const openSettings = await ctx.ui.confirm(
502
+ "Microphone access",
503
+ "Microphone capture failed. Open macOS Privacy & Security → Microphone settings?",
504
+ );
505
+ if (openSettings) await openMacOSMicrophoneSettings(pi, ctx);
506
+ }
507
+
508
+ export async function showSettingsMenu(
509
+ pi: ExtensionAPI,
510
+ ctx: ExtensionContext,
511
+ configured: TranscribeSettings,
512
+ registeredShortcut: string,
513
+ ): Promise<boolean> {
514
+ if (ctx.mode !== "tui") {
515
+ ctx.ui.notify("Pi Voice settings require the interactive TUI", "error");
516
+ return false;
517
+ }
518
+
519
+ let reload = configured.shortcut !== registeredShortcut;
520
+ // Checked on open and refreshed whenever the Microphone row is activated,
521
+ // where access problems are surfaced and fixed.
522
+ let permission = microphonePermission();
523
+ while (true) {
524
+ const action = await showSettingsHome(ctx, configured, permission);
525
+ if (!action) return reload;
526
+
527
+ if (action === "preferred-languages") {
528
+ const selection = await chooseLanguages(ctx, configured.preferredLanguages, {
529
+ cancelLabel: "back",
530
+ });
531
+ if (!selection || languagesEqual(selection.languages, configured.preferredLanguages)) {
532
+ continue;
533
+ }
534
+ await saveUpdatedSettings(
535
+ ctx,
536
+ configured,
537
+ { ...configured, preferredLanguages: selection.languages },
538
+ "Preferred languages saved",
539
+ );
540
+ continue;
541
+ }
542
+
543
+ if (action === "model") {
544
+ const updated = await runModelSelection(ctx, {
545
+ shortcut: configured.shortcut,
546
+ preferredLanguages: configured.preferredLanguages,
547
+ transcriptionLanguage: configured.transcriptionLanguage,
548
+ chineseOutput: configured.chineseOutput,
549
+ currentModelId: configured.model.id,
550
+ microphone: configured.microphone,
551
+ backend: configured.backend,
552
+ postActivation: "stay",
553
+ onPreferredLanguagesChange: async (preferredLanguages) => {
554
+ if (languagesEqual(preferredLanguages, configured.preferredLanguages)) return;
555
+ const next = { ...configured, preferredLanguages };
556
+ await writeSettings(next);
557
+ Object.assign(configured, next);
558
+ ctx.ui.notify("Preferred languages saved", "info");
559
+ },
560
+ });
561
+ if (updated) Object.assign(configured, updated);
562
+ continue;
563
+ }
564
+
565
+ if (action === "backend") {
566
+ await editBackend(ctx, configured);
567
+ continue;
568
+ }
569
+
570
+ if (action === "transcription-language") {
571
+ const transcriptionLanguage = await chooseTranscriptionLanguage(ctx, configured);
572
+ if (
573
+ !transcriptionLanguage ||
574
+ transcriptionLanguage === configured.transcriptionLanguage
575
+ ) {
576
+ continue;
577
+ }
578
+ const model = getCatalogModel(configured.model.id)!;
579
+ const summary = transcriptionLanguageSummary(transcriptionLanguage, model);
580
+ await saveUpdatedSettings(
581
+ ctx,
582
+ configured,
583
+ { ...configured, transcriptionLanguage },
584
+ `Transcription language saved as ${summary}`,
585
+ );
586
+ continue;
587
+ }
588
+
589
+ if (action === "chinese-output") {
590
+ const chineseOutput = await chooseChineseOutput(ctx, configured.chineseOutput);
591
+ if (!chineseOutput || chineseOutput === configured.chineseOutput) continue;
592
+ const summary = chineseOutputSummary(chineseOutput);
593
+ await saveUpdatedSettings(
594
+ ctx,
595
+ configured,
596
+ { ...configured, chineseOutput },
597
+ `Chinese output saved as ${summary}`,
598
+ );
599
+ continue;
600
+ }
601
+
602
+ if (action === "microphone") {
603
+ permission = microphonePermission();
604
+ if (permission === "denied" && process.platform === "darwin") {
605
+ // Choosing a device is pointless while capture is blocked; go
606
+ // straight to the fix.
607
+ await openMacOSMicrophoneSettings(pi, ctx);
608
+ continue;
609
+ }
610
+ const microphone = await chooseMicrophone(ctx, configured.microphone, permission);
611
+ if (!microphone || microphonesEqual(microphone, configured.microphone)) continue;
612
+ const summary = microphoneSummary(microphone);
613
+ await saveUpdatedSettings(
614
+ ctx,
615
+ configured,
616
+ { ...configured, microphone },
617
+ `Microphone saved as ${summary}`,
618
+ );
619
+ continue;
620
+ }
621
+
622
+ if (action === "shortcut") {
623
+ const shortcut = await chooseShortcut(ctx, configured.shortcut);
624
+ if (!shortcut || shortcut === configured.shortcut) continue;
625
+ const saved = await saveUpdatedSettings(ctx, configured, {
626
+ ...configured,
627
+ shortcut,
628
+ });
629
+ if (saved) {
630
+ reload = configured.shortcut !== registeredShortcut;
631
+ ctx.ui.notify(
632
+ `Shortcut saved as ${displayShortcut(shortcut)}. It will apply when settings close; other open Pi processes must be reloaded separately.`,
633
+ "info",
634
+ );
635
+ }
636
+ }
637
+ }
638
+ }
@@ -0,0 +1,19 @@
1
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
2
+ import { join } from "node:path";
3
+
4
+ export const SETTINGS_FILENAME = "pi-voice.json";
5
+ export const LEGACY_SETTINGS_FILENAME = "pi-transcribe.json";
6
+
7
+ export function settingsPath(): string {
8
+ return join(getAgentDir(), SETTINGS_FILENAME);
9
+ }
10
+
11
+ export function legacySettingsPath(): string {
12
+ return join(getAgentDir(), LEGACY_SETTINGS_FILENAME);
13
+ }
14
+
15
+ export const LOG_FILENAME = "pi-voice.log";
16
+
17
+ export function logPath(): string {
18
+ return join(getAgentDir(), LOG_FILENAME);
19
+ }