Skip to content

A capture hook, so the UI can be recorded deterministically - #17

Open
spacedevin wants to merge 2 commits into
mainfrom
feat/capture-hook
Open

A capture hook, so the UI can be recorded deterministically#17
spacedevin wants to merge 2 commits into
mainfrom
feat/capture-hook

Conversation

@spacedevin

Copy link
Copy Markdown
Owner

Recording Deckard playing a set meant screencasting it in real time — which drops frames under load
and never renders the same way twice.

The UI already subscribes to transport.position on the signal bus to drive the playhead, grid and
timeline. So a recorder can publish that position itself, one frame at a time, and screenshot each
one: every frame is then drawn at exactly the timecode it will occupy in the video.

The seam was missing. signals is created inside the App component and nothing outside can reach it.
With ?capture=1, this exposes the runtime handle and the bus:

if (typeof window !== "undefined" && window.location &&
    String(window.location.search).indexOf("capture=1") >= 0) {
  window.__deckardCapture = { rt: rt, signals: signals }
}

It extends the same rt escape-hatch object the app already keeps for setActiveDeck and the
undo/redo entry points, rather than inventing a new mechanism. Gated on the query string, so it is
not an API the app ships to ordinary users, and a normal load is unchanged.

What it is for

A capture harness in the chuggie repo (scripts/deckard-mix) that loads preset sets onto decks,
crossfades between them, renders the mix offline, and records the UI against it. It drives Deckard's
real model, real preset sets and real sequencer — the same move renderDeckLoopToBuffer() already
makes internally, widened from one mono deck to a stereo multi-deck mix.

Building it surfaced two bugs worth knowing about here, both in the render path rather than in this
diff:

  • Voices retire themselves with setTimeout against wall clock. An offline render finishes a
    minute of audio in about a second, so those cleanup timers fire mid-render and tear down notes
    that are still sounding. Deferring them for one render recovered 7 dB of RMS and a truncated
    tail. Live playback is unaffected — wall clock and audio time agree there — but anything rendering
    offline through these voices is losing note tails today.
  • Noise voices seed from Math.random(), so no two renders match. The harness installs a seeded
    PRNG; after that, two runs differ by less than one 16-bit LSB.

Verification

  • npm test516 pass, 0 fail.
  • Hook verified in a real browser: __deckardCapture.rt.project reachable, signals.publish
    present, rt.setProject re-renders after loading sets onto decks.
  • A 4-bar capture produced 230 frames at 30fps muxed against the rendered audio, with the playhead
    confirmed advancing between frames.

Recording Deckard playing a set meant screencasting it in real time, which drops frames under load
and never renders the same way twice.

The UI already subscribes to `transport.position` on the signal bus to drive the playhead, grid and
timeline — so a recorder can publish that position itself, one frame at a time, and screenshot each
one. Every frame is then drawn at exactly the timecode it will occupy in the video.

The seam was missing: the signal bus is created inside the App component and nothing outside can
reach it. With `?capture=1` this exposes the runtime handle and the bus, extending the same `rt`
escape-hatch object the app already keeps for `setActiveDeck` and the undo/redo entry points.

Gated on the query string, so it is not an API the app ships to ordinary users, and nothing changes
for a normal load.
The hook exposed the runtime handle and the signal bus, which was enough to place the playhead and
load sets — but not enough to show the app being USED. A recording made through it had the
crossfader parked dead centre and every knob untouched for its whole length, because the mixing was
happening to model fields behind the UI's back.

midiControl and midiNote are the doors a hardware surface already uses. Driving those moves the
4-way fader, turns the deck FILTER and LO knobs, and lights pads, because the writes go through the
app's own path — deckEq mutate, applyDeckEq, scheduleDeckMix, deckMixRev — exactly as they do when a
controller is plugged in.

It also keeps a capture honest about what the controls can express: the deck EQ knobs are +/-12 dB,
so a bass cut written straight into the model could ask for a value the hardware cannot reach.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant