@absolutejs/voice 0.0.16 → 0.0.18

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.
@@ -0,0 +1,888 @@
1
+ // src/client/actions.ts
2
+ var normalizeErrorMessage = (value) => {
3
+ if (typeof value === "string" && value.trim()) {
4
+ return value;
5
+ }
6
+ if (value instanceof Error && value.message.trim()) {
7
+ return value.message;
8
+ }
9
+ if (value && typeof value === "object") {
10
+ const record = value;
11
+ for (const key of ["message", "reason", "description"]) {
12
+ const candidate = record[key];
13
+ if (typeof candidate === "string" && candidate.trim()) {
14
+ return candidate;
15
+ }
16
+ }
17
+ if ("error" in record) {
18
+ return normalizeErrorMessage(record.error);
19
+ }
20
+ if ("cause" in record) {
21
+ return normalizeErrorMessage(record.cause);
22
+ }
23
+ try {
24
+ return JSON.stringify(value);
25
+ } catch {}
26
+ }
27
+ return "Unexpected error";
28
+ };
29
+ var serverMessageToAction = (message) => {
30
+ switch (message.type) {
31
+ case "assistant":
32
+ return {
33
+ text: message.text,
34
+ type: "assistant"
35
+ };
36
+ case "complete":
37
+ return {
38
+ sessionId: message.sessionId,
39
+ type: "complete"
40
+ };
41
+ case "error":
42
+ return {
43
+ message: normalizeErrorMessage(message.message),
44
+ type: "error"
45
+ };
46
+ case "final":
47
+ return {
48
+ transcript: message.transcript,
49
+ type: "final"
50
+ };
51
+ case "partial":
52
+ return {
53
+ transcript: message.transcript,
54
+ type: "partial"
55
+ };
56
+ case "session":
57
+ return {
58
+ sessionId: message.sessionId,
59
+ status: message.status,
60
+ type: "session"
61
+ };
62
+ case "turn":
63
+ return {
64
+ turn: message.turn,
65
+ type: "turn"
66
+ };
67
+ default:
68
+ return null;
69
+ }
70
+ };
71
+
72
+ // src/client/connection.ts
73
+ var WS_OPEN = 1;
74
+ var WS_CLOSED = 3;
75
+ var WS_NORMAL_CLOSURE = 1000;
76
+ var DEFAULT_MAX_RECONNECT_ATTEMPTS = 10;
77
+ var DEFAULT_PING_INTERVAL = 30000;
78
+ var RECONNECT_DELAY_MS = 500;
79
+ var noop = () => {};
80
+ var noopUnsubscribe = () => noop;
81
+ var NOOP_CONNECTION = {
82
+ close: noop,
83
+ endTurn: noop,
84
+ getReadyState: () => WS_CLOSED,
85
+ getSessionId: () => "",
86
+ send: noop,
87
+ sendAudio: noop,
88
+ subscribe: noopUnsubscribe
89
+ };
90
+ var createSessionId = () => crypto.randomUUID();
91
+ var buildWsUrl = (path, sessionId) => {
92
+ const { hostname, port, protocol } = window.location;
93
+ const wsProtocol = protocol === "https:" ? "wss:" : "ws:";
94
+ const portSuffix = port ? `:${port}` : "";
95
+ const url = new URL(`${wsProtocol}//${hostname}${portSuffix}${path}`);
96
+ url.searchParams.set("sessionId", sessionId);
97
+ return url.toString();
98
+ };
99
+ var isVoiceServerMessage = (value) => {
100
+ if (!value || typeof value !== "object" || !("type" in value)) {
101
+ return false;
102
+ }
103
+ switch (value.type) {
104
+ case "assistant":
105
+ case "complete":
106
+ case "error":
107
+ case "final":
108
+ case "partial":
109
+ case "pong":
110
+ case "session":
111
+ case "turn":
112
+ return true;
113
+ default:
114
+ return false;
115
+ }
116
+ };
117
+ var parseServerMessage = (event) => {
118
+ if (typeof event.data !== "string") {
119
+ return null;
120
+ }
121
+ try {
122
+ const parsed = JSON.parse(event.data);
123
+ return isVoiceServerMessage(parsed) ? parsed : null;
124
+ } catch {
125
+ return null;
126
+ }
127
+ };
128
+ var createVoiceConnection = (path, options = {}) => {
129
+ if (typeof window === "undefined") {
130
+ return NOOP_CONNECTION;
131
+ }
132
+ const listeners = new Set;
133
+ const shouldReconnect = options.reconnect !== false;
134
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? DEFAULT_MAX_RECONNECT_ATTEMPTS;
135
+ const pingInterval = options.pingInterval ?? DEFAULT_PING_INTERVAL;
136
+ const state = {
137
+ isConnected: false,
138
+ pendingMessages: [],
139
+ pingInterval: null,
140
+ reconnectAttempts: 0,
141
+ reconnectTimeout: null,
142
+ sessionId: options.sessionId ?? createSessionId(),
143
+ ws: null
144
+ };
145
+ const clearTimers = () => {
146
+ if (state.pingInterval) {
147
+ clearInterval(state.pingInterval);
148
+ state.pingInterval = null;
149
+ }
150
+ if (state.reconnectTimeout) {
151
+ clearTimeout(state.reconnectTimeout);
152
+ state.reconnectTimeout = null;
153
+ }
154
+ };
155
+ const flushPendingMessages = () => {
156
+ if (state.ws?.readyState !== WS_OPEN) {
157
+ return;
158
+ }
159
+ while (state.pendingMessages.length > 0) {
160
+ const next = state.pendingMessages.shift();
161
+ if (next !== undefined) {
162
+ state.ws.send(next);
163
+ }
164
+ }
165
+ };
166
+ const scheduleReconnect = () => {
167
+ state.reconnectAttempts += 1;
168
+ state.reconnectTimeout = setTimeout(() => {
169
+ if (state.reconnectAttempts > maxReconnectAttempts) {
170
+ return;
171
+ }
172
+ connect();
173
+ }, RECONNECT_DELAY_MS);
174
+ };
175
+ const connect = () => {
176
+ const ws = new WebSocket(buildWsUrl(path, state.sessionId));
177
+ ws.binaryType = "arraybuffer";
178
+ ws.onopen = () => {
179
+ state.isConnected = true;
180
+ state.reconnectAttempts = 0;
181
+ flushPendingMessages();
182
+ listeners.forEach((listener) => listener({
183
+ sessionId: state.sessionId,
184
+ status: "active",
185
+ type: "session"
186
+ }));
187
+ state.pingInterval = setInterval(() => {
188
+ if (ws.readyState === WS_OPEN) {
189
+ ws.send(JSON.stringify({ type: "ping" }));
190
+ }
191
+ }, pingInterval);
192
+ };
193
+ ws.onmessage = (event) => {
194
+ const parsed = parseServerMessage(event);
195
+ if (!parsed) {
196
+ return;
197
+ }
198
+ if (parsed.type === "session") {
199
+ state.sessionId = parsed.sessionId;
200
+ }
201
+ listeners.forEach((listener) => listener(parsed));
202
+ };
203
+ ws.onclose = (event) => {
204
+ state.isConnected = false;
205
+ clearTimers();
206
+ const reconnectable = shouldReconnect && event.code !== WS_NORMAL_CLOSURE && state.reconnectAttempts < maxReconnectAttempts;
207
+ if (reconnectable) {
208
+ scheduleReconnect();
209
+ }
210
+ };
211
+ state.ws = ws;
212
+ };
213
+ const sendSerialized = (value) => {
214
+ if (state.ws?.readyState === WS_OPEN) {
215
+ state.ws.send(value);
216
+ return;
217
+ }
218
+ state.pendingMessages.push(value);
219
+ };
220
+ const send = (message) => {
221
+ sendSerialized(JSON.stringify(message));
222
+ };
223
+ const sendAudio = (audio) => {
224
+ sendSerialized(audio);
225
+ };
226
+ const endTurn = () => {
227
+ send({ type: "end_turn" });
228
+ };
229
+ const close = () => {
230
+ clearTimers();
231
+ if (state.ws) {
232
+ state.ws.close(WS_NORMAL_CLOSURE);
233
+ state.ws = null;
234
+ }
235
+ state.isConnected = false;
236
+ listeners.clear();
237
+ };
238
+ const subscribe = (callback) => {
239
+ listeners.add(callback);
240
+ return () => {
241
+ listeners.delete(callback);
242
+ };
243
+ };
244
+ connect();
245
+ return {
246
+ close,
247
+ endTurn,
248
+ getReadyState: () => state.ws?.readyState ?? WS_CLOSED,
249
+ getSessionId: () => state.sessionId,
250
+ send,
251
+ sendAudio,
252
+ subscribe
253
+ };
254
+ };
255
+
256
+ // src/client/store.ts
257
+ var createInitialState = () => ({
258
+ assistantTexts: [],
259
+ error: null,
260
+ isConnected: false,
261
+ partial: "",
262
+ sessionId: null,
263
+ status: "idle",
264
+ turns: []
265
+ });
266
+ var createVoiceStreamStore = () => {
267
+ let state = createInitialState();
268
+ const subscribers = new Set;
269
+ const notify = () => {
270
+ subscribers.forEach((subscriber) => subscriber());
271
+ };
272
+ const dispatch = (action) => {
273
+ switch (action.type) {
274
+ case "assistant":
275
+ state = {
276
+ ...state,
277
+ assistantTexts: [...state.assistantTexts, action.text]
278
+ };
279
+ break;
280
+ case "complete":
281
+ state = {
282
+ ...state,
283
+ sessionId: action.sessionId,
284
+ status: "completed"
285
+ };
286
+ break;
287
+ case "connected":
288
+ state = {
289
+ ...state,
290
+ isConnected: true
291
+ };
292
+ break;
293
+ case "disconnected":
294
+ state = {
295
+ ...state,
296
+ isConnected: false
297
+ };
298
+ break;
299
+ case "error":
300
+ state = {
301
+ ...state,
302
+ error: action.message
303
+ };
304
+ break;
305
+ case "final":
306
+ state = {
307
+ ...state,
308
+ partial: action.transcript.text,
309
+ turns: state.turns.map((turn) => turn)
310
+ };
311
+ break;
312
+ case "partial":
313
+ state = {
314
+ ...state,
315
+ partial: action.transcript.text
316
+ };
317
+ break;
318
+ case "session":
319
+ state = {
320
+ ...state,
321
+ error: null,
322
+ isConnected: action.status === "active",
323
+ sessionId: action.sessionId,
324
+ status: action.status
325
+ };
326
+ break;
327
+ case "turn":
328
+ state = {
329
+ ...state,
330
+ partial: "",
331
+ turns: [...state.turns, action.turn]
332
+ };
333
+ break;
334
+ }
335
+ notify();
336
+ };
337
+ return {
338
+ dispatch,
339
+ getServerSnapshot: () => state,
340
+ getSnapshot: () => state,
341
+ subscribe: (subscriber) => {
342
+ subscribers.add(subscriber);
343
+ return () => {
344
+ subscribers.delete(subscriber);
345
+ };
346
+ }
347
+ };
348
+ };
349
+
350
+ // src/client/createVoiceStream.ts
351
+ var createVoiceStream = (path, options = {}) => {
352
+ const connection = createVoiceConnection(path, options);
353
+ const store = createVoiceStreamStore();
354
+ const subscribers = new Set;
355
+ const notify = () => {
356
+ subscribers.forEach((subscriber) => subscriber());
357
+ };
358
+ const unsubscribeConnection = connection.subscribe((message) => {
359
+ const action = serverMessageToAction(message);
360
+ if (action) {
361
+ store.dispatch(action);
362
+ notify();
363
+ }
364
+ });
365
+ return {
366
+ close() {
367
+ unsubscribeConnection();
368
+ connection.close();
369
+ store.dispatch({ type: "disconnected" });
370
+ notify();
371
+ },
372
+ endTurn() {
373
+ connection.endTurn();
374
+ },
375
+ get error() {
376
+ return store.getSnapshot().error;
377
+ },
378
+ getServerSnapshot() {
379
+ return store.getServerSnapshot();
380
+ },
381
+ getSnapshot() {
382
+ return store.getSnapshot();
383
+ },
384
+ get isConnected() {
385
+ return store.getSnapshot().isConnected;
386
+ },
387
+ get partial() {
388
+ return store.getSnapshot().partial;
389
+ },
390
+ get sessionId() {
391
+ return connection.getSessionId();
392
+ },
393
+ get status() {
394
+ return store.getSnapshot().status;
395
+ },
396
+ get turns() {
397
+ return store.getSnapshot().turns;
398
+ },
399
+ get assistantTexts() {
400
+ return store.getSnapshot().assistantTexts;
401
+ },
402
+ sendAudio(audio) {
403
+ connection.sendAudio(audio);
404
+ },
405
+ subscribe(subscriber) {
406
+ subscribers.add(subscriber);
407
+ return () => {
408
+ subscribers.delete(subscriber);
409
+ };
410
+ }
411
+ };
412
+ };
413
+
414
+ // src/client/htmx.ts
415
+ var DEFAULT_EVENT_NAME = "voice-refresh";
416
+ var DEFAULT_QUERY_PARAM = "sessionId";
417
+ var resolveElement = (input) => {
418
+ if (typeof input !== "string") {
419
+ return input;
420
+ }
421
+ return document.querySelector(input);
422
+ };
423
+ var buildRoute = (element, route, queryParam, sessionId) => {
424
+ const baseRoute = route ?? element.getAttribute("hx-get") ?? "";
425
+ if (!baseRoute) {
426
+ return "";
427
+ }
428
+ const url = new URL(baseRoute, window.location.origin);
429
+ if (sessionId) {
430
+ url.searchParams.set(queryParam, sessionId);
431
+ } else {
432
+ url.searchParams.delete(queryParam);
433
+ }
434
+ return `${url.pathname}${url.search}${url.hash}`;
435
+ };
436
+ var bindVoiceHTMX = (stream, options) => {
437
+ if (typeof window === "undefined" || typeof document === "undefined") {
438
+ return () => {};
439
+ }
440
+ const element = resolveElement(options.element);
441
+ if (!element) {
442
+ return () => {};
443
+ }
444
+ const eventName = options.eventName ?? DEFAULT_EVENT_NAME;
445
+ const queryParam = options.sessionQueryParam ?? DEFAULT_QUERY_PARAM;
446
+ const sync = () => {
447
+ const htmxWindow = window;
448
+ const nextRoute = buildRoute(element, options.route, queryParam, stream.sessionId);
449
+ if (nextRoute) {
450
+ element.setAttribute("hx-get", nextRoute);
451
+ }
452
+ htmxWindow.htmx?.process?.(element);
453
+ htmxWindow.htmx?.trigger?.(element, eventName);
454
+ };
455
+ const unsubscribe = stream.subscribe(sync);
456
+ sync();
457
+ return () => {
458
+ unsubscribe();
459
+ };
460
+ };
461
+
462
+ // src/client/microphone.ts
463
+ var clampSample = (value) => Math.max(-1, Math.min(1, value));
464
+ var floatTo16BitPCM = (input) => {
465
+ const output = new Int16Array(input.length);
466
+ for (let index = 0;index < input.length; index += 1) {
467
+ const sample = clampSample(input[index] ?? 0);
468
+ output[index] = sample < 0 ? sample * 32768 : sample * 32767;
469
+ }
470
+ return new Uint8Array(output.buffer);
471
+ };
472
+ var downsampleBuffer = (input, sourceRate, targetRate) => {
473
+ if (sourceRate === targetRate) {
474
+ return input;
475
+ }
476
+ const ratio = sourceRate / targetRate;
477
+ const length = Math.round(input.length / ratio);
478
+ const output = new Float32Array(length);
479
+ let offsetResult = 0;
480
+ let offsetBuffer = 0;
481
+ while (offsetResult < output.length) {
482
+ const nextOffsetBuffer = Math.round((offsetResult + 1) * ratio);
483
+ let accum = 0;
484
+ let count = 0;
485
+ for (let index = offsetBuffer;index < nextOffsetBuffer && index < input.length; index += 1) {
486
+ accum += input[index] ?? 0;
487
+ count += 1;
488
+ }
489
+ output[offsetResult] = count > 0 ? accum / count : 0;
490
+ offsetResult += 1;
491
+ offsetBuffer = nextOffsetBuffer;
492
+ }
493
+ return output;
494
+ };
495
+ var createMicrophoneCapture = (options) => {
496
+ let audioContext = null;
497
+ let sourceNode = null;
498
+ let processorNode = null;
499
+ let mediaStream = null;
500
+ const start = async () => {
501
+ if (typeof navigator === "undefined" || !navigator.mediaDevices?.getUserMedia) {
502
+ throw new Error("Browser microphone capture requires navigator.mediaDevices.getUserMedia.");
503
+ }
504
+ const AudioContextCtor = (typeof window !== "undefined" ? window.AudioContext ?? window.webkitAudioContext : undefined) ?? AudioContext;
505
+ if (!AudioContextCtor) {
506
+ throw new Error("Browser microphone capture requires AudioContext support.");
507
+ }
508
+ mediaStream = await navigator.mediaDevices.getUserMedia({
509
+ audio: {
510
+ channelCount: options.channelCount ?? 1
511
+ }
512
+ });
513
+ audioContext = new AudioContextCtor;
514
+ sourceNode = audioContext.createMediaStreamSource(mediaStream);
515
+ processorNode = audioContext.createScriptProcessor(4096, 1, 1);
516
+ processorNode.onaudioprocess = (event) => {
517
+ const channel = event.inputBuffer.getChannelData(0);
518
+ const downsampled = downsampleBuffer(channel, audioContext?.sampleRate ?? 48000, options.sampleRateHz ?? 16000);
519
+ options.onAudio(floatTo16BitPCM(downsampled));
520
+ };
521
+ sourceNode.connect(processorNode);
522
+ processorNode.connect(audioContext.destination);
523
+ };
524
+ const stop = () => {
525
+ processorNode?.disconnect();
526
+ sourceNode?.disconnect();
527
+ mediaStream?.getTracks().forEach((track) => track.stop());
528
+ audioContext?.close();
529
+ audioContext = null;
530
+ mediaStream = null;
531
+ processorNode = null;
532
+ sourceNode = null;
533
+ };
534
+ return { start, stop };
535
+ };
536
+
537
+ // src/client/htmxBootstrap.ts
538
+ var VOICE_WAVE_POINTS = 48;
539
+ var VOICE_WAVE_WIDTH = 320;
540
+ var VOICE_WAVE_HEIGHT = 88;
541
+ var DEFAULT_GUIDED_LABEL = "Guided test";
542
+ var DEFAULT_GENERAL_LABEL = "General recording";
543
+ var DEFAULT_IDLE_LEAD = "Pick a mode to begin the demo.";
544
+ var DEFAULT_GUIDED_LEAD = "I can walk you through a short guided voice test.";
545
+ var DEFAULT_GENERAL_LEAD = "I can capture one freeform recording and confirm that it landed.";
546
+ var DEFAULT_IDLE_PROMPT = "Choose a mode to begin. Guided test asks follow-up prompts. General recording just captures what you say.";
547
+ var DEFAULT_GENERAL_IDLE_PROMPT = "Click Start general recording to capture one freeform answer.";
548
+ var DEFAULT_GENERAL_LIVE_PROMPT = "Speak freely. When you pause, the recording will be captured.";
549
+ var DEFAULT_GENERAL_COMPLETE_PROMPT = "Recording saved. Start again if you want another capture.";
550
+ var DEFAULT_GUIDED_COMPLETE_PROMPT = "Guided test complete. Review the saved summary below.";
551
+ var DEFAULT_GUIDED_OVERFLOW_PROMPT = "All prompts are covered. You can stop the microphone or keep speaking for extra detail.";
552
+ var DEFAULT_MIC_IDLE = "Ready. Start guided test or general recording to begin.";
553
+ var DEFAULT_MIC_LIVE = "Live. Answer the prompt, then click Stop microphone when finished.";
554
+ var DEFAULT_GUIDED_PROMPTS = [
555
+ "Start with a quick introduction about who you are.",
556
+ "Now describe what you are trying to do or test.",
557
+ "Finish with any detail that feels blocked, risky, or unclear."
558
+ ];
559
+ var clamp = (value, min, max) => Math.min(max, Math.max(min, value));
560
+ var escapeHtml = (value) => value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
561
+ var readErrorField = (value, key) => {
562
+ const candidate = value[key];
563
+ if (typeof candidate === "string" && candidate.trim()) {
564
+ return candidate;
565
+ }
566
+ return null;
567
+ };
568
+ var formatErrorMessage = (error) => {
569
+ if (typeof error === "string" && error.trim()) {
570
+ return error;
571
+ }
572
+ if (error instanceof Error && error.message.trim()) {
573
+ return error.message;
574
+ }
575
+ if (error && typeof error === "object") {
576
+ const record = error;
577
+ const direct = readErrorField(record, "message") ?? readErrorField(record, "reason") ?? readErrorField(record, "description");
578
+ if (direct) {
579
+ return direct;
580
+ }
581
+ if ("error" in record) {
582
+ return formatErrorMessage(record.error);
583
+ }
584
+ if ("cause" in record) {
585
+ return formatErrorMessage(record.cause);
586
+ }
587
+ try {
588
+ return JSON.stringify(error);
589
+ } catch {}
590
+ }
591
+ return "Unexpected error";
592
+ };
593
+ var createInitialVoiceWaveLevels = (count = VOICE_WAVE_POINTS) => Array.from({ length: count }, () => 0);
594
+ var pushVoiceWaveLevel = (levels, nextLevel, count = VOICE_WAVE_POINTS) => {
595
+ const next = levels.slice(-(count - 1));
596
+ next.push(clamp(nextLevel, 0, 1));
597
+ while (next.length < count) {
598
+ next.unshift(0);
599
+ }
600
+ return next;
601
+ };
602
+ var createVoiceWavePath = (levels, width = VOICE_WAVE_WIDTH, height = VOICE_WAVE_HEIGHT) => {
603
+ const samples = levels.length > 1 ? levels : createInitialVoiceWaveLevels(VOICE_WAVE_POINTS);
604
+ const step = width / (samples.length - 1);
605
+ const center = height / 2;
606
+ const maxAmplitude = height * 0.34;
607
+ const peakLevel = Math.max(...samples, 0);
608
+ if (peakLevel <= 0.015) {
609
+ return `M 0 ${center} L ${width} ${center}`;
610
+ }
611
+ const points = samples.map((level, index) => {
612
+ const phase = index * 0.76;
613
+ const wobble = Math.sin(phase) * 0.78 + Math.sin(phase * 0.41) * 0.22;
614
+ const amplitude = level * maxAmplitude;
615
+ const x = step * index;
616
+ const y = clamp(center + wobble * amplitude, 8, height - 8);
617
+ return { x, y };
618
+ });
619
+ if (points.length === 0) {
620
+ return `M 0 ${center} L ${width} ${center}`;
621
+ }
622
+ let path = `M ${points[0]?.x ?? 0} ${points[0]?.y ?? center}`;
623
+ for (let index = 1;index < points.length; index += 1) {
624
+ const previous = points[index - 1];
625
+ const current = points[index];
626
+ if (!previous || !current) {
627
+ continue;
628
+ }
629
+ const controlX = (previous.x + current.x) / 2;
630
+ path += ` Q ${controlX} ${previous.y} ${current.x} ${current.y}`;
631
+ }
632
+ return path;
633
+ };
634
+ var getPcmLevel = (audio) => {
635
+ const bytes = audio instanceof Uint8Array ? audio : new Uint8Array(audio);
636
+ if (bytes.byteLength < 2) {
637
+ return 0;
638
+ }
639
+ const samples = new Int16Array(bytes.buffer, bytes.byteOffset, Math.floor(bytes.byteLength / 2));
640
+ if (samples.length === 0) {
641
+ return 0;
642
+ }
643
+ let sumSquares = 0;
644
+ for (const sample of samples) {
645
+ const normalized = sample / 32768;
646
+ sumSquares += normalized * normalized;
647
+ }
648
+ const rms = Math.sqrt(sumSquares / samples.length);
649
+ return clamp(rms * 5.5, 0, 1);
650
+ };
651
+ var parsePromptList = (value) => {
652
+ if (!value) {
653
+ return DEFAULT_GUIDED_PROMPTS;
654
+ }
655
+ try {
656
+ const parsed = JSON.parse(value);
657
+ if (Array.isArray(parsed)) {
658
+ const prompts = parsed.filter((entry) => typeof entry === "string").map((entry) => entry.trim()).filter(Boolean);
659
+ if (prompts.length > 0) {
660
+ return prompts;
661
+ }
662
+ }
663
+ } catch {}
664
+ return DEFAULT_GUIDED_PROMPTS;
665
+ };
666
+ var requireElement = (root, selector, ctor, name) => {
667
+ const value = selector ? document.querySelector(selector) : null;
668
+ if (value instanceof ctor) {
669
+ return value;
670
+ }
671
+ const fallback = root.querySelector(`#${name}`);
672
+ if (fallback instanceof ctor) {
673
+ return fallback;
674
+ }
675
+ throw new Error(`Voice HTMX bootstrap could not find the required element "${name}".`);
676
+ };
677
+ var resolveLeadMessage = (input) => {
678
+ if (!input.mode) {
679
+ return DEFAULT_IDLE_LEAD;
680
+ }
681
+ if (!input.hasStarted) {
682
+ return input.mode === "guided" ? DEFAULT_GUIDED_LEAD : DEFAULT_GENERAL_LEAD;
683
+ }
684
+ if (input.status === "completed") {
685
+ return input.mode === "guided" ? DEFAULT_GUIDED_COMPLETE_PROMPT : DEFAULT_GENERAL_COMPLETE_PROMPT;
686
+ }
687
+ if (input.mode === "general") {
688
+ return DEFAULT_GENERAL_LIVE_PROMPT;
689
+ }
690
+ return input.guidedPrompts[input.turnCount] ?? DEFAULT_GUIDED_OVERFLOW_PROMPT;
691
+ };
692
+ var resolvePromptMessage = (input) => {
693
+ if (!input.mode) {
694
+ return DEFAULT_IDLE_PROMPT;
695
+ }
696
+ if (input.status === "completed") {
697
+ return input.mode === "guided" ? DEFAULT_GUIDED_COMPLETE_PROMPT : DEFAULT_GENERAL_COMPLETE_PROMPT;
698
+ }
699
+ if (!input.hasStarted) {
700
+ return input.mode === "guided" ? `Click Start guided test to begin. First prompt: ${input.guidedPrompts[0] ?? "Answer the first prompt."}` : DEFAULT_GENERAL_IDLE_PROMPT;
701
+ }
702
+ if (input.mode === "general") {
703
+ return input.turnCount === 0 ? DEFAULT_GENERAL_LIVE_PROMPT : DEFAULT_GENERAL_COMPLETE_PROMPT;
704
+ }
705
+ return input.guidedPrompts[input.turnCount] ?? DEFAULT_GUIDED_OVERFLOW_PROMPT;
706
+ };
707
+ var createDemoMicrophone = (onAudio, onLevel) => {
708
+ let capture = null;
709
+ return {
710
+ start: async () => {
711
+ if (capture) {
712
+ return;
713
+ }
714
+ const nextCapture = createMicrophoneCapture({
715
+ onAudio: (audio) => {
716
+ onLevel(getPcmLevel(audio));
717
+ onAudio(audio);
718
+ },
719
+ sampleRateHz: 16000
720
+ });
721
+ capture = nextCapture;
722
+ try {
723
+ await capture.start();
724
+ } catch (error) {
725
+ capture = null;
726
+ throw error;
727
+ }
728
+ },
729
+ stop: () => {
730
+ capture?.stop();
731
+ capture = null;
732
+ onLevel(0);
733
+ }
734
+ };
735
+ };
736
+ var initVoiceHTMXRoot = (root) => {
737
+ const guidedPath = root.dataset.voiceGuidedPath;
738
+ const generalPath = root.dataset.voiceGeneralPath;
739
+ if (!guidedPath || !generalPath) {
740
+ throw new Error("Voice HTMX bootstrap requires data-voice-guided-path and data-voice-general-path.");
741
+ }
742
+ const guidedPrompts = parsePromptList(root.dataset.voiceGuidedPrompts);
743
+ const guidedLabel = root.dataset.voiceGuidedLabel ?? DEFAULT_GUIDED_LABEL;
744
+ const generalLabel = root.dataset.voiceGeneralLabel ?? DEFAULT_GENERAL_LABEL;
745
+ const syncElement = requireElement(document, root.dataset.voiceSync, HTMLElement, "voice-htmx-sync");
746
+ const connectionMetric = requireElement(root, root.dataset.voiceConnection, HTMLElement, "metric-connection");
747
+ const errorStatus = requireElement(root, root.dataset.voiceError, HTMLElement, "status-error");
748
+ const microphoneStatus = requireElement(root, root.dataset.voiceMicrophone, HTMLElement, "status-mic");
749
+ const promptStatus = requireElement(root, root.dataset.voicePrompt, HTMLElement, "status-prompt");
750
+ const chatList = requireElement(root, root.dataset.voiceChat, HTMLElement, "chat-list");
751
+ const startGuidedButton = requireElement(root, root.dataset.voiceStartGuided, HTMLButtonElement, "start-guided");
752
+ const startGeneralButton = requireElement(root, root.dataset.voiceStartGeneral, HTMLButtonElement, "start-general");
753
+ const stopButton = requireElement(root, root.dataset.voiceStop, HTMLButtonElement, "stop-mic");
754
+ const voiceMonitor = requireElement(root, root.dataset.voiceMonitor, HTMLElement, "voice-monitor");
755
+ const voiceMonitorCopy = requireElement(root, root.dataset.voiceMonitorCopy, HTMLElement, "voice-monitor-copy");
756
+ const voiceWaveGlow = requireElement(root, root.dataset.voiceWaveGlow, SVGPathElement, "voice-wave-glow");
757
+ const voiceWavePath = requireElement(root, root.dataset.voiceWavePath, SVGPathElement, "voice-wave-path");
758
+ const guidedVoice = createVoiceStream(guidedPath);
759
+ const generalVoice = createVoiceStream(generalPath);
760
+ const stopGuidedBinding = bindVoiceHTMX(guidedVoice, { element: syncElement });
761
+ const stopGeneralBinding = bindVoiceHTMX(generalVoice, {
762
+ element: syncElement
763
+ });
764
+ let activeMode = null;
765
+ let hasStartedModes = {
766
+ general: false,
767
+ guided: false
768
+ };
769
+ let isCapturing = false;
770
+ let micError = null;
771
+ let waveLevels = createInitialVoiceWaveLevels();
772
+ const currentVoice = () => activeMode === "general" ? generalVoice : guidedVoice;
773
+ const renderWave = () => {
774
+ const path = createVoiceWavePath(waveLevels);
775
+ voiceWaveGlow.setAttribute("d", path);
776
+ voiceWavePath.setAttribute("d", path);
777
+ voiceMonitorCopy.innerHTML = `<span class="voice-live-dot"></span>${isCapturing ? "Microphone live" : "Microphone idle"}`;
778
+ voiceMonitorCopy.classList.toggle("is-live", isCapturing);
779
+ voiceMonitor.classList.toggle("is-live", isCapturing);
780
+ };
781
+ const render = () => {
782
+ const voice = currentVoice();
783
+ const hasStarted = (activeMode ? hasStartedModes[activeMode] : false) || voice.turns.length > 0;
784
+ const status = voice.status;
785
+ connectionMetric.textContent = voice.isConnected ? "Connected" : "Waiting";
786
+ errorStatus.textContent = micError || voice.error || "None";
787
+ microphoneStatus.textContent = isCapturing ? DEFAULT_MIC_LIVE : DEFAULT_MIC_IDLE;
788
+ promptStatus.textContent = resolvePromptMessage({
789
+ guidedPrompts,
790
+ hasStarted,
791
+ mode: activeMode,
792
+ status,
793
+ turnCount: voice.turns.length
794
+ });
795
+ startGuidedButton.hidden = isCapturing;
796
+ startGeneralButton.hidden = isCapturing;
797
+ stopButton.hidden = !isCapturing;
798
+ chatList.innerHTML = `<article class="voice-chat-message assistant">
799
+ <div class="voice-chat-role">${escapeHtml(activeMode === "general" ? generalLabel : activeMode === "guided" ? guidedLabel : "Voice demo")}</div>
800
+ <p class="voice-turn-text">${escapeHtml(resolveLeadMessage({
801
+ generalLabel,
802
+ guidedLabel,
803
+ guidedPrompts,
804
+ hasStarted,
805
+ mode: activeMode,
806
+ status,
807
+ turnCount: voice.turns.length
808
+ }))}</p>
809
+ </article>${voice.turns.map((turn) => `<div class="voice-chat-stack">
810
+ <article class="voice-chat-message user">
811
+ <div class="voice-chat-role">You</div>
812
+ <p class="voice-turn-text">${escapeHtml(turn.text)}</p>
813
+ </article>
814
+ ${turn.assistantText ? `<article class="voice-chat-message assistant">
815
+ <div class="voice-chat-role">${escapeHtml(activeMode === "general" ? generalLabel : activeMode === "guided" ? guidedLabel : "Guide")}</div>
816
+ <p class="voice-turn-text">${escapeHtml(turn.assistantText)}</p>
817
+ </article>` : ""}
818
+ </div>`).join("")}${voice.partial ? `<article class="voice-chat-message user pending">
819
+ <div class="voice-chat-role">Speaking</div>
820
+ <p class="voice-turn-text">${escapeHtml(voice.partial)}</p>
821
+ </article>` : ""}`;
822
+ renderWave();
823
+ };
824
+ const microphone = createDemoMicrophone((audio) => currentVoice().sendAudio(audio), (level) => {
825
+ waveLevels = pushVoiceWaveLevel(waveLevels, level);
826
+ renderWave();
827
+ });
828
+ const stopMic = () => {
829
+ microphone.stop();
830
+ isCapturing = false;
831
+ micError = null;
832
+ waveLevels = createInitialVoiceWaveLevels();
833
+ render();
834
+ };
835
+ const startMode = async (mode) => {
836
+ activeMode = mode;
837
+ hasStartedModes = {
838
+ ...hasStartedModes,
839
+ [mode]: true
840
+ };
841
+ try {
842
+ await microphone.start();
843
+ micError = null;
844
+ isCapturing = true;
845
+ render();
846
+ } catch (error) {
847
+ microphone.stop();
848
+ isCapturing = false;
849
+ waveLevels = createInitialVoiceWaveLevels();
850
+ micError = formatErrorMessage(error);
851
+ render();
852
+ }
853
+ };
854
+ guidedVoice.subscribe(render);
855
+ generalVoice.subscribe(render);
856
+ startGuidedButton.addEventListener("click", () => {
857
+ startMode("guided");
858
+ });
859
+ startGeneralButton.addEventListener("click", () => {
860
+ startMode("general");
861
+ });
862
+ stopButton.addEventListener("click", () => {
863
+ stopMic();
864
+ });
865
+ window.addEventListener("beforeunload", () => {
866
+ microphone.stop();
867
+ stopGuidedBinding();
868
+ stopGeneralBinding();
869
+ guidedVoice.close();
870
+ generalVoice.close();
871
+ });
872
+ render();
873
+ };
874
+ var initVoiceHTMX = () => {
875
+ if (typeof window === "undefined" || typeof document === "undefined") {
876
+ return;
877
+ }
878
+ const roots = Array.from(document.querySelectorAll("[data-voice-htmx]"));
879
+ for (const root of roots) {
880
+ if (root instanceof HTMLElement) {
881
+ initVoiceHTMXRoot(root);
882
+ }
883
+ }
884
+ };
885
+ initVoiceHTMX();
886
+ export {
887
+ initVoiceHTMX
888
+ };