mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 10:14:30 +00:00
* fix(studio): make the volume fader tell the truth about the gain it writes The fader travels in dB, so its stops are irrational values; serializing them through the generic two-decimal numeric formatter collapsed the bottom quarter of its travel onto "0" — a hard mute — and made the knob jump on release everywhere below unity. Both panels now use the exact serializer, which round-trips every integer stop back to itself. Raise the volume automation lane to the same ceiling the fader reaches. Clamping the lane at unity meant automating a boosted clip silently discarded the boost, and the panel disables the fader while a lane owns the level, so there was no way back. This rescales the lane's vertical axis: unity now sits a quarter of the way up rather than at the top. Add audio_volume_tween_overrides_gain. Tween values on `volume` are absolute — they replace the authored gain rather than scaling it — so a clip carrying both plays at whatever the tween names, and the fader gives no sign of it. The rule reuses the tween detector the sibling lane/tween rule already has. * fix(lint): treat a missing data-volume as unity, not as silence readAttr returns null when the attribute is absent, and Number(null) is 0 — finite, and not 1 — so a clip carrying NO data-volume cleared both filters and was reported as authored at silence. Both halves of that were false: absent means unity everywhere else in the runtime. It fired on exactly the case the rule exists to bless. The docs this PR edits say data-volume is the baseline for elements no tween touches, so a tweened clip is expected not to carry one — the common audio fade. A warning does not fail check, but an agent reading the fixHint would have written a gain to correct a level that was never wrong.
575 lines
20 KiB
TypeScript
575 lines
20 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
applyCurve,
|
|
shapeProgress,
|
|
fxAutomationTarget,
|
|
HF_AUDIO_AUTOMATION_ATTR,
|
|
HF_AUDIO_AUTOMATION_DATA_KEY,
|
|
isConstantLane,
|
|
parseAutomation,
|
|
parseAutomationTarget,
|
|
resolveAutomation,
|
|
resolveAutomationRange,
|
|
sampleAutomationCurve,
|
|
sampleAutomationLane,
|
|
serializeAutomation,
|
|
steadyViaPoint,
|
|
VOLUME_RANGE,
|
|
type HfAutomationLane,
|
|
} from "./audioAutomation.js";
|
|
import { mintAudioFxNodeId, parseAudioFxChain, type HfAudioFxChain } from "./audioFx.js";
|
|
import { MAX_AUDIO_GAIN } from "./audioGain.js";
|
|
|
|
const chain: HfAudioFxChain = {
|
|
version: 1,
|
|
nodes: [
|
|
{ type: "peaking", id: "n1", enabled: true, params: { frequency: 1000, gain: 0, Q: 1 } },
|
|
{ type: "highpass", id: "n2", enabled: true, params: {} },
|
|
],
|
|
};
|
|
|
|
const lane = (points: HfAutomationLane["points"], target = "volume"): HfAutomationLane => ({
|
|
target,
|
|
points,
|
|
});
|
|
|
|
describe("the automation attribute's two spellings", () => {
|
|
it("names the same attribute either way", () => {
|
|
// Same split as the FX chain: written as an attribute, read as a dataset
|
|
// key. Derived, so a rename cannot half-land.
|
|
expect(HF_AUDIO_AUTOMATION_ATTR).toBe(`data-${HF_AUDIO_AUTOMATION_DATA_KEY}`);
|
|
});
|
|
});
|
|
|
|
describe("targets", () => {
|
|
it("reads volume and fx targets, and rejects anything else", () => {
|
|
expect(parseAutomationTarget("volume")).toEqual({ kind: "volume" });
|
|
expect(parseAutomationTarget("fx.n1.frequency")).toEqual({
|
|
kind: "fx",
|
|
nodeId: "n1",
|
|
param: "frequency",
|
|
});
|
|
expect(parseAutomationTarget("fx.n1")).toBeNull();
|
|
expect(parseAutomationTarget("gain")).toBeNull();
|
|
expect(parseAutomationTarget("")).toBeNull();
|
|
});
|
|
|
|
it("resolves a range from the registry, not from the lane", () => {
|
|
const r = resolveAutomationRange(fxAutomationTarget("n1", "frequency"), chain);
|
|
expect(r).not.toBeNull();
|
|
expect(r?.scale).toBe("log");
|
|
expect(r?.unit).toBe("Hz");
|
|
expect(r?.min).toBeGreaterThan(0);
|
|
expect(resolveAutomationRange("volume", chain)).toEqual(VOLUME_RANGE);
|
|
});
|
|
|
|
it("has no range for a missing node, a missing param, or an enum param", () => {
|
|
expect(resolveAutomationRange("fx.nope.frequency", chain)).toBeNull();
|
|
expect(resolveAutomationRange("fx.n1.nonsense", chain)).toBeNull();
|
|
// `poles` is an enum: there is no envelope between one and two poles.
|
|
expect(resolveAutomationRange("fx.n2.poles", chain)).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("normalisation", () => {
|
|
it("sorts points and collapses duplicate times, keeping the later value", () => {
|
|
const parsed = parseAutomation(
|
|
JSON.stringify({
|
|
version: 1,
|
|
lanes: [
|
|
{
|
|
target: "volume",
|
|
points: [
|
|
{ t: 2, v: 0.2 },
|
|
{ t: 0, v: 1 },
|
|
{ t: 2, v: 0.9 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(parsed.lanes[0]!.points).toEqual([
|
|
{ t: 0, v: 1 },
|
|
{ t: 2, v: 0.9 },
|
|
]);
|
|
});
|
|
|
|
it("drops non-finite points rather than letting NaN reach an AudioParam", () => {
|
|
const parsed = parseAutomation(
|
|
JSON.stringify({
|
|
version: 1,
|
|
lanes: [
|
|
{
|
|
target: "volume",
|
|
points: [
|
|
{ t: 0, v: 0.5 },
|
|
{ t: 1, v: null },
|
|
{ t: "x", v: 1 },
|
|
{ t: 2, v: 0.25 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
expect(parsed.lanes[0]!.points).toEqual([
|
|
{ t: 0, v: 0.5 },
|
|
{ t: 2, v: 0.25 },
|
|
]);
|
|
});
|
|
|
|
it("clamps volume into the authoring gain range at parse time", () => {
|
|
const parsed = parseAutomation(
|
|
JSON.stringify({
|
|
version: 1,
|
|
lanes: [
|
|
{
|
|
target: "volume",
|
|
points: [
|
|
{ t: 0, v: 4 },
|
|
{ t: 1, v: -2 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
// The lane shares the fader's ceiling. Clamping it at unity discarded the
|
|
// boost of any clip authored above 0 dB the moment it was automated.
|
|
expect(parsed.lanes[0]!.points.map((p) => p.v)).toEqual([MAX_AUDIO_GAIN, 0]);
|
|
});
|
|
|
|
it("refuses malformed input instead of silently losing an envelope", () => {
|
|
expect(() => parseAutomation("{")).toThrow(/not valid JSON/);
|
|
expect(() => parseAutomation(JSON.stringify({ version: 9, lanes: [] }))).toThrow(
|
|
/Unsupported automation version/,
|
|
);
|
|
expect(() => parseAutomation(JSON.stringify({ version: 1 }))).toThrow(/lanes/);
|
|
expect(() =>
|
|
parseAutomation(JSON.stringify({ version: 1, lanes: [{ target: "nope", points: [] }] })),
|
|
).toThrow(/unreadable target/);
|
|
});
|
|
|
|
it("round-trips through the attribute", () => {
|
|
const source = {
|
|
version: 1,
|
|
lanes: [
|
|
lane([
|
|
{ t: 0, v: 0.8 },
|
|
{ t: 3, v: 0.2, curve: 0.5 },
|
|
]),
|
|
lane(
|
|
[
|
|
{ t: 0, v: 200 },
|
|
{ t: 4, v: 8000 },
|
|
],
|
|
"fx.n1.frequency",
|
|
),
|
|
],
|
|
};
|
|
expect(parseAutomation(serializeAutomation(source))).toEqual(source);
|
|
});
|
|
|
|
it("round-trips a via point, and keeps it as a pair", () => {
|
|
const source = {
|
|
version: 1,
|
|
lanes: [
|
|
lane([
|
|
// A via point with no `curve` at all: the bend is entirely described by
|
|
// where the segment goes. It has to survive on its own, or a bend
|
|
// dragged near a breakpoint silently straightens on the next load.
|
|
{ t: 0, v: 0.8, viaX: 0.7, viaY: 0.6 },
|
|
{ t: 2, v: 0.2, curve: 0.5, viaX: 0.3, viaY: 0.44 },
|
|
{ t: 3, v: 0.5 },
|
|
]),
|
|
],
|
|
};
|
|
expect(parseAutomation(serializeAutomation(source))).toEqual(source);
|
|
});
|
|
|
|
it("drops a via point that says nothing, and clamps one off the segment", () => {
|
|
const parsed = parseAutomation(
|
|
JSON.stringify({
|
|
version: 1,
|
|
lanes: [
|
|
{
|
|
target: "volume",
|
|
points: [
|
|
// Half a via point describes no shape; taking one coordinate on its
|
|
// own would leave the segment's shape depending on which half
|
|
// survived a hand edit.
|
|
{ t: 0, v: 0.5, viaX: 0.4 },
|
|
// On the diagonal: this IS the straight line, so storing it would
|
|
// claim a bend the segment does not have.
|
|
{ t: 1, v: 0.6, viaX: 0.4, viaY: 0.4 },
|
|
// Outside the segment entirely: pulled back to the steady region
|
|
// rather than describing a shape no curve can draw.
|
|
{ t: 2, v: 0.7, viaX: 5, viaY: -3 },
|
|
{ t: 3, v: 1 },
|
|
],
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const points = parsed.lanes[0]!.points;
|
|
expect(points[0]).not.toHaveProperty("viaX");
|
|
expect(points[1]).not.toHaveProperty("viaX");
|
|
expect(points[2]!.viaX).toBeCloseTo(0.999, 6);
|
|
expect(points[2]!.viaY).toBeCloseTo(0.001, 6);
|
|
});
|
|
});
|
|
|
|
describe("resolveAutomation", () => {
|
|
it("drops lanes whose effect was deleted and clamps the rest to the registry", () => {
|
|
const resolved = resolveAutomation(
|
|
{
|
|
version: 1,
|
|
lanes: [
|
|
lane([{ t: 0, v: 1_000_000 }], "fx.n1.frequency"),
|
|
lane([{ t: 0, v: 0.5 }], "fx.gone.frequency"),
|
|
lane([{ t: 0, v: 0.5 }]),
|
|
],
|
|
},
|
|
chain,
|
|
);
|
|
expect(resolved.lanes.map((l) => l.target)).toEqual(["fx.n1.frequency", "volume"]);
|
|
const range = resolveAutomationRange("fx.n1.frequency", chain);
|
|
expect(resolved.lanes[0]!.points[0]!.v).toBe(range?.max);
|
|
});
|
|
|
|
it("drops every fx lane when the track has no chain at all", () => {
|
|
const resolved = resolveAutomation(
|
|
{ version: 1, lanes: [lane([{ t: 0, v: 1 }], "fx.n1.frequency"), lane([{ t: 0, v: 1 }])] },
|
|
undefined,
|
|
);
|
|
expect(resolved.lanes.map((l) => l.target)).toEqual(["volume"]);
|
|
});
|
|
});
|
|
|
|
describe("sampling", () => {
|
|
const ramp = lane([
|
|
{ t: 1, v: 0 },
|
|
{ t: 3, v: 1 },
|
|
]);
|
|
|
|
it("holds the end values outside the points", () => {
|
|
expect(sampleAutomationLane(ramp, 0)).toBe(0);
|
|
expect(sampleAutomationLane(ramp, 1)).toBe(0);
|
|
expect(sampleAutomationLane(ramp, 3)).toBe(1);
|
|
expect(sampleAutomationLane(ramp, 99)).toBe(1);
|
|
});
|
|
|
|
it("interpolates linearly between them", () => {
|
|
expect(sampleAutomationLane(ramp, 2)).toBeCloseTo(0.5, 10);
|
|
expect(sampleAutomationLane(ramp, 1.5)).toBeCloseTo(0.25, 10);
|
|
});
|
|
|
|
it("interpolates a log-scaled parameter in log space", () => {
|
|
const sweep = lane(
|
|
[
|
|
{ t: 0, v: 200 },
|
|
{ t: 4, v: 8000 },
|
|
],
|
|
"fx.n1.frequency",
|
|
);
|
|
// Halfway through the sweep is the geometric mean, not the arithmetic one:
|
|
// an even-sounding sweep, which is what a log knob already promises.
|
|
expect(sampleAutomationLane(sweep, 2, "log")).toBeCloseTo(Math.sqrt(200 * 8000), 6);
|
|
expect(sampleAutomationLane(sweep, 2, "linear")).toBeCloseTo(4100, 6);
|
|
});
|
|
|
|
it("bends a segment with curve, staying pinned at both ends", () => {
|
|
const bent = lane([
|
|
{ t: 0, v: 0, curve: 1 },
|
|
{ t: 1, v: 1 },
|
|
]);
|
|
expect(sampleAutomationLane(bent, 0)).toBe(0);
|
|
expect(sampleAutomationLane(bent, 1)).toBe(1);
|
|
// Positive curve holds low and rises late.
|
|
expect(sampleAutomationLane(bent, 0.5)).toBeLessThan(0.5);
|
|
const eased = lane([
|
|
{ t: 0, v: 0, curve: -1 },
|
|
{ t: 1, v: 1 },
|
|
]);
|
|
expect(sampleAutomationLane(eased, 0.5)).toBeGreaterThan(0.5);
|
|
expect(applyCurve(0.5, 0)).toBe(0.5);
|
|
});
|
|
|
|
it("leaves a legacy curve-only point sampling exactly as it always did", () => {
|
|
for (const x of [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1]) {
|
|
expect(shapeProgress(x, { curve: 0.5 })).toBe(Math.pow(x, Math.pow(2, 1)));
|
|
}
|
|
expect(shapeProgress(0.5, {})).toBe(0.5);
|
|
});
|
|
|
|
it("passes exactly through any via point, however deep or off-centre", () => {
|
|
// The contract the drag depends on: the pointer's position IS the shape. No
|
|
// depth cap and no position cap — an earlier version held bends to a maximum
|
|
// slope, which capped how far the line could be pulled and, worse, could clamp a
|
|
// bend onto the straight line and flatten it mid-drag.
|
|
for (const [vx, vy] of [
|
|
[0.5, 0.7],
|
|
[0.5, 0.95],
|
|
[0.2, 0.8],
|
|
[0.1, 0.9],
|
|
[0.05, 0.95],
|
|
[0.9, 0.15],
|
|
[0.3, 0.05],
|
|
[0.95, 0.55],
|
|
] as const) {
|
|
expect(shapeProgress(vx, { viaX: vx, viaY: vy })).toBeCloseTo(vy, 6);
|
|
}
|
|
});
|
|
|
|
it("puts the curve's furthest point from straight exactly at the via point", () => {
|
|
// "The cursor is the apex": not near it, at it. The conic passes through the via
|
|
// point at its own midparameter, and its two halves are symmetric in parameter,
|
|
// so the deepest departure from the straight line lands there by construction.
|
|
for (const [vx, vy] of [
|
|
[0.1, 0.9],
|
|
[0.2, 0.8],
|
|
[0.5, 0.95],
|
|
[0.8, 0.3],
|
|
[0.9, 0.15],
|
|
[0.95, 0.55],
|
|
] as const) {
|
|
let deepest = 0;
|
|
let at = 0;
|
|
for (let k = 1; k < 1000; k++) {
|
|
const x = k / 1000;
|
|
const gap = Math.abs(shapeProgress(x, { viaX: vx, viaY: vy }) - x);
|
|
if (gap > deepest) {
|
|
deepest = gap;
|
|
at = x;
|
|
}
|
|
}
|
|
expect(at).toBeCloseTo(vx, 2);
|
|
expect(deepest).toBeCloseTo(Math.abs(vy - vx), 2);
|
|
}
|
|
});
|
|
|
|
it("reaches a bend far deeper than a plain quadratic could", () => {
|
|
// A plain quadratic needs its control point at 2Q - M, which leaves the segment
|
|
// once the via point is past the middle half — so it cannot pass through a deep
|
|
// point at all. The conic's weight is what buys the reach.
|
|
expect(shapeProgress(0.1, { viaX: 0.1, viaY: 0.9 })).toBeCloseTo(0.9, 6);
|
|
expect(shapeProgress(0.05, { viaX: 0.05, viaY: 0.95 })).toBeCloseTo(0.95, 6);
|
|
});
|
|
|
|
it("never returns NaN for a via point dragged past the segment edge", () => {
|
|
// A via point pulled out to (5, -3) clamps to (0.999, 0.001) — exactly on the
|
|
// steady region's edge, where `edge - viaX` is 0 and the conic's weight used
|
|
// to divide out to Infinity, then NaN a few steps later. NaN reaching
|
|
// setValueCurveAtTime silences the node for the rest of the render.
|
|
for (const x of [0, 0.1, 0.25, 0.5, 0.75, 0.9, 1]) {
|
|
const y = shapeProgress(x, { viaX: 5, viaY: -3 });
|
|
expect(Number.isFinite(y)).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("never flattens a bend that is pulled harder", () => {
|
|
// The regression. Holding a bend's depth inside the steady region by clamping
|
|
// its coordinates one at a time snapped the curve flat mid-drag: near the ends
|
|
// of a segment almost every legal value sits on ONE side of the straight line,
|
|
// so a bend pulled the other way got clamped onto the line itself and vanished.
|
|
// Pulling further has to keep bending the way the pointer asked, always.
|
|
for (const viaX of [0.1, 0.3, 0.5, 0.7, 0.9]) {
|
|
for (const viaY of [0.9, 0.6, 0.45, 0.3, 0.05, -0.5, 1.5]) {
|
|
if (Math.abs(viaY - viaX) < 0.02) continue;
|
|
const via = steadyViaPoint(viaX, viaY);
|
|
expect(via).not.toBeNull();
|
|
if (!via) continue;
|
|
// Same side of the straight line as the pointer asked for.
|
|
expect(Math.sign(via.viaY - via.viaX)).toBe(Math.sign(viaY - viaX));
|
|
// And a bend worth seeing, not a hair off straight.
|
|
expect(Math.abs(via.viaY - via.viaX)).toBeGreaterThan(0.01);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("stays a steady curve through the via point, with no corner to see", () => {
|
|
// A shape built from two curves meeting AT the via point can only be as smooth
|
|
// as that join, and keeping such a join monotone forces its tangent down to a
|
|
// fraction of the slope the curve arrives with — which reads as a sharp corner
|
|
// exactly where the pointer is. One conic arc has no join: slope is continuous
|
|
// by construction, so the ratio across the via point stays near 1 even for the
|
|
// extreme bends where a spliced curve kinked hardest.
|
|
const slopeAt = (x: number, viaX: number, viaY: number): number => {
|
|
const h = 1e-4;
|
|
return (
|
|
(shapeProgress(x + h, { viaX, viaY }) - shapeProgress(x - h, { viaX, viaY })) / (2 * h)
|
|
);
|
|
};
|
|
for (const [viaX, viaY] of [
|
|
[0.1, 0.9],
|
|
[0.9, 0.1],
|
|
[0.15, 0.6],
|
|
[0.8, 0.95],
|
|
[0.5, 0.95],
|
|
[0.5, 0.05],
|
|
] as const) {
|
|
const before = slopeAt(viaX - 0.02, viaX, viaY);
|
|
const after = slopeAt(viaX + 0.02, viaX, viaY);
|
|
expect(before).toBeGreaterThan(0);
|
|
expect(after).toBeGreaterThan(0);
|
|
// Within a factor of 2.2 across a 4% window either side — that much is real
|
|
// curvature on a tight bend. Two cubics spliced at the via point measured
|
|
// 27.6 on the first of these, and 2.4-3.6 on the gentler ones.
|
|
const ratio = before > after ? before / after : after / before;
|
|
expect(ratio).toBeLessThan(2.2);
|
|
}
|
|
});
|
|
|
|
it("never changes slope abruptly anywhere along the segment", () => {
|
|
// The same property swept rather than probed at the via point, so a corner
|
|
// introduced anywhere else would fail too.
|
|
for (const [viaX, viaY] of [
|
|
[0.2, 0.8],
|
|
[0.85, 0.35],
|
|
[0.5, 0.9],
|
|
[0.1, 0.9],
|
|
[0.9, 0.08],
|
|
] as const) {
|
|
let previous: number | null = null;
|
|
for (let k = 2; k < 98; k++) {
|
|
const x = k / 100;
|
|
const h = 1e-3;
|
|
const slope =
|
|
(shapeProgress(x + h, { viaX, viaY }) - shapeProgress(x - h, { viaX, viaY })) / (2 * h);
|
|
if (previous !== null) {
|
|
const ratio = slope > previous ? slope / previous : previous / slope;
|
|
// 1% of the segment at a time: a steady curve changes slope gradually.
|
|
// The spliced version measured 2.2 here, and 14.5 with the via point
|
|
// dragged into a corner.
|
|
expect(ratio).toBeLessThan(1.7);
|
|
}
|
|
previous = slope;
|
|
}
|
|
}
|
|
});
|
|
|
|
it("keeps the segment pinned at both breakpoints", () => {
|
|
for (const [vx, vy] of [
|
|
[0.2, 0.7],
|
|
[0.85, 0.3],
|
|
] as const) {
|
|
expect(shapeProgress(0, { viaX: vx, viaY: vy })).toBeCloseTo(0, 9);
|
|
expect(shapeProgress(1, { viaX: vx, viaY: vy })).toBeCloseTo(1, 9);
|
|
}
|
|
});
|
|
|
|
it("stays monotone for every via point, so a render never sags mid-segment", () => {
|
|
// Not cosmetic: a segment is baked into setValueCurveAtTime, and progress
|
|
// that dipped backwards is a rising fader audibly dropping. This is the
|
|
// property the Fritsch-Carlson tangent limit is there to guarantee, so it is
|
|
// swept rather than spot-checked.
|
|
for (let ix = 1; ix < 20; ix++) {
|
|
for (let iy = 1; iy < 20; iy++) {
|
|
const viaX = ix / 20;
|
|
const viaY = iy / 20;
|
|
let previous = -Infinity;
|
|
for (let k = 0; k <= 60; k++) {
|
|
const y = shapeProgress(k / 60, { viaX, viaY });
|
|
expect(y).toBeGreaterThanOrEqual(previous - 1e-9);
|
|
previous = y;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
it("puts the bend where the via point is, not always on the same side", () => {
|
|
// The complaint this replaced: every upward bend a single exponent could draw
|
|
// deviated most in the first fifth of the segment. Now the peak deviation
|
|
// tracks the via point across the whole span.
|
|
const apexOf = (viaX: number, viaY: number): number => {
|
|
let best = 0;
|
|
let at = 0;
|
|
for (let k = 1; k < 100; k++) {
|
|
const x = k / 100;
|
|
const gap = Math.abs(shapeProgress(x, { viaX, viaY }) - x);
|
|
if (gap > best) {
|
|
best = gap;
|
|
at = x;
|
|
}
|
|
}
|
|
return at;
|
|
};
|
|
// Near, not exactly at: the deviation is smooth through the knot, so its peak
|
|
// can sit slightly inside. What matters is that it tracks the via point
|
|
// across the whole segment instead of parking in the first fifth.
|
|
for (const viaX of [0.3, 0.4, 0.5, 0.6, 0.7]) {
|
|
expect(Math.abs(apexOf(viaX, viaX + 0.06) - viaX)).toBeLessThan(0.12);
|
|
}
|
|
expect(apexOf(0.3, 0.36)).toBeLessThan(apexOf(0.7, 0.76));
|
|
});
|
|
|
|
it("bends either way from the same via progress", () => {
|
|
const at = (viaY: number) =>
|
|
sampleAutomationLane(
|
|
lane([
|
|
{ t: 0, v: 0, viaX: 0.7, viaY },
|
|
{ t: 1, v: 1 },
|
|
]),
|
|
0.7,
|
|
);
|
|
expect(at(0.56)).toBeCloseTo(0.56, 6);
|
|
expect(at(0.73)).toBeCloseTo(0.73, 6);
|
|
});
|
|
|
|
it("walks a dense lane by bisection, not by scanning", () => {
|
|
const points = Array.from({ length: 200 }, (_, i) => ({ t: i, v: i % 2 }));
|
|
const dense = lane(points);
|
|
expect(sampleAutomationLane(dense, 100)).toBe(0);
|
|
expect(sampleAutomationLane(dense, 101)).toBe(1);
|
|
expect(sampleAutomationLane(dense, 100.5)).toBeCloseTo(0.5, 10);
|
|
});
|
|
|
|
it("caps a pathological lane so the scheduler cannot be hung", () => {
|
|
const parsed = parseAutomation(
|
|
JSON.stringify({
|
|
version: 1,
|
|
lanes: [
|
|
{ target: "volume", points: Array.from({ length: 5000 }, (_, i) => ({ t: i, v: 0.5 })) },
|
|
],
|
|
}),
|
|
);
|
|
expect(parsed.lanes[0]!.points.length).toBe(512);
|
|
});
|
|
|
|
it("samples a curve at both endpoints", () => {
|
|
const curve = sampleAutomationCurve(ramp, 1, 3, 5);
|
|
expect(curve.length).toBe(5);
|
|
expect(curve[0]).toBe(0);
|
|
expect(curve[4]).toBe(1);
|
|
expect(curve[2]).toBeCloseTo(0.5, 6);
|
|
});
|
|
|
|
it("spots a lane not worth scheduling", () => {
|
|
expect(isConstantLane(lane([{ t: 0, v: 0.5 }]))).toBe(true);
|
|
expect(
|
|
isConstantLane(
|
|
lane([
|
|
{ t: 0, v: 0.5 },
|
|
{ t: 2, v: 0.5 },
|
|
]),
|
|
),
|
|
).toBe(true);
|
|
expect(isConstantLane(ramp)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("chain node ids", () => {
|
|
it("mints the first free id and survives a round trip", () => {
|
|
expect(mintAudioFxNodeId({ version: 1, nodes: [] })).toBe("n1");
|
|
expect(mintAudioFxNodeId(chain)).toBe("n3");
|
|
const gap: HfAudioFxChain = { version: 1, nodes: [{ type: "peaking", id: "n2" }] };
|
|
expect(mintAudioFxNodeId(gap)).toBe("n1");
|
|
});
|
|
|
|
it("keeps ids through parse so lanes stay pointed at the same effect", () => {
|
|
const json = JSON.stringify({
|
|
version: 1,
|
|
nodes: [{ type: "peaking", id: "n7", params: {} }],
|
|
});
|
|
expect(parseAudioFxChain(json).nodes[0]!.id).toBe("n7");
|
|
});
|
|
});
|