Skip to content

Commit 67d7a98

Browse files
committed
fix(agents): clip the audio curve at the target's edges and keep peaks at zero attack
A simplified envelope can have no vertex inside the target window while still crossing it, so the mapping now inserts interpolated values at the window's start and end before dropping outside vertices, instead of reporting no overlap. A zero attack placed the rise at the onset itself and the guard skipped every beat; the rise now sits one epsilon before the onset so the peak survives as a step. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013in93XM9r3h7RaFz62YGwN
1 parent ebea68e commit 67d7a98

2 files changed

Lines changed: 213 additions & 16 deletions

File tree

packages/agents/src/capabilities/timeline-audio-bake.ts

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,36 @@ export interface MapCurveOptions {
7979
offsetMs: number;
8080
}
8181

82+
/** One point of the measured curve, carried onto the timeline. */
83+
interface TimedCurvePoint {
84+
timelineMs: number;
85+
value: number;
86+
}
87+
88+
/** The value the segment `a → b` holds at `timelineMs`. */
89+
function interpolateAt(
90+
a: TimedCurvePoint,
91+
b: TimedCurvePoint,
92+
timelineMs: number
93+
): number {
94+
const span = b.timelineMs - a.timelineMs;
95+
if (span <= 0) return b.value;
96+
return a.value + (b.value - a.value) * ((timelineMs - a.timelineMs) / span);
97+
}
98+
8299
/**
83100
* Carry a series measured in audio-source time onto the target clip's clock,
84-
* dropping what falls outside the target's own window.
101+
* clipped to the stretch of timeline the target occupies.
85102
*
86-
* A point the target never shows is dropped rather than clamped: a source
103+
* The clip is geometric, not a filter: the incoming series is already
104+
* simplified, so a target can sit between two retained keyframes and still be
105+
* fully covered by the motion between them. Each boundary the curve crosses
106+
* contributes an interpolated point, and only then are the vertices outside
107+
* dropped. Testing membership first instead returned nothing for such a target
108+
* — the capability reported "does not overlap" over a curve that spans it —
109+
* and turned the ends of a partially-covered target flat.
110+
*
111+
* A vertex the target never shows is dropped rather than clamped: a source
87112
* curve holds its first and last value flat outside its ends, so the retained
88113
* motion is the motion the target actually plays, and the validator's
89114
* `source_curve_outside_window` stays quiet.
@@ -95,18 +120,52 @@ export function mapAudioCurveToTarget(
95120
const { targetClip } = options;
96121
const windowStartMs = targetClip.startMs;
97122
const windowEndMs = targetClip.startMs + targetClip.durationMs;
98-
const mapped: BakedCurvePoint[] = [];
99-
for (const point of points) {
100-
const timelineMs =
123+
const timed: TimedCurvePoint[] = points.map((point) => ({
124+
timelineMs:
101125
audioSourceMsToTimelineMs(options.audioClip, point.timeMs) +
102-
options.offsetMs;
103-
if (timelineMs < windowStartMs || timelineMs > windowEndMs) continue;
104-
mapped.push({
105-
sourceMs: Math.max(0, timelineMsToTargetSourceMs(targetClip, timelineMs)),
106-
value: point.value
107-
});
126+
options.offsetMs,
127+
value: point.value
128+
}));
129+
130+
const kept: TimedCurvePoint[] = [];
131+
for (let index = 0; index < timed.length; index += 1) {
132+
const current = timed[index]!;
133+
const previous = index > 0 ? timed[index - 1]! : undefined;
134+
if (previous) {
135+
if (
136+
previous.timelineMs < windowStartMs &&
137+
current.timelineMs > windowStartMs
138+
) {
139+
kept.push({
140+
timelineMs: windowStartMs,
141+
value: interpolateAt(previous, current, windowStartMs)
142+
});
143+
}
144+
if (
145+
previous.timelineMs < windowEndMs &&
146+
current.timelineMs > windowEndMs
147+
) {
148+
kept.push({
149+
timelineMs: windowEndMs,
150+
value: interpolateAt(previous, current, windowEndMs)
151+
});
152+
}
153+
}
154+
if (
155+
current.timelineMs >= windowStartMs &&
156+
current.timelineMs <= windowEndMs
157+
) {
158+
kept.push(current);
159+
}
108160
}
109-
return mapped;
161+
162+
return kept.map((point) => ({
163+
sourceMs: Math.max(
164+
0,
165+
timelineMsToTargetSourceMs(targetClip, point.timelineMs)
166+
),
167+
value: point.value
168+
}));
110169
}
111170

112171
export interface EnvelopeCurveOptions {
@@ -174,6 +233,13 @@ const EPSILON_MS = 1e-3;
174233
* Pulses that would overlap are shortened rather than interleaved — a series
175234
* whose times went backwards is refused by the curve gate, and an onset inside
176235
* the previous pulse's release is one the ear hears as part of it anyway.
236+
*
237+
* `attackMs: 0` is a step, not a skipped onset. The route and the inspector
238+
* both accept zero, and it used to put the rest point *at* the onset, which
239+
* the ascending guard then dropped — every onset in the series, leaving a flat
240+
* curve between the two resting endpoints. The rest point sits `EPSILON_MS`
241+
* before the onset instead, so the times still ascend and the value jumps at
242+
* the onset itself.
177243
*/
178244
export function beatCurve(
179245
onsetsMs: readonly number[],
@@ -186,11 +252,11 @@ export function beatCurve(
186252

187253
for (const onsetMs of [...onsetsMs].sort((a, b) => a - b)) {
188254
if (onsetMs <= lastTimeMs || onsetMs > windowEndMs) continue;
189-
const riseMs = Math.max(
190-
onsetMs - Math.max(0, options.attackMs),
191-
lastTimeMs + EPSILON_MS
255+
const riseMs = Math.min(
256+
Math.max(onsetMs - Math.max(0, options.attackMs), lastTimeMs + EPSILON_MS),
257+
onsetMs - EPSILON_MS
192258
);
193-
if (riseMs >= onsetMs) continue;
259+
if (riseMs <= lastTimeMs) continue;
194260
points.push({ timeMs: riseMs, value: lo });
195261
points.push({ timeMs: onsetMs, value: hi });
196262
const fallMs = Math.min(

packages/agents/tests/capabilities-bake-audio-animation.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ import { createCapabilityRun, UNGATED } from "../src/capabilities/invoke.js";
2222
import { analyzeAudioFrames } from "../src/capabilities/analysis.js";
2323
import {
2424
audioSourceMsToTimelineMs,
25+
beatCurve,
26+
mapAudioCurveToTarget,
2527
timelineMsToTargetSourceMs
2628
} from "../src/capabilities/timeline-audio-bake.js";
2729

@@ -190,6 +192,135 @@ describe("the two time hops", () => {
190192
});
191193
});
192194

195+
/**
196+
* The curve reaching the mapper is already simplified, so "no keyframe inside
197+
* the target" is not "no motion over the target". These cases pin the
198+
* geometric clip that replaced the membership test.
199+
*/
200+
describe("clipping a simplified curve to the target's window", () => {
201+
/** 1:1 on the timeline — audio-source ms and timeline ms are one number. */
202+
const AUDIO = { startMs: 0, durationMs: 4000 };
203+
/** A linear envelope over 0–4000ms, simplified to its two ends. */
204+
const RAMP = [
205+
{ timeMs: 0, value: 1 },
206+
{ timeMs: 4000, value: 2 }
207+
];
208+
209+
it("interpolates both boundaries when the target sits between two keyframes", () => {
210+
// The reviewer's reproduction: this returned [] before, and the capability
211+
// reported "does not overlap" over a ramp that covers the whole target.
212+
expect(
213+
mapAudioCurveToTarget(RAMP, {
214+
audioClip: AUDIO,
215+
targetClip: { startMs: 1000, durationMs: 2000 },
216+
offsetMs: 0
217+
})
218+
).toEqual([
219+
{ sourceMs: 0, value: 1.25 },
220+
{ sourceMs: 2000, value: 1.75 }
221+
]);
222+
});
223+
224+
it("clips to one segment of a curve that has interior vertices elsewhere", () => {
225+
const curve = [
226+
{ timeMs: 0, value: 1 },
227+
{ timeMs: 1000, value: 2 },
228+
{ timeMs: 3000, value: 3 },
229+
{ timeMs: 4000, value: 1 }
230+
];
231+
// 1500–2500ms falls inside the 1000→3000ms segment, so both ends are
232+
// interpolated and no vertex of the curve is retained.
233+
expect(
234+
mapAudioCurveToTarget(curve, {
235+
audioClip: AUDIO,
236+
targetClip: { startMs: 1500, durationMs: 1000 },
237+
offsetMs: 0
238+
})
239+
).toEqual([
240+
{ sourceMs: 0, value: 2.25 },
241+
{ sourceMs: 1000, value: 2.75 }
242+
]);
243+
});
244+
245+
it("interpolates the crossed boundary and keeps the rest on a partial overlap", () => {
246+
const curve = [
247+
{ timeMs: 0, value: 1 },
248+
{ timeMs: 2000, value: 3 },
249+
{ timeMs: 4000, value: 1 }
250+
];
251+
// The target starts mid-curve and outlasts it, so only the start boundary
252+
// is crossed and the peak and tail come through as measured.
253+
expect(
254+
mapAudioCurveToTarget(curve, {
255+
audioClip: AUDIO,
256+
targetClip: { startMs: 1000, durationMs: 5000 },
257+
offsetMs: 0
258+
})
259+
).toEqual([
260+
{ sourceMs: 0, value: 2 },
261+
{ sourceMs: 1000, value: 3 },
262+
{ sourceMs: 3000, value: 1 }
263+
]);
264+
});
265+
266+
it("still returns nothing when the curve never reaches the target", () => {
267+
expect(
268+
mapAudioCurveToTarget(RAMP, {
269+
audioClip: AUDIO,
270+
targetClip: { startMs: 10000, durationMs: 2000 },
271+
offsetMs: 0
272+
})
273+
).toEqual([]);
274+
});
275+
});
276+
277+
describe("beatCurve", () => {
278+
const ONSETS = [1000, 2000, 3000];
279+
const OPTIONS = {
280+
releaseMs: 150,
281+
outputRange: [1, 1.2] as [number, number],
282+
windowMs: [0, 4000] as [number, number],
283+
tolerance: 0.01,
284+
maxPoints: 4096
285+
};
286+
287+
const peakTimes = (points: readonly { timeMs: number; value: number }[]) =>
288+
points.filter((point) => point.value === 1.2).map((point) => point.timeMs);
289+
290+
it("keeps every peak with a zero attack, as a step into the onset", () => {
291+
// Zero attack is what the route and the inspector accept as "no rise";
292+
// it used to drop every onset and leave a flat curve.
293+
const points = beatCurve(ONSETS, { ...OPTIONS, attackMs: 0 });
294+
expect(peakTimes(points)).toEqual(ONSETS);
295+
296+
for (const onsetMs of ONSETS) {
297+
const peak = points.findIndex((point) => point.timeMs === onsetMs);
298+
const rest = points[peak - 1]!;
299+
expect(rest.value).toBe(1);
300+
// Inside a millisecond of the onset: the step is a jump, not a ramp.
301+
expect(rest.timeMs).toBeGreaterThan(onsetMs - 1);
302+
expect(rest.timeMs).toBeLessThan(onsetMs);
303+
}
304+
expect(
305+
points.every(
306+
(point, index) => index === 0 || point.timeMs > points[index - 1]!.timeMs
307+
)
308+
).toBe(true);
309+
expect(points[0]).toEqual({ timeMs: 0, value: 1 });
310+
expect(points[points.length - 1]).toEqual({ timeMs: 4000, value: 1 });
311+
});
312+
313+
it("leaves a non-zero attack untouched: the rise starts attack_ms early", () => {
314+
const points = beatCurve(ONSETS, { ...OPTIONS, attackMs: 30 });
315+
expect(peakTimes(points)).toEqual(ONSETS);
316+
for (const onsetMs of ONSETS) {
317+
const peak = points.findIndex((point) => point.timeMs === onsetMs);
318+
expect(points[peak - 1]).toEqual({ timeMs: onsetMs - 30, value: 1 });
319+
expect(points[peak + 1]).toEqual({ timeMs: onsetMs + 150, value: 1 });
320+
}
321+
});
322+
});
323+
193324
describe("bake_audio_animation", () => {
194325
let bench: Harness | null = null;
195326

0 commit comments

Comments
 (0)