// Co-performance in the room page (WAI co-performance): what the room's statements say, shown // as they arrive, and what this page sends or asks for. // // - The floor: the turn schedule in effect (WPT1) and its handovers (WPH1), who holds the floor // now and the turns ahead, in microseconds or bars at a tempo; the marks the room's clock // signs (WPM1) for an object out of turn or late. // - What's next: each participant's intents (WPI1) as upcoming lines, and, once the instant has // passed, what played: kept, diverged or missed, read from the participant's own captions and // chats under the room's policy (WPP1); an AI participant's marked as an AI system's. // - The audience: the selection window open now (WPW1), and, for a viewer whose grant carries // the audience-input power, its options or a prompt box; each window's commitment (WPK1) and // selection (WPS1) with its credits. // - Fidelity: the clock's last fidelity statement (WPF1), each participant's kept of announced. // // A lead (a grant with the direct power) asks the room's clock to state a schedule, a handover // or a window, and a fidelity statement, through the room service; the holder of a turn may // hand it over. A performer's page sends media only while it holds the floor, a guard inside // each turn, so that what it plays arrives in its turn; `playOutOfTurn` lifts that for a // moment, as a pickup that starts before the bar line does. // // What a page shows is its own reading. The room's verification (co-performance §9, with // wai-live-web's `Room.coVerify`) is what decides. const GUARD_US = 150_000; // a performer's media starts this far into its turn and stops this far before its end const STATEMENT_KIND = 0x01; // `meta` (live-events §4.2) const TYPE_BIT = { prompt: 1, sketch: 2, vote: 4, movement: 8 }; export class Co { constructor(o) { Object.assign(this, o); // wai, log, $, nameOf, isAi, roomNow, me, grantOf, present, emit, emitDetached, api this.policy = JSON.parse(this.wai.coPolicy(o.policyBytes)); this.windowUs = (this.policy.window_ms || 120) * 1000; this.schedules = []; // in the order held this.handovers = []; this.intents = []; this.said = {}; // principal -> [{t, text, digest}] this.marks = { out_of_turn: 0, late: 0, records: [] }; this.windows = new Map(); // digest -> window this.inputs = new Map(); // digest -> input this.fidelity = null; this.sent = {}; // window digest -> inputs this page sent this.outOfTurnUntil = 0; this.lateNext = 0; this.statements = 0; this.requests = []; } // ---------------------------------------------------------------------------- the records // A record the room accepted (`v`, room.offer's view) and its bytes. onRecord(v, bytes) { if (v.kind_name === "chat" || v.kind_name === "caption") { (this.said[v.principal] = this.said[v.principal] || []).push({ t: v.t, text: v.payload && v.payload.text, digest: v.digest }); return; } if (v.kind_name !== "meta") return; const s = JSON.parse(this.wai.coEventStatement(bytes)); if (!s || !s.statement) return; this.statements++; const at = { t: v.t, principal: v.principal, digest: v.digest }; switch (s.statement) { case "schedule": this.schedules.push({ ...s, ...at, order: this.statements }); break; case "handover": this.onHandover(s, at); break; case "intent": this.intents.push({ ...s, ...at }); break; case "mark": if (s.out_of_turn) this.marks.out_of_turn++; if (s.late) this.marks.late++; this.marks.records.push({ ...s, t: v.t }); break; case "window": this.windows.set(v.digest, { ...s, ...at, inputs: [], commit: null, selection: null }); break; case "input": { this.inputs.set(v.digest, { ...s, ...at }); const w = this.windows.get(s.window); if (w) w.inputs.push(v.digest); break; } case "commit": { const w = this.windows.get(s.window); if (w) w.commit = { ...s, ...at }; break; } case "selection": { const w = this.windows.get(s.window); if (w) w.selection = { ...s, ...at }; break; } case "fidelity": this.fidelity = { ...s, ...at }; break; } this.render(); } onHandover(s, at) { const turn = this.turnAt(s.at); if (!turn || !turn.holders.includes(s.to)) return; // the room's verifier refuses it; nothing to show this.handovers.push({ ...s, ...at, schedule: turn.schedule.digest, segment: turn.segment, turn: turn.index, order: this.statements }); } // ------------------------------------------------------------------------------ the floor scheduleAt(x) { for (let i = this.schedules.length - 1; i >= 0; i--) if (this.schedules[i].from <= x) return this.schedules[i]; return null; } // The turn running at room-clock `x` (§6.2), with its holder at `x` (§6.3). turnAt(x) { const s = this.scheduleAt(x); if (!s) return null; for (let i = 0; i < s.segments.length; i++) { const g = s.segments[i]; const L = g.turn_us; if (!L) continue; if (g.start <= x && x < g.start + g.turns * L) { const n = Math.floor((x - g.start) / L); const scheduled = g.holders[n % g.holders.length]; const h = this.handovers.filter((h) => h.schedule === s.digest && h.segment === i && h.turn === n && h.at <= x).sort((a, b) => a.at - b.at || a.order - b.order).pop(); return { schedule: s, segment: i, index: n, start: g.start + n * L, end: g.start + (n + 1) * L, scheduled, holder: h ? h.to : scheduled, holders: g.holders, role: g.pairs ? (n % 2 ? "response" : "call") : "solo", mode: s.mode, of: g.turns }; } } return null; } // The turns ahead of `x`, under the schedule in effect at each. turnsAhead(x, n) { const out = []; let y = x; for (let i = 0; i < n; i++) { const t = this.turnAt(y); if (!t) { // The next segment or schedule to start, if any. const next = this.nextStart(y); if (next == null) break; y = next; continue; } out.push(t); y = t.end; } return out; } nextStart(y) { let best = null; for (const s of this.schedules) for (const g of s.segments) if (g.start > y && (best == null || g.start < best)) best = g.start; return best; } // Whether this page sends its media now: unless a schedule governs it and another holds the // floor, or the turn is within its guard of its start or end (§6.4). sends(x) { if (x < this.outOfTurnUntil) return true; const t = this.turnAt(x); if (!t || !t.holders.includes(this.me().principal)) return true; return t.holder === this.me().principal && x >= t.start + GUARD_US && x < t.end - GUARD_US; } // A moment out of turn, on purpose: a pickup before the bar line. playOutOfTurn(ms) { this.outOfTurnUntil = this.roomNow() + ms * 1000; } // The due instant the next audio object states is `ms` earlier than its capture: it arrives // that much later than its due instant. stampNextLate(ms) { this.lateNext = ms * 1000; } takeLate() { const l = this.lateNext; this.lateNext = 0; return l; } // ------------------------------------------------------------------------------- intents // What played for an intent so far (§5.4, text intents): the author's chat or caption nearest // its instant within the policy's window. outcome(i, now) { if (i.form !== "text") return i.at + this.windowUs < now ? "announced" : "upcoming"; const near = (this.said[i.principal] || []).filter((x) => Math.abs(x.t - i.at) <= this.windowUs).sort((a, b) => Math.abs(a.t - i.at) - Math.abs(b.t - i.at))[0]; if (near) return near.text === i.text ? "kept" : "diverged"; return now > i.at + this.windowUs ? "missed" : "upcoming"; } // Announce a line `inMs` ahead, and say `say` (the line itself unless given) at its instant, // as a caption a machine made when this page is an AI participant. async announce({ text, inMs = 1500, spanMs = 1000, say }) { const at = this.roomNow() + inMs * 1000; const p = this.wai.coEncode(JSON.stringify({ statement: "intent", at, span: spanMs * 1000, form: "text", text })); const view = await this.emit(STATEMENT_KIND, p); // Said a little before its instant, so that a timer that fires late still says it within // the policy's window of it (§5.4). const spoken = new Promise((resolve) => { setTimeout(async () => { try { resolve(await this.say(say == null ? text : say)); } catch (e) { resolve({ error: String(e) }); } }, Math.max(0, (at - this.roomNow()) / 1000 - 20)); }); this.spoken = spoken; return { at, intent: view.digest, spoken }; } // ------------------------------------------------------------------------------ audience myGrant() { return this.grantOf(this.me().principal); } audienceLimit() { const g = this.myGrant(); const a = g && g.attrs.find((x) => x[0] === "audience_input"); return a ? Number(a[1]) : 0; } isLead() { const g = this.myGrant(); return !!g && (g.powers & 2) !== 0; } openWindow(now) { const open = [...this.windows.values()].filter((w) => w.close > now); return open[open.length - 1] || null; } // An input to window `window` (§7.3): a vote's `option`, or a prompt's `text`. async input({ window, type, option, text }) { const p = this.wai.coEncode(JSON.stringify({ statement: "input", window, type, option, text })); const view = await this.emit(STATEMENT_KIND, p); this.sent[window] = (this.sent[window] || 0) + 1; this.render(); return view; } // An input signed as this page's next record and sent, but not kept in its chain: for a test // that the relay withholds what §7.3 refuses, which nobody then receives. async inputDetached(desc) { let p; try { p = this.wai.coEncode(JSON.stringify({ statement: "input", ...desc })); } catch { // A body that does not read: a vote with a byte too many. const w = this.wai.coEncode(JSON.stringify({ statement: "input", window: desc.window, type: "vote", option: 0 })); p = new Uint8Array(w.length + 1); p.set(w); } return this.emitDetached(STATEMENT_KIND, p); } // ------------------------------------------------------------------- asking the room's clock async ask(path, body) { const r = await this.api(path, body || {}); this.requests.push({ path, status: r.status, body: r.body }); if (r.status !== 200) this.log(`co: ${path} answered ${r.status} ${JSON.stringify(r.body)}`); return r; } // Two-or-more holders trading turns of `seconds` (mode `mark` or `refuse`), starting soon. startTurns({ seconds = 4, turns = 4, mode = "mark", holders, pairs = false }) { const hs = holders || this.present().filter((p) => this.isPublisher(p)); return this.ask("/co/schedule", { mode, beat_us: 0, beats_per_bar: 0, segments: [{ length: Math.round(seconds * 1e6), turns, pairs, holders: hs }] }); } passFloor() { const t = this.turnAt(this.roomNow() + 400_000); if (!t) return Promise.resolve({ status: 0, body: { error: "no turn" } }); const i = t.holders.indexOf(t.holder); return this.ask("/co/handover", { to: t.holders[(i + 1) % t.holders.length] }); } openVote({ question, options = 3, seconds = 8 }) { return this.ask("/co/window", { rule: "plurality", options, k: 1, seconds, question }); } askPrompts({ question, seconds = 8, k = 1, rule = "draw" }) { return this.ask("/co/window", { rule, types: ["prompt"], options: 0, k, seconds, question }); } stateFidelity() { return this.ask("/co/fidelity", {}); } // ------------------------------------------------------------------------------- the view render() { const $ = this.$; const sec = $("co"); if (!sec) return; sec.hidden = false; const now = this.roomNow(); const me = this.me(); const name = (p) => (p === me.principal ? "you" : this.nameOf(p)) + (this.isAi(p) ? " (AI system)" : ""); const s = (us) => `${(us / 1e6).toFixed(1)} s`; // The floor. const t = this.turnAt(now); const nowEl = $("co-now"); if (nowEl) { nowEl.textContent = t ? `${name(t.holder)} ${t.holder === me.principal ? "hold" : "holds"} the floor · ${s(t.end - now)} left · turn ${t.index + 1} of ${t.of}${t.role === "solo" ? "" : ` (${t.role})`} · out of turn is ${t.mode === "refuse" ? "refused" : "marked"}` : this.schedules.length ? "The floor is open: no turn runs now." : "No turn schedule yet: the floor is open."; nowEl.dataset.holder = t ? t.holder : ""; } const turnsEl = $("co-turns"); if (turnsEl) { turnsEl.replaceChildren(...this.turnsAhead(now, 6).map((x) => { const li = document.createElement("li"); li.dataset.holder = x.holder; li.textContent = `${x.start <= now ? "now" : `in ${s(x.start - now)}`}: ${name(x.holder)}${x.holder !== x.scheduled ? " (handed over)" : ""}${x.role === "solo" ? "" : `, ${x.role}`}`; return li; })); } const marksEl = $("co-marks"); if (marksEl) marksEl.textContent = this.marks.out_of_turn + this.marks.late ? `Marked by the room's clock: ${this.marks.out_of_turn} out of turn, ${this.marks.late} late.` : ""; // What's next. const intentsEl = $("co-intents"); if (intentsEl) { const shown = this.intents.filter((i) => i.at + i.span > now - 20e6).slice(-8); intentsEl.replaceChildren(...shown.map((i) => { const li = document.createElement("li"); const o = this.outcome(i, now); li.dataset.outcome = o; li.dataset.intent = i.digest; const what = i.form === "text" ? `“${i.text}”` : i.form === "object" ? `an object ${i.digest.slice(0, 8)}` : i.form; li.textContent = o === "upcoming" ? `${name(i.principal)} · next, in ${s(Math.max(0, i.at - now))}: ${what}` : `${name(i.principal)} · ${what} · ${o}`; return li; })); } // The audience. const w = this.openWindow(now); const q = $("co-question"); if (q) q.textContent = w ? `${w.question || "The audience is asked"} · closes in ${s(w.close - now)}` : "No window is open."; const form = $("co-input"); const limit = this.audienceLimit(); const present = this.present().includes(me.principal); if (form) { const take = w && limit > 0 && present; form.hidden = !take; if (take && form.dataset.window !== w.digest) { form.dataset.window = w.digest; const opts = $("co-options"); opts.replaceChildren(); if (w.rule === "plurality") { for (let o = 0; o < w.options; o++) { const b = document.createElement("button"); b.type = "button"; b.textContent = `Option ${o + 1}`; b.dataset.option = String(o); b.addEventListener("click", () => this.input({ window: w.digest, type: "vote", option: o }).catch((e) => this.log(`co: ${e}`))); opts.appendChild(b); } } $("co-prompt-row").hidden = w.rule === "plurality"; } const left = w ? limit - (this.sent[w.digest] || 0) : 0; if ($("co-left")) $("co-left").textContent = take ? `${left} of ${limit} input${limit === 1 ? "" : "s"} left` : ""; for (const b of form.querySelectorAll("button")) b.disabled = left <= 0; } const selEl = $("co-selections"); if (selEl) { const done = [...this.windows.values()].filter((x) => x.selection).slice(-4); selEl.replaceChildren(...done.map((x) => { const li = document.createElement("li"); li.dataset.window = x.digest; const sel = x.selection; const picks = sel.picks.map((p, n) => { const c = sel.credits[n]; if (p.option != null) return `option ${p.option + 1}, credited to its ${c.count} voter${c.count === 1 ? "" : "s"} together`; const inp = this.inputs.get(p.input); return `“${inp && inp.text != null ? inp.text : p.input.slice(0, 8)}”, credited to ${name(c.party)}`; }); li.textContent = `${x.question || "a window"}: ${picks.length ? picks.join("; ") : "nothing selected"} (${x.commit ? x.commit.count : 0} input${x.commit && x.commit.count === 1 ? "" : "s"} committed, ${sel.rule})`; return li; })); } // Fidelity. const fEl = $("co-fidelity"); if (fEl && this.fidelity) { fEl.replaceChildren(...this.fidelity.entries.map((e) => { const li = document.createElement("li"); li.dataset.principal = e.principal; li.textContent = `${name(e.principal)}: kept ${e.kept} of ${e.announced} announced (${e.diverged} diverged, ${e.missed} missed)`; return li; })); } const lead = $("co-lead"); if (lead) lead.hidden = !this.isLead(); const pass = $("co-pass"); if (pass) pass.hidden = !(t && (t.holder === me.principal || this.isLead())); } snapshot() { const now = this.roomNow(); return { now, policy: this.policy, schedules: this.schedules.map((s) => ({ digest: s.digest, mode: s.mode, from: s.from, segments: s.segments })), handovers: this.handovers.map((h) => ({ digest: h.digest, at: h.at, to: h.to })), turn: this.turnAt(now), marks: { out_of_turn: this.marks.out_of_turn, late: this.marks.late }, intents: this.intents.map((i) => ({ digest: i.digest, principal: i.principal, at: i.at, text: i.text, outcome: this.outcome(i, now) })), windows: [...this.windows.values()].map((w) => ({ digest: w.digest, rule: w.rule, close: w.close, question: w.question, inputs: w.inputs.length, commit: w.commit && { count: w.commit.count, digest: w.commit.digest }, selection: w.selection && { picks: w.selection.picks, credits: w.selection.credits } })), fidelity: this.fidelity && { t: this.fidelity.t, through: this.fidelity.through, entries: this.fidelity.entries, digest: this.fidelity.digest }, audience: { limit: this.audienceLimit(), sent: this.sent }, lead: this.isLead(), requests: this.requests.map((r) => ({ path: r.path, status: r.status })), }; } } // The wiring of the section's controls (index.html), once the page has a Co. export function wireControls(co, $, fail) { const on = (id, f) => { const el = $(id); if (el) el.addEventListener("click", () => f().catch(fail)); }; const num = (id, d) => Number(($(id) || {}).value || d); on("co-start", () => co.startTurns({ seconds: num("co-turn-s", 4), turns: num("co-turns-n", 4), mode: ($("co-mode") || {}).value || "mark" })); on("co-pass", () => co.passFloor()); on("co-vote", () => co.openVote({ question: ($("co-q") || {}).value || "Which way next?", options: num("co-options-n", 3), seconds: num("co-window-s", 8) })); on("co-prompts", () => co.askPrompts({ question: ($("co-q") || {}).value || "What should happen next?", seconds: num("co-window-s", 8) })); on("co-fid", () => co.stateFidelity()); const form = $("co-input"); if (form) form.addEventListener("submit", (e) => { e.preventDefault(); const el = $("co-prompt"); const text = el.value.trim(); if (!text || !form.dataset.window) return; el.value = ""; co.input({ window: form.dataset.window, type: "prompt", text }).catch(fail); }); } export { GUARD_US, TYPE_BIT };