@wenathlan/debonair 2.0.81 → 2.0.84

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wenathlan/debonair",
3
- "version": "2.0.81",
3
+ "version": "2.0.84",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/wenathlan/devthink"
@@ -0,0 +1,165 @@
1
+ // # mixer.graph.test — honest unit tests for the mixing graph model, runnable
2
+ // with the node built-in runner (no dependencies, no install):
3
+ // node --test tests/mixer.graph.test.ts
4
+ // The desk fixtures reuse the real mixer strips the site seed serves.
5
+ import assert from "node:assert/strict";
6
+ import { describe, it } from "node:test";
7
+ import {
8
+ assertAcyclic,
9
+ assertGraph,
10
+ dbToLinear,
11
+ effectiveGainDb,
12
+ effectiveGains,
13
+ graphFromStrips,
14
+ linearToDb,
15
+ MixerGraphError,
16
+ patchChannel,
17
+ resolvePaths,
18
+ type MixerChannel,
19
+ } from "../mixer.graph.ts";
20
+ import { seedMixerStrips } from "../seed.ts";
21
+
22
+ describe("mixer graph construction", () => {
23
+ it("builds the desk from the real seed strips, master included", () => {
24
+ const graph = graphFromStrips(seedMixerStrips);
25
+ assert.equal(graph.channels.length, seedMixerStrips.length);
26
+ assert.equal(graph.masterid, "Master");
27
+ assert.equal(graph.channels.find((channel) => channel.id === "Drums")?.gaindb, -4);
28
+ });
29
+
30
+ it("refuses a desk without a master row", () => {
31
+ const orphans = seedMixerStrips.map((strip) => ({ ...strip, master: false }));
32
+ assert.throws(() => graphFromStrips(orphans), (error: unknown) => {
33
+ assert.ok(error instanceof MixerGraphError);
34
+ assert.equal(error.code, "missing-master");
35
+ return true;
36
+ });
37
+ assert.throws(() => graphFromStrips([]), MixerGraphError);
38
+ });
39
+
40
+ it("validates duplicate ids, unknown sends and non finite gains", () => {
41
+ const graph = graphFromStrips(seedMixerStrips);
42
+ const duplicated: MixerChannel = { id: "Drums", name: "Drums", gaindb: 0, mute: false, solo: false, sends: [] };
43
+ assert.throws(() => assertGraph({ ...graph, channels: [...graph.channels, duplicated] }), (error: unknown) => {
44
+ assert.ok(error instanceof MixerGraphError);
45
+ assert.equal(error.code, "duplicate-channel");
46
+ return true;
47
+ });
48
+ const withbadsend = patchChannel(graph, "Drums", { sends: [{ targetid: "ghost", gaindb: -3 }] });
49
+ assert.throws(() => assertGraph(withbadsend), (error: unknown) => {
50
+ assert.ok(error instanceof MixerGraphError);
51
+ assert.equal(error.code, "unknown-send");
52
+ return true;
53
+ });
54
+ const withnangain = patchChannel(graph, "Drums", { gaindb: Number.NaN });
55
+ assert.throws(() => assertGraph(withnangain), (error: unknown) => {
56
+ assert.ok(error instanceof MixerGraphError);
57
+ assert.equal(error.code, "bad-gain");
58
+ return true;
59
+ });
60
+ });
61
+ });
62
+
63
+ describe("mixer graph cycles", () => {
64
+ const graph = graphFromStrips(seedMixerStrips);
65
+
66
+ it("rejects a send loop between two strips", () => {
67
+ const looped = patchChannel(
68
+ patchChannel(graph, "Drums", { sends: [{ targetid: "Bass", gaindb: -3 }] }),
69
+ "Bass",
70
+ { sends: [{ targetid: "Drums", gaindb: -3 }] },
71
+ );
72
+ assert.throws(() => assertAcyclic(looped), (error: unknown) => {
73
+ assert.ok(error instanceof MixerGraphError);
74
+ assert.equal(error.code, "cycle");
75
+ assert.equal(error.channelid, "Drums");
76
+ return true;
77
+ });
78
+ });
79
+
80
+ it("rejects a strip that sends to itself", () => {
81
+ const selfsend = patchChannel(graph, "Bass", { sends: [{ targetid: "Bass", gaindb: 0 }] });
82
+ assert.throws(() => assertAcyclic(selfsend), MixerGraphError);
83
+ });
84
+
85
+ it("accepts every strip sending straight to the master", () => {
86
+ const fanned = patchChannel(graph, "Drums", { sends: [{ targetid: "Master", gaindb: -6 }] });
87
+ assert.doesNotThrow(() => assertAcyclic(fanned));
88
+ });
89
+ });
90
+
91
+ describe("mixer graph gains", () => {
92
+ it("converts dB to linear and back", () => {
93
+ assert.equal(dbToLinear(0), 1);
94
+ assert.ok(Math.abs(dbToLinear(-6) - 0.501) < 0.001);
95
+ assert.equal(dbToLinear(Number.NEGATIVE_INFINITY), 0);
96
+ assert.equal(linearToDb(1), 0);
97
+ assert.ok(Math.abs(linearToDb(dbToLinear(-6)) - -6) < 1e-9, `expected the -6 dB roundtrip to hold, got ${linearToDb(dbToLinear(-6))}`);
98
+ assert.equal(linearToDb(0), Number.NEGATIVE_INFINITY);
99
+ });
100
+
101
+ it("chains the fader and the master additively in dB", () => {
102
+ const graph = graphFromStrips(seedMixerStrips);
103
+ // Drums -4 dB fader over the -2 dB master bus
104
+ assert.ok(Math.abs(effectiveGainDb(graph, "Drums") - -6) < 1e-9);
105
+ assert.equal(effectiveGainDb(graph, "Master"), -2);
106
+ });
107
+
108
+ it("mutes a strip to negative infinity and never mutates the input graph", () => {
109
+ const graph = graphFromStrips(seedMixerStrips);
110
+ const muted = patchChannel(graph, "Drums", { mute: true });
111
+ assert.equal(effectiveGainDb(muted, "Drums"), Number.NEGATIVE_INFINITY);
112
+ assert.equal(effectiveGainDb(graph, "Drums"), -6);
113
+ });
114
+
115
+ it("lets solo win: a soloed strip stays audible even muted, the rest falls silent", () => {
116
+ const graph = patchChannel(graphFromStrips(seedMixerStrips), "Bass", { solo: true, mute: true });
117
+ // solo wins over its own mute
118
+ assert.equal(effectiveGainDb(graph, "Bass"), -8);
119
+ // every unsoloed strip is silenced while a solo runs
120
+ assert.equal(effectiveGainDb(graph, "Drums"), Number.NEGATIVE_INFINITY);
121
+ assert.equal(effectiveGains(graph).Keys, Number.NEGATIVE_INFINITY);
122
+ });
123
+
124
+ it("answers the whole desk in one pass", () => {
125
+ const gains = effectiveGains(graphFromStrips(seedMixerStrips));
126
+ assert.equal(Object.keys(gains).length, seedMixerStrips.length);
127
+ assert.equal(gains.Master, -2);
128
+ });
129
+ });
130
+
131
+ describe("mixer graph routing paths", () => {
132
+ it("resolves the direct desk path when a strip sends nowhere", () => {
133
+ const graph = graphFromStrips(seedMixerStrips);
134
+ const paths = resolvePaths(graph, "Drums");
135
+ assert.equal(paths.length, 1);
136
+ assert.deepEqual(paths[0].hops, ["Drums", "Master"]);
137
+ assert.ok(Math.abs(paths[0].gaindb - -6) < 1e-9);
138
+ assert.ok(Math.abs(paths[0].linear - dbToLinear(-6)) < 1e-9);
139
+ });
140
+
141
+ it("resolves the send path with the send and master gains chained", () => {
142
+ const graph = patchChannel(graphFromStrips(seedMixerStrips), "Drums", { sends: [{ targetid: "Master", gaindb: -6 }] });
143
+ const paths = resolvePaths(graph, "Drums");
144
+ assert.equal(paths.length, 2);
145
+ assert.ok(Math.abs(paths[0].gaindb - -6) < 1e-9); // direct: -4 + -2
146
+ assert.ok(Math.abs(paths[1].gaindb - -12) < 1e-9); // send: -4 + -6 + -2
147
+ });
148
+
149
+ it("answers the master itself as a single hop", () => {
150
+ const graph = graphFromStrips(seedMixerStrips);
151
+ const paths = resolvePaths(graph, "Master");
152
+ assert.equal(paths.length, 1);
153
+ assert.deepEqual(paths[0].hops, ["Master"]);
154
+ assert.equal(paths[0].gaindb, -2);
155
+ });
156
+
157
+ it("refuses an unknown channel", () => {
158
+ const graph = graphFromStrips(seedMixerStrips);
159
+ assert.throws(() => resolvePaths(graph, "ghost"), (error: unknown) => {
160
+ assert.ok(error instanceof MixerGraphError);
161
+ assert.equal(error.code, "unknown-channel");
162
+ return true;
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,182 @@
1
+ // # timeline.model.test — honest unit tests for the arrangement timeline
2
+ // model (snap, overlap, split, trim, move), runnable with the node runner:
3
+ // node --test tests/timeline.model.test.ts
4
+ // The clip fixtures mirror the shapes the studio timeline serves.
5
+ import assert from "node:assert/strict";
6
+ import { describe, it } from "node:test";
7
+ import {
8
+ activeAt,
9
+ clipsOverlap,
10
+ MINIMUMDURATION,
11
+ moveClip,
12
+ snapClip,
13
+ snapToGrid,
14
+ splitClip,
15
+ TimelineModelError,
16
+ totalDuration,
17
+ trimClip,
18
+ validatePlacement,
19
+ type TimelineClip,
20
+ } from "../timeline.model.ts";
21
+
22
+ const GRID = { stepseconds: 0.25 };
23
+
24
+ /** builds one clip with the given span on a track. */
25
+ function clip(id: string, track: string, start: number, duration: number, label = id): TimelineClip {
26
+ return { id, track, label, start, duration };
27
+ }
28
+
29
+ describe("timeline grid snap", () => {
30
+ it("snaps a time to the nearest step", () => {
31
+ assert.equal(snapToGrid(1.04, GRID), 1);
32
+ assert.equal(snapToGrid(1.13, GRID), 1.25);
33
+ assert.equal(snapToGrid(0, GRID), 0);
34
+ });
35
+
36
+ it("cleans the float noise of the division", () => {
37
+ assert.equal(snapToGrid(0.3, { stepseconds: 0.1 }), 0.3);
38
+ });
39
+
40
+ it("snaps a clip start and rounds its duration to whole steps", () => {
41
+ const snapped = snapClip(clip("c1", "Drums", 1.04, 1.1), GRID);
42
+ assert.equal(snapped.start, 1);
43
+ assert.equal(snapped.duration, 1);
44
+ assert.equal(snapped.track, "Drums");
45
+ });
46
+
47
+ it("refuses a bad time or a bad grid", () => {
48
+ assert.throws(() => snapToGrid(-1, GRID), TimelineModelError);
49
+ assert.throws(() => snapToGrid(1, { stepseconds: 0 }), (error: unknown) => {
50
+ assert.ok(error instanceof TimelineModelError);
51
+ assert.equal(error.code, "bad-grid");
52
+ return true;
53
+ });
54
+ });
55
+ });
56
+
57
+ describe("timeline overlap", () => {
58
+ it("flags same track sharing time and spares touching edges", () => {
59
+ assert.equal(clipsOverlap(clip("a", "Drums", 0, 1), clip("b", "Drums", 0.5, 1)), true);
60
+ assert.equal(clipsOverlap(clip("a", "Drums", 0, 1), clip("b", "Drums", 1, 1)), false);
61
+ });
62
+
63
+ it("never flags different tracks", () => {
64
+ assert.equal(clipsOverlap(clip("a", "Drums", 0, 1), clip("b", "Bass", 0, 1)), false);
65
+ });
66
+
67
+ it("validates the placement and reports the offending pair", () => {
68
+ const arrangement = [clip("a", "Drums", 0, 2), clip("b", "Bass", 0, 4)];
69
+ assert.doesNotThrow(() => validatePlacement(clip("c", "Bass", 5, 1), arrangement));
70
+ assert.throws(() => validatePlacement(clip("c", "Drums", 1, 2), arrangement), (error: unknown) => {
71
+ assert.ok(error instanceof TimelineModelError);
72
+ assert.equal(error.code, "overlap");
73
+ assert.equal(error.clipid, "c");
74
+ return true;
75
+ });
76
+ });
77
+
78
+ it("skips the clip itself and honors the allowoverlap escape hatch", () => {
79
+ const arrangement = [clip("a", "Drums", 0, 2)];
80
+ assert.doesNotThrow(() => validatePlacement(clip("a", "Drums", 0, 2), arrangement));
81
+ assert.doesNotThrow(() => validatePlacement(clip("c", "Drums", 1, 2), arrangement, { allowoverlap: true }));
82
+ });
83
+ });
84
+
85
+ describe("timeline split", () => {
86
+ it("cuts one clip into two spans that cover the original", () => {
87
+ const [left, right] = splitClip(clip("c1", "Bass", 2, 1.5), 2.5);
88
+ assert.equal(left.id, "c1.a");
89
+ assert.equal(left.start, 2);
90
+ assert.equal(left.duration, 0.5);
91
+ assert.equal(right.id, "c1.b");
92
+ assert.equal(right.start, 2.5);
93
+ assert.equal(right.duration, 1);
94
+ assert.ok(!clipsOverlap(left, right));
95
+ });
96
+
97
+ it("refuses a split on the edges or outside the span", () => {
98
+ const target = clip("c1", "Bass", 2, 1.5);
99
+ assert.throws(() => splitClip(target, 2), (error: unknown) => {
100
+ assert.ok(error instanceof TimelineModelError);
101
+ assert.equal(error.code, "split-outside");
102
+ assert.equal(error.clipid, "c1");
103
+ return true;
104
+ });
105
+ assert.throws(() => splitClip(target, 3.5), TimelineModelError);
106
+ assert.throws(() => splitClip(target, 9), TimelineModelError);
107
+ });
108
+ });
109
+
110
+ describe("timeline trim", () => {
111
+ it("slides the start right and keeps the end", () => {
112
+ const trimmed = trimClip(clip("c1", "Drums", 2, 1.5), "start", 2.5);
113
+ assert.equal(trimmed.start, 2.5);
114
+ assert.equal(trimmed.duration, 1);
115
+ });
116
+
117
+ it("slides the end left and keeps the start", () => {
118
+ const trimmed = trimClip(clip("c1", "Drums", 2, 1.5), "end", 3);
119
+ assert.equal(trimmed.start, 2);
120
+ assert.equal(trimmed.duration, 1);
121
+ });
122
+
123
+ it("refuses a trim that leaves the span or breaks the minimum duration", () => {
124
+ const target = clip("c1", "Drums", 2, 1.5);
125
+ assert.throws(() => trimClip(target, "start", 1.9), (error: unknown) => {
126
+ assert.ok(error instanceof TimelineModelError);
127
+ assert.equal(error.code, "trim-invalid");
128
+ return true;
129
+ });
130
+ assert.throws(() => trimClip(target, "start", 3.5 - MINIMUMDURATION + 0.01), TimelineModelError);
131
+ assert.throws(() => trimClip(target, "end", 2 + MINIMUMDURATION - 0.01), TimelineModelError);
132
+ assert.throws(() => trimClip(target, "end", 9), TimelineModelError);
133
+ });
134
+ });
135
+
136
+ describe("timeline move", () => {
137
+ const arrangement = [clip("a", "Drums", 0, 2), clip("b", "Drums", 4, 2), clip("c", "Bass", 0, 4)];
138
+
139
+ it("moves a clip into a free span", () => {
140
+ const moved = moveClip(clip("d", "Drums", 7, 1), -0.5, arrangement);
141
+ assert.equal(moved.start, 6.5);
142
+ });
143
+
144
+ it("clamps the landing at zero", () => {
145
+ const moved = moveClip(clip("d", "Lead", 1, 1), -9, arrangement);
146
+ assert.equal(moved.start, 0);
147
+ });
148
+
149
+ it("rejects the landing on an occupied spot", () => {
150
+ assert.throws(() => moveClip(clip("d", "Drums", 2, 1), 1.5, arrangement), (error: unknown) => {
151
+ assert.ok(error instanceof TimelineModelError);
152
+ assert.equal(error.code, "overlap");
153
+ return true;
154
+ });
155
+ });
156
+
157
+ it("snaps the landing when a grid is passed and moves across tracks", () => {
158
+ const moved = moveClip(clip("d", "Drums", 6, 1), 0.9, arrangement, { grid: GRID });
159
+ assert.equal(moved.start, 7);
160
+ const rebassed = moveClip(clip("d", "Drums", 6, 1), 0, arrangement, { track: "Bass", allowoverlap: true });
161
+ assert.equal(rebassed.track, "Bass");
162
+ });
163
+ });
164
+
165
+ describe("timeline queries", () => {
166
+ const arrangement = [clip("a", "Drums", 0, 1), clip("b", "Bass", 0.5, 2), clip("c", "Lead", 5, 1)];
167
+
168
+ it("answers the clips playing at one instant", () => {
169
+ assert.deepEqual(activeAt(arrangement, 0.75).map((row) => row.id), ["a", "b"]);
170
+ assert.deepEqual(activeAt(arrangement, 1).map((row) => row.id), ["b"]);
171
+ assert.deepEqual(activeAt(arrangement, 10), []);
172
+ });
173
+
174
+ it("refuses a negative instant", () => {
175
+ assert.throws(() => activeAt(arrangement, -0.1), TimelineModelError);
176
+ });
177
+
178
+ it("measures the arrangement length by the last clip end", () => {
179
+ assert.equal(totalDuration(arrangement), 6);
180
+ assert.equal(totalDuration([]), 0);
181
+ });
182
+ });