Spaces:
Sleeping
Sleeping
BarScript: serve the fitted (non-uniform) bar timeline by default; report grid source, spread and fit quality in the UI
05d2106 verified | 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"; | |
| }); | |