pachet's picture
Make playback stop cancel scheduled notes
964781b
Raw
History Blame Contribute Delete
16.1 kB
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;
});