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