gemi 0.132.0 → 0.134.0
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/ai/index.d.ts +2 -2
- package/dist/ai/index.d.ts.map +1 -1
- package/dist/ai/index.js +9 -9
- package/dist/ai/index.js.map +4 -4
- package/dist/ai/providers/realtime.d.ts +75 -4
- package/dist/ai/providers/realtime.d.ts.map +1 -1
- package/dist/ai/providers/sideband.d.ts +7 -2
- package/dist/ai/providers/sideband.d.ts.map +1 -1
- package/dist/ai/voice/energyVad.d.ts +42 -0
- package/dist/ai/voice/energyVad.d.ts.map +1 -0
- package/dist/ai/voice/index.d.ts +7 -0
- package/dist/ai/voice/index.d.ts.map +1 -1
- package/dist/ai/voice/index.js +601 -1
- package/dist/ai/voice/index.js.map +1 -1
- package/dist/ai/voice/liveTranscription.d.ts +99 -0
- package/dist/ai/voice/liveTranscription.d.ts.map +1 -0
- package/dist/ai/voice/transcriptionState.d.ts +52 -0
- package/dist/ai/voice/transcriptionState.d.ts.map +1 -0
- package/dist/ai/voice/useLiveTranscription.d.ts +86 -0
- package/dist/ai/voice/useLiveTranscription.d.ts.map +1 -0
- package/dist/{chunk-2vteyrjj.js → chunk-3xm7ysef.js} +1 -1
- package/dist/{chunk-t79n15f8.js → chunk-778kv5wf.js} +1 -1
- package/dist/{chunk-mr66zxsz.js → chunk-c7g1jb3f.js} +2 -2
- package/dist/{chunk-mr66zxsz.js.map → chunk-c7g1jb3f.js.map} +1 -1
- package/dist/orm/defaults.d.ts.map +1 -1
- package/dist/orm/index.js +3 -3
- package/dist/orm/index.js.map +3 -3
- package/dist/server/index.js +1 -1
- package/package.json +1 -1
- /package/dist/{chunk-2vteyrjj.js.map → chunk-3xm7ysef.js.map} +0 -0
- /package/dist/{chunk-t79n15f8.js.map → chunk-778kv5wf.js.map} +0 -0
package/dist/ai/voice/index.js
CHANGED
|
@@ -428,6 +428,606 @@ function useVoiceSession(path, params = {}) {
|
|
|
428
428
|
};
|
|
429
429
|
}
|
|
430
430
|
//#endregion
|
|
431
|
-
|
|
431
|
+
//#region ai/voice/energyVad.ts
|
|
432
|
+
var DEFAULT_SILENCE_MS = 350;
|
|
433
|
+
function createEnergyVad(options = {}) {
|
|
434
|
+
const silenceMs = options.silenceMs ?? 350;
|
|
435
|
+
const minSpeechMs = options.minSpeechMs ?? 120;
|
|
436
|
+
const minThreshold = options.threshold ?? .015;
|
|
437
|
+
const floorRatio = options.floorRatio ?? 3;
|
|
438
|
+
let floor = minThreshold / floorRatio;
|
|
439
|
+
let speaking = false;
|
|
440
|
+
/** When the current run of loud frames began, before it counts as speech. */
|
|
441
|
+
let loudSince = null;
|
|
442
|
+
/** When the current run of quiet frames began, while speaking. */
|
|
443
|
+
let quietSince = null;
|
|
444
|
+
return {
|
|
445
|
+
get speaking() {
|
|
446
|
+
return speaking;
|
|
447
|
+
},
|
|
448
|
+
reset() {
|
|
449
|
+
speaking = false;
|
|
450
|
+
loudSince = null;
|
|
451
|
+
quietSince = null;
|
|
452
|
+
},
|
|
453
|
+
push(rms, at) {
|
|
454
|
+
const threshold = Math.max(minThreshold, floor * floorRatio);
|
|
455
|
+
if (!speaking) {
|
|
456
|
+
if (rms > threshold) {
|
|
457
|
+
loudSince ??= at;
|
|
458
|
+
if (at - loudSince >= minSpeechMs) {
|
|
459
|
+
speaking = true;
|
|
460
|
+
quietSince = null;
|
|
461
|
+
return "start";
|
|
462
|
+
}
|
|
463
|
+
} else {
|
|
464
|
+
loudSince = null;
|
|
465
|
+
floor = rms < floor ? floor * .9 + rms * .1 : floor * .995 + rms * .005;
|
|
466
|
+
}
|
|
467
|
+
return null;
|
|
468
|
+
}
|
|
469
|
+
if (rms > threshold * .7) {
|
|
470
|
+
quietSince = null;
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
quietSince ??= at;
|
|
474
|
+
if (at - quietSince >= silenceMs) {
|
|
475
|
+
speaking = false;
|
|
476
|
+
loudSince = null;
|
|
477
|
+
quietSince = null;
|
|
478
|
+
return "end";
|
|
479
|
+
}
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
/** RMS of float time-domain samples. */
|
|
485
|
+
function rmsOf(samples) {
|
|
486
|
+
let sum = 0;
|
|
487
|
+
for (let i = 0; i < samples.length; i++) sum += samples[i] * samples[i];
|
|
488
|
+
return samples.length ? Math.sqrt(sum / samples.length) : 0;
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region ai/voice/liveTranscription.ts
|
|
492
|
+
/**
|
|
493
|
+
* The browser's half of a live transcription session (#929): the microphone,
|
|
494
|
+
* a raw `RTCPeerConnection` to the vendor, the `oai-events` data channel, a
|
|
495
|
+
* level meter and end-of-speech detection that commits each turn.
|
|
496
|
+
*
|
|
497
|
+
* No vendor SDK, and nothing from `gemi/ai`'s server half: the secret (or the
|
|
498
|
+
* SDP answer) comes from the app's own route, which holds the key.
|
|
499
|
+
*
|
|
500
|
+
* @experimental Follows OpenAI's realtime transcription and WebRTC guides; not
|
|
501
|
+
* yet run against a live session.
|
|
502
|
+
*/
|
|
503
|
+
/** OpenAI's WebRTC endpoint, for a secret whose route doesn't say otherwise.
|
|
504
|
+
* `clientSecret()` on the server returns `callsUrl` (Azure's host included). */
|
|
505
|
+
var OPENAI_REALTIME_CALLS_URL = "https://api.openai.com/v1/realtime/calls";
|
|
506
|
+
var LiveTranscriptionFailure = class extends Error {
|
|
507
|
+
code;
|
|
508
|
+
status;
|
|
509
|
+
constructor(message, options = {}) {
|
|
510
|
+
super(message, { cause: options.cause });
|
|
511
|
+
this.name = "LiveTranscriptionError";
|
|
512
|
+
this.code = options.code;
|
|
513
|
+
this.status = options.status;
|
|
514
|
+
}
|
|
515
|
+
};
|
|
516
|
+
/** How often the level meter (and the built-in detector) reads the mic. */
|
|
517
|
+
var FRAME_MS = 30;
|
|
518
|
+
function microphoneError(error) {
|
|
519
|
+
const name = error?.name;
|
|
520
|
+
return name === "NotAllowedError" || name === "SecurityError" ? new LiveTranscriptionFailure("Microphone access was denied.", {
|
|
521
|
+
code: "microphone_denied",
|
|
522
|
+
cause: error
|
|
523
|
+
}) : new LiveTranscriptionFailure("No microphone is available.", {
|
|
524
|
+
code: "microphone_unavailable",
|
|
525
|
+
cause: error
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
async function routeError(response, what) {
|
|
529
|
+
const body = await response.json().catch(() => null);
|
|
530
|
+
return new LiveTranscriptionFailure(body?.error?.message ?? `${what} (${response.status}).`, {
|
|
531
|
+
status: response.status,
|
|
532
|
+
code: body?.error?.code
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Opens a live transcription session. Resolves once the SDP answer is applied;
|
|
537
|
+
* `onOpen` follows when the data channel opens. `signal` aborts a start in
|
|
538
|
+
* flight (unmount, `stop()` while connecting): everything opened so far is
|
|
539
|
+
* closed and the promise rejects with an `AbortError`.
|
|
540
|
+
*/
|
|
541
|
+
async function startLiveTranscription(options, signal) {
|
|
542
|
+
if (typeof RTCPeerConnection === "undefined" || !navigator.mediaDevices?.getUserMedia) throw new LiveTranscriptionFailure("This browser can't stream audio.", { code: "unsupported" });
|
|
543
|
+
const connect = options.connect ?? "token";
|
|
544
|
+
const post = (url, payload) => fetch(url, {
|
|
545
|
+
method: "POST",
|
|
546
|
+
headers: {
|
|
547
|
+
"Content-Type": "application/json",
|
|
548
|
+
...options.headers
|
|
549
|
+
},
|
|
550
|
+
body: JSON.stringify(payload),
|
|
551
|
+
signal
|
|
552
|
+
});
|
|
553
|
+
const tokenPromise = connect === "sdp" ? Promise.resolve(null) : typeof options.source === "function" ? options.source() : post(options.source, options.body ?? {}).then(async (response) => {
|
|
554
|
+
if (!response.ok) throw await routeError(response, "Live transcription is unavailable");
|
|
555
|
+
return response.json();
|
|
556
|
+
});
|
|
557
|
+
tokenPromise.catch(() => void 0);
|
|
558
|
+
let stream;
|
|
559
|
+
try {
|
|
560
|
+
stream = await navigator.mediaDevices.getUserMedia({ audio: {
|
|
561
|
+
echoCancellation: true,
|
|
562
|
+
noiseSuppression: true,
|
|
563
|
+
autoGainControl: true
|
|
564
|
+
} });
|
|
565
|
+
} catch (error) {
|
|
566
|
+
throw microphoneError(error);
|
|
567
|
+
}
|
|
568
|
+
const track = stream.getAudioTracks()[0];
|
|
569
|
+
let pc;
|
|
570
|
+
let channel;
|
|
571
|
+
let audioContext;
|
|
572
|
+
let meter;
|
|
573
|
+
let detector;
|
|
574
|
+
let closed = false;
|
|
575
|
+
/** `onClose` is for a session that started; a failed start throws instead. */
|
|
576
|
+
let started = false;
|
|
577
|
+
/** Speech heard since the last commit: a commit with none is pointless. */
|
|
578
|
+
let heardSpeech = false;
|
|
579
|
+
const close = (error) => {
|
|
580
|
+
if (closed) return;
|
|
581
|
+
closed = true;
|
|
582
|
+
if (meter) clearInterval(meter);
|
|
583
|
+
try {
|
|
584
|
+
detector?.stop();
|
|
585
|
+
} catch {}
|
|
586
|
+
try {
|
|
587
|
+
channel?.close();
|
|
588
|
+
} catch {}
|
|
589
|
+
pc?.close();
|
|
590
|
+
for (const t of stream.getTracks()) t.stop();
|
|
591
|
+
audioContext?.close().catch(() => void 0);
|
|
592
|
+
if (started) options.onClose(error);
|
|
593
|
+
};
|
|
594
|
+
const send = (event) => {
|
|
595
|
+
if (closed || channel?.readyState !== "open") return false;
|
|
596
|
+
channel.send(JSON.stringify(event));
|
|
597
|
+
return true;
|
|
598
|
+
};
|
|
599
|
+
const commit = () => {
|
|
600
|
+
const sent = send({ type: "input_audio_buffer.commit" });
|
|
601
|
+
if (sent) heardSpeech = false;
|
|
602
|
+
return sent;
|
|
603
|
+
};
|
|
604
|
+
const speech = {
|
|
605
|
+
speechStart() {
|
|
606
|
+
if (closed) return;
|
|
607
|
+
heardSpeech = true;
|
|
608
|
+
options.onSpeechStart();
|
|
609
|
+
},
|
|
610
|
+
speechEnd() {
|
|
611
|
+
if (closed) return;
|
|
612
|
+
options.onSpeechEnd(heardSpeech ? commit() : false);
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
try {
|
|
616
|
+
if (!track) throw new LiveTranscriptionFailure("No microphone is available.", { code: "microphone_unavailable" });
|
|
617
|
+
pc = new RTCPeerConnection();
|
|
618
|
+
pc.addTrack(track, stream);
|
|
619
|
+
channel = pc.createDataChannel("oai-events");
|
|
620
|
+
channel.addEventListener("open", () => {
|
|
621
|
+
if (!closed) options.onOpen();
|
|
622
|
+
});
|
|
623
|
+
channel.addEventListener("message", (message) => {
|
|
624
|
+
let event;
|
|
625
|
+
try {
|
|
626
|
+
event = JSON.parse(String(message.data));
|
|
627
|
+
} catch {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
if (event && typeof event === "object" && typeof event.type === "string") options.onEvent(event);
|
|
631
|
+
});
|
|
632
|
+
channel.addEventListener("close", () => close());
|
|
633
|
+
const peer = pc;
|
|
634
|
+
peer.addEventListener("connectionstatechange", () => {
|
|
635
|
+
if (peer.connectionState === "failed") close(new LiveTranscriptionFailure("The transcription connection failed.", { code: "connection_failed" }));
|
|
636
|
+
if (peer.connectionState === "closed") close();
|
|
637
|
+
});
|
|
638
|
+
const offer = await pc.createOffer();
|
|
639
|
+
await pc.setLocalDescription(offer);
|
|
640
|
+
let answer;
|
|
641
|
+
if (connect === "sdp") {
|
|
642
|
+
if (typeof options.source !== "string") throw new TypeError("connect: \"sdp\" needs the route's URL as the source.");
|
|
643
|
+
const response = await post(options.source, {
|
|
644
|
+
...options.body,
|
|
645
|
+
sdp: offer.sdp
|
|
646
|
+
});
|
|
647
|
+
if (!response.ok) throw await routeError(response, "Live transcription is unavailable");
|
|
648
|
+
answer = String((await response.json()).sdp ?? "");
|
|
649
|
+
} else {
|
|
650
|
+
const token = await tokenPromise;
|
|
651
|
+
if (!token || typeof token.value !== "string") throw new LiveTranscriptionFailure("The token route answered no secret.", { code: "no_token" });
|
|
652
|
+
const response = await fetch(token.callsUrl ?? "https://api.openai.com/v1/realtime/calls", {
|
|
653
|
+
method: "POST",
|
|
654
|
+
headers: {
|
|
655
|
+
Authorization: `Bearer ${token.value}`,
|
|
656
|
+
"Content-Type": "application/sdp"
|
|
657
|
+
},
|
|
658
|
+
body: offer.sdp,
|
|
659
|
+
signal
|
|
660
|
+
});
|
|
661
|
+
if (!response.ok) throw await routeError(response, "The transcription session could not start");
|
|
662
|
+
answer = await response.text();
|
|
663
|
+
}
|
|
664
|
+
if (!answer) throw new LiveTranscriptionFailure("No SDP answer.", { code: "provider_error" });
|
|
665
|
+
if (signal?.aborted) throw signal.reason ?? new DOMException("Aborted", "AbortError");
|
|
666
|
+
await pc.setRemoteDescription({
|
|
667
|
+
type: "answer",
|
|
668
|
+
sdp: answer
|
|
669
|
+
});
|
|
670
|
+
const energy = options.vad === void 0 || options.vad === "energy" ? createEnergyVad(options.energy) : null;
|
|
671
|
+
if (typeof AudioContext !== "undefined") {
|
|
672
|
+
audioContext = new AudioContext();
|
|
673
|
+
audioContext.resume?.().catch(() => void 0);
|
|
674
|
+
const analyser = audioContext.createAnalyser();
|
|
675
|
+
analyser.fftSize = 1024;
|
|
676
|
+
audioContext.createMediaStreamSource(stream).connect(analyser);
|
|
677
|
+
const samples = new Float32Array(analyser.fftSize);
|
|
678
|
+
let lastLevel = -1;
|
|
679
|
+
meter = setInterval(() => {
|
|
680
|
+
analyser.getFloatTimeDomainData(samples);
|
|
681
|
+
const rms = track.enabled ? rmsOf(samples) : 0;
|
|
682
|
+
const level = Math.min(1, rms / .2);
|
|
683
|
+
if (Math.abs(level - lastLevel) >= .02) {
|
|
684
|
+
lastLevel = level;
|
|
685
|
+
options.onLevel(level);
|
|
686
|
+
}
|
|
687
|
+
const change = energy?.push(rms, Date.now());
|
|
688
|
+
if (change === "start") speech.speechStart();
|
|
689
|
+
else if (change === "end") speech.speechEnd();
|
|
690
|
+
}, FRAME_MS);
|
|
691
|
+
} else if (energy) throw new LiveTranscriptionFailure("This browser can't analyse audio.", { code: "unsupported" });
|
|
692
|
+
if (typeof options.vad === "function") detector = await options.vad(stream, speech);
|
|
693
|
+
} catch (error) {
|
|
694
|
+
close();
|
|
695
|
+
if (signal?.aborted || error?.name === "AbortError") throw error;
|
|
696
|
+
throw error instanceof LiveTranscriptionFailure ? error : new LiveTranscriptionFailure("Live transcription could not start.", { cause: error });
|
|
697
|
+
}
|
|
698
|
+
if (closed) throw new LiveTranscriptionFailure("The transcription connection closed while starting.", { code: "connection_failed" });
|
|
699
|
+
started = true;
|
|
700
|
+
return {
|
|
701
|
+
commit,
|
|
702
|
+
flush: () => heardSpeech || options.vad === false ? commit() : false,
|
|
703
|
+
send,
|
|
704
|
+
mute(on) {
|
|
705
|
+
if (track) track.enabled = !on;
|
|
706
|
+
},
|
|
707
|
+
close: () => close()
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
//#endregion
|
|
711
|
+
//#region ai/voice/transcriptionState.ts
|
|
712
|
+
function initialTranscriptionState() {
|
|
713
|
+
return {
|
|
714
|
+
order: [],
|
|
715
|
+
partials: {},
|
|
716
|
+
turns: [],
|
|
717
|
+
pendingCommits: 0
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
/** The live text: every unfinished item's text, in order. */
|
|
721
|
+
function partialText(state) {
|
|
722
|
+
return state.order.map((id) => state.partials[id]).filter((text) => !!text).join(" ").trim();
|
|
723
|
+
}
|
|
724
|
+
/** Vendor error codes that are part of normal operation, not failures: a
|
|
725
|
+
* commit with nothing in the buffer (a manual `commit()` in silence). */
|
|
726
|
+
var BENIGN_ERRORS = /* @__PURE__ */ new Set(["input_audio_buffer_commit_empty"]);
|
|
727
|
+
function applyTranscriptionEvent(state, event) {
|
|
728
|
+
const itemId = typeof event.item_id === "string" ? event.item_id : void 0;
|
|
729
|
+
const seen = (id) => state.order.includes(id) ? state.order : [...state.order, id];
|
|
730
|
+
switch (event.type) {
|
|
731
|
+
case "conversation.item.input_audio_transcription.delta":
|
|
732
|
+
if (!itemId || typeof event.delta !== "string") break;
|
|
733
|
+
if (state.turns.some((turn) => turn.id === itemId)) break;
|
|
734
|
+
return {
|
|
735
|
+
state: {
|
|
736
|
+
...state,
|
|
737
|
+
order: seen(itemId),
|
|
738
|
+
partials: {
|
|
739
|
+
...state.partials,
|
|
740
|
+
[itemId]: (state.partials[itemId] ?? "") + event.delta
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
effects: []
|
|
744
|
+
};
|
|
745
|
+
case "input_audio_buffer.committed":
|
|
746
|
+
if (!itemId) break;
|
|
747
|
+
return {
|
|
748
|
+
state: {
|
|
749
|
+
...state,
|
|
750
|
+
order: seen(itemId)
|
|
751
|
+
},
|
|
752
|
+
effects: []
|
|
753
|
+
};
|
|
754
|
+
case "conversation.item.input_audio_transcription.completed": {
|
|
755
|
+
if (!itemId || state.turns.some((turn) => turn.id === itemId)) break;
|
|
756
|
+
const usage = event.usage;
|
|
757
|
+
const seconds = usage?.type === "duration" ? Number(usage.seconds) : NaN;
|
|
758
|
+
const turn = {
|
|
759
|
+
id: itemId,
|
|
760
|
+
text: typeof event.transcript === "string" ? event.transcript.trim() : "",
|
|
761
|
+
...Number.isFinite(seconds) ? { seconds } : {}
|
|
762
|
+
};
|
|
763
|
+
const order = seen(itemId);
|
|
764
|
+
const { [itemId]: _done, ...partials } = state.partials;
|
|
765
|
+
const all = [...state.turns, turn].sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
|
766
|
+
const turns = all.slice(-50);
|
|
767
|
+
const dropped = new Set(all.slice(0, -50).map((t) => t.id));
|
|
768
|
+
return {
|
|
769
|
+
state: {
|
|
770
|
+
...state,
|
|
771
|
+
order: dropped.size ? order.filter((id) => !dropped.has(id)) : order,
|
|
772
|
+
partials,
|
|
773
|
+
turns,
|
|
774
|
+
pendingCommits: Math.max(0, state.pendingCommits - 1)
|
|
775
|
+
},
|
|
776
|
+
effects: [{
|
|
777
|
+
type: "final",
|
|
778
|
+
turn
|
|
779
|
+
}]
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
case "conversation.item.input_audio_transcription.failed": {
|
|
783
|
+
const { [itemId ?? ""]: _dropped, ...partials } = state.partials;
|
|
784
|
+
const error = event.error ?? {};
|
|
785
|
+
return {
|
|
786
|
+
state: {
|
|
787
|
+
...state,
|
|
788
|
+
partials,
|
|
789
|
+
pendingCommits: Math.max(0, state.pendingCommits - 1)
|
|
790
|
+
},
|
|
791
|
+
effects: [{
|
|
792
|
+
type: "error",
|
|
793
|
+
code: error.code,
|
|
794
|
+
message: error.message ?? "Transcription failed."
|
|
795
|
+
}]
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
case "error": {
|
|
799
|
+
const error = event.error ?? {};
|
|
800
|
+
if (error.code && BENIGN_ERRORS.has(error.code)) return {
|
|
801
|
+
state: {
|
|
802
|
+
...state,
|
|
803
|
+
pendingCommits: Math.max(0, state.pendingCommits - 1)
|
|
804
|
+
},
|
|
805
|
+
effects: []
|
|
806
|
+
};
|
|
807
|
+
return {
|
|
808
|
+
state,
|
|
809
|
+
effects: [{
|
|
810
|
+
type: "error",
|
|
811
|
+
code: error.code,
|
|
812
|
+
message: error.message ?? "Vendor error."
|
|
813
|
+
}]
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return {
|
|
818
|
+
state,
|
|
819
|
+
effects: []
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
//#endregion
|
|
823
|
+
//#region ai/voice/useLiveTranscription.ts
|
|
824
|
+
/**
|
|
825
|
+
* Live speech-to-text (#929): the microphone streams to a transcription
|
|
826
|
+
* session (`gpt-live-transcribe` by default) over WebRTC. `partial` grows as
|
|
827
|
+
* the user speaks; when they stop for `silenceMs`, the turn is committed and
|
|
828
|
+
* its final transcript arrives in `turns` and `onFinal`. Nothing is spoken
|
|
829
|
+
* back.
|
|
830
|
+
*
|
|
831
|
+
* `source` is the app's route that answers a client secret (from
|
|
832
|
+
* `realtime.clientSecret({ session: { type: "transcription", … } })`), or a
|
|
833
|
+
* function returning one. With `connect: "sdp"`, it is the route that takes
|
|
834
|
+
* the SDP offer and answers `{ sdp }`.
|
|
835
|
+
*
|
|
836
|
+
* ```tsx
|
|
837
|
+
* const live = useLiveTranscription("/api/voice/token", {
|
|
838
|
+
* onFinal: (turn) => route(turn.text),
|
|
839
|
+
* });
|
|
840
|
+
* <button onClick={() => (live.status === "idle" ? live.start() : live.stop())}>Voice</button>
|
|
841
|
+
* <p>{live.partial}</p>
|
|
842
|
+
* ```
|
|
843
|
+
*
|
|
844
|
+
* From `gemi/ai/voice`, so only the views that use it load it.
|
|
845
|
+
*
|
|
846
|
+
* @experimental Tested against faked browser APIs only.
|
|
847
|
+
*/
|
|
848
|
+
function useLiveTranscription(source, options = {}) {
|
|
849
|
+
const [status, setStatus] = useState("idle");
|
|
850
|
+
const [transcript, setTranscript] = useState(initialTranscriptionState);
|
|
851
|
+
const [speaking, setSpeaking] = useState(false);
|
|
852
|
+
const [level, setLevel] = useState(0);
|
|
853
|
+
const [muted, setMuted] = useState(false);
|
|
854
|
+
const [error, setError] = useState(null);
|
|
855
|
+
const latest = useRef({
|
|
856
|
+
source,
|
|
857
|
+
options
|
|
858
|
+
});
|
|
859
|
+
latest.current = {
|
|
860
|
+
source,
|
|
861
|
+
options
|
|
862
|
+
};
|
|
863
|
+
const stateRef = useRef(transcript);
|
|
864
|
+
const connectionRef = useRef(null);
|
|
865
|
+
const abortRef = useRef(null);
|
|
866
|
+
/** Resolves `stop({ flush })`'s wait when the last pending final arrives. */
|
|
867
|
+
const drainedRef = useRef(null);
|
|
868
|
+
const mounted = useRef(true);
|
|
869
|
+
const update = useCallback((next) => {
|
|
870
|
+
const before = partialText(stateRef.current);
|
|
871
|
+
stateRef.current = next;
|
|
872
|
+
if (!mounted.current) return;
|
|
873
|
+
setTranscript(next);
|
|
874
|
+
const after = partialText(next);
|
|
875
|
+
if (after !== before) latest.current.options.onPartial?.(after);
|
|
876
|
+
}, []);
|
|
877
|
+
const fail = useCallback((failure) => {
|
|
878
|
+
if (!mounted.current) return;
|
|
879
|
+
setError(failure);
|
|
880
|
+
latest.current.options.onError?.(failure);
|
|
881
|
+
}, []);
|
|
882
|
+
const teardown = useCallback(() => {
|
|
883
|
+
abortRef.current?.abort();
|
|
884
|
+
abortRef.current = null;
|
|
885
|
+
const connection = connectionRef.current;
|
|
886
|
+
connectionRef.current = null;
|
|
887
|
+
connection?.close();
|
|
888
|
+
}, []);
|
|
889
|
+
useEffect(() => {
|
|
890
|
+
mounted.current = true;
|
|
891
|
+
return () => {
|
|
892
|
+
mounted.current = false;
|
|
893
|
+
teardown();
|
|
894
|
+
};
|
|
895
|
+
}, [teardown]);
|
|
896
|
+
const start = useCallback(async () => {
|
|
897
|
+
if (connectionRef.current || abortRef.current) return;
|
|
898
|
+
const abort = new AbortController();
|
|
899
|
+
abortRef.current = abort;
|
|
900
|
+
setError(null);
|
|
901
|
+
setStatus("connecting");
|
|
902
|
+
const { source: src, options: opts } = latest.current;
|
|
903
|
+
let opened = false;
|
|
904
|
+
try {
|
|
905
|
+
const connection = await startLiveTranscription({
|
|
906
|
+
connect: opts.connect,
|
|
907
|
+
source: src,
|
|
908
|
+
body: opts.body,
|
|
909
|
+
headers: opts.headers,
|
|
910
|
+
vad: opts.vad,
|
|
911
|
+
energy: {
|
|
912
|
+
...opts.energy,
|
|
913
|
+
silenceMs: opts.silenceMs
|
|
914
|
+
},
|
|
915
|
+
onOpen: () => {
|
|
916
|
+
opened = true;
|
|
917
|
+
if (mounted.current && connectionRef.current) setStatus("listening");
|
|
918
|
+
},
|
|
919
|
+
onEvent: (event) => {
|
|
920
|
+
const { state, effects } = applyTranscriptionEvent(stateRef.current, event);
|
|
921
|
+
if (state !== stateRef.current) update(state);
|
|
922
|
+
for (const effect of effects) if (effect.type === "final") {
|
|
923
|
+
if (mounted.current) latest.current.options.onFinal?.(effect.turn);
|
|
924
|
+
} else fail(Object.assign(new Error(effect.message), { code: effect.code }));
|
|
925
|
+
if (stateRef.current.pendingCommits === 0) drainedRef.current?.();
|
|
926
|
+
},
|
|
927
|
+
onSpeechStart: () => {
|
|
928
|
+
if (!mounted.current) return;
|
|
929
|
+
setSpeaking(true);
|
|
930
|
+
latest.current.options.onSpeechStart?.();
|
|
931
|
+
},
|
|
932
|
+
onSpeechEnd: (committed) => {
|
|
933
|
+
if (committed) update({
|
|
934
|
+
...stateRef.current,
|
|
935
|
+
pendingCommits: stateRef.current.pendingCommits + 1
|
|
936
|
+
});
|
|
937
|
+
if (!mounted.current) return;
|
|
938
|
+
setSpeaking(false);
|
|
939
|
+
latest.current.options.onSpeechEnd?.();
|
|
940
|
+
},
|
|
941
|
+
onLevel: (value) => {
|
|
942
|
+
if (mounted.current) setLevel(value);
|
|
943
|
+
},
|
|
944
|
+
onClose: (closeError) => {
|
|
945
|
+
connectionRef.current = null;
|
|
946
|
+
drainedRef.current?.();
|
|
947
|
+
if (!mounted.current) return;
|
|
948
|
+
setStatus("idle");
|
|
949
|
+
setSpeaking(false);
|
|
950
|
+
setLevel(0);
|
|
951
|
+
if (closeError) fail(closeError);
|
|
952
|
+
}
|
|
953
|
+
}, abort.signal);
|
|
954
|
+
if (abort.signal.aborted) {
|
|
955
|
+
connection.close();
|
|
956
|
+
return;
|
|
957
|
+
}
|
|
958
|
+
connectionRef.current = connection;
|
|
959
|
+
connection.mute(muted);
|
|
960
|
+
if (opened && mounted.current) setStatus("listening");
|
|
961
|
+
} catch (failure) {
|
|
962
|
+
if (abort.signal.aborted) return;
|
|
963
|
+
if (mounted.current) setStatus("idle");
|
|
964
|
+
fail(failure instanceof Error ? failure : new Error(String(failure)));
|
|
965
|
+
} finally {
|
|
966
|
+
if (abortRef.current === abort) abortRef.current = null;
|
|
967
|
+
}
|
|
968
|
+
}, [
|
|
969
|
+
fail,
|
|
970
|
+
update,
|
|
971
|
+
muted
|
|
972
|
+
]);
|
|
973
|
+
const commit = useCallback(() => {
|
|
974
|
+
if (connectionRef.current?.commit()) update({
|
|
975
|
+
...stateRef.current,
|
|
976
|
+
pendingCommits: stateRef.current.pendingCommits + 1
|
|
977
|
+
});
|
|
978
|
+
}, [update]);
|
|
979
|
+
const stop = useCallback(async ({ flush = false, timeoutMs = 1500 } = {}) => {
|
|
980
|
+
const connection = connectionRef.current;
|
|
981
|
+
if (flush && connection) {
|
|
982
|
+
if (connection.flush()) update({
|
|
983
|
+
...stateRef.current,
|
|
984
|
+
pendingCommits: stateRef.current.pendingCommits + 1
|
|
985
|
+
});
|
|
986
|
+
if (stateRef.current.pendingCommits > 0) {
|
|
987
|
+
await new Promise((resolve) => {
|
|
988
|
+
const timer = setTimeout(resolve, timeoutMs);
|
|
989
|
+
drainedRef.current = () => {
|
|
990
|
+
clearTimeout(timer);
|
|
991
|
+
resolve();
|
|
992
|
+
};
|
|
993
|
+
});
|
|
994
|
+
drainedRef.current = null;
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
teardown();
|
|
998
|
+
if (mounted.current) {
|
|
999
|
+
setStatus("idle");
|
|
1000
|
+
setSpeaking(false);
|
|
1001
|
+
setLevel(0);
|
|
1002
|
+
}
|
|
1003
|
+
}, [teardown, update]);
|
|
1004
|
+
const mute = useCallback((on) => {
|
|
1005
|
+
setMuted((was) => {
|
|
1006
|
+
const next = on ?? !was;
|
|
1007
|
+
connectionRef.current?.mute(next);
|
|
1008
|
+
return next;
|
|
1009
|
+
});
|
|
1010
|
+
}, []);
|
|
1011
|
+
const reset = useCallback(() => update({
|
|
1012
|
+
...initialTranscriptionState(),
|
|
1013
|
+
pendingCommits: stateRef.current.pendingCommits
|
|
1014
|
+
}), [update]);
|
|
1015
|
+
return {
|
|
1016
|
+
status,
|
|
1017
|
+
partial: partialText(transcript),
|
|
1018
|
+
turns: transcript.turns,
|
|
1019
|
+
speaking,
|
|
1020
|
+
level,
|
|
1021
|
+
muted,
|
|
1022
|
+
error,
|
|
1023
|
+
start,
|
|
1024
|
+
stop,
|
|
1025
|
+
commit,
|
|
1026
|
+
mute,
|
|
1027
|
+
reset
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
//#endregion
|
|
1031
|
+
export { DEFAULT_SILENCE_MS, OPENAI_REALTIME_CALLS_URL, createEnergyVad, useLiveTranscription, useVoiceSession };
|
|
432
1032
|
|
|
433
1033
|
//# sourceMappingURL=index.js.map
|