hci-atrium 0.1.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 +182 -0
- package/dist/atrium.d.ts +522 -0
- package/dist/atrium.d.ts.map +1 -0
- package/dist/atrium.js +806 -0
- package/dist/atrium.js.map +1 -0
- package/dist/auth.d.ts +85 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +369 -0
- package/dist/auth.js.map +1 -0
- package/dist/by.d.ts +84 -0
- package/dist/by.d.ts.map +1 -0
- package/dist/by.js +87 -0
- package/dist/by.js.map +1 -0
- package/dist/decider.d.ts +40 -0
- package/dist/decider.d.ts.map +1 -0
- package/dist/decider.js +76 -0
- package/dist/decider.js.map +1 -0
- package/dist/discovery.d.ts +15 -0
- package/dist/discovery.d.ts.map +1 -0
- package/dist/discovery.js +38 -0
- package/dist/discovery.js.map +1 -0
- package/dist/env.d.ts +11 -0
- package/dist/env.d.ts.map +1 -0
- package/dist/env.js +28 -0
- package/dist/env.js.map +1 -0
- package/dist/errors.d.ts +67 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +86 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +7 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +2 -0
- package/dist/files.js.map +1 -0
- package/dist/http.d.ts +11 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +21 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/models.d.ts +456 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +487 -0
- package/dist/models.js.map +1 -0
- package/dist/pkce.d.ts +16 -0
- package/dist/pkce.d.ts.map +1 -0
- package/dist/pkce.js +40 -0
- package/dist/pkce.js.map +1 -0
- package/dist/react/index.d.ts +37 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +89 -0
- package/dist/react/index.js.map +1 -0
- package/dist/rooms/armed-set.d.ts +33 -0
- package/dist/rooms/armed-set.d.ts.map +1 -0
- package/dist/rooms/armed-set.js +65 -0
- package/dist/rooms/armed-set.js.map +1 -0
- package/dist/rooms/brokers.d.ts +62 -0
- package/dist/rooms/brokers.d.ts.map +1 -0
- package/dist/rooms/brokers.js +109 -0
- package/dist/rooms/brokers.js.map +1 -0
- package/dist/rooms/codec.d.ts +100 -0
- package/dist/rooms/codec.d.ts.map +1 -0
- package/dist/rooms/codec.js +101 -0
- package/dist/rooms/codec.js.map +1 -0
- package/dist/rooms/index.d.ts +40 -0
- package/dist/rooms/index.d.ts.map +1 -0
- package/dist/rooms/index.js +45 -0
- package/dist/rooms/index.js.map +1 -0
- package/dist/rooms/offer-capture.d.ts +96 -0
- package/dist/rooms/offer-capture.d.ts.map +1 -0
- package/dist/rooms/offer-capture.js +338 -0
- package/dist/rooms/offer-capture.js.map +1 -0
- package/dist/rooms/offer-transcription.d.ts +63 -0
- package/dist/rooms/offer-transcription.d.ts.map +1 -0
- package/dist/rooms/offer-transcription.js +231 -0
- package/dist/rooms/offer-transcription.js.map +1 -0
- package/dist/rooms/participant.d.ts +35 -0
- package/dist/rooms/participant.d.ts.map +1 -0
- package/dist/rooms/participant.js +53 -0
- package/dist/rooms/participant.js.map +1 -0
- package/dist/rooms/recorder.d.ts +74 -0
- package/dist/rooms/recorder.d.ts.map +1 -0
- package/dist/rooms/recorder.js +157 -0
- package/dist/rooms/recorder.js.map +1 -0
- package/dist/rooms/room.d.ts +542 -0
- package/dist/rooms/room.d.ts.map +1 -0
- package/dist/rooms/room.js +1357 -0
- package/dist/rooms/room.js.map +1 -0
- package/dist/rooms/snippet.d.ts +48 -0
- package/dist/rooms/snippet.d.ts.map +1 -0
- package/dist/rooms/snippet.js +70 -0
- package/dist/rooms/snippet.js.map +1 -0
- package/dist/rooms/sse.d.ts +102 -0
- package/dist/rooms/sse.d.ts.map +1 -0
- package/dist/rooms/sse.js +347 -0
- package/dist/rooms/sse.js.map +1 -0
- package/dist/rooms/standing-source.d.ts +190 -0
- package/dist/rooms/standing-source.d.ts.map +1 -0
- package/dist/rooms/standing-source.js +431 -0
- package/dist/rooms/standing-source.js.map +1 -0
- package/dist/rooms/transcriber.d.ts +81 -0
- package/dist/rooms/transcriber.d.ts.map +1 -0
- package/dist/rooms/transcriber.js +111 -0
- package/dist/rooms/transcriber.js.map +1 -0
- package/dist/rooms/transport-shared.d.ts +28 -0
- package/dist/rooms/transport-shared.d.ts.map +1 -0
- package/dist/rooms/transport-shared.js +42 -0
- package/dist/rooms/transport-shared.js.map +1 -0
- package/dist/rooms/transport.d.ts +161 -0
- package/dist/rooms/transport.d.ts.map +1 -0
- package/dist/rooms/transport.js +258 -0
- package/dist/rooms/transport.js.map +1 -0
- package/dist/shaping/assets.d.ts +42 -0
- package/dist/shaping/assets.d.ts.map +1 -0
- package/dist/shaping/assets.js +77 -0
- package/dist/shaping/assets.js.map +1 -0
- package/dist/shaping/chat.d.ts +39 -0
- package/dist/shaping/chat.d.ts.map +1 -0
- package/dist/shaping/chat.js +62 -0
- package/dist/shaping/chat.js.map +1 -0
- package/dist/shaping/index.d.ts +14 -0
- package/dist/shaping/index.d.ts.map +1 -0
- package/dist/shaping/index.js +13 -0
- package/dist/shaping/index.js.map +1 -0
- package/dist/shaping/library.d.ts +84 -0
- package/dist/shaping/library.d.ts.map +1 -0
- package/dist/shaping/library.js +288 -0
- package/dist/shaping/library.js.map +1 -0
- package/dist/shaping/request.d.ts +47 -0
- package/dist/shaping/request.d.ts.map +1 -0
- package/dist/shaping/request.js +25 -0
- package/dist/shaping/request.js.map +1 -0
- package/dist/shaping/rooms.d.ts +128 -0
- package/dist/shaping/rooms.d.ts.map +1 -0
- package/dist/shaping/rooms.js +330 -0
- package/dist/shaping/rooms.js.map +1 -0
- package/dist/shaping/sources.d.ts +29 -0
- package/dist/shaping/sources.d.ts.map +1 -0
- package/dist/shaping/sources.js +51 -0
- package/dist/shaping/sources.js.map +1 -0
- package/dist/shaping/structured.d.ts +47 -0
- package/dist/shaping/structured.d.ts.map +1 -0
- package/dist/shaping/structured.js +115 -0
- package/dist/shaping/structured.js.map +1 -0
- package/dist/storage.d.ts +36 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +86 -0
- package/dist/storage.js.map +1 -0
- package/dist/transcribe-stream.d.ts +81 -0
- package/dist/transcribe-stream.d.ts.map +1 -0
- package/dist/transcribe-stream.js +253 -0
- package/dist/transcribe-stream.js.map +1 -0
- package/dist/transcribe.d.ts +57 -0
- package/dist/transcribe.d.ts.map +1 -0
- package/dist/transcribe.js +267 -0
- package/dist/transcribe.js.map +1 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +56 -0
|
@@ -0,0 +1,1357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The live room handle — pure transport over the room WebSocket. Returned by ``client.rooms.open`` /
|
|
3
|
+
* ``client.rooms.join(code)`` / the loginless {@link joinRoom}. Feature-equivalent to the Python
|
|
4
|
+
* SDK's ``Room`` (rooms.py) + ``AsyncRoom`` (aio.py), but JS needs no sync/async split — there is one
|
|
5
|
+
* API: ``room.on(...)`` registers, ``await room.request(...)`` correlates.
|
|
6
|
+
*
|
|
7
|
+
* Transport: everything rides one WebSocket (``/v1/rooms/{id}/ws``) — text signals *and* the
|
|
8
|
+
* ephemeral binary audio stream. That differs from the Python SDK (SSE receive + a REST ``/signal``
|
|
9
|
+
* POST); it's the extract-from-use path (the Player/tools already speak WS) and the only transport
|
|
10
|
+
* that carries binary. The REST ``/signal`` wire body is still the canonical cross-SDK shape
|
|
11
|
+
* ({@link ../shaping/rooms.buildRoomSignal}); the fields match field-for-field.
|
|
12
|
+
*
|
|
13
|
+
* Correlation (mirror of ``_RoomDispatcher``): dispatch starts with {@link connect}. Each inbound
|
|
14
|
+
* signal routes to the handler registered for its ``type``; a handler's return value is sent back as
|
|
15
|
+
* the response when the signal was a {@link request} (carried an ``ackId``) — so a responder writes
|
|
16
|
+
* no correlation plumbing, it just returns. Pending {@link request}s resolve by ``replyTo``.
|
|
17
|
+
*
|
|
18
|
+
* Durable & reactive surfaces (wave 2): {@link Room.record} bridges the room into a session over an
|
|
19
|
+
* SSE-observe {@link ./recorder.Recorder} (every WS send is ephemeral, so durable capture rides the
|
|
20
|
+
* observe stream); {@link Room.audioVectors} embeds the live snippet stream and {@link Room.audioScores}
|
|
21
|
+
* fuses it against named anchors for a {@link ../decider.Decider}. Still deferred to later waves: the
|
|
22
|
+
* ``offer_embeds`` / ``offer_chat`` broker protocol, and ``offerAudioCapture`` (which will bind to the
|
|
23
|
+
* active recorder via {@link Room.activeRecorders} / {@link Room.onRecordersChanged}).
|
|
24
|
+
*/
|
|
25
|
+
import { by, toAnchorMembers } from "../by.js";
|
|
26
|
+
import { AtriumError } from "../errors.js";
|
|
27
|
+
import { asError, isBrowser } from "../http.js";
|
|
28
|
+
import { buildLayerClearBody, buildLayerEffectsBody, buildLayerPositionBody, buildPlayBody, buildRoomRotateCode, buildRoomSignal, buildShowBody, } from "../shaping/rooms.js";
|
|
29
|
+
import { BrokerError, CAPTURE_OFFER_ROLE, CHAT_BROKER_ROLE, DEFAULT_CHAT_MAX_TOKENS, DEFAULT_MAX_ANCHORS, EMBED_BROKER_ROLE, parseAnchorDescriptor, TRANSCRIPTION_OFFER_ROLE, validateChatMessages, } from "./brokers.js";
|
|
30
|
+
import { AUDIO_ROLE, AUDIO_SIGNAL, decodeAudioFrame, } from "./codec.js";
|
|
31
|
+
import { OfferedCapture } from "./offer-capture.js";
|
|
32
|
+
import { OfferedTranscription } from "./offer-transcription.js";
|
|
33
|
+
import { parseParticipant, participantMatches, } from "./participant.js";
|
|
34
|
+
import { Recorder } from "./recorder.js";
|
|
35
|
+
import { AUDIO_SNIPPET_CONSUMER_ROLE, decodeAudioSnippetFrame, } from "./snippet.js";
|
|
36
|
+
import { RoomEventStream, readObserveRoster } from "./sse.js";
|
|
37
|
+
import { TRANSCRIBER_SOURCE, Transcriber } from "./transcriber.js";
|
|
38
|
+
import { RoomSocket, } from "./transport.js";
|
|
39
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;
|
|
40
|
+
// A raw audio-consumer socket ({@link Room.audioClips} / {@link Room.snippetStream}) buffers decoded
|
|
41
|
+
// frames for a consumer that pulls in its own time; a slow consumer must not grow this without bound,
|
|
42
|
+
// so it drops the OLDEST buffered frame past this cap (live audio favours recency — mirror of the
|
|
43
|
+
// offers' per-source ClipQueue). A prompt consumer never reaches it.
|
|
44
|
+
const RAW_CONSUMER_QUEUE_MAX = 32;
|
|
45
|
+
function toRoomEvent(sig) {
|
|
46
|
+
return {
|
|
47
|
+
type: sig.type ?? "",
|
|
48
|
+
payload: sig.payload && typeof sig.payload === "object"
|
|
49
|
+
? sig.payload
|
|
50
|
+
: {},
|
|
51
|
+
source: sig.source ?? null,
|
|
52
|
+
from: sig.from ?? null,
|
|
53
|
+
to: sig.to ?? null,
|
|
54
|
+
ackId: sig.ack_id ?? null,
|
|
55
|
+
replyTo: sig.reply_to ?? null,
|
|
56
|
+
ephemeral: Boolean(sig.ephemeral),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/** A random opaque id (a UUID with dashes stripped, else a time-seeded fallback) — request ack ids
|
|
60
|
+
* and dedicated-consumer socket identities. Shared with the standing offers ({@link ./standing-source}). */
|
|
61
|
+
export function randomId() {
|
|
62
|
+
const c = globalThis.crypto;
|
|
63
|
+
if (c?.randomUUID)
|
|
64
|
+
return c.randomUUID().replace(/-/g, "");
|
|
65
|
+
return Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
66
|
+
}
|
|
67
|
+
/** Open a URL in a new browser tab (``window.open``). Throws outside a browser — there is no tab to
|
|
68
|
+
* open (the SDK is browser-safe, so it guards on {@link isBrowser} rather than assume ``window``). */
|
|
69
|
+
function openTab(url) {
|
|
70
|
+
if (!isBrowser() || typeof window.open !== "function") {
|
|
71
|
+
throw new Error(`cannot open a browser tab outside a browser — use the URL directly: ${url}`);
|
|
72
|
+
}
|
|
73
|
+
window.open(url, "_blank", "noopener");
|
|
74
|
+
}
|
|
75
|
+
export class Room {
|
|
76
|
+
id;
|
|
77
|
+
connectCode;
|
|
78
|
+
name;
|
|
79
|
+
projectId;
|
|
80
|
+
deps;
|
|
81
|
+
socket = null;
|
|
82
|
+
// The single room-level transport decision, latched at connect: true once the signal channel fell
|
|
83
|
+
// back to (or was pinned to) SSE. Binary audio is WS-only, so every audio consumer AND standing
|
|
84
|
+
// offer CONSULTS this (via requireWsAudio) and refuses fast rather than spinning a doomed WS
|
|
85
|
+
// reconnect — signals and audio agree on the environment. The latch is per-handle (a fresh Room
|
|
86
|
+
// re-probes).
|
|
87
|
+
usingSse = false;
|
|
88
|
+
connectPromise = null;
|
|
89
|
+
handlers = new Map();
|
|
90
|
+
anyHandlers = new Set();
|
|
91
|
+
pending = new Map();
|
|
92
|
+
// Terminal-end fan-out: listeners registered via `onEnd`, fired exactly once from `handleEnd`
|
|
93
|
+
// (the transport's typed classification), guarded by `ended` so a doubled transport callback or a
|
|
94
|
+
// close() racing the end can't fire them twice.
|
|
95
|
+
ended = false;
|
|
96
|
+
endListeners = new Set();
|
|
97
|
+
audioSubscribers = new Set();
|
|
98
|
+
// Audio consumption rides a DEDICATED socket that announces `role: audio-consumer`, so a producer
|
|
99
|
+
// sees demand (its broadcast gate). Opened on the first subscriber, closed on the last — mirror of
|
|
100
|
+
// the Player/tools `use-audio-signal` consumer connection.
|
|
101
|
+
audioSocket = null;
|
|
102
|
+
layerWatchers = new Set();
|
|
103
|
+
// Durable-capture recorders started by `record(into)`, plus the change-listeners a wave-4 capture
|
|
104
|
+
// offer subscribes with — the recorder-registration seam (`_notify_recorders_changed` in Python).
|
|
105
|
+
recorders = [];
|
|
106
|
+
recorderListeners = new Set();
|
|
107
|
+
// Live transcription brokers started by `transcribe()` — stopped on room close (a side effect,
|
|
108
|
+
// like `record`). Mirror of Python's `AsyncRoom._transcribers`.
|
|
109
|
+
transcribers = [];
|
|
110
|
+
// Standing-service offers (`offerTranscription` / `offerAudioCapture`) — each stopped (winding down
|
|
111
|
+
// + finalizing) on room close. Mirror of Python's `AsyncRoom._offered_services`.
|
|
112
|
+
offeredServices = [];
|
|
113
|
+
// Discoverable roles registered by a capability broker / standing offer — merged into the presence
|
|
114
|
+
// announce at connect time, so registering before connect() puts the role on the first connection.
|
|
115
|
+
brokerRoles = [];
|
|
116
|
+
constructor(deps) {
|
|
117
|
+
this.deps = deps;
|
|
118
|
+
this.id = deps.id;
|
|
119
|
+
this.connectCode = deps.connectCode;
|
|
120
|
+
this.name = deps.name ?? null;
|
|
121
|
+
this.projectId = deps.projectId ?? null;
|
|
122
|
+
if (deps.signal) {
|
|
123
|
+
if (deps.signal.aborted)
|
|
124
|
+
this.close();
|
|
125
|
+
else
|
|
126
|
+
deps.signal.addEventListener("abort", () => this.close(), { once: true });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** The server-assigned connection id, or ``null`` before {@link connect} settles. */
|
|
130
|
+
get connectionId() {
|
|
131
|
+
return this.socket?.connectionId() ?? null;
|
|
132
|
+
}
|
|
133
|
+
/** The live participant roster (excludes self), from the ``ready`` snapshot + presence deltas —
|
|
134
|
+
* the raw {@link RosterEntry} view. For the parsed, query-friendly {@link Participant} (merged
|
|
135
|
+
* ``roles`` / ``name`` / ``connectedAt``) use {@link participants} / {@link has}. */
|
|
136
|
+
get presence() {
|
|
137
|
+
return this.socket?.participants() ?? [];
|
|
138
|
+
}
|
|
139
|
+
/** Open the live socket and start dispatch; resolves once the server's ``ready`` frame arrives.
|
|
140
|
+
* Idempotent — safe to call repeatedly, and every wire method calls it for you. */
|
|
141
|
+
connect() {
|
|
142
|
+
if (!this.connectPromise)
|
|
143
|
+
this.connectPromise = this.startSocket();
|
|
144
|
+
return this.connectPromise;
|
|
145
|
+
}
|
|
146
|
+
async startSocket() {
|
|
147
|
+
const mode = this.deps.transport ?? "auto";
|
|
148
|
+
if (mode === "sse") {
|
|
149
|
+
this.socket = this.makeSse();
|
|
150
|
+
this.usingSse = true;
|
|
151
|
+
return this.socket.connect();
|
|
152
|
+
}
|
|
153
|
+
// WS (preferred). In "auto" mode the socket is a *probe*: if it can't reach `ready` on the first
|
|
154
|
+
// attempt (a proxy stripping the upgrade looks exactly like this), it rejects fast instead of
|
|
155
|
+
// hanging the connect timeout, and we fall back to SSE — mirror of Python's SSE-primary path
|
|
156
|
+
// being reachable behind proxies where WS dies.
|
|
157
|
+
let wsUnavailable = false;
|
|
158
|
+
const ws = new RoomSocket({
|
|
159
|
+
roomId: this.id,
|
|
160
|
+
code: this.connectCode,
|
|
161
|
+
baseUrl: this.deps.baseUrl,
|
|
162
|
+
identity: this.deps.identity,
|
|
163
|
+
announce: this.mergedAnnounce(),
|
|
164
|
+
reconnect: this.deps.reconnect,
|
|
165
|
+
probe: mode === "auto",
|
|
166
|
+
WebSocketCtor: this.deps.WebSocketCtor,
|
|
167
|
+
onSignal: (sig) => this.handleSignal(sig),
|
|
168
|
+
// Binary audio + the audio text signals ride the dedicated audio socket (below), never this
|
|
169
|
+
// one — so a frame isn't dispatched twice (both sockets are separate room subscribers).
|
|
170
|
+
onReady: () => this.onReady(),
|
|
171
|
+
onEnd: (reason) => this.handleEnd(reason),
|
|
172
|
+
onUnavailable: () => {
|
|
173
|
+
wsUnavailable = true;
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
this.socket = ws;
|
|
177
|
+
try {
|
|
178
|
+
await ws.connect();
|
|
179
|
+
}
|
|
180
|
+
catch (err) {
|
|
181
|
+
if (mode === "auto" && wsUnavailable) {
|
|
182
|
+
ws.close();
|
|
183
|
+
const sse = this.makeSse();
|
|
184
|
+
this.socket = sse;
|
|
185
|
+
this.usingSse = true;
|
|
186
|
+
return sse.connect();
|
|
187
|
+
}
|
|
188
|
+
throw err;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
makeSse() {
|
|
192
|
+
return new RoomEventStream({
|
|
193
|
+
roomId: this.id,
|
|
194
|
+
code: this.connectCode,
|
|
195
|
+
baseUrl: this.deps.baseUrl,
|
|
196
|
+
identity: this.deps.identity,
|
|
197
|
+
announce: this.mergedAnnounce(),
|
|
198
|
+
reconnect: this.deps.reconnect,
|
|
199
|
+
fetchImpl: this.deps.fetchImpl,
|
|
200
|
+
onSignal: (sig) => this.handleSignal(sig),
|
|
201
|
+
onReady: () => this.onReady(),
|
|
202
|
+
onEnd: (reason) => this.handleEnd(reason),
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// A terminal transport end (code rotated / bad / room gone) — the socket has stopped reconnecting
|
|
206
|
+
// for good, so tear the handle down and fail any in-flight requests with the reason (rather than
|
|
207
|
+
// letting them hang until timeout). Fans the typed reason out to every `onEnd` listener exactly
|
|
208
|
+
// once (both transports funnel their connect-time and mid-session terminal ends here).
|
|
209
|
+
handleEnd(reason) {
|
|
210
|
+
if (this.ended)
|
|
211
|
+
return;
|
|
212
|
+
this.ended = true;
|
|
213
|
+
for (const p of this.pending.values()) {
|
|
214
|
+
clearTimeout(p.timer);
|
|
215
|
+
p.reject(new Error(`room ${reason}`));
|
|
216
|
+
}
|
|
217
|
+
this.pending.clear();
|
|
218
|
+
const listeners = [...this.endListeners];
|
|
219
|
+
this.close();
|
|
220
|
+
for (const cb of listeners) {
|
|
221
|
+
try {
|
|
222
|
+
cb(reason);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
console.error("Atrium room: an onEnd listener failed:", err);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Register a handler for one signal type. The handler's return value auto-replies to a
|
|
230
|
+
* {@link request} (a signal carrying an ``ackId``) — so a responder just returns. */
|
|
231
|
+
on(type, handler) {
|
|
232
|
+
this.handlers.set(type, handler);
|
|
233
|
+
return this;
|
|
234
|
+
}
|
|
235
|
+
/** Register a catch-all observer invoked for **every** inbound signal (replies included) — the
|
|
236
|
+
* parity for a raw `receive()` loop (logging, inspection). Observers never auto-reply. Returns an
|
|
237
|
+
* unsubscribe function. */
|
|
238
|
+
onAny(handler) {
|
|
239
|
+
this.anyHandlers.add(handler);
|
|
240
|
+
return () => {
|
|
241
|
+
this.anyHandlers.delete(handler);
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/** Register a callback for a **terminal** room end — the connection stopped for good with a typed
|
|
245
|
+
* {@link RoomEndReason} (``"code-rotated"`` / ``"bad-code"`` / ``"room-gone"``), on either the WS or
|
|
246
|
+
* SSE transport and whether the end is rejected at connect time or happens mid-session. Fires
|
|
247
|
+
* **exactly once** per handle; a deliberate {@link close} is not an end and never fires it. Returns
|
|
248
|
+
* an unsubscribe function. The typed reason is what a driver maps to its own UI state instead of
|
|
249
|
+
* string-matching a request's reject message. Register before {@link connect} to catch a connect-time
|
|
250
|
+
* reject. Python owes the port (``on_end``). */
|
|
251
|
+
onEnd(cb) {
|
|
252
|
+
this.endListeners.add(cb);
|
|
253
|
+
return () => {
|
|
254
|
+
this.endListeners.delete(cb);
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
/** Send a live message — fire-and-forget, never stored. ``to`` (a connection id/identity, or a
|
|
258
|
+
* list) targets peers; omit it to broadcast to everyone else. (``ephemeral`` is accepted for
|
|
259
|
+
* parity, but the WS inbound path forces ``ephemeral: false`` server-side.) */
|
|
260
|
+
async send(type, payload, opts = {}) {
|
|
261
|
+
await this.connect();
|
|
262
|
+
this.socket?.sendSignal({ type, payload, to: opts.to });
|
|
263
|
+
}
|
|
264
|
+
/** Send a request and await one reply (a peer's handler return). ``to`` targets one peer; omit it
|
|
265
|
+
* to broadcast — first matching reply wins. Rejects if no reply arrives within the timeout. */
|
|
266
|
+
request(type, payload, opts = {}) {
|
|
267
|
+
const ms = opts.timeout ?? this.deps.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
268
|
+
return this.connect().then(() => new Promise((resolve, reject) => {
|
|
269
|
+
const ackId = randomId();
|
|
270
|
+
const timer = setTimeout(() => {
|
|
271
|
+
this.pending.delete(ackId);
|
|
272
|
+
reject(new Error(`no response to '${type}' within ${ms}ms`));
|
|
273
|
+
}, ms);
|
|
274
|
+
this.pending.set(ackId, { resolve, reject, timer });
|
|
275
|
+
this.socket?.sendSignal({ type, payload, to: opts.to, ackId });
|
|
276
|
+
}));
|
|
277
|
+
}
|
|
278
|
+
handleSignal(sig) {
|
|
279
|
+
const event = toRoomEvent(sig);
|
|
280
|
+
// Catch-all observers (loggers/inspectors) see every inbound signal, replies included — the
|
|
281
|
+
// parity for Python's raw `receive()`. Observers never auto-reply; their return is ignored.
|
|
282
|
+
for (const cb of this.anyHandlers)
|
|
283
|
+
cb(event);
|
|
284
|
+
// A reply correlates to an in-flight request and stops here — matched or not. Mirror of
|
|
285
|
+
// aio.py's `_handle`: a `reply_to` is never dispatched to `on()` handlers.
|
|
286
|
+
if (event.replyTo != null) {
|
|
287
|
+
const p = this.pending.get(event.replyTo);
|
|
288
|
+
if (p) {
|
|
289
|
+
this.pending.delete(event.replyTo);
|
|
290
|
+
clearTimeout(p.timer);
|
|
291
|
+
p.resolve(event.payload);
|
|
292
|
+
}
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
// Fan out to the layer-state subscribers (independent of a registered `on` handler).
|
|
296
|
+
if (event.type === "layer.state") {
|
|
297
|
+
for (const cb of this.layerWatchers)
|
|
298
|
+
cb(event.payload);
|
|
299
|
+
}
|
|
300
|
+
const handler = this.handlers.get(event.type);
|
|
301
|
+
if (!handler)
|
|
302
|
+
return;
|
|
303
|
+
Promise.resolve(handler(event))
|
|
304
|
+
.then((result) => {
|
|
305
|
+
if (event.ackId != null) {
|
|
306
|
+
// Mirror aio.py:1077 — a plain object is the reply payload as-is; anything else
|
|
307
|
+
// (a scalar, an array, null) is wrapped as `{ result }`.
|
|
308
|
+
const isPlainObject = result !== null && typeof result === "object" && !Array.isArray(result);
|
|
309
|
+
const reply = isPlainObject ? result : { result };
|
|
310
|
+
this.socket?.sendSignal({
|
|
311
|
+
type: event.type,
|
|
312
|
+
payload: reply,
|
|
313
|
+
to: event.from ?? undefined,
|
|
314
|
+
replyTo: event.ackId,
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
})
|
|
318
|
+
.catch((err) => {
|
|
319
|
+
console.error(`Atrium room handler for '${event.type}' failed:`, err);
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
handleAudioBinary(data) {
|
|
323
|
+
if (this.audioSubscribers.size === 0)
|
|
324
|
+
return;
|
|
325
|
+
const frame = decodeAudioFrame(data);
|
|
326
|
+
if (!frame)
|
|
327
|
+
return; // foreign/other binary — ignore
|
|
328
|
+
for (const cb of this.audioSubscribers)
|
|
329
|
+
cb({ kind: "frame", frame });
|
|
330
|
+
}
|
|
331
|
+
dispatchAudio(sig) {
|
|
332
|
+
if (this.audioSubscribers.size === 0)
|
|
333
|
+
return;
|
|
334
|
+
const event = toRoomEvent(sig);
|
|
335
|
+
let out = null;
|
|
336
|
+
if (event.type === AUDIO_SIGNAL.track)
|
|
337
|
+
out = { kind: "track", track: event.payload };
|
|
338
|
+
else if (event.type === AUDIO_SIGNAL.beat)
|
|
339
|
+
out = { kind: "beat", beat: event.payload };
|
|
340
|
+
else if (event.type === AUDIO_SIGNAL.announce)
|
|
341
|
+
out = { kind: "announce", announce: event.payload };
|
|
342
|
+
if (out)
|
|
343
|
+
for (const cb of this.audioSubscribers)
|
|
344
|
+
cb(out);
|
|
345
|
+
}
|
|
346
|
+
onReady() {
|
|
347
|
+
// Re-establish demand-gated subscriptions after every (re)connect (rooms have no replay).
|
|
348
|
+
if (this.layerWatchers.size > 0)
|
|
349
|
+
this.socket?.sendSignal({ type: "layer.subscribe" });
|
|
350
|
+
}
|
|
351
|
+
// ── Audio signals (consumption) ──────────────────────────────────────────
|
|
352
|
+
/**
|
|
353
|
+
* Subscribe to the room's audio-signal stream — the continuous binary loudness/spectrum frames
|
|
354
|
+
* plus the ``audio.track`` / ``audio.beat`` / ``audio.announce`` text signals a hosted Player (or
|
|
355
|
+
* any producer) broadcasts. The callback receives a discriminated {@link AudioSignal}; switch on
|
|
356
|
+
* ``.kind``. The first subscriber opens a dedicated socket that announces ``role: audio-consumer``
|
|
357
|
+
* (a producer's broadcast gate) and sends an ``audio.discover`` on every (re)connect; the last
|
|
358
|
+
* unsubscribe closes it, releasing demand. Returns an unsubscribe function.
|
|
359
|
+
*/
|
|
360
|
+
audioSignals(callback) {
|
|
361
|
+
this.requireWsAudio("audioSignals");
|
|
362
|
+
this.audioSubscribers.add(callback);
|
|
363
|
+
if (!this.audioSocket)
|
|
364
|
+
this.openAudioSocket();
|
|
365
|
+
return () => {
|
|
366
|
+
this.audioSubscribers.delete(callback);
|
|
367
|
+
if (this.audioSubscribers.size === 0) {
|
|
368
|
+
this.audioSocket?.close();
|
|
369
|
+
this.audioSocket = null;
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
openAudioSocket() {
|
|
374
|
+
const socket = new RoomSocket({
|
|
375
|
+
roomId: this.id,
|
|
376
|
+
code: this.connectCode,
|
|
377
|
+
baseUrl: this.deps.baseUrl,
|
|
378
|
+
identity: this.deps.identity,
|
|
379
|
+
// Announce audio-consumer demand so an elected producer starts broadcasting (analyzer gate).
|
|
380
|
+
announce: { ...this.deps.announce, role: AUDIO_ROLE.consumer },
|
|
381
|
+
reconnect: this.deps.reconnect,
|
|
382
|
+
WebSocketCtor: this.deps.WebSocketCtor,
|
|
383
|
+
onSignal: (sig) => this.dispatchAudio(sig),
|
|
384
|
+
onBinary: (data) => this.handleAudioBinary(data),
|
|
385
|
+
onReady: () => this.audioSocket?.sendSignal({ type: AUDIO_SIGNAL.discover, ackId: `disc-${Date.now()}` }),
|
|
386
|
+
});
|
|
387
|
+
this.audioSocket = socket;
|
|
388
|
+
void socket.connect().catch(() => { });
|
|
389
|
+
}
|
|
390
|
+
/** Refuse fast if this handle latched onto the SSE transport (the WS upgrade wasn't reachable) —
|
|
391
|
+
* binary audio is WS-only, so a doomed WS reconnect would otherwise spin forever. Consulted by every
|
|
392
|
+
* audio consumer AND, via {@link offerDeps}, every standing offer, so signals and audio stay coherent
|
|
393
|
+
* about the environment. Throws {@link AtriumError}. When the transport isn't yet decided (the main
|
|
394
|
+
* socket hasn't connected), it proceeds — the offers, which engage over an already-live signal
|
|
395
|
+
* channel, always see the settled decision. */
|
|
396
|
+
requireWsAudio(what) {
|
|
397
|
+
if (this.usingSse) {
|
|
398
|
+
throw new AtriumError(`${what} requires the WebSocket transport, but this room connection fell back to SSE ` +
|
|
399
|
+
"(the WebSocket upgrade wasn't reachable — e.g. a proxy stripping it). Binary audio is WS-only.");
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Subscribe to the room's raw ``audio.snippet`` stream — the ~10s Opus windows a Player (v1) or a
|
|
404
|
+
* Capture tool (v2) shares — as an async iterator of {@link AudioClip}s. The rawest audio consumer:
|
|
405
|
+
* where a later ``audioVectors`` embeds and ``audioTranscripts`` transcribes, this hands you the
|
|
406
|
+
* bytes. Subscribing *is* the demand signal (it announces the ``audio-snippet-consumer`` role, so a
|
|
407
|
+
* producer's own default-off "Share audio clips" toggle actually captures). Break out of the
|
|
408
|
+
* ``for await`` loop (or ``return``) to end the subscription and close the socket.
|
|
409
|
+
*
|
|
410
|
+
* ``sourceName`` is resolved from the roster snapshot **at subscribe time**, so a producer that
|
|
411
|
+
* joins *after* this subscribes has ``null`` there until you re-subscribe (mirror of Python's
|
|
412
|
+
* ``audio_clips``). Binary rides the WebSocket only — a room latched to the SSE fallback throws.
|
|
413
|
+
*/
|
|
414
|
+
async *audioClips() {
|
|
415
|
+
this.requireWsAudio("audioClips");
|
|
416
|
+
const queue = [];
|
|
417
|
+
let wake = null;
|
|
418
|
+
let ended = false;
|
|
419
|
+
const names = new Map();
|
|
420
|
+
let snapped = false;
|
|
421
|
+
const socket = new RoomSocket({
|
|
422
|
+
roomId: this.id,
|
|
423
|
+
code: this.connectCode,
|
|
424
|
+
baseUrl: this.deps.baseUrl,
|
|
425
|
+
identity: `audio-clips:${randomId().slice(0, 8)}`,
|
|
426
|
+
announce: { ...this.deps.announce, role: AUDIO_SNIPPET_CONSUMER_ROLE },
|
|
427
|
+
reconnect: this.deps.reconnect,
|
|
428
|
+
WebSocketCtor: this.deps.WebSocketCtor,
|
|
429
|
+
onReady: () => {
|
|
430
|
+
// Freeze the source-name lookup at the first subscribe-time roster snapshot (the "joined
|
|
431
|
+
// after subscribe → null name" caveat) — a plain consumer socket has no live roster view.
|
|
432
|
+
if (snapped)
|
|
433
|
+
return;
|
|
434
|
+
snapped = true;
|
|
435
|
+
for (const p of socket.participants()) {
|
|
436
|
+
const name = p.announce?.name;
|
|
437
|
+
names.set(p.connectionId, typeof name === "string" ? name : null);
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
onBinary: (data) => {
|
|
441
|
+
const frame = decodeAudioSnippetFrame(data);
|
|
442
|
+
if (!frame)
|
|
443
|
+
return; // a foreign / DSP binary frame — not ours
|
|
444
|
+
if (queue.length >= RAW_CONSUMER_QUEUE_MAX)
|
|
445
|
+
queue.shift(); // drop oldest — bound a slow consumer
|
|
446
|
+
queue.push({
|
|
447
|
+
data: frame.data,
|
|
448
|
+
mime: frame.mimeType,
|
|
449
|
+
sequence: frame.sequence,
|
|
450
|
+
windowSeconds: frame.windowSeconds,
|
|
451
|
+
source: frame.source,
|
|
452
|
+
sourceName: frame.source ? (names.get(frame.source) ?? null) : null,
|
|
453
|
+
});
|
|
454
|
+
wake?.();
|
|
455
|
+
wake = null;
|
|
456
|
+
},
|
|
457
|
+
onEnd: () => {
|
|
458
|
+
ended = true;
|
|
459
|
+
wake?.();
|
|
460
|
+
wake = null;
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
try {
|
|
464
|
+
try {
|
|
465
|
+
await socket.connect();
|
|
466
|
+
}
|
|
467
|
+
catch (err) {
|
|
468
|
+
// A close that raced the connect (the socket ended before ``ready``) rejects "room closed" —
|
|
469
|
+
// an expected teardown; only a genuine connect failure propagates.
|
|
470
|
+
if (!ended)
|
|
471
|
+
throw err;
|
|
472
|
+
}
|
|
473
|
+
while (!ended) {
|
|
474
|
+
while (queue.length > 0) {
|
|
475
|
+
yield queue.shift();
|
|
476
|
+
}
|
|
477
|
+
if (ended)
|
|
478
|
+
break;
|
|
479
|
+
await new Promise((resolve) => {
|
|
480
|
+
wake = resolve;
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
while (queue.length > 0) {
|
|
484
|
+
yield queue.shift();
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
finally {
|
|
488
|
+
socket.close();
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
// ── Durable capture (record → session) ───────────────────────────────────
|
|
492
|
+
/**
|
|
493
|
+
* Start capturing this room's live signals into a durable ``session`` — the single durable binding
|
|
494
|
+
* between a room (pure transport) and a session (an append-only log). A **side effect**: it starts
|
|
495
|
+
* immediately in the background and returns a live {@link Recorder} handle. Stop it with
|
|
496
|
+
* ``rec.stop()``, or let {@link close} (an ``await using`` room) stop it. It skips ``ephemeral``
|
|
497
|
+
* signals and presence traffic — only durable-worthy signals are recorded, each stamped with
|
|
498
|
+
* ``via`` provenance (``{ room_id, room_name, from, to }``). Mirror of Python's ``room.record``.
|
|
499
|
+
*
|
|
500
|
+
* A dropped stream reconnects with backoff (the gap is lost — rooms have no replay); only a
|
|
501
|
+
* terminal end stops it cleanly with a {@link Recorder.stoppedReason} (a code rotation, or the
|
|
502
|
+
* room being gone / its code rejected — the latter also sets {@link Recorder.error}). Needs an
|
|
503
|
+
* owned room (durable sessions require an account) — throws on a loginless {@link joinRoom} handle.
|
|
504
|
+
*/
|
|
505
|
+
record(into) {
|
|
506
|
+
const rest = this.deps.rest;
|
|
507
|
+
if (!rest) {
|
|
508
|
+
throw new AtriumError("this room handle has no account credential — join(code) can only send/receive; " +
|
|
509
|
+
"record(into) needs an owned room from client.rooms");
|
|
510
|
+
}
|
|
511
|
+
const recorder = new Recorder({
|
|
512
|
+
roomId: this.id,
|
|
513
|
+
code: this.connectCode,
|
|
514
|
+
roomName: this.name,
|
|
515
|
+
baseUrl: this.deps.baseUrl,
|
|
516
|
+
rest,
|
|
517
|
+
reconnect: this.deps.reconnect,
|
|
518
|
+
fetchImpl: this.deps.fetchImpl,
|
|
519
|
+
onChange: () => {
|
|
520
|
+
void this.notifyRecordersChanged();
|
|
521
|
+
},
|
|
522
|
+
}, into);
|
|
523
|
+
this.recorders.push(recorder);
|
|
524
|
+
recorder.start();
|
|
525
|
+
return recorder;
|
|
526
|
+
}
|
|
527
|
+
/** The recorders currently running on this handle (membership **and** liveness — a stopped one no
|
|
528
|
+
* longer counts) — the source of truth a wave-4 ``offerAudioCapture`` reads for audio-capture
|
|
529
|
+
* availability, and binds its capture to the first active recorder's session. */
|
|
530
|
+
activeRecorders() {
|
|
531
|
+
return this.recorders.filter((r) => r.alive);
|
|
532
|
+
}
|
|
533
|
+
/** Register a listener fired whenever the active-recorder set changes (a {@link record} started, or
|
|
534
|
+
* a recorder stopped/terminated) — the seam a wave-4 ``offerAudioCapture`` binds to, so it can
|
|
535
|
+
* re-derive availability and disengage when the recorder it wrote under is gone. Returns an
|
|
536
|
+
* unsubscribe function. Mirror of Python's ``_notify_recorders_changed`` registration. */
|
|
537
|
+
onRecordersChanged(listener) {
|
|
538
|
+
this.recorderListeners.add(listener);
|
|
539
|
+
return () => {
|
|
540
|
+
this.recorderListeners.delete(listener);
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
async notifyRecordersChanged() {
|
|
544
|
+
// Isolate each hook — one misbehaving listener must not starve the others nor throw out of a
|
|
545
|
+
// recorder's teardown path (this fires from a recorder's terminal wind-down).
|
|
546
|
+
for (const listener of [...this.recorderListeners]) {
|
|
547
|
+
try {
|
|
548
|
+
await listener();
|
|
549
|
+
}
|
|
550
|
+
catch (err) {
|
|
551
|
+
console.error("Atrium room: a recorders-changed listener failed:", err);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
// ── Live transcription (the transcript.* broker) ─────────────────────────
|
|
556
|
+
/**
|
|
557
|
+
* Transcribe this room's live audio into ``transcript.*`` signals — the in-room broker. Consumes the
|
|
558
|
+
* room's ``audio.snippet`` stream (announcing the snippet-consumer role, so a Player starts
|
|
559
|
+
* capturing), pushes it through the same engine as {@link ../atrium.Atrium.transcribeLive} on
|
|
560
|
+
* **your** credentials/compute, and publishes back:
|
|
561
|
+
*
|
|
562
|
+
* * ``transcript.partial`` — ``{ text }``, **ephemeral** (a live caption the recorder skips).
|
|
563
|
+
* Wholesale-replaced — render the latest, don't append.
|
|
564
|
+
* * ``transcript.final`` — ``{ text, start, end, seq }``, durable (the {@link record} bridge persists
|
|
565
|
+
* it — no new recording API). ``seq`` is a per-handle counter from 0; the signal's ``source`` is
|
|
566
|
+
* ``"transcriber"``.
|
|
567
|
+
*
|
|
568
|
+
* Any consumer — including a loginless code-joined tool — just does ``room.on("transcript.final")``.
|
|
569
|
+
* Like {@link record}, it's a **side effect**: it starts immediately in the background and is stopped
|
|
570
|
+
* when the room closes, or via ``await handle.stop()`` / ``await using``. Needs an owned room
|
|
571
|
+
* (transcription runs on an account credential) — throws on a loginless {@link joinRoom} handle.
|
|
572
|
+
*/
|
|
573
|
+
transcribe(opts = {}) {
|
|
574
|
+
const client = this.requireClient("transcribe()");
|
|
575
|
+
const transcriber = new Transcriber({
|
|
576
|
+
model: opts.model,
|
|
577
|
+
language: opts.language,
|
|
578
|
+
snippetBytes: (signal) => this.snippetBytesFor(signal),
|
|
579
|
+
engine: (clips, o) => client.transcribeStream(clips, {
|
|
580
|
+
model: o.model,
|
|
581
|
+
language: o.language,
|
|
582
|
+
// The producer ships Opus-in-WebM; the backend decodes each clip. Partials are published
|
|
583
|
+
// as ephemeral live captions, so the engine must surface them.
|
|
584
|
+
format: "webm",
|
|
585
|
+
partials: true,
|
|
586
|
+
skipEmpty: true,
|
|
587
|
+
signal: o.signal,
|
|
588
|
+
webSocketCtor: this.deps.WebSocketCtor,
|
|
589
|
+
}),
|
|
590
|
+
publish: (type, payload, o) => client.request(buildRoomSignal(this.id, {
|
|
591
|
+
type,
|
|
592
|
+
payload,
|
|
593
|
+
source: TRANSCRIBER_SOURCE,
|
|
594
|
+
ephemeral: o.ephemeral,
|
|
595
|
+
})),
|
|
596
|
+
});
|
|
597
|
+
this.transcribers.push(transcriber);
|
|
598
|
+
transcriber.start();
|
|
599
|
+
return transcriber;
|
|
600
|
+
}
|
|
601
|
+
// The raw snippet bytes for the transcription broker — a dedicated consumer socket, ended when the
|
|
602
|
+
// handle's stop() aborts the signal (which closes the socket).
|
|
603
|
+
async *snippetBytesFor(signal) {
|
|
604
|
+
for await (const frame of this.snippetStream(`transcriber:${randomId().slice(0, 8)}`, signal)) {
|
|
605
|
+
yield frame.data;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// ── Standing-service offers + capability brokers ─────────────────────────
|
|
609
|
+
/** Merge the registered broker/offer roles into the presence announce (mirror of Python's
|
|
610
|
+
* ``_events_params``) — so registering a broker before ``connect()`` rides the first connection. */
|
|
611
|
+
mergedAnnounce() {
|
|
612
|
+
if (this.brokerRoles.length === 0)
|
|
613
|
+
return this.deps.announce;
|
|
614
|
+
const merged = { ...this.deps.announce };
|
|
615
|
+
const roles = Array.isArray(merged.roles) ? [...merged.roles] : [];
|
|
616
|
+
for (const role of this.brokerRoles)
|
|
617
|
+
if (!roles.includes(role))
|
|
618
|
+
roles.push(role);
|
|
619
|
+
merged.roles = roles;
|
|
620
|
+
return merged;
|
|
621
|
+
}
|
|
622
|
+
announceRole(role) {
|
|
623
|
+
if (!this.brokerRoles.includes(role))
|
|
624
|
+
this.brokerRoles.push(role);
|
|
625
|
+
}
|
|
626
|
+
requirePreConnect() {
|
|
627
|
+
if (this.connectionId !== null) {
|
|
628
|
+
throw new AtriumError("register offer_*/offerEmbeds/offerChat before connect() — the broker role is only " +
|
|
629
|
+
"advertised when the connection opens");
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
/** The single capability-injection guard — the authed client slice (embed / chat / compare /
|
|
633
|
+
* transcribe) an owned room carries and a loginless ``join(code)`` handle lacks. Always throws
|
|
634
|
+
* {@link AtriumError} (never a bare ``Error``), so every SDK refusal is catchable under the one
|
|
635
|
+
* hierarchy. ``feature`` names the surface for the message. */
|
|
636
|
+
requireClient(feature) {
|
|
637
|
+
if (!this.deps.client) {
|
|
638
|
+
throw new AtriumError(`${feature} needs an owned room from client.rooms (open/get/join) — this handle has no ` +
|
|
639
|
+
"account credential (a loginless joinRoom can only send/receive)");
|
|
640
|
+
}
|
|
641
|
+
return this.deps.client;
|
|
642
|
+
}
|
|
643
|
+
offerDeps(client) {
|
|
644
|
+
return {
|
|
645
|
+
roomId: this.id,
|
|
646
|
+
getCode: () => this.connectCode,
|
|
647
|
+
baseUrl: this.deps.baseUrl,
|
|
648
|
+
reconnect: this.deps.reconnect,
|
|
649
|
+
WebSocketCtor: this.deps.WebSocketCtor,
|
|
650
|
+
// The offer consults the room's latched transport decision — refuse fast on an SSE handle.
|
|
651
|
+
requireWsAudio: () => this.requireWsAudio("this standing offer"),
|
|
652
|
+
// The ``*.available`` broadcast (and per-source ``transcript.*``) ride the REST /signal POST so
|
|
653
|
+
// an ephemeral hint survives — the WS inbound path forces ``ephemeral: false``.
|
|
654
|
+
publishSignal: (type, payload, o) => client.request(buildRoomSignal(this.id, { type, payload, source: o.source, ephemeral: o.ephemeral })),
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
// Wire a peer-toggleable standing service (the third broker kind) — announce ``role`` and route the
|
|
658
|
+
// ``<name>.start`` / ``.stop`` / ``.status`` requests to ``service`` (mirror of ``_offer_service``).
|
|
659
|
+
// Engage is sender-scoped (the server-stamped ``from``) unless the payload carries ``all: true``.
|
|
660
|
+
offerService(name, role, service) {
|
|
661
|
+
this.requirePreConnect();
|
|
662
|
+
this.announceRole(role);
|
|
663
|
+
this.offeredServices.push(service);
|
|
664
|
+
this.on(`${name}.start`, async (e) => {
|
|
665
|
+
try {
|
|
666
|
+
await service.start({ source: e.from, all: Boolean(e.payload.all) });
|
|
667
|
+
}
|
|
668
|
+
catch (err) {
|
|
669
|
+
return { error: asError(err).message };
|
|
670
|
+
}
|
|
671
|
+
return { ok: true };
|
|
672
|
+
});
|
|
673
|
+
this.on(`${name}.stop`, async (e) => {
|
|
674
|
+
try {
|
|
675
|
+
await service.stopRequest({ source: e.from, all: Boolean(e.payload.all) });
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
return { error: asError(err).message };
|
|
679
|
+
}
|
|
680
|
+
return { ok: true };
|
|
681
|
+
});
|
|
682
|
+
this.on(`${name}.status`, () => ({ ok: true, ...service.status() }));
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Offer live transcription as a peer-toggleable standing service — a code-joined tool turns it on
|
|
686
|
+
* with a ``transcription.start`` request and off with ``transcription.stop`` (both idempotent).
|
|
687
|
+
* Announces the discoverable ``transcription-offer`` role. **Scoped, multi-source**: ``start`` /
|
|
688
|
+
* ``stop`` are scoped to the requesting connection unless the payload carries ``{ all: true }``;
|
|
689
|
+
* only armed sources are transcribed (up to ``maxSources``, default 8). While on it runs the same
|
|
690
|
+
* engine as {@link transcribe} on **your** credentials, publishing source-attributed ``transcript.*``
|
|
691
|
+
* signals. Register **before** ``connect()``; needs an owned room (throws on a loginless handle).
|
|
692
|
+
* Returns the handle (for ``handle.start({ all: true })`` from the host). Mirror of Python's
|
|
693
|
+
* ``offer_transcription``.
|
|
694
|
+
*/
|
|
695
|
+
offerTranscription(opts = {}) {
|
|
696
|
+
const client = this.requireClient("offerTranscription");
|
|
697
|
+
const service = new OfferedTranscription({
|
|
698
|
+
...this.offerDeps(client),
|
|
699
|
+
engine: (clips, o) => client.transcribeStream(clips, {
|
|
700
|
+
model: o.model,
|
|
701
|
+
language: o.language,
|
|
702
|
+
format: "webm", // the producer ships Opus-in-WebM; the backend decodes each clip
|
|
703
|
+
partials: true,
|
|
704
|
+
skipEmpty: true,
|
|
705
|
+
signal: o.signal,
|
|
706
|
+
webSocketCtor: this.deps.WebSocketCtor,
|
|
707
|
+
}),
|
|
708
|
+
}, { model: opts.model, language: opts.language, maxSources: opts.maxSources });
|
|
709
|
+
this.offerService("transcription", TRANSCRIPTION_OFFER_ROLE, service);
|
|
710
|
+
return service;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Offer audio capture as a peer-toggleable standing service — **session-less**: it writes into the
|
|
714
|
+
* **active recorder's** session ({@link record} is the single durable binding), so a ``capture.start``
|
|
715
|
+
* with no active recorder replies an error. A code-joined tool toggles it via ``capture.start`` /
|
|
716
|
+
* ``capture.stop`` (idempotent). Announces the discoverable ``capture-offer`` role. **Scoped,
|
|
717
|
+
* multi-source** (up to ``maxSources``, default 8): only armed sources are recorded; a same-name
|
|
718
|
+
* reconnect within grace resumes one file, two simultaneous same-name producers get their own files.
|
|
719
|
+
* Register **before** ``connect()``; needs an owned room (throws on a loginless handle). Returns the
|
|
720
|
+
* handle (for ``handle.start({ all: true })``). Mirror of Python's ``offer_audio_capture``.
|
|
721
|
+
*/
|
|
722
|
+
offerAudioCapture(opts = {}) {
|
|
723
|
+
const client = this.requireClient("offerAudioCapture");
|
|
724
|
+
const service = new OfferedCapture({
|
|
725
|
+
...this.offerDeps(client),
|
|
726
|
+
request: (req) => client.request(req),
|
|
727
|
+
activeRecorders: () => this.activeRecorders(),
|
|
728
|
+
onRecordersChanged: (listener) => this.onRecordersChanged(listener),
|
|
729
|
+
}, { maxSources: opts.maxSources });
|
|
730
|
+
this.offerService("capture", CAPTURE_OFFER_ROLE, service);
|
|
731
|
+
return service;
|
|
732
|
+
}
|
|
733
|
+
async resolveEmbedSpace(client, requested) {
|
|
734
|
+
const spaces = await client.library.spaces();
|
|
735
|
+
if (requested !== undefined) {
|
|
736
|
+
const matched = spaces.find((s) => s.id === requested);
|
|
737
|
+
return { space: requested, model: matched?.model ?? null };
|
|
738
|
+
}
|
|
739
|
+
const matched = spaces.find((s) => s.defaultModalities.includes("text"));
|
|
740
|
+
if (!matched) {
|
|
741
|
+
throw new AtriumError("no default text search space is configured on this deployment — the embed.resolve request " +
|
|
742
|
+
"must pass space");
|
|
743
|
+
}
|
|
744
|
+
return { space: matched.id, model: matched.model ?? null };
|
|
745
|
+
}
|
|
746
|
+
async resolveAnchorVector(client, desc, space, model, cache) {
|
|
747
|
+
const anchor = parseAnchorDescriptor(desc); // throws BrokerError on a malformed anchor
|
|
748
|
+
if (anchor.kind === "vector")
|
|
749
|
+
return [...anchor.vector];
|
|
750
|
+
if (anchor.kind === "asset") {
|
|
751
|
+
const key = `${space}|asset:${anchor.asset}:${anchor.matchBy ?? ""}`;
|
|
752
|
+
let vec = cache.get(key);
|
|
753
|
+
if (!vec) {
|
|
754
|
+
const query = { space };
|
|
755
|
+
if (anchor.matchBy !== null)
|
|
756
|
+
query.match_by = anchor.matchBy;
|
|
757
|
+
const rows = (await client.request({
|
|
758
|
+
method: "GET",
|
|
759
|
+
path: `/v1/assets/${anchor.asset}/vectors`,
|
|
760
|
+
query,
|
|
761
|
+
}));
|
|
762
|
+
const first = Array.isArray(rows) ? rows[0] : undefined;
|
|
763
|
+
if (!first || !Array.isArray(first.vector)) {
|
|
764
|
+
throw new BrokerError(`asset '${anchor.asset}' has no stored vector in space '${space}'`);
|
|
765
|
+
}
|
|
766
|
+
vec = first.vector.map(Number);
|
|
767
|
+
cache.set(key, vec);
|
|
768
|
+
}
|
|
769
|
+
return vec;
|
|
770
|
+
}
|
|
771
|
+
if (anchor.kind === "text") {
|
|
772
|
+
const key = `${space}|text:${anchor.text}`;
|
|
773
|
+
let vec = cache.get(key);
|
|
774
|
+
if (!vec) {
|
|
775
|
+
if (model === null) {
|
|
776
|
+
throw new BrokerError(`space '${space}' has no model configured — cannot embed text`);
|
|
777
|
+
}
|
|
778
|
+
vec = await client.embed({ text: anchor.text, model });
|
|
779
|
+
cache.set(key, vec);
|
|
780
|
+
}
|
|
781
|
+
return vec;
|
|
782
|
+
}
|
|
783
|
+
const key = `${space}|concept:${anchor.concept}:${anchor.collection ?? ""}`;
|
|
784
|
+
let vec = cache.get(key);
|
|
785
|
+
if (!vec) {
|
|
786
|
+
const query = { concept: anchor.concept, space };
|
|
787
|
+
if (anchor.collection !== null)
|
|
788
|
+
query.collection = anchor.collection;
|
|
789
|
+
const data = (await client.request({
|
|
790
|
+
method: "GET",
|
|
791
|
+
path: "/v1/library/concept-vector",
|
|
792
|
+
query,
|
|
793
|
+
}));
|
|
794
|
+
vec = data.vector.map(Number);
|
|
795
|
+
cache.set(key, vec);
|
|
796
|
+
}
|
|
797
|
+
return vec;
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Handle ``embed.resolve`` requests from code-joined peers (e.g. a loginless Unity scene) — resolve
|
|
801
|
+
* named operand descriptors (``{ text }`` / ``{ concept, collection? }`` / ``{ vector }`` /
|
|
802
|
+
* ``{ asset, match_by? }``) to vectors in ONE embedding space, so a peer with no credentials can
|
|
803
|
+
* compare live signals against library concepts/text/assets. **The requester chooses the space**
|
|
804
|
+
* (omitted → the default text space). A per-anchor
|
|
805
|
+
* failure lands in ``errors`` without failing the others; a malformed / over-``maxAnchors`` request
|
|
806
|
+
* or a ``space`` outside ``spaces`` gets a whole-request ``{ error }``. Announces the discoverable
|
|
807
|
+
* ``embed-broker`` role — call before ``connect()``. Mirror of Python's ``offer_embeds``.
|
|
808
|
+
*/
|
|
809
|
+
offerEmbeds(opts = {}) {
|
|
810
|
+
const client = this.requireClient("offerEmbeds");
|
|
811
|
+
this.requirePreConnect();
|
|
812
|
+
const allowed = opts.spaces ? new Set(opts.spaces) : null;
|
|
813
|
+
const maxAnchors = opts.maxAnchors ?? DEFAULT_MAX_ANCHORS;
|
|
814
|
+
const cache = new Map();
|
|
815
|
+
this.announceRole(EMBED_BROKER_ROLE);
|
|
816
|
+
this.on("embed.resolve", async (e) => {
|
|
817
|
+
const anchors = e.payload.anchors;
|
|
818
|
+
if (typeof anchors !== "object" ||
|
|
819
|
+
anchors === null ||
|
|
820
|
+
Array.isArray(anchors) ||
|
|
821
|
+
Object.keys(anchors).length === 0) {
|
|
822
|
+
return { error: "anchors must be a non-empty object" };
|
|
823
|
+
}
|
|
824
|
+
const entries = Object.entries(anchors);
|
|
825
|
+
if (entries.length > maxAnchors) {
|
|
826
|
+
return { error: `too many anchors (${entries.length} > ${maxAnchors})` };
|
|
827
|
+
}
|
|
828
|
+
const requested = typeof e.payload.space === "string" ? e.payload.space : undefined;
|
|
829
|
+
if (requested !== undefined && allowed !== null && !allowed.has(requested)) {
|
|
830
|
+
return { error: `space '${requested}' is not served by this broker` };
|
|
831
|
+
}
|
|
832
|
+
let space;
|
|
833
|
+
let model;
|
|
834
|
+
try {
|
|
835
|
+
;
|
|
836
|
+
({ space, model } = await this.resolveEmbedSpace(client, requested));
|
|
837
|
+
}
|
|
838
|
+
catch (err) {
|
|
839
|
+
return { error: asError(err).message };
|
|
840
|
+
}
|
|
841
|
+
if (allowed !== null && !allowed.has(space)) {
|
|
842
|
+
return { error: `space '${space}' is not served by this broker` };
|
|
843
|
+
}
|
|
844
|
+
const vectors = {};
|
|
845
|
+
const errors = {};
|
|
846
|
+
for (const [nm, desc] of entries) {
|
|
847
|
+
try {
|
|
848
|
+
vectors[nm] = await this.resolveAnchorVector(client, desc, space, model, cache);
|
|
849
|
+
}
|
|
850
|
+
catch (err) {
|
|
851
|
+
errors[nm] = asError(err).message;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const reply = { space, vectors };
|
|
855
|
+
if (Object.keys(errors).length > 0)
|
|
856
|
+
reply.errors = errors;
|
|
857
|
+
return reply;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Handle ``chat.generate`` requests from code-joined peers — a stateless chat completion (no history
|
|
862
|
+
* broker-side), so a loginless peer gets an LLM reply without its own credentials. Reply:
|
|
863
|
+
* ``{ text, model }``, or ``{ error }`` if ``messages`` is malformed / over the payload cap. The
|
|
864
|
+
* **broker** pins ``model`` (the requester cannot choose it — cost control); ``maxTokens`` caps each
|
|
865
|
+
* reply. Announces the discoverable ``chat-broker`` role — call before ``connect()``. Every peer
|
|
866
|
+
* holding the connect code can trigger a reply on **your** billing. Mirror of Python's ``offer_chat``.
|
|
867
|
+
*/
|
|
868
|
+
offerChat(opts = {}) {
|
|
869
|
+
const client = this.requireClient("offerChat");
|
|
870
|
+
this.requirePreConnect();
|
|
871
|
+
const maxTokens = opts.maxTokens ?? DEFAULT_CHAT_MAX_TOKENS;
|
|
872
|
+
this.announceRole(CHAT_BROKER_ROLE);
|
|
873
|
+
this.on("chat.generate", async (e) => {
|
|
874
|
+
let messages;
|
|
875
|
+
try {
|
|
876
|
+
messages = validateChatMessages(e.payload.messages);
|
|
877
|
+
}
|
|
878
|
+
catch (err) {
|
|
879
|
+
return { error: asError(err).message };
|
|
880
|
+
}
|
|
881
|
+
try {
|
|
882
|
+
const reply = await client.chat(messages, { model: opts.model, max_tokens: maxTokens });
|
|
883
|
+
return { text: reply.content, model: reply.model };
|
|
884
|
+
}
|
|
885
|
+
catch (err) {
|
|
886
|
+
return { error: asError(err).message };
|
|
887
|
+
}
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
// ── Audio embedding (vectors + reactive scores) ──────────────────────────
|
|
891
|
+
// Resolve the (space, model) pair for the audio surfaces (mirror of Python's `_resolve_audio_space`):
|
|
892
|
+
// an explicit space+model skips the lookup; otherwise the member-facing spaces list resolves the
|
|
893
|
+
// deployment's default audio space and/or reads the space's configured model.
|
|
894
|
+
async resolveAudioSpace(space, model) {
|
|
895
|
+
if (space !== undefined && model !== undefined)
|
|
896
|
+
return { space, model };
|
|
897
|
+
const spaces = await this.requireClient("audioVectors / audioScores").library.spaces();
|
|
898
|
+
let matched;
|
|
899
|
+
if (space === undefined) {
|
|
900
|
+
matched = spaces.find((s) => s.defaultModalities.includes("audio"));
|
|
901
|
+
if (!matched) {
|
|
902
|
+
throw new Error("no default audio search space is configured on this deployment — pass space to audioVectors()");
|
|
903
|
+
}
|
|
904
|
+
space = matched.id;
|
|
905
|
+
}
|
|
906
|
+
else {
|
|
907
|
+
matched = spaces.find((s) => s.id === space);
|
|
908
|
+
if (!matched)
|
|
909
|
+
throw new Error(`space '${space}' not found`);
|
|
910
|
+
}
|
|
911
|
+
return { space, model: model ?? matched?.model ?? null };
|
|
912
|
+
}
|
|
913
|
+
// A dedicated snippet-consumer socket (announcing the demand role so a producer captures), decoding
|
|
914
|
+
// its binary frames — the shared source behind audioVectors/audioScores (mirror of audioClips).
|
|
915
|
+
async *snippetStream(identity, signal) {
|
|
916
|
+
this.requireWsAudio("live audio streaming");
|
|
917
|
+
const queue = [];
|
|
918
|
+
let wake = null;
|
|
919
|
+
let ended = false;
|
|
920
|
+
const socket = new RoomSocket({
|
|
921
|
+
roomId: this.id,
|
|
922
|
+
code: this.connectCode,
|
|
923
|
+
baseUrl: this.deps.baseUrl,
|
|
924
|
+
identity,
|
|
925
|
+
announce: { ...this.deps.announce, role: AUDIO_SNIPPET_CONSUMER_ROLE },
|
|
926
|
+
reconnect: this.deps.reconnect,
|
|
927
|
+
WebSocketCtor: this.deps.WebSocketCtor,
|
|
928
|
+
onBinary: (data) => {
|
|
929
|
+
const frame = decodeAudioSnippetFrame(data);
|
|
930
|
+
if (!frame)
|
|
931
|
+
return; // a foreign / DSP binary frame — not ours
|
|
932
|
+
if (queue.length >= RAW_CONSUMER_QUEUE_MAX)
|
|
933
|
+
queue.shift(); // drop oldest — bound a slow consumer
|
|
934
|
+
queue.push(frame);
|
|
935
|
+
wake?.();
|
|
936
|
+
wake = null;
|
|
937
|
+
},
|
|
938
|
+
onEnd: () => {
|
|
939
|
+
ended = true;
|
|
940
|
+
wake?.();
|
|
941
|
+
wake = null;
|
|
942
|
+
},
|
|
943
|
+
});
|
|
944
|
+
// An abort (a `transcribe()` handle's stop) closes the socket → ends this generator at once.
|
|
945
|
+
const onAbort = () => {
|
|
946
|
+
ended = true;
|
|
947
|
+
socket.close();
|
|
948
|
+
wake?.();
|
|
949
|
+
wake = null;
|
|
950
|
+
};
|
|
951
|
+
if (signal) {
|
|
952
|
+
if (signal.aborted)
|
|
953
|
+
onAbort();
|
|
954
|
+
else
|
|
955
|
+
signal.addEventListener("abort", onAbort);
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
try {
|
|
959
|
+
await socket.connect();
|
|
960
|
+
}
|
|
961
|
+
catch (err) {
|
|
962
|
+
// A pre-/mid-connect abort closed the socket → connect() rejects "room closed"; that's an
|
|
963
|
+
// expected teardown, not a failure. Only a genuine connect error (bad code) propagates.
|
|
964
|
+
if (!ended)
|
|
965
|
+
throw err;
|
|
966
|
+
}
|
|
967
|
+
while (!ended) {
|
|
968
|
+
while (queue.length > 0)
|
|
969
|
+
yield queue.shift();
|
|
970
|
+
if (ended)
|
|
971
|
+
break;
|
|
972
|
+
await new Promise((resolve) => {
|
|
973
|
+
wake = resolve;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
while (queue.length > 0)
|
|
977
|
+
yield queue.shift();
|
|
978
|
+
}
|
|
979
|
+
finally {
|
|
980
|
+
signal?.removeEventListener("abort", onAbort);
|
|
981
|
+
socket.close();
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
/**
|
|
985
|
+
* Subscribe to the room's live ``audio.snippet`` stream and embed each ~10s window, yielding an
|
|
986
|
+
* {@link AudioVector} per snippet — a live vector of "what the room sounds like right now".
|
|
987
|
+
* Subscribing *is* the demand signal (announces the ``audio-snippet-consumer`` role, so a
|
|
988
|
+
* producer's default-off "Share audio clips" toggle actually captures). Break out of the
|
|
989
|
+
* ``for await`` loop to end it. Mirror of Python's ``audio_vectors``.
|
|
990
|
+
*
|
|
991
|
+
* ``space`` pins the search space snippets are labeled with (default: the deployment's default
|
|
992
|
+
* audio space, so a no-arg vector is comparable against library-derived vectors); ``model`` the
|
|
993
|
+
* embedding model (default: the resolved space's configured model). ``relay: true`` also relays
|
|
994
|
+
* each vector into the room as an ephemeral ``audio.vector`` signal — so a loginless code-joined
|
|
995
|
+
* peer can consume the live mood vector without credentials of its own. Needs an authenticated
|
|
996
|
+
* client to embed with (throws on a loginless handle); binary rides the WebSocket only.
|
|
997
|
+
*/
|
|
998
|
+
async *audioVectors(opts = {}) {
|
|
999
|
+
const client = this.requireClient("audioVectors / audioScores");
|
|
1000
|
+
const { space, model } = await this.resolveAudioSpace(opts.space, opts.model);
|
|
1001
|
+
if (model === null) {
|
|
1002
|
+
throw new Error(`the audio search space '${space}' has no model configured — pass model to audioVectors()`);
|
|
1003
|
+
}
|
|
1004
|
+
for await (const frame of this.snippetStream(`audio-vectors:${randomId().slice(0, 8)}`)) {
|
|
1005
|
+
const fmt = frame.mimeType.split(";")[0]?.split("/").pop();
|
|
1006
|
+
const vector = await client.embed({
|
|
1007
|
+
audio: new Blob([frame.data], { type: frame.mimeType }),
|
|
1008
|
+
model,
|
|
1009
|
+
format: fmt,
|
|
1010
|
+
});
|
|
1011
|
+
const vec = {
|
|
1012
|
+
vector,
|
|
1013
|
+
space,
|
|
1014
|
+
sequence: frame.sequence,
|
|
1015
|
+
windowSeconds: frame.windowSeconds,
|
|
1016
|
+
timestamp: Date.now() / 1000,
|
|
1017
|
+
};
|
|
1018
|
+
if (opts.relay) {
|
|
1019
|
+
await this.send("audio.vector", {
|
|
1020
|
+
vector: vec.vector,
|
|
1021
|
+
space: vec.space,
|
|
1022
|
+
sequence: vec.sequence,
|
|
1023
|
+
window_seconds: vec.windowSeconds,
|
|
1024
|
+
timestamp: vec.timestamp,
|
|
1025
|
+
}, { ephemeral: true });
|
|
1026
|
+
}
|
|
1027
|
+
yield vec;
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
async preflightConceptAnchors(members, space) {
|
|
1031
|
+
// Resolve every concept anchor's vector once before waiting on any frame — a typo'd/unresolvable
|
|
1032
|
+
// concept fails immediately, naming the anchor, rather than surfacing mid-stream.
|
|
1033
|
+
const client = this.requireClient("audioVectors / audioScores");
|
|
1034
|
+
for (const [name, ops] of Object.entries(members)) {
|
|
1035
|
+
for (const op of ops) {
|
|
1036
|
+
if (op.kind !== "concept")
|
|
1037
|
+
continue;
|
|
1038
|
+
const query = { concept: op.name, space };
|
|
1039
|
+
if (op.collection !== undefined)
|
|
1040
|
+
query.collection = op.collection;
|
|
1041
|
+
try {
|
|
1042
|
+
await client.request({ method: "GET", path: "/v1/library/concept-vector", query });
|
|
1043
|
+
}
|
|
1044
|
+
catch (err) {
|
|
1045
|
+
throw new Error(`anchor '${name}': ${err.message}`);
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
/**
|
|
1051
|
+
* Fuse {@link audioVectors} with per-anchor ``library.compare`` scoring — a live "which anchor does
|
|
1052
|
+
* the room sound like right now" signal. ``anchors`` maps a name to anything the ``by`` grammar
|
|
1053
|
+
* accepts (a bare string / {@link "../by".Asset} / ``by.*`` marker), **or an array** of those (a
|
|
1054
|
+
* few-shot category, scored as the mean of its top-``topN`` members' calibrated compare scores;
|
|
1055
|
+
* default ``topN: 1`` — the nearest member wins). Each frame's anchors are scored concurrently.
|
|
1056
|
+
*
|
|
1057
|
+
* Each {@link ScoredAudio} carries the calibrated ``scores`` plus ``mix`` (normalized to sum 1,
|
|
1058
|
+
* uniform when every score is 0 — gate on ``max(scores)`` for silence). No smoothing — ease the
|
|
1059
|
+
* stream with a {@link ../decider.Decider}. ``relay: true`` forwards to {@link audioVectors} (it
|
|
1060
|
+
* relays the raw ``audio.vector``, not the computed scores). Concept anchors are validated once up
|
|
1061
|
+
* front (a typo raises before any frame); a mid-stream compare failure re-throws named by anchor.
|
|
1062
|
+
* Mirror of Python's ``audio_scores``.
|
|
1063
|
+
*/
|
|
1064
|
+
async *audioScores(anchors, opts = {}) {
|
|
1065
|
+
const client = this.requireClient("audioVectors / audioScores");
|
|
1066
|
+
const names = Object.keys(anchors);
|
|
1067
|
+
const members = {};
|
|
1068
|
+
for (const name of names)
|
|
1069
|
+
members[name] = toAnchorMembers(anchors[name]);
|
|
1070
|
+
const { space, model } = await this.resolveAudioSpace(opts.space, opts.model);
|
|
1071
|
+
if (model === null) {
|
|
1072
|
+
throw new Error(`the audio search space '${space}' has no model configured — pass model to audioScores()`);
|
|
1073
|
+
}
|
|
1074
|
+
await this.preflightConceptAnchors(members, space);
|
|
1075
|
+
const topN = Math.max(1, opts.topN ?? 1);
|
|
1076
|
+
for await (const vec of this.audioVectors({ space, model, relay: opts.relay })) {
|
|
1077
|
+
const vectorOp = by.vector(vec.vector);
|
|
1078
|
+
// Score every member of every anchor concurrently — a handful of compares is cheap vs. the ~10s
|
|
1079
|
+
// window. Flatten to (name, op) pairs, then fold back into per-anchor top-N means below.
|
|
1080
|
+
const flat = names.flatMap((name) => members[name].map((op) => ({ name, op })));
|
|
1081
|
+
const results = await Promise.all(flat.map(async ({ name, op }) => {
|
|
1082
|
+
try {
|
|
1083
|
+
const r = await client.library.compare(vectorOp, op, { space: vec.space });
|
|
1084
|
+
return r.score;
|
|
1085
|
+
}
|
|
1086
|
+
catch (err) {
|
|
1087
|
+
throw new Error(`anchor '${name}': ${err.message}`);
|
|
1088
|
+
}
|
|
1089
|
+
}));
|
|
1090
|
+
const scores = {};
|
|
1091
|
+
let i = 0;
|
|
1092
|
+
for (const name of names) {
|
|
1093
|
+
const n = members[name].length;
|
|
1094
|
+
const top = results
|
|
1095
|
+
.slice(i, i + n)
|
|
1096
|
+
.sort((a, b) => b - a)
|
|
1097
|
+
.slice(0, topN);
|
|
1098
|
+
scores[name] = top.reduce((a, b) => a + b, 0) / top.length;
|
|
1099
|
+
i += n;
|
|
1100
|
+
}
|
|
1101
|
+
const total = Object.values(scores).reduce((a, b) => a + b, 0);
|
|
1102
|
+
const mix = {};
|
|
1103
|
+
for (const name of names) {
|
|
1104
|
+
mix[name] = total > 0 ? scores[name] / total : 1 / names.length;
|
|
1105
|
+
}
|
|
1106
|
+
yield {
|
|
1107
|
+
scores,
|
|
1108
|
+
mix,
|
|
1109
|
+
vector: vec.vector,
|
|
1110
|
+
space: vec.space,
|
|
1111
|
+
sequence: vec.sequence,
|
|
1112
|
+
windowSeconds: vec.windowSeconds,
|
|
1113
|
+
timestamp: vec.timestamp,
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* The room's current presence roster as typed {@link Participant}s (merged ``roles`` / ``name`` /
|
|
1119
|
+
* ``connectedAt`` as a ``Date``). Opens one short **observe** SSE stream, reads the roster off its
|
|
1120
|
+
* first ``ready`` frame, and closes — a per-web-worker, best-effort snapshot (mirror of Python's
|
|
1121
|
+
* ``participants()``). Rejects if the stream is refused (a 4xx — room deleted, or a bad code).
|
|
1122
|
+
*/
|
|
1123
|
+
async participants() {
|
|
1124
|
+
const roster = await readObserveRoster({
|
|
1125
|
+
roomId: this.id,
|
|
1126
|
+
code: this.connectCode,
|
|
1127
|
+
baseUrl: this.deps.baseUrl,
|
|
1128
|
+
fetchImpl: this.deps.fetchImpl,
|
|
1129
|
+
});
|
|
1130
|
+
return roster.map(parseParticipant);
|
|
1131
|
+
}
|
|
1132
|
+
/**
|
|
1133
|
+
* Is any current participant matching the given predicate connected? Sugar over
|
|
1134
|
+
* {@link participants} (same best-effort per-worker caveat). Give at least one of ``role`` /
|
|
1135
|
+
* ``name`` / ``identityPrefix`` — several are ANDed; ``role`` matches the merged roles, ``name``
|
|
1136
|
+
* the announced name exactly, ``identityPrefix`` the identity's leading segment (a hosted Player
|
|
1137
|
+
* announces ``player:web``, so ``{ identityPrefix: "player:" }`` is the launch-once guard). Throws
|
|
1138
|
+
* if no predicate is given (mirror of Python's ``has()``).
|
|
1139
|
+
*/
|
|
1140
|
+
async has(pred) {
|
|
1141
|
+
if (pred.role === undefined && pred.name === undefined && pred.identityPrefix === undefined) {
|
|
1142
|
+
throw new Error("has() needs at least one of role, name, or identityPrefix");
|
|
1143
|
+
}
|
|
1144
|
+
const roster = await this.participants();
|
|
1145
|
+
return roster.some((p) => participantMatches(p, pred));
|
|
1146
|
+
}
|
|
1147
|
+
// ── Player controls (the `layer.*` / `play` dialect) ─────────────────────
|
|
1148
|
+
/** Play a sound (or several) on a layer — the one playback verb. Per-sound options (``effects``,
|
|
1149
|
+
* ``spatial`` — including its optional ``distance`` attenuation — ``delay_seconds``, ``loop`` …)
|
|
1150
|
+
* ride a hand-rolled track dict and pass through untouched. */
|
|
1151
|
+
async play(source, opts = {}) {
|
|
1152
|
+
await this.connect();
|
|
1153
|
+
this.socket?.sendSignal({ type: "play", payload: buildPlayBody({ source, ...opts }) });
|
|
1154
|
+
}
|
|
1155
|
+
/** Declare (or replace) the Player's layer set — idempotent. */
|
|
1156
|
+
async configureLayers(layers) {
|
|
1157
|
+
await this.connect();
|
|
1158
|
+
this.socket?.sendSignal({ type: "layer.configure", payload: { layers } });
|
|
1159
|
+
}
|
|
1160
|
+
/** Fade out and empty a layer — its queue and one-shots (omit ``layer`` → ``"default"``). */
|
|
1161
|
+
async clearLayer(opts = {}) {
|
|
1162
|
+
await this.connect();
|
|
1163
|
+
this.socket?.sendSignal({
|
|
1164
|
+
type: "layer.clear",
|
|
1165
|
+
payload: buildLayerClearBody(opts.layer, opts.fadeOutMs),
|
|
1166
|
+
});
|
|
1167
|
+
}
|
|
1168
|
+
/** Fade out and empty **every** layer at once — "stop all audio" (the ``"*"`` wildcard). */
|
|
1169
|
+
async clearAll(opts = {}) {
|
|
1170
|
+
await this.clearLayer({ layer: "*", fadeOutMs: opts.fadeOutMs });
|
|
1171
|
+
}
|
|
1172
|
+
/** Set (or ramp) a layer's effects, e.g. ``{ lowpass: { hz: 700 } }`` for a backroom muffle.
|
|
1173
|
+
* ``lowpass`` / ``highpass`` / ``reverb`` / ``echo`` are all live (an absent implemented key ramps
|
|
1174
|
+
* back to neutral); ``layer`` may be ``"*"`` to apply to every existing layer. */
|
|
1175
|
+
async setLayerEffects(layer, effects, opts = {}) {
|
|
1176
|
+
await this.connect();
|
|
1177
|
+
this.socket?.sendSignal({
|
|
1178
|
+
type: "layer.effects",
|
|
1179
|
+
payload: buildLayerEffectsBody(layer, effects, opts.fadeMs),
|
|
1180
|
+
});
|
|
1181
|
+
}
|
|
1182
|
+
/** Set (or ramp) a layer's HRTF position — placed whole-layer in 3D over ``fadeMs`` (default 50ms
|
|
1183
|
+
* to avoid zipper noise, applied Player-side). ``z`` defaults to 0. ``distance`` opts into distance
|
|
1184
|
+
* attenuation (``{ model, ref, max, rolloff }``); absent → direction-only. No ``"*"`` wildcard. */
|
|
1185
|
+
async setLayerPosition(layer, x, y, z = 0, opts = {}) {
|
|
1186
|
+
await this.connect();
|
|
1187
|
+
this.socket?.sendSignal({
|
|
1188
|
+
type: "layer.position",
|
|
1189
|
+
payload: buildLayerPositionBody({
|
|
1190
|
+
layer,
|
|
1191
|
+
x,
|
|
1192
|
+
y,
|
|
1193
|
+
z,
|
|
1194
|
+
fadeMs: opts.fadeMs,
|
|
1195
|
+
distance: opts.distance,
|
|
1196
|
+
}),
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
/** Pull the Player's current layer state (a broadcast request; first Player to reply wins). */
|
|
1200
|
+
async queryLayers(opts = {}) {
|
|
1201
|
+
return this.request("layer.query", {}, { timeout: opts.timeout });
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Subscribe to the Player's live layer state, invoking ``callback`` with each ``{ layers }``
|
|
1205
|
+
* snapshot the Player pushes (every meaningful change + a ~1s heartbeat while playing). Demand-
|
|
1206
|
+
* gated: the Player only broadcasts while a subscriber is present. Returns an unsubscribe function
|
|
1207
|
+
* that sends ``layer.unsubscribe``.
|
|
1208
|
+
*/
|
|
1209
|
+
watchLayers(callback) {
|
|
1210
|
+
const first = this.layerWatchers.size === 0;
|
|
1211
|
+
this.layerWatchers.add(callback);
|
|
1212
|
+
void this.connect().then(() => {
|
|
1213
|
+
if (first || this.connectionId)
|
|
1214
|
+
this.socket?.sendSignal({ type: "layer.subscribe" });
|
|
1215
|
+
});
|
|
1216
|
+
return () => {
|
|
1217
|
+
this.layerWatchers.delete(callback);
|
|
1218
|
+
if (this.layerWatchers.size === 0)
|
|
1219
|
+
this.socket?.sendSignal({ type: "layer.unsubscribe" });
|
|
1220
|
+
};
|
|
1221
|
+
}
|
|
1222
|
+
/** The hosted Player's launch URL for this room (``<base>/tools/player?code=<code>``). Pure. */
|
|
1223
|
+
playerUrl(opts = {}) {
|
|
1224
|
+
const params = new URLSearchParams({ code: this.connectCode });
|
|
1225
|
+
if (opts.analysis !== undefined)
|
|
1226
|
+
params.set("analysis", opts.analysis ? "on" : "off");
|
|
1227
|
+
if (opts.clips !== undefined)
|
|
1228
|
+
params.set("clips", opts.clips ? "on" : "off");
|
|
1229
|
+
return `${this.toolBase()}/tools/player?${params.toString()}`;
|
|
1230
|
+
}
|
|
1231
|
+
// ── Viewer controls (the `show` / `show.clear` dialect) ──────────────────
|
|
1232
|
+
/**
|
|
1233
|
+
* Show an image on the Viewer — the one display verb. Give exactly one of ``assetId`` (a stored
|
|
1234
|
+
* asset) or ``url`` (any reachable image); passing both, or neither, throws. ``caption`` labels
|
|
1235
|
+
* the frame; ``projection`` (``"flat"`` for a plain image, ``"360"`` for an equirectangular
|
|
1236
|
+
* panorama) forces how the Viewer renders it — omit it and the Viewer's own default applies. See
|
|
1237
|
+
* ``docs/reference/viewer-dialect.md``.
|
|
1238
|
+
*/
|
|
1239
|
+
async show(opts = {}) {
|
|
1240
|
+
await this.connect();
|
|
1241
|
+
this.socket?.sendSignal({ type: "show", payload: buildShowBody(opts) });
|
|
1242
|
+
}
|
|
1243
|
+
/** Clear the Viewer — blank the frame (the ``show.clear`` verb, empty body). */
|
|
1244
|
+
async clearView() {
|
|
1245
|
+
await this.connect();
|
|
1246
|
+
this.socket?.sendSignal({ type: "show.clear", payload: {} });
|
|
1247
|
+
}
|
|
1248
|
+
/** The hosted Viewer's launch URL for this room (``<base>/tools/viewer?code=<code>``). Pure. The
|
|
1249
|
+
* Viewer defaults to auto-turn on; ``autoTurn`` bakes its persistent preference into the URL as
|
|
1250
|
+
* ``autoturn=1``/``0`` (omitted → the Viewer's stored/default preference applies). */
|
|
1251
|
+
viewerUrl(opts = {}) {
|
|
1252
|
+
const params = new URLSearchParams({ code: this.connectCode });
|
|
1253
|
+
if (opts.autoTurn !== undefined)
|
|
1254
|
+
params.set("autoturn", opts.autoTurn ? "1" : "0");
|
|
1255
|
+
return `${this.toolBase()}/tools/viewer?${params.toString()}`;
|
|
1256
|
+
}
|
|
1257
|
+
/** The observation dashboard's launch URL (``<base>/tools/dashboard?code=<code>``). Pure — the
|
|
1258
|
+
* dashboard takes only the connect code (mirror of Python's ``dashboard_url``). */
|
|
1259
|
+
dashboardUrl() {
|
|
1260
|
+
return `${this.toolBase()}/tools/dashboard?${new URLSearchParams({ code: this.connectCode })}`;
|
|
1261
|
+
}
|
|
1262
|
+
/** Open the observation dashboard for this room in a browser tab. Always opens — it does not check
|
|
1263
|
+
* presence. Throws outside a browser (there is no tab to open). */
|
|
1264
|
+
openDashboard() {
|
|
1265
|
+
openTab(this.dashboardUrl());
|
|
1266
|
+
}
|
|
1267
|
+
/** The Capture tool's launch URL for this room (``<base>/tools/capture?code=<code>``). Pure.
|
|
1268
|
+
* ``name`` prefills the participant name field; ``captions`` / ``save`` pre-engage the "Live
|
|
1269
|
+
* captions" / "Save recording" toggles (emitted only when truthy — else the tool's own default
|
|
1270
|
+
* applies). Mirror of Python's ``capture_url``. */
|
|
1271
|
+
captureUrl(opts = {}) {
|
|
1272
|
+
const params = new URLSearchParams({ code: this.connectCode });
|
|
1273
|
+
if (opts.name !== undefined)
|
|
1274
|
+
params.set("name", opts.name);
|
|
1275
|
+
if (opts.captions)
|
|
1276
|
+
params.set("captions", "on");
|
|
1277
|
+
if (opts.save)
|
|
1278
|
+
params.set("save", "on");
|
|
1279
|
+
return `${this.toolBase()}/tools/capture?${params.toString()}`;
|
|
1280
|
+
}
|
|
1281
|
+
/** Open the Capture tool for this room in a browser tab (see {@link captureUrl}). Always opens —
|
|
1282
|
+
* it does not check presence. Throws outside a browser (there is no tab to open). */
|
|
1283
|
+
openCapture(opts = {}) {
|
|
1284
|
+
openTab(this.captureUrl(opts));
|
|
1285
|
+
}
|
|
1286
|
+
toolBase() {
|
|
1287
|
+
return this.deps.baseUrl.replace(/\/+$/, "");
|
|
1288
|
+
}
|
|
1289
|
+
// ── Management (authenticated handles only) ──────────────────────────────
|
|
1290
|
+
rest(req) {
|
|
1291
|
+
if (!this.deps.rest) {
|
|
1292
|
+
throw new AtriumError("this room handle has no account credential — join(code) can only send/receive");
|
|
1293
|
+
}
|
|
1294
|
+
return this.deps.rest(req);
|
|
1295
|
+
}
|
|
1296
|
+
/** Rotate the connect code (owner only); updates ``connectCode`` and returns it. */
|
|
1297
|
+
async rotateCode() {
|
|
1298
|
+
const data = (await this.rest(buildRoomRotateCode(this.id)));
|
|
1299
|
+
this.connectCode = data.connect_code ?? this.connectCode;
|
|
1300
|
+
return this.connectCode;
|
|
1301
|
+
}
|
|
1302
|
+
/** Delete the room for good (its connect code dies with it). */
|
|
1303
|
+
async delete() {
|
|
1304
|
+
await this.rest({ method: "DELETE", path: `/v1/rooms/${this.id}` });
|
|
1305
|
+
}
|
|
1306
|
+
// ── Lifecycle ────────────────────────────────────────────────────────────
|
|
1307
|
+
/** Disconnect the socket, stop dispatch, and fail any in-flight requests — the fire-and-forget
|
|
1308
|
+
* teardown. Idempotent. The synchronous state (sockets closed, requests failed, listeners cleared)
|
|
1309
|
+
* settles at once; the standing offers' async wind-down (**including finalizing their open
|
|
1310
|
+
* recordings**) and the transcribers' stop run in the background. To *await* that finalize — so a
|
|
1311
|
+
* process exit can't drop it — use {@link closeAndFinalize} / ``await using room``. */
|
|
1312
|
+
close() {
|
|
1313
|
+
void this.teardown();
|
|
1314
|
+
}
|
|
1315
|
+
/** Await the full teardown end-to-end — every recorder/transcriber stopped and every standing offer
|
|
1316
|
+
* wound down **with its open recordings finalized** — mirroring Python's awaited ``async with room``
|
|
1317
|
+
* exit. Idempotent; ``await using room`` calls this. Prefer it over {@link close} when the offers'
|
|
1318
|
+
* finalize POSTs must land before the process moves on. */
|
|
1319
|
+
async closeAndFinalize() {
|
|
1320
|
+
await this.teardown();
|
|
1321
|
+
}
|
|
1322
|
+
teardown() {
|
|
1323
|
+
const stops = [];
|
|
1324
|
+
for (const rec of this.recorders.splice(0))
|
|
1325
|
+
rec.stop();
|
|
1326
|
+
for (const t of this.transcribers.splice(0))
|
|
1327
|
+
stops.push(t.stop().catch(() => { }));
|
|
1328
|
+
for (const s of this.offeredServices.splice(0)) {
|
|
1329
|
+
stops.push(Promise.resolve(s.stop()).catch(() => { }));
|
|
1330
|
+
s.detach?.(); // capture unsubscribes from the recorder seam
|
|
1331
|
+
}
|
|
1332
|
+
this.socket?.close();
|
|
1333
|
+
this.socket = null;
|
|
1334
|
+
this.audioSocket?.close();
|
|
1335
|
+
this.audioSocket = null;
|
|
1336
|
+
this.connectPromise = null;
|
|
1337
|
+
for (const p of this.pending.values()) {
|
|
1338
|
+
clearTimeout(p.timer);
|
|
1339
|
+
p.reject(new Error("room closed"));
|
|
1340
|
+
}
|
|
1341
|
+
this.pending.clear();
|
|
1342
|
+
this.audioSubscribers.clear();
|
|
1343
|
+
this.layerWatchers.clear();
|
|
1344
|
+
return stops.length > 0 ? Promise.allSettled(stops).then(() => { }) : Promise.resolve();
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
if (typeof Symbol.dispose === "symbol") {
|
|
1348
|
+
Room.prototype[Symbol.dispose] = function () {
|
|
1349
|
+
this.close();
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
if (typeof Symbol.asyncDispose === "symbol") {
|
|
1353
|
+
Room.prototype[Symbol.asyncDispose] = async function () {
|
|
1354
|
+
await this.closeAndFinalize();
|
|
1355
|
+
};
|
|
1356
|
+
}
|
|
1357
|
+
//# sourceMappingURL=room.js.map
|