feat: tier-1 song generator
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
470e089f92
commit
cd82e05d85
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
Reference in New Issue
Block a user