Transpose the whole track, or one lane at a time, on one clock - #479
Merged
Conversation
added 3 commits
August 27, 2026 18:18
tests/e2e/serve.sh is stored LF but there was no .gitattributes, so a checkout with core.autocrlf=true (the Windows default) wrote it out with CRLF. bash treats the trailing CR as part of each command, so `npx playwright test` could not start its web server at all and the whole suite was unrunnable locally. CI never saw this because it runs on Linux, where autocrlf is off. The practical effect was that the browser tests got skipped on the primary desktop development platform and were assumed to pass. Pinning *.sh to eol=lf fixes it for a fresh clone rather than for one working copy; running dos2unix on the file works until the next checkout. The file itself is renormalised here so existing clones pick it up without waiting for a checkout to touch it. Closes #478
The per-lane VU meters were linear in amplitude, min(1, rms * 2.5).
Loudness is logarithmic and a separated stem sits around -20 to -30 dBFS,
so normal playback lived in the bottom tenth of every bar and the top
three quarters was decoration.
Measured across four tracks of a real library, median RMS per window
through the old scale:
vocals 5.0% drums 1.7% bass 5.2% other 1.6%
vocals 20.5% drums 8.5% bass 34.1% other 2.8%
mean median 10.4%
A -60 dBFS floor mapped onto the bar is the usual choice for a small
console meter: quiet passages still register and full scale stays
reachable without being reached constantly. The same stems now read
27.8%, 26.9%, 43.4% and 71.1%.
This forced the meters off getByteTimeDomainData. Its quantisation step,
one part in 128, is -42 dBFS, which on any dB scale with a lower floor
would light every meter to about a third of full over silence.
The scale lives in its own module because player.js cannot be imported
outside a browser, and a scale nothing can measure is how the linear
version survived this long. tests/js/vu-scale.test.mjs asserts what
fraction of the bar real measured stem levels light, and keeps the old
formula as a control so the comparison is a measurement rather than a
claim about the past. It guards the other direction too: no clipping to
full, headroom preserved, and a stem 20 dB down still reads 20 dB down.
Closes #477
Adds a semitone control to the transport bar and one to every mixer
lane. The transport control moves the whole track; a lane control moves
that instrument alone, so a harmony line can be practised against the
original melody rather than instead of it.
Drums are never transposed. A resampled snare is not the same drum in
another key, it is a different drum. That refusal is enforced in three
places because there are three ways in: the UI never offers it, the
engine ignores it if a caller asks anyway, and the worklet's unpitched
input is where drums are wired regardless. It also covers drums you
cannot see: a stem deselected at import is folded into original.wav, so
playbackStems.js rebuilds that lane from the retained raw stems and
routes the drums out of it. When a job predates those stems and cannot
be rebuilt, the whole lane stays unpitched rather than risk resampling a
kit that is mixed into it.
One shared clock, not one shifter per lane. WSOLA latency moves with the
shift, so independently clocked chains put lanes up to 144 ms apart from
each other:
pitch 0, tempo 1 0 ms (bypass)
pitch +2, tempo 1 115 ms
pitch -5, tempo 1 144 ms
pitch 0, tempo 0.75 437 ms
So the worklet takes one input per semitone, each a pitch chain, and
sums them into a single tempo stage. A lane's key is which input it is
connected to, not a parameter. Measured with five chains live at once:
0.35 cents worst pitch error, 12 ms worst skew between buses, no drift
over a minute.
Changing a lane mid-playback neither restarts nor flushes anything.
Every bus is primed with the same amount of silence, so the lane's old
chain plays out what it had buffered while the new one takes over as it
runs dry. The handover is a short duck, not a gap.
Lane keys are absolute rather than offsets from the global control, and
the global control applies its change rather than its value. So the
number on a lane is always the key that lane is in, and a lane
deliberately put a third above the rest stays a third above the rest
when the whole track moves. A reset returns the global key and every
lane at once, which stepping the global back to zero cannot do.
Three things the measurements changed along the way:
- An earlier revision split attacks out of the music bus and passed
them at unity while the rest went through the pitch chain. A delayed
copy plus an undelayed copy cannot sum back to the input, and it put
a 0.64 full-scale step into the output at every onset against a
source whose own largest step was 0.10. Heard as static. Removed.
- The chain produced exactly one quantum per quantum with no slack, so
a quantum occasionally came up short and was emitted zero-padded. A
hard edge to zero mid-block is a click. Fixed with an output reserve
and an input cushion banked during priming.
- The old alignment test reported 2 ms, but only because the attack
splitter was passing its probe through the bypass. Real alignment
was 18 ms and one-sided, being the WSOLA sequence sawtooth. It is
predictable, so it is now cancelled rather than tolerated.
The lane row is about 300px and was already full, so the meter column
gives up 18px to keep the fader usable, and the meters take that as
height: 5px read as a hairline rather than a meter.
Closes #245
Closes #476
Closed
2 tasks
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.
Transposition, per-lane and global, plus two things found on the way.
Closes #245
Closes #476
Closes #477
Closes #478
Transpose (#245, #476)
A semitone control in the transport bar moves the whole track. One on
every mixer lane moves that instrument alone, so a harmony line can be
practised against the original melody rather than instead of it.
Drums are never transposed, and that is enforced in three places
because there are three ways in: the UI never offers it, the engine
ignores it if a caller asks anyway, and the worklet's unpitched input is
where drums are wired regardless. It also covers drums you cannot see.
A stem deselected at import is folded into
original.wav, soplaybackStems.jsrebuilds that lane from the retained raw stems androutes its drums out. A job too old to rebuild keeps the whole lane
unpitched rather than risk resampling a kit mixed into it.
One clock, not one shifter per lane
The obvious build does not work. WSOLA latency moves with the shift, so
independently clocked chains put lanes up to 144 ms apart:
So the worklet takes one input per semitone, each a pitch chain, summed
into a single tempo stage. A lane's key is which input it is connected
to, not a parameter. Measured with five chains live at once:
Changing a lane mid-playback neither restarts nor flushes. Every bus is
primed with the same silence, so the old chain plays out what it
buffered while the new one takes over as it runs dry. A short duck, not
a gap.
Absolute lane keys
A lane shows the key it is actually in, and the global control applies
its change rather than its value. So a lane deliberately put a third
above the rest stays a third above the rest when the track moves, and
there is never a second number to add in your head. Reset returns the
global key and every lane at once, which stepping the global back to
zero cannot do once lanes have moved individually.
Three things the measurements changed
at unity while the rest went through the pitch chain. A delayed copy
plus an undelayed copy cannot sum back to the input: it put a 0.64
full-scale step into the output at every onset, against a source whose
own largest step was 0.10. Heard as static. Removed.
quantum occasionally came up short and was emitted zero-padded. A hard
edge to zero mid-block is a click. Fixed with an output reserve and an
input cushion banked during priming.
splitter was passing its probe through the bypass. Real alignment was
18 ms and one-sided, being the WSOLA sequence sawtooth. Predictable,
so now cancelled rather than tolerated.
Lane meters (#477)
They were linear in amplitude. Loudness is logarithmic and a separated
stem sits around -20 to -30 dBFS, so normal playback lived in the bottom
tenth of the bar. Measured median across a real library: 10.4%, with
drums and "other" under 2%.
Now dB-scaled with a -60 dBFS floor:
This forced the meters off
getByteTimeDomainData: its quantisationstep, one part in 128, is -42 dBFS, which on a dB scale would light
every meter to a third of full over silence.
Windows test harness (#478)
tests/e2e/serve.shis stored LF with no.gitattributes, so aWindows checkout wrote it CRLF and bash could not run a single line of
it. The browser suite was unrunnable on the primary desktop development
platform, and CI never saw it because CI is Linux.
Testing
Two of these deserve a note.
tests/js/pitch-shift.test.mjsincludes acontrol that rebuilds the worklet with the input cushion removed and
confirms the click gate fails, so it can actually catch the regression
it exists for.
tests/js/vu-scale.test.mjsasserts what fraction of thebar real measured stem levels light and keeps the old linear formula as
a control, so the comparison is a measurement rather than a claim.
Not covered here
tests/e2e/serve.shrunsuv run, which resynced a project environmenton a GPU box, uninstalled seven packages and tried to replace a
hand-installed
torch 2.6.0+cu124with the plain wheel. It failedpartway on a locked DLL and rolled back, so nothing was lost. Noted in
#478 as worth its own issue; not fixed here.
No Python dependencies changed, so the desktop in-app updater is
unaffected.
Before merging: the Unraid template pin in
templates/stemdeck.xmlisstill
0.15.2and is a separate decision.