Spaces:
Running
Running
| // 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; | |
| } | |
| }; |