@tychenjiajun/pi-voice 0.4.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 CHANGED
@@ -4,7 +4,7 @@ Speech-to-text for Pi — on-device model or OpenAI-compatible remote ASR, with
4
4
 
5
5
  Press a keyboard shortcut, talk, and the resulting transcript will be put directly into your chat.
6
6
 
7
- > **Fork notice.** `@tychenjiajun/pi-voice` is a fork of [`@earendil-works/pi-voice`](https://github.com/earendil-works/pi-voice) that adds a remote ASR backend (any OpenAI-compatible `input_audio` chat-completions endpoint) with the local model kept as a fallback, plus compaction-style post-processing that refines a transcript with Pi's own model. It is published independently under a personal npm scope. See the [changelog](#changelog) for what changed.
7
+ > **Fork notice.** `@tychenjiajun/pi-voice` is a fork of [`@earendil-works/pi-voice`](https://github.com/earendil-works/pi-voice) that adds a remote ASR backend (any OpenAI-compatible `input_audio` chat-completions endpoint) in an exclusive local-only or remote-only setup, plus compaction-style post-processing that refines a transcript with Pi's own model. It is published independently under a personal npm scope. See the [changelog](#changelog) for what changed.
8
8
 
9
9
  ## Install
10
10
 
@@ -49,12 +49,13 @@ export OPENAI_API_KEY=sk-...
49
49
 
50
50
  Behavior:
51
51
 
52
- - **Local model stays as fallback.** If a remote request fails (network, quota, HTTP error, or the API key is unset), the full recording is transcribed with the configured local model instead; the failure is logged to `~/.pi/agent/pi-voice.log`. Aborted/cancelled transcriptions are never retried locally.
52
+ - **Remote-only mode.** Remote settings store no local model: setup skips the model download step, and a failed remote request (network, quota, HTTP error, or an unset API key) surfaces as an error instead of being retried offline. Failures are logged to `~/.pi/agent/pi-voice.log`.
53
53
  - **Batch-only.** Remote transcription happens when recording stops (the ASR request carries the whole clip), so there is no streaming preview.
54
- - **16 kHz mono WAV** is uploaded as a base64 data URL.
55
- - Switching back to **Local model** restores the original behavior; the local model is still required either way (it is the fallback and file transcription still needs it).
54
+ - **16 kHz mono WAV** is uploaded as a base64 WAV data URL.
55
+ - **Exclusive modes.** Switching **Transcription backend** to **Local model** asks you to pick a model first; switching to **Remote ASR** drops the local model from settings. The **Model** and **Transcription language** settings rows appear only in local mode.
56
+ - `transcribe_file` uses whichever mode is configured — in remote mode the file audio is sent to the endpoint instead of a local model.
56
57
 
57
- Transcripts still go through the **Chinese output** script conversion preference; because a remote response carries no detected language, Han characters in the transcript are what trigger the conversion when the language is set to automatic.
58
+ Transcripts still go through the **Chinese output** script conversion preference; because a remote response carries no detected language, Han characters in the transcript are what trigger the conversion when the language is set to automatic. The **Chinese output** row stays reachable in remote mode, since the endpoint can always produce Chinese.
58
59
 
59
60
  ## Post-processing (compaction-style refinement)
60
61
 
@@ -111,10 +112,21 @@ export PI_VOICE_FFMPEG_PATH=/path/to/ffmpeg
111
112
 
112
113
  The legacy `PI_TRANSCRIBE_FFMPEG_PATH` variable remains supported when `PI_VOICE_FFMPEG_PATH` is not set.
113
114
 
114
- When FFmpeg is unavailable, `transcribe_file` reports platform-specific guidance to the agent. The agent should ask before running a package-manager command. Model setup is still explicit: run `/voice-settings` once in the interactive TUI to choose and, after confirmation, download a local model.
115
+ When FFmpeg is unavailable, `transcribe_file` reports platform-specific guidance to the agent. The agent should ask before running a package-manager command. Model setup is still explicit: run `/voice-settings` once in the interactive TUI to choose a mode and, in local mode, confirm the download of a local model.
115
116
 
116
117
  ## Changelog
117
118
 
119
+ ### 0.5.0
120
+
121
+ **Changed — Exclusive local / remote modes (no fallback)**
122
+
123
+ - Local and remote transcription are now mutually exclusive: remote settings store no local model, and selecting a local model drops any remote configuration. Settings files with both are normalized to remote-only on read.
124
+ - First-run setup in remote mode skips the model step entirely; no local model is downloaded (onboarding jumps from step 2 to step 4, the Try it pane).
125
+ - Removed the local fallback for remote ASR. Network, quota, HTTP, and unset-API-key failures now surface directly and are logged to `~/.pi/agent/pi-voice.log`.
126
+ - The settings menu hides the **Model** and **Transcription language** rows in remote mode and always shows **Chinese output** there; switching the backend to local asks you to pick a model first, and switching to remote clears the local model.
127
+ - Try it shows a **Local model** row in remote mode; choosing a model from it switches setup to local mode.
128
+ - `transcribe_file` works in either mode and reports the remote model name in its details when remote.
129
+
118
130
  ### 0.4.0
119
131
 
120
132
  **Added — Backend choice in first-run setup**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tychenjiajun/pi-voice",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Speech-to-text for Pi (local model or OpenAI-compatible remote ASR, compaction-style post-processing)",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,11 @@
1
1
  import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
2
2
  import { describeError, log, logStep, sinceKeyPress } from "./log.js";
3
3
  import { PcmChunker } from "./pcm-chunker.js";
4
- import type { MicrophoneSetting, TranscribeSettings } from "./settings.js";
4
+ import {
5
+ transcriptionModelLabel,
6
+ type MicrophoneSetting,
7
+ type TranscribeSettings,
8
+ } from "./settings.js";
5
9
  import type { DictationReservation, TranscriptionService } from "./transcription-service.js";
6
10
 
7
11
  /** What a recording captured. `interruption` is why it ended early, if it did. */
@@ -90,7 +94,7 @@ export class DictationController {
90
94
  /** Reserves the model for a take; on failure the state says why. */
91
95
  private reserve(settings: TranscribeSettings): Take | undefined {
92
96
  this.readiness = "loading";
93
- logStep("reserving model", settings.model.id);
97
+ logStep("reserving model", transcriptionModelLabel(settings));
94
98
  const reservedAt = this.now();
95
99
  let reservation: DictationReservation;
96
100
  try {
@@ -12,7 +12,7 @@ import { basename, join, resolve } from "node:path";
12
12
  import { Type } from "typebox";
13
13
  import { AsyncLimiter } from "./async-limiter.js";
14
14
  import { polishTranscript } from "./polish.js";
15
- import type { TranscribeSettings } from "./settings.js";
15
+ import { transcriptionModelLabel, type TranscribeSettings } from "./settings.js";
16
16
  import type { TranscriptionService } from "./transcription-service.js";
17
17
 
18
18
  const CONTEXT_LINE_LENGTH = 1_000;
@@ -84,7 +84,7 @@ export function registerFileTranscriptionTool(
84
84
  pi.registerTool({
85
85
  name: "transcribe_file",
86
86
  label: "Transcribe File",
87
- description: `Transcribe speech from a local audio or video file using Pi Voice's configured transcription backend (local model, or the configured remote ASR endpoint with the local model as fallback), then applies Pi Voice's compaction-style post-processing when it is enabled. Requires the ffmpeg executable on PATH (or PI_VOICE_FFMPEG_PATH). Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; a complete transcript is saved to a temporary file when needed.`,
87
+ description: `Transcribe speech from a local audio or video file using Pi Voice's configured transcription backend (local model or remote ASR endpoint), then applies Pi Voice's compaction-style post-processing when it is enabled. Requires the ffmpeg executable on PATH (or PI_VOICE_FFMPEG_PATH). Output is truncated to ${DEFAULT_MAX_LINES} lines or ${formatSize(DEFAULT_MAX_BYTES)}; a complete transcript is saved to a temporary file when needed.`,
88
88
  promptSnippet: "Transcribe speech from local audio or video files",
89
89
  promptGuidelines: [
90
90
  "Use transcribe_file when speech in a local audio or video file needs to be read, analyzed, or transcribed.",
@@ -118,10 +118,11 @@ export function registerFileTranscriptionTool(
118
118
 
119
119
  const configured = await options.getSettings();
120
120
  const service = await options.getService();
121
+ const modelLabel = transcriptionModelLabel(configured);
121
122
  if (fileOperations.saturated) {
122
123
  onUpdate?.({
123
124
  content: [{ type: "text", text: "Waiting for file transcription capacity…" }],
124
- details: { inputPath, modelId: configured.model.id, seconds: 0 },
125
+ details: { inputPath, modelId: modelLabel, seconds: 0 },
125
126
  });
126
127
  }
127
128
 
@@ -129,13 +130,13 @@ export function registerFileTranscriptionTool(
129
130
  if (fileDecoders.saturated) {
130
131
  onUpdate?.({
131
132
  content: [{ type: "text", text: `Waiting to decode ${basename(inputPath)}…` }],
132
- details: { inputPath, modelId: configured.model.id, seconds: 0 },
133
+ details: { inputPath, modelId: modelLabel, seconds: 0 },
133
134
  });
134
135
  }
135
136
  const audio = await fileDecoders.run(async () => {
136
137
  onUpdate?.({
137
138
  content: [{ type: "text", text: `Decoding ${basename(inputPath)} with FFmpeg…` }],
138
- details: { inputPath, modelId: configured.model.id, seconds: 0 },
139
+ details: { inputPath, modelId: modelLabel, seconds: 0 },
139
140
  });
140
141
  const { decodeFileAudio } = await import("./file-audio.js");
141
142
  return decodeFileAudio(inputPath, operationSignal);
@@ -150,7 +151,7 @@ export function registerFileTranscriptionTool(
150
151
  ],
151
152
  details: {
152
153
  inputPath,
153
- modelId: configured.model.id,
154
+ modelId: modelLabel,
154
155
  seconds: audio.seconds,
155
156
  },
156
157
  });
@@ -170,7 +171,7 @@ export function registerFileTranscriptionTool(
170
171
  : rawTranscript;
171
172
  const details: FileTranscriptionDetails = {
172
173
  inputPath,
173
- modelId: configured.model.id,
174
+ modelId: modelLabel,
174
175
  seconds: audio.seconds,
175
176
  };
176
177
 
package/src/onboarding.ts CHANGED
@@ -20,6 +20,7 @@ import { tryVoice } from "./try-it.js";
20
20
  import {
21
21
  DEFAULT_MICROPHONE,
22
22
  settingsForModel,
23
+ settingsRemoteOnly,
23
24
  writeSettings,
24
25
  type BackendSettings,
25
26
  type ChineseOutput,
@@ -96,7 +97,7 @@ export async function runModelSelection(
96
97
  }),
97
98
  (settings) => {
98
99
  configured = settings;
99
- currentModelId = settings.model.id;
100
+ currentModelId = settings.model?.id;
100
101
  },
101
102
  );
102
103
 
@@ -237,7 +238,7 @@ export async function changeOnboardingModel(
237
238
  expanded: true,
238
239
  onboardingStep: 3,
239
240
  })
240
- : await chooseCatalogModel(ctx, languages, chosen?.model.id ?? current.model.id, {
241
+ : await chooseCatalogModel(ctx, languages, chosen?.model?.id ?? current.model?.id, {
241
242
  postActivation: "advance",
242
243
  onActivate: activation.activate,
243
244
  cancelLabel: "back",
@@ -274,7 +275,7 @@ export type SetupMode = "local" | "remote";
274
275
 
275
276
  /**
276
277
  * First screen of first-run setup: on-device model, or a remote ASR endpoint.
277
- * A local model is downloaded in both cases because it is the fallback.
278
+ * The modes are exclusive: remote setup downloads no local model.
278
279
  */
279
280
  async function chooseSetupMode(ctx: ExtensionContext): Promise<SetupMode | undefined> {
280
281
  const choices: SingleSelectChoice<SetupMode>[] = [
@@ -287,7 +288,7 @@ async function chooseSetupMode(ctx: ExtensionContext): Promise<SetupMode | undef
287
288
  value: "remote",
288
289
  label: "Remote ASR (OpenAI-compatible)",
289
290
  description:
290
- "Send audio to an OpenAI-compatible ASR endpoint; a local model is still downloaded as fallback",
291
+ "Send audio to an OpenAI-compatible ASR endpoint; no local model is downloaded",
291
292
  },
292
293
  ];
293
294
  return ctx.ui.custom<SetupMode | undefined>((tui, theme, keybindings, done) =>
@@ -323,7 +324,6 @@ export async function runOnboarding(
323
324
  if (!backend) return undefined;
324
325
  warnMissingApiKey(ctx, backend.apiKeyEnv);
325
326
  }
326
- const usingRemote = backend !== undefined;
327
327
 
328
328
  let languages = defaultSpokenLanguages();
329
329
  // Navigation cannot undo a completed settings commit. Keep it across a
@@ -336,6 +336,18 @@ export async function runOnboarding(
336
336
  });
337
337
  if (!chosen?.confirmed) return configured;
338
338
  languages = chosen.languages;
339
+
340
+ // Remote mode is remote-only: no model step, settings are complete once
341
+ // the endpoint and languages are chosen.
342
+ if (backend?.type === "openai-asr") {
343
+ const settings = settingsRemoteOnly({ backend, shortcut, preferredLanguages: languages });
344
+ if (!(await saveOnboardingSettings(ctx, settings))) return configured;
345
+ configured = settings;
346
+ return finishOnboarding(ctx, configured, (current) =>
347
+ changeOnboardingModel(ctx, current),
348
+ );
349
+ }
350
+
339
351
  // The picks are judged on the benchmark rig; the Try it step measures
340
352
  // the real wait on this machine.
341
353
  const picks = recommendModels(CATALOG_MODELS, languages);
@@ -355,7 +367,6 @@ export async function runOnboarding(
355
367
  activate,
356
368
  {
357
369
  onboardingStep: 3,
358
- ...(usingRemote ? { title: "Choose the local fallback model" } : {}),
359
370
  },
360
371
  );
361
372
  await waitForCommits();
@@ -368,12 +379,12 @@ export async function runOnboarding(
368
379
  continue;
369
380
  }
370
381
 
371
- const selection = await chooseCatalogModel(ctx, languages, configured?.model.id, {
382
+ const selection = await chooseCatalogModel(ctx, languages, configured?.model?.id, {
372
383
  postActivation: "advance",
373
384
  onActivate: activate,
374
385
  cancelLabel: "back",
375
386
  onboardingStep: 3,
376
- title: usingRemote ? "Choose the local fallback model" : "Browse all models",
387
+ title: "Browse all models",
377
388
  });
378
389
  await waitForCommits();
379
390
  if (selection?.type === "complete" && configured) {
package/src/openai-asr.ts CHANGED
@@ -5,18 +5,11 @@ import { describeError, writeLog } from "./log.js";
5
5
  import type { BackendSettings, ChineseOutput } from "./settings.js";
6
6
  import type {
7
7
  DictationStream,
8
- TranscribeCppBackend,
9
8
  TranscriptionOptions,
10
9
  } from "./transcription.js";
11
10
 
12
11
  type OpenAIAsrSettings = Extract<BackendSettings, { type: "openai-asr" }>;
13
12
 
14
- /** The slice of the local backend this module needs for offline fallback. */
15
- export type LocalFallback = Pick<
16
- TranscribeCppBackend,
17
- "prepare" | "transcribe" | "dispose"
18
- >;
19
-
20
13
  export type AsrRequest = {
21
14
  /** `data:audio/wav;base64,…` of the whole recording. */
22
15
  dataUrl: string;
@@ -136,8 +129,6 @@ export async function finishRemoteTranscript(
136
129
  }
137
130
 
138
131
  export type OpenAIAsrBackendDeps = {
139
- /** Creates the local model used when a remote call fails. */
140
- createFallback?: () => Promise<LocalFallback>;
141
132
  /** Overrides the network transport (tests). */
142
133
  transport?: AsrTransport;
143
134
  };
@@ -145,12 +136,11 @@ export type OpenAIAsrBackendDeps = {
145
136
  /**
146
137
  * Batch-only remote backend: `startStream` resolves to undefined so the
147
138
  * service records the whole clip and transcribes it after stop, exactly like
148
- * a non-streaming local model. Remote failures fall back to the local model;
149
- * aborts are never retried locally.
139
+ * a non-streaming local model. Remote failures surface to the caller; there
140
+ * is no local fallback.
150
141
  */
151
142
  export class OpenAIAsrBackend {
152
143
  private client: { transport: AsrTransport; dispose: () => void } | undefined;
153
- private fallback: Promise<LocalFallback> | undefined;
154
144
  private disposed = false;
155
145
  private readonly injected: AsrTransport | undefined;
156
146
 
@@ -164,8 +154,8 @@ export class OpenAIAsrBackend {
164
154
  async prepare(): Promise<void> {
165
155
  if (this.disposed) throw new Error("Remote ASR backend has been disposed");
166
156
  // The transport is created lazily in transcribe() so that a missing API
167
- // key (or any construction failure) falls back to the local model instead
168
- // of failing prepare() and blocking every subsequent transcription.
157
+ // key surfaces per recording instead of failing prepare() and blocking
158
+ // every subsequent transcription.
169
159
  }
170
160
 
171
161
  /** Batch-only: the dictation service drops live chunks and transcribes on stop. */
@@ -184,9 +174,9 @@ export class OpenAIAsrBackend {
184
174
  if (!transport) {
185
175
  if (this.disposed) throw new Error("Remote ASR backend has been disposed");
186
176
  // Lazily build the transport inside the try so a missing API key or
187
- // any construction failure falls back to the local model. Success is
188
- // cached; a failure is not, so a later call retries once the env is
189
- // fixed.
177
+ // any construction failure is reported like a request failure.
178
+ // Success is cached; a failure is not, so a later call retries once
179
+ // the env is fixed.
190
180
  const client = this.client ?? createOpenAITransport(this.config);
191
181
  this.client = client;
192
182
  transport = client.transport;
@@ -205,20 +195,12 @@ export class OpenAIAsrBackend {
205
195
  return await finishRemoteTranscript(text, options);
206
196
  } catch (error) {
207
197
  if (signal?.aborted || this.disposed) throw error;
208
- if (!this.deps.createFallback) throw error;
209
198
  writeLog(
210
199
  "warn",
211
200
  "voice",
212
- `remote ASR failed (${describeError(error)}); falling back to the local model`,
201
+ `remote ASR failed (${describeError(error)})`,
213
202
  );
214
- try {
215
- const local = await this.fallbackBackend();
216
- return await local.transcribe(pcm, options);
217
- } catch (fallbackError) {
218
- throw new Error(
219
- `Remote ASR failed (${describeError(error)}) and the local fallback failed (${describeError(fallbackError)})`,
220
- );
221
- }
203
+ throw error;
222
204
  }
223
205
  }
224
206
 
@@ -227,21 +209,5 @@ export class OpenAIAsrBackend {
227
209
  this.disposed = true;
228
210
  this.client?.dispose();
229
211
  this.client = undefined;
230
- if (this.fallback) {
231
- const local = await this.fallback.catch(() => undefined);
232
- await local?.dispose().catch(() => undefined);
233
- }
234
- }
235
-
236
- private fallbackBackend(): Promise<LocalFallback> {
237
- if (!this.fallback) {
238
- // A rejected fallback is not cached: a later attempt can re-create the
239
- // local model instead of re-throwing a stale failure.
240
- this.fallback = this.deps.createFallback!().catch((error) => {
241
- this.fallback = undefined;
242
- throw error;
243
- });
244
- }
245
- return this.fallback;
246
212
  }
247
213
  }
package/src/remote-asr.ts CHANGED
@@ -26,7 +26,7 @@ export async function chooseBackendType(
26
26
  value: "openai-asr",
27
27
  label: "Remote ASR (OpenAI-compatible)",
28
28
  description:
29
- "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; falls back to the local model if the request fails",
29
+ "Upload audio to any OpenAI-compatible chat-completions ASR endpoint; no local model is used",
30
30
  },
31
31
  ];
32
32
  return ctx.ui.custom<BackendType | undefined>((tui, theme, keybindings, done) =>
@@ -74,11 +74,11 @@ function remoteBackendsEqual(
74
74
  );
75
75
  }
76
76
 
77
- /** Warn when the named variable is unset, so remote calls silently fall back to local. */
77
+ /** Warn when the named variable is unset, so remote calls fail until it is set. */
78
78
  export function warnMissingApiKey(ctx: ExtensionContext, apiKeyEnv: string): void {
79
79
  if (process.env[apiKeyEnv]) return;
80
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`,
81
+ `${apiKeyEnv} is not set in Pi's environment; remote transcription will fail until it is set`,
82
82
  "warning",
83
83
  );
84
84
  }
package/src/runtime.ts CHANGED
@@ -145,7 +145,7 @@ export function createPiVoiceRuntime(
145
145
  preferredLanguages: previous.preferredLanguages,
146
146
  transcriptionLanguage: previous.transcriptionLanguage,
147
147
  chineseOutput: previous.chineseOutput,
148
- currentModelId: previous.model.id,
148
+ currentModelId: previous.model?.id,
149
149
  microphone: previous.microphone,
150
150
  backend: previous.backend,
151
151
  polishEnabled: previous.polishEnabled,
@@ -165,12 +165,12 @@ export function createPiVoiceRuntime(
165
165
  ctx.ui.notify(settingsReadWarning, "warning");
166
166
  }
167
167
 
168
- if (settings && existsSync(settings.model.path)) {
168
+ if (settings && (!settings.model || existsSync(settings.model.path))) {
169
169
  return { configured: settings, completedFirstRun: false };
170
170
  }
171
171
 
172
172
  const previous = settings;
173
- if (settings) {
173
+ if (settings?.model) {
174
174
  ctx.ui.notify(
175
175
  `Configured model file is missing: ${settings.model.path}. Choose a model again; nothing will be downloaded without confirmation.`,
176
176
  "warning",
@@ -189,15 +189,15 @@ export function createPiVoiceRuntime(
189
189
  await loadSettingsOnce();
190
190
  if (settingsReadWarning && !settings) {
191
191
  throw new Error(
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.`,
192
+ `${settingsReadWarning} Ask the user to run /voice-settings in Pi's interactive TUI to set up Pi Voice (a local model or a remote ASR endpoint), then retry transcribe_file.`,
193
193
  );
194
194
  }
195
195
  if (!settings) {
196
196
  throw new Error(
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.",
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, or configure a remote ASR endpoint), then retry transcribe_file.",
198
198
  );
199
199
  }
200
- if (!existsSync(settings.model.path)) {
200
+ if (settings.model && !existsSync(settings.model.path)) {
201
201
  throw new Error(
202
202
  `The configured transcription model is missing: ${settings.model.path}. Ask the user to run /voice-settings and choose a model again, then retry transcribe_file.`,
203
203
  );
@@ -408,7 +408,7 @@ export function createPiVoiceRuntime(
408
408
  const { clearTranscribeWidget, showTranscribeStatus } = await loadVisualizer();
409
409
  logStep("loading settings");
410
410
  await loadSettingsOnce();
411
- if (settings && existsSync(settings.model.path)) {
411
+ if (settings && (!settings.model || existsSync(settings.model.path))) {
412
412
  showTranscribeStatus(ctx, "Starting microphone…");
413
413
  } else {
414
414
  // Setup panes replace only the editor, so a status line set here or by
@@ -100,7 +100,9 @@ export function settingsHomeChoices(
100
100
  configured: TranscribeSettings,
101
101
  permission: Permission,
102
102
  ): SettingsHomeChoice[] {
103
- const model = getCatalogModel(configured.model.id)!;
103
+ // Local mode always carries a model; remote mode never does.
104
+ const model = configured.model ? getCatalogModel(configured.model.id) : undefined;
105
+ const remote = configured.backend.type === "openai-asr";
104
106
  const choices: SettingsHomeChoice[] = [
105
107
  {
106
108
  value: "preferred-languages",
@@ -108,18 +110,24 @@ export function settingsHomeChoices(
108
110
  summary: preferredLanguagesSummary(configured.preferredLanguages),
109
111
  description: "Languages you speak, used to rank and recommend transcription models",
110
112
  },
111
- {
113
+ ];
114
+
115
+ if (model) {
116
+ choices.push({
112
117
  value: "model",
113
118
  label: "Model",
114
119
  summary: model.name,
115
120
  description: "Switch between downloaded models, or download a new one",
116
- },
121
+ });
122
+ }
123
+
124
+ choices.push(
117
125
  {
118
126
  value: "backend",
119
127
  label: "Transcription backend",
120
128
  summary: backendSummary(configured.backend),
121
129
  description:
122
- "Local model, or an OpenAI-compatible remote ASR endpoint (local model stays as fallback)",
130
+ "Local model or remote ASR endpoint; the two modes are exclusive",
123
131
  },
124
132
  {
125
133
  value: "post-processing",
@@ -128,24 +136,25 @@ export function settingsHomeChoices(
128
136
  description:
129
137
  "Refine transcripts with Pi's current model after ASR, like context compaction",
130
138
  },
131
- {
139
+ );
140
+
141
+ if (model) {
142
+ choices.push({
132
143
  value: "transcription-language",
133
144
  label: "Transcription language",
134
145
  summary: transcriptionLanguageSummary(configured.transcriptionLanguage, model),
135
- description:
136
- configured.backend.type === "openai-asr"
137
- ? "Used by the local fallback and Chinese output conversion; the remote endpoint detects the language itself"
138
- : "Language expected in recordings, or automatic detection when supported",
139
- },
140
- ];
146
+ description: "Language expected in recordings, or automatic detection when supported",
147
+ });
148
+ }
141
149
 
142
150
  // Chinese script conversion applies when the configured language is Chinese,
143
151
  // when a preferred language is Chinese, or when a detecting model can pick
144
- // Chinese on its own. Gating only on the first two leaves the row unreachable
145
- // for anyone relying on auto-detection.
152
+ // Chinese on its own. Remote mode can always produce Chinese, so its row is
153
+ // never gated.
146
154
  const detectsChinese =
147
- model.capabilities.languageDetection && model.languages.some(isChineseLanguage);
155
+ model?.capabilities.languageDetection && model.languages.some(isChineseLanguage);
148
156
  if (
157
+ remote ||
149
158
  isChineseLanguage(configured.transcriptionLanguage) ||
150
159
  configured.preferredLanguages.some(isChineseLanguage) ||
151
160
  detectsChinese
@@ -264,7 +273,8 @@ async function chooseTranscriptionLanguage(
264
273
  ctx: ExtensionContext,
265
274
  configured: TranscribeSettings,
266
275
  ): Promise<TranscriptionLanguage | undefined> {
267
- const model = getCatalogModel(configured.model.id)!;
276
+ const model = configured.model ? getCatalogModel(configured.model.id) : undefined;
277
+ if (!model) return undefined;
268
278
  return ctx.ui.custom<TranscriptionLanguage | undefined>(
269
279
  (tui, theme, keybindings, done) =>
270
280
  createTranscriptionLanguagePicker(
@@ -297,12 +307,26 @@ async function editBackend(
297
307
  if (!type) return false;
298
308
  if (type === "transcribe-cpp") {
299
309
  if (configured.backend.type === "transcribe-cpp") return false;
300
- return saveUpdatedSettings(
301
- ctx,
302
- configured,
303
- { ...configured, backend: { type: "transcribe-cpp" } },
304
- "Using the local model",
305
- );
310
+ // Remote-only settings carry no local model; pick one first. Every commit
311
+ // writes local-mode settings, so cancelling keeps the remote setup.
312
+ const updated = await runModelSelection(ctx, {
313
+ shortcut: configured.shortcut,
314
+ preferredLanguages: configured.preferredLanguages,
315
+ transcriptionLanguage: configured.transcriptionLanguage,
316
+ chineseOutput: configured.chineseOutput,
317
+ microphone: configured.microphone,
318
+ backend: { type: "transcribe-cpp" },
319
+ polishEnabled: configured.polishEnabled,
320
+ polishModel: configured.polishModel,
321
+ postActivation: "stay",
322
+ });
323
+ if (!updated) {
324
+ ctx.ui.notify("Local mode needs a local model; keeping remote ASR", "info");
325
+ return false;
326
+ }
327
+ Object.assign(configured, updated);
328
+ ctx.ui.notify("Using the local model", "info");
329
+ return true;
306
330
  }
307
331
 
308
332
  const current =
@@ -313,8 +337,8 @@ async function editBackend(
313
337
  const saved = await saveUpdatedSettings(
314
338
  ctx,
315
339
  configured,
316
- { ...configured, backend },
317
- `Remote ASR configured (${backend.model})`,
340
+ { ...configured, backend, model: undefined },
341
+ `Remote ASR configured (${backend.model}); the local model is no longer used`,
318
342
  );
319
343
  if (saved) warnMissingApiKey(ctx, backend.apiKeyEnv);
320
344
  return saved;
@@ -514,7 +538,7 @@ export async function showSettingsMenu(
514
538
  preferredLanguages: configured.preferredLanguages,
515
539
  transcriptionLanguage: configured.transcriptionLanguage,
516
540
  chineseOutput: configured.chineseOutput,
517
- currentModelId: configured.model.id,
541
+ currentModelId: configured.model?.id,
518
542
  microphone: configured.microphone,
519
543
  backend: configured.backend,
520
544
  polishEnabled: configured.polishEnabled,
@@ -550,8 +574,12 @@ export async function showSettingsMenu(
550
574
  ) {
551
575
  continue;
552
576
  }
553
- const model = getCatalogModel(configured.model.id)!;
554
- const summary = transcriptionLanguageSummary(transcriptionLanguage, model);
577
+ const model = configured.model
578
+ ? getCatalogModel(configured.model.id)
579
+ : undefined;
580
+ const summary = model
581
+ ? transcriptionLanguageSummary(transcriptionLanguage, model)
582
+ : transcriptionLanguage;
555
583
  await saveUpdatedSettings(
556
584
  ctx,
557
585
  configured,
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") {
@@ -209,7 +213,7 @@ export class TryItPane implements Component {
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
215
  ...render(setting(
212
- this.settings.backend.type === "openai-asr" ? "Fallback" : "Model",
216
+ localModel ? "Model" : "Local model",
213
217
  modelName,
214
218
  this.keys.keyText("voice.tryIt.model"),
215
219
  compact,