src/model/theory.js
- /**
- * Chromatic note-name array (12 entries starting from C);
- * flat-preferring by default (also used as MIDI key-signature labels),
- * sharp-preferring when keyIndex (kappa) is a sharp-favouring key.
- * @param {number} [keyIndex] - 0-11 (kappa); omitted means flat-preferring.
- * @return {string[]}
- * @example
- * noteNames(); // ["C", "Db", "D", "Eb", ...] flat default
- * noteNames(0); // ["C", "C#", "D", "D#", ...] C major prefers sharps
- * noteNames(5); // ["C", "Db", "D", "Eb", ...] F major prefers flats
- */
- export function noteNames(keyIndex) {
- return new Set([0, 2, 4, 7, 9, 11]).has(keyIndex)
- ? "C C# D D# E F F# G G# A A# B".split(" ")
- : "C Db D Eb E F Gb G Ab A Bb B".split(" ");
- }
- /**
- * Per-part time-grid; subdivision (tau) is the LCM of voice durations.
- * @param {{duration: string}[]} voice - Per-voice configs; only the first 'voices' entries are active.
- * @param {number} voices - Active voice count (|S|).
- * @param {number} numerator - Time-signature numerator (zeta).
- * @param {number} bars - Bar count for this part (iota).
- * @return {{subdivision: number, subBeatsPerBar: number, numSubBeats: number, activeBeats: Set<number>[]}}
- * Subdivision (tau); sub-beats per bar; T = iota * zeta * tau; per-voice onset sets (omicron).
- */
- export function timeGrid(voice, voices, numerator, bars) {
- const subdiv = {
- whole: 1,
- half: 1,
- quarter: 1,
- eighth: 2,
- triplet: 3,
- sixteenth: 4,
- };
- const gcd = (a, b) => (b === 0 ? a : gcd(b, a % b));
- const subdivision = voice.reduce((acc, v) => {
- const s = subdiv[v.duration] || 1;
- return (acc / gcd(acc, s)) * s;
- }, 1);
- const subBeatsPerBar = numerator * subdivision;
- const numSubBeats = bars * subBeatsPerBar;
- const stride = {
- whole: numerator * subdivision,
- half: Math.max(1, Math.round((numerator * subdivision) / 2)),
- quarter: subdivision,
- eighth: Math.max(1, Math.round(subdivision / 2)),
- triplet: Math.max(1, Math.round(subdivision / 3)),
- sixteenth: Math.max(1, Math.round(subdivision / 4)),
- };
- const activeBeats = voice.slice(0, voices).map((v) => {
- const s = stride[v.duration];
- const count = Math.floor(((bars - 1) * subBeatsPerBar) / s) + 1;
- return new Set(Array.from({ length: count }, (_, i) => i * s));
- });
- return { subdivision, subBeatsPerBar, numSubBeats, activeBeats };
- }
- /**
- * Chord tones per mode (mu): pitch classes (mod 12) for each Roman-numeral.
- * Insertion order = diatonic ordering. "NC" allows all classes.
- * V and NC are duplicated across major/minor (small DRY trade-off).
- * @type {{major: Object<string, number[]>, minor: Object<string, number[]>}}
- */
- export const CHORDS = {
- major: {
- I: [0, 4, 7],
- ii: [2, 5, 9],
- iii: [4, 7, 11],
- IV: [5, 9, 0],
- V: [7, 11, 2],
- vi: [9, 0, 4],
- viio: [11, 2, 5],
- NC: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
- },
- minor: {
- i: [0, 3, 7],
- iio: [2, 5, 8],
- III: [3, 7, 10],
- iv: [5, 8, 0],
- V: [7, 11, 2],
- bVI: [8, 0, 3],
- bVII: [10, 2, 5],
- NC: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11],
- },
- };
- /**
- * Roman-numeral display: with keyIndex returns the concrete chord name;
- * without keyIndex returns the function-label form for UI dropdowns.
- * @param {string} node - Roman-numeral chord symbol; "NC" and unknowns pass through.
- * @param {number} [keyIndex] - 0-11 (kappa); omitted means label form.
- * @return {string}
- * @example
- * chordName("I"); // "I: Tonic" (label form, key-independent)
- * chordName("V", 0); // "G" (dominant in C major)
- * chordName("vi", 0); // "Am" (minor from lowercase numeral)
- * chordName("viio", 0); // "Bo" (diminished from "o" suffix)
- * chordName("NC", 0); // "NC"
- */
- export function chordName(node, keyIndex) {
- if (keyIndex === undefined) {
- const labels = {
- I: "Tonic",
- ii: "Supertonic",
- iii: "Mediant",
- IV: "Subdominant",
- V: "Dominant",
- vi: "Submediant",
- viio: "Leading Tone",
- i: "Tonic",
- iio: "Supertonic",
- III: "Mediant",
- iv: "Subdominant",
- bVI: "Submediant",
- bVII: "Subtonic",
- NC: "No Chord",
- };
- return labels[node] ? `${node}: ${labels[node]}` : node;
- }
- if (node === "NC") {
- return "NC";
- }
- const root = (CHORDS.major[node] ?? CHORDS.minor[node])?.[0];
- if (root === undefined) {
- return node;
- }
- const suffix = /o$/.test(node) ? "o" : /^b?[a-z]/.test(node) ? "m" : "";
- return noteNames(keyIndex)[(root + keyIndex) % 12] + suffix;
- }
- /**
- * Creates the chord digraph G = (V, E, w) for a given mode, optionally tuned by a corpus preset (pi).
- * @param {'major'|'minor'} [mode='major'] - Tonal mode (mu); drives node set and default costs.
- * @param {?Object<string, {edges: Object<string, number>, chromatic: string[]}>} [preset]
- * Corpus preset (pi) keyed by mode (mu);
- * per mode edges (corpus-frequency nu thresholded by epsilon) and chromatic nodes;
- * null falls back to DEFAULTS.
- * @return {{
- * nodes: string[],
- * activeNodes: string[],
- * edges: [string,string][],
- * costs: Map<string,number>,
- * tonic: string,
- * }}
- * nodes (V, incl. chromatic), activeNodes (subset participating in edges),
- * edges (E, valid-edge keys of costs), costs (w, edge cost map), tonic ("I" or "i").
- */
- export function chordGraph(mode = "major", preset = null) {
- const tonic = mode === "minor" ? "i" : "I";
- const presetMode = preset?.[mode];
- const nodes = [
- ...Object.keys(CHORDS[mode]),
- ...(presetMode?.chromatic ?? []),
- ];
- const costs = edgeCosts(nodes, mode, presetMode);
- const edges = [...costs.keys()].map((k) => k.split("->"));
- const activeNodes = nodes.filter((n) => new Set(edges.flat()).has(n));
- return { nodes, activeNodes, edges, costs, tonic };
- }
- /**
- * Default chord-graph configuration:
- * structural costs (LOOP, NC, MISSING) and edge tables [cost, {major, minor}].
- * CADENCE (after NORMAL) overwrites shared edges.
- * MISSING is the sentinel cost for non-edges in the MZN cost matrix:
- * higher than any valid cost so the solver never traverses non-edge cells.
- * @type {{
- * CADENCE: [number, {major: string, minor: string}],
- * NORMAL: [number, {major: string, minor: string}],
- * NC: number,
- * LOOP: number,
- * MISSING: number,
- * }}
- */
- export const EDGES = {
- CADENCE: [
- 1,
- {
- major: `V->I IV->I ii->V`,
- minor: `V->i iv->i iio->V`,
- },
- ],
- NORMAL: [
- 2,
- {
- major: `
- I->ii I->iii I->IV I->V I->vi I->viio
- ii->V ii->viio
- iii->vi iii->IV
- IV->V IV->I IV->viio
- V->I V->vi
- vi->ii vi->IV
- viio->I viio->iii
- `,
- minor: `
- i->iv i->V i->bVI i->bVII i->III i->iio
- iv->V iv->i iv->iio iv->bVII
- V->i V->bVI
- iio->V
- III->bVI III->bVII III->iv
- bVI->bVII bVI->iv bVI->iio bVI->V
- bVII->III bVII->i bVII->bVI
- `,
- },
- ],
- NC: 4,
- LOOP: 8,
- MISSING: 16,
- };
- /**
- * @private Calculates costs with preset edges (skipping out-of-graph nodes) or
- * default NORMAL+CADENCE table for the mode.
- */
- function edgeCosts(nodes, mode, presetMode) {
- const costs = new Map();
- for (const v of nodes) {
- costs.set(`${v}->${v}`, EDGES.LOOP);
- if (v === "NC") {
- continue;
- }
- costs.set(`${v}->NC`, EDGES.NC);
- costs.set(`NC->${v}`, EDGES.NC);
- }
- if (presetMode) {
- const nodeSet = new Set(nodes);
- for (const [edge, cost] of Object.entries(presetMode.edges)) {
- const [from, to] = edge.split("->");
- if (!nodeSet.has(from) || !nodeSet.has(to)) {
- continue;
- }
- costs.set(edge, from === to ? Math.max(cost, EDGES.LOOP) : cost);
- }
- return costs; // skip defaults if preset edges exist
- }
- for (const [cost, byMode] of [EDGES.NORMAL, EDGES.CADENCE]) {
- for (const edge of byMode[mode].trim().split(/\s+/)) {
- costs.set(edge, cost);
- }
- }
- return costs;
- }
