@tychenjiajun/pi-voice 0.3.0 → 0.5.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 +40 -9
- package/package.json +1 -1
- package/src/chinese.ts +15 -0
- package/src/dictation-controller.ts +6 -2
- package/src/file-transcription.ts +9 -8
- package/src/onboarding.ts +76 -7
- package/src/openai-asr.ts +13 -47
- package/src/remote-asr.ts +212 -0
- package/src/runtime.ts +13 -11
- package/src/settings-menu.ts +66 -206
- package/src/settings.ts +79 -20
- package/src/transcription-service.ts +15 -13
- package/src/try-it.ts +15 -6
- package/src/ui-components.ts +8 -2
package/src/settings.ts
CHANGED
|
@@ -21,8 +21,8 @@ export type ChineseOutput = "simplified" | "traditional-taiwan" | "traditional-h
|
|
|
21
21
|
/**
|
|
22
22
|
* Where recordings are transcribed. `transcribe-cpp` runs a local catalog
|
|
23
23
|
* model; `openai-asr` uploads to any OpenAI-compatible chat-completions ASR
|
|
24
|
-
* endpoint via the `input_audio` content block. The
|
|
25
|
-
*
|
|
24
|
+
* endpoint via the `input_audio` content block. The two are exclusive: remote
|
|
25
|
+
* mode stores no local model, and local mode has no remote configuration.
|
|
26
26
|
*/
|
|
27
27
|
export type BackendSettings =
|
|
28
28
|
| { type: "transcribe-cpp" }
|
|
@@ -57,7 +57,11 @@ export type TranscribeSettings = {
|
|
|
57
57
|
polishEnabled?: boolean;
|
|
58
58
|
/** Optional post-processing model override; absent uses the agent's current model. */
|
|
59
59
|
polishModel?: PolishModelRef;
|
|
60
|
-
|
|
60
|
+
/**
|
|
61
|
+
* The local catalog model. Present only in local mode (backend
|
|
62
|
+
* `transcribe-cpp`); remote mode stores no local model.
|
|
63
|
+
*/
|
|
64
|
+
model?: {
|
|
61
65
|
source: "catalog";
|
|
62
66
|
id: string;
|
|
63
67
|
path: string;
|
|
@@ -164,6 +168,17 @@ function normalizePolishModel(value: unknown): PolishModelRef | undefined {
|
|
|
164
168
|
return { provider: value.provider.trim(), id: value.id.trim() };
|
|
165
169
|
}
|
|
166
170
|
|
|
171
|
+
function validateModelValue(
|
|
172
|
+
value: unknown,
|
|
173
|
+
): { id: string; path: string; catalogModel: CatalogModel } | undefined {
|
|
174
|
+
if (!isObject(value) || value.source !== "catalog") return undefined;
|
|
175
|
+
if (typeof value.id !== "string") return undefined;
|
|
176
|
+
const catalogModel = getCatalogModel(value.id);
|
|
177
|
+
if (!catalogModel) return undefined;
|
|
178
|
+
if (typeof value.path !== "string" || value.path.length === 0) return undefined;
|
|
179
|
+
return { id: value.id, path: value.path, catalogModel };
|
|
180
|
+
}
|
|
181
|
+
|
|
167
182
|
function validateSettings(value: unknown): TranscribeSettings | undefined {
|
|
168
183
|
if (!isObject(value) || value.version !== SETTINGS_VERSION) return undefined;
|
|
169
184
|
const backend = validateBackend(value.backend);
|
|
@@ -171,36 +186,43 @@ function validateSettings(value: unknown): TranscribeSettings | undefined {
|
|
|
171
186
|
const shortcut =
|
|
172
187
|
typeof value.shortcut === "string" ? normalizeShortcut(value.shortcut) : undefined;
|
|
173
188
|
if (!shortcut) return undefined;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
if (
|
|
189
|
+
|
|
190
|
+
const selected = validateModelValue(value.model);
|
|
191
|
+
// Local mode needs a model; remote mode drops any leftover local model so
|
|
192
|
+
// settings are ever either local-only or remote-only, never both.
|
|
193
|
+
if (backend.type === "transcribe-cpp" && !selected) return undefined;
|
|
179
194
|
|
|
180
195
|
const preferredLanguages = normalizeLanguages(value.preferredLanguages);
|
|
181
196
|
const microphone = validateMicrophone(value.microphone);
|
|
182
197
|
if (!preferredLanguages || !microphone) return undefined;
|
|
183
198
|
const polishModel = normalizePolishModel(value.polishModel);
|
|
184
199
|
|
|
200
|
+
// The remote endpoint detects the language itself; the stored language only
|
|
201
|
+
// triggers Chinese script conversion, so any previous value is kept as-is.
|
|
202
|
+
const transcriptionLanguage =
|
|
203
|
+
backend.type === "openai-asr"
|
|
204
|
+
? typeof value.transcriptionLanguage === "string" && value.transcriptionLanguage
|
|
205
|
+
? value.transcriptionLanguage
|
|
206
|
+
: "auto"
|
|
207
|
+
: transcriptionLanguageForModel(
|
|
208
|
+
value.transcriptionLanguage,
|
|
209
|
+
selected!.catalogModel,
|
|
210
|
+
preferredLanguages,
|
|
211
|
+
);
|
|
212
|
+
|
|
185
213
|
return {
|
|
186
214
|
version: SETTINGS_VERSION,
|
|
187
215
|
backend,
|
|
188
216
|
shortcut,
|
|
189
217
|
preferredLanguages,
|
|
190
|
-
transcriptionLanguage
|
|
191
|
-
value.transcriptionLanguage,
|
|
192
|
-
model,
|
|
193
|
-
preferredLanguages,
|
|
194
|
-
),
|
|
218
|
+
transcriptionLanguage,
|
|
195
219
|
chineseOutput: validateChineseOutput(value.chineseOutput),
|
|
196
220
|
microphone,
|
|
197
221
|
polishEnabled: value.polishEnabled === true,
|
|
198
222
|
...(polishModel ? { polishModel } : {}),
|
|
199
|
-
|
|
200
|
-
source: "catalog",
|
|
201
|
-
|
|
202
|
-
path: value.model.path,
|
|
203
|
-
},
|
|
223
|
+
...(backend.type === "transcribe-cpp"
|
|
224
|
+
? { model: { source: "catalog" as const, id: selected!.id, path: selected!.path } }
|
|
225
|
+
: {}),
|
|
204
226
|
};
|
|
205
227
|
}
|
|
206
228
|
|
|
@@ -262,7 +284,10 @@ type ModelSettingsOptions = {
|
|
|
262
284
|
transcriptionLanguage?: TranscriptionLanguage;
|
|
263
285
|
chineseOutput?: ChineseOutput;
|
|
264
286
|
microphone?: MicrophoneSetting;
|
|
265
|
-
/**
|
|
287
|
+
/**
|
|
288
|
+
* Preserved across model re-selection when local; picking a model always
|
|
289
|
+
* yields local-only settings, since the backends are exclusive.
|
|
290
|
+
*/
|
|
266
291
|
backend?: BackendSettings;
|
|
267
292
|
/** Preserved across model re-selection; defaults to disabled. */
|
|
268
293
|
polishEnabled?: boolean;
|
|
@@ -282,7 +307,11 @@ export function settingsForModel(
|
|
|
282
307
|
];
|
|
283
308
|
return {
|
|
284
309
|
version: SETTINGS_VERSION,
|
|
285
|
-
|
|
310
|
+
// Exclusive modes: a selected local model drops any remote configuration.
|
|
311
|
+
backend:
|
|
312
|
+
options.backend && options.backend.type === "transcribe-cpp"
|
|
313
|
+
? options.backend
|
|
314
|
+
: { type: "transcribe-cpp" },
|
|
286
315
|
shortcut: options.shortcut ?? DEFAULT_SHORTCUT,
|
|
287
316
|
preferredLanguages,
|
|
288
317
|
transcriptionLanguage: transcriptionLanguageForModel(
|
|
@@ -297,3 +326,33 @@ export function settingsForModel(
|
|
|
297
326
|
model: { source: "catalog", id: modelId, path: modelPath },
|
|
298
327
|
};
|
|
299
328
|
}
|
|
329
|
+
|
|
330
|
+
type RemoteSettingsOptions = Omit<ModelSettingsOptions, "backend"> & {
|
|
331
|
+
backend: Extract<BackendSettings, { type: "openai-asr" }>;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
/** Remote-only settings: no local model is stored, so the endpoint is the sole backend. */
|
|
335
|
+
export function settingsRemoteOnly(options: RemoteSettingsOptions): TranscribeSettings {
|
|
336
|
+
const preferredLanguages = [
|
|
337
|
+
...new Set((options.preferredLanguages ?? ["en"]).map(languageIdentity)),
|
|
338
|
+
];
|
|
339
|
+
return {
|
|
340
|
+
version: SETTINGS_VERSION,
|
|
341
|
+
backend: options.backend,
|
|
342
|
+
shortcut: options.shortcut ?? DEFAULT_SHORTCUT,
|
|
343
|
+
preferredLanguages,
|
|
344
|
+
// The endpoint detects the language itself; "auto" keeps the stored value
|
|
345
|
+
// from triggering Chinese conversion against a language that is not there.
|
|
346
|
+
transcriptionLanguage: options.transcriptionLanguage ?? "auto",
|
|
347
|
+
chineseOutput: options.chineseOutput ?? defaultChineseOutput(),
|
|
348
|
+
microphone: { ...(options.microphone ?? DEFAULT_MICROPHONE) },
|
|
349
|
+
polishEnabled: options.polishEnabled ?? false,
|
|
350
|
+
...(options.polishModel ? { polishModel: options.polishModel } : {}),
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** The configured transcription source: local model id, or the remote ASR model name. */
|
|
355
|
+
export function transcriptionModelLabel(settings: TranscribeSettings): string {
|
|
356
|
+
if (settings.model) return settings.model.id;
|
|
357
|
+
return settings.backend.type === "openai-asr" ? settings.backend.model : "none";
|
|
358
|
+
}
|
|
@@ -59,13 +59,22 @@ type BackendFactory = (
|
|
|
59
59
|
settings: TranscribeSettings,
|
|
60
60
|
) => ReusableBackend | Promise<ReusableBackend>;
|
|
61
61
|
|
|
62
|
-
/** Local backends key on the model file; remote
|
|
62
|
+
/** Local backends key on the model file; remote keys on endpoint identity. */
|
|
63
|
+
function requireLocalModel(settings: TranscribeSettings): { path: string } {
|
|
64
|
+
if (!settings.model) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
"No local model is configured. Run /voice-settings and choose a local model, or switch the transcription backend to remote ASR.",
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return settings.model;
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
function backendKeyFor(settings: TranscribeSettings): string {
|
|
64
73
|
if (settings.backend.type === "openai-asr") {
|
|
65
74
|
const { baseUrl, model } = settings.backend;
|
|
66
|
-
return `openai-asr:${baseUrl}|${model}
|
|
75
|
+
return `openai-asr:${baseUrl}|${model}`;
|
|
67
76
|
}
|
|
68
|
-
return settings.
|
|
77
|
+
return requireLocalModel(settings).path;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
80
|
function abortError(signal: AbortSignal): Error {
|
|
@@ -104,19 +113,12 @@ export class TranscriptionService {
|
|
|
104
113
|
|
|
105
114
|
constructor(
|
|
106
115
|
private readonly createBackend: BackendFactory = async (_key, settings) => {
|
|
107
|
-
const { TranscribeCppBackend } = await import("./transcription.js");
|
|
108
116
|
if (settings.backend.type === "openai-asr") {
|
|
109
117
|
const { OpenAIAsrBackend } = await import("./openai-asr.js");
|
|
110
|
-
|
|
111
|
-
return new OpenAIAsrBackend(settings.backend, {
|
|
112
|
-
createFallback: async () => {
|
|
113
|
-
const local = new TranscribeCppBackend(modelPath);
|
|
114
|
-
await local.prepare();
|
|
115
|
-
return local;
|
|
116
|
-
},
|
|
117
|
-
});
|
|
118
|
+
return new OpenAIAsrBackend(settings.backend);
|
|
118
119
|
}
|
|
119
|
-
|
|
120
|
+
const { TranscribeCppBackend } = await import("./transcription.js");
|
|
121
|
+
return new TranscribeCppBackend(requireLocalModel(settings).path);
|
|
120
122
|
},
|
|
121
123
|
) {}
|
|
122
124
|
|
package/src/try-it.ts
CHANGED
|
@@ -116,7 +116,10 @@ export class TryItPane implements Component {
|
|
|
116
116
|
|
|
117
117
|
const state = this.dictation.state;
|
|
118
118
|
const shortcut = displayShortcut(this.settings.shortcut);
|
|
119
|
-
const
|
|
119
|
+
const localModel = this.settings.model;
|
|
120
|
+
const modelName = localModel
|
|
121
|
+
? getCatalogModel(localModel.id)?.name ?? localModel.id
|
|
122
|
+
: "Not installed (remote mode)";
|
|
120
123
|
const title = "Try it";
|
|
121
124
|
const fg = (color: Parameters<UiTheme["fg"]>[0], text: string) => this.theme.fg(color, text);
|
|
122
125
|
const text = (value: string) => new Text(value, PANEL_PADDING, 0).render(width);
|
|
@@ -132,7 +135,8 @@ export class TryItPane implements Component {
|
|
|
132
135
|
});
|
|
133
136
|
} else if (
|
|
134
137
|
(state.phase === "idle" || state.phase === "ready") &&
|
|
135
|
-
this.dictation.modelState === "loading"
|
|
138
|
+
this.dictation.modelState === "loading" &&
|
|
139
|
+
localModel
|
|
136
140
|
) {
|
|
137
141
|
activity = fg("muted", `Loading ${modelName}… You can start recording now.`);
|
|
138
142
|
} else if (state.phase === "transcribing") {
|
|
@@ -147,7 +151,7 @@ export class TryItPane implements Component {
|
|
|
147
151
|
activity = fg("muted", formatTranscriptionSummary(speechSeconds, transcribeSeconds));
|
|
148
152
|
if (interruption) {
|
|
149
153
|
details = fg("warning", `The microphone stopped after ${speechSeconds.toFixed(1)}s: ${interruption.message}`);
|
|
150
|
-
} else if (needsFasterModel(speechSeconds, transcribeSeconds)) {
|
|
154
|
+
} else if (localModel && needsFasterModel(speechSeconds, transcribeSeconds)) {
|
|
151
155
|
details = fg("warning", `Slow on this machine? Press ${this.keys.keyText("voice.tryIt.model")} to try another model.`);
|
|
152
156
|
}
|
|
153
157
|
} else if (state.phase === "error") {
|
|
@@ -189,7 +193,7 @@ export class TryItPane implements Component {
|
|
|
189
193
|
const topChrome = (compact: boolean): string[] => [
|
|
190
194
|
...panelBorder(this.theme).render(width),
|
|
191
195
|
...(compact ? [] : [""]),
|
|
192
|
-
...onboardingHeader(this.theme, title,
|
|
196
|
+
...onboardingHeader(this.theme, title, 4).render(width),
|
|
193
197
|
...(compact ? [] : [""]),
|
|
194
198
|
...(compact ? [line(instructions(true))] : text(instructions(false))),
|
|
195
199
|
...(compact ? [] : [""]),
|
|
@@ -208,7 +212,12 @@ export class TryItPane implements Component {
|
|
|
208
212
|
...permission,
|
|
209
213
|
...render(setting("Shortcut", shortcut, this.keys.keyText("voice.tryIt.shortcut"), compact)),
|
|
210
214
|
...render(setting("Microphone", microphoneSummary(this.settings.microphone), this.keys.keyText("voice.tryIt.microphone"), compact)),
|
|
211
|
-
...render(setting(
|
|
215
|
+
...render(setting(
|
|
216
|
+
localModel ? "Model" : "Local model",
|
|
217
|
+
modelName,
|
|
218
|
+
this.keys.keyText("voice.tryIt.model"),
|
|
219
|
+
compact,
|
|
220
|
+
)),
|
|
212
221
|
...(compact ? [] : [""]),
|
|
213
222
|
...text(hints),
|
|
214
223
|
...(compact ? [] : [""]),
|
|
@@ -230,7 +239,7 @@ export class TryItPane implements Component {
|
|
|
230
239
|
if (top.length + bottom.length + frameRows + 1 > budget) {
|
|
231
240
|
// Tiny terminals: drop settings, but keep the task and current actions visible.
|
|
232
241
|
top = [
|
|
233
|
-
...onboardingHeader(this.theme, title,
|
|
242
|
+
...onboardingHeader(this.theme, title, 4).render(width),
|
|
234
243
|
line(instructions(true)),
|
|
235
244
|
];
|
|
236
245
|
bottom = [...text(hints), ...panelBorder(this.theme).render(width)];
|
package/src/ui-components.ts
CHANGED
|
@@ -37,7 +37,7 @@ export function onboardingHeader(
|
|
|
37
37
|
theme: UiTheme,
|
|
38
38
|
title: string,
|
|
39
39
|
step: number,
|
|
40
|
-
total =
|
|
40
|
+
total = 4,
|
|
41
41
|
): Component {
|
|
42
42
|
const context = `Pi Voice setup · ${step} of ${total}`;
|
|
43
43
|
const compactContext = `${step} of ${total}`;
|
|
@@ -217,6 +217,8 @@ export class SingleSelectPicker<T extends string> extends Container implements F
|
|
|
217
217
|
searchable?: boolean;
|
|
218
218
|
maximumVisible?: number;
|
|
219
219
|
cancelLabel?: string;
|
|
220
|
+
/** Adds setup context and progress to the title. */
|
|
221
|
+
onboardingStep?: number;
|
|
220
222
|
/** Extra footer legend, appended after the ✓ current marker. */
|
|
221
223
|
legend?: string;
|
|
222
224
|
/** Custom row body after the cursor and ✓ markers; handles its own active styling. */
|
|
@@ -245,7 +247,11 @@ export class SingleSelectPicker<T extends string> extends Container implements F
|
|
|
245
247
|
|
|
246
248
|
this.addChild(panelBorder(theme));
|
|
247
249
|
this.addChild(new Spacer(1));
|
|
248
|
-
|
|
250
|
+
if (typeof options.onboardingStep === "number") {
|
|
251
|
+
this.addChild(onboardingHeader(theme, options.title, options.onboardingStep));
|
|
252
|
+
} else {
|
|
253
|
+
this.addChild(this.titleText);
|
|
254
|
+
}
|
|
249
255
|
if (this.subtitleText) this.addChild(this.subtitleText);
|
|
250
256
|
this.addChild(new Spacer(1));
|
|
251
257
|
if (options.searchable) {
|