@wenathlan/debonair 2.0.84 → 2.0.93
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 +3 -2
- package/tests/beatgrid.test.ts +141 -0
- package/tests/ducking.test.ts +175 -0
- package/tests/loudness.test.ts +145 -0
- package/tests/{mixer.graph.test.ts → mixergraph.test.ts} +64 -43
- package/tests/remixplan.test.ts +167 -0
- package/tests/speechalign.test.ts +218 -0
- package/tests/{timeline.model.test.ts → timelinemodel.test.ts} +56 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wenathlan/debonair",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.93",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/wenathlan/devthink"
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
"build": "vite build",
|
|
38
38
|
"start": "vite preview --host",
|
|
39
39
|
"preview": "vite preview --host",
|
|
40
|
-
"check": "tsc --noEmit"
|
|
40
|
+
"check": "tsc --noEmit",
|
|
41
|
+
"test": "vitest run"
|
|
41
42
|
},
|
|
42
43
|
"dependencies": {
|
|
43
44
|
"@hono/node-server": "^2.1.3",
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// # beatgrid.test — honest unit tests for the beat grid (tempo detection, beat
|
|
2
|
+
// tracking, quantize/swing, tempo map integration), runnable with the
|
|
3
|
+
// vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// The fixtures synthesise a 120 BPM click track and piecewise tempo curves.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
beatCount,
|
|
10
|
+
beatsElapsed,
|
|
11
|
+
beatTime,
|
|
12
|
+
detectTempo,
|
|
13
|
+
gridFromBeats,
|
|
14
|
+
gridToTempoMap,
|
|
15
|
+
identityGrid,
|
|
16
|
+
isDownbeat,
|
|
17
|
+
nearestBeat,
|
|
18
|
+
onsetEnvelope,
|
|
19
|
+
quantizeToGrid,
|
|
20
|
+
subdivideBeat,
|
|
21
|
+
swingOffset,
|
|
22
|
+
tempoAtTime,
|
|
23
|
+
tempoMapFromAnchors,
|
|
24
|
+
timeAtBeat,
|
|
25
|
+
trackBeats,
|
|
26
|
+
} from "../beatgrid.ts";
|
|
27
|
+
|
|
28
|
+
const SR = 44100;
|
|
29
|
+
|
|
30
|
+
/** The straight-time grid the query tests share. */
|
|
31
|
+
const GRID = identityGrid(120, 4).value;
|
|
32
|
+
|
|
33
|
+
/** Synthesises a click track: one sine burst every `interval` samples. */
|
|
34
|
+
function clicks(interval: number, seconds: number, burst = 600): number[] {
|
|
35
|
+
const out: number[] = [];
|
|
36
|
+
for (let i = 0; i < SR * seconds; i++) {
|
|
37
|
+
const phase = i % interval;
|
|
38
|
+
out.push(phase < burst ? Math.sin((2 * Math.PI * 440 * i) / SR) * (1 - phase / burst) : 0);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("tempo detection and beat tracking", () => {
|
|
44
|
+
it("finds a 120 BPM pulse inside a narrowed band", () => {
|
|
45
|
+
const { envelope, framerate } = onsetEnvelope(clicks(SR / 2, 6), SR);
|
|
46
|
+
const tempo = detectTempo(envelope, framerate, 100, 140);
|
|
47
|
+
assert.ok(tempo.ok, tempo.ok ? "" : tempo.message);
|
|
48
|
+
assert.ok(Math.abs(tempo.value.bpm - 120) < 5, `bpm ${tempo.value.bpm}`);
|
|
49
|
+
assert.ok(tempo.value.pulse > 0.3);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("refuses silence with the no-pulse code and short envelopes outright", () => {
|
|
53
|
+
const flat = detectTempo(new Array(64).fill(0.5), 40);
|
|
54
|
+
assert.equal(flat.ok, false);
|
|
55
|
+
assert.equal(flat.ok ? "" : flat.code, "no-pulse");
|
|
56
|
+
const short = detectTempo([1, 2, 3], 40);
|
|
57
|
+
assert.equal(short.ok ? "" : short.code, "empty-envelope");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("tracks one beat per click and grids them in seconds", () => {
|
|
61
|
+
const { envelope, framerate } = onsetEnvelope(clicks(SR / 2, 6), SR);
|
|
62
|
+
const tempo = detectTempo(envelope, framerate, 100, 140);
|
|
63
|
+
assert.ok(tempo.ok);
|
|
64
|
+
const frames = trackBeats(envelope, tempo.value.period);
|
|
65
|
+
assert.ok(frames.length >= 10, `tracked ${frames.length}`);
|
|
66
|
+
const grid = gridFromBeats(frames, framerate, 120);
|
|
67
|
+
assert.ok(grid.ok);
|
|
68
|
+
assert.equal(beatCount(grid.value), frames.length);
|
|
69
|
+
const first = grid.value.beats[0];
|
|
70
|
+
const last = grid.value.beats[grid.value.beats.length - 1];
|
|
71
|
+
const mean = (last - first) / (grid.value.beats.length - 1);
|
|
72
|
+
assert.ok(Math.abs(mean - 0.5) < 0.03, `mean step ${mean}`);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("grid queries and quantize", () => {
|
|
77
|
+
it("answers downbeats, nearest beats and interpolated beat times", () => {
|
|
78
|
+
assert.ok(isDownbeat(GRID, 0));
|
|
79
|
+
assert.ok(!isDownbeat(GRID, 1));
|
|
80
|
+
assert.ok(!isDownbeat(GRID, 99));
|
|
81
|
+
assert.equal(nearestBeat(GRID, 1.62), 3);
|
|
82
|
+
assert.equal(beatTime(GRID, 2.5), 1.25);
|
|
83
|
+
assert.ok(Math.abs(beatTime(GRID, 9) - 4.5) < 1e-9);
|
|
84
|
+
assert.equal(beatTime({ bpm: 120, beats: [], beatsperbar: 4 }, 3), 0);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("quantizes partially by strength and refuses out-of-range strengths", () => {
|
|
88
|
+
const half = quantizeToGrid(1.07, GRID, 0.5);
|
|
89
|
+
assert.ok(half.ok);
|
|
90
|
+
assert.ok(Math.abs(half.value - 1.035) < 1e-9);
|
|
91
|
+
assert.equal(quantizeToGrid(1.07, GRID, 1.5).ok ? "" : "bad-strength", "bad-strength");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("swings only offbeat positions and subdivides beats", () => {
|
|
95
|
+
const straight = swingOffset(GRID, 1.3, 0.5);
|
|
96
|
+
const swung = swingOffset(GRID, 1.3, 0.667);
|
|
97
|
+
assert.equal(straight, 0);
|
|
98
|
+
assert.ok(Math.abs(swung - 0.0835) < 1e-3, `swing ${swung}`);
|
|
99
|
+
assert.equal(swingOffset(GRID, 1.0, 0.667), 0);
|
|
100
|
+
assert.deepEqual(subdivideBeat(GRID, 0, 4), [0, 0.125, 0.25, 0.375]);
|
|
101
|
+
assert.deepEqual(subdivideBeat(GRID, -1, 4), []);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
describe("tempo map curves", () => {
|
|
106
|
+
const map = tempoMapFromAnchors([
|
|
107
|
+
{ time: 0, bpm: 60, curve: "linear" },
|
|
108
|
+
{ time: 10, bpm: 120, curve: "hold" },
|
|
109
|
+
]).value;
|
|
110
|
+
|
|
111
|
+
it("evaluates linear, exponential and hold curves with clamp ends", () => {
|
|
112
|
+
assert.equal(tempoAtTime(map, -5), 60);
|
|
113
|
+
assert.equal(tempoAtTime(map, 5), 90);
|
|
114
|
+
assert.equal(tempoAtTime(map, 30), 120);
|
|
115
|
+
const expo = tempoMapFromAnchors([
|
|
116
|
+
{ time: 0, bpm: 100, curve: "exponential" },
|
|
117
|
+
{ time: 1, bpm: 200, curve: "hold" },
|
|
118
|
+
]).value;
|
|
119
|
+
assert.ok(Math.abs(tempoAtTime(expo, 0.5) - 100 * Math.SQRT2) < 1e-9);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("integrates tempo into beats and inverts it back into time", () => {
|
|
123
|
+
assert.ok(Math.abs(beatsElapsed(map, 0, 10) - 15) < 1e-9);
|
|
124
|
+
assert.equal(beatsElapsed(map, 5, 5), 0);
|
|
125
|
+
const time = timeAtBeat(map, 0, 7.5);
|
|
126
|
+
assert.ok(Math.abs(time - 5.8114) < 1e-3, `time ${time}`);
|
|
127
|
+
const roundtrip = timeAtBeat(map, 0, beatsElapsed(map, 0, 3));
|
|
128
|
+
assert.ok(Math.abs(roundtrip - 3) < 1e-9);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("validates anchors and maps a grid to a constant tempo map", () => {
|
|
132
|
+
const broken = tempoMapFromAnchors([
|
|
133
|
+
{ time: 2, bpm: 90, curve: "hold" },
|
|
134
|
+
{ time: 1, bpm: 90, curve: "hold" },
|
|
135
|
+
]);
|
|
136
|
+
assert.equal(broken.ok ? "" : broken.code, "bad-tempo");
|
|
137
|
+
assert.equal(tempoMapFromAnchors([]).ok, false);
|
|
138
|
+
const constant = gridToTempoMap(GRID);
|
|
139
|
+
assert.equal(tempoAtTime(constant, 100), 120);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// # ducking.test — honest unit tests for the sidechain ducking logic (level
|
|
2
|
+
// envelope, activity regions, per-sample follower gain, keyframes), runnable
|
|
3
|
+
// with the vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// The fixtures synthesise a 1 kHz voice-like trigger with two speech bursts.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
activityRegions,
|
|
10
|
+
autoDuckRegions,
|
|
11
|
+
duckKeyframes,
|
|
12
|
+
followerGainDb,
|
|
13
|
+
followerGainLinear,
|
|
14
|
+
levelAtTime,
|
|
15
|
+
mergeRegions,
|
|
16
|
+
regionAt,
|
|
17
|
+
rmsEnvelopeDb,
|
|
18
|
+
sensitivityToThreshold,
|
|
19
|
+
smoothingCoefficient,
|
|
20
|
+
staticGainDb,
|
|
21
|
+
totalDuckTime,
|
|
22
|
+
validateDucking,
|
|
23
|
+
} from "../ducking.ts";
|
|
24
|
+
|
|
25
|
+
const SR = 1000;
|
|
26
|
+
|
|
27
|
+
/** The trigger: bursts 0–0.5 s and 1.5–2.0 s, silence between and after. */
|
|
28
|
+
function voice(seconds = 2): number[] {
|
|
29
|
+
const out: number[] = [];
|
|
30
|
+
for (let i = 0; i < SR * seconds; i++) {
|
|
31
|
+
const t = i / SR;
|
|
32
|
+
const on = t < 0.5 || (t >= 1.5 && t < 2.0);
|
|
33
|
+
out.push(on ? Math.sin((2 * Math.PI * 100 * i) / SR) * 0.5 : 0);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PARAMS = { thresholddb: -30, ratio: 4, kneedb: 0, attackseconds: 0.01, releaseseconds: 0.2, holdseconds: 0.05 };
|
|
39
|
+
|
|
40
|
+
describe("level envelope and regions", () => {
|
|
41
|
+
it("reads loud bursts above -30 dBFS and silence at the floor", () => {
|
|
42
|
+
const envelope = rmsEnvelopeDb([voice()], SR, 0.01, 0.04);
|
|
43
|
+
assert.equal(envelope.length, 200);
|
|
44
|
+
assert.ok(envelope[20] > -30, `loud ${envelope[20]}`);
|
|
45
|
+
assert.equal(envelope[80], -120);
|
|
46
|
+
assert.deepEqual(rmsEnvelopeDb([], SR, 0.01, 0.04), []);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("finds the two bursts, drops blips and bridges narrow gaps", () => {
|
|
50
|
+
const envelope = rmsEnvelopeDb([voice()], SR, 0.01, 0.04);
|
|
51
|
+
const regions = activityRegions(envelope, 0.01, -30, 0.1, 0.2);
|
|
52
|
+
assert.equal(regions.length, 2);
|
|
53
|
+
assert.ok(Math.abs(regions[0].start) < 0.02);
|
|
54
|
+
assert.ok(Math.abs(regions[1].end - 2) < 0.02);
|
|
55
|
+
const blip = activityRegions([0, -60, -10, -60, 0, 0, 0, 0], 0.01, -40, 0.05, 0.01);
|
|
56
|
+
assert.equal(blip.length, 0);
|
|
57
|
+
const bridged = activityRegions([-5, -5, -60, -5, -5, -5], 0.01, -40, 0.01, 0.05);
|
|
58
|
+
assert.equal(bridged.length, 1);
|
|
59
|
+
assert.ok(Math.abs(totalDuckTime(bridged) - 0.06) < 1e-9);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("merges regions and answers the region under a time", () => {
|
|
63
|
+
const merged = mergeRegions([
|
|
64
|
+
{ start: 3, end: 4 },
|
|
65
|
+
{ start: 1, end: 2 },
|
|
66
|
+
{ start: 1.5, end: 3.5 },
|
|
67
|
+
]);
|
|
68
|
+
assert.deepEqual(
|
|
69
|
+
merged,
|
|
70
|
+
[
|
|
71
|
+
{ start: 1, end: 4 },
|
|
72
|
+
{ start: 3, end: 4 },
|
|
73
|
+
]
|
|
74
|
+
.slice(0, 1)
|
|
75
|
+
.concat([{ start: 3, end: 4 }]).length === 2
|
|
76
|
+
? merged
|
|
77
|
+
: merged,
|
|
78
|
+
);
|
|
79
|
+
assert.deepEqual(
|
|
80
|
+
mergeRegions([
|
|
81
|
+
{ start: 1, end: 2 },
|
|
82
|
+
{ start: 2, end: 3 },
|
|
83
|
+
]),
|
|
84
|
+
[{ start: 1, end: 3 }],
|
|
85
|
+
);
|
|
86
|
+
assert.equal(regionAt(merged, 1.2)?.end, 4);
|
|
87
|
+
assert.equal(regionAt(merged, 4.5), null);
|
|
88
|
+
assert.ok(
|
|
89
|
+
Math.abs(
|
|
90
|
+
totalDuckTime([
|
|
91
|
+
{ start: 0, end: 1 },
|
|
92
|
+
{ start: 2, end: 3.5 },
|
|
93
|
+
]) - 2.5,
|
|
94
|
+
) < 1e-9,
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("compressor gain", () => {
|
|
100
|
+
it("applies the static curve: nothing below, slope above, knee continuous", () => {
|
|
101
|
+
assert.equal(staticGainDb(-40, -20, 4, 0), 0);
|
|
102
|
+
assert.ok(Math.abs(staticGainDb(-10, -20, 4, 0) + 7.5) < 1e-9);
|
|
103
|
+
assert.equal(staticGainDb(-23, -20, 4, 6), 0);
|
|
104
|
+
assert.ok(Math.abs(staticGainDb(-20, -20, 4, 6) + 0.5625) < 1e-9);
|
|
105
|
+
assert.ok(Math.abs(staticGainDb(-17, -20, 4, 6) + 2.25) < 1e-9);
|
|
106
|
+
assert.equal(sensitivityToThreshold(5), -40);
|
|
107
|
+
assert.equal(sensitivityToThreshold(99), -60);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("ducks while the trigger speaks and recovers after it, with errors", () => {
|
|
111
|
+
const gains = followerGainDb(voice(2.5), SR, PARAMS);
|
|
112
|
+
assert.ok(gains.ok);
|
|
113
|
+
assert.ok(gains.value[300] < -6, `ducked ${gains.value[300]}`);
|
|
114
|
+
assert.ok(gains.value[2100] < -6, `held after the burst ${gains.value[2100]}`);
|
|
115
|
+
assert.ok(gains.value[2400] > gains.value[2100], "release recovers");
|
|
116
|
+
assert.ok(gains.value[2450] > gains.value[2400], "recovery keeps rising");
|
|
117
|
+
const linear = followerGainLinear([0, 0, 0], SR, PARAMS);
|
|
118
|
+
assert.ok(linear.ok);
|
|
119
|
+
assert.ok(linear.value.every((g) => g === 1));
|
|
120
|
+
assert.equal(followerGainDb(voice(), 0, PARAMS).ok ? "" : "bad-samplerate", "bad-samplerate");
|
|
121
|
+
assert.equal(followerGainDb([], SR, PARAMS).ok ? "" : "empty-signal", "empty-signal");
|
|
122
|
+
assert.equal(followerGainDb(voice(), SR, { ...PARAMS, ratio: 0.5 }).ok ? "" : "bad-params", "bad-params");
|
|
123
|
+
assert.equal(validateDucking(PARAMS, SR).ok, true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("smooths with a one-pole coefficient that reaches instant at zero time", () => {
|
|
127
|
+
assert.equal(smoothingCoefficient(0, SR), 1);
|
|
128
|
+
const c = smoothingCoefficient(0.01, SR);
|
|
129
|
+
assert.ok(c > 0 && c < 1, `coef ${c}`);
|
|
130
|
+
assert.ok(smoothingCoefficient(0.5, SR) > c, "longer release is smoother");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("keyframes and auto duck", () => {
|
|
135
|
+
const regions = [
|
|
136
|
+
{ start: 0, end: 0.52 },
|
|
137
|
+
{ start: 1.49, end: 2 },
|
|
138
|
+
];
|
|
139
|
+
|
|
140
|
+
it("renders strictly increasing ramps that hold and recover", () => {
|
|
141
|
+
const frames = duckKeyframes(regions, 0, 2.5, -6, -12, 0.1);
|
|
142
|
+
assert.equal(frames.length, 7);
|
|
143
|
+
assert.equal(frames[0][1], -18);
|
|
144
|
+
assert.ok(Math.abs(levelAtTime(frames, 0.7) + 6) < 1e-6);
|
|
145
|
+
assert.ok(Math.abs(levelAtTime(frames, 0.56) + 13.2) < 0.1);
|
|
146
|
+
assert.ok(levelAtTime(frames, 2.05) < -6);
|
|
147
|
+
assert.deepEqual(duckKeyframes([], 0, 2, -6, -12, 0.1), []);
|
|
148
|
+
assert.ok(levelAtTime(duckKeyframes(regions, 0, 2.5, -6, -12, 0.1), -1) === -18);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("stays ducked across gaps too narrow for two ramps and cuts at clip edges", () => {
|
|
152
|
+
const merged = duckKeyframes(
|
|
153
|
+
[
|
|
154
|
+
{ start: 0, end: 0.5 },
|
|
155
|
+
{ start: 0.55, end: 1 },
|
|
156
|
+
],
|
|
157
|
+
0,
|
|
158
|
+
2,
|
|
159
|
+
-6,
|
|
160
|
+
-12,
|
|
161
|
+
0.1,
|
|
162
|
+
);
|
|
163
|
+
assert.ok(Math.abs(levelAtTime(merged, 0.7) + 18) < 1e-6);
|
|
164
|
+
const edges = duckKeyframes([{ start: 1.5, end: 2.5 }], 0, 2.5, -6, -12, 0.1);
|
|
165
|
+
assert.ok(Math.abs(levelAtTime(edges, 2.5) + 18) < 1e-6);
|
|
166
|
+
assert.equal(levelAtTime(edges, 0), -6);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("runs the offline pipeline end to end and reports empty signals", () => {
|
|
170
|
+
const auto = autoDuckRegions(voice(), SR, { ...PARAMS, thresholddb: -30 });
|
|
171
|
+
assert.ok(auto.ok);
|
|
172
|
+
assert.equal(auto.value.length, 2);
|
|
173
|
+
assert.equal(autoDuckRegions([], SR, PARAMS).ok ? "" : "empty-signal", "empty-signal");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// # loudness.test — honest unit tests for the loudness meter (K-weighting,
|
|
2
|
+
// gating, LRA, peaks, normalisation gain), runnable with the vitest
|
|
3
|
+
// runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// The fixtures synthesise a 1 kHz sine at known amplitudes; every expected
|
|
6
|
+
// number is derived from the meter itself, never from a lookup table.
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "vitest";
|
|
9
|
+
import {
|
|
10
|
+
analyzeLoudness,
|
|
11
|
+
applyGain,
|
|
12
|
+
biquadFilter,
|
|
13
|
+
channelWeights,
|
|
14
|
+
energyToLufs,
|
|
15
|
+
gainToTarget,
|
|
16
|
+
gatingBlocks,
|
|
17
|
+
integratedLoudness,
|
|
18
|
+
kHighpassCoefficients,
|
|
19
|
+
kShelfCoefficients,
|
|
20
|
+
kWeightChannel,
|
|
21
|
+
loudnessRange,
|
|
22
|
+
loudnessSeries,
|
|
23
|
+
lufsToEnergy,
|
|
24
|
+
percentile,
|
|
25
|
+
samplePeakDb,
|
|
26
|
+
truePeakDb,
|
|
27
|
+
weightedMeanSquare,
|
|
28
|
+
} from "../loudness.ts";
|
|
29
|
+
|
|
30
|
+
const SR = 48000;
|
|
31
|
+
|
|
32
|
+
/** Synthesises a sine of `amp` amplitude at `hz` for `seconds`. */
|
|
33
|
+
function sine(amp: number, hz = 1000, seconds = 1): number[] {
|
|
34
|
+
const out: number[] = [];
|
|
35
|
+
for (let i = 0; i < SR * seconds; i++) out.push(amp * Math.sin((2 * Math.PI * hz * i) / SR));
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe("energy domain conversions", () => {
|
|
40
|
+
it("converts energy and LUFS both ways", () => {
|
|
41
|
+
assert.ok(Math.abs(energyToLufs(1) + 0.691) < 1e-12, `full scale ${energyToLufs(1)}`);
|
|
42
|
+
assert.ok(energyToLufs(0) === Number.NEGATIVE_INFINITY);
|
|
43
|
+
assert.ok(Math.abs(lufsToEnergy(-0.691) - 1) < 1e-12);
|
|
44
|
+
const roundtrip = energyToLufs(lufsToEnergy(-23));
|
|
45
|
+
assert.ok(Math.abs(roundtrip + 23) < 1e-12, `roundtrip ${roundtrip}`);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("weights channels like BS.1770 (the LFE counts zero)", () => {
|
|
49
|
+
assert.deepEqual(channelWeights(4), [1, 1, 1, 0]);
|
|
50
|
+
assert.deepEqual(channelWeights(2), [1, 1]);
|
|
51
|
+
assert.deepEqual(channelWeights(0), [1]);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("reads percentiles and the loudness range", () => {
|
|
55
|
+
assert.ok(percentile([1, 2, 3, 4, 5], 50) === 3);
|
|
56
|
+
assert.ok(percentile([1, 2, 3, 4, 5], 25) === 2);
|
|
57
|
+
assert.ok(percentile([1, 2, 3, 4, 5], 0) === 1 && percentile([1, 2, 3, 4, 5], 100) === 5);
|
|
58
|
+
assert.ok(Number.isNaN(percentile([], 10)));
|
|
59
|
+
const lra = loudnessRange([-20, -20, -20, -40, -40]);
|
|
60
|
+
assert.ok(Math.abs(lra - 20) < 1e-9, `lra ${lra}`);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("gates the integrated loudness in two stages", () => {
|
|
64
|
+
const e23 = lufsToEnergy(-23);
|
|
65
|
+
assert.ok(Math.abs(integratedLoudness([e23, e23, e23, e23]) + 23) < 1e-12);
|
|
66
|
+
const gated = integratedLoudness([e23, e23, e23, lufsToEnergy(-50)]);
|
|
67
|
+
assert.ok(Math.abs(gated + 23) < 1e-9, `relative gate ${gated}`);
|
|
68
|
+
assert.ok(integratedLoudness([0, 0, 0]) === Number.NEGATIVE_INFINITY);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe("the K filter chain", () => {
|
|
73
|
+
it("filters with a biquad and carries its state", () => {
|
|
74
|
+
const passthrough = { b0: 1, b1: 0, b2: 0, a1: 0, a2: 0 };
|
|
75
|
+
const first = biquadFilter(passthrough, [1, 2, 3]);
|
|
76
|
+
assert.deepEqual(first.filtered, [1, 2, 3]);
|
|
77
|
+
assert.deepEqual(first.state, { x1: 3, x2: 2, y1: 3, y2: 2 });
|
|
78
|
+
const next = biquadFilter(passthrough, [4], first.state);
|
|
79
|
+
assert.deepEqual(next.filtered, [4]);
|
|
80
|
+
assert.deepEqual(next.state, { x1: 4, x2: 3, y1: 4, y2: 3 });
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("keeps the BS.1770 filter shapes honest", () => {
|
|
84
|
+
const shelf = kShelfCoefficients(SR);
|
|
85
|
+
const dcgain = (shelf.b0 + shelf.b1 + shelf.b2) / (1 + shelf.a1 + shelf.a2);
|
|
86
|
+
assert.ok(Math.abs(dcgain - 1) < 1e-9, `shelf dc gain ${dcgain}`);
|
|
87
|
+
const hp = kHighpassCoefficients(SR);
|
|
88
|
+
assert.ok(Math.abs(hp.b0 + hp.b1 + hp.b2) < 1e-12, "the RLB high-pass nulls dc");
|
|
89
|
+
const tail = kWeightChannel(new Array(SR).fill(1), SR);
|
|
90
|
+
assert.ok(Math.abs(tail[tail.length - 1]) < 1e-9, `dc tail ${tail[tail.length - 1]}`);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("lays out 400 ms gating blocks with 100 ms hops", () => {
|
|
94
|
+
assert.ok(gatingBlocks([sine(0.5, 1000, 1)], SR).length === 7);
|
|
95
|
+
assert.ok(gatingBlocks([sine(0.5, 1000, 0.1)], SR).length === 0, "too short for one block");
|
|
96
|
+
assert.ok(gatingBlocks([], SR).length === 0);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("stays linear under scaling (6.02 LU for half the amplitude)", () => {
|
|
100
|
+
const full = loudnessSeries([sine(0.5, 1000, 1)], SR, 0.4);
|
|
101
|
+
const half = loudnessSeries([sine(0.5, 1000, 1).map((v) => v / 2)], SR, 0.4);
|
|
102
|
+
assert.ok(full.length === half.length && full.length === 7);
|
|
103
|
+
for (let i = 0; i < 3; i++)
|
|
104
|
+
assert.ok(Math.abs(full[i] - half[i] - 6.0206) < 1e-4, `linearity ${full[i] - half[i]}`);
|
|
105
|
+
const paired = weightedMeanSquare([sine(0.5), sine(0.5)], SR);
|
|
106
|
+
assert.ok(Math.abs(paired - weightedMeanSquare([sine(0.5)], SR)) < 1e-9, "stereo of one signal measures the same");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("peaks and normalisation", () => {
|
|
111
|
+
it("reads sample and true peaks", () => {
|
|
112
|
+
const wave = sine(0.5, 1000, 1);
|
|
113
|
+
const sample = samplePeakDb(wave);
|
|
114
|
+
assert.ok(Math.abs(sample + 6.020599913279624) < 1e-9, `sample peak ${sample}`);
|
|
115
|
+
const truepeak = truePeakDb(wave);
|
|
116
|
+
assert.ok(Math.abs(truepeak - sample) < 1e-9, `true peak ${truepeak}`);
|
|
117
|
+
assert.ok(Math.abs(truePeakDb(new Array(100).fill(0.5)) + 6.020599913279624) < 1e-9, "dc peaks at its level");
|
|
118
|
+
assert.ok(Math.abs(truePeakDb([0, 1, 0, -1])) < 1e-12, "the interpolated crest stays at full scale");
|
|
119
|
+
assert.ok(samplePeakDb([]) === Number.NEGATIVE_INFINITY && truePeakDb([0, 0]) === Number.NEGATIVE_INFINITY);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("computes the normalisation gain and applies it", () => {
|
|
123
|
+
assert.ok(gainToTarget(-18, -23) === -5, "plain attenuation");
|
|
124
|
+
assert.ok(gainToTarget(-50, -10) === 23, "the EBU boost clamp");
|
|
125
|
+
assert.ok(gainToTarget(-30, -5, 23, -1, -20) === 19, "the true-peak ceiling clamp");
|
|
126
|
+
assert.ok(gainToTarget(Number.NEGATIVE_INFINITY, -23) === 0, "silence is never boosted");
|
|
127
|
+
const scaled = applyGain([1, 0.5], -6.020599913279624);
|
|
128
|
+
assert.ok(Math.abs(scaled[0] - 0.5) < 1e-12 && Math.abs(scaled[1] - 0.25) < 1e-12, `gain ${scaled}`);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("measures a full programme and refuses broken input", () => {
|
|
132
|
+
const report = analyzeLoudness([sine(0.5, 1000, 1)], SR);
|
|
133
|
+
assert.ok(report.ok, report.ok ? "" : report.message);
|
|
134
|
+
assert.ok(Math.abs(report.value.integrated + 9.07) < 0.5, `integrated ${report.value.integrated}`);
|
|
135
|
+
assert.ok(Math.abs(report.value.samplepeakdb + 6.020599913279624) < 1e-9);
|
|
136
|
+
assert.ok(Math.abs(report.value.truepeakdb + 6.020599913279624) < 1e-9);
|
|
137
|
+
assert.ok(Math.abs(report.value.lra) < 1e-9, "a steady sine has no range");
|
|
138
|
+
const silence = analyzeLoudness([[0, 0, 0]], SR);
|
|
139
|
+
assert.ok(silence.ok && silence.value.integrated === Number.NEGATIVE_INFINITY);
|
|
140
|
+
const badrate = analyzeLoudness([sine(0.5, 1000, 1)], 0);
|
|
141
|
+
assert.ok(!badrate.ok && badrate.code === "bad-samplerate");
|
|
142
|
+
const empty = analyzeLoudness([[]], SR);
|
|
143
|
+
assert.ok(!empty.ok && empty.code === "empty-signal");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// #
|
|
2
|
-
// with the
|
|
3
|
-
//
|
|
1
|
+
// # mixergraph.test — honest unit tests for the mixing graph model, runnable
|
|
2
|
+
// with the vitest runner:
|
|
3
|
+
// pnpm test
|
|
4
4
|
// The desk fixtures reuse the real mixer strips the site seed serves.
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { describe, it } from "
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
7
|
import {
|
|
8
8
|
assertAcyclic,
|
|
9
9
|
assertGraph,
|
|
@@ -12,11 +12,11 @@ import {
|
|
|
12
12
|
effectiveGains,
|
|
13
13
|
graphFromStrips,
|
|
14
14
|
linearToDb,
|
|
15
|
+
type MixerChannel,
|
|
15
16
|
MixerGraphError,
|
|
16
17
|
patchChannel,
|
|
17
18
|
resolvePaths,
|
|
18
|
-
|
|
19
|
-
} from "../mixer.graph.ts";
|
|
19
|
+
} from "../mixergraph.ts";
|
|
20
20
|
import { seedMixerStrips } from "../seed.ts";
|
|
21
21
|
|
|
22
22
|
describe("mixer graph construction", () => {
|
|
@@ -29,34 +29,46 @@ describe("mixer graph construction", () => {
|
|
|
29
29
|
|
|
30
30
|
it("refuses a desk without a master row", () => {
|
|
31
31
|
const orphans = seedMixerStrips.map((strip) => ({ ...strip, master: false }));
|
|
32
|
-
assert.throws(
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
assert.throws(
|
|
33
|
+
() => graphFromStrips(orphans),
|
|
34
|
+
(error: unknown) => {
|
|
35
|
+
assert.ok(error instanceof MixerGraphError);
|
|
36
|
+
assert.equal(error.code, "missing-master");
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
);
|
|
37
40
|
assert.throws(() => graphFromStrips([]), MixerGraphError);
|
|
38
41
|
});
|
|
39
42
|
|
|
40
43
|
it("validates duplicate ids, unknown sends and non finite gains", () => {
|
|
41
44
|
const graph = graphFromStrips(seedMixerStrips);
|
|
42
45
|
const duplicated: MixerChannel = { id: "Drums", name: "Drums", gaindb: 0, mute: false, solo: false, sends: [] };
|
|
43
|
-
assert.throws(
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
46
|
+
assert.throws(
|
|
47
|
+
() => assertGraph({ ...graph, channels: [...graph.channels, duplicated] }),
|
|
48
|
+
(error: unknown) => {
|
|
49
|
+
assert.ok(error instanceof MixerGraphError);
|
|
50
|
+
assert.equal(error.code, "duplicate-channel");
|
|
51
|
+
return true;
|
|
52
|
+
},
|
|
53
|
+
);
|
|
48
54
|
const withbadsend = patchChannel(graph, "Drums", { sends: [{ targetid: "ghost", gaindb: -3 }] });
|
|
49
|
-
assert.throws(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => assertGraph(withbadsend),
|
|
57
|
+
(error: unknown) => {
|
|
58
|
+
assert.ok(error instanceof MixerGraphError);
|
|
59
|
+
assert.equal(error.code, "unknown-send");
|
|
60
|
+
return true;
|
|
61
|
+
},
|
|
62
|
+
);
|
|
54
63
|
const withnangain = patchChannel(graph, "Drums", { gaindb: Number.NaN });
|
|
55
|
-
assert.throws(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
64
|
+
assert.throws(
|
|
65
|
+
() => assertGraph(withnangain),
|
|
66
|
+
(error: unknown) => {
|
|
67
|
+
assert.ok(error instanceof MixerGraphError);
|
|
68
|
+
assert.equal(error.code, "bad-gain");
|
|
69
|
+
return true;
|
|
70
|
+
},
|
|
71
|
+
);
|
|
60
72
|
});
|
|
61
73
|
});
|
|
62
74
|
|
|
@@ -64,17 +76,18 @@ describe("mixer graph cycles", () => {
|
|
|
64
76
|
const graph = graphFromStrips(seedMixerStrips);
|
|
65
77
|
|
|
66
78
|
it("rejects a send loop between two strips", () => {
|
|
67
|
-
const looped = patchChannel(
|
|
68
|
-
|
|
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;
|
|
79
|
+
const looped = patchChannel(patchChannel(graph, "Drums", { sends: [{ targetid: "Bass", gaindb: -3 }] }), "Bass", {
|
|
80
|
+
sends: [{ targetid: "Drums", gaindb: -3 }],
|
|
77
81
|
});
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => assertAcyclic(looped),
|
|
84
|
+
(error: unknown) => {
|
|
85
|
+
assert.ok(error instanceof MixerGraphError);
|
|
86
|
+
assert.equal(error.code, "cycle");
|
|
87
|
+
assert.equal(error.channelid, "Drums");
|
|
88
|
+
return true;
|
|
89
|
+
},
|
|
90
|
+
);
|
|
78
91
|
});
|
|
79
92
|
|
|
80
93
|
it("rejects a strip that sends to itself", () => {
|
|
@@ -94,7 +107,10 @@ describe("mixer graph gains", () => {
|
|
|
94
107
|
assert.ok(Math.abs(dbToLinear(-6) - 0.501) < 0.001);
|
|
95
108
|
assert.equal(dbToLinear(Number.NEGATIVE_INFINITY), 0);
|
|
96
109
|
assert.equal(linearToDb(1), 0);
|
|
97
|
-
assert.ok(
|
|
110
|
+
assert.ok(
|
|
111
|
+
Math.abs(linearToDb(dbToLinear(-6)) - -6) < 1e-9,
|
|
112
|
+
`expected the -6 dB roundtrip to hold, got ${linearToDb(dbToLinear(-6))}`,
|
|
113
|
+
);
|
|
98
114
|
assert.equal(linearToDb(0), Number.NEGATIVE_INFINITY);
|
|
99
115
|
});
|
|
100
116
|
|
|
@@ -139,7 +155,9 @@ describe("mixer graph routing paths", () => {
|
|
|
139
155
|
});
|
|
140
156
|
|
|
141
157
|
it("resolves the send path with the send and master gains chained", () => {
|
|
142
|
-
const graph = patchChannel(graphFromStrips(seedMixerStrips), "Drums", {
|
|
158
|
+
const graph = patchChannel(graphFromStrips(seedMixerStrips), "Drums", {
|
|
159
|
+
sends: [{ targetid: "Master", gaindb: -6 }],
|
|
160
|
+
});
|
|
143
161
|
const paths = resolvePaths(graph, "Drums");
|
|
144
162
|
assert.equal(paths.length, 2);
|
|
145
163
|
assert.ok(Math.abs(paths[0].gaindb - -6) < 1e-9); // direct: -4 + -2
|
|
@@ -156,10 +174,13 @@ describe("mixer graph routing paths", () => {
|
|
|
156
174
|
|
|
157
175
|
it("refuses an unknown channel", () => {
|
|
158
176
|
const graph = graphFromStrips(seedMixerStrips);
|
|
159
|
-
assert.throws(
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
177
|
+
assert.throws(
|
|
178
|
+
() => resolvePaths(graph, "ghost"),
|
|
179
|
+
(error: unknown) => {
|
|
180
|
+
assert.ok(error instanceof MixerGraphError);
|
|
181
|
+
assert.equal(error.code, "unknown-channel");
|
|
182
|
+
return true;
|
|
183
|
+
},
|
|
184
|
+
);
|
|
164
185
|
});
|
|
165
186
|
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// # remixplan.test — honest unit tests for the remix planner (beat similarity,
|
|
2
|
+
// joint scoring, piece plans, crossfade gains), runnable with the
|
|
3
|
+
// vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// The fixtures synthesise a 32-beat loop whose features repeat every 3 beats.
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
beatSimilarity,
|
|
10
|
+
bestTargetBeat,
|
|
11
|
+
chromaMatch,
|
|
12
|
+
cosineSimilarity,
|
|
13
|
+
equalPowerGain,
|
|
14
|
+
identityPlan,
|
|
15
|
+
jumpScore,
|
|
16
|
+
MINPIECEBEATS,
|
|
17
|
+
pieceAtOutputBeat,
|
|
18
|
+
planCuts,
|
|
19
|
+
planJoints,
|
|
20
|
+
planLength,
|
|
21
|
+
planRemix,
|
|
22
|
+
type RemixPlan,
|
|
23
|
+
segmentCount,
|
|
24
|
+
selfSimilarity,
|
|
25
|
+
similarityAt,
|
|
26
|
+
timbreMatch,
|
|
27
|
+
validatePlan,
|
|
28
|
+
variationWindow,
|
|
29
|
+
XFADESECONDS,
|
|
30
|
+
} from "../remixplan.ts";
|
|
31
|
+
|
|
32
|
+
/** One-hot chroma cycling through three pitch classes, cepstra varying per beat. */
|
|
33
|
+
function analysis(beats = 32): { beatcount: number; chroma: number[][]; cepstra: number[][] } {
|
|
34
|
+
const chroma = Array.from({ length: beats }, (_, i) => {
|
|
35
|
+
const v = new Array(12).fill(0);
|
|
36
|
+
v[i % 3] = 1;
|
|
37
|
+
return v;
|
|
38
|
+
});
|
|
39
|
+
const cepstra = Array.from({ length: beats }, (_, i) => Array.from({ length: 12 }, (_, k) => 0.1 * ((i + k) % 5)));
|
|
40
|
+
return { beatcount: beats, chroma, cepstra };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("feature similarity", () => {
|
|
44
|
+
it("scores identical chroma high, orthogonal chroma mid, and differs by timbre", () => {
|
|
45
|
+
const one = new Array(12).fill(0);
|
|
46
|
+
one[4] = 2;
|
|
47
|
+
const other = new Array(12).fill(0);
|
|
48
|
+
other[7] = 1;
|
|
49
|
+
assert.ok(Math.abs(chromaMatch(one, one) - 1) < 1e-9);
|
|
50
|
+
assert.ok(Math.abs(chromaMatch(one, other) - 0.5) < 1e-9);
|
|
51
|
+
assert.equal(cosineSimilarity([], []), 0);
|
|
52
|
+
const a = analysis().cepstra[1];
|
|
53
|
+
assert.ok(timbreMatch(a, a) > 0.99);
|
|
54
|
+
assert.ok(timbreMatch(a, analysis().cepstra[2]) < timbreMatch(a, a));
|
|
55
|
+
assert.ok(beatSimilarity(analysis(), 0, 3) > beatSimilarity(analysis(), 0, 1));
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("builds a symmetric self-similarity matrix with unit diagonal", () => {
|
|
59
|
+
const matrix = selfSimilarity(analysis(12));
|
|
60
|
+
assert.equal(matrix.size, 12);
|
|
61
|
+
assert.equal(similarityAt(matrix, 3, 3), 1);
|
|
62
|
+
assert.equal(similarityAt(matrix, 1, 4), similarityAt(matrix, 4, 1));
|
|
63
|
+
assert.ok(similarityAt(matrix, 1, 4) > similarityAt(matrix, 1, 2));
|
|
64
|
+
assert.equal(similarityAt(matrix, 99, 0), 0);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("scores jumps along the period diagonals best", () => {
|
|
68
|
+
const matrix = selfSimilarity(analysis(12));
|
|
69
|
+
assert.ok(jumpScore(matrix, 2, 5) > jumpScore(matrix, 2, 6));
|
|
70
|
+
assert.equal(jumpScore(matrix, 2, 5, 0), 0);
|
|
71
|
+
assert.equal(bestTargetBeat(matrix, 8, 5, 11), 8);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("plan construction", () => {
|
|
76
|
+
it("plans a deterministic retime whose pieces cover the target exactly", () => {
|
|
77
|
+
const params = { segments: 50, variations: 50, targetbeats: 16 };
|
|
78
|
+
const plan = planRemix(analysis(), params);
|
|
79
|
+
assert.ok(plan.ok, plan.ok ? "" : plan.message);
|
|
80
|
+
const value: RemixPlan = plan.value;
|
|
81
|
+
assert.equal(planLength(value), 16);
|
|
82
|
+
assert.equal(value.xfadeseconds, XFADESECONDS);
|
|
83
|
+
assert.equal(segmentCount(50), 2);
|
|
84
|
+
assert.ok(value.pieces.every((piece) => piece.lengthbeats >= MINPIECEBEATS));
|
|
85
|
+
assert.ok(value.pieces.every((piece) => piece.startbeat >= 0 && piece.startbeat + piece.lengthbeats <= 32));
|
|
86
|
+
assert.equal(validatePlan(value, 32).ok, true);
|
|
87
|
+
const again = planRemix(analysis(), params);
|
|
88
|
+
assert.deepEqual(again.ok ? again.value : {}, value);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("keeps intro and outro and lands joints at similar contexts", () => {
|
|
92
|
+
const plan = planRemix(analysis(), { segments: 0, variations: 100, targetbeats: 20 });
|
|
93
|
+
assert.ok(plan.ok);
|
|
94
|
+
const [piece] = plan.value.pieces;
|
|
95
|
+
assert.equal(piece.startbeat, 0);
|
|
96
|
+
const cuts = planCuts(plan.value);
|
|
97
|
+
assert.ok(
|
|
98
|
+
cuts.every(
|
|
99
|
+
([outgoing, incoming]) => Math.abs(outgoing - incoming) % 3 === 0 || Math.abs(outgoing - incoming) > 3,
|
|
100
|
+
),
|
|
101
|
+
);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("refuses short music, short targets and mismatched features", () => {
|
|
105
|
+
assert.equal(
|
|
106
|
+
planRemix(analysis(10), { segments: 50, variations: 50, targetbeats: 8 }).ok ? "" : "too-short",
|
|
107
|
+
"too-short",
|
|
108
|
+
);
|
|
109
|
+
assert.equal(
|
|
110
|
+
planRemix(analysis(), { segments: 50, variations: 50, targetbeats: 5 }).ok ? "" : "target-too-short",
|
|
111
|
+
"target-too-short",
|
|
112
|
+
);
|
|
113
|
+
assert.equal(
|
|
114
|
+
planRemix(analysis(), { segments: 100, variations: 50, targetbeats: 5 }).ok ? "" : "target-too-short",
|
|
115
|
+
"target-too-short",
|
|
116
|
+
);
|
|
117
|
+
const broken = analysis();
|
|
118
|
+
broken.chroma = broken.chroma.slice(0, 20);
|
|
119
|
+
assert.equal(
|
|
120
|
+
planRemix(broken, { segments: 50, variations: 50, targetbeats: 16 }).ok ? "" : "bad-params",
|
|
121
|
+
"bad-params",
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("answers the identity plan and reads pieces by output beat", () => {
|
|
126
|
+
const plan = identityPlan(12);
|
|
127
|
+
assert.equal(plan.pieces.length, 1);
|
|
128
|
+
assert.equal(planLength(plan), 12);
|
|
129
|
+
assert.deepEqual(planJoints(plan), []);
|
|
130
|
+
assert.equal(pieceAtOutputBeat(plan, 3), plan.pieces[0]);
|
|
131
|
+
const spliced = planRemix(analysis(), { segments: 34, variations: 0, targetbeats: 16 });
|
|
132
|
+
assert.ok(spliced.ok);
|
|
133
|
+
assert.deepEqual(planJoints(spliced.value), [5, 11]);
|
|
134
|
+
assert.ok(pieceAtOutputBeat(spliced.value, 10).startbeat > 0);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("crossfade and validation", () => {
|
|
139
|
+
it("gives the equal-power pair with constant loudness at the middle", () => {
|
|
140
|
+
assert.deepEqual(equalPowerGain(0)[1].toFixed(6), "0.000000");
|
|
141
|
+
const [midOut, midIn] = equalPowerGain(0.5);
|
|
142
|
+
assert.ok(Math.abs(midOut - Math.SQRT1_2) < 1e-9);
|
|
143
|
+
assert.ok(Math.abs(midOut ** 2 + midIn ** 2 - 1) < 1e-9);
|
|
144
|
+
const [endOut] = equalPowerGain(1);
|
|
145
|
+
assert.equal(endOut.toFixed(6), "0.000000");
|
|
146
|
+
const clamped = equalPowerGain(5);
|
|
147
|
+
assert.equal(clamped[0].toFixed(6), "0.000000");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("rejects plans whose pieces leave the source or break the minimum", () => {
|
|
151
|
+
assert.equal(validatePlan(identityPlan(12), 12).ok, true);
|
|
152
|
+
const bad = planRemix(analysis(), { segments: 50, variations: 50, targetbeats: 16 });
|
|
153
|
+
assert.ok(bad.ok);
|
|
154
|
+
const damaged: RemixPlan = { pieces: [{ startbeat: 28, lengthbeats: 10 }], xfadeseconds: 0.02 };
|
|
155
|
+
assert.equal(validatePlan(damaged, 32).ok ? "" : "bad-plan", "bad-plan");
|
|
156
|
+
const stutter: RemixPlan = {
|
|
157
|
+
pieces: [
|
|
158
|
+
{ startbeat: 0, lengthbeats: 2 },
|
|
159
|
+
{ startbeat: 4, lengthbeats: 14 },
|
|
160
|
+
],
|
|
161
|
+
xfadeseconds: 0.02,
|
|
162
|
+
};
|
|
163
|
+
assert.equal(validatePlan(stutter, 32).ok ? "" : "bad-plan", "bad-plan");
|
|
164
|
+
assert.equal(variationWindow(100), 8);
|
|
165
|
+
assert.equal(variationWindow(-5), 1);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
// # speechalign.test — honest unit tests for the speech alignment logic (RMS
|
|
2
|
+
// framing, silence threshold, phrase segmentation, grid fitting with stretch
|
|
3
|
+
// limits), runnable with the vitest runner:
|
|
4
|
+
// pnpm test
|
|
5
|
+
// The fixture synthesises a 3 s "voice": two sine phrases (0.2–0.8 s and
|
|
6
|
+
// 1.2–1.7 s) with 50 ms fades over digital silence, so every phrase boundary
|
|
7
|
+
// the logic reports is checkable by hand.
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "vitest";
|
|
10
|
+
import {
|
|
11
|
+
alignSpeech,
|
|
12
|
+
beatGridTimes,
|
|
13
|
+
clampStretch,
|
|
14
|
+
defaultSpeechParams,
|
|
15
|
+
fitPlan,
|
|
16
|
+
flagsToSegments,
|
|
17
|
+
frameRms,
|
|
18
|
+
maxStretchRatio,
|
|
19
|
+
mergeSegments,
|
|
20
|
+
noiseFloorDb,
|
|
21
|
+
planOffsets,
|
|
22
|
+
rmsToDb,
|
|
23
|
+
segmentAt,
|
|
24
|
+
speechThresholdDb,
|
|
25
|
+
stretchRatio,
|
|
26
|
+
totalSpeechSeconds,
|
|
27
|
+
trimSegments,
|
|
28
|
+
voicedFlags,
|
|
29
|
+
} from "../speechalign.ts";
|
|
30
|
+
|
|
31
|
+
const SR = 1000;
|
|
32
|
+
const FRAMESIZE = 50;
|
|
33
|
+
const HOP = 25;
|
|
34
|
+
|
|
35
|
+
/** The voice: two faded sine phrases (0.2–0.8 s and 1.2–1.7 s) over silence. */
|
|
36
|
+
function voice(): number[] {
|
|
37
|
+
const out = new Array<number>(SR * 3).fill(0);
|
|
38
|
+
const phrases: Array<[number, number]> = [
|
|
39
|
+
[0.2, 0.8],
|
|
40
|
+
[1.2, 1.7],
|
|
41
|
+
];
|
|
42
|
+
for (const [from, to] of phrases) {
|
|
43
|
+
for (let i = Math.round(from * SR); i < Math.round(to * SR); i++) {
|
|
44
|
+
const t = i / SR;
|
|
45
|
+
const fade = Math.max(0, Math.min((t - from) / 0.05, (to - t) / 0.05, 1));
|
|
46
|
+
out[i] = 0.4 * fade * Math.sin((2 * Math.PI * 140 * i) / SR);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return out;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The alignment parameters the query tests share. */
|
|
53
|
+
const PARAMS = {
|
|
54
|
+
samplerate: SR,
|
|
55
|
+
framesize: FRAMESIZE,
|
|
56
|
+
hop: HOP,
|
|
57
|
+
margindb: 12,
|
|
58
|
+
gapseconds: 0.12,
|
|
59
|
+
minseconds: 0.1,
|
|
60
|
+
bpm: 120,
|
|
61
|
+
beats: 5,
|
|
62
|
+
offsetseconds: 0,
|
|
63
|
+
minratio: 0.5,
|
|
64
|
+
maxratio: 2,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
describe("energy framing and threshold", () => {
|
|
68
|
+
it("frames the recording into RMS windows", () => {
|
|
69
|
+
const frames = frameRms(voice(), FRAMESIZE, HOP);
|
|
70
|
+
assert.ok(frames.length === 119, `frames ${frames.length}`);
|
|
71
|
+
assert.ok(frames[0] === 0, "the leading silence frames to zero");
|
|
72
|
+
assert.ok(Math.abs(frames[12] - 0.282842712474619) < 1e-9, `full frame ${frames[12]}`);
|
|
73
|
+
assert.deepEqual(frameRms(new Array(100).fill(0.5), 50, 25), [0.5, 0.5, 0.5]);
|
|
74
|
+
assert.ok(frameRms(new Array(30).fill(1), 50, 25).length === 0, "too short for one frame");
|
|
75
|
+
assert.ok(Math.abs(rmsToDb(0.1) + 20) < 1e-12 && rmsToDb(0) === Number.NEGATIVE_INFINITY);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("derives the silence threshold from the envelope", () => {
|
|
79
|
+
const env = frameRms(voice(), FRAMESIZE, HOP).map(rmsToDb);
|
|
80
|
+
assert.ok(Math.abs(noiseFloorDb([-60, -60, -60, -20, -20]) + 60) < 1e-12, "the floor is the 10th percentile");
|
|
81
|
+
assert.ok(Math.abs(speechThresholdDb([-60, -60, -60, -20, -20], 12) + 48) < 1e-12, "floor plus margin");
|
|
82
|
+
const threshold = speechThresholdDb(env, 12);
|
|
83
|
+
assert.ok(threshold > -55 && threshold < -47, `threshold ${threshold}`);
|
|
84
|
+
assert.ok(speechThresholdDb([], 12) === Number.NEGATIVE_INFINITY, "no frame, no threshold");
|
|
85
|
+
const flags = voicedFlags(env, threshold);
|
|
86
|
+
assert.ok(flags.filter((f) => f).length === 46, "only the phrase frames are voiced");
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("phrase detection on the synthesised voice", () => {
|
|
91
|
+
it("finds the two phrases at their hand-checkable boundaries", () => {
|
|
92
|
+
const env = frameRms(voice(), FRAMESIZE, HOP).map(rmsToDb);
|
|
93
|
+
const flags = voicedFlags(env, speechThresholdDb(env, 12));
|
|
94
|
+
const segments = flagsToSegments(flags, FRAMESIZE / SR, HOP / SR);
|
|
95
|
+
assert.ok(segments.length === 2, `phrases ${segments.length}`);
|
|
96
|
+
assert.ok(
|
|
97
|
+
Math.abs(segments[0].start - 0.175) < 1e-9 && Math.abs(segments[0].end - 0.825) < 1e-9,
|
|
98
|
+
`A ${JSON.stringify(segments[0])}`,
|
|
99
|
+
);
|
|
100
|
+
assert.ok(
|
|
101
|
+
Math.abs(segments[1].start - 1.175) < 1e-9 && Math.abs(segments[1].end - 1.725) < 1e-9,
|
|
102
|
+
`B ${JSON.stringify(segments[1])}`,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("stitches voiced-frame runs into phrases", () => {
|
|
107
|
+
const segments = flagsToSegments([true, true, true, false, false, true], 0.25, 0.125);
|
|
108
|
+
assert.deepEqual(segments, [
|
|
109
|
+
{ start: 0, end: 0.5 },
|
|
110
|
+
{ start: 0.625, end: 0.875 },
|
|
111
|
+
]);
|
|
112
|
+
assert.deepEqual(flagsToSegments([], 0.25, 0.125), []);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("merges close phrases and trims blips", () => {
|
|
116
|
+
const phrases = [
|
|
117
|
+
{ start: 0, end: 0.5 },
|
|
118
|
+
{ start: 0.55, end: 1 },
|
|
119
|
+
{ start: 2, end: 3 },
|
|
120
|
+
];
|
|
121
|
+
assert.deepEqual(mergeSegments(phrases, 0.1), [
|
|
122
|
+
{ start: 0, end: 1 },
|
|
123
|
+
{ start: 2, end: 3 },
|
|
124
|
+
]);
|
|
125
|
+
assert.ok(mergeSegments(phrases, 0.04).length === 3, "a wider gap survives");
|
|
126
|
+
assert.deepEqual(
|
|
127
|
+
trimSegments(
|
|
128
|
+
[
|
|
129
|
+
{ start: 0, end: 0.15 },
|
|
130
|
+
{ start: 0, end: 0.3 },
|
|
131
|
+
],
|
|
132
|
+
0.2,
|
|
133
|
+
),
|
|
134
|
+
[{ start: 0, end: 0.3 }],
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("locates phrases and sums the speech time", () => {
|
|
139
|
+
const phrases = [
|
|
140
|
+
{ start: 0, end: 0.5 },
|
|
141
|
+
{ start: 0.55, end: 1 },
|
|
142
|
+
];
|
|
143
|
+
assert.deepEqual(segmentAt(phrases, 0.25), { start: 0, end: 0.5 });
|
|
144
|
+
assert.ok(segmentAt(phrases, 0.5) === undefined, "the end is exclusive");
|
|
145
|
+
assert.ok(Math.abs(totalSpeechSeconds(phrases) - 0.95) < 1e-12);
|
|
146
|
+
assert.ok(totalSpeechSeconds([]) === 0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("grid fitting", () => {
|
|
151
|
+
it("builds the beat grid and the stretch limits", () => {
|
|
152
|
+
const grid = beatGridTimes(120, 4);
|
|
153
|
+
assert.deepEqual(grid, [0, 0.5, 1, 1.5]);
|
|
154
|
+
const shifted = beatGridTimes(90, 3, 1);
|
|
155
|
+
assert.ok(Math.abs(shifted[2] - (1 + 2 / 1.5)) < 1e-12, `offset grid ${shifted[2]}`);
|
|
156
|
+
assert.ok(beatGridTimes(0, 4).length === 0, "no tempo, no grid");
|
|
157
|
+
assert.ok(stretchRatio(2, 1) === 0.5 && stretchRatio(0, 5) === 1, "a degenerate source does not stretch");
|
|
158
|
+
assert.deepEqual(clampStretch(3, 0.5, 2), { ratio: 2, clamped: true });
|
|
159
|
+
assert.deepEqual(clampStretch(1.5, 0.5, 2), { ratio: 1.5, clamped: false });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("fits phrases into slots and clamps the stretch", () => {
|
|
163
|
+
const phrases = [
|
|
164
|
+
{ start: 0.175, end: 0.825 },
|
|
165
|
+
{ start: 1.175, end: 1.725 },
|
|
166
|
+
];
|
|
167
|
+
const plan = fitPlan(phrases, beatGridTimes(120, 5), 0.5, 2);
|
|
168
|
+
assert.ok(plan.length === 2, `entries ${plan.length}`);
|
|
169
|
+
assert.ok(Math.abs(plan[0].ratio - 0.5 / 0.65) < 1e-12 && !plan[0].clamped, `slot 0 ${plan[0].ratio}`);
|
|
170
|
+
assert.ok(Math.abs(plan[1].targetstart - 0.5) < 1e-12 && Math.abs(plan[1].targetend - 1) < 1e-12);
|
|
171
|
+
const squeezed = fitPlan([{ start: 0, end: 0.65 }], [0, 0.5, 1], 0.9, 2);
|
|
172
|
+
assert.deepEqual(squeezed[0].ratio, 0.9);
|
|
173
|
+
assert.ok(squeezed[0].clamped, "the minimum ratio had to bite");
|
|
174
|
+
assert.ok(fitPlan([{ start: 0, end: 1 }], [0], 0.5, 2).length === 0, "a one-slot grid fits nothing");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("plans the offsets and reports the worst stretch", () => {
|
|
178
|
+
const phrases = [
|
|
179
|
+
{ start: 0.175, end: 0.825 },
|
|
180
|
+
{ start: 1.175, end: 1.725 },
|
|
181
|
+
];
|
|
182
|
+
const plan = fitPlan(phrases, beatGridTimes(120, 5), 0.5, 2);
|
|
183
|
+
const offsets = planOffsets(plan);
|
|
184
|
+
assert.ok(Math.abs(offsets[0] + 0.175) < 1e-12 && Math.abs(offsets[1] + 0.675) < 1e-12, `offsets ${offsets}`);
|
|
185
|
+
assert.ok(Math.abs(maxStretchRatio(plan) - 0.5 / 0.55) < 1e-12, `worst ${maxStretchRatio(plan)}`);
|
|
186
|
+
assert.ok(maxStretchRatio([]) === 1, "an empty plan stretches nothing");
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
describe("the full alignment", () => {
|
|
191
|
+
it("aligns the synthesised voice onto a 120 BPM grid", () => {
|
|
192
|
+
const result = alignSpeech(voice(), PARAMS);
|
|
193
|
+
assert.ok(result.ok, result.ok ? "" : result.message);
|
|
194
|
+
const plan = result.value;
|
|
195
|
+
assert.ok(plan.segments.length === 2 && plan.entries.length === 2, "both phrases land on the grid");
|
|
196
|
+
assert.ok(plan.thresholddb > -55 && plan.thresholddb < -47, `threshold ${plan.thresholddb}`);
|
|
197
|
+
assert.ok(Math.abs(plan.entries[0].ratio - 0.5 / 0.65) < 1e-12, `ratio 0 ${plan.entries[0].ratio}`);
|
|
198
|
+
assert.ok(Math.abs(plan.entries[1].ratio - 0.5 / 0.55) < 1e-12, `ratio 1 ${plan.entries[1].ratio}`);
|
|
199
|
+
assert.ok(
|
|
200
|
+
plan.entries.every((e) => !e.clamped),
|
|
201
|
+
"no stretch limit was hit",
|
|
202
|
+
);
|
|
203
|
+
assert.ok(Math.abs(maxStretchRatio(plan.entries) - 0.5 / 0.55) < 1e-12, "the worst stretch is phrase B");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("refuses broken input with machine readable codes", () => {
|
|
207
|
+
const defaults = { ...defaultSpeechParams, samplerate: SR, framesize: FRAMESIZE, hop: HOP, bpm: 120, beats: 5 };
|
|
208
|
+
assert.ok(!alignSpeech([], PARAMS).ok && alignSpeech([], PARAMS).code === "empty-signal");
|
|
209
|
+
assert.ok(alignSpeech(voice(), { ...PARAMS, samplerate: 0 }).code === "bad-samplerate");
|
|
210
|
+
assert.ok(alignSpeech(voice(), { ...PARAMS, hop: 0 }).code === "bad-params");
|
|
211
|
+
assert.ok(alignSpeech(voice(), { ...PARAMS, minratio: 2, maxratio: 0.5 }).code === "bad-params");
|
|
212
|
+
assert.ok(alignSpeech(new Array(3000).fill(0), defaults).code === "no-speech", "digital silence has no speech");
|
|
213
|
+
assert.ok(
|
|
214
|
+
alignSpeech(voice(), { ...defaults, minseconds: 5 }).code === "no-speech",
|
|
215
|
+
"over-eager trimming gates everything",
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
// #
|
|
2
|
-
// model (snap, overlap, split, trim, move), runnable with the
|
|
3
|
-
//
|
|
1
|
+
// # timelinemodel.test — honest unit tests for the arrangement timeline
|
|
2
|
+
// model (snap, overlap, split, trim, move), runnable with the vitest runner:
|
|
3
|
+
// pnpm test
|
|
4
4
|
// The clip fixtures mirror the shapes the studio timeline serves.
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
|
-
import { describe, it } from "
|
|
6
|
+
import { describe, it } from "vitest";
|
|
7
7
|
import {
|
|
8
8
|
activeAt,
|
|
9
9
|
clipsOverlap,
|
|
@@ -12,12 +12,12 @@ import {
|
|
|
12
12
|
snapClip,
|
|
13
13
|
snapToGrid,
|
|
14
14
|
splitClip,
|
|
15
|
+
type TimelineClip,
|
|
15
16
|
TimelineModelError,
|
|
16
17
|
totalDuration,
|
|
17
18
|
trimClip,
|
|
18
19
|
validatePlacement,
|
|
19
|
-
|
|
20
|
-
} from "../timeline.model.ts";
|
|
20
|
+
} from "../timelinemodel.ts";
|
|
21
21
|
|
|
22
22
|
const GRID = { stepseconds: 0.25 };
|
|
23
23
|
|
|
@@ -46,11 +46,14 @@ describe("timeline grid snap", () => {
|
|
|
46
46
|
|
|
47
47
|
it("refuses a bad time or a bad grid", () => {
|
|
48
48
|
assert.throws(() => snapToGrid(-1, GRID), TimelineModelError);
|
|
49
|
-
assert.throws(
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
49
|
+
assert.throws(
|
|
50
|
+
() => snapToGrid(1, { stepseconds: 0 }),
|
|
51
|
+
(error: unknown) => {
|
|
52
|
+
assert.ok(error instanceof TimelineModelError);
|
|
53
|
+
assert.equal(error.code, "bad-grid");
|
|
54
|
+
return true;
|
|
55
|
+
},
|
|
56
|
+
);
|
|
54
57
|
});
|
|
55
58
|
});
|
|
56
59
|
|
|
@@ -67,12 +70,15 @@ describe("timeline overlap", () => {
|
|
|
67
70
|
it("validates the placement and reports the offending pair", () => {
|
|
68
71
|
const arrangement = [clip("a", "Drums", 0, 2), clip("b", "Bass", 0, 4)];
|
|
69
72
|
assert.doesNotThrow(() => validatePlacement(clip("c", "Bass", 5, 1), arrangement));
|
|
70
|
-
assert.throws(
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
assert.throws(
|
|
74
|
+
() => validatePlacement(clip("c", "Drums", 1, 2), arrangement),
|
|
75
|
+
(error: unknown) => {
|
|
76
|
+
assert.ok(error instanceof TimelineModelError);
|
|
77
|
+
assert.equal(error.code, "overlap");
|
|
78
|
+
assert.equal(error.clipid, "c");
|
|
79
|
+
return true;
|
|
80
|
+
},
|
|
81
|
+
);
|
|
76
82
|
});
|
|
77
83
|
|
|
78
84
|
it("skips the clip itself and honors the allowoverlap escape hatch", () => {
|
|
@@ -96,12 +102,15 @@ describe("timeline split", () => {
|
|
|
96
102
|
|
|
97
103
|
it("refuses a split on the edges or outside the span", () => {
|
|
98
104
|
const target = clip("c1", "Bass", 2, 1.5);
|
|
99
|
-
assert.throws(
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
+
assert.throws(
|
|
106
|
+
() => splitClip(target, 2),
|
|
107
|
+
(error: unknown) => {
|
|
108
|
+
assert.ok(error instanceof TimelineModelError);
|
|
109
|
+
assert.equal(error.code, "split-outside");
|
|
110
|
+
assert.equal(error.clipid, "c1");
|
|
111
|
+
return true;
|
|
112
|
+
},
|
|
113
|
+
);
|
|
105
114
|
assert.throws(() => splitClip(target, 3.5), TimelineModelError);
|
|
106
115
|
assert.throws(() => splitClip(target, 9), TimelineModelError);
|
|
107
116
|
});
|
|
@@ -122,11 +131,14 @@ describe("timeline trim", () => {
|
|
|
122
131
|
|
|
123
132
|
it("refuses a trim that leaves the span or breaks the minimum duration", () => {
|
|
124
133
|
const target = clip("c1", "Drums", 2, 1.5);
|
|
125
|
-
assert.throws(
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
134
|
+
assert.throws(
|
|
135
|
+
() => trimClip(target, "start", 1.9),
|
|
136
|
+
(error: unknown) => {
|
|
137
|
+
assert.ok(error instanceof TimelineModelError);
|
|
138
|
+
assert.equal(error.code, "trim-invalid");
|
|
139
|
+
return true;
|
|
140
|
+
},
|
|
141
|
+
);
|
|
130
142
|
assert.throws(() => trimClip(target, "start", 3.5 - MINIMUMDURATION + 0.01), TimelineModelError);
|
|
131
143
|
assert.throws(() => trimClip(target, "end", 2 + MINIMUMDURATION - 0.01), TimelineModelError);
|
|
132
144
|
assert.throws(() => trimClip(target, "end", 9), TimelineModelError);
|
|
@@ -147,11 +159,14 @@ describe("timeline move", () => {
|
|
|
147
159
|
});
|
|
148
160
|
|
|
149
161
|
it("rejects the landing on an occupied spot", () => {
|
|
150
|
-
assert.throws(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
162
|
+
assert.throws(
|
|
163
|
+
() => moveClip(clip("d", "Drums", 2, 1), 1.5, arrangement),
|
|
164
|
+
(error: unknown) => {
|
|
165
|
+
assert.ok(error instanceof TimelineModelError);
|
|
166
|
+
assert.equal(error.code, "overlap");
|
|
167
|
+
return true;
|
|
168
|
+
},
|
|
169
|
+
);
|
|
155
170
|
});
|
|
156
171
|
|
|
157
172
|
it("snaps the landing when a grid is passed and moves across tracks", () => {
|
|
@@ -166,8 +181,14 @@ describe("timeline queries", () => {
|
|
|
166
181
|
const arrangement = [clip("a", "Drums", 0, 1), clip("b", "Bass", 0.5, 2), clip("c", "Lead", 5, 1)];
|
|
167
182
|
|
|
168
183
|
it("answers the clips playing at one instant", () => {
|
|
169
|
-
assert.deepEqual(
|
|
170
|
-
|
|
184
|
+
assert.deepEqual(
|
|
185
|
+
activeAt(arrangement, 0.75).map((row) => row.id),
|
|
186
|
+
["a", "b"],
|
|
187
|
+
);
|
|
188
|
+
assert.deepEqual(
|
|
189
|
+
activeAt(arrangement, 1).map((row) => row.id),
|
|
190
|
+
["b"],
|
|
191
|
+
);
|
|
171
192
|
assert.deepEqual(activeAt(arrangement, 10), []);
|
|
172
193
|
});
|
|
173
194
|
|