@tychenjiajun/pi-voice 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +134 -2
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +68 -4
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio-errors.ts +14 -0
- package/src/audio.ts +109 -0
- package/src/catalog.generated.ts +1305 -0
- package/src/catalog.ts +89 -0
- package/src/chinese.ts +52 -0
- package/src/deferred.ts +30 -0
- package/src/dictation-controller.ts +315 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +135 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +119 -0
- package/src/languages.ts +22 -0
- package/src/log.ts +171 -0
- package/src/microphone-picker.ts +95 -0
- package/src/model-activation.ts +74 -0
- package/src/model-cells.ts +218 -0
- package/src/model-picker.ts +1026 -0
- package/src/model-ratings-help.md +41 -0
- package/src/model-ratings-help.ts +146 -0
- package/src/model-selection-controller.ts +185 -0
- package/src/models.ts +263 -0
- package/src/onboarding.ts +319 -0
- package/src/openai-asr.ts +247 -0
- package/src/pcm-chunker.ts +46 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +524 -0
- package/src/settings-menu.ts +638 -0
- package/src/settings-path.ts +19 -0
- package/src/settings.ts +273 -0
- package/src/shortcut-core.ts +85 -0
- package/src/shortcuts.ts +167 -0
- package/src/startup-shortcut.ts +24 -0
- package/src/transcript-preview.ts +52 -0
- package/src/transcription-service.ts +570 -0
- package/src/transcription.ts +203 -0
- package/src/try-it.ts +318 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +271 -0
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import {
|
|
3
|
+
chooseCatalogModel,
|
|
4
|
+
chooseLanguages,
|
|
5
|
+
defaultSpokenLanguages,
|
|
6
|
+
type CatalogModelPostActivation,
|
|
7
|
+
} from "./model-picker.js";
|
|
8
|
+
import { createModelActivation } from "./model-activation.js";
|
|
9
|
+
import { microphonePermission } from "./audio.js";
|
|
10
|
+
import { chooseMicrophone, microphonesEqual } from "./microphone-picker.js";
|
|
11
|
+
import {
|
|
12
|
+
chooseRecommendedModel,
|
|
13
|
+
hasRecommendedAlternatives,
|
|
14
|
+
} from "./recommendation-picker.js";
|
|
15
|
+
import { CATALOG_MODELS } from "./catalog.js";
|
|
16
|
+
import { recommendModels } from "./recommendations.js";
|
|
17
|
+
import { createShortcutPicker } from "./shortcuts.js";
|
|
18
|
+
import { tryVoice } from "./try-it.js";
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_MICROPHONE,
|
|
21
|
+
settingsForModel,
|
|
22
|
+
writeSettings,
|
|
23
|
+
type BackendSettings,
|
|
24
|
+
type ChineseOutput,
|
|
25
|
+
type MicrophoneSetting,
|
|
26
|
+
type TranscribeSettings,
|
|
27
|
+
type TranscriptionLanguage,
|
|
28
|
+
} from "./settings.js";
|
|
29
|
+
import { DEFAULT_SHORTCUT } from "./shortcut-core.js";
|
|
30
|
+
|
|
31
|
+
function requireTui(ctx: ExtensionContext): boolean {
|
|
32
|
+
if (ctx.mode === "tui") return true;
|
|
33
|
+
ctx.ui.notify("Pi Voice configuration requires the interactive TUI", "error");
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
type SettingsOptions = NonNullable<Parameters<typeof settingsForModel>[2]>;
|
|
38
|
+
|
|
39
|
+
/** Build the shared download-and-save pipeline from the flow's live settings. */
|
|
40
|
+
function createSettingsActivation(
|
|
41
|
+
options: () => SettingsOptions,
|
|
42
|
+
onCommitted: (settings: TranscribeSettings) => void,
|
|
43
|
+
) {
|
|
44
|
+
return createModelActivation({
|
|
45
|
+
buildSettings: (model, path) => settingsForModel(model.id, path, options()),
|
|
46
|
+
onCommitted,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
type ModelSelectionOptions = {
|
|
51
|
+
shortcut?: string;
|
|
52
|
+
preferredLanguages?: readonly string[];
|
|
53
|
+
transcriptionLanguage?: TranscriptionLanguage;
|
|
54
|
+
chineseOutput?: ChineseOutput;
|
|
55
|
+
currentModelId?: string;
|
|
56
|
+
microphone?: MicrophoneSetting;
|
|
57
|
+
/** Preserved across the model re-pick so remote config survives. */
|
|
58
|
+
backend?: BackendSettings;
|
|
59
|
+
/** Persists language changes made before this flow activates a model. */
|
|
60
|
+
onPreferredLanguagesChange?: (languages: string[]) => Promise<void>;
|
|
61
|
+
postActivation?: CatalogModelPostActivation;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export async function runModelSelection(
|
|
65
|
+
ctx: ExtensionContext,
|
|
66
|
+
options: ModelSelectionOptions = {},
|
|
67
|
+
): Promise<TranscribeSettings | undefined> {
|
|
68
|
+
if (!requireTui(ctx)) return undefined;
|
|
69
|
+
|
|
70
|
+
let preferredLanguages = [
|
|
71
|
+
...(options.preferredLanguages ?? defaultSpokenLanguages()),
|
|
72
|
+
];
|
|
73
|
+
let currentModelId = options.currentModelId;
|
|
74
|
+
let configured: TranscribeSettings | undefined;
|
|
75
|
+
const { activate, waitForCommits } = createSettingsActivation(
|
|
76
|
+
() => ({
|
|
77
|
+
shortcut: configured?.shortcut ?? options.shortcut ?? DEFAULT_SHORTCUT,
|
|
78
|
+
preferredLanguages,
|
|
79
|
+
transcriptionLanguage:
|
|
80
|
+
configured?.transcriptionLanguage ?? options.transcriptionLanguage,
|
|
81
|
+
chineseOutput: configured?.chineseOutput ?? options.chineseOutput,
|
|
82
|
+
microphone: configured?.microphone ?? options.microphone ?? DEFAULT_MICROPHONE,
|
|
83
|
+
backend: configured?.backend ?? options.backend,
|
|
84
|
+
}),
|
|
85
|
+
(settings) => {
|
|
86
|
+
configured = settings;
|
|
87
|
+
currentModelId = settings.model.id;
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
// One pane switches between the models on disk and downloads new ones.
|
|
92
|
+
while (true) {
|
|
93
|
+
const selection = await chooseCatalogModel(ctx, preferredLanguages, currentModelId, {
|
|
94
|
+
postActivation: options.postActivation,
|
|
95
|
+
// Keep the post-selection state when the picker reopens after a
|
|
96
|
+
// round-trip through the language step.
|
|
97
|
+
activatedInFlow: configured !== undefined,
|
|
98
|
+
onActivate: activate,
|
|
99
|
+
cancelLabel: "close",
|
|
100
|
+
});
|
|
101
|
+
// The picker can close while its last commit is still in flight; wait so
|
|
102
|
+
// configured reflects every selection that will land on disk.
|
|
103
|
+
await waitForCommits();
|
|
104
|
+
|
|
105
|
+
if (!selection || selection.type === "complete") return configured;
|
|
106
|
+
|
|
107
|
+
// Esc and Continue both keep the selection here; the picker edits live
|
|
108
|
+
// state rather than gating it behind a confirm.
|
|
109
|
+
const changed = await chooseLanguages(ctx, preferredLanguages);
|
|
110
|
+
if (changed) {
|
|
111
|
+
try {
|
|
112
|
+
if (configured) {
|
|
113
|
+
const updated = { ...configured, preferredLanguages: changed.languages };
|
|
114
|
+
await writeSettings(updated);
|
|
115
|
+
configured = updated;
|
|
116
|
+
} else {
|
|
117
|
+
await options.onPreferredLanguagesChange?.(changed.languages);
|
|
118
|
+
}
|
|
119
|
+
preferredLanguages = changed.languages;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
ctx.ui.notify(
|
|
122
|
+
`Could not save preferred languages: ${error instanceof Error ? error.message : String(error)}`,
|
|
123
|
+
"error",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Reaching the language pane after a commit takes a Tab that landed while
|
|
129
|
+
// the save was in flight. Esc there closes the flow instead of bouncing
|
|
130
|
+
// back to a model pane that no longer offers the language step.
|
|
131
|
+
if (!changed?.confirmed && configured) return configured;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function chooseOnboardingShortcut(
|
|
136
|
+
ctx: ExtensionContext,
|
|
137
|
+
current: string,
|
|
138
|
+
): Promise<string | undefined> {
|
|
139
|
+
return ctx.ui.custom<string | undefined>((tui, theme, keybindings, done) =>
|
|
140
|
+
createShortcutPicker(tui, theme, keybindings, current, done),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function saveOnboardingSettings(
|
|
145
|
+
ctx: ExtensionContext,
|
|
146
|
+
settings: TranscribeSettings,
|
|
147
|
+
): Promise<boolean> {
|
|
148
|
+
try {
|
|
149
|
+
await writeSettings(settings);
|
|
150
|
+
return true;
|
|
151
|
+
} catch (error) {
|
|
152
|
+
ctx.ui.notify(
|
|
153
|
+
`Could not save settings: ${error instanceof Error ? error.message : String(error)}`,
|
|
154
|
+
"error",
|
|
155
|
+
);
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The Try it step, with its shortcut, microphone, and model adjustments looping back into it. */
|
|
161
|
+
async function finishOnboarding(
|
|
162
|
+
ctx: ExtensionContext,
|
|
163
|
+
initial: TranscribeSettings,
|
|
164
|
+
changeModel: (current: TranscribeSettings) => Promise<TranscribeSettings | undefined>,
|
|
165
|
+
): Promise<TranscribeSettings> {
|
|
166
|
+
let configured = initial;
|
|
167
|
+
while (true) {
|
|
168
|
+
const result = await tryVoice(ctx, configured);
|
|
169
|
+
if (result?.action === "model") {
|
|
170
|
+
configured = (await changeModel(configured)) ?? configured;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (result?.action === "shortcut") {
|
|
174
|
+
const shortcut = await chooseOnboardingShortcut(ctx, configured.shortcut);
|
|
175
|
+
if (!shortcut || shortcut === configured.shortcut) continue;
|
|
176
|
+
const updated = { ...configured, shortcut };
|
|
177
|
+
if (await saveOnboardingSettings(ctx, updated)) configured = updated;
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (result?.action === "microphone") {
|
|
181
|
+
const permission = microphonePermission();
|
|
182
|
+
const microphone = await chooseMicrophone(ctx, configured.microphone, permission);
|
|
183
|
+
if (!microphone || microphonesEqual(microphone, configured.microphone)) continue;
|
|
184
|
+
const updated = { ...configured, microphone };
|
|
185
|
+
if (await saveOnboardingSettings(ctx, updated)) configured = updated;
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
return configured;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Change the model from Try it, including a round trip through languages. */
|
|
193
|
+
export async function changeOnboardingModel(
|
|
194
|
+
ctx: ExtensionContext,
|
|
195
|
+
current: TranscribeSettings,
|
|
196
|
+
): Promise<TranscribeSettings | undefined> {
|
|
197
|
+
let languages = [...current.preferredLanguages];
|
|
198
|
+
let picks = recommendModels(CATALOG_MODELS, languages);
|
|
199
|
+
// With nothing to recommend besides the current model, the catalog is the
|
|
200
|
+
// whole flow. Otherwise the recommendation pane fronts it, and Esc from
|
|
201
|
+
// the catalog returns there rather than to Try it.
|
|
202
|
+
let recommending = hasRecommendedAlternatives(picks);
|
|
203
|
+
let pane: "recommended" | "browse" = recommending ? "recommended" : "browse";
|
|
204
|
+
let chosen: TranscribeSettings | undefined;
|
|
205
|
+
const activation = createSettingsActivation(
|
|
206
|
+
() => ({
|
|
207
|
+
shortcut: current.shortcut,
|
|
208
|
+
preferredLanguages: languages,
|
|
209
|
+
microphone: current.microphone,
|
|
210
|
+
chineseOutput: current.chineseOutput,
|
|
211
|
+
backend: current.backend,
|
|
212
|
+
}),
|
|
213
|
+
(settings) => {
|
|
214
|
+
chosen = settings;
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
while (true) {
|
|
219
|
+
const result = pane === "recommended"
|
|
220
|
+
? await chooseRecommendedModel(ctx, languages, picks, activation.activate, {
|
|
221
|
+
title: "Change model",
|
|
222
|
+
expanded: true,
|
|
223
|
+
onboardingStep: 3,
|
|
224
|
+
})
|
|
225
|
+
: await chooseCatalogModel(ctx, languages, chosen?.model.id ?? current.model.id, {
|
|
226
|
+
postActivation: "advance",
|
|
227
|
+
onActivate: activation.activate,
|
|
228
|
+
cancelLabel: "back",
|
|
229
|
+
onboardingStep: 3,
|
|
230
|
+
title: "Browse all models",
|
|
231
|
+
});
|
|
232
|
+
await activation.waitForCommits();
|
|
233
|
+
if (!result && pane === "browse" && recommending) {
|
|
234
|
+
pane = "recommended";
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (!result || result.type === "complete" || result.type === "back") return chosen;
|
|
238
|
+
if (result.type === "other-models") {
|
|
239
|
+
pane = "browse";
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const changed = await chooseLanguages(ctx, languages, {
|
|
244
|
+
cancelLabel: "back",
|
|
245
|
+
onboardingStep: 3,
|
|
246
|
+
});
|
|
247
|
+
if (!changed?.confirmed) continue;
|
|
248
|
+
// Language edits remain a draft until a model is selected. Esc back to
|
|
249
|
+
// Try it must not silently alter the settings it is about to test.
|
|
250
|
+
languages = changed.languages;
|
|
251
|
+
picks = recommendModels(CATALOG_MODELS, languages);
|
|
252
|
+
// Even a single pick deserves its recommendation after languages change.
|
|
253
|
+
recommending = true;
|
|
254
|
+
pane = "recommended";
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
export async function runOnboarding(
|
|
259
|
+
ctx: ExtensionContext,
|
|
260
|
+
shortcut = DEFAULT_SHORTCUT,
|
|
261
|
+
): Promise<TranscribeSettings | undefined> {
|
|
262
|
+
if (!requireTui(ctx)) return undefined;
|
|
263
|
+
|
|
264
|
+
let languages = defaultSpokenLanguages();
|
|
265
|
+
// Navigation cannot undo a completed settings commit. Keep it across a
|
|
266
|
+
// return to languages, even if the user then exits without another choice.
|
|
267
|
+
let configured: TranscribeSettings | undefined;
|
|
268
|
+
while (true) {
|
|
269
|
+
const chosen = await chooseLanguages(ctx, languages, {
|
|
270
|
+
cancelLabel: "exit",
|
|
271
|
+
onboardingStep: 1,
|
|
272
|
+
});
|
|
273
|
+
if (!chosen?.confirmed) return configured;
|
|
274
|
+
languages = chosen.languages;
|
|
275
|
+
// The picks are judged on the benchmark rig; the Try it step measures
|
|
276
|
+
// the real wait on this machine.
|
|
277
|
+
const picks = recommendModels(CATALOG_MODELS, languages);
|
|
278
|
+
const { activate, waitForCommits } = createSettingsActivation(
|
|
279
|
+
() => ({ shortcut, preferredLanguages: languages }),
|
|
280
|
+
(settings) => {
|
|
281
|
+
configured = settings;
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
let changeLanguages = false;
|
|
286
|
+
while (!changeLanguages) {
|
|
287
|
+
const recommendation = await chooseRecommendedModel(
|
|
288
|
+
ctx,
|
|
289
|
+
languages,
|
|
290
|
+
picks,
|
|
291
|
+
activate,
|
|
292
|
+
{ onboardingStep: 2 },
|
|
293
|
+
);
|
|
294
|
+
await waitForCommits();
|
|
295
|
+
if (!recommendation) return configured;
|
|
296
|
+
if (recommendation.type === "complete" && configured) {
|
|
297
|
+
return finishOnboarding(ctx, configured, (current) => changeOnboardingModel(ctx, current));
|
|
298
|
+
}
|
|
299
|
+
if (recommendation.type === "change-languages" || recommendation.type === "back") {
|
|
300
|
+
changeLanguages = true;
|
|
301
|
+
continue;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const selection = await chooseCatalogModel(ctx, languages, configured?.model.id, {
|
|
305
|
+
postActivation: "advance",
|
|
306
|
+
onActivate: activate,
|
|
307
|
+
cancelLabel: "back",
|
|
308
|
+
onboardingStep: 2,
|
|
309
|
+
title: "Browse all models",
|
|
310
|
+
});
|
|
311
|
+
await waitForCommits();
|
|
312
|
+
if (selection?.type === "complete" && configured) {
|
|
313
|
+
return finishOnboarding(ctx, configured, (current) => changeOnboardingModel(ctx, current));
|
|
314
|
+
}
|
|
315
|
+
if (selection?.type === "change-languages") changeLanguages = true;
|
|
316
|
+
// Esc from the complete selector returns to the recommendation pane.
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import OpenAI from "openai";
|
|
2
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
3
|
+
import { convertChineseOutput, isChineseLanguage } from "./chinese.js";
|
|
4
|
+
import { describeError, writeLog } from "./log.js";
|
|
5
|
+
import type { BackendSettings, ChineseOutput } from "./settings.js";
|
|
6
|
+
import type {
|
|
7
|
+
DictationStream,
|
|
8
|
+
TranscribeCppBackend,
|
|
9
|
+
TranscriptionOptions,
|
|
10
|
+
} from "./transcription.js";
|
|
11
|
+
|
|
12
|
+
type OpenAIAsrSettings = Extract<BackendSettings, { type: "openai-asr" }>;
|
|
13
|
+
|
|
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
|
+
export type AsrRequest = {
|
|
21
|
+
/** `data:audio/wav;base64,…` of the whole recording. */
|
|
22
|
+
dataUrl: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/** Uploads the recording and returns the transcript text. Injectable for tests. */
|
|
26
|
+
export type AsrTransport = (
|
|
27
|
+
request: AsrRequest,
|
|
28
|
+
signal?: AbortSignal,
|
|
29
|
+
) => Promise<string>;
|
|
30
|
+
|
|
31
|
+
const SAMPLE_RATE = CAPTURE_SAMPLE_RATE;
|
|
32
|
+
const REQUEST_TIMEOUT_MS = 60_000;
|
|
33
|
+
const MAX_RETRIES = 1;
|
|
34
|
+
|
|
35
|
+
/** 16-bit mono PCM WAV from float samples in [-1, 1]. */
|
|
36
|
+
export function encodeWav(pcm: Float32Array, sampleRate: number): Buffer {
|
|
37
|
+
const dataSize = pcm.length * 2;
|
|
38
|
+
const buffer = Buffer.alloc(44 + dataSize);
|
|
39
|
+
buffer.write("RIFF", 0, "ascii");
|
|
40
|
+
buffer.writeUInt32LE(36 + dataSize, 4);
|
|
41
|
+
buffer.write("WAVE", 8, "ascii");
|
|
42
|
+
buffer.write("fmt ", 12, "ascii");
|
|
43
|
+
buffer.writeUInt32LE(16, 16); // PCM fmt chunk size
|
|
44
|
+
buffer.writeUInt16LE(1, 20); // format: PCM
|
|
45
|
+
buffer.writeUInt16LE(1, 22); // channels: mono
|
|
46
|
+
buffer.writeUInt32LE(sampleRate, 24);
|
|
47
|
+
buffer.writeUInt32LE(sampleRate * 2, 28); // byte rate
|
|
48
|
+
buffer.writeUInt16LE(2, 32); // block align
|
|
49
|
+
buffer.writeUInt16LE(16, 34); // bits per sample
|
|
50
|
+
buffer.write("data", 36, "ascii");
|
|
51
|
+
buffer.writeUInt32LE(dataSize, 40);
|
|
52
|
+
for (let i = 0; i < pcm.length; i++) {
|
|
53
|
+
const sample = Math.max(-1, Math.min(1, pcm[i]!));
|
|
54
|
+
buffer.writeInt16LE(Math.round(sample * 32767), 44 + i * 2);
|
|
55
|
+
}
|
|
56
|
+
return buffer;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function toDataUrl(pcm: Float32Array, sampleRate: number): string {
|
|
60
|
+
return `data:audio/wav;base64,${encodeWav(pcm, sampleRate).toString("base64")}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Chat-completions body with an `input_audio` content block carrying the WAV
|
|
65
|
+
* recording as a base64 data URL. Generic OpenAI-compatible shape — no
|
|
66
|
+
* provider-specific extensions are attached.
|
|
67
|
+
*/
|
|
68
|
+
export function buildChatBody(
|
|
69
|
+
model: string,
|
|
70
|
+
dataUrl: string,
|
|
71
|
+
): Record<string, unknown> {
|
|
72
|
+
return {
|
|
73
|
+
model,
|
|
74
|
+
messages: [
|
|
75
|
+
{
|
|
76
|
+
role: "user",
|
|
77
|
+
content: [{ type: "input_audio", input_audio: { data: dataUrl } }],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
stream: false,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type ChatCompletionLike = {
|
|
85
|
+
choices?: Array<{ message?: { content?: unknown } }>;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
function createOpenAITransport(config: OpenAIAsrSettings): {
|
|
89
|
+
transport: AsrTransport;
|
|
90
|
+
dispose: () => void;
|
|
91
|
+
} {
|
|
92
|
+
const apiKey = process.env[config.apiKeyEnv]?.trim();
|
|
93
|
+
if (!apiKey) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`${config.apiKeyEnv} is not set. Export your ASR API key (e.g. in your shell profile) and restart Pi.`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const client = new OpenAI({
|
|
99
|
+
apiKey,
|
|
100
|
+
baseURL: config.baseUrl,
|
|
101
|
+
timeout: REQUEST_TIMEOUT_MS,
|
|
102
|
+
maxRetries: MAX_RETRIES,
|
|
103
|
+
});
|
|
104
|
+
return {
|
|
105
|
+
transport: async (request, signal) => {
|
|
106
|
+
const body = buildChatBody(config.model, request.dataUrl);
|
|
107
|
+
const completion = (await client.chat.completions.create(
|
|
108
|
+
body as never,
|
|
109
|
+
{ signal } as never,
|
|
110
|
+
)) as ChatCompletionLike;
|
|
111
|
+
const content = completion.choices?.[0]?.message?.content;
|
|
112
|
+
if (typeof content !== "string" || !content.trim()) {
|
|
113
|
+
throw new Error("the ASR endpoint returned an empty transcript");
|
|
114
|
+
}
|
|
115
|
+
return content;
|
|
116
|
+
},
|
|
117
|
+
// openai v7 is fetch-based and keeps no sockets open between requests.
|
|
118
|
+
dispose: () => undefined,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Trims, then applies the Chinese script preference exactly like the local
|
|
124
|
+
* backend. The chat-completions response carries no detected language, so
|
|
125
|
+
* this mirrors the local `finishTranscript` with detectedLanguage = "" —
|
|
126
|
+
* conversion runs only when the configured language is Chinese.
|
|
127
|
+
*/
|
|
128
|
+
export async function finishRemoteTranscript(
|
|
129
|
+
text: string,
|
|
130
|
+
options: TranscriptionOptions,
|
|
131
|
+
): Promise<string> {
|
|
132
|
+
const trimmed = text.trim();
|
|
133
|
+
return isChineseLanguage(options.language ?? "")
|
|
134
|
+
? convertChineseOutput(trimmed, options.chineseOutput ?? "simplified")
|
|
135
|
+
: trimmed;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export type OpenAIAsrBackendDeps = {
|
|
139
|
+
/** Creates the local model used when a remote call fails. */
|
|
140
|
+
createFallback?: () => Promise<LocalFallback>;
|
|
141
|
+
/** Overrides the network transport (tests). */
|
|
142
|
+
transport?: AsrTransport;
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Batch-only remote backend: `startStream` resolves to undefined so the
|
|
147
|
+
* 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.
|
|
150
|
+
*/
|
|
151
|
+
export class OpenAIAsrBackend {
|
|
152
|
+
private client: { transport: AsrTransport; dispose: () => void } | undefined;
|
|
153
|
+
private fallback: Promise<LocalFallback> | undefined;
|
|
154
|
+
private disposed = false;
|
|
155
|
+
private readonly injected: AsrTransport | undefined;
|
|
156
|
+
|
|
157
|
+
constructor(
|
|
158
|
+
private readonly config: OpenAIAsrSettings,
|
|
159
|
+
private readonly deps: OpenAIAsrBackendDeps = {},
|
|
160
|
+
) {
|
|
161
|
+
this.injected = deps.transport;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async prepare(): Promise<void> {
|
|
165
|
+
if (this.disposed) throw new Error("Remote ASR backend has been disposed");
|
|
166
|
+
// 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.
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Batch-only: the dictation service drops live chunks and transcribes on stop. */
|
|
172
|
+
async startStream(): Promise<DictationStream | undefined> {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async transcribe(
|
|
177
|
+
pcm: Float32Array,
|
|
178
|
+
options: TranscriptionOptions = {},
|
|
179
|
+
): Promise<string> {
|
|
180
|
+
if (pcm.length === 0) throw new Error("No audio samples were provided");
|
|
181
|
+
const signal = options.signal;
|
|
182
|
+
try {
|
|
183
|
+
let transport = this.injected;
|
|
184
|
+
if (!transport) {
|
|
185
|
+
if (this.disposed) throw new Error("Remote ASR backend has been disposed");
|
|
186
|
+
// 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.
|
|
190
|
+
const client = this.client ?? createOpenAITransport(this.config);
|
|
191
|
+
this.client = client;
|
|
192
|
+
transport = client.transport;
|
|
193
|
+
}
|
|
194
|
+
signal?.throwIfAborted();
|
|
195
|
+
const text = await transport(
|
|
196
|
+
{ dataUrl: toDataUrl(pcm, SAMPLE_RATE) },
|
|
197
|
+
signal,
|
|
198
|
+
);
|
|
199
|
+
signal?.throwIfAborted();
|
|
200
|
+
writeLog(
|
|
201
|
+
"debug",
|
|
202
|
+
"voice",
|
|
203
|
+
`remote ASR transcribed ${(pcm.length / SAMPLE_RATE).toFixed(1)}s`,
|
|
204
|
+
);
|
|
205
|
+
return await finishRemoteTranscript(text, options);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
if (signal?.aborted || this.disposed) throw error;
|
|
208
|
+
if (!this.deps.createFallback) throw error;
|
|
209
|
+
writeLog(
|
|
210
|
+
"warn",
|
|
211
|
+
"voice",
|
|
212
|
+
`remote ASR failed (${describeError(error)}); falling back to the local model`,
|
|
213
|
+
);
|
|
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
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
async dispose(): Promise<void> {
|
|
226
|
+
if (this.disposed) return;
|
|
227
|
+
this.disposed = true;
|
|
228
|
+
this.client?.dispose();
|
|
229
|
+
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
|
+
}
|
|
247
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { CAPTURE_SAMPLE_RATE } from "./audio-constants.js";
|
|
2
|
+
|
|
3
|
+
const STREAM_CHUNK_SAMPLES = CAPTURE_SAMPLE_RATE / 2;
|
|
4
|
+
|
|
5
|
+
/** Coalesces recorder chunks into fresh Float32 PCM chunks without timers. */
|
|
6
|
+
export class PcmChunker {
|
|
7
|
+
private pieces: Float32Array[] = [];
|
|
8
|
+
private sampleCount = 0;
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
private readonly onChunk: (chunk: Float32Array) => void,
|
|
12
|
+
private readonly targetSamples = STREAM_CHUNK_SAMPLES,
|
|
13
|
+
) {
|
|
14
|
+
if (!Number.isInteger(targetSamples) || targetSamples <= 0) {
|
|
15
|
+
throw new Error("PCM chunk size must be a positive integer");
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
push(samples: Float32Array): void {
|
|
20
|
+
if (samples.length === 0) return;
|
|
21
|
+
this.pieces.push(samples);
|
|
22
|
+
this.sampleCount += samples.length;
|
|
23
|
+
if (this.sampleCount >= this.targetSamples) this.emit();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
flush(): void {
|
|
27
|
+
if (this.sampleCount > 0) this.emit();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
discard(): void {
|
|
31
|
+
this.pieces = [];
|
|
32
|
+
this.sampleCount = 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private emit(): void {
|
|
36
|
+
const pcm = new Float32Array(this.sampleCount);
|
|
37
|
+
let offset = 0;
|
|
38
|
+
for (const piece of this.pieces) {
|
|
39
|
+
pcm.set(piece, offset);
|
|
40
|
+
offset += piece.length;
|
|
41
|
+
}
|
|
42
|
+
this.pieces = [];
|
|
43
|
+
this.sampleCount = 0;
|
|
44
|
+
this.onChunk(pcm);
|
|
45
|
+
}
|
|
46
|
+
}
|