@wenathlan/debonair 2.0.84 → 2.0.89
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 +1 -1
- 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} +3 -3
- package/tests/remixplan.test.ts +167 -0
- package/tests/speechalign.test.ts +219 -0
- package/tests/{timeline.model.test.ts → timelinemodel.test.ts} +3 -3
package/package.json
CHANGED
|
@@ -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 node
|
|
3
|
+
// built-in runner (no dependencies, no install):
|
|
4
|
+
// node --test tests/beatgrid.test.ts
|
|
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 "node:test";
|
|
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 node built-in runner (no dependencies, no install):
|
|
4
|
+
// node --test tests/ducking.test.ts
|
|
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 "node:test";
|
|
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 node built-in
|
|
3
|
+
// runner (no dependencies, no install):
|
|
4
|
+
// node --test tests/loudness.test.ts
|
|
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 "node:test";
|
|
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,6 +1,6 @@
|
|
|
1
|
-
// #
|
|
1
|
+
// # mixergraph.test — honest unit tests for the mixing graph model, runnable
|
|
2
2
|
// with the node built-in runner (no dependencies, no install):
|
|
3
|
-
// node --test tests/
|
|
3
|
+
// node --test tests/mixergraph.test.ts
|
|
4
4
|
// The desk fixtures reuse the real mixer strips the site seed serves.
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { describe, it } from "node:test";
|
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
patchChannel,
|
|
17
17
|
resolvePaths,
|
|
18
18
|
type MixerChannel,
|
|
19
|
-
} from "../
|
|
19
|
+
} from "../mixergraph.ts";
|
|
20
20
|
import { seedMixerStrips } from "../seed.ts";
|
|
21
21
|
|
|
22
22
|
describe("mixer graph construction", () => {
|
|
@@ -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 node
|
|
3
|
+
// built-in runner (no dependencies, no install):
|
|
4
|
+
// node --test tests/remixplan.test.ts
|
|
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 "node:test";
|
|
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,219 @@
|
|
|
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 node built-in runner (no dependencies, no
|
|
4
|
+
// install):
|
|
5
|
+
// node --test tests/speechalign.test.ts
|
|
6
|
+
// The fixture synthesises a 3 s "voice": two sine phrases (0.2–0.8 s and
|
|
7
|
+
// 1.2–1.7 s) with 50 ms fades over digital silence, so every phrase boundary
|
|
8
|
+
// the logic reports is checkable by hand.
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import {
|
|
12
|
+
alignSpeech,
|
|
13
|
+
beatGridTimes,
|
|
14
|
+
clampStretch,
|
|
15
|
+
defaultSpeechParams,
|
|
16
|
+
fitPlan,
|
|
17
|
+
flagsToSegments,
|
|
18
|
+
frameRms,
|
|
19
|
+
maxStretchRatio,
|
|
20
|
+
mergeSegments,
|
|
21
|
+
noiseFloorDb,
|
|
22
|
+
planOffsets,
|
|
23
|
+
rmsToDb,
|
|
24
|
+
segmentAt,
|
|
25
|
+
speechThresholdDb,
|
|
26
|
+
stretchRatio,
|
|
27
|
+
totalSpeechSeconds,
|
|
28
|
+
trimSegments,
|
|
29
|
+
voicedFlags,
|
|
30
|
+
} from "../speechalign.ts";
|
|
31
|
+
|
|
32
|
+
const SR = 1000;
|
|
33
|
+
const FRAMESIZE = 50;
|
|
34
|
+
const HOP = 25;
|
|
35
|
+
|
|
36
|
+
/** The voice: two faded sine phrases (0.2–0.8 s and 1.2–1.7 s) over silence. */
|
|
37
|
+
function voice(): number[] {
|
|
38
|
+
const out = new Array<number>(SR * 3).fill(0);
|
|
39
|
+
const phrases: Array<[number, number]> = [
|
|
40
|
+
[0.2, 0.8],
|
|
41
|
+
[1.2, 1.7],
|
|
42
|
+
];
|
|
43
|
+
for (const [from, to] of phrases) {
|
|
44
|
+
for (let i = Math.round(from * SR); i < Math.round(to * SR); i++) {
|
|
45
|
+
const t = i / SR;
|
|
46
|
+
const fade = Math.max(0, Math.min((t - from) / 0.05, (to - t) / 0.05, 1));
|
|
47
|
+
out[i] = 0.4 * fade * Math.sin((2 * Math.PI * 140 * i) / SR);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The alignment parameters the query tests share. */
|
|
54
|
+
const PARAMS = {
|
|
55
|
+
samplerate: SR,
|
|
56
|
+
framesize: FRAMESIZE,
|
|
57
|
+
hop: HOP,
|
|
58
|
+
margindb: 12,
|
|
59
|
+
gapseconds: 0.12,
|
|
60
|
+
minseconds: 0.1,
|
|
61
|
+
bpm: 120,
|
|
62
|
+
beats: 5,
|
|
63
|
+
offsetseconds: 0,
|
|
64
|
+
minratio: 0.5,
|
|
65
|
+
maxratio: 2,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("energy framing and threshold", () => {
|
|
69
|
+
it("frames the recording into RMS windows", () => {
|
|
70
|
+
const frames = frameRms(voice(), FRAMESIZE, HOP);
|
|
71
|
+
assert.ok(frames.length === 119, `frames ${frames.length}`);
|
|
72
|
+
assert.ok(frames[0] === 0, "the leading silence frames to zero");
|
|
73
|
+
assert.ok(Math.abs(frames[12] - 0.282842712474619) < 1e-9, `full frame ${frames[12]}`);
|
|
74
|
+
assert.deepEqual(frameRms(new Array(100).fill(0.5), 50, 25), [0.5, 0.5, 0.5]);
|
|
75
|
+
assert.ok(frameRms(new Array(30).fill(1), 50, 25).length === 0, "too short for one frame");
|
|
76
|
+
assert.ok(Math.abs(rmsToDb(0.1) + 20) < 1e-12 && rmsToDb(0) === Number.NEGATIVE_INFINITY);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("derives the silence threshold from the envelope", () => {
|
|
80
|
+
const env = frameRms(voice(), FRAMESIZE, HOP).map(rmsToDb);
|
|
81
|
+
assert.ok(Math.abs(noiseFloorDb([-60, -60, -60, -20, -20]) + 60) < 1e-12, "the floor is the 10th percentile");
|
|
82
|
+
assert.ok(Math.abs(speechThresholdDb([-60, -60, -60, -20, -20], 12) + 48) < 1e-12, "floor plus margin");
|
|
83
|
+
const threshold = speechThresholdDb(env, 12);
|
|
84
|
+
assert.ok(threshold > -55 && threshold < -47, `threshold ${threshold}`);
|
|
85
|
+
assert.ok(speechThresholdDb([], 12) === Number.NEGATIVE_INFINITY, "no frame, no threshold");
|
|
86
|
+
const flags = voicedFlags(env, threshold);
|
|
87
|
+
assert.ok(flags.filter((f) => f).length === 46, "only the phrase frames are voiced");
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("phrase detection on the synthesised voice", () => {
|
|
92
|
+
it("finds the two phrases at their hand-checkable boundaries", () => {
|
|
93
|
+
const env = frameRms(voice(), FRAMESIZE, HOP).map(rmsToDb);
|
|
94
|
+
const flags = voicedFlags(env, speechThresholdDb(env, 12));
|
|
95
|
+
const segments = flagsToSegments(flags, FRAMESIZE / SR, HOP / SR);
|
|
96
|
+
assert.ok(segments.length === 2, `phrases ${segments.length}`);
|
|
97
|
+
assert.ok(
|
|
98
|
+
Math.abs(segments[0].start - 0.175) < 1e-9 && Math.abs(segments[0].end - 0.825) < 1e-9,
|
|
99
|
+
`A ${JSON.stringify(segments[0])}`,
|
|
100
|
+
);
|
|
101
|
+
assert.ok(
|
|
102
|
+
Math.abs(segments[1].start - 1.175) < 1e-9 && Math.abs(segments[1].end - 1.725) < 1e-9,
|
|
103
|
+
`B ${JSON.stringify(segments[1])}`,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("stitches voiced-frame runs into phrases", () => {
|
|
108
|
+
const segments = flagsToSegments([true, true, true, false, false, true], 0.25, 0.125);
|
|
109
|
+
assert.deepEqual(segments, [
|
|
110
|
+
{ start: 0, end: 0.5 },
|
|
111
|
+
{ start: 0.625, end: 0.875 },
|
|
112
|
+
]);
|
|
113
|
+
assert.deepEqual(flagsToSegments([], 0.25, 0.125), []);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("merges close phrases and trims blips", () => {
|
|
117
|
+
const phrases = [
|
|
118
|
+
{ start: 0, end: 0.5 },
|
|
119
|
+
{ start: 0.55, end: 1 },
|
|
120
|
+
{ start: 2, end: 3 },
|
|
121
|
+
];
|
|
122
|
+
assert.deepEqual(mergeSegments(phrases, 0.1), [
|
|
123
|
+
{ start: 0, end: 1 },
|
|
124
|
+
{ start: 2, end: 3 },
|
|
125
|
+
]);
|
|
126
|
+
assert.ok(mergeSegments(phrases, 0.04).length === 3, "a wider gap survives");
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
trimSegments(
|
|
129
|
+
[
|
|
130
|
+
{ start: 0, end: 0.15 },
|
|
131
|
+
{ start: 0, end: 0.3 },
|
|
132
|
+
],
|
|
133
|
+
0.2,
|
|
134
|
+
),
|
|
135
|
+
[{ start: 0, end: 0.3 }],
|
|
136
|
+
);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("locates phrases and sums the speech time", () => {
|
|
140
|
+
const phrases = [
|
|
141
|
+
{ start: 0, end: 0.5 },
|
|
142
|
+
{ start: 0.55, end: 1 },
|
|
143
|
+
];
|
|
144
|
+
assert.deepEqual(segmentAt(phrases, 0.25), { start: 0, end: 0.5 });
|
|
145
|
+
assert.ok(segmentAt(phrases, 0.5) === undefined, "the end is exclusive");
|
|
146
|
+
assert.ok(Math.abs(totalSpeechSeconds(phrases) - 0.95) < 1e-12);
|
|
147
|
+
assert.ok(totalSpeechSeconds([]) === 0);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe("grid fitting", () => {
|
|
152
|
+
it("builds the beat grid and the stretch limits", () => {
|
|
153
|
+
const grid = beatGridTimes(120, 4);
|
|
154
|
+
assert.deepEqual(grid, [0, 0.5, 1, 1.5]);
|
|
155
|
+
const shifted = beatGridTimes(90, 3, 1);
|
|
156
|
+
assert.ok(Math.abs(shifted[2] - (1 + 2 / 1.5)) < 1e-12, `offset grid ${shifted[2]}`);
|
|
157
|
+
assert.ok(beatGridTimes(0, 4).length === 0, "no tempo, no grid");
|
|
158
|
+
assert.ok(stretchRatio(2, 1) === 0.5 && stretchRatio(0, 5) === 1, "a degenerate source does not stretch");
|
|
159
|
+
assert.deepEqual(clampStretch(3, 0.5, 2), { ratio: 2, clamped: true });
|
|
160
|
+
assert.deepEqual(clampStretch(1.5, 0.5, 2), { ratio: 1.5, clamped: false });
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("fits phrases into slots and clamps the stretch", () => {
|
|
164
|
+
const phrases = [
|
|
165
|
+
{ start: 0.175, end: 0.825 },
|
|
166
|
+
{ start: 1.175, end: 1.725 },
|
|
167
|
+
];
|
|
168
|
+
const plan = fitPlan(phrases, beatGridTimes(120, 5), 0.5, 2);
|
|
169
|
+
assert.ok(plan.length === 2, `entries ${plan.length}`);
|
|
170
|
+
assert.ok(Math.abs(plan[0].ratio - 0.5 / 0.65) < 1e-12 && !plan[0].clamped, `slot 0 ${plan[0].ratio}`);
|
|
171
|
+
assert.ok(Math.abs(plan[1].targetstart - 0.5) < 1e-12 && Math.abs(plan[1].targetend - 1) < 1e-12);
|
|
172
|
+
const squeezed = fitPlan([{ start: 0, end: 0.65 }], [0, 0.5, 1], 0.9, 2);
|
|
173
|
+
assert.deepEqual(squeezed[0].ratio, 0.9);
|
|
174
|
+
assert.ok(squeezed[0].clamped, "the minimum ratio had to bite");
|
|
175
|
+
assert.ok(fitPlan([{ start: 0, end: 1 }], [0], 0.5, 2).length === 0, "a one-slot grid fits nothing");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("plans the offsets and reports the worst stretch", () => {
|
|
179
|
+
const phrases = [
|
|
180
|
+
{ start: 0.175, end: 0.825 },
|
|
181
|
+
{ start: 1.175, end: 1.725 },
|
|
182
|
+
];
|
|
183
|
+
const plan = fitPlan(phrases, beatGridTimes(120, 5), 0.5, 2);
|
|
184
|
+
const offsets = planOffsets(plan);
|
|
185
|
+
assert.ok(Math.abs(offsets[0] + 0.175) < 1e-12 && Math.abs(offsets[1] + 0.675) < 1e-12, `offsets ${offsets}`);
|
|
186
|
+
assert.ok(Math.abs(maxStretchRatio(plan) - 0.5 / 0.55) < 1e-12, `worst ${maxStretchRatio(plan)}`);
|
|
187
|
+
assert.ok(maxStretchRatio([]) === 1, "an empty plan stretches nothing");
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
describe("the full alignment", () => {
|
|
192
|
+
it("aligns the synthesised voice onto a 120 BPM grid", () => {
|
|
193
|
+
const result = alignSpeech(voice(), PARAMS);
|
|
194
|
+
assert.ok(result.ok, result.ok ? "" : result.message);
|
|
195
|
+
const plan = result.value;
|
|
196
|
+
assert.ok(plan.segments.length === 2 && plan.entries.length === 2, "both phrases land on the grid");
|
|
197
|
+
assert.ok(plan.thresholddb > -55 && plan.thresholddb < -47, `threshold ${plan.thresholddb}`);
|
|
198
|
+
assert.ok(Math.abs(plan.entries[0].ratio - 0.5 / 0.65) < 1e-12, `ratio 0 ${plan.entries[0].ratio}`);
|
|
199
|
+
assert.ok(Math.abs(plan.entries[1].ratio - 0.5 / 0.55) < 1e-12, `ratio 1 ${plan.entries[1].ratio}`);
|
|
200
|
+
assert.ok(
|
|
201
|
+
plan.entries.every((e) => !e.clamped),
|
|
202
|
+
"no stretch limit was hit",
|
|
203
|
+
);
|
|
204
|
+
assert.ok(Math.abs(maxStretchRatio(plan.entries) - 0.5 / 0.55) < 1e-12, "the worst stretch is phrase B");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("refuses broken input with machine readable codes", () => {
|
|
208
|
+
const defaults = { ...defaultSpeechParams, samplerate: SR, framesize: FRAMESIZE, hop: HOP, bpm: 120, beats: 5 };
|
|
209
|
+
assert.ok(!alignSpeech([], PARAMS).ok && alignSpeech([], PARAMS).code === "empty-signal");
|
|
210
|
+
assert.ok(alignSpeech(voice(), { ...PARAMS, samplerate: 0 }).code === "bad-samplerate");
|
|
211
|
+
assert.ok(alignSpeech(voice(), { ...PARAMS, hop: 0 }).code === "bad-params");
|
|
212
|
+
assert.ok(alignSpeech(voice(), { ...PARAMS, minratio: 2, maxratio: 0.5 }).code === "bad-params");
|
|
213
|
+
assert.ok(alignSpeech(new Array(3000).fill(0), defaults).code === "no-speech", "digital silence has no speech");
|
|
214
|
+
assert.ok(
|
|
215
|
+
alignSpeech(voice(), { ...defaults, minseconds: 5 }).code === "no-speech",
|
|
216
|
+
"over-eager trimming gates everything",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// #
|
|
1
|
+
// # timelinemodel.test — honest unit tests for the arrangement timeline
|
|
2
2
|
// model (snap, overlap, split, trim, move), runnable with the node runner:
|
|
3
|
-
// node --test tests/
|
|
3
|
+
// node --test tests/timelinemodel.test.ts
|
|
4
4
|
// The clip fixtures mirror the shapes the studio timeline serves.
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { describe, it } from "node:test";
|
|
@@ -17,7 +17,7 @@ import {
|
|
|
17
17
|
trimClip,
|
|
18
18
|
validatePlacement,
|
|
19
19
|
type TimelineClip,
|
|
20
|
-
} from "../
|
|
20
|
+
} from "../timelinemodel.ts";
|
|
21
21
|
|
|
22
22
|
const GRID = { stepseconds: 0.25 };
|
|
23
23
|
|