const { loadMeta, loadModel, loadCorpus } = require("../training"); const { touch } = require("../state"); function weightedChoice(map, fallback) { const entries = Object.entries(map || {}).filter(([, weight]) => Number(weight) > 0); if (!entries.length) return fallback; const total = entries.reduce((sum, [, weight]) => sum + Number(weight), 0); let threshold = Math.random() * total; for (const [value, weight] of entries) { threshold -= Number(weight); if (threshold <= 0) return value; } return entries[entries.length - 1][0]; } function pickTop(map, count, fallback = []) { if (!map || !Object.keys(map).length) return [...fallback]; return Object.entries(map) .sort((a, b) => Number(b[1]) - Number(a[1])) .slice(0, count) .map(([key]) => key); } function parseTimeSignature(signature) { if (typeof signature !== "string") return { beats: 4, note: 4 }; const [beats, note] = signature.split("/").map(Number); return { beats: Number.isFinite(beats) && beats > 0 ? beats : 4, note: Number.isFinite(note) && note > 0 ? note : 4 }; } function rotateProgression(progression, offset) { if (!Array.isArray(progression) || !progression.length) return []; const len = progression.length; const shift = ((offset % len) + len) % len; return progression.map((_, idx) => progression[(idx + shift) % len]); } function deriveTempo(profile) { const mean = profile?.tempo?.mean; const min = profile?.tempo?.min; const max = profile?.tempo?.max; if (Number.isFinite(mean) && Number.isFinite(min) && Number.isFinite(max)) { const variance = Math.max(1, (max - min) / 6); const jitter = (Math.random() - 0.5) * variance; return Math.round(Math.max(40, mean + jitter)); } if (Number.isFinite(mean)) return Math.round(mean); if (Number.isFinite(min) && Number.isFinite(max)) return Math.round((min + max) / 2); return 120; } function buildArrangement(profile, duration, timeSignature, tempo, progression) { const beatsPerBar = timeSignature.beats; const blueprint = weightedChoice(profile.arrangementBlueprints, null); const sectionNames = blueprint ? blueprint.split(" > ") : ["intro", "theme", "development", "outro"]; const weights = sectionNames.map(name => profile.sections?.[name]?.avgLength || 8); const totalWeight = weights.reduce((sum, value) => sum + value, 0) || sectionNames.length; const baseProgression = progression; let cursor = 0; return sectionNames.map((name, index) => { const weight = weights[index] || (totalWeight / sectionNames.length); const lengthSeconds = duration * (weight / totalWeight); const beats = tempo * (lengthSeconds / 60); const bars = Math.max(2, Math.round(beats / beatsPerBar)); const rotated = rotateProgression(baseProgression, index * 2); const section = { name, start: Number(cursor.toFixed(2)), lengthSeconds: Number(lengthSeconds.toFixed(2)), bars, progression: rotated, focus: { instruments: pickTop(profile.instruments, 3), textures: pickTop(profile.textures, 2), motif: weightedChoice(profile.melodicMotifs, null) } }; cursor += lengthSeconds; return section; }); } function buildAutomation(arrangement, textures) { const automation = []; for (const section of arrangement) { const texture = section.focus.textures[0] || textures[0] || "dynamic layer"; automation.push({ at: section.start, action: `Emphasise ${texture} in the ${section.name} section`, duration: section.lengthSeconds, intensity: section.name.includes("build") || section.name.includes("climax") ? "high" : "medium" }); } return automation; } function selectProfile(model, genre, mood) { const directKey = `${genre}::${mood}`; if (model.profiles[directKey]) return { key: directKey, profile: model.profiles[directKey] }; const fallbackEntries = Object.entries(model.profiles) .filter(([key]) => key.startsWith(`${genre}::`)) .sort((a, b) => (b[1].count || 0) - (a[1].count || 0)); if (fallbackEntries.length) return { key: fallbackEntries[0][0], profile: fallbackEntries[0][1] }; const generic = Object.entries(model.profiles) .sort((a, b) => (b[1].count || 0) - (a[1].count || 0))[0]; if (generic) return { key: generic[0], profile: generic[1] }; throw new Error("No trained profiles available yet. Please run the trainer."); } function deriveConfidence(profile, model) { const coverage = Object.keys(model.profiles || {}).length || 1; const density = (profile.count || 0) / (model.datasetSize || 1); const variety = ( Object.keys(profile.chordProgressions || {}).length + Object.keys(profile.textures || {}).length + Object.keys(profile.melodicMotifs || {}).length ) / 12; const confidence = Math.max(0.25, Math.min(0.98, 0.35 + density * 0.4 + variety * 0.3 + Math.log1p(coverage) * 0.05)); return Number(confidence.toFixed(3)); } function ensureNumber(value, fallback) { return Number.isFinite(value) ? value : fallback; } function bootstrapProfilesFromCorpus(corpus) { const profiles = {}; const ensureProfile = (genre, mood) => { const key = `${genre}::${mood}`; if (!profiles[key]) { profiles[key] = { genre, mood, count: 0, tempo: { min: Infinity, max: 0, mean: 0 }, timeSignatures: {}, keys: {}, chordProgressions: {}, bassStyles: {}, drumPatterns: {}, melodicMotifs: {}, textures: {}, instruments: {}, sections: {}, arrangementBlueprints: {} }; } return profiles[key]; }; const increment = (map, value) => { if (value === undefined || value === null) return; const key = Array.isArray(value) ? value.join(" | ") : String(value); if (!key.trim()) return; map[key] = (map[key] || 0) + 1; }; for (const sample of corpus) { if (!sample) continue; const genre = (sample.genre || "unknown").toLowerCase(); const moods = Array.isArray(sample.moods) && sample.moods.length ? sample.moods : ["default"]; for (const moodRaw of moods) { const mood = String(moodRaw || "default").toLowerCase(); const profile = ensureProfile(genre, mood); profile.count += 1; const tempoInfo = sample.tempo || {}; const min = ensureNumber(tempoInfo.min, ensureNumber(tempoInfo.mean, ensureNumber(tempoInfo, 120))); const max = ensureNumber(tempoInfo.max, ensureNumber(tempoInfo.mean, min)); const mean = ensureNumber(tempoInfo.mean, (min + max) / 2); profile.tempo.min = Number.isFinite(profile.tempo.min) ? Math.min(profile.tempo.min, min) : min; profile.tempo.max = Number.isFinite(profile.tempo.max) ? Math.max(profile.tempo.max, max) : max; profile.tempo.mean += (mean - profile.tempo.mean) / profile.count; for (const sig of sample.timeSignatures || []) increment(profile.timeSignatures, sig); for (const keySig of sample.keys || []) increment(profile.keys, keySig); for (const progression of sample.chordProgressions || []) increment(profile.chordProgressions, progression); for (const bass of sample.bassStyles || []) increment(profile.bassStyles, bass); for (const drum of sample.drumPatterns || []) increment(profile.drumPatterns, drum); for (const motif of sample.melodicMotifs || []) increment(profile.melodicMotifs, motif); for (const texture of sample.textures || []) increment(profile.textures, texture); for (const instrument of sample.instruments || []) increment(profile.instruments, instrument); if (Array.isArray(sample.arrangement)) { const names = []; for (const part of sample.arrangement) { if (!part) continue; const name = String(part.section || part.name || part.part || "section").toLowerCase(); const length = Number(part.length || part.bars || part.measures || 8); names.push(name); const section = profile.sections[name] || { count: 0, avgLength: 0 }; section.count += 1; section.avgLength += (length - section.avgLength) / section.count; profile.sections[name] = section; } if (names.length) increment(profile.arrangementBlueprints, names.join(" > ")); } } } return profiles; } function ensureModelProfiles(model, corpus) { if (Object.keys(model.profiles || {}).length) return model; if (!Array.isArray(corpus) || !corpus.length) { throw new Error("No model profiles available and dataset is empty. Provide training data first."); } const bootstrapped = bootstrapProfilesFromCorpus(corpus); model = { ...model, profiles: bootstrapped, datasetSize: corpus.length, updatedAt: new Date().toISOString() }; return model; } function generateMusicPlan(params = {}) { const genre = (params.genre || "electronic").toLowerCase(); const mood = (params.mood || "uplifting").toLowerCase(); const duration = Math.max(10, Number(params.duration) || 30); const meta = loadMeta(); let model = loadModel(); const corpus = loadCorpus(); model = ensureModelProfiles(model, corpus); const { key, profile } = selectProfile(model, genre, mood); const tempo = deriveTempo(profile); const signatureText = weightedChoice(profile.timeSignatures, "4/4"); const timeSignature = parseTimeSignature(signatureText); const keySignature = weightedChoice(profile.keys, "C Major"); const progressionText = weightedChoice(profile.chordProgressions, "I | V | vi | IV"); const progression = progressionText.split("|").map(token => token.trim()).filter(Boolean); const arrangement = buildArrangement(profile, duration, timeSignature, tempo, progression); const instrumentation = pickTop(profile.instruments, 6, ["synth pad", "percussion"]); const rhythm = pickTop(profile.drumPatterns, 4, ["steady beat"]); const bass = pickTop(profile.bassStyles, 3, ["root notes"]); const motifs = pickTop(profile.melodicMotifs, 4); const textures = pickTop(profile.textures, 4); const automation = buildAutomation(arrangement, textures); const confidence = deriveConfidence(profile, model); const datasetInfo = { datasetSize: model.datasetSize || corpus.length, corpusEntries: corpus.length, profileKey: key, profileObservations: profile.count || 0 }; touch("last_music_generation"); return { ok: true, metadata: { genre, mood, duration, tempo, key: keySignature, timeSignature: signatureText, engineVersion: "narrow-ai/v1", generatedAt: new Date().toISOString(), datasetDigest: model.datasetDigest || meta.datasetDigest || null, trainingStep: meta.step }, composition: { progression, arrangement, instrumentation: { core: instrumentation, rhythm, bass, motifs, textures }, performance: { groove: rhythm[0] || "steady beat", bassLeadInteraction: bass[0] ? `${bass[0]} supporting ${progression[0] || "root"}` : "root support", melodicFocus: motifs[0] || progression[0] || "motif driven", dynamics: arrangement.map(section => ({ section: section.name, target: section.focus.textures[0] || section.focus.instruments[0] || "ensemble", contour: section.name.includes("build") || section.name.includes("climax") ? "crescendo" : "balanced" })) }, automation, recommendations: { transitions: arrangement.slice(1).map(section => ({ at: section.start, idea: `Use a ${textures[0] || "filter"} sweep into the ${section.name} section.` })), mixing: [ `Set master tempo to ${tempo} BPM with ${signatureText} feel.`, bass[0] ? `Anchor the low end with ${bass[0]}.` : "Ensure low frequencies remain tight.", textures[0] ? `Highlight ${textures[0]} using slow attack compression.` : "Add depth with subtle reverb layers.", rhythm[0] ? `Keep ${rhythm[0]} consistent for cohesion.` : "Maintain a steady pulse throughout." ] } }, analysis: { confidence, dataset: datasetInfo, reasoning: [ `Selected profile ${key} with ${profile.count || 0} observations from the corpus.`, `Tempo of ${tempo} BPM derived from mean ${profile.tempo?.mean ?? tempo} BPM in the dataset.`, `Chord progression emphasises ${progression[0] || "primary"} movement favoured in the dataset.`, textures[0] ? `Textural focus leans on ${textures[0]} which appears frequently in this mood.` : "Texture palette kept intentionally broad." ] } }; } module.exports = { generateMusicPlan };