Skip to content

Carry the mixer's transpose into the exported file - #599

Merged
thcp merged 3 commits into
mainfrom
feat/592-transpose-on-export
Sep 8, 2026
Merged

thcp merged 3 commits into
mainfrom
feat/592-transpose-on-export

Conversation

@thcp

@thcp thcp commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Closes #592

Transposition happens in the browser, in a SoundTouch AudioWorklet. The server had no pitch stage at all, so a mix exported from a transposed session came back in the original key with nothing to say it had.

What it does

The export takes a per-lane semitone shift (?pitches=, parallel to stems/gains, the shape the endpoint already uses) and applies it with ffmpeg's rubberband filter.

I measured that choice rather than assuming it. On the bundled build, across the whole -6..+6 range:

Shift Duration Dominant tone Target
+2 30.000000s 494.0 Hz 493.9
-3 30.000000s 370.8 Hz 370.0
+6 30.000000s 622.2 Hz 622.3

Duration holding exactly is the part that matters: a lane whose length moved would desync the mix. That is why asetrate/atempo was not used.

Three things that needed care

Drums are never transposed. effective_pitch mirrors effectivePitch in static/js/pitchBus.js, so the export cannot contain something the studio refuses to play. Verified end to end: rendering vocals+drums at +2 gives 440 -> 494.0 Hz and leaves drums at 1000.0 Hz.

original needed more than a filter. It is the complement lane, so it usually has drums summed into it, and shifting that file would resample them. Playback never does this -- buildPlaybackStems rebuilds the lane from component stems and routes drums around the pitch stage. The export now does the same. When a component is missing (an older job that kept only the rendered mix) the lane stays at its original key, which is the same conservative choice playback already makes there.

librubberband is GPL and plenty of builds omit it. The bundled macOS ffmpeg has it; the Homebrew one on this dev machine does not. So it is probed once per process, and a transpose asked of a build that cannot do it is refused with a reason rather than served in the original key -- which would look exactly like the bug this fixes. An export with no transpose never probes and renders exactly as before.

Compatibility

The shift enters the render cache key only when something is actually shifted, so every export made before this keeps the entry it already had. A client that predates this omits the parameter and behaves identically.

The video mux takes the same parameter. An MP4 whose audio ignored the transpose would be this issue reported a second time.

Verification

Gate Result
pytest tests/ -q 1034 passed, 2 pre-existing failures
new tests/test_transpose_export.py 18 passed
npx playwright test 129 passed
ruff check / format --check clean
node --check + JS suites clean

The 2 failures are the ogg pair that fails on unmodified main on this machine (local ffmpeg has no libvorbis).

Not done

No export-settings checkbox. The transpose follows the mixer, so an export matches what you are hearing without a second control to keep in sync. The issue asked for an option; if you want the file to be able to differ from the session deliberately, say so and I will add the toggle.

Transposition happens in the browser, in a SoundTouch AudioWorklet. The
server has no pitch stage at all, so a mix exported from a transposed
session came back in the original key with nothing to say it had.

The export now takes a per-lane semitone shift and applies it with ffmpeg's
rubberband filter. Measured on the bundled build: duration holds to the
microsecond across the whole -6..+6 range and the pitch lands within 1 Hz of
target, which matters because a lane whose length moved would desync the
mix. asetrate/atempo was not used for that reason.

Drums are never transposed, on the server as on the client. Resampling a
snare does not move it to another key, it makes it a different drum, and
effective_pitch here mirrors effectivePitch in static/js/pitchBus.js so an
export cannot contain something the studio refuses to play.

"original" needed more than a filter. It is the complement lane, so it
usually has drums summed into it, and shifting that one file would resample
them. Playback never does: buildPlaybackStems rebuilds the lane from the
component stems and routes drums around the pitch stage. The export now does
the same, and falls back to leaving the lane at its original key when a
component is missing -- an older job that kept only the rendered mix -- which
is the same conservative choice playback makes there.

librubberband is GPL and plenty of builds omit it: the bundled macOS ffmpeg
has it, the Homebrew one on a dev machine does not. So it is probed once per
process, and a transpose asked of a build that cannot do it is refused with
a reason rather than served in the original key, which would look exactly
like the bug this fixes. An export with no transpose never probes at all and
renders exactly as it did before.

The video mux takes the same parameter. An MP4 whose audio ignored the
transpose would be this issue reported a second time.

The shift enters the render cache key only when something is actually
shifted, so every export made before this keeps the entry it already had,
and a client that predates it omits the parameter and is unchanged.

Closes #592
Comment thread app/api/stems.py Fixed
…l audio

Both surfaced by exporting from an actual library rather than a fixture, and
neither was visible to the tests as they stood.

Validity depended on the transpose. `_expand_render_lanes` rebuilt the
"original" lane from component stems without first checking the lane exists
at all, so ?stems=vocals,original 404'd untransposed and rendered when
transposed. Whether a lane exists is a property of the job, not of the
request. The lane is now validated before anything else, and the
reconstruction is a decision made after that.

The fallback pointed at the wrong file. The component loop bound its path to
`path`, the same name already holding the validated original.wav, so a job
whose components are missing fell back to whichever component was tried last
-- drums.wav -- instead of the lane the caller asked for. Renamed, with a
comment saying why the name is not reused.

Verified against a real 224 s track rather than only in tests, measuring the
shift by per-frame median log-spectral correlation and validating that method
against a known-good direct rubberband render first:

    bass  pitches=+2  ->  +2.00 semitones
    bass  pitches=-3  ->  -2.96 semitones
    drums pitches=+2  ->  byte-identical to no transpose

MP3 and WAV both export; duration is unchanged in every case.

Refs #592
@thcp

thcp commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator Author

Listening check done, on a full six-lane mix from a real 224 s track, 25 s region, exported at 0, +2, +5, -3 and -6 semitones.

Every file lands on the requested shift, measured by per-frame median log-spectral correlation (validated against a known-good direct rubberband render first, because two earlier measurement attempts gave wrong answers):

Asked Measured
+2 +2.00 st
+5 +5.00 st
-3 -3.00 st
-6 -5.96 st

Confirmed by ear at +5 and -6, the two extremes where rubberband artifacts would show first: quality holds, including the drums staying at their own pitch while the rest of the mix moves around them.

That was the one thing no test could settle. Nothing outstanding on the audio side.

@thcp
thcp marked this pull request as ready for review September 8, 2026 16:51
…g it

A probe that raised was silently treated as "no pitch support". That is the
right answer, but it collapses two different situations into one message: a
build genuinely without the filter and an ffmpeg that would not run at all
both reach the user as "this ffmpeg build cannot transpose", with nothing to
tell them apart.

Logs which it was at debug level. Raised by the code-quality review on #599.

Refs #592
@thcp
thcp merged commit c5df889 into main Sep 8, 2026
10 checks passed
@thcp
thcp deleted the feat/592-transpose-on-export branch September 8, 2026 17:00
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.

[Feature]: Respect transposition when exporting a mix

1 participant