plszz's picture
Update worker.js
2d0b70d verified
Raw
History Blame Contribute Delete
44.5 kB
// NanoMaestro Web Worker for isolated model inference, intelligence heuristics, and parsing
try {
// WASM-only build. WebGPU EP was removed: on most devices it was slower than WASM
// and, worse, produced audibly broken/incoherent output because the int8-quantized
// graph used by this model isn't reliably supported by the WebGPU EP yet. Rather than
// ship a backend that silently corrupts generation, we standardize on the well-tested
// multi-threaded WASM/SIMD path.
importScripts("https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/ort.min.js");
} catch (err) {
console.error("Worker failed to import ONNX Runtime script:", err);
}
// ONNX runtime configuration
if (typeof ort !== "undefined") {
ort.env.wasm.wasmPaths = "https://cdn.jsdelivr.net/npm/onnxruntime-web@1.20.1/dist/";
const hwThreads = (typeof navigator !== "undefined" && navigator.hardwareConcurrency) || 4;
ort.env.wasm.numThreads = Math.max(1, Math.min(hwThreads, 8));
ort.env.wasm.simd = true;
ort.env.wasm.proxy = false;
}
// State variables
let session = null;
let stoi = null;
let itos = null;
let vocabSize = 0;
let h = null;
let c = null;
let currentId = 0;
let activeModelName = "";
const currentEpUsed = "wasm";
// Model parameters
let hiddenSize = 2048;
let numLayers = 2;
// Playback settings & User BPM Override
let temperature = 0.85;
let topK = 40;
let currentBpm = 120;
let userBpmOverride = false;
let midiBpmMode = "lock";
// Seeding settings
// playingStyle: "default" | "bach" | "mozart" | "chopin" | "debussy" | "beethoven" | "satie"
// | "midi" (continue a single uploaded track)
// | "trained" (improvise in a style learned from one or more uploaded tracks)
let playingStyle = "default";
let midiTokens = [];
let midiStartBar = 0;
let isWarmingUp = false;
let lastSampledPitch = null;
// Rendering state
let isRendering = false;
let cancelRenderRequested = false;
// Intelligence & Heuristics Controls
let heuristicSettings = {
dynamicTempEnabled: true,
velocitySmoothingEnabled: true,
chordPenaltiesEnabled: true,
phraseMemoryEnabled: true,
keyBiasEnabled: true,
keyBiasStrength: 0.8,
rhythmBalancingEnabled: true,
cadenceGenEnabled: true,
midiSimilarityWeight: 0.85
};
// Reused buffers for top-k sampling
const MAX_TOPK_BUFFER = 256;
const topIdxBuf = new Int32Array(MAX_TOPK_BUFFER);
const topScoreBuf = new Float32Array(MAX_TOPK_BUFFER);
// ==========================================
// MUSICAL INTELLIGENCE ENGINE IMPLEMENTATION
// ==========================================
// 1. Key Detector using Krumhansl-Schmuckler Key-Finding Algorithm
class KeyDetector {
constructor() {
this.pitchCounts = new Float32Array(12);
this.recentPitches = [];
this.maxMemory = 48;
this.detectedRoot = 0;
this.isMinor = false;
this.confidence = 0;
this.majorProfile = [6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88];
this.minorProfile = [6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 2.69, 3.34, 3.17, 3.28];
this.noteNames = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"];
}
reset() {
this.pitchCounts.fill(0);
this.recentPitches = [];
this.detectedRoot = 0;
this.isMinor = false;
this.confidence = 0;
}
addPitch(midiPitch) {
const pc = ((midiPitch % 12) + 12) % 12;
this.recentPitches.push(pc);
if (this.recentPitches.length > this.maxMemory) {
this.recentPitches.shift();
}
this.pitchCounts.fill(0);
for (let i = 0; i < this.recentPitches.length; i++) {
this.pitchCounts[this.recentPitches[i]] += 1;
}
this.analyzeKey();
}
analyzeKey() {
if (this.recentPitches.length < 6) return;
let bestCorr = -2.0;
let bestRoot = 0;
let bestIsMinor = false;
for (let root = 0; root < 12; root++) {
const majCorr = this.correlation(this.pitchCounts, this.majorProfile, root);
if (majCorr > bestCorr) {
bestCorr = majCorr;
bestRoot = root;
bestIsMinor = false;
}
const minCorr = this.correlation(this.pitchCounts, this.minorProfile, root);
if (minCorr > bestCorr) {
bestCorr = minCorr;
bestRoot = root;
bestIsMinor = true;
}
}
this.detectedRoot = bestRoot;
this.isMinor = bestIsMinor;
this.confidence = Math.max(0, Math.min(1.0, (bestCorr + 0.5) / 1.5));
}
correlation(counts, profile, rootShift) {
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0, sumY2 = 0;
for (let i = 0; i < 12; i++) {
const x = counts[(i + rootShift) % 12];
const y = profile[i];
sumX += x;
sumY += y;
sumXY += x * y;
sumX2 += x * x;
sumY2 += y * y;
}
const num = 12 * sumXY - sumX * sumY;
const den = Math.sqrt((12 * sumX2 - sumX * sumX) * (12 * sumY2 - sumY * sumY));
return den === 0 ? 0 : num / den;
}
getScaleDegrees() {
const root = this.detectedRoot;
if (this.isMinor) {
return new Set([root, (root + 2) % 12, (root + 3) % 12, (root + 5) % 12, (root + 7) % 12, (root + 8) % 12, (root + 10) % 12, (root + 11) % 12]);
} else {
return new Set([root, (root + 2) % 12, (root + 4) % 12, (root + 5) % 12, (root + 7) % 12, (root + 9) % 12, (root + 11) % 12]);
}
}
getKeyName() {
return `${this.noteNames[this.detectedRoot]} ${this.isMinor ? "Minor" : "Major"}`;
}
}
const keyDetector = new KeyDetector();
// 2. Phrase Memory & Motif Tracker (remembers 4 bars)
class PhraseMemory {
constructor() {
this.history = new Array(256).fill(null);
this.lastDurations = [];
this.maxDurHistory = 6;
}
reset() {
this.history.fill(null);
this.lastDurations = [];
}
recordNote(bar, pos, pitch, durationSteps) {
const globalStep = (Math.max(0, bar) * 64 + Math.max(0, pos)) % 256;
this.history[globalStep] = { pitch, durationSteps, bar, pos };
}
recordDuration(durSteps) {
this.lastDurations.push(durSteps);
if (this.lastDurations.length > this.maxDurHistory) {
this.lastDurations.shift();
}
}
getMotifPitchForStep(bar, pos) {
const currentStep = (Math.max(0, bar) * 64 + Math.max(0, pos)) % 256;
const prev2Bar = (currentStep + 128) % 256;
const prev4Bar = currentStep;
if (this.history[prev2Bar]) return this.history[prev2Bar].pitch;
if (this.history[prev4Bar]) return this.history[prev4Bar].pitch;
return null;
}
getMonotonyPenalty(candidateDurSteps) {
if (this.lastDurations.length < 3) return 0;
let count = 0;
for (let i = this.lastDurations.length - 1; i >= 0; i--) {
if (this.lastDurations[i] === candidateDurSteps) count++;
else break;
}
if (count >= 4) return -2.2;
if (count >= 3) return -1.2;
return 0;
}
}
const phraseMemory = new PhraseMemory();
// 3. STYLE PROFILE — learns from one or many uploaded MIDI files and can be
// exported/imported as plain JSON so a person's trained style survives a page refresh.
//
// Rather than requiring the live generation to stay in lockstep with a single source
// bar-by-bar (which broke down quickly whenever the model's sampled rhythm drifted even
// slightly from the source file), this profile stores *statistical* habits keyed by
// grid position: how often a note lands there, which pitch-classes it tends to be, how
// long it tends to last, and how the melody tends to move (interval habits). Those
// statistics are blended into the sampler every step, so similarity degrades gracefully
// instead of collapsing the moment generation diverges from the source's bar count.
class StyleProfile {
constructor() {
this.name = "Untitled Style";
this.grid = 64;
this.posPitchClass = {}; // pos(0..grid-1) -> { pitchClass: count }
this.posDuration = {}; // pos -> { durationSteps: count }
this.posActive = {}; // pos -> count of note onsets landing here (rhythm habit)
this.intervalCounts = {}; // melodic interval in semitones (-12..12) -> count
this.velocityCounts = {}; // velocity bucket (1-8) -> count
this.totalNotes = 0;
this.sourceFiles = [];
}
static bump(obj, key, amount = 1) {
obj[key] = (obj[key] || 0) + amount;
return obj;
}
// Ingest one already-tokenized MIDI file's worth of tokens into this profile.
// Multiple calls accumulate — this is how "train on several files" works.
ingest(tokens, fileName) {
let pos = 0;
let pendingPitch = null;
let lastPitch = null;
for (const t of tokens) {
if (t === "BAR") {
pos = 0;
continue;
}
if (t.startsWith("POS_")) {
pos = Number(t.slice(4));
if (Number.isFinite(pos)) StyleProfile.bump(this.posActive, pos);
continue;
}
if (t.startsWith("NOTE_")) {
pendingPitch = Number(t.slice(5));
continue;
}
if (t.startsWith("DUR_") && pendingPitch !== null) {
const dur = Math.max(1, Math.min(64, Number(t.slice(4)) || 1));
const pc = ((pendingPitch % 12) + 12) % 12;
this.posPitchClass[pos] ??= {};
StyleProfile.bump(this.posPitchClass[pos], pc);
this.posDuration[pos] ??= {};
StyleProfile.bump(this.posDuration[pos], dur);
if (lastPitch !== null) {
const interval = Math.max(-12, Math.min(12, pendingPitch - lastPitch));
StyleProfile.bump(this.intervalCounts, interval);
}
lastPitch = pendingPitch;
this.totalNotes += 1;
continue;
}
if (t.startsWith("VEL_") && pendingPitch !== null) {
const bucket = Number(t.slice(4)) || 4;
StyleProfile.bump(this.velocityCounts, bucket);
pendingPitch = null;
continue;
}
}
if (fileName) this.sourceFiles.push(fileName);
}
isEmpty() {
return this.totalNotes === 0;
}
// 0..1 — how strongly a note onset "belongs" at this grid position in the learned style
activityBiasAt(pos) {
const total = this._activeTotal ??= Object.values(this.posActive).reduce((a, b) => a + b, 0) || 1;
return (this.posActive[pos] || 0) / total;
}
// 0..1 — likelihood this pitch-class is used when a note lands at `pos`
pitchClassBiasAt(pos, pc) {
const bucket = this.posPitchClass[pos];
if (!bucket) return -1; // no data at this position
const total = Object.values(bucket).reduce((a, b) => a + b, 0) || 1;
return (bucket[pc] || 0) / total;
}
// 0..1 — likelihood a note landing at `pos` has (approximately) this duration
durationBiasAt(pos, dur) {
const bucket = this.posDuration[pos];
if (!bucket) return -1;
const total = Object.values(bucket).reduce((a, b) => a + b, 0) || 1;
let matched = 0;
for (const [d, count] of Object.entries(bucket)) {
if (Math.abs(Number(d) - dur) <= 2) matched += count;
}
return matched / total;
}
// 0..1 — likelihood the melody moves by this many semitones from the previous note
intervalBias(interval) {
const total = Object.values(this.intervalCounts).reduce((a, b) => a + b, 0) || 1;
return (this.intervalCounts[interval] || 0) / total;
}
toJSON() {
return {
formatVersion: 1,
kind: "nanomaestro-style-profile",
name: this.name,
grid: this.grid,
posPitchClass: this.posPitchClass,
posDuration: this.posDuration,
posActive: this.posActive,
intervalCounts: this.intervalCounts,
velocityCounts: this.velocityCounts,
totalNotes: this.totalNotes,
sourceFiles: this.sourceFiles,
createdAt: new Date().toISOString()
};
}
static fromJSON(obj) {
const p = new StyleProfile();
if (!obj || obj.kind !== "nanomaestro-style-profile") {
throw new Error("That file doesn't look like a NanoMaestro trained style export.");
}
p.name = obj.name || "Imported Style";
p.grid = obj.grid || 64;
p.posPitchClass = obj.posPitchClass || {};
p.posDuration = obj.posDuration || {};
p.posActive = obj.posActive || {};
p.intervalCounts = obj.intervalCounts || {};
p.velocityCounts = obj.velocityCounts || {};
p.totalNotes = obj.totalNotes || 0;
p.sourceFiles = obj.sourceFiles || [];
return p;
}
}
// The currently active learned style (used by both "midi" continue-mode and "trained" mode)
let activeStyleProfile = null;
// Exact bar-indexed guide used only in "midi" (continue-a-single-track) mode for the
// high-fidelity "reproduce it almost exactly" end of the similarity slider.
let exactGuideBars = []; // barIdx -> Map(pos -> Array<{pitch, durationSteps}>)
let exactGuideTotalBars = 0;
function buildExactGuide(tokens) {
exactGuideBars = [];
exactGuideTotalBars = 0;
let currentBarMap = new Map();
let started = false;
let pos = 0;
let pendingPitch = null;
for (const t of tokens) {
if (t === "BAR") {
if (started) {
exactGuideBars.push(currentBarMap);
}
started = true;
currentBarMap = new Map();
pos = 0;
continue;
}
if (t.startsWith("POS_")) { pos = Number(t.slice(4)) || 0; continue; }
if (t.startsWith("NOTE_")) { pendingPitch = Number(t.slice(5)); continue; }
if (t.startsWith("DUR_") && pendingPitch !== null) {
const dur = Number(t.slice(4)) || 4;
if (!currentBarMap.has(pos)) currentBarMap.set(pos, []);
currentBarMap.get(pos).push({ pitch: pendingPitch, durationSteps: dur });
pendingPitch = null;
continue;
}
}
if (started && currentBarMap.size > 0) exactGuideBars.push(currentBarMap);
exactGuideTotalBars = exactGuideBars.length;
}
function getExactGuideNotes(barIdx, pos) {
if (exactGuideTotalBars === 0) return null;
const mapped = ((barIdx % exactGuideTotalBars) + exactGuideTotalBars) % exactGuideTotalBars;
const barMap = exactGuideBars[mapped];
if (!barMap) return null;
return barMap.get(pos) || null;
}
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 * 1 * hiddenSize), [numLayers, 1, hiddenSize]);
}
function updateModelDimensions(session) {
try {
const inputNames = session.inputNames;
if (inputNames.includes("h") && session.handler && session.handler._model) {
const inputs = session.handler._model.graph.inputs;
const hInput = inputs.find(i => i.name === "h");
if (hInput && hInput.type && hInput.type.tensorType && hInput.type.tensorType.shape) {
const shape = hInput.type.tensorType.shape.dim;
const layers = Number(shape[0].dimValue);
const hidden = Number(shape[2].dimValue);
if (layers > 0 && hidden > 0) {
numLayers = layers;
hiddenSize = hidden;
console.log(`Worker set model dimensions: layers=${numLayers}, hiddenSize=${hiddenSize}`);
}
}
}
} catch (e) {
console.error("Worker failed to detect model dimensions:", e);
}
}
async function stepModel(record = true) {
if (!session) return "?";
const inputTensor = makeTensorId(currentId);
const output = await session.run({ input: inputTensor, h, c });
inputTensor.dispose();
if (h) { try { h.dispose(); } catch (e) {} }
if (c) { try { c.dispose(); } catch (e) {} }
h = output.h_out;
c = output.c_out;
const logitsData = output.logits.data;
currentId = sampleFromLogitsWithHeuristics(logitsData);
output.logits.dispose();
const token = itos[currentId] ?? "?";
if (token.startsWith("NOTE_")) {
const p = Number(token.slice(5));
if (Number.isFinite(p)) lastSampledPitch = p;
}
if (token === "EOS") {
if (h) { try { h.dispose(); } catch (e) {} }
if (c) { try { c.dispose(); } catch (e) {} }
h = zeroState();
c = zeroState();
}
if (record && parser) {
parser.feed(token);
}
return token;
}
// Logit modifier incorporating all heuristics, exact rhythm, & trained-style guidance
function sampleFromLogitsWithHeuristics(logits) {
let effectiveTemp = Math.max(0.05, temperature);
const currentBar = parser ? parser.bar : 0;
const currentPos = parser ? parser.pos : 0;
const phraseBarIdx = Math.max(0, currentBar) % 4;
const isCadenceStep = (phraseBarIdx === 3) && (currentPos >= 48);
if (heuristicSettings.dynamicTempEnabled) {
if (phraseBarIdx <= 1) {
effectiveTemp *= 0.85;
} else if (phraseBarIdx === 2) {
effectiveTemp *= 1.0;
} else if (phraseBarIdx === 3) {
effectiveTemp *= 1.18;
}
}
const k = Math.max(1, Math.min(topK, MAX_TOPK_BUFFER, logits.length));
let filled = 0;
const scaleDegrees = keyDetector.getScaleDegrees();
const detectedRoot = keyDetector.detectedRoot;
const isStrongBeat = (currentPos === 0 || currentPos === 16 || currentPos === 32 || currentPos === 48);
const simWeight = heuristicSettings.midiSimilarityWeight;
const useSimilarity = (playingStyle === "midi" || playingStyle === "trained") && simWeight > 0 && activeStyleProfile && !activeStyleProfile.isEmpty();
// Exact bar-for-bar guidance only applies to single-track "continue" mode, and only
// matters once the similarity weight is fairly high (it's the "strict reproduction" end).
const useExactGuide = playingStyle === "midi" && simWeight > 0.5 && exactGuideTotalBars > 0;
const exactStrength = useExactGuide ? Math.min(1, (simWeight - 0.5) / 0.5) : 0; // 0..1 above the 0.5 midpoint
let guideNotes = null;
if (useExactGuide) guideNotes = getExactGuideNotes(currentBar, currentPos);
for (let i = 0; i < logits.length; i++) {
const token = itos[i];
if (token === undefined) continue;
let score = logits[i] / effectiveTemp;
// BAN EOS AND EOP WHEN PLAYING A GUIDED STYLE TO PREVENT MODEL FROM STOPPING MID-SONG
if (token === "<EOP>" || token === "EOS") {
if (playingStyle === "midi" || playingStyle === "trained") {
score -= 100.0;
} else {
score -= 5.0;
}
}
if (!isWarmingUp) {
// 🎵 1. RHYTHM GUIDANCE (POSITION TOKENS POS_)
if (token.startsWith("POS_")) {
const posVal = Number(token.slice(4));
if (Number.isFinite(posVal)) {
if (useExactGuide) {
const exactNotes = getExactGuideNotes(currentBar, posVal);
if (exactNotes && exactNotes.length > 0) {
score += 14.0 * exactStrength;
}
}
if (useSimilarity) {
const activity = activeStyleProfile.activityBiasAt(posVal);
score += activity * 10.0 * simWeight;
}
}
}
// 🎵 2. EXACT NOTE PITCH GUIDANCE (NOTE_ TOKENS)
if (token.startsWith("NOTE_")) {
const pitch = Number(token.slice(5));
if (Number.isFinite(pitch)) {
const pc = ((pitch % 12) + 12) % 12;
if (useExactGuide && guideNotes && guideNotes.length > 0) {
let matchedExact = false;
let matchedOctave = false;
let matchedHarmony = false;
for (const gNote of guideNotes) {
if (pitch === gNote.pitch) { matchedExact = true; break; }
const gPc = ((gNote.pitch % 12) + 12) % 12;
if (pc === gPc) matchedOctave = true;
else if (Math.abs(pc - gPc) === 3 || Math.abs(pc - gPc) === 4 || Math.abs(pc - gPc) === 7) {
matchedHarmony = true;
}
}
if (matchedExact) score += 20.0 * exactStrength;
else if (matchedOctave) score += 6.0 * exactStrength;
else if (matchedHarmony) score += 3.0 * exactStrength;
else score -= 10.0 * exactStrength;
}
if (useSimilarity) {
const pcBias = activeStyleProfile.pitchClassBiasAt(currentPos, pc);
if (pcBias >= 0) {
// Data exists at this position: pull toward what the trained style favors,
// and softly push away from pitch-classes it never uses here.
score += (pcBias - 0.5) * 16.0 * simWeight;
}
if (lastSampledPitch !== null) {
const interval = Math.max(-12, Math.min(12, pitch - lastSampledPitch));
const intervalBias = activeStyleProfile.intervalBias(interval);
score += intervalBias * 6.0 * simWeight;
}
}
if (heuristicSettings.keyBiasEnabled) {
if (scaleDegrees.has(pc)) {
score += 0.45 * heuristicSettings.keyBiasStrength;
} else {
score -= 0.65 * heuristicSettings.keyBiasStrength;
}
}
if (heuristicSettings.chordPenaltiesEnabled && isStrongBeat) {
const triadNotes = [detectedRoot, (detectedRoot + (keyDetector.isMinor ? 3 : 4)) % 12, (detectedRoot + 7) % 12];
if (triadNotes.includes(pc)) {
score += 0.5;
} else if (!scaleDegrees.has(pc)) {
score -= 0.9;
}
}
if (heuristicSettings.phraseMemoryEnabled) {
const motifPitch = phraseMemory.getMotifPitchForStep(currentBar, currentPos);
if (motifPitch !== null) {
if (pitch === motifPitch) {
score += 0.6;
} else if (((pitch % 12) + 12) % 12 === ((motifPitch % 12) + 12) % 12) {
score += 0.35;
}
}
}
if (heuristicSettings.cadenceGenEnabled && isCadenceStep) {
const tonicPitch = detectedRoot;
const dominantPitch = (detectedRoot + 7) % 12;
if (pc === tonicPitch) {
score += 0.8;
} else if (pc === dominantPitch) {
score += 0.5;
}
}
}
}
// 🎵 3. DURATION GUIDANCE (DUR_ TOKENS)
if (token.startsWith("DUR_")) {
const durSteps = Number(token.slice(4));
if (Number.isFinite(durSteps)) {
if (useExactGuide && guideNotes && guideNotes.length > 0) {
for (const gNote of guideNotes) {
if (durSteps === gNote.durationSteps) {
score += 18.0 * exactStrength;
break;
}
}
}
if (useSimilarity) {
const durBias = activeStyleProfile.durationBiasAt(currentPos, durSteps);
if (durBias >= 0) {
score += (durBias - 0.3) * 10.0 * simWeight;
}
}
if (heuristicSettings.rhythmBalancingEnabled) {
score += phraseMemory.getMonotonyPenalty(durSteps);
}
if (heuristicSettings.cadenceGenEnabled && isCadenceStep) {
if (durSteps >= 16) {
score += 0.7;
} else {
score -= 0.5;
}
}
}
}
}
if (filled < k) {
let pos = filled;
while (pos > 0 && topScoreBuf[pos - 1] < score) {
topScoreBuf[pos] = topScoreBuf[pos - 1];
topIdxBuf[pos] = topIdxBuf[pos - 1];
pos--;
}
topScoreBuf[pos] = score;
topIdxBuf[pos] = i;
filled++;
} else if (score > topScoreBuf[k - 1]) {
let pos = k - 1;
while (pos > 0 && topScoreBuf[pos - 1] < score) {
topScoreBuf[pos] = topScoreBuf[pos - 1];
topIdxBuf[pos] = topIdxBuf[pos - 1];
pos--;
}
topScoreBuf[pos] = score;
topIdxBuf[pos] = i;
}
}
const maxScore = topScoreBuf[0];
let sum = 0;
for (let j = 0; j < filled; j++) {
const p = Math.exp(topScoreBuf[j] - maxScore);
topScoreBuf[j] = p;
sum += p;
}
let r = Math.random() * sum;
for (let j = 0; j < filled; j++) {
r -= topScoreBuf[j];
if (r <= 0) return topIdxBuf[j];
}
return topIdxBuf[filled - 1];
}
function durationToSecondsFromEventSteps(steps, grid) {
const quarterSeconds = 60 / currentBpm;
return Math.max(0.025, (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 pushEvent(event) {
if (isWarmingUp) return;
tempQueue.push(event);
}
function getTokensUpToBar(tokens, targetBar) {
let barCount = 0;
const sliced = [];
for (const t of tokens) {
sliced.push(t);
if (t === "BAR") {
barCount += 1;
if (barCount > targetBar && sliced.length >= 24) {
break;
}
}
}
if (sliced.length < 16 && tokens.length >= 16) {
return tokens.slice(0, Math.min(tokens.length, 128));
}
return sliced;
}
function makeEventParser() {
let lastVelocity = 0.72;
return {
grid: 64,
bar: -1,
pos: 0,
pendingPosition: null,
pendingNotes: [],
pendingNote: null,
lastQ: 0,
reset() {
this.grid = 64;
this.bar = -1;
this.pos = 0;
this.pendingPosition = null;
this.pendingNotes = [];
this.pendingNote = null;
this.lastQ = 0;
lastVelocity = 0.72;
keyDetector.reset();
phraseMemory.reset();
},
feed(token) {
if (token.startsWith("BPM_")) {
const bpm = Number(token.slice(4));
if (Number.isFinite(bpm) && bpm >= 40 && bpm <= 400) {
if (!userBpmOverride && (playingStyle !== "midi" || isWarmingUp || midiBpmMode === "model")) {
currentBpm = bpm;
self.postMessage({ action: "tempo", bpm: 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 };
keyDetector.addPitch(midi);
}
return;
}
if (token.startsWith("DUR_") && this.pendingNote) {
const steps = Number(token.slice(4));
if (Number.isFinite(steps)) {
this.pendingNote.durationSteps = Math.max(1, steps);
phraseMemory.recordDuration(steps);
}
return;
}
if (token.startsWith("VEL_") && this.pendingNote) {
const bucket = Number(token.slice(4));
if (Number.isFinite(bucket)) {
let rawVel = Math.max(0.2, Math.min(0.95, bucket / 8));
if (heuristicSettings.velocitySmoothingEnabled) {
rawVel = lastVelocity * 0.55 + rawVel * 0.45;
const phraseBarIdx = Math.max(0, this.bar) % 4;
let phraseMult = 1.0;
if (phraseBarIdx === 0) phraseMult = 0.92;
else if (phraseBarIdx === 1) phraseMult = 1.05;
else if (phraseBarIdx === 2) phraseMult = 1.02;
else if (phraseBarIdx === 3) phraseMult = 0.88;
rawVel = Math.max(0.2, Math.min(0.98, rawVel * phraseMult));
lastVelocity = rawVel;
}
this.pendingNote.velocity = rawVel;
phraseMemory.recordNote(this.bar, this.pos, this.pendingNote.midi, this.pendingNote.durationSteps);
}
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) / currentBpm;
},
};
}
function makeParser() {
return makeEventParser();
}
async function warmPrompt() {
h = zeroState();
c = zeroState();
lastSampledPitch = null;
exactGuideBars = [];
exactGuideTotalBars = 0;
let allTokensStr;
if (playingStyle === "midi" && midiTokens.length > 0) {
allTokensStr = getTokensUpToBar(midiTokens, midiStartBar);
buildExactGuide(midiTokens);
// Also (re)build a probabilistic profile from this single track so the softer
// similarity blending at lower slider values has statistics to draw on too.
const singleTrackProfile = new StyleProfile();
singleTrackProfile.name = "Current Track";
singleTrackProfile.ingest(midiTokens, "current-track");
activeStyleProfile = singleTrackProfile;
} else if (playingStyle === "trained") {
// No single sequence to replay — the model just improvises from an empty prompt,
// steered the whole time by the learned style profile's statistics.
allTokensStr = ["BOS", `BPM_${Math.round(currentBpm)}`, "GRID_64", "BAR", "POS_0"];
} else if (playingStyle === "bach") {
allTokensStr = [
"BOS", "BPM_100", "GRID_64",
"BAR",
"POS_0", "NOTE_81", "DUR_4", "VEL_6",
"POS_4", "NOTE_79", "DUR_4", "VEL_6",
"POS_8", "NOTE_77", "DUR_4", "VEL_6",
"POS_12", "NOTE_76", "DUR_4", "VEL_6",
"POS_16", "NOTE_74", "DUR_4", "VEL_6",
"POS_20", "NOTE_73", "DUR_4", "VEL_6",
"POS_24", "NOTE_74", "DUR_16", "VEL_6",
"BAR"
];
} else if (playingStyle === "mozart") {
allTokensStr = [
"BOS", "BPM_132", "GRID_64",
"BAR",
"POS_0", "NOTE_55", "DUR_8", "VEL_6",
"NOTE_67", "DUR_8", "VEL_6",
"POS_8", "NOTE_74", "DUR_4", "VEL_7",
"POS_12", "NOTE_71", "DUR_4", "VEL_6",
"POS_16", "NOTE_79", "DUR_8", "VEL_7",
"POS_24", "NOTE_78", "DUR_4", "VEL_6",
"POS_28", "NOTE_76", "DUR_4", "VEL_6",
"POS_32", "NOTE_74", "DUR_8", "VEL_7",
"POS_40", "NOTE_72", "DUR_4", "VEL_6",
"POS_44", "NOTE_71", "DUR_4", "VEL_6",
"POS_48", "NOTE_69", "DUR_8", "VEL_6",
"POS_56", "NOTE_67", "DUR_8", "VEL_7",
"BAR",
"POS_0", "NOTE_60", "DUR_16", "VEL_5",
"NOTE_64", "DUR_16", "VEL_5",
"NOTE_69", "DUR_16", "VEL_5",
"POS_16", "NOTE_72", "DUR_4", "VEL_6",
"POS_20", "NOTE_76", "DUR_4", "VEL_6",
"POS_24", "NOTE_81", "DUR_8", "VEL_7",
"POS_32", "NOTE_79", "DUR_4", "VEL_6",
"POS_36", "NOTE_78", "DUR_4", "VEL_6",
"POS_40", "NOTE_76", "DUR_4", "VEL_6",
"POS_44", "NOTE_74", "DUR_4", "VEL_6",
"POS_48", "NOTE_71", "DUR_8", "VEL_6",
"POS_56", "NOTE_67", "DUR_8", "VEL_7",
"BAR"
];
} else if (playingStyle === "chopin") {
allTokensStr = [
"BOS", "BPM_68", "GRID_64",
"BAR",
"POS_0", "NOTE_36", "DUR_16", "VEL_4", "NOTE_72", "DUR_12", "VEL_6",
"POS_8", "NOTE_43", "DUR_8", "VEL_4",
"POS_12", "NOTE_75", "DUR_8", "VEL_6",
"POS_16", "NOTE_48", "DUR_8", "VEL_4",
"POS_24", "NOTE_51", "DUR_8", "VEL_4", "NOTE_77", "DUR_12", "VEL_6",
"POS_32", "NOTE_39", "DUR_16", "VEL_4", "NOTE_79", "DUR_16", "VEL_7",
"POS_40", "NOTE_46", "DUR_8", "VEL_4",
"POS_48", "NOTE_51", "DUR_8", "VEL_4", "NOTE_77", "DUR_8", "VEL_6",
"POS_56", "NOTE_55", "DUR_8", "VEL_4", "NOTE_75", "DUR_8", "VEL_5",
"BAR"
];
} else if (playingStyle === "debussy") {
allTokensStr = [
"BOS", "BPM_74", "GRID_64",
"BAR",
"POS_0", "NOTE_38", "DUR_32", "VEL_3", "NOTE_57", "DUR_16", "VEL_4", "NOTE_62", "DUR_16", "VEL_4", "NOTE_69", "DUR_16", "VEL_5",
"POS_16", "NOTE_59", "DUR_16", "VEL_4", "NOTE_64", "DUR_16", "VEL_4", "NOTE_71", "DUR_16", "VEL_5",
"POS_32", "NOTE_45", "DUR_32", "VEL_3", "NOTE_61", "DUR_16", "VEL_4", "NOTE_66", "DUR_16", "VEL_4", "NOTE_73", "DUR_16", "VEL_5",
"POS_48", "NOTE_64", "DUR_16", "VEL_4", "NOTE_69", "DUR_16", "VEL_4", "NOTE_76", "DUR_16", "VEL_5",
"BAR"
];
} else if (playingStyle === "beethoven") {
allTokensStr = [
"BOS", "BPM_126", "GRID_64",
"BAR",
"POS_0", "NOTE_36", "DUR_8", "VEL_7", "NOTE_48", "DUR_8", "VEL_7", "NOTE_67", "DUR_4", "VEL_7",
"POS_4", "NOTE_70", "DUR_4", "VEL_7",
"POS_8", "NOTE_68", "DUR_8", "VEL_8",
"POS_16", "NOTE_43", "DUR_8", "VEL_6", "NOTE_55", "DUR_8", "VEL_6", "NOTE_65", "DUR_4", "VEL_7",
"POS_20", "NOTE_68", "DUR_4", "VEL_7",
"POS_24", "NOTE_67", "DUR_8", "VEL_8",
"BAR"
];
} else if (playingStyle === "satie") {
allTokensStr = [
"BOS", "BPM_62", "GRID_64",
"BAR",
"POS_0", "NOTE_38", "DUR_32", "VEL_3", "NOTE_69", "DUR_20", "VEL_5",
"POS_16", "NOTE_57", "DUR_24", "VEL_3", "NOTE_62", "DUR_24", "VEL_3", "NOTE_65", "DUR_24", "VEL_3",
"BAR"
];
} else {
allTokensStr = ["BOS", "BPM_120", "GRID_64", "BAR", "POS_0"];
}
isWarmingUp = true;
if (parser) {
for (const token of allTokensStr) {
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;
}
async function pumpTokens(targetCount = 64) {
tempQueue = [];
let steps = 0;
const stepsCap = Math.max(150, targetCount * 4);
while (tempQueue.length < targetCount && steps < stepsCap) {
await stepModel(true);
steps += 1;
}
const phraseBarIdx = (Math.max(0, parser ? parser.bar : 0) % 4) + 1;
const isCadence = (phraseBarIdx === 4) && (parser ? parser.pos >= 48 : false);
self.postMessage({
action: "intelligence_state",
detectedKey: keyDetector.getKeyName(),
keyConfidence: keyDetector.confidence,
phraseBar: phraseBarIdx,
totalBar: Math.max(1, parser ? parser.bar + 1 : 1),
isCadence: isCadence,
epUsed: currentEpUsed
});
return {
events: tempQueue,
lastToken: itos[currentId] ?? "?"
};
}
// Background Offline Full Song Renderer (No Audio Latency Limits)
async function renderFullSong(targetDurationSec = 60.0) {
isRendering = true;
cancelRenderRequested = false;
const targetQuarters = Math.round((currentBpm / 60) * targetDurationSec);
const targetBars = Math.max(4, Math.round(targetQuarters / 4));
parser = makeParser();
parser.reset();
tempQueue = [];
await warmPrompt();
const renderStartTime = performance.now();
let stepCount = 0;
while (isRendering && !cancelRenderRequested) {
await stepModel(true);
stepCount++;
const currentBar = Math.max(0, parser.bar);
if (stepCount % 24 === 0) {
const elapsedMs = performance.now() - renderStartTime;
const progressPct = Math.min(99, Math.round((currentBar / targetBars) * 100));
const estTotalMs = progressPct > 2 ? (elapsedMs / (progressPct / 100)) : 0;
const estRemainingMs = Math.max(0, estTotalMs - elapsedMs);
const noteCount = tempQueue.filter(e => e.type === "note").length;
self.postMessage({
action: "renderProgress",
pct: progressPct,
currentBar: currentBar + 1,
targetBars: targetBars,
noteCount: noteCount,
elapsedMs: elapsedMs,
estRemainingMs: estRemainingMs
});
}
if (currentBar >= targetBars) {
break;
}
}
if (cancelRenderRequested) {
isRendering = false;
self.postMessage({ action: "renderCancelled" });
return;
}
parser.flushTo(parser.absoluteQFor(parser.bar + 1, 0));
isRendering = false;
const totalNotes = tempQueue.filter(e => e.type === "note").length;
self.postMessage({
action: "renderComplete",
events: tempQueue,
totalBars: parser.bar + 1,
bpm: currentBpm,
noteCount: totalNotes
});
}
// Message Router
self.onmessage = async function (e) {
const data = e.data;
switch (data.action) {
case "init":
try {
console.log(`Worker loading model: ${data.activeModelName} (WASM CPU backend)`);
activeModelName = data.activeModelName;
stoi = data.vocab.stoi;
itos = Object.fromEntries(Object.entries(data.vocab.itos).map(([key, value]) => [Number(key), value]));
vocabSize = data.vocab.vocab_size;
if (data.modelConfig) {
numLayers = data.modelConfig.numLayers || 2;
hiddenSize = data.modelConfig.hiddenSize || 2048;
}
if (session) {
try { session.dispose(); } catch (e) {}
session = null;
}
session = await ort.InferenceSession.create(data.modelBuffer, {
executionProviders: ["wasm"],
graphOptimizationLevel: "all",
});
updateModelDimensions(session);
if (h) { try { h.dispose(); } catch (e) {} h = null; }
if (c) { try { c.dispose(); } catch (e) {} c = null; }
self.postMessage({
action: "initialized",
activeModelName: activeModelName,
vocabSize: vocabSize,
epUsed: currentEpUsed
});
} catch (err) {
console.error("Worker initialization failed:", err);
self.postMessage({ action: "error", message: `Init failed: ${err.message}` });
}
break;
case "start":
try {
temperature = data.temperature;
topK = data.topK;
if (typeof data.bpm === "number" && data.bpm >= 30) {
currentBpm = data.bpm;
}
playingStyle = data.playingStyle || "default";
midiTokens = data.midiTokens || [];
midiStartBar = data.midiStartBar || 0;
midiBpmMode = data.midiBpmMode || "lock";
if (data.heuristicSettings) {
heuristicSettings = { ...heuristicSettings, ...data.heuristicSettings };
}
if (playingStyle === "trained" && (!activeStyleProfile || activeStyleProfile.isEmpty())) {
self.postMessage({ action: "error", message: "No trained style is loaded yet. Train one or load a saved style file first." });
break;
}
parser = makeParser();
parser.reset();
await warmPrompt();
const chunkSize = data.chunkSize || 64;
const initialBatch = await pumpTokens(chunkSize);
self.postMessage({
action: "started",
events: initialBatch.events,
lastToken: initialBatch.lastToken
});
} catch (err) {
console.error("Worker start failed:", err);
self.postMessage({ action: "error", message: `Start failed: ${err.message}` });
}
break;
case "pump":
try {
temperature = data.temperature;
topK = data.topK;
if (data.heuristicSettings) {
heuristicSettings = { ...heuristicSettings, ...data.heuristicSettings };
}
const chunkSize = data.chunkSize || 64;
const batch = await pumpTokens(chunkSize);
self.postMessage({
action: "events",
events: batch.events,
lastToken: batch.lastToken
});
} catch (err) {
console.error("Worker pump failed:", err);
self.postMessage({ action: "error", message: `Pump failed: ${err.message}` });
}
break;
case "renderSong":
try {
temperature = data.temperature;
topK = data.topK;
if (typeof data.bpm === "number" && data.bpm >= 30) {
currentBpm = data.bpm;
}
playingStyle = data.playingStyle || "default";
midiTokens = data.midiTokens || [];
midiStartBar = data.midiStartBar || 0;
midiBpmMode = data.midiBpmMode || "lock";
if (data.heuristicSettings) {
heuristicSettings = { ...heuristicSettings, ...data.heuristicSettings };
}
if (playingStyle === "trained" && (!activeStyleProfile || activeStyleProfile.isEmpty())) {
self.postMessage({ action: "error", message: "No trained style is loaded yet. Train one or load a saved style file first." });
break;
}
await renderFullSong(data.targetDurationSec || 60.0);
} catch (err) {
console.error("Worker render failed:", err);
self.postMessage({ action: "error", message: `Render failed: ${err.message}` });
}
break;
case "cancelRender":
cancelRenderRequested = true;
break;
case "updateBpm":
if (typeof data.bpm === "number" && data.bpm >= 30 && data.bpm <= 400) {
currentBpm = data.bpm;
userBpmOverride = true;
self.postMessage({ action: "tempo", bpm: currentBpm });
}
break;
case "updateHeuristics":
if (data.heuristicSettings) {
heuristicSettings = { ...heuristicSettings, ...data.heuristicSettings };
}
break;
// Train a new style profile from one or more uploaded (and already tokenized) MIDI files.
case "trainStyle": {
try {
const profile = new StyleProfile();
profile.name = data.name || "Untitled Style";
const datasets = data.datasets || [];
for (const set of datasets) {
profile.ingest(set.tokens || [], set.fileName || "upload.mid");
}
if (profile.isEmpty()) {
self.postMessage({ action: "error", message: "Couldn't find any notes in the uploaded MIDI file(s) to train on." });
break;
}
activeStyleProfile = profile;
self.postMessage({
action: "styleTrained",
name: profile.name,
totalNotes: profile.totalNotes,
fileCount: profile.sourceFiles.length,
fileNames: profile.sourceFiles
});
} catch (err) {
console.error("Worker trainStyle failed:", err);
self.postMessage({ action: "error", message: `Training failed: ${err.message}` });
}
break;
}
// Load a previously exported style profile (JSON) back into memory, e.g. after a page refresh.
case "loadStyleProfile": {
try {
const profile = StyleProfile.fromJSON(data.profile);
activeStyleProfile = profile;
self.postMessage({
action: "styleLoaded",
name: profile.name,
totalNotes: profile.totalNotes,
fileCount: profile.sourceFiles.length,
fileNames: profile.sourceFiles
});
} catch (err) {
console.error("Worker loadStyleProfile failed:", err);
self.postMessage({ action: "error", message: err.message || "Couldn't load that style file." });
}
break;
}
// Serialize the currently active trained style so the main thread can offer it as a download.
case "exportStyle": {
if (!activeStyleProfile || activeStyleProfile.isEmpty()) {
self.postMessage({ action: "error", message: "No trained style to export yet." });
break;
}
if (typeof data.name === "string" && data.name.trim()) {
activeStyleProfile.name = data.name.trim();
}
self.postMessage({
action: "styleExported",
profile: activeStyleProfile.toJSON()
});
break;
}
case "stop":
userBpmOverride = false;
break;
}
};