@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.
- package/LICENSE +21 -0
- package/README.md +86 -0
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +69 -0
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio.ts +193 -0
- package/src/catalog.generated.ts +1305 -0
- package/src/catalog.ts +89 -0
- package/src/chinese.ts +52 -0
- package/src/deferred.ts +30 -0
- package/src/dictation-controller.ts +204 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +114 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +118 -0
- package/src/languages.ts +22 -0
- package/src/microphone-picker.ts +99 -0
- package/src/model-activation.ts +74 -0
- package/src/model-cells.ts +218 -0
- package/src/model-picker.ts +1026 -0
- package/src/model-ratings-help.md +41 -0
- package/src/model-ratings-help.ts +146 -0
- package/src/model-selection-controller.ts +185 -0
- package/src/models.ts +263 -0
- package/src/onboarding.ts +314 -0
- package/src/pcm-chunker.ts +42 -0
- package/src/pcm.ts +19 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +501 -0
- package/src/settings-menu.ts +410 -0
- package/src/settings-path.ts +13 -0
- package/src/settings.ts +235 -0
- package/src/shortcut-core.ts +85 -0
- package/src/shortcuts.ts +167 -0
- package/src/startup-shortcut.ts +24 -0
- package/src/transcript-preview.ts +52 -0
- package/src/transcription-service.ts +548 -0
- package/src/transcription.ts +186 -0
- package/src/try-it.ts +327 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +269 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionCommandContext,
|
|
4
|
+
ExtensionContext,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getKeybindings } from "@earendil-works/pi-tui";
|
|
7
|
+
import { existsSync } from "node:fs";
|
|
8
|
+
import { DictationController } from "./dictation-controller.js";
|
|
9
|
+
import { VoiceKeys } from "./keybindings.js";
|
|
10
|
+
import type { TranscribeSettings } from "./settings.js";
|
|
11
|
+
import { displayShortcut } from "./shortcut-core.js";
|
|
12
|
+
import { TranscriptionService } from "./transcription-service.js";
|
|
13
|
+
import type { RecordingMeter } from "./visualizer.js";
|
|
14
|
+
|
|
15
|
+
type ActiveRecording = {
|
|
16
|
+
dictation: DictationController;
|
|
17
|
+
meter: RecordingMeter;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const COMPLETION_WIDGET_MS = 5_000;
|
|
21
|
+
/** Setup confirmation stays long enough to read the shortcut and follow-up command. */
|
|
22
|
+
const READY_WIDGET_MS = 20_000;
|
|
23
|
+
|
|
24
|
+
export type PiVoiceRuntime = {
|
|
25
|
+
readonly service: TranscriptionService;
|
|
26
|
+
requireConfiguredSettingsForTool(): Promise<TranscribeSettings>;
|
|
27
|
+
toggleCapture(ctx: ExtensionContext): Promise<void>;
|
|
28
|
+
showSettings(ctx: ExtensionCommandContext): Promise<void>;
|
|
29
|
+
replayOnboarding(ctx: ExtensionCommandContext): Promise<void>;
|
|
30
|
+
shutdown(ctx: ExtensionContext): Promise<void>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function isMicrophoneUnavailableError(error: unknown): boolean {
|
|
34
|
+
return error instanceof Error && error.name === "MicrophoneUnavailableError";
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function captureErrorMessage(error: unknown): string {
|
|
38
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
39
|
+
const permissionHelp =
|
|
40
|
+
process.platform === "darwin" && !isMicrophoneUnavailableError(error)
|
|
41
|
+
? " Check System Settings → Privacy & Security → Microphone for your terminal app."
|
|
42
|
+
: "";
|
|
43
|
+
return `Microphone capture failed: ${message}${permissionHelp}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function transcriptionErrorMessage(error: unknown): string {
|
|
47
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
48
|
+
return `Local transcription failed: ${message}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function createPiVoiceRuntime(
|
|
52
|
+
pi: ExtensionAPI,
|
|
53
|
+
registeredShortcut: string,
|
|
54
|
+
): PiVoiceRuntime {
|
|
55
|
+
let recording: ActiveRecording | undefined;
|
|
56
|
+
let operation: Promise<void> | undefined;
|
|
57
|
+
let dictation: DictationController | undefined;
|
|
58
|
+
let shuttingDown = false;
|
|
59
|
+
let stopListening: (() => void) | undefined;
|
|
60
|
+
let completionWidgetTimer: ReturnType<typeof setTimeout> | undefined;
|
|
61
|
+
let settings: TranscribeSettings | undefined;
|
|
62
|
+
let settingsLoaded = false;
|
|
63
|
+
let settingsReadWarning: string | undefined;
|
|
64
|
+
let settingsWarningShown = false;
|
|
65
|
+
let audioModulePromise: Promise<typeof import("./audio.js")> | undefined;
|
|
66
|
+
let visualizerModulePromise: Promise<typeof import("./visualizer.js")> | undefined;
|
|
67
|
+
const transcriptionService = new TranscriptionService();
|
|
68
|
+
|
|
69
|
+
function loadAudio(): Promise<typeof import("./audio.js")> {
|
|
70
|
+
return (audioModulePromise ??= import("./audio.js"));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function loadVisualizer(): Promise<typeof import("./visualizer.js")> {
|
|
74
|
+
return (visualizerModulePromise ??= import("./visualizer.js"));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function reportCaptureError(ctx: ExtensionContext, error: unknown): Promise<void> {
|
|
78
|
+
ctx.ui.notify(captureErrorMessage(error), "error");
|
|
79
|
+
if (!isMicrophoneUnavailableError(error)) {
|
|
80
|
+
const { offerMacOSPermissionHelp } = await import("./settings-menu.js");
|
|
81
|
+
await offerMacOSPermissionHelp(pi, ctx);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function rememberSettings(configured: TranscribeSettings): void {
|
|
86
|
+
settings = configured;
|
|
87
|
+
settingsLoaded = true;
|
|
88
|
+
settingsReadWarning = undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function notifyReady(ctx: ExtensionContext, configured: TranscribeSettings): Promise<void> {
|
|
92
|
+
// Pi binds shortcuts at extension load. The command path reloads on its
|
|
93
|
+
// own; the shortcut path cannot, so say what it takes to use a new one.
|
|
94
|
+
const reloadNeeded = configured.shortcut !== registeredShortcut;
|
|
95
|
+
const talk = reloadNeeded
|
|
96
|
+
? `run /reload, then ${displayShortcut(configured.shortcut)} to talk`
|
|
97
|
+
: `${displayShortcut(configured.shortcut)} to talk`;
|
|
98
|
+
const command = "/voice-settings";
|
|
99
|
+
const commandDescription = "to change settings and download new models";
|
|
100
|
+
const summary = `${command} ${commandDescription}`;
|
|
101
|
+
|
|
102
|
+
// The TUI renders a success-colored widget in the meter slot so the user
|
|
103
|
+
// sees where Pi Voice talks to them. RPC and print keep the plain
|
|
104
|
+
// notification: RPC forwards widget lines verbatim, so theme escapes leak.
|
|
105
|
+
if (ctx.mode !== "tui") {
|
|
106
|
+
ctx.ui.notify(`✓ Pi Voice ready · ${talk}\n${summary}`, "info");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const { clearTranscribeWidget, showReadyStatus } = await loadVisualizer();
|
|
110
|
+
showReadyStatus(ctx, {
|
|
111
|
+
talk,
|
|
112
|
+
help: { command, description: commandDescription },
|
|
113
|
+
});
|
|
114
|
+
holdCompletionWidget(ctx, clearTranscribeWidget, READY_WIDGET_MS);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function loadSettingsOnce(): Promise<void> {
|
|
118
|
+
if (settingsLoaded) return;
|
|
119
|
+
const { readSettings } = await import("./settings.js");
|
|
120
|
+
const result = await readSettings();
|
|
121
|
+
settingsLoaded = true;
|
|
122
|
+
settings = result.settings;
|
|
123
|
+
settingsReadWarning = result.warning;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function configureFirstRun(
|
|
127
|
+
ctx: ExtensionContext,
|
|
128
|
+
): Promise<TranscribeSettings | undefined> {
|
|
129
|
+
const { runOnboarding } = await import("./onboarding.js");
|
|
130
|
+
const configured = await runOnboarding(ctx, registeredShortcut);
|
|
131
|
+
if (configured) rememberSettings(configured);
|
|
132
|
+
return configured;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function configureModel(
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
previous: TranscribeSettings,
|
|
138
|
+
): Promise<TranscribeSettings | undefined> {
|
|
139
|
+
const { runModelSelection } = await import("./onboarding.js");
|
|
140
|
+
const configured = await runModelSelection(ctx, {
|
|
141
|
+
shortcut: previous.shortcut,
|
|
142
|
+
preferredLanguages: previous.preferredLanguages,
|
|
143
|
+
transcriptionLanguage: previous.transcriptionLanguage,
|
|
144
|
+
chineseOutput: previous.chineseOutput,
|
|
145
|
+
currentModelId: previous.model.id,
|
|
146
|
+
microphone: previous.microphone,
|
|
147
|
+
postActivation: "advance",
|
|
148
|
+
});
|
|
149
|
+
if (configured) rememberSettings(configured);
|
|
150
|
+
return configured;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function ensureSettings(
|
|
154
|
+
ctx: ExtensionContext,
|
|
155
|
+
): Promise<{ configured?: TranscribeSettings; completedFirstRun: boolean }> {
|
|
156
|
+
await loadSettingsOnce();
|
|
157
|
+
if (settingsReadWarning && !settingsWarningShown) {
|
|
158
|
+
settingsWarningShown = true;
|
|
159
|
+
ctx.ui.notify(settingsReadWarning, "warning");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (settings && existsSync(settings.model.path)) {
|
|
163
|
+
return { configured: settings, completedFirstRun: false };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const previous = settings;
|
|
167
|
+
if (settings) {
|
|
168
|
+
ctx.ui.notify(
|
|
169
|
+
`Configured model file is missing: ${settings.model.path}. Choose a model again; nothing will be downloaded without confirmation.`,
|
|
170
|
+
"warning",
|
|
171
|
+
);
|
|
172
|
+
settings = undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const configured = previous
|
|
176
|
+
? await configureModel(ctx, previous)
|
|
177
|
+
: await configureFirstRun(ctx);
|
|
178
|
+
if (configured) await notifyReady(ctx, configured);
|
|
179
|
+
return { configured, completedFirstRun: previous === undefined && configured !== undefined };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function requireConfiguredSettingsForTool(): Promise<TranscribeSettings> {
|
|
183
|
+
await loadSettingsOnce();
|
|
184
|
+
if (settingsReadWarning && !settings) {
|
|
185
|
+
throw new Error(
|
|
186
|
+
`${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to configure a local model, then retry transcribe_file.`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
if (!settings) {
|
|
190
|
+
throw new Error(
|
|
191
|
+
"Pi Voice is not configured. Ask the user to run /voice-settings in Pi's interactive TUI once to choose and download a local model, then retry transcribe_file.",
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
if (!existsSync(settings.model.path)) {
|
|
195
|
+
throw new Error(
|
|
196
|
+
`The configured transcription model is missing: ${settings.model.path}. Ask the user to run /voice-settings and choose a model again, then retry transcribe_file.`,
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return settings;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function listenForCancel(ctx: ExtensionContext): void {
|
|
203
|
+
stopListening?.();
|
|
204
|
+
if (!ctx.hasUI) return;
|
|
205
|
+
// No pane here to receive an injected manager; pi's global is the same one.
|
|
206
|
+
const keys = new VoiceKeys(getKeybindings());
|
|
207
|
+
stopListening = ctx.ui.onTerminalInput((data) => {
|
|
208
|
+
if (!keys.matches(data, "voice.dictation.cancel")) return;
|
|
209
|
+
if (recording) {
|
|
210
|
+
void runExclusive(ctx, () => cancelRecording(ctx));
|
|
211
|
+
return { consume: true };
|
|
212
|
+
}
|
|
213
|
+
if (dictation?.state.phase === "transcribing") {
|
|
214
|
+
void dictation.cancel();
|
|
215
|
+
ctx.ui.notify("Transcription cancelled", "info");
|
|
216
|
+
return { consume: true };
|
|
217
|
+
}
|
|
218
|
+
if (dictation?.state.phase === "cancelling") return { consume: true };
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function clearCancelListener(): void {
|
|
223
|
+
stopListening?.();
|
|
224
|
+
stopListening = undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function cancelCompletionWidgetTimer(): void {
|
|
228
|
+
if (completionWidgetTimer) clearTimeout(completionWidgetTimer);
|
|
229
|
+
completionWidgetTimer = undefined;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function dismissCompletionWidget(ctx: ExtensionContext): Promise<void> {
|
|
233
|
+
if (!completionWidgetTimer) return;
|
|
234
|
+
cancelCompletionWidgetTimer();
|
|
235
|
+
const { clearTranscribeWidget } = await loadVisualizer();
|
|
236
|
+
clearTranscribeWidget(ctx);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function holdCompletionWidget(
|
|
240
|
+
ctx: ExtensionContext,
|
|
241
|
+
clearTranscribeWidget: (ctx: ExtensionContext) => void,
|
|
242
|
+
durationMs = COMPLETION_WIDGET_MS,
|
|
243
|
+
): void {
|
|
244
|
+
cancelCompletionWidgetTimer();
|
|
245
|
+
const timer = setTimeout(() => {
|
|
246
|
+
if (completionWidgetTimer !== timer) return;
|
|
247
|
+
completionWidgetTimer = undefined;
|
|
248
|
+
clearTranscribeWidget(ctx);
|
|
249
|
+
}, durationMs);
|
|
250
|
+
completionWidgetTimer = timer;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function cancelRecording(ctx: ExtensionContext): Promise<void> {
|
|
254
|
+
const active = recording;
|
|
255
|
+
if (!active) return;
|
|
256
|
+
recording = undefined;
|
|
257
|
+
active.meter.stop();
|
|
258
|
+
await active.dictation.dispose();
|
|
259
|
+
if (dictation === active.dictation) dictation = undefined;
|
|
260
|
+
clearCancelListener();
|
|
261
|
+
if (!shuttingDown) ctx.ui.notify("Recording discarded", "info");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function reportDictationError(ctx: ExtensionContext, controller: DictationController): Promise<void> {
|
|
265
|
+
const state = controller.state;
|
|
266
|
+
if (state.phase !== "error" || shuttingDown) return;
|
|
267
|
+
if (state.stage === "capture") await reportCaptureError(ctx, state.cause);
|
|
268
|
+
else ctx.ui.notify(transcriptionErrorMessage(state.cause), "error");
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
async function stopAndTranscribe(ctx: ExtensionContext): Promise<void> {
|
|
272
|
+
const {
|
|
273
|
+
clearTranscribeWidget,
|
|
274
|
+
formatTranscriptionSummary,
|
|
275
|
+
showTranscribeStatus,
|
|
276
|
+
} = await loadVisualizer();
|
|
277
|
+
const active = recording!;
|
|
278
|
+
recording = undefined;
|
|
279
|
+
active.meter.stop({ clearWidget: false });
|
|
280
|
+
const cancelKeys = new VoiceKeys(getKeybindings()).keyText("voice.dictation.cancel");
|
|
281
|
+
showTranscribeStatus(ctx, "Transcribing…", { cancelKeys });
|
|
282
|
+
let keepCompletionVisible = false;
|
|
283
|
+
try {
|
|
284
|
+
const result = await active.dictation.stop();
|
|
285
|
+
if (shuttingDown) return;
|
|
286
|
+
if (!result) {
|
|
287
|
+
await reportDictationError(ctx, active.dictation);
|
|
288
|
+
} else if (result.text) {
|
|
289
|
+
ctx.ui.pasteToEditor(result.text);
|
|
290
|
+
showTranscribeStatus(
|
|
291
|
+
ctx,
|
|
292
|
+
formatTranscriptionSummary(result.speechSeconds, result.transcribeSeconds),
|
|
293
|
+
);
|
|
294
|
+
keepCompletionVisible = true;
|
|
295
|
+
} else {
|
|
296
|
+
ctx.ui.notify(`No speech detected in ${result.speechSeconds.toFixed(1)}s of audio`, "warning");
|
|
297
|
+
}
|
|
298
|
+
} finally {
|
|
299
|
+
await active.dictation.dispose();
|
|
300
|
+
if (dictation === active.dictation) dictation = undefined;
|
|
301
|
+
clearCancelListener();
|
|
302
|
+
if (keepCompletionVisible) holdCompletionWidget(ctx, clearTranscribeWidget);
|
|
303
|
+
else clearTranscribeWidget(ctx);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function startRecording(
|
|
308
|
+
ctx: ExtensionContext,
|
|
309
|
+
configured: TranscribeSettings,
|
|
310
|
+
): Promise<void> {
|
|
311
|
+
const { createMicrophoneCapture, testMicrophonePermission } = await loadAudio();
|
|
312
|
+
if (process.platform === "darwin") {
|
|
313
|
+
const micStatus = await testMicrophonePermission();
|
|
314
|
+
if (micStatus.status === "denied") {
|
|
315
|
+
const openSettings = await ctx.ui.confirm(
|
|
316
|
+
"Microphone access",
|
|
317
|
+
"Microphone access is denied in System Settings. Open Privacy & Security → Microphone settings?",
|
|
318
|
+
);
|
|
319
|
+
if (openSettings) {
|
|
320
|
+
const { openMacOSMicrophoneSettings } = await import("./settings-menu.js");
|
|
321
|
+
await openMacOSMicrophoneSettings(pi, ctx);
|
|
322
|
+
}
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
const { RecordingMeter } = await loadVisualizer();
|
|
327
|
+
if (shuttingDown) return;
|
|
328
|
+
const meter = new RecordingMeter();
|
|
329
|
+
const controller = new DictationController(transcriptionService, {
|
|
330
|
+
createCapture: createMicrophoneCapture,
|
|
331
|
+
onFrame: (frame) => meter.push(frame),
|
|
332
|
+
onChange: () => meter.setModelState(controller.modelState),
|
|
333
|
+
});
|
|
334
|
+
dictation = controller;
|
|
335
|
+
try {
|
|
336
|
+
// Paint startup feedback before opening the native device blocks the loop.
|
|
337
|
+
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
338
|
+
if (shuttingDown) return;
|
|
339
|
+
await controller.start(configured);
|
|
340
|
+
if (controller.state.phase !== "listening") {
|
|
341
|
+
await reportDictationError(ctx, controller);
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
// Key text via the same formatter as the Try It pane so the meter
|
|
345
|
+
// reads exactly like the hint the user learned during setup.
|
|
346
|
+
const cancelKeys = new VoiceKeys(getKeybindings()).keyText("voice.dictation.cancel");
|
|
347
|
+
meter.start(ctx, {
|
|
348
|
+
action: `${displayShortcut(registeredShortcut)} to transcribe`,
|
|
349
|
+
discard: `${cancelKeys} to discard`,
|
|
350
|
+
});
|
|
351
|
+
meter.setModelState(controller.modelState);
|
|
352
|
+
recording = { dictation: controller, meter };
|
|
353
|
+
listenForCancel(ctx);
|
|
354
|
+
} catch (error) {
|
|
355
|
+
recording = undefined;
|
|
356
|
+
meter.stop();
|
|
357
|
+
clearCancelListener();
|
|
358
|
+
ctx.ui.notify(`Recording failed to start: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
359
|
+
} finally {
|
|
360
|
+
if (recording?.dictation !== controller) {
|
|
361
|
+
await controller.dispose();
|
|
362
|
+
if (dictation === controller) dictation = undefined;
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
async function toggleCaptureTask(ctx: ExtensionContext): Promise<void> {
|
|
368
|
+
if (shuttingDown) return;
|
|
369
|
+
// A fresh action replaces the transient completion in the shared meter slot.
|
|
370
|
+
cancelCompletionWidgetTimer();
|
|
371
|
+
if (recording) {
|
|
372
|
+
await stopAndTranscribe(ctx);
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// First-press module loading and microphone initialization take a
|
|
377
|
+
// noticeable moment; show feedback until the recording meter takes over.
|
|
378
|
+
// Static text on the shared widget slot: an animated spinner repaints every
|
|
379
|
+
// frame, and the meter replaces plain lines without a component swap.
|
|
380
|
+
const { clearTranscribeWidget, showTranscribeStatus } = await loadVisualizer();
|
|
381
|
+
await loadSettingsOnce();
|
|
382
|
+
if (settings && existsSync(settings.model.path)) {
|
|
383
|
+
showTranscribeStatus(ctx, "Starting microphone…");
|
|
384
|
+
} else {
|
|
385
|
+
// Setup panes replace only the editor, so a status line set here or by
|
|
386
|
+
// the first-press handler in index.ts would sit above every setup step.
|
|
387
|
+
clearTranscribeWidget(ctx);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const { configured, completedFirstRun } = await ensureSettings(ctx);
|
|
391
|
+
if (configured && !completedFirstRun) await startRecording(ctx, configured);
|
|
392
|
+
// The meter shares the widget slot and has replaced the spinner when
|
|
393
|
+
// recording began; clear the spinner only when recording never started.
|
|
394
|
+
// A finished first-run setup leaves the Ready widget in that slot with a
|
|
395
|
+
// hold timer armed, so leave that one alone.
|
|
396
|
+
if (!recording && !completionWidgetTimer) clearTranscribeWidget(ctx);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function runExclusive(
|
|
400
|
+
ctx: ExtensionContext,
|
|
401
|
+
task: () => Promise<void>,
|
|
402
|
+
): Promise<void> {
|
|
403
|
+
if (operation) {
|
|
404
|
+
ctx.ui.notify("A Pi Voice operation is already in progress", "warning");
|
|
405
|
+
return operation;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const nextOperation = task().finally(() => {
|
|
409
|
+
if (operation === nextOperation) operation = undefined;
|
|
410
|
+
});
|
|
411
|
+
operation = nextOperation;
|
|
412
|
+
return nextOperation;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
async function toggleCapture(ctx: ExtensionContext): Promise<void> {
|
|
416
|
+
await runExclusive(ctx, () => toggleCaptureTask(ctx));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
async function showSettings(ctx: ExtensionCommandContext): Promise<void> {
|
|
420
|
+
await dismissCompletionWidget(ctx);
|
|
421
|
+
if (recording) {
|
|
422
|
+
ctx.ui.notify(
|
|
423
|
+
`Stop recording with ${displayShortcut(registeredShortcut)} before opening settings`,
|
|
424
|
+
"warning",
|
|
425
|
+
);
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
let reload = false;
|
|
430
|
+
await runExclusive(ctx, async () => {
|
|
431
|
+
await loadSettingsOnce();
|
|
432
|
+
const hadConfiguration = Boolean(settings && existsSync(settings.model.path));
|
|
433
|
+
const { configured } = await ensureSettings(ctx);
|
|
434
|
+
if (!configured) return;
|
|
435
|
+
if (!hadConfiguration) {
|
|
436
|
+
// First-run setup ends on its Ready message rather than falling
|
|
437
|
+
// straight through into the regular settings menu.
|
|
438
|
+
reload = configured.shortcut !== registeredShortcut;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const { showSettingsMenu } = await import("./settings-menu.js");
|
|
442
|
+
reload = await showSettingsMenu(pi, ctx, configured, registeredShortcut);
|
|
443
|
+
});
|
|
444
|
+
if (reload) {
|
|
445
|
+
await ctx.reload();
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
async function replayOnboarding(ctx: ExtensionCommandContext): Promise<void> {
|
|
450
|
+
await dismissCompletionWidget(ctx);
|
|
451
|
+
if (recording) {
|
|
452
|
+
ctx.ui.notify(
|
|
453
|
+
`Stop recording with ${displayShortcut(registeredShortcut)} before replaying onboarding`,
|
|
454
|
+
"warning",
|
|
455
|
+
);
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
await runExclusive(ctx, async () => {
|
|
460
|
+
await loadSettingsOnce();
|
|
461
|
+
const { runOnboarding } = await import("./onboarding.js");
|
|
462
|
+
const configured = await runOnboarding(
|
|
463
|
+
ctx,
|
|
464
|
+
settings?.shortcut ?? registeredShortcut,
|
|
465
|
+
);
|
|
466
|
+
if (!configured) return;
|
|
467
|
+
rememberSettings(configured);
|
|
468
|
+
// End on the same Ready state as first-run setup. A replay should expose
|
|
469
|
+
// the complete user flow rather than a debug-only completion message.
|
|
470
|
+
await notifyReady(ctx, configured);
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function shutdown(ctx: ExtensionContext): Promise<void> {
|
|
475
|
+
shuttingDown = true;
|
|
476
|
+
cancelCompletionWidgetTimer();
|
|
477
|
+
const disposal = dictation?.dispose();
|
|
478
|
+
recording?.meter.stop();
|
|
479
|
+
clearCancelListener();
|
|
480
|
+
await Promise.all([
|
|
481
|
+
disposal,
|
|
482
|
+
operation?.catch(() => undefined),
|
|
483
|
+
transcriptionService.shutdown().catch(() => undefined),
|
|
484
|
+
]);
|
|
485
|
+
recording = undefined;
|
|
486
|
+
dictation = undefined;
|
|
487
|
+
if (visualizerModulePromise) {
|
|
488
|
+
const visualizer = await visualizerModulePromise.catch(() => undefined);
|
|
489
|
+
visualizer?.clearTranscribeWidget(ctx);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
service: transcriptionService,
|
|
495
|
+
requireConfiguredSettingsForTool,
|
|
496
|
+
toggleCapture,
|
|
497
|
+
showSettings,
|
|
498
|
+
replayOnboarding,
|
|
499
|
+
shutdown,
|
|
500
|
+
};
|
|
501
|
+
}
|