Skip to content

Commit be2fceb

Browse files
committed
chore: pull in main after #3191's squash merge
# Conflicts: # packages/core/src/audio/audioFxGraph.ts # packages/studio/src/components/editor/propertyPanelAudioFxGroup.tsx # packages/studio/src/components/editor/propertyPanelFxNodeRow.tsx # packages/studio/src/components/editor/propertyPanelFxPresetMenu.tsx # packages/studio/src/components/editor/propertyPanelFxSection.tsx # packages/studio/src/player/components/TimelineTrackRow.tsx # plans/audio-automation-lanes/SPEC.md # plans/audio-fx-presets.md # plans/audio-fx-ux/README.md # skills-manifest.json # skills/hyperframes-audio/SKILL.md
2 parents 59aba07 + e3ec48a commit be2fceb

7 files changed

Lines changed: 50 additions & 207 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/TimelineTrackRow.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,12 @@ interface TimelineTrackRowProps {
77
rowKey: number;
88
logicalRow: TimelineLogicalRow;
99
propertyRows: readonly TimelineLogicalRow[];
10-
/** Ids the visible canvas property-lanes container (`<TimelinePropertyLanes id={lanesId}>`). */
10+
/** Names the canvas-side content cell — the active clip's own property lanes,
11+
* minted with this single id in TimelinePropertyLanes. */
1112
lanesId: string;
12-
/** Ids the visible header lane row — a distinct DOM node, so a distinct id. */
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. */
1316
headerLanesId: string;
1417
top: number;
1518
height: number;

plans/audio-automation-lanes/SPEC.md

Lines changed: 0 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -111,44 +111,6 @@ valid — they just can't be automation targets until the panel touches them.
111111

112112
**Normalization** (`normalizeAutomation`, mirrors `normalizeAudioFxParams`):
113113

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

plans/audio-fx-presets.md

Lines changed: 7 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -133,13 +133,13 @@ The carve pattern: read the decoded samples, measure something, emit a chain
133133
and/or automation lanes. **The machinery for this is already built and shipped**
134134
— what carve does with a voice against a bed generalises:
135135

136-
| Script | Reuses | Emits |
137-
| --------------------------------------------- | -------------------------------------------------- | ----------------------------- |
138-
| Voice carve _(shipped)_ | `analyseCarveBands` | peaking cuts + per-band lanes |
139-
| Auto-duck _(shipped, inside carve)_ | `analyseCarveDuck` | one volume lane |
140-
| Leveller _(**shipped**`audioLeveller.ts`)_ | windowed RMS | lane on a `gain` node |
141-
| De-esser | `analyseCarveDynamics`, re-parameterised (see §5e) | lane on a peaking cut |
142-
| Tone match | `powerSpectrum` vs a target curve | 3–5 peaking nodes |
136+
| Script | Reuses | Emits |
137+
| ----------------------------------- | -------------------------------------------------- | ----------------------------- |
138+
| Voice carve _(shipped)_ | `analyseCarveBands` | peaking cuts + per-band lanes |
139+
| Auto-duck _(shipped, inside carve)_ | `analyseCarveDuck` | one volume lane |
140+
| De-esser | `analyseCarveDynamics`, re-parameterised (see §5e) | lane on a peaking cut |
141+
| Leveller | `windowDb` walk | lane on a `gain` node |
142+
| Tone match | `powerSpectrum` vs a target curve | 3–5 peaking nodes |
143143

144144
This is the strongest argument in the doc: **none of them needs new DSP**
145145
only a different question asked of code that already runs. One (de-ess) needs
@@ -431,61 +431,6 @@ Four notes:
431431

432432
---
433433

434-
## 6b. What shipped
435-
436-
| Commit | What |
437-
| ----------- | -------------------------------------------------------------------- |
438-
| `a533d1677` | the preset catalogue in core — 18 presets over four shelves |
439-
| `de2b78047` | applying presets from the rack |
440-
| `29f53f935` | `label` on a node, so a chain reads as jobs rather than filter types |
441-
| `e984a9e62` | the multi-band EQ in core, as a composite over shipping filters |
442-
| `2eaa71cac` | the Tone module — faders in the rack |
443-
| `e723216a1` | the levelling script |
444-
445-
Three things worth carrying forward:
446-
447-
**`parseAudioFxChain` silently dropped `fromPreset`** until `29f53f935`. The
448-
round-trip test compared only node _types_, so it passed while the tag that
449-
lets a preset find its own nodes was being lost on every reload. Any new tag on
450-
a node (`fromEq`, `fromLeveller`, `label`) must be added to BOTH the parser and
451-
the serializer, and the round-trip test must compare it.
452-
453-
**The single-knob rule broke on `peaking`** and it took someone asking to see
454-
it. "How much" cannot be the one knob when the range is the first decision. The
455-
answer was to make the range the module — the add menu offers _jobs_ — which
456-
also dissolved the duplicate-name problem at the root.
457-
458-
**The levelling target must be a level the track already reaches.** Anchoring
459-
it to an absolute figure turns levelling into a volume change. The 80th
460-
percentile of the track's own speaking windows is the figure that works.
461-
462-
## 6c. The two scripts NOT built, and why
463-
464-
**De-esser — deferred, not abandoned.** The leveller is its structural template:
465-
analysis → profile → result → remove → summary, with a mutation pass over each.
466-
Two constraints have to be designed for before it is written, and neither is
467-
visible until you try:
468-
469-
1. `analyseCarveDynamics` cannot be reused unchanged. Its hop is
470-
`max(FRAME, length / POINT_BUDGET)` — 85 ms at best, ~150 ms on a
471-
real-length track — while sibilants are 50–150 ms events. At that resolution
472-
the envelope cannot land on them, and its `ATTACK_S`/`RELEASE_S` are tuned
473-
for musical ducking besides. Same machinery, re-parameterised for a
474-
sibilance timescale.
475-
2. **`MAX_AUTOMATION_POINTS` is 512.** A dip needs three or four points, and a
476-
long voiceover holds hundreds of sibilant events, so a naive lane blows the
477-
cap and the scheduler truncates it — silently, leaving an envelope that
478-
stops partway through the clip. Budget it up front: strongest-N events, or
479-
merge adjacent dips.
480-
481-
**Tone match — superseded for v1.** It existed to give a casual author a way to
482-
fix the tone of a track without understanding frequencies. The Tone EQ now does
483-
that with a control they already know, and it does it _predictably_, which
484-
matching against a reference clip does not. What remains is genuinely advanced
485-
— matching one track to another — and it carries real unknowns: which reference,
486-
how much correction, what to do when the two sources have different content.
487-
Not worth building before anyone has asked for it.
488-
489434
## 7. What to build first
490435

491436
1. **Character presets** (§5c) — highest ratio of delight to risk. Pure

plans/audio-fx-ux/README.md

Lines changed: 25 additions & 102 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,14 @@ routing, what is driven versus set. But information a casual author cannot read
55
is decoration, and the rack speaks entirely in Hz, dB and ratios. So the drawing
66
stays and the **language changes**.
77

8-
The plain-language layer over every effect in the registry ships as
9-
`packages/core/src/audioFxCopy.ts`, and the coverage that used to gate a review
10-
page — every effect, parameter and preset must have copy — is now
11-
`audioFxCopy.test.ts`, so it runs on every commit rather than when somebody
12-
remembers to regenerate a page.
8+
`copy.mts` is the design work: a plain-language layer over every effect in the
9+
registry. `build-preview.mts` renders the review page from it **plus the real
10+
registry and preset catalogue**, and **fails** if any effect, parameter or
11+
preset lacks copy — so the page cannot quietly omit something that ships.
1312

14-
This document is the design record. What it describes is built: read it against
15-
the FX rack in the studio, or against `audioFxCopy.ts`, `audioFxJobs.ts`,
16-
`audioFxProfiles.ts` and the panel components under
17-
`packages/studio/src/components/editor/propertyPanelFx*`.
13+
```bash
14+
bun plans/audio-fx-ux/build-preview.mts /tmp/rack-ux.html
15+
```
1816

1917
## The three rules
2018

@@ -130,9 +128,7 @@ the frequency under **Details**, for the author who wants to.
130128
This changes the catalogue, not just the copy: the presets should reference
131129
named jobs, and `EFFECT_COPY.peaking` stops being one entry.
132130

133-
## Shipped: a multi-band EQ ("Tone")
134-
135-
_Built in `e984a9e62` / `2eaa71cac`. The design below is what was built._
131+
## Proposed: a multi-band EQ ("Tone")
136132

137133
The clearest failure this exercise surfaced is a rack holding two _Shape One
138134
Range_ modules doing different jobs. A multi-band EQ is the answer, and it is a
@@ -161,97 +157,24 @@ detent is what an equaliser looks like to everyone who has met one. Collapsed,
161157
it reads like every other module: "Bass +3, Middle −2, Treble +2", or "Flat"
162158
when nothing has been touched.
163159

164-
## Decided (2026-08-10)
165-
166-
All three were open until now, and the first was blocking the wiring.
167-
168-
**The plain name replaces the DSP name; the DSP name lives under Details.**
169-
The rack reads plain top to bottom — `Remove Rumble`, not `highpass` — and the
170-
header stays narrow, which matters because it already carries a summary, a
171-
bypass, two arrows and a delete. Nothing is lost: opening a module shows the
172-
DSP name beside its real parameters, so the author who wants the mapping finds
173-
it exactly where the mechanism is, and the author who does not never meets it.
160+
## What still needs deciding
174161

175-
**Presets sort by complaint; the effect list stays in signal order.**
176-
The two menus stop competing to be the same thing. Presets are the casual
177-
author's door and `PRESET_PROBLEM` already carries the line for all 18 of them,
178-
so this costs no new writing. The effect list stays Filters / Dynamics /
179-
Non-linear / Time — it is the expert's tool, and that grouping teaches the
180-
signal path the rack itself is ordered by.
181-
182-
**Everything auditions on hover, with a spinner for the ones that measure.**
183-
Static presets apply to the playing audio and revert on leave, which the graph
184-
rebuild path already supports. A carve or an Even Out Levels analyses first and
185-
says so while it does — the same spinner the carve module already shows. This
186-
is the expensive answer of the three: analysis is seconds, and a hover that
187-
takes seconds is one the author has often already left, so whatever gets built
188-
needs a cancel on leave and must not apply a result that arrives late.
162+
- Does the plain name **replace** the DSP name or sit beside it? Replacing is
163+
friendlier but strands what the author learns.
164+
- Should the **menus** be organised by complaint ("my voice sounds boomy")
165+
rather than by effect family? The rack itself must stay in signal order,
166+
because order is audible — but the menus have no such constraint, and the
167+
preset section of the preview is written that way to show the difference.
168+
- How much should **hover audition**? Hearing a preset before committing is the
169+
single strongest affordance here. Cheap for static presets; a measuring script
170+
has to analyse first and cannot preview instantly.
189171

190172
## Status
191173

192-
The EQ, the named jobs and the levelling script are **built**, and the copy
193-
layer has now landed as `packages/core/src/audioFxCopy.ts``EFFECT_COPY`,
194-
`BANDS`, `PRESET_PROBLEM` and `SUMMARY`, with the completeness check as a test
195-
beside it rather than a build step.
196-
197-
**It is wired.** `propertyPanelFxNodeRow.tsx` takes the name from `EFFECT_COPY`,
198-
the sentence under it from `SUMMARY`, and every knob's name from the same place
199-
via `plainDef` — which writes the words over the registry's def and leaves range,
200-
step, unit and automatability alone. The DSP name sits above the knobs as
201-
`Details — High-pass`. The preset shelf leads with `PRESET_PROBLEM` and follows
202-
with the preset's own name. Hover and focus both audition, through the same
203-
preview channel a slider drag uses; the leveller measures first, says
204-
"measuring…" while it does, caches the decode per `src`, and drops a result that
205-
arrives after the pointer has gone.
206-
207-
Three things that took a second pass, all worth knowing before touching this
208-
again. An audition has to survive the panel re-rendering under it — the group
209-
re-renders every playhead tick, so anything keyed on its inline callbacks tears
210-
down thirty times a second. Applying must NOT revert, since the audition _was_
211-
the thing applied. And moving between two entries in a shelf is not leaving it,
212-
so each entry has to call its neighbours' auditions off itself.
213-
214-
**The three rules are built too**, and so is the visual direction:
215-
216-
- _Two faces._ A module opens on its name, what it is for, and the one control
217-
that carries it — `EFFECT_COPY.primary`, with `primaryEnds` saying what its
218-
two ends sound like. Everything else is behind a Details disclosure, which is
219-
also where the DSP name lives. Ten of fifteen effects; the five whose primary
220-
is "strength" open on all their controls until `PROFILES` ships, which is
221-
honest — inventing one knob for them now would be a knob that lies.
222-
- _One knob that matters._ `packages/core/src/audioFxProfiles.ts`. Five effects
223-
get a derived control over several parameters, continuous rather than the
224-
three-point tables proposed here. **Three of the five figures were wrong** and
225-
only rendering showed it — the write-up is `~/audio-fx-profiles-ab/README.md`.
226-
- _Name the outcome._ Modules, knobs, the add menu and the preset shelf.
227-
228-
- **The range IS the module.** The add menu offers five named jobs — Tame
229-
Boominess, Reduce Mud, Reduce Boxiness, Add Clarity, Soften Harshness — and
230-
`peaking` is not offered as itself. `packages/core/src/audioFxJobs.ts`. Every
231-
one is a job the preset catalogue already ships, at the settings it ships it
232-
with, so the list names the vocabulary the presets were written in rather than
233-
inventing a second one.
234-
- **The shared ruler.** Every spectral module shows where it acts across the
235-
seven named ranges, log-spaced, with the range it is in named underneath.
236-
- **Family lettering and the tint step.** Four families told apart by the sans,
237-
the serif spent on non-linear, monospace for the measuring modules, and a
238-
lightness step per module derived from registry position.
239-
- **The schematic**, translated to one column: IN and OUT terminals, every step
240-
numbered over what the rack shows, and a preset's consecutive nodes bracketed
241-
as the one thing that was added.
242-
243-
Everything in this document is now built.
244-
245-
It has no entry for Tone or for the levelling module, because both carry their
246-
own copy in core (`audioEqSummary`, `levellingSummary`). That is the right home
247-
for it: a summary that has to read the chain belongs beside the code that
248-
writes it.
249-
250-
The `PROFILES` figures — what one knob derives at gentle/middle/strong — have
251-
**shipped and been corrected**. They are `HF_AUDIO_FX_PROFILES` in
252-
`packages/core/src/audioFxProfiles.ts`, continuous rather than three-point, and
253-
three of the five were wrong as proposed: the compressor's make-up left the
254-
track quieter at full evenness, saturation's trim went the wrong way and made
255-
"Warmth" mean "much quieter", and the gate did essentially nothing because
256-
`release` was not in the profile at all. Measurements, method and the sweep that
257-
found the last one are in `~/audio-fx-profiles-ab/README.md`.
174+
`copy.mts` is a proposal, not shipped code. When it lands it wants to be
175+
`packages/core/src/audioFxCopy.ts` beside the registry, with the completeness
176+
check as a test rather than a build step.
177+
178+
The `PROFILES` figures — what one knob derives at gentle/middle/strong — are
179+
proposed values, not measured ones. They want the same before/after listen the
180+
clip-before-duck fix got.

skills-manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@
2626
"files": 121
2727
},
2828
"hyperframes-audio": {
29-
"hash": "36435b85d7697d22",
30-
"files": 5
29+
"hash": "534cea75fe0f2bc6",
30+
"files": 6
3131
},
3232
"hyperframes-cli": {
3333
"hash": "e042fcaaa3f9767f",

0 commit comments

Comments
 (0)