Skip to content

Commit c84e94d

Browse files
committed
chore: pull in main after #3190's squash merge
2 parents a800433 + 6bcf739 commit c84e94d

10 files changed

Lines changed: 175 additions & 314 deletions

File tree

packages/cli/src/commands/render.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,17 @@ vi.mock("../browser/preflight.js", () => ({
193193
runEnvironmentChecks: vi.fn(async () => preflightState.result),
194194
}));
195195

196+
// The "render command explicit composition" test below drives the real
197+
// `render.js` command handler, which takes the plan-based `execute.ts` path
198+
// (not the `renderLocal` unit under test above) — that path calls
199+
// `ensureBrowser` directly instead of going through the mocked preflight.
200+
// Unmocked, it performs a real network download of chrome-headless-shell into
201+
// the shared `~/.cache/hyperframes/chrome`, racing other packages' browser
202+
// tests in CI.
203+
vi.mock("../browser/manager.js", () => ({
204+
ensureBrowser: vi.fn(async () => ({ executablePath: "/mock/chrome", source: "cache" })),
205+
}));
206+
196207
vi.mock("../utils/orphanCleanup.js", () => ({
197208
killOrphanedProcesses: vi.fn(() => {
198209
orphanCleanupState.calls += 1;

packages/core/src/audio/audioFxGraph.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -691,7 +691,6 @@ export function buildFxChain(
691691
for (const p of presets) {
692692
(presetTargets[p.id] ??= []).push(...mixTargets(p.wet.gain, p.dry.gain));
693693
}
694-
695694
return {
696695
input,
697696
output,

packages/studio/src/player/components/TimelineLanes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,7 @@ export function TimelineLanes({
255255
logicalRow={logicalRow}
256256
propertyRows={trackLogicalRows.slice(1)}
257257
lanesId={lanesId}
258+
headerLanesId={`${lanesId} ${automationLanesId}`}
258259
top={rowGeometry.getRowTop(row)}
259260
height={rowHeight}
260261
virtualized={rowsVirtualized}

packages/studio/src/player/components/TimelineTrackRow.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ interface TimelineTrackRowProps {
77
rowKey: number;
88
logicalRow: TimelineLogicalRow;
99
propertyRows: readonly TimelineLogicalRow[];
10+
/** Names the canvas-side content cell — the active clip's own property lanes,
11+
* minted with this single id in TimelinePropertyLanes. */
1012
lanesId: string;
13+
/** Names the header cell. Space-separated because the caret it lives under
14+
* expands two disjoint subtrees (the clip's keyframe lanes AND the track's
15+
* automation lanes) — see TimelineTrackHeader for why they cannot share one id. */
16+
headerLanesId: string;
1117
top: number;
1218
height: number;
1319
virtualized: boolean;
@@ -24,6 +30,7 @@ export function TimelineTrackRow({
2430
logicalRow,
2531
propertyRows,
2632
lanesId,
33+
headerLanesId,
2734
top,
2835
height,
2936
virtualized,
@@ -78,7 +85,7 @@ export function TimelineTrackRow({
7885
<div
7986
role="rowheader"
8087
aria-colindex={1}
81-
aria-owns={timelineLogicalRowCellId(lanesId, row.id, "header")}
88+
aria-owns={timelineLogicalRowCellId(headerLanesId, row.id, "header")}
8289
>
8390
{group}
8491
</div>

plans/audio-automation-lanes/SPEC.md

Lines changed: 42 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Two facts make this cheaper here than in most editors:
1616
1. **Web Audio has native envelope playback.** `AudioParam` scheduling
1717
(`linearRampToValueAtTime`, `setValueCurveAtTime`) is sample-accurate and
1818
runs on the audio thread. No per-frame JS evaluates the envelope; the studio
19-
only *schedules* it.
19+
only _schedules_ it.
2020
2. **Preview and render share one graph.** The render runs the same builders in
2121
an `OfflineAudioContext`, so an envelope scheduled the same way in both
2222
places is identical by construction. No parity harness needed.
@@ -50,16 +50,16 @@ panel).
5050

5151
## 3. UX spec (Ableton mapping)
5252

53-
| Ableton | Here |
54-
| --- | --- |
55-
| Automation triangle on track header | Expand toggle on audio track rows in the timeline gutter |
53+
| Ableton | Here |
54+
| --------------------------------------------- | ------------------------------------------------------------------------------------------------------ |
55+
| Automation triangle on track header | Expand toggle on audio track rows in the timeline gutter |
5656
| One parameter per lane, selector at lane left | Same. Selector lists `Volume` + every automatable param of every chain node (`Compressor · Threshold`) |
57-
| Breakpoint envelope over the clip | SVG envelope drawn over the existing waveform, clip-local |
58-
| Double-click segment → add point | Same |
59-
| Drag point (value tooltip) | Same; tooltip shows value + unit from the registry |
60-
| Drag segment vertically → bend curvature | Same (Phase 2; format supports it from v1) |
61-
| Delete key / right-click → remove point | Same |
62-
| Dimmed line when no automation | Flat line at the current static value; first edit creates the lane |
57+
| Breakpoint envelope over the clip | SVG envelope drawn over the existing waveform, clip-local |
58+
| Double-click segment → add point | Same |
59+
| Drag point (value tooltip) | Same; tooltip shows value + unit from the registry |
60+
| Drag segment vertically → bend curvature | Same (Phase 2; format supports it from v1) |
61+
| Delete key / right-click → remove point | Same |
62+
| Dimmed line when no automation | Flat line at the current static value; first edit creates the lane |
6363

6464
Lane height ~48 px expanded. Multiple lanes per track may be open at once
6565
(one per parameter), matching Ableton's "+" lanes — Phase 2; V1 shows one lane
@@ -75,7 +75,10 @@ coalesced per gesture — free.
7575
Serialised on the element, versioned, same pattern as `data-fx-chain`:
7676

7777
```html
78-
<audio id="music" src="..." data-volume="0.55"
78+
<audio
79+
id="music"
80+
src="..."
81+
data-volume="0.55"
7982
data-fx-chain='{"version":1,"nodes":[{"id":"n1","type":"peaking",...}]}'
8083
data-automation='{
8184
"version": 1,
@@ -85,7 +88,8 @@ Serialised on the element, versioned, same pattern as `data-fx-chain`:
8588
{ "target": "fx.n1.frequency",
8689
"points": [ {"t":0,"v":200}, {"t":4,"v":8000} ] }
8790
]
88-
}'>
91+
}'
92+
></audio>
8993
```
9094

9195
- **`t`** — seconds, **clip-local** (relative to the element's `data-start`).
@@ -96,7 +100,7 @@ Serialised on the element, versioned, same pattern as `data-fx-chain`:
96100
(dB for a compressor threshold, Hz for a cutoff). Volume is **linear 0..1**,
97101
consistent with `data-volume` and the existing linear-domain envelope
98102
machinery — no dB conversion enters the volume path.
99-
- **`curve`** — optional, `-1..1`, curvature of the segment *leaving* this
103+
- **`curve`** — optional, `-1..1`, curvature of the segment _leaving_ this
100104
point. `0`/absent = linear. Power-curve bend, Ableton-style.
101105
- **`target`**`"volume"` or `"fx.<nodeId>.<paramKey>"`.
102106

@@ -106,43 +110,7 @@ so reordering the chain never re-targets a lane. Chains without ids stay
106110
valid — they just can't be automation targets until the panel touches them.
107111

108112
**Normalization** (`normalizeAutomation`, mirrors `normalizeAudioFxParams`):
109-
- points sorted by `t`; duplicate `t` keeps the later point
110-
- `v` clamped to the target's registry range; non-finite → point dropped
111-
- lanes targeting a node id that no longer exists in the chain are **dropped**
112-
(the author deleted the device; its automation dies with it — panel also
113-
removes them eagerly on node delete)
114-
- 1-point lane = constant; empty lanes array = attribute removed
115-
116-
**Precedence for volume** (documented + linted):
117-
`data-automation` volume lane → GSAP volume tween → `data-volume`.
118-
New lint rule `audio_volume_double_automation` (warning) when an element has
119-
both a volume lane and a GSAP tween on `volume`.
120-
121-
## 5. Interpolation semantics
122-
123-
- Between points: linear in the parameter's **working domain**. Params with
124-
registry `scale: "log"` (frequency, some times) interpolate in log domain —
125-
a 200 Hz → 8 kHz sweep is perceptually linear, matching what a DAW does.
126-
- `curve` bends the segment: `f(x) = x^(2^(k·s))` shaping applied in the
127-
working domain (s = curve, k ≈ 2). Exact constant chosen to visually match
128-
Ableton's feel; pinned by unit tests once chosen.
129-
- Before the first point: hold first value. After the last: hold last value.
130-
- One shared implementation `sampleAutomationLane(lane, t)` in core — used by
131-
the lane renderer (drawing), the scheduler (curve sampling), and the render
132-
path. One interpolator, three consumers, or preview and picture drift.
133-
134-
## 6. Preview architecture
135-
136-
Scheduling hooks into `schedulePlayback` (transport), which already runs on
137-
play / seek / rate change with the clip's `elapsed` offset:
138-
139-
- **Volume lane** → scheduled on the source's existing `gainNode.gain`
140-
(post-FX, i.e. fader semantics — matches Ableton, matches the render order
141-
where FX runs before the volume bake).
142-
- **FX param lanes** → scheduled on AudioParams exposed by the graph builders
143-
(§8) of the chain instance spliced for this source.
144-
145-
Mechanics per lane, at schedule time:
113+
146114
1. Convert clip-local envelope → context-time segments starting at
147115
`scheduledAt`, offset by `elapsed`, scaled by playback rate.
148116
2. Linear segments → `setValueAtTime` + `linearRampToValueAtTime` (log-domain
@@ -180,17 +148,18 @@ mid-playback without rescheduling the source.
180148

181149
**Automatable in V1** (param maps to a real AudioParam):
182150

183-
| Effect | Params |
184-
| --- | --- |
185-
| Peaking / shelves | frequency, gain, Q |
186-
| High/low-pass (2-pole) | frequency, Q |
187-
| Delay | time (delayTime), feedback, mix |
188-
| Chorus | rate, depth, mix |
189-
| Phaser | rate, wet/dry gains |
190-
| Reverb | wet, dry |
191-
| *Volume* | (transport gainNode) |
151+
| Effect | Params |
152+
| ---------------------- | ------------------------------- |
153+
| Peaking / shelves | frequency, gain, Q |
154+
| High/low-pass (2-pole) | frequency, Q |
155+
| Delay | time (delayTime), feedback, mix |
156+
| Chorus | rate, depth, mix |
157+
| Phaser | rate, wet/dry gains |
158+
| Reverb | wet, dry |
159+
| _Volume_ | (transport gainNode) |
192160

193161
**Not automatable in V1**, greyed out in the selector, with reasons:
162+
194163
- **Worklet effects** (compressor, limiter, gate, bitcrush): params travel by
195164
`postMessage`, not AudioParams. V2 path: declare
196165
`parameterDescriptors` in the processors and read `parameters` in
@@ -227,14 +196,14 @@ mid-playback without rescheduling the source.
227196

228197
## 11. PR breakdown (all < 1000 LOC)
229198

230-
| PR | Scope | Est. LOC |
231-
| --- | --- | --- |
232-
| A `wa-10-automation-model` | core: types, parse/normalize/serialize, `sampleAutomationLane`, curvature math, chain node ids, lint rule | ~450 |
233-
| B `wa-11-param-exposure` | core: `automatable` flags, `FxNodeHandle.params`, invariant test | ~350 |
234-
| C `wa-12-preview-scheduling` | core: transport + attach-path scheduling, cancel/re-schedule on live edit | ~400 |
235-
| D `wa-13-render-scheduling` | core/engine: offline scheduling in runtime entry, volume→bake bridge, sweep fixture test | ~350 |
236-
| E `wa-14-lane-ui` | studio: lane component, expand toggle, selector, point editing, orphan cleanup | ~800 |
237-
| F `wa-15-curvature` (Phase 2) | studio: segment-bend drag; worklet `parameterDescriptors` migration | ~300+ |
199+
| PR | Scope | Est. LOC |
200+
| ----------------------------- | --------------------------------------------------------------------------------------------------------- | -------- |
201+
| A `wa-10-automation-model` | core: types, parse/normalize/serialize, `sampleAutomationLane`, curvature math, chain node ids, lint rule | ~450 |
202+
| B `wa-11-param-exposure` | core: `automatable` flags, `FxNodeHandle.params`, invariant test | ~350 |
203+
| C `wa-12-preview-scheduling` | core: transport + attach-path scheduling, cancel/re-schedule on live edit | ~400 |
204+
| D `wa-13-render-scheduling` | core/engine: offline scheduling in runtime entry, volume→bake bridge, sweep fixture test | ~350 |
205+
| E `wa-14-lane-ui` | studio: lane component, expand toggle, selector, point editing, orphan cleanup | ~800 |
206+
| F `wa-15-curvature` (Phase 2) | studio: segment-bend drag; worklet `parameterDescriptors` migration | ~300+ |
238207

239208
A→B→C→D are dependency-ordered; E needs A+B (draws and writes) and benefits
240209
from C (audible while editing). F is optional polish.
@@ -243,13 +212,13 @@ from C (audible while editing). F is optional polish.
243212

244213
1. **Volume lane display unit** — data stays linear either way; show the axis
245214
as % (matches `data-volume`) or dB (matches DAW muscle memory)?
246-
*Default if unanswered: %.*
215+
_Default if unanswered: %._
247216
2. **Curvature in V1?** Format supports it from day one regardless. Building
248-
the bend-drag in V1 adds ~2 days to E. *Default: defer to F, straight lines
249-
first.*
217+
the bend-drag in V1 adds ~2 days to E. _Default: defer to F, straight lines
218+
first._
250219
3. **Worklet-param automation deferral acceptable?** Compressor threshold
251-
automation is the notable absence. *Default: defer; it's a self-contained
252-
follow-up.*
220+
automation is the notable absence. _Default: defer; it's a self-contained
221+
follow-up._
253222
4. **Clip-envelope semantics confirmed?** Automation travels with the clip.
254-
If you expected Ableton *arrangement* behaviour (stays put), say so now —
223+
If you expected Ableton _arrangement_ behaviour (stays put), say so now —
255224
it changes the data model (composition-global times, stored off-element).

0 commit comments

Comments
 (0)