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 = ``;
}
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 = ``;
}
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();