hci-atrium 0.1.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 +266 -8
- package/dist/asset-meta.d.ts +31 -0
- package/dist/asset-meta.d.ts.map +1 -0
- package/dist/asset-meta.js +25 -0
- package/dist/asset-meta.js.map +1 -0
- package/dist/atrium.d.ts +220 -7
- package/dist/atrium.d.ts.map +1 -1
- package/dist/atrium.js +410 -39
- package/dist/atrium.js.map +1 -1
- package/dist/dash.d.ts +206 -0
- package/dist/dash.d.ts.map +1 -0
- package/dist/dash.js +302 -0
- package/dist/dash.js.map +1 -0
- package/dist/direct.d.ts +86 -0
- package/dist/direct.d.ts.map +1 -0
- package/dist/direct.js +121 -0
- package/dist/direct.js.map +1 -0
- package/dist/index.d.ts +8 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/index.js.map +1 -1
- package/dist/models.d.ts +70 -0
- package/dist/models.d.ts.map +1 -1
- package/dist/models.js +56 -0
- package/dist/models.js.map +1 -1
- package/dist/player/audio-layers.d.ts +306 -0
- package/dist/player/audio-layers.d.ts.map +1 -0
- package/dist/player/audio-layers.js +1245 -0
- package/dist/player/audio-layers.js.map +1 -0
- package/dist/player/index.d.ts +13 -0
- package/dist/player/index.d.ts.map +1 -0
- package/dist/player/index.js +13 -0
- package/dist/player/index.js.map +1 -0
- package/dist/player/layer-signals.d.ts +80 -0
- package/dist/player/layer-signals.d.ts.map +1 -0
- package/dist/player/layer-signals.js +174 -0
- package/dist/player/layer-signals.js.map +1 -0
- package/dist/player/play-command.d.ts +55 -0
- package/dist/player/play-command.d.ts.map +1 -0
- package/dist/player/play-command.js +36 -0
- package/dist/player/play-command.js.map +1 -0
- package/dist/player/play-intent.d.ts +18 -0
- package/dist/player/play-intent.d.ts.map +1 -0
- package/dist/player/play-intent.js +26 -0
- package/dist/player/play-intent.js.map +1 -0
- package/dist/player/runtime.d.ts +105 -0
- package/dist/player/runtime.d.ts.map +1 -0
- package/dist/player/runtime.js +402 -0
- package/dist/player/runtime.js.map +1 -0
- package/dist/react/index.d.ts +1 -0
- package/dist/react/index.d.ts.map +1 -1
- package/dist/react/index.js +1 -0
- package/dist/react/index.js.map +1 -1
- package/dist/react/use-room-connection.d.ts +40 -0
- package/dist/react/use-room-connection.d.ts.map +1 -0
- package/dist/react/use-room-connection.js +85 -0
- package/dist/react/use-room-connection.js.map +1 -0
- package/dist/rooms/recorder.d.ts +4 -0
- package/dist/rooms/recorder.d.ts.map +1 -1
- package/dist/rooms/recorder.js +7 -0
- package/dist/rooms/recorder.js.map +1 -1
- package/dist/rooms/room.d.ts +78 -0
- package/dist/rooms/room.d.ts.map +1 -1
- package/dist/rooms/room.js +208 -3
- package/dist/rooms/room.js.map +1 -1
- package/dist/rooms/standing-source.d.ts +3 -0
- package/dist/rooms/standing-source.d.ts.map +1 -1
- package/dist/rooms/standing-source.js +6 -0
- package/dist/rooms/standing-source.js.map +1 -1
- package/dist/rooms/transcriber.d.ts +4 -0
- package/dist/rooms/transcriber.d.ts.map +1 -1
- package/dist/rooms/transcriber.js +1 -0
- package/dist/rooms/transcriber.js.map +1 -1
- package/dist/shaping/index.d.ts +1 -0
- package/dist/shaping/index.d.ts.map +1 -1
- package/dist/shaping/index.js +1 -0
- package/dist/shaping/index.js.map +1 -1
- package/dist/shaping/request.d.ts +1 -1
- package/dist/shaping/request.d.ts.map +1 -1
- package/dist/shaping/surveys.d.ts +69 -0
- package/dist/shaping/surveys.d.ts.map +1 -0
- package/dist/shaping/surveys.js +129 -0
- package/dist/shaping/surveys.js.map +1 -0
- package/dist/tools.d.ts +84 -0
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +105 -0
- package/dist/tools.js.map +1 -0
- package/dist/trace.d.ts +96 -0
- package/dist/trace.d.ts.map +1 -0
- package/dist/trace.js +251 -0
- package/dist/trace.js.map +1 -0
- package/dist/transcribe.d.ts +5 -0
- package/dist/transcribe.d.ts.map +1 -1
- package/dist/transcribe.js +1 -1
- package/dist/transcribe.js.map +1 -1
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -57,6 +57,36 @@ function Dashboard() {
|
|
|
57
57
|
Atrium" button otherwise (pass `fallback` for your own landing page). The callback that Atrium
|
|
58
58
|
redirects back to is handled automatically — no route or effect to wire up.
|
|
59
59
|
|
|
60
|
+
### useRoomConnection
|
|
61
|
+
|
|
62
|
+
`useRoomConnection(code, { identity, baseUrl?, onRoom? })` is the connect/reconnect skeleton for a
|
|
63
|
+
component that consumes a [room](#live-rooms) by connect code — loginless, so it needs no provider
|
|
64
|
+
and no sign-in. It joins, classifies failures, and tears the handle down on unmount, returning
|
|
65
|
+
`{ status, room }`: `status` is `"connecting"` → `"connected"`, `"unreachable"` for a join/connect
|
|
66
|
+
failure, or a typed terminal end (`"bad-code"` / `"code-rotated"` / `"room-gone"`); `room` is the
|
|
67
|
+
`Room` handle, `null` before it exists and again after a terminal end. Register signal handlers in
|
|
68
|
+
`onRoom` — it runs the moment the handle exists, **before** `connect()`, so a replayed durable signal
|
|
69
|
+
isn't missed — and return a cleanup from it if you need one. `identity` is the presence name (e.g.
|
|
70
|
+
`"viewer:web"`); `baseUrl` defaults to `location.origin`.
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
import { useState } from "react"
|
|
74
|
+
import { useRoomConnection } from "hci-atrium/react"
|
|
75
|
+
|
|
76
|
+
function Scene({ code }: { code: string }) {
|
|
77
|
+
const [name, setName] = useState("—")
|
|
78
|
+
const { status, room } = useRoomConnection(code, {
|
|
79
|
+
identity: "viewer:web",
|
|
80
|
+
onRoom: (room) => {
|
|
81
|
+
room.on("scene.change", (e) => setName(String(e.payload.name)))
|
|
82
|
+
},
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
if (status !== "connected") return <p>{status}</p>
|
|
86
|
+
return <button onClick={() => void room?.send("applause")}>{name}</button>
|
|
87
|
+
}
|
|
88
|
+
```
|
|
89
|
+
|
|
60
90
|
## Core API (no framework)
|
|
61
91
|
|
|
62
92
|
```ts
|
|
@@ -89,10 +119,10 @@ const atrium = new Atrium({ baseUrl, clientId })
|
|
|
89
119
|
(retry once) on a 401, and concurrent callers share one refresh. If refresh fails, the SDK drops
|
|
90
120
|
to a signed-out state and emits a change.
|
|
91
121
|
|
|
92
|
-
##
|
|
122
|
+
## The client surface
|
|
93
123
|
|
|
94
|
-
Beyond auth, the client exposes the library / assets / sources / sessions / rooms
|
|
95
|
-
`chat` and `embed`. Every method takes an **options object** with `filters` kept nested
|
|
124
|
+
Beyond auth, the client exposes the library / assets / sources / sessions / rooms / surveys
|
|
125
|
+
namespaces plus `chat`, `agent` and `embed`. Every method takes an **options object** with `filters` kept nested
|
|
96
126
|
(`search("rain", { modality, filters: { … } })`); names are **camelCase**, translated to the wire's
|
|
97
127
|
snake_case in one place (the shaping layer). Authenticate with an OAuth `clientId` **or** an
|
|
98
128
|
`apiKey` — the latter works in the browser too (a one-time console note points at PKCE for hosted
|
|
@@ -108,7 +138,10 @@ apps).
|
|
|
108
138
|
| `sources.list()` / `sources.search(query, opts?)` / `sources.import(opts)` | External-provider gateway — read-only fan-out search, then import a `Candidate`. |
|
|
109
139
|
| `sessions.start(opts?)` / `sessions.resume(id)` / `sessions.event(id, type, opts?)` | Durable, append-only session logs. |
|
|
110
140
|
| `rooms.open(opts?)` / `rooms.get(id)` / `rooms.list()` / `rooms.join(code)` | Live [`Room`](#live-rooms) handles — `open`/`get`/`list` are authenticated, `join(code)` is a loginless participant. |
|
|
111
|
-
| `
|
|
141
|
+
| `surveys.start(template?, opts)` / `surveys.template(name, body, opts?)` / … | [Surveys](#surveys) on a session, and the project's survey templates. |
|
|
142
|
+
| `chat(messages, opts?)` | Chat completion. Pass `responseFormat` a **Zod** schema for validated structured output (Zod is an optional peer dep), or `stream: true` for an [`AsyncIterable<string>`](#streaming-chat) of deltas. |
|
|
143
|
+
| `agent(prompt, { tools, … })` | A [tool-calling loop](#agents-and-tools) over explicit tool descriptors. |
|
|
144
|
+
| `addDirectModel(name, { baseUrl, apiKey? })` | Route one model name straight to an OpenAI-compatible endpoint ([direct models](#direct-models)) instead of Atrium. |
|
|
112
145
|
| `embed(opts)` | Embed exactly one of `text` / `image` / `audio` / `video` into a vector; `model` is required. |
|
|
113
146
|
| `transcribe(audio, opts?)` | Speech-to-text over a multipart audio upload; `opts.verbose` fills `.language` / `.duration` / `.segments`. |
|
|
114
147
|
| `by.*` | Operand grammar for `searchByExamples` / `compare`: `by.asset` / `by.text` / `by.file` / `by.concept`, and `by.vector([…])` — the only way to pass a raw vector. |
|
|
@@ -125,15 +158,172 @@ a matching `room.request(...)`); `room.send(type, payload, { to })` fans out; pl
|
|
|
125
158
|
loudness/spectrum/beat stream. `connect()` opens the socket (every wire method calls it for you);
|
|
126
159
|
`close()` / `await using` / a passed `AbortSignal` tears it down.
|
|
127
160
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
161
|
+
Host-side surfaces on an **owned** room (`atrium.rooms.open/get/join`, not the loginless `joinRoom`):
|
|
162
|
+
|
|
163
|
+
| Member | Description |
|
|
164
|
+
| --- | --- |
|
|
165
|
+
| `room.record(into)` / `room.transcribe(opts?)` | Bridge the room into a durable session; run live transcription into `transcript.*` signals. |
|
|
166
|
+
| `room.audioVectors(opts?)` / `room.audioScores(anchors, opts?)` / `room.audioClips()` | Embed / score / take raw the live ~10s audio windows. |
|
|
167
|
+
| `room.audioTranscripts(opts?)` | The raw per-snippet transcript iterator — the speech-to-text sibling of `audioVectors`. Streaming when the backend offers it, per-clip batch otherwise (then each transcript carries its source window under `raw.snippet`). Takes `{ model, language, partials, skipEmpty, signal }`; `break` (or abort) ends the subscription. |
|
|
168
|
+
| `room.dashboard(opts?)` → [`Dashboard`](#observation-dashboard) | Host-side state for the hosted observation dashboard (`/tools/dashboard`). |
|
|
169
|
+
| `room.trace()` → `RoomTrace` | Mirror the SDK's own activity into the room as `trace.*` signals (default off). |
|
|
170
|
+
| `room.offerTranscription()` / `offerAudioCapture()` / `offerEmbeds()` / `offerChat()` | Standing services + capability brokers for code-joined peers. |
|
|
171
|
+
|
|
172
|
+
This SDK now covers every Python-SDK surface except the documented parity exceptions — the CLI, the
|
|
173
|
+
Home Assistant bridge, a sync surface, and raw-PCM mic input — each with its reason in
|
|
174
|
+
[`PARITY.md`](PARITY.md), alongside the handful of deliberate micro-divergences. In Node <22 pass a
|
|
175
|
+
`WebSocketCtor` (e.g. from `ws`); browsers and Node ≥22 use the global.
|
|
176
|
+
|
|
177
|
+
### Observation dashboard
|
|
178
|
+
|
|
179
|
+
`room.dashboard({ title })` returns a `Dashboard`: the app owns its state as a grid of **tiles** and
|
|
180
|
+
retells it — each joining dashboard gets a full `dash.snapshot`, every mutation broadcasts a
|
|
181
|
+
`dash.delta`. All `dash.*` traffic is ephemeral (the room stores nothing — the durable record is the
|
|
182
|
+
session). Tiles: `status(id, label, value, { style })` · `text(id, text)` · `image(id, { assetId,
|
|
183
|
+
url, caption })` · `sound(id, { assetId, playing })` · `decision(id, candidates, { picked,
|
|
184
|
+
reasoning })`, each taking `{ title, colSpan, rowSpan, group }` (spans 1..3). `error(message,
|
|
185
|
+
detail?)` pins the dashboard's sticky error strip — it is *not* a tile. Controls ride the snapshot:
|
|
186
|
+
`button(id, label, handler, { variant, confirm })`, `textInput(id, handler, { label, placeholder })`
|
|
187
|
+
(the handler's `{ ok?, message? }` is toasted), and `onNote(handler)` for researcher notes.
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
const dash = room.dashboard({ title: "Reactive Soundscape" })
|
|
191
|
+
await dash.status("phase", "Phase", "Calibration", { style: "good" })
|
|
192
|
+
await dash.decision("pick", ["Forest Rain", "City Drizzle"], { picked: "Forest Rain" })
|
|
193
|
+
dash.button("advance", "Start next phase", async () => {
|
|
194
|
+
await dash.status("phase", "Phase", "Running")
|
|
195
|
+
return { message: "Phase advanced" }
|
|
196
|
+
})
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Tracing
|
|
200
|
+
|
|
201
|
+
`room.trace()` mirrors what the SDK is doing into the room, so a dashboard shows it without any
|
|
202
|
+
app-side plumbing. Library/model calls on the same client (`library.search` / `searchByExamples` /
|
|
203
|
+
`enrich` / `chat`) and `room.play` surface a `trace.<label>` (a one-line summary plus a capped
|
|
204
|
+
detail); recorder / transcriber / standing-offer transitions surface a `trace.state.<label>`. It is
|
|
205
|
+
**off by default** (it broadcasts app internals to anyone holding the connect code), costs nothing
|
|
206
|
+
while off, never breaks the traced call, and stops with `t.stop()`, `using t = room.trace()`, or the
|
|
207
|
+
room's teardown.
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
using t = room.trace()
|
|
211
|
+
await atrium.library.search("rain") // → trace.library.search on the dashboard
|
|
212
|
+
```
|
|
132
213
|
|
|
133
214
|
Errors derive from `AtriumError`: HTTP failures are `APIError` (401/403 → `AuthError`, 429/5xx →
|
|
134
215
|
`TransientAuthError`), and caller mistakes (mutually-exclusive inputs, unsupported filters) are
|
|
135
216
|
`InvalidArgumentError`.
|
|
136
217
|
|
|
218
|
+
### Surveys
|
|
219
|
+
|
|
220
|
+
A **survey** is a data object on a session, guarded by a per-object capability token: whoever holds
|
|
221
|
+
it can read and write exactly that object, nothing else. `surveys.start()` creates it — pinning the
|
|
222
|
+
template's *current* version, so later edits never change a running survey — and returns the token
|
|
223
|
+
plus the hosted tool's `url`. Hand either to the participant-facing environment, or `survey.open()`
|
|
224
|
+
a tab on the machine itself.
|
|
225
|
+
|
|
226
|
+
`surveys.template(name, body)` is the code-first **ensure** flow: run it on every start-up. It
|
|
227
|
+
creates the template if it's missing, appends a new immutable version only when the body actually
|
|
228
|
+
changed, and flips `default` when asked — so your templates live in your repo, not in a UI.
|
|
229
|
+
|
|
230
|
+
```ts
|
|
231
|
+
await atrium.surveys.template("post-study", postStudyBody, { default: true })
|
|
232
|
+
|
|
233
|
+
const session = await atrium.sessions.start({ participantRef: "p-07" })
|
|
234
|
+
const survey = await atrium.surveys.start("post-study", {
|
|
235
|
+
into: session,
|
|
236
|
+
returnTo: "https://study.example/next",
|
|
237
|
+
})
|
|
238
|
+
survey.open() // …or send survey.url / survey.token to the participant's device
|
|
239
|
+
|
|
240
|
+
const rows = await atrium.surveys.responses("post-study", { submittedOnly: true })
|
|
241
|
+
const csv = await atrium.surveys.responsesCsv("post-study") // Uint8Array, one row per response
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Everything project-scoped (`template` / `templates` / `responses` / `responsesCsv` / `uploadFile`)
|
|
245
|
+
takes `project` per call, or inherits the client's. `uploadFile(blob)` stores a write-once project
|
|
246
|
+
file (survey imagery) and returns the `id` a template body's `image` field references.
|
|
247
|
+
|
|
248
|
+
### Streaming chat
|
|
249
|
+
|
|
250
|
+
`chat(messages, { stream: true })` returns an `AsyncIterable<string>` of content deltas — **not** a
|
|
251
|
+
promise, so there is nothing to `await` before the loop. It works the same on a direct endpoint, and
|
|
252
|
+
can't be combined with a schema `responseFormat` (structured output validates a complete reply).
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
for await (const piece of atrium.chat("describe a rainy street", { stream: true })) {
|
|
256
|
+
process.stdout.write(piece)
|
|
257
|
+
}
|
|
258
|
+
```
|
|
259
|
+
|
|
260
|
+
### Agents and tools
|
|
261
|
+
|
|
262
|
+
`agent(prompt, { tools })` runs the tool-calling loop: the model may call your tools until it
|
|
263
|
+
answers, or until `maxTurns` (default 6). A tool is an explicit descriptor — TypeScript has no
|
|
264
|
+
runtime signatures or docstrings to derive a schema from, so what you write is what the model sees.
|
|
265
|
+
Pass `responseFormat` a Zod schema to structure the **final** answer (it applies after the loop —
|
|
266
|
+
json-mode conflicts with the tool-calling turns).
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
const reply = await atrium.agent("what should I play for a storm scene?", {
|
|
270
|
+
tools: [
|
|
271
|
+
{
|
|
272
|
+
name: "search_library",
|
|
273
|
+
description: "Find sounds in the Atrium library.",
|
|
274
|
+
parameters: {
|
|
275
|
+
type: "object",
|
|
276
|
+
properties: { query: { type: "string" } },
|
|
277
|
+
required: ["query"],
|
|
278
|
+
},
|
|
279
|
+
execute: async ({ query }) => {
|
|
280
|
+
const hits = await atrium.library.search(String(query), { modality: "audio", limit: 3 })
|
|
281
|
+
return hits.map((h) => h.asset.title)
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
})
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
`parameters` is plain JSON Schema. To validate the model's arguments as well, pass
|
|
289
|
+
`{ jsonSchema, validate }` where `validate` is any [Standard Schema](https://standardschema.dev)
|
|
290
|
+
object (Zod ≥3.24, Valibot, ArkType): its parsed output is what `execute` receives. A *bare* schema
|
|
291
|
+
object is rejected — deriving JSON Schema from it would mean depending on that library, and this
|
|
292
|
+
package has no runtime dependencies.
|
|
293
|
+
|
|
294
|
+
```ts
|
|
295
|
+
import { z } from "zod"
|
|
296
|
+
|
|
297
|
+
const city = z.object({ city: z.string() })
|
|
298
|
+
const tool = {
|
|
299
|
+
name: "get_weather",
|
|
300
|
+
parameters: {
|
|
301
|
+
jsonSchema: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
|
|
302
|
+
validate: city,
|
|
303
|
+
},
|
|
304
|
+
execute: ({ city }) => forecastFor(String(city)),
|
|
305
|
+
}
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
### Direct models
|
|
309
|
+
|
|
310
|
+
Register a model name to route its `chat` / `agent` calls **straight** to an OpenAI-compatible
|
|
311
|
+
endpoint — a local Ollama or LM Studio, or any remote server — instead of Atrium. A registered name
|
|
312
|
+
shadows an Atrium model of the same name (that's the point), and only chat is intercepted:
|
|
313
|
+
embeddings, search, sessions and rooms always go to Atrium.
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
atrium.addDirectModel("gemma3", { baseUrl: "http://localhost:11434/v1" })
|
|
317
|
+
await atrium.chat("hi", { model: "gemma3" }) // → localhost, not Atrium
|
|
318
|
+
|
|
319
|
+
await atrium.discoverModels("http://localhost:11434/v1") // what's installed? (registers nothing)
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Direct calls carry **only** that endpoint's own key (or none) — your Atrium credential is never sent
|
|
323
|
+
to a third-party endpoint. `discoverModels` is subject to browser CORS: a local server that sends no
|
|
324
|
+
`Access-Control-Allow-Origin` blocks the read before the SDK sees it (that's the endpoint's policy to
|
|
325
|
+
change, e.g. Ollama's `OLLAMA_ORIGINS`); from Node it just works.
|
|
326
|
+
|
|
137
327
|
## Quickstart (search + play)
|
|
138
328
|
|
|
139
329
|
```ts
|
|
@@ -164,6 +354,72 @@ room.audioSignals((sig) => {
|
|
|
164
354
|
})
|
|
165
355
|
```
|
|
166
356
|
|
|
357
|
+
## Player
|
|
358
|
+
|
|
359
|
+
The `hci-atrium/player` subpath ships the audio-layers engine (`LayerEngine`), the headless
|
|
360
|
+
[`PlayerRuntime`](#playerruntime), and the `play` / `layer.*` dialect helpers that back the hosted
|
|
361
|
+
[Player tool](../../tools) — named, independently mixed WebAudio layers (music/ambient/SFX) with
|
|
362
|
+
crossfades, reverb/echo/distance effects, and 3D positioning. It's the code an embedding app drives
|
|
363
|
+
from resolved room signals; it owns the per-layer WebAudio graph and nothing about React or
|
|
364
|
+
transport.
|
|
365
|
+
|
|
366
|
+
```ts
|
|
367
|
+
import { LayerEngine } from "hci-atrium/player"
|
|
368
|
+
|
|
369
|
+
const engine = new LayerEngine({ context: audioContext, destination: masterGain, loadBuffer })
|
|
370
|
+
engine.configure([{ id: "music", volume: 0.8 }])
|
|
371
|
+
engine.enqueue("music", [{ url: trackUrl }], "now")
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
See [`docs/reference/audio-layers-dialect.md`](../../docs/reference/audio-layers-dialect.md) for the
|
|
375
|
+
full wire dialect.
|
|
376
|
+
|
|
377
|
+
### PlayerRuntime
|
|
378
|
+
|
|
379
|
+
`PlayerRuntime` is the headless Player — the dialect brain of the hosted tool, lifted out of its
|
|
380
|
+
page. It owns the master chain (gain → limiter) *and* a `LayerEngine`, and dispatches the whole
|
|
381
|
+
`play` / `layer.*` dialect: hand it room frames via `handleSignal(frame)` and give it a `send` sink
|
|
382
|
+
for its outbound traffic (`layer.query` replies and `layer.state` broadcasts to `layer.subscribe`rs).
|
|
383
|
+
It never opens a socket, touches the DOM, or imports React — transport, UI, and
|
|
384
|
+
the intents it can't play itself (Spotify, "open link") stay with the host, the latter arriving
|
|
385
|
+
through the `unhandled` hook.
|
|
386
|
+
|
|
387
|
+
```ts
|
|
388
|
+
import { joinRoom } from "hci-atrium"
|
|
389
|
+
import { PlayerRuntime } from "hci-atrium/player"
|
|
390
|
+
|
|
391
|
+
const baseUrl = "https://atrium.example.edu"
|
|
392
|
+
const room = await joinRoom("ABC12345", { baseUrl })
|
|
393
|
+
const context = new AudioContext() // create it on the user's Start gesture — autoplay policy
|
|
394
|
+
|
|
395
|
+
const runtime = new PlayerRuntime({
|
|
396
|
+
context,
|
|
397
|
+
baseUrl, // asset-resolution root for the `asset_id`-only play shape
|
|
398
|
+
send: (type, payload, opts) => void room.send(type, payload as Record<string, unknown>, opts),
|
|
399
|
+
onLog: (line) => console.log(line),
|
|
400
|
+
onSnapshot: (layers) => renderLayers(layers), // engine snapshot → your UI
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
// Every inbound frame goes to the runtime; types outside the dialect fall through to `unhandled`.
|
|
404
|
+
room.onAny((e) =>
|
|
405
|
+
runtime.handleSignal({
|
|
406
|
+
type: e.type,
|
|
407
|
+
payload: e.payload,
|
|
408
|
+
from: e.from ?? undefined,
|
|
409
|
+
ack_id: e.ackId ?? undefined,
|
|
410
|
+
}),
|
|
411
|
+
)
|
|
412
|
+
await room.connect()
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`LayerEngine` is the mixer; `PlayerRuntime` is the mixer *plus* the dialect. It exposes
|
|
416
|
+
`runtime.engine` for listener-side controls (per-layer / master volume, normalization, device-change
|
|
417
|
+
resume) and `runtime.masterGain` as an analyzer tap (pre-limiter); `dispose()` tears down the engine,
|
|
418
|
+
its timers, and the master chain it built, leaving the host's `AudioContext` alone. One caveat on the
|
|
419
|
+
`send` sink: `Room.send` carries `to` and `ephemeral` but not `replyTo`, so a host that must answer a
|
|
420
|
+
peer's `layer.query` builds the envelope itself — `buildSignalEnvelope` from the same subpath does
|
|
421
|
+
exactly that for a `POST /v1/rooms/{id}/signal` transport, which is what the hosted Player runs on.
|
|
422
|
+
|
|
167
423
|
## Honest caveat: the guard is UX, not secrecy
|
|
168
424
|
|
|
169
425
|
For a static single-page app the client-side guard is **user experience, not a security boundary**
|
|
@@ -180,3 +436,5 @@ pnpm build # tsc → dist/ (.js + .d.ts)
|
|
|
180
436
|
pnpm test # vitest (mocked fetch, no live server)
|
|
181
437
|
pnpm lint # biome check
|
|
182
438
|
```
|
|
439
|
+
|
|
440
|
+
Releases are published to npm by hand from the maintainer's account — there is no CI publish job.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-asset metadata fetch — the public content-by-id capability.
|
|
3
|
+
*
|
|
4
|
+
* ``GET /v1/assets/{id}`` needs no auth (asset bytes + metadata are public by id), so this is a
|
|
5
|
+
* plain ``httpFetch`` against the public shape. Used by the browser tools (Viewer, Player) to recover a
|
|
6
|
+
* video mime + a name fallback for a bare ``show``/``play`` that only carried an asset id, and to
|
|
7
|
+
* read extracted ``facts`` (e.g. ``bpm`` for the analyzer's tempo prior). A failed request or an
|
|
8
|
+
* aborted one (superseded by a newer show/play) resolves to ``null`` — no error surfaces.
|
|
9
|
+
*/
|
|
10
|
+
/** The public fields the browser tools read from an asset (mirrors ``AssetPublicRead``). */
|
|
11
|
+
export interface AssetMeta {
|
|
12
|
+
/** Human name from an external source; the UI falls back to ``filename`` when null. */
|
|
13
|
+
title?: string | null;
|
|
14
|
+
filename?: string;
|
|
15
|
+
content_type?: string;
|
|
16
|
+
/** Extracted Tier-1 facts (duration, loudness, dimensions, bpm, …) — an opaque passthrough. */
|
|
17
|
+
facts?: {
|
|
18
|
+
bpm?: number;
|
|
19
|
+
loudness?: number;
|
|
20
|
+
[k: string]: unknown;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Fetch an asset's public metadata by id. Returns ``null`` on a non-OK response, a network error,
|
|
25
|
+
* or an aborted request (via ``opts.signal``). ``baseUrl`` is the resolution root (``""`` for
|
|
26
|
+
* same-origin).
|
|
27
|
+
*/
|
|
28
|
+
export declare function fetchAssetMeta(baseUrl: string, assetId: string, opts?: {
|
|
29
|
+
signal?: AbortSignal;
|
|
30
|
+
}): Promise<AssetMeta | null>;
|
|
31
|
+
//# sourceMappingURL=asset-meta.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"asset-meta.d.ts","sourceRoot":"","sources":["../src/asset-meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,4FAA4F;AAC5F,MAAM,WAAW,SAAS;IACxB,uFAAuF;IACvF,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;IACrB,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,+FAA+F;IAC/F,KAAK,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAAA;KAAE,CAAA;CAClE;AAED;;;;GAIG;AACH,wBAAsB,cAAc,CAClC,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,IAAI,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,WAAW,CAAA;CAAE,GAC9B,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC,CAU3B"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Single-asset metadata fetch — the public content-by-id capability.
|
|
3
|
+
*
|
|
4
|
+
* ``GET /v1/assets/{id}`` needs no auth (asset bytes + metadata are public by id), so this is a
|
|
5
|
+
* plain ``httpFetch`` against the public shape. Used by the browser tools (Viewer, Player) to recover a
|
|
6
|
+
* video mime + a name fallback for a bare ``show``/``play`` that only carried an asset id, and to
|
|
7
|
+
* read extracted ``facts`` (e.g. ``bpm`` for the analyzer's tempo prior). A failed request or an
|
|
8
|
+
* aborted one (superseded by a newer show/play) resolves to ``null`` — no error surfaces.
|
|
9
|
+
*/
|
|
10
|
+
import { httpFetch } from "./http.js";
|
|
11
|
+
/**
|
|
12
|
+
* Fetch an asset's public metadata by id. Returns ``null`` on a non-OK response, a network error,
|
|
13
|
+
* or an aborted request (via ``opts.signal``). ``baseUrl`` is the resolution root (``""`` for
|
|
14
|
+
* same-origin).
|
|
15
|
+
*/
|
|
16
|
+
export async function fetchAssetMeta(baseUrl, assetId, opts) {
|
|
17
|
+
try {
|
|
18
|
+
const res = await httpFetch(`${baseUrl}/v1/assets/${encodeURIComponent(assetId)}`, opts?.signal ? { signal: opts.signal } : undefined);
|
|
19
|
+
return res.ok ? (await res.json()) : null;
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=asset-meta.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"asset-meta.js","sourceRoot":"","sources":["../src/asset-meta.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAYrC;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,cAAc,CAClC,OAAe,EACf,OAAe,EACf,IAA+B;IAE/B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,SAAS,CACzB,GAAG,OAAO,cAAc,kBAAkB,CAAC,OAAO,CAAC,EAAE,EACrD,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,CAAC,CAAC,CAAC,SAAS,CACnD,CAAA;QACD,OAAO,GAAG,CAAC,EAAE,CAAC,CAAC,CAAE,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAe,CAAC,CAAC,CAAC,IAAI,CAAA;IAC1D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC"}
|