Spaces:
Running
Running
| const state = { | |
| offset: 0, | |
| limit: 24, | |
| selectedThemeId: null, | |
| currentSource: null, | |
| audioContext: null, | |
| masterGain: null, | |
| playbackSession: 0, | |
| scheduled: [], | |
| activeMidiNotes: [], | |
| midiAccess: null, | |
| midiOutputId: "", | |
| composerSuggestTimer: null, | |
| }; | |
| const $ = (id) => document.getElementById(id); | |
| function text(value) { | |
| return value === null || value === undefined || value === "" ? "Unknown" : String(value); | |
| } | |
| function setStatus(message, ok = true) { | |
| const el = $("generatorStatus"); | |
| el.textContent = message; | |
| el.className = ok ? "status-ok" : "status-bad"; | |
| } | |
| function debounceComposerSuggestions() { | |
| window.clearTimeout(state.composerSuggestTimer); | |
| state.composerSuggestTimer = window.setTimeout(loadComposerSuggestions, 160); | |
| } | |
| async function api(path, options = {}) { | |
| const response = await fetch(path, { | |
| headers: { "Content-Type": "application/json" }, | |
| ...options, | |
| }); | |
| if (!response.ok) { | |
| let detail = response.statusText; | |
| try { | |
| const payload = await response.json(); | |
| detail = payload.detail || detail; | |
| } catch { | |
| detail = response.statusText; | |
| } | |
| throw new Error(detail); | |
| } | |
| return response.json(); | |
| } | |
| async function loadComposerSuggestions() { | |
| const query = $("composerInput").value.trim(); | |
| const payload = await api(`/api/composers?${new URLSearchParams({ q: query, limit: 14 })}`); | |
| const list = $("composerSuggestions"); | |
| list.replaceChildren(); | |
| for (const item of payload.items) { | |
| const option = document.createElement("option"); | |
| option.value = item.composer; | |
| option.label = `${item.count} themes`; | |
| list.append(option); | |
| } | |
| } | |
| async function loadStats() { | |
| const [stats, health] = await Promise.all([api("/api/stats"), api("/api/health")]); | |
| $("statsLine").textContent = `${stats.themes.toLocaleString()} themes, ${stats.notes.toLocaleString()} notes`; | |
| $("healthButton").title = `Database ${health.database ? "ready" : "missing"}, Verovio ${health.verovio ? "ready" : "missing"}`; | |
| } | |
| function queryParams() { | |
| const params = new URLSearchParams(); | |
| params.set("limit", state.limit); | |
| params.set("offset", state.offset); | |
| const q = $("searchInput").value.trim(); | |
| const composer = $("composerInput").value.trim(); | |
| const key = $("keyFilter").value; | |
| if (q) params.set("q", q); | |
| if (composer) params.set("composer", composer); | |
| if (key) params.set("key", key); | |
| return params; | |
| } | |
| async function loadThemes() { | |
| const payload = await api(`/api/themes?${queryParams()}`); | |
| const list = $("themeList"); | |
| list.replaceChildren(); | |
| for (const item of payload.items) { | |
| const button = document.createElement("button"); | |
| button.className = "theme-item"; | |
| button.dataset.themeId = item.id; | |
| if (item.id === state.selectedThemeId) button.classList.add("active"); | |
| button.type = "button"; | |
| button.addEventListener("click", () => selectTheme(item.id)); | |
| const title = document.createElement("strong"); | |
| title.textContent = item.title || item.id; | |
| const composer = document.createElement("p"); | |
| composer.textContent = item.composer || "Unknown composer"; | |
| const tags = document.createElement("div"); | |
| tags.className = "tag-row"; | |
| tags.append(tag(item.id), tag(item.key_root || "key", "key"), tag(item.abc_meter || "meter", "meter")); | |
| if (item.note_count) tags.append(tag(`${item.note_count} notes`)); | |
| button.append(title, composer, tags); | |
| list.append(button); | |
| } | |
| $("pageLabel").textContent = `${payload.offset + 1}-${payload.offset + payload.items.length}`; | |
| $("prevPage").disabled = state.offset === 0; | |
| $("nextPage").disabled = payload.items.length < state.limit; | |
| if (!state.selectedThemeId && payload.items.length) { | |
| selectTheme(payload.items[0].id); | |
| } | |
| } | |
| function tag(label, extra = "") { | |
| const span = document.createElement("span"); | |
| span.className = `tag ${extra}`.trim(); | |
| span.textContent = label; | |
| return span; | |
| } | |
| async function selectTheme(id) { | |
| state.selectedThemeId = id; | |
| const item = await api(`/api/themes/${id}`); | |
| state.currentSource = { type: "theme", notesUrl: item.notes_url, title: item.title }; | |
| $("detailTitle").textContent = item.title || item.id; | |
| $("detailMeta").textContent = `${text(item.composer)} · ${text(item.key_root)} · ${text(item.abc_meter)} · ${text(item.note_count)} notes`; | |
| $("themeDescription").textContent = item.description || item.keywords || ""; | |
| $("playTheme").disabled = false; | |
| const score = $("scoreImage"); | |
| score.src = item.svg_url; | |
| score.alt = item.title || item.id; | |
| score.closest(".score-surface").classList.add("has-score"); | |
| renderExports($("themeExports"), [ | |
| ["MIDI", item.midi_url], | |
| ["ABC", item.abc_url], | |
| ]); | |
| for (const button of document.querySelectorAll(".theme-item")) { | |
| button.classList.toggle("active", button.dataset.themeId === item.id); | |
| } | |
| } | |
| function renderExports(container, links) { | |
| container.replaceChildren(); | |
| for (const [label, href] of links) { | |
| if (!href) continue; | |
| const a = document.createElement("a"); | |
| a.href = href; | |
| a.textContent = label; | |
| a.target = "_blank"; | |
| container.append(a); | |
| } | |
| } | |
| function midiToHz(pitch) { | |
| return 440 * Math.pow(2, (pitch - 69) / 12); | |
| } | |
| function stopPlayback() { | |
| state.playbackSession += 1; | |
| const ctx = state.audioContext; | |
| if (state.masterGain && ctx) { | |
| try { | |
| state.masterGain.gain.cancelScheduledValues(ctx.currentTime); | |
| state.masterGain.gain.setValueAtTime(0, ctx.currentTime); | |
| } catch { | |
| // Already disconnected. | |
| } | |
| const oldMaster = state.masterGain; | |
| window.setTimeout(() => { | |
| try { | |
| oldMaster.disconnect(); | |
| } catch { | |
| // Already disconnected. | |
| } | |
| }, 60); | |
| state.masterGain = null; | |
| } | |
| for (const node of state.scheduled) { | |
| try { | |
| if (typeof node === "number") { | |
| window.clearTimeout(node); | |
| } else { | |
| if (typeof node.stop === "function" && ctx) node.stop(ctx.currentTime); | |
| if (typeof node.disconnect === "function") node.disconnect(); | |
| } | |
| } catch { | |
| // Already stopped. | |
| } | |
| } | |
| state.scheduled = []; | |
| const output = selectedMidiOutput(); | |
| if (output) { | |
| if (typeof output.clear === "function") output.clear(); | |
| for (const note of state.activeMidiNotes) { | |
| output.send([0x80 + note.channel, note.pitch, 0]); | |
| } | |
| state.activeMidiNotes = []; | |
| for (let channel = 0; channel < 16; channel += 1) { | |
| output.send([0xb0 + channel, 123, 0]); | |
| output.send([0xb0 + channel, 120, 0]); | |
| } | |
| } | |
| } | |
| function selectedMidiOutput() { | |
| if (!state.midiAccess || !state.midiOutputId) return null; | |
| return state.midiAccess.outputs.get(state.midiOutputId) || null; | |
| } | |
| async function requestMidiAccess() { | |
| if (!("requestMIDIAccess" in navigator)) { | |
| setStatus("Web MIDI is not available in this browser", false); | |
| return null; | |
| } | |
| if (!state.midiAccess) { | |
| state.midiAccess = await navigator.requestMIDIAccess(); | |
| state.midiAccess.addEventListener("statechange", renderMidiOutputs); | |
| } | |
| renderMidiOutputs(); | |
| return state.midiAccess; | |
| } | |
| function renderMidiOutputs() { | |
| const select = $("midiOutputSelect"); | |
| const previous = select.value || state.midiOutputId; | |
| select.replaceChildren(); | |
| const browser = document.createElement("option"); | |
| browser.value = ""; | |
| browser.textContent = "Browser audio"; | |
| select.append(browser); | |
| if (state.midiAccess) { | |
| for (const output of state.midiAccess.outputs.values()) { | |
| const option = document.createElement("option"); | |
| option.value = output.id; | |
| option.textContent = output.name || output.manufacturer || output.id; | |
| select.append(option); | |
| } | |
| } | |
| if ([...select.options].some((option) => option.value === previous)) { | |
| select.value = previous; | |
| } | |
| state.midiOutputId = select.value; | |
| } | |
| async function refreshMidiOutputs() { | |
| try { | |
| await requestMidiAccess(); | |
| const count = state.midiAccess ? state.midiAccess.outputs.size : 0; | |
| setStatus(count ? `${count} MIDI output${count === 1 ? "" : "s"}` : "No MIDI outputs found", Boolean(count)); | |
| } catch (error) { | |
| setStatus(error.message, false); | |
| } | |
| } | |
| function playMidiNotes(notes, bpm = 120, output) { | |
| stopPlayback(); | |
| const session = state.playbackSession; | |
| const secondsPerBeat = 60 / bpm; | |
| const startDelay = 70; | |
| for (const note of notes) { | |
| const pitch = Number(note.pitch); | |
| const velocity = Math.max(1, Math.min(127, Number(note.velocity || 72))); | |
| const channel = 0; | |
| const begin = startDelay + Number(note.start_beat) * secondsPerBeat * 1000; | |
| const duration = Math.max(0.04, Number(note.duration_beats) * secondsPerBeat); | |
| const noteOn = window.setTimeout(() => { | |
| if (session !== state.playbackSession) return; | |
| state.activeMidiNotes.push({ channel, pitch }); | |
| output.send([0x90 + channel, pitch, velocity]); | |
| }, begin); | |
| const noteOff = window.setTimeout(() => { | |
| if (session !== state.playbackSession) return; | |
| output.send([0x80 + channel, pitch, 0]); | |
| state.activeMidiNotes = state.activeMidiNotes.filter((item) => item.channel !== channel || item.pitch !== pitch); | |
| }, begin + duration * 1000); | |
| state.scheduled.push(noteOn, noteOff); | |
| } | |
| const lastEnd = Math.max( | |
| 0, | |
| ...notes.map((note) => (Number(note.start_beat) + Number(note.duration_beats)) * secondsPerBeat * 1000), | |
| ); | |
| const timeout = window.setTimeout(() => { | |
| if (session !== state.playbackSession) return; | |
| state.scheduled = state.scheduled.filter((item) => item !== timeout); | |
| state.activeMidiNotes = []; | |
| }, startDelay + lastEnd + 200); | |
| state.scheduled.push(timeout); | |
| } | |
| async function playNotes(notes, bpm = 120) { | |
| const midiOutput = selectedMidiOutput(); | |
| if (midiOutput) { | |
| playMidiNotes(notes, bpm, midiOutput); | |
| return; | |
| } | |
| stopPlayback(); | |
| const session = state.playbackSession; | |
| if (!state.audioContext) { | |
| state.audioContext = new AudioContext(); | |
| } | |
| const ctx = state.audioContext; | |
| if (ctx.state === "suspended") await ctx.resume(); | |
| if (session !== state.playbackSession) return; | |
| const master = ctx.createGain(); | |
| master.gain.value = 1; | |
| master.connect(ctx.destination); | |
| state.masterGain = master; | |
| const secondsPerBeat = 60 / bpm; | |
| const startAt = ctx.currentTime + 0.06; | |
| for (const note of notes) { | |
| if (session !== state.playbackSession) break; | |
| const osc = ctx.createOscillator(); | |
| const gain = ctx.createGain(); | |
| const begin = startAt + Number(note.start_beat) * secondsPerBeat; | |
| const duration = Math.max(0.04, Number(note.duration_beats) * secondsPerBeat); | |
| const end = begin + duration; | |
| osc.type = "triangle"; | |
| osc.frequency.value = midiToHz(Number(note.pitch)); | |
| gain.gain.setValueAtTime(0.0001, begin); | |
| gain.gain.exponentialRampToValueAtTime(0.14, begin + 0.015); | |
| gain.gain.exponentialRampToValueAtTime(0.0001, end); | |
| osc.connect(gain).connect(master); | |
| osc.addEventListener("ended", () => { | |
| try { | |
| osc.disconnect(); | |
| gain.disconnect(); | |
| } catch { | |
| // Already disconnected. | |
| } | |
| }); | |
| osc.start(begin); | |
| osc.stop(end + 0.02); | |
| state.scheduled.push(osc); | |
| } | |
| } | |
| async function playCurrentTheme() { | |
| if (!state.currentSource) return; | |
| const payload = await api(state.currentSource.notesUrl); | |
| await playNotes(payload.notes, payload.bpm || 120); | |
| } | |
| function generationPayload() { | |
| const engine = $("engineSelect").value; | |
| const payload = { | |
| engine: $("engineSelect").value, | |
| samples: Number($("samplesInput").value), | |
| length: Number($("lengthInput").value), | |
| key: $("generateKey").value, | |
| seed: Number($("seedInput").value), | |
| endpoint_strength: Number($("endpointInput").value), | |
| }; | |
| if (engine === "markov") { | |
| payload.max_order = Number($("orderInput").value); | |
| } else { | |
| payload.transformer_steps = Number($("stepsInput").value); | |
| } | |
| return payload; | |
| } | |
| function updateEngineControls() { | |
| const engine = $("engineSelect").value; | |
| for (const el of document.querySelectorAll(".markov-param")) { | |
| el.classList.toggle("is-hidden", engine !== "markov"); | |
| } | |
| for (const el of document.querySelectorAll(".transformer-param")) { | |
| el.classList.toggle("is-hidden", engine !== "transformer"); | |
| } | |
| } | |
| async function generate() { | |
| setStatus("Generating..."); | |
| $("generateButton").disabled = true; | |
| try { | |
| const payload = await api("/api/generate", { | |
| method: "POST", | |
| body: JSON.stringify(generationPayload()), | |
| }); | |
| renderGenerated(payload); | |
| setStatus(`${payload.samples.length} samples · ${payload.engine}`); | |
| } catch (error) { | |
| setStatus(error.message, false); | |
| } finally { | |
| $("generateButton").disabled = false; | |
| } | |
| } | |
| function renderGenerated(payload) { | |
| const list = $("generatedList"); | |
| list.replaceChildren(); | |
| for (const sample of payload.samples) { | |
| const card = document.createElement("article"); | |
| card.className = "sample-card"; | |
| const header = document.createElement("div"); | |
| header.className = "section-header"; | |
| const title = document.createElement("h3"); | |
| title.textContent = sample.title; | |
| const actions = document.createElement("div"); | |
| actions.className = "toolbar"; | |
| const play = document.createElement("button"); | |
| play.type = "button"; | |
| play.textContent = "Play"; | |
| play.addEventListener("click", () => playNotes(sample.notes, 120)); | |
| const stop = document.createElement("button"); | |
| stop.type = "button"; | |
| stop.className = "secondary"; | |
| stop.textContent = "Stop"; | |
| stop.addEventListener("click", stopPlayback); | |
| actions.append(play, stop); | |
| header.append(title, actions); | |
| const score = document.createElement("div"); | |
| score.className = "sample-score"; | |
| if (sample.svg_url) { | |
| const img = document.createElement("img"); | |
| img.src = sample.svg_url; | |
| img.alt = sample.title; | |
| score.append(img); | |
| } else { | |
| const empty = document.createElement("div"); | |
| empty.className = "empty-state"; | |
| empty.textContent = "MusicXML ready"; | |
| score.append(empty); | |
| } | |
| const links = document.createElement("div"); | |
| links.className = "export-row"; | |
| renderExports(links, [ | |
| ["MIDI", sample.midi_url], | |
| ["ABC", sample.abc_url], | |
| ["MusicXML", sample.musicxml_url], | |
| ["SVG", sample.svg_url], | |
| ]); | |
| const pcs = document.createElement("div"); | |
| pcs.className = "code-line"; | |
| pcs.textContent = `pc ${sample.relative_pcs.join(" ")}`; | |
| const durs = document.createElement("div"); | |
| durs.className = "code-line"; | |
| durs.textContent = sample.durations.join(" "); | |
| card.append(header, score, links, pcs, durs); | |
| list.append(card); | |
| } | |
| } | |
| function bindEvents() { | |
| $("searchForm").addEventListener("submit", (event) => { | |
| event.preventDefault(); | |
| state.offset = 0; | |
| loadThemes(); | |
| }); | |
| $("clearSearch").addEventListener("click", () => { | |
| $("searchInput").value = ""; | |
| $("composerInput").value = ""; | |
| $("keyFilter").value = ""; | |
| state.offset = 0; | |
| loadThemes(); | |
| }); | |
| $("prevPage").addEventListener("click", () => { | |
| state.offset = Math.max(0, state.offset - state.limit); | |
| loadThemes(); | |
| }); | |
| $("nextPage").addEventListener("click", () => { | |
| state.offset += state.limit; | |
| loadThemes(); | |
| }); | |
| $("playTheme").addEventListener("click", playCurrentTheme); | |
| $("stopPlayback").addEventListener("click", stopPlayback); | |
| $("generateButton").addEventListener("click", generate); | |
| $("engineSelect").addEventListener("change", updateEngineControls); | |
| $("refreshMidiOutputs").addEventListener("click", refreshMidiOutputs); | |
| $("midiOutputSelect").addEventListener("change", (event) => { | |
| state.midiOutputId = event.target.value; | |
| stopPlayback(); | |
| }); | |
| $("composerInput").addEventListener("input", debounceComposerSuggestions); | |
| $("composerInput").addEventListener("focus", loadComposerSuggestions); | |
| } | |
| async function init() { | |
| bindEvents(); | |
| updateEngineControls(); | |
| await loadStats(); | |
| await loadThemes(); | |
| } | |
| init().catch((error) => { | |
| $("statsLine").textContent = error.message; | |
| }); | |