jevstrudel.git / website / src / jev / performance.mjs
1// Recorded performances: one play of a song as Jev arranged it, stored on
2// the site (worker/src/listening.ts) under a random id, with who played it
3// (the Worker reads the session), and replayed from a link,
4// /songs/<theme>/<song>/?performance=<id>, or for a listener's song
5// /?listener=<id>&performance=<id>, which replays in its sandbox.
6//
7// - Recording (JevPerformance.jsx's recorder): when a song as written ends,
8//   or is stopped (or replaced) after its opening section, `performanceOf`
9//   reads what every jev() played from the booth: per jev, in declaration
10//   order, per segment, its status, Jev's time, the cycle a late answer
11//   played from, and each question's answer (choice, score or noul, with
12//   confidence, probabilities, and whether it was sampled, a fallback,
13//   forced or absent). With the SHA-256 of the code that played, it is
14//   posted once; the booth then offers the link.
15// - Replaying (useReplContext, on a song page with ?performance=): the
16//   performance is fetched before the song is evaluated and handed to
17//   jevCore's `setReplay`, so every jev() plays the recorded answers and
18//   asks Jev nothing. When the song's code hash differs, it says the song
19//   has changed since, and plays the performance where the form still
20//   allows (jevCore.mjs, Replay). Loading another song ends it.
21import { setReplay } from './jevCore.mjs';
22
23export const PERFORMANCES = '/jev/performances';
24export const PERFORMANCE_PARAM = 'performance';
25const ID = /^[A-Za-z0-9_-]{22}$/;
26// what the Worker accepts (listening.ts): names, options, and how many
27const NAME = /^[A-Za-z_]\w{0,63}$/;
28const OPTION = /^[A-Za-z0-9_-]{1,64}$/;
29const MAX_SEGMENTS = 64;
30const MAX_JEVS = 4;
31
32// The performance id a page's query names, or null.
33export function performanceParam(search = typeof location !== 'undefined' ? location.search : '') {
34  const id = new URLSearchParams(search).get(PERFORMANCE_PARAM);
35  return id && ID.test(id) ? id : null;
36}
37
38// A take's replay link: a site song's page, or, for a listener's song
39// ("listener:<id>", which has no page), the home page with ?listener=,
40// where it replays in the sandbox (client.mjs's loadListenerSong).
41const LISTENER_SONG = /^listener:([A-Za-z0-9_-]{22})$/;
42export function performancePath(songId, id) {
43  const listener = LISTENER_SONG.exec(songId);
44  return listener
45    ? `/?listener=${listener[1]}&${PERFORMANCE_PARAM}=${id}`
46    : `/songs/${songId}/?${PERFORMANCE_PARAM}=${id}`;
47}
48
49// SHA-256 of the code, in hex: how a replay knows the song changed.
50export async function codeHash(code) {
51  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(code));
52  return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
53}
54
55const unit = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1;
56
57// One answer as it played, with only what the Worker keeps.
58export function answerOf(a) {
59  const out = {};
60  if (typeof a.choice === 'string') out.choice = a.choice;
61  else if (typeof a.score === 'number') out.score = a.score;
62  else if (typeof a.noul === 'number') out.noul = a.noul;
63  if (unit(a.confidence) && !a.fallback) out.confidence = a.confidence;
64  const p = Object.entries(a.probabilities ?? {}).filter(([k, v]) => OPTION.test(k) && unit(v));
65  if (p.length) out.probabilities = Object.fromEntries(p.slice(0, 64));
66  if (typeof a.sampled === 'number' && a.sampled > 0) out.sampled = a.sampled;
67  if (typeof a.top === 'string' && OPTION.test(a.top)) out.top = a.top;
68  for (const flag of ['fallback', 'forced', 'absent']) if (a[flag]) out[flag] = true;
69  return out;
70}
71
72// The performance in `booth` (boothStore's snapshot) through segment
73// `upTo`, or null when there is nothing to record: no jev(), jev()s on
74// different cadences, or more of them than the site keeps.
75export function performanceOf(booth, { song, codeHash, ended, upTo }) {
76  const views = booth?.jevs ?? (booth ? [booth] : []);
77  if (!views.length || views.length > MAX_JEVS || views.some((v) => v.every !== views[0].every)) return null;
78  const last = Math.min(upTo, MAX_SEGMENTS - 1);
79  let form = null;
80  const jevs = views.map((view, jev) => {
81    if (view.form) form = { jev, question: view.form.name };
82    const segments = [];
83    for (let n = 0; n <= last; n++) {
84      const d = view.decisions[n];
85      if (!d) break;
86      // still being asked when the song stopped: it played its fallbacks
87      const status = d.status === 'asking' ? 'fallback' : d.status;
88      const values = Object.fromEntries(
89        Object.entries(d.values)
90          .filter(([name]) => NAME.test(name))
91          .map(([name, a]) => [name, answerOf(a)]),
92      );
93      segments.push({
94        status,
95        ...(status === 'answered' && Number.isFinite(d.ms) ? { ms: Math.round(d.ms) } : {}),
96        ...(Number.isInteger(d.from) && status === 'answered' ? { from: d.from } : {}),
97        values,
98      });
99    }
100    return { segments };
101  });
102  if (jevs.some((j) => !j.segments.length)) return null;
103  return { song, codeHash, model: views[0].model, every: views[0].every, ended, form, jevs };
104}
105
106// Stores a performance; resolves to its id.
107export async function postPerformance(body, fetchImpl = globalThis.fetch) {
108  const res = await fetchImpl(PERFORMANCES, {
109    method: 'POST',
110    headers: { 'Content-Type': 'application/json' },
111    body: JSON.stringify(body),
112    keepalive: true,
113  });
114  if (!res.ok) throw new Error(`the site answered ${res.status}: ${(await res.text()).slice(0, 120)}`);
115  const { id } = await res.json();
116  if (!ID.test(id ?? '')) throw new Error('the site answered without an id');
117  return id;
118}
119
120export async function fetchPerformance(id, fetchImpl = globalThis.fetch) {
121  const res = await fetchImpl(`${PERFORMANCES}/${id}`);
122  if (res.status === 404) throw new Error('no such performance');
123  if (!res.ok) throw new Error(`the site answered ${res.status}`);
124  return res.json();
125}
126
127// Arms the replay of performance `id` for `song` (the page's, from the
128// song index): every jev() evaluated from now on plays it. Resolves to
129// { changed } (whether the song's code differs from the one recorded), or
130// throws when it cannot be replayed, leaving Jev to play the song live.
131export async function armReplay(id, song, fetchImpl = globalThis.fetch) {
132  const performance = await fetchPerformance(id, fetchImpl);
133  if (performance.song !== song.id) throw new Error(`the performance is of ${performance.song}, not ${song.id}`);
134  const changed = performance.codeHash !== (await codeHash(song.code));
135  setReplay({ jevs: performance.jevs, changed });
136  return { changed, performance };
137}
138
139export const endReplay = () => setReplay(null);
140
141// What the booth shows of the latest recording: for which evaluation (its
142// first view), and whether it is being saved, saved (with its link) or failed.
143let recording = null;
144const listeners = new Set();
145export const recordingStore = {
146  subscribe(listener) {
147    listeners.add(listener);
148    return () => listeners.delete(listener);
149  },
150  get: () => recording,
151  set(next) {
152    recording = next;
153    for (const listener of listeners) listener();
154  },
155};