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; });