jevstrudel.git / website / src / jev / seek.mjs
1// Seeking, by section (SeekBar.jsx): jump the playing song to the start of
2// one of its sections.
3//
4// Back, or forward within what Jev has decided, is a jump: the scheduler
5// moves to the section's first cycle and the jev()s' playing time with it,
6// and the sections play again exactly as decided.
7//
8// Ahead of that there is nothing to jump to yet: Jev arranges the song as
9// it plays, each section told what came before. So the song buffers, like a
10// video seeking past what has loaded: the audio is held, Jev is asked about
11// each section up to the target in order, and then it jumps. If Jev ends
12// the song before the target, it lands on the section the song ends on.
13//
14// Sections are where it lands because that is where sung lines and held
15// notes begin: nothing is dropped into halfway through.
16//
17// Only the page's own song: one in the sandbox keeps its scheduler in the
18// frame, and a listening party runs on one clock for everyone.
19import { releaseSoundingVoices } from '@strudel/webaudio';
20import { audioControls, pause, unpause } from './audioControls.mjs';
21import { boothStore } from './boothStore.mjs';
22import { decideThrough } from './jevCore.mjs';
23import { lyricsSeeked } from './lyrics.mjs';
24
25let state = { buffering: null }; // the segment being sought while Jev is asked
26const listeners = new Set();
27function set(patch) {
28  state = { ...state, ...patch };
29  for (const l of listeners) l();
30}
31export const seekStore = {
32  subscribe(l) {
33    listeners.add(l);
34    return () => listeners.delete(l);
35  },
36  get: () => state,
37};
38
39// How many of the booth's sections are settled, counting from the first:
40// the song can jump anywhere before that without asking.
41export function decidedThrough(booth) {
42  let n = 0;
43  while (booth.decisions[n] && booth.decisions[n].status !== 'asking') n++;
44  return n - 1;
45}
46
47// The last section a seek may aim at: the song's last once known, else the
48// most the form allows.
49export const lastSection = (booth) => (booth.last !== null ? booth.last : booth.segments - 1);
50
51let seeks = 0;
52export async function seekToSection(editor, segment) {
53  const scheduler = editor?.repl?.scheduler;
54  const booth = boothStore.getSnapshot().booth;
55  if (!scheduler?.started || !booth?.jevs || !Number.isFinite(booth.segments)) return;
56  const mine = ++seeks;
57  let target = Math.max(0, Math.min(Math.floor(segment), lastSection(booth)));
58  if (target > decidedThrough(booth)) {
59    // buffer: hold the audio where it is while Jev decides up to there
60    const wasPaused = audioControls.get().paused;
61    set({ buffering: target });
62    try {
63      await pause();
64      target = await decideThrough(booth.jevs, target);
65    } finally {
66      if (mine === seeks) set({ buffering: null });
67    }
68    // a newer seek, or a stop, took over while this one waited
69    if (mine !== seeks || !scheduler.started) return;
70    jump(scheduler, booth, target);
71    if (!wasPaused) await unpause();
72    return;
73  }
74  jump(scheduler, booth, target);
75}
76
77function jump(scheduler, booth, segment) {
78  const cycle = segment * booth.every;
79  scheduler.seek(cycle);
80  for (const view of booth.jevs) view.seek?.(cycle);
81  // what was sounding belongs to where the song was
82  releaseSoundingVoices();
83  lyricsSeeked();
84}