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}