| 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; |
|
|
| |
| 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 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"); |
|
|
| |
| let activeModelName = ""; |
| let activeTokenizer = "abc"; |
| let session; |
| let stoi; |
| let itos; |
| let vocabSize = 0; |
| let h; |
| let c; |
| let currentId; |
| let running = false; |
| let pumping = false; |
| let schedulerId = 0; |
| 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; |
|
|
| |
| 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"); |
|
|
| let midiSeedActive = false; |
| let midiTokens = []; |
| let midiStartBar = 0; |
| let midiFileName = ""; |
| let midiBpm = 120; |
| let midiTotalBars = 0; |
| let isWarmingUp = false; |
|
|
| |
| 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 }; |
| } |
| } |
|
|
| |
| 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 }; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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; |
| } |
|
|
| |
| 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 |
| }; |
| } |
|
|
| |
| 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}`; |
| |
| |
| 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(); |
| } |
|
|
| |
| 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"); |
| |
| 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"); |
| } |
| }); |
| } |
|
|
| |
| 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", |
| }; |
|
|
| const abcNotePattern = /^(?:\^\^|__|\^|_|=)?[A-Ga-g][,']*$/; |
|
|
| |
| 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"); |
| } |
| } |
| } |
|
|
| |
| 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 tokenId(token) { |
| return stoi?.[token] ?? null; |
| } |
|
|
| function makeTensorId(id) { |
| return new ort.Tensor("int64", BigInt64Array.from([BigInt(id)]), [1, 1]); |
| } |
|
|
| function zeroState() { |
| return new ort.Tensor("float32", new Float32Array(numLayers * hiddenSize), [numLayers, 1, hiddenSize]); |
| } |
|
|
| |
|
|
| function promptIds() { |
| if (midiSeedActive && midiTokens.length > 0) { |
| const sliced = getTokensUpToBar(midiTokens, midiStartBar); |
| return sliced.map(tokenId).filter((id) => id !== null); |
| } |
| const eventPrompt = ["BOS", "BPM_120", "GRID_64", "BAR", "POS_0"]; |
| const abcPrompt = ["X:", "1", "<NL>", "T:", "piece", "<NL>", "M:", "4/4", "<NL>", "L:", "1/8", "<NL>", "Q:", "1/4=120", "<NL>", "K:", "C", "<NL>"]; |
| const prompt = activeTokenizer === "giantmidi_event" ? eventPrompt : abcPrompt; |
| const ids = prompt.map(tokenId).filter((id) => id !== null); |
| return ids.length ? ids : [0]; |
| } |
|
|
| async function warmPrompt() { |
| h = zeroState(); |
| c = zeroState(); |
| |
| const allTokensStr = midiSeedActive && midiTokens.length > 0 |
| ? getTokensUpToBar(midiTokens, midiStartBar) |
| : (activeTokenizer === "giantmidi_event" |
| ? ["BOS", "BPM_120", "GRID_64", "BAR", "POS_0"] |
| : ["X:", "1", "<NL>", "T:", "piece", "<NL>", "M:", "4/4", "<NL>", "L:", "1/8", "<NL>", "Q:", "1/4=120", "<NL>", "K:", "C", "<NL>"]); |
| |
| isWarmingUp = true; |
| |
| |
| for (const token of allTokensStr) { |
| if (parser) { |
| parser.feed(token); |
| } |
| } |
| |
| |
| const modelTokensStr = allTokensStr.slice(-256); |
| const ids = modelTokensStr.map(tokenId).filter((id) => id !== null); |
| |
| for (const id of ids) { |
| currentId = id; |
| await stepModel(false); |
| } |
| |
| isWarmingUp = false; |
| } |
|
|
| function sampleFromLogits(logits) { |
| const temperature = Math.max(0.05, Number(tempInput.value)); |
| const topK = Math.max(1, Number(topKInput.value)); |
| const scored = []; |
| for (let i = 0; i < logits.length; i += 1) { |
| const token = itos[i]; |
| if (token === undefined) continue; |
| let score = logits[i] / temperature; |
| if (token === "<EOP>" || token === "EOS") score -= 1.0; |
| scored.push([i, score]); |
| } |
| scored.sort((a, b) => b[1] - a[1]); |
| const picked = scored.slice(0, Math.min(topK, scored.length)); |
| const maxScore = picked[0]?.[1] ?? 0; |
| let sum = 0; |
| for (const item of picked) { |
| item[2] = Math.exp(item[1] - maxScore); |
| sum += item[2]; |
| } |
| let r = Math.random() * sum; |
| for (const item of picked) { |
| r -= item[2]; |
| if (r <= 0) return item[0]; |
| } |
| return picked[picked.length - 1][0]; |
| } |
|
|
| async function stepModel(record = true) { |
| const output = await session.run({ input: makeTensorId(currentId), h, c }); |
| h = output.h_out; |
| c = output.c_out; |
| currentId = sampleFromLogits(output.logits.data); |
| const token = itos[currentId] ?? "?"; |
| if (record) { |
| updateReadouts(token); |
| parser.feed(token); |
| } |
| return token; |
| } |
|
|
| function durationToSecondsFromAbc(duration) { |
| let eighths = 1; |
| if (duration) { |
| if (duration === "/") eighths = 0.5; |
| else if (duration.startsWith("/")) eighths = 1 / Number(duration.slice(1)); |
| else if (duration.includes("/")) { |
| const [a, b] = duration.split("/").map(Number); |
| eighths = a / b; |
| } else { |
| eighths = Number(duration); |
| } |
| } |
| const quarterSeconds = 60 / Number(tempoEl.textContent || 120); |
| return Math.max(0.08, eighths * quarterSeconds * 0.5); |
| } |
|
|
| function durationToSecondsFromEventSteps(steps, grid) { |
| const quarterSeconds = 60 / Number(tempoEl.textContent || 120); |
| return Math.max(0.035, (Math.max(1, steps) * 4 * quarterSeconds) / Math.max(1, grid)); |
| } |
|
|
| function midiToTonePitch(midi) { |
| const names = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]; |
| return `${names[((midi % 12) + 12) % 12]}${Math.floor(midi / 12) - 1}`; |
| } |
|
|
| function abcToTonePitch(token) { |
| const match = token.match(/^(\^\^|__|\^|_|=)?([A-Ga-g])([,']*)$/); |
| if (!match) return null; |
| const accidental = match[1] || ""; |
| const step = match[2]; |
| const marks = match[3] || ""; |
| let octave = step === step.toLowerCase() ? 5 : 4; |
| for (const mark of marks) octave += mark === "'" ? 1 : -1; |
| const accidentalText = accidental === "^^" ? "##" : accidental === "__" ? "bb" : accidental === "^" ? "#" : accidental === "_" ? "b" : ""; |
| return `${step.toUpperCase()}${accidentalText}${octave}`; |
| } |
|
|
| 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 (isWarmingUp) return; |
| if (event.duration < 0 || queue.length > TARGET_QUEUE_EVENTS * 3) return; |
| queue.push(event); |
| generatedEvents += event.type === "note" ? 1 : 0; |
| updateReadouts(); |
| } |
|
|
| function makeAbcParser() { |
| return { |
| pending: null, |
| chord: null, |
| reset() { |
| this.pending = null; |
| this.chord = null; |
| }, |
| feed(token) { |
| if (token === "Q:") { |
| this.pending = { type: "tempoHeader" }; |
| return; |
| } |
| if (this.pending?.type === "tempoHeader") { |
| const bpm = Number(String(token).split("=").pop()); |
| if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 220) { |
| if (!midiSeedActive || isWarmingUp) { |
| if (tempoEl) tempoEl.textContent = String(Math.round(bpm)); |
| } |
| } |
| this.pending = null; |
| return; |
| } |
| if (token === "[") { |
| this.chord = []; |
| this.pending = null; |
| return; |
| } |
| if (this.chord) { |
| if (token === "]") { |
| this.pending = { type: "chord", notes: this.chord.map(abcToTonePitch).filter(Boolean) }; |
| this.chord = null; |
| return; |
| } |
| if (abcNotePattern.test(token)) this.chord.push(token); |
| return; |
| } |
| if (token === "z") { |
| this.pending = { type: "rest" }; |
| return; |
| } |
| if (abcNotePattern.test(token)) { |
| this.pending = { type: "note", note: abcToTonePitch(token) }; |
| return; |
| } |
| if (token.startsWith("DUR:") && this.pending) { |
| this.emitPending(token.slice(4)); |
| return; |
| } |
| if (this.pending && (token === "|" || token === "<NL>" || token === "<EOP>")) this.emitPending(null); |
| }, |
| emitPending(durationToken) { |
| const duration = durationToSecondsFromAbc(durationToken); |
| if (this.pending.type === "rest") pushEvent({ type: "rest", duration }); |
| if (this.pending.type === "note" && this.pending.note) pushEvent({ type: "note", notes: [this.pending.note], duration, advance: duration }); |
| if (this.pending.type === "chord" && this.pending.notes.length) pushEvent({ type: "note", notes: this.pending.notes.slice(0, 8), duration, advance: duration }); |
| this.pending = null; |
| }, |
| }; |
| } |
|
|
| function makeEventParser() { |
| return { |
| grid: 64, |
| bar: -1, |
| pos: 0, |
| pendingPosition: null, |
| pendingNotes: [], |
| pendingNote: null, |
| lastQ: 0, |
| reset() { |
| this.grid = tokenId("GRID_64") !== null ? 64 : 64; |
| this.bar = -1; |
| this.pos = 0; |
| this.pendingPosition = null; |
| this.pendingNotes = []; |
| this.pendingNote = null; |
| this.lastQ = 0; |
| }, |
| feed(token) { |
| if (token.startsWith("BPM_")) { |
| const bpm = Number(token.slice(4)); |
| if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 220) { |
| if (!midiSeedActive || isWarmingUp) { |
| if (tempoEl) tempoEl.textContent = String(Math.round(bpm)); |
| } |
| } |
| return; |
| } |
| if (token.startsWith("GRID_")) { |
| const grid = Number(token.slice(5)); |
| if (Number.isFinite(grid) && grid > 0) this.grid = grid; |
| return; |
| } |
| if (token === "BAR") { |
| this.flushTo(this.absoluteQFor(this.bar + 1, 0)); |
| this.bar += 1; |
| this.pos = 0; |
| return; |
| } |
| if (token.startsWith("POS_")) { |
| const pos = Number(token.slice(4)); |
| if (!Number.isFinite(pos)) return; |
| this.flushTo(this.absoluteQFor(this.bar, pos)); |
| this.pos = pos; |
| return; |
| } |
| if (token.startsWith("NOTE_")) { |
| const midi = Number(token.slice(5)); |
| if (Number.isFinite(midi) && midi >= 0 && midi <= 127) { |
| this.pendingNote = { midi, durationSteps: 1, velocity: 0.72 }; |
| } |
| return; |
| } |
| if (token.startsWith("DUR_") && this.pendingNote) { |
| const steps = Number(token.slice(4)); |
| if (Number.isFinite(steps)) this.pendingNote.durationSteps = Math.max(1, steps); |
| return; |
| } |
| if (token.startsWith("VEL_") && this.pendingNote) { |
| const bucket = Number(token.slice(4)); |
| if (Number.isFinite(bucket)) this.pendingNote.velocity = Math.max(0.2, Math.min(0.95, bucket / 8)); |
| const q = this.absoluteQFor(this.bar, this.pos); |
| this.pendingPosition ??= q; |
| this.pendingNotes.push(this.pendingNote); |
| this.pendingNote = null; |
| } |
| }, |
| absoluteQFor(bar, pos) { |
| return Math.max(0, bar) * 4 + (Math.max(0, pos) * 4) / Math.max(1, this.grid); |
| }, |
| flushTo(nextQ) { |
| if (this.pendingNote) { |
| const q = this.absoluteQFor(this.bar, this.pos); |
| this.pendingPosition ??= q; |
| this.pendingNotes.push(this.pendingNote); |
| this.pendingNote = null; |
| } |
| if (this.pendingNotes.length && this.pendingPosition !== null) { |
| const gap = Math.max(0, this.pendingPosition - this.lastQ); |
| if (gap > 0) pushEvent({ type: "rest", duration: this.quartersToSeconds(gap) }); |
| pushEvent({ |
| type: "note", |
| notes: this.pendingNotes.map((note) => midiToTonePitch(note.midi)), |
| perNoteDurations: this.pendingNotes.map((note) => durationToSecondsFromEventSteps(note.durationSteps, this.grid)), |
| velocities: this.pendingNotes.map((note) => note.velocity), |
| duration: 0, |
| advance: 0, |
| }); |
| this.lastQ = this.pendingPosition; |
| } |
| const finalGap = Math.max(0, nextQ - this.lastQ); |
| if (finalGap > 0) pushEvent({ type: "rest", duration: this.quartersToSeconds(finalGap) }); |
| this.lastQ = Math.max(this.lastQ, nextQ); |
| this.pendingPosition = null; |
| this.pendingNotes = []; |
| }, |
| quartersToSeconds(quarters) { |
| return (quarters * 60) / Number(tempoEl.textContent || 120); |
| }, |
| }; |
| } |
|
|
| function makeParser() { |
| return activeTokenizer === "giantmidi_event" ? makeEventParser() : makeAbcParser(); |
| } |
|
|
| async function pumpTokens() { |
| if (!running || pumping || queue.length >= TARGET_QUEUE_EVENTS) return; |
| pumping = true; |
| setStatus("Generating...", "buffering"); |
| try { |
| let steps = 0; |
| while (running && queue.length < TARGET_QUEUE_EVENTS && steps < STEPS_PER_PUMP) { |
| await stepModel(true); |
| steps += 1; |
| } |
| if (running) { |
| setStatus(`Playing ${activeModelName}`, "playing"); |
| } |
| } catch (error) { |
| console.error(error); |
| setStatus(`Generation error: ${error.message}`, "idle"); |
| stop(); |
| } finally { |
| pumping = false; |
| } |
| } |
|
|
| 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); |
| } |
| 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); |
| } |
|
|
| |
| function setVolume(pct) { |
| if (!Tone) return; |
| if (pct === 0) { |
| Tone.getDestination().volume.value = -999; |
| } else { |
| |
| 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 ensurePiano() { |
| 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); |
| |
| synth = new Tone.Sampler({ |
| urls: pianoSamples, |
| baseUrl: "https://tonejs.github.io/audio/salamander/", |
| attack: 0, |
| release: initialRel, |
| curve: "exponential", |
| }).connect(delay); |
| |
| setVolume(initialVol); |
| |
| |
| if (reverbValue) reverbValue.textContent = `${initialRev}%`; |
| if (delayValue) delayValue.textContent = `${initialDel}%`; |
| if (releaseValue) releaseValue.textContent = `${initialRel.toFixed(1)}s`; |
|
|
| await Tone.loaded(); |
| } |
|
|
| async function start() { |
| if (running || !session) return; |
| startBtn.disabled = true; |
| setStatus("Starting audio...", "loading"); |
| |
| await Tone.start(); |
| await ensurePiano(); |
| |
| queue = []; |
| generatedEvents = 0; |
| visualNotes = []; |
| if (tempoEl) tempoEl.textContent = midiSeedActive ? String(Math.round(midiBpm)) : "120"; |
| |
| parser = makeParser(); |
| parser.reset(); |
| |
| setStatus("Warming AI model prompt...", "buffering"); |
| await warmPrompt(); |
| |
| running = true; |
| stopBtn.disabled = false; |
| if (newSongBtn) newSongBtn.disabled = false; |
| if (modelFileInput) modelFileInput.disabled = true; |
| if (vocabFileInput) vocabFileInput.disabled = true; |
| toggleButtons.forEach(btn => btn.disabled = true); |
| midiFileInput.disabled = true; |
| midiStartBarInput.disabled = true; |
| |
| setStatus(`Playing ${activeModelName}`, "playing"); |
| await pumpTokens(); |
| |
| nextNoteTime = Tone.now() + 0.12; |
| startVisualizer(); |
| scheduleAudio(); |
| } |
|
|
| function stop() { |
| running = false; |
| window.clearTimeout(schedulerId); |
| schedulerId = 0; |
| startBtn.disabled = !session; |
| stopBtn.disabled = true; |
| if (newSongBtn) newSongBtn.disabled = true; |
| if (modelFileInput) modelFileInput.disabled = false; |
| if (vocabFileInput) vocabFileInput.disabled = false; |
| toggleButtons.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(session ? `Ready: ${activeModelName}` : "Stopped", "idle"); |
| } |
|
|
| async function loadSelectedModel() { |
| stop(); |
| session = null; |
| 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()); |
| stoi = vocab.stoi; |
| itos = Object.fromEntries(Object.entries(vocab.itos).map(([key, value]) => [Number(key), value])); |
| vocabSize = vocab.vocab_size; |
| activeTokenizer = vocab.tokenizer || "abc"; |
| |
| setStatus(`Loading ${modelFile.name}...`, "loading"); |
| const modelBuffer = await modelFile.arrayBuffer(); |
| session = await ort.InferenceSession.create(modelBuffer, { |
| executionProviders: ["wasm"], |
| graphOptimizationLevel: "all", |
| }); |
| updateModelDimensions(session); |
| 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; |
| } |
|
|
| |
| 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, |
| 3: 2, |
| 6: 4, |
| 8: 5, |
| 10: 6 |
| }; |
| const borderIdx = octave * 7 + blackKeyBorderOffsets[noteInOctave]; |
| const borderX = borderIdx * whiteKeyW; |
| const w = whiteKeyW * 0.62; |
| const x = borderX - w / 2; |
| return { x, w, isBlack: true, center: borderX }; |
| } |
| } |
|
|
| function drawVisualizer() { |
| const width = canvasWidth; |
| const height = canvasHeight; |
| |
| |
| 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 = 120; |
| const playLineY = height - keyboardH; |
| const speed = playLineY / secondsVisible; |
| |
| canvasCtx.clearRect(0, 0, width, height); |
| |
| |
| 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); |
| } |
| } |
| |
| |
| 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; |
| |
| |
| 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(); |
| } |
| |
| |
| const activePitches = new Set(); |
| |
| |
| 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"; |
| 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(); |
| } |
| } |
| |
| |
| canvasCtx.shadowBlur = 0; |
| canvasCtx.globalAlpha = 1.0; |
| |
| |
| |
| 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(); |
| |
| |
| canvasCtx.fillStyle = "rgba(0, 0, 0, 0.35)"; |
| canvasCtx.fillRect(x, playLineY, 1, keyboardH); |
| } |
| |
| |
| 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(); |
| |
| |
| canvasCtx.fillStyle = active ? "rgba(255, 255, 255, 0.15)" : "rgba(255, 255, 255, 0.05)"; |
| canvasCtx.fillRect(keyInfo.x, playLineY, keyInfo.w, 2); |
| } |
| |
| |
| canvasCtx.fillStyle = "rgba(255, 255, 255, 0.1)"; |
| canvasCtx.fillRect(0, playLineY - 1, width, 1); |
| |
| |
| 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); |
| |
| |
| 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); |
| } |
|
|
| |
| async function fetchWithProgress(url, onProgress) { |
| const response = await fetch(url); |
| if (!response.ok) { |
| throw new Error(`HTTP ${response.status}: ${response.statusText} on ${url}`); |
| } |
| const contentLength = response.headers.get("content-length"); |
| if (!contentLength) { |
| |
| const blob = await response.blob(); |
| return blob; |
| } |
| const total = parseInt(contentLength, 10); |
| let loaded = 0; |
| 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); |
| } |
|
|
| const MODEL_CACHE_NAME = "nanomaestro-cache-v1"; |
|
|
| |
| async function fetchWithCache(url, onProgress, isJson = false) { |
| if (!('caches' in window)) { |
| const response = await fetch(url); |
| if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`); |
| return isJson ? response.json() : response.blob(); |
| } |
|
|
| const cache = await caches.open(MODEL_CACHE_NAME); |
| const cachedResponse = await cache.match(url); |
|
|
| if (cachedResponse) { |
| console.log(`Loaded from browser cache: ${url}`); |
| if (onProgress) onProgress(1, 1); |
| return isJson ? cachedResponse.json() : cachedResponse.blob(); |
| } |
|
|
| console.log(`Cache miss. Downloading remotely: ${url}`); |
| if (isJson) { |
| const response = await fetch(url); |
| if (!response.ok) throw new Error(`HTTP ${response.status} fetching ${url}`); |
| await cache.put(url, response.clone()); |
| return response.json(); |
| } else { |
| const blob = await fetchWithProgress(url, onProgress); |
| const responseToCache = new Response(blob); |
| await cache.put(url, responseToCache); |
| return blob; |
| } |
| } |
|
|
| |
| 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; |
| }; |
| |
| try { |
| updateReadouts(); |
| startBtn.disabled = true; |
| |
| |
| updateProgress(5, "Downloading vocabulary metadata..."); |
| const vocab = await fetchWithCache("https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Realtime/NM2.4%20ONNX%20int8/vocab.json", null, true); |
| stoi = vocab.stoi; |
| itos = Object.fromEntries(Object.entries(vocab.itos).map(([key, value]) => [Number(key), value])); |
| vocabSize = vocab.vocab_size; |
| activeTokenizer = vocab.tokenizer || "abc"; |
| |
| |
| updateProgress(15, "Downloading AI model..."); |
| const modelBlob = await fetchWithCache("https://huggingface.co/utkucoban/NanoMaestro-Realtime/resolve/main/NanoMaestro-Realtime/NM2.4%20ONNX%20int8/model_int8.onnx", (loaded, total) => { |
| |
| const pct = 15 + (loaded / total) * 65; |
| const loadedMB = (loaded / (1024 * 1024)).toFixed(1); |
| const totalMB = (total / (1024 * 1024)).toFixed(1); |
| updateProgress(pct, `Downloading AI model (${loadedMB}MB / ${totalMB}MB)...`); |
| }, false); |
| |
| |
| updateProgress(82, "Initializing neural network engine..."); |
| ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/"; |
| ort.env.wasm.numThreads = Math.min(4, navigator.hardwareConcurrency || 1); |
| |
| const modelBuffer = await modelBlob.arrayBuffer(); |
| session = await ort.InferenceSession.create(modelBuffer, { |
| executionProviders: ["wasm"], |
| graphOptimizationLevel: "all", |
| }); |
| numLayers = 2; |
| hiddenSize = 1024; |
| activeModelName = "model_int8.onnx (v2.4)"; |
| |
| |
| updateProgress(90, "Loading piano audio samples..."); |
| await ensurePiano(); |
| |
| updateProgress(100, "Ready!"); |
| |
| |
| setTimeout(() => { |
| if (loadingOverlay) { |
| loadingOverlay.classList.add("fade-out"); |
| } |
| setStatus(`Ready: ${activeModelName}`, "idle"); |
| startBtn.disabled = false; |
| |
| |
| resizeCanvas(); |
| drawVisualizer(); |
| }, 600); |
| |
| } catch (error) { |
| console.error("Initialization failed:", error); |
| if (loadStatus) { |
| loadStatus.textContent = `Auto-load failed: ${error.message}`; |
| loadStatus.style.color = "#ff8787"; |
| } |
| |
| |
| setTimeout(() => { |
| if (loadingOverlay) { |
| loadingOverlay.classList.add("fade-out"); |
| } |
| setStatus("Select model_int8.onnx and matching vocab.json in Advanced settings", "idle"); |
| }, 3000); |
| } |
| } |
|
|
| |
| 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 (modelFileInput) { |
| modelFileInput.addEventListener("change", async () => { |
| try { |
| await loadSelectedModel(); |
| } catch (error) { |
| console.error(error); |
| setStatus(`Load failed: ${error.message}`, "idle"); |
| startBtn.disabled = true; |
| } |
| }); |
| } |
|
|
| if (vocabFileInput) { |
| vocabFileInput.addEventListener("change", async () => { |
| try { |
| await loadSelectedModel(); |
| } catch (error) { |
| console.error(error); |
| setStatus(`Load failed: ${error.message}`, "idle"); |
| startBtn.disabled = true; |
| } |
| }); |
| } |
|
|
| |
|
|
| 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; |
| if (value === "midi") { |
| midiSeedActive = true; |
| midiSeedControls.style.display = "block"; |
| } else { |
| midiSeedActive = false; |
| midiSeedControls.style.display = "none"; |
| } |
| }); |
| }); |
|
|
| 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(); |
| |
| |
| 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}`; |
| |
| |
| const minBar = findMinStartBar(tokens); |
| midiStartBarInput.min = String(minBar); |
| midiStartBarInput.max = String(midiTotalBars - 1); |
| midiStartBarInput.value = String(minBar); |
| midiStartBar = minBar; |
| midiStartBarValue.textContent = String(minBar); |
| |
| |
| 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); |
| }); |
|
|
| window.addEventListener("resize", resizeCanvas); |
|
|
| |
| void init(); |
|
|