@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,410 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from "@earendil-works/pi-coding-agent";
5
+ import { truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { testMicrophonePermission } from "./audio.js";
7
+ import { displayLanguage, getCatalogModel } from "./catalog.js";
8
+ import { chineseOutputSummary, isChineseLanguage } from "./chinese.js";
9
+ import {
10
+ chooseLanguages,
11
+ createTranscriptionLanguagePicker,
12
+ transcriptionLanguageSummary,
13
+ } from "./model-picker.js";
14
+ import {
15
+ chooseMicrophone,
16
+ microphonePermissionSummary,
17
+ microphonesEqual,
18
+ microphoneSummary,
19
+ type MicrophonePermission,
20
+ } from "./microphone-picker.js";
21
+ import { runModelSelection } from "./onboarding.js";
22
+ import {
23
+ writeSettings,
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
+ padToWidth,
33
+ SingleSelectPicker,
34
+ type SingleSelectChoice,
35
+ } from "./ui-components.js";
36
+
37
+ const MACOS_MICROPHONE_SETTINGS_URL =
38
+ "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone";
39
+ const SETTINGS_LABEL_WIDTH = 25;
40
+ // Text padding (2), cursor gutter (2), label column, and its gap (2).
41
+ const SETTINGS_ROW_OVERHEAD = SETTINGS_LABEL_WIDTH + 6;
42
+
43
+ type SettingsAction =
44
+ | "preferred-languages"
45
+ | "model"
46
+ | "transcription-language"
47
+ | "chinese-output"
48
+ | "microphone"
49
+ | "shortcut";
50
+
51
+ type SettingsHomeChoice = SingleSelectChoice<SettingsAction> & {
52
+ summary: string;
53
+ /** Render the summary in the error color. */
54
+ alert?: boolean;
55
+ };
56
+
57
+ function preferredLanguagesSummary(languages: readonly string[]): string {
58
+ const names = languages.map(displayLanguage);
59
+ const visible = names.slice(0, 3).join(", ");
60
+ return names.length > 3 ? `${visible} +${names.length - 3}` : visible;
61
+ }
62
+
63
+ function languagesEqual(left: readonly string[], right: readonly string[]): boolean {
64
+ return left.join("\0") === right.join("\0");
65
+ }
66
+
67
+ async function saveUpdatedSettings(
68
+ ctx: ExtensionContext,
69
+ configured: TranscribeSettings,
70
+ updated: TranscribeSettings,
71
+ successMessage?: string,
72
+ ): Promise<boolean> {
73
+ try {
74
+ await writeSettings(updated);
75
+ Object.assign(configured, updated);
76
+ if (successMessage) ctx.ui.notify(successMessage, "info");
77
+ return true;
78
+ } catch (error) {
79
+ ctx.ui.notify(
80
+ `Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
81
+ "error",
82
+ );
83
+ return false;
84
+ }
85
+ }
86
+
87
+ function settingsHomeChoices(
88
+ configured: TranscribeSettings,
89
+ permission: MicrophonePermission,
90
+ ): SettingsHomeChoice[] {
91
+ const model = getCatalogModel(configured.model.id)!;
92
+ const choices: SettingsHomeChoice[] = [
93
+ {
94
+ value: "preferred-languages",
95
+ label: "Preferred languages",
96
+ summary: preferredLanguagesSummary(configured.preferredLanguages),
97
+ description: "Languages you speak, used to rank and recommend transcription models",
98
+ },
99
+ {
100
+ value: "model",
101
+ label: "Model",
102
+ summary: model.name,
103
+ description: "Switch between downloaded models, or download a new one",
104
+ },
105
+ {
106
+ value: "transcription-language",
107
+ label: "Transcription language",
108
+ summary: transcriptionLanguageSummary(configured.transcriptionLanguage, model),
109
+ description: "Language expected in recordings, or automatic detection when supported",
110
+ },
111
+ ];
112
+
113
+ if (
114
+ isChineseLanguage(configured.transcriptionLanguage) ||
115
+ configured.preferredLanguages.some(isChineseLanguage)
116
+ ) {
117
+ choices.push({
118
+ value: "chinese-output",
119
+ label: "Chinese output",
120
+ summary: chineseOutputSummary(configured.chineseOutput),
121
+ description: "Character style used for Chinese transcripts",
122
+ });
123
+ }
124
+
125
+ choices.push(
126
+ {
127
+ value: "microphone",
128
+ label: "Microphone",
129
+ // A permission problem replaces the device summary so it is visible
130
+ // from the home screen; selecting the row then goes straight to the
131
+ // System Settings fix.
132
+ ...(permission.status === "denied" && process.platform === "darwin"
133
+ ? {
134
+ summary: "✗ Access denied",
135
+ alert: true,
136
+ description: "Grant microphone access to the terminal application running Pi",
137
+ }
138
+ : {
139
+ summary: microphoneSummary(configured.microphone),
140
+ description: "Input device used for dictation",
141
+ }),
142
+ },
143
+ {
144
+ value: "shortcut",
145
+ label: "Shortcut",
146
+ summary: displayShortcut(configured.shortcut),
147
+ description: "Terminal shortcut that starts and stops microphone dictation",
148
+ },
149
+ );
150
+
151
+ return choices;
152
+ }
153
+
154
+ async function showSettingsHome(
155
+ ctx: ExtensionContext,
156
+ configured: TranscribeSettings,
157
+ permission: MicrophonePermission,
158
+ ): Promise<SettingsAction | undefined> {
159
+ return ctx.ui.custom<SettingsAction | undefined>((tui, theme, keybindings, done) => {
160
+ const choices = settingsHomeChoices(configured, permission);
161
+ const rows = new Map(choices.map((choice) => [choice.value, choice]));
162
+ return new SingleSelectPicker(
163
+ tui,
164
+ theme,
165
+ keybindings,
166
+ choices,
167
+ undefined,
168
+ {
169
+ title: "Pi Voice settings",
170
+ cancelLabel: "close",
171
+ renderLabel: (choice, active, width) => {
172
+ const row = rows.get(choice.value);
173
+ const labelText = padToWidth(choice.label, SETTINGS_LABEL_WIDTH);
174
+ const label = active ? theme.fg("accent", labelText) : labelText;
175
+ // Long summaries (language lists, microphone names) truncate so
176
+ // they never wrap the row and break the column layout.
177
+ const summary = truncateToWidth(
178
+ row?.summary ?? "",
179
+ Math.max(12, width - SETTINGS_ROW_OVERHEAD),
180
+ "…",
181
+ );
182
+ const value = row?.alert
183
+ ? theme.fg("error", summary)
184
+ : theme.fg("dim", summary);
185
+ return `${label} ${value}`;
186
+ },
187
+ },
188
+ done,
189
+ );
190
+ });
191
+ }
192
+
193
+ async function chooseChineseOutput(
194
+ ctx: ExtensionContext,
195
+ current: ChineseOutput,
196
+ ): Promise<ChineseOutput | undefined> {
197
+ const choices: SingleSelectChoice<ChineseOutput>[] = [
198
+ {
199
+ value: "simplified",
200
+ label: "Simplified",
201
+ description: "Convert Chinese transcripts to simplified characters",
202
+ },
203
+ {
204
+ value: "traditional-taiwan",
205
+ label: "Traditional (Taiwan)",
206
+ description: "Use traditional characters and Taiwan conventions",
207
+ },
208
+ {
209
+ value: "traditional-hong-kong",
210
+ label: "Traditional (Hong Kong)",
211
+ description: "Use traditional characters and Hong Kong conventions",
212
+ },
213
+ ];
214
+ return ctx.ui.custom<ChineseOutput | undefined>((tui, theme, keybindings, done) =>
215
+ new SingleSelectPicker(
216
+ tui,
217
+ theme,
218
+ keybindings,
219
+ choices,
220
+ current,
221
+ { title: "Choose Chinese output", cancelLabel: "back" },
222
+ done,
223
+ ),
224
+ );
225
+ }
226
+
227
+ async function chooseTranscriptionLanguage(
228
+ ctx: ExtensionContext,
229
+ configured: TranscribeSettings,
230
+ ): Promise<TranscriptionLanguage | undefined> {
231
+ const model = getCatalogModel(configured.model.id)!;
232
+ return ctx.ui.custom<TranscriptionLanguage | undefined>(
233
+ (tui, theme, keybindings, done) =>
234
+ createTranscriptionLanguagePicker(
235
+ tui,
236
+ theme,
237
+ keybindings,
238
+ model,
239
+ configured.transcriptionLanguage,
240
+ configured.preferredLanguages,
241
+ done,
242
+ ),
243
+ );
244
+ }
245
+
246
+ async function chooseShortcut(
247
+ ctx: ExtensionContext,
248
+ current: string,
249
+ ): Promise<string | undefined> {
250
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
251
+ createShortcutPicker(tui, theme, keybindings, current, done),
252
+ );
253
+ }
254
+
255
+ export async function openMacOSMicrophoneSettings(
256
+ pi: ExtensionAPI,
257
+ ctx: ExtensionContext,
258
+ ): Promise<void> {
259
+ if (process.platform !== "darwin") return;
260
+ const result = await pi.exec("open", [MACOS_MICROPHONE_SETTINGS_URL]);
261
+ if (result.code === 0) {
262
+ ctx.ui.notify(
263
+ "Enable microphone access for your terminal app, then return to Pi and try recording. A terminal restart may be required.",
264
+ "info",
265
+ );
266
+ } else {
267
+ ctx.ui.notify(
268
+ "Could not open System Settings. Open Privacy & Security → Microphone manually.",
269
+ "error",
270
+ );
271
+ }
272
+ }
273
+
274
+ export async function offerMacOSPermissionHelp(
275
+ pi: ExtensionAPI,
276
+ ctx: ExtensionContext,
277
+ ): Promise<void> {
278
+ if (process.platform !== "darwin") return;
279
+ const openSettings = await ctx.ui.confirm(
280
+ "Microphone access",
281
+ "Microphone capture failed. Open macOS Privacy & Security → Microphone settings?",
282
+ );
283
+ if (openSettings) await openMacOSMicrophoneSettings(pi, ctx);
284
+ }
285
+
286
+ export async function showSettingsMenu(
287
+ pi: ExtensionAPI,
288
+ ctx: ExtensionContext,
289
+ configured: TranscribeSettings,
290
+ registeredShortcut: string,
291
+ ): Promise<boolean> {
292
+ if (ctx.mode !== "tui") {
293
+ ctx.ui.notify("Pi Voice settings require the interactive TUI", "error");
294
+ return false;
295
+ }
296
+
297
+ let reload = configured.shortcut !== registeredShortcut;
298
+ // Checked on open and refreshed whenever the Microphone row is activated,
299
+ // where access problems are surfaced and fixed.
300
+ let permission = await testMicrophonePermission();
301
+ while (true) {
302
+ const action = await showSettingsHome(ctx, configured, permission);
303
+ if (!action) return reload;
304
+
305
+ if (action === "preferred-languages") {
306
+ const selection = await chooseLanguages(ctx, configured.preferredLanguages, {
307
+ cancelLabel: "back",
308
+ });
309
+ if (!selection || languagesEqual(selection.languages, configured.preferredLanguages)) {
310
+ continue;
311
+ }
312
+ await saveUpdatedSettings(
313
+ ctx,
314
+ configured,
315
+ { ...configured, preferredLanguages: selection.languages },
316
+ "Preferred languages saved",
317
+ );
318
+ continue;
319
+ }
320
+
321
+ if (action === "model") {
322
+ const updated = await runModelSelection(ctx, {
323
+ shortcut: configured.shortcut,
324
+ preferredLanguages: configured.preferredLanguages,
325
+ transcriptionLanguage: configured.transcriptionLanguage,
326
+ chineseOutput: configured.chineseOutput,
327
+ currentModelId: configured.model.id,
328
+ microphone: configured.microphone,
329
+ postActivation: "stay",
330
+ onPreferredLanguagesChange: async (preferredLanguages) => {
331
+ if (languagesEqual(preferredLanguages, configured.preferredLanguages)) return;
332
+ const next = { ...configured, preferredLanguages };
333
+ await writeSettings(next);
334
+ Object.assign(configured, next);
335
+ ctx.ui.notify("Preferred languages saved", "info");
336
+ },
337
+ });
338
+ if (updated) Object.assign(configured, updated);
339
+ continue;
340
+ }
341
+
342
+ if (action === "transcription-language") {
343
+ const transcriptionLanguage = await chooseTranscriptionLanguage(ctx, configured);
344
+ if (
345
+ !transcriptionLanguage ||
346
+ transcriptionLanguage === configured.transcriptionLanguage
347
+ ) {
348
+ continue;
349
+ }
350
+ const model = getCatalogModel(configured.model.id)!;
351
+ const summary = transcriptionLanguageSummary(transcriptionLanguage, model);
352
+ await saveUpdatedSettings(
353
+ ctx,
354
+ configured,
355
+ { ...configured, transcriptionLanguage },
356
+ `Transcription language saved as ${summary}`,
357
+ );
358
+ continue;
359
+ }
360
+
361
+ if (action === "chinese-output") {
362
+ const chineseOutput = await chooseChineseOutput(ctx, configured.chineseOutput);
363
+ if (!chineseOutput || chineseOutput === configured.chineseOutput) continue;
364
+ const summary = chineseOutputSummary(chineseOutput);
365
+ await saveUpdatedSettings(
366
+ ctx,
367
+ configured,
368
+ { ...configured, chineseOutput },
369
+ `Chinese output saved as ${summary}`,
370
+ );
371
+ continue;
372
+ }
373
+
374
+ if (action === "microphone") {
375
+ permission = await testMicrophonePermission();
376
+ if (permission.status === "denied" && process.platform === "darwin") {
377
+ // Choosing a device is pointless while capture is blocked; go
378
+ // straight to the fix.
379
+ await openMacOSMicrophoneSettings(pi, ctx);
380
+ continue;
381
+ }
382
+ const microphone = await chooseMicrophone(ctx, configured.microphone, permission);
383
+ if (!microphone || microphonesEqual(microphone, configured.microphone)) continue;
384
+ const summary = microphoneSummary(microphone);
385
+ await saveUpdatedSettings(
386
+ ctx,
387
+ configured,
388
+ { ...configured, microphone },
389
+ `Microphone saved as ${summary}`,
390
+ );
391
+ continue;
392
+ }
393
+
394
+ if (action === "shortcut") {
395
+ const shortcut = await chooseShortcut(ctx, configured.shortcut);
396
+ if (!shortcut || shortcut === configured.shortcut) continue;
397
+ const saved = await saveUpdatedSettings(ctx, configured, {
398
+ ...configured,
399
+ shortcut,
400
+ });
401
+ if (saved) {
402
+ reload = configured.shortcut !== registeredShortcut;
403
+ ctx.ui.notify(
404
+ `Shortcut saved as ${displayShortcut(shortcut)}. It will apply when settings close; other open Pi processes must be reloaded separately.`,
405
+ "info",
406
+ );
407
+ }
408
+ }
409
+ }
410
+ }
@@ -0,0 +1,13 @@
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
+ }
@@ -0,0 +1,235 @@
1
+ import { readFile, rename, unlink, writeFile } from "node:fs/promises";
2
+ import {
3
+ languageIdentity,
4
+ getCatalogModel,
5
+ resolveModelLanguage,
6
+ type CatalogModel,
7
+ } from "./catalog.js";
8
+ import { DEFAULT_SHORTCUT, normalizeShortcut } from "./shortcut-core.js";
9
+ import { legacySettingsPath, settingsPath } from "./settings-path.js";
10
+
11
+ const SETTINGS_VERSION = 1;
12
+
13
+ export type MicrophoneSetting =
14
+ | { type: "system-default" }
15
+ | { type: "device"; name: string; occurrence: number };
16
+
17
+ export const DEFAULT_MICROPHONE: MicrophoneSetting = { type: "system-default" };
18
+
19
+ export type ChineseOutput = "simplified" | "traditional-taiwan" | "traditional-hong-kong";
20
+
21
+ function defaultChineseOutput(): ChineseOutput {
22
+ const locale = Intl.DateTimeFormat().resolvedOptions().locale;
23
+ const subtags = locale.toLowerCase().split("-");
24
+ if (subtags.includes("hk") || subtags.includes("mo")) return "traditional-hong-kong";
25
+ if (subtags.includes("tw") || subtags.includes("hant")) return "traditional-taiwan";
26
+ return "simplified";
27
+ }
28
+
29
+ /** "auto" asks a capable model to detect the language; otherwise this is a language code. */
30
+ export type TranscriptionLanguage = string;
31
+
32
+ export type TranscribeSettings = {
33
+ version: 1;
34
+ backend: { type: "transcribe-cpp" };
35
+ shortcut: string;
36
+ preferredLanguages: string[];
37
+ transcriptionLanguage: TranscriptionLanguage;
38
+ chineseOutput: ChineseOutput;
39
+ microphone: MicrophoneSetting;
40
+ model: {
41
+ source: "catalog";
42
+ id: string;
43
+ path: string;
44
+ };
45
+ };
46
+
47
+ type SettingsReadResult = {
48
+ settings?: TranscribeSettings;
49
+ warning?: string;
50
+ };
51
+
52
+ function isObject(value: unknown): value is Record<string, unknown> {
53
+ return typeof value === "object" && value !== null && !Array.isArray(value);
54
+ }
55
+
56
+ function normalizeLanguages(value: unknown): string[] | undefined {
57
+ if (!Array.isArray(value) || !value.every((language) => typeof language === "string")) {
58
+ return undefined;
59
+ }
60
+ const languages = [...new Set(value.map(languageIdentity).filter(Boolean))];
61
+ return languages.length > 0 ? languages : undefined;
62
+ }
63
+
64
+ function validateChineseOutput(value: unknown): ChineseOutput {
65
+ return value === "simplified" ||
66
+ value === "traditional-taiwan" ||
67
+ value === "traditional-hong-kong"
68
+ ? value
69
+ : defaultChineseOutput();
70
+ }
71
+
72
+ function validateMicrophone(value: unknown): MicrophoneSetting | undefined {
73
+ if (!isObject(value)) return undefined;
74
+ if (value.type === "system-default") return { type: "system-default" };
75
+ if (
76
+ value.type !== "device" ||
77
+ typeof value.name !== "string" ||
78
+ !value.name.trim() ||
79
+ !Number.isInteger(value.occurrence) ||
80
+ (value.occurrence as number) < 0
81
+ ) {
82
+ return undefined;
83
+ }
84
+ return { type: "device", name: value.name, occurrence: value.occurrence as number };
85
+ }
86
+
87
+ function defaultTranscriptionLanguage(
88
+ model: CatalogModel,
89
+ preferredLanguages: readonly string[] = [],
90
+ ): TranscriptionLanguage {
91
+ if (model.capabilities.languageDetection) return "auto";
92
+ for (const language of preferredLanguages) {
93
+ const code = resolveModelLanguage(model, language);
94
+ if (code) return code;
95
+ }
96
+ return model.languages[0] ?? "en";
97
+ }
98
+
99
+ /** Keep an exact model language when possible; otherwise choose a safe default. */
100
+ export function transcriptionLanguageForModel(
101
+ value: unknown,
102
+ model: CatalogModel,
103
+ preferredLanguages: readonly string[] = [],
104
+ ): TranscriptionLanguage {
105
+ if (typeof value === "string") {
106
+ if (value === "auto" && model.capabilities.languageDetection) return value;
107
+ if (value !== "auto") {
108
+ const code = resolveModelLanguage(model, value);
109
+ if (code) return code;
110
+ }
111
+ }
112
+ return defaultTranscriptionLanguage(model, preferredLanguages);
113
+ }
114
+
115
+ function validateSettings(value: unknown): TranscribeSettings | undefined {
116
+ if (!isObject(value) || value.version !== SETTINGS_VERSION) return undefined;
117
+ if (!isObject(value.backend) || value.backend.type !== "transcribe-cpp") return undefined;
118
+ const shortcut =
119
+ typeof value.shortcut === "string" ? normalizeShortcut(value.shortcut) : undefined;
120
+ if (!shortcut) return undefined;
121
+ if (!isObject(value.model) || value.model.source !== "catalog") return undefined;
122
+ if (typeof value.model.id !== "string") return undefined;
123
+ const model = getCatalogModel(value.model.id);
124
+ if (!model) return undefined;
125
+ if (typeof value.model.path !== "string" || value.model.path.length === 0) return undefined;
126
+
127
+ const preferredLanguages = normalizeLanguages(value.preferredLanguages);
128
+ const microphone = validateMicrophone(value.microphone);
129
+ if (!preferredLanguages || !microphone) return undefined;
130
+
131
+ return {
132
+ version: SETTINGS_VERSION,
133
+ backend: { type: "transcribe-cpp" },
134
+ shortcut,
135
+ preferredLanguages,
136
+ transcriptionLanguage: transcriptionLanguageForModel(
137
+ value.transcriptionLanguage,
138
+ model,
139
+ preferredLanguages,
140
+ ),
141
+ chineseOutput: validateChineseOutput(value.chineseOutput),
142
+ microphone,
143
+ model: {
144
+ source: "catalog",
145
+ id: value.model.id,
146
+ path: value.model.path,
147
+ },
148
+ };
149
+ }
150
+
151
+ async function readSettingsFile(path: string): Promise<SettingsReadResult> {
152
+ try {
153
+ const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
154
+ const settings = validateSettings(parsed);
155
+ return settings
156
+ ? { settings }
157
+ : { warning: `Invalid settings in ${path}; configuration is required.` };
158
+ } catch (error) {
159
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return {};
160
+ return {
161
+ warning: `Could not read ${path}: ${error instanceof Error ? error.message : String(error)}`,
162
+ };
163
+ }
164
+ }
165
+
166
+ export async function readSettings(): Promise<SettingsReadResult> {
167
+ const currentPath = settingsPath();
168
+ const current = await readSettingsFile(currentPath);
169
+ if (current.settings || current.warning) return current;
170
+
171
+ const legacyPath = legacySettingsPath();
172
+ const legacy = await readSettingsFile(legacyPath);
173
+ if (!legacy.settings) return legacy;
174
+
175
+ try {
176
+ await writeSettings(legacy.settings);
177
+ await unlink(legacyPath).catch((error: NodeJS.ErrnoException) => {
178
+ if (error.code !== "ENOENT") throw error;
179
+ });
180
+ return { settings: legacy.settings };
181
+ } catch (error) {
182
+ return {
183
+ settings: legacy.settings,
184
+ warning: `Loaded legacy settings from ${legacyPath}, but could not migrate them to ${currentPath}: ${error instanceof Error ? error.message : String(error)}`,
185
+ };
186
+ }
187
+ }
188
+
189
+ export async function writeSettings(settings: TranscribeSettings): Promise<void> {
190
+ const path = settingsPath();
191
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
192
+ const content = `${JSON.stringify(settings, null, 2)}\n`;
193
+
194
+ try {
195
+ await writeFile(temporaryPath, content, { encoding: "utf8", mode: 0o600 });
196
+ await rename(temporaryPath, path);
197
+ } catch (error) {
198
+ await unlink(temporaryPath).catch(() => undefined);
199
+ throw error;
200
+ }
201
+ }
202
+
203
+ type ModelSettingsOptions = {
204
+ shortcut?: string;
205
+ preferredLanguages?: readonly string[];
206
+ transcriptionLanguage?: TranscriptionLanguage;
207
+ chineseOutput?: ChineseOutput;
208
+ microphone?: MicrophoneSetting;
209
+ };
210
+
211
+ export function settingsForModel(
212
+ modelId: string,
213
+ modelPath: string,
214
+ options: ModelSettingsOptions = {},
215
+ ): TranscribeSettings {
216
+ const model = getCatalogModel(modelId);
217
+ if (!model) throw new Error(`Unknown catalog model: ${modelId}`);
218
+ const preferredLanguages = [
219
+ ...new Set((options.preferredLanguages ?? ["en"]).map(languageIdentity)),
220
+ ];
221
+ return {
222
+ version: SETTINGS_VERSION,
223
+ backend: { type: "transcribe-cpp" },
224
+ shortcut: options.shortcut ?? DEFAULT_SHORTCUT,
225
+ preferredLanguages,
226
+ transcriptionLanguage: transcriptionLanguageForModel(
227
+ options.transcriptionLanguage,
228
+ model,
229
+ preferredLanguages,
230
+ ),
231
+ chineseOutput: options.chineseOutput ?? defaultChineseOutput(),
232
+ microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
233
+ model: { source: "catalog", id: modelId, path: modelPath },
234
+ };
235
+ }