From cd82e05d852c62f082c6c5dc1dafee5176ee375b Mon Sep 17 00:00:00 2001 From: Lucas Winther Date: Fri, 30 Oct 2026 02:45:32 +0100 Subject: [PATCH] feat: tier-1 song generator Co-Authored-By: Claude Sonnet 5.5 --- src/gen/generate.ts | 122 +++++++++++++++++++++++++++++++++++++ src/gen/harmony.ts | 107 ++++++++++++++++++++++++++++++++ src/gen/parts.ts | 110 +++++++++++++++++++++++++++++++++ src/gen/tiers.ts | 19 ++++++ tests/gen/generate.test.ts | 73 ++++++++++++++++++++++ 5 files changed, 431 insertions(+) create mode 100644 src/gen/generate.ts create mode 100644 src/gen/harmony.ts create mode 100644 src/gen/parts.ts create mode 100644 src/gen/tiers.ts create mode 100644 tests/gen/generate.test.ts diff --git a/src/gen/generate.ts b/src/gen/generate.ts new file mode 100644 index 0000000..103c3da --- /dev/null +++ b/src/gen/generate.ts @@ -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>; + +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 = {}): 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, + }; +} diff --git a/src/gen/harmony.ts b/src/gen/harmony.ts new file mode 100644 index 0000000..ab99982 --- /dev/null +++ b/src/gen/harmony.ts @@ -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 = { 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 = { + 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!; +} diff --git a/src/gen/parts.ts b/src/gen/parts.ts new file mode 100644 index 0000000..5afb68d --- /dev/null +++ b/src/gen/parts.ts @@ -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; +} diff --git a/src/gen/tiers.ts b/src/gen/tiers.ts new file mode 100644 index 0000000..93727e4 --- /dev/null +++ b/src/gen/tiers.ts @@ -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 = { + 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 }, +}; diff --git a/tests/gen/generate.test.ts b/tests/gen/generate.test.ts new file mode 100644 index 0000000..34ce707 --- /dev/null +++ b/tests/gen/generate.test.ts @@ -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(); + 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"); + } +});