@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,203 @@
1
+ import type {
2
+ Capabilities,
3
+ Session,
4
+ Stream,
5
+ TranscribeModel,
6
+ } from "transcribe-cpp";
7
+ import { convertChineseOutput, isChineseLanguage } from "./chinese.js";
8
+ import { writeLog, type LogLevel } from "./log.js";
9
+ import type { ChineseOutput } from "./settings.js";
10
+
11
+ export type TranscriptionOptions = {
12
+ signal?: AbortSignal;
13
+ language?: string;
14
+ chineseOutput?: ChineseOutput;
15
+ };
16
+
17
+ export type DictationStream = {
18
+ feed(chunk: Float32Array): Promise<void>;
19
+ finalize(): Promise<string>;
20
+ reset(): void;
21
+ };
22
+
23
+ function validateLanguage(
24
+ capabilities: Pick<Capabilities, "languages">,
25
+ language: string | undefined,
26
+ ): void {
27
+ if (language && !capabilities.languages.includes(language)) {
28
+ throw new Error(
29
+ `Configured language ${language} is not supported by this model. Open /voice-settings and choose another language.`,
30
+ );
31
+ }
32
+ }
33
+
34
+ /**
35
+ * Shared final step for batch and streaming output: trim, then apply the
36
+ * Chinese script preference when the detected (or configured) language is
37
+ * Chinese. Keeping this in one place keeps the two paths from drifting.
38
+ */
39
+ async function finishTranscript(
40
+ text: string,
41
+ detectedLanguage: string,
42
+ configuredLanguage: string | undefined,
43
+ chineseOutput: ChineseOutput,
44
+ ): Promise<string> {
45
+ const trimmed = text.trim();
46
+ return isChineseLanguage(detectedLanguage || configuredLanguage || "")
47
+ ? convertChineseOutput(trimmed, chineseOutput)
48
+ : trimmed;
49
+ }
50
+
51
+ class TranscribeCppDictationStream implements DictationStream {
52
+ private closed = false;
53
+
54
+ constructor(
55
+ private readonly session: Session,
56
+ private readonly stream: Stream,
57
+ private readonly language: string | undefined,
58
+ private readonly chineseOutput: ChineseOutput,
59
+ ) {}
60
+
61
+ async feed(chunk: Float32Array): Promise<void> {
62
+ if (this.closed) throw new Error("Dictation stream is closed");
63
+ await this.stream.feed(chunk);
64
+ }
65
+
66
+ async finalize(): Promise<string> {
67
+ if (this.closed) throw new Error("Dictation stream is closed");
68
+
69
+ let text: string;
70
+ let detectedLanguage: string;
71
+ try {
72
+ await this.stream.finalize();
73
+ if (this.closed) {
74
+ throw new Error("Dictation stream was reset while finalizing");
75
+ }
76
+ const snapshot = this.stream.snapshot;
77
+ text = snapshot.text;
78
+ detectedLanguage = snapshot.language;
79
+ } finally {
80
+ this.close();
81
+ }
82
+
83
+ return finishTranscript(text, detectedLanguage, this.language, this.chineseOutput);
84
+ }
85
+
86
+ reset(): void {
87
+ this.close();
88
+ }
89
+
90
+ private close(): void {
91
+ if (this.closed) return;
92
+ this.closed = true;
93
+ // reset() is synchronous at the binding boundary and queues native teardown
94
+ // behind any in-flight feed/finalize before the session is freed.
95
+ this.stream.reset();
96
+ this.session.dispose();
97
+ }
98
+ }
99
+
100
+ // transcribe-cpp's levels (TRANSCRIBE_LOG_LEVEL_*). Its info is a screenful
101
+ // of GPU setup on every model load, so it goes to debug.
102
+ const TRANSCRIBE_LEVELS: Record<number, LogLevel> = { 1: "debug", 2: "warn", 3: "error", 4: "debug" };
103
+ const TRANSCRIBE_CONTINUATION = 5;
104
+ let lastTranscribeLevel: LogLevel = "debug";
105
+
106
+ function routeTranscribeLog(setLogHandler: typeof import("transcribe-cpp").setLogHandler): void {
107
+ setLogHandler((level, message) => {
108
+ if (level !== TRANSCRIBE_CONTINUATION) lastTranscribeLevel = TRANSCRIBE_LEVELS[level] ?? "debug";
109
+ writeLog(lastTranscribeLevel, "transcribe", message);
110
+ });
111
+ }
112
+
113
+ /** A reusable loaded transcribe.cpp model. Calls must be scheduled sequentially. */
114
+ export class TranscribeCppBackend {
115
+ private model: TranscribeModel | undefined;
116
+ private loading: Promise<TranscribeModel> | undefined;
117
+ private disposed = false;
118
+
119
+ constructor(private readonly modelPath: string) {}
120
+
121
+ async prepare(): Promise<void> {
122
+ if (this.model) return;
123
+ if (this.disposed) throw new Error("Transcription backend has been disposed");
124
+
125
+ if (!this.loading) {
126
+ this.loading = import("transcribe-cpp")
127
+ .then(({ TranscribeModel, setLogHandler }) => {
128
+ routeTranscribeLog(setLogHandler);
129
+ return TranscribeModel.load(this.modelPath);
130
+ })
131
+ .then((model) => {
132
+ if (this.disposed) {
133
+ model.dispose();
134
+ throw new Error("Transcription backend was disposed while loading");
135
+ }
136
+ this.model = model;
137
+ return model;
138
+ });
139
+ }
140
+
141
+ try {
142
+ await this.loading;
143
+ } finally {
144
+ this.loading = undefined;
145
+ }
146
+ }
147
+
148
+ async startStream(
149
+ options: TranscriptionOptions = {},
150
+ ): Promise<DictationStream | undefined> {
151
+ await this.prepare();
152
+ const model = this.model!;
153
+ const capabilities = model.capabilities;
154
+ validateLanguage(capabilities, options.language);
155
+ if (!capabilities.supportsStreaming) return undefined;
156
+
157
+ const session = model.createSession();
158
+ try {
159
+ const stream = await session.stream({
160
+ timestamps: "none",
161
+ ...(options.language ? { language: options.language } : {}),
162
+ });
163
+ return new TranscribeCppDictationStream(
164
+ session,
165
+ stream,
166
+ options.language,
167
+ options.chineseOutput ?? "simplified",
168
+ );
169
+ } catch (error) {
170
+ session.dispose();
171
+ throw error;
172
+ }
173
+ }
174
+
175
+ async transcribe(
176
+ pcm: Float32Array,
177
+ options: TranscriptionOptions = {},
178
+ ): Promise<string> {
179
+ if (pcm.length === 0) throw new Error("No audio samples were provided");
180
+ await this.prepare();
181
+ const model = this.model!;
182
+ validateLanguage(model.capabilities, options.language);
183
+
184
+ const result = await model.transcribe(pcm, {
185
+ signal: options.signal,
186
+ timestamps: "none",
187
+ ...(options.language ? { language: options.language } : {}),
188
+ });
189
+ return finishTranscript(
190
+ result.text,
191
+ result.language,
192
+ options.language,
193
+ options.chineseOutput ?? "simplified",
194
+ );
195
+ }
196
+
197
+ async dispose(): Promise<void> {
198
+ this.disposed = true;
199
+ await this.loading?.catch(() => undefined);
200
+ this.model?.dispose();
201
+ this.model = undefined;
202
+ }
203
+ }
package/src/try-it.ts ADDED
@@ -0,0 +1,318 @@
1
+ import { rawKeyHint, type ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ type Component,
4
+ Text,
5
+ truncateToWidth,
6
+ type KeybindingsManager,
7
+ type TUI,
8
+ } from "@earendil-works/pi-tui";
9
+ import { microphonePermission, openMicrophone } from "./audio.js";
10
+ import { isMacOSPermissionDenied } from "./audio-errors.js";
11
+ import { markKeyPress, watchEventLoop } from "./log.js";
12
+ import { getCatalogModel } from "./catalog.js";
13
+ import { DictationController, type DictationControllerOptions } from "./dictation-controller.js";
14
+ import { microphoneSummary } from "./microphone-picker.js";
15
+ import { matchesShortcut, VoiceKeys } from "./keybindings.js";
16
+ import { COMFORTABLE_REAL_TIME_FACTOR } from "./recommendations.js";
17
+ import type { TranscribeSettings } from "./settings.js";
18
+ import { displayShortcut } from "./shortcut-core.js";
19
+ import { TranscriptionService } from "./transcription-service.js";
20
+ import { TranscriptPreview } from "./transcript-preview.js";
21
+ import { editorBorder, onboardingHeader, PANEL_PADDING, panelBorder, paneRowBudget } from "./ui-components.js";
22
+ import {
23
+ formatTranscriptionSummary,
24
+ METER_UPDATE_MS,
25
+ renderMeterLine,
26
+ SpectrumAnalyzer,
27
+ } from "./visualizer.js";
28
+
29
+ type UiTheme = ExtensionContext["ui"]["theme"];
30
+
31
+ type TryItPaneOptions = Pick<DictationControllerOptions, "openCapture" | "now"> & {
32
+ /** Shown only before the first recording attempt when macOS has not asked yet. */
33
+ showMacPermissionNote?: boolean;
34
+ };
35
+
36
+ export type TryItResult =
37
+ | { action: "done" }
38
+ | { action: "skip" }
39
+ | { action: "shortcut" }
40
+ | { action: "microphone" }
41
+ | { action: "model" };
42
+
43
+ /** Shorter takes are dominated by fixed costs and say little about speed. */
44
+ const MIN_SPEECH_SECONDS_TO_JUDGE = 5;
45
+
46
+ export function realTimeFactor(speechSeconds: number, transcribeSeconds: number): number {
47
+ return speechSeconds / Math.max(transcribeSeconds, 0.05);
48
+ }
49
+
50
+ export function needsFasterModel(speechSeconds: number, transcribeSeconds: number): boolean {
51
+ return (
52
+ speechSeconds >= MIN_SPEECH_SECONDS_TO_JUDGE &&
53
+ realTimeFactor(speechSeconds, transcribeSeconds) < COMFORTABLE_REAL_TIME_FACTOR
54
+ );
55
+ }
56
+
57
+ /** Presentation and navigation only; native resources belong to the controller. */
58
+ export class TryItPane implements Component {
59
+ private readonly dictation: DictationController;
60
+ private readonly analyzer = new SpectrumAnalyzer();
61
+ private readonly preview: TranscriptPreview;
62
+ private readonly keys: VoiceKeys;
63
+ private nextPaintAt = 0;
64
+ private disposed = false;
65
+ private closed = false;
66
+ private showMacPermissionNote: boolean;
67
+ private modelPreparationScheduled = false;
68
+ /** Onboarding loads models in the background; watch the loop while it's up. */
69
+ private readonly unwatch = watchEventLoop();
70
+
71
+ constructor(
72
+ private readonly tui: TUI,
73
+ private readonly theme: UiTheme,
74
+ keybindings: KeybindingsManager,
75
+ private readonly settings: TranscribeSettings,
76
+ service: Pick<TranscriptionService, "reserveDictation">,
77
+ private readonly done: (result: TryItResult) => void,
78
+ options: TryItPaneOptions = { openCapture: openMicrophone },
79
+ ) {
80
+ this.keys = new VoiceKeys(keybindings);
81
+ this.preview = new TranscriptPreview(this.keys);
82
+ this.showMacPermissionNote = options.showMacPermissionNote ?? false;
83
+ this.dictation = new DictationController(service, {
84
+ openCapture: options.openCapture,
85
+ now: options.now,
86
+ onChange: () => this.refresh(),
87
+ onAudio: (chunk) => {
88
+ this.analyzer.push(chunk);
89
+ const now = Date.now();
90
+ if (now < this.nextPaintAt) return;
91
+ this.nextPaintAt = now + METER_UPDATE_MS;
92
+ this.refresh();
93
+ },
94
+ });
95
+ }
96
+
97
+ private refresh(): void {
98
+ if (!this.closed && !this.disposed) this.tui.requestRender();
99
+ }
100
+
101
+ invalidate(): void {
102
+ this.preview.invalidate();
103
+ this.refresh();
104
+ }
105
+
106
+ render(width: number): string[] {
107
+ // The native backend performs some synchronous first-use initialization.
108
+ // Start it only after this first render has put Try It on screen, so the
109
+ // model picker never looks stuck while the next model is being prepared.
110
+ if (!this.modelPreparationScheduled) {
111
+ this.modelPreparationScheduled = true;
112
+ setImmediate(() => {
113
+ if (!this.closed && !this.disposed) this.dictation.prepare(this.settings);
114
+ });
115
+ }
116
+
117
+ const state = this.dictation.state;
118
+ const shortcut = displayShortcut(this.settings.shortcut);
119
+ const modelName = getCatalogModel(this.settings.model.id)?.name ?? this.settings.model.id;
120
+ const title = "Try it";
121
+ const fg = (color: Parameters<UiTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
122
+ const text = (value: string) => new Text(value, PANEL_PADDING, 0).render(width);
123
+ const line = (value: string) => truncateToWidth(` ${value}`, width);
124
+ let activity = "";
125
+ let content = fg("dim", "Your transcript will appear here.");
126
+ let details = "";
127
+ if (state.phase === "listening") {
128
+ activity = renderMeterLine(this.theme, {
129
+ bands: this.analyzer.bands, elapsedMs: this.dictation.elapsedMs,
130
+ modelState: this.dictation.modelState,
131
+ microphoneStopped: this.dictation.interruption !== undefined,
132
+ });
133
+ } else if (
134
+ (state.phase === "idle" || state.phase === "ready") &&
135
+ this.dictation.modelState === "loading"
136
+ ) {
137
+ activity = fg("muted", `Loading ${modelName}… You can start recording now.`);
138
+ } else if (state.phase === "transcribing") {
139
+ activity = fg("accent", "Transcribing…");
140
+ } else if (state.phase === "starting") {
141
+ activity = fg("muted", "Starting microphone…");
142
+ } else if (state.phase === "cancelling") {
143
+ activity = fg("muted", "Cancelling…");
144
+ } else if (state.phase === "result") {
145
+ const { text: transcript, speechSeconds, transcribeSeconds, interruption } = state.result;
146
+ content = transcript || fg("muted", "No speech detected");
147
+ activity = fg("muted", formatTranscriptionSummary(speechSeconds, transcribeSeconds));
148
+ if (interruption) {
149
+ details = fg("warning", `The microphone stopped after ${speechSeconds.toFixed(1)}s: ${interruption.message}`);
150
+ } else if (needsFasterModel(speechSeconds, transcribeSeconds)) {
151
+ details = fg("warning", `Slow on this machine? Press ${this.keys.keyText("voice.tryIt.model")} to try another model.`);
152
+ }
153
+ } else if (state.phase === "error") {
154
+ activity = fg("error", state.stage === "model" ? "Could not load the model" : state.stage === "capture" ? "Microphone capture failed" : "Transcription failed");
155
+ const message = state.cause instanceof Error ? state.cause.message : String(state.cause);
156
+ details = fg("error", message);
157
+ if (state.stage === "capture" && isMacOSPermissionDenied(state.cause)) {
158
+ details += "\nCheck System Settings → Privacy & Security → Microphone for your terminal app.";
159
+ }
160
+ }
161
+ this.preview.setText(content);
162
+
163
+ let hints: string;
164
+ if (state.phase === "listening") {
165
+ hints = `${rawKeyHint(this.settings.shortcut, "to transcribe")} ${this.keys.hint("tui.select.cancel", "to discard")}`;
166
+ } else if (
167
+ state.phase === "transcribing" ||
168
+ state.phase === "starting" ||
169
+ state.phase === "cancelling"
170
+ ) {
171
+ hints = this.keys.hint("tui.select.cancel", "cancel");
172
+ } else if (state.phase === "result") {
173
+ hints = `${this.keys.hint("tui.select.confirm", "looks good")} ${this.keys.hint("tui.select.cancel", "done")} ${rawKeyHint(this.settings.shortcut, "try again")}`;
174
+ } else {
175
+ hints = `${rawKeyHint(this.settings.shortcut, state.phase === "error" ? "try again" : "record")} ${this.keys.hint("tui.select.cancel", "skip")}`;
176
+ }
177
+
178
+ const setting = (label: string, value: string, key: string, compact: boolean) => {
179
+ const suffix = ` (${key} to change)`;
180
+ const labelColumn = compact ? `${label}: ` : `${label}:`.padEnd(12);
181
+ const body = compact
182
+ ? truncateToWidth(`${labelColumn}${value}`, Math.max(1, width - 2 - suffix.length))
183
+ : `${labelColumn}${value}`;
184
+ return fg("muted", body) + fg("dim", suffix);
185
+ };
186
+ const instructions = (compact: boolean) => compact
187
+ ? `${shortcut} starts/stops recording`
188
+ : `Press ${shortcut} to record, start speaking, then press again to transcribe.`;
189
+ const topChrome = (compact: boolean): string[] => [
190
+ ...panelBorder(this.theme).render(width),
191
+ ...(compact ? [] : [""]),
192
+ ...onboardingHeader(this.theme, title, 3).render(width),
193
+ ...(compact ? [] : [""]),
194
+ ...(compact ? [line(instructions(true))] : text(instructions(false))),
195
+ ...(compact ? [] : [""]),
196
+ ...(activity ? (compact ? [line(activity)] : text(activity)) : []),
197
+ ];
198
+ const bottomChrome = (compact: boolean): string[] => {
199
+ const render = compact ? (value: string) => [line(value)] : text;
200
+ const permission = !compact && this.showMacPermissionNote
201
+ ? [
202
+ ...text(fg("muted", "macOS will ask for microphone access the first time. Your terminal may need to be restarted.")),
203
+ "",
204
+ ]
205
+ : compact || details ? [] : [""];
206
+ return [
207
+ ...(details ? [...text(details), ...(compact ? [] : [""])] : []),
208
+ ...permission,
209
+ ...render(setting("Shortcut", shortcut, this.keys.keyText("voice.tryIt.shortcut"), compact)),
210
+ ...render(setting("Microphone", microphoneSummary(this.settings.microphone), this.keys.keyText("voice.tryIt.microphone"), compact)),
211
+ ...render(setting("Model", modelName, this.keys.keyText("voice.tryIt.model"), compact)),
212
+ ...(compact ? [] : [""]),
213
+ ...text(hints),
214
+ ...(compact ? [] : [""]),
215
+ ...panelBorder(this.theme).render(width),
216
+ ];
217
+ };
218
+
219
+ const budget = Math.max(1, paneRowBudget(this.tui) ?? 32);
220
+ const frame = budget >= 6 ? editorBorder(this.theme).render(width) : [];
221
+ const frameRows = frame.length * 2;
222
+ let top = topChrome(false);
223
+ let bottom = bottomChrome(false);
224
+ // Reserve useful room for a transcript or error before switching to the compact chrome.
225
+ const previewReserve = state.phase === "result" || state.phase === "error" ? 5 : 3;
226
+ if (top.length + bottom.length + frameRows + previewReserve > budget) {
227
+ top = topChrome(true);
228
+ bottom = bottomChrome(true);
229
+ }
230
+ if (top.length + bottom.length + frameRows + 1 > budget) {
231
+ // Tiny terminals: drop settings, but keep the task and current actions visible.
232
+ top = [
233
+ ...onboardingHeader(this.theme, title, 3).render(width),
234
+ line(instructions(true)),
235
+ ];
236
+ bottom = [...text(hints), ...panelBorder(this.theme).render(width)];
237
+ }
238
+ bottom = bottom.slice(0, Math.max(0, budget - frameRows - 1));
239
+ top = top.slice(0, Math.max(0, budget - bottom.length - frameRows - 1));
240
+ const available = Math.max(1, budget - top.length - bottom.length - frameRows);
241
+ const preview = this.preview.render(width, available, (value) => fg("dim", value));
242
+ return [...top, ...frame, ...preview, ...frame, ...bottom];
243
+ }
244
+
245
+ private start(): void {
246
+ markKeyPress();
247
+ this.showMacPermissionNote = false;
248
+ this.preview.setText("");
249
+ this.analyzer.reset();
250
+ this.nextPaintAt = 0;
251
+ void this.dictation.start(this.settings);
252
+ }
253
+
254
+ private leave(result: TryItResult): void {
255
+ if (this.closed || this.disposed) return;
256
+ this.closed = true;
257
+ void this.dictation.dispose();
258
+ this.done(result);
259
+ }
260
+ handleInput(data: string): void {
261
+ if (this.closed || this.disposed) return;
262
+ const phase = this.dictation.state.phase;
263
+ if (matchesShortcut(data, this.settings.shortcut)) {
264
+ if (phase === "listening") {
265
+ void this.dictation.stop();
266
+ } else if (["idle", "ready", "result", "error"].includes(phase)) {
267
+ this.start();
268
+ }
269
+ return;
270
+ }
271
+ if (this.keys.matches(data, "tui.select.cancel")) {
272
+ if (["starting", "listening", "transcribing", "cancelling"].includes(phase)) {
273
+ void this.dictation.cancel();
274
+ } else {
275
+ this.leave({ action: phase === "result" ? "done" : "skip" });
276
+ }
277
+ return;
278
+ }
279
+ if (!["idle", "ready", "result", "error"].includes(phase)) return;
280
+ if ((phase === "result" || phase === "error") && this.preview.handleInput(data)) {
281
+ this.refresh();
282
+ return;
283
+ }
284
+ if (this.keys.matches(data, "tui.select.confirm")) {
285
+ if (phase === "result") this.leave({ action: "done" });
286
+ return;
287
+ }
288
+ if (this.keys.matches(data, "voice.tryIt.microphone")) {
289
+ this.leave({ action: "microphone" });
290
+ } else if (this.keys.matches(data, "voice.tryIt.shortcut")) {
291
+ this.leave({ action: "shortcut" });
292
+ } else if (this.keys.matches(data, "voice.tryIt.model")) {
293
+ this.leave({ action: "model" });
294
+ }
295
+ }
296
+
297
+ dispose(): Promise<void> {
298
+ this.disposed = true;
299
+ this.unwatch();
300
+ return this.dictation.dispose();
301
+ }
302
+ }
303
+
304
+ export async function tryVoice(ctx: ExtensionContext, settings: TranscribeSettings): Promise<TryItResult | undefined> {
305
+ const service = new TranscriptionService();
306
+ let pane: TryItPane | undefined;
307
+ try {
308
+ return await ctx.ui.custom<TryItResult>((tui, theme, keybindings, done) =>
309
+ (pane = new TryItPane(tui, theme, keybindings, settings, service, done, {
310
+ openCapture: openMicrophone,
311
+ showMacPermissionNote: process.platform === "darwin" && microphonePermission() === "not-determined",
312
+ })),
313
+ );
314
+ } finally {
315
+ await pane?.dispose();
316
+ await service.shutdown();
317
+ }
318
+ }