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

Lyrics on screen: the words of the clip that is playing, syllable by syllable (Lyrics.jsx draws them, with a ball that lands on each).

A sung line is one sample, started by one hap. The editor's drawer hands every frame the haps sounding now (useReplContext's onDraw), the same ones the code highlighting uses, so the words follow whatever Jev arranges and whatever the tempo is: a line starts when its hap does. From there it runs on the clock, because a clip plays at its own speed whatever the tempo.

Only the page's own scheduler is read. A song in the sandbox shows none.

11const LYRICS_URL = 'jev-samples/lyrics.json';

how long a finished line stays up, and how early a line may be shown late

13const LINGER_MS = 700;

── pure ────────────────────────────────────────── Where a line is ms after it started: the syllable being sung (-1 before the first), how far towards the next one (0 to 1), and whether it is over.

18export function position(lyric, ms) {
19  const t = ms / 1000;
20  const starts = lyric.syllables.map(([, at]) => at);
21  let index = -1;
22  while (index + 1 < starts.length && starts[index + 1] <= t) index++;
23  const from = index < 0 ? 0 : starts[index];
24  const to = index + 1 < starts.length ? starts[index + 1] : lyric.seconds;
25  const progress = to > from ? Math.min(1, Math.max(0, (t - from) / (to - from))) : 1;
26  return { index, progress, over: t >= lyric.seconds };
27}

The haps of a frame that start a line, as { key, sound, since, rate, cut }: since is the cycles since it began, rate how fast the clip plays (speed), cut the fraction of the clip that plays (end), and held the cycles it sounds for when it is clipped to its hap (clip), else null. A hap is one start however many frames see it, so its key is its sound and the cycle it began on. A slice of a clip (begin, slice, chop) or one played backwards is a sound, not the line: it starts nothing.

36export function starting(haps, time, lyrics) {
37  const out = [];
38  for (const hap of haps) {
39    const v = hap.value ?? {};
40    const sound = v.s;
41    if (typeof sound !== 'string' || !Object.hasOwn(lyrics, sound) || !hap.whole) continue;
42    if (v.n !== undefined && v.n !== 0) continue; // another file of the sound
43    if (typeof v.begin === 'number' && v.begin > 0) continue;
44    const rate = typeof v.speed === 'number' ? v.speed : 1;
45    if (!(rate > 0)) continue;
46    const begin = hap.whole.begin.valueOf();
47    if (begin > time) continue;
48    out.push({
49      key: `${sound}@${begin}`,
50      sound,
51      since: time - begin,
52      rate,
53      cut: typeof v.end === 'number' && v.end > 0 && v.end < 1 ? v.end : 1,
54      held: typeof v.clip === 'number' && v.clip > 0 ? (hap.whole.end.valueOf() - begin) * v.clip : null,
55    });
56  }
57  return out;
58}

── the store Lyrics.jsx reads ────────────────────

61let lyrics = null; // by sound, once loaded
62let loading = null;

{ key, sound, lyric, startedAt (clock ms), rate, until (seconds of the clip that play) }

64let line = null;
65const seen = new Set();
66const listeners = new Set();
67const emit = () => listeners.forEach((l) => l());

The clock lines run on, in ms: the audio's own (useReplContext sets it), so a paused song's line waits with it. Until then, the page's.

71let clock = () => performance.now();
72export const setLyricsClock = (fn) => (clock = fn);
73export const lyricsNow = () => clock();

Where the line is now, in ms of the clip.

75export const lineMs = (l, now = clock()) => (now - l.startedAt) * l.rate;
77export const lyricsStore = {
78  subscribe(l) {
79    listeners.add(l);
80    return () => listeners.delete(l);
81  },
82  get: () => line,
83};
84
85function load() {
86  loading ??= fetch(LYRICS_URL)
87    .then((r) => (r.ok ? r.json() : {}))
88    .catch(() => ({}))
89    .then((map) => (lyrics = map));
90}

useReplContext, every frame while the page's scheduler plays.

93export function lyricsFrame(haps, time, cps) {
94  if (!lyrics) return load();
95  const now = clock();
96  for (const { key, sound, since, rate, cut, held } of starting(haps, time, lyrics)) {
97    if (seen.has(key)) continue;
98    seen.add(key);
99    const lyric = lyrics[sound];
100    const startedAt = now - (cps > 0 ? (since / cps) * 1000 : 0);
101    // what plays of the clip, in its own seconds: to its end, its `end`, or its hap's
102    const until = Math.min(lyric.seconds * cut, held !== null && cps > 0 ? (held / cps) * rate : Infinity);
103    const next = { key, sound, lyric, startedAt, rate, until };
104    // a line whose clip is already over (the frame came late) is not shown
105    if (lineMs(next, now) > until * 1000) continue;
106    line = next;
107    emit();
108  }
109  if (line && lineMs(line, now) > line.until * 1000 + LINGER_MS * line.rate) {
110    line = null;
111    emit();
112  }
113}

seek.mjs, when the song jumps: a line already shown may play again.

116export const lyricsSeeked = () => lyricsStopped();

useReplContext, when the scheduler stops: a song played again starts its cycles over, so the same keys must start their lines again.

120export function lyricsStopped() {
121  seen.clear();
122  if (!line) return;
123  line = null;
124  emit();
125}