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}