feat: tier-1 song generator

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 02:45:32 +01:00
co-authored by Claude Sonnet 5.5
parent 470e089f92
commit cd82e05d85
5 changed files with 431 additions and 0 deletions
+122
View File
@@ -0,0 +1,122 @@
import { BASS_STANDARD, GUITAR_STANDARD, type FxPreset, type Role, type ScoreNote, type Song, type Track } from "../core/types.ts";
import { createRng } from "../core/rng.ts";
import { MAJOR_KEYS, MAJOR_SCALE, PROGRESSIONS, chordFor, pcName, type Chord } from "./harmony.ts";
import { BAR, bassBar, drumBar, guitarBar, guitarPhrase, keysBar, melodyLine, type GuitarStyle } from "./parts.ts";
import { TIERS, type TierId } from "./tiers.ts";
export { TIERS, type TierId } from "./tiers.ts";
const FORM = ["A", "A", "B", "A"] as const; // 4 bars each
const SECTION_BARS = 4;
type PartKey = "g1" | "g2" | "vox" | "bass" | "drums" | "keys";
type SectionParts = Partial<Record<PartKey, ScoreNote[]>>;
const scaleIn = (tonicPc: number, lo: number, hi: number): number[] => {
const pcs = MAJOR_SCALE.map((d) => (tonicPc + d) % 12);
const out: number[] = [];
for (let p = lo; p <= hi; p++) if (pcs.includes(p % 12)) out.push(p);
return out;
};
export function generateSong(tier: TierId, seed: number): Song {
const spec = TIERS[tier];
const rng = createRng(seed * 7919 + 17);
const keyName = rng.pick(Object.keys(MAJOR_KEYS));
const tonicPc = MAJOR_KEYS[keyName]!;
const progA = rng.pick(PROGRESSIONS);
const progB = rng.pick(PROGRESSIONS.filter((p) => p !== progA));
const minor = progA[0] === 5; // vi-first progression reads as the relative minor
const key = minor ? pcName((tonicPc + 9) % 12) + "m" : keyName;
const bpm = rng.int(spec.bpm[0], spec.bpm[1]);
const gtrPrograms = rng.chance(0.5) ? [25, 27] : [27, 25];
const keysProgram = rng.pick([0, 89]);
const keysMode = rng.pick(["pad", "halves"] as const);
const bassRhythm = rng.pick(["quarters", "halves"] as const);
const g2Fx: FxPreset = rng.pick(["clean-chorus", "light-drive"] as const);
const styleA: GuitarStyle = rng.pick(["strum", "arp"] as const);
const styleB: GuitarStyle = styleA === "strum" ? "arp" : "strum";
const kickIdx = rng.int(0, 2);
const strumA = rng.int(0, 3), strumB = rng.int(0, 3), arpA = rng.int(0, 2), arpB = rng.int(0, 2);
const chordsOf = (label: "A" | "B"): Chord[] => (label === "A" ? progA : progB).map((d) => chordFor(tonicPc, d));
const renderSection = (label: "A" | "B"): SectionParts => {
const srng = createRng(seed * 104729 + (label === "A" ? 1 : 2));
const chords = chordsOf(label);
const parts: SectionParts = {};
const style = label === "A" ? styleA : styleB;
const pat = style === "strum" ? (label === "A" ? strumA : strumB) : label === "A" ? arpA : arpB;
const gtr = (st: GuitarStyle, p: number): ScoreNote[] =>
chords.flatMap((c, b) => guitarBar(srng, st, c, p).map((n) => ({ ...n, tick: n.tick + b * BAR })));
// Guitar 1 (the only guitar for t1a-t1c): chord accompaniment, with a short melodic phrase closing the B section in solo tiers.
let g1 = gtr(style, pat);
if (spec.id === "t1a" && label === "B") {
const lastBar = SECTION_BARS - 1;
const phrase = guitarPhrase(melodyLine(srng, [chords[lastBar]!], scaleIn(tonicPc, 55, 71), lastBar * BAR, true));
g1 = [...g1.filter((n) => n.tick < lastBar * BAR), ...phrase];
}
parts.g1 = g1;
if (spec.guitars === 2) {
// Guitar 1 strums, guitar 2 arpeggiates (and plays the closing lead phrase in B).
parts.g1 = gtr("strum", label === "A" ? strumA : strumB);
let g2 = gtr("arp", label === "A" ? arpA : arpB);
if (label === "B") {
const lastBar = SECTION_BARS - 1;
const phrase = guitarPhrase(melodyLine(srng, [chords[lastBar]!], scaleIn(tonicPc, 55, 71), lastBar * BAR, true));
g2 = [...g2.filter((n) => n.tick < lastBar * BAR), ...phrase];
}
parts.g2 = g2;
}
if (spec.bass) parts.bass = chords.flatMap((c, b) => bassBar(c, bassRhythm).map((n) => ({ ...n, tick: n.tick + b * BAR })));
if (spec.drums) {
parts.drums = chords.flatMap((_, b) =>
drumBar(kickIdx, b === 0, b === SECTION_BARS - 1 && srng.chance(0.5)).map((n) => ({ ...n, tick: n.tick + b * BAR })));
}
if (spec.keys) {
let prev: number[] | undefined;
parts.keys = chords.flatMap((c, b) => {
const r = keysBar(c, prev, keysMode);
prev = r.voicing;
return r.notes.map((n) => ({ ...n, tick: n.tick + b * BAR }));
});
}
if (spec.vocals) parts.vox = melodyLine(srng, chords, scaleIn(tonicPc, 62, 76), 0, true);
return parts;
};
const sections = { A: renderSection("A"), B: renderSection("B") };
const assemble = (k: PartKey): ScoreNote[] =>
FORM.flatMap((label, i) =>
(sections[label][k] ?? []).map((n) => ({ ...n, tick: n.tick + i * SECTION_BARS * BAR })));
const tracks: Track[] = [];
const guitar = (k: "g1" | "g2", name: string, program: number, pan: number, fx: FxPreset): void => {
tracks.push({ name, role: "guitar", program, tuning: GUITAR_STANDARD, pan, fx, gainDb: 0, notes: assemble(k) });
};
if (spec.guitars === 1) guitar("g1", "Guitar", gtrPrograms[0]!, 0, "clean");
else {
guitar("g1", "Guitar 1", gtrPrograms[0]!, -0.6, "clean");
guitar("g2", "Guitar 2", gtrPrograms[1]!, 0.6, g2Fx);
}
const add = (role: Role, name: string, program: number, k: PartKey, gainDb: number, extra: Partial<Track> = {}): void => {
tracks.push({ name, role, program, pan: 0, fx: "none", gainDb, notes: assemble(k), ...extra });
};
if (spec.vocals) add("vocals", "Vocals", 53, "vox", -2);
if (spec.bass) add("bass", "Bass", 33, "bass", -3, { tuning: BASS_STANDARD });
if (spec.drums) add("drums", "Drums", 0, "drums", -4);
if (spec.keys) add("keys", "Keys", keysProgram, "keys", -6);
return {
title: `Drift ${tier} ${seed}`,
tier,
seed,
bpm,
timeSig: [4, 4],
bars: FORM.length * SECTION_BARS,
key,
tracks,
};
}
+107
View File
@@ -0,0 +1,107 @@
import { BASS_STANDARD, GUITAR_STANDARD } from "../core/types.ts";
/** Major-scale semitone offsets. */
export const MAJOR_SCALE = [0, 2, 4, 5, 7, 9, 11];
/** Major key tonics (pitch class) available to the generator; relative minors come from vi-first progressions. */
export const MAJOR_KEYS: Record<string, number> = { C: 0, G: 7, D: 2, A: 9, E: 4, F: 5 };
/** Progressions as 0-based scale degrees (I=0, IV=3, V=4, vi=5). */
export const PROGRESSIONS: readonly (readonly number[])[] = [
[0, 4, 5, 3], // I-V-vi-IV
[5, 3, 0, 4], // vi-IV-I-V
[0, 5, 3, 4], // I-vi-IV-V
[0, 3, 5, 4], // I-IV-vi-V
];
const PC_NAMES = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "G#", "A", "Bb", "B"];
export const pcName = (pc: number): string => PC_NAMES[((pc % 12) + 12) % 12]!;
/** Open/low-position chord shapes: fret per string, highest string first (e B G D A E); -1 = muted. */
export const SHAPES: Record<string, readonly number[]> = {
C: [0, 1, 0, 2, 3, -1],
G: [3, 0, 0, 0, 2, 3],
D: [2, 3, 2, 0, -1, -1],
A: [0, 2, 2, 2, 0, -1],
E: [0, 0, 1, 2, 2, 0],
F: [1, 1, 2, 3, 3, 1],
Am: [0, 1, 2, 2, 0, -1],
Em: [0, 0, 0, 2, 2, 0],
Dm: [1, 3, 2, 0, -1, -1],
Bm: [2, 3, 4, 4, 2, -1],
B: [2, 4, 4, 4, 2, -1],
Bb: [1, 3, 3, 3, 1, -1],
"C#m": [4, 5, 6, 6, 4, -1],
"F#m": [2, 2, 2, 4, 4, 2],
};
export interface Chord { name: string; rootPc: number; minor: boolean; shape: readonly number[] }
/** Diatonic triad on a degree of the major key with the given tonic pitch class (only I, IV, V, vi, plus ii/iii if needed). */
export function chordFor(tonicPc: number, degree: number): Chord {
const rootPc = (tonicPc + MAJOR_SCALE[degree]!) % 12;
const minor = degree === 1 || degree === 2 || degree === 5;
const name = pcName(rootPc) + (minor ? "m" : "");
const shape = SHAPES[name];
if (!shape) throw new Error(`no guitar shape for ${name}`);
return { name, rootPc, minor, shape };
}
export const chordPcs = (c: Chord): number[] => [c.rootPc, (c.rootPc + (c.minor ? 3 : 4)) % 12, (c.rootPc + 7) % 12];
export interface FrettedNote { pitch: number; string: number; fret: number }
/** Sounding notes of a shape, ordered low string to high string. */
export function shapeNotes(shape: readonly number[]): FrettedNote[] {
const out: FrettedNote[] = [];
for (let i = shape.length - 1; i >= 0; i--) {
const fret = shape[i]!;
if (fret >= 0) out.push({ pitch: GUITAR_STANDARD[i]! + fret, string: i + 1, fret });
}
return out;
}
/** Lowest-fret placement of a pitch on guitar (frets 0..maxFret); throws if unreachable. */
export function guitarPlacement(pitch: number, maxFret = 7): FrettedNote {
let best: FrettedNote | undefined;
GUITAR_STANDARD.forEach((open, i) => {
const fret = pitch - open;
if (fret >= 0 && fret <= maxFret && (!best || fret < best.fret)) best = { pitch, string: i + 1, fret };
});
if (!best) throw new Error(`pitch ${pitch} not playable in first ${maxFret} frets`);
return best;
}
/** Root of the chord on bass (E1..D#2 register), lowest fret placement. */
export function bassRoot(c: Chord): FrettedNote {
const pitch = 28 + (((c.rootPc - 28) % 12) + 12) % 12;
let best: FrettedNote | undefined;
BASS_STANDARD.forEach((open, i) => {
const fret = pitch - open;
if (fret >= 0 && fret <= 12 && (!best || fret < best.fret)) best = { pitch, string: i + 1, fret };
});
return best!;
}
/** Closed-position triad voicing in C3..C5 minimising movement from the previous voicing. */
export function keysVoicing(c: Chord, prev?: number[]): number[] {
const pcs = chordPcs(c);
let best: number[] | undefined;
let bestCost = Infinity;
for (let inv = 0; inv < 3; inv++) {
const order = [...pcs.slice(inv), ...pcs.slice(0, inv)];
for (let base = 48; base <= 60; base++) {
if (((base - order[0]!) % 12 + 12) % 12 !== 0) continue;
const v = [base];
for (const pc of order.slice(1)) {
let p = v[v.length - 1]! + 1;
while (((p - pc) % 12 + 12) % 12 !== 0) p++;
v.push(p);
}
if (v[v.length - 1]! > 72) continue;
const cost = prev ? v.reduce((a, p, i) => a + Math.abs(p - (prev[i] ?? prev[prev.length - 1]!)), 0) + Math.abs(v[0]! - 55) * 0.1 : Math.abs(v[0]! - 55);
if (cost < bestCost) { bestCost = cost; best = v; }
}
}
return best!;
}
+110
View File
@@ -0,0 +1,110 @@
import { TPQ, type ScoreNote } from "../core/types.ts";
import type { Rng } from "../core/rng.ts";
import { bassRoot, chordPcs, guitarPlacement, keysVoicing, shapeNotes, type Chord } from "./harmony.ts";
export const S16 = TPQ / 4; // 240
export const BAR = TPQ * 4;
const fretted = (tick: number, dur: number, f: { pitch: number; string: number; fret: number }): ScoreNote =>
({ tick, dur, pitch: f.pitch, string: f.string, fret: f.fret });
/** Strum onsets (in 16ths) per bar. */
const STRUMS: readonly (readonly number[])[] = [[0, 8], [0, 4, 8, 12], [0, 6, 8, 12], [0, 4, 6, 10, 12]];
/** Arpeggio patterns over 8 eighths; index into low->high string list, negative = from the top. */
const ARPS: readonly (readonly number[])[] = [
[0, 2, -2, -1, -2, 2, -2, -1],
[0, 1, 2, 3, -1, 3, 2, 1],
[0, 2, 3, 2, -1, 2, 3, 2],
];
export type GuitarStyle = "strum" | "arp";
export function guitarBar(rng: Rng, style: GuitarStyle, chord: Chord, patternIdx: number): ScoreNote[] {
const notes = shapeNotes(chord.shape);
const out: ScoreNote[] = [];
if (style === "strum") {
const pat = STRUMS[patternIdx % STRUMS.length]!;
pat.forEach((pos, i) => {
const end = i + 1 < pat.length ? pat[i + 1]! : 16;
for (const f of notes) out.push(fretted(pos * S16, (end - pos) * S16, f));
});
} else {
const pat = ARPS[patternIdx % ARPS.length]!;
pat.forEach((idx, i) => {
const f = notes[idx < 0 ? notes.length + idx : Math.min(idx, notes.length - 1)]!;
out.push(fretted(i * 2 * S16, 2 * S16, f));
});
}
void rng;
return out;
}
export const guitarPhrase = (melody: ScoreNote[]): ScoreNote[] =>
melody.map((n) => fretted(n.tick, n.dur, guitarPlacement(n.pitch)));
const RHYTHMS: readonly (readonly [number, number][])[] = [
[[0, 4], [4, 2], [6, 2], [8, 8]],
[[0, 8], [8, 4], [12, 4]],
[[2, 2], [4, 4], [8, 4], [12, 4]],
[[0, 6], [6, 2], [8, 4], [12, 2], [14, 2]],
[[0, 4], [4, 4], [8, 8]],
];
/**
* Monophonic stepwise melody: chord tones on strong beats, scale steps between, final note rests on a chord tone.
* `scale` = sorted scale pitches within the allowed range; one chord per bar.
*/
export function melodyLine(rng: Rng, chords: Chord[], scale: number[], startTick = 0, restOnLast = true): ScoreNote[] {
const out: ScoreNote[] = [];
let idx = Math.floor(scale.length / 2);
chords.forEach((chord, bar) => {
const pcs = chordPcs(chord);
const lastBar = bar === chords.length - 1;
const rhythm = lastBar && restOnLast ? [[0, 4], [4, 4], [8, 8]] as [number, number][] : rng.pick(RHYTHMS);
rhythm.forEach(([pos, len], k) => {
const strong = pos % 8 === 0;
if (strong || (lastBar && k === rhythm.length - 1)) {
let best = idx, bestD = Infinity;
scale.forEach((p, i) => {
if (!pcs.includes(p % 12)) return;
const d = Math.abs(i - idx) + (lastBar && k === rhythm.length - 1 && p % 12 !== chord.rootPc ? 3 : 0);
if (d < bestD || (d === bestD && rng.chance(0.5))) { best = i; bestD = d; }
});
idx = best;
} else {
idx = Math.max(0, Math.min(scale.length - 1, idx + rng.pick([-2, -1, -1, 0, 1, 1, 2])));
}
out.push({ tick: startTick + bar * BAR + pos * S16, dur: len * S16, pitch: scale[idx]! });
});
});
return out;
}
export function bassBar(chord: Chord, rhythm: "quarters" | "halves"): ScoreNote[] {
const f = bassRoot(chord);
const step = rhythm === "quarters" ? TPQ : 2 * TPQ;
const out: ScoreNote[] = [];
for (let t = 0; t < BAR; t += step) out.push(fretted(t, step, f));
return out;
}
export function keysBar(chord: Chord, prev: number[] | undefined, mode: "pad" | "halves"): { notes: ScoreNote[]; voicing: number[] } {
const v = keysVoicing(chord, prev);
const len = mode === "pad" ? BAR : BAR / 2;
const out: ScoreNote[] = [];
for (let t = 0; t < BAR; t += len) for (const p of v) out.push({ tick: t, dur: len, pitch: p });
return { notes: out, voicing: v };
}
const KICKS: readonly (readonly number[])[] = [[0, 8], [0, 6, 8], [0, 8, 10]];
export function drumBar(kickIdx: number, sectionStart: boolean, fill: boolean): ScoreNote[] {
const out: ScoreNote[] = [];
const hit = (pos: number, pitch: number) => out.push({ tick: pos * S16, dur: S16, pitch });
for (const k of KICKS[kickIdx % KICKS.length]!) hit(k, 36);
for (const s of [4, 12]) hit(s, 38);
if (fill) { hit(14, 38); hit(15, 38); }
for (let p = 0; p < 16; p += 2) if (!(p === 0 && sectionStart)) hit(p, 42);
if (sectionStart) hit(0, 49);
return out;
}
+19
View File
@@ -0,0 +1,19 @@
export type TierId = "t1a" | "t1b" | "t1c" | "t1d";
export interface TierSpec {
id: TierId;
guitars: 1 | 2;
bass: boolean;
drums: boolean;
keys: boolean;
vocals: boolean;
bpm: [number, number];
}
const bpm: [number, number] = [66, 96];
export const TIERS: Record<TierId, TierSpec> = {
t1a: { id: "t1a", guitars: 1, bass: false, drums: false, keys: false, vocals: false, bpm },
t1b: { id: "t1b", guitars: 1, bass: true, drums: true, keys: false, vocals: false, bpm },
t1c: { id: "t1c", guitars: 1, bass: true, drums: true, keys: true, vocals: true, bpm },
t1d: { id: "t1d", guitars: 2, bass: true, drums: true, keys: true, vocals: true, bpm },
};
+73
View File
@@ -0,0 +1,73 @@
import { test, expect } from "bun:test";
import { generateSong, TIERS } from "../../src/gen/generate.ts";
import { songToGp, truthFromGp } from "../../src/score/gp.ts";
import { BASS_STANDARD, GUITAR_STANDARD, TPQ } from "../../src/core/types.ts";
const EXPECTED_ROLES = {
t1a: ["guitar"],
t1b: ["bass", "drums", "guitar"],
t1c: ["bass", "drums", "guitar", "keys", "vocals"],
t1d: ["bass", "drums", "guitar", "guitar", "keys", "vocals"],
} as const;
test("tiers: structure, determinism, valid GP", () => {
for (const tier of ["t1a", "t1b", "t1c", "t1d"] as const) {
expect(TIERS[tier]).toBeDefined();
for (const seed of [1, 2, 3]) {
const s = generateSong(tier, seed);
expect(generateSong(tier, seed)).toEqual(s);
expect(s.bpm).toBeGreaterThanOrEqual(66);
expect(s.bpm).toBeLessThanOrEqual(96);
const guitars = s.tracks.filter((t) => t.role === "guitar");
expect(guitars.length).toBe(tier === "t1d" ? 2 : 1);
for (const g of guitars) {
expect(g.notes.length).toBeGreaterThan(0);
for (const n of g.notes) expect(GUITAR_STANDARD[n.string! - 1]! + n.fret!).toBe(n.pitch);
}
expect(s.tracks.map((t) => t.role).sort()).toEqual([...EXPECTED_ROLES[tier]]);
expect(new Set(s.tracks.map((t) => t.name)).size).toBe(s.tracks.length);
const end = s.bars * 4 * TPQ;
for (const t of s.tracks) for (const n of t.notes) {
expect(n.tick + n.dur).toBeLessThanOrEqual(end);
expect(n.tick % 240).toBe(0);
expect(n.dur % 240).toBe(0);
}
expect(truthFromGp(songToGp(s)).tracks.length).toBe(s.tracks.length);
}
}
const d = generateSong("t1d", 7);
const [g1, g2] = d.tracks.filter((t) => t.role === "guitar");
expect(Math.sign(g1!.pan)).not.toBe(Math.sign(g2!.pan));
expect(generateSong("t1a", 1)).not.toEqual(generateSong("t1a", 2));
});
test("musical sanity: bass on roots, vocals range/mono, keys range, no same-string clashes", () => {
for (const seed of [1, 2, 3, 4, 5]) {
const s = generateSong("t1d", seed);
const get = (r: string) => s.tracks.filter((t) => t.role === r);
const bass = get("bass")[0]!;
expect(bass.tuning).toEqual(BASS_STANDARD);
// bass is monophonic and each bar's first note is the same pitch class as the guitar's lowest... checked via key: all pcs in diatonic set
const ticks = bass.notes.map((n) => n.tick);
expect(new Set(ticks).size).toBe(ticks.length);
for (const n of bass.notes) expect(BASS_STANDARD[n.string! - 1]! + n.fret!).toBe(n.pitch);
const vox = get("vocals")[0]!;
expect(vox.program).toBe(53);
const sorted = [...vox.notes].sort((a, b) => a.tick - b.tick);
sorted.forEach((n, i) => {
expect(n.pitch).toBeGreaterThanOrEqual(60);
expect(n.pitch).toBeLessThanOrEqual(79);
if (i > 0) expect(sorted[i - 1]!.tick + sorted[i - 1]!.dur).toBeLessThanOrEqual(n.tick);
});
for (const n of get("keys")[0]!.notes) {
expect(n.pitch).toBeGreaterThanOrEqual(48);
expect(n.pitch).toBeLessThanOrEqual(72);
}
for (const g of get("guitar")) {
const byTick = new Map<number, number[]>();
for (const n of g.notes) (byTick.get(n.tick) ?? byTick.set(n.tick, []).get(n.tick)!).push(n.string!);
for (const strs of byTick.values()) expect(new Set(strs).size).toBe(strs.length);
}
expect(get("guitar").map((g) => g.fx).sort()).toContain("clean");
}
});