@wenathlan/debonair 2.0.94 → 2.0.117
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -1
- package/docs/.gitkeep +0 -0
- package/docs/competitor-dissection.md +572 -0
- package/docs/enginemap.md +94 -0
- package/docs/planning.ladder.md +1107 -0
- package/docs/planning.v2.28-completo.md +69 -0
- package/docs/planning.versions.2.0.97-3.0.md +48 -0
- package/package.json +25 -1
- package/tests/.gitkeep +0 -0
- package/tests/analyze.test.ts +54 -0
- package/tests/arrangement.test.ts +109 -0
- package/tests/arranger.test.ts +116 -0
- package/tests/beatgrid.test.ts +0 -0
- package/tests/dawview.test.ts +104 -0
- package/tests/ducking.test.ts +0 -0
- package/tests/family.test.ts +80 -0
- package/tests/familyurl.test.ts +32 -0
- package/tests/fingerprint.test.ts +61 -0
- package/tests/genrematrix.test.ts +116 -0
- package/tests/harmonyengine.test.ts +204 -0
- package/tests/katexis.test.ts +97 -0
- package/tests/loudness.test.ts +0 -0
- package/tests/midiwriter.test.ts +183 -0
- package/tests/mixergraph.test.ts +0 -0
- package/tests/motionpref.test.ts +82 -0
- package/tests/musictheory.test.ts +231 -0
- package/tests/onboardingstate.test.ts +68 -0
- package/tests/pianoroll.test.ts +336 -0
- package/tests/remixplan.test.ts +0 -0
- package/tests/rhythmgrammar.test.ts +205 -0
- package/tests/solcss.test.ts +148 -0
- package/tests/spectrum.test.ts +59 -0
- package/tests/speechalign.test.ts +0 -0
- package/tests/stems.test.ts +127 -0
- package/tests/takeevents.test.ts +129 -0
- package/tests/takestore.test.ts +93 -0
- package/tests/timelinemodel.test.ts +0 -0
- package/tests/tuning.test.ts +27 -0
- package/tests/vocalsynth.test.ts +91 -0
- package/tests/wavwriter.test.ts +137 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// # familyurl.test — the family link resolver of the root layer: the relative
|
|
2
|
+
// sibling path the chrome builds for the other deploy units of the family,
|
|
3
|
+
// runnable with the vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// Every test watches the contract the family promises: the url always climbs
|
|
6
|
+
// one level into the sibling folder, is slash-terminated, survives slugs
|
|
7
|
+
// that arrive decorated with slashes and answers for every member of the
|
|
8
|
+
// family.
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, it } from "vitest";
|
|
11
|
+
import { familyurl } from "../familyurl.ts";
|
|
12
|
+
|
|
13
|
+
describe("familyurl", () => {
|
|
14
|
+
it("climbs one level and enters the sibling folder, slash-terminated", () => {
|
|
15
|
+
assert.equal(familyurl("cadria"), "../cadria/");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("strips decorative slashes from the slug", () => {
|
|
19
|
+
assert.equal(familyurl("/cadria/"), "../cadria/");
|
|
20
|
+
assert.equal(familyurl("///cadria///"), "../cadria/");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("answers for every deploy unit of the family", () => {
|
|
24
|
+
for (const slug of ["devthink", "cadria", "stealthhead", "foundry", "forge", "vault", "getry"]) {
|
|
25
|
+
assert.equal(familyurl(slug), `../${slug}/`);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("keeps multi-segment slugs as the caller wrote them (minus the decoration)", () => {
|
|
30
|
+
assert.equal(familyurl("family/apps"), "../family/apps/");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// # fingerprint.test — the audio identity domain: the constellation is
|
|
2
|
+
// deterministic, the landmarks fold into stable 32-bit hashes, the db
|
|
3
|
+
// answers the right track by votes and stays null on an empty index.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "vitest";
|
|
6
|
+
import { FingerprintDB, fingerprintOf, landmarksOf, peaksOfFrame } from "../fingerprint.ts";
|
|
7
|
+
import { frameSpectrum } from "../spectrum.ts";
|
|
8
|
+
|
|
9
|
+
/** Builds a deterministic tone mixture the tests analyze. */
|
|
10
|
+
function tone(seedHz: number, seconds: number, sampleRate = 44100): number[] {
|
|
11
|
+
const out: number[] = [];
|
|
12
|
+
for (let i = 0; i < seconds * sampleRate; i++) {
|
|
13
|
+
const t = i / sampleRate;
|
|
14
|
+
out.push(0.5 * Math.sin(2 * Math.PI * seedHz * t) + 0.25 * Math.sin(2 * Math.PI * (seedHz * 2) * t));
|
|
15
|
+
}
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe("fingerprint — constellation", () => {
|
|
20
|
+
it("picks at most one peak per band and keeps them in-band", () => {
|
|
21
|
+
const sine = tone(220, 0.1);
|
|
22
|
+
const frame = frameSpectrum(sine.slice(0, 4096));
|
|
23
|
+
const peaks = peaksOfFrame(frame, 0);
|
|
24
|
+
assert.ok(peaks.length >= 1 && peaks.length <= 5);
|
|
25
|
+
for (const peak of peaks) assert.ok(peak.bin > 0 && peak.bin < frame.magnitudes.length);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("answers identical landmarks for identical audio", () => {
|
|
29
|
+
const a = fingerprintOf(tone(330, 0.5), 44100);
|
|
30
|
+
const b = fingerprintOf(tone(330, 0.5), 44100);
|
|
31
|
+
assert.deepEqual(a, b);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("folds pair lags into the hash without collisions on distinct pairs", () => {
|
|
35
|
+
const landmarks = landmarksOf([
|
|
36
|
+
{ frame: 0, bin: 10 },
|
|
37
|
+
{ frame: 4, bin: 40 },
|
|
38
|
+
{ frame: 8, bin: 90 },
|
|
39
|
+
]);
|
|
40
|
+
const hashes = new Set(landmarks.map((l) => l.hash));
|
|
41
|
+
assert.equal(hashes.size, landmarks.length);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("fingerprint — FingerprintDB", () => {
|
|
46
|
+
it("identifies the registered track by votes", () => {
|
|
47
|
+
const db = new FingerprintDB();
|
|
48
|
+
const track = fingerprintOf(tone(294, 0.6), 44100);
|
|
49
|
+
db.add("track-a", track);
|
|
50
|
+
const match = db.search(fingerprintOf(tone(294, 0.6), 44100));
|
|
51
|
+
assert.equal(match.trackId, "track-a");
|
|
52
|
+
assert.ok(match.score > 0);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("answers a null track on an empty db", () => {
|
|
56
|
+
const db = new FingerprintDB();
|
|
57
|
+
const match = db.search(fingerprintOf(tone(294, 0.4), 44100));
|
|
58
|
+
assert.equal(match.trackId, null);
|
|
59
|
+
assert.equal(match.score, 0);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// # genrematrix.test — honest unit tests for the style dictionary (the
|
|
2
|
+
// fifteen entries, their bpm windows and loudness targets, the family,
|
|
3
|
+
// progression and scale references, the seeded bpm pick, the free-text
|
|
4
|
+
// genre match and the whole-matrix validation), runnable with the vitest
|
|
5
|
+
// runner:
|
|
6
|
+
// pnpm test
|
|
7
|
+
// Every fixture is the matrix table itself — no randomness beyond the
|
|
8
|
+
// engine's mulberry32 driven by fixed seeds.
|
|
9
|
+
import { describe, expect, it } from "vitest";
|
|
10
|
+
import { GENRES, genreById, matchGenre, pickBpm, validateMatrix } from "../genrematrix.ts";
|
|
11
|
+
import { PROGRESSION_IDS } from "../harmonyengine.ts";
|
|
12
|
+
import { SCALE_NAMES } from "../musictheory.ts";
|
|
13
|
+
import { FAMILY_NAMES } from "../rhythmgrammar.ts";
|
|
14
|
+
|
|
15
|
+
describe("the matrix table", () => {
|
|
16
|
+
it("holds the fifteen documented genres", () => {
|
|
17
|
+
expect(GENRES.length).toBe(15);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("carries unique lowercase ids", () => {
|
|
21
|
+
const ids = GENRES.map((genre) => genre.id);
|
|
22
|
+
expect(new Set(ids).size).toBe(15);
|
|
23
|
+
for (const id of ids) {
|
|
24
|
+
expect(id.length > 0).toBe(true);
|
|
25
|
+
expect(id, `${id} stays lowercase`).toBe(id.toLowerCase());
|
|
26
|
+
expect(/^[a-z][a-z0-9-]*$/.test(id), `${id} is a stable token`).toBe(true);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("references real rhythmgrammar families", () => {
|
|
31
|
+
for (const genre of GENRES)
|
|
32
|
+
expect(FAMILY_NAMES.includes(genre.pattern), `${genre.id} -> ${genre.pattern}`).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("references real progression sets, known scales and a legal swing", () => {
|
|
36
|
+
for (const genre of GENRES) {
|
|
37
|
+
expect(PROGRESSION_IDS.includes(genre.progressions), `${genre.id} -> ${genre.progressions}`).toBe(true);
|
|
38
|
+
expect(genre.scales.length > 0, `${genre.id} biases a scale`).toBe(true);
|
|
39
|
+
for (const scale of genre.scales) expect(SCALE_NAMES.includes(scale), `${genre.id}: ${scale}`).toBe(true);
|
|
40
|
+
expect(genre.swing >= 0.5 && genre.swing <= 0.75, `${genre.id} swing ${genre.swing}`).toBe(true);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("keeps every bpm window inside the 40-220 band with a default inside it", () => {
|
|
45
|
+
for (const genre of GENRES) {
|
|
46
|
+
expect(genre.bpmin < genre.bpmax, `${genre.id} window opens`).toBe(true);
|
|
47
|
+
expect(genre.bpmin >= 40 && genre.bpmax <= 220, `${genre.id} window [${genre.bpmin}, ${genre.bpmax}]`).toBe(true);
|
|
48
|
+
expect(genre.bpm >= genre.bpmin && genre.bpm <= genre.bpmax, `${genre.id} default bpm ${genre.bpm}`).toBe(true);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("masters inside the documented loudness band", () => {
|
|
53
|
+
for (const genre of GENRES) {
|
|
54
|
+
expect(Number.isFinite(genre.lufs), `${genre.id} lufs is a number`).toBe(true);
|
|
55
|
+
expect(genre.lufs >= -16 && genre.lufs <= -6, `${genre.id} lufs ${genre.lufs}`).toBe(true);
|
|
56
|
+
}
|
|
57
|
+
expect(genreById("ambient")?.lufs).toBe(-16);
|
|
58
|
+
expect(genreById("hyperpop")?.lufs).toBe(-7);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("tags non-empty timbre hints on every genre", () => {
|
|
62
|
+
for (const genre of GENRES) {
|
|
63
|
+
expect(genre.timbre.length > 0, `${genre.id} carries timbre`).toBe(true);
|
|
64
|
+
for (const tag of genre.timbre) expect(tag.length > 0, `${genre.id}: "${tag}"`).toBe(true);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("lookups", () => {
|
|
70
|
+
it("answers genreById with the entry or nothing", () => {
|
|
71
|
+
expect(genreById("trap")).toBe(GENRES[0]);
|
|
72
|
+
expect(genreById("trap")?.label).toBe("Trap");
|
|
73
|
+
expect(genreById("rude-trap")?.pattern).toBe("trap");
|
|
74
|
+
expect(genreById("no-such-genre")).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("seeded bpm pick", () => {
|
|
79
|
+
it("replays the same bpm for the same seed, always inside the window", () => {
|
|
80
|
+
for (const genre of GENRES) {
|
|
81
|
+
expect(pickBpm(genre, "demo")).toBe(pickBpm(genre, "demo"));
|
|
82
|
+
const bpm = pickBpm(genre, "seed-a");
|
|
83
|
+
expect(Number.isInteger(bpm), `${genre.id} picks an integer`).toBe(true);
|
|
84
|
+
expect(bpm >= genre.bpmin && bpm <= genre.bpmax, `${genre.id} bpm ${bpm}`).toBe(true);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("varies with the seed while staying inside the window", () => {
|
|
89
|
+
const trap = GENRES[0];
|
|
90
|
+
const picks = new Set(["one", "two", "three", "four", "five"].map((seed) => pickBpm(trap, seed)));
|
|
91
|
+
expect(picks.size > 1, "different seeds move the pick").toBe(true);
|
|
92
|
+
for (const bpm of picks) expect(bpm >= trap.bpmin && bpm <= trap.bpmax).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("free-text match", () => {
|
|
97
|
+
it("matches by label, alias and timbre tags", () => {
|
|
98
|
+
expect(matchGenre("Trap")?.id).toBe("trap");
|
|
99
|
+
expect(matchGenre("Drum & Bass")?.id).toBe("dnb");
|
|
100
|
+
expect(matchGenre("lo-fi")?.id).toBe("lofi");
|
|
101
|
+
expect(matchGenre("808 sub")?.id).toBe("trap");
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("scores free text and answers nothing on a miss", () => {
|
|
105
|
+
expect(matchGenre("make me a drill beat")?.id).toBe("drill");
|
|
106
|
+
expect(matchGenre("hyperpop glitch")?.id).toBe("hyperpop");
|
|
107
|
+
expect(matchGenre("zzz qqq wibble")).toBeUndefined();
|
|
108
|
+
expect(matchGenre("")).toBeUndefined();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe("matrix validation", () => {
|
|
113
|
+
it("clears the matrix against the sibling tables", () => {
|
|
114
|
+
expect(validateMatrix()).toEqual([]);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// # harmonyengine.test — honest unit tests for the progression grammar
|
|
2
|
+
// (progression sets, seeded generator, tension curve, cadence detection,
|
|
3
|
+
// reharmonisation passes), runnable with the vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// The only randomness is the engine's own mulberry32 driven by fixed seeds.
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
type Chord,
|
|
9
|
+
detectCadence,
|
|
10
|
+
generateProgression,
|
|
11
|
+
hashSeed,
|
|
12
|
+
type Key,
|
|
13
|
+
mulberry32,
|
|
14
|
+
PROGRESSION_IDS,
|
|
15
|
+
PROGRESSION_SETS,
|
|
16
|
+
pickProgression,
|
|
17
|
+
reharmonizeDiatonic,
|
|
18
|
+
reharmonizeRelative,
|
|
19
|
+
tensionOf,
|
|
20
|
+
} from "../harmonyengine.ts";
|
|
21
|
+
import { chordSymbol, parseChord, parseRoman, scalePcs } from "../musictheory.ts";
|
|
22
|
+
|
|
23
|
+
const C = { tonic: 0, mode: "major" } as const;
|
|
24
|
+
const Am = { tonic: 9, mode: "aeolian" } as const;
|
|
25
|
+
|
|
26
|
+
/** Parses a lead-sheet symbol and asserts the landing (the non-null guard the suite pins). */
|
|
27
|
+
const chord = (token: string): Chord => {
|
|
28
|
+
const parsed = parseChord(token);
|
|
29
|
+
expect(parsed, `${token} parses`).toBeTruthy();
|
|
30
|
+
return parsed as Chord;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Parses a roman numeral against a key and asserts the landing. */
|
|
34
|
+
const roman = (token: string, key: Key): Chord => {
|
|
35
|
+
const parsed = parseRoman(token, key);
|
|
36
|
+
expect(parsed, `${token} parses`).toBeTruthy();
|
|
37
|
+
return parsed as Chord;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe("seeded randomness", () => {
|
|
41
|
+
it("folds a seed string into one uint32, deterministically", () => {
|
|
42
|
+
expect(hashSeed("42")).toBe(hashSeed("42"));
|
|
43
|
+
expect(hashSeed("42")).not.toBe(hashSeed("43"));
|
|
44
|
+
expect(hashSeed(""), "the FNV offset basis").toBe(0x811c9dc5);
|
|
45
|
+
expect(Number.isInteger(hashSeed("katexis")) && hashSeed("katexis") >= 0).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("runs the same mulberry32 sequence for the same seed", () => {
|
|
49
|
+
const a = mulberry32(hashSeed("seed"));
|
|
50
|
+
const b = mulberry32(hashSeed("seed"));
|
|
51
|
+
const c = mulberry32(hashSeed("other"));
|
|
52
|
+
const first = [a(), a(), a()];
|
|
53
|
+
expect(first).toEqual([b(), b(), b()]);
|
|
54
|
+
expect(first.every((v) => v >= 0 && v < 1)).toBe(true);
|
|
55
|
+
expect(first).not.toEqual([c(), c(), c()]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("progression sets", () => {
|
|
60
|
+
it("documents the nine style families", () => {
|
|
61
|
+
expect([...PROGRESSION_IDS].sort()).toEqual(
|
|
62
|
+
["ambient", "dnb", "drill", "house", "jazz", "lofi", "pop", "techno", "trap"].sort(),
|
|
63
|
+
);
|
|
64
|
+
for (const id of PROGRESSION_IDS) {
|
|
65
|
+
const spec = PROGRESSION_SETS[id];
|
|
66
|
+
expect(spec.mode === "major" || spec.mode === "minor", `${id} names a mode`).toBe(true);
|
|
67
|
+
expect(spec.loops.length >= 2, `${id} carries loops`).toBe(true);
|
|
68
|
+
expect(spec.moods.length >= 2, `${id} carries moods`).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("parses every numeral of every loop in the set's home mode", () => {
|
|
73
|
+
for (const id of PROGRESSION_IDS) {
|
|
74
|
+
const spec = PROGRESSION_SETS[id];
|
|
75
|
+
const key: Key = spec.mode === "major" ? { tonic: 0, mode: "major" } : { tonic: 9, mode: "aeolian" };
|
|
76
|
+
for (const loop of spec.loops) {
|
|
77
|
+
for (const token of loop.split("-")) {
|
|
78
|
+
const parsed = roman(token, key);
|
|
79
|
+
if (!/^[b#]/.test(token))
|
|
80
|
+
expect(scalePcs(key).includes(parsed.root), `${id}: ${token} roots inside the scale`).toBe(true);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe("progression generator", () => {
|
|
88
|
+
it("plays the same chords for the same seed, key and mood", () => {
|
|
89
|
+
const a = generateProgression({ seed: "demo", key: C, mood: "bright radio", bars: 8 });
|
|
90
|
+
const b = generateProgression({ seed: "demo", key: C, mood: "bright radio", bars: 8 });
|
|
91
|
+
expect(a).toEqual(b);
|
|
92
|
+
expect(
|
|
93
|
+
a.map((e) => e.symbol),
|
|
94
|
+
"the pop set cycles I-V-vi-IV",
|
|
95
|
+
).toEqual(["C", "G", "Am", "F", "C", "G", "Am", "F"]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("varies with the seed and honours the bar count", () => {
|
|
99
|
+
const symbols = (seed: string) =>
|
|
100
|
+
generateProgression({ seed, key: C, mood: "bright", bars: 4 }).map((e) => e.symbol);
|
|
101
|
+
const variants = new Set(["one", "two", "three", "four"].map(symbols));
|
|
102
|
+
expect(variants.size > 1, "different seeds move the pick").toBe(true);
|
|
103
|
+
const nine = generateProgression({ seed: "demo", key: C, mood: "bright", bars: 9 });
|
|
104
|
+
expect(nine.length).toBe(9);
|
|
105
|
+
expect(nine.map((e) => e.bar)).toEqual([0, 1, 2, 3, 4, 5, 6, 7, 8]);
|
|
106
|
+
expect(nine[8].symbol, "the loop wraps onto the ninth bar").toBe(nine[0].symbol);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("routes moods to the documented sets", () => {
|
|
110
|
+
const trapKey = pickProgression(Am, "dark hard 808 night", mulberry32(1));
|
|
111
|
+
expect(PROGRESSION_IDS.find((id) => PROGRESSION_SETS[id] === trapKey)).toBe("trap");
|
|
112
|
+
const lofiKey = pickProgression(C, "chill study tape", mulberry32(1));
|
|
113
|
+
expect(PROGRESSION_IDS.find((id) => PROGRESSION_SETS[id] === lofiKey)).toBe("lofi");
|
|
114
|
+
const fallback = pickProgression(Am, "", mulberry32(1));
|
|
115
|
+
expect(fallback.mode, "a minor key with no mood still lands on a minor-mode set").toBe("minor");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("keeps every tension inside the unit band", () => {
|
|
119
|
+
for (const seed of ["a", "b", "c", "d"]) {
|
|
120
|
+
const events = generateProgression({ seed, key: Am, mood: "dark", bars: 8 });
|
|
121
|
+
for (const event of events) {
|
|
122
|
+
expect(Number.isFinite(event.tension)).toBe(true);
|
|
123
|
+
expect(event.tension >= 0 && event.tension <= 1, `tension ${event.tension}`).toBe(true);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("tension curve", () => {
|
|
130
|
+
it("weights the functional degrees and quality extensions", () => {
|
|
131
|
+
expect(tensionOf(chord("C"), C), "the tonic rests").toBeLessThan(0.2);
|
|
132
|
+
expect(tensionOf(chord("G7"), C), "the dominant pulls").toBeGreaterThan(tensionOf(chord("C"), C));
|
|
133
|
+
expect(tensionOf(chord("G7"), C), "the seventh bites").toBeGreaterThan(tensionOf(chord("G"), C));
|
|
134
|
+
expect(tensionOf(chord("Bdim7"), C), "alterations never relax the degree").toBeGreaterThanOrEqual(
|
|
135
|
+
tensionOf(chord("Bdim"), C),
|
|
136
|
+
);
|
|
137
|
+
expect(tensionOf(chord("Cmaj7"), C), "maj7 softens").toBeLessThan(tensionOf(chord("C"), C) + 1e-9);
|
|
138
|
+
expect(tensionOf(chord("Db7"), C), "a chromatic root sits at 0.7 plus the seventh").toBeCloseTo(0.75, 9);
|
|
139
|
+
expect(
|
|
140
|
+
tensionOf(chord("G7"), Am),
|
|
141
|
+
"the same chord weighs by its degree in the key: VII in minor, V in major",
|
|
142
|
+
).toBeGreaterThan(tensionOf(chord("G7"), C));
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("cadence detection", () => {
|
|
147
|
+
it("answers the documented table", () => {
|
|
148
|
+
expect(detectCadence([chord("G7"), chord("C")], C)).toBe("perfect");
|
|
149
|
+
expect(detectCadence([chord("G"), chord("C")], C)).toBe("authentic");
|
|
150
|
+
expect(detectCadence([chord("F"), chord("C")], C)).toBe("plagal");
|
|
151
|
+
expect(detectCadence([chord("Dm"), chord("G")], C)).toBe("half");
|
|
152
|
+
expect(detectCadence([chord("G"), chord("Am")], C)).toBe("deceptive");
|
|
153
|
+
expect(detectCadence([chord("Dm"), chord("E")], Am), "iv -> V in a minor key").toBe("phrygian");
|
|
154
|
+
expect(detectCadence([chord("C"), chord("Dm"), chord("G")], C), "only the last pair counts").toBe("half");
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("answers none for short, chromatic or non-cadential pairs", () => {
|
|
158
|
+
expect(detectCadence([chord("C")], C)).toBe("none");
|
|
159
|
+
expect(detectCadence([chord("Db"), chord("C")], C), "chromatic roots stay silent").toBe("none");
|
|
160
|
+
expect(detectCadence([chord("C"), chord("Dm")], C)).toBe("none");
|
|
161
|
+
expect(detectCadence([chord("Dm"), chord("G"), chord("Am")], Am), "vii-less minor pairs").toBe("none");
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("reharmonisation passes", () => {
|
|
166
|
+
it("substitutes along the documented third relations, seeded", () => {
|
|
167
|
+
const base = [chord("C"), chord("Dm"), chord("G"), chord("F"), chord("Db7")];
|
|
168
|
+
expect(reharmonizeDiatonic(base, C, mulberry32(7), 0).map(chordSymbol), "probability 0 keeps everything").toEqual([
|
|
169
|
+
"C",
|
|
170
|
+
"Dm",
|
|
171
|
+
"G",
|
|
172
|
+
"F",
|
|
173
|
+
"C#7",
|
|
174
|
+
]);
|
|
175
|
+
const all = reharmonizeDiatonic(base, C, mulberry32(1), 1).map(chordSymbol);
|
|
176
|
+
expect(all, "probability 1 swaps every diatonic partner, chromatic roots stay").toEqual([
|
|
177
|
+
"Am",
|
|
178
|
+
"F",
|
|
179
|
+
"Em",
|
|
180
|
+
"Dm",
|
|
181
|
+
"C#7",
|
|
182
|
+
]);
|
|
183
|
+
const once = reharmonizeDiatonic(base, C, mulberry32(11), 0.5);
|
|
184
|
+
const again = reharmonizeDiatonic(base, C, mulberry32(11), 0.5);
|
|
185
|
+
expect(once, "the same rng seed replays the same pass").toEqual(again);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("swaps a major key to its relative minor, notes untouched", () => {
|
|
189
|
+
const loop = [chord("C"), chord("G"), chord("Am"), chord("F")];
|
|
190
|
+
const swap = reharmonizeRelative(loop, C);
|
|
191
|
+
expect(swap.key).toEqual({ tonic: 9, mode: "aeolian" });
|
|
192
|
+
expect(swap.numerals).toEqual(["III", "VII", "i", "VI"]);
|
|
193
|
+
for (let i = 0; i < loop.length; i++) {
|
|
194
|
+
const back = roman(swap.numerals[i] ?? "", swap.key);
|
|
195
|
+
expect(chordSymbol(back), "every chord keeps its notes").toBe(chordSymbol(loop[i]));
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("swaps a minor key back to its relative major", () => {
|
|
200
|
+
const swap = reharmonizeRelative([chord("Am"), chord("F")], Am);
|
|
201
|
+
expect(swap.key).toEqual({ tonic: 0, mode: "major" });
|
|
202
|
+
expect(swap.numerals).toEqual(["vi", "IV"]);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// # katexis.test — the pure helper contract of the katexis engine root,
|
|
2
|
+
// runnable with vitest:
|
|
3
|
+
// pnpm vitest run tests/katexis.test.ts
|
|
4
|
+
// Every test watches the promises the helpers make: the tone maps stay
|
|
5
|
+
// total (every status answers a tone, every tone answers its class
|
|
6
|
+
// suffix), the mastering spec rides the documented loudness targets, the
|
|
7
|
+
// fader readout always spells one decimal with the dB unit, the track name
|
|
8
|
+
// keeps the first four words with the trailing punctuation trimmed, and
|
|
9
|
+
// the filter answers every row untouched on an empty query. The random
|
|
10
|
+
// draws only face their documented bounds (six-digit seed, 2:00-3:59).
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { describe, it } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
deriveSeed,
|
|
15
|
+
estimateDuration,
|
|
16
|
+
filterTracks,
|
|
17
|
+
formatDb,
|
|
18
|
+
genreByLabel,
|
|
19
|
+
type LibraryTrackRow,
|
|
20
|
+
MASTERING,
|
|
21
|
+
PROMPT_MAX_LENGTH,
|
|
22
|
+
statusTone,
|
|
23
|
+
toneClass,
|
|
24
|
+
trackNameFromPrompt,
|
|
25
|
+
} from "../katexis.ts";
|
|
26
|
+
|
|
27
|
+
describe("katexis tones", () => {
|
|
28
|
+
it("maps every library status to a badge tone", () => {
|
|
29
|
+
assert.equal(statusTone("ready"), "success");
|
|
30
|
+
assert.equal(statusTone("rendering"), "warning");
|
|
31
|
+
assert.equal(statusTone("draft"), "info");
|
|
32
|
+
});
|
|
33
|
+
it("answers the class suffix per tone and empty for the default", () => {
|
|
34
|
+
assert.equal(toneClass("default"), "");
|
|
35
|
+
assert.equal(toneClass("success"), " success");
|
|
36
|
+
assert.equal(toneClass("error"), " error");
|
|
37
|
+
assert.equal(toneClass("warning"), " warning");
|
|
38
|
+
assert.equal(toneClass("info"), " info");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("katexis spec", () => {
|
|
43
|
+
it("pins the mastering targets the engine documents", () => {
|
|
44
|
+
assert.equal(MASTERING.sampleRateHz, 48000);
|
|
45
|
+
assert.equal(MASTERING.truePeakDbtp, -1);
|
|
46
|
+
assert.equal(MASTERING.loudnessStandard, "BS.1770-4");
|
|
47
|
+
assert.deepEqual(MASTERING.platformTargetsLufs, { spotify: -14, apple: -16, beatport: -9 });
|
|
48
|
+
assert.equal(PROMPT_MAX_LENGTH, 280);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("katexis helpers", () => {
|
|
53
|
+
it("formats fader readouts with one decimal and the unit", () => {
|
|
54
|
+
assert.equal(formatDb(-4), "-4.0 dB");
|
|
55
|
+
assert.equal(formatDb(-12.25), "-12.3 dB");
|
|
56
|
+
assert.equal(formatDb(0), "0.0 dB");
|
|
57
|
+
});
|
|
58
|
+
it("draws the seed inside the six-digit band", () => {
|
|
59
|
+
for (let i = 0; i < 200; i++) {
|
|
60
|
+
const seed = deriveSeed();
|
|
61
|
+
assert.ok(seed >= 100000 && seed <= 999999);
|
|
62
|
+
assert.equal(Number.isInteger(seed), true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
it("keeps the estimate inside the 2:00-3:59 band", () => {
|
|
66
|
+
for (let i = 0; i < 200; i++) {
|
|
67
|
+
const estimate = estimateDuration();
|
|
68
|
+
assert.match(estimate, /^[23]:\d{2}$/);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
it("builds the track name from the first four words, punctuation trimmed", () => {
|
|
72
|
+
assert.equal(trackNameFromPrompt(" midnight harbor run. longer tail "), "Midnight harbor run. longer");
|
|
73
|
+
assert.equal(trackNameFromPrompt("deep, dark; brassy!"), "Deep, dark; brassy");
|
|
74
|
+
assert.equal(trackNameFromPrompt("solo"), "Solo");
|
|
75
|
+
});
|
|
76
|
+
it("finds a genre by its exact label", () => {
|
|
77
|
+
const genres = [
|
|
78
|
+
{ id: "techno", label: "Techno" },
|
|
79
|
+
{ id: "jazz", label: "Jazz" },
|
|
80
|
+
];
|
|
81
|
+
assert.deepEqual(genreByLabel(genres, "Jazz"), { id: "jazz", label: "Jazz" });
|
|
82
|
+
assert.equal(genreByLabel(genres, "jazz"), undefined);
|
|
83
|
+
assert.equal(genreByLabel(genres, "Ambient"), undefined);
|
|
84
|
+
});
|
|
85
|
+
it("filters rows over name, genre and status, empty query copies all", () => {
|
|
86
|
+
const rows: LibraryTrackRow[] = [
|
|
87
|
+
{ name: "Midnight Harbor", genre: "Techno", status: "ready", duration: "2:40" },
|
|
88
|
+
{ name: "Brass Dusk", genre: "Jazz", status: "draft", duration: "3:05" },
|
|
89
|
+
];
|
|
90
|
+
assert.equal(filterTracks(rows, "").length, 2);
|
|
91
|
+
assert.deepEqual(filterTracks(rows, ""), rows);
|
|
92
|
+
assert.equal(filterTracks(rows, "techno").length, 1);
|
|
93
|
+
assert.equal(filterTracks(rows, "READY").length, 1);
|
|
94
|
+
assert.equal(filterTracks(rows, "harbor")[0]?.name, "Midnight Harbor");
|
|
95
|
+
assert.equal(filterTracks(rows, "nope").length, 0);
|
|
96
|
+
});
|
|
97
|
+
});
|
package/tests/loudness.test.ts
CHANGED
|
File without changes
|