LilyScript / web /score-player.js
k-l-lambda's picture
added MidiAudio.awaitWarmup
9de43a5
/* LilyScript score player — client-side bridge to the lilylet-live-editor pipeline.
*
* Pipeline (all in the browser, no server round-trip):
* lyl text --LilyletLib.parseCode + meiEncoder--> MEI XML
* MEI --Verovio WASM toolkit--> SVG (preview) + MIDI (playback)
* MIDI --music-widgets MidiPlayer + soundfont--> audio + note highlight
*
* Globals provided by the vendored scripts (loaded via <script src> in <head>):
* window.LilyletLib { parseCode, meiEncoder, serializeLilyletDoc }
* window.verovio Verovio module factory (verovio-toolkit-wasm.js)
* window.musicWidgetsBrowser { MIDI, MidiPlayer, MusicNotation, MidiAudio }
*
* Generation gate (the key UX rule): while the model is generating, we render
* SVG-only and the MIDI player is hidden/disabled. The player is built + shown
* only once generation has finished (LilyScore.setGenerating(false)). This keeps
* the live preview cheap during streaming and avoids re-initialising the MIDI
* engine on every measure-boundary editor sync.
*/
(function () {
'use strict';
const SOUNDFONT_URL = window.__LILYSCRIPT_SOUNDFONT_URL || './soundfont/';
const state = {
toolkit: null, // Verovio toolkit
verovioReady: false,
audio: null, // { MIDI, MidiPlayer, MusicNotation, MidiAudio }
audioReady: false,
player: null, // MidiPlayer instance
midiData: null,
generating: false, // gate: true while the model streams
lastCode: '', // last lyl rendered (dedupe)
lastMei: null,
// playback
isPlaying: false,
currentTime: 0,
duration: 0,
playStartTime: 0,
lastEventIndex: 0,
pausedTime: 0,
updateInterval: null,
highlighted: new Set(),
};
const els = {}; // cached DOM nodes, filled by mount()
const HIGHLIGHT_THROTTLE_MS = 50;
let lastHighlightUpdate = 0;
let lastAutoScroll = 0;
function log (msg) { console.log('[LilyScore]', msg); }
// ---- Verovio init -------------------------------------------------------
async function initVerovio () {
if (state.verovioReady) return state.toolkit;
if (state._verovioInitPromise) return state._verovioInitPromise;
if (!window.VerovioInit) { log('VerovioInit global missing'); return null; }
// VerovioInit() awaits the Emscripten WASM module's readyPromise, then
// returns a constructed toolkit — the path proven by lilylet-live-editor.
state._verovioInitPromise = (async function () {
try {
const tk = await window.VerovioInit();
tk.setOptions({ scale: 40, adjustPageHeight: true, breaks: 'auto', pageWidth: 2100, pageHeight: 2970 });
state.toolkit = tk;
state.verovioReady = true;
log('verovio ready ' + (tk.getVersion ? tk.getVersion() : '?'));
return tk;
} catch (e) {
log('verovio init failed: ' + (e && e.message ? e.message : e));
state._verovioInitPromise = null;
return null;
}
})();
return state._verovioInitPromise;
}
// ---- lyl -> MEI -> SVG --------------------------------------------------
function lylToMei (code) {
if (!window.LilyletLib) throw new Error('LilyletLib not loaded');
const doc = window.LilyletLib.parseCode(code);
const mei = window.LilyletLib.meiEncoder.encode(doc);
let staffCount = 1;
if (doc.measures && doc.measures.length) {
const m0 = doc.measures[0];
staffCount = m0.parts.reduce((tot, part) => {
const maxStaff = part.voices.reduce((mx, v) => Math.max(mx, v.staff || 1), 1);
return tot + maxStaff;
}, 0) || 1;
}
return { mei, measureCount: (doc.measures && doc.measures.length) || 1, staffCount };
}
function injectSvg (svgString) {
const parser = new DOMParser();
const doc = parser.parseFromString(svgString, 'image/svg+xml');
if (doc.querySelector('parsererror')) { log('svg parse error'); return; }
const svg = doc.querySelector('svg');
if (!svg) return;
els.svg.innerHTML = '';
els.svg.appendChild(document.importNode(svg, true));
// re-attach the playback cursor (innerHTML reset above removed it)
if (els.cursor) { els.cursor.style.display = 'none'; els.svg.appendChild(els.cursor); }
}
function setStatus (text, kind) {
if (!els.status) return;
els.status.className = 'ls-status' + (kind ? ' ls-' + kind : '');
// Errors (esp. lilylet parse errors) are multi-line with a `^` caret aligned
// under the offending token — wrap them in <pre> so the whitespace/newlines
// are preserved (a plain div collapses them). textContent keeps it XSS-safe.
if (kind === 'err' && text) {
els.status.innerHTML = '';
const pre = document.createElement('pre');
pre.className = 'ls-err-pre';
pre.textContent = text;
els.status.appendChild(pre);
} else {
els.status.textContent = text || '';
}
}
// Render lyl -> SVG. Returns true on success. Does NOT touch the MIDI player
// (that is gated separately on generation state).
async function render (code) {
const tk = await initVerovio();
if (!tk) { setStatus('Verovio not ready', 'err'); return false; }
code = (code || '').trim();
if (!code) { els.svg.innerHTML = ''; state.lastCode = ''; state.lastMei = null; updatePlayerVisibility(); return false; }
if (code === state.lastCode) return true;
setStatus('Rendering…', 'busy');
try {
const { mei, measureCount, staffCount } = lylToMei(code);
const pageHeight = Math.max(2000, Math.ceil(measureCount / 20) * 2000) * 2 * staffCount;
tk.setOptions({ scale: 40, adjustPageHeight: true, pageWidth: 2100, pageHeight: pageHeight });
if (!tk.loadData(mei)) { setStatus('Verovio load failed', 'err'); return false; }
injectSvg(tk.renderToSVG(1));
state.lastCode = code;
state.lastMei = mei;
setStatus('', '');
// new score -> existing MIDI is stale
state.midiData = null;
if (state.player) { try { state.player.dispose(); } catch (e) {} state.player = null; }
stop();
updatePlayerVisibility();
return true;
} catch (e) {
setStatus('Parse error: ' + (e && e.message ? e.message : e), 'err');
log('render error: ' + (e && e.stack ? e.stack : e));
return false;
}
}
// ---- MIDI audio + player (gated: only when NOT generating) ---------------
async function initAudio () {
if (state.audioReady) return true;
const mw = window.musicWidgetsBrowser;
if (!mw) { log('musicWidgetsBrowser missing'); return false; }
state.audio = { MIDI: mw.MIDI, MidiPlayer: mw.MidiPlayer, MusicNotation: mw.MusicNotation, MidiAudio: mw.MidiAudio };
try {
await state.audio.MidiAudio.loadPlugin({ soundfontUrl: SOUNDFONT_URL, api: 'webaudio' });
state.audioReady = true;
// warm up the AudioContext eagerly so later plays are already hot (the
// in-gesture warmup in play() is what actually satisfies autoplay policy)
var WA = state.audio.MidiAudio.WebAudio;
if (WA && WA.awaitWarmup) { try { await WA.awaitWarmup(); } catch (e) {} }
log('audio ready');
return true;
} catch (e) {
log('audio load failed: ' + (e && e.message ? e.message : e));
return false;
}
}
// Build a MidiPlayer for the current score's MEI. Idempotent per MEI.
async function buildPlayer () {
if (!state.lastMei || !state.toolkit) return false;
if (!(await initAudio())) return false;
if (state.player && state.midiData) return true; // already built for this MEI
try {
const midiBase64 = state.toolkit.renderToMIDI();
const bin = atob(midiBase64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
const raw = state.audio.MIDI.parseMidiData(bytes.buffer);
const notation = state.audio.MusicNotation.Notation.parseMidi(raw);
if (!notation.tempos || !notation.tempos.length) notation.tempos = [{ tempo: 500000, tick: 0, time: 0 }];
state.midiData = notation;
state.duration = notation.endTime;
if (state.player) { try { state.player.dispose(); } catch (e) {} }
state.player = new state.audio.MidiPlayer(notation, { cacheSpan: 400, onMidi: function () {} });
stop();
return true;
} catch (e) {
log('buildPlayer error: ' + (e && e.message ? e.message : e));
return false;
}
}
function findEventIndexAtTime (time) {
if (!state.midiData) return 0;
const ev = state.midiData.events;
let lo = 0, hi = ev.length;
while (lo < hi) { const mid = (lo + hi) >>> 1; if (ev[mid].time < time) lo = mid + 1; else hi = mid; }
return lo;
}
async function play () {
if (!state.player || !state.midiData || state.isPlaying || state.generating) return;
// warm up the AudioContext (browser autoplay policy) inside the play gesture
// before scheduling notes, so the opening notes aren't dropped on first play
var WA = state.audio && state.audio.MidiAudio && state.audio.MidiAudio.WebAudio;
if (WA && WA.awaitWarmup) { try { await WA.awaitWarmup(); } catch (e) {} }
state.isPlaying = true;
if (state.pausedTime > 0) {
state.playStartTime = performance.now() - state.pausedTime;
state.lastEventIndex = findEventIndexAtTime(state.pausedTime);
} else {
state.playStartTime = performance.now();
state.lastEventIndex = 0;
}
updateTransport();
state.updateInterval = setInterval(function () {
if (!state.isPlaying || !state.midiData) return;
const elapsed = performance.now() - state.playStartTime;
state.currentTime = elapsed;
const ev = state.midiData.events;
const A = state.audio.MidiAudio;
for (; state.lastEventIndex < ev.length; state.lastEventIndex++) {
const e = ev[state.lastEventIndex];
if (e.time > elapsed) break;
if (e.data.type === 'channel') {
const ts = state.playStartTime + e.time;
if (e.data.subtype === 'noteOn') A.noteOn(e.data.channel, e.data.noteNumber, e.data.velocity, ts);
else if (e.data.subtype === 'noteOff') A.noteOff(e.data.channel, e.data.noteNumber, ts);
else if (e.data.subtype === 'programChange') A.programChange(e.data.channel, e.data.programNumber);
}
}
updateHighlightsThrottled(state.currentTime);
updateProgress();
if (elapsed >= state.duration) stop();
}, 30);
}
function pause () {
if (state.updateInterval) { clearInterval(state.updateInterval); state.updateInterval = null; }
state.pausedTime = state.currentTime;
state.isPlaying = false;
state.audio && state.audio.MidiAudio.stopAllNotes && state.audio.MidiAudio.stopAllNotes();
updateTransport();
}
function stop () {
if (state.updateInterval) { clearInterval(state.updateInterval); state.updateInterval = null; }
state.isPlaying = false;
state.currentTime = 0;
state.pausedTime = 0;
state.lastEventIndex = 0;
clearHighlights();
state.audio && state.audio.MidiAudio.stopAllNotes && state.audio.MidiAudio.stopAllNotes();
updateTransport();
updateProgress();
}
function seekTo (t) {
if (!state.midiData) return;
t = Math.max(0, Math.min(t, state.duration));
state.audio && state.audio.MidiAudio.stopAllNotes && state.audio.MidiAudio.stopAllNotes();
state.currentTime = t;
state.pausedTime = t;
state.lastEventIndex = findEventIndexAtTime(t);
if (state.isPlaying) state.playStartTime = performance.now() - t;
updateHighlights(t);
updateProgress();
}
// ---- highlight + cursor -------------------------------------------------
function updateHighlightsThrottled (time) {
const now = performance.now();
if (now - lastHighlightUpdate < HIGHLIGHT_THROTTLE_MS) return;
lastHighlightUpdate = now;
updateHighlights(time);
}
function updateHighlights (time) {
if (!state.toolkit) return;
try {
const res = state.toolkit.getElementsAtTime(time);
const now = new Set(res.notes || []);
state.highlighted.forEach(function (id) {
if (!now.has(id)) { const el = document.getElementById(id); if (el) el.classList.remove('ls-hl'); }
});
now.forEach(function (id) {
if (!state.highlighted.has(id)) { const el = document.getElementById(id); if (el) el.classList.add('ls-hl'); }
});
state.highlighted = now;
// move the playback cursor to the first currently-sounding note
const ids = res.notes || [];
if (ids.length) updateCursor(ids[0]);
} catch (e) { /* ignore */ }
}
// Position the vertical cursor at a note element, spanning its system's height.
function updateCursor (noteId) {
if (!els.cursor || !els.svg) return;
const note = document.getElementById(noteId);
if (!note) { els.cursor.style.display = 'none'; return; }
const sysOrStaff = note.closest('.system') || note.closest('.staff');
const boxRect = els.svg.getBoundingClientRect();
const noteRect = note.getBoundingClientRect();
const x = noteRect.left + noteRect.width / 2 - boxRect.left;
let top = 0, height = boxRect.height;
if (sysOrStaff) {
const sr = sysOrStaff.getBoundingClientRect();
top = sr.top - boxRect.top;
height = sr.height;
}
els.cursor.style.left = x + 'px';
els.cursor.style.top = top + 'px';
els.cursor.style.height = height + 'px';
els.cursor.style.display = 'block';
// keep the cursor in view while playing
scrollCursorIntoView(noteRect, boxRect);
}
// Auto-scroll the preview so the cursor stays visible (throttled, gentle).
function scrollCursorIntoView (noteRect, boxRect) {
if (!els.preview) return;
const now = performance.now();
if (now - lastAutoScroll < 300) return;
const pr = els.preview.getBoundingClientRect();
const above = noteRect.top < pr.top + 60;
const below = noteRect.bottom > pr.bottom - 60;
if (!above && !below) return;
lastAutoScroll = now;
const target = els.preview.scrollTop + (noteRect.top - pr.top) - els.preview.clientHeight * 0.35;
els.preview.scrollTo({ top: Math.max(0, target), behavior: 'smooth' });
}
function hideCursor () {
if (els.cursor) els.cursor.style.display = 'none';
}
function clearHighlights () {
state.highlighted.forEach(function (id) { const el = document.getElementById(id); if (el) el.classList.remove('ls-hl'); });
state.highlighted = new Set();
hideCursor();
}
// ---- transport UI -------------------------------------------------------
function fmt (ms) {
const s = Math.floor(ms / 1000), m = Math.floor(s / 60);
return m + ':' + String(s % 60).padStart(2, '0');
}
function updateProgress () {
if (els.fill) els.fill.style.width = (state.duration > 0 ? (state.currentTime / state.duration) * 100 : 0) + '%';
if (els.time) els.time.textContent = fmt(state.currentTime) + ' / ' + fmt(state.duration);
}
function updateTransport () {
if (!els.playBtn) return;
els.playBtn.style.display = state.isPlaying ? 'none' : '';
els.pauseBtn.style.display = state.isPlaying ? '' : 'none';
}
// Show the player bar only when: not generating, a score is rendered, audio
// available. While generating we keep SVG visible but hide the transport.
function updatePlayerVisibility () {
if (!els.player) return;
const show = !state.generating && !!state.lastMei;
els.player.style.display = show ? '' : 'none';
if (show) buildPlayer().then(function (ok) {
if (ok) updateProgress();
els.playBtn.disabled = !ok;
});
}
// ---- mount + public API -------------------------------------------------
function mount (root) {
if (els.root === root && els.svg) return; // already mounted here
root.innerHTML = '';
root.classList.add('ls-score-root');
const wrap = document.createElement('div'); wrap.className = 'ls-preview';
const status = document.createElement('div'); status.className = 'ls-status';
const svgBox = document.createElement('div'); svgBox.className = 'ls-svg';
const cursor = document.createElement('div'); cursor.className = 'ls-cursor';
svgBox.appendChild(cursor);
wrap.appendChild(status); wrap.appendChild(svgBox);
// transport bar (above the score)
const player = document.createElement('div'); player.className = 'ls-player'; player.style.display = 'none';
player.innerHTML =
'<button class="ls-btn ls-play" title="Play">▶</button>' +
'<button class="ls-btn ls-pause" title="Pause" style="display:none">⏸</button>' +
'<button class="ls-btn ls-stop" title="Stop">■</button>' +
'<span class="ls-time">0:00 / 0:00</span>' +
'<div class="ls-progress"><div class="ls-fill"></div></div>';
root.appendChild(player); root.appendChild(wrap);
els.root = root; els.svg = svgBox; els.preview = wrap; els.status = status; els.player = player; els.cursor = cursor;
els.playBtn = player.querySelector('.ls-play');
els.pauseBtn = player.querySelector('.ls-pause');
els.stopBtn = player.querySelector('.ls-stop');
els.time = player.querySelector('.ls-time');
els.progress = player.querySelector('.ls-progress');
els.fill = player.querySelector('.ls-fill');
els.playBtn.addEventListener('click', play);
els.pauseBtn.addEventListener('click', pause);
els.stopBtn.addEventListener('click', stop);
els.progress.addEventListener('click', function (e) {
if (!state.midiData) return;
const r = els.progress.getBoundingClientRect();
seekTo(state.duration * ((e.clientX - r.left) / r.width));
});
// click in the score -> seek to the nearest note/rest. We don't rely on the
// click target (elementFromPoint often lands on the bare <svg> between
// noteheads, or on a staff line): instead find the .note/.chord/.rest whose
// on-screen box is closest to the click, then seek to its time.
els.svg.addEventListener('click', function (e) {
if (!state.toolkit || state.generating || !state.midiData) return;
const svg = els.svg.querySelector('svg');
if (!svg) return;
const cands = svg.querySelectorAll('.note, .chord, .rest, .mRest');
let best = null, bestD = Infinity;
for (var i = 0; i < cands.length; i++) {
if (!cands[i].id) continue;
const r = cands[i].getBoundingClientRect();
const dx = e.clientX - (r.left + r.width / 2);
const dy = e.clientY - (r.top + r.height / 2);
const d = dx * dx + dy * dy;
if (d < bestD) { bestD = d; best = cands[i]; }
}
if (!best) return;
const time = state.toolkit.getTimeForElement(best.id);
if (typeof time === 'number' && !isNaN(time) && time >= 0) seekTo(time);
});
initVerovio(); // warm up early
}
// Public API consumed by app.py's injected glue.
window.LilyScore = {
mount: mount,
// Render lyl text to the SVG preview. Safe to call repeatedly (deduped).
render: function (code) { return render(code); },
// Generation gate. While generating: SVG-only, transport hidden, playback
// stopped. On finish: re-render final text and reveal the player.
setGenerating: function (flag) {
flag = !!flag;
if (flag === state.generating) return;
state.generating = flag;
// faint yellow score background only while generating
if (els.svg) els.svg.classList.toggle('ls-generating-bg', flag);
if (flag) { stop(); }
updatePlayerVisibility();
},
isReady: function () { return state.verovioReady; },
};
log('score-player.js loaded');
})();