xo-harness 0.2.1 → 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.
Files changed (115) hide show
  1. package/README.md +92 -5
  2. package/dist/browser/worklets/capture-processor.js +34 -0
  3. package/dist/browser/worklets/playback-processor.js +188 -0
  4. package/dist/browser.d.ts +1 -0
  5. package/dist/browser.js +1 -0
  6. package/dist/internal/browser/browser-voice-client.d.ts +33 -0
  7. package/dist/internal/browser/browser-voice-client.js +316 -0
  8. package/dist/internal/browser/index.d.ts +2 -0
  9. package/dist/internal/browser/index.js +1 -0
  10. package/dist/internal/harness/conversation-context.d.ts +45 -0
  11. package/dist/internal/harness/conversation-context.js +79 -0
  12. package/dist/internal/harness/conversation-projection.d.ts +55 -0
  13. package/dist/internal/harness/conversation-projection.js +145 -0
  14. package/dist/internal/harness/event-stream.d.ts +23 -2
  15. package/dist/internal/harness/event-stream.js +148 -18
  16. package/dist/internal/harness/index.d.ts +7 -1
  17. package/dist/internal/harness/index.js +7 -1
  18. package/dist/internal/harness/message.d.ts +24 -11
  19. package/dist/internal/harness/message.js +271 -77
  20. package/dist/internal/harness/report-diff.d.ts +3 -0
  21. package/dist/internal/harness/report-diff.js +10 -0
  22. package/dist/internal/harness/report.d.ts +17 -14
  23. package/dist/internal/harness/report.js +41 -24
  24. package/dist/internal/harness/runtime-limits.d.ts +18 -0
  25. package/dist/internal/harness/runtime-limits.js +19 -0
  26. package/dist/internal/harness/session-persistence.d.ts +16 -0
  27. package/dist/internal/harness/session-persistence.js +106 -0
  28. package/dist/internal/harness/shadow.d.ts +5 -5
  29. package/dist/internal/harness/shadow.js +214 -52
  30. package/dist/internal/harness/socket-bridge.d.ts +45 -4
  31. package/dist/internal/harness/socket-bridge.js +215 -46
  32. package/dist/internal/harness/task-supervisor.d.ts +6 -2
  33. package/dist/internal/harness/task-supervisor.js +97 -15
  34. package/dist/internal/harness/tool-calls.d.ts +40 -0
  35. package/dist/internal/harness/tool-calls.js +132 -0
  36. package/dist/internal/harness/tool-policy.d.ts +2 -2
  37. package/dist/internal/harness/tool-runtime.d.ts +2 -2
  38. package/dist/internal/harness/tool-runtime.js +37 -40
  39. package/dist/internal/harness/tools.d.ts +21 -0
  40. package/dist/internal/harness/tools.js +7 -1
  41. package/dist/internal/harness/usage-tracker.d.ts +39 -0
  42. package/dist/internal/harness/usage-tracker.js +104 -0
  43. package/dist/internal/harness/voice-session.d.ts +39 -8
  44. package/dist/internal/harness/voice-session.js +353 -58
  45. package/dist/internal/harness/xo.d.ts +7 -12
  46. package/dist/internal/harness/xo.js +25 -13
  47. package/dist/internal/protocol/async-queue.d.ts +22 -1
  48. package/dist/internal/protocol/async-queue.js +86 -12
  49. package/dist/internal/protocol/audio.d.ts +16 -2
  50. package/dist/internal/protocol/audio.js +23 -5
  51. package/dist/internal/protocol/backend-output.d.ts +70 -0
  52. package/dist/internal/protocol/backend-output.js +39 -0
  53. package/dist/internal/protocol/event-json.d.ts +3 -0
  54. package/dist/internal/protocol/event-json.js +15 -0
  55. package/dist/internal/protocol/events.d.ts +342 -4
  56. package/dist/internal/protocol/events.js +53 -25
  57. package/dist/internal/protocol/index.d.ts +5 -0
  58. package/dist/internal/protocol/index.js +5 -0
  59. package/dist/internal/protocol/output-source.d.ts +18 -0
  60. package/dist/internal/protocol/output-source.js +15 -0
  61. package/dist/internal/protocol/paced-audio.d.ts +27 -0
  62. package/dist/internal/protocol/paced-audio.js +117 -0
  63. package/dist/internal/protocol/parts.d.ts +154 -0
  64. package/dist/internal/protocol/parts.js +32 -6
  65. package/dist/internal/protocol/provider.d.ts +308 -1
  66. package/dist/internal/protocol/provider.js +102 -3
  67. package/dist/internal/protocol/records.d.ts +773 -0
  68. package/dist/internal/protocol/records.js +46 -0
  69. package/dist/internal/protocol/tools.d.ts +40 -0
  70. package/dist/internal/protocol/tools.js +12 -0
  71. package/dist/internal/protocol/transcript.d.ts +19 -0
  72. package/dist/internal/protocol/transcript.js +53 -0
  73. package/dist/internal/provider/contract.d.ts +25 -2
  74. package/dist/internal/provider/event-queue.d.ts +17 -0
  75. package/dist/internal/provider/event-queue.js +78 -0
  76. package/dist/internal/provider/grok-voice.d.ts +12 -9
  77. package/dist/internal/provider/grok-voice.js +26 -15
  78. package/dist/internal/provider/index.d.ts +1 -0
  79. package/dist/internal/provider/index.js +1 -0
  80. package/dist/internal/provider/live-session.d.ts +29 -0
  81. package/dist/internal/provider/live-session.js +840 -0
  82. package/dist/internal/provider/node-socket.js +6 -0
  83. package/dist/internal/provider/openai-live.d.ts +62 -0
  84. package/dist/internal/provider/openai-live.js +160 -0
  85. package/dist/internal/provider/openai-realtime.d.ts +9 -5
  86. package/dist/internal/provider/openai-realtime.js +24 -10
  87. package/dist/internal/provider/realtime-session.d.ts +12 -1
  88. package/dist/internal/provider/realtime-session.js +369 -68
  89. package/dist/internal/provider/realtime-socket.d.ts +3 -1
  90. package/dist/internal/provider/tool-status-context.d.ts +10 -0
  91. package/dist/internal/provider/tool-status-context.js +30 -0
  92. package/dist/internal/provider/workers-socket.js +63 -15
  93. package/dist/internal/provider/workers.d.ts +1 -0
  94. package/dist/internal/provider/workers.js +1 -0
  95. package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
  96. package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
  97. package/dist/internal/storage/event-store.d.ts +22 -4
  98. package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
  99. package/dist/internal/storage/jsonl-event-store.js +21 -16
  100. package/dist/internal/storage/memory-event-store.d.ts +4 -3
  101. package/dist/internal/storage/memory-event-store.js +8 -2
  102. package/dist/internal/storage/memory.d.ts +1 -1
  103. package/dist/internal/testkit/events.d.ts +14 -0
  104. package/dist/internal/testkit/events.js +33 -0
  105. package/dist/internal/testkit/runtime.d.ts +3 -0
  106. package/dist/internal/testkit/runtime.js +3 -0
  107. package/dist/internal/testkit/trajectory.d.ts +23 -0
  108. package/dist/internal/testkit/trajectory.js +58 -0
  109. package/dist/internal/tools-openai/index.d.ts +2 -0
  110. package/dist/internal/tools-openai/index.js +79 -72
  111. package/dist/internal/tools-openai/responses.d.ts +5 -1
  112. package/dist/internal/tools-openai/responses.js +53 -5
  113. package/dist/testing.d.ts +1 -0
  114. package/dist/testing.js +1 -0
  115. package/package.json +7 -1
