gemi 0.133.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.
@@ -428,6 +428,606 @@ function useVoiceSession(path, params = {}) {
428
428
  };
429
429
  }
430
430
  //#endregion
431
- export { useVoiceSession };
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