Repository navigation
feat(click): click as its own stem in the stems zip, and a click sound selector - #831
Merged
Merged
Conversation
Five presets (Classic, Woodblock, Rimshot, Soft, Cowbell), each with an accent, a group-start and a beat voice, synthesised procedurally (sines, seeded noise through a band-pass, the original click's exponential envelope). The table lives once, as strict JSON inside static/js/clickSounds.js; app/pipeline/click_sounds.py parses that block and runs the same formulas in numpy. Classic is the old click: its renders are byte-identical to big-release. Presets are level-matched to Classic by EBU R128 loudness of a 16 s 4/4 loop (all -27.8 LUFS, woodblock -27.7), with every voice peak under 0.97 at full click volume. tests/fixtures/click_sounds.json pins both renderers to one set of measurements per preset, level and rate (length, peak position and level, 1 ms RMS envelope, spectral centroid, sample sum).
- click_sound on mixdown, video, stems/all.zip and the take render body, checked against the preset whitelist (422 otherwise), default classic. It is only a table key and a cache-key suffix, never a path or argument. - stems/all.zip?click=1 adds <song>_click.<format>: rendered from the beat grid (user edits included) with the mix's rate, accents, grouping and sound, at the stems' rate, channels and exact decoded length (also for MP3-stored songs), from sample 0. Gain is what the click contributes to a mix export (click_gain, -3 dB per channel from the mono-to-stereo conversion), so the zip sums to the mix. Ends in the export limiter for the format. No count-in: zip stems are not delayed. - Without click=1 the zip is unchanged.
A Click sound select in the click track options popover, filled from the preset table. The choice is a global preference saved with the other click settings (stemdeck:metronome), reaches the live click and the count-in, and every export URL (sent only when it is not Classic, so Classic exports keep their URL and cache entry). The live click now plays each preset voice from an AudioBuffer rendered once per sound, level and context rate: one node per click instead of an oscillator and a gain. Changing the sound re-voices the clicks already queued at their own times and leaves the one sounding to finish, so the switch lands on the next beat without a pop, a lost beat or a leaked node. Include click track now also adds the click to the stems .zip; its tooltip and Guide sentence say so. New Guide row for the selector. All ten tables (and pt-PT where the wording differs) translated. Closes #805
thcp
marked this pull request as ready for review
October 22, 2026 01:18
This was referenced Oct 22, 2026
thcp
added a commit
that referenced
this pull request
Oct 22, 2026
…g-release (#836) Closes #835 ## What changed - **PR to `main`, push to `main`, release:** full suite, as before. - **PR to any other base:** smoke + changed specs + mapped specs. A hub module (former `full` source entries: `main.js`, `state.js`, `mixer.js`, `catalog.js`, `index.html`, `app/api/jobs.py`, `stems.py`, `app/core/**`, ...) or an unmapped file under `static/` or `app/` now adds a fixed **core set** (17 specs) instead of everything. `uv.lock` and `pyproject.toml` add core too. - **Full suite on a non-main PR** only for a change to how the suite runs: `playwright.config.mjs`, `tests/e2e/helpers*`, `serve.sh`, `seed.py`, `package*.json`, the three e2e workflows, the selector, `spec-map.json`, or unmapped code under `tests/e2e/`. - **New `e2e-full.yml`:** full suite on every push to `big-release`, nightly (04:17 UTC) on `big-release`, and `workflow_dispatch` with a `ref` input. - **New reusable `e2e.yml`** (`workflow_call`, inputs `mode`, `base-ref`, `checkout-ref`) holds the steps once. Same pinned SHAs, `permissions: {}`, no secrets. - **Concurrency:** a new push to a PR cancels that PR's older e2e run. Pushes, releases, nightly and manual runs are never cancelled. `timeout-minutes: 30` per shard. ## What did not change - Application code, specs, `uv.lock`, package files. Three shards, `--pass-with-no-tests`. - Check names gain a prefix: `frontend-e2e / frontend-e2e (1)` to `(3)`. A reusable workflow always prefixes the caller job. No branch protection or ruleset requires these names today (checked). ## Real diffs, before and after | Diff | Files | Before | After | |---|---|---|---| | #806 (queue.js, catalog.js) | 5 | FULL 105 | 30 (core), includes queue-progress | | #820 | 7 | FULL 105 | 59 (core) | | #821 | 2 | 47 | 47 | | #823 | 8 | 5 | 5 | | #828 (uv.lock) | 4 | 5 | 22 (core) | | #819, #830, #831 | 7 to 19 | FULL 105 | FULL 105 (edit `spec-map.json`) | | #829 | 19 | FULL 105 | FULL 105 (edits `helpers.mjs`) | | #822 | 33 | FULL 105 | FULL 105 (edits `seed.py`) | | `main...big-release` | 331 | FULL | FULL (PR to main) | `npx playwright test --list <selected>` matches the selector exactly for #806 (30 files, 200 tests) and #820 (59 files, 465 tests). Full suite: 724 tests in 105 files. Core + smoke alone: 159 tests in 22 files. ## Trade-off A non-main PR can pass while a spec outside its core and mapped sets breaks. The push to `big-release` run catches it on the merge commit, minutes later. ## Limits - **The nightly schedule only starts once `e2e-full.yml` reaches `main`.** GitHub runs scheduled workflows from the default branch, and the job checks out `big-release` explicitly for that reason. - Neither concurrency nor the timeout helps a hosted runner GitHub never assigns (queue time does not count toward the timeout). Fix by hand: re-run the job, or `gh run cancel <id>` then `gh run rerun <id> --failed`. - This PR changes `ci.yml`, so its own CI runs the full suite. Expected.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #805
What changed
<song>_click.<format>to the .zip (wav, mp3, flac or ogg, same as the stems). Unticked: the zip is exactly as before.stemdeck:metronome). Used by the live click, the count-in, mix and region exports, the click stem, the MP4 export and take renders (click_soundin the render body).static/js/clickSounds.js.app/pipeline/click_sounds.pyparses that block and runs the same synthesis in numpy (sines with optional exponential glide, seeded xorshift32 noise through an RBJ band-pass, the original click's two exponential ramps).tests/fixtures/click_sounds.jsonholds per-voice measurements (length, peak index and level, 1 ms RMS envelope, power-weighted spectral centroid, sample sum) for every preset, level and rate (44.1/48 kHz). pytest asserts Python still produces it;tests/js/click-sounds.test.mjsasserts the browser synthesis matches within tolerance. Same pattern asclick_levels.json, because the two CI jobs cannot run both languages.AudioBufferSourceNodeper click from a cached voice buffer (was an oscillator plus a gain). A sound change re-voices queued clicks at their own times and lets the sounding one finish.click_soundonmixdown.{ext},video.mp4,stems/all.zip, take render; whitelisted against the table, 422 otherwise, defaultclassic. Used only as a table key and a cache-key suffix.stems/all.zipalso takesclick,click_mult,click_accent,click_gain,click_groups.What did not change
"kind": "sample"and arenderVoicebranch for it on each side (both refuse any kind butsynthtoday); the selector, whitelist and fixture already read the table.Zip layout
clickcannot collide: no stem is named that (checked at import), and takes are<label>_<take id>.Gain rule
The click stem carries what the click contributes to a mix export:
click_gainand the mix graph's mono-to-stereo conversion (-3 dB per channel). The zip at unity sums to the mix export within 4 LSB (tested). Ends in the format's export limiter (bit-transparent below -1 dBFS for WAV/FLAC, true-peak for MP3/OGG).Alignment guarantee
Rendered from the beat grid (user edits included) with the mix's rate, accent, grouping and sound, from sample 0, at the reference stem's rate and channels and its exact decoded frame count (also for MP3-stored songs). Real check, 30 s 120 BPM file, real separation, MP3-stored: every file 1,323,000 frames / 30.000000 s / 44.1 kHz / 2 ch (wav and mp3 zips); 60 of 60 click onsets on the beats.json times, 0 samples off, for Classic and Cowbell.
Count-in
Mix exports only, as today. Zip stems start at the song's first sample and are not re-encoded or delayed, so there is nowhere before them for a count-in; adding one only to the click would misalign it.
Level matching
EBU R128 integrated loudness of a 16 s 4/4 loop at 120 BPM: all presets -27.8 LUFS (Woodblock -27.7), max voice peak 0.968 at full click volume. Tested within 0.5 LU and under 0.99 peak.
Risks
durationis in the stem but dropped from the mix's click (pre-existing; durations match on real songs).Tests
npm run test:js, i18n coverage clean, bandit clean, ruff clean,uv.lockunchanged.tests/test_click_sounds.py,tests/js/click-sounds.test.mjs,tests/e2e/click-sound.spec.mjs.