jevstrudel.git / website / src / jev / seek.mjs

Seeking, by section (SeekBar.jsx): jump the playing song to the start of one of its sections.

Back, or forward within what Jev has decided, is a jump: the scheduler moves to the section's first cycle and the jev()s' playing time with it, and the sections play again exactly as decided.

Ahead of that there is nothing to jump to yet: Jev arranges the song as it plays, each section told what came before. So the song buffers, like a video seeking past what has loaded: the audio is held, Jev is asked about each section up to the target in order, and then it jumps. If Jev ends the song before the target, it lands on the section the song ends on.

Sections are where it lands because that is where sung lines and held notes begin: nothing is dropped into halfway through.

Only the page's own song: one in the sandbox keeps its scheduler in the 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';
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};

How many of the booth's sections are settled, counting from the first: 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}

The last section a seek may aim at: the song's last once known, else the most the form allows.

49export const lastSection = (booth) => (booth.last !== null ? booth.last : booth.segments - 1);
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}