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