/** * Seeded melody generator (Phrase Mode). * * Deterministic: all randomness flows through a supplied seed so Retry can * replay the identical phrase (the trainer never regenerates) and tests can * assert invariants over many seeds (contract C3). * * Hard invariants (tested): every pitch ∈ scale ∩ playable pool, exact bar * totals, valid spelling, determinism, bounded loops. * Musical preferences (steps, small contours, tonic endings) are SOFT — a * sparse fretboard pool can make them impossible, and the generator must * still terminate and stay in the pool rather than escape its constraints. */ import { PPQ, validateScore, type Result, type Score, type ScoreEvent } from '../score/model'; import { keyFor, scalePitches, type ModeId } from './scales'; /** mulberry32 — small, fast, deterministic. */ export function makeRng(seed: number): () => number { let a = seed >>> 0; return () => { a |= 0; a = (a + 0x6D2B79F5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } export interface MelodyConfig { readonly keyTonic: string; readonly keyMode: ModeId; /** 1..8 */ readonly bars: number; readonly meter: { readonly numerator: 3 | 4 | 6; readonly denominator: 4 }; /** 1 = Simple (quarters/halves/wholes), 2 = Mixed (+ paired eighths, dotted quarters), * 3 = Elaborate (+ sixteenths, sixteenth groups, dotted eighths). */ readonly rhythmLevel: 1 | 2 | 3; readonly seed: number; } // Rhythm templates in ticks (quarter = 480). Each template fills one full bar. const TEMPLATES_4_L1: number[][] = [ [480, 480, 480, 480], [960, 960], [480, 480, 960], [960, 480, 480], [480, 960, 480], [1920], [1440, 480], ]; const TEMPLATES_4_L2: number[][] = [ [480, 480, 240, 240, 480], [240, 240, 480, 480, 480], [480, 480, 480, 240, 240], [720, 720, 480], [720, 480, 240, 240, 240], ]; // Level 3: sixteenth-note material (120 = 16th, 360 = dotted 8th). const TEMPLATES_4_L3: number[][] = [ [480, 480, 120, 120, 120, 120, 480], [120, 120, 120, 120, 480, 480, 480], [480, 120, 120, 120, 120, 480, 480], [360, 120, 480, 480, 480], [480, 360, 120, 480, 480], [120, 120, 120, 120, 120, 120, 120, 120, 480, 480], [480, 480, 360, 120, 480], [720, 120, 120, 120, 120, 480], ]; // 6/8 templates (6 quarter-ticks = 2880 per bar; 8th-note oriented). const TEMPLATES_6_L1: number[][] = [ [240, 240, 240, 240, 240, 240, 240, 240, 480, 480], [480, 480, 240, 240, 240, 240, 480, 480], [240, 240, 480, 480, 480, 240, 240, 240, 240], [960, 480, 480, 480, 480], [480, 480, 480, 480, 960], [1440, 480, 480, 480], ]; const TEMPLATES_6_L2: number[][] = [ [480, 480, 120, 120, 120, 120, 240, 240, 480, 480], [120, 120, 120, 120, 480, 480, 240, 240, 240, 240, 240, 240], [480, 240, 240, 480, 240, 240, 240, 240, 240, 240], [480, 240, 240, 480, 240, 240, 240, 240, 240, 240], [480, 240, 240, 480, 240, 240, 240, 240, 240, 240],]; const TEMPLATES_3_L1: number[][] = [ [480, 480, 480], [960, 480], [480, 960], [1440], ]; const TEMPLATES_3_L2: number[][] = [ [240, 240, 480, 480], [480, 240, 240, 480], [720, 720], [720, 240, 240, 240], ]; // Level 3 for 3/4: sixteenth material filling 1440 ticks. const TEMPLATES_3_L3: number[][] = [ [480, 120, 120, 120, 120, 480, 480], [120, 120, 120, 120, 480, 480, 480], [360, 120, 480, 480, 480], [480, 360, 120, 480, 480], [120, 120, 120, 120, 120, 120, 120, 120, 480], ]; function templatesFor(meter: { numerator: 3 | 4 | 6 }, level: 1 | 2 | 3): number[][] { let base: number[][]; if (meter.numerator === 4) { base = level === 1 ? TEMPLATES_4_L1 : level === 2 ? [...TEMPLATES_4_L1, ...TEMPLATES_4_L2] : [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_4_L3]; } else if (meter.numerator === 6) { base = level === 1 ? TEMPLATES_6_L1 : [...TEMPLATES_6_L1, ...TEMPLATES_6_L2]; } else { base = level === 1 ? TEMPLATES_3_L1 : level === 2 ? [...TEMPLATES_3_L1, ...TEMPLATES_3_L2] : [...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_3_L3]; } const out = base.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ); // Never return an empty pool — fall back to the closest available level // so generator.pick on an empty array can never crash the app. if (out.length === 0) { const all = [...TEMPLATES_4_L1, ...TEMPLATES_4_L2, ...TEMPLATES_3_L1, ...TEMPLATES_3_L2, ...TEMPLATES_6_L1]; const anyFit = all.filter(t => t.reduce((a, b) => a + b, 0) === meter.numerator * PPQ); return anyFit.length > 0 ? anyFit : [[meter.numerator * PPQ]]; } return out; } /** Motion weights by semitone distance (soft preference, contract C3). */ function motionWeight(delta: number, afterLeap: boolean, repeats: number): number { const ad = Math.abs(delta); if (delta === 0) return repeats >= 2 ? 0 : 6; // never more than two in a row if (ad <= 2) return afterLeap ? 240 : 70; // steps (preferred, esp. after leaps) if (ad <= 4) return afterLeap ? 120 : 20; // thirds if (ad <= 7) return 8; // fourth/fifth if (ad <= 12) return 2; // sixth/octave return 0; // out of pool handled separately } export function generateMelody( config: MelodyConfig, eligiblePitches: number[], ): Result<{ score: Score; seed: number }> { const { bars, meter, rhythmLevel, seed } = config; if (!Number.isInteger(bars) || bars < 1 || bars > 8) { return { ok: false, error: 'Bars must be 1–8.' }; } const key = keyFor(config.keyTonic, config.keyMode); const pool = Array.from(new Set(eligiblePitches.filter(m => Number.isInteger(m)))).sort((a, b) => a - b); if (pool.length === 0) return { ok: false, error: 'No playable notes in the selected range — widen the note set or change the key.' }; const scale = scalePitches(key, pool[0], pool[pool.length - 1]).filter(p => pool.includes(p.midi)); if (scale.length < 2) { return { ok: false, error: 'The selected range/key combination leaves fewer than two usable pitches — widen the range or change the key.' }; } const templates = templatesFor(meter, rhythmLevel); const endingTemplates = templates.filter(t => t[t.length - 1] >= 960); const finalTemplates = endingTemplates.length > 0 ? endingTemplates : templates; const rng = makeRng(seed); const pick = (arr: T[]): T => arr[Math.floor(rng() * arr.length)]; // ---- Build the rhythm (bar by bar) ------------------------------------- const rhythm: number[] = []; for (let bar = 0; bar < bars; bar++) { const t = bar === bars - 1 ? finalTemplates : templates; rhythm.push(...pick(t)); } // ---- Build the pitches ------------------------------------------------- // Weighted random walk over the eligible scale notes with soft musical // preferences. Bounded: exactly one decision per rhythm slot. const tonicPc = key.pitchClasses[0]; const thirdPc = key.pitchClasses[2]; const fifthPc = key.pitchClasses[4]; const events: ScoreEvent[] = []; let idx = Math.floor(rng() * scale.length); // start anywhere; endpoints preferred next // Preferred start: tonic, else 3rd/5th. const startCandidates = scale .map((p, i) => ({ p, i })) .filter(({ p }) => p.pitch.midi % 12 === tonicPc); if (startCandidates.length > 0) idx = pick(startCandidates).i; else { const alt = scale.map((p, i) => ({ p, i })).filter(({ p }) => [thirdPc, fifthPc].includes(p.pitch.midi % 12)); if (alt.length > 0) idx = pick(alt).i; } let lastWasLeap = false; // previous motion was a leap ≥ 5 semitones let leapFromMidi = 0; // origin pitch of that leap (step-back target) let repeats = 0; let cursor = 0; for (let slot = 0; slot < rhythm.length; slot++) { const dur = rhythm[slot]; const isFinal = slot === rhythm.length - 1; const cur = scale[idx]; let chosen = idx; if (isFinal) { // Soft: end on tonic, else 3rd/5th, else nearest to current. const finals = scale.map((p, i) => ({ p, i })).filter(({ p }) => p.pitch.midi % 12 === tonicPc); const alt = scale.map((p, i) => ({ p, i })).filter(({ p }) => [thirdPc, fifthPc].includes(p.pitch.midi % 12)); const cand = finals.length > 0 ? finals : alt; if (cand.length > 0) { chosen = pick(cand).i; } } else { const weights = scale.map((p, i) => { if (i === idx) return motionWeight(0, false, repeats); const delta = p.midi - cur.midi; let w = motionWeight(delta, lastWasLeap, repeats); // A leap (≥5) prefers a subsequent step back toward its origin. if (lastWasLeap && Math.abs(delta) <= 2) { const stepBackToward = Math.sign(delta) === Math.sign(leapFromMidi - cur.midi) || leapFromMidi === cur.midi; if (stepBackToward) w = Math.max(w, 240); } return w; }); const total = weights.reduce((a, b) => a + b, 0); if (total > 0) { let r = rng() * total; for (let i = 0; i < weights.length; i++) { r -= weights[i]; if (r <= 0) { chosen = i; break; } } } else { chosen = Math.floor(rng() * scale.length); } } const note = scale[chosen]; const moved = Math.abs(note.midi - cur.midi); if (moved >= 5) { lastWasLeap = true; leapFromMidi = cur.midi; } else { lastWasLeap = false; leapFromMidi = 0; } if (note.midi === cur.midi) { if (!isFinal) repeats++; } else { repeats = 0; } events.push({ id: `n${slot}`, startTick: cursor, durationTicks: dur, pitch: { ...note.pitch }, }); cursor += dur; idx = chosen; } const score: Score = { version: 1, id: `melody-${config.keyTonic}-${config.keyMode}-${meter.numerator}4-${bars}b-${rhythmLevel}-s${seed}`, title: `${key.tonic} ${key.degrees.length === 5 ? (config.keyMode === 'major-pentatonic' ? 'Major Pentatonic' : 'Minor Pentatonic') : 'Melody'}`, meter, key: { tonic: config.keyTonic, mode: config.keyMode, signature: key.signature }, measures: Array.from({ length: bars }, (_, i) => ({ number: i + 1, startTick: i * meter.numerator * PPQ, durationTicks: meter.numerator * PPQ, })), voices: [{ id: 'melody', events }], }; const problems = validateScore(score); if (problems.length > 0) return { ok: false, error: `Internal generator error: ${problems[0]}` }; return { ok: true, value: { score, seed } }; }