package/README.md CHANGED
@@ -4,28 +4,78 @@
4
4
  contains the harness, provider adapters, durable stores, shared protocol, and deterministic testing
5
5
  providers.
6
6
 
7
+ GPT Live is an opt-in adapter in 0.3.0. Real tests found occasional duplicate typed greetings and
8
+ incorrect spoken associations despite correct tool execution; captions are not proof of playback.
9
+ Keep verified tool outcomes authoritative and evaluate your application's prompts and audio path.
10
+ See the repository's [production evidence and limits](https://github.com/zvadaadam/XO/blob/main/docs/production-readiness.md).
11
+
7
12
  ```sh
8
13
  npm install xo-harness
9
14
  ```
10
15
 
16
+ This Node smoke test opens Realtime, observes one assistant response, and closes the session.
17
+ The explicit limits are starting values to measure for your application, not a production preset.
18
+
11
19
  ```ts
12
20
  import { XO } from "xo-harness/harness";
13
21
  import { OpenAIRealtimeVoiceProvider } from "xo-harness/provider";
14
22
  import { JsonlEventStore } from "xo-harness/storage";
15
23
 
16
- const harness = new XO({ store: new JsonlEventStore(".xo/sessions") });
24
+ const apiKey = process.env.OPENAI_API_KEY;
25
+ if (!apiKey) throw new Error("Set OPENAI_API_KEY");
26
+ const harness = new XO({
27
+ // Keep conversation evidence and audio timing, without storing a recording.
28
+ store: new JsonlEventStore(".xo/sessions", { audioRetention: "metadata" }),
29
+ runtimeLimits: {
30
+ maxReplayEvents: 2_000,
31
+ maxReplayAudioBytes: 2 * 1024 * 1024,
32
+ maxPendingAppends: 64,
33
+ maxPendingAudioBytes: 512 * 1024,
34
+ persistenceTimeoutMs: 2_000,
35
+ },
36
+ });
17
37
  const session = await harness.startSession({
18
- provider: new OpenAIRealtimeVoiceProvider({ apiKey: process.env.OPENAI_API_KEY! }),
38
+ provider: new OpenAIRealtimeVoiceProvider({
39
+ apiKey,
40
+ eventQueueLimits: { maxEvents: 512, maxAudioBytes: 1_440_000 },
41
+ }),
42
+ maxDurationMs: 30_000,
19
43
  });
20
44
 
21
- // The model can open from its existing instructions/context; no fake user turn is added.
22
- await session.requestAssistantTurn({ instructions: "Welcome the caller in one sentence." });
45
+ try {
46
+ await session.requestAssistantTurn({ instructions: "Welcome the caller in one sentence." });
47
+ let receivedAssistantTranscript = false;
48
+ for await (const event of session.events()) {
49
+ console.log(event.sequence, event.type);
50
+ if (event.type === "transcript" && event.role === "assistant") receivedAssistantTranscript = true;
51
+ // Realtime's final response/usage follows its final transcript.
52
+ if (receivedAssistantTranscript && event.type === "response.state" && event.state === "idle") break;
53
+ }
54
+ } finally {
55
+ await session.close("example_finished");
56
+ }
23
57
  ```
24
58
 
59
+ This example observes generated output; it does not open a microphone or play audio. For a real
60
+ client, use the [browser transport and complete application lifecycle](https://github.com/zvadaadam/XO/blob/main/docs/integrating.md).
61
+ GPT Live uses continuous audio: enable forwarding, then append opening instructions after the first
62
+ input frame. Wait for their matching `context.delivery` acknowledgement, check the opening signal,
63
+ and append the commentary cue “Begin the conversation now, following the instructions provided.”
64
+ Keep input audio running while waiting; `appendContext()` itself confirms submission only. See the
65
+ integration guide for failure and cancellation handling. This differs from Realtime's `requestAssistantTurn`.
66
+
67
+ Library defaults preserve full audio storage and unlimited replay/storage waits for compatibility.
68
+ Metadata retention and runtime limits are independent: omitting recordings does not bound live
69
+ replay. Lagging readers fail explicitly and require source recovery; metadata is not playable audio.
70
+ See [runtime limits](https://github.com/zvadaadam/XO/blob/main/docs/runtime-limits.md) before hosting
71
+ long calls or allowing concurrent draining sessions.
72
+
25
73
  Additional entry points are available for focused imports:
26
74
 
27
75
  - `xo-harness/protocol`
28
76
  - `xo-harness/harness`
77
+ - `xo-harness/browser` (optional microphone/playback client; explicit socket and worklet URLs)
78
+ - `xo-harness/browser/worklets/capture-processor.js` and `.../playback-processor.js` (serve these assets)
29
79
  - `xo-harness/provider`
30
80
  - `xo-harness/provider/workers`
31
81
  - `xo-harness/storage`
@@ -33,7 +83,13 @@ Additional entry points are available for focused imports:
33
83
  - `xo-harness/skills` (catalogs, bundled skills, and on-demand reading tools)
34
84
  - `xo-harness/skills/node` (filesystem discovery)
35
85
  - `xo-harness/tools/openai` (web search and background reasoning with `createOpenAITools`)
36
- - `xo-harness/testing`
86
+ - `xo-harness/testing` (fake/scripted/replay providers, `waitForEvent`, `runTrajectory`; portable)
87
+
88
+ For Cloudflare Workers, use `xo-harness/provider/workers` and `xo-harness/storage/memory`, or supply
89
+ a durable `EventStore` for your database. The root entry and `xo-harness/storage` include Node JSONL
90
+ storage; use focused imports in portable code. `MemoryEventStore({ audioRetention: "metadata" })`
91
+ stores no PCM but remains volatile and retains growing metadata. For portable tool modules, declare
92
+ `zod` as a dependency and import `z` from it rather than from the Node-oriented package root.
37
93
 
38
94
  Skills follow the standard `SKILL.md` format. Compose `createSkillTools(skills)` with your application
39
95
  tools; reads use the existing admission policy and durable event log. See the repository's
@@ -48,5 +104,36 @@ work on session close. Voice and worker reasoning efforts are independent. See t
48
104
  Tool results are delivered in full by default. `maxToolResultBytes` opts into string previews for
49
105
  oversized results; finite values must be integers of at least 128 bytes, excluding provider envelopes.
50
106
 
107
+ The harness exposes frozen `session.capabilities`, input submission receipts, targeted
108
+ `session.cancelTask`, and opt-in `tasks.startOutcome` for authoritative worker outcomes. The
109
+ `ConversationProjector` adds ordered persistence changes around the existing message fold;
110
+ `SessionUsageTracker` shares accounting with reports. Applications own database transactions,
111
+ durable worker jobs, memory extraction, and billing.
112
+
113
+ To continue in a new session, replay stored `HarnessRecord`s through `ConversationProjector`, then
114
+ use `selectConversationContext(snapshot, { maxMessages, maxTextBytes, unconfirmedText })`. It preserves
115
+ roles, text and provenance and reports omitted parts; map its selected text to the provider's startup
116
+ input. Live captions require an explicit choice about unconfirmed text. Byte budgets are not token
117
+ limits, and selecting history neither restores provider state nor reruns tools. See the
118
+ [executable restart example](https://github.com/zvadaadam/XO/blob/main/examples/integration/README.md).
119
+
120
+ `sendText` supports strings/text-only parts; images and files must use the application backend.
121
+ Browser callers must wait for `onStarted` and catch synchronous submission errors. See the
122
+ [integration guide](https://github.com/zvadaadam/XO/blob/main/docs/integrating.md) for startup signals,
123
+ graceful close, receipts, and migration details.
124
+
125
+ Before exposing a `VoiceSocketBridge` to clients, supply a product event allowlist through
126
+ `shouldSendEvent`. Without a predicate, JSON events include tool payloads and startup instructions.
127
+ Filtering leaves the durable log, server observers, audio and bridge controls intact. Authenticate
128
+ the caller and select permitted tools/instructions on the server; the SDK does not choose that policy.
129
+
130
+ For a simple app, register ordinary memory/search functions directly. The optional
131
+ `createOpenAITools({ apiKey, tools: ["web_search"] })` supplies only OpenAI-backed search, without a
132
+ background worker. Voice adapters and the core harness do not depend on that tool bundle.
133
+
134
+ Test real application tools through `runTrajectory` and `waitForEvent` instead of hand-building
135
+ execution contexts. The [SDK testing guide](https://github.com/zvadaadam/XO/blob/main/docs/testing.md)
136
+ shows the portable helpers and the simple voice/optional worker contract scenarios.
137
+
51
138
  See the [XO repository](https://github.com/zvadaadam/XO) for the complete documentation and voice
52
139
  application.
@@ -0,0 +1,34 @@
1
+ // Captures mono microphone audio and posts 100 ms Int16 PCM frames to the main thread.
2
+ const FRAME_SAMPLES = 2400;
3
+
4
+ class CaptureProcessor extends AudioWorkletProcessor {
5
+ constructor() {
6
+ super();
7
+ this.buffer = new Float32Array(FRAME_SAMPLES);
8
+ this.filled = 0;
9
+ }
10
+
11
+ process(inputs) {
12
+ const channel = inputs[0]?.[0];
13
+ if (!channel) return true;
14
+ let offset = 0;
15
+ while (offset < channel.length) {
16
+ const take = Math.min(channel.length - offset, FRAME_SAMPLES - this.filled);
17
+ this.buffer.set(channel.subarray(offset, offset + take), this.filled);
18
+ this.filled += take;
19
+ offset += take;
20
+ if (this.filled === FRAME_SAMPLES) {
21
+ const pcm = new Int16Array(FRAME_SAMPLES);
22
+ for (let i = 0; i < FRAME_SAMPLES; i += 1) {
23
+ const sample = Math.max(-1, Math.min(1, this.buffer[i]));
24
+ pcm[i] = Math.round(sample * 32767);
25
+ }
26
+ this.port.postMessage(pcm.buffer, [pcm.buffer]);
27
+ this.filled = 0;
28
+ }
29
+ }
30
+ return true;
31
+ }
32
+ }
33
+
34
+ registerProcessor("xo-capture", CaptureProcessor);
@@ -0,0 +1,188 @@
1
+ // Plays queued PCM16 model audio and reports played-through sample positions per stream.
2
+ // Barge-in clears buffered audio immediately, without waiting for another model response.
3
+ const REPORT_INTERVAL_SAMPLES = 6000;
4
+ const CONTINUOUS_RESERVE_SAMPLES = Math.ceil(sampleRate * 0.2);
5
+
6
+ class PlaybackProcessor extends AudioWorkletProcessor {
7
+ constructor() {
8
+ super();
9
+ this.queue = [];
10
+ this.queuedSamples = 0;
11
+ this.failed = false;
12
+ this.playedByStream = new Map();
13
+ this.interruptedStreams = new Set();
14
+ this.sinceReport = 0;
15
+ this.ranges = false;
16
+ this.pendingPlayed = new Map();
17
+ this.buffering = false;
18
+ this.bufferingWaited = 0;
19
+ this.underrunStream = undefined;
20
+ this.port.onmessage = (event) => {
21
+ const message = event.data;
22
+ if (message.type === "configure") {
23
+ this.ranges = message.ranges === true;
24
+ // Continuous output arrives at roughly playback speed, so delivery jitter
25
+ // needs a small reserve. Response audio can still start immediately.
26
+ this.buffering = this.ranges;
27
+ this.bufferingWaited = 0;
28
+ } else if (message.type === "chunk") {
29
+ if (this.failed || this.interruptedStreams.has(message.streamId) || message.pcm.byteLength === 0)
30
+ return;
31
+ if (this.queuedSamples + message.pcm.byteLength / 2 > sampleRate * 30) {
32
+ for (const streamId of new Set(this.queue.map((entry) => entry.streamId))) this.report(streamId);
33
+ this.queue = [];
34
+ this.queuedSamples = 0;
35
+ this.failed = true;
36
+ this.port.postMessage({
37
+ type: "playout.error",
38
+ message: "Playback is more than 30 seconds behind",
39
+ });
40
+ return;
41
+ }
42
+ if (!this.ranges && !this.playedByStream.has(message.streamId)) {
43
+ // Declare known zero playout before the first render, so immediate
44
+ // barge-in is distinguishable from a legacy client with no receipts.
45
+ this.playedByStream.set(message.streamId, 0);
46
+ this.report(message.streamId);
47
+ }
48
+ const pcm = new Int16Array(message.pcm);
49
+ const samples = new Float32Array(pcm.length);
50
+ for (let i = 0; i < pcm.length; i += 1) samples[i] = pcm[i] / 32768;
51
+ this.queue.push({ streamId: message.streamId, startSample: message.startSample, samples, offset: 0 });
52
+ this.queuedSamples += samples.length;
53
+ } else if (message.type === "interrupt") {
54
+ this.interrupt(message.streamId);
55
+ } else if (message.type === "discard") {
56
+ if (!this.ranges) {
57
+ // A response receipt describes a contiguous played prefix, so it cannot resume after a gap.
58
+ for (const streamId of new Set(this.queue.map((entry) => entry.streamId))) this.interrupt(streamId);
59
+ return;
60
+ }
61
+ for (const streamId of this.pendingPlayed.keys()) this.report(streamId);
62
+ for (const entry of this.queue) {
63
+ this.port.postMessage({
64
+ type: "playout.range",
65
+ streamId: entry.streamId,
66
+ startSample: entry.startSample + entry.offset,
67
+ endSample: entry.startSample + entry.samples.length,
68
+ status: "discarded",
69
+ });
70
+ }
71
+ this.queue = [];
72
+ this.queuedSamples = 0;
73
+ this.buffering = true;
74
+ this.bufferingWaited = 0;
75
+ this.underrunStream = undefined;
76
+ } else if (message.type === "reset") {
77
+ this.queue = [];
78
+ this.queuedSamples = 0;
79
+ this.failed = false;
80
+ this.playedByStream.clear();
81
+ this.interruptedStreams.clear();
82
+ this.sinceReport = 0;
83
+ this.pendingPlayed.clear();
84
+ this.buffering = this.ranges;
85
+ this.bufferingWaited = 0;
86
+ this.underrunStream = undefined;
87
+ }
88
+ };
89
+ }
90
+
91
+ interrupt(streamId) {
92
+ this.interruptedStreams.add(streamId);
93
+ this.queue = this.queue.filter((entry) => entry.streamId !== streamId);
94
+ this.queuedSamples = this.queue.reduce((total, entry) => total + entry.samples.length - entry.offset, 0);
95
+ if (this.ranges && this.queue.length === 0) {
96
+ this.buffering = true;
97
+ this.bufferingWaited = 0;
98
+ }
99
+ if (this.underrunStream === streamId) this.underrunStream = undefined;
100
+ this.report(streamId);
101
+ }
102
+
103
+ report(streamId) {
104
+ if (this.ranges) {
105
+ const range = this.pendingPlayed.get(streamId);
106
+ if (range) this.port.postMessage({ type: "playout.range", streamId, ...range, status: "played" });
107
+ this.pendingPlayed.delete(streamId);
108
+ return;
109
+ }
110
+ const played = this.playedByStream.get(streamId) ?? 0;
111
+ this.port.postMessage({ type: "playout", streamId, playedThroughSample: played });
112
+ }
113
+
114
+ process(_inputs, outputs) {
115
+ const output = outputs[0]?.[0];
116
+ if (!output) return true;
117
+ if (this.buffering) {
118
+ const queued = this.queuedSamples;
119
+ // A finite tail still plays if the source stops before filling the reserve.
120
+ if (
121
+ queued === 0 ||
122
+ (queued < CONTINUOUS_RESERVE_SAMPLES && this.bufferingWaited < CONTINUOUS_RESERVE_SAMPLES)
123
+ ) {
124
+ this.bufferingWaited = queued === 0 ? 0 : this.bufferingWaited + output.length;
125
+ output.fill(0);
126
+ return true;
127
+ }
128
+ this.buffering = false;
129
+ this.bufferingWaited = 0;
130
+ if (this.underrunStream !== undefined) {
131
+ this.port.postMessage({
132
+ type: "playout.rebuffered",
133
+ streamId: this.underrunStream,
134
+ bufferedMs: (queued / sampleRate) * 1000,
135
+ });
136
+ this.underrunStream = undefined;
137
+ }
138
+ }
139
+ let written = 0;
140
+ let streamId;
141
+ while (written < output.length && this.queue.length > 0) {
142
+ const head = this.queue[0];
143
+ streamId = head.streamId;
144
+ const take = Math.min(output.length - written, head.samples.length - head.offset);
145
+ if (this.ranges) {
146
+ const startSample = head.startSample + head.offset;
147
+ const previous = this.pendingPlayed.get(head.streamId);
148
+ if (previous && previous.endSample !== startSample) this.report(head.streamId);
149
+ this.pendingPlayed.set(head.streamId, {
150
+ startSample: this.pendingPlayed.get(head.streamId)?.startSample ?? startSample,
151
+ endSample: startSample + take,
152
+ });
153
+ }
154
+ output.set(head.samples.subarray(head.offset, head.offset + take), written);
155
+ head.offset += take;
156
+ this.queuedSamples -= take;
157
+ written += take;
158
+ this.playedByStream.set(
159
+ head.streamId,
160
+ Math.max(this.playedByStream.get(head.streamId) ?? 0, head.startSample + head.offset),
161
+ );
162
+ this.sinceReport += take;
163
+ if (head.offset === head.samples.length) {
164
+ this.queue.shift();
165
+ if (this.queue.length === 0 || this.queue[0].streamId !== head.streamId) {
166
+ this.report(head.streamId);
167
+ this.sinceReport = 0;
168
+ }
169
+ }
170
+ }
171
+ if (written < output.length) {
172
+ output.fill(0, written);
173
+ if (this.ranges) {
174
+ this.buffering = true;
175
+ this.bufferingWaited = 0;
176
+ this.underrunStream = streamId ?? [...this.playedByStream.keys()].at(-1);
177
+ this.port.postMessage({ type: "playout.underrun", streamId: this.underrunStream });
178
+ }
179
+ }
180
+ if (this.sinceReport >= REPORT_INTERVAL_SAMPLES && this.queue.length > 0) {
181
+ this.report(this.queue[0].streamId);
182
+ this.sinceReport = 0;
183
+ }
184
+ return true;
185
+ }
186
+ }
187
+
188
+ registerProcessor("xo-playback", PlaybackProcessor);
@@ -0,0 +1 @@
1
+ export * from "./internal/browser/index.js";
@@ -0,0 +1 @@
1
+ export * from "./internal/browser/index.js";
@@ -0,0 +1,33 @@
1
+ import type { VoiceStartRequest } from "../harness/index.js";
2
+ export interface BrowserVoiceClientCallbacks {
3
+ onEvent(event: Record<string, unknown>): void;
4
+ onStarted(sessionId: string): void;
5
+ onError(message: string): void;
6
+ onClosed(reason: string): void;
7
+ onStats(delta: {
8
+ sentBytes?: number;
9
+ receivedBytes?: number;
10
+ }): void;
11
+ }
12
+ export interface BrowserVoiceClientOptions {
13
+ socketUrl: string | URL;
14
+ start: VoiceStartRequest;
15
+ workletUrls: {
16
+ capture: string | URL;
17
+ playback: string | URL;
18
+ };
19
+ /** Cancels pending setup only. After start resolves, use stop() for a graceful hang-up. */
20
+ signal?: AbortSignal;
21
+ }
22
+ /** One live voice conversation: microphone up over the socket, model audio down to the speakers. */
23
+ export declare class BrowserVoiceClient {
24
+ #private;
25
+ private constructor();
26
+ /** Resolves after submitting start locally; onStarted reports the server's readiness frame. */
27
+ static start(options: BrowserVoiceClientOptions, callbacks: BrowserVoiceClientCallbacks): Promise<BrowserVoiceClient>;
28
+ stop(): Promise<void>;
29
+ /** Submits locally after onStarted; throws if the session cannot accept the message. */
30
+ sendText(text: string): void;
31
+ setMicrophoneMuted(muted: boolean): void;
32
+ discardPlayback(): void;
33
+ }