Skip to content

Commit 49023c0

Browse files
committed
feat(ai): let the agent see and place audio tracks
This branch landed timeline audio with no agent surface: imported audio was absent from `documentSnapshotForModel` and from the tool roster, so the model could neither name it nor touch it. An editor whose LLM cannot see half its timeline is two products. The snapshot now carries `audioTracks` — collapsed with `collapseTracksToPills`, because a track ventilated across a clip boundary is several fragments the user sees as one thing, and the model has to name what the user sees — plus `assets[].kind`, without which an audio asset is something the model tries to place as footage. - `addAudio` lays an ALREADY-IMPORTED `kind: "audio"` asset over a span, on either lane. It refuses an unknown or video id, and the refusal lists the audio the project actually has: importing or recording is the editor's job, and a guessed id is the failure mode worth spending a sentence on. It also refuses an offset at or past the end of a known file, which would otherwise mint a 0.1s track that plays silence and gets reported as placed audio. - `setAudio` moves, resizes, re-levels, re-lanes, mutes, loops or re-points a track. Payload edits go through `patchAudioTrack` so every fragment of the group agrees; a span or lane change re-anchors through `anchorAudioTrackFragments` rather than patching fragments cut against the old span. - `removeModifier` resolves an audio track, so deleting one is the same first-class action it is for every other kind. Built on this branch's own helpers rather than a second set: `trackGroupId`, `collapseTracksToPills`, `patchAudioTrack` and `anchorAudioTrackFragments` already express exactly what these tools need. Refs #350, #560.
1 parent 9dcc816 commit 49023c0

4 files changed

Lines changed: 367 additions & 2 deletions

File tree

‎electron/ai-edition/agent-tools.test.ts‎

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,6 +172,7 @@ describe("the mutating-tool table", () => {
172172
expect([...MUTATING_TOOL_NAMES].sort()).toEqual(
173173
[
174174
"addAnnotation",
175+
"addAudio",
175176
"addCameraFullscreen",
176177
"addSpeed",
177178
"addTrim",
@@ -184,6 +185,7 @@ describe("the mutating-tool table", () => {
184185
"removeTrim",
185186
"replaceTimeline",
186187
"setAnnotation",
188+
"setAudio",
187189
"setCameraFullscreen",
188190
"setClipRange",
189191
"setSpeed",
@@ -2054,3 +2056,133 @@ describe("setZoom answers for the focus it kept", () => {
20542056
expect(result.resultJson).not.toContain("cursorAnchor");
20552057
});
20562058
});
2059+
2060+
// Issue #350 / #560 — the audio tools. #561 landed the timeline audio without any
2061+
// agent surface, so these cover both that the model can see it and that it cannot
2062+
// invent an asset to place.
2063+
describe("addAudio / setAudio", () => {
2064+
/** The fixture plus one imported audio asset. */
2065+
function withAudioAsset(durationSec: number | null = 30): AxcutDocument {
2066+
const doc = fixtureDocument();
2067+
return documentSchema.parse({
2068+
...doc,
2069+
assets: [
2070+
...doc.assets,
2071+
{
2072+
id: "audio_1",
2073+
kind: "audio",
2074+
label: "bed.mp3",
2075+
originalPath: "C:/audio/bed.mp3",
2076+
...(durationSec == null ? {} : { durationSec }),
2077+
},
2078+
],
2079+
});
2080+
}
2081+
2082+
const place = (doc: AxcutDocument, args: Record<string, unknown>) =>
2083+
executeAgentTool(doc, "addAudio", JSON.stringify(args));
2084+
2085+
it("reports imported audio in the snapshot, with the asset kind beside it", () => {
2086+
// Without `kind` the model sees an asset it cannot explain and tries to place it
2087+
// as footage; without `audioTracks` it cannot see the lanes at all.
2088+
const placed = place(withAudioAsset(), {
2089+
assetId: "audio_1",
2090+
startSec: 2,
2091+
endSec: 6,
2092+
kind: "voiceover",
2093+
});
2094+
expect(placed.ok).toBe(true);
2095+
const snapshot = executeAgentTool(placed.document as AxcutDocument, "getCurrentDocument", "");
2096+
const parsed = JSON.parse(snapshot.resultJson);
2097+
expect(parsed.assets.find((a: { id: string }) => a.id === "audio_1").kind).toBe("audio");
2098+
expect(parsed.audioTracks).toHaveLength(1);
2099+
expect(parsed.audioTracks[0]).toMatchObject({
2100+
assetId: "audio_1",
2101+
kind: "voiceover",
2102+
startSec: 2,
2103+
endSec: 6,
2104+
});
2105+
});
2106+
2107+
it("anchors the placed track to the clip under it", () => {
2108+
const result = place(withAudioAsset(), { assetId: "audio_1", startSec: 2, endSec: 6 });
2109+
expect(result.ok).toBe(true);
2110+
const track = (result.document as AxcutDocument).audioTracks[0];
2111+
// The anchor is what makes it travel with its clip; a bare startMs/endMs would not.
2112+
expect(track.clipId).toBe("clip_1");
2113+
expect(track.origin).toBe("agent");
2114+
});
2115+
2116+
it("plays the whole file when endSec is omitted", () => {
2117+
const result = place(withAudioAsset(20), { assetId: "audio_1", startSec: 0, offsetSec: 5 });
2118+
expect(result.ok).toBe(true);
2119+
const track = (result.document as AxcutDocument).audioTracks[0];
2120+
// 20s file from an in-point of 5s = 15s of span, so the model never computes it.
2121+
expect(track.endMs - track.startMs).toBe(15_000);
2122+
});
2123+
2124+
it("refuses an offset at or past the end of a known file", () => {
2125+
// Otherwise the omitted-end fallback mints a 0.1s track that plays silence, and
2126+
// the model reports it as having placed audio.
2127+
const result = place(withAudioAsset(20), { assetId: "audio_1", startSec: 0, offsetSec: 20 });
2128+
expect(result.ok).toBe(false);
2129+
expect(result.resultJson).toContain("offsetSec");
2130+
});
2131+
2132+
it("allows any offset while the duration is unknown", () => {
2133+
// A failed probe leaves no duration; refusing on that would block a legitimate call.
2134+
expect(place(withAudioAsset(null), { assetId: "audio_1", startSec: 0, offsetSec: 99 }).ok).toBe(
2135+
true,
2136+
);
2137+
});
2138+
2139+
it("refuses an unknown asset and names the audio the project actually has", () => {
2140+
const result = place(withAudioAsset(), { assetId: "nope", startSec: 0, endSec: 4 });
2141+
expect(result.ok).toBe(false);
2142+
expect(result.resultJson).toContain("audio_1");
2143+
});
2144+
2145+
it("refuses a video asset, pointing at the tool that does place footage", () => {
2146+
const result = place(withAudioAsset(), { assetId: "asset_1", startSec: 0, endSec: 4 });
2147+
expect(result.ok).toBe(false);
2148+
expect(result.resultJson).toContain("replaceTimeline");
2149+
});
2150+
2151+
it("setAudio re-levels and re-lanes the track it names", () => {
2152+
const placed = place(withAudioAsset(), { assetId: "audio_1", startSec: 2, endSec: 6 });
2153+
const id = JSON.parse(placed.resultJson).audioId;
2154+
const result = executeAgentTool(
2155+
placed.document as AxcutDocument,
2156+
"setAudio",
2157+
JSON.stringify({ audioId: id, gainDb: -6, kind: "voiceover" }),
2158+
);
2159+
expect(result.ok).toBe(true);
2160+
expect((result.document as AxcutDocument).audioTracks[0]).toMatchObject({
2161+
gainDb: -6,
2162+
kind: "voiceover",
2163+
});
2164+
});
2165+
2166+
it("setAudio applies the same offset guard as addAudio", () => {
2167+
const placed = place(withAudioAsset(20), { assetId: "audio_1", startSec: 2, endSec: 6 });
2168+
const id = JSON.parse(placed.resultJson).audioId;
2169+
const result = executeAgentTool(
2170+
placed.document as AxcutDocument,
2171+
"setAudio",
2172+
JSON.stringify({ audioId: id, offsetSec: 25 }),
2173+
);
2174+
expect(result.ok).toBe(false);
2175+
});
2176+
2177+
it("removeModifier deletes an audio track by id, like every other kind", () => {
2178+
const placed = place(withAudioAsset(), { assetId: "audio_1", startSec: 2, endSec: 6 });
2179+
const id = JSON.parse(placed.resultJson).audioId;
2180+
const result = executeAgentTool(
2181+
placed.document as AxcutDocument,
2182+
"removeModifier",
2183+
JSON.stringify({ id }),
2184+
);
2185+
expect(result.ok).toBe(true);
2186+
expect((result.document as AxcutDocument).audioTracks).toEqual([]);
2187+
});
2188+
});

0 commit comments

Comments
 (0)