@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/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 local catalog model stays
25
- * configured in both cases and acts as the fallback when the remote call fails.
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
- model: {
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
- if (!isObject(value.model) || value.model.source !== "catalog") return undefined;
175
- if (typeof value.model.id !== "string") return undefined;
176
- const model = getCatalogModel(value.model.id);
177
- if (!model) return undefined;
178
- if (typeof value.model.path !== "string" || value.model.path.length === 0) return undefined;
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: transcriptionLanguageForModel(
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
- model: {
200
- source: "catalog",
201
- id: value.model.id,
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
- /** Preserved across model re-selection; defaults to the local backend. */
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
- backend: options.backend ?? { type: "transcribe-cpp" },
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 adds its endpoint identity. */
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}|${settings.model.path}`;
75
+ return `openai-asr:${baseUrl}|${model}`;
67
76
  }
68
- return settings.model.path;
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
- const modelPath = settings.model.path;
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
- return new TranscribeCppBackend(settings.model.path);
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 modelName = getCatalogModel(this.settings.model.id)?.name ?? this.settings.model.id;
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, 3).render(width),
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("Model", modelName, this.keys.keyText("voice.tryIt.model"), compact)),
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, 3).render(width),
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)];
@@ -37,7 +37,7 @@ export function onboardingHeader(
37
37
  theme: UiTheme,
38
38
  title: string,
39
39
  step: number,
40
- total = 3,
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
- this.addChild(this.titleText);
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) {