import { Client, handle_file } from "https://cdn.jsdelivr.net/npm/@gradio/client@2.3.1/+esm"; const ENDPOINT = "/generate_chart"; const $ = (selector, root = document) => root.querySelector(selector); const $$ = (selector, root = document) => [...root.querySelectorAll(selector)]; const ui = { form: $("#generate-form"), fileInput: $("#file-input"), dropzone: $("#dropzone"), sourceCard: $("#source-card"), sourceAudio: $("#source-audio"), fileName: $("#file-name"), fileMeta: $("#file-meta"), removeFile: $("#remove-file"), fileError: $("#file-error"), model: $("#model"), experimentalModels: $("#experimental-models"), modelNotices: $("#model-notices"), modelNoticesTitle: $("#model-notices-title"), modelNoticesList: $("#model-notices-list"), level: $("#level"), levelOutput: $("#level-output"), bpm: $("#bpm"), autoPlan: $("#auto-plan"), useBeat: $("#use-beat"), usePlanner: $("#use-planner"), sampling: $("#sampling"), samplingParameters: $("#sampling-parameters"), temperature: $("#temperature"), temperatureOutput: $("#temperature-output"), topP: $("#top-p"), topPOutput: $("#top-p-output"), drumVolume: $("#drum-volume"), drumVolumeOutput: $("#drum-volume-output"), generateButton: $("#generate-button"), connection: $("#connection"), connectionLabel: $("#connection-label"), processPanel: $("#process-panel"), stageCanvas: $("#stage-canvas"), statusChip: $("#status-chip"), stageEyebrow: $("#stage-eyebrow"), stageTitle: $("#stage-title"), stageDetail: $("#stage-detail"), progressNumber: $("#progress-number"), progressTrack: $("#progress-track"), progressBar: $("#progress-bar"), queueLabel: $("#queue-label"), cancelButton: $("#cancel-button"), processError: $("#process-error"), processErrorMessage: $("#process-error-message"), retryButton: $("#retry-button"), resultPanel: $("#result-panel"), auditionCard: $("#audition-card"), resultAudio: $("#result-audio"), metricsGrid: $("#metrics-grid"), previewBlock: $(".preview-block"), chartTab: $("#chart-tab"), planTab: $("#plan-tab"), chartPreview: $("#chart-preview"), planPreview: $("#plan-preview"), chartImage: $("#chart-image"), planImage: $("#plan-image"), chartEmpty: $("#chart-empty"), planEmpty: $("#plan-empty"), downloadTja: $("#download-tja"), downloadAudio: $("#download-audio"), newChartButton: $("#new-chart-button"), announcer: $("#announcer") }; const stageDefaults = { loading: { eyebrow: "PREPARING", title: "Preparing", detail: "Loading SoftChart." }, audio: { eyebrow: "LISTENING", title: "Listening", detail: "Mapping rhythm, melody, and timbre." }, beat: { eyebrow: "ALIGNING", title: "Finding the beat", detail: "Finding beats and tempo." }, plan: { eyebrow: "PLANNING", title: "Shaping the arc", detail: "Planning density and climaxes." }, generate: { eyebrow: "COMPOSING", title: "Writing the chart", detail: "Writing playable Taiko patterns." }, export: { eyebrow: "RENDERING", title: "Rendering", detail: "Building the TJA and previews." }, mix: { eyebrow: "MIXING", title: "Mixing", detail: "Mixing Taiko with your track." }, complete: { eyebrow: "COMPLETE", title: "Ready", detail: "Play it or download it." } }; const metricLabels = { bpm: "BPM", notes: "Notes", spans: "Drumroll sections", timing: "Timing mode", grid_rms_ms: "Grid error", preview_hits: "Audition hits", model: "Model", difficulty: "Difficulty", plan_source: "Song plan", chart_grid: "Chart lattice", grid_anchor: "Grid anchor", density_bucket: "Density bucket", sampling: "Sampling", // The bar timeline: where the barlines fell, whether the song's tempo change // survived into them, and how well the beat fit did. The server sends these // as finished sentences -- the interface must not restate or soften them. // They come last so the full-width rows land under the compact tiles. beat_fit: "Beat fit", bar_grid: "Bar grid", tempo_map: "Tempo map", grid_repairs: "Grid repairs" }; // Metrics whose value is a sentence rather than a number or a short label. // A compact tile would ellipsis them away, and these are exactly the ones a // user has to be able to read: whether this song's tempo change was handled. const wideMetrics = new Set(["beat_fit", "bar_grid", "tempo_map", "grid_repairs"]); // Filled from /api/capabilities: the server owns the model list and the model // card's limitation text, so the interface never restates either from memory. let capabilities = null; let client = null; let connectPromise = null; let currentFile = null; let sourceObjectUrl = ""; let currentJob = null; let currentRun = 0; let running = false; let receivedTerminalPayload = false; function announce(message) { ui.announcer.textContent = ""; window.setTimeout(() => { ui.announcer.textContent = message; }, 40); } function setConnection(state, label) { ui.connection.classList.toggle("is-ready", state === "ready"); ui.connection.classList.toggle("is-error", state === "error"); ui.connectionLabel.textContent = label; } async function connect() { if (client) return client; if (connectPromise) return connectPromise; setConnection("connecting", "Connecting"); connectPromise = Client.connect(new URL(".", window.location.href).href, { events: ["status", "data"] }).then((connectedClient) => { client = connectedClient; setConnection("ready", "Service ready"); return client; }).catch((error) => { connectPromise = null; setConnection("error", "Connection lost"); throw error; }); return connectPromise; } function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes < 0) return ""; if (bytes < 1024) return `${bytes} B`; const units = ["KB", "MB", "GB"]; let value = bytes / 1024; let unit = units[0]; for (let index = 1; index < units.length && value >= 1024; index += 1) { value /= 1024; unit = units[index]; } const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2; return `${value.toFixed(precision)} ${unit}`; } function formatDuration(seconds) { if (!Number.isFinite(seconds) || seconds <= 0) return ""; const rounded = Math.round(seconds); const minutes = Math.floor(rounded / 60); const remainder = String(rounded % 60).padStart(2, "0"); return `${minutes}:${remainder}`; } function isSupportedAudio(file) { return /\.(mp3|wav|flac|ogg)$/i.test(file.name); } function clearFileError() { ui.fileError.hidden = true; ui.fileError.textContent = ""; } function showFileError(message) { ui.fileError.textContent = message; ui.fileError.hidden = false; announce(message); } function clearFile() { if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl); sourceObjectUrl = ""; currentFile = null; ui.fileInput.value = ""; ui.sourceAudio.removeAttribute("src"); ui.sourceAudio.load(); ui.sourceCard.hidden = true; ui.dropzone.hidden = false; clearFileError(); } function selectFile(file) { clearFileError(); if (!file) return; if (!isSupportedAudio(file)) { showFileError("Choose an MP3, WAV, FLAC, or OGG audio file."); return; } if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl); currentFile = file; sourceObjectUrl = URL.createObjectURL(file); ui.fileName.textContent = file.name; ui.fileMeta.textContent = formatBytes(file.size); ui.sourceAudio.src = sourceObjectUrl; ui.dropzone.hidden = true; ui.sourceCard.hidden = false; ui.sourceAudio.addEventListener("loadedmetadata", () => { const size = formatBytes(file.size); const duration = formatDuration(ui.sourceAudio.duration); ui.fileMeta.textContent = [size, duration].filter(Boolean).join(" · "); }, { once: true }); announce(`Selected ${file.name}`); } function setRangeState(input, output, formatter) { const min = Number(input.min); const max = Number(input.max); const value = Number(input.value); const progress = ((value - min) / (max - min)) * 100; input.style.setProperty("--range-progress", `${progress}%`); output.textContent = formatter(value); } function updateRanges() { setRangeState(ui.level, ui.levelOutput, (value) => `★ ${value.toFixed(0)}`); setRangeState(ui.temperature, ui.temperatureOutput, (value) => value.toFixed(2)); setRangeState(ui.topP, ui.topPOutput, (value) => value.toFixed(2)); setRangeState(ui.drumVolume, ui.drumVolumeOutput, (value) => `${Math.round(value * 100)}%`); } function setSamplingEnabled(enabled) { ui.temperature.disabled = !enabled; ui.topP.disabled = !enabled; ui.samplingParameters.setAttribute("aria-disabled", String(!enabled)); } function normalizeStage(stage) { const value = String(stage || "").trim().toLowerCase(); if (Object.hasOwn(stageDefaults, value)) return value; throw new Error(`Unsupported generation stage: ${value}`); } function setStage(stage, data = {}) { const normalized = normalizeStage(stage); $$(".stage-visual", ui.stageCanvas).forEach((visual) => { visual.classList.toggle("is-active", visual.dataset.stage === normalized); }); const defaults = stageDefaults[normalized]; ui.stageEyebrow.textContent = defaults.eyebrow; ui.stageTitle.textContent = typeof data.title === "string" && data.title.trim() ? data.title.trim() : defaults.title; ui.stageDetail.textContent = typeof data.detail === "string" && data.detail.trim() ? data.detail.trim() : defaults.detail; } function progressPercent(progress) { const numeric = Number(progress); if (!Number.isFinite(numeric)) return 0; const percent = numeric <= 1 ? numeric * 100 : numeric; return Math.min(100, Math.max(0, percent)); } function setProgress(progress) { const percent = progressPercent(progress); const rounded = Math.round(percent); ui.progressBar.style.width = `${percent}%`; ui.progressNumber.textContent = `${rounded}%`; ui.progressTrack.setAttribute("aria-valuenow", String(rounded)); } function setStatusChip(state, text) { ui.statusChip.className = `status-chip is-${state}`; ui.statusChip.textContent = text; } function setBusy(isBusy) { running = isBusy; ui.generateButton.disabled = isBusy; ui.removeFile.disabled = isBusy; ui.generateButton.classList.toggle("is-running", isBusy); ui.stageCanvas.classList.toggle("is-running", isBusy); ui.processPanel.setAttribute("aria-busy", String(isBusy)); $("span", ui.generateButton).textContent = isBusy ? "Generating chart" : "Generate chart"; ui.cancelButton.hidden = !isBusy; } function resetProcess() { setBusy(false); setStage("loading", { title: "Waiting for your music", detail: "Upload a song, choose a difficulty, and start creating your chart." }); ui.stageEyebrow.textContent = "STANDBY"; setProgress(0); setStatusChip("idle", "Ready"); ui.queueLabel.textContent = "Not started"; ui.processError.hidden = true; ui.processErrorMessage.textContent = ""; } function showProcessError(message) { const friendlyMessage = typeof message === "string" && message.trim() ? message.trim() : "An unexpected error occurred. Please try again shortly."; setBusy(false); setStatusChip("error", "Error"); ui.processErrorMessage.textContent = friendlyMessage; ui.processError.hidden = false; ui.queueLabel.textContent = "Generation incomplete"; announce(`Generation incomplete: ${friendlyMessage}`); } function handleQueueStatus(message) { const status = String(message.stage || "").toLowerCase(); if (status === "pending") { const position = Number(message.position); const queueSize = Number(message.size); const eta = Number(message.eta); const parts = []; if (Number.isFinite(position)) parts.push(`${Math.max(0, position)} job${position === 1 ? "" : "s"} ahead`); if (Number.isFinite(queueSize)) parts.push(`${queueSize} job${queueSize === 1 ? "" : "s"} in queue`); if (Number.isFinite(eta) && eta > 0) parts.push(`about ${Math.ceil(eta)} sec`); ui.queueLabel.textContent = parts.length ? parts.join(" · ") : "Added to the generation queue"; setStatusChip("active", "Queued"); return; } if (status === "generating") { ui.queueLabel.textContent = "The GPU is processing your song"; setStatusChip("active", "Generating"); return; } if (status === "error") { showProcessError(message.message || "The server could not complete this generation."); } } function fileDescriptor(value) { if (!value) return { url: "", name: "" }; if (typeof value !== "object") return { url: "", name: "" }; return { url: typeof value.url === "string" ? value.url : "", name: typeof value.orig_name === "string" ? value.orig_name : "" }; } function resolvedFile(value) { const descriptor = fileDescriptor(value); if (!descriptor.url) return descriptor; try { const url = new URL(descriptor.url, window.location.href); if (!["http:", "https:", "blob:"].includes(url.protocol)) return { url: "", name: "" }; return { url: url.href, name: descriptor.name }; } catch { return { url: "", name: "" }; } } function setImage(image, emptyState, fileValue) { const file = resolvedFile(fileValue); if (file.url) { image.src = file.url; image.hidden = false; emptyState.hidden = true; return true; } image.removeAttribute("src"); image.hidden = true; emptyState.hidden = false; return false; } function setDownload(anchor, fileValue, defaultName) { const file = resolvedFile(fileValue); if (!file.url) { anchor.hidden = true; anchor.removeAttribute("href"); return false; } anchor.href = file.url; anchor.download = file.name || defaultName; anchor.hidden = false; return true; } function metricValue(key, value) { if (value === null || value === undefined || value === "") return "—"; if (key === "timing") { if (value === "slot-exact") return "Grid-exact"; if (value === "time-quantized") return "Time-quantized"; } if (key === "grid_rms_ms" && Number.isFinite(Number(value))) return `${Number(value).toFixed(1)} ms`; if (key === "bpm" && Number.isFinite(Number(value))) return Number(value).toFixed(1); if (typeof value === "boolean") return value ? "Yes" : "No"; if (typeof value === "number") return Number.isInteger(value) ? String(value) : value.toFixed(2); if (typeof value === "object") return JSON.stringify(value); // Underscores are prettified only in single-token enum values. Sentences // from the server quote identifiers verbatim (`inlier_frac`, `rms_ms`, a // fit's own reason string); rewriting those would misquote the server. const text = String(value); return text.includes(" ") ? text : text.replaceAll("_", " "); } function renderMetrics(metrics) { ui.metricsGrid.replaceChildren(); if (!metrics || typeof metrics !== "object" || Array.isArray(metrics)) { ui.metricsGrid.hidden = true; return; } // Only metrics the server actually reported. A key it omitted (the 2.0 // lattice fields on a 1.x run, say) is not a missing value to show as "—". const entries = Object.entries(metricLabels) .filter(([key]) => key in metrics) .map(([key, label]) => [key, label, metrics[key]]); ui.metricsGrid.hidden = entries.length === 0; entries.forEach(([key, label, value]) => { const wrapper = document.createElement("div"); const term = document.createElement("dt"); const description = document.createElement("dd"); wrapper.className = wideMetrics.has(key) ? "metric metric--wide" : "metric"; term.textContent = label; description.textContent = metricValue(key, value); description.title = description.textContent; wrapper.append(term, description); ui.metricsGrid.append(wrapper); }); } function activatePreview(name, focus = false) { const chartActive = name === "chart"; ui.chartTab.classList.toggle("is-active", chartActive); ui.planTab.classList.toggle("is-active", !chartActive); ui.chartTab.setAttribute("aria-selected", String(chartActive)); ui.planTab.setAttribute("aria-selected", String(!chartActive)); ui.chartTab.tabIndex = chartActive ? 0 : -1; ui.planTab.tabIndex = chartActive ? -1 : 0; ui.chartPreview.hidden = !chartActive; ui.planPreview.hidden = chartActive; if (focus) (chartActive ? ui.chartTab : ui.planTab).focus(); } function renderResult(payload) { const files = payload.files && typeof payload.files === "object" ? payload.files : {}; const audio = resolvedFile(files.audio); if (audio.url) { ui.resultAudio.src = audio.url; ui.auditionCard.hidden = false; } else { ui.resultAudio.removeAttribute("src"); ui.resultAudio.load(); ui.auditionCard.hidden = true; } renderMetrics(payload.metrics); const hasChart = setImage(ui.chartImage, ui.chartEmpty, files.chart_image); const hasPlan = setImage(ui.planImage, ui.planEmpty, files.plan_image); ui.previewBlock.hidden = !hasChart && !hasPlan; ui.chartTab.hidden = !hasChart; ui.planTab.hidden = !hasPlan; activatePreview(hasChart ? "chart" : "plan"); setDownload(ui.downloadTja, files.tja, "softchart.tja"); setDownload(ui.downloadAudio, files.audio, "softchart-taiko-preview.wav"); ui.resultPanel.hidden = false; } function handlePayload(payload) { if (!payload || typeof payload !== "object") return; const kind = String(payload.kind || "progress").toLowerCase(); // Gradio may replay the generator's final yield before its terminal status. if (kind === "complete" && receivedTerminalPayload) return; if (kind === "error") { receivedTerminalPayload = true; showProcessError(payload.detail || payload.title || "The server could not complete this generation."); return; } setStage(payload.stage, payload); setProgress(payload.progress); ui.processError.hidden = true; if (kind === "complete") { receivedTerminalPayload = true; setStage("complete", payload); setProgress(100); setBusy(false); setStatusChip("complete", "Complete"); ui.queueLabel.textContent = "All outputs are ready"; renderResult(payload); announce("The chart, audition audio, and previews are ready"); } } function selectedCourseValue() { const selected = $('input[name="course"]:checked', ui.form); return selected ? selected.value : "oni"; } function selectedModelValue() { return ui.model ? ui.model.value : "v1.7"; } // The model card's wording, verbatim. Anything shown here is a defect the // tester is meant to recognise as already known, so it is never paraphrased. function renderNotices() { if (!capabilities || !ui.modelNotices) return; const model = (capabilities.models || []).find((m) => m.value === selectedModelValue()); const course = selectedCourseValue(); const courseNotices = (capabilities.course_notices || {})[course] || []; const plannerOn = ui.usePlanner ? ui.usePlanner.checked : false; const items = []; if (model && model.experimental) { (model.limitations || []).forEach((entry) => items.push(entry)); (model.notes || []).forEach((text) => items.push({ text })); } courseNotices.forEach((entry) => { if (entry.when === "planner" && !plannerOn) return; if (entry.when === "planner" && model && model.experimental) return; items.push({ text: entry.text }); }); ui.modelNoticesList.replaceChildren(); items.forEach((entry) => { const li = document.createElement("li"); if (entry.section) { const tag = document.createElement("span"); tag.className = "notice-section"; tag.textContent = entry.section; li.append(tag); } li.append(document.createTextNode(entry.text)); ui.modelNoticesList.append(li); }); ui.modelNoticesTitle.textContent = model && model.experimental ? "Experimental — known limitations" : "Known limitations"; ui.modelNotices.hidden = items.length === 0; } async function loadCapabilities() { const response = await fetch("/api/capabilities", { headers: { Accept: "application/json" } }); if (!response.ok) throw new Error(`capabilities ${response.status}`); capabilities = await response.json(); const experimental = (capabilities.models || []).filter((m) => m.experimental); if (ui.experimentalModels && experimental.length) { ui.experimentalModels.replaceChildren(); experimental.forEach((model) => { const option = document.createElement("option"); option.value = model.value; option.textContent = model.label; ui.experimentalModels.append(option); }); if (experimental[0].group) ui.experimentalModels.label = experimental[0].group; ui.experimentalModels.disabled = false; ui.experimentalModels.hidden = false; } renderNotices(); } function requestValues() { const selectedCourse = $('input[name="course"]:checked', ui.form); const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value); return [ handle_file(currentFile), currentFile.name, selectedCourse ? selectedCourse.value : "oni", Number.parseInt(ui.level.value, 10), Number.isFinite(bpm) ? bpm : 0, ui.autoPlan.checked, ui.useBeat.checked, ui.usePlanner.checked, ui.sampling.checked, Number(ui.temperature.value), Number(ui.topP.value), Number(ui.drumVolume.value), ui.model ? ui.model.value : "v1.7" ]; } async function generate(event) { event.preventDefault(); if (running) return; clearFileError(); if (!currentFile) { showFileError("Choose a song before generating a chart."); ui.dropzone.focus(); return; } const bpm = ui.bpm.value.trim() === "" ? 0 : Number(ui.bpm.value); if (!Number.isFinite(bpm) || (bpm !== 0 && (bpm < 30 || bpm > 400))) { showFileError("Manual BPM must be between 30 and 400, or left blank for automatic detection."); ui.bpm.focus(); return; } const runId = ++currentRun; receivedTerminalPayload = false; ui.resultPanel.hidden = true; ui.processError.hidden = true; setBusy(true); setStage("loading"); setProgress(1); setStatusChip("active", "Preparing"); ui.queueLabel.textContent = "Connecting to the generation service"; ui.processPanel.scrollIntoView({ behavior: "smooth", block: "start" }); announce("Chart generation submitted"); try { const app = await connect(); if (runId !== currentRun) return; currentJob = app.submit(ENDPOINT, requestValues()); for await (const message of currentJob) { if (runId !== currentRun) break; if (message.type === "status") { handleQueueStatus(message); } else if (message.type === "data") { const payload = Array.isArray(message.data) ? message.data[0] : message.data; handlePayload(payload); } } if (runId === currentRun && running && !receivedTerminalPayload) { showProcessError("The generation connection ended before a complete result arrived. Please try again."); } } catch (error) { if (runId !== currentRun) return; const message = error instanceof Error ? error.message : String(error); showProcessError(message || "Could not connect to the SoftChart service."); setConnection("error", "Connection lost"); } finally { if (runId === currentRun) currentJob = null; } } async function cancelGeneration() { if (!running) return; const job = currentJob; currentRun += 1; currentJob = null; if (job && typeof job.cancel === "function") { try { await job.cancel(); } catch { // The local job is still detached below even if it already started remotely. } } setBusy(false); setStatusChip("idle", "Cancelled"); setStage("loading", { title: "Generation cancelled", detail: "Adjust the settings whenever you are ready to try again." }); ui.stageEyebrow.textContent = "CANCELLED"; ui.queueLabel.textContent = "Stopped receiving generation results"; announce("Generation cancelled"); } function bindEvents() { ui.form.addEventListener("submit", generate); ui.fileInput.addEventListener("change", () => selectFile(ui.fileInput.files[0])); ui.dropzone.addEventListener("click", () => ui.fileInput.click()); ui.dropzone.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault(); ui.fileInput.click(); } }); ["dragenter", "dragover"].forEach((name) => { ui.dropzone.addEventListener(name, (event) => { event.preventDefault(); if (event.dataTransfer) event.dataTransfer.dropEffect = "copy"; ui.dropzone.classList.add("is-dragging"); }); }); ["dragleave", "dragend"].forEach((name) => { ui.dropzone.addEventListener(name, () => ui.dropzone.classList.remove("is-dragging")); }); ui.dropzone.addEventListener("drop", (event) => { event.preventDefault(); ui.dropzone.classList.remove("is-dragging"); selectFile(event.dataTransfer?.files?.[0]); }); ui.removeFile.addEventListener("click", clearFile); [ui.level, ui.temperature, ui.topP, ui.drumVolume].forEach((range) => { range.addEventListener("input", updateRanges); }); ui.sampling.addEventListener("change", () => setSamplingEnabled(ui.sampling.checked)); if (ui.model) ui.model.addEventListener("change", renderNotices); if (ui.usePlanner) ui.usePlanner.addEventListener("change", renderNotices); $$('input[name="course"]', ui.form).forEach((radio) => { radio.addEventListener("change", renderNotices); }); ui.cancelButton.addEventListener("click", cancelGeneration); ui.retryButton.addEventListener("click", () => ui.form.requestSubmit()); ui.newChartButton.addEventListener("click", () => { ui.resultPanel.hidden = true; clearFile(); resetProcess(); ui.form.scrollIntoView({ behavior: "smooth", block: "start" }); }); ui.chartTab.addEventListener("click", () => activatePreview("chart")); ui.planTab.addEventListener("click", () => activatePreview("plan")); $$(".preview-tab").forEach((tab) => { tab.addEventListener("keydown", (event) => { if (!["ArrowLeft", "ArrowRight"].includes(event.key)) return; event.preventDefault(); const next = tab === ui.chartTab ? "plan" : "chart"; const target = next === "chart" ? ui.chartTab : ui.planTab; if (!target.hidden) activatePreview(next, true); }); }); window.addEventListener("beforeunload", () => { if (sourceObjectUrl) URL.revokeObjectURL(sourceObjectUrl); }); } bindEvents(); updateRanges(); setSamplingEnabled(ui.sampling.checked); resetProcess(); loadCapabilities().catch(() => { // The seven published models are in the markup already; only the // experimental entry and the limitation text depend on this call. }); connect().catch(() => { ui.queueLabel.textContent = "We will reconnect when you start generation"; });