jevstrudel.git / website / src / jev / lyrics.mjs
1// Lyrics on screen: the words of the clip that is playing, syllable by
2// syllable (Lyrics.jsx draws them, with a ball that lands on each).
3//
4// A sung line is one sample, started by one hap. The editor's drawer hands
5// every frame the haps sounding now (useReplContext's onDraw), the same ones
6// the code highlighting uses, so the words follow whatever Jev arranges and
7// whatever the tempo is: a line starts when its hap does. From there it runs
8// on the clock, because a clip plays at its own speed whatever the tempo.
9//
10// Only the page's own scheduler is read. A song in the sandbox shows none.
11const LYRICS_URL = 'jev-samples/lyrics.json';
12// how long a finished line stays up, and how early a line may be shown late
13const LINGER_MS = 700;
14
15// ── pure ──────────────────────────────────────────
16// Where a line is `ms` after it started: the syllable being sung (-1 before
17// 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}
28
29// The haps of a frame that start a line, as { key, sound, since, rate, cut }:
30// `since` is the cycles since it began, `rate` how fast the clip plays
31// (`speed`), `cut` the fraction of the clip that plays (`end`), and `held`
32// the cycles it sounds for when it is clipped to its hap (`clip`), else null.
33// A hap is one start however many frames see it, so its key is its sound and
34// the cycle it began on. A slice of a clip (`begin`, `slice`, `chop`) or one
35// 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}
59
60// ── the store Lyrics.jsx reads ────────────────────
61let lyrics = null; // by sound, once loaded
62let loading = null;
63// { 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());
68
69// The clock lines run on, in ms: the audio's own (useReplContext sets it),
70// 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();
74// Where the line is now, in ms of the clip.
75export const lineMs = (l, now = clock()) => (now - l.startedAt) * l.rate;
76
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}
91
92// 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}
114
115// seek.mjs, when the song jumps: a line already shown may play again.
116export const lyricsSeeked = () => lyricsStopped();
117
118// useReplContext, when the scheduler stops: a song played again starts its
119// 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}