1import { describe, expect, it } from 'vitest'; 2import { pure, sequence, stack } from '@strudel/core'; 3import { askOpening, asWritten, choice, createJev, noul, score, setReplay, setWrittenParts, walk } from './jevCore.mjs'; 4import { performanceOf } from './performance.mjs'; 5import { boothStore } from './boothStore.mjs'; 6 7const settle = () => new Promise((r) => setTimeout(r, 0)); 8const values = (pat, b, e) => pat.queryArc(b, e).map((h) => h.value);
play cycles one at a time, letting answers land between them
17const moves = choice('Pick the move', { asWritten: 'as written', other: 'the other one' }); 18 19describe('jev', () => { 20 it('asks every question for the next section in one request, and answers become patterns', async () => { 21 const calls = []; 22 const jev = createJev(async (state, questions, timing) => { 23 calls.push({ state, questions, timing }); 24 return { 25 move: { type: 'choice', choice: 'other', confidence: 0.8, probabilities: { asWritten: 0.2, other: 0.8 } }, 26 energy: { type: 'score', score: 1.5, confidence: 0.7 }, 27 drop: { type: 'noul', noul: 0.9 }, 28 }; 29 }); 30 const djJev = jev({ 31 state: { title: 'T' }, 32 questions: { 33 move: moves, 34 energy: score('How intense?', ['low', 'mid', 'high']), 35 drop: noul('Drop the drums?', { true: 'drop them', false: 'keep them' }), 36 }, 37 }); 38 const { move, energy, drop } = djJev.every(2, { fallback: { energy: 2, drop: 0 } }); 39 // section 0 plays the fallbacks; querying its second half asks about section 1 40 expect(values(move.choice, 0, 2)).toEqual(['asWritten']); 41 expect(values(energy.score, 0, 2)).toEqual([2]); 42 expect(values(drop.noul, 0, 2)).toEqual([0]); 43 await settle(); 44 expect(calls).toHaveLength(1); 45 expect(Object.keys(calls[0].questions)).toEqual(['move', 'energy', 'drop']); 46 expect(calls[0].questions.move).toEqual({ 47 type: 'choice', 48 instructions: 'Pick the move', 49 criteria: moves.criteria, 50 }); 51 expect(calls[0].state).toMatchObject({ song: { title: 'T' }, segment: 2, cycles: '3-4' }); 52 // the cycle its section begins at, for the page's timing in the relay's log 53 expect(calls[0].timing).toEqual({ begins: 2 }); 54 expect(values(move.choice, 2, 4)).toEqual(['other']); 55 expect(values(move.confidence, 2, 4)).toEqual([0.8]); 56 expect(values(energy.score, 2, 4)).toEqual([1.5]); 57 expect(values(drop.noul, 2, 4)).toEqual([0.9]); 58 // composition is plain Strudel 59 const song = move.choice.pick({ asWritten: pure('a'), other: pure('b') }); 60 expect(values(song, 2, 3)).toEqual(['b']); 61 }); 62 63 it('plays the fallback for anything unusable, and when Jev cannot be reached', async () => { 64 for (const answer of [{ move: { choice: 'rm -rf' } }, { move: { choice: 'toString' } }, {}, undefined]) { 65 const { move } = createJev(async () => answer)({ questions: { move: moves } }).every(1); 66 await play(move.choice, 1); 67 expect(values(move.choice, 1, 2)).toEqual(['asWritten']); 68 } 69 const down = createJev(() => Promise.reject(new Error('offline')))({ questions: { move: moves } }).every(1); 70 await play(down.move.choice, 1); 71 expect(values(down.move.choice, 1, 2)).toEqual(['asWritten']); 72 }); 73 74 it('plays the fallbacks through a section that began before Jev answered', async () => { 75 let answer; 76 const answers = createJev(() => new Promise((r) => (answer = r)))({ questions: { move: moves } }).every(1); 77 const { choice: move } = answers.move; 78 values(move, 0, 1); 79 await settle(); 80 values(move, 1, 2); // section 1 begins with its request still out 81 answer({ move: { choice: 'other' } }); 82 await settle(); 83 expect(values(move, 1, 2)).toEqual(['asWritten']); 84 }); 85 86 it('a late answer keeps the section and plays from the next cycle', async () => { 87 let answer; 88 const levels = score('How much?', ['out', 'back', 'full']); 89 const dj = createJev(() => new Promise((r) => (answer = r)))({ questions: { drums: levels } }).every(4, { 90 fallback: { drums: 2 }, 91 }); 92 for (let c = 0; c < 5; c++) { 93 dj.drums.score.queryArc(c, c + 1); // section 1 (cycles 4-7) begins with its answer still out 94 await settle(); 95 } 96 answer({ drums: { score: 0.2, confidence: 1 } }); 97 await settle(); 98 // cycle 4 already played the fallback; from cycle 5 on, Jev's answer 99 expect(values(dj.drums.score, 4, 8)).toEqual([2, 0.2]); 100 expect(dj.drums.score.queryArc(4, 8).map((h) => [h.whole.begin.valueOf(), h.whole.end.valueOf()])).toEqual([ 101 [4, 5], 102 [5, 8], 103 ]); 104 }); 105 106 it('askOpening: asks about the opening before the song starts', async () => { 107 const calls = []; 108 boothStore.beginEval(); 109 const dj = createJev(async (state) => { 110 calls.push(state); 111 return { move: { choice: 'other', confidence: 0.9 } }; 112 })({ questions: { move: moves } }).every(4); 113 await askOpening(1000); 114 expect(calls).toHaveLength(1); 115 expect(calls[0]).toMatchObject({ segment: 1, cycles: '1-4' }); 116 expect(values(dj.move.choice, 0, 4)).toEqual(['other']); 117 boothStore.commitEval(); 118 }); 119 120 it('a read ahead (.early) does not count as the section beginning, and keeps the fallback it played', async () => { 121 // the answer lands while the song reads section 1 a cycle early, before section 1 plays: 122 // the drums take it, and the transition already sounding keeps its fallback to the bar's end 123 let answer; 124 const levels = score('How much?', ['out', 'back', 'full']); 125 boothStore.beginEval(); 126 const dj = createJev(() => new Promise((r) => (answer = r)))({ questions: { drums: levels, into: moves } }).every( 127 4, 128 { fallback: { drums: 2 } }, 129 ); 130 boothStore.commitEval(); 131 const song = pure(0).stack(dj.drums.score, dj.into.choice.early(1)); 132 for (let c = 0; c < 4; c++) { 133 song.queryArc(c, c + 1); 134 await settle(); 135 } 136 answer({ drums: { score: 0.2, confidence: 1 }, into: { choice: 'other', confidence: 1 } }); 137 await settle(); 138 expect(values(dj.drums.score, 4, 5)).toEqual([0.2]); 139 expect(values(dj.into.choice, 4, 5)).toEqual(['asWritten']); 140 expect(values(dj.into.choice.early(1), 3, 4)).toEqual(['asWritten']); 141 const d = boothStore.getSnapshot().booth.decisions[1]; 142 expect(d.status).toBe('answered'); 143 expect(d.problems).toEqual(['into: answered after a read ahead played it; it kept the fallback']); 144 }); 145 146 describe('walk: a form, in code', () => { 147 const song = sequence(0, 1, 2, 3).slow(4); // each cycle's value is its own number 148 const sectionQ = choice('Pick the next section', { intro: 'the intro', verse: 'the verse', outro: 'the outro' }); 149 const sections = { 150 intro: { at: 0, next: ['verse'] }, 151 verse: { at: 2, next: ['verse', 'outro'] }, 152 outro: { at: 4, next: [] }, 153 }; 154 const heard = (pat, n) => [...Array(n).keys()].map((c) => String(values(pat, c, c + 1)[0] ?? '-')); 155 156 it('narrows the choice to the sections allowed next, and each section plays its own cycles', async () => { 157 const picks = ['verse', 'outro']; 158 const calls = []; 159 const { section } = createJev(async (state, questions) => { 160 calls.push({ state, questions }); 161 return questions.section ? { section: { choice: picks.shift() } } : {}; 162 })({ questions: { section: sectionQ } }).every(2); 163 const form = walk(section, { sections }); 164 const arranged = form.arrange(song); 165 await play(arranged, 12); 166 // intro may only be followed by verse: nothing to ask, no request 167 expect(calls[0].state.form).toMatchObject({ playingNow: 'verse', mayComeNext: ['verse', 'outro'] }); 168 expect(calls[0].questions.section).toEqual({ 169 type: 'choice', 170 instructions: 'Pick the next section', 171 criteria: { verse: 'the verse', outro: 'the outro' }, 172 }); 173 expect(calls[1].state.history.map((h) => h.section)).toEqual(['intro', 'verse', 'verse']); 174 expect(heard(section.choice, 10)).toEqual([ 175 'intro', 176 'intro', 177 'verse', 178 'verse', 179 'verse', 180 'verse', 181 'outro', 182 'outro', 183 '-', 184 '-', 185 ]); 186 // the outro plays cycles 4-5, where this 4-cycle song wraps to 0-1 187 expect(heard(arranged, 10)).toEqual(['0', '1', '2', '3', '2', '3', '0', '1', '-', '-']); 188 }); 189 190 it('an answer composed inside arrange() reads the section playing, not its written position', async () => { 191 // Jev repeats the verse; each section gets its own move 192 const moveOf = ['asWritten', 'other', 'asWritten', 'other']; 193 let n = 0; 194 const answers = createJev(async (state, q) => ({ 195 ...(q.section ? { section: { choice: state.segment <= 3 ? 'verse' : 'outro' } } : {}), 196 move: { choice: moveOf[n++ + 1] ?? 'asWritten' }, 197 }))({ questions: { section: sectionQ, move: moves } }).every(2); 198 const form = walk(answers.section, { sections }); 199 // the move is picked INSIDE the arrangement 200 const played = form.arrange(answers.move.choice.pick({ asWritten: pure('a'), other: pure('b') })); 201 await play(played, 10); 202 // sections: intro, verse, verse (repeated, at the verse's written cycles), outro 203 expect(heard(answers.section.choice, 8)).toEqual([ 204 'intro', 205 'intro', 206 'verse', 207 'verse', 208 'verse', 209 'verse', 210 'outro', 211 'outro', 212 ]); 213 const moves3 = [0, 2, 4, 6].map((c) => answers.move.choice.queryArc(c, c + 1)[0].value); 214 expect(heard(played, 8)).toEqual(moves3.flatMap((m) => (m === 'other' ? ['b', 'b'] : ['a', 'a']))); 215 }); 216 217 it('plays in written order without Jev, and forces an ending at the cap', async () => { 218 const down = createJev(() => Promise.reject(new Error('offline')))({ questions: { section: sectionQ } }).every(2); 219 await play(walk(down.section, { sections }).arrange(song), 8); 220 expect(down.section.choice.queryArc(0, 8).map((h) => h.value)).toEqual(['intro', 'verse', 'outro']); 221 222 const loops = createJev(async (s, q) => (q.section ? { section: { choice: 'verse' } } : {}))({ 223 questions: { section: sectionQ }, 224 }).every(2); 225 await play(walk(loops.section, { sections, max: 4 }).arrange(song), 12); 226 expect(loops.section.choice.queryArc(0, 12).map((h) => h.value)).toEqual(['intro', 'verse', 'verse', 'outro']); 227 }); 228 229 it('asWritten: a query sees the song as written, and asks and changes nothing', async () => { 230 let calls = 0; 231 const dj = createJev(async (s, q) => { 232 calls++; 233 return q.section ? { section: { choice: 'verse' } } : {}; 234 })({ questions: { section: sectionQ } }).every(2); 235 const arranged = walk(dj.section, { sections, max: 4 }).arrange(song); 236 const all = asWritten(() => ({ 237 sections: dj.section.choice.queryArc(0, 12).map((h) => h.value), 238 cycles: heard(arranged, 12), 239 })); 240 await settle(); 241 expect(all.sections).toEqual(['intro', 'verse', 'outro']); 242 // the outro is written at cycles 4-5, where the four-cycle song reads 0 and 1 243 expect(all.cycles).toEqual(['0', '1', '2', '3', '0', '1', '-', '-', '-', '-', '-', '-']); 244 expect(calls).toBe(0); 245 // a real play afterwards still asks, and plays Jev's answers 246 await play(arranged, 12); 247 expect(calls).toBeGreaterThan(0); 248 expect(dj.section.choice.queryArc(0, 12).map((h) => h.value)).toEqual(['intro', 'verse', 'verse', 'outro']); 249 }); 250 251 it('asks about a section it skipped before the one after it', async () => { 252 const asked = []; 253 const answers = createJev(async (state) => { 254 asked.push(state.segment); 255 return {}; 256 })({ questions: { move: moves, section: sectionQ } }).every(2); 257 walk(answers.section, { sections }); 258 values(answers.move.choice, 3.5, 4); // lands in section 1's second half first 259 await settle(); 260 expect(asked).toEqual([2, 3]); 261 }); 262 263 it('asks nothing after a section whose pending fallback ends the song', async () => { 264 // the verse's written next is the outro, so that is its fallback while Jev is slow 265 const q = choice('Pick', { intro: 'i', verse: 'v', outro: 'o' }); 266 const form = { 267 intro: { at: 0, next: ['verse'] }, 268 verse: { at: 2, next: ['outro', 'verse'] }, 269 outro: { at: 4, next: [] }, 270 }; 271 const calls = []; 272 let answer; 273 const { section } = createJev((state, questions) => { 274 calls.push(questions); 275 return new Promise((r) => (answer = r)); 276 })({ questions: { section: q } }).every(2); 277 walk(section, { sections: form, max: 8 }); 278 await play(section.choice, 4); // intro, then the verse (forced), which asks about section 2 279 expect(calls).toHaveLength(1); 280 expect(() => section.choice.queryArc(4, 6)).not.toThrow(); // section 2 plays while Jev is out 281 await settle(); 282 expect(calls).toHaveLength(1); // nothing asked about what follows the pending outro 283 answer({ section: { choice: 'verse' } }); // too late: section 2 already began 284 await play(section.choice, 10); 285 expect(values(section.choice, 4, 5)).toEqual(['outro']); 286 expect(values(section.choice, 6, 7)).toEqual([]); 287 expect(calls.every((c) => !c.section || Object.keys(c.section.criteria).length)).toBe(true); 288 }); 289 290 it('offers only sections that can still reach the ending by the cap', async () => { 291 // chorus needs two more sections (bridge, outro); at the cap it is not offered 292 const q = choice('Pick', { intro: 'i', verse: 'v', chorus: 'c', bridge: 'b', outro: 'o' }); 293 const long = { 294 intro: { at: 0, next: ['verse'] }, 295 verse: { at: 2, next: ['verse', 'chorus', 'outro'] }, 296 chorus: { at: 4, next: ['bridge'] }, 297 bridge: { at: 6, next: ['outro'] }, 298 outro: { at: 8, next: [] }, 299 }; 300 const offered = []; 301 const { section } = createJev(async (state, questions) => { 302 if (questions.section) offered.push(Object.keys(questions.section.criteria)); 303 return questions.section ? { section: { choice: 'verse' } } : {}; 304 })({ questions: { section: q } }).every(2); 305 walk(section, { sections: long, max: 5 }); 306 await play(section.choice, 10); 307 // sections 0-4: intro, then verse; chorus would need 3 more slots 308 expect(offered[0]).toEqual(['verse', 'chorus', 'outro']); 309 expect(offered.at(-1)).toEqual(['verse', 'outro']); 310 expect(section.choice.queryArc(8, 9).map((h) => h.value)).toEqual(['outro']); 311 expect(() => 312 walk(createJev(async () => ({}))({ questions: { section: q } }).every(2).section, { sections: long, max: 2 }), 313 ).toThrow(/over the cap/); 314 }); 315 316 describe('times: the most plays of a section in one performance', () => { 317 // a groove that loops a → b → c → a, as borrow-checker-blues' does: no 318 // section repeats back to back, so only a total bounds the loop 319 const q = choice('Pick', { intro: 'i', a: 'a', b: 'b', c: 'c', outro: 'o' }); 320 const grooves = (times) => ({ 321 intro: { at: 0, next: ['a'] }, 322 a: { at: 2, next: ['b'], ...(times ? { times } : {}) }, 323 b: { at: 4, next: ['c', 'outro'] }, 324 c: { at: 6, next: ['a', 'outro'] }, 325 outro: { at: 8, next: [] }, 326 }); 327 // a Jev that always loops back when it may 328 const looper = (offered) => async (state, questions) => { 329 if (!questions.section) return {}; 330 const options = Object.keys(questions.section.criteria); 331 offered.push(options); 332 return { section: { choice: ['a', 'c', 'b'].find((o) => options.includes(o)) ?? options[0] } }; 333 }; 334 const path = (section) => section.choice.queryArc(0, 64).map((h) => h.value); 335 336 it('without it, a loop runs to the cap', async () => { 337 const { section } = createJev(looper([]))({ questions: { section: q } }).every(2); 338 walk(section, { sections: grooves(), max: 16 }); 339 await play(section.choice, 40); 340 expect(path(section).filter((s) => s === 'a')).toHaveLength(5); 341 }); 342 343 it('with it, the loop goes round at most that often, and the song still ends', async () => { 344 const offered = []; 345 const { section } = createJev(looper(offered))({ questions: { section: q } }).every(2); 346 walk(section, { sections: grooves(2), max: 16 }); 347 await play(section.choice, 40); 348 expect(path(section)).toEqual(['intro', 'a', 'b', 'c', 'a', 'b', 'c', 'outro']); 349 // once a has played twice, c is offered only the outro: nothing is asked 350 expect(offered.some((o) => o.includes('a') && !o.includes('b'))).toBe(true); 351 expect(offered.at(-1)).not.toContain('a'); 352 }); 353 354 it('never offers a section whose only way to the ending runs through one used up', async () => { 355 // x may only go on through y, and y plays once: after y, x is not offered 356 const q2 = choice('Pick', { intro: 'i', x: 'x', y: 'y', v: 'v', outro: 'o' }); 357 const sections2 = { 358 intro: { at: 0, next: ['v', 'x'] }, 359 v: { at: 2, next: ['x', 'v', 'outro'] }, 360 x: { at: 4, next: ['y'] }, 361 y: { at: 6, next: ['outro', 'v'], times: 1 }, 362 outro: { at: 8, next: [] }, 363 }; 364 const offered = []; 365 const { section } = createJev(async (state, questions) => { 366 if (!questions.section) return {}; 367 const options = Object.keys(questions.section.criteria); 368 offered.push(options); 369 return { section: { choice: options.includes('x') ? 'x' : options.includes('v') ? 'v' : options[0] } }; 370 })({ questions: { section: q2 } }).every(2); 371 walk(section, { sections: sections2, max: 10 }); 372 await play(section.choice, 40); 373 const played = path(section); 374 expect(played.filter((s) => s === 'y')).toHaveLength(1); 375 expect(played.at(-1)).toBe('outro'); 376 // after y, x is offered no more, since x leads only to y 377 expect(offered[0]).toContain('x'); 378 expect(offered.slice(1).every((o) => !o.includes('x'))).toBe(true); 379 }); 380 381 it('the fallback is the written next while it may play, else the shortest way on', async () => { 382 // Jev detours to c early, then goes offline at b, whose written next is c, used up 383 const q3 = choice('Pick', { intro: 'i', a: 'a', b: 'b', c: 'c', outro: 'o' }); 384 const sections3 = { 385 intro: { at: 0, next: ['a', 'c'] }, 386 a: { at: 2, next: ['b'] }, 387 b: { at: 4, next: ['c', 'outro', 'a'] }, 388 c: { at: 6, next: ['outro', 'b'], times: 1 }, 389 outro: { at: 8, next: [] }, 390 }; 391 const picks = ['c', 'b']; 392 const { section } = createJev(async (state, questions) => { 393 if (!questions.section) return {}; 394 if (picks.length) return { section: { choice: picks.shift() } }; 395 throw new Error('offline'); 396 })({ questions: { section: q3 } }).every(2); 397 walk(section, { sections: sections3, max: 16 }); 398 await play(section.choice, 40); 399 expect(path(section)).toEqual(['intro', 'c', 'b', 'outro']); 400 }); 401 402 it('is refused unless a whole number of plays, at least 1', () => { 403 const fresh = () => createJev(async () => ({}))({ questions: { section: q } }).every(2).section; 404 for (const times of [0, 1.5, '2', -1]) { 405 expect(() => walk(fresh(), { sections: { ...grooves(), a: { at: 2, next: ['b'], times } } })).toThrow( 406 /times/, 407 ); 408 } 409 expect(() => walk(fresh(), { sections: { ...grooves(), a: { at: 2, next: ['b'], time: 2 } } })).toThrow( 410 /not time/, 411 ); 412 }); 413 }); 414 415 it('a section the form does not have, from Jev or a recording, plays the fallback and the song ends', async () => { 416 // "reading 'next'": a section name missing from the form must never be settled 417 const answers = [ 418 ...['bridge', 'toString', '__proto__', undefined].map((choice) => ({ choice, confidence: 1 })), 419 // a usable top pick whose probabilities name a section the song lacks: sampling may not pick it 420 { choice: 'outro', confidence: 0.1, probabilities: { bridge: 0.99, outro: 0.01 } }, 421 ]; 422 for (const answer of answers) { 423 const { section } = createJev(async (s, q) => (q.section ? { section: answer } : {}), { 424 random: () => 0.5, 425 })({ 426 questions: { section: sectionQ }, 427 }).every(2, { sample: 1 }); 428 walk(section, { sections, max: 6 }); 429 await play(section.choice, 14); 430 expect(section.choice.queryArc(0, 14).map((h) => h.value)).toEqual(['intro', 'verse', 'outro']); 431 } 432 setReplay({ 433 changed: true, 434 jevs: [ 435 { 436 segments: [ 437 { status: 'answered', values: { section: { choice: 'intro' } } }, 438 { status: 'answered', values: { section: { choice: 'verse' } } }, 439 { status: 'answered', values: { section: { choice: 'bridge' } } }, // gone from the song 440 ], 441 }, 442 ], 443 }); 444 try { 445 const { section } = createJev(async () => { 446 throw new Error('a replay asks nothing'); 447 })({ questions: { section: sectionQ } }).every(2); 448 walk(section, { sections, max: 6 }); 449 await play(section.choice, 14); 450 expect(section.choice.queryArc(0, 14).map((h) => h.value)).toEqual(['intro', 'verse', 'outro']); 451 } finally { 452 setReplay(null); 453 } 454 }); 455 456 it('refuses a form that does not match its choice, or cannot fall back to its written order', () => { 457 const jev = createJev(async () => ({})); 458 const fresh = () => jev({ questions: { section: sectionQ } }).every(2).section; 459 expect(() => walk(fresh(), { sections: { intro: sections.intro, verse: sections.verse } })).toThrow( 460 /missing outro/, 461 ); 462 const skips = { 463 intro: { at: 0, next: ['outro'] }, 464 verse: { at: 2, next: ['outro'] }, 465 outro: { at: 4, next: [] }, 466 }; 467 expect(() => walk(fresh(), { sections: skips })).toThrow(/must include/); 468 const noEnd = { 469 intro: { at: 0, next: ['verse'] }, 470 verse: { at: 2, next: ['outro'] }, 471 outro: { at: 4, next: ['intro'] }, 472 }; 473 expect(() => walk(fresh(), { sections: noEnd })).toThrow(/ending/); 474 expect(() => walk({}, { sections })).toThrow(/answer from jev/); 475 }); 476 477 it('refuses a way on, or a section, that can never play within the cap', () => { 478 // hey-listen's drop2d → drop1: reached late, and the long way round from there overruns the cap 479 const q = choice('Pick', { intro: 'i', a: 'a', b: 'b', c: 'c', outro: 'o' }); 480 const fresh = () => createJev(async () => ({}))({ questions: { section: q } }).every(2).section; 481 const form = (cNext) => ({ 482 intro: { at: 0, next: ['a'] }, 483 a: { at: 2, next: ['b'] }, 484 b: { at: 4, next: ['c'] }, 485 c: { at: 6, next: cNext }, 486 outro: { at: 8, next: [] }, 487 }); 488 // c is the 4th section at the earliest; back to a from there needs a, b, c, outro: 8 > 6 489 expect(() => walk(fresh(), { sections: form(['outro', 'a']), max: 6 })).toThrow(/"c" → "a" can never be taken/); 490 expect(() => walk(fresh(), { sections: form(['outro', 'a']), max: 8 })).not.toThrow(); 491 // a section only reachable by a way that is too long to end in time 492 const far = { 493 intro: { at: 0, next: ['a', 'outro'] }, 494 a: { at: 2, next: ['b'] }, 495 b: { at: 4, next: ['c'] }, 496 c: { at: 6, next: ['outro'] }, 497 outro: { at: 8, next: [] }, 498 }; 499 expect(() => walk(fresh(), { sections: far, max: 4 })).toThrow(/"a" can never play|can never be taken/); 500 }); 501 }); 502 503 it('tells Jev how the listener reacted', async () => { 504 const states = []; 505 boothStore.beginEval(); 506 const { move } = createJev(async (state) => { 507 states.push(state); 508 return {}; 509 })({ questions: { move: moves } }).every(1); 510 boothStore.commitEval(); 511 const view = boothStore.getSnapshot().booth; 512 view.react(0, 'fire'); 513 view.react(0, 'fire'); 514 await play(move.choice, 1); 515 expect(states.at(-1).audience).toEqual([{ cycles: '1', fire: 2, sleep: 0 }]); 516 }); 517 518 it('refuses what it cannot ask', () => { 519 const jev = createJev(async () => ({})); 520 expect(() => jev({})).toThrow(/questions/); 521 expect(() => jev({ every: 8, questions: { move: moves } })).toThrow(/state and questions only/); 522 expect(() => jev({ questions: { move: { type: 'extract' } } })).toThrow(/choice\(\), score\(\) or noul\(\)/); 523 expect(() => jev({ questions: { move: choice('x', { a: 'a' }) } })).toThrow(/at least two/); 524 expect(() => jev({ questions: { e: score('x', ['a', 'b']) } }).every(8)).toThrow(/fallback from 0 to 1/); 525 expect(() => jev({ questions: { d: noul('x') } }).every(8)).toThrow(/fallback from 0 to 1/); 526 expect(() => jev({ questions: { move: moves } }).every(8, { fallback: { move: 'nope' } })).toThrow(/not an option/); 527 expect(() => jev({ questions: { move: moves } }).every(8, { fallback: { stray: 1 } })).toThrow(/not a question/); 528 const twice = jev({ questions: { move: moves } }); 529 twice.every(8); 530 expect(() => twice.every(4)).toThrow(/one .every\(\)/); 531 expect(() => jev({ questions: { move: choice(pure('x'), { a: 'a', b: 'b' }) } })).toThrow(/single quotes/); 532 }); 533 534 describe('.every options', () => { 535 const sectionQ = choice('Pick the next section', { intro: 'the intro', verse: 'the verse', outro: 'the outro' }); 536 const sections = { 537 intro: { at: 0, next: ['verse'] }, 538 verse: { at: 2, next: ['verse', 'outro'] }, 539 outro: { at: 4, next: [] }, 540 }; 541 542 it('after: asks once the other jev has decided, with its answer in the state', async () => { 543 boothStore.beginEval(); 544 const order = []; 545 const formJev = createJev(async (state, q) => { 546 order.push('section'); 547 return q.section ? { section: { choice: 'outro' } } : {}; 548 })({ questions: { section: sectionQ } }); 549 const { section } = formJev.every(2); 550 const form = walk(section, { sections }); 551 const told = []; 552 const djJev = createJev(async (state) => { 553 order.push('move'); 554 told.push(state); 555 return { move: { choice: 'other', confidence: 0.9 } }; 556 })({ questions: { move: moves } }); 557 const { move } = djJev.every(2, { after: { section } }); 558 await play(form.arrange(move.choice), 8); 559 // section 1 is forced (intro → verse): no section request, the move asked with it known 560 expect(order[0]).toBe('move'); 561 expect(told[0].decidedThisSection).toEqual({ section: 'verse: the verse' }); 562 expect(told.at(-1).decidedThisSection).toEqual({ section: 'outro: the outro' }); 563 expect(order).toContain('section'); 564 // after the outro the song is over for both 565 expect(values(move.choice, 6, 8)).toEqual([]); 566 // and the move asked about the section after it says the song ended, not that Jev failed 567 const view = boothStore.starting().find((v) => v.questions.some((q) => q.name === 'move')) ?? boothStore.getSnapshot().booth; 568 console.log('DBG', JSON.stringify(boothStore.starting().map((v) => [v.questions.map((q) => q.name), v.decisions.map((d) => d && [d.segment, d.status, d.reason, d.ended])]))); 569 const ended = view.decisions.filter((d) => d?.ended); 570 expect(ended.map((d) => [d.segment, d.status])).toEqual([[3, 'fallback']]); 571 }); 572 573 it('forget: leaves a question out of the history; a part absent from its section is not asked', async () => { 574 const levels = score('How much?', ['out', 'back', 'full']); 575 const told = []; 576 boothStore.beginEval(); 577 const formJev = createJev(async () => ({}))({ questions: { section: sectionQ } }); 578 const { section } = formJev.every(2); 579 const form = walk(section, { sections }); 580 const dj = createJev(async (state, q) => { 581 told.push({ state, asked: Object.keys(q) }); 582 return { drums: { score: 1, confidence: 1 }, move: { choice: 'other', confidence: 1 } }; 583 })({ questions: { drums: levels, move: moves } }).every(2, { 584 after: { section }, 585 fallback: { drums: 2 }, 586 forget: ['move'], 587 }); 588 // as written, the drums play in the intro and the verse (cycles 0-3), not the outro 589 setWrittenParts([ 590 { value: { s: 'bd', orbit: 'drums' }, whole: { begin: 0, end: 4 } }, 591 { value: { s: 'x', orbit: 'other' }, whole: { begin: 4, end: 6 } }, 592 ]); 593 boothStore.commitEval(); 594 await play(form.arrange(stack(dj.drums.score, dj.move.choice)), 8); 595 const verse = told.find((t) => t.state.decidedThisSection.section.startsWith('verse')); 596 const outro = told.find((t) => t.state.decidedThisSection.section.startsWith('outro')); 597 expect(verse.asked).toEqual(['drums', 'move']); 598 expect(outro.asked).toEqual(['move']); 599 expect(outro.state.history.every((row) => !('move' in row))).toBe(true); 600 expect(outro.state.history.some((row) => 'drums' in row)).toBe(true); 601 expect(() => createJev(async () => ({}))({ questions: { move: moves } }).every(2, { forget: ['nope'] })).toThrow( 602 /forget: "nope"/, 603 ); 604 }); 605 606 it('after: history rows say which section each was, where the song is, and which rows were fallbacks', async () => { 607 const long = choice('Pick', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' }); 608 const form4 = { 609 intro: { at: 0, next: ['verse'] }, 610 verse: { at: 2, next: ['chorus', 'verse'] }, 611 chorus: { at: 4, next: ['outro'] }, 612 outro: { at: 6, next: [] }, 613 }; 614 const { section } = createJev(async (s, q) => (q.section ? { section: { choice: 'chorus' } } : {}))({ 615 questions: { section: long }, 616 }).every(2); 617 walk(section, { sections: form4 }); 618 const told = []; 619 let n = 0; 620 const { move } = createJev(async (state) => { 621 told.push(state); 622 // Jev cannot be reached about section 1; it answers after that 623 if (n++ === 0) throw new Error('offline'); 624 return { move: { choice: 'other', confidence: 0.9 } }; 625 })({ questions: { move: moves } }).every(2, { after: { section } }); 626 await play(move.choice, 8); 627 const last = told.at(-1); 628 expect(last.form).toEqual({ playingNow: 'chorus', sectionsLeft: 13 }); 629 expect(last.history).toEqual([ 630 { cycles: '1-2', decidedBefore: { section: 'intro' }, move: 'asWritten' }, 631 { cycles: '3-4', decidedBefore: { section: 'verse' }, move: 'asWritten (fallback)' }, 632 { cycles: '5-6', decidedBefore: { section: 'chorus' }, move: 'other' }, 633 ]); 634 }); 635 636 it("the form's history carries how each past section was arranged, less what is forgotten or absent", async () => { 637 const long = choice('Pick', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' }); 638 const form4 = { 639 intro: { at: 0, next: ['verse'] }, 640 verse: { at: 2, next: ['chorus', 'verse'] }, 641 chorus: { at: 4, next: ['outro', 'verse'] }, 642 outro: { at: 6, next: [] }, 643 }; 644 const levels = score('How much?', ['out', 'back', 'full']); 645 const formTold = []; 646 boothStore.beginEval(); 647 const { section } = createJev(async (state, q) => { 648 formTold.push(state); 649 return q.section ? { section: { choice: state.segment < 4 ? 'verse' : 'outro' } } : {}; 650 })({ questions: { section: long } }).every(2); 651 const form = walk(section, { sections: form4 }); 652 let n = 0; 653 const dj = createJev(async (state) => { 654 // the opening (no drums in the intro), the verse with the drums 655 // back, then Jev cannot be reached about the repeated verse 656 n++; 657 if (n === 3) throw new Error('offline'); 658 return { 659 drums: { score: 1, confidence: 1 }, 660 into: { choice: 'other', confidence: 1 }, 661 }; 662 })({ questions: { drums: levels, into: moves } }).every(2, { 663 after: { section }, 664 fallback: { drums: 0 }, 665 forget: ['into'], 666 }); 667 // as written the drums play from the verse on, not in the intro 668 setWrittenParts([{ value: { s: 'bd', orbit: 'drums' }, whole: { begin: 2, end: 8 } }]); 669 boothStore.commitEval(); 670 await askOpening(100); 671 await play(form.arrange(stack(section.choice, dj.drums.score, dj.into.choice)), 8); 672 const last = formTold.find((s) => s.segment === 4); 673 expect(last.history).toEqual([ 674 { cycles: '1-2', section: 'intro' }, 675 { cycles: '3-4', section: 'verse', arrangement: { drums: '1 (back)' } }, 676 { cycles: '5-6', section: 'verse', arrangement: { drums: '0 (out) (fallback)' } }, 677 ]); 678 // the parts' own history is as before: its own answers, with the section before 679 expect(JSON.stringify(last)).not.toMatch(/other/); 680 }); 681 682 it('refuses two jev()s after one form that share a question name, which its history could not tell apart', () => { 683 const jev = createJev(async () => ({})); 684 const { section } = jev({ questions: { section: sectionQ } }).every(2); 685 jev({ questions: { move: moves } }).every(2, { after: { section } }); 686 expect(() => jev({ questions: { move: moves } }).every(2, { after: { section } })).toThrow(/both have "move"/); 687 // a name one of them forgets is not in the history, so both may have it 688 const other = jev({ questions: { section: sectionQ } }).every(2).section; 689 jev({ questions: { into: moves } }).every(2, { after: { section: other }, forget: ['into'] }); 690 expect(() => jev({ questions: { into: moves } }).every(2, { after: { section: other } })).not.toThrow(); 691 }); 692 693 it('allow: narrows a choice per request from what is decided, and plays a lone option unasked', async () => { 694 const intoQ = choice('How in?', { cut: 'c', fill: 'f', riser: 'r' }); 695 const { section } = createJev(async (s, q) => (q.section ? { section: { choice: 'outro' } } : {}))({ 696 questions: { section: sectionQ }, 697 }).every(2); 698 walk(section, { sections }); 699 const asked = []; 700 const seen = []; 701 const { into } = createJev(async (state, q) => { 702 asked.push(q.into && Object.keys(q.into.criteria)); 703 return q.into ? { into: { choice: 'riser' } } : {}; 704 })({ questions: { into: intoQ } }).every(2, { 705 after: { section }, 706 allow: { 707 into: (d) => { 708 seen.push(d); 709 return d.section === 'outro' ? ['cut'] : ['cut', 'riser']; 710 }, 711 }, 712 }); 713 await play(into.choice, 8); 714 expect(seen[0]).toEqual({ section: 'verse', playingNow: 'intro' }); 715 expect(asked[0]).toEqual(['cut', 'riser']); 716 // into the outro only a cut is allowed: nothing is asked 717 expect(asked).toHaveLength(1); 718 expect([0, 2, 4].map((c) => values(into.choice, c, c + 1)[0])).toEqual(['cut', 'riser', 'cut']); 719 720 const noFallback = createJev(async () => ({ into: { choice: 'fill' } }))({ questions: { into: intoQ } }).every( 721 1, 722 { 723 allow: { into: () => ['fill', 'riser'] }, 724 }, 725 ); 726 await play(noFallback.into.choice, 1); 727 expect(values(noFallback.into.choice, 1, 2)).toEqual(['cut']); 728 expect(() => 729 createJev(async () => ({}))({ questions: { into: intoQ, e: score('x', ['a', 'b']) } }).every(1, { 730 fallback: { e: 0 }, 731 allow: { e: () => [] }, 732 }), 733 ).toThrow(/must name a choice/); 734 }); 735 736 it("sample: draws from Jev's probabilities; minConfidence: falls back when Jev is unsure", async () => { 737 const answer = { choice: 'asWritten', confidence: 0.2, probabilities: { asWritten: 0.6, other: 0.4 } }; 738 const sampled = createJev(async () => ({ move: { ...answer, confidence: 0.9 } }), { random: () => 0.99 })({ 739 questions: { move: moves }, 740 }).every(1, { sample: 1 }); 741 await play(sampled.move.choice, 1); 742 expect(values(sampled.move.choice, 1, 2)).toEqual(['other']); 743 744 const unsure = createJev(async () => ({ move: { ...answer, choice: 'other' } }))({ 745 questions: { move: moves }, 746 }).every(1, { minConfidence: 0.5 }); 747 await play(unsure.move.choice, 1); 748 expect(values(unsure.move.choice, 1, 2)).toEqual(['asWritten']);
not both on one question: sampling acts on the probabilities
756 it('tells Jev how the last sections measured', async () => { 757 let told; 758 const { move } = createJev( 759 async (state) => { 760 told = state; 761 return {}; 762 }, 763 { hear: (from, to) => ({ loudnessDbfs: -from, peakDbfs: -1, clipped: false }) }, 764 )({ questions: { move: moves } }).every(2); 765 await play(move.choice, 6); 766 // the sections finished before the one playing: here 1-2 and 3-4, as 5-6 plays 767 expect(told.measuredSound.map((m) => m.cycles)).toEqual(['1-2', '3-4']); 768 }); 769 770 it('tells Jev how each part measured', async () => { 771 let told; 772 const parts = { 773 riff: { loudnessDbfs: -18, peakDbfs: -3, silentShare: 0 }, 774 bass: { loudnessDbfs: -40, peakDbfs: -30, silentShare: 0.9 }, 775 }; 776 const { move } = createJev( 777 async (state) => { 778 told = state; 779 return {}; 780 }, 781 { hear: () => ({ loudnessDbfs: -16, peakDbfs: -1, clipped: false, silentShare: 0, parts }) }, 782 )({ questions: { move: moves } }).every(2); 783 await play(move.choice, 6); 784 expect(told.measuredSound[0].parts).toEqual(parts); 785 }); 786 787 it('shows two jev()s on one cadence as one strip', async () => { 788 boothStore.beginEval(); 789 const { section } = createJev(async () => ({}))({ questions: { section: sectionQ } }).every(2); 790 const { move } = createJev(async () => ({}))({ questions: { move: moves } }).every(2, { after: { section } }); 791 boothStore.commitEval(); 792 await play(move.choice, 3); 793 const booth = boothStore.getSnapshot().booth; 794 expect(booth.questions.map((q) => q.name)).toEqual(['section', 'move']); 795 expect(Object.keys(booth.decisions[1].values)).toEqual(['section', 'move']); 796 }); 797 }); 798 799 describe('listenersSoFar: every listener, cut to the sections that matter', () => { 800 const sectionQ = choice('Pick the next section', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' }); 801 const sections = { 802 intro: { at: 0, next: ['verse', 'chorus'] }, 803 verse: { at: 2, next: ['chorus', 'outro'] }, 804 chorus: { at: 4, next: ['verse', 'outro'] }, 805 outro: { at: 6, next: [] }, 806 }; 807 808 it("sends the tally for the sections played and those that may come next, and only the form's names", async () => { 809 const told = []; 810 const tally = { 811 intro: { fire: 1, sleep: 4 }, 812 verse: { fire: 7, sleep: 0 }, 813 outro: { fire: 2, sleep: 2 }, 814 '<script>': { fire: 99, sleep: 0 }, // anyone's writing: not a section 815 chorus: { fire: -3, sleep: 'x' }, // nonsense counts become 0 816 }; 817 const { section } = createJev( 818 async (state, q) => { 819 told.push(state); 820 return q.section ? { section: { choice: 'verse' } } : {}; 821 }, 822 { listeners: () => tally }, 823 )({ questions: { section: sectionQ } }).every(2); 824 walk(section, { sections }); 825 await play(section.choice, 3); 826 // asked about segment 1 while the intro plays: the intro, and verse or chorus next 827 const s1 = told.find((s) => s.segment === 2); 828 expect(s1.listenersSoFar).toEqual({ 829 intro: { fire: 1, sleep: 4 }, 830 verse: { fire: 7, sleep: 0 }, 831 chorus: { fire: 0, sleep: 0 }, 832 }); 833 }); 834 835 it('a jev asked after a form hears the tally of the section it is entering', async () => { 836 const told = []; 837 const listeners = () => ({ verse: { fire: 3, sleep: 1 } }); 838 const { section } = createJev(async (s, q) => (q.section ? { section: { choice: 'verse' } } : {}), { 839 listeners, 840 })({ questions: { section: sectionQ } }).every(2); 841 const form = walk(section, { sections }); 842 const { move } = createJev( 843 async (state) => { 844 told.push(state); 845 return {}; 846 }, 847 { listeners }, 848 )({ questions: { move: moves } }).every(2, { after: { section } }); 849 await play(form.arrange(move.choice), 3); 850 expect(told.find((s) => s.segment === 2).listenersSoFar).toEqual({ verse: { fire: 3, sleep: 1 } }); 851 }); 852 853 it('without a tally, sends nothing', async () => { 854 const told = []; 855 const { move } = createJev(async (state) => void told.push(state), { listeners: () => null })({ 856 questions: { move: moves }, 857 }).every(1); 858 await play(move.choice, 2); 859 expect(told.length).toBeGreaterThan(0); 860 expect(told.every((s) => !('listenersSoFar' in s))).toBe(true); 861 }); 862 }); 863 864 describe('replay: a recorded performance, played without asking Jev', () => { 865 const sectionQ = choice('Pick the next section', { intro: 'i', verse: 'v', chorus: 'c', outro: 'o' }); 866 const sections = { 867 intro: { at: 0, next: ['verse', 'chorus'] }, 868 verse: { at: 2, next: ['chorus', 'verse', 'outro'] }, 869 chorus: { at: 4, next: ['verse', 'outro'] }, 870 outro: { at: 6, next: [] }, 871 }; 872 const levels = score('How much?', ['out', 'back', 'full']);
A song as songs are: a form's jev() sampled, and parts asked after it.
875 function song(decide) { 876 const jev = createJev(decide, { random: () => 0.01 }); 877 boothStore.beginEval(); 878 const { section } = jev({ questions: { section: sectionQ } }).every(2, { sample: 1 }); 879 const form = walk(section, { sections, max: 6 }); 880 const dj = jev({ questions: { drums: levels, move: moves, drop: noul('Drop?') } }).every(2, { 881 after: { section }, 882 fallback: { drums: 2, drop: 0 }, 883 }); 884 boothStore.commitEval(); 885 const pattern = form.arrange(stack(section.choice, dj.drums.score, dj.move.choice, dj.drop.noul)); 886 return { pattern, section, dj }; 887 } 888 const heard = (s) => { 889 const out = []; 890 for (let c = 0; c < 12; c++) out.push(values(s.pattern, c, c + 1)); 891 return out; 892 }; 893 // a live Jev that varies its answers from call to call 894 const live = () => { 895 let n = 0; 896 return async (state, q) => { 897 n++; 898 const out = {}; 899 if (q.section) { 900 const options = Object.keys(q.section.criteria); 901 const top = options.includes('chorus') ? 'chorus' : options.includes('outro') ? 'outro' : options[0]; 902 out.section = { 903 choice: top, 904 confidence: 0.6, 905 probabilities: Object.fromEntries(options.map((o) => [o, o === top ? 0.6 : 0.4 / (options.length - 1)])), 906 }; 907 } 908 if (q.drums) out.drums = { score: (n % 3) * 0.7, confidence: 0.5, probabilities: { 0: 0.2, 1: 0.3, 2: 0.5 } }; 909 if (q.move) out.move = { choice: n % 2 ? 'other' : 'asWritten', confidence: 0.9 }; 910 if (q.drop) out.drop = { noul: n / 10 }; 911 return out; 912 }; 913 };
915 it('plays the recorded answers, the same sections and levels, and makes no request', async () => { 916 const recorded = song(live()); 917 await askOpening(100); 918 await play(recorded.pattern, 12); 919 const original = heard(recorded); 920 const booth = boothStore.getSnapshot().booth; 921 const perf = performanceOf(booth, { song: 'jev/x', codeHash: 'h', ended: 'finished', upTo: booth.last }); 922 expect(perf.form).toEqual({ jev: 0, question: 'section' }); 923 // a sampled pick is recorded as it played, with Jev's top pick beside it 924 expect(perf.jevs[0].segments.some((s) => s.values.section.sampled === 1)).toBe(true); 925 // what the site gives back is JSON 926 setReplay(JSON.parse(JSON.stringify({ jevs: perf.jevs }))); 927 try { 928 let asked = 0; 929 const replayed = song(async () => { 930 asked++; 931 return {}; 932 }); 933 await askOpening(100); 934 await play(replayed.pattern, 12); 935 expect(asked).toBe(0); 936 expect(heard(replayed)).toEqual(original); 937 const again = boothStore.getSnapshot().booth; 938 expect(again.replay).toEqual({ changed: false }); 939 expect(again.last).toBe(booth.last); 940 expect(again.decisions.map((d) => d.status)).toEqual(booth.decisions.map((d) => d.status)); 941 expect(again.decisions.every((d) => !d.states.length)).toBe(true); 942 } finally { 943 setReplay(null); 944 } 945 }); 946 947 it("plays a changed song where its form still allows, and the fallbacks where it doesn't", async () => { 948 setReplay({ 949 changed: true, 950 jevs: [ 951 { 952 segments: [ 953 { status: 'answered', values: { section: { choice: 'intro' } } }, 954 { status: 'answered', values: { section: { choice: 'outro' } } }, // intro → outro is not allowed 955 { status: 'answered', values: { section: { choice: 'chorus' } } }, 956 ], 957 }, 958 { 959 segments: [ 960 { status: 'answered', values: { drums: { score: 1 }, move: { choice: 'gone' }, drop: { noul: 0.5 } } }, 961 { status: 'answered', values: { drums: { score: 0 }, move: { choice: 'other' }, drop: { noul: 0.2 } } }, 962 ], 963 }, 964 ], 965 }); 966 try { 967 let asked = 0; 968 const s = song(async () => { 969 asked++; 970 return {}; 971 }); 972 await askOpening(100); 973 await play(s.pattern, 12); 974 expect(asked).toBe(0); 975 const booth = boothStore.getSnapshot().booth; 976 expect(booth.replay).toEqual({ changed: true }); 977 // segment 1: the recorded outro is not allowed after the intro; the written verse plays 978 expect(booth.decisions[1].values.section.choice).toBe('verse'); 979 expect(booth.decisions[1].problems.join()).toMatch(/section: the recorded answer is not allowed/); 980 // the parts it recorded still play; an option no longer there plays the fallback 981 expect(values(s.dj.drums.score, 0, 2)).toEqual([1]); 982 expect(values(s.dj.move.choice, 0, 2)).toEqual(['asWritten']); 983 expect(booth.decisions[0].problems.join()).toMatch(/move: the recorded answer/); 984 expect(values(s.dj.drums.score, 2, 4)).toEqual([0]); 985 // segment 2 is recorded for the form only; the parts it lacks play their fallbacks 986 expect(booth.decisions[2].values.section.choice).toBe('chorus'); 987 expect(values(s.dj.drums.score, 4, 6)).toEqual([2]); 988 } finally { 989 setReplay(null); 990 } 991 }); 992 993 it('a late answer replays from the cycle it played from', async () => { 994 setReplay({ 995 jevs: [ 996 { 997 segments: [ 998 { status: 'answered', values: { move: { choice: 'asWritten' } } }, 999 { status: 'answered', from: 5, values: { move: { choice: 'other' } } }, 1000 ], 1001 }, 1002 ], 1003 }); 1004 try { 1005 boothStore.beginEval(); 1006 const dj = createJev(async () => { 1007 throw new Error('asked'); 1008 })({ questions: { move: moves } }).every(4, { segments: 2 }); 1009 boothStore.commitEval(); 1010 await askOpening(100); 1011 await play(dj.move.choice, 8); 1012 expect(values(dj.move.choice, 4, 8)).toEqual(['asWritten', 'other']); 1013 } finally { 1014 setReplay(null); 1015 } 1016 }); 1017 }); 1018});