Spaces:
Running
Running
| let hiddenSize = 1024; | |
| let numLayers = 2; | |
| const TARGET_QUEUE_EVENTS = 96; | |
| const MIN_QUEUE_EVENTS = 48; | |
| const LOOKAHEAD_MS = 120; | |
| const SCHEDULE_AHEAD_SECONDS = 4.0; | |
| const STEPS_PER_PUMP = 128; | |
| // DOM Selectors | |
| const appStatusEl = document.querySelector("#appStatus"); | |
| const statusDotEl = document.querySelector("#statusDot"); | |
| const statusTextEl = document.querySelector("#statusText"); | |
| const modelFileInput = document.querySelector("#modelFile"); | |
| const vocabFileInput = document.querySelector("#vocabFile"); | |
| const startBtn = document.querySelector("#startBtn"); | |
| const stopBtn = document.querySelector("#stopBtn"); | |
| const newSongBtn = document.querySelector("#newSongBtn"); | |
| const recordBtn = document.querySelector("#recordBtn"); | |
| const recordIcon = document.querySelector("#recordIcon"); | |
| const recordBtnText = document.querySelector("#recordBtnText"); | |
| const tempInput = document.querySelector("#temperature"); | |
| const tempValue = document.querySelector("#temperatureValue"); | |
| const topKInput = document.querySelector("#topK"); | |
| const topKValue = document.querySelector("#topKValue"); | |
| const volumeInput = document.querySelector("#masterVolume"); | |
| const volumeValue = document.querySelector("#volumeValue"); | |
| const reverbInput = document.querySelector("#reverbWet"); | |
| const reverbValue = document.querySelector("#reverbValue"); | |
| const delayInput = document.querySelector("#delayWet"); | |
| const delayValue = document.querySelector("#delayValue"); | |
| const releaseInput = document.querySelector("#synthRelease"); | |
| const releaseValue = document.querySelector("#releaseValue"); | |
| const lastTokenEl = document.querySelector("#lastToken"); | |
| const eventCountEl = document.querySelector("#eventCount"); | |
| const queueCountEl = document.querySelector("#queueCount"); | |
| const queueFillEl = document.querySelector("#queueFill"); | |
| const tempoEl = document.querySelector("#tempo"); | |
| const noteCanvas = document.querySelector("#noteCanvas"); | |
| const canvasCtx = noteCanvas.getContext("2d"); | |
| // Inference & Audio Variables | |
| let activeModelName = ""; | |
| let isModelReady = false; | |
| let running = false; | |
| let pumping = false; | |
| let schedulerId = 0; | |
| // Web Worker instance & routing | |
| const worker = new Worker("./worker.js"); | |
| let modelInitResolve = null; | |
| let modelInitReject = null; | |
| let startResolve = null; | |
| let startReject = null; | |
| worker.onmessage = function (e) { | |
| const data = e.data; | |
| switch (data.action) { | |
| case "initialized": | |
| isModelReady = true; | |
| if (modelInitResolve) modelInitResolve(); | |
| break; | |
| case "started": | |
| if (data.events) { | |
| data.events.forEach(event => pushEvent(event)); | |
| } | |
| if (data.lastToken) { | |
| updateReadouts(data.lastToken); | |
| } | |
| if (startResolve) startResolve(); | |
| break; | |
| case "events": | |
| if (data.events) { | |
| data.events.forEach(event => pushEvent(event)); | |
| } | |
| if (data.lastToken) { | |
| updateReadouts(data.lastToken); | |
| } | |
| pumping = false; | |
| break; | |
| case "tempo": | |
| if (tempoEl) tempoEl.textContent = String(Math.round(data.bpm)); | |
| break; | |
| case "error": | |
| console.error("Worker error:", data.message); | |
| setStatus(data.message, "idle"); | |
| stop(); | |
| if (modelInitReject) modelInitReject(new Error(data.message)); | |
| if (startReject) startReject(new Error(data.message)); | |
| break; | |
| } | |
| }; | |
| worker.onerror = function (err) { | |
| console.error("Web Worker uncaught error:", err); | |
| const errMsg = err.message || "Web Worker script runtime error"; | |
| setStatus(`Worker error: ${errMsg}`, "idle"); | |
| stop(); | |
| if (modelInitReject) modelInitReject(new Error(errMsg)); | |
| if (startReject) startReject(new Error(errMsg)); | |
| }; | |
| let nextNoteTime = 0; | |
| let generatedEvents = 0; | |
| let queue = []; | |
| let visualNotes = []; | |
| let synth; | |
| let reverb; | |
| let delay; | |
| let visualFrame = 0; | |
| let canvasWidth = 0; | |
| let canvasHeight = 0; | |
| let parser; | |
| let lastAudioTime = 0; | |
| let lastPerfTime = 0; | |
| const VISUAL_DELAY = 0.06; // 60ms audio latency compensation | |
| // MIDI Seeding Elements & Variables | |
| const seedSourceGroup = document.querySelector("#seedSourceGroup"); | |
| const toggleButtons = seedSourceGroup ? seedSourceGroup.querySelectorAll(".toggle-btn") : []; | |
| const midiSeedControls = document.querySelector("#midiSeedControls"); | |
| const midiFileInput = document.querySelector("#midiFileInput"); | |
| const midiFileInfo = document.querySelector("#midiFileInfo"); | |
| const midiFileNameEl = document.querySelector("#midiFileName"); | |
| const midiFileStatsEl = document.querySelector("#midiFileStats"); | |
| const midiStartBarInput = document.querySelector("#midiStartBar"); | |
| const midiStartBarValue = document.querySelector("#midiStartBarValue"); | |
| const midiStartBarTimeEl = document.querySelector("#midiStartBarTime"); | |
| const midiBpmModeGroup = document.querySelector("#midiBpmModeGroup"); | |
| const midiBpmModeButtons = midiBpmModeGroup ? midiBpmModeGroup.querySelectorAll(".toggle-btn") : []; | |
| let activePlayingStyle = "beethoven"; | |
| let midiBpmMode = "lock"; | |
| let midiTokens = []; | |
| let midiStartBar = 0; | |
| let midiFileName = ""; | |
| let midiBpm = 120; | |
| let midiTotalBars = 0; | |
| let isWarmingUp = false; | |
| let isRecording = false; | |
| let recordingStartTime = 0; | |
| let recordedNotes = []; | |
| // Binary MIDI Parser | |
| class MidiParser { | |
| constructor(arrayBuffer) { | |
| this.view = new DataView(arrayBuffer); | |
| this.pos = 0; | |
| } | |
| readUint8() { | |
| const v = this.view.getUint8(this.pos); | |
| this.pos += 1; | |
| return v; | |
| } | |
| readUint16() { | |
| const v = this.view.getUint16(this.pos); | |
| this.pos += 2; | |
| return v; | |
| } | |
| readUint32() { | |
| const v = this.view.getUint32(this.pos); | |
| this.pos += 4; | |
| return v; | |
| } | |
| readBytes(len) { | |
| const buf = new Uint8Array(this.view.buffer, this.pos + this.view.byteOffset, len); | |
| this.pos += len; | |
| return buf; | |
| } | |
| readVarInt() { | |
| let value = 0; | |
| while (true) { | |
| const b = this.readUint8(); | |
| value = (value << 7) | (b & 0x7F); | |
| if (!(b & 0x80)) break; | |
| } | |
| return value; | |
| } | |
| parse() { | |
| const mthd = String.fromCharCode(...this.readBytes(4)); | |
| if (mthd !== "MThd") throw new Error("Not a valid MIDI file (missing MThd)"); | |
| const headerLength = this.readUint32(); | |
| const format = this.readUint16(); | |
| const numTracks = this.readUint16(); | |
| const division = this.readUint16(); | |
| if (headerLength > 6) { | |
| this.readBytes(headerLength - 6); | |
| } | |
| let bpm = 120; | |
| const notes = []; | |
| const activeNotes = new Map(); | |
| for (let t = 0; t < numTracks; t++) { | |
| const mtrk = String.fromCharCode(...this.readBytes(4)); | |
| if (mtrk !== "MTrk") { | |
| const len = this.readUint32(); | |
| this.readBytes(len); | |
| continue; | |
| } | |
| const trackLength = this.readUint32(); | |
| const endPos = this.pos + trackLength; | |
| let tick = 0; | |
| let runningStatus = 0; | |
| while (this.pos < endPos) { | |
| const deltaTime = this.readVarInt(); | |
| tick += deltaTime; | |
| let status = this.readUint8(); | |
| if (status < 0x80) { | |
| this.pos -= 1; | |
| status = runningStatus; | |
| } else { | |
| runningStatus = status; | |
| } | |
| const eventType = status & 0xF0; | |
| const channel = status & 0x0F; | |
| if (eventType === 0x90) { | |
| const pitch = this.readUint8(); | |
| const velocity = this.readUint8(); | |
| const key = `${pitch}_${channel}`; | |
| if (velocity > 0) { | |
| if (!activeNotes.has(key)) { | |
| activeNotes.set(key, { startTick: tick, velocity }); | |
| } | |
| } else { | |
| const active = activeNotes.get(key); | |
| if (active) { | |
| notes.push({ | |
| pitch, | |
| startTick: active.startTick, | |
| endTick: tick, | |
| velocity: active.velocity | |
| }); | |
| activeNotes.delete(key); | |
| } | |
| } | |
| } else if (eventType === 0x80) { | |
| const pitch = this.readUint8(); | |
| const velocity = this.readUint8(); | |
| const key = `${pitch}_${channel}`; | |
| const active = activeNotes.get(key); | |
| if (active) { | |
| notes.push({ | |
| pitch, | |
| startTick: active.startTick, | |
| endTick: tick, | |
| velocity: active.velocity | |
| }); | |
| activeNotes.delete(key); | |
| } | |
| } else if (status === 0xFF) { | |
| const metaType = this.readUint8(); | |
| const len = this.readVarInt(); | |
| const data = this.readBytes(len); | |
| if (metaType === 0x51 && len === 3) { | |
| const tempo = (data[0] << 16) | (data[1] << 8) | data[2]; | |
| bpm = Math.round(60000000 / tempo); | |
| } | |
| } else if (eventType === 0xA0 || eventType === 0xB0 || eventType === 0xE0) { | |
| this.readBytes(2); | |
| } else if (eventType === 0xC0 || eventType === 0xD0) { | |
| this.readBytes(1); | |
| } else if (status === 0xF0 || status === 0xF7) { | |
| const len = this.readVarInt(); | |
| this.readBytes(len); | |
| } | |
| } | |
| } | |
| for (const [key, active] of activeNotes.entries()) { | |
| const [pitch, channel] = key.split("_").map(Number); | |
| notes.push({ | |
| pitch, | |
| startTick: active.startTick, | |
| endTick: active.startTick + division, | |
| velocity: active.velocity | |
| }); | |
| } | |
| return { bpm, division, notes }; | |
| } | |
| } | |
| // Tokenize MIDI to Model-Compatible Event Tokens | |
| function tokenizeMidi(parsedMidi, grid = 64) { | |
| const { bpm, division, notes } = parsedMidi; | |
| const stepsPerQuarter = grid / 4; | |
| const quantizedNotes = notes.map(note => { | |
| const start_step = Math.round((note.startTick / division) * stepsPerQuarter); | |
| const end_step = Math.round((note.endTick / division) * stepsPerQuarter); | |
| const duration_steps = Math.max(1, end_step - start_step); | |
| const velocity_bucket = Math.max(1, Math.min(8, Math.ceil(note.velocity / 16))); | |
| return { | |
| pitch: note.pitch, | |
| start_step, | |
| duration_steps, | |
| velocity_bucket | |
| }; | |
| }); | |
| quantizedNotes.sort((a, b) => { | |
| if (a.start_step !== b.start_step) return a.start_step - b.start_step; | |
| return a.pitch - b.pitch; | |
| }); | |
| const tokens = ["BOS", `BPM_${Math.round(bpm)}`, `GRID_${grid}`]; | |
| let currentBar = -1; | |
| const notesByStep = new Map(); | |
| for (const note of quantizedNotes) { | |
| if (!notesByStep.has(note.start_step)) { | |
| notesByStep.set(note.start_step, []); | |
| } | |
| notesByStep.get(note.start_step).push(note); | |
| } | |
| const steps = Array.from(notesByStep.keys()).sort((a, b) => a - b); | |
| for (const step of steps) { | |
| const note_bar = Math.floor(step / grid); | |
| const note_pos = step % grid; | |
| while (currentBar < note_bar) { | |
| tokens.push("BAR"); | |
| currentBar += 1; | |
| } | |
| tokens.push(`POS_${note_pos}`); | |
| const stepNotes = notesByStep.get(step); | |
| for (const note of stepNotes) { | |
| tokens.push(`NOTE_${note.pitch}`); | |
| tokens.push(`DUR_${Math.min(256, note.duration_steps)}`); | |
| tokens.push(`VEL_${note.velocity_bucket}`); | |
| } | |
| } | |
| return { tokens, maxBar: currentBar }; | |
| } | |
| // Slice prompt tokens up to target bar (exclusive start of target bar) | |
| function getTokensUpToBar(tokens, targetBar) { | |
| let barCount = 0; | |
| const sliced = []; | |
| for (const t of tokens) { | |
| if (t === "BAR") { | |
| barCount += 1; | |
| if (barCount > targetBar) { | |
| break; | |
| } | |
| } | |
| sliced.push(t); | |
| } | |
| return sliced; | |
| } | |
| // Find the first bar that has at least 256 tokens preceding it | |
| function findMinStartBar(tokens) { | |
| let barCount = 0; | |
| let tokenCount = 0; | |
| for (let i = 0; i < tokens.length; i++) { | |
| const t = tokens[i]; | |
| if (t === "BAR") { | |
| if (tokenCount >= 256) { | |
| return barCount; | |
| } | |
| barCount += 1; | |
| } | |
| tokenCount += 1; | |
| } | |
| return 0; | |
| } | |
| // Analyse tokens to get per-bar metadata (token counts, note densities) | |
| function analyzeMidiTokens(tokens) { | |
| const barsData = []; | |
| let currentBar = -1; | |
| let barTokensCount = 0; | |
| let barNotesCount = 0; | |
| for (const t of tokens) { | |
| if (t === "BAR") { | |
| if (currentBar >= 0) { | |
| barsData.push({ | |
| barNum: currentBar, | |
| tokenCount: barTokensCount, | |
| noteCount: barNotesCount | |
| }); | |
| } | |
| currentBar += 1; | |
| barTokensCount = 1; | |
| barNotesCount = 0; | |
| } else { | |
| barTokensCount += 1; | |
| if (t.startsWith("NOTE_")) { | |
| barNotesCount += 1; | |
| } | |
| } | |
| } | |
| if (currentBar >= 0) { | |
| barsData.push({ | |
| barNum: currentBar, | |
| tokenCount: barTokensCount, | |
| noteCount: barNotesCount | |
| }); | |
| } | |
| return barsData; | |
| } | |
| // Calculate which bars fall in the 256-token context window | |
| function getWarmUpInfo(tokens, targetBar) { | |
| let barCount = 0; | |
| const tokensUpToBar = []; | |
| for (const t of tokens) { | |
| if (t === "BAR") { | |
| if (barCount === targetBar) { | |
| break; | |
| } | |
| barCount += 1; | |
| } | |
| tokensUpToBar.push(t); | |
| } | |
| const totalTokens = tokensUpToBar.length; | |
| const warmUpCount = Math.min(256, totalTokens); | |
| const startIndex = totalTokens - warmUpCount; | |
| let currentBarOfStart = -1; | |
| for (let i = 0; i < startIndex; i++) { | |
| if (tokens[i] === "BAR") { | |
| currentBarOfStart += 1; | |
| } | |
| } | |
| return { | |
| startBar: Math.max(0, currentBarOfStart), | |
| endBar: targetBar - 1, | |
| tokenCount: warmUpCount, | |
| totalTokens: totalTokens | |
| }; | |
| } | |
| // Render the visual timeline bars | |
| function renderMidiTimeline(barsData) { | |
| const midiTimeline = document.querySelector("#midiTimeline"); | |
| if (!midiTimeline) return; | |
| midiTimeline.innerHTML = ""; | |
| if (!barsData || barsData.length === 0) { | |
| midiTimeline.style.display = "none"; | |
| return; | |
| } | |
| midiTimeline.style.display = "flex"; | |
| const maxNotes = Math.max(...barsData.map(b => b.noteCount), 1); | |
| const minBar = findMinStartBar(midiTokens); | |
| barsData.forEach((bar, index) => { | |
| const col = document.createElement("div"); | |
| col.className = "timeline-bar-col"; | |
| col.dataset.barNum = String(index); | |
| col.dataset.barLabel = `${index + 1}`; | |
| // Label every Nth bar to prevent crowding | |
| const totalBars = barsData.length; | |
| const labelInterval = totalBars > 64 ? 16 : (totalBars > 32 ? 8 : 4); | |
| if (index % labelInterval === 0) { | |
| col.classList.add("labeled"); | |
| } | |
| const densityVal = (bar.noteCount / maxNotes) * 100; | |
| const fill = document.createElement("div"); | |
| fill.className = "density-fill"; | |
| fill.style.height = `${Math.max(10, densityVal)}%`; | |
| col.appendChild(fill); | |
| if (index < minBar) { | |
| col.classList.add("disabled"); | |
| } else { | |
| col.addEventListener("click", () => { | |
| midiStartBarInput.value = String(index); | |
| midiStartBar = index; | |
| midiStartBarValue.textContent = String(index); | |
| updateMidiTimelineHighlights(); | |
| updateMidiStartBarText(); | |
| }); | |
| } | |
| midiTimeline.appendChild(col); | |
| }); | |
| updateMidiTimelineHighlights(); | |
| } | |
| // Refresh visual highlighting on the timeline | |
| function updateMidiTimelineHighlights() { | |
| const midiTimeline = document.querySelector("#midiTimeline"); | |
| if (!midiTimeline || !midiTokens || midiTokens.length === 0) return; | |
| const cols = midiTimeline.querySelectorAll(".timeline-bar-col"); | |
| const info = getWarmUpInfo(midiTokens, midiStartBar); | |
| cols.forEach((col, index) => { | |
| col.classList.remove("in-warmup", "is-selected"); | |
| if (index === midiStartBar) { | |
| col.classList.add("is-selected"); | |
| // Scroll active bar into view if timeline overflows | |
| if (typeof col.scrollIntoViewIfNeeded === "function") { | |
| col.scrollIntoViewIfNeeded({ behavior: "smooth", block: "nearest" }); | |
| } else { | |
| col.scrollIntoView({ behavior: "smooth", block: "nearest", inline: "nearest" }); | |
| } | |
| } else if (midiStartBar > 0 && index >= info.startBar && index <= info.endBar) { | |
| col.classList.add("in-warmup"); | |
| } | |
| }); | |
| } | |
| // Update text detail about context window priming | |
| function updateMidiStartBarText() { | |
| if (!midiStartBarTimeEl) return; | |
| if (midiStartBar === 0) { | |
| midiStartBarTimeEl.textContent = "Will prime model with bars 0 to -1 (empty prompt)"; | |
| } else { | |
| const info = getWarmUpInfo(midiTokens, midiStartBar); | |
| if (info.startBar === 0) { | |
| midiStartBarTimeEl.textContent = `Bars 0 to ${info.endBar} (${info.tokenCount} tokens) will prime the model context window.`; | |
| } else { | |
| midiStartBarTimeEl.textContent = `Bars ${info.startBar} to ${info.endBar} (${info.tokenCount} tokens) will prime the model context window (older bars truncated).`; | |
| } | |
| } | |
| } | |
| const pianoSamples = { | |
| A0: "A0.mp3", | |
| C1: "C1.mp3", | |
| "D#1": "Ds1.mp3", | |
| "F#1": "Fs1.mp3", | |
| A1: "A1.mp3", | |
| C2: "C2.mp3", | |
| "D#2": "Ds2.mp3", | |
| "F#2": "Fs2.mp3", | |
| A2: "A2.mp3", | |
| C3: "C3.mp3", | |
| "D#3": "Ds3.mp3", | |
| "F#3": "Fs3.mp3", | |
| A3: "A3.mp3", | |
| C4: "C4.mp3", | |
| "D#4": "Ds4.mp3", | |
| "F#4": "Fs4.mp3", | |
| A4: "A4.mp3", | |
| C5: "C5.mp3", | |
| "D#5": "Ds5.mp3", | |
| "F#5": "Fs5.mp3", | |
| A5: "A5.mp3", | |
| C6: "C6.mp3", | |
| "D#6": "Ds6.mp3", | |
| "F#6": "Fs6.mp3", | |
| A6: "A6.mp3", | |
| C7: "C7.mp3", | |
| "D#7": "Ds7.mp3", | |
| "F#7": "Fs7.mp3", | |
| A7: "A7.mp3", | |
| C8: "C8.mp3", | |
| }; | |
| let currentInstrument = "piano"; | |
| const instrumentSamples = { | |
| xylophone: { | |
| 'C8': 'C8.mp3', | |
| 'G4': 'G4.mp3', | |
| 'G5': 'G5.mp3', | |
| 'G6': 'G6.mp3', | |
| 'G7': 'G7.mp3', | |
| 'C5': 'C5.mp3', | |
| 'C6': 'C6.mp3', | |
| 'C7': 'C7.mp3' | |
| }, | |
| violin: { | |
| 'A3': 'A3.mp3', | |
| 'A4': 'A4.mp3', | |
| 'A5': 'A5.mp3', | |
| 'A6': 'A6.mp3', | |
| 'C4': 'C4.mp3', | |
| 'C5': 'C5.mp3', | |
| 'C6': 'C6.mp3', | |
| 'C7': 'C7.mp3', | |
| 'E4': 'E4.mp3', | |
| 'E5': 'E5.mp3', | |
| 'E6': 'E6.mp3', | |
| 'G4': 'G4.mp3', | |
| 'G5': 'G5.mp3', | |
| 'G6': 'G6.mp3' | |
| }, | |
| trumpet: { | |
| 'C6': 'C6.mp3', | |
| 'D5': 'D5.mp3', | |
| 'D#4': 'Ds4.mp3', | |
| 'F3': 'F3.mp3', | |
| 'F4': 'F4.mp3', | |
| 'F5': 'F5.mp3', | |
| 'G4': 'G4.mp3', | |
| 'A3': 'A3.mp3', | |
| 'A5': 'A5.mp3', | |
| 'A#4': 'As4.mp3', | |
| 'C4': 'C4.mp3' | |
| }, | |
| saxophone: { | |
| 'D#5': 'Ds5.mp3', | |
| 'E3': 'E3.mp3', | |
| 'E4': 'E4.mp3', | |
| 'E5': 'E5.mp3', | |
| 'F3': 'F3.mp3', | |
| 'F4': 'F4.mp3', | |
| 'F5': 'F5.mp3', | |
| 'F#3': 'Fs3.mp3', | |
| 'F#4': 'Fs4.mp3', | |
| 'F#5': 'Fs5.mp3', | |
| 'G3': 'G3.mp3', | |
| 'G4': 'G4.mp3', | |
| 'G5': 'G5.mp3', | |
| 'G#3': 'Gs3.mp3', | |
| 'G#4': 'Gs4.mp3', | |
| 'G#5': 'Gs5.mp3', | |
| 'A4': 'A4.mp3', | |
| 'A5': 'A5.mp3', | |
| 'A#3': 'As3.mp3', | |
| 'A#4': 'As4.mp3', | |
| 'B3': 'B3.mp3', | |
| 'B4': 'B4.mp3', | |
| 'C4': 'C4.mp3', | |
| 'C5': 'C5.mp3', | |
| 'C#3': 'Cs3.mp3', | |
| 'C#4': 'Cs4.mp3', | |
| 'C#5': 'Cs5.mp3', | |
| 'D3': 'D3.mp3', | |
| 'D4': 'D4.mp3', | |
| 'D5': 'D5.mp3', | |
| 'D#3': 'Ds3.mp3', | |
| 'D#4': 'Ds4.mp3' | |
| }, | |
| organ: { | |
| 'C3': 'C3.mp3', | |
| 'C4': 'C4.mp3', | |
| 'C5': 'C5.mp3', | |
| 'C6': 'C6.mp3', | |
| 'D#1': 'Ds1.mp3', | |
| 'D#2': 'Ds2.mp3', | |
| 'D#3': 'Ds3.mp3', | |
| 'D#4': 'Ds4.mp3', | |
| 'D#5': 'Ds5.mp3', | |
| 'F#1': 'Fs1.mp3', | |
| 'F#2': 'Fs2.mp3', | |
| 'F#3': 'Fs3.mp3', | |
| 'F#4': 'Fs4.mp3', | |
| 'F#5': 'Fs5.mp3', | |
| 'A1': 'A1.mp3', | |
| 'A2': 'A2.mp3', | |
| 'A3': 'A3.mp3', | |
| 'A4': 'A4.mp3', | |
| 'A5': 'A5.mp3', | |
| 'C1': 'C1.mp3', | |
| 'C2': 'C2.mp3' | |
| }, | |
| harp: { | |
| 'C5': 'C5.mp3', | |
| 'D2': 'D2.mp3', | |
| 'D4': 'D4.mp3', | |
| 'D6': 'D6.mp3', | |
| 'D7': 'D7.mp3', | |
| 'E1': 'E1.mp3', | |
| 'E3': 'E3.mp3', | |
| 'E5': 'E5.mp3', | |
| 'F2': 'F2.mp3', | |
| 'F4': 'F4.mp3', | |
| 'F6': 'F6.mp3', | |
| 'F7': 'F7.mp3', | |
| 'G1': 'G1.mp3', | |
| 'G3': 'G3.mp3', | |
| 'G5': 'G5.mp3', | |
| 'A2': 'A2.mp3', | |
| 'A4': 'A4.mp3', | |
| 'A6': 'A6.mp3', | |
| 'B1': 'B1.mp3', | |
| 'B3': 'B3.mp3', | |
| 'B5': 'B5.mp3', | |
| 'B6': 'B6.mp3', | |
| 'C3': 'C3.mp3' | |
| }, | |
| 'guitar-acoustic': { | |
| 'F4': 'F4.mp3', | |
| 'F#2': 'Fs2.mp3', | |
| 'F#3': 'Fs3.mp3', | |
| 'F#4': 'Fs4.mp3', | |
| 'G2': 'G2.mp3', | |
| 'G3': 'G3.mp3', | |
| 'G4': 'G4.mp3', | |
| 'G#2': 'Gs2.mp3', | |
| 'G#3': 'Gs3.mp3', | |
| 'G#4': 'Gs4.mp3', | |
| 'A2': 'A2.mp3', | |
| 'A3': 'A3.mp3', | |
| 'A4': 'A4.mp3', | |
| 'A#2': 'As2.mp3', | |
| 'A#3': 'As3.mp3', | |
| 'A#4': 'As4.mp3', | |
| 'B2': 'B2.mp3', | |
| 'B3': 'B3.mp3', | |
| 'B4': 'B4.mp3', | |
| 'C3': 'C3.mp3', | |
| 'C4': 'C4.mp3', | |
| 'C5': 'C5.mp3', | |
| 'C#3': 'Cs3.mp3', | |
| 'C#4': 'Cs4.mp3', | |
| 'C#5': 'Cs5.mp3', | |
| 'D2': 'D2.mp3', | |
| 'D3': 'D3.mp3', | |
| 'D4': 'D4.mp3', | |
| 'D5': 'D5.mp3', | |
| 'D#2': 'Ds2.mp3', | |
| 'D#3': 'Ds3.mp3', | |
| 'D#4': 'Ds3.mp3', | |
| 'E2': 'E2.mp3', | |
| 'E3': 'E3.mp3', | |
| 'E4': 'E4.mp3', | |
| 'F2': 'F2.mp3', | |
| 'F3': 'F3.mp3' | |
| }, | |
| 'guitar-electric': { | |
| 'D#3': 'Ds3.mp3', | |
| 'D#4': 'Ds4.mp3', | |
| 'D#5': 'Ds5.mp3', | |
| 'E2': 'E2.mp3', | |
| 'F#2': 'Fs2.mp3', | |
| 'F#3': 'Fs3.mp3', | |
| 'F#4': 'Fs4.mp3', | |
| 'F#5': 'Fs5.mp3', | |
| 'A2': 'A2.mp3', | |
| 'A3': 'A3.mp3', | |
| 'A4': 'A4.mp3', | |
| 'A5': 'A5.mp3', | |
| 'C3': 'C3.mp3', | |
| 'C4': 'C4.mp3', | |
| 'C5': 'C5.mp3', | |
| 'C6': 'C6.mp3', | |
| 'C#2': 'Cs2.mp3' | |
| }, | |
| 'bass-electric': { | |
| 'A#1': 'As1.mp3', | |
| 'A#2': 'As2.mp3', | |
| 'A#3': 'As3.mp3', | |
| 'A#4': 'As4.mp3', | |
| 'C#1': 'Cs1.mp3', | |
| 'C#2': 'Cs2.mp3', | |
| 'C#3': 'Cs3.mp3', | |
| 'C#4': 'Cs4.mp3', | |
| 'E1': 'E1.mp3', | |
| 'E2': 'E2.mp3', | |
| 'E3': 'E3.mp3', | |
| 'E4': 'E4.mp3', | |
| 'G1': 'G1.mp3', | |
| 'G2': 'G2.mp3', | |
| 'G3': 'G3.mp3', | |
| 'G4': 'G4.mp3' | |
| } | |
| }; | |
| // Status Handler | |
| function setStatus(message, state = "idle") { | |
| if (appStatusEl) appStatusEl.textContent = message; | |
| if (statusTextEl) statusTextEl.textContent = state.toUpperCase(); | |
| if (statusDotEl) { | |
| statusDotEl.className = "status-dot"; | |
| if (state === "playing") { | |
| statusDotEl.classList.add("playing"); | |
| } else if (state === "buffering" || state === "loading") { | |
| statusDotEl.classList.add("buffering"); | |
| } | |
| } | |
| } | |
| // UI readouts update | |
| function updateReadouts(lastToken = null) { | |
| if (lastToken !== null && lastTokenEl) lastTokenEl.textContent = lastToken; | |
| if (eventCountEl) eventCountEl.textContent = String(generatedEvents); | |
| if (queueCountEl) queueCountEl.textContent = String(queue.length); | |
| if (queueFillEl) { | |
| queueFillEl.style.width = `${Math.min(100, (queue.length / TARGET_QUEUE_EVENTS) * 100)}%`; | |
| } | |
| if (tempValue) tempValue.textContent = Number(tempInput.value).toFixed(2); | |
| if (topKValue) topKValue.textContent = topKInput.value; | |
| } | |
| function midiFromPitchName(note) { | |
| const match = String(note).match(/^([A-G])([#b]{0,2})(-?\d+)$/); | |
| if (!match) return null; | |
| const semitone = { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 }[match[1]]; | |
| const accidental = match[2].split("").reduce((sum, char) => sum + (char === "#" ? 1 : -1), 0); | |
| return (Number(match[3]) + 1) * 12 + semitone + accidental; | |
| } | |
| function recordVisualEvent(event, startTime) { | |
| if (event.type !== "note") return; | |
| event.notes.forEach((note, index) => { | |
| const midi = midiFromPitchName(note); | |
| if (midi === null) return; | |
| const duration = event.perNoteDurations | |
| ? event.perNoteDurations[index] | |
| : event.duration; | |
| visualNotes.push({ | |
| midi, | |
| note, | |
| start: startTime, | |
| duration: Math.max(0.08, duration) | |
| }); | |
| }); | |
| const now = Tone.now(); | |
| visualNotes = visualNotes.filter((item) => item.start + item.duration > now - 1.5); | |
| } | |
| function pushEvent(event) { | |
| if (event.duration < 0 || queue.length > TARGET_QUEUE_EVENTS * 3) return; | |
| queue.push(event); | |
| generatedEvents += event.type === "note" ? 1 : 0; | |
| updateReadouts(); | |
| } | |
| function pumpTokens() { | |
| if (!running || pumping || queue.length >= TARGET_QUEUE_EVENTS) return; | |
| pumping = true; | |
| worker.postMessage({ | |
| action: "pump", | |
| temperature: Math.max(0.05, Number(tempInput.value)), | |
| topK: Math.max(1, Number(topKInput.value)) | |
| }); | |
| } | |
| function scheduleAudio() { | |
| if (!running) return; | |
| const now = Tone.now(); | |
| if (nextNoteTime < now + 0.08) nextNoteTime = now + 0.08; | |
| while (queue.length && nextNoteTime < now + SCHEDULE_AHEAD_SECONDS) { | |
| const event = queue.shift(); | |
| if (event.type === "note") { | |
| if (event.perNoteDurations) { | |
| event.notes.forEach((note, index) => { | |
| synth.triggerAttackRelease(note, Math.min(3.8, event.perNoteDurations[index]), nextNoteTime, event.velocities?.[index] ?? 0.72); | |
| }); | |
| } else { | |
| synth.triggerAttackRelease(event.notes, Math.min(3.2, event.duration * 0.92), nextNoteTime, 0.72); | |
| } | |
| if (isRecording) { | |
| const eventTime = nextNoteTime; | |
| if (event.perNoteDurations) { | |
| event.notes.forEach((note, index) => { | |
| const midi = midiFromPitchName(note); | |
| if (midi !== null) { | |
| recordedNotes.push({ | |
| midi, | |
| velocity: event.velocities?.[index] ?? 0.72, | |
| time: eventTime - recordingStartTime, | |
| duration: event.perNoteDurations[index] | |
| }); | |
| } | |
| }); | |
| } else { | |
| event.notes.forEach((note) => { | |
| const midi = midiFromPitchName(note); | |
| if (midi !== null) { | |
| recordedNotes.push({ | |
| midi, | |
| velocity: 0.72, | |
| time: eventTime - recordingStartTime, | |
| duration: event.duration | |
| }); | |
| } | |
| }); | |
| } | |
| } | |
| recordVisualEvent({ ...event, duration: event.duration || Math.max(...(event.perNoteDurations ?? [0.2])) }, nextNoteTime); | |
| } | |
| nextNoteTime += event.advance ?? event.duration; | |
| } | |
| if (queue.length < MIN_QUEUE_EVENTS) void pumpTokens(); | |
| updateReadouts(); | |
| schedulerId = window.setTimeout(scheduleAudio, LOOKAHEAD_MS); | |
| } | |
| // Audio Node Setters | |
| function setVolume(pct) { | |
| if (!Tone) return; | |
| if (pct === 0) { | |
| Tone.getDestination().volume.value = -999; | |
| } else { | |
| // Map 0-100 percentage to decibels | |
| Tone.getDestination().volume.value = 20 * Math.log10(pct / 100); | |
| } | |
| if (volumeValue) volumeValue.textContent = `${pct}%`; | |
| } | |
| function setRelease(val) { | |
| if (synth) { | |
| synth.release = Number(val); | |
| } | |
| if (releaseValue) releaseValue.textContent = `${Number(val).toFixed(1)}s`; | |
| } | |
| async function createSampler(instrumentName) { | |
| if (synth) { | |
| try { | |
| synth.dispose(); | |
| } catch (e) {} | |
| synth = null; | |
| } | |
| const initialRel = Number(releaseInput.value); | |
| let urls, baseUrl; | |
| if (instrumentName === "piano") { | |
| urls = pianoSamples; | |
| baseUrl = "https://tonejs.github.io/audio/salamander/"; | |
| } else { | |
| urls = instrumentSamples[instrumentName]; | |
| baseUrl = `https://nbrosowsky.github.io/tonejs-instruments/samples/${instrumentName}/`; | |
| } | |
| const originalStatus = appStatusEl ? appStatusEl.textContent : ""; | |
| const originalState = statusTextEl ? statusTextEl.textContent : "IDLE"; | |
| setStatus(`Loading ${instrumentName} audio samples...`, "loading"); | |
| synth = new Tone.Sampler({ | |
| urls: urls, | |
| baseUrl: baseUrl, | |
| attack: 0, | |
| release: initialRel, | |
| curve: "exponential", | |
| }).connect(delay); | |
| await Tone.loaded(); | |
| setStatus(originalStatus.includes("Loading") ? "Ready to perform" : originalStatus, originalState.toLowerCase()); | |
| } | |
| async function ensureInstrument() { | |
| if (synth) return; | |
| const initialVol = Number(volumeInput.value); | |
| const initialRev = Number(reverbInput.value); | |
| const initialDel = Number(delayInput.value); | |
| const initialRel = Number(releaseInput.value); | |
| reverb = new Tone.Reverb({ decay: 2.8, wet: initialRev / 100 }).toDestination(); | |
| delay = new Tone.FeedbackDelay({ delayTime: "8n", feedback: 0.18, wet: initialDel / 100 }).connect(reverb); | |
| await createSampler(currentInstrument); | |
| setVolume(initialVol); | |
| // Set UI readouts | |
| if (reverbValue) reverbValue.textContent = `${initialRev}%`; | |
| if (delayValue) delayValue.textContent = `${initialDel}%`; | |
| if (releaseValue) releaseValue.textContent = `${initialRel.toFixed(1)}s`; | |
| } | |
| async function start() { | |
| if (running || !isModelReady) return; | |
| startBtn.disabled = true; | |
| setStatus("Starting audio...", "loading"); | |
| await Tone.start(); | |
| await ensureInstrument(); | |
| queue = []; | |
| generatedEvents = 0; | |
| visualNotes = []; | |
| if (tempoEl) tempoEl.textContent = (activePlayingStyle === "midi") ? String(Math.round(midiBpm)) : "120"; | |
| setStatus("Warming AI model prompt...", "buffering"); | |
| await new Promise((resolve, reject) => { | |
| startResolve = resolve; | |
| startReject = reject; | |
| worker.postMessage({ | |
| action: "start", | |
| temperature: Math.max(0.05, Number(tempInput.value)), | |
| topK: Math.max(1, Number(topKInput.value)), | |
| bpm: Number(tempoEl.textContent || 120), | |
| playingStyle: activePlayingStyle, | |
| midiTokens: (activePlayingStyle === "midi") ? midiTokens : [], | |
| midiStartBar: midiStartBar, | |
| midiBpmMode: midiBpmMode | |
| }); | |
| }); | |
| running = true; | |
| stopBtn.disabled = false; | |
| if (newSongBtn) newSongBtn.disabled = false; | |
| if (recordBtn) recordBtn.disabled = false; | |
| if (modelFileInput) modelFileInput.disabled = true; | |
| if (vocabFileInput) vocabFileInput.disabled = true; | |
| toggleButtons.forEach(btn => btn.disabled = true); | |
| midiBpmModeButtons.forEach(btn => btn.disabled = true); | |
| midiFileInput.disabled = true; | |
| midiStartBarInput.disabled = true; | |
| setStatus(`Playing ${activeModelName}`, "playing"); | |
| nextNoteTime = Tone.now() + 0.12; | |
| startVisualizer(); | |
| scheduleAudio(); | |
| } | |
| function stop() { | |
| if (isRecording) { | |
| stopRecording(true); | |
| } | |
| running = false; | |
| window.clearTimeout(schedulerId); | |
| schedulerId = 0; | |
| worker.postMessage({ action: "stop" }); | |
| startBtn.disabled = !isModelReady; | |
| stopBtn.disabled = true; | |
| if (newSongBtn) newSongBtn.disabled = true; | |
| if (recordBtn) recordBtn.disabled = true; | |
| if (modelFileInput) modelFileInput.disabled = false; | |
| if (vocabFileInput) vocabFileInput.disabled = false; | |
| toggleButtons.forEach(btn => btn.disabled = false); | |
| midiBpmModeButtons.forEach(btn => btn.disabled = false); | |
| midiFileInput.disabled = false; | |
| midiStartBarInput.disabled = false; | |
| if (synth) { | |
| try { synth.dispose(); } catch (e) { console.error(e); } | |
| synth = null; | |
| } | |
| if (reverb) { | |
| try { reverb.dispose(); } catch (e) { console.error(e); } | |
| reverb = null; | |
| } | |
| if (delay) { | |
| try { delay.dispose(); } catch (e) { console.error(e); } | |
| delay = null; | |
| } | |
| if (visualFrame) cancelAnimationFrame(visualFrame); | |
| visualFrame = 0; | |
| setStatus(isModelReady ? `Ready: ${activeModelName}` : "Stopped", "idle"); | |
| } | |
| // MIDI Recording functions | |
| function startRecording() { | |
| if (isRecording || !running) return; | |
| isRecording = true; | |
| recordedNotes = []; | |
| recordingStartTime = Tone.now(); | |
| if (recordBtn) { | |
| recordBtn.classList.add("btn-recording"); | |
| if (recordIcon) recordIcon.classList.add("pulse-record"); | |
| if (recordIcon) { | |
| recordIcon.innerHTML = `<rect x="6" y="6" width="12" height="12" fill="#d9534f"/>`; | |
| } | |
| if (recordBtnText) recordBtnText.textContent = "Stop & Download"; | |
| } | |
| } | |
| function stopRecording(download = true) { | |
| if (!isRecording) return; | |
| isRecording = false; | |
| if (recordBtn) { | |
| recordBtn.classList.remove("btn-recording"); | |
| if (recordIcon) recordIcon.classList.remove("pulse-record"); | |
| if (recordIcon) { | |
| recordIcon.innerHTML = `<circle cx="12" cy="12" r="8" fill="#d9534f"/>`; | |
| } | |
| if (recordBtnText) recordBtnText.textContent = "Record"; | |
| } | |
| if (download && recordedNotes.length > 0) { | |
| const bpm = Number(tempoEl?.textContent || 120); | |
| const fileBytes = compileMidiFile(recordedNotes, bpm); | |
| downloadMidi(fileBytes); | |
| } | |
| recordedNotes = []; | |
| } | |
| function encodeVLQ(value) { | |
| const bytes = []; | |
| let buffer = value & 0x7F; | |
| value = value >> 7; | |
| while (value > 0) { | |
| bytes.push((value & 0x7F) | 0x80); | |
| value = value >> 7; | |
| } | |
| bytes.reverse(); | |
| bytes.push(buffer); | |
| return bytes; | |
| } | |
| function compileMidiFile(recordedNotes, bpm) { | |
| const division = 480; // Standard ticks per quarter note | |
| const trackBytes = []; | |
| // 1. Add tempo meta event (microsec per quarter note) at tick 0 | |
| const tempoMicro = Math.round(60000000 / bpm); | |
| trackBytes.push( | |
| 0, // delta-time = 0 | |
| 0xFF, 0x51, 0x03, // Meta Tempo, length = 3 | |
| (tempoMicro >> 16) & 0xFF, | |
| (tempoMicro >> 8) & 0xFF, | |
| tempoMicro & 0xFF | |
| ); | |
| // 2. Add note events to a flat list in absolute ticks | |
| const midiEvents = []; | |
| for (const note of recordedNotes) { | |
| const onTicks = Math.round(note.time * (bpm * division / 60)); | |
| const offTicks = Math.round((note.time + note.duration) * (bpm * division / 60)); | |
| midiEvents.push({ | |
| ticks: onTicks, | |
| type: 0x90, | |
| note: note.midi, | |
| velocity: Math.round(note.velocity * 127) | |
| }); | |
| midiEvents.push({ | |
| ticks: offTicks, | |
| type: 0x80, | |
| note: note.midi, | |
| velocity: 0 | |
| }); | |
| } | |
| // 3. Sort events chronologically (with note-off before note-on for identical ticks) | |
| midiEvents.sort((a, b) => { | |
| if (a.ticks !== b.ticks) return a.ticks - b.ticks; | |
| return a.type - b.type; | |
| }); | |
| // 4. Generate delta-times and append events | |
| let lastTicks = 0; | |
| for (const event of midiEvents) { | |
| const delta = event.ticks - lastTicks; | |
| lastTicks = event.ticks; | |
| trackBytes.push(...encodeVLQ(delta)); | |
| trackBytes.push(event.type, event.note, event.velocity); | |
| } | |
| // 5. Add End of Track event | |
| trackBytes.push(0, 0xFF, 0x2F, 0x00); | |
| // 6. Build File Bytes | |
| const headerBytes = [ | |
| 0x4d, 0x54, 0x68, 0x64, // "MThd" | |
| 0, 0, 0, 6, // Length | |
| 0, 0, // Format 0 | |
| 0, 1, // Number of tracks | |
| (division >> 8) & 0xFF, | |
| division & 0xFF | |
| ]; | |
| const trackLen = trackBytes.length; | |
| const trackHeaderBytes = [ | |
| 0x4d, 0x54, 0x72, 0x6b, // "MTrk" | |
| (trackLen >> 24) & 0xFF, | |
| (trackLen >> 16) & 0xFF, | |
| (trackLen >> 8) & 0xFF, | |
| trackLen & 0xFF | |
| ]; | |
| const fileBytes = new Uint8Array(headerBytes.length + trackHeaderBytes.length + trackBytes.length); | |
| fileBytes.set(headerBytes, 0); | |
| fileBytes.set(trackHeaderBytes, headerBytes.length); | |
| fileBytes.set(trackBytes, headerBytes.length + trackHeaderBytes.length); | |
| return fileBytes; | |
| } | |
| function downloadMidi(fileBytes, filename = "nanomaestro_performance.mid") { | |
| const blob = new Blob([fileBytes], { type: "audio/midi" }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = filename; | |
| document.body.appendChild(a); | |
| a.click(); | |
| document.body.removeChild(a); | |
| URL.revokeObjectURL(url); | |
| } | |
| async function loadCustomUserFiles() { | |
| stop(); | |
| isModelReady = false; | |
| startBtn.disabled = true; | |
| if (!modelFileInput || !vocabFileInput) { | |
| setStatus("Select model_int8.onnx and matching vocab.json in Advanced settings", "idle"); | |
| return; | |
| } | |
| const modelFile = modelFileInput.files?.[0]; | |
| const vocabFile = vocabFileInput.files?.[0]; | |
| if (!modelFile || !vocabFile) { | |
| activeModelName = ""; | |
| setStatus("Select model_int8.onnx and matching vocab.json in Advanced settings", "idle"); | |
| return; | |
| } | |
| activeModelName = modelFile.name; | |
| setStatus(`Reading ${vocabFile.name}...`, "loading"); | |
| const vocab = JSON.parse(await vocabFile.text()); | |
| setStatus(`Loading ${modelFile.name}...`, "loading"); | |
| const modelBuffer = await modelFile.arrayBuffer(); | |
| await new Promise((resolve, reject) => { | |
| modelInitResolve = resolve; | |
| modelInitReject = reject; | |
| worker.postMessage({ | |
| action: "init", | |
| activeModelName: modelFile.name, | |
| vocab: vocab, | |
| modelBuffer: modelBuffer | |
| }, [modelBuffer]); | |
| }); | |
| setStatus(`Ready: ${activeModelName}`, "idle"); | |
| startBtn.disabled = false; | |
| } | |
| function resizeCanvas() { | |
| const rect = noteCanvas.getBoundingClientRect(); | |
| const dpr = window.devicePixelRatio || 1; | |
| const width = Math.max(1, Math.floor(rect.width * dpr)); | |
| const height = Math.max(1, Math.floor(rect.height * dpr)); | |
| if (noteCanvas.width !== width || noteCanvas.height !== height) { | |
| noteCanvas.width = width; | |
| noteCanvas.height = height; | |
| } | |
| canvasCtx.setTransform(dpr, 0, 0, dpr, 0, 0); | |
| canvasWidth = rect.width; | |
| canvasHeight = rect.height; | |
| } | |
| // Maps MIDI note number to its key position on the horizontal keyboard | |
| function getKeyPosition(midi, width) { | |
| const minMidi = 36; | |
| const maxMidi = 96; | |
| const numWhiteKeys = 36; | |
| const whiteKeyW = width / numWhiteKeys; | |
| const noteInOctave = (midi - minMidi) % 12; | |
| const octave = Math.floor((midi - minMidi) / 12); | |
| const whiteKeyOffsets = [0, null, 1, null, 2, 3, null, 4, null, 5, null, 6]; | |
| const isBlack = whiteKeyOffsets[noteInOctave] === null; | |
| if (!isBlack) { | |
| const whiteIdx = octave * 7 + whiteKeyOffsets[noteInOctave]; | |
| const x = whiteIdx * whiteKeyW; | |
| return { x, w: whiteKeyW, isBlack: false, center: x + whiteKeyW / 2 }; | |
| } else { | |
| const blackKeyBorderOffsets = { | |
| 1: 1, // C# | |
| 3: 2, // D# | |
| 6: 4, // F# | |
| 8: 5, // G# | |
| 10: 6 // A# | |
| }; | |
| const borderIdx = octave * 7 + blackKeyBorderOffsets[noteInOctave]; | |
| const borderX = borderIdx * whiteKeyW; | |
| const w = whiteKeyW * 0.62; // Black key width | |
| const x = borderX - w / 2; | |
| return { x, w, isBlack: true, center: borderX }; | |
| } | |
| } | |
| function drawVisualizer() { | |
| const width = canvasWidth; | |
| const height = canvasHeight; | |
| // Interpolate smooth audio time from high-precision performance clock | |
| const perfNow = performance.now(); | |
| const rawAudioTime = Tone.now(); | |
| if (rawAudioTime !== lastAudioTime) { | |
| lastAudioTime = rawAudioTime; | |
| lastPerfTime = perfNow; | |
| } | |
| const smoothNow = lastAudioTime + (perfNow - lastPerfTime) / 1000; | |
| const visualNow = smoothNow - VISUAL_DELAY; | |
| const minMidi = 36; | |
| const maxMidi = 96; | |
| const secondsVisible = 4.5; | |
| const keyboardH = width < 640 ? 80 : 120; | |
| const playLineY = height - keyboardH; | |
| const speed = playLineY / secondsVisible; | |
| canvasCtx.clearRect(0, 0, width, height); | |
| // 1. Draw background lanes for black keys | |
| for (let m = minMidi; m <= maxMidi; m++) { | |
| const keyInfo = getKeyPosition(m, width); | |
| if (keyInfo.isBlack) { | |
| canvasCtx.fillStyle = "rgba(255, 255, 255, 0.015)"; | |
| canvasCtx.fillRect(keyInfo.x, 0, keyInfo.w, playLineY); | |
| } | |
| } | |
| // 2. Draw horizontal scrolling beat and bar grid lines | |
| const currentBpm = Number(tempoEl?.textContent || 120); | |
| const secondsPerBeat = 60 / currentBpm; | |
| const startBeat = Math.floor((visualNow - 1.0) / secondsPerBeat); | |
| const endBeat = Math.ceil((visualNow + secondsVisible) / secondsPerBeat); | |
| canvasCtx.font = "8px ui-sans-serif, system-ui, sans-serif"; | |
| for (let b = startBeat; b <= endBeat; b++) { | |
| if (b < 0) continue; | |
| const beatTime = b * secondsPerBeat; | |
| const y = playLineY - (beatTime - visualNow) * speed; | |
| if (y < 0 || y > playLineY) continue; | |
| const isBar = b % 4 === 0; | |
| if (isBar) { | |
| canvasCtx.strokeStyle = "rgba(255, 255, 255, 0.045)"; | |
| canvasCtx.lineWidth = 1; | |
| // Draw Bar text indicator | |
| canvasCtx.fillStyle = "rgba(255, 255, 255, 0.2)"; | |
| canvasCtx.fillText(`BAR ${b / 4 + 1}`, 10, y - 4); | |
| } else { | |
| canvasCtx.strokeStyle = "rgba(255, 255, 255, 0.015)"; | |
| canvasCtx.lineWidth = 0.5; | |
| } | |
| canvasCtx.beginPath(); | |
| canvasCtx.moveTo(0, y); | |
| canvasCtx.lineTo(width, y); | |
| canvasCtx.stroke(); | |
| } | |
| // Track active pitches for the keyboard representation | |
| const activePitches = new Set(); | |
| // 3. Draw note capsules falling down | |
| for (const item of visualNotes) { | |
| const end = item.start + item.duration; | |
| if (end < visualNow - 0.5 || item.start > visualNow + secondsVisible) continue; | |
| const active = item.start <= visualNow && end >= visualNow; | |
| const midiClamped = Math.max(minMidi, Math.min(maxMidi, item.midi)); | |
| if (active) activePitches.add(midiClamped); | |
| const keyInfo = getKeyPosition(midiClamped, width); | |
| const noteW = keyInfo.isBlack ? keyInfo.w * 0.85 : keyInfo.w * 0.75; | |
| const noteX = keyInfo.center - noteW / 2; | |
| const bottomY = playLineY - (item.start - visualNow) * speed; | |
| const topY = playLineY - (end - visualNow) * speed; | |
| const drawTop = Math.max(-100, topY); | |
| const drawBottom = Math.max(-100, bottomY); | |
| const noteH = drawBottom - drawTop; | |
| if (noteH > 0) { | |
| if (active) { | |
| canvasCtx.fillStyle = "#ffffff"; | |
| canvasCtx.shadowBlur = 8; | |
| canvasCtx.shadowColor = "rgba(255, 255, 255, 0.5)"; | |
| canvasCtx.globalAlpha = 0.95; | |
| } else { | |
| canvasCtx.fillStyle = "#3d4853"; // Muted slate gray note | |
| canvasCtx.shadowBlur = 0; | |
| canvasCtx.globalAlpha = 0.55; | |
| } | |
| canvasCtx.beginPath(); | |
| if (typeof canvasCtx.roundRect === "function") { | |
| canvasCtx.roundRect(noteX, drawTop, noteW, noteH, Math.min(noteW / 2, 4)); | |
| } else { | |
| canvasCtx.rect(noteX, drawTop, noteW, noteH); | |
| } | |
| canvasCtx.fill(); | |
| } | |
| } | |
| // Reset shadow settings | |
| canvasCtx.shadowBlur = 0; | |
| canvasCtx.globalAlpha = 1.0; | |
| // 4. Draw Piano Keyboard at the bottom | |
| // Draw white keys first | |
| const numWhiteKeys = 36; | |
| const whiteKeyW = width / numWhiteKeys; | |
| for (let i = 0; i < numWhiteKeys; i++) { | |
| const octave = Math.floor(i / 7); | |
| const offsetIdx = i % 7; | |
| const whiteToNote = [0, 2, 4, 5, 7, 9, 11]; | |
| const midi = 36 + octave * 12 + whiteToNote[offsetIdx]; | |
| const active = activePitches.has(midi); | |
| const x = i * whiteKeyW; | |
| canvasCtx.fillStyle = active ? "#ffffff" : "#1a1f26"; | |
| canvasCtx.beginPath(); | |
| if (typeof canvasCtx.roundRect === "function") { | |
| canvasCtx.roundRect(x + 1, playLineY, whiteKeyW - 2, keyboardH, [0, 0, 3, 3]); | |
| } else { | |
| canvasCtx.rect(x + 1, playLineY, whiteKeyW - 2, keyboardH); | |
| } | |
| canvasCtx.fill(); | |
| // Key divider shadow | |
| canvasCtx.fillStyle = "rgba(0, 0, 0, 0.35)"; | |
| canvasCtx.fillRect(x, playLineY, 1, keyboardH); | |
| } | |
| // Draw black keys on top | |
| const blackKeyHeight = keyboardH * 0.62; | |
| for (let m = minMidi; m <= maxMidi; m++) { | |
| const noteInOctave = (m - minMidi) % 12; | |
| const isBlack = [1, 3, 6, 8, 10].includes(noteInOctave); | |
| if (!isBlack) continue; | |
| const active = activePitches.has(m); | |
| const keyInfo = getKeyPosition(m, width); | |
| canvasCtx.fillStyle = active ? "#6f9c78" : "#090a0d"; | |
| canvasCtx.beginPath(); | |
| if (typeof canvasCtx.roundRect === "function") { | |
| canvasCtx.roundRect(keyInfo.x, playLineY, keyInfo.w, blackKeyHeight, [0, 0, 2, 2]); | |
| } else { | |
| canvasCtx.rect(keyInfo.x, playLineY, keyInfo.w, blackKeyHeight); | |
| } | |
| canvasCtx.fill(); | |
| // 3D highlight | |
| canvasCtx.fillStyle = active ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"; | |
| canvasCtx.fillRect(keyInfo.x, playLineY, keyInfo.w, 2); | |
| } | |
| // Play line divider | |
| canvasCtx.fillStyle = "rgba(255, 255, 255, 0.1)"; | |
| canvasCtx.fillRect(0, playLineY - 1, width, 1); | |
| // Shadow below the play line onto the keyboard keys | |
| const playLineShadow = canvasCtx.createLinearGradient(0, playLineY, 0, playLineY + 6); | |
| playLineShadow.addColorStop(0, "rgba(0, 0, 0, 0.45)"); | |
| playLineShadow.addColorStop(1, "rgba(0, 0, 0, 0)"); | |
| canvasCtx.fillStyle = playLineShadow; | |
| canvasCtx.fillRect(0, playLineY, width, 6); | |
| // 5. Draw HUD label texts inside visualizer | |
| canvasCtx.fillStyle = "rgba(255, 255, 255, 0.15)"; | |
| canvasCtx.font = "9px ui-sans-serif, system-ui, sans-serif"; | |
| canvasCtx.fillText("BASS", 12, playLineY - 10); | |
| canvasCtx.fillText("TREBLE", width - 48, playLineY - 10); | |
| visualNotes = visualNotes.filter((item) => item.start + item.duration > visualNow - 1.5); | |
| if (running) visualFrame = requestAnimationFrame(drawVisualizer); | |
| } | |
| function startVisualizer() { | |
| if (visualFrame) cancelAnimationFrame(visualFrame); | |
| visualFrame = requestAnimationFrame(drawVisualizer); | |
| } | |
| // Dual-Layer Model Caching Engine (IndexedDB + Cache Storage API) | |
| let cacheDBPromise = null; | |
| function getCacheDB() { | |
| if (!cacheDBPromise) { | |
| cacheDBPromise = new Promise((resolve) => { | |
| if (!window.indexedDB) return resolve(null); | |
| const req = indexedDB.open("nanomaestro_cache_v2", 1); | |
| req.onupgradeneeded = (e) => { | |
| const db = e.target.result; | |
| if (!db.objectStoreNames.contains("models")) { | |
| db.createObjectStore("models"); | |
| } | |
| }; | |
| req.onsuccess = (e) => resolve(e.target.result); | |
| req.onerror = (e) => { | |
| console.warn("IndexedDB open error:", e); | |
| resolve(null); | |
| }; | |
| }); | |
| } | |
| return cacheDBPromise; | |
| } | |
| async function getCachedData(key) { | |
| try { | |
| const db = await getCacheDB(); | |
| if (!db) return null; | |
| return new Promise((resolve) => { | |
| const tx = db.transaction("models", "readonly"); | |
| const store = tx.objectStore("models"); | |
| const req = store.get(key); | |
| req.onsuccess = () => resolve(req.result || null); | |
| req.onerror = () => resolve(null); | |
| }); | |
| } catch (e) { | |
| return null; | |
| } | |
| } | |
| async function setCachedData(key, data) { | |
| try { | |
| const db = await getCacheDB(); | |
| if (!db) return; | |
| const tx = db.transaction("models", "readwrite"); | |
| const store = tx.objectStore("models"); | |
| store.put(data, key); | |
| } catch (e) { | |
| // Fail silently if browser quota limit is reached | |
| } | |
| } | |
| async function getFromCacheStorage(cacheKey) { | |
| try { | |
| if (!("caches" in window)) return null; | |
| const cache = await caches.open("nanomaestro_models_v2"); | |
| const res = await cache.match(cacheKey); | |
| if (res && res.ok) { | |
| return res; | |
| } | |
| } catch (e) { | |
| // Ignore cache storage errors | |
| } | |
| return null; | |
| } | |
| async function setInCacheStorage(cacheKey, response) { | |
| try { | |
| if (!("caches" in window)) return; | |
| const cache = await caches.open("nanomaestro_models_v2"); | |
| await cache.put(cacheKey, response); | |
| } catch (e) { | |
| // Ignore cache storage errors | |
| } | |
| } | |
| // Stream reader helper with progress callback | |
| async function fetchStreamWithProgress(response, onProgress) { | |
| const contentLength = response.headers.get("content-length"); | |
| const total = contentLength ? parseInt(contentLength, 10) : 0; | |
| let loaded = 0; | |
| if (response.body && typeof response.body.getReader === "function") { | |
| const reader = response.body.getReader(); | |
| const chunks = []; | |
| while (true) { | |
| const { done, value } = await reader.read(); | |
| if (done) break; | |
| chunks.push(value); | |
| loaded += value.byteLength; | |
| if (onProgress) onProgress(loaded, total); | |
| } | |
| return new Blob(chunks); | |
| } else { | |
| const blob = await response.blob(); | |
| if (onProgress) onProgress(blob.size, blob.size); | |
| return blob; | |
| } | |
| } | |
| // Dual-layer cache fetch helper with stable asset keys | |
| async function fetchAssetWithCache(cacheKey, localUrl, remoteUrl, onProgress, isJson = false) { | |
| // 1. Check IndexedDB FIRST | |
| try { | |
| const cachedData = await getCachedData(cacheKey); | |
| if (cachedData) { | |
| console.log(`[Cache HIT - IDB] Loaded ${cacheKey}`); | |
| if (onProgress) { | |
| const size = isJson ? 1 : (cachedData.size || 1); | |
| onProgress(size, size); | |
| } | |
| return cachedData; | |
| } | |
| } catch (e) { | |
| console.warn("IndexedDB read error:", e); | |
| } | |
| // 2. Check Cache Storage SECOND | |
| try { | |
| const cachedResponse = await getFromCacheStorage(cacheKey); | |
| if (cachedResponse) { | |
| console.log(`[Cache HIT - Cache API] Loaded ${cacheKey}`); | |
| let data = isJson ? await cachedResponse.json() : await cachedResponse.blob(); | |
| if (onProgress) { | |
| const size = isJson ? 1 : (data.size || 1); | |
| onProgress(size, size); | |
| } | |
| void setCachedData(cacheKey, data); | |
| return data; | |
| } | |
| } catch (e) { | |
| console.warn("Cache storage read error:", e); | |
| } | |
| // 3. Network Fetch: Try localUrl first, fallback to remoteUrl | |
| console.log(`[Cache MISS] Fetching ${cacheKey} from network...`); | |
| let data; | |
| let responseToCache = null; | |
| try { | |
| const response = await fetch(localUrl); | |
| if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${localUrl}`); | |
| responseToCache = response.clone(); | |
| if (isJson) { | |
| data = await response.json(); | |
| if (onProgress) onProgress(1, 1); | |
| } else { | |
| data = await fetchStreamWithProgress(response, onProgress); | |
| } | |
| } catch (localErr) { | |
| console.warn(`Local fetch failed for ${localUrl}, fetching from Hugging Face: ${remoteUrl}`); | |
| const response = await fetch(remoteUrl); | |
| if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${remoteUrl}`); | |
| responseToCache = response.clone(); | |
| if (isJson) { | |
| data = await response.json(); | |
| if (onProgress) onProgress(1, 1); | |
| } else { | |
| data = await fetchStreamWithProgress(response, onProgress); | |
| } | |
| } | |
| // 4. Save downloaded asset into BOTH caches! | |
| void setCachedData(cacheKey, data); | |
| if (responseToCache) { | |
| void setInCacheStorage(cacheKey, responseToCache); | |
| } | |
| return data; | |
| } | |
| const MODEL_PRESETS = { | |
| pro: { | |
| key: "pro", | |
| name: "NanoMaestro Pro (54M)", | |
| modelUrl: "./NanoMaestro-Pro/model_int8.onnx", | |
| remoteModelUrl: "https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Pro/model_int8.onnx", | |
| vocabUrl: "./NanoMaestro-Pro/vocab.json", | |
| remoteVocabUrl: "https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Pro/vocab.json", | |
| ep: "wasm", | |
| config: { numLayers: 2, hiddenSize: 2048 }, | |
| desc: "NanoMaestro Pro is a 50 MB music-generation model with 54 million parameters, designed to run continuously in real time on almost any consumer CPU." | |
| }, | |
| light: { | |
| key: "light", | |
| name: "NanoMaestro Light (13M)", | |
| modelUrl: "./NanoMaestro-Light/model_int8.onnx", | |
| remoteModelUrl: "https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Light/model_int8.onnx", | |
| vocabUrl: "./NanoMaestro-Light/vocab.json", | |
| remoteVocabUrl: "https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Light/vocab.json", | |
| ep: "wasm", | |
| config: { numLayers: 2, hiddenSize: 1024 }, | |
| desc: "NanoMaestro Light is a tiny, 13 MB music-generation model with 13 million parameters, designed to run continuously in real time on almost any consumer CPU." | |
| } | |
| }; | |
| let currentModelKey = "pro"; | |
| function syncModelSelectors(key) { | |
| const topSelect = document.querySelector("#topModelSelect"); | |
| const settingsSelect = document.querySelector("#modelSelect"); | |
| if (topSelect && topSelect.value !== key) topSelect.value = key; | |
| if (settingsSelect && settingsSelect.value !== key) settingsSelect.value = key; | |
| } | |
| async function loadSelectedModel(presetKey, updateProgress) { | |
| const preset = MODEL_PRESETS[presetKey] || MODEL_PRESETS.pro; | |
| currentModelKey = presetKey; | |
| isModelReady = false; | |
| syncModelSelectors(presetKey); | |
| const vocabCacheKey = `vocab_${preset.key}_v1`; | |
| const modelCacheKey = `model_${preset.key}_v1`; | |
| if (updateProgress) updateProgress(5, `Loading ${preset.name} vocabulary...`); | |
| const vocab = await fetchAssetWithCache(vocabCacheKey, preset.vocabUrl, preset.remoteVocabUrl, null, true); | |
| if (updateProgress) updateProgress(15, `Loading ${preset.name} weights...`); | |
| const modelBlob = await fetchAssetWithCache(modelCacheKey, preset.modelUrl, preset.remoteModelUrl, (loaded, total) => { | |
| if (updateProgress) { | |
| const loadedMB = (loaded / (1024 * 1024)).toFixed(1); | |
| if (total > 0) { | |
| const pct = 15 + (loaded / total) * 65; | |
| const totalMB = (total / (1024 * 1024)).toFixed(1); | |
| updateProgress(pct, `Downloading ${preset.name} (${loadedMB}MB / ${totalMB}MB)...`); | |
| } else { | |
| const estPct = Math.min(78, 15 + (loaded / (54 * 1024 * 1024)) * 63); | |
| updateProgress(estPct, `Loading ${preset.name} (${loadedMB}MB loaded)...`); | |
| } | |
| } | |
| }, false); | |
| if (updateProgress) updateProgress(82, `Initializing ${preset.name} engine (CPU)...`); | |
| const modelBuffer = await modelBlob.arrayBuffer(); | |
| await new Promise((resolve, reject) => { | |
| let timer = setTimeout(() => { | |
| reject(new Error("Neural network initialization timed out (25s)")); | |
| }, 25000); | |
| modelInitResolve = () => { | |
| clearTimeout(timer); | |
| resolve(); | |
| }; | |
| modelInitReject = (err) => { | |
| clearTimeout(timer); | |
| reject(err); | |
| }; | |
| worker.postMessage({ | |
| action: "init", | |
| activeModelName: preset.name, | |
| vocab: vocab, | |
| modelBuffer: modelBuffer, | |
| ep: preset.ep, | |
| modelConfig: preset.config | |
| }, [modelBuffer]); | |
| }); | |
| activeModelName = preset.name; | |
| isModelReady = true; | |
| const modelDescEl = document.querySelector("#modelDesc"); | |
| if (modelDescEl) { | |
| modelDescEl.textContent = preset.desc; | |
| } | |
| } | |
| // Initializer Flow | |
| async function init() { | |
| const loadingOverlay = document.querySelector("#loadingOverlay"); | |
| const loadProgress = document.querySelector("#loadProgress"); | |
| const loadStatus = document.querySelector("#loadStatus"); | |
| const loadPercent = document.querySelector("#loadPercent"); | |
| const updateProgress = (pct, text) => { | |
| if (loadProgress) loadProgress.style.width = `${pct}%`; | |
| if (loadPercent) loadPercent.textContent = `${Math.round(pct)}%`; | |
| if (loadStatus) loadStatus.textContent = text; | |
| }; | |
| updateProgress(2, "Initializing NanoMaestro engine..."); | |
| try { | |
| updateReadouts(); | |
| startBtn.disabled = true; | |
| const topSelect = document.querySelector("#topModelSelect"); | |
| const settingsSelect = document.querySelector("#modelSelect"); | |
| const selectedKey = (topSelect ? topSelect.value : null) || (settingsSelect ? settingsSelect.value : "pro"); | |
| await loadSelectedModel(selectedKey, updateProgress); | |
| // Step 4. Loading Audio Samples | |
| updateProgress(90, `Loading ${currentInstrument} audio samples...`); | |
| await ensureInstrument(); | |
| updateProgress(100, "Ready!"); | |
| // Remove loading overlay and set status | |
| setTimeout(() => { | |
| if (loadingOverlay) { | |
| loadingOverlay.classList.add("fade-out"); | |
| } | |
| setStatus(`Ready: ${activeModelName}`, "idle"); | |
| startBtn.disabled = false; | |
| // Draw static visualizer background once | |
| resizeCanvas(); | |
| drawVisualizer(); | |
| }, 600); | |
| } catch (error) { | |
| console.error("Initialization failed:", error); | |
| if (loadStatus) { | |
| loadStatus.textContent = `Auto-load failed: ${error.message}`; | |
| loadStatus.style.color = "#ff8787"; | |
| } | |
| } | |
| } | |
| // Bind Buttons and Input Controls | |
| startBtn.disabled = true; | |
| startBtn.addEventListener("click", () => void start()); | |
| stopBtn.addEventListener("click", stop); | |
| if (newSongBtn) { | |
| newSongBtn.addEventListener("click", async () => { | |
| if (!running) return; | |
| newSongBtn.disabled = true; | |
| stop(); | |
| setTimeout(async () => { | |
| try { | |
| await start(); | |
| } catch (error) { | |
| console.error("New song restart failed:", error); | |
| } | |
| }, 150); | |
| }); | |
| } | |
| if (recordBtn) { | |
| recordBtn.addEventListener("click", () => { | |
| if (isRecording) { | |
| stopRecording(true); | |
| } else { | |
| startRecording(); | |
| } | |
| }); | |
| } | |
| if (modelFileInput) { | |
| modelFileInput.addEventListener("change", async () => { | |
| try { | |
| await loadCustomUserFiles(); | |
| } catch (error) { | |
| console.error(error); | |
| setStatus(`Load failed: ${error.message}`, "idle"); | |
| startBtn.disabled = true; | |
| } | |
| }); | |
| } | |
| if (vocabFileInput) { | |
| vocabFileInput.addEventListener("change", async () => { | |
| try { | |
| await loadCustomUserFiles(); | |
| } catch (error) { | |
| console.error(error); | |
| setStatus(`Load failed: ${error.message}`, "idle"); | |
| startBtn.disabled = true; | |
| } | |
| }); | |
| } | |
| // Dynamic loading models logic removed since we load directly from directory. | |
| tempInput.addEventListener("input", () => updateReadouts()); | |
| topKInput.addEventListener("input", () => updateReadouts()); | |
| volumeInput.addEventListener("input", (e) => { | |
| setVolume(Number(e.target.value)); | |
| }); | |
| reverbInput.addEventListener("input", (e) => { | |
| const wet = Number(e.target.value); | |
| if (reverb) reverb.wet.value = wet / 100; | |
| if (reverbValue) reverbValue.textContent = `${wet}%`; | |
| }); | |
| delayInput.addEventListener("input", (e) => { | |
| const wet = Number(e.target.value); | |
| if (delay) delay.wet.value = wet / 100; | |
| if (delayValue) delayValue.textContent = `${wet}%`; | |
| }); | |
| toggleButtons.forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| if (btn.disabled) return; | |
| toggleButtons.forEach(b => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| const value = btn.dataset.value; | |
| activePlayingStyle = value; | |
| if (value === "midi") { | |
| midiSeedControls.style.display = "block"; | |
| } else { | |
| midiSeedControls.style.display = "none"; | |
| } | |
| }); | |
| }); | |
| midiBpmModeButtons.forEach(btn => { | |
| btn.addEventListener("click", () => { | |
| if (btn.disabled) return; | |
| midiBpmModeButtons.forEach(b => b.classList.remove("active")); | |
| btn.classList.add("active"); | |
| midiBpmMode = btn.dataset.value; | |
| }); | |
| }); | |
| midiFileInput.addEventListener("change", async (e) => { | |
| const file = e.target.files?.[0]; | |
| if (!file) return; | |
| midiFileName = file.name; | |
| midiFileNameEl.textContent = `File: ${file.name}`; | |
| midiFileInfo.style.display = "block"; | |
| midiFileStatsEl.textContent = "Parsing MIDI file..."; | |
| try { | |
| const buffer = await file.arrayBuffer(); | |
| const parser = new MidiParser(buffer); | |
| const parsed = parser.parse(); | |
| // Tokenize | |
| const { tokens, maxBar } = tokenizeMidi(parsed, 64); | |
| midiTokens = tokens; | |
| midiBpm = parsed.bpm; | |
| midiTotalBars = Math.max(1, maxBar + 1); | |
| midiFileStatsEl.textContent = `BPM: ${parsed.bpm} | Total Bars: ${midiTotalBars} | Notes: ${parsed.notes.length}`; | |
| // Configure start bar slider | |
| const minBar = findMinStartBar(tokens); | |
| midiStartBarInput.min = String(minBar); | |
| midiStartBarInput.max = String(midiTotalBars - 1); | |
| midiStartBarInput.value = String(minBar); | |
| midiStartBar = minBar; | |
| midiStartBarValue.textContent = String(minBar); | |
| // Analyze and render timeline | |
| const barsData = analyzeMidiTokens(tokens); | |
| renderMidiTimeline(barsData); | |
| updateMidiStartBarText(); | |
| } catch (error) { | |
| console.error(error); | |
| midiFileStatsEl.textContent = `Error: ${error.message}`; | |
| } | |
| }); | |
| midiStartBarInput.addEventListener("input", (e) => { | |
| const val = Number(e.target.value); | |
| midiStartBar = val; | |
| midiStartBarValue.textContent = String(val); | |
| updateMidiTimelineHighlights(); | |
| updateMidiStartBarText(); | |
| }); | |
| releaseInput.addEventListener("input", (e) => { | |
| const rel = Number(e.target.value); | |
| setRelease(rel); | |
| }); | |
| const instrumentSelect = document.querySelector("#instrumentSelect"); | |
| if (instrumentSelect) { | |
| instrumentSelect.addEventListener("change", async (e) => { | |
| currentInstrument = e.target.value; | |
| if (synth) { | |
| const isAlreadyRunning = running; | |
| if (isAlreadyRunning) { | |
| // Temporarily show loading status during swap | |
| setStatus(`Loading ${currentInstrument} audio samples...`, "loading"); | |
| } | |
| await createSampler(currentInstrument); | |
| if (isAlreadyRunning) { | |
| setStatus("Performing", "playing"); | |
| } | |
| } | |
| }); | |
| } | |
| const handleModelChange = async (e) => { | |
| const newKey = e.target.value; | |
| if (running) { | |
| stop(); | |
| } | |
| startBtn.disabled = true; | |
| setStatus(`Switching to ${MODEL_PRESETS[newKey]?.name || newKey}...`, "loading"); | |
| try { | |
| await loadSelectedModel(newKey, (pct, msg) => setStatus(msg, "loading")); | |
| setStatus(`Ready: ${activeModelName}`, "idle"); | |
| startBtn.disabled = false; | |
| } catch (err) { | |
| console.error("Model switch failed:", err); | |
| setStatus(`Model switch failed: ${err.message}`, "idle"); | |
| } | |
| }; | |
| const topModelSelect = document.querySelector("#topModelSelect"); | |
| if (topModelSelect) { | |
| topModelSelect.addEventListener("change", handleModelChange); | |
| } | |
| const modelSelect = document.querySelector("#modelSelect"); | |
| if (modelSelect) { | |
| modelSelect.addEventListener("change", handleModelChange); | |
| } | |
| window.addEventListener("resize", resizeCanvas); | |
| // Trigger setup on start | |
| void init(); | |