Spaces:
Running
Running
| /* 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'); | |
| })(); | |