Files
sheetmusictrainer/src/music/melodyGenerator.ts
OpenClaw 0b85f24797 Mobile clipping fix + 6/8 render crash fix (root cause) + more variants
- 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.
2026-09-15 03:35:00 +00:00

264 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 } };
}