@simfinity/constellation-react 0.0.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/dist/index.cjs ADDED
@@ -0,0 +1,919 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ AudioCaptureConfig: () => AudioCaptureConfig,
34
+ ConstellationProvider: () => ConstellationProvider,
35
+ useAudioCaptureState: () => useAudioCaptureState,
36
+ useAudioPlaybackState: () => useAudioPlaybackState,
37
+ useConstellationSession: () => useConstellationSession
38
+ });
39
+ module.exports = __toCommonJS(index_exports);
40
+
41
+ // src/constellationProvider.tsx
42
+ var import_react3 = __toESM(require("react"), 1);
43
+ var import_constellation_client = require("@simfinity/constellation-client");
44
+
45
+ // src/useAudioPlayback.tsx
46
+ var import_react = require("react");
47
+
48
+ // src/lib/pcm16.ts
49
+ function float32ToPcm16Base64(float32) {
50
+ const pcm16 = new Int16Array(float32.length);
51
+ for (let i = 0; i < float32.length; i++) {
52
+ const s = Math.max(-1, Math.min(1, float32[i]));
53
+ pcm16[i] = s < 0 ? s * 32768 : s * 32767;
54
+ }
55
+ const bytes = new Uint8Array(pcm16.buffer);
56
+ let binary = "";
57
+ for (let i = 0; i < bytes.length; i++) {
58
+ binary += String.fromCharCode(bytes[i]);
59
+ }
60
+ return btoa(binary);
61
+ }
62
+ function pcm16Base64ToFloat32(base64) {
63
+ const binary = atob(base64);
64
+ const bytes = new Uint8Array(binary.length);
65
+ for (let i = 0; i < binary.length; i++) {
66
+ bytes[i] = binary.charCodeAt(i);
67
+ }
68
+ const pcm16 = new Int16Array(bytes.buffer);
69
+ const float32 = new Float32Array(pcm16.length);
70
+ for (let i = 0; i < pcm16.length; i++) {
71
+ float32[i] = pcm16[i] / (pcm16[i] < 0 ? 32768 : 32767);
72
+ }
73
+ return float32;
74
+ }
75
+
76
+ // src/lib/stateStore.ts
77
+ var StateStore = class {
78
+ constructor(state) {
79
+ this.state = state;
80
+ this.listeners = /* @__PURE__ */ new Set();
81
+ this.subscribe = (listener) => {
82
+ this.listeners.add(listener);
83
+ return () => this.listeners.delete(listener);
84
+ };
85
+ }
86
+ update(patch) {
87
+ this.state = { ...this.state, ...patch };
88
+ this.listeners.forEach((l) => l());
89
+ }
90
+ get snapshot() {
91
+ return this.state;
92
+ }
93
+ };
94
+
95
+ // src/useAudioPlayback.tsx
96
+ var PLAYBACK_SAMPLE_RATE = 24e3;
97
+ var PREBUFFER_SAMPLES = Math.ceil(PLAYBACK_SAMPLE_RATE * 0.2);
98
+ var PREBUFFER_FLUSH_TIMEOUT_MS = 400;
99
+ function useAudioPlayback() {
100
+ const stateRef = (0, import_react.useRef)(new StateStore({ playing: false }));
101
+ const contextRef = (0, import_react.useRef)(null);
102
+ const queueRef = (0, import_react.useRef)([]);
103
+ const sourcesRef = (0, import_react.useRef)(/* @__PURE__ */ new Set());
104
+ const nextStartTimeRef = (0, import_react.useRef)(0);
105
+ const playbackStartTimeRef = (0, import_react.useRef)(null);
106
+ const prebufferRef = (0, import_react.useRef)([]);
107
+ const prebufferedSamplesRef = (0, import_react.useRef)(0);
108
+ const isPreBufferingRef = (0, import_react.useRef)(false);
109
+ const prebufferFlushTimerRef = (0, import_react.useRef)(null);
110
+ const getContext = (0, import_react.useCallback)(() => {
111
+ if (!contextRef.current || contextRef.current.state === "closed") {
112
+ contextRef.current = new AudioContext({ sampleRate: PLAYBACK_SAMPLE_RATE });
113
+ }
114
+ return contextRef.current;
115
+ }, []);
116
+ const scheduleChunk = (0, import_react.useCallback)((ctx, samples) => {
117
+ const buffer = ctx.createBuffer(1, samples.length, PLAYBACK_SAMPLE_RATE);
118
+ buffer.getChannelData(0).set(samples);
119
+ const source = ctx.createBufferSource();
120
+ source.buffer = buffer;
121
+ source.connect(ctx.destination);
122
+ const startTime = Math.max(ctx.currentTime, nextStartTimeRef.current);
123
+ source.start(startTime);
124
+ sourcesRef.current.add(source);
125
+ nextStartTimeRef.current = startTime + buffer.duration;
126
+ source.onended = () => {
127
+ sourcesRef.current.delete(source);
128
+ if (ctx.currentTime >= nextStartTimeRef.current - 0.01) {
129
+ playbackStartTimeRef.current = null;
130
+ stateRef.current.update({ playing: false });
131
+ }
132
+ };
133
+ }, []);
134
+ const flushPrebuffer = (0, import_react.useCallback)((ctx) => {
135
+ if (prebufferFlushTimerRef.current !== null) {
136
+ clearTimeout(prebufferFlushTimerRef.current);
137
+ prebufferFlushTimerRef.current = null;
138
+ }
139
+ isPreBufferingRef.current = false;
140
+ nextStartTimeRef.current = ctx.currentTime;
141
+ for (const bufferedSamples of prebufferRef.current) {
142
+ scheduleChunk(ctx, bufferedSamples);
143
+ }
144
+ prebufferRef.current = [];
145
+ prebufferedSamplesRef.current = 0;
146
+ }, [scheduleChunk]);
147
+ const enqueue = (0, import_react.useCallback)((base64Chunk) => {
148
+ const samples = pcm16Base64ToFloat32(base64Chunk);
149
+ const ctx = getContext();
150
+ const isSilent = !isPreBufferingRef.current && sourcesRef.current.size === 0 && ctx.currentTime >= nextStartTimeRef.current - 0.01;
151
+ const flushPreBufferOrScheduleTimeout = () => {
152
+ if (prebufferedSamplesRef.current >= PREBUFFER_SAMPLES) {
153
+ flushPrebuffer(ctx);
154
+ return;
155
+ }
156
+ if (prebufferFlushTimerRef.current !== null) {
157
+ clearTimeout(prebufferFlushTimerRef.current);
158
+ }
159
+ prebufferFlushTimerRef.current = setTimeout(() => {
160
+ prebufferFlushTimerRef.current = null;
161
+ if (isPreBufferingRef.current && prebufferRef.current.length > 0) {
162
+ flushPrebuffer(getContext());
163
+ }
164
+ }, PREBUFFER_FLUSH_TIMEOUT_MS);
165
+ };
166
+ if (isSilent) {
167
+ isPreBufferingRef.current = true;
168
+ prebufferRef.current = [samples];
169
+ prebufferedSamplesRef.current = samples.length;
170
+ playbackStartTimeRef.current = performance.now();
171
+ stateRef.current.update({ playing: true });
172
+ flushPreBufferOrScheduleTimeout();
173
+ return;
174
+ }
175
+ if (isPreBufferingRef.current) {
176
+ prebufferRef.current.push(samples);
177
+ prebufferedSamplesRef.current += samples.length;
178
+ flushPreBufferOrScheduleTimeout();
179
+ return;
180
+ }
181
+ scheduleChunk(ctx, samples);
182
+ if (!playbackStartTimeRef.current) {
183
+ playbackStartTimeRef.current = performance.now();
184
+ stateRef.current.update({ playing: true });
185
+ }
186
+ }, [getContext, scheduleChunk, flushPrebuffer]);
187
+ const clearQueue = (0, import_react.useCallback)((minPlaybackDurationMs) => {
188
+ const ctx = contextRef.current;
189
+ const playbackDurationMs = getPlayingDurationMs();
190
+ if (minPlaybackDurationMs && playbackDurationMs <= minPlaybackDurationMs) {
191
+ return;
192
+ }
193
+ sourcesRef.current.forEach((source) => {
194
+ try {
195
+ source.stop(0);
196
+ } catch {
197
+ }
198
+ });
199
+ sourcesRef.current.clear();
200
+ if (ctx) {
201
+ nextStartTimeRef.current = ctx.currentTime;
202
+ } else {
203
+ nextStartTimeRef.current = 0;
204
+ }
205
+ if (prebufferFlushTimerRef.current !== null) {
206
+ clearTimeout(prebufferFlushTimerRef.current);
207
+ prebufferFlushTimerRef.current = null;
208
+ }
209
+ isPreBufferingRef.current = false;
210
+ prebufferRef.current = [];
211
+ prebufferedSamplesRef.current = 0;
212
+ playbackStartTimeRef.current = null;
213
+ stateRef.current.update({ playing: false });
214
+ }, []);
215
+ const release = (0, import_react.useCallback)(async () => {
216
+ if (prebufferFlushTimerRef.current !== null) {
217
+ clearTimeout(prebufferFlushTimerRef.current);
218
+ prebufferFlushTimerRef.current = null;
219
+ }
220
+ if (contextRef.current && contextRef.current.state !== "closed") {
221
+ await contextRef.current.close();
222
+ contextRef.current = null;
223
+ }
224
+ queueRef.current = [];
225
+ isPreBufferingRef.current = false;
226
+ prebufferRef.current = [];
227
+ prebufferedSamplesRef.current = 0;
228
+ nextStartTimeRef.current = 0;
229
+ playbackStartTimeRef.current = null;
230
+ stateRef.current.update({ playing: false });
231
+ }, []);
232
+ const getPlayingDurationMs = (0, import_react.useCallback)(() => {
233
+ return isPreBufferingRef.current || !playbackStartTimeRef.current ? Infinity : performance.now() - playbackStartTimeRef.current;
234
+ }, []);
235
+ return {
236
+ sampleRate: PLAYBACK_SAMPLE_RATE,
237
+ preBufferDurationsMs: PREBUFFER_SAMPLES,
238
+ enqueue,
239
+ clearQueue,
240
+ release,
241
+ getPlayingDurationMs,
242
+ subscribeState: stateRef.current.subscribe,
243
+ getState: () => stateRef.current.snapshot
244
+ };
245
+ }
246
+
247
+ // src/useAudioCapture.tsx
248
+ var import_react2 = require("react");
249
+
250
+ // src/lib/audioRingBuffer.ts
251
+ var VadState = {
252
+ Idle: 0,
253
+ PreSpeech: 1,
254
+ // noise candidate
255
+ Speaking: 2,
256
+ // confirmed speech
257
+ PostSpeech: 3
258
+ // silence after speech
259
+ };
260
+ var AudioRingBufferConstants = {
261
+ /**
262
+ * Number of speech-chunks before the early speech signal is fired.
263
+ */
264
+ preSpeechEarlySignalChunksCount: 2,
265
+ /**
266
+ * Number of silence-chunks before the pre-speech state fails & return to idle.
267
+ */
268
+ preSpeechSilenceChunksDebounceCount: 10,
269
+ /**
270
+ * Silence duration allowed before transitioning from Speaking to Post-Speech state.
271
+ * Expressed in % of total silence allowed before end-of-speech i.e. RingBufferConfig.silenceDurationMs.
272
+ *
273
+ * @remarks
274
+ * This does not delay end-of-speech, the amount of time waited before transitioning to post-speech is
275
+ * transferred to the post-speech state. Thus, a value of 0.5 here for a silence duration of 1s means that the
276
+ * post-speech silence only has to last 500ms, having already waited 500ms before leaving Speaking state.
277
+ *
278
+ * TODO: consider exposing this as setting
279
+ */
280
+ speechSilenceThresholdPercent: 0.5,
281
+ /**
282
+ * RMS detection stabilizer:
283
+ * Very important with fine-grained chunks (AudioWorklet typically have ~8ms chunks):
284
+ * The subtleties of the human voice are reflected in the chunks, having many ups and down
285
+ * with very short silence or near silence gaps.
286
+ *
287
+ * @remarks
288
+ * Smaller is smoother with more lag, higher is more responsive but jumpy.
289
+ */
290
+ rmsSmoothing: 0.3
291
+ };
292
+ var AudioRingBuffer = class {
293
+ constructor(config) {
294
+ this.consts = AudioRingBufferConstants;
295
+ this.chunkDuration = 8;
296
+ this.buffer = [];
297
+ this.smoothedRms = 0;
298
+ // -- State machine
299
+ this.state = VadState.Idle;
300
+ // Current stage
301
+ this.readIndex = 0;
302
+ // Streaming cursor: consumer read new data start here
303
+ // Stage index trackers
304
+ this.globalIndex = 0;
305
+ // Global/absolute chunk index counter, goes beyond buffer boundaries
306
+ this.globalBufferStartIndex = 0;
307
+ // The global index of this.buffer[0]
308
+ this.speechStartIndex = 0;
309
+ // Global index of when confirmed speech started
310
+ // Not used for now, but might become useful in future features
311
+ //private silenceStartIndex = 0; // Global index of when post-speech silence started
312
+ // -- State transition counters
313
+ // [Post-Speech Silence] count consequence silence chunks (in post-speech)
314
+ this.silenceChunkCount = 0;
315
+ // [Post-Speech Silence] For confirmed silence, add a short debounce period to go back to speaking
316
+ this.silenceDebounceStartIndex = 0;
317
+ // [Post-Speech Silence] In silence debounce, queue a pending commit
318
+ this.pendingCommit = false;
319
+ // [Pre-Speech] In Pre-Speech add short debounce to not reset on first silence
320
+ this.preSpeechSilenceChunks = 0;
321
+ // [Speaking] Count consecutive speech chunks
322
+ this.speechChunkCount = 0;
323
+ // [Speaking] Early signal speech-start, before confirmed speech
324
+ this.speechStartSignaled = false;
325
+ this.config = config;
326
+ this.maxChunks = Math.ceil(config.maxDurationMs / this.chunkDuration);
327
+ }
328
+ /**
329
+ * Wipes internal state and speech tracking, the ring buffer remains
330
+ * ready to be filled again, only starting from a blank page.s
331
+ */
332
+ forceResetState() {
333
+ this.reset();
334
+ }
335
+ /**
336
+ * Returns chunks that should be streamed NOW
337
+ */
338
+ consume() {
339
+ if (this.state === VadState.Idle || this.state === VadState.PreSpeech) {
340
+ return [];
341
+ }
342
+ const out = [];
343
+ while (this.readIndex < this.globalIndex) {
344
+ const index = this.readIndex - this.globalBufferStartIndex;
345
+ if (index >= 0 && index < this.buffer.length) {
346
+ out.push(this.buffer[index].data);
347
+ }
348
+ this.readIndex++;
349
+ }
350
+ return out;
351
+ }
352
+ /**
353
+ * Push a new chunk of data into the ring buffer
354
+ * @param chunk
355
+ */
356
+ push(chunk) {
357
+ this.buffer.push(chunk);
358
+ this.globalIndex++;
359
+ if (this.buffer.length > this.maxChunks) {
360
+ this.buffer.shift();
361
+ this.globalBufferStartIndex++;
362
+ if (this.readIndex < this.globalBufferStartIndex) {
363
+ this.readIndex = this.globalBufferStartIndex;
364
+ }
365
+ }
366
+ this.updateSmoothRms(chunk.rms);
367
+ this.updateState();
368
+ }
369
+ // --- Getters
370
+ get speaking() {
371
+ return this.state === VadState.Speaking;
372
+ }
373
+ get stateName() {
374
+ return this.state;
375
+ }
376
+ // --- Setters
377
+ set chunkDurationMs(duration) {
378
+ this.chunkDuration = duration;
379
+ this.maxChunks = Math.ceil(this.config.maxDurationMs / this.chunkDuration);
380
+ }
381
+ // --- Private API ---
382
+ updateState() {
383
+ var _a, _b, _c;
384
+ const log = (message) => {
385
+ console.log(`${Date.now() % 1e5} - ${message}`);
386
+ };
387
+ const isSpeech = this.smoothedRms > this.config.silenceThreshold;
388
+ switch (this.state) {
389
+ case VadState.Idle:
390
+ if (isSpeech) {
391
+ log("[AudioBuffer] [Idle] Entering Pre-Speech state");
392
+ this.state = VadState.PreSpeech;
393
+ this.speechStartIndex = this.globalIndex;
394
+ this.readIndex = this.speechStartIndex;
395
+ this.speechChunkCount = 1;
396
+ this.speechStartSignaled = false;
397
+ }
398
+ break;
399
+ case VadState.PreSpeech:
400
+ if (isSpeech) {
401
+ this.preSpeechSilenceChunks = 0;
402
+ this.speechChunkCount++;
403
+ if (!this.speechStartSignaled && this.speechChunkCount >= this.consts.preSpeechEarlySignalChunksCount) {
404
+ (_a = this.onSpeechStart) == null ? void 0 : _a.call(this, "early");
405
+ this.speechStartSignaled = true;
406
+ }
407
+ if (this.speechChunkCount * this.chunkDuration >= this.config.minSpeechDurationMs) {
408
+ log("[AudioBuffer] [Pre-Speech] Entering Speaking state");
409
+ this.state = VadState.Speaking;
410
+ (_b = this.onSpeechStart) == null ? void 0 : _b.call(this, "confirmed");
411
+ }
412
+ } else {
413
+ this.preSpeechSilenceChunks++;
414
+ if (this.preSpeechSilenceChunks > this.consts.preSpeechSilenceChunksDebounceCount) {
415
+ log("[AudioBuffer] [Pre-Speech] Debounce failed");
416
+ this.reset();
417
+ }
418
+ }
419
+ break;
420
+ case VadState.Speaking:
421
+ if (isSpeech) {
422
+ this.silenceChunkCount = 0;
423
+ } else {
424
+ this.silenceChunkCount++;
425
+ const silenceDuration = this.silenceChunkCount * this.chunkDuration;
426
+ if (silenceDuration >= this.config.silenceDurationMs * this.consts.speechSilenceThresholdPercent) {
427
+ log("[AudioBuffer] [Speaking] Speaking silence threshold reached, entering Post-Speech state");
428
+ this.state = VadState.PostSpeech;
429
+ }
430
+ }
431
+ break;
432
+ case VadState.PostSpeech:
433
+ if (isSpeech) {
434
+ log("[AudioBuffer] [Post-Speech] Speech detected, reverting to Speaking state");
435
+ this.state = VadState.Speaking;
436
+ this.silenceChunkCount = 0;
437
+ this.pendingCommit = false;
438
+ } else {
439
+ this.silenceChunkCount++;
440
+ const silenceDuration = this.silenceChunkCount * this.chunkDuration;
441
+ if (!this.pendingCommit) {
442
+ if (silenceDuration >= this.config.silenceDurationMs) {
443
+ log("[AudioBuffer] [Post-Speech] Silence debounce state");
444
+ this.pendingCommit = true;
445
+ this.silenceDebounceStartIndex = this.globalIndex;
446
+ }
447
+ } else {
448
+ const debounceDuration = this.durationSince(this.silenceDebounceStartIndex);
449
+ if (debounceDuration >= this.config.silenceDebounceMs) {
450
+ log("[AudioBuffer] [Post-Speech] Silence debounce passed");
451
+ (_c = this.onCommit) == null ? void 0 : _c.call(this);
452
+ this.reset();
453
+ }
454
+ }
455
+ }
456
+ break;
457
+ }
458
+ }
459
+ durationSince(startIndex) {
460
+ return (this.globalIndex - startIndex) * this.chunkDuration;
461
+ }
462
+ updateSmoothRms(rms) {
463
+ this.smoothedRms = this.consts.rmsSmoothing * rms + (1 - this.consts.rmsSmoothing) * this.smoothedRms;
464
+ }
465
+ reset() {
466
+ this.state = VadState.Idle;
467
+ this.speechStartIndex = 0;
468
+ this.silenceChunkCount = 0;
469
+ this.pendingCommit = false;
470
+ this.silenceDebounceStartIndex = 0;
471
+ this.preSpeechSilenceChunks = 0;
472
+ this.readIndex = this.globalIndex;
473
+ }
474
+ };
475
+
476
+ // src/lib/rms.ts
477
+ function computeRms(samples) {
478
+ let sum = 0;
479
+ for (let i = 0; i < samples.length; i++) {
480
+ sum += samples[i] * samples[i];
481
+ }
482
+ return Math.sqrt(sum / samples.length);
483
+ }
484
+
485
+ // src/useAudioCapture.tsx
486
+ var workletCode = `
487
+ class CaptureProcessor extends AudioWorkletProcessor {
488
+ process(inputs) {
489
+ const input = inputs[0];
490
+ if (input && input[0]) {
491
+ // Send Float32Array to main thread
492
+ this.port.postMessage(input[0]);
493
+ }
494
+ return true;
495
+ }
496
+ }
497
+
498
+ registerProcessor('capture-processor', CaptureProcessor);
499
+ `;
500
+ var SAMPLE_RATE = 24e3;
501
+ var SILENCE_THRESHOLD = 0.04;
502
+ var SILENCE_DURATION_MS = 1e3;
503
+ var MIN_SPEECH_DURATION_MS = 200;
504
+ var AudioCaptureConfig = class {
505
+ constructor() {
506
+ this.sampleRate = SAMPLE_RATE;
507
+ /**
508
+ * Minimum amount of energy for a noise to be considered as actual input and be recorded.
509
+ * - Below it, audio data is silently discarded
510
+ * - Above it, onAudioChunk events containing the audio stream will be fired
511
+ *
512
+ * @remarks
513
+ * This is the main parameter for noise-cancelling
514
+ */
515
+ this.silenceThreshold = SILENCE_THRESHOLD;
516
+ /**
517
+ * Once an input was detected, this is the duration of silence required before
518
+ * detecting the end-of-speech and triggering the onSilenceCommit event.
519
+ *
520
+ * IMPORTANT:
521
+ * Internally, duration is computed on audio chunks count, not system clock.
522
+ * A chunk is usually 8ms, therefore to see the effect of a silence duration change, it must be
523
+ * such that the number of chunks count changes e.g. going from 800ms to 810ms increases
524
+ * the chunk count by one.
525
+ *
526
+ * @remarks
527
+ * This is the main voice-activation-detection parameter.
528
+ */
529
+ this.silenceDurationMs = SILENCE_DURATION_MS;
530
+ /**
531
+ * Minimum input duration: below it, an input will not trigger an onSilenceCommit event.
532
+ *
533
+ * @remarks
534
+ * This is an important interruption parameter: it prevents very short inputs from
535
+ * interrupting an ongoing audio response.
536
+ */
537
+ this.minSpeechDurationMs = MIN_SPEECH_DURATION_MS;
538
+ }
539
+ };
540
+ function useAudioCapture() {
541
+ const stateRef = (0, import_react2.useRef)(new StateStore({
542
+ recording: false,
543
+ speaking: false,
544
+ muted: false
545
+ }));
546
+ const configRef = (0, import_react2.useRef)(new AudioCaptureConfig());
547
+ const mediaStreamRef = (0, import_react2.useRef)(null);
548
+ const contextRef = (0, import_react2.useRef)(null);
549
+ const sourceRef = (0, import_react2.useRef)(null);
550
+ const workletUrlRef = (0, import_react2.useRef)(null);
551
+ const workletNodeRef = (0, import_react2.useRef)(null);
552
+ const audioCallbackRef = (0, import_react2.useRef)(null);
553
+ const commitCallbackRef = (0, import_react2.useRef)(null);
554
+ const speechEarlyRef = (0, import_react2.useRef)(null);
555
+ const speechConfirmedRef = (0, import_react2.useRef)(null);
556
+ const ringRef = (0, import_react2.useRef)(null);
557
+ const chunkDurationMsRef = (0, import_react2.useRef)(0);
558
+ const configure = (0, import_react2.useCallback)((settings) => {
559
+ configRef.current = settings;
560
+ }, []);
561
+ const start = (0, import_react2.useCallback)(async (params) => {
562
+ var _a, _b, _c;
563
+ await stop();
564
+ try {
565
+ audioCallbackRef.current = params.onAudioChunk;
566
+ commitCallbackRef.current = (_a = params.onSilenceCommit) != null ? _a : null;
567
+ speechEarlyRef.current = (_b = params.onSpeechConsidered) != null ? _b : null;
568
+ speechConfirmedRef.current = (_c = params.onSpeechConfirmed) != null ? _c : null;
569
+ const stream = await navigator.mediaDevices.getUserMedia({
570
+ audio: {
571
+ sampleRate: configRef.current.sampleRate,
572
+ channelCount: 1,
573
+ echoCancellation: true,
574
+ noiseSuppression: true
575
+ }
576
+ });
577
+ mediaStreamRef.current = stream;
578
+ const audioContext = new AudioContext({ sampleRate: configRef.current.sampleRate });
579
+ contextRef.current = audioContext;
580
+ const source = audioContext.createMediaStreamSource(stream);
581
+ sourceRef.current = source;
582
+ const ringDuration = 1.25 * configRef.current.minSpeechDurationMs + configRef.current.silenceDurationMs;
583
+ ringRef.current = new AudioRingBuffer({
584
+ maxDurationMs: ringDuration,
585
+ silenceThreshold: configRef.current.silenceThreshold,
586
+ minSpeechDurationMs: configRef.current.minSpeechDurationMs,
587
+ silenceDurationMs: configRef.current.silenceDurationMs,
588
+ silenceDebounceMs: 350
589
+ });
590
+ ringRef.current.onCommit = () => {
591
+ var _a2;
592
+ console.log("[Audio] Commit triggered");
593
+ stateRef.current.update({ speaking: false });
594
+ (_a2 = commitCallbackRef.current) == null ? void 0 : _a2.call(commitCallbackRef);
595
+ };
596
+ ringRef.current.onSpeechStart = (stage) => {
597
+ var _a2, _b2;
598
+ if (stage === "early") {
599
+ (_a2 = speechEarlyRef.current) == null ? void 0 : _a2.call(speechEarlyRef);
600
+ }
601
+ if (stage === "confirmed") {
602
+ (_b2 = speechConfirmedRef.current) == null ? void 0 : _b2.call(speechConfirmedRef);
603
+ }
604
+ };
605
+ const onAudioChunkReceived = (inputData) => {
606
+ var _a2;
607
+ if (stateRef.current.snapshot.muted) {
608
+ if (stateRef.current.snapshot.speaking) {
609
+ stateRef.current.update({ speaking: false });
610
+ }
611
+ return;
612
+ }
613
+ const rms = computeRms(inputData);
614
+ const chunkBase64 = float32ToPcm16Base64(inputData);
615
+ const ring = ringRef.current;
616
+ if (!ring) return;
617
+ const originalState = ring.stateName;
618
+ ring.push({ data: chunkBase64, rms });
619
+ if (ring.speaking && !stateRef.current.snapshot.speaking) {
620
+ stateRef.current.update({ speaking: true });
621
+ }
622
+ const chunksToSend = ring.consume();
623
+ for (const chunk of chunksToSend) {
624
+ (_a2 = audioCallbackRef.current) == null ? void 0 : _a2.call(audioCallbackRef, chunk);
625
+ }
626
+ if (originalState !== ring.stateName) {
627
+ console.log("[Audio]", { state: ring.stateName, speaking: ring.speaking });
628
+ }
629
+ };
630
+ const blob = new Blob([workletCode], { type: "application/javascript" });
631
+ const url = URL.createObjectURL(blob);
632
+ workletUrlRef.current = url;
633
+ await audioContext.audioWorklet.addModule(url);
634
+ const workletNode = new AudioWorkletNode(audioContext, "capture-processor");
635
+ workletNodeRef.current = workletNode;
636
+ workletNode.port.onmessage = (event) => {
637
+ const inputData = event.data;
638
+ if (!chunkDurationMsRef.current) {
639
+ chunkDurationMsRef.current = inputData.length / configRef.current.sampleRate * 1e3;
640
+ if (ringRef.current) {
641
+ ringRef.current.chunkDurationMs = chunkDurationMsRef.current;
642
+ }
643
+ console.log("[AudioCapture] chunkDurationMs =", chunkDurationMsRef.current);
644
+ }
645
+ onAudioChunkReceived(inputData);
646
+ };
647
+ source.connect(workletNode);
648
+ workletNode.connect(audioContext.destination);
649
+ stateRef.current.update({ recording: true });
650
+ } catch (ex) {
651
+ await stop();
652
+ throw ex;
653
+ }
654
+ }, []);
655
+ const stop = (0, import_react2.useCallback)(async () => {
656
+ try {
657
+ if (mediaStreamRef.current) {
658
+ mediaStreamRef.current.getTracks().forEach((t) => t.stop());
659
+ }
660
+ if (sourceRef.current) {
661
+ sourceRef.current.disconnect();
662
+ }
663
+ if (workletNodeRef.current) {
664
+ workletNodeRef.current.port.onmessage = null;
665
+ workletNodeRef.current = null;
666
+ }
667
+ if (contextRef.current) {
668
+ await contextRef.current.close();
669
+ }
670
+ sourceRef.current = null;
671
+ contextRef.current = null;
672
+ mediaStreamRef.current = null;
673
+ audioCallbackRef.current = null;
674
+ commitCallbackRef.current = null;
675
+ speechEarlyRef.current = null;
676
+ speechConfirmedRef.current = null;
677
+ chunkDurationMsRef.current = 0;
678
+ ringRef.current = null;
679
+ stateRef.current.update({ recording: false, speaking: false });
680
+ } finally {
681
+ if (workletUrlRef.current) {
682
+ URL.revokeObjectURL(workletUrlRef.current);
683
+ workletUrlRef.current = null;
684
+ }
685
+ }
686
+ }, []);
687
+ const setMuted = (0, import_react2.useCallback)((muted) => {
688
+ if (!stateRef.current.snapshot.muted && muted) {
689
+ if (ringRef.current) {
690
+ ringRef.current.forceResetState();
691
+ }
692
+ }
693
+ if (stateRef.current.snapshot.muted != muted) {
694
+ const speaking = muted ? false : stateRef.current.snapshot.speaking;
695
+ stateRef.current.update({ muted, speaking });
696
+ }
697
+ }, []);
698
+ return {
699
+ start,
700
+ stop,
701
+ configure,
702
+ setMuted,
703
+ subscribeState: stateRef.current.subscribe,
704
+ getState: () => stateRef.current.snapshot
705
+ };
706
+ }
707
+
708
+ // src/constellationProvider.tsx
709
+ var import_jsx_runtime = require("react/jsx-runtime");
710
+ var ConstellationContext = import_react3.default.createContext(null);
711
+ function ConstellationProvider({ config, children }) {
712
+ const clientRef = (0, import_react3.useRef)(null);
713
+ if (!clientRef.current) {
714
+ clientRef.current = new import_constellation_client.WebClient(config);
715
+ }
716
+ const audioPlayback = useAudioPlayback();
717
+ const audioCapture = useAudioCapture();
718
+ const value = (0, import_react3.useMemo)(() => ({
719
+ client: clientRef.current,
720
+ audioPlayback,
721
+ audioCapture
722
+ }), [audioPlayback, audioCapture]);
723
+ (0, import_react3.useEffect)(() => {
724
+ return () => {
725
+ var _a, _b;
726
+ (_b = (_a = clientRef.current) == null ? void 0 : _a.closeStream) == null ? void 0 : _b.call(_a);
727
+ void audioCapture.stop();
728
+ void audioPlayback.release();
729
+ };
730
+ }, [audioCapture, audioPlayback]);
731
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ConstellationContext.Provider, { value, children });
732
+ }
733
+
734
+ // src/useConstellationSession.tsx
735
+ var import_react4 = require("react");
736
+ var import_constellation_client2 = require("@simfinity/constellation-client");
737
+ function handleResponsePacket(event, handlers, enqueueAudio) {
738
+ var _a, _b, _c, _d, _e, _f;
739
+ const data = event.data;
740
+ if (data.audio) {
741
+ if (data.audio.data !== "") {
742
+ enqueueAudio(data.audio.data);
743
+ }
744
+ if (data.audio.done) {
745
+ }
746
+ }
747
+ if (data.transcript) {
748
+ if (data.transcript.input) {
749
+ if (data.transcript.input.done) {
750
+ (_a = handlers.onTranscriptInput) == null ? void 0 : _a.call(handlers, data.transcript.input.text);
751
+ } else {
752
+ (_b = handlers.onTranscriptInputPart) == null ? void 0 : _b.call(handlers, data.transcript.input.text, data.transcript.input.done);
753
+ }
754
+ }
755
+ if (data.transcript.response) {
756
+ if (data.transcript.response.done) {
757
+ (_c = handlers.onTranscriptResponse) == null ? void 0 : _c.call(handlers, data.transcript.response.text);
758
+ } else {
759
+ (_d = handlers.onTranscriptResponsePart) == null ? void 0 : _d.call(handlers, data.transcript.response.text, data.transcript.response.done);
760
+ }
761
+ }
762
+ }
763
+ if (data.done) {
764
+ (_f = handlers.onResponseEnd) == null ? void 0 : _f.call(handlers, (_e = data.canceled) != null ? _e : false);
765
+ }
766
+ }
767
+ function useConstellationSession() {
768
+ const ctx = (0, import_react4.useContext)(ConstellationContext);
769
+ if (!ctx) throw new Error("Must be used inside ConstellationProvider");
770
+ const [sessionId, setSessionId] = (0, import_react4.useState)(null);
771
+ const unsubscribersRef = (0, import_react4.useRef)([]);
772
+ const clearEventSubscriptions = (0, import_react4.useCallback)(() => {
773
+ for (const unsubscribe of unsubscribersRef.current) {
774
+ unsubscribe();
775
+ }
776
+ unsubscribersRef.current = [];
777
+ }, []);
778
+ (0, import_react4.useEffect)(() => {
779
+ return () => clearEventSubscriptions();
780
+ }, [clearEventSubscriptions]);
781
+ return (0, import_react4.useMemo)(() => ({
782
+ startSession: async (params) => {
783
+ await ctx.client.startSession(params);
784
+ setSessionId(ctx.client.session);
785
+ },
786
+ endSession: async () => {
787
+ clearEventSubscriptions();
788
+ const stats = await ctx.client.endSession();
789
+ setSessionId(null);
790
+ ctx.audioCapture.stop();
791
+ ctx.audioPlayback.release();
792
+ return stats;
793
+ },
794
+ joinSession: async (audio, handlers) => {
795
+ clearEventSubscriptions();
796
+ unsubscribersRef.current.push(
797
+ ctx.client.on(import_constellation_client2.WebClientEvents.SystemConnectionClosed, (e) => {
798
+ var _a;
799
+ handlers.onStreamClosed((_a = e.data.reason) != null ? _a : "");
800
+ })
801
+ );
802
+ unsubscribersRef.current.push(
803
+ ctx.client.on(import_constellation_client2.WebClientEvents.ResponsePacket, (e) => {
804
+ handleResponsePacket(e, handlers, ctx.audioPlayback.enqueue);
805
+ })
806
+ );
807
+ if (handlers.onLatencyUpdate) {
808
+ unsubscribersRef.current.push(
809
+ ctx.client.on(import_constellation_client2.WebClientEvents.SystemLatency, (e) => {
810
+ var _a;
811
+ (_a = handlers.onLatencyUpdate) == null ? void 0 : _a.call(handlers, e.data.latencyMs);
812
+ })
813
+ );
814
+ }
815
+ if (handlers.onSessionConfigured) {
816
+ unsubscribersRef.current.push(
817
+ ctx.client.on(import_constellation_client2.WebClientEvents.SessionConfigured, (e) => {
818
+ var _a;
819
+ (_a = handlers.onSessionConfigured) == null ? void 0 : _a.call(handlers, e.data);
820
+ })
821
+ );
822
+ }
823
+ if (handlers.onAgentResponse) {
824
+ unsubscribersRef.current.push(
825
+ ctx.client.on(import_constellation_client2.WebClientEvents.AgentResponse, (e) => {
826
+ var _a;
827
+ (_a = handlers.onAgentResponse) == null ? void 0 : _a.call(handlers, e.data);
828
+ })
829
+ );
830
+ }
831
+ if (handlers.onClientAction) {
832
+ unsubscribersRef.current.push(
833
+ ctx.client.on(import_constellation_client2.WebClientEvents.ClientAction, (e) => {
834
+ var _a;
835
+ (_a = handlers.onClientAction) == null ? void 0 : _a.call(handlers, e.data.action, e.data.payload);
836
+ })
837
+ );
838
+ }
839
+ if (handlers.onTechnicalError) {
840
+ unsubscribersRef.current.push(
841
+ ctx.client.on(import_constellation_client2.WebClientEvents.TechnicalError, (e) => {
842
+ var _a;
843
+ (_a = handlers.onTechnicalError) == null ? void 0 : _a.call(handlers, e.data.error);
844
+ })
845
+ );
846
+ }
847
+ try {
848
+ await ctx.client.joinSession(
849
+ audio
850
+ /*, { ...handlers, onAudioResponseChunk: (chunk: string) => { ctx.audioPlayback.enqueue(chunk); }}*/
851
+ );
852
+ } catch (ex) {
853
+ clearEventSubscriptions();
854
+ throw ex;
855
+ }
856
+ },
857
+ configureSession: (settings) => {
858
+ ctx.client.configureSession(settings);
859
+ },
860
+ sendText: (text) => {
861
+ ctx.client.sendText(text);
862
+ },
863
+ sendWhisper: (text) => {
864
+ ctx.client.sendWhisper(text);
865
+ },
866
+ audioDevice: () => ({
867
+ in: {
868
+ start: async () => {
869
+ const captureParams = {
870
+ onSpeechConsidered: () => {
871
+ },
872
+ onSpeechConfirmed: () => ctx.audioPlayback.clearQueue(),
873
+ onAudioChunk: (chunk) => ctx.client.sendAudioChunk(chunk),
874
+ onSilenceCommit: () => ctx.client.commitAudioChunks()
875
+ };
876
+ await ctx.audioCapture.start(captureParams);
877
+ },
878
+ stop: async () => {
879
+ await ctx.audioCapture.stop();
880
+ },
881
+ setMuted: (muted) => {
882
+ ctx.audioCapture.setMuted(muted);
883
+ }
884
+ },
885
+ out: {}
886
+ }),
887
+ sessionId
888
+ }), [ctx, sessionId]);
889
+ }
890
+
891
+ // src/useAudioPlaybackState.tsx
892
+ var import_react5 = require("react");
893
+ function useAudioPlaybackState(selector) {
894
+ const ctx = (0, import_react5.useContext)(ConstellationContext);
895
+ if (!ctx) throw new Error("Must be used inside ConstellationProvider");
896
+ return (0, import_react5.useSyncExternalStore)(
897
+ ctx.audioPlayback.subscribeState,
898
+ () => selector(ctx.audioPlayback.getState())
899
+ );
900
+ }
901
+
902
+ // src/useAudioCaptureState.tsx
903
+ var import_react6 = require("react");
904
+ function useAudioCaptureState(selector) {
905
+ const ctx = (0, import_react6.useContext)(ConstellationContext);
906
+ if (!ctx) throw new Error("Must be used inside ConstellationProvider");
907
+ return (0, import_react6.useSyncExternalStore)(
908
+ ctx.audioCapture.subscribeState,
909
+ () => selector(ctx.audioCapture.getState())
910
+ );
911
+ }
912
+ // Annotate the CommonJS export names for ESM import in node:
913
+ 0 && (module.exports = {
914
+ AudioCaptureConfig,
915
+ ConstellationProvider,
916
+ useAudioCaptureState,
917
+ useAudioPlaybackState,
918
+ useConstellationSession
919
+ });