jevstrudel.git / website / src / jev / JevBooth.jsx
JevBooth.jsx595 lines · 22.2 KB · raw
1// Jev at work, made visible. Everything here reads boothStore.mjs, which
2// `jev({...})` (jevCore.mjs) writes:
3//
4// - JevBooth: the top of the pane under the editor (JevPane.jsx). One cell per section: the
5//   section the form is on, each choice Jev made, a bar per score and a
6//   percentage per noul, and whether Jev answered, is being asked, or could
7//   not be reached (the fallbacks play). The playing cell fills as it plays;
8//   clicking a cell shows every answer's probabilities and what Jev was
9//   told. Before any song it shows empty cells (BoothSkeleton).
10//   JevBoothEffects lights up the playing choices in the code and writes
11//   each request to Jev's request log (jevLog.mjs, JevLog.jsx). 🔥/😴 tell Jev how the section lands, and
12//   count for the song across every listener (reactions.mjs). Once a
13//   performance is stored it offers its link, and a replay says so
14//   (JevPerformance.jsx).
15// - JevMixer.jsx: the same answers as a mixing desk, under the booth.
16import { useEffect, useRef, useState, useSyncExternalStore } from 'react';
17import cx from '@src/cx.mjs';
18import { boothStore, playing } from './boothStore.mjs';
19import { highlightMove, installJevSyntax } from './boothHighlight.mjs';
20import { useParty } from './ListenTogether.jsx';
21
22import { themeColor as ACCENT } from './site.mjs';
23import { pressReaction } from './press.mjs';
24import { PerformanceShare } from './JevPerformance.jsx';
25import { sandboxCycle } from './sandbox.mjs';
26import { jevLog } from './jevLog.mjs';
27
28export { playing };
29
30export function useBooth() {
31  return useSyncExternalStore(boothStore.subscribe, boothStore.getSnapshot, boothStore.getSnapshot).booth;
32}
33
34// The scheduler's cycle, read a few times a second while playing: the
35// page's, or the sandbox's while a listener's song plays there (sandbox.mjs).
36export function usePlayhead(editorRef, started) {
37  const [cycle, setCycle] = useState(null);
38  useEffect(() => {
39    if (!started) {
40      setCycle(null);
41      return;
42    }
43    const read = () => {
44      try {
45        setCycle(sandboxCycle() ?? editorRef.current?.repl?.scheduler?.now() ?? null);
46      } catch {
47        setCycle(null);
48      }
49    };
50    read();
51    const timer = setInterval(read, 200);
52    return () => clearInterval(timer);
53  }, [editorRef, started]);
54  return cycle;
55}
56
57const pct = (p) => (typeof p === 'number' ? `${Math.round(p * 100)}%` : '');
58const aboutText = (about) => (typeof about === 'string' ? about : (about?.title ?? ''));
59
60// The questions in display order: the form's (walk) first, as 'form'.
61export function questionsOf(booth) {
62  const form = booth.form?.name;
63  const qs = booth.questions.map((q) => (q.name === form ? { ...q, type: 'form' } : q));
64  return [...qs.filter((q) => q.type === 'form'), ...qs.filter((q) => q.type !== 'form')];
65}
66
67// One answer in a few words: a name, "2.4", "90%".
68function short(q, a) {
69  if (!a) return '—';
70  if (q.type === 'form' || q.type === 'choice') return a.choice;
71  if (q.type === 'score') return a.score.toFixed(1);
72  return pct(a.noul);
73}
74function summary(booth, d) {
75  return questionsOf(booth)
76    .map((q) =>
77      // every answer but the form's section is named: a row of bare choices
78      // (b1 · end · end · s000) says nothing about which question each is
79      q.type === 'form' ? short(q, d.values[q.name]) : `${q.name} ${short(q, d.values[q.name])}`,
80    )
81    .join(' · ');
82}
83// Why a section played its fallbacks: the relay's refusal, the backoff, or
84// the answer's problems. Its values are only the song's defaults.
85const whyFallback = (d) => d.reason ?? d.problems?.join('; ') ?? 'no answer';
86const cellTitle = (booth, d) =>
87  d.status === 'fallback'
88    ? `Jev could not answer (${whyFallback(d)}); playing the fallbacks: ${summary(booth, d)}`
89    : summary(booth, d);
90// The names Jev chose in a decision: the form's section and each choice.
91const choicesIn = (booth, d) =>
92  d
93    ? questionsOf(booth)
94        .filter((q) => q.type === 'form' || q.type === 'choice')
95        .map((q) => d.values[q.name]?.choice)
96    : [];
97
98function Spinner() {
99  return (
100    <span className="inline-block animate-spin" style={{ color: ACCENT }}>
101      ◌
102    </span>
103  );
104}
105
106function Mark({ decision }) {
107  if (!decision) return <span className="opacity-40">·</span>;
108  switch (decision.status) {
109    case 'asking':
110      return <Spinner />;
111    case 'answered':
112      return decision.problems?.length ? (
113        <span className="text-yellow-400" title={decision.problems.join('; ')}>
114          ✓
115        </span>
116      ) : (
117        <span style={{ color: ACCENT }}>✓</span>
118      );
119    case 'fallback':
120      return (
121        <span className="text-red-400" title={decision.reason ?? decision.problems?.join('; ')}>
122          ⚠
123        </span>
124      );
125    default:
126      return (
127        <span className="opacity-60" title="Jev is not asked about the opening">
128          start
129        </span>
130      );
131  }
132}
133
134function Bars({ probabilities, chosen, describe }) {
135  const rows = Object.entries(probabilities ?? {}).sort((a, b) => b[1] - a[1]);
136  if (!rows.length) return null;
137  return (
138    <div className="space-y-1">
139      {rows.map(([name, p]) => (
140        <div key={name} className="flex items-center gap-2">
141          <span className="w-28 truncate" title={describe?.(name)}>
142            {name}
143          </span>
144          <span className="grow h-2 bg-background rounded">
145            <span
146              className="block h-2 rounded"
147              style={{ width: pct(p), background: ACCENT, opacity: String(name) === String(chosen) ? 1 : 0.4 }}
148            />
149          </span>
150          <span className="w-10 text-right">{pct(p)}</span>
151        </div>
152      ))}
153    </div>
154  );
155}
156
157function AnswerDetail({ booth, q, a }) {
158  if (!a) return null;
159  const label = (
160    <div>
161      <b>{q.name}</b>
162      {q.instructions && q.type !== 'form' && <span className="opacity-70"> — {q.instructions}</span>}
163      {a.fallback && <span className="opacity-70"> (fallback)</span>}
164    </div>
165  );
166  if (q.type === 'form' || q.type === 'choice') {
167    const describe = (n) => q.criteria[n];
168    return (
169      <div className="space-y-1">
170        {label}
171        <div>
172          → <b>{a.choice}</b> <span className="opacity-70">{describe(a.choice)}</span>
173          {a.sampled && <span className="opacity-60"> · sampled (Jev's top pick: {a.top})</span>}
174          {!a.fallback && a.confidence !== undefined && (
175            <span className="opacity-60"> · confidence {pct(a.confidence)}</span>
176          )}
177        </div>
178        <Bars probabilities={a.probabilities} chosen={a.choice} describe={describe} />
179      </div>
180    );
181  }
182  if (q.type === 'score') {
183    return (
184      <div className="space-y-1">
185        {label}
186        <div>
187          → <b>{a.score.toFixed(2)}</b> of {q.criteria.length - 1}:{' '}
188          <span className="opacity-70">{q.criteria[Math.round(a.score)]}</span>
189          {!a.fallback && a.confidence !== undefined && (
190            <span className="opacity-60"> · confidence {pct(a.confidence)}</span>
191          )}
192        </div>
193        <Bars probabilities={a.probabilities} chosen={Math.round(a.score)} describe={(i) => q.criteria[i]} />
194      </div>
195    );
196  }
197  return (
198    <div className="space-y-1">
199      {label}
200      <div>
201        → <b>{pct(a.noul)}</b> yes
202      </div>
203    </div>
204  );
205}
206
207const dbText = (x) => (Number.isFinite(x) ? `${x} dB` : 'silent');
208
209// How each finished section measured, in total and per part (meter.mjs), as
210// Jev was told it: one row per section, one column per part.
211function Measured({ measured }) {
212  if (!measured?.length) return null;
213  const parts = [...new Set(measured.flatMap((m) => Object.keys(m.parts ?? {})))];
214  return (
215    <div>
216      <div className="opacity-70 mb-1">how the last sections measured (loudness · peak, dBFS)</div>
217      <table className="border-collapse">
218        <thead>
219          <tr className="opacity-60">
220            <th className="text-left pr-3 font-normal">cycles</th>
221            <th className="text-left pr-3 font-normal">all</th>
222            {parts.map((p) => (
223              <th key={p} className="text-left pr-3 font-normal">
224                {p}
225              </th>
226            ))}
227          </tr>
228        </thead>
229        <tbody>
230          {measured.map((m) => (
231            <tr key={m.cycles}>
232              <td className="pr-3 opacity-60">{m.cycles}</td>
233              <td className={cx('pr-3', m.clipped && 'text-red-400')}>
234                {dbText(m.loudnessDbfs)} · {dbText(m.peakDbfs)}
235                {m.clipped && ' clipped'}
236              </td>
237              {parts.map((p) => {
238                const part = m.parts?.[p];
239                return (
240                  <td key={p} className={cx('pr-3', (!part || part.silentShare >= 0.9) && 'opacity-50')}>
241                    {part ? `${dbText(part.loudnessDbfs)} · ${dbText(part.peakDbfs)}` : '—'}
242                  </td>
243                );
244              })}
245            </tr>
246          ))}
247        </tbody>
248      </table>
249    </div>
250  );
251}
252
253function Detail({ booth, decision, onClose }) {
254  return (
255    <div className="px-3 py-2 border-t border-muted text-xs space-y-3 max-h-72 overflow-auto">
256      <div className="flex justify-between gap-2">
257        <span>cycles {decision.cycles}</span>
258        <button className="opacity-60 hover:opacity-100 shrink-0" onClick={onClose}>
259          close
260        </button>
261      </div>
262      {decision.status === 'opening' && (
263        <div className="opacity-70">The opening plays the fallbacks; Jev is asked about every section after it.</div>
264      )}
265      {decision.status === 'asking' && <div>Asking Jev… the fallbacks play if it has not answered in time.</div>}
266      {decision.status === 'fallback' && (
267        <div className="text-red-400">
268          {decision.reason ? `Jev could not answer (${decision.reason})` : decision.problems?.join('; ')}, so the
269          fallbacks play.
270        </div>
271      )}
272      {decision.status === 'answered' && decision.problems?.length > 0 && (
273        <div className="text-yellow-400">Partly unusable: {decision.problems.join('; ')}.</div>
274      )}
275      {questionsOf(booth).map((q) => (
276        <AnswerDetail key={q.name} booth={booth} q={q} a={decision.values[q.name]} />
277      ))}
278      {decision.ms !== undefined && (
279        <div className="opacity-60">
280          {booth.model} answered in {decision.ms} ms
281        </div>
282      )}
283      <Measured
284        measured={
285          (decision.states ?? (decision.state ? [decision.state] : [])).find((s) => s.measuredSound)?.measuredSound
286        }
287      />
288      {(decision.states ?? (decision.state ? [decision.state] : [])).map((state, i, all) => (
289        <details key={i}>
290          <summary className="cursor-pointer opacity-70">
291            what Jev was told{all.length > 1 ? ` (request ${i + 1} of ${all.length})` : ''}
292          </summary>
293          <pre className="whitespace-pre-wrap">{JSON.stringify(state, null, 2)}</pre>
294        </details>
295      ))}
296    </div>
297  );
298}
299
300// The same song's views are recombined on every change; this is stable
301// for as long as the evaluation is.
302const identityOf = (booth) => booth?.views?.[0] ?? booth;
303
304// Each request for a section, into Jev's request log (jevLog.mjs): a line
305// when it is asked, filled in where it is when the answer lands. Keyed per
306// evaluation, so a new one's section 1 is a new line.
307function useRequestLog(booth) {
308  const lines = useRef(new WeakMap());
309  const identity = identityOf(booth);
310  useEffect(() => {
311    if (!booth) return;
312    if (!lines.current.has(identity)) lines.current.set(identity, new Map());
313    const mine = lines.current.get(identity);
314    const song = booth.about?.title;
315    for (const d of booth.decisions) {
316      if (!d || !['asking', 'answered', 'fallback'].includes(d.status)) continue;
317      const fields = d.ended
318        ? { status: 'ended', text: `cycles ${d.cycles}: not needed, the song ended first` }
319        : d.status === 'asking'
320          ? { status: 'asking', text: `cycles ${d.cycles}: asking…` }
321          : d.status === 'answered'
322            ? { status: 'answered', text: `cycles ${d.cycles}: ${summary(booth, d)}`, ms: d.ms }
323            : {
324                status: 'fallback',
325                text: `cycles ${d.cycles}: Jev unreachable (${whyFallback(d)}); playing the fallbacks: ${summary(booth, d)}`,
326                ms: d.ms,
327              };
328      // a song's jev()s answer one at a time, so a line changes as each lands
329      const key = `${fields.status}|${fields.text}|${fields.ms}`;
330      const line = mine.get(d.segment);
331      if (!line) mine.set(d.segment, { id: jevLog.add({ ...fields, song }), key });
332      else if (line.key !== key) {
333        jevLog.update(line.id, fields);
334        line.key = key;
335      }
336    }
337  });
338}
339
340function Cell({ booth, i, d, isNow, now, onClick }) {
341  const qs = questionsOf(booth);
342  return (
343    <button
344      onClick={onClick}
345      className="relative shrink-0 w-36 text-left rounded border px-2 py-1 overflow-hidden"
346      style={{ borderColor: isNow ? ACCENT : 'var(--muted)', opacity: d || isNow ? 1 : 0.45 }}
347      title={d ? cellTitle(booth, d) : 'not asked yet'}
348    >
349      {isNow && (
350        <span
351          className="absolute inset-y-0 left-0 pointer-events-none"
352          style={{ width: `${now.progress * 100}%`, background: ACCENT, opacity: 0.18 }}
353        />
354      )}
355      <span className="relative flex justify-between text-xs opacity-70">
356        <span>{d?.cycles ?? `${i * booth.every + 1}-${(i + 1) * booth.every}`}</span>
357        <Mark decision={d} />
358      </span>
359      {qs
360        .filter((q) => q.type !== 'noul')
361        .map((q) => {
362          const a = d?.values[q.name];
363          if (q.type === 'form' || q.type === 'choice') {
364            return (
365              <span key={q.name} className={`relative flex gap-1 truncate ${q.type === 'form' ? 'font-bold' : ''}`}>
366                {q.type === 'choice' && <span className="opacity-60 truncate w-10 shrink-0 text-xs self-center">{q.name}</span>}
367                <span className="truncate">{a ? a.choice : '—'}</span>
368              </span>
369            );
370          }
371          const share = a ? a.score / (q.criteria.length - 1) : 0;
372          return (
373            <span key={q.name} className="relative flex items-center gap-1 text-xs mt-0.5" title={q.instructions}>
374              <span className="opacity-60 truncate w-10">{q.name}</span>
375              <span className="grow h-1 bg-background rounded">
376                {a && (
377                  <span
378                    className="block h-1 rounded"
379                    style={{ width: `${Math.max(share, 0.04) * 100}%`, background: ACCENT }}
380                  />
381                )}
382              </span>
383            </span>
384          );
385        })}
386      {qs.some((q) => q.type === 'noul') && (
387        <span className="relative flex flex-wrap gap-x-1 text-[10px] mt-0.5 leading-tight">
388          {qs
389            .filter((q) => q.type === 'noul')
390            .map((q) => {
391              const a = d?.values[q.name];
392              const on = a ? a.noul >= 0.5 : false;
393              return (
394                <span
395                  key={q.name}
396                  title={`${q.instructions}: ${a ? pct(a.noul) : '—'}`}
397                  style={{ color: on ? ACCENT : undefined, opacity: a ? (on ? 1 : 0.35) : 0.2 }}
398                >
399                  {q.name}
400                </span>
401              );
402            })}
403        </span>
404      )}
405    </button>
406  );
407}
408
409// The booth before any song with a jev() has played: the same rows, with
410// SKELETON_SECTIONS empty cells, so the pane keeps its place and shows
411// what will fill it.
412const SKELETON_SECTIONS = 8;
413function BoothSkeleton({ extra }) {
414  return (
415    <div className="relative z-20 border-b border-muted bg-lineHighlight text-foreground font-mono text-sm shrink-0">
416      <div className="flex items-center gap-3 px-3 py-1">
417        <span className="font-bold whitespace-nowrap" style={{ color: ACCENT }}>
418          🎧 Jev
419        </span>
420        {extra}
421        <span className="text-xs opacity-60 truncate">
422          no song with Jev yet: play one, and Jev decides each section here
423        </span>
424      </div>
425      <div className="flex gap-1 px-3 pb-2 overflow-x-auto" aria-hidden>
426        {Array.from({ length: SKELETON_SECTIONS }, (_, i) => (
427          <div key={i} className="shrink-0 w-36 rounded border border-muted px-2 py-1 opacity-45" data-skeleton>
428            <div className="flex justify-between text-xs opacity-70">
429              <span>section {i + 1}</span>
430            </div>
431            <div className="h-3 w-16 my-1 rounded bg-background" />
432            {[0.8, 0.55, 0.7].map((w, j) => (
433              <div key={j} className="flex items-center gap-1 mt-1">
434                <span className="h-2 w-8 rounded bg-background" />
435                <span className="grow h-1 rounded bg-background" style={{ maxWidth: `${w * 100}%` }} />
436              </div>
437            ))}
438          </div>
439        ))}
440      </div>
441    </div>
442  );
443}
444
445// What the booth does whether or not it is shown: Jev's calls in the code
446// in its colour, the playing choices lit up in the code, and each request
447// in Jev's request log. Always mounted (ReplEditor.jsx), apart from the booth,
448// which zen mode hides.
449export function JevBoothEffects({ context }) {
450  const booth = useBooth();
451  const cycle = usePlayhead(context.editorRef, context.started);
452  const now = playing(booth, cycle);
453  useRequestLog(booth);
454
455  // jev calls in the code, always in Jev's colour
456  useEffect(() => {
457    const t = setInterval(() => {
458      const view = context.editorRef.current?.editor;
459      if (view) {
460        installJevSyntax(view);
461        clearInterval(t);
462      }
463    }, 500);
464    return () => clearInterval(t);
465  }, [context.editorRef]);
466
467  const names = booth && now ? choicesIn(booth, now.decision).join('|') : '';
468  useEffect(() => {
469    highlightMove(context.editorRef.current?.editor, names ? names.split('|') : []);
470  }, [names, context.editorRef]);
471
472  return null;
473}
474
475export function JevBooth({ context, extra = null }) {
476  const booth = useBooth();
477  const party = useParty();
478  const cycle = usePlayhead(context.editorRef, context.started);
479  const now = playing(booth, cycle);
480  const [open, setOpen] = useState(null);
481  useEffect(() => setOpen(null), [identityOf(booth)]);
482
483  // keep the newest card in view: the one being asked about, or the last
484  const row = useRef(null);
485  const decided = booth?.decisions.length ?? 0;
486  useEffect(() => {
487    const el = row.current;
488    const cell = el?.children[Math.min(decided, el.children.length - 1)];
489    if (!cell) return;
490    const right = cell.offsetLeft + cell.offsetWidth - el.offsetLeft + 12;
491    if (right > el.scrollLeft + el.clientWidth) el.scrollTo({ left: right - el.clientWidth, behavior: 'smooth' });
492  }, [decided, identityOf(booth)]);
493
494  if (!booth) return <BoothSkeleton extra={extra} />;
495
496  // every section of a fixed-length song; otherwise what has been decided
497  // and one more, until the song's end is known
498  const count =
499    booth.last !== null
500      ? booth.last + 1
501      : Math.min(booth.segments, Math.max(booth.decisions.length + 1, (now?.segment ?? 0) + 2));
502  const cells = Array.from({ length: count }, (_, i) => i);
503  const next = now ? booth.decisions[now.segment + 1] : undefined;
504  const openDecision = open === null ? null : booth.decisions[open];
505
506  let status;
507  if (!context.started) status = <span className="opacity-70">stopped</span>;
508  else if (!now) status = <span className="opacity-70">done</span>;
509  else if (next?.status === 'asking')
510    status = (
511      <>
512        asking about cycles {next.cycles} <Spinner />
513      </>
514    );
515  else if (next)
516    status = (
517      <>
518        next: <b>{summary(booth, next)}</b> in {now.left.toFixed(1)} cycles
519      </>
520    );
521  else if (booth.last === now.segment) status = <>last section: ends in {now.left.toFixed(1)} cycles</>;
522  else
523    status = (
524      <>
525        playing <b>{summary(booth, now.decision)}</b>
526      </>
527    );
528
529  return (
530    <div className="relative z-20 border-b border-muted bg-lineHighlight text-foreground font-mono text-sm shrink-0">
531      <div className="flex items-center gap-3 px-3 py-1">
532        <span className="font-bold whitespace-nowrap" style={{ color: ACCENT }} title={aboutText(booth.about)}>
533          🎧 Jev
534        </span>
535        {extra}
536        <span className="text-xs opacity-60 whitespace-nowrap hidden sm:inline">
537          {booth.model} · every {booth.every} cycles ·{' '}
538          {questionsOf(booth)
539            .map((q) => q.name)
540            .join(', ')}
541        </span>
542        {now && context.started && (
543          <span
544            className="flex items-center gap-1 text-xs"
545            title="tell Jev how this section lands; it hears it before choosing, and it counts for the song with every listener's"
546          >
547            {[
548              ['fire', '🔥'],
549              ['sleep', '😴'],
550            ].map(([kind, icon]) => (
551              <button
552                key={kind}
553                className="rounded border border-muted px-1 hover:border-yellow-400"
554                onClick={() => pressReaction(booth, now.segment, kind, context.editorRef.current)}
555              >
556                {icon}
557                {booth.reactions[now.segment]?.[kind] ? ` ${booth.reactions[now.segment][kind]}` : ''}
558              </button>
559            ))}
560          </span>
561        )}
562        <PerformanceShare booth={booth} />
563        {party.status === 'open' && (
564          <span
565            className="text-xs whitespace-nowrap"
566            data-party-people={party.people}
567            title={
568              party.role === 'host'
569                ? 'your listening party: your Jev decides for everyone, and hears everyone’s 🔥/😴'
570                : 'a listening party: the host’s Jev decides, and hears your 🔥/😴'
571            }
572          >
573            👥 {party.people}
574            {party.role === 'guest' ? ' · following the host' : ''}
575          </span>
576        )}
577        <span className="text-xs whitespace-nowrap ml-auto truncate">{status}</span>
578      </div>
579      <div ref={row} className="flex gap-1 px-3 pb-2 overflow-x-auto">
580        {cells.map((i) => (
581          <Cell
582            key={i}
583            booth={booth}
584            i={i}
585            d={booth.decisions[i]}
586            isNow={now?.segment === i}
587            now={now}
588            onClick={() => setOpen(open === i || !booth.decisions[i] ? null : i)}
589          />
590        ))}
591      </div>
592      {openDecision && <Detail booth={booth} decision={openDecision} onClose={() => setOpen(null)} />}
593    </div>
594  );
595}