jevstrudel.git / website / src / jev / playThrough.mjs
1// Song code evaluated the way the REPL does and played through against a
2// stand-in Jev, for the tests that must prove code runs: every song
3// (allSongs.test.mjs), and the jev reference's examples and the MCP
4// primer's song (reference.test.mjs). Test-only: the browser never imports
5// it (it pulls in upstream's mocked Node runtime).
6import { evalScope } from '@strudel/core';
7import { evaluate } from '@strudel/transpiler';
8// the REPL's scope, with audio and drawing mocked (upstream's tune tests)
9import '../../../test/runtime.mjs';
10import { whyNot } from '../../../worker/src/relay';
11import { boothStore } from './boothStore.mjs';
12import { askOpening, choice, createJev, JEV_MODEL, noul, score, walk } from './jevCore.mjs';
13
14// How far the scheduler reads per tick, in cycles.
15export const STEP = 1 / 4;
16
17// A stand-in Jev: a valid answer to every question, varied by a seeded
18// generator so forms take different paths, and every request checked
19// against the relay's own rules.
20function standIn(requests, now) {
21  let seed = 7;
22  const random = () => ((seed = (seed * 16807) % 2147483647) - 1) / 2147483646;
23  return async (state, questions) => {
24    const body = { model: JEV_MODEL, questions, state };
25    requests.push({
26      why: whyNot(body),
27      bytes: new TextEncoder().encode(JSON.stringify(body)).length,
28      cycle: now(),
29      // a form's request, and whether its history says how sections were arranged
30      form: 'mayComeNext' in (state.form ?? {}) && !('decidedThisSection' in state),
31      arranged: (state.history ?? []).some((row) => row.arrangement),
32    });
33    return Object.fromEntries(
34      Object.entries(questions).map(([name, q]) => {
35        if (q.type === 'choice') {
36          const options = Object.keys(q.criteria);
37          const pick = options[Math.floor(random() * options.length)];
38          const probabilities = Object.fromEntries(options.map((o) => [o, o === pick ? 0.7 : 0.3 / (options.length - 1)]));
39          return [name, { type: 'choice', choice: pick, confidence: 0.6, probabilities }];
40        }
41        if (q.type === 'score') return [name, { type: 'score', score: random() * (q.criteria.length - 1), confidence: 0.5 }];
42        return [name, { type: 'noul', noul: random() }];
43      }),
44    );
45  };
46}
47
48const tick = () => new Promise((resolve) => setImmediate(resolve));
49
50// Evaluates `code` with a stand-in Jev, asks about its opening as the page
51// does before it starts (useReplContext), then plays it a slice at a time
52// for `maxCycles`, the answers landing between slices. Returns the requests
53// Jev was sent, the tempo the code set, how many onsets it played, the
54// cycle of its last, and the booth's views (each jev()'s decisions).
55export async function playThrough(code, maxCycles) {
56  const requests = [];
57  let t = 0;
58  let cps = null;
59  const jev = createJev(standIn(requests, () => t), { hear: () => null, random: () => 0.5 });
60  // the sample maps are the site's to serve; nothing plays here
61  await evalScope({ jev, choice, score, noul, walk, samples: async () => {}, setcps: (x) => (cps = x) });
62  boothStore.beginEval();
63  const { pattern } = await evaluate(code);
64  boothStore.commitEval();
65  const views = boothStore.starting();
66  await askOpening(1000);
67  if (!pattern) throw new Error('the code does not evaluate to a pattern');
68  let heard = 0;
69  let lastSound = -1;
70  for (; t < maxCycles; t += STEP) {
71    const haps = pattern.queryArc(t, t + STEP).filter((h) => h.hasOnset());
72    if (haps.length) [heard, lastSound] = [heard + haps.length, t];
73    await tick();
74  }
75  return { requests, cps, heard, lastSound, views };
76}