@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.
- package/README.md +54 -6
- package/package.json +2 -2
- package/src/chinese.ts +15 -0
- package/src/file-transcription.ts +13 -3
- package/src/log.ts +1 -1
- package/src/onboarding.ts +72 -5
- package/src/openai-asr.ts +4 -4
- package/src/polish.ts +165 -0
- package/src/remote-asr.ts +212 -0
- package/src/runtime.ts +17 -8
- package/src/settings-menu.ts +149 -174
- package/src/settings.ts +26 -0
- package/src/try-it.ts +8 -3
- package/src/ui-components.ts +8 -2
|
@@ -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 `
|
|
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
|
|
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
|
-
|
|
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
|
|
455
|
-
const { configured } = await ensureSettings(ctx);
|
|
461
|
+
const { configured, completedFirstRun } = await ensureSettings(ctx);
|
|
456
462
|
if (!configured) return;
|
|
457
|
-
if (
|
|
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
|
});
|