@happyvertical/speech 0.102.7 → 0.103.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +147 -1
- package/dist/browser.js.map +1 -1
- package/dist/shared/speech-playback.d.ts +28 -0
- package/dist/shared/speech-playback.d.ts.map +1 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -56,6 +56,10 @@ const spoken = await speech.synthesize({
|
|
|
56
56
|
|
|
57
57
|
The OpenAI-compatible TTS base URL resolves like the transcriber's: a server root (`http://gateway:8080`) gets `/v1/audio/speech`, a base ending in a version segment (`http://gateway/tts/v1`) gets `/audio/speech`, and a URL already ending in `/audio/speech` is used as-is. The query string is preserved and a fragment is dropped. An explicit `speechPath` skips this: it resolves relative to the base URL, or is used as-is when absolute.
|
|
58
58
|
|
|
59
|
+
## Browser playback and levels
|
|
60
|
+
|
|
61
|
+
`@happyvertical/speech/browser` plays server-synthesized bytes and reports a smoothed 0..1 RMS level from the audio element's Web Audio analyser. It never synthesizes speech or handles credentials. Start it from a user action, then use `onLevel` for a character jaw, visualizer, or caption timing surface.
|
|
62
|
+
|
|
59
63
|
Studio Server and Qwen3 accept pre-extracted provider voice prompts through `SpeechVoice.prompt`; the adapters forward these as the multipart `voice_prompt` field.
|
|
60
64
|
|
|
61
65
|
## OpenAI-compatible Transcription
|
package/dist/browser.d.ts
CHANGED
|
@@ -5,5 +5,6 @@
|
|
|
5
5
|
*/
|
|
6
6
|
export { PcmCaptureError, type PcmCaptureErrorReason, } from './shared/errors.js';
|
|
7
7
|
export { createPcmCapture, type PcmCapture, type PcmCaptureOptions, type PcmCaptureResult, pcmCaptureSupported, } from './shared/pcm-capture.js';
|
|
8
|
+
export { createSpeechPlayback, type SpeechPlayback, type SpeechPlaybackEvents, type SpeechPlaybackOptions, speechPlaybackSupported, } from './shared/speech-playback.js';
|
|
8
9
|
export { createVadCapture, VAD_CAPTURE_SAMPLE_RATE, type VadCapture, type VadCaptureEventMap, type VadCaptureEventType, type VadCaptureOptions, type VadEndReason, type VadOptions, vadCaptureSupported, } from './shared/vad-capture.js';
|
|
9
10
|
//# sourceMappingURL=browser.d.ts.map
|
package/dist/browser.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,eAAe,EACf,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,mBAAmB,GACpB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,mBAAmB,GACpB,MAAM,yBAAyB,CAAC"}
|
|
1
|
+
{"version":3,"file":"browser.d.ts","sourceRoot":"","sources":["../src/browser.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,EACL,eAAe,EACf,KAAK,qBAAqB,GAC3B,MAAM,oBAAoB,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,KAAK,UAAU,EACf,KAAK,iBAAiB,EACtB,KAAK,gBAAgB,EACrB,mBAAmB,GACpB,MAAM,yBAAyB,CAAC;AACjC,OAAO,EACL,oBAAoB,EACpB,KAAK,cAAc,EACnB,KAAK,oBAAoB,EACzB,KAAK,qBAAqB,EAC1B,uBAAuB,GACxB,MAAM,6BAA6B,CAAC;AACrC,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,KAAK,UAAU,EACf,KAAK,kBAAkB,EACvB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,EACtB,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,mBAAmB,GACpB,MAAM,yBAAyB,CAAC"}
|
package/dist/browser.js
CHANGED
|
@@ -157,6 +157,152 @@ function createPcmCapture(stream, options) {
|
|
|
157
157
|
};
|
|
158
158
|
}
|
|
159
159
|
//#endregion
|
|
160
|
+
//#region src/shared/speech-playback.ts
|
|
161
|
+
function speechPlaybackSupported() {
|
|
162
|
+
return typeof Audio !== "undefined" && typeof AudioContext !== "undefined";
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Plays server-produced speech in a browser and samples the playing element's
|
|
166
|
+
* analyser node. Provider credentials and synthesis deliberately stay server-side.
|
|
167
|
+
*/
|
|
168
|
+
function createSpeechPlayback(options = {}) {
|
|
169
|
+
const requestedSmoothing = options.smoothing ?? .28;
|
|
170
|
+
if (!Number.isFinite(requestedSmoothing)) throw new RangeError("speech playback smoothing must be finite");
|
|
171
|
+
const smoothing = Math.min(1, Math.max(.01, requestedSmoothing));
|
|
172
|
+
let audio;
|
|
173
|
+
let context;
|
|
174
|
+
let source;
|
|
175
|
+
let analyser;
|
|
176
|
+
let frame;
|
|
177
|
+
let url;
|
|
178
|
+
let disposed = false;
|
|
179
|
+
let active = 0;
|
|
180
|
+
let playing = false;
|
|
181
|
+
let operation;
|
|
182
|
+
const emitLevel = (level) => options.onLevel?.(Math.min(1, Math.max(0, level)));
|
|
183
|
+
const finish = (token, { ended = false, failure, closeContext = false } = {}) => {
|
|
184
|
+
if (token !== active) return;
|
|
185
|
+
if (frame !== void 0) cancelAnimationFrame(frame);
|
|
186
|
+
frame = void 0;
|
|
187
|
+
audio?.pause();
|
|
188
|
+
audio = void 0;
|
|
189
|
+
source?.disconnect();
|
|
190
|
+
source = void 0;
|
|
191
|
+
analyser?.disconnect();
|
|
192
|
+
analyser = void 0;
|
|
193
|
+
if (url) URL.revokeObjectURL(url);
|
|
194
|
+
url = void 0;
|
|
195
|
+
if (closeContext) {
|
|
196
|
+
const closing = context;
|
|
197
|
+
context = void 0;
|
|
198
|
+
if (closing && closing.state !== "closed") closing.close();
|
|
199
|
+
}
|
|
200
|
+
const emittedAudioLevel = playing;
|
|
201
|
+
playing = false;
|
|
202
|
+
if (operation?.token === token) {
|
|
203
|
+
if (failure) operation.reject(failure);
|
|
204
|
+
else operation.resolve();
|
|
205
|
+
operation = void 0;
|
|
206
|
+
}
|
|
207
|
+
++active;
|
|
208
|
+
if (emittedAudioLevel) emitLevel(0);
|
|
209
|
+
if (ended) options.onEnd?.();
|
|
210
|
+
};
|
|
211
|
+
return {
|
|
212
|
+
get playing() {
|
|
213
|
+
return playing;
|
|
214
|
+
},
|
|
215
|
+
async prepare() {
|
|
216
|
+
if (disposed) throw new Error("Speech playback has been destroyed");
|
|
217
|
+
if (!speechPlaybackSupported()) throw new Error("Browser audio playback is unavailable");
|
|
218
|
+
context ??= new AudioContext();
|
|
219
|
+
if (context.state === "suspended") await context.resume();
|
|
220
|
+
},
|
|
221
|
+
async play(speech) {
|
|
222
|
+
if (disposed) throw new Error("Speech playback has been destroyed");
|
|
223
|
+
if (!speechPlaybackSupported()) throw new Error("Browser audio playback is unavailable");
|
|
224
|
+
const activeBeforeStop = active;
|
|
225
|
+
this.stop();
|
|
226
|
+
if (disposed) throw new Error("Speech playback has been destroyed");
|
|
227
|
+
if (active !== activeBeforeStop + 1) return;
|
|
228
|
+
const token = ++active;
|
|
229
|
+
let resolveCompletion;
|
|
230
|
+
let rejectCompletion;
|
|
231
|
+
const completion = new Promise((resolve, reject) => {
|
|
232
|
+
resolveCompletion = resolve;
|
|
233
|
+
rejectCompletion = reject;
|
|
234
|
+
});
|
|
235
|
+
completion.catch(() => {});
|
|
236
|
+
operation = {
|
|
237
|
+
token,
|
|
238
|
+
resolve: resolveCompletion,
|
|
239
|
+
reject: rejectCompletion
|
|
240
|
+
};
|
|
241
|
+
try {
|
|
242
|
+
url = URL.createObjectURL(new Blob([speech.audio], { type: speech.contentType }));
|
|
243
|
+
const nextAudio = new Audio(url);
|
|
244
|
+
const nextContext = context ?? new AudioContext();
|
|
245
|
+
context = nextContext;
|
|
246
|
+
const nextSource = nextContext.createMediaElementSource(nextAudio);
|
|
247
|
+
const nextAnalyser = nextContext.createAnalyser();
|
|
248
|
+
nextAnalyser.fftSize = 512;
|
|
249
|
+
nextSource.connect(nextAnalyser);
|
|
250
|
+
nextAnalyser.connect(nextContext.destination);
|
|
251
|
+
source = nextSource;
|
|
252
|
+
analyser = nextAnalyser;
|
|
253
|
+
const samples = new Uint8Array(nextAnalyser.fftSize);
|
|
254
|
+
let level = 0;
|
|
255
|
+
const sample = () => {
|
|
256
|
+
if (token !== active || !playing) return;
|
|
257
|
+
nextAnalyser.getByteTimeDomainData(samples);
|
|
258
|
+
let sum = 0;
|
|
259
|
+
for (const value of samples) {
|
|
260
|
+
const normalized = (value - 128) / 128;
|
|
261
|
+
sum += normalized * normalized;
|
|
262
|
+
}
|
|
263
|
+
const target = Math.min(1, Math.sqrt(sum / samples.length) * 4);
|
|
264
|
+
level += (target - level) * smoothing;
|
|
265
|
+
emitLevel(level);
|
|
266
|
+
frame = requestAnimationFrame(sample);
|
|
267
|
+
};
|
|
268
|
+
audio = nextAudio;
|
|
269
|
+
nextAudio.onended = () => finish(token, { ended: true });
|
|
270
|
+
nextAudio.onerror = () => {
|
|
271
|
+
if (token !== active) return;
|
|
272
|
+
const error = /* @__PURE__ */ new Error("Browser speech audio playback failed");
|
|
273
|
+
finish(token, { failure: error });
|
|
274
|
+
options.onError?.(error);
|
|
275
|
+
};
|
|
276
|
+
await Promise.race([nextContext.resume(), completion]);
|
|
277
|
+
if (token !== active || disposed) return;
|
|
278
|
+
await Promise.race([nextAudio.play(), completion]);
|
|
279
|
+
if (token !== active || disposed) return;
|
|
280
|
+
playing = true;
|
|
281
|
+
options.onStart?.();
|
|
282
|
+
if (token !== active || disposed) return;
|
|
283
|
+
sample();
|
|
284
|
+
await completion;
|
|
285
|
+
} catch (cause) {
|
|
286
|
+
const error = cause instanceof Error ? cause : /* @__PURE__ */ new Error("Browser speech audio playback failed");
|
|
287
|
+
if (token === active) {
|
|
288
|
+
finish(token, { failure: error });
|
|
289
|
+
options.onError?.(error);
|
|
290
|
+
}
|
|
291
|
+
throw error;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
stop() {
|
|
295
|
+
finish(active);
|
|
296
|
+
},
|
|
297
|
+
destroy() {
|
|
298
|
+
if (!disposed) {
|
|
299
|
+
disposed = true;
|
|
300
|
+
finish(active, { closeContext: true });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
//#endregion
|
|
160
306
|
//#region src/shared/vad-capture.ts
|
|
161
307
|
/**
|
|
162
308
|
* Hands-free microphone capture: opens the microphone, runs its audio through
|
|
@@ -293,6 +439,6 @@ async function createVadCapture(options = {}) {
|
|
|
293
439
|
};
|
|
294
440
|
}
|
|
295
441
|
//#endregion
|
|
296
|
-
export { PcmCaptureError, VAD_CAPTURE_SAMPLE_RATE, createPcmCapture, createVadCapture, pcmCaptureSupported, vadCaptureSupported };
|
|
442
|
+
export { PcmCaptureError, VAD_CAPTURE_SAMPLE_RATE, createPcmCapture, createSpeechPlayback, createVadCapture, pcmCaptureSupported, speechPlaybackSupported, vadCaptureSupported };
|
|
297
443
|
|
|
298
444
|
//# sourceMappingURL=browser.js.map
|
package/dist/browser.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"browser.js","names":[],"sources":["../src/shared/pcm-capture.ts","../src/shared/vad-capture.ts"],"sourcesContent":["/**\n * Browser PCM capture: `MediaStream` -> mono Float32 at a requested rate and a\n * ready 16-bit PCM WAV, via `AudioContext` + `AudioWorklet`. The worklet code\n * is inlined and loaded from a Blob URL, so no bundler configuration is\n * needed. Nothing here touches Node APIs; the Web Audio globals are read when\n * `createPcmCapture` is called, so importing this module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport { resampleMono } from './resample.js';\nimport { encodeWavPcm16 } from './wav.js';\n\nconst PROCESSOR_NAME = 'hv-pcm-tap';\n\n// Mixes every input channel to mono and posts one transferable block per\n// render quantum. Returns true to stay alive until the node is disconnected.\nconst WORKLET_SOURCE = `\nclass HvPcmTap extends AudioWorkletProcessor {\n process(inputs) {\n const channels = inputs[0];\n if (channels && channels.length > 0 && channels[0].length > 0) {\n const frames = channels[0].length;\n const mono = new Float32Array(frames);\n for (let c = 0; c < channels.length; c += 1) {\n const data = channels[c];\n for (let i = 0; i < frames; i += 1) mono[i] += data[i];\n }\n if (channels.length > 1) {\n for (let i = 0; i < frames; i += 1) mono[i] /= channels.length;\n }\n this.port.postMessage(mono, [mono.buffer]);\n }\n return true;\n }\n}\nregisterProcessor('${PROCESSOR_NAME}', HvPcmTap);\n`;\n\nexport interface PcmCaptureOptions {\n /** Rate of the returned samples and WAV, in Hz (e.g. 16000 for Voxtral). */\n sampleRate: number;\n /** Audio beyond this duration is dropped, not stored. */\n maxDurationMs: number;\n /** Called once, when the cap is reached; the capture keeps running until stopped. */\n onLimit?: () => void;\n /**\n * Streams every mono block as it arrives, at the audio context's own rate\n * (`contextSampleRate`), before any cap or resampling. The block is yours to\n * keep. Used by `createVadCapture` for live analysis.\n */\n onSamples?: (samples: Float32Array, contextSampleRate: number) => void;\n /**\n * Keep the audio for `stop()`. Default true. `false` only streams to\n * `onSamples`: memory stays flat for open-ended listening, `maxDurationMs`\n * never trims, and `stop()` resolves with empty samples.\n */\n retain?: boolean;\n}\n\nexport interface PcmCaptureResult {\n /** Mono samples at `sampleRate`, -1..1. */\n samples: Float32Array;\n /** The same audio as a 16-bit PCM mono WAV. */\n wav: Uint8Array;\n /** True when audio past `maxDurationMs` was dropped. */\n truncated: boolean;\n}\n\nexport interface PcmCapture {\n /** Settles once the audio graph is running; rejects if setup failed. Optional for custom capture factories. */\n readonly ready?: Promise<void>;\n /** Ends capture, tears everything down, and resolves with the audio. Idempotent. */\n stop(): Promise<PcmCaptureResult>;\n /** Discards the audio and tears everything down. Idempotent; a pending `stop()` rejects. */\n cancel(): void;\n}\n\n/** Whether this runtime has the Web Audio pieces `createPcmCapture` needs. */\nexport function pcmCaptureSupported(): boolean {\n return (\n typeof AudioContext !== 'undefined' &&\n typeof AudioWorkletNode !== 'undefined' &&\n typeof Blob !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function'\n );\n}\n\n/**\n * Starts capturing `stream`. Throws `PcmCaptureError` (`unsupported`) when Web\n * Audio is unavailable. The caller keeps ownership of the stream and its\n * tracks: stop them yourself after `stop()` or `cancel()`.\n */\nexport function createPcmCapture(\n stream: MediaStream,\n options: PcmCaptureOptions,\n): PcmCapture {\n const { sampleRate, maxDurationMs } = options;\n if (!Number.isInteger(sampleRate) || sampleRate < 1) {\n throw new RangeError('sampleRate must be a positive integer');\n }\n if (!Number.isFinite(maxDurationMs) || maxDurationMs <= 0) {\n throw new RangeError('maxDurationMs must be a positive number');\n }\n if (!pcmCaptureSupported()) {\n throw new PcmCaptureError(\n 'unsupported',\n 'PCM capture needs AudioContext, AudioWorkletNode and Blob URLs',\n );\n }\n\n const context = new AudioContext();\n const capSamples = Math.ceil((maxDurationMs / 1000) * context.sampleRate);\n const frames: Float32Array[] = [];\n let captured = 0;\n let truncated = false;\n let limitFired = false;\n let cancelled = false;\n let finished = false;\n let url: string | undefined;\n let source: MediaStreamAudioSourceNode | undefined;\n let node: AudioWorkletNode | undefined;\n let closing: Promise<void> | undefined;\n\n const onFrame = (event: MessageEvent<Float32Array>): void => {\n if (finished) return;\n let frame = event.data;\n options.onSamples?.(frame, context.sampleRate);\n if (options.retain === false) return;\n const room = capSamples - captured;\n if (frame.length > room) {\n frame = frame.subarray(0, Math.max(0, room));\n truncated = true;\n }\n if (frame.length > 0) {\n frames.push(frame);\n captured += frame.length;\n }\n if (captured >= capSamples && !limitFired) {\n limitFired = true;\n options.onLimit?.();\n }\n };\n\n const revoke = (): void => {\n if (url !== undefined) {\n const stale = url;\n url = undefined;\n try {\n URL.revokeObjectURL(stale);\n } catch {\n // already revoked\n }\n }\n };\n\n const teardown = (): Promise<void> => {\n if (closing) return closing;\n finished = true;\n revoke();\n if (node) node.port.onmessage = null;\n try {\n source?.disconnect();\n node?.disconnect();\n } catch {\n // graph already torn down\n }\n closing = (async () => {\n try {\n if (context.state !== 'closed') await context.close();\n } catch {\n // closing a dead context is not worth reporting\n }\n })();\n return closing;\n };\n\n const ready = (async (): Promise<void> => {\n url = URL.createObjectURL(\n new Blob([WORKLET_SOURCE], { type: 'text/javascript' }),\n );\n try {\n await context.audioWorklet.addModule(url);\n } finally {\n revoke();\n }\n if (finished) return;\n source = context.createMediaStreamSource(stream);\n node = new AudioWorkletNode(context, PROCESSOR_NAME, {\n numberOfInputs: 1,\n numberOfOutputs: 1,\n outputChannelCount: [1],\n });\n node.port.onmessage = onFrame;\n source.connect(node);\n // A worklet only runs while connected to the destination; it emits silence.\n node.connect(context.destination);\n if (context.state === 'suspended') await context.resume();\n })();\n // Failure is reported by stop(); never an unhandled rejection.\n ready.catch(() => undefined);\n\n let result: Promise<PcmCaptureResult> | undefined;\n\n return {\n ready,\n stop(): Promise<PcmCaptureResult> {\n result ??= (async () => {\n try {\n await ready;\n } catch (error) {\n await teardown();\n throw new PcmCaptureError(\n 'setup_failed',\n 'PCM capture could not start',\n error,\n );\n }\n await teardown();\n if (cancelled) {\n throw new PcmCaptureError('cancelled', 'PCM capture was cancelled');\n }\n const joined = new Float32Array(captured);\n let offset = 0;\n for (const frame of frames.splice(0)) {\n joined.set(frame, offset);\n offset += frame.length;\n }\n const samples = resampleMono(joined, context.sampleRate, sampleRate);\n return { samples, wav: encodeWavPcm16(samples, sampleRate), truncated };\n })();\n return result;\n },\n cancel(): void {\n if (cancelled) return;\n cancelled = true;\n frames.length = 0;\n void teardown();\n },\n };\n}\n","/**\n * Hands-free microphone capture: opens the microphone, runs its audio through\n * the on-device voice activity detector (`vad.ts`) and emits one finished\n * utterance at a time as 16 kHz mono Float32 PCM. Audio stays in memory; the\n * microphone is released by `stop()`. Nothing here touches Node APIs; the Web\n * Audio globals are read when `createVadCapture` is called, so importing this\n * module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport {\n createPcmCapture,\n type PcmCapture,\n type PcmCaptureOptions,\n pcmCaptureSupported,\n} from './pcm-capture.js';\nimport { resampleMono } from './resample.js';\nimport {\n createUtteranceCollector,\n type UtteranceCollector,\n type VadEndReason,\n type VadOptions,\n} from './vad.js';\n\n/** Rate of the PCM in `speechend` events. */\nexport const VAD_CAPTURE_SAMPLE_RATE = 16_000;\n\n/** Events emitted by a `VadCapture`. */\nexport interface VadCaptureEventMap {\n /** An utterance began. */\n speechstart: Record<string, never>;\n /** An utterance ended (a pause, the length cap, or `stop()`). */\n speechend: {\n /** Mono samples, -1..1, at `sampleRate`, including the pre-roll. */\n samples: Float32Array;\n sampleRate: number;\n durationMs: number;\n reason: VadEndReason;\n };\n /** Loudness 0..1 for each analysed frame, for a meter. */\n level: { level: number };\n}\n\nexport type VadCaptureEventType = keyof VadCaptureEventMap;\n\nexport interface VadCaptureOptions extends VadOptions {\n /**\n * Use this stream instead of opening the microphone. The caller keeps\n * ownership: `stop()` does not stop its tracks.\n */\n stream?: MediaStream;\n /**\n * Constraints for the microphone when no `stream` is given. Default:\n * `{ audio: { echoCancellation, noiseSuppression, autoGainControl: true } }`,\n * a secondary defence against hearing the page's own playback. Browsers do\n * not reliably cancel `speechSynthesis`; use `suspend()` while it plays.\n */\n constraints?: MediaStreamConstraints;\n /**\n * After `resume()`, audio of this length is still ignored so the tail of the\n * playback and its room reverb are not heard as speech. Default 350.\n */\n resumeGuardMs?: number;\n /** Test seam: replaces `createPcmCapture`. */\n createCapture?: (\n stream: MediaStream,\n options: PcmCaptureOptions,\n ) => PcmCapture;\n}\n\nexport interface VadCapture {\n /** Subscribes to an event; returns the unsubscribe function. */\n on<T extends VadCaptureEventType>(\n type: T,\n listener: (event: VadCaptureEventMap[T]) => void,\n ): () => void;\n /** Currently inside an utterance. */\n readonly speaking: boolean;\n /** Half-duplex gate is closed (between `suspend()` and `resume()`). */\n readonly suspended: boolean;\n /**\n * Half-duplex gate: stops listening while the page itself is playing audio\n * (for example `speechSynthesis`). Frames are dropped, an utterance in\n * progress is discarded (no `speechend`), the noise floor does not adapt to\n * the played audio, and the microphone stays open. Idempotent.\n */\n suspend(): void;\n /**\n * Reopens the gate after `resumeGuardMs` of further audio has been ignored.\n * No-op when not suspended.\n */\n resume(): void;\n /**\n * Ends an utterance in progress (a final `speechend` with reason `flush`),\n * then releases the microphone. Idempotent.\n */\n stop(): Promise<void>;\n /** Releases the microphone and discards any utterance in progress. Idempotent. */\n cancel(): void;\n}\n\n/** Whether this runtime can open a microphone and tap it with Web Audio. */\nexport function vadCaptureSupported(): boolean {\n return (\n pcmCaptureSupported() &&\n typeof navigator !== 'undefined' &&\n typeof navigator.mediaDevices?.getUserMedia === 'function'\n );\n}\n\n/** One hour; with `retain: false` this is never reached, it only satisfies validation. */\nconst UNBOUNDED_MS = 3_600_000;\n\n/**\n * Opens the microphone (or uses `options.stream`) and starts detecting speech.\n * Rejects with `PcmCaptureError` (`unsupported`) when Web Audio or the\n * microphone API is missing, and with the browser's own error when permission\n * is denied.\n */\nexport async function createVadCapture(\n options: VadCaptureOptions = {},\n): Promise<VadCapture> {\n const makeCapture = options.createCapture ?? createPcmCapture;\n if (!options.createCapture && !vadCaptureSupported() && !options.stream) {\n throw new PcmCaptureError(\n 'unsupported',\n 'Hands-free capture needs the microphone API and Web Audio',\n );\n }\n\n const ownsStream = !options.stream;\n const stream =\n options.stream ??\n (await navigator.mediaDevices.getUserMedia(\n options.constraints ?? {\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n },\n },\n ));\n\n const listeners: {\n [T in VadCaptureEventType]: Set<(event: VadCaptureEventMap[T]) => void>;\n } = { speechstart: new Set(), speechend: new Set(), level: new Set() };\n const emit = <T extends VadCaptureEventType>(\n type: T,\n event: VadCaptureEventMap[T],\n ): void => {\n for (const listener of [...listeners[type]]) {\n try {\n listener(event);\n } catch {\n // A throwing listener must not break capture or its siblings.\n }\n }\n };\n\n let collector: UtteranceCollector | undefined;\n let closed = false;\n let suspended = false;\n // Milliseconds of audio still to drop after `resume()`.\n let guardMsLeft = 0;\n const resumeGuardMs = Math.max(0, options.resumeGuardMs ?? 350);\n const releaseStream = (): void => {\n if (!ownsStream) return;\n for (const track of stream.getTracks()) track.stop();\n };\n\n let capture: PcmCapture;\n try {\n capture = makeCapture(stream, {\n sampleRate: VAD_CAPTURE_SAMPLE_RATE,\n maxDurationMs: UNBOUNDED_MS,\n retain: false,\n onSamples: (samples, contextRate) => {\n if (closed) return;\n if (suspended) return;\n if (guardMsLeft > 0) {\n guardMsLeft -= (samples.length / contextRate) * 1000;\n return;\n }\n collector ??= createUtteranceCollector({\n ...options,\n sampleRate: contextRate,\n onSpeaking: (speaking) => {\n if (speaking) emit('speechstart', {});\n },\n onLevel: (level) => emit('level', { level }),\n onUtterance: (utterance) => {\n const pcm = resampleMono(\n utterance.pcm,\n utterance.sampleRate,\n VAD_CAPTURE_SAMPLE_RATE,\n );\n emit('speechend', {\n samples: pcm,\n sampleRate: VAD_CAPTURE_SAMPLE_RATE,\n durationMs: utterance.durationMs,\n reason: utterance.reason,\n });\n },\n });\n collector.push(samples);\n },\n });\n } catch (error) {\n releaseStream();\n throw error;\n }\n\n // A worklet that fails to load (CSP, blocked autoplay) must not look like a\n // working microphone: release the stream and surface the failure.\n if (capture.ready) {\n try {\n await capture.ready;\n } catch (error) {\n closed = true;\n capture.cancel();\n releaseStream();\n throw new PcmCaptureError(\n 'setup_failed',\n 'Hands-free capture could not start',\n error,\n );\n }\n }\n\n const finish = async (flush: boolean): Promise<void> => {\n if (closed) return;\n // Flush before `closed` so the final utterance is still delivered.\n if (flush) collector?.flush();\n closed = true;\n capture.cancel();\n releaseStream();\n };\n\n return {\n on(type, listener) {\n const set = listeners[type] as Set<typeof listener>;\n set.add(listener);\n return () => {\n set.delete(listener);\n };\n },\n get speaking() {\n return collector?.speaking ?? false;\n },\n get suspended() {\n return suspended;\n },\n suspend() {\n if (closed || suspended) return;\n suspended = true;\n guardMsLeft = 0;\n collector?.discard();\n emit('level', { level: 0 });\n },\n resume() {\n if (closed || !suspended) return;\n suspended = false;\n guardMsLeft = resumeGuardMs;\n },\n stop: () => finish(true),\n cancel: () => {\n void finish(false);\n },\n };\n}\n\nexport type { VadEndReason, VadOptions } from './vad.js';\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"browser.js","names":[],"sources":["../src/shared/pcm-capture.ts","../src/shared/speech-playback.ts","../src/shared/vad-capture.ts"],"sourcesContent":["/**\n * Browser PCM capture: `MediaStream` -> mono Float32 at a requested rate and a\n * ready 16-bit PCM WAV, via `AudioContext` + `AudioWorklet`. The worklet code\n * is inlined and loaded from a Blob URL, so no bundler configuration is\n * needed. Nothing here touches Node APIs; the Web Audio globals are read when\n * `createPcmCapture` is called, so importing this module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport { resampleMono } from './resample.js';\nimport { encodeWavPcm16 } from './wav.js';\n\nconst PROCESSOR_NAME = 'hv-pcm-tap';\n\n// Mixes every input channel to mono and posts one transferable block per\n// render quantum. Returns true to stay alive until the node is disconnected.\nconst WORKLET_SOURCE = `\nclass HvPcmTap extends AudioWorkletProcessor {\n process(inputs) {\n const channels = inputs[0];\n if (channels && channels.length > 0 && channels[0].length > 0) {\n const frames = channels[0].length;\n const mono = new Float32Array(frames);\n for (let c = 0; c < channels.length; c += 1) {\n const data = channels[c];\n for (let i = 0; i < frames; i += 1) mono[i] += data[i];\n }\n if (channels.length > 1) {\n for (let i = 0; i < frames; i += 1) mono[i] /= channels.length;\n }\n this.port.postMessage(mono, [mono.buffer]);\n }\n return true;\n }\n}\nregisterProcessor('${PROCESSOR_NAME}', HvPcmTap);\n`;\n\nexport interface PcmCaptureOptions {\n /** Rate of the returned samples and WAV, in Hz (e.g. 16000 for Voxtral). */\n sampleRate: number;\n /** Audio beyond this duration is dropped, not stored. */\n maxDurationMs: number;\n /** Called once, when the cap is reached; the capture keeps running until stopped. */\n onLimit?: () => void;\n /**\n * Streams every mono block as it arrives, at the audio context's own rate\n * (`contextSampleRate`), before any cap or resampling. The block is yours to\n * keep. Used by `createVadCapture` for live analysis.\n */\n onSamples?: (samples: Float32Array, contextSampleRate: number) => void;\n /**\n * Keep the audio for `stop()`. Default true. `false` only streams to\n * `onSamples`: memory stays flat for open-ended listening, `maxDurationMs`\n * never trims, and `stop()` resolves with empty samples.\n */\n retain?: boolean;\n}\n\nexport interface PcmCaptureResult {\n /** Mono samples at `sampleRate`, -1..1. */\n samples: Float32Array;\n /** The same audio as a 16-bit PCM mono WAV. */\n wav: Uint8Array;\n /** True when audio past `maxDurationMs` was dropped. */\n truncated: boolean;\n}\n\nexport interface PcmCapture {\n /** Settles once the audio graph is running; rejects if setup failed. Optional for custom capture factories. */\n readonly ready?: Promise<void>;\n /** Ends capture, tears everything down, and resolves with the audio. Idempotent. */\n stop(): Promise<PcmCaptureResult>;\n /** Discards the audio and tears everything down. Idempotent; a pending `stop()` rejects. */\n cancel(): void;\n}\n\n/** Whether this runtime has the Web Audio pieces `createPcmCapture` needs. */\nexport function pcmCaptureSupported(): boolean {\n return (\n typeof AudioContext !== 'undefined' &&\n typeof AudioWorkletNode !== 'undefined' &&\n typeof Blob !== 'undefined' &&\n typeof URL !== 'undefined' &&\n typeof URL.createObjectURL === 'function'\n );\n}\n\n/**\n * Starts capturing `stream`. Throws `PcmCaptureError` (`unsupported`) when Web\n * Audio is unavailable. The caller keeps ownership of the stream and its\n * tracks: stop them yourself after `stop()` or `cancel()`.\n */\nexport function createPcmCapture(\n stream: MediaStream,\n options: PcmCaptureOptions,\n): PcmCapture {\n const { sampleRate, maxDurationMs } = options;\n if (!Number.isInteger(sampleRate) || sampleRate < 1) {\n throw new RangeError('sampleRate must be a positive integer');\n }\n if (!Number.isFinite(maxDurationMs) || maxDurationMs <= 0) {\n throw new RangeError('maxDurationMs must be a positive number');\n }\n if (!pcmCaptureSupported()) {\n throw new PcmCaptureError(\n 'unsupported',\n 'PCM capture needs AudioContext, AudioWorkletNode and Blob URLs',\n );\n }\n\n const context = new AudioContext();\n const capSamples = Math.ceil((maxDurationMs / 1000) * context.sampleRate);\n const frames: Float32Array[] = [];\n let captured = 0;\n let truncated = false;\n let limitFired = false;\n let cancelled = false;\n let finished = false;\n let url: string | undefined;\n let source: MediaStreamAudioSourceNode | undefined;\n let node: AudioWorkletNode | undefined;\n let closing: Promise<void> | undefined;\n\n const onFrame = (event: MessageEvent<Float32Array>): void => {\n if (finished) return;\n let frame = event.data;\n options.onSamples?.(frame, context.sampleRate);\n if (options.retain === false) return;\n const room = capSamples - captured;\n if (frame.length > room) {\n frame = frame.subarray(0, Math.max(0, room));\n truncated = true;\n }\n if (frame.length > 0) {\n frames.push(frame);\n captured += frame.length;\n }\n if (captured >= capSamples && !limitFired) {\n limitFired = true;\n options.onLimit?.();\n }\n };\n\n const revoke = (): void => {\n if (url !== undefined) {\n const stale = url;\n url = undefined;\n try {\n URL.revokeObjectURL(stale);\n } catch {\n // already revoked\n }\n }\n };\n\n const teardown = (): Promise<void> => {\n if (closing) return closing;\n finished = true;\n revoke();\n if (node) node.port.onmessage = null;\n try {\n source?.disconnect();\n node?.disconnect();\n } catch {\n // graph already torn down\n }\n closing = (async () => {\n try {\n if (context.state !== 'closed') await context.close();\n } catch {\n // closing a dead context is not worth reporting\n }\n })();\n return closing;\n };\n\n const ready = (async (): Promise<void> => {\n url = URL.createObjectURL(\n new Blob([WORKLET_SOURCE], { type: 'text/javascript' }),\n );\n try {\n await context.audioWorklet.addModule(url);\n } finally {\n revoke();\n }\n if (finished) return;\n source = context.createMediaStreamSource(stream);\n node = new AudioWorkletNode(context, PROCESSOR_NAME, {\n numberOfInputs: 1,\n numberOfOutputs: 1,\n outputChannelCount: [1],\n });\n node.port.onmessage = onFrame;\n source.connect(node);\n // A worklet only runs while connected to the destination; it emits silence.\n node.connect(context.destination);\n if (context.state === 'suspended') await context.resume();\n })();\n // Failure is reported by stop(); never an unhandled rejection.\n ready.catch(() => undefined);\n\n let result: Promise<PcmCaptureResult> | undefined;\n\n return {\n ready,\n stop(): Promise<PcmCaptureResult> {\n result ??= (async () => {\n try {\n await ready;\n } catch (error) {\n await teardown();\n throw new PcmCaptureError(\n 'setup_failed',\n 'PCM capture could not start',\n error,\n );\n }\n await teardown();\n if (cancelled) {\n throw new PcmCaptureError('cancelled', 'PCM capture was cancelled');\n }\n const joined = new Float32Array(captured);\n let offset = 0;\n for (const frame of frames.splice(0)) {\n joined.set(frame, offset);\n offset += frame.length;\n }\n const samples = resampleMono(joined, context.sampleRate, sampleRate);\n return { samples, wav: encodeWavPcm16(samples, sampleRate), truncated };\n })();\n return result;\n },\n cancel(): void {\n if (cancelled) return;\n cancelled = true;\n frames.length = 0;\n void teardown();\n },\n };\n}\n","import type { SynthesizedSpeech } from './types.js';\n\n/** Events from actual browser audio playback, never from synthesis completion. */\nexport interface SpeechPlaybackEvents {\n onStart?: () => void;\n onEnd?: () => void;\n onError?: (error: Error) => void;\n /** Smoothed RMS amplitude, bounded to the inclusive 0..1 range. */\n onLevel?: (level: number) => void;\n}\n\nexport interface SpeechPlaybackOptions extends SpeechPlaybackEvents {\n /** Defaults to 0.28; higher values follow the waveform more closely. */\n smoothing?: number;\n}\n\nexport interface SpeechPlayback {\n /** Call directly from the user's Play action to unlock browser audio. */\n prepare(): Promise<void>;\n play(speech: Pick<SynthesizedSpeech, 'audio' | 'contentType'>): Promise<void>;\n stop(): void;\n destroy(): void;\n readonly playing: boolean;\n}\n\nexport function speechPlaybackSupported(): boolean {\n return typeof Audio !== 'undefined' && typeof AudioContext !== 'undefined';\n}\n\n/**\n * Plays server-produced speech in a browser and samples the playing element's\n * analyser node. Provider credentials and synthesis deliberately stay server-side.\n */\nexport function createSpeechPlayback(\n options: SpeechPlaybackOptions = {},\n): SpeechPlayback {\n const requestedSmoothing = options.smoothing ?? 0.28;\n if (!Number.isFinite(requestedSmoothing))\n throw new RangeError('speech playback smoothing must be finite');\n const smoothing = Math.min(1, Math.max(0.01, requestedSmoothing));\n let audio: HTMLAudioElement | undefined;\n let context: AudioContext | undefined;\n let source: MediaElementAudioSourceNode | undefined;\n let analyser: AnalyserNode | undefined;\n let frame: number | undefined;\n let url: string | undefined;\n let disposed = false;\n let active = 0;\n let playing = false;\n let operation:\n | {\n token: number;\n resolve: () => void;\n reject: (error: Error) => void;\n }\n | undefined;\n\n const emitLevel = (level: number) =>\n options.onLevel?.(Math.min(1, Math.max(0, level)));\n const finish = (\n token: number,\n {\n ended = false,\n failure,\n closeContext = false,\n }: {\n ended?: boolean;\n failure?: Error;\n closeContext?: boolean;\n } = {},\n ) => {\n if (token !== active) return;\n if (frame !== undefined) cancelAnimationFrame(frame);\n frame = undefined;\n audio?.pause();\n audio = undefined;\n source?.disconnect();\n source = undefined;\n analyser?.disconnect();\n analyser = undefined;\n if (url) URL.revokeObjectURL(url);\n url = undefined;\n if (closeContext) {\n const closing = context;\n context = undefined;\n if (closing && closing.state !== 'closed') void closing.close();\n }\n const emittedAudioLevel = playing;\n playing = false;\n if (operation?.token === token) {\n if (failure) operation.reject(failure);\n else operation.resolve();\n operation = undefined;\n }\n ++active;\n if (emittedAudioLevel) emitLevel(0);\n if (ended) options.onEnd?.();\n };\n\n return {\n get playing() {\n return playing;\n },\n async prepare() {\n if (disposed) throw new Error('Speech playback has been destroyed');\n if (!speechPlaybackSupported())\n throw new Error('Browser audio playback is unavailable');\n context ??= new AudioContext();\n if (context.state === 'suspended') await context.resume();\n },\n async play(speech) {\n if (disposed) throw new Error('Speech playback has been destroyed');\n if (!speechPlaybackSupported())\n throw new Error('Browser audio playback is unavailable');\n const activeBeforeStop = active;\n this.stop();\n if (disposed) throw new Error('Speech playback has been destroyed');\n // `finish()` advances active once. A terminal callback may reenter\n // `play()` and claim a newer operation; that newest call owns playback.\n if (active !== activeBeforeStop + 1) return;\n const token = ++active;\n let resolveCompletion!: () => void;\n let rejectCompletion!: (error: Error) => void;\n const completion = new Promise<void>((resolve, reject) => {\n resolveCompletion = resolve;\n rejectCompletion = reject;\n });\n // A native resume/play promise cannot be cancelled. Keep a rejection\n // observer attached while cancellation settles this public operation.\n void completion.catch(() => {});\n operation = {\n token,\n resolve: resolveCompletion,\n reject: rejectCompletion,\n };\n try {\n url = URL.createObjectURL(\n new Blob([speech.audio], { type: speech.contentType }),\n );\n const nextAudio = new Audio(url);\n const nextContext = context ?? new AudioContext();\n context = nextContext;\n const nextSource = nextContext.createMediaElementSource(nextAudio);\n const nextAnalyser = nextContext.createAnalyser();\n nextAnalyser.fftSize = 512;\n nextSource.connect(nextAnalyser);\n nextAnalyser.connect(nextContext.destination);\n source = nextSource;\n analyser = nextAnalyser;\n const samples = new Uint8Array(nextAnalyser.fftSize);\n let level = 0;\n const sample = () => {\n if (token !== active || !playing) return;\n nextAnalyser.getByteTimeDomainData(samples);\n let sum = 0;\n for (const value of samples) {\n const normalized = (value - 128) / 128;\n sum += normalized * normalized;\n }\n const target = Math.min(1, Math.sqrt(sum / samples.length) * 4);\n level += (target - level) * smoothing;\n emitLevel(level);\n frame = requestAnimationFrame(sample);\n };\n audio = nextAudio;\n nextAudio.onended = () => finish(token, { ended: true });\n nextAudio.onerror = () => {\n if (token !== active) return;\n const error = new Error('Browser speech audio playback failed');\n finish(token, { failure: error });\n options.onError?.(error);\n };\n await Promise.race([nextContext.resume(), completion]);\n if (token !== active || disposed) return;\n await Promise.race([nextAudio.play(), completion]);\n if (token !== active || disposed) return;\n playing = true;\n options.onStart?.();\n if (token !== active || disposed) return;\n sample();\n await completion;\n } catch (cause) {\n const error =\n cause instanceof Error\n ? cause\n : new Error('Browser speech audio playback failed');\n if (token === active) {\n finish(token, { failure: error });\n options.onError?.(error);\n }\n throw error;\n }\n },\n stop() {\n finish(active);\n },\n destroy() {\n if (!disposed) {\n disposed = true;\n finish(active, { closeContext: true });\n }\n },\n };\n}\n","/**\n * Hands-free microphone capture: opens the microphone, runs its audio through\n * the on-device voice activity detector (`vad.ts`) and emits one finished\n * utterance at a time as 16 kHz mono Float32 PCM. Audio stays in memory; the\n * microphone is released by `stop()`. Nothing here touches Node APIs; the Web\n * Audio globals are read when `createVadCapture` is called, so importing this\n * module is SSR-safe.\n */\n\nimport { PcmCaptureError } from './errors.js';\nimport {\n createPcmCapture,\n type PcmCapture,\n type PcmCaptureOptions,\n pcmCaptureSupported,\n} from './pcm-capture.js';\nimport { resampleMono } from './resample.js';\nimport {\n createUtteranceCollector,\n type UtteranceCollector,\n type VadEndReason,\n type VadOptions,\n} from './vad.js';\n\n/** Rate of the PCM in `speechend` events. */\nexport const VAD_CAPTURE_SAMPLE_RATE = 16_000;\n\n/** Events emitted by a `VadCapture`. */\nexport interface VadCaptureEventMap {\n /** An utterance began. */\n speechstart: Record<string, never>;\n /** An utterance ended (a pause, the length cap, or `stop()`). */\n speechend: {\n /** Mono samples, -1..1, at `sampleRate`, including the pre-roll. */\n samples: Float32Array;\n sampleRate: number;\n durationMs: number;\n reason: VadEndReason;\n };\n /** Loudness 0..1 for each analysed frame, for a meter. */\n level: { level: number };\n}\n\nexport type VadCaptureEventType = keyof VadCaptureEventMap;\n\nexport interface VadCaptureOptions extends VadOptions {\n /**\n * Use this stream instead of opening the microphone. The caller keeps\n * ownership: `stop()` does not stop its tracks.\n */\n stream?: MediaStream;\n /**\n * Constraints for the microphone when no `stream` is given. Default:\n * `{ audio: { echoCancellation, noiseSuppression, autoGainControl: true } }`,\n * a secondary defence against hearing the page's own playback. Browsers do\n * not reliably cancel `speechSynthesis`; use `suspend()` while it plays.\n */\n constraints?: MediaStreamConstraints;\n /**\n * After `resume()`, audio of this length is still ignored so the tail of the\n * playback and its room reverb are not heard as speech. Default 350.\n */\n resumeGuardMs?: number;\n /** Test seam: replaces `createPcmCapture`. */\n createCapture?: (\n stream: MediaStream,\n options: PcmCaptureOptions,\n ) => PcmCapture;\n}\n\nexport interface VadCapture {\n /** Subscribes to an event; returns the unsubscribe function. */\n on<T extends VadCaptureEventType>(\n type: T,\n listener: (event: VadCaptureEventMap[T]) => void,\n ): () => void;\n /** Currently inside an utterance. */\n readonly speaking: boolean;\n /** Half-duplex gate is closed (between `suspend()` and `resume()`). */\n readonly suspended: boolean;\n /**\n * Half-duplex gate: stops listening while the page itself is playing audio\n * (for example `speechSynthesis`). Frames are dropped, an utterance in\n * progress is discarded (no `speechend`), the noise floor does not adapt to\n * the played audio, and the microphone stays open. Idempotent.\n */\n suspend(): void;\n /**\n * Reopens the gate after `resumeGuardMs` of further audio has been ignored.\n * No-op when not suspended.\n */\n resume(): void;\n /**\n * Ends an utterance in progress (a final `speechend` with reason `flush`),\n * then releases the microphone. Idempotent.\n */\n stop(): Promise<void>;\n /** Releases the microphone and discards any utterance in progress. Idempotent. */\n cancel(): void;\n}\n\n/** Whether this runtime can open a microphone and tap it with Web Audio. */\nexport function vadCaptureSupported(): boolean {\n return (\n pcmCaptureSupported() &&\n typeof navigator !== 'undefined' &&\n typeof navigator.mediaDevices?.getUserMedia === 'function'\n );\n}\n\n/** One hour; with `retain: false` this is never reached, it only satisfies validation. */\nconst UNBOUNDED_MS = 3_600_000;\n\n/**\n * Opens the microphone (or uses `options.stream`) and starts detecting speech.\n * Rejects with `PcmCaptureError` (`unsupported`) when Web Audio or the\n * microphone API is missing, and with the browser's own error when permission\n * is denied.\n */\nexport async function createVadCapture(\n options: VadCaptureOptions = {},\n): Promise<VadCapture> {\n const makeCapture = options.createCapture ?? createPcmCapture;\n if (!options.createCapture && !vadCaptureSupported() && !options.stream) {\n throw new PcmCaptureError(\n 'unsupported',\n 'Hands-free capture needs the microphone API and Web Audio',\n );\n }\n\n const ownsStream = !options.stream;\n const stream =\n options.stream ??\n (await navigator.mediaDevices.getUserMedia(\n options.constraints ?? {\n audio: {\n echoCancellation: true,\n noiseSuppression: true,\n autoGainControl: true,\n },\n },\n ));\n\n const listeners: {\n [T in VadCaptureEventType]: Set<(event: VadCaptureEventMap[T]) => void>;\n } = { speechstart: new Set(), speechend: new Set(), level: new Set() };\n const emit = <T extends VadCaptureEventType>(\n type: T,\n event: VadCaptureEventMap[T],\n ): void => {\n for (const listener of [...listeners[type]]) {\n try {\n listener(event);\n } catch {\n // A throwing listener must not break capture or its siblings.\n }\n }\n };\n\n let collector: UtteranceCollector | undefined;\n let closed = false;\n let suspended = false;\n // Milliseconds of audio still to drop after `resume()`.\n let guardMsLeft = 0;\n const resumeGuardMs = Math.max(0, options.resumeGuardMs ?? 350);\n const releaseStream = (): void => {\n if (!ownsStream) return;\n for (const track of stream.getTracks()) track.stop();\n };\n\n let capture: PcmCapture;\n try {\n capture = makeCapture(stream, {\n sampleRate: VAD_CAPTURE_SAMPLE_RATE,\n maxDurationMs: UNBOUNDED_MS,\n retain: false,\n onSamples: (samples, contextRate) => {\n if (closed) return;\n if (suspended) return;\n if (guardMsLeft > 0) {\n guardMsLeft -= (samples.length / contextRate) * 1000;\n return;\n }\n collector ??= createUtteranceCollector({\n ...options,\n sampleRate: contextRate,\n onSpeaking: (speaking) => {\n if (speaking) emit('speechstart', {});\n },\n onLevel: (level) => emit('level', { level }),\n onUtterance: (utterance) => {\n const pcm = resampleMono(\n utterance.pcm,\n utterance.sampleRate,\n VAD_CAPTURE_SAMPLE_RATE,\n );\n emit('speechend', {\n samples: pcm,\n sampleRate: VAD_CAPTURE_SAMPLE_RATE,\n durationMs: utterance.durationMs,\n reason: utterance.reason,\n });\n },\n });\n collector.push(samples);\n },\n });\n } catch (error) {\n releaseStream();\n throw error;\n }\n\n // A worklet that fails to load (CSP, blocked autoplay) must not look like a\n // working microphone: release the stream and surface the failure.\n if (capture.ready) {\n try {\n await capture.ready;\n } catch (error) {\n closed = true;\n capture.cancel();\n releaseStream();\n throw new PcmCaptureError(\n 'setup_failed',\n 'Hands-free capture could not start',\n error,\n );\n }\n }\n\n const finish = async (flush: boolean): Promise<void> => {\n if (closed) return;\n // Flush before `closed` so the final utterance is still delivered.\n if (flush) collector?.flush();\n closed = true;\n capture.cancel();\n releaseStream();\n };\n\n return {\n on(type, listener) {\n const set = listeners[type] as Set<typeof listener>;\n set.add(listener);\n return () => {\n set.delete(listener);\n };\n },\n get speaking() {\n return collector?.speaking ?? false;\n },\n get suspended() {\n return suspended;\n },\n suspend() {\n if (closed || suspended) return;\n suspended = true;\n guardMsLeft = 0;\n collector?.discard();\n emit('level', { level: 0 });\n },\n resume() {\n if (closed || !suspended) return;\n suspended = false;\n guardMsLeft = resumeGuardMs;\n },\n stop: () => finish(true),\n cancel: () => {\n void finish(false);\n },\n };\n}\n\nexport type { VadEndReason, VadOptions } from './vad.js';\n"],"mappings":"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"}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { SynthesizedSpeech } from './types.js';
|
|
2
|
+
/** Events from actual browser audio playback, never from synthesis completion. */
|
|
3
|
+
export interface SpeechPlaybackEvents {
|
|
4
|
+
onStart?: () => void;
|
|
5
|
+
onEnd?: () => void;
|
|
6
|
+
onError?: (error: Error) => void;
|
|
7
|
+
/** Smoothed RMS amplitude, bounded to the inclusive 0..1 range. */
|
|
8
|
+
onLevel?: (level: number) => void;
|
|
9
|
+
}
|
|
10
|
+
export interface SpeechPlaybackOptions extends SpeechPlaybackEvents {
|
|
11
|
+
/** Defaults to 0.28; higher values follow the waveform more closely. */
|
|
12
|
+
smoothing?: number;
|
|
13
|
+
}
|
|
14
|
+
export interface SpeechPlayback {
|
|
15
|
+
/** Call directly from the user's Play action to unlock browser audio. */
|
|
16
|
+
prepare(): Promise<void>;
|
|
17
|
+
play(speech: Pick<SynthesizedSpeech, 'audio' | 'contentType'>): Promise<void>;
|
|
18
|
+
stop(): void;
|
|
19
|
+
destroy(): void;
|
|
20
|
+
readonly playing: boolean;
|
|
21
|
+
}
|
|
22
|
+
export declare function speechPlaybackSupported(): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Plays server-produced speech in a browser and samples the playing element's
|
|
25
|
+
* analyser node. Provider credentials and synthesis deliberately stay server-side.
|
|
26
|
+
*/
|
|
27
|
+
export declare function createSpeechPlayback(options?: SpeechPlaybackOptions): SpeechPlayback;
|
|
28
|
+
//# sourceMappingURL=speech-playback.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"speech-playback.d.ts","sourceRoot":"","sources":["../../src/shared/speech-playback.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAEpD,kFAAkF;AAClF,MAAM,WAAW,oBAAoB;IACnC,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,IAAI,CAAC;IACnB,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,mEAAmE;IACnE,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACnC;AAED,MAAM,WAAW,qBAAsB,SAAQ,oBAAoB;IACjE,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,cAAc;IAC7B,yEAAyE;IACzE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACzB,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,iBAAiB,EAAE,OAAO,GAAG,aAAa,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC9E,IAAI,IAAI,IAAI,CAAC;IACb,OAAO,IAAI,IAAI,CAAC;IAChB,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;CAC3B;AAED,wBAAgB,uBAAuB,IAAI,OAAO,CAEjD;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,GAAE,qBAA0B,GAClC,cAAc,CAwKhB"}
|