@mulmoclaude/core 0.12.1 → 0.12.2
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/assets/helps/error-recovery.md +90 -0
- package/dist/scheduler/index.cjs +44 -8
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +44 -8
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +5 -0
- package/dist/whisper/client.cjs +187 -96
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.d.ts +50 -0
- package/dist/whisper/client.js +185 -97
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +147 -113
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +147 -113
- package/dist/whisper/index.js.map +1 -1
- package/dist/whisper/sidecar.d.ts +24 -0
- package/package.json +1 -1
package/dist/whisper/client.js
CHANGED
|
@@ -79,12 +79,13 @@ function blobToDataUrl(blob) {
|
|
|
79
79
|
function toMessage(err) {
|
|
80
80
|
return err instanceof Error ? err.message : String(err);
|
|
81
81
|
}
|
|
82
|
-
function
|
|
82
|
+
function createCaptureState(onState) {
|
|
83
83
|
let available = false;
|
|
84
84
|
let listening = false;
|
|
85
85
|
let transcribing = false;
|
|
86
|
+
let pending = 0;
|
|
86
87
|
function emit() {
|
|
87
|
-
|
|
88
|
+
onState?.({
|
|
88
89
|
available,
|
|
89
90
|
listening,
|
|
90
91
|
transcribing
|
|
@@ -102,23 +103,6 @@ function createVoiceCapture(transport, language, callbacks) {
|
|
|
102
103
|
emit();
|
|
103
104
|
}
|
|
104
105
|
}
|
|
105
|
-
let stream = null;
|
|
106
|
-
let audioCtx = null;
|
|
107
|
-
let analyser = null;
|
|
108
|
-
let vadBuffer = /* @__PURE__ */ new Float32Array(0);
|
|
109
|
-
let monitorHandle = null;
|
|
110
|
-
let recorder = null;
|
|
111
|
-
let chunks = [];
|
|
112
|
-
let mimeType = "";
|
|
113
|
-
let segmentHasSpeech = false;
|
|
114
|
-
let silenceStart = null;
|
|
115
|
-
let segmentStart = 0;
|
|
116
|
-
let pending = 0;
|
|
117
|
-
let queue = Promise.resolve();
|
|
118
|
-
let availabilityPollHandle = null;
|
|
119
|
-
let generation = 0;
|
|
120
|
-
let segmentGeneration = 0;
|
|
121
|
-
let startInFlight = false;
|
|
122
106
|
function setPending(delta) {
|
|
123
107
|
pending += delta;
|
|
124
108
|
const next = pending > 0;
|
|
@@ -127,145 +111,249 @@ function createVoiceCapture(transport, language, callbacks) {
|
|
|
127
111
|
emit();
|
|
128
112
|
}
|
|
129
113
|
}
|
|
130
|
-
|
|
114
|
+
return {
|
|
115
|
+
setAvailable,
|
|
116
|
+
setListening,
|
|
117
|
+
setPending,
|
|
118
|
+
isListening: () => listening
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function createAvailabilityPoller(transport, setAvailable) {
|
|
122
|
+
let availabilityPollHandle = null;
|
|
123
|
+
function stop() {
|
|
131
124
|
if (availabilityPollHandle !== null) {
|
|
132
125
|
window.clearInterval(availabilityPollHandle);
|
|
133
126
|
availabilityPollHandle = null;
|
|
134
127
|
}
|
|
135
128
|
}
|
|
136
|
-
async function
|
|
129
|
+
async function refresh() {
|
|
137
130
|
let status;
|
|
138
131
|
try {
|
|
139
132
|
status = await transport.getStatus();
|
|
140
133
|
} catch {
|
|
141
134
|
setAvailable(false);
|
|
142
|
-
|
|
135
|
+
stop();
|
|
143
136
|
return;
|
|
144
137
|
}
|
|
145
138
|
setAvailable(status.ready);
|
|
146
139
|
if (status.downloading) {
|
|
147
140
|
if (availabilityPollHandle === null) availabilityPollHandle = window.setInterval(() => {
|
|
148
|
-
|
|
141
|
+
refresh();
|
|
149
142
|
}, AVAILABILITY_POLL_MS);
|
|
150
|
-
} else
|
|
143
|
+
} else stop();
|
|
151
144
|
}
|
|
145
|
+
return {
|
|
146
|
+
refresh,
|
|
147
|
+
stop
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
function createSegmentQueue(options) {
|
|
151
|
+
const { transport, language, callbacks, setPending, getGeneration } = options;
|
|
152
|
+
let queue = Promise.resolve();
|
|
152
153
|
async function sendSegment(blob, gen) {
|
|
153
|
-
if (gen !==
|
|
154
|
+
if (gen !== getGeneration()) return;
|
|
154
155
|
try {
|
|
155
156
|
const dataUrl = await blobToDataUrl(blob);
|
|
156
157
|
const result = await transport.transcribe(dataUrl, language());
|
|
157
|
-
if (gen !==
|
|
158
|
+
if (gen !== getGeneration()) return;
|
|
158
159
|
const text = result.text.trim();
|
|
159
160
|
if (text.length === 0) callbacks.onEmpty?.();
|
|
160
161
|
else callbacks.onTranscript(text);
|
|
161
162
|
} catch (err) {
|
|
162
|
-
if (gen ===
|
|
163
|
+
if (gen === getGeneration()) callbacks.onError?.(toMessage(err));
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
function enqueue(blob, gen) {
|
|
166
167
|
setPending(1);
|
|
167
168
|
queue = queue.then(() => sendSegment(blob, gen)).catch(() => void 0).finally(() => setPending(-1));
|
|
168
169
|
}
|
|
169
|
-
|
|
170
|
+
return { enqueue };
|
|
171
|
+
}
|
|
172
|
+
function createAudioGraph() {
|
|
173
|
+
let stream = null;
|
|
174
|
+
let audioCtx = null;
|
|
175
|
+
let analyser = null;
|
|
176
|
+
let vadBuffer = /* @__PURE__ */ new Float32Array(0);
|
|
177
|
+
function attach(nextStream) {
|
|
178
|
+
stream = nextStream;
|
|
179
|
+
audioCtx = new AudioContext();
|
|
180
|
+
analyser = audioCtx.createAnalyser();
|
|
181
|
+
analyser.fftSize = 2048;
|
|
182
|
+
vadBuffer = new Float32Array(analyser.fftSize);
|
|
183
|
+
audioCtx.createMediaStreamSource(nextStream).connect(analyser);
|
|
184
|
+
}
|
|
185
|
+
function sample() {
|
|
186
|
+
analyser?.getFloatTimeDomainData(vadBuffer);
|
|
187
|
+
return vadBuffer;
|
|
188
|
+
}
|
|
189
|
+
function teardown() {
|
|
190
|
+
if (audioCtx) {
|
|
191
|
+
audioCtx.close().catch(() => void 0);
|
|
192
|
+
audioCtx = null;
|
|
193
|
+
}
|
|
194
|
+
analyser = null;
|
|
195
|
+
stream?.getTracks().forEach((track) => track.stop());
|
|
196
|
+
stream = null;
|
|
197
|
+
}
|
|
198
|
+
return {
|
|
199
|
+
isAttached: () => analyser !== null,
|
|
200
|
+
sample,
|
|
201
|
+
getStream: () => stream,
|
|
202
|
+
attach,
|
|
203
|
+
teardown
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
async function bootstrapCapture(callbacks, generationAtStart, generationNow) {
|
|
207
|
+
const mimeType = pickRecorderMime() ?? "";
|
|
208
|
+
if (!mimeType || !navigator.mediaDevices?.getUserMedia) {
|
|
209
|
+
callbacks.onError?.("unsupported");
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
let acquired;
|
|
213
|
+
try {
|
|
214
|
+
acquired = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
215
|
+
} catch {
|
|
216
|
+
callbacks.onError?.("permission-denied");
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
if (generationAtStart !== generationNow()) {
|
|
220
|
+
acquired.getTracks().forEach((track) => track.stop());
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
const audioGraph = createAudioGraph();
|
|
224
|
+
try {
|
|
225
|
+
audioGraph.attach(acquired);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
audioGraph.teardown();
|
|
228
|
+
throw err;
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
audioGraph,
|
|
232
|
+
mimeType
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
function createRecorderSession(deps) {
|
|
236
|
+
let recorder = null;
|
|
237
|
+
let chunks = [];
|
|
238
|
+
let segmentHasSpeech = false;
|
|
239
|
+
let sessionGen = 0;
|
|
240
|
+
let sessionMimeType = "";
|
|
241
|
+
function onStop() {
|
|
170
242
|
const hadSpeech = segmentHasSpeech;
|
|
171
|
-
const gen =
|
|
172
|
-
const blob = new Blob(chunks, { type: containerTypeFromMime(
|
|
173
|
-
|
|
174
|
-
if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);
|
|
243
|
+
const gen = sessionGen;
|
|
244
|
+
const blob = new Blob(chunks, { type: containerTypeFromMime(sessionMimeType) });
|
|
245
|
+
deps.onSegmentEnd(blob, hadSpeech, gen);
|
|
175
246
|
}
|
|
176
|
-
function
|
|
177
|
-
if (!stream) return;
|
|
247
|
+
function startNext(stream, mimeType, gen) {
|
|
178
248
|
chunks = [];
|
|
179
249
|
segmentHasSpeech = false;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
segmentGeneration = generation;
|
|
250
|
+
sessionGen = gen;
|
|
251
|
+
sessionMimeType = mimeType;
|
|
183
252
|
recorder = new MediaRecorder(stream, { mimeType });
|
|
184
253
|
recorder.ondataavailable = (event) => {
|
|
185
254
|
if (event.data.size > 0) chunks.push(event.data);
|
|
186
255
|
};
|
|
187
|
-
recorder.onstop =
|
|
256
|
+
recorder.onstop = onStop;
|
|
188
257
|
recorder.start();
|
|
189
258
|
}
|
|
190
|
-
|
|
191
|
-
|
|
259
|
+
return {
|
|
260
|
+
isRecording: () => recorder?.state === "recording",
|
|
261
|
+
hadSpeech: () => segmentHasSpeech,
|
|
262
|
+
markSpeech: () => {
|
|
263
|
+
segmentHasSpeech = true;
|
|
264
|
+
},
|
|
265
|
+
startNext,
|
|
266
|
+
cut: () => {
|
|
267
|
+
if (recorder?.state === "recording") recorder.stop();
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
var INITIAL_RUNTIME = {
|
|
272
|
+
audioGraph: null,
|
|
273
|
+
monitorHandle: null,
|
|
274
|
+
mimeType: "",
|
|
275
|
+
silenceStart: null,
|
|
276
|
+
segmentStart: 0,
|
|
277
|
+
generation: 0,
|
|
278
|
+
startInFlight: false
|
|
279
|
+
};
|
|
280
|
+
function stopCapture(runtime, state, session) {
|
|
281
|
+
runtime.generation += 1;
|
|
282
|
+
state.setListening(false);
|
|
283
|
+
if (runtime.monitorHandle !== null) {
|
|
284
|
+
window.clearInterval(runtime.monitorHandle);
|
|
285
|
+
runtime.monitorHandle = null;
|
|
192
286
|
}
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
silenceStart
|
|
203
|
-
|
|
287
|
+
session.cut();
|
|
288
|
+
runtime.audioGraph?.teardown();
|
|
289
|
+
runtime.audioGraph = null;
|
|
290
|
+
}
|
|
291
|
+
function monitorTick(runtime, session) {
|
|
292
|
+
if (!runtime.audioGraph?.isAttached()) return;
|
|
293
|
+
const rms = computeRms(runtime.audioGraph.sample());
|
|
294
|
+
const { hasSpeech, silenceStart: nextSilence, cut } = evaluateVad({
|
|
295
|
+
hasSpeech: session.hadSpeech(),
|
|
296
|
+
silenceStart: runtime.silenceStart
|
|
297
|
+
}, runtime.segmentStart, rms, Date.now(), VAD_CONFIG);
|
|
298
|
+
if (hasSpeech) session.markSpeech();
|
|
299
|
+
runtime.silenceStart = nextSilence;
|
|
300
|
+
if (cut) session.cut();
|
|
301
|
+
}
|
|
302
|
+
function createVoiceCapture(transport, language, callbacks) {
|
|
303
|
+
const state = createCaptureState(callbacks.onState);
|
|
304
|
+
const poller = createAvailabilityPoller(transport, state.setAvailable);
|
|
305
|
+
const runtime = { ...INITIAL_RUNTIME };
|
|
306
|
+
const segments = createSegmentQueue({
|
|
307
|
+
transport,
|
|
308
|
+
language,
|
|
309
|
+
callbacks,
|
|
310
|
+
setPending: state.setPending,
|
|
311
|
+
getGeneration: () => runtime.generation
|
|
312
|
+
});
|
|
313
|
+
const session = createRecorderSession({ onSegmentEnd: handleSegmentEnd });
|
|
314
|
+
function handleSegmentEnd(blob, hadSpeech, gen) {
|
|
315
|
+
if (state.isListening()) startRecorder();
|
|
316
|
+
if (hadSpeech && blob.size > 0 && gen === runtime.generation) segments.enqueue(blob, gen);
|
|
317
|
+
}
|
|
318
|
+
function startRecorder() {
|
|
319
|
+
const stream = runtime.audioGraph?.getStream();
|
|
320
|
+
if (!stream) return;
|
|
321
|
+
runtime.silenceStart = null;
|
|
322
|
+
runtime.segmentStart = Date.now();
|
|
323
|
+
session.startNext(stream, runtime.mimeType, runtime.generation);
|
|
204
324
|
}
|
|
205
325
|
async function start() {
|
|
206
|
-
if (startInFlight ||
|
|
207
|
-
startInFlight = true;
|
|
208
|
-
const startGen = generation;
|
|
326
|
+
if (runtime.startInFlight || state.isListening()) return true;
|
|
327
|
+
runtime.startInFlight = true;
|
|
328
|
+
const startGen = runtime.generation;
|
|
209
329
|
try {
|
|
210
|
-
|
|
211
|
-
if (!
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
let acquired;
|
|
216
|
-
try {
|
|
217
|
-
acquired = await navigator.mediaDevices.getUserMedia({ audio: true });
|
|
218
|
-
} catch {
|
|
219
|
-
callbacks.onError?.("permission-denied");
|
|
220
|
-
return false;
|
|
221
|
-
}
|
|
222
|
-
if (startGen !== generation) {
|
|
223
|
-
acquired.getTracks().forEach((track) => track.stop());
|
|
224
|
-
return false;
|
|
225
|
-
}
|
|
226
|
-
stream = acquired;
|
|
227
|
-
audioCtx = new AudioContext();
|
|
228
|
-
analyser = audioCtx.createAnalyser();
|
|
229
|
-
analyser.fftSize = 2048;
|
|
230
|
-
vadBuffer = new Float32Array(analyser.fftSize);
|
|
231
|
-
audioCtx.createMediaStreamSource(stream).connect(analyser);
|
|
232
|
-
setListening(true);
|
|
330
|
+
const capture = await bootstrapCapture(callbacks, startGen, () => runtime.generation);
|
|
331
|
+
if (!capture) return false;
|
|
332
|
+
runtime.audioGraph = capture.audioGraph;
|
|
333
|
+
runtime.mimeType = capture.mimeType;
|
|
334
|
+
state.setListening(true);
|
|
233
335
|
startRecorder();
|
|
234
|
-
monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);
|
|
336
|
+
runtime.monitorHandle = window.setInterval(() => monitorTick(runtime, session), MONITOR_INTERVAL_MS);
|
|
235
337
|
return true;
|
|
236
338
|
} finally {
|
|
237
|
-
startInFlight = false;
|
|
339
|
+
runtime.startInFlight = false;
|
|
238
340
|
}
|
|
239
341
|
}
|
|
240
342
|
function stop() {
|
|
241
|
-
|
|
242
|
-
setListening(false);
|
|
243
|
-
if (monitorHandle !== null) {
|
|
244
|
-
window.clearInterval(monitorHandle);
|
|
245
|
-
monitorHandle = null;
|
|
246
|
-
}
|
|
247
|
-
if (recorder && recorder.state === "recording") recorder.stop();
|
|
248
|
-
recorder = null;
|
|
249
|
-
if (audioCtx) {
|
|
250
|
-
audioCtx.close().catch(() => void 0);
|
|
251
|
-
audioCtx = null;
|
|
252
|
-
}
|
|
253
|
-
analyser = null;
|
|
254
|
-
stream?.getTracks().forEach((track) => track.stop());
|
|
255
|
-
stream = null;
|
|
343
|
+
stopCapture(runtime, state, session);
|
|
256
344
|
}
|
|
257
345
|
function dispose() {
|
|
258
|
-
|
|
346
|
+
poller.stop();
|
|
259
347
|
stop();
|
|
260
348
|
}
|
|
261
349
|
return {
|
|
262
|
-
refreshAvailability,
|
|
350
|
+
refreshAvailability: poller.refresh,
|
|
263
351
|
start,
|
|
264
352
|
stop,
|
|
265
353
|
dispose
|
|
266
354
|
};
|
|
267
355
|
}
|
|
268
356
|
//#endregion
|
|
269
|
-
export { createVoiceCapture, localeToWhisperLanguage };
|
|
357
|
+
export { createAvailabilityPoller, createCaptureState, createSegmentQueue, createVoiceCapture, localeToWhisperLanguage };
|
|
270
358
|
|
|
271
359
|
//# sourceMappingURL=client.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client-helpers.ts","../../src/whisper/client.ts"],"sourcesContent":["// Pure, framework-neutral helpers for the browser voice-capture controller.\n// Kept separate from `client.ts` so the audio math and VAD state machine can be\n// unit-tested without a DOM / MediaRecorder / Web Audio environment.\n\nexport function computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\n/** The recorder mime can carry a codec suffix (`audio/webm;codecs=opus`); the\n * Blob container type is just the part before the `;`. */\nexport function containerTypeFromMime(mimeType: string): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n}\n\nexport interface VadState {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n}\n\nexport interface VadConfig {\n readonly speechRms: number;\n readonly silenceMs: number;\n readonly maxSegmentMs: number;\n}\n\nexport interface VadDecision {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n readonly cut: boolean;\n}\n\nfunction isSpeech(rms: number, config: VadConfig): boolean {\n return rms > config.speechRms;\n}\n\nfunction nextSilenceStart(state: VadState, rms: number, nowMs: number, config: VadConfig): number | null {\n if (isSpeech(rms, config)) return null;\n if (state.hasSpeech && state.silenceStart === null) return nowMs;\n return state.silenceStart;\n}\n\nfunction silenceExceeded(state: VadState, rms: number, nowMs: number, config: VadConfig): boolean {\n if (isSpeech(rms, config) || !state.hasSpeech || state.silenceStart === null) return false;\n return nowMs - state.silenceStart > config.silenceMs;\n}\n\n/** Advance the pause-detector one tick: fold the current RMS/time into the\n * segment state and decide whether the segment should be force-cut (either a\n * long-enough silence after speech, or the max-segment length reached). */\nexport function evaluateVad(state: VadState, segmentStartMs: number, rms: number, nowMs: number, config: VadConfig): VadDecision {\n const hasSpeech = isSpeech(rms, config) || state.hasSpeech;\n const silenceStart = nextSilenceStart(state, rms, nowMs, config);\n const maxCut = hasSpeech && nowMs - segmentStartMs > config.maxSegmentMs;\n return { hasSpeech, silenceStart, cut: silenceExceeded(state, rms, nowMs, config) || maxCut };\n}\n","// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/done/feat-extract-whisper-package.md.\n\nimport { computeRms, containerTypeFromMime, evaluateVad, type VadConfig } from \"./client-helpers.ts\";\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\nconst VAD_CONFIG: VadConfig = { speechRms: SPEECH_RMS, silenceMs: SILENCE_MS, maxSegmentMs: MAX_SEGMENT_MS };\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n let available = false;\n let listening = false;\n let transcribing = false;\n\n function emit(): void {\n callbacks.onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n let monitorHandle: number | null = null;\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let mimeType = \"\";\n let segmentHasSpeech = false;\n let silenceStart: number | null = null;\n let segmentStart = 0;\n let pending = 0;\n let queue: Promise<void> = Promise.resolve();\n let availabilityPollHandle: number | null = null;\n // Bumped on stop(). Segments captured / sends resolved under an older\n // generation are dropped, so a late transcript never leaks across sessions.\n let generation = 0;\n let segmentGeneration = 0;\n // Single-flight guard for start(): true between entry and the moment capture\n // is set up (or the attempt aborts), so a second start can't race the first.\n let startInFlight = false;\n\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n function stopAvailabilityPoll(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refreshAvailability(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stopAvailabilityPoll();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refreshAvailability();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stopAvailabilityPoll();\n }\n }\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== generation) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== generation) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === generation) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n function onSegmentStop(): void {\n const hadSpeech = segmentHasSpeech;\n const gen = segmentGeneration;\n const blob = new Blob(chunks, { type: containerTypeFromMime(mimeType) });\n if (listening) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === generation) enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n if (!stream) return;\n chunks = [];\n segmentHasSpeech = false;\n silenceStart = null;\n segmentStart = Date.now();\n segmentGeneration = generation;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onSegmentStop;\n recorder.start();\n }\n\n function cutSegment(): void {\n if (recorder && recorder.state === \"recording\") recorder.stop();\n }\n\n function monitorTick(): void {\n if (!analyser) return;\n analyser.getFloatTimeDomainData(vadBuffer);\n const rms = computeRms(vadBuffer);\n const { hasSpeech, silenceStart: nextSilence, cut } = evaluateVad({ hasSpeech: segmentHasSpeech, silenceStart }, segmentStart, rms, Date.now(), VAD_CONFIG);\n segmentHasSpeech = hasSpeech;\n silenceStart = nextSilence;\n if (cut) cutSegment();\n }\n\n async function start(): Promise<boolean> {\n // Single-flight: `listening` only flips true AFTER getUserMedia resolves, so\n // a benign skip returns true (a start is already active / in progress).\n if (startInFlight || listening) return true;\n startInFlight = true;\n const startGen = generation;\n try {\n mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return false;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return false;\n }\n // stop() bumps the generation; if it fired while we awaited permission\n // this start is stale — release the mic and abort.\n if (startGen !== generation) {\n acquired.getTracks().forEach((track) => track.stop());\n return false;\n }\n stream = acquired;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(stream).connect(analyser);\n setListening(true);\n startRecorder();\n monitorHandle = window.setInterval(monitorTick, MONITOR_INTERVAL_MS);\n return true;\n } finally {\n startInFlight = false;\n }\n }\n\n function stop(): void {\n // Bump the generation so any in-flight/queued segment is dropped rather than\n // applied after the user stops or switches sessions.\n generation += 1;\n setListening(false);\n if (monitorHandle !== null) {\n window.clearInterval(monitorHandle);\n monitorHandle = null;\n }\n if (recorder && recorder.state === \"recording\") recorder.stop();\n recorder = null;\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n function dispose(): void {\n stopAvailabilityPoll();\n stop();\n }\n\n return { refreshAvailability, start, stop, dispose };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"client.js","names":[],"sources":["../../src/whisper/client-helpers.ts","../../src/whisper/client.ts"],"sourcesContent":["// Pure, framework-neutral helpers for the browser voice-capture controller.\n// Kept separate from `client.ts` so the audio math and VAD state machine can be\n// unit-tested without a DOM / MediaRecorder / Web Audio environment.\n\nexport function computeRms(buffer: Float32Array): number {\n let sum = 0;\n for (const sample of buffer) sum += sample * sample;\n return Math.sqrt(sum / buffer.length);\n}\n\n/** The recorder mime can carry a codec suffix (`audio/webm;codecs=opus`); the\n * Blob container type is just the part before the `;`. */\nexport function containerTypeFromMime(mimeType: string): string {\n return mimeType.split(\";\")[0] || \"audio/webm\";\n}\n\nexport interface VadState {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n}\n\nexport interface VadConfig {\n readonly speechRms: number;\n readonly silenceMs: number;\n readonly maxSegmentMs: number;\n}\n\nexport interface VadDecision {\n readonly hasSpeech: boolean;\n readonly silenceStart: number | null;\n readonly cut: boolean;\n}\n\nfunction isSpeech(rms: number, config: VadConfig): boolean {\n return rms > config.speechRms;\n}\n\nfunction nextSilenceStart(state: VadState, rms: number, nowMs: number, config: VadConfig): number | null {\n if (isSpeech(rms, config)) return null;\n if (state.hasSpeech && state.silenceStart === null) return nowMs;\n return state.silenceStart;\n}\n\nfunction silenceExceeded(state: VadState, rms: number, nowMs: number, config: VadConfig): boolean {\n if (isSpeech(rms, config) || !state.hasSpeech || state.silenceStart === null) return false;\n return nowMs - state.silenceStart > config.silenceMs;\n}\n\n/** Advance the pause-detector one tick: fold the current RMS/time into the\n * segment state and decide whether the segment should be force-cut (either a\n * long-enough silence after speech, or the max-segment length reached). */\nexport function evaluateVad(state: VadState, segmentStartMs: number, rms: number, nowMs: number, config: VadConfig): VadDecision {\n const hasSpeech = isSpeech(rms, config) || state.hasSpeech;\n const silenceStart = nextSilenceStart(state, rms, nowMs, config);\n const maxCut = hasSpeech && nowMs - segmentStartMs > config.maxSegmentMs;\n return { hasSpeech, silenceStart, cut: silenceExceeded(state, rms, nowMs, config) || maxCut };\n}\n","// @mulmoclaude/core/whisper/client — framework-neutral browser capture controller.\n// Records one utterance at a time with MediaRecorder, segments on pauses via a\n// Web Audio VAD, and sends each segment through an injected transport. State is\n// pushed via `onState`; the host wraps this into its own reactivity (Vue refs,\n// React state, …). No framework dependency. See plans/done/feat-extract-whisper-package.md.\n\nimport { computeRms, containerTypeFromMime, evaluateVad, type VadConfig } from \"./client-helpers.ts\";\n\n// Map a UI locale to a Whisper language code. UI language is a strong prior for\n// the spoken language; \"auto\" lets Whisper detect it from the audio.\nconst LOCALE_TO_WHISPER: Record<string, string> = {\n en: \"en\",\n ja: \"ja\",\n zh: \"zh\",\n ko: \"ko\",\n es: \"es\",\n \"pt-BR\": \"pt\",\n fr: \"fr\",\n de: \"de\",\n};\n\nexport function localeToWhisperLanguage(locale: string): string {\n return LOCALE_TO_WHISPER[locale] ?? \"auto\";\n}\n\n// VAD tuning. RMS over [-1,1] float samples; a pause is SILENCE_MS of\n// sub-threshold level after speech. MAX_SEGMENT_MS force-cuts a long unbroken\n// utterance so no clip exceeds Whisper's 30s window or the server's size cap.\nconst SPEECH_RMS = 0.015;\nconst SILENCE_MS = 800;\nconst MAX_SEGMENT_MS = 20_000;\nconst MONITOR_INTERVAL_MS = 100;\nconst AVAILABILITY_POLL_MS = 2_000;\nconst VAD_CONFIG: VadConfig = { speechRms: SPEECH_RMS, silenceMs: SILENCE_MS, maxSegmentMs: MAX_SEGMENT_MS };\n\nfunction pickRecorderMime(): string | undefined {\n const candidates = [\"audio/webm;codecs=opus\", \"audio/webm\", \"audio/mp4\"];\n if (typeof MediaRecorder === \"undefined\") return undefined;\n return candidates.find((type) => MediaRecorder.isTypeSupported(type));\n}\n\nfunction blobToDataUrl(blob: Blob): Promise<string> {\n return new Promise((resolve, reject) => {\n const reader = new FileReader();\n reader.onload = () => resolve(String(reader.result));\n reader.onerror = () => reject(reader.error ?? new Error(\"FileReader failed\"));\n reader.readAsDataURL(blob);\n });\n}\n\nfunction toMessage(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n\nexport interface VoiceCaptureTransport {\n /** Transcribe one segment. Throws on failure. */\n transcribe: (dataUrl: string, language: string) => Promise<{ text: string }>;\n /** Current readiness. `downloading` true keeps the controller polling so it\n * flips to ready as soon as a model download finishes. */\n getStatus: () => Promise<{ ready: boolean; downloading: boolean }>;\n}\n\nexport interface VoiceCaptureState {\n available: boolean;\n listening: boolean;\n transcribing: boolean;\n}\n\nexport interface VoiceCaptureCallbacks {\n /** A recognized (non-empty) segment transcript. */\n onTranscript: (text: string) => void;\n /** A segment produced no speech. */\n onEmpty?: () => void;\n /** A recoverable error message (transport failure, permission denied, etc.). */\n onError?: (message: string) => void;\n /** Pushed whenever available/listening/transcribing changes. */\n onState?: (state: VoiceCaptureState) => void;\n}\n\nexport interface VoiceCapture {\n refreshAvailability: () => Promise<void>;\n start: () => Promise<boolean>;\n stop: () => void;\n dispose: () => void;\n}\n\nexport interface CaptureStateController {\n setAvailable: (value: boolean) => void;\n setListening: (value: boolean) => void;\n setPending: (delta: number) => void;\n isListening: () => boolean;\n}\n\n// Owns the three observable flags. Each setter emits only on an actual change so\n// the host's reactivity never churns on a no-op write. `pending` is a private\n// counter; `transcribing` is true exactly while at least one send is in flight.\nexport function createCaptureState(onState?: (state: VoiceCaptureState) => void): CaptureStateController {\n let available = false;\n let listening = false;\n let transcribing = false;\n let pending = 0;\n\n function emit(): void {\n onState?.({ available, listening, transcribing });\n }\n function setAvailable(value: boolean): void {\n if (available !== value) {\n available = value;\n emit();\n }\n }\n function setListening(value: boolean): void {\n if (listening !== value) {\n listening = value;\n emit();\n }\n }\n function setPending(delta: number): void {\n pending += delta;\n const next = pending > 0;\n if (transcribing !== next) {\n transcribing = next;\n emit();\n }\n }\n\n return { setAvailable, setListening, setPending, isListening: () => listening };\n}\n\nexport interface AvailabilityPoller {\n refresh: () => Promise<void>;\n stop: () => void;\n}\n\n// Owns the polling timer. `refresh` mirrors transport readiness into\n// `setAvailable`; while a model is downloading it self-schedules so `available`\n// flips as soon as the download finishes. A `getStatus` throw fails closed\n// (unavailable) and tears the timer down.\nexport function createAvailabilityPoller(transport: VoiceCaptureTransport, setAvailable: (value: boolean) => void): AvailabilityPoller {\n let availabilityPollHandle: number | null = null;\n\n function stop(): void {\n if (availabilityPollHandle !== null) {\n window.clearInterval(availabilityPollHandle);\n availabilityPollHandle = null;\n }\n }\n\n async function refresh(): Promise<void> {\n let status: { ready: boolean; downloading: boolean };\n try {\n status = await transport.getStatus();\n } catch {\n setAvailable(false);\n stop();\n return;\n }\n setAvailable(status.ready);\n if (status.downloading) {\n if (availabilityPollHandle === null) {\n availabilityPollHandle = window.setInterval(() => {\n void refresh();\n }, AVAILABILITY_POLL_MS);\n }\n } else {\n stop();\n }\n }\n\n return { refresh, stop };\n}\n\nexport interface SegmentQueueOptions {\n transport: VoiceCaptureTransport;\n language: () => string;\n callbacks: VoiceCaptureCallbacks;\n setPending: (delta: number) => void;\n // Read-only epoch seam: the queue never mutates the generation, it only reads\n // the parent's current value to drop segments captured under an older session.\n getGeneration: () => number;\n}\n\nexport interface SegmentQueue {\n enqueue: (blob: Blob, gen: number) => void;\n}\n\n// Owns the serialized send chain. Each blob is transcribed in capture order; the\n// generation is checked at entry and again after transcription so a segment\n// belonging to a session the user already left is dropped silently.\nexport function createSegmentQueue(options: SegmentQueueOptions): SegmentQueue {\n const { transport, language, callbacks, setPending, getGeneration } = options;\n let queue: Promise<void> = Promise.resolve();\n\n async function sendSegment(blob: Blob, gen: number): Promise<void> {\n if (gen !== getGeneration()) return;\n try {\n const dataUrl = await blobToDataUrl(blob);\n const result = await transport.transcribe(dataUrl, language());\n if (gen !== getGeneration()) return;\n const text = result.text.trim();\n if (text.length === 0) callbacks.onEmpty?.();\n else callbacks.onTranscript(text);\n } catch (err) {\n // Generation-guard the failure path too: a send rejected after stop()/\n // session change belongs to a session the user already left.\n if (gen === getGeneration()) callbacks.onError?.(toMessage(err));\n }\n }\n\n // Serialize sends so transcripts append in capture order; `pending` keeps\n // `transcribing` true from enqueue until the send resolves.\n function enqueue(blob: Blob, gen: number): void {\n setPending(1);\n queue = queue\n .then(() => sendSegment(blob, gen))\n .catch(() => undefined)\n .finally(() => setPending(-1));\n }\n\n return { enqueue };\n}\n\nexport interface AudioGraph {\n /** True between `attach()` and the next `teardown()`. */\n isAttached: () => boolean;\n /** Read the current PCM window into an internal buffer and return it.\n * Callers must not mutate the returned array. */\n sample: () => Float32Array;\n /** Live mic stream — the outer needs it to hand to MediaRecorder. */\n getStream: () => MediaStream | null;\n /** Attach a fresh mic stream: create AudioContext + AnalyserNode + VAD buffer,\n * connect the source. Called only by `bootstrapCapture` after the caller has\n * passed the stale-generation / permission checks. */\n attach: (stream: MediaStream) => void;\n /** Close the AudioContext, stop the mic tracks, null everything. Safe to\n * call before `attach()` or twice in a row. */\n teardown: () => void;\n}\n\n// Owns the browser-side audio pipeline: mic stream + AudioContext + AnalyserNode\n// + the reusable Float32 buffer VAD reads samples into. Separated from\n// `createVoiceCapture` so its four browser-API fields don't inflate the parent\n// factory's line count and so the setup/teardown ordering is in one place.\nfunction createAudioGraph(): AudioGraph {\n let stream: MediaStream | null = null;\n let audioCtx: AudioContext | null = null;\n let analyser: AnalyserNode | null = null;\n let vadBuffer = new Float32Array(0);\n\n function attach(nextStream: MediaStream): void {\n stream = nextStream;\n audioCtx = new AudioContext();\n analyser = audioCtx.createAnalyser();\n analyser.fftSize = 2048;\n vadBuffer = new Float32Array(analyser.fftSize);\n audioCtx.createMediaStreamSource(nextStream).connect(analyser);\n }\n\n function sample(): Float32Array {\n analyser?.getFloatTimeDomainData(vadBuffer);\n return vadBuffer;\n }\n\n function teardown(): void {\n if (audioCtx) {\n audioCtx.close().catch(() => undefined);\n audioCtx = null;\n }\n analyser = null;\n stream?.getTracks().forEach((track) => track.stop());\n stream = null;\n }\n\n return { isAttached: () => analyser !== null, sample, getStream: () => stream, attach, teardown };\n}\n\ninterface BootstrappedCapture {\n audioGraph: AudioGraph;\n mimeType: string;\n}\n\n// Acquire the mic + probe MediaRecorder support + wire an AudioGraph, returning\n// null (with `callbacks.onError` fired) on any recoverable failure. Deliberately\n// does NOT touch `state` or start the recorder — the caller commits the session\n// only after this resolves successfully, so a stale `generation` detected mid-\n// flight can drop the tracks and bail without partially publishing state.\nasync function bootstrapCapture(callbacks: VoiceCaptureCallbacks, generationAtStart: number, generationNow: () => number): Promise<BootstrappedCapture | null> {\n const mimeType = pickRecorderMime() ?? \"\";\n if (!mimeType || !navigator.mediaDevices?.getUserMedia) {\n callbacks.onError?.(\"unsupported\");\n return null;\n }\n let acquired: MediaStream;\n try {\n acquired = await navigator.mediaDevices.getUserMedia({ audio: true });\n } catch {\n callbacks.onError?.(\"permission-denied\");\n return null;\n }\n if (generationAtStart !== generationNow()) {\n acquired.getTracks().forEach((track) => track.stop());\n return null;\n }\n const audioGraph = createAudioGraph();\n try {\n audioGraph.attach(acquired);\n } catch (err) {\n // teardown() is null-safe and closes any half-created AudioContext AND\n // stops the mic tracks — the pre-refactor code only stopped tracks and\n // leaked the context (codex/CodeRabbit findings on this PR).\n audioGraph.teardown();\n throw err;\n }\n return { audioGraph, mimeType };\n}\n\nexport interface RecorderSession {\n isRecording: () => boolean;\n hadSpeech: () => boolean;\n markSpeech: () => void;\n /** Start a new segment on `stream`, tagged with `gen`. Resets per-segment\n * state (chunks + hadSpeech) synchronously so a subsequent VAD tick sees\n * a fresh window. */\n startNext: (stream: MediaStream, mimeType: string, gen: number) => void;\n /** Force-close the current segment (silence exceeded or MAX_SEGMENT_MS). */\n cut: () => void;\n}\n\ninterface RecorderSessionDeps {\n /** Fires each time a segment finishes. The parent decides whether to\n * restart (via `session.startNext`) and whether to enqueue the blob. */\n onSegmentEnd: (blob: Blob, hadSpeech: boolean, gen: number) => void;\n}\n\n// Owns MediaRecorder + its per-segment scratchpad (chunks, hadSpeech, gen,\n// mimeType). The parent keeps VAD state and the global generation, and calls\n// `startNext` on both initial-start and post-onstop restart paths.\nfunction createRecorderSession(deps: RecorderSessionDeps): RecorderSession {\n let recorder: MediaRecorder | null = null;\n let chunks: Blob[] = [];\n let segmentHasSpeech = false;\n let sessionGen = 0;\n let sessionMimeType = \"\";\n\n function onStop(): void {\n // Snapshot BEFORE the parent's onSegmentEnd callback runs; `startNext`\n // (if the parent chooses to restart) will clear chunks synchronously.\n const hadSpeech = segmentHasSpeech;\n const gen = sessionGen;\n const blob = new Blob(chunks, { type: containerTypeFromMime(sessionMimeType) });\n deps.onSegmentEnd(blob, hadSpeech, gen);\n }\n\n function startNext(stream: MediaStream, mimeType: string, gen: number): void {\n chunks = [];\n segmentHasSpeech = false;\n sessionGen = gen;\n sessionMimeType = mimeType;\n recorder = new MediaRecorder(stream, { mimeType });\n recorder.ondataavailable = (event) => {\n if (event.data.size > 0) chunks.push(event.data);\n };\n recorder.onstop = onStop;\n recorder.start();\n }\n\n return {\n isRecording: () => recorder?.state === \"recording\",\n hadSpeech: () => segmentHasSpeech,\n markSpeech: () => {\n segmentHasSpeech = true;\n },\n startNext,\n cut: () => {\n if (recorder?.state === \"recording\") recorder.stop();\n },\n };\n}\n\n// Zero-values used to seed a fresh `CaptureRuntime` at the top of every\n// `createVoiceCapture` call. Kept module-scope so the seed doesn't inflate the\n// factory's line count.\nconst INITIAL_RUNTIME: Readonly<CaptureRuntime> = {\n audioGraph: null,\n monitorHandle: null,\n mimeType: \"\",\n silenceStart: null,\n segmentStart: 0,\n generation: 0,\n startInFlight: false,\n};\n\n// Mutable capture-time state, kept as one object so module-scope helpers\n// (`stopCapture`, `monitorTick`) can be typed against one shape rather than\n// threading each field through their signatures.\ninterface CaptureRuntime {\n audioGraph: AudioGraph | null;\n monitorHandle: number | null;\n mimeType: string;\n silenceStart: number | null;\n segmentStart: number;\n /** Bumped on stop(); segments captured under an older value are dropped so a\n * late transcript never leaks across sessions. */\n generation: number;\n /** Single-flight guard on start() — true between entry and completion so a\n * second start can't race the first before `listening` flips. */\n startInFlight: boolean;\n}\n\n// Tear down the browser-side resources. `state.setListening(false)` MUST happen\n// BEFORE `session.cut()` so the parent's onSegmentEnd callback sees\n// isListening()===false and does NOT restart. The monitor interval MUST clear\n// BEFORE `audioGraph.teardown()` so a scheduled tick can't run against a\n// half-closed AudioContext.\nfunction stopCapture(runtime: CaptureRuntime, state: CaptureStateController, session: RecorderSession): void {\n runtime.generation += 1;\n state.setListening(false);\n if (runtime.monitorHandle !== null) {\n window.clearInterval(runtime.monitorHandle);\n runtime.monitorHandle = null;\n }\n session.cut();\n runtime.audioGraph?.teardown();\n runtime.audioGraph = null;\n}\n\n// One VAD tick against the audio graph's current window. No-op when the graph\n// is not attached (defensive: `stopCapture` clears `monitorHandle` before\n// `audioGraph.teardown()`, so this is the belt-and-braces for any late fire).\nfunction monitorTick(runtime: CaptureRuntime, session: RecorderSession): void {\n if (!runtime.audioGraph?.isAttached()) return;\n const rms = computeRms(runtime.audioGraph.sample());\n const {\n hasSpeech,\n silenceStart: nextSilence,\n cut,\n } = evaluateVad({ hasSpeech: session.hadSpeech(), silenceStart: runtime.silenceStart }, runtime.segmentStart, rms, Date.now(), VAD_CONFIG);\n if (hasSpeech) session.markSpeech();\n runtime.silenceStart = nextSilence;\n if (cut) session.cut();\n}\n\nexport function createVoiceCapture(transport: VoiceCaptureTransport, language: () => string, callbacks: VoiceCaptureCallbacks): VoiceCapture {\n const state = createCaptureState(callbacks.onState);\n const poller = createAvailabilityPoller(transport, state.setAvailable);\n const runtime: CaptureRuntime = { ...INITIAL_RUNTIME };\n const segments = createSegmentQueue({ transport, language, callbacks, setPending: state.setPending, getGeneration: () => runtime.generation });\n\n const session = createRecorderSession({ onSegmentEnd: handleSegmentEnd });\n\n function handleSegmentEnd(blob: Blob, hadSpeech: boolean, gen: number): void {\n // Restart FIRST so audio-loss between segments is minimal; the snapshot\n // in blob/hadSpeech/gen is already independent of session state.\n if (state.isListening()) startRecorder();\n if (hadSpeech && blob.size > 0 && gen === runtime.generation) segments.enqueue(blob, gen);\n }\n\n function startRecorder(): void {\n const stream = runtime.audioGraph?.getStream();\n if (!stream) return;\n runtime.silenceStart = null;\n runtime.segmentStart = Date.now();\n session.startNext(stream, runtime.mimeType, runtime.generation);\n }\n\n async function start(): Promise<boolean> {\n if (runtime.startInFlight || state.isListening()) return true;\n runtime.startInFlight = true;\n const startGen = runtime.generation;\n try {\n const capture = await bootstrapCapture(callbacks, startGen, () => runtime.generation);\n if (!capture) return false;\n runtime.audioGraph = capture.audioGraph;\n runtime.mimeType = capture.mimeType;\n state.setListening(true);\n startRecorder();\n runtime.monitorHandle = window.setInterval(() => monitorTick(runtime, session), MONITOR_INTERVAL_MS);\n return true;\n } finally {\n runtime.startInFlight = false;\n }\n }\n\n function stop(): void {\n stopCapture(runtime, state, session);\n }\n\n function dispose(): void {\n poller.stop();\n stop();\n }\n\n return { refreshAvailability: poller.refresh, start, stop, dispose };\n}\n"],"mappings":"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"}
|