@theoremjs/react 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +246 -2
- package/client/attachment-hover-preview.d.ts +11 -0
- package/client/attachment-hover-preview.js +36 -0
- package/client/audio-level.d.ts +5 -0
- package/client/audio-level.js +24 -0
- package/client/composer-attachments.d.ts +30 -0
- package/client/composer-attachments.js +33 -0
- package/client/composer-drawer.d.ts +19 -0
- package/client/composer-drawer.js +15 -0
- package/client/composer-hints.d.ts +25 -0
- package/client/composer-hints.js +20 -0
- package/client/composer-primary.d.ts +16 -0
- package/client/composer-primary.js +25 -0
- package/client/decision-transport.d.ts +48 -0
- package/client/decision-transport.js +102 -0
- package/client/decode-composer-draft.d.ts +11 -0
- package/client/decode-composer-draft.js +12 -0
- package/client/encode-composer-draft.d.ts +9 -0
- package/client/encode-composer-draft.js +34 -0
- package/client/encode-files.d.ts +18 -0
- package/client/encode-files.js +54 -0
- package/client/failure.d.ts +32 -0
- package/client/failure.js +34 -0
- package/client/generation-selection.d.ts +15 -0
- package/client/generation-selection.js +24 -0
- package/client/host-call.d.ts +51 -0
- package/client/host-call.js +42 -0
- package/client/host-transport.d.ts +68 -0
- package/client/host-transport.js +74 -0
- package/client/image-output.d.ts +6 -0
- package/client/image-output.js +13 -0
- package/client/index.d.ts +20 -0
- package/client/index.js +18 -0
- package/client/ink-waveform.d.ts +17 -0
- package/client/ink-waveform.js +69 -0
- package/client/live/apply-live-turn-tool-event.d.ts +14 -0
- package/client/live/apply-live-turn-tool-event.js +39 -0
- package/client/live/live-captions.d.ts +23 -0
- package/client/live/live-captions.js +70 -0
- package/client/live/live-mic-forward.d.ts +29 -0
- package/client/live/live-mic-forward.js +37 -0
- package/client/live/live-state.d.ts +11 -0
- package/client/live/live-state.js +20 -0
- package/client/live/live-tool.d.ts +11 -0
- package/client/live/live-tool.js +1 -0
- package/client/live/live-video.d.ts +10 -0
- package/client/live/live-video.js +71 -0
- package/client/live/run-live-tool-call.d.ts +17 -0
- package/client/live/run-live-tool-call.js +32 -0
- package/client/live-client.d.ts +141 -0
- package/client/live-client.js +638 -0
- package/client/live-errors.d.ts +2 -0
- package/client/live-errors.js +9 -0
- package/client/live-messages.d.ts +110 -0
- package/client/live-messages.js +67 -0
- package/client/mic-capture.d.ts +4 -0
- package/client/mic-capture.js +6 -0
- package/client/oauth-popup.d.ts +26 -0
- package/client/oauth-popup.js +30 -0
- package/client/pcm-downsample.d.ts +4 -0
- package/client/pcm-downsample.js +45 -0
- package/client/run-commit.d.ts +61 -0
- package/client/run-commit.js +124 -0
- package/client/run-session.d.ts +80 -0
- package/client/run-session.js +215 -0
- package/client/schema-fields.d.ts +63 -0
- package/client/schema-fields.js +178 -0
- package/client/shaped-data.d.ts +118 -0
- package/client/shaped-data.js +314 -0
- package/client/source-citations.d.ts +13 -0
- package/client/source-citations.js +56 -0
- package/client/state-fields.d.ts +16 -0
- package/client/state-fields.js +44 -0
- package/client/tool-result.d.ts +78 -0
- package/client/tool-result.js +275 -0
- package/client/tool-resume.d.ts +33 -0
- package/client/tool-resume.js +11 -0
- package/client/trace-feed.d.ts +13 -0
- package/client/trace-feed.js +18 -0
- package/client/trace-story.d.ts +163 -0
- package/client/trace-story.js +416 -0
- package/client/trace-view.d.ts +130 -0
- package/client/trace-view.js +293 -0
- package/client/transcript-groups.d.ts +114 -0
- package/client/transcript-groups.js +167 -0
- package/client/transcript-scroll.d.ts +7 -0
- package/client/transcript-scroll.js +6 -0
- package/client/transport.d.ts +176 -0
- package/client/transport.js +256 -0
- package/client/turn-client.d.ts +35 -0
- package/client/turn-client.js +93 -0
- package/client/voice-label.d.ts +2 -0
- package/client/voice-label.js +15 -0
- package/client/voice-recorder.d.ts +27 -0
- package/client/voice-recorder.js +165 -0
- package/client/walk-away.d.ts +34 -0
- package/client/walk-away.js +48 -0
- package/client/wire-line.d.ts +56 -0
- package/client/wire-line.js +90 -0
- package/client/worklets/mic-capture.js +16 -0
- package/components/InkWaveform.d.ts +10 -0
- package/components/InkWaveform.js +108 -0
- package/components/live/use-live-runner-controls.d.ts +48 -0
- package/components/live/use-live-runner-controls.js +100 -0
- package/components/live/use-live-runner-model.d.ts +45 -0
- package/components/live/use-live-runner-model.js +175 -0
- package/components/live/use-live-runner-ui.d.ts +65 -0
- package/components/live/use-live-runner-ui.js +159 -0
- package/components/live/use-live-session-client.d.ts +41 -0
- package/components/live/use-live-session-client.js +123 -0
- package/components/use-composer-voice.d.ts +28 -0
- package/components/use-composer-voice.js +90 -0
- package/components/use-editor-selection.d.ts +3 -0
- package/components/use-editor-selection.js +16 -0
- package/components/use-voice-playback.d.ts +16 -0
- package/components/use-voice-playback.js +68 -0
- package/hooks/use-described.d.ts +8 -0
- package/hooks/use-described.js +23 -0
- package/hooks/use-theorem-chat-actions.d.ts +75 -0
- package/hooks/use-theorem-chat-actions.js +265 -0
- package/hooks/use-theorem-chat-state.d.ts +66 -0
- package/hooks/use-theorem-chat-state.js +118 -0
- package/hooks/use-theorem-chat.d.ts +63 -0
- package/hooks/use-theorem-chat.js +340 -0
- package/hooks/use-theorem-decision.d.ts +23 -0
- package/hooks/use-theorem-decision.js +75 -0
- package/hooks/use-theorem-host.d.ts +44 -0
- package/hooks/use-theorem-host.js +117 -0
- package/hooks/use-theorem-interface.d.ts +19 -0
- package/hooks/use-theorem-interface.js +25 -0
- package/hooks/use-trace-records.d.ts +4 -0
- package/hooks/use-trace-records.js +7 -0
- package/index.d.ts +14 -0
- package/index.js +14 -0
- package/live.d.ts +8 -0
- package/live.js +10 -0
- package/package.json +73 -4
- package/server/credential-store.d.ts +24 -0
- package/server/credential-store.js +14 -0
- package/server/decision-handler.d.ts +34 -0
- package/server/decision-handler.js +55 -0
- package/server/handler.d.ts +114 -0
- package/server/handler.js +568 -0
- package/server/json-response.d.ts +2 -0
- package/server/json-response.js +7 -0
- package/server/mod.d.ts +16 -0
- package/server/mod.js +15 -0
- package/server/request-check.d.ts +16 -0
- package/server/request-check.js +34 -0
- package/server/session-store.d.ts +74 -0
- package/server/session-store.js +49 -0
- package/server/steer-inbox.d.ts +35 -0
- package/server/steer-inbox.js +52 -0
- package/server/walk-away.d.ts +22 -0
- package/server/walk-away.js +41 -0
- package/styles/ink-controls.css +134 -0
- package/ui/ChatComposerBar.d.ts +39 -0
- package/ui/ChatComposerBar.js +251 -0
- package/ui/ChatTranscript.d.ts +30 -0
- package/ui/ChatTranscript.js +474 -0
- package/ui/ConsoleFrame.d.ts +47 -0
- package/ui/ConsoleFrame.js +46 -0
- package/ui/DecisionAnswers.d.ts +15 -0
- package/ui/DecisionAnswers.js +102 -0
- package/ui/LiveRunner.d.ts +28 -0
- package/ui/LiveRunner.js +193 -0
- package/ui/SchemaFields.d.ts +10 -0
- package/ui/SchemaFields.js +122 -0
- package/ui/ShapedData.d.ts +11 -0
- package/ui/ShapedData.js +151 -0
- package/ui/SidePanel.d.ts +57 -0
- package/ui/SidePanel.js +50 -0
- package/ui/StateFields.d.ts +36 -0
- package/ui/StateFields.js +96 -0
- package/ui/TheoremChat.d.ts +71 -0
- package/ui/TheoremChat.js +146 -0
- package/ui/TheoremDecision.d.ts +38 -0
- package/ui/TheoremDecision.js +87 -0
- package/ui/TheoremHost.d.ts +38 -0
- package/ui/TheoremHost.js +150 -0
- package/ui/ToolGateCard.d.ts +28 -0
- package/ui/ToolGateCard.js +100 -0
- package/ui/ToolResult.d.ts +10 -0
- package/ui/ToolResult.js +60 -0
- package/ui/ToolResultCharts.d.ts +5 -0
- package/ui/ToolResultCharts.js +86 -0
- package/ui/TraceCharts.d.ts +13 -0
- package/ui/TraceCharts.js +235 -0
- package/ui/TraceGuardrails.d.ts +10 -0
- package/ui/TraceGuardrails.js +56 -0
- package/ui/TraceInspector.d.ts +6 -0
- package/ui/TraceInspector.js +189 -0
- package/ui/TraceInspectorPanel.d.ts +24 -0
- package/ui/TraceInspectorPanel.js +57 -0
- package/ui/TraceOverview.d.ts +34 -0
- package/ui/TraceOverview.js +96 -0
- package/ui/TraceSpanDetail.d.ts +10 -0
- package/ui/TraceSpanDetail.js +188 -0
- package/ui/TraceStory.d.ts +20 -0
- package/ui/TraceStory.js +107 -0
- package/ui/TraceValues.d.ts +36 -0
- package/ui/TraceValues.js +113 -0
- package/ui/TraceWaterfall.d.ts +13 -0
- package/ui/TraceWaterfall.js +132 -0
- package/ui/VoiceNote.d.ts +12 -0
- package/ui/VoiceNote.js +28 -0
- package/ui/built/theme.css +774 -0
- package/ui/built/theorem.d.ts +13 -0
- package/ui/built/theorem.js +631 -0
- package/ui/built/theorem.variants.d.ts +16 -0
- package/ui/disclosure-motion.d.ts +6 -0
- package/ui/disclosure-motion.js +163 -0
- package/ui/icons.d.ts +4 -0
- package/ui/icons.js +37 -0
- package/ui/index.d.ts +25 -0
- package/ui/index.js +29 -0
- package/ui/labels-provider.d.ts +16 -0
- package/ui/labels-provider.js +61 -0
- package/ui/labels.d.ts +1153 -0
- package/ui/labels.js +603 -0
- package/ui/theme.d.ts +10 -0
- package/ui/theme.js +13 -0
- package/ui/theorem-theme.d.ts +10 -0
- package/ui/theorem-theme.js +83 -0
- package/ui/trace-palette.d.ts +24 -0
- package/ui/trace-palette.js +24 -0
- package/ui/trace-patterns.d.ts +32 -0
- package/ui/trace-patterns.js +44 -0
- package/ui/transcript-copy-text.d.ts +4 -0
- package/ui/transcript-copy-text.js +39 -0
|
@@ -0,0 +1,638 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure client-side SDK for THEOREM Gemini 3.1 Flash Live sessions over WebSocket relay.
|
|
3
|
+
*
|
|
4
|
+
* Handles:
|
|
5
|
+
* - Bidirectional WebSocket connection to `/api/live/relay`.
|
|
6
|
+
* - 16-bit 16kHz PCM microphone audio recording & streaming.
|
|
7
|
+
* - Gapless 24kHz PCM / WAV model voice playback scheduling.
|
|
8
|
+
* - Barge-in interruption cancellation (instant audio queue flush).
|
|
9
|
+
* - Quiet-mic gate while model audio plays (reduces false barge-in from speaker bleed).
|
|
10
|
+
* - UI tool execution and response routing.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import { describeError, TheoremError, } from '@theoremjs/agents';
|
|
15
|
+
import { base64ToBytes, bytesToBase64 } from '@theoremjs/agents/kernel';
|
|
16
|
+
import { float32Rms, float32RmsToLevel, timeDomainBytesToLevel } from "./audio-level.js";
|
|
17
|
+
import { isPermissionDeniedError } from "./live-errors.js";
|
|
18
|
+
import { applyLiveToolTurnEvent, liveTranscriptFromEvidence, shouldForwardMicFrame, } from "./live/live-mic-forward.js";
|
|
19
|
+
import { parseLiveServerEnvelope, } from "./live-messages.js";
|
|
20
|
+
import { MIC_CAPTURE_PROCESSOR, micCaptureWorkletUrl } from "./mic-capture.js";
|
|
21
|
+
import { downsampleAndConvertToInt16, pcm16BytesToFloat32 } from "./pcm-downsample.js";
|
|
22
|
+
import { hostError } from "./transport.js";
|
|
23
|
+
/** One message to the relay, in the shape a relay checks with `parseLiveClientMessage`. */
|
|
24
|
+
function clientMessage(message) {
|
|
25
|
+
return JSON.stringify(message);
|
|
26
|
+
}
|
|
27
|
+
function emptyInboundTurnAccum() {
|
|
28
|
+
return {
|
|
29
|
+
toolCalls: [],
|
|
30
|
+
mediaChunks: [],
|
|
31
|
+
cancelledToolIds: new Set(),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* While the model is playing audio, mic frames below this RMS are not forwarded.
|
|
36
|
+
* Speaker bleed / keyboard / room noise sit under this; intentional barge-in speech
|
|
37
|
+
* sits above it. Gemini VAD only offers LOW|HIGH start sensitivity — this is the
|
|
38
|
+
* fine-grained "less choppy barge-in" knob.
|
|
39
|
+
*/
|
|
40
|
+
const BARGE_IN_RMS_WHILE_SPEAKING = 0.05;
|
|
41
|
+
/** A caught value as an `Error`; one that is not keeps its text as `internal` detail. */
|
|
42
|
+
function asError(err) {
|
|
43
|
+
return err instanceof Error ? err : new TheoremError('internal', describeError(err), { cause: err });
|
|
44
|
+
}
|
|
45
|
+
function safeDisconnect(node) {
|
|
46
|
+
if (!node || typeof node.disconnect !== 'function')
|
|
47
|
+
return;
|
|
48
|
+
try {
|
|
49
|
+
node.disconnect();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* ignore */
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
function stopMediaTracks(stream) {
|
|
56
|
+
if (!stream || typeof stream.getTracks !== 'function')
|
|
57
|
+
return;
|
|
58
|
+
for (const track of stream.getTracks()) {
|
|
59
|
+
track.stop();
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function checkMicFrameForward(args) {
|
|
63
|
+
const rms = float32Rms(args.inputFloat32);
|
|
64
|
+
const modelPlaying = args.hasPlaybackNodes || args.status === 'speaking';
|
|
65
|
+
return shouldForwardMicFrame({
|
|
66
|
+
isMuted: args.isMuted,
|
|
67
|
+
socketOpen: args.wsOpen,
|
|
68
|
+
modelPlaying,
|
|
69
|
+
rms,
|
|
70
|
+
bargeInRmsWhileSpeaking: BARGE_IN_RMS_WHILE_SPEAKING,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function resolveWorkingStatusTransition(currentStatus, serverWorking) {
|
|
74
|
+
if (currentStatus === 'listening' && serverWorking)
|
|
75
|
+
return 'working';
|
|
76
|
+
if (currentStatus === 'working' && !serverWorking)
|
|
77
|
+
return 'listening';
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
export class LiveSessionClient {
|
|
81
|
+
ws = null;
|
|
82
|
+
audioContext = null;
|
|
83
|
+
micStream = null;
|
|
84
|
+
micSource = null;
|
|
85
|
+
micWorklet = null;
|
|
86
|
+
micWorkletModuleLoaded = false;
|
|
87
|
+
playbackNodes = [];
|
|
88
|
+
playbackBus = null;
|
|
89
|
+
playbackAnalyser = null;
|
|
90
|
+
playbackMeterFrame = 0;
|
|
91
|
+
playbackMeterBuffer = null;
|
|
92
|
+
nextPlaybackTime = 0;
|
|
93
|
+
status = 'disconnected';
|
|
94
|
+
/** Server reported background work (reasoning / async tools) still in flight. */
|
|
95
|
+
serverWorking = false;
|
|
96
|
+
micActivating = false;
|
|
97
|
+
connectTimeout = null;
|
|
98
|
+
isMuted = false;
|
|
99
|
+
options;
|
|
100
|
+
/** Serialize control-plane handling (tools / status) without waiting on audio decode. */
|
|
101
|
+
inboundChain = Promise.resolve();
|
|
102
|
+
/** Ordered model-audio playback queue (separate so tools are not stuck behind decode). */
|
|
103
|
+
audioChain = Promise.resolve();
|
|
104
|
+
/**
|
|
105
|
+
* Tool calls, one at a time in the order the model made them. Apart from
|
|
106
|
+
* `inboundChain`: a call waits on the relay's reply, which arrives inbound.
|
|
107
|
+
*/
|
|
108
|
+
toolChain = Promise.resolve();
|
|
109
|
+
/** Bumped on barge-in / cancel so stale audioChain work is skipped. */
|
|
110
|
+
audioEpoch = 0;
|
|
111
|
+
pendingExecuteResults = new Map();
|
|
112
|
+
constructor(options) {
|
|
113
|
+
this.options = options;
|
|
114
|
+
}
|
|
115
|
+
setStatus(newStatus) {
|
|
116
|
+
this.status = newStatus;
|
|
117
|
+
if (newStatus === 'listening' || newStatus === 'disconnected' || newStatus === 'error') {
|
|
118
|
+
this.clearConnectTimeout();
|
|
119
|
+
}
|
|
120
|
+
this.options.onStatusChange?.(newStatus);
|
|
121
|
+
}
|
|
122
|
+
setConnectPhase(phase) {
|
|
123
|
+
this.options.onConnectPhase?.(phase);
|
|
124
|
+
}
|
|
125
|
+
clearConnectTimeout() {
|
|
126
|
+
if (this.connectTimeout) {
|
|
127
|
+
clearTimeout(this.connectTimeout);
|
|
128
|
+
this.connectTimeout = null;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
detachWebSocket() {
|
|
132
|
+
for (const [callId, pending] of this.pendingExecuteResults) {
|
|
133
|
+
// lexicon-exempt: internal diagnostic; the user reads error.network
|
|
134
|
+
pending.reject(new TheoremError('network', `live session closed before call ${callId} settled`));
|
|
135
|
+
}
|
|
136
|
+
this.pendingExecuteResults.clear();
|
|
137
|
+
if (!this.ws)
|
|
138
|
+
return;
|
|
139
|
+
this.ws.onopen = null;
|
|
140
|
+
this.ws.onmessage = null;
|
|
141
|
+
this.ws.onclose = null;
|
|
142
|
+
this.ws.onerror = null;
|
|
143
|
+
try {
|
|
144
|
+
this.ws.close();
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* ignore */
|
|
148
|
+
}
|
|
149
|
+
this.ws = null;
|
|
150
|
+
}
|
|
151
|
+
teardownConnection() {
|
|
152
|
+
this.clearConnectTimeout();
|
|
153
|
+
this.micActivating = false;
|
|
154
|
+
this.setConnectPhase(null);
|
|
155
|
+
this.detachWebSocket();
|
|
156
|
+
this.cleanupAudio();
|
|
157
|
+
}
|
|
158
|
+
/** End the session on `error`: a failed connect or a lost socket. */
|
|
159
|
+
failSession(error) {
|
|
160
|
+
if (this.status === 'error' || this.status === 'disconnected')
|
|
161
|
+
return;
|
|
162
|
+
this.teardownConnection();
|
|
163
|
+
this.options.onError?.(error);
|
|
164
|
+
this.setStatus('error');
|
|
165
|
+
}
|
|
166
|
+
createSocket(url) {
|
|
167
|
+
return this.options.createSocket?.() ?? new WebSocket(url);
|
|
168
|
+
}
|
|
169
|
+
// fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
|
|
170
|
+
async connect() {
|
|
171
|
+
this.teardownConnection();
|
|
172
|
+
this.setStatus('connecting');
|
|
173
|
+
this.setConnectPhase('socket');
|
|
174
|
+
try {
|
|
175
|
+
const AudioContextClass = globalThis.AudioContext;
|
|
176
|
+
this.audioContext = new AudioContextClass();
|
|
177
|
+
if (this.audioContext.state === 'suspended') {
|
|
178
|
+
await this.audioContext.resume();
|
|
179
|
+
}
|
|
180
|
+
const protocol = globalThis.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
181
|
+
const profileParam = this.options.profile
|
|
182
|
+
? `?profile=${encodeURIComponent(this.options.profile)}`
|
|
183
|
+
: '';
|
|
184
|
+
const defaultUrl = `${protocol}//${globalThis.location.host}/api/live/relay${profileParam}`;
|
|
185
|
+
const url = this.options.relayUrl || defaultUrl;
|
|
186
|
+
this.ws = this.createSocket(url);
|
|
187
|
+
this.ws.binaryType = 'arraybuffer';
|
|
188
|
+
const { openMessage } = this.options;
|
|
189
|
+
if (openMessage) {
|
|
190
|
+
this.ws.onopen = () => {
|
|
191
|
+
this.ws?.send(JSON.stringify(openMessage));
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
this.connectTimeout = setTimeout(() => {
|
|
195
|
+
if (this.status === 'connecting') {
|
|
196
|
+
// lexicon-exempt: internal diagnostic; the user reads error.timeout
|
|
197
|
+
this.failSession(new TheoremError('timeout', 'live connect timed out'));
|
|
198
|
+
}
|
|
199
|
+
}, 20_000);
|
|
200
|
+
this.ws.onmessage = (event) => {
|
|
201
|
+
this.enqueueServerMessage(event.data);
|
|
202
|
+
};
|
|
203
|
+
this.ws.onclose = () => {
|
|
204
|
+
if (this.status === 'connecting') {
|
|
205
|
+
// lexicon-exempt: internal diagnostic; the user reads error.network
|
|
206
|
+
this.failSession(new TheoremError('network', 'live socket closed before ready'));
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
this.teardownConnection();
|
|
210
|
+
this.setStatus('disconnected');
|
|
211
|
+
};
|
|
212
|
+
this.ws.onerror = () => {
|
|
213
|
+
// lexicon-exempt: internal diagnostic; the user reads error.network
|
|
214
|
+
this.failSession(new TheoremError('network', 'live socket error'));
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
this.failSession(new TheoremError('internal', describeError(err), { cause: err }));
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async ensureAudioContext() {
|
|
222
|
+
if (!this.audioContext || this.audioContext.state === 'closed') {
|
|
223
|
+
const AudioContextClass = globalThis.AudioContext;
|
|
224
|
+
this.audioContext = new AudioContextClass();
|
|
225
|
+
if (this.audioContext.state === 'suspended') {
|
|
226
|
+
await this.audioContext.resume();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async activateMicrophone() {
|
|
231
|
+
if (this.micActivating || this.status !== 'connecting')
|
|
232
|
+
return;
|
|
233
|
+
this.micActivating = true;
|
|
234
|
+
this.setConnectPhase('microphone');
|
|
235
|
+
try {
|
|
236
|
+
await this.ensureAudioContext();
|
|
237
|
+
this.micStream = await navigator.mediaDevices.getUserMedia({
|
|
238
|
+
audio: {
|
|
239
|
+
channelCount: 1,
|
|
240
|
+
echoCancellation: true,
|
|
241
|
+
noiseSuppression: true,
|
|
242
|
+
autoGainControl: true,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
await this.setupMicrophonePipeline();
|
|
246
|
+
this.setConnectPhase(null);
|
|
247
|
+
this.setStatus('listening');
|
|
248
|
+
}
|
|
249
|
+
catch (err) {
|
|
250
|
+
const denied = isPermissionDeniedError(err);
|
|
251
|
+
this.failSession(new TheoremError(denied ? 'auth' : 'internal', describeError(err), {
|
|
252
|
+
cause: err,
|
|
253
|
+
copy: { key: denied ? 'voice.permission' : 'voice.unavailable' },
|
|
254
|
+
}));
|
|
255
|
+
}
|
|
256
|
+
finally {
|
|
257
|
+
this.micActivating = false;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
sendMicFrame(inputFloat32, sampleRate) {
|
|
261
|
+
const pcm16 = downsampleAndConvertToInt16(inputFloat32, sampleRate, 16000);
|
|
262
|
+
const base64 = bytesToBase64(new Uint8Array(pcm16.buffer));
|
|
263
|
+
const socket = this.ws;
|
|
264
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
265
|
+
socket.send(clientMessage({ type: 'audio', data: base64 }));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
reportMicVolume(inputFloat32) {
|
|
269
|
+
if (this.isMuted)
|
|
270
|
+
return;
|
|
271
|
+
this.options.onVolumeLevel?.(float32RmsToLevel(inputFloat32), true);
|
|
272
|
+
}
|
|
273
|
+
forwardMicBuffer(inputFloat32) {
|
|
274
|
+
this.reportMicVolume(inputFloat32);
|
|
275
|
+
const forward = checkMicFrameForward({
|
|
276
|
+
isMuted: this.isMuted,
|
|
277
|
+
wsOpen: this.ws?.readyState === WebSocket.OPEN,
|
|
278
|
+
hasPlaybackNodes: this.playbackNodes.length > 0,
|
|
279
|
+
status: this.status,
|
|
280
|
+
inputFloat32,
|
|
281
|
+
});
|
|
282
|
+
if (forward) {
|
|
283
|
+
const sampleRate = this.audioContext ? this.audioContext.sampleRate : 48000;
|
|
284
|
+
this.sendMicFrame(inputFloat32, sampleRate);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
async setupMicrophonePipeline() {
|
|
288
|
+
if (!this.micStream || !this.audioContext)
|
|
289
|
+
return;
|
|
290
|
+
this.micSource = this.audioContext.createMediaStreamSource(this.micStream);
|
|
291
|
+
const silent = this.audioContext.createGain();
|
|
292
|
+
silent.gain.value = 0;
|
|
293
|
+
if (!this.micWorkletModuleLoaded) {
|
|
294
|
+
await this.audioContext.audioWorklet.addModule(micCaptureWorkletUrl());
|
|
295
|
+
this.micWorkletModuleLoaded = true;
|
|
296
|
+
}
|
|
297
|
+
this.micWorklet = new AudioWorkletNode(this.audioContext, MIC_CAPTURE_PROCESSOR);
|
|
298
|
+
this.micWorklet.port.onmessage = (event) => {
|
|
299
|
+
const inputFloat32 = new Float32Array(event.data);
|
|
300
|
+
this.forwardMicBuffer(inputFloat32);
|
|
301
|
+
};
|
|
302
|
+
this.micSource.connect(this.micWorklet);
|
|
303
|
+
this.micWorklet.connect(silent);
|
|
304
|
+
silent.connect(this.audioContext.destination);
|
|
305
|
+
}
|
|
306
|
+
ensurePlaybackBus() {
|
|
307
|
+
if (!this.audioContext || this.playbackBus)
|
|
308
|
+
return;
|
|
309
|
+
this.playbackBus = this.audioContext.createGain();
|
|
310
|
+
this.playbackAnalyser = this.audioContext.createAnalyser();
|
|
311
|
+
this.playbackAnalyser.fftSize = 512;
|
|
312
|
+
this.playbackAnalyser.smoothingTimeConstant = 0.35;
|
|
313
|
+
this.playbackBus.connect(this.playbackAnalyser);
|
|
314
|
+
this.playbackAnalyser.connect(this.audioContext.destination);
|
|
315
|
+
this.playbackMeterBuffer = new Uint8Array(this.playbackAnalyser.fftSize);
|
|
316
|
+
}
|
|
317
|
+
stopPlaybackMeter() {
|
|
318
|
+
if (this.playbackMeterFrame) {
|
|
319
|
+
cancelAnimationFrame(this.playbackMeterFrame);
|
|
320
|
+
this.playbackMeterFrame = 0;
|
|
321
|
+
}
|
|
322
|
+
this.options.onVolumeLevel?.(0, false);
|
|
323
|
+
}
|
|
324
|
+
startPlaybackMeter() {
|
|
325
|
+
if (this.playbackMeterFrame)
|
|
326
|
+
return;
|
|
327
|
+
const tick = () => {
|
|
328
|
+
if (this.playbackNodes.length === 0 || !this.playbackAnalyser || !this.playbackMeterBuffer) {
|
|
329
|
+
this.stopPlaybackMeter();
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.playbackAnalyser.getByteTimeDomainData(this.playbackMeterBuffer);
|
|
333
|
+
this.options.onVolumeLevel?.(timeDomainBytesToLevel(this.playbackMeterBuffer), false);
|
|
334
|
+
this.playbackMeterFrame = requestAnimationFrame(tick);
|
|
335
|
+
};
|
|
336
|
+
this.playbackMeterFrame = requestAnimationFrame(tick);
|
|
337
|
+
}
|
|
338
|
+
async processServerEnvelope(payload) {
|
|
339
|
+
if (await this.tryHandleControlEnvelope(payload))
|
|
340
|
+
return;
|
|
341
|
+
if (payload.type !== 'events')
|
|
342
|
+
return;
|
|
343
|
+
const accum = this.collectInboundTurn(payload.events);
|
|
344
|
+
const runnableTools = accum.toolCalls.filter((call) => !accum.cancelledToolIds.has(call.id));
|
|
345
|
+
for (const call of runnableTools) {
|
|
346
|
+
this.toolChain = this.toolChain
|
|
347
|
+
.then(() => this.options.onToolCall(call.name, call.arguments, { callId: call.id,
|
|
348
|
+
}))
|
|
349
|
+
.catch((err) => {
|
|
350
|
+
this.options.onError?.(asError(err));
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
this.scheduleMediaChunks(accum.mediaChunks);
|
|
354
|
+
}
|
|
355
|
+
enqueueServerMessage(data) {
|
|
356
|
+
this.inboundChain = this.inboundChain
|
|
357
|
+
.then(async () => {
|
|
358
|
+
if (typeof data !== 'string')
|
|
359
|
+
return;
|
|
360
|
+
const payload = parseLiveServerEnvelope(data);
|
|
361
|
+
// An envelope the client can't use reaches onTurnEvent like any event; the call goes on.
|
|
362
|
+
if (payload.type === 'unsupported' || payload.type === 'malformed') {
|
|
363
|
+
this.options.onTurnEvent?.(payload);
|
|
364
|
+
}
|
|
365
|
+
else
|
|
366
|
+
await this.processServerEnvelope(payload);
|
|
367
|
+
})
|
|
368
|
+
.catch((err) => {
|
|
369
|
+
this.options.onError?.(asError(err));
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
async handleReadyEnvelope(payload) {
|
|
373
|
+
this.options.onSessionReady?.({
|
|
374
|
+
sessionId: payload.sessionId,
|
|
375
|
+
profile: payload.profile,
|
|
376
|
+
});
|
|
377
|
+
if (this.options.voiceIngress === false) {
|
|
378
|
+
this.setConnectPhase(null);
|
|
379
|
+
this.setStatus('listening');
|
|
380
|
+
}
|
|
381
|
+
else {
|
|
382
|
+
await this.activateMicrophone();
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
handleExecuteToolResultEnvelope(payload) {
|
|
386
|
+
const pending = this.pendingExecuteResults.get(payload.callId);
|
|
387
|
+
if (!pending)
|
|
388
|
+
return;
|
|
389
|
+
this.pendingExecuteResults.delete(payload.callId);
|
|
390
|
+
if (payload.status === 'refused') {
|
|
391
|
+
pending.reject(hostError(payload.body, 'request'));
|
|
392
|
+
}
|
|
393
|
+
else if (payload.status === 'gated') {
|
|
394
|
+
pending.resolve({ status: 'gated', gate: payload.gate });
|
|
395
|
+
}
|
|
396
|
+
else
|
|
397
|
+
pending.resolve({ status: 'settled' });
|
|
398
|
+
}
|
|
399
|
+
async tryHandleControlEnvelope(payload) {
|
|
400
|
+
if (payload.type === 'ready') {
|
|
401
|
+
await this.handleReadyEnvelope(payload);
|
|
402
|
+
return true;
|
|
403
|
+
}
|
|
404
|
+
if (payload.type === 'executeToolResult') {
|
|
405
|
+
this.handleExecuteToolResultEnvelope(payload);
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
if (payload.type === 'trace') {
|
|
409
|
+
this.options.onTrace?.(payload.record);
|
|
410
|
+
return true;
|
|
411
|
+
}
|
|
412
|
+
if (payload.type === 'error') {
|
|
413
|
+
// The relay's kind and wording when it sent them, else `unavailable`.
|
|
414
|
+
this.options.onError?.(hostError(payload, 'unavailable'));
|
|
415
|
+
this.setStatus('error');
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
collectInboundTurn(events) {
|
|
421
|
+
const accum = emptyInboundTurnAccum();
|
|
422
|
+
for (const event of events) {
|
|
423
|
+
this.processTurnEvent(event, accum);
|
|
424
|
+
}
|
|
425
|
+
return accum;
|
|
426
|
+
}
|
|
427
|
+
processTurnEvent(event, accum) {
|
|
428
|
+
this.options.onTurnEvent?.(event);
|
|
429
|
+
switch (event.type) {
|
|
430
|
+
case 'evidence':
|
|
431
|
+
this.handleEvidenceTurnEvent(event);
|
|
432
|
+
break;
|
|
433
|
+
case 'session':
|
|
434
|
+
this.handleSessionTurnEvent(event);
|
|
435
|
+
break;
|
|
436
|
+
case 'media':
|
|
437
|
+
this.collectMediaTurnEvent(event, accum);
|
|
438
|
+
break;
|
|
439
|
+
case 'tool':
|
|
440
|
+
applyLiveToolTurnEvent(event.tool, accum);
|
|
441
|
+
break;
|
|
442
|
+
case 'done':
|
|
443
|
+
this.handleDoneTurnEvent(event);
|
|
444
|
+
break;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
handleEvidenceTurnEvent(event) {
|
|
448
|
+
const transcript = liveTranscriptFromEvidence(event);
|
|
449
|
+
if (transcript) {
|
|
450
|
+
this.options.onTranscript?.(transcript.text, transcript.isUser, { interim: transcript.interim,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
handleSessionTurnEvent(event) {
|
|
455
|
+
if (this.notifySessionLifecycle(event.session))
|
|
456
|
+
return;
|
|
457
|
+
this.serverWorking = event.session.kind === 'working';
|
|
458
|
+
const nextStatus = resolveWorkingStatusTransition(this.status, this.serverWorking);
|
|
459
|
+
if (nextStatus)
|
|
460
|
+
this.setStatus(nextStatus);
|
|
461
|
+
}
|
|
462
|
+
/** Hand the session's closing and end to the host; `true` when it was one of them. */
|
|
463
|
+
notifySessionLifecycle(session) {
|
|
464
|
+
if (session.kind === 'closing_soon') {
|
|
465
|
+
this.options.onSessionClosing?.(session.timeLeftMs);
|
|
466
|
+
return true;
|
|
467
|
+
}
|
|
468
|
+
if (session.kind !== 'ended')
|
|
469
|
+
return false;
|
|
470
|
+
this.options.onSessionEnded?.(session);
|
|
471
|
+
return true;
|
|
472
|
+
}
|
|
473
|
+
/** Status to settle into once model speech stops. */
|
|
474
|
+
restingStatus() {
|
|
475
|
+
return this.serverWorking ? 'working' : 'listening';
|
|
476
|
+
}
|
|
477
|
+
collectMediaTurnEvent(event, accum) {
|
|
478
|
+
if (!event.media.data)
|
|
479
|
+
return;
|
|
480
|
+
this.setStatus('speaking');
|
|
481
|
+
accum.mediaChunks.push({
|
|
482
|
+
data: event.media.data,
|
|
483
|
+
mimeType: event.media.mimeType,
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
handleDoneTurnEvent(event) {
|
|
487
|
+
if (event.interrupted) {
|
|
488
|
+
this.cancelPlayback();
|
|
489
|
+
}
|
|
490
|
+
if (event.stop.kind !== 'generation_complete') {
|
|
491
|
+
this.serverWorking = false;
|
|
492
|
+
this.setStatus('listening');
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
scheduleMediaChunks(mediaChunks) {
|
|
496
|
+
if (mediaChunks.length === 0)
|
|
497
|
+
return;
|
|
498
|
+
const epoch = this.audioEpoch;
|
|
499
|
+
for (const chunk of mediaChunks) {
|
|
500
|
+
this.audioChain = this.audioChain
|
|
501
|
+
.then(async () => {
|
|
502
|
+
if (epoch !== this.audioEpoch)
|
|
503
|
+
return;
|
|
504
|
+
await this.enqueueAudioChunk(chunk.data, chunk.mimeType);
|
|
505
|
+
})
|
|
506
|
+
.catch((err) => {
|
|
507
|
+
this.options.onError?.(asError(err));
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
/** The relay runs `LiveSession.executeTool`; see `ExecuteToolOnRelay`. */
|
|
512
|
+
executeToolOnRelay(args) {
|
|
513
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
514
|
+
throw new TheoremError('request', 'live session is not connected'); // lexicon-exempt: internal diagnostic
|
|
515
|
+
}
|
|
516
|
+
const resultPromise = new Promise((resolve, reject) => {
|
|
517
|
+
this.pendingExecuteResults.set(args.callId, { resolve, reject });
|
|
518
|
+
});
|
|
519
|
+
this.ws.send(clientMessage({ type: 'executeTool', ...args }));
|
|
520
|
+
return resultPromise;
|
|
521
|
+
}
|
|
522
|
+
async enqueueAudioChunk(base64Data, mimeType) {
|
|
523
|
+
if (!this.audioContext)
|
|
524
|
+
return;
|
|
525
|
+
try {
|
|
526
|
+
this.ensurePlaybackBus();
|
|
527
|
+
if (!this.playbackBus)
|
|
528
|
+
return;
|
|
529
|
+
const bytes = base64ToBytes(base64Data);
|
|
530
|
+
let audioBuffer;
|
|
531
|
+
if (mimeType?.includes('wav')) {
|
|
532
|
+
const wavBuffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
|
|
533
|
+
audioBuffer = await this.audioContext.decodeAudioData(wavBuffer);
|
|
534
|
+
}
|
|
535
|
+
else {
|
|
536
|
+
// Raw PCM 24kHz 1-channel 16-bit LE
|
|
537
|
+
const float32 = pcm16BytesToFloat32(bytes);
|
|
538
|
+
audioBuffer = this.audioContext.createBuffer(1, float32.length, 24000);
|
|
539
|
+
audioBuffer.getChannelData(0).set(float32);
|
|
540
|
+
}
|
|
541
|
+
const source = this.audioContext.createBufferSource();
|
|
542
|
+
source.buffer = audioBuffer;
|
|
543
|
+
source.connect(this.playbackBus);
|
|
544
|
+
const now = this.audioContext.currentTime;
|
|
545
|
+
const startTime = Math.max(now, this.nextPlaybackTime);
|
|
546
|
+
source.start(startTime);
|
|
547
|
+
this.nextPlaybackTime = startTime + audioBuffer.duration;
|
|
548
|
+
this.playbackNodes.push(source);
|
|
549
|
+
this.startPlaybackMeter();
|
|
550
|
+
source.onended = () => {
|
|
551
|
+
const idx = this.playbackNodes.indexOf(source);
|
|
552
|
+
if (idx !== -1)
|
|
553
|
+
this.playbackNodes.splice(idx, 1);
|
|
554
|
+
if (this.playbackNodes.length === 0) {
|
|
555
|
+
this.stopPlaybackMeter();
|
|
556
|
+
if (this.status === 'speaking') {
|
|
557
|
+
this.setStatus(this.restingStatus());
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
}
|
|
562
|
+
catch (err) {
|
|
563
|
+
this.options.onError?.(asError(err));
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
cancelPlayback() {
|
|
567
|
+
this.audioEpoch += 1;
|
|
568
|
+
this.audioChain = Promise.resolve();
|
|
569
|
+
for (const node of this.playbackNodes) {
|
|
570
|
+
try {
|
|
571
|
+
node.stop();
|
|
572
|
+
node.disconnect();
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
/* ignore */
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
this.playbackNodes = [];
|
|
579
|
+
this.stopPlaybackMeter();
|
|
580
|
+
if (this.audioContext) {
|
|
581
|
+
this.nextPlaybackTime = this.audioContext.currentTime;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
// fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
|
|
585
|
+
toggleMute() {
|
|
586
|
+
this.isMuted = !this.isMuted;
|
|
587
|
+
return this.isMuted;
|
|
588
|
+
}
|
|
589
|
+
// fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
|
|
590
|
+
sendText(text) {
|
|
591
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
592
|
+
this.ws.send(clientMessage({ type: 'text', text }));
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
// fallow-ignore-next-line unused-class-member -- called by hosts that tell the model where the visitor is
|
|
596
|
+
sendContext(text) {
|
|
597
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
598
|
+
this.ws.send(clientMessage({ type: 'context', text }));
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// fallow-ignore-next-line unused-class-member -- called from useLiveRunnerControls via client refs
|
|
602
|
+
sendVideo(data, mimeType = 'image/jpeg') {
|
|
603
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
604
|
+
this.ws.send(clientMessage({ type: 'video', data, mimeType }));
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// fallow-ignore-next-line unused-class-member -- called from useLiveSessionClient teardown
|
|
608
|
+
disconnect() {
|
|
609
|
+
this.teardownConnection();
|
|
610
|
+
this.setStatus('disconnected');
|
|
611
|
+
}
|
|
612
|
+
cleanupAudio() {
|
|
613
|
+
this.cancelPlayback();
|
|
614
|
+
this.stopPlaybackMeter();
|
|
615
|
+
safeDisconnect(this.playbackBus);
|
|
616
|
+
this.playbackBus = null;
|
|
617
|
+
this.playbackAnalyser = null;
|
|
618
|
+
this.playbackMeterBuffer = null;
|
|
619
|
+
if (this.micWorklet) {
|
|
620
|
+
this.micWorklet.port.onmessage = null;
|
|
621
|
+
safeDisconnect(this.micWorklet);
|
|
622
|
+
this.micWorklet = null;
|
|
623
|
+
}
|
|
624
|
+
safeDisconnect(this.micSource);
|
|
625
|
+
this.micSource = null;
|
|
626
|
+
stopMediaTracks(this.micStream);
|
|
627
|
+
this.micStream = null;
|
|
628
|
+
if (this.audioContext && this.audioContext.state !== 'closed') {
|
|
629
|
+
try {
|
|
630
|
+
void this.audioContext.close();
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
/* ignore */
|
|
634
|
+
}
|
|
635
|
+
this.audioContext = null;
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Whether a DOM/media error indicates the user denied microphone access. */
|
|
2
|
+
export function isPermissionDeniedError(err) {
|
|
3
|
+
if (!(err instanceof Error))
|
|
4
|
+
return false;
|
|
5
|
+
const name = 'name' in err && typeof err.name === 'string' ? err.name : '';
|
|
6
|
+
return (name === 'NotAllowedError' ||
|
|
7
|
+
name === 'PermissionDeniedError' ||
|
|
8
|
+
/permission|not allowed|denied/i.test(err.message));
|
|
9
|
+
}
|