@earendil-works/pi-voice 0.1.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 +86 -0
- package/catalog/recommendations.json +710 -0
- package/index.ts +1 -0
- package/package.json +69 -0
- package/src/async-limiter.ts +77 -0
- package/src/audio-constants.ts +1 -0
- package/src/audio.ts +193 -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 +204 -0
- package/src/file-audio.ts +164 -0
- package/src/file-transcription.ts +212 -0
- package/src/index.ts +114 -0
- package/src/install-migration.ts +47 -0
- package/src/keybindings.ts +118 -0
- package/src/languages.ts +22 -0
- package/src/microphone-picker.ts +99 -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 +314 -0
- package/src/pcm-chunker.ts +42 -0
- package/src/pcm.ts +19 -0
- package/src/recommendation-picker.ts +512 -0
- package/src/recommendations.ts +423 -0
- package/src/runtime.ts +501 -0
- package/src/settings-menu.ts +410 -0
- package/src/settings-path.ts +13 -0
- package/src/settings.ts +235 -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 +548 -0
- package/src/transcription.ts +186 -0
- package/src/try-it.ts +327 -0
- package/src/ui-components.ts +432 -0
- package/src/visualizer.ts +269 -0
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
import { Deferred } from "./deferred.js";
|
|
2
|
+
import type { TranscribeSettings } from "./settings.js";
|
|
3
|
+
import type {
|
|
4
|
+
DictationStream,
|
|
5
|
+
TranscribeCppBackend,
|
|
6
|
+
TranscriptionOptions,
|
|
7
|
+
} from "./transcription.js";
|
|
8
|
+
|
|
9
|
+
type TranscriptionJob = {
|
|
10
|
+
settings: TranscribeSettings;
|
|
11
|
+
pcm: Float32Array;
|
|
12
|
+
signal?: AbortSignal;
|
|
13
|
+
resolve: (text: string) => void;
|
|
14
|
+
reject: (error: unknown) => void;
|
|
15
|
+
started: boolean;
|
|
16
|
+
settled: boolean;
|
|
17
|
+
removeAbortListener?: () => void;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ReservationState = {
|
|
21
|
+
settings: TranscribeSettings;
|
|
22
|
+
accepting: boolean;
|
|
23
|
+
submitted: boolean;
|
|
24
|
+
cancelled: boolean;
|
|
25
|
+
started: boolean;
|
|
26
|
+
/** Settles once the model (and stream, when available) is ready, or on failure. */
|
|
27
|
+
ready: Deferred;
|
|
28
|
+
/** Resolves when recording ends, whether by submit or cancel. */
|
|
29
|
+
submission: Deferred;
|
|
30
|
+
result: Deferred<string>;
|
|
31
|
+
fullPcm?: Float32Array;
|
|
32
|
+
signal?: AbortSignal;
|
|
33
|
+
removeAbortListener?: () => void;
|
|
34
|
+
chunks: Float32Array[];
|
|
35
|
+
stream?: DictationStream;
|
|
36
|
+
drain?: Promise<void>;
|
|
37
|
+
streamUnavailable: boolean;
|
|
38
|
+
streamError?: unknown;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type DictationReservation = {
|
|
42
|
+
readonly ready: Promise<void>;
|
|
43
|
+
feed(chunk: Float32Array): void;
|
|
44
|
+
submit(pcm: Float32Array, signal?: AbortSignal): Promise<string>;
|
|
45
|
+
cancel(): void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
type ReusableBackend = Pick<
|
|
49
|
+
TranscribeCppBackend,
|
|
50
|
+
"prepare" | "transcribe" | "dispose"
|
|
51
|
+
> & {
|
|
52
|
+
startStream?: (
|
|
53
|
+
options?: TranscriptionOptions,
|
|
54
|
+
) => Promise<DictationStream | undefined>;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type BackendFactory = (
|
|
58
|
+
modelPath: string,
|
|
59
|
+
) => ReusableBackend | Promise<ReusableBackend>;
|
|
60
|
+
|
|
61
|
+
function abortError(signal: AbortSignal): Error {
|
|
62
|
+
return signal.reason instanceof Error ? signal.reason : new Error("Transcription cancelled");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cancellationError(state: ReservationState): Error {
|
|
66
|
+
return state.signal?.aborted ? abortError(state.signal) : new Error("Dictation cancelled");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function transcriptionOptions(
|
|
70
|
+
settings: TranscribeSettings,
|
|
71
|
+
signal?: AbortSignal,
|
|
72
|
+
): TranscriptionOptions {
|
|
73
|
+
return {
|
|
74
|
+
signal,
|
|
75
|
+
language:
|
|
76
|
+
settings.transcriptionLanguage === "auto"
|
|
77
|
+
? undefined
|
|
78
|
+
: settings.transcriptionLanguage,
|
|
79
|
+
chineseOutput: settings.chineseOutput,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Owns the loaded model and schedules dictation ahead of queued file jobs. */
|
|
84
|
+
export class TranscriptionService {
|
|
85
|
+
private backend: ReusableBackend | undefined;
|
|
86
|
+
private modelPath: string | undefined;
|
|
87
|
+
private readonly dictationQueue: ReservationState[] = [];
|
|
88
|
+
private readonly fileQueue: TranscriptionJob[] = [];
|
|
89
|
+
private readonly reservationStates = new Set<ReservationState>();
|
|
90
|
+
private reservation: ReservationState | undefined;
|
|
91
|
+
private loop: Promise<void> | undefined;
|
|
92
|
+
private shuttingDown = false;
|
|
93
|
+
private readonly shutdownController = new AbortController();
|
|
94
|
+
|
|
95
|
+
constructor(
|
|
96
|
+
private readonly createBackend: BackendFactory = async (modelPath) => {
|
|
97
|
+
const { TranscribeCppBackend } = await import("./transcription.js");
|
|
98
|
+
return new TranscribeCppBackend(modelPath);
|
|
99
|
+
},
|
|
100
|
+
) {}
|
|
101
|
+
|
|
102
|
+
reserveDictation(settings: TranscribeSettings): DictationReservation {
|
|
103
|
+
if (this.shuttingDown) throw new Error("Pi Voice is shutting down");
|
|
104
|
+
if (this.reservation) throw new Error("A dictation reservation is already active");
|
|
105
|
+
|
|
106
|
+
const state: ReservationState = {
|
|
107
|
+
settings,
|
|
108
|
+
accepting: true,
|
|
109
|
+
submitted: false,
|
|
110
|
+
cancelled: false,
|
|
111
|
+
started: false,
|
|
112
|
+
ready: new Deferred(),
|
|
113
|
+
submission: new Deferred(),
|
|
114
|
+
result: new Deferred<string>(),
|
|
115
|
+
chunks: [],
|
|
116
|
+
streamUnavailable: false,
|
|
117
|
+
};
|
|
118
|
+
// Cancellation can happen before callers attach handlers to either promise.
|
|
119
|
+
void state.ready.promise.catch(() => undefined);
|
|
120
|
+
void state.result.promise.catch(() => undefined);
|
|
121
|
+
this.reservation = state;
|
|
122
|
+
this.reservationStates.add(state);
|
|
123
|
+
this.schedule();
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
ready: state.ready.promise,
|
|
127
|
+
feed: (chunk) => {
|
|
128
|
+
if (
|
|
129
|
+
!state.accepting ||
|
|
130
|
+
state.streamUnavailable ||
|
|
131
|
+
state.streamError !== undefined ||
|
|
132
|
+
chunk.length === 0
|
|
133
|
+
) return;
|
|
134
|
+
state.chunks.push(chunk);
|
|
135
|
+
this.startDrain(state);
|
|
136
|
+
},
|
|
137
|
+
submit: (pcm, signal) => {
|
|
138
|
+
if (!state.accepting || state.submitted) {
|
|
139
|
+
return Promise.reject(new Error("The dictation reservation is no longer active"));
|
|
140
|
+
}
|
|
141
|
+
state.accepting = false;
|
|
142
|
+
state.submitted = true;
|
|
143
|
+
state.fullPcm = pcm;
|
|
144
|
+
if (this.reservation === state) this.reservation = undefined;
|
|
145
|
+
if (!state.started) this.dictationQueue.push(state);
|
|
146
|
+
state.signal = signal;
|
|
147
|
+
if (signal) {
|
|
148
|
+
const onAbort = (): void => this.abortReservation(state);
|
|
149
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
150
|
+
state.removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
151
|
+
if (signal.aborted) onAbort();
|
|
152
|
+
}
|
|
153
|
+
state.submission.resolve();
|
|
154
|
+
this.schedule();
|
|
155
|
+
return state.result.promise;
|
|
156
|
+
},
|
|
157
|
+
cancel: () => {
|
|
158
|
+
if (!state.accepting || state.submitted) return;
|
|
159
|
+
state.accepting = false;
|
|
160
|
+
state.cancelled = true;
|
|
161
|
+
state.chunks.length = 0;
|
|
162
|
+
if (this.reservation === state) this.reservation = undefined;
|
|
163
|
+
this.resetStream(state);
|
|
164
|
+
state.ready.reject(cancellationError(state));
|
|
165
|
+
state.submission.resolve();
|
|
166
|
+
if (!state.started) this.reservationStates.delete(state);
|
|
167
|
+
this.schedule();
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
transcribeFile(
|
|
173
|
+
settings: TranscribeSettings,
|
|
174
|
+
pcm: Float32Array,
|
|
175
|
+
signal?: AbortSignal,
|
|
176
|
+
): Promise<string> {
|
|
177
|
+
if (this.shuttingDown) return Promise.reject(new Error("Pi Voice is shutting down"));
|
|
178
|
+
if (signal?.aborted) return Promise.reject(abortError(signal));
|
|
179
|
+
|
|
180
|
+
const result = new Promise<string>((resolve, reject) => {
|
|
181
|
+
const job: TranscriptionJob = {
|
|
182
|
+
settings,
|
|
183
|
+
pcm,
|
|
184
|
+
signal,
|
|
185
|
+
resolve,
|
|
186
|
+
reject,
|
|
187
|
+
started: false,
|
|
188
|
+
settled: false,
|
|
189
|
+
};
|
|
190
|
+
if (signal) {
|
|
191
|
+
const onAbort = (): void => {
|
|
192
|
+
if (job.started || job.settled) return;
|
|
193
|
+
const index = this.fileQueue.indexOf(job);
|
|
194
|
+
if (index >= 0) this.fileQueue.splice(index, 1);
|
|
195
|
+
this.settleJob(job, () => reject(abortError(signal)));
|
|
196
|
+
this.schedule();
|
|
197
|
+
};
|
|
198
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
199
|
+
job.removeAbortListener = () => signal.removeEventListener("abort", onAbort);
|
|
200
|
+
}
|
|
201
|
+
this.fileQueue.push(job);
|
|
202
|
+
});
|
|
203
|
+
this.schedule();
|
|
204
|
+
return result;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private abortReservation(state: ReservationState): void {
|
|
208
|
+
if (state.cancelled || state.result.settled) return;
|
|
209
|
+
state.cancelled = true;
|
|
210
|
+
state.accepting = false;
|
|
211
|
+
state.chunks.length = 0;
|
|
212
|
+
// Queue native reset before the scheduler can begin another model operation.
|
|
213
|
+
this.resetStream(state);
|
|
214
|
+
state.submission.resolve();
|
|
215
|
+
|
|
216
|
+
// A submitted reservation waiting behind active work has no native state to
|
|
217
|
+
// tear down, so preserve the old immediate-cancellation behavior.
|
|
218
|
+
if (!state.started) {
|
|
219
|
+
const index = this.dictationQueue.indexOf(state);
|
|
220
|
+
if (index >= 0) this.dictationQueue.splice(index, 1);
|
|
221
|
+
this.reservationStates.delete(state);
|
|
222
|
+
state.removeAbortListener?.();
|
|
223
|
+
const error = cancellationError(state);
|
|
224
|
+
state.ready.reject(error);
|
|
225
|
+
state.result.reject(error);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private resetStream(state: ReservationState): void {
|
|
230
|
+
const stream = state.stream;
|
|
231
|
+
state.stream = undefined;
|
|
232
|
+
stream?.reset();
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private startDrain(state: ReservationState): void {
|
|
236
|
+
if (
|
|
237
|
+
!state.stream ||
|
|
238
|
+
state.drain ||
|
|
239
|
+
state.cancelled ||
|
|
240
|
+
state.streamUnavailable ||
|
|
241
|
+
state.streamError !== undefined ||
|
|
242
|
+
state.chunks.length === 0
|
|
243
|
+
) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const draining = this.drainStream(state).finally(() => {
|
|
248
|
+
if (state.drain === draining) state.drain = undefined;
|
|
249
|
+
this.startDrain(state);
|
|
250
|
+
});
|
|
251
|
+
state.drain = draining;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
private async drainStream(state: ReservationState): Promise<void> {
|
|
255
|
+
const stream = state.stream!;
|
|
256
|
+
while (
|
|
257
|
+
state.stream === stream &&
|
|
258
|
+
!state.cancelled &&
|
|
259
|
+
state.chunks.length > 0
|
|
260
|
+
) {
|
|
261
|
+
const chunk = state.chunks.shift()!;
|
|
262
|
+
try {
|
|
263
|
+
await stream.feed(chunk);
|
|
264
|
+
} catch (error) {
|
|
265
|
+
state.streamError = error;
|
|
266
|
+
state.chunks.length = 0;
|
|
267
|
+
// A batch fallback may only start after reset has been issued.
|
|
268
|
+
this.resetStream(state);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private settleJob(job: TranscriptionJob, settle: () => void): void {
|
|
275
|
+
if (job.settled) return;
|
|
276
|
+
job.settled = true;
|
|
277
|
+
job.removeAbortListener?.();
|
|
278
|
+
settle();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private schedule(): void {
|
|
282
|
+
if (this.loop) return;
|
|
283
|
+
const loop = this.process().finally(() => {
|
|
284
|
+
if (this.loop === loop) this.loop = undefined;
|
|
285
|
+
if (this.hasRunnableWork()) this.schedule();
|
|
286
|
+
});
|
|
287
|
+
this.loop = loop;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private hasRunnableWork(): boolean {
|
|
291
|
+
if (this.shuttingDown) return false;
|
|
292
|
+
return (
|
|
293
|
+
this.dictationQueue.length > 0 ||
|
|
294
|
+
this.reservation !== undefined ||
|
|
295
|
+
this.fileQueue.length > 0
|
|
296
|
+
);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
private async process(): Promise<void> {
|
|
300
|
+
// Model work is non-preemptive. At each operation boundary, submitted
|
|
301
|
+
// dictation and active recording reservations run before queued file jobs.
|
|
302
|
+
// An active recording holds the model lane until submit or cancel.
|
|
303
|
+
while (!this.shuttingDown) {
|
|
304
|
+
const queuedDictation = this.dictationQueue.shift();
|
|
305
|
+
if (queuedDictation) {
|
|
306
|
+
queuedDictation.started = true;
|
|
307
|
+
await this.runReservation(queuedDictation);
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const reservation = this.reservation;
|
|
312
|
+
if (reservation) {
|
|
313
|
+
reservation.started = true;
|
|
314
|
+
await this.runReservation(reservation);
|
|
315
|
+
if (this.reservation === reservation) this.reservation = undefined;
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const file = this.fileQueue.shift();
|
|
320
|
+
if (file) {
|
|
321
|
+
await this.runJob(file);
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A dispose failure here would otherwise reject the loop promise unobserved.
|
|
326
|
+
await this.unloadModel().catch(() => undefined);
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
private async runReservation(state: ReservationState): Promise<void> {
|
|
332
|
+
try {
|
|
333
|
+
await this.runReservationWork(state);
|
|
334
|
+
} finally {
|
|
335
|
+
state.removeAbortListener?.();
|
|
336
|
+
this.reservationStates.delete(state);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
private settleCancelledReservation(state: ReservationState): void {
|
|
341
|
+
this.resetStream(state);
|
|
342
|
+
const error = cancellationError(state);
|
|
343
|
+
state.ready.reject(error);
|
|
344
|
+
if (state.submitted) state.result.reject(error);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
private async runReservationWork(state: ReservationState): Promise<void> {
|
|
348
|
+
if (state.cancelled) {
|
|
349
|
+
this.settleCancelledReservation(state);
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
let backend: ReusableBackend;
|
|
354
|
+
try {
|
|
355
|
+
backend = await this.ensureModel(state.settings.model.path);
|
|
356
|
+
} catch (error) {
|
|
357
|
+
if (state.cancelled) {
|
|
358
|
+
this.settleCancelledReservation(state);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
state.streamUnavailable = true;
|
|
362
|
+
state.chunks.length = 0;
|
|
363
|
+
state.ready.reject(error);
|
|
364
|
+
await state.submission.promise;
|
|
365
|
+
if (state.submitted) state.result.reject(error);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (state.cancelled) {
|
|
370
|
+
this.settleCancelledReservation(state);
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// If recording already ended while the model loaded (or while the stream
|
|
375
|
+
// opened), avoid replaying the complete clip through a newly opened stream
|
|
376
|
+
// and use the batch path.
|
|
377
|
+
if (!state.submitted && backend.startStream) {
|
|
378
|
+
try {
|
|
379
|
+
const stream = await backend.startStream(
|
|
380
|
+
transcriptionOptions(state.settings),
|
|
381
|
+
);
|
|
382
|
+
if (stream && state.submitted) {
|
|
383
|
+
stream.reset();
|
|
384
|
+
} else {
|
|
385
|
+
state.stream = stream;
|
|
386
|
+
}
|
|
387
|
+
if (!state.stream) {
|
|
388
|
+
state.streamUnavailable = true;
|
|
389
|
+
state.chunks.length = 0;
|
|
390
|
+
}
|
|
391
|
+
} catch (error) {
|
|
392
|
+
// Stream setup is an optimization; preserve dictation via batch fallback.
|
|
393
|
+
state.streamError = error;
|
|
394
|
+
state.chunks.length = 0;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
if (state.cancelled) {
|
|
399
|
+
this.settleCancelledReservation(state);
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
state.ready.resolve();
|
|
404
|
+
this.startDrain(state);
|
|
405
|
+
await state.submission.promise;
|
|
406
|
+
|
|
407
|
+
if (!state.submitted) {
|
|
408
|
+
this.resetStream(state);
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
if (state.cancelled || state.signal?.aborted) throw cancellationError(state);
|
|
414
|
+
|
|
415
|
+
this.startDrain(state);
|
|
416
|
+
while (state.drain) await state.drain;
|
|
417
|
+
|
|
418
|
+
if (state.cancelled || state.signal?.aborted) throw cancellationError(state);
|
|
419
|
+
|
|
420
|
+
const pcm = state.fullPcm!;
|
|
421
|
+
const stream = state.stream;
|
|
422
|
+
// An empty clip takes the batch path so it fails exactly as it did before
|
|
423
|
+
// streaming existed, instead of finalizing a stream that was never fed.
|
|
424
|
+
if (stream && state.streamError === undefined && pcm.length > 0) {
|
|
425
|
+
try {
|
|
426
|
+
const text = await stream.finalize();
|
|
427
|
+
state.signal?.throwIfAborted();
|
|
428
|
+
state.result.resolve(text);
|
|
429
|
+
return;
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (state.signal?.aborted) throw abortError(state.signal);
|
|
432
|
+
state.streamError = error;
|
|
433
|
+
} finally {
|
|
434
|
+
// Idempotent if finalize already released it. This must happen before
|
|
435
|
+
// the reservation is released and file work is scheduled.
|
|
436
|
+
if (state.stream === stream) state.stream = undefined;
|
|
437
|
+
stream.reset();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Stream setup/feed/finalize failure: reset precedes the batch fallback.
|
|
442
|
+
this.resetStream(state);
|
|
443
|
+
state.chunks.length = 0;
|
|
444
|
+
const text = await this.transcribeBatch(
|
|
445
|
+
backend,
|
|
446
|
+
state.settings,
|
|
447
|
+
pcm,
|
|
448
|
+
this.withShutdown(state.signal),
|
|
449
|
+
);
|
|
450
|
+
state.result.resolve(text);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
this.resetStream(state);
|
|
453
|
+
state.result.reject(
|
|
454
|
+
state.signal?.aborted ? abortError(state.signal) : error,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private withShutdown(signal?: AbortSignal): AbortSignal {
|
|
460
|
+
return signal
|
|
461
|
+
? AbortSignal.any([signal, this.shutdownController.signal])
|
|
462
|
+
: this.shutdownController.signal;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The single batch path shared by file jobs and the dictation fallback. */
|
|
466
|
+
private async transcribeBatch(
|
|
467
|
+
backend: ReusableBackend,
|
|
468
|
+
settings: TranscribeSettings,
|
|
469
|
+
pcm: Float32Array,
|
|
470
|
+
signal: AbortSignal,
|
|
471
|
+
): Promise<string> {
|
|
472
|
+
signal.throwIfAborted();
|
|
473
|
+
return backend.transcribe(pcm, transcriptionOptions(settings, signal));
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
private async runJob(job: TranscriptionJob): Promise<void> {
|
|
477
|
+
if (job.settled) return;
|
|
478
|
+
job.started = true;
|
|
479
|
+
const signal = this.withShutdown(job.signal);
|
|
480
|
+
|
|
481
|
+
try {
|
|
482
|
+
signal.throwIfAborted();
|
|
483
|
+
const backend = await this.ensureModel(job.settings.model.path);
|
|
484
|
+
const text = await this.transcribeBatch(backend, job.settings, job.pcm, signal);
|
|
485
|
+
this.settleJob(job, () => job.resolve(text));
|
|
486
|
+
} catch (error) {
|
|
487
|
+
this.settleJob(job, () => job.reject(error));
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
private async ensureModel(modelPath: string): Promise<ReusableBackend> {
|
|
492
|
+
if (this.backend && this.modelPath === modelPath) {
|
|
493
|
+
await this.backend.prepare();
|
|
494
|
+
return this.backend;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
await this.unloadModel();
|
|
498
|
+
const backend = await this.createBackend(modelPath);
|
|
499
|
+
this.backend = backend;
|
|
500
|
+
this.modelPath = modelPath;
|
|
501
|
+
try {
|
|
502
|
+
await backend.prepare();
|
|
503
|
+
return backend;
|
|
504
|
+
} catch (error) {
|
|
505
|
+
if (this.backend === backend) {
|
|
506
|
+
this.backend = undefined;
|
|
507
|
+
this.modelPath = undefined;
|
|
508
|
+
}
|
|
509
|
+
await backend.dispose().catch(() => undefined);
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private async unloadModel(): Promise<void> {
|
|
515
|
+
const backend = this.backend;
|
|
516
|
+
if (!backend) return;
|
|
517
|
+
this.backend = undefined;
|
|
518
|
+
this.modelPath = undefined;
|
|
519
|
+
await backend.dispose();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async shutdown(): Promise<void> {
|
|
523
|
+
if (!this.shuttingDown) {
|
|
524
|
+
this.shuttingDown = true;
|
|
525
|
+
this.shutdownController.abort(new Error("Pi Voice is shutting down"));
|
|
526
|
+
const shutdownError = new Error("Pi Voice is shutting down");
|
|
527
|
+
this.reservation = undefined;
|
|
528
|
+
this.dictationQueue.length = 0;
|
|
529
|
+
for (const reservation of this.reservationStates) {
|
|
530
|
+
reservation.accepting = false;
|
|
531
|
+
reservation.cancelled = true;
|
|
532
|
+
reservation.chunks.length = 0;
|
|
533
|
+
this.resetStream(reservation);
|
|
534
|
+
reservation.ready.reject(shutdownError);
|
|
535
|
+
reservation.result.reject(shutdownError);
|
|
536
|
+
reservation.submission.resolve();
|
|
537
|
+
}
|
|
538
|
+
for (const job of this.fileQueue) {
|
|
539
|
+
this.settleJob(job, () => job.reject(shutdownError));
|
|
540
|
+
}
|
|
541
|
+
this.fileQueue.length = 0;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
await this.loop?.catch(() => undefined);
|
|
545
|
+
this.reservationStates.clear();
|
|
546
|
+
await this.unloadModel().catch(() => undefined);
|
|
547
|
+
}
|
|
548
|
+
}
|