xo-harness 0.2.0 → 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/README.md +92 -5
- package/dist/browser/worklets/capture-processor.js +34 -0
- package/dist/browser/worklets/playback-processor.js +188 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +1 -0
- package/dist/internal/browser/browser-voice-client.d.ts +33 -0
- package/dist/internal/browser/browser-voice-client.js +316 -0
- package/dist/internal/browser/index.d.ts +2 -0
- package/dist/internal/browser/index.js +1 -0
- package/dist/internal/harness/conversation-context.d.ts +45 -0
- package/dist/internal/harness/conversation-context.js +79 -0
- package/dist/internal/harness/conversation-projection.d.ts +55 -0
- package/dist/internal/harness/conversation-projection.js +145 -0
- package/dist/internal/harness/event-stream.d.ts +23 -2
- package/dist/internal/harness/event-stream.js +148 -18
- package/dist/internal/harness/index.d.ts +7 -1
- package/dist/internal/harness/index.js +7 -1
- package/dist/internal/harness/message.d.ts +24 -11
- package/dist/internal/harness/message.js +271 -77
- package/dist/internal/harness/report-diff.d.ts +3 -0
- package/dist/internal/harness/report-diff.js +10 -0
- package/dist/internal/harness/report.d.ts +17 -14
- package/dist/internal/harness/report.js +41 -24
- package/dist/internal/harness/runtime-limits.d.ts +18 -0
- package/dist/internal/harness/runtime-limits.js +19 -0
- package/dist/internal/harness/session-persistence.d.ts +16 -0
- package/dist/internal/harness/session-persistence.js +106 -0
- package/dist/internal/harness/shadow.d.ts +5 -5
- package/dist/internal/harness/shadow.js +214 -52
- package/dist/internal/harness/socket-bridge.d.ts +45 -4
- package/dist/internal/harness/socket-bridge.js +215 -46
- package/dist/internal/harness/task-supervisor.d.ts +6 -2
- package/dist/internal/harness/task-supervisor.js +97 -15
- package/dist/internal/harness/tool-calls.d.ts +40 -0
- package/dist/internal/harness/tool-calls.js +132 -0
- package/dist/internal/harness/tool-policy.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.d.ts +2 -2
- package/dist/internal/harness/tool-runtime.js +37 -40
- package/dist/internal/harness/tools.d.ts +21 -0
- package/dist/internal/harness/tools.js +7 -1
- package/dist/internal/harness/usage-tracker.d.ts +39 -0
- package/dist/internal/harness/usage-tracker.js +104 -0
- package/dist/internal/harness/voice-session.d.ts +39 -8
- package/dist/internal/harness/voice-session.js +353 -58
- package/dist/internal/harness/xo.d.ts +7 -12
- package/dist/internal/harness/xo.js +25 -13
- package/dist/internal/protocol/async-queue.d.ts +22 -1
- package/dist/internal/protocol/async-queue.js +86 -12
- package/dist/internal/protocol/audio.d.ts +16 -2
- package/dist/internal/protocol/audio.js +23 -5
- package/dist/internal/protocol/backend-output.d.ts +70 -0
- package/dist/internal/protocol/backend-output.js +39 -0
- package/dist/internal/protocol/event-json.d.ts +3 -0
- package/dist/internal/protocol/event-json.js +15 -0
- package/dist/internal/protocol/events.d.ts +342 -4
- package/dist/internal/protocol/events.js +53 -25
- package/dist/internal/protocol/index.d.ts +5 -0
- package/dist/internal/protocol/index.js +5 -0
- package/dist/internal/protocol/output-source.d.ts +18 -0
- package/dist/internal/protocol/output-source.js +15 -0
- package/dist/internal/protocol/paced-audio.d.ts +27 -0
- package/dist/internal/protocol/paced-audio.js +117 -0
- package/dist/internal/protocol/parts.d.ts +154 -0
- package/dist/internal/protocol/parts.js +32 -6
- package/dist/internal/protocol/provider.d.ts +308 -1
- package/dist/internal/protocol/provider.js +102 -3
- package/dist/internal/protocol/records.d.ts +773 -0
- package/dist/internal/protocol/records.js +46 -0
- package/dist/internal/protocol/tools.d.ts +40 -0
- package/dist/internal/protocol/tools.js +12 -0
- package/dist/internal/protocol/transcript.d.ts +19 -0
- package/dist/internal/protocol/transcript.js +53 -0
- package/dist/internal/provider/contract.d.ts +25 -2
- package/dist/internal/provider/event-queue.d.ts +17 -0
- package/dist/internal/provider/event-queue.js +78 -0
- package/dist/internal/provider/grok-voice.d.ts +12 -9
- package/dist/internal/provider/grok-voice.js +26 -15
- package/dist/internal/provider/index.d.ts +1 -0
- package/dist/internal/provider/index.js +1 -0
- package/dist/internal/provider/live-session.d.ts +29 -0
- package/dist/internal/provider/live-session.js +840 -0
- package/dist/internal/provider/node-socket.js +6 -0
- package/dist/internal/provider/openai-live.d.ts +62 -0
- package/dist/internal/provider/openai-live.js +160 -0
- package/dist/internal/provider/openai-realtime.d.ts +9 -5
- package/dist/internal/provider/openai-realtime.js +24 -10
- package/dist/internal/provider/realtime-session.d.ts +12 -1
- package/dist/internal/provider/realtime-session.js +373 -70
- package/dist/internal/provider/realtime-socket.d.ts +3 -1
- package/dist/internal/provider/tool-status-context.d.ts +10 -0
- package/dist/internal/provider/tool-status-context.js +30 -0
- package/dist/internal/provider/workers-socket.js +63 -15
- package/dist/internal/provider/workers.d.ts +1 -0
- package/dist/internal/provider/workers.js +1 -0
- package/dist/internal/provider-fake/replay-voice-provider.d.ts +9 -11
- package/dist/internal/provider-fake/replay-voice-provider.js +47 -23
- package/dist/internal/storage/event-store.d.ts +22 -4
- package/dist/internal/storage/jsonl-event-store.d.ts +4 -4
- package/dist/internal/storage/jsonl-event-store.js +21 -16
- package/dist/internal/storage/memory-event-store.d.ts +4 -3
- package/dist/internal/storage/memory-event-store.js +8 -2
- package/dist/internal/storage/memory.d.ts +1 -1
- package/dist/internal/testkit/events.d.ts +14 -0
- package/dist/internal/testkit/events.js +33 -0
- package/dist/internal/testkit/runtime.d.ts +3 -0
- package/dist/internal/testkit/runtime.js +3 -0
- package/dist/internal/testkit/trajectory.d.ts +23 -0
- package/dist/internal/testkit/trajectory.js +58 -0
- package/dist/internal/tools-openai/index.d.ts +2 -0
- package/dist/internal/tools-openai/index.js +79 -72
- package/dist/internal/tools-openai/responses.d.ts +5 -1
- package/dist/internal/tools-openai/responses.js +53 -5
- package/dist/testing.d.ts +1 -0
- package/dist/testing.js +1 -0
- 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
|
|
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({
|
|
38
|
+
provider: new OpenAIRealtimeVoiceProvider({
|
|
39
|
+
apiKey,
|
|
40
|
+
eventQueueLimits: { maxEvents: 512, maxAudioBytes: 1_440_000 },
|
|
41
|
+
}),
|
|
42
|
+
maxDurationMs: 30_000,
|
|
19
43
|
});
|
|
20
44
|
|
|
21
|
-
|
|
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";
|
package/dist/browser.js
ADDED
|
@@ -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
|
+
}
|