@tychenjiajun/pi-voice 0.2.0 → 0.4.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.
@@ -0,0 +1,212 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Box, Container, Input, Spacer, Text, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import { VoiceKeys } from "./keybindings.js";
4
+ import type { BackendSettings } from "./settings.js";
5
+ import {
6
+ LIST_PADDING,
7
+ panelBorder,
8
+ SingleSelectPicker,
9
+ type SingleSelectChoice,
10
+ } from "./ui-components.js";
11
+
12
+ export type BackendType = "transcribe-cpp" | "openai-asr";
13
+
14
+ /** Pick the transcription backend; shared by first-run setup and the settings menu. */
15
+ export async function chooseBackendType(
16
+ ctx: ExtensionContext,
17
+ current: BackendSettings,
18
+ ): Promise<BackendType | undefined> {
19
+ const choices: SingleSelectChoice<BackendType>[] = [
20
+ {
21
+ value: "transcribe-cpp",
22
+ label: "Local model",
23
+ description: "Transcribe fully on-device with the downloaded model; works offline",
24
+ },
25
+ {
26
+ value: "openai-asr",
27
+ label: "Remote ASR (OpenAI-compatible)",
28
+ description:
29
+ "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
30
+ },
31
+ ];
32
+ return ctx.ui.custom<BackendType | undefined>((tui, theme, keybindings, done) =>
33
+ new SingleSelectPicker(
34
+ tui,
35
+ theme,
36
+ keybindings,
37
+ choices,
38
+ current.type,
39
+ { title: "Transcription backend", cancelLabel: "back" },
40
+ done,
41
+ ),
42
+ );
43
+ }
44
+
45
+ function validateBaseUrl(value: string): string | undefined {
46
+ if (!value) return "Base URL is required";
47
+ try {
48
+ const url = new URL(value);
49
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
50
+ return "Base URL must start with http:// or https://";
51
+ }
52
+ } catch {
53
+ return "Base URL must be a valid URL";
54
+ }
55
+ return undefined;
56
+ }
57
+
58
+ function validateEnvName(value: string): string | undefined {
59
+ if (!value) return "Environment variable name is required";
60
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(value)) {
61
+ return "Use a plain environment variable name, e.g. OPENAI_API_KEY";
62
+ }
63
+ return undefined;
64
+ }
65
+
66
+ function remoteBackendsEqual(
67
+ left: Extract<BackendSettings, { type: "openai-asr" }>,
68
+ right: Extract<BackendSettings, { type: "openai-asr" }>,
69
+ ): boolean {
70
+ return (
71
+ left.baseUrl === right.baseUrl &&
72
+ left.model === right.model &&
73
+ left.apiKeyEnv === right.apiKeyEnv
74
+ );
75
+ }
76
+
77
+ /** Warn when the named variable is unset, so remote calls silently fall back to local. */
78
+ export function warnMissingApiKey(ctx: ExtensionContext, apiKeyEnv: string): void {
79
+ if (process.env[apiKeyEnv]) return;
80
+ ctx.ui.notify(
81
+ `${apiKeyEnv} is not set in Pi's environment; remote transcription will fall back to the local model until it is set`,
82
+ "warning",
83
+ );
84
+ }
85
+
86
+ /**
87
+ * Single-field text prompt built on pi-tui's Input. Empty input may be legal
88
+ * (the caller decides via validate); Escape backs out of the whole flow.
89
+ */
90
+ function promptText(
91
+ ctx: ExtensionContext,
92
+ options: {
93
+ title: string;
94
+ description?: string;
95
+ value: string;
96
+ placeholder?: string;
97
+ validate?: (value: string) => string | undefined;
98
+ },
99
+ ): Promise<string | undefined> {
100
+ return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) => {
101
+ const keys = new VoiceKeys(keybindings);
102
+ const input = new Input();
103
+ input.setValue(options.value);
104
+ let error: string | undefined;
105
+ const container = new Container();
106
+
107
+ const rebuild = (): void => {
108
+ container.clear();
109
+ container.addChild(panelBorder(theme));
110
+ container.addChild(new Spacer(1));
111
+ container.addChild(new Text(theme.fg("accent", theme.bold(options.title)), 1, 0));
112
+ if (options.description) {
113
+ container.addChild(new Text(theme.fg("muted", options.description), 1, 0));
114
+ }
115
+ container.addChild(new Spacer(1));
116
+ if (error) {
117
+ container.addChild(new Text(theme.fg("error", error), 1, 0));
118
+ container.addChild(new Spacer(1));
119
+ }
120
+ if (!input.getValue() && options.placeholder) {
121
+ container.addChild(new Text(theme.fg("dim", options.placeholder), 2, 0));
122
+ }
123
+ const inputBox = new Box(LIST_PADDING, 0);
124
+ inputBox.addChild(input);
125
+ container.addChild(inputBox);
126
+ container.addChild(new Spacer(1));
127
+ container.addChild(
128
+ new Text(
129
+ `${keys.hint("tui.input.submit", "save")} ${keys.hint("tui.select.cancel", "back")}`,
130
+ 1,
131
+ 0,
132
+ ),
133
+ );
134
+ container.addChild(new Spacer(1));
135
+ container.addChild(panelBorder(theme));
136
+ };
137
+ rebuild();
138
+
139
+ input.onSubmit = (value) => {
140
+ const problem = options.validate?.(value.trim());
141
+ if (problem) {
142
+ error = problem;
143
+ rebuild();
144
+ tui.requestRender();
145
+ return;
146
+ }
147
+ done(value.trim());
148
+ };
149
+ input.onEscape = () => done(undefined);
150
+
151
+ return {
152
+ get focused() {
153
+ return input.focused;
154
+ },
155
+ set focused(value: boolean) {
156
+ input.focused = value;
157
+ },
158
+ render(width: number): string[] {
159
+ return container
160
+ .render(width)
161
+ .map((line) => truncateToWidth(line, width, ""));
162
+ },
163
+ handleInput(data: string): void {
164
+ input.handleInput(data);
165
+ },
166
+ invalidate(): void {
167
+ container.invalidate();
168
+ },
169
+ };
170
+ });
171
+ }
172
+
173
+ /**
174
+ * Collect base URL, model, and API key env var for an OpenAI-compatible ASR
175
+ * endpoint. Returns undefined if the user backs out of any field. `current`
176
+ * pre-fills the prompts when editing an existing backend.
177
+ */
178
+ export async function promptRemoteBackend(
179
+ ctx: ExtensionContext,
180
+ current?: Extract<BackendSettings, { type: "openai-asr" }>,
181
+ ): Promise<Extract<BackendSettings, { type: "openai-asr" }> | undefined> {
182
+ const baseUrl = await promptText(ctx, {
183
+ title: "ASR base URL",
184
+ description: "OpenAI-compatible endpoint root, without the /chat/completions path",
185
+ value: current?.baseUrl ?? "",
186
+ placeholder: "https://api.example.com/v1",
187
+ validate: validateBaseUrl,
188
+ });
189
+ if (baseUrl === undefined) return undefined;
190
+
191
+ const model = await promptText(ctx, {
192
+ title: "ASR model",
193
+ value: current?.model ?? "",
194
+ placeholder: "whisper-1",
195
+ validate: (value) => (value ? undefined : "Model name is required"),
196
+ });
197
+ if (model === undefined) return undefined;
198
+
199
+ const apiKeyEnv = await promptText(ctx, {
200
+ title: "API key environment variable",
201
+ description:
202
+ "The key itself is read from this variable at runtime; it is never stored in settings",
203
+ value: current?.apiKeyEnv ?? "",
204
+ placeholder: "OPENAI_API_KEY",
205
+ validate: validateEnvName,
206
+ });
207
+ if (apiKeyEnv === undefined) return undefined;
208
+
209
+ const backend = { type: "openai-asr" as const, baseUrl, model, apiKeyEnv };
210
+ if (current && remoteBackendsEqual(current, backend)) return undefined;
211
+ return backend;
212
+ }
package/src/runtime.ts CHANGED
@@ -11,6 +11,7 @@ import { isMacOSPermissionDenied } from "./audio-errors.js";
11
11
  import { logStep, watchEventLoop } from "./log.js";
12
12
  import { DictationController } from "./dictation-controller.js";
13
13
  import { VoiceKeys } from "./keybindings.js";
14
+ import { polishTranscript } from "./polish.js";
14
15
  import type { TranscribeSettings } from "./settings.js";
15
16
  import { displayShortcut } from "./shortcut-core.js";
16
17
  import { TranscriptionService } from "./transcription-service.js";
@@ -19,6 +20,7 @@ import type { RecordingMeter } from "./visualizer.js";
19
20
  type ActiveRecording = {
20
21
  dictation: DictationController;
21
22
  meter: RecordingMeter;
23
+ settings: TranscribeSettings;
22
24
  /** Ends the event-loop watch held for the whole recording. */
23
25
  unwatch: () => void;
24
26
  };
@@ -46,7 +48,7 @@ function captureErrorMessage(error: unknown): string {
46
48
 
47
49
  function transcriptionErrorMessage(error: unknown): string {
48
50
  const message = error instanceof Error ? error.message : String(error);
49
- return `Local transcription failed: ${message}`;
51
+ return `Transcription failed: ${message}`;
50
52
  }
51
53
 
52
54
  export function createPiVoiceRuntime(
@@ -146,6 +148,8 @@ export function createPiVoiceRuntime(
146
148
  currentModelId: previous.model.id,
147
149
  microphone: previous.microphone,
148
150
  backend: previous.backend,
151
+ polishEnabled: previous.polishEnabled,
152
+ polishModel: previous.polishModel,
149
153
  postActivation: "advance",
150
154
  });
151
155
  if (configured) rememberSettings(configured);
@@ -185,12 +189,12 @@ export function createPiVoiceRuntime(
185
189
  await loadSettingsOnce();
186
190
  if (settingsReadWarning && !settings) {
187
191
  throw new Error(
188
- `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to configure a local model, then retry transcribe_file.`,
192
+ `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to set up Pi Voice (a local model, and optionally a remote ASR endpoint), then retry transcribe_file.`,
189
193
  );
190
194
  }
191
195
  if (!settings) {
192
196
  throw new Error(
193
- "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.",
197
+ "Pi Voice is not configured. Ask the user to run /voice-settings in Pi's interactive TUI once to set up Pi Voice (choose and download a local model, and optionally a remote ASR endpoint), then retry transcribe_file.",
194
198
  );
195
199
  }
196
200
  if (!existsSync(settings.model.path)) {
@@ -293,7 +297,10 @@ export function createPiVoiceRuntime(
293
297
  if (!result) {
294
298
  await reportDictationError(ctx, active.dictation);
295
299
  } else if (result.text) {
296
- ctx.ui.pasteToEditor(result.text);
300
+ const text = active.settings.polishEnabled
301
+ ? await polishTranscript(ctx, result.text, true, active.settings.polishModel)
302
+ : result.text;
303
+ ctx.ui.pasteToEditor(text);
297
304
  showTranscribeStatus(
298
305
  ctx,
299
306
  formatTranscriptionSummary(result.speechSeconds, result.transcribeSeconds),
@@ -369,7 +376,7 @@ export function createPiVoiceRuntime(
369
376
  `${rawKeyHint(stopKeys, "to transcribe")} ${keys.hint("voice.dictation.cancel", "to discard")}`,
370
377
  );
371
378
  updateMeter();
372
- recording = { dictation: controller, meter, unwatch: watchEventLoop() };
379
+ recording = { dictation: controller, meter, settings: configured, unwatch: watchEventLoop() };
373
380
  listenForKeys(ctx);
374
381
  } catch (error) {
375
382
  recording?.unwatch();
@@ -451,15 +458,17 @@ export function createPiVoiceRuntime(
451
458
  let reload = false;
452
459
  await runExclusive(ctx, async () => {
453
460
  await loadSettingsOnce();
454
- const hadConfiguration = Boolean(settings && existsSync(settings.model.path));
455
- const { configured } = await ensureSettings(ctx);
461
+ const { configured, completedFirstRun } = await ensureSettings(ctx);
456
462
  if (!configured) return;
457
- if (!hadConfiguration) {
463
+ if (completedFirstRun) {
458
464
  // First-run setup ends on its Ready message rather than falling
459
465
  // straight through into the regular settings menu.
460
466
  reload = configured.shortcut !== registeredShortcut;
461
467
  return;
462
468
  }
469
+ // A recovered missing model file also lands here: the user asked for
470
+ // settings, so continue into them instead of making them run the
471
+ // command a second time.
463
472
  const { showSettingsMenu } = await import("./settings-menu.js");
464
473
  reload = await showSettingsMenu(pi, ctx, configured, registeredShortcut);
465
474
  });