@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,69 @@
|
|
|
1
|
+
# planning.v2.28-completo — the ladder to the complete debonair app
|
|
2
|
+
|
|
3
|
+
> owner directive: make the app COMPLETE (suno.com generation x FL studio desk),
|
|
4
|
+
> house tree, no src/, no ui/ folder, doctrine of #oficial 4 + DESIGN_MAP v2.
|
|
5
|
+
> this plan is the single source of truth for the wave that closes the gaps
|
|
6
|
+
> listed in DEBONAI-RULEMAP.md section 7 and DEBONAI-VERSIONS.md backlog.
|
|
7
|
+
> step ladder generated from the live inventory: docs/planning.ladder.md
|
|
8
|
+
|
|
9
|
+
## where the app stands (inherited, verified this session)
|
|
10
|
+
|
|
11
|
+
- native app at `debonair/debonair/` (house tree: pure ts engines at the root,
|
|
12
|
+
Sol/ theme, docs/, tests/). 28 engines + kathesis b2 (spectrum, fingerprint,
|
|
13
|
+
analyze, vocalsynth). 261 vitest cases green, tsc 0, biome 0. commit 504b8ae
|
|
14
|
+
on branch kathesis/b2-wave, pushed.
|
|
15
|
+
- served in the sandbox by the next.js gateway (single launcher page, iframe of
|
|
16
|
+
the built bundle under public/debonair/). nothing is duplicated in the gateway.
|
|
17
|
+
- auth: device flow token persisted (iakadion). pushes land on
|
|
18
|
+
github.com/wenathlan/devthink.
|
|
19
|
+
|
|
20
|
+
## gaps this wave closes (the definition of "complete")
|
|
21
|
+
|
|
22
|
+
| gap (inherited) | wave | delivery |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| daw editing is cosmetic (algebra exists, ui does not edit) | K3 | timeline editor in seconds: move (drag+snap), trim, split, duplicate, delete, clip selection; piano roll editor over the scheduled notes with add/remove/move/resize/quantize; honest re-schedule through takeevents + audioengine; aria + keyboard paths |
|
|
25
|
+
| no wav / midi / stems writers | K4 | wavwriter.ts (16/24/32f encoder, riff-correct), midiwriter.ts (smf format 1, vlq, tempo meta), stems.ts (per-role stem plans + manifest schema v1); export actions in the studio desk |
|
|
26
|
+
| no live visualization (analyser to canvas) | K5 | spectrum scope + waveform overview wired to the real analyser tap; playhead clock; reduced-motion guard |
|
|
27
|
+
| no server persistence (RenderJob idle) | K6 | gateway api projects (list/upsert/delete) on sqlite via prisma; cloudsync.ts engine (seam: fetch first, sessionStorage fallback, 2.5s timeout like onboardingstate); settings page shows sync state honestly |
|
|
28
|
+
| competitors dissected but not written down | K1 | docs/competitor-dissection.md from the five clones in allan/temp (libsonare, pulsegridstudio, magenta-js, acestep.cpp, heartlib) |
|
|
29
|
+
| planning ladder + version ladder | K2 | this file + docs/planning.ladder.md (900+ steps from the live inventory) |
|
|
30
|
+
|
|
31
|
+
## waves (task ids continue the worklog ladder)
|
|
32
|
+
|
|
33
|
+
- K1 — competitor dissection (read-only on allan/temp; writes docs/).
|
|
34
|
+
- K2 — planning: this document + generated ladder.
|
|
35
|
+
- K3 — studio daw editing (pianoroll.ts engine + Sol/studio/timeline + pianoroll ui + tests).
|
|
36
|
+
- K4 — writers: wavwriter.ts, midiwriter.ts, stems.ts + contract tests.
|
|
37
|
+
- K5 — live viz (spectrum scope, waveform overview, clock) on the real analyser.
|
|
38
|
+
- K6 — gateway persistence (api + cloudsync.ts engine + settings surface).
|
|
39
|
+
- K7 — gates: vitest full suite, tsc strict, biome zero; vite build; sync
|
|
40
|
+
public/debonair; commit as iakadion on kathesis/b3-wave; fetch before push.
|
|
41
|
+
- K8 — browser verification of the served app (agent-browser) + worklog close.
|
|
42
|
+
|
|
43
|
+
## doctrine invariants every wave must keep
|
|
44
|
+
|
|
45
|
+
1. engines are pure ts at the root: no DOM, no AudioContext, no storage;
|
|
46
|
+
tables first, orchestrator last, ~300 correlated logics per file, JSDoc on
|
|
47
|
+
every export, typed results or error classes with machine codes.
|
|
48
|
+
2. determinism: same prompt + same seed = same take; mulberry32(hashSeed(fnv-1a))
|
|
49
|
+
is the only rng; editors (pianoroll, timeline) are deterministic algebras.
|
|
50
|
+
3. sol.css is the single design source; windows accent #4cc2ff only in chrome
|
|
51
|
+
focus, amber #d9962e only in brand/cta; zero emoji icons (lucide only);
|
|
52
|
+
zero pills; reduced-motion guards; solcss.test.ts must stay green.
|
|
53
|
+
4. ui speaks english, lowercase chrome voice; mono tabular for bpm/key/lufs.
|
|
54
|
+
5. one audible transport: playerbus; studio hushes the bus on mount.
|
|
55
|
+
6. no touch on sibling apps (devthink, cadria, getry, vault, saddle...).
|
|
56
|
+
getry/vault integration stays an adapter seam, never reimplemented.
|
|
57
|
+
7. gates before any push: biome 0, tsc 0, vitest green. commits as iakadion.
|
|
58
|
+
8. honest ui: no fake metrics, no invented reviews; export is real (wav/midi
|
|
59
|
+
download of rendered audio), sync states read real endpoints with timeouts.
|
|
60
|
+
|
|
61
|
+
## acceptance criteria (the wave is done when)
|
|
62
|
+
|
|
63
|
+
- [ ] all six gaps above closed with green gates.
|
|
64
|
+
- [ ] vitest suite green with the new engine contract tests.
|
|
65
|
+
- [ ] served app (gateway iframe) verified in a real browser: generate a take,
|
|
66
|
+
open the desk, edit a clip, edit a note, export wav + midi + stems,
|
|
67
|
+
see the spectrum move, save to cloud, reload, find the take.
|
|
68
|
+
- [ ] worklog carries every task id; version ladder updated (v2.28/2.29/2.30
|
|
69
|
+
delivered; v2.31 speechalign recording and v3.0 render queue remain).
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# planning.versions — the 2.0.97 → 3.0 ladder of debonair
|
|
2
|
+
|
|
3
|
+
> the document the previous operation died creating (session corte at L2485-2495 of
|
|
4
|
+
> the context corpus). the ladder runs in houses of ~200-300: each house closes a
|
|
5
|
+
> capability block, ships green (biome 0/0 · tsc 0 · vitest green) and bumps the
|
|
6
|
+
> lockstep version. tree wins; the engine is the debonair root; the family imports
|
|
7
|
+
> the published `@wenathlan/debonair` library — never a path, never a bundle.
|
|
8
|
+
|
|
9
|
+
## where the house stands (verified 2026-10-12)
|
|
10
|
+
|
|
11
|
+
- `667c20a` head · katexis b1 + sol v2 trylle foundation · 240 tests green
|
|
12
|
+
- wave b2 (kathesis) landed in this operation: `spectrum.ts`, `fingerprint.ts`,
|
|
13
|
+
`analyze.ts`, `vocalsynth.ts` + 4 suites → **261 tests green** (26 files)
|
|
14
|
+
|
|
15
|
+
## the ladder
|
|
16
|
+
|
|
17
|
+
### house 2.0.97 → 2.0.150 — the kathesis analysis core (this wave, b2)
|
|
18
|
+
- [x] spectrum kernel (fft · hann · chroma · kk correlation)
|
|
19
|
+
- [x] fingerprint identity (constellation · landmarks · db)
|
|
20
|
+
- [x] analyze pipeline (flux tempo · octave law · key · lufs · structure · report.schema v1)
|
|
21
|
+
- [x] formant voice (vowel table · seeded vibrato · syllable grid · level law)
|
|
22
|
+
- [x] enginemap honest refresh
|
|
23
|
+
- [ ] `report.schema.ts` — the json/txt formatters the corpus docs 12/26 prescribe
|
|
24
|
+
- [ ] `restage.ts` — the reference→analysis→plan loop (doc 14→16): a real track's
|
|
25
|
+
report feeds the arranger so the take follows the reference structure
|
|
26
|
+
|
|
27
|
+
### house 2.0.150 → 2.0.200 — the daw body
|
|
28
|
+
- [ ] timeline editing for real: drag/split/trim wired to `timelinemodel` on the studio page
|
|
29
|
+
- [ ] piano roll page (`#oficial4` APP-0491..0735: Onboarding/Login/Mixer/Timeline/PianoRoll/Library/Master/Settings)
|
|
30
|
+
- [ ] wav/midi writers (export seam, `<50MB` validation before export)
|
|
31
|
+
- [ ] speechalign → vocal recording with ducking (the mic leg)
|
|
32
|
+
|
|
33
|
+
### house 2.0.200 → 2.1.0 — the ai acoustics seam
|
|
34
|
+
- [ ] onnxruntime-node seat for the foundation embeddings (clap/beats, int8)
|
|
35
|
+
- [ ] fpcalc/olaf binary orchestration via the saddle contract (node executes, binaries in `tools/bin/`)
|
|
36
|
+
- [ ] whisper.cpp transcription of the vocal stem → the lyricist reads, "A SONORIDADE É INTOCÁVEL"
|
|
37
|
+
- [ ] fingerprint dedup on every render job before the library accepts it
|
|
38
|
+
|
|
39
|
+
### house 2.1.0 → 3.0 — the family contract
|
|
40
|
+
- [ ] publish `@wenathlan/debonair` (npm/ghcr) — the engine leaves the house
|
|
41
|
+
- [ ] devthink music/musicstudio imports the published engine (no duplication)
|
|
42
|
+
- [ ] cadria/getry/vault consume the audio primitives through the same library
|
|
43
|
+
- [ ] 44.1k/48k master spec locked: −14 lufs streaming · −1 dbtp · anti-nan · pre-export validation
|
|
44
|
+
|
|
45
|
+
## the laws this ladder obeys
|
|
46
|
+
1. tree wins · 2. one domain per file ≤300 lines · 3. tables at the top, orchestrator last
|
|
47
|
+
4. seeded determinism in everything generated · 5. zero hardcode, zero consumer data in the engine
|
|
48
|
+
6. gates green before any bump · 7. commits as iakadion · 8. actions by tag, never sha
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenathlan/debonair",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.117",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/wenathlan/devthink"
|
|
@@ -17,6 +17,24 @@
|
|
|
17
17
|
"static-site"
|
|
18
18
|
],
|
|
19
19
|
"license": "GPL-3.0-only",
|
|
20
|
+
"engines": {
|
|
21
|
+
"bare": "^1.18.0",
|
|
22
|
+
"bun": "^1.4.2",
|
|
23
|
+
"chrome": "^16.0.912",
|
|
24
|
+
"cnpm": "^9.4.0",
|
|
25
|
+
"corepack": "^0.35.0",
|
|
26
|
+
"deno": "^2.9.5",
|
|
27
|
+
"ecmascript": "^5.0.0",
|
|
28
|
+
"firefox": ">=0.8.0",
|
|
29
|
+
"iojs": "^1.0.0",
|
|
30
|
+
"node": "^26.8.2",
|
|
31
|
+
"npm": "^12.1.0",
|
|
32
|
+
"pnpm": "^12.6.0",
|
|
33
|
+
"teleport": ">=0.2.0",
|
|
34
|
+
"typescript": "^5.8.0",
|
|
35
|
+
"vscode": "^1.118.0",
|
|
36
|
+
"yarn": "^4.17.1"
|
|
37
|
+
},
|
|
20
38
|
"files": [
|
|
21
39
|
"README.md",
|
|
22
40
|
"docs",
|
|
@@ -38,9 +56,14 @@
|
|
|
38
56
|
"start": "vite preview --host",
|
|
39
57
|
"preview": "vite preview --host",
|
|
40
58
|
"check": "tsc --noEmit",
|
|
59
|
+
"typecheck": "tsc --noEmit",
|
|
60
|
+
"restore:entry": "node -e \"const{readFileSync}=require('node:fs');const{execSync}=require('node:child_process');const face=/script[^>]*App\\\\.tsx/;const cdn=/fonts\\\\.googleapis\\\\.com/;if(face.test(readFileSync('index.html','utf8'))){console.log('index.html already rides the source face');process.exit(0)}const head=execSync('git show HEAD:./index.html',{encoding:'utf8'});if(!face.test(head)){console.error('refusing: HEAD index.html is not the source face yet - land the source-face fix first; nothing was touched');process.exit(1)}if(cdn.test(head)){console.error('refusing: HEAD index.html still rides the font CDN - land the self-host fix first; nothing was touched');process.exit(1)}execSync('git checkout -- index.html',{stdio:'inherit'});console.log('index.html restored from HEAD (the source face)')\"",
|
|
41
61
|
"test": "vitest run"
|
|
42
62
|
},
|
|
43
63
|
"dependencies": {
|
|
64
|
+
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
|
|
65
|
+
"@fontsource-variable/jetbrains-mono": "^5.3.0",
|
|
66
|
+
"@fontsource-variable/outfit": "^5.3.0",
|
|
44
67
|
"@hono/node-server": "^2.1.3",
|
|
45
68
|
"@prisma/adapter-libsql": "^7.10.0",
|
|
46
69
|
"hono": "^4.13.12",
|
|
@@ -58,6 +81,7 @@
|
|
|
58
81
|
"@capacitor/cli": "^8.5.2",
|
|
59
82
|
"@capacitor/core": "^8.5.2",
|
|
60
83
|
"@capacitor/ios": "^8.5.2",
|
|
84
|
+
"@fluentui/react-icons": "^2.0.344",
|
|
61
85
|
"@hookform/resolvers": "^5.9.1",
|
|
62
86
|
"@libsql/client": "^0.18.0",
|
|
63
87
|
"@opencode-ai/sdk": "^1.18.34",
|
package/tests/.gitkeep
CHANGED
|
File without changes
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// # analyze.test — the analysis pipeline: a synthetic click track answers
|
|
2
|
+
// its true bpm, a pure-tone key answers its pitch class, the report carries
|
|
3
|
+
// the schema v1 shape and the structure segments stay inside the duration.
|
|
4
|
+
import assert from "node:assert/strict";
|
|
5
|
+
import { describe, it } from "vitest";
|
|
6
|
+
import { analyze, BAND_LABELS } from "../analyze.ts";
|
|
7
|
+
|
|
8
|
+
/** Builds a percussive click track at the given bpm (deterministic). */
|
|
9
|
+
function clicks(bpm: number, seconds: number, sampleRate = 44100): number[] {
|
|
10
|
+
const out = new Array<number>(Math.round(seconds * sampleRate)).fill(0);
|
|
11
|
+
const beat = (60 / bpm) * sampleRate;
|
|
12
|
+
for (let hit = 0; hit * beat < out.length; hit++) {
|
|
13
|
+
const start = Math.round(hit * beat);
|
|
14
|
+
for (let i = 0; i < 900 && start + i < out.length; i++) {
|
|
15
|
+
out[start + i] = Math.exp((-3 * i) / 300) * Math.sin((2 * Math.PI * 1000 * i) / sampleRate);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("analyze — the pipeline", () => {
|
|
22
|
+
it("reads the tempo of a 120 bpm click track", () => {
|
|
23
|
+
const report = analyze(clicks(120, 6), 44100);
|
|
24
|
+
assert.ok(Math.abs(report.rhythm.bpm - 120) <= 6, `bpm ${report.rhythm.bpm} drifted past 6`);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("keys a 220hz-dominant tone to the A pitch class", () => {
|
|
28
|
+
const tone = Array.from({ length: 44100 * 2 }, (_, i) => 0.6 * Math.sin((2 * Math.PI * 220 * i) / 44100));
|
|
29
|
+
const report = analyze(tone, 44100);
|
|
30
|
+
assert.equal(report.key.tonicName, "A");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("answers the schema v1 shape", () => {
|
|
34
|
+
const report = analyze(clicks(90, 4), 44100);
|
|
35
|
+
assert.equal(report.sampleRate, 44100);
|
|
36
|
+
assert.ok(Math.abs(report.durationSeconds - 4) < 0.1);
|
|
37
|
+
assert.equal(report.spectrum.bands.length, BAND_LABELS.length);
|
|
38
|
+
assert.ok(report.loudness.integrated < 0, "integrated lufs must be negative");
|
|
39
|
+
assert.ok(Array.isArray(report.structure));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps structure sections inside the duration", () => {
|
|
43
|
+
const report = analyze(clicks(140, 5), 44100);
|
|
44
|
+
for (const section of report.structure) {
|
|
45
|
+
assert.ok(section.start >= 0 && section.end <= report.durationSeconds + 0.2);
|
|
46
|
+
assert.ok(section.energy > 0 && section.energy <= 1);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("counts fingerprint hashes when the switch is on", () => {
|
|
51
|
+
const report = analyze(clicks(120, 2), 44100, { fingerprint: true });
|
|
52
|
+
assert.ok(report.fingerprintHashes > 0);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// # arrangement.test — the clip binding of the katexis engine: every test
|
|
2
|
+
// watches the law the desk rides — the identity (feeding the plan's own
|
|
3
|
+
// clips answers the seeded schedule), the move (a clip plays what it played,
|
|
4
|
+
// from its new start), the delete (the survivors re-slice the material), the
|
|
5
|
+
// split (the children share the parent's slice), the determinism (same plan,
|
|
6
|
+
// same clips, same schedule) and the refusals (unsound clips are named).
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import { ArrangementError, rebaseSchedule, validateArrangement } from "../arrangement.ts";
|
|
10
|
+
import { planTake } from "../arranger.ts";
|
|
11
|
+
import { scheduleTake, validateSchedule } from "../takeevents.ts";
|
|
12
|
+
|
|
13
|
+
describe("arrangement — rebaseSchedule", () => {
|
|
14
|
+
const plan = planTake({ prompt: "midnight techno, dark sub bass, hypnotic arp", seed: "demo-1" });
|
|
15
|
+
const seeded = scheduleTake(plan);
|
|
16
|
+
|
|
17
|
+
it("is the identity when the desk carries the plan's own clips", () => {
|
|
18
|
+
const rebased = rebaseSchedule(plan, plan.clips, seeded);
|
|
19
|
+
assert.equal(rebased.events.length, seeded.events.length);
|
|
20
|
+
for (let index = 0; index < seeded.events.length; index++) {
|
|
21
|
+
assert.equal(rebased.events[index].role, seeded.events[index].role);
|
|
22
|
+
assert.ok(Math.abs(rebased.events[index].time - seeded.events[index].time) < 1e-6, `event ${index} drifted`);
|
|
23
|
+
}
|
|
24
|
+
assert.deepEqual(rebased.perRole, seeded.perRole);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("moves a clip's material to the clip's new start", () => {
|
|
28
|
+
const role = "drums";
|
|
29
|
+
const clip = plan.clips.find((entry) => entry.track === role);
|
|
30
|
+
assert.ok(clip);
|
|
31
|
+
const shifted = plan.clips.map((entry) =>
|
|
32
|
+
entry.id === clip.id ? { ...entry, start: entry.start + entry.duration } : entry,
|
|
33
|
+
);
|
|
34
|
+
const rebased = rebaseSchedule(plan, shifted, seeded);
|
|
35
|
+
const sourceTimes = seeded.events
|
|
36
|
+
.filter((event) => event.role === role && event.time >= clip.start && event.time < clip.start + clip.duration)
|
|
37
|
+
.map((event) => event.time - clip.start + clip.start + clip.duration);
|
|
38
|
+
const playedTimes = rebased.events.filter((event) => event.role === role).map((event) => event.time);
|
|
39
|
+
assert.ok(sourceTimes.length > 0, "the source window carried no events");
|
|
40
|
+
for (const expected of sourceTimes) {
|
|
41
|
+
assert.ok(
|
|
42
|
+
playedTimes.some((time) => Math.abs(time - expected) < 1e-6),
|
|
43
|
+
`no event landed at ${expected.toFixed(3)}`,
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("re-slices the material across the survivors when a clip is deleted", () => {
|
|
49
|
+
const role = "bass";
|
|
50
|
+
const clips = plan.clips.filter((entry) => entry.track === role);
|
|
51
|
+
assert.ok(clips.length > 1, "the test needs more than one bass clip");
|
|
52
|
+
const removed = clips[0];
|
|
53
|
+
const survivors = clips.slice(1);
|
|
54
|
+
const rebased = rebaseSchedule(plan, survivors, seeded);
|
|
55
|
+
const sourceCount = seeded.events.filter(
|
|
56
|
+
(event) => event.role === role && event.time >= removed.start && event.time < removed.start + removed.duration,
|
|
57
|
+
).length;
|
|
58
|
+
const playedCount = rebased.events.filter((event) => event.role === role).length;
|
|
59
|
+
const expected = seeded.events.filter((event) => event.role === role).length - sourceCount;
|
|
60
|
+
assert.equal(playedCount, expected, "the survivors must carry the remaining material");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps every event inside its clip's own gate after the re-slice", () => {
|
|
64
|
+
const shrunk = plan.clips.map((entry) =>
|
|
65
|
+
entry.track === "keys" ? { ...entry, duration: entry.duration / 2 } : entry,
|
|
66
|
+
);
|
|
67
|
+
const rebased = rebaseSchedule(plan, shrunk, seeded);
|
|
68
|
+
for (const event of rebased.events) {
|
|
69
|
+
const clip = shrunk.find(
|
|
70
|
+
(entry) =>
|
|
71
|
+
entry.track === event.role &&
|
|
72
|
+
event.time >= entry.start - 1e-6 &&
|
|
73
|
+
event.time < entry.start + entry.duration - 1e-6,
|
|
74
|
+
);
|
|
75
|
+
assert.ok(clip, `event at ${event.time} sits outside every clip of ${event.role}`);
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("answers a valid schedule the transport validator accepts", () => {
|
|
80
|
+
const edited = plan.clips.map((entry) =>
|
|
81
|
+
entry.track === "lead" ? { ...entry, start: entry.start + 0.5 * entry.duration } : entry,
|
|
82
|
+
);
|
|
83
|
+
const rebased = rebaseSchedule(plan, edited, seeded);
|
|
84
|
+
assert.deepEqual(validateSchedule(rebased), []);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("is deterministic: same plan, same clips, same schedule", () => {
|
|
88
|
+
const edited = plan.clips.map((entry) => ({ ...entry, start: entry.start + 0.25 * entry.duration }));
|
|
89
|
+
const left = JSON.parse(JSON.stringify(rebaseSchedule(plan, edited, seeded)));
|
|
90
|
+
const right = JSON.parse(JSON.stringify(rebaseSchedule(plan, edited, seeded)));
|
|
91
|
+
assert.deepEqual(left, right);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("refuses unsound clips with the clip's own id", () => {
|
|
95
|
+
const broken = plan.clips.map((entry) => (entry.track === "drums" ? { ...entry, duration: -1 } : entry));
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => rebaseSchedule(plan, broken, seeded),
|
|
98
|
+
(error: unknown) => {
|
|
99
|
+
assert.ok(error instanceof ArrangementError);
|
|
100
|
+
assert.equal(error.code, "bad-clips");
|
|
101
|
+
return true;
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("validates the edit base and names a sound plan empty", () => {
|
|
107
|
+
assert.deepEqual(validateArrangement(plan), []);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// # arranger.test — the composer of the katexis engine: every test watches
|
|
2
|
+
// the contract the DAW surfaces ride — the plan is deterministic (same
|
|
3
|
+
// prompt+seed, same take), the genre resolves from the matrix, the bpm stays
|
|
4
|
+
// inside the genre band, the chord timeline covers the bars, the clips sit
|
|
5
|
+
// on their roles without overlap, the desk carries a master and the
|
|
6
|
+
// validator answers empty for a sound plan.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
planTake,
|
|
11
|
+
RACK_LANES,
|
|
12
|
+
resolveGenre,
|
|
13
|
+
SECTION_PLAN,
|
|
14
|
+
stepSecondsOf,
|
|
15
|
+
TOTAL_BARS,
|
|
16
|
+
takeId,
|
|
17
|
+
validatePlan,
|
|
18
|
+
} from "../arranger.ts";
|
|
19
|
+
import { GENRES } from "../genrematrix.ts";
|
|
20
|
+
import { clipsOverlap, totalDuration } from "../timelinemodel.ts";
|
|
21
|
+
|
|
22
|
+
describe("arranger — planTake", () => {
|
|
23
|
+
const plan = planTake({ prompt: "midnight techno, dark sub bass, hypnotic arp", seed: "demo-1" });
|
|
24
|
+
|
|
25
|
+
it("is deterministic: same prompt and seed rebuild the exact take", () => {
|
|
26
|
+
const again = planTake({ prompt: "midnight techno, dark sub bass, hypnotic arp", seed: "demo-1" });
|
|
27
|
+
assert.deepEqual(JSON.parse(JSON.stringify(again)), JSON.parse(JSON.stringify(plan)));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("carries the prompt name, the id and the seed back on the plan", () => {
|
|
31
|
+
assert.equal(plan.name, "Midnight techno, dark sub");
|
|
32
|
+
assert.equal(plan.seed, "demo-1");
|
|
33
|
+
assert.equal(plan.id, takeId("demo-1", "midnight techno, dark sub bass, hypnotic arp"));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("resolves the genre from the matrix and keeps the bpm inside its band", () => {
|
|
37
|
+
const genre = GENRES.find((entry) => entry.id === plan.genreId);
|
|
38
|
+
assert.ok(genre);
|
|
39
|
+
assert.equal(plan.genreLabel, genre.label);
|
|
40
|
+
assert.ok(plan.bpm >= genre.bpmin && plan.bpm <= genre.bpmax);
|
|
41
|
+
assert.equal(plan.pattern, genre.pattern);
|
|
42
|
+
assert.equal(plan.lufsTarget, genre.lufs);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("honours a pinned genre over the prompt match", () => {
|
|
46
|
+
const pinned = planTake({ prompt: "midnight techno", seed: "demo-1", genreId: "lofi" });
|
|
47
|
+
assert.equal(pinned.genreId, "lofi");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("covers the sixteen bars with chords, one per bar", () => {
|
|
51
|
+
assert.equal(TOTAL_BARS, 16);
|
|
52
|
+
assert.equal(plan.bars, 16);
|
|
53
|
+
assert.equal(plan.chords.length, 16);
|
|
54
|
+
for (const bar of plan.chords) assert.ok(bar.symbol.length > 0);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("splits the sections over the section plan and sums back to the bars", () => {
|
|
58
|
+
const sum = SECTION_PLAN.reduce((total, section) => total + section.bars, 0);
|
|
59
|
+
assert.equal(sum, plan.bars);
|
|
60
|
+
assert.equal(plan.sections.length, SECTION_PLAN.length);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("places non-overlapping clips on the four roles", () => {
|
|
64
|
+
const roles = new Set(plan.clips.map((clip) => clip.track));
|
|
65
|
+
assert.deepEqual([...roles].sort(), ["bass", "drums", "keys", "lead"]);
|
|
66
|
+
for (let left = 0; left < plan.clips.length; left++) {
|
|
67
|
+
for (let right = left + 1; right < plan.clips.length; right++) {
|
|
68
|
+
assert.equal(clipsOverlap(plan.clips[left], plan.clips[right]), false);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
assert.ok(totalDuration(plan.clips) <= plan.durationSeconds + 1e-6);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("opens the desk with the four role strips plus the master", () => {
|
|
75
|
+
const names = plan.strips.map((strip) => strip.name);
|
|
76
|
+
assert.deepEqual(names, ["drums", "bass", "keys", "lead", "master"]);
|
|
77
|
+
assert.ok(plan.strips.some((strip) => strip.master));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("carries a sixteen-step grid with every rack lane and swing offsets", () => {
|
|
81
|
+
assert.equal(plan.grid.steps, 16);
|
|
82
|
+
for (const lane of RACK_LANES) assert.equal(plan.grid.lanes[lane].length, 16);
|
|
83
|
+
assert.equal(plan.offsets.length, 16);
|
|
84
|
+
assert.equal(stepSecondsOf(plan), 60 / plan.bpm / 4);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("validates clean: the sound plan answers no problems", () => {
|
|
88
|
+
assert.deepEqual(validatePlan(plan), []);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("flags a broken plan (duration tampered)", () => {
|
|
92
|
+
const broken = { ...plan, durationSeconds: 1 };
|
|
93
|
+
assert.ok(validatePlan(broken).length > 0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("never fails for a short prompt (the fallback genre catches it)", () => {
|
|
97
|
+
const tiny = planTake({ prompt: "go", seed: "s" });
|
|
98
|
+
assert.ok(GENRES.some((genre) => genre.id === tiny.genreId));
|
|
99
|
+
assert.deepEqual(validatePlan(tiny), []);
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("arranger — resolveGenre", () => {
|
|
104
|
+
it("matches a prompt that names the genre", () => {
|
|
105
|
+
assert.equal(resolveGenre("hard drill night")?.id, "drill");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("pins the genre id when asked", () => {
|
|
109
|
+
assert.equal(resolveGenre("anything", "ambient")?.id, "ambient");
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("falls back into the matrix for a prompt with no hit", () => {
|
|
113
|
+
const genre = resolveGenre("qqzz wumpus");
|
|
114
|
+
assert.ok(GENRES.some((entry) => entry.id === genre.id));
|
|
115
|
+
});
|
|
116
|
+
});
|
package/tests/beatgrid.test.ts
CHANGED
|
File without changes
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// # dawview.test — the pure view math of the DAW surfaces: the clock format,
|
|
2
|
+
// the clip geometry clamps, the fader travel round-trip, the meter percent
|
|
3
|
+
// and the playhead percent. The studio reads these numbers; the tests pin
|
|
4
|
+
// them.
|
|
5
|
+
import assert from "node:assert/strict";
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
|
+
import {
|
|
8
|
+
clampFader,
|
|
9
|
+
clipStyle,
|
|
10
|
+
FADER_MAX_DB,
|
|
11
|
+
FADER_MIN_DB,
|
|
12
|
+
faderToUnit,
|
|
13
|
+
formatClock,
|
|
14
|
+
formatGain,
|
|
15
|
+
levelToPercent,
|
|
16
|
+
playheadPercent,
|
|
17
|
+
unitToFader,
|
|
18
|
+
} from "../dawview.ts";
|
|
19
|
+
|
|
20
|
+
describe("dawview — formatClock", () => {
|
|
21
|
+
it("formats the classic m:ss.t readout", () => {
|
|
22
|
+
assert.equal(formatClock(0), "0:00.0");
|
|
23
|
+
assert.equal(formatClock(4.12), "0:04.1");
|
|
24
|
+
assert.equal(formatClock(65.5), "1:05.5");
|
|
25
|
+
assert.equal(formatClock(601.25), "10:01.2");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("answers the zero clock for junk input", () => {
|
|
29
|
+
assert.equal(formatClock(Number.NEGATIVE_INFINITY), "0:00.0");
|
|
30
|
+
assert.equal(formatClock(Number.NaN), "0:00.0");
|
|
31
|
+
assert.equal(formatClock(-3), "0:00.0");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("dawview — clipStyle", () => {
|
|
36
|
+
it("maps a clip onto its percent geometry", () => {
|
|
37
|
+
const geo = clipStyle(10, 5, 20);
|
|
38
|
+
assert.equal(geo.left, 50);
|
|
39
|
+
assert.equal(geo.width, 25);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("clamps a clip that runs past the arrangement", () => {
|
|
43
|
+
const geo = clipStyle(15, 10, 20);
|
|
44
|
+
assert.equal(geo.left, 75);
|
|
45
|
+
assert.equal(geo.width, 25);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("never answers negative or over-full geometry", () => {
|
|
49
|
+
const over = clipStyle(25, 5, 20);
|
|
50
|
+
assert.equal(over.left, 100);
|
|
51
|
+
assert.equal(over.width, 0);
|
|
52
|
+
const empty = clipStyle(1, 1, 0);
|
|
53
|
+
assert.deepEqual(empty, { left: 0, width: 0 });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("dawview — fader travel", () => {
|
|
58
|
+
it("maps the −60..+6 dB travel onto 0..1", () => {
|
|
59
|
+
assert.equal(faderToUnit(FADER_MIN_DB), 0);
|
|
60
|
+
assert.equal(faderToUnit(0), 60 / 66);
|
|
61
|
+
assert.equal(faderToUnit(FADER_MAX_DB), 1);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("round-trips a fader value through the unit space", () => {
|
|
65
|
+
for (let db = FADER_MIN_DB; db <= FADER_MAX_DB; db += 0.5) {
|
|
66
|
+
assert.ok(Math.abs(unitToFader(faderToUnit(db)) - db) < 1e-9);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("clamps out-of-travel values", () => {
|
|
71
|
+
assert.equal(clampFader(-100), FADER_MIN_DB);
|
|
72
|
+
assert.equal(clampFader(20), FADER_MAX_DB);
|
|
73
|
+
assert.equal(unitToFader(-2), FADER_MIN_DB);
|
|
74
|
+
assert.equal(unitToFader(2), FADER_MAX_DB);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("snaps the unit walk onto 0.5 dB steps", () => {
|
|
78
|
+
const db = unitToFader(0.5);
|
|
79
|
+
assert.equal(db, Math.round(db * 2) / 2);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
describe("dawview — meters and playhead", () => {
|
|
84
|
+
it("maps a dBFS level onto the 0..100 meter with the −48 floor", () => {
|
|
85
|
+
assert.equal(levelToPercent(Number.NEGATIVE_INFINITY), 0);
|
|
86
|
+
assert.equal(levelToPercent(-48), 0);
|
|
87
|
+
assert.equal(levelToPercent(-24), 50);
|
|
88
|
+
assert.equal(levelToPercent(0), 100);
|
|
89
|
+
assert.equal(levelToPercent(6), 100);
|
|
90
|
+
assert.equal(levelToPercent(Number.NaN), 0);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("maps the playhead percent and clamps both ends", () => {
|
|
94
|
+
assert.equal(playheadPercent(5, 20), 25);
|
|
95
|
+
assert.equal(playheadPercent(-1, 20), 0);
|
|
96
|
+
assert.equal(playheadPercent(30, 20), 100);
|
|
97
|
+
assert.equal(playheadPercent(5, 0), 0);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("formats gains with the silence glyph", () => {
|
|
101
|
+
assert.equal(formatGain(-4), "-4.0");
|
|
102
|
+
assert.equal(formatGain(Number.NEGATIVE_INFINITY), "−∞");
|
|
103
|
+
});
|
|
104
|
+
});
|
package/tests/ducking.test.ts
CHANGED
|
File without changes
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// # family.test — the family table the chrome rides (campaign 5-b, the
|
|
2
|
+
// contract D): the eight siblings beside debonair are listed once, every
|
|
3
|
+
// slug resolves through the familyurl contract into a climb-one-level url,
|
|
4
|
+
// every member carries the spec §12 accent with its own dot ink, and no
|
|
5
|
+
// accent ever collides with the banned palette. The rail foot, the landing
|
|
6
|
+
// footer and this test consume the same table — the row cannot drift.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import { FAMILY_ACCENTS, FAMILY_SLUGS, familyrow } from "../family.ts";
|
|
10
|
+
import { familyurl } from "../familyurl.ts";
|
|
11
|
+
|
|
12
|
+
const BANNED = new Set(["#3b82f6", "#2563eb", "#f97316"]);
|
|
13
|
+
|
|
14
|
+
describe("family table", () => {
|
|
15
|
+
it("lists the eight siblings beside debonair, never debonair itself", () => {
|
|
16
|
+
assert.deepEqual(FAMILY_SLUGS, ["argan", "cadria", "forge", "foundry", "getry", "saddle", "stealthhead", "vault"]);
|
|
17
|
+
assert.ok(!FAMILY_SLUGS.includes("debonair"));
|
|
18
|
+
assert.ok(!FAMILY_SLUGS.includes("devthink"));
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("answers an accent for every listed sibling", () => {
|
|
22
|
+
for (const slug of FAMILY_SLUGS) {
|
|
23
|
+
const member = FAMILY_ACCENTS[slug];
|
|
24
|
+
assert.ok(member, `${slug} must carry an accent`);
|
|
25
|
+
assert.equal(member.slug, slug);
|
|
26
|
+
assert.match(member.accent, /^#[0-9a-f]{6}$/);
|
|
27
|
+
assert.match(member.dotInk, /^#[0-9a-f]{6}$/);
|
|
28
|
+
}
|
|
29
|
+
// the table holds exactly the siblings — no orphan accents
|
|
30
|
+
assert.deepEqual(Object.keys(FAMILY_ACCENTS).sort(), [...FAMILY_SLUGS].sort());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("resolves every member through the familyurl contract", () => {
|
|
34
|
+
for (const slug of FAMILY_SLUGS) {
|
|
35
|
+
assert.equal(familyurl(slug), `../${slug}/`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("keeps the spec §12 accents stable", () => {
|
|
40
|
+
assert.equal(FAMILY_ACCENTS.argan.accent, "#34d8a8");
|
|
41
|
+
assert.equal(FAMILY_ACCENTS.cadria.accent, "#f472b6");
|
|
42
|
+
assert.equal(FAMILY_ACCENTS.forge.accent, "#a2cb3a");
|
|
43
|
+
assert.equal(FAMILY_ACCENTS.foundry.accent, "#2fb8b5");
|
|
44
|
+
assert.equal(FAMILY_ACCENTS.getry.accent, "#8b5cf6");
|
|
45
|
+
assert.equal(FAMILY_ACCENTS.saddle.accent, "#e86f2d");
|
|
46
|
+
assert.equal(FAMILY_ACCENTS.stealthhead.accent, "#e8563f");
|
|
47
|
+
assert.equal(FAMILY_ACCENTS.vault.accent, "#a3d7e6");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("pairs every accent with its own spec ink", () => {
|
|
51
|
+
assert.equal(FAMILY_ACCENTS.argan.dotInk, "#04231a");
|
|
52
|
+
assert.equal(FAMILY_ACCENTS.cadria.dotInk, "#2a0715");
|
|
53
|
+
assert.equal(FAMILY_ACCENTS.forge.dotInk, "#161d03");
|
|
54
|
+
assert.equal(FAMILY_ACCENTS.foundry.dotInk, "#03211f");
|
|
55
|
+
assert.equal(FAMILY_ACCENTS.getry.dotInk, "#120533");
|
|
56
|
+
assert.equal(FAMILY_ACCENTS.saddle.dotInk, "#2a0f02");
|
|
57
|
+
assert.equal(FAMILY_ACCENTS.stealthhead.dotInk, "#2c0703");
|
|
58
|
+
assert.equal(FAMILY_ACCENTS.vault.dotInk, "#072330");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("never ships a banned hue in the row", () => {
|
|
62
|
+
for (const member of familyrow()) {
|
|
63
|
+
assert.ok(!BANNED.has(member.accent), `${member.slug} rides a banned accent`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("keeps every accent distinct — the row never repeats a hue", () => {
|
|
68
|
+
const accents = familyrow().map((member) => member.accent);
|
|
69
|
+
assert.equal(new Set(accents).size, accents.length);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("builds the rail row in slug order without gaps", () => {
|
|
73
|
+
const row = familyrow();
|
|
74
|
+
assert.equal(row.length, FAMILY_SLUGS.length);
|
|
75
|
+
assert.deepEqual(
|
|
76
|
+
row.map((member) => member.slug),
|
|
77
|
+
[...FAMILY_SLUGS],
|
|
78
|
+
);
|
|
79
|
+
});
|
|
80
|
+
});
|