jevstrudel.git / website / src / jev / performance.mjs

Recorded performances: one play of a song as Jev arranged it, stored on the site (worker/src/listening.ts) under a random id, with who played it (the Worker reads the session), and replayed from a link, /songs/<theme>/<song>/?performance=<id>, or for a listener's song /?listener=<id>&performance=<id>, which replays in its sandbox.

  • Recording (JevPerformance.jsx's recorder): when a song as written ends, or is stopped (or replaced) after its opening section, performanceOf reads what every jev() played from the booth: per jev, in declaration order, per segment, its status, Jev's time, the cycle a late answer played from, and each question's answer (choice, score or noul, with confidence, probabilities, and whether it was sampled, a fallback, forced or absent). With the SHA-256 of the code that played, it is posted once; the booth then offers the link.
  • Replaying (useReplContext, on a song page with ?performance=): the performance is fetched before the song is evaluated and handed to jevCore's setReplay, so every jev() plays the recorded answers and asks Jev nothing. When the song's code hash differs, it says the song has changed since, and plays the performance where the form still allows (jevCore.mjs, Replay). Loading another song ends it.
21import { setReplay } from './jevCore.mjs';
23export const PERFORMANCES = '/jev/performances';
24export const PERFORMANCE_PARAM = 'performance';
25const ID = /^[A-Za-z0-9_-]{22}$/;

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;

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}

A take's replay link: a site song's page, or, for a listener's song ("listener:<id>", which has no page), the home page with ?listener=, 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}

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}
55const unit = (x) => typeof x === 'number' && Number.isFinite(x) && x >= 0 && x <= 1;

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}

The performance in booth (boothStore's snapshot) through segment upTo, or null when there is nothing to record: no jev(), jev()s on 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}

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}
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}

Arms the replay of performance id for song (the page's, from the song index): every jev() evaluated from now on plays it. Resolves to { changed } (whether the song's code differs from the one recorded), or 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}
139export const endReplay = () => setReplay(null);

What the booth shows of the latest recording: for which evaluation (its 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};