mirror of
https://github.com/9x/sheetmusictrainer.git
synced 2026-11-03 22:03:54 +01:00
- Clipping: min width per bar raised (16ths 420px, 8ths 330px, quarters 240px) and format width reserves more room (clef/key 100px, barline margin 24px) — narrow screens now wrap into more rows with real margins instead of clipping the last bar. - 6/8 crash ROOT CAUSE: Giuliani figures whose length didn't divide the bar into renderable note values produced 96-tick durations (32nd-ish) that durationCode can't spell -> renderer threw -> white screen. Giuliani generator now snaps figure subdivision to renderable durations (per 2/3/4 divisions, quarter-note walk as last resort); pimamim in 6/8 renders as a walking quarter figure. - 6/8 melody templates: fixed sums (were 2640/3120 in 4 templates — those filtered out, silently degrading to the fallback). - More variants: arpeggio figures 1-3-5-3, 1-2-1-3-2-1, 1-5-4-5-3-5, 1-3-2-5-3-2; Giuliani patterns p-i-a-i, p-m-a-m-i, p-i-m-a-m-i (waltz), p-i-m-a-i-a. - Tests 179/179.
264 lines
11 KiB
TypeScript
264 lines
11 KiB
TypeScript
/**
|
||
* 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 = <T,>(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 } };
|
||
}
|