Skip to content

Count-in, and a transport footer rebuilt around the studio's column grid - #369

Merged
thcp merged 7 commits into
mainfrom
feat/count-in-269
Aug 16, 2026
Merged

Count-in, and a transport footer rebuilt around the studio's column grid#369
thcp merged 7 commits into
mainfrom
feat/count-in-269

Conversation

@thcp

@thcp thcp commented Aug 16, 2026

Copy link
Copy Markdown
Collaborator

Closes #269.
Closes #370.
Closes #371.

Count-in (#269)

One bar of click leads into playback and into audio exports, independent of
the running click track — a clean backing track can still get a count-in. The
lead-in math is defined once and mirrored between metronome.js and
click_render.py, pinned by parity tests on both sides.

  • Playback: audioEngine schedules stem playback on a future ctx-time
    start so the count clicks land in the silent gap before the song begins.
  • Export: stems are delayed via ffmpeg's adelay and the click WAV is
    rendered in output coordinates, so it isn't re-trimmed by the region -ss
    like a plain click.

Transport footer

The footer was rebuilt around labelled control groups (Transport, Position,
Speed, Click track) instead of a right-click popover, then aligned to the
studio's own column grid:

  • Two columns, not three stacked rows. Everything time-related — the
    control clusters, the waveform, its ruler and the detection note — starts
    exactly where the lane waveforms start and runs flush to the window edge,
    so a position sits at the same x in both strips. The track identity (art,
    title, meta, favourite, Export Mix) sits in the left column under the mixer
    panel and shares its width and padding. That also drops a row: 255px tall
    where the three tiers were 318px.
  • The 300px column width is now --daw-col-w, read by the stems panel, the
    label cell above it and the footer, instead of being hardcoded three times.
  • Both rulers share tickStep(), so a time is labelled at the same x in each.
  • Playback speed collapses to three practice presets (0.25x / 0.5x / 1x) as a
    segmented control.
  • Click track, count-in and grid are all press-to-toggle buttons with the same
    shape and lit state — click on, click off. The G shortcut is gone; the
    button says what it does, and a single letter bound to a modal editor is
    easy to hit by accident.
  • A divider stranded at the end of a wrapped control row is hidden by
    syncFooterDividers, using visibility so removing its box can't change
    the wrap it just measured.

Testing

  • tests/js/count-in.test.mjs — lead-in parity, accent handling, multiplier
    levels (12 checks).
  • tests/test_click_render.py — the render side of the same math.
  • 13/13 Playwright e2e (export-menu.spec.mjs) pass.
  • Driven by hand in the browser at 1100 / 1440 / 1600 / 1900px, in empty,
    loaded, playing and menu-open states. Alignment verified by measurement:
    lane waveforms and footer canvas both 690→1600, ruler ticks matching at
    690, 874, 1059, 1243, 1428.

Known limitation

Alignment holds at fit-zoom. Below ~720px of lane width the lanes zoom and
scroll independently while the footer strip always shows the whole song, so
there the two share only a left edge.

Thales added 6 commits August 16, 2026 01:39
…ort footer

Count-in (#269): one bar of click count-in leads into playback and into
audio exports, independent of the running click track (a clean backing
track can still get a count-in). The lead-in math is defined once and
mirrored between metronome.js and click_render.py, pinned by parity
tests on both sides.

- Playback: audioEngine schedules stem playback on a future ctx-time
  start so the count-in clicks land in the silent gap before the song
  begins; the metronome schedules them through the same clock mapping
  the running click already uses.
- Export: stems are delayed via ffmpeg's adelay and the click WAV is
  rendered in output coordinates when a count-in is requested, so it
  isn't re-trimmed by the region -ss like a plain click.

Also rebuilds the transport footer around labelled control groups
(Transport, Position, Speed, Click Track) instead of a right-click
popover: playback speed collapses to three practice presets (0.25x /
0.5x / 1x), the click track gets an on/off toggle and a count-in
switch, and the track-info block collapses from four stacked detail
rows to one compact line.
The panel lost its default "hidden" class when it changed from a
right-click popover to always-inline (#269 follow-up) -- on a fresh
page load, before any track was ever picked, nothing forced it
hidden, so "Ready to import a track" showed a full set of live-
looking click controls for a track that didn't exist.
…olume readout

- Time labels above the footer's mini waveform, matching the main ruler.
- Divider marks between control clusters in the footer's controls row,
  hidden via ResizeObserver when wrapping strands one at the end of a
  line with nothing after it to separate.
- Click volume percentage shown next to the slider again instead of
  screen-reader-only -- a level you can only learn by hovering isn't
  one you can reliably match between sessions.
- Count-in switched from a checkbox to a press-to-toggle button,
  matching the click on/off control beside it (both answer "is this on
  for the next play?", so they read as the same kind of control now).
The chunked engine is the default playback path (engineMode() falls
back to "chunked" unless a debug localStorage flag forces
"fulldecode") -- but count-in support (play(leadIn), supportsCountIn,
a clamped getCurrentTime during the lead-in) was only ever added to
audioEngine.js, the full-decode path. Since _armCountIn() bails out
whenever eng.supportsCountIn is falsy, count-in silently never armed
for any track played through the engine essentially everyone actually
uses, and playback started immediately regardless of the toggle.

Mirrors the same fix in chunkedAudioEngine.js: play() accepts a
leadIn and schedules the first chunk that far in the future (falling
back to the existing 10ms/50ms margins when there is no count-in),
and getCurrentTime() clamps to the start offset during that gap
instead of reading negative.

Verified directly against the running engine clock (not just DOM
text, which rounds to whole seconds): the position holds at the start
offset for the full lead-in and then advances normally, pausing
mid-count-in stops cleanly with no phantom scheduled audio, and
replaying re-arms a fresh count-in.
…to it

The footer's waveform strip ran the full width of the window while the lane
waveforms above it start after the 300px stems/mixer panel, so the same
position sat at two different x positions in the two strips and neither
ruler's ticks lined up with the other's.

The footer is now two columns on the studio's own grid. Everything
time-related -- the control clusters, the waveform, its ruler and the
detection note -- sits in the right column and starts exactly where the lane
waveforms start, running flush to the window edge like they do. The track
identity (art, title, meta, favourite, Export Mix) moves into the left column
under the mixer panel and shares its width and 14px padding, so titles, stem
names and the "Mixer" heading share one left edge down the page. That also
drops a whole row from the footer: 255px tall where the three stacked tiers
were 318px.

- The 300px is now --daw-col-w, read by the stems panel, the label cell above
  it and the footer, instead of being hardcoded in each.
- The waveform strip is full-bleed with top/bottom rules rather than a
  rounded inset panel: a side border would have offset the canvas by its own
  width, which is exactly the misalignment being fixed.
- Both rulers share tickStep(), so a time is labelled at the same x in each.
- The export menu opens up and to the right; right-aligned from the left
  column it would have hung over the sidebar.

Grid becomes a press-to-toggle button matching the click and count-in buttons
beside it -- click opens the editor and lights it, click again closes it. Its
lit state is synced inside toggleBeatGridEditor, the one place every open and
close runs through, so Done, Escape and losing the beat grid all leave the
button correct. The G shortcut is gone: the button says what it does now, and
a single letter bound to a modal editor is easy to hit by accident.
The strip carries only top and bottom rules -- side borders were dropped so
the canvas would land exactly on the lane waveforms' left edge -- which left
its left end open, the two rules stopping in mid-air.

Drawn as an outset box-shadow rather than a border-left: a border sits inside
the box and would push the canvas a pixel off the alignment it exists to
keep. The line falls on the same x as the stems panel's right border, so that
seam now runs unbroken from the top of the mixer to the bottom of the strip.
Every interactive element in the export menu called stopPropagation so the
document-level dismiss handler would not fire, but the two option checkboxes
had no click handler at all -- so ticking one bubbled out and closed the menu
under the pointer.

That was survivable with one checkbox. This branch adds a second ("Add
count-in"), and wanting both is the normal case for practising to a click:
the first tick closed the menu, and the second needed it reopened.

Guard the panel itself rather than adding a third per-element stopPropagation
that the next option added would forget: a click inside a menu is not a click
away from it. Nothing depended on the bubble to close the menu -- the export
actions close it themselves through enterBusy() -> closePanel().
@thcp
thcp merged commit fea4fcf into main Aug 16, 2026
10 checks passed
@thcp
thcp deleted the feat/count-in-269 branch August 16, 2026 18:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant