From 42c114da3bea019137c916ee1ce05bbcd4a4bbe2 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Thu, 27 Aug 2026 18:18:55 +0100 Subject: [PATCH 1/3] Make the browser tests runnable on a Windows checkout 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 --- .gitattributes | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..802b3dd9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts are executed by bash, which treats a trailing CR as part of the +# command and fails on every line. On Windows `core.autocrlf=true` would check +# them out with CRLF, so the browser test harness (tests/e2e/serve.sh) is only +# runnable on Linux. Pin them to LF regardless of the developer's setting. +*.sh text eol=lf From 43fee6967bf894c60f940fbc228f96255a26f3e2 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Thu, 27 Aug 2026 18:19:50 +0100 Subject: [PATCH 2/3] Scale the lane meters in dB, so they use more than a tenth of the bar 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 --- static/js/player.js | 15 ++-- static/js/vuScale.js | 29 ++++++++ tests/js/vu-scale.test.mjs | 146 +++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 static/js/vuScale.js create mode 100644 tests/js/vu-scale.test.mjs diff --git a/static/js/player.js b/static/js/player.js index 1bd850a5..909822ea 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -29,6 +29,7 @@ import { } from "./state.js"; import { createAudioEngine, estimateDecodedBytes } from "./audioEngine.js"; import { createChunkedAudioEngine } from "./chunkedAudioEngine.js"; +import { vuLevel } from "./vuScale.js"; import { createMetronome } from "./metronome.js"; import { initBeatGrid, destroyBeatGrid } from "./beatgrid.js"; import { setBeatGridAvailable, syncBeatGridButtons } from "./beatgridUi.js"; @@ -670,7 +671,10 @@ function startAnalyserVuLoop(stems, engine, token) { return { name: stem.name, analyser, - data: new Uint8Array(analyser.fftSize), + // Float rather than byte samples. On a dB scale the 8-bit path's own + // quantisation step, one part in 128, is -42 dBFS: it would light every + // meter to roughly a third of full even over silence. + data: new Float32Array(analyser.fftSize), miniMeterEl: document.querySelector(`.stem-list [data-stem="${stem.name}"] .mini-meter`), vuEl: mixerEl.querySelector(`.lane-vu[data-stem="${stem.name}"]`), peak: 0, @@ -691,13 +695,10 @@ function startAnalyserVuLoop(stems, engine, token) { const gain = stemVuGain(m.name); let input = 0; if (playing && gain > 0) { - m.analyser.getByteTimeDomainData(m.data); + m.analyser.getFloatTimeDomainData(m.data); let sum = 0; - for (let i = 0; i < m.data.length; i++) { - const v = (m.data[i] - 128) / 128; - sum += v * v; - } - input = Math.min(1, Math.sqrt(sum / m.data.length) * 2.5); + for (let i = 0; i < m.data.length; i++) sum += m.data[i] * m.data[i]; + input = vuLevel(Math.sqrt(sum / Math.max(1, m.data.length))); } else { m.peak = 0; m.peakHold = 0; diff --git a/static/js/vuScale.js b/static/js/vuScale.js new file mode 100644 index 00000000..6bef7743 --- /dev/null +++ b/static/js/vuScale.js @@ -0,0 +1,29 @@ +// How a lane meter turns an RMS amplitude into a bar length. +// +// This was linear once, `min(1, rms * 2.5)`, which sounds reasonable and is +// not: loudness is logarithmic, and a separated stem sits around -20 to +// -30 dBFS. Measured across a real library the median lane filled 10.4% of its +// meter and the drum and "other" stems sat under 2%, so the top three quarters +// of every bar were decoration. +// +// Its own module so the mapping can be measured directly. player.js cannot be +// imported outside a browser, and a scale nothing can check is how the linear +// version survived as long as it did. + +// Lowest level a meter draws. Below this a lane reads as silent. +// +// -60 dB is the usual floor for a small console meter: quiet passages still +// register, and full scale stays reachable without being reached constantly. +export const VU_FLOOR_DB = -60; + +/** + * Map an RMS amplitude onto 0..1, in dB rather than in amplitude. + * + * Takes RMS, not peak: peak meters on separated stems are dominated by + * transients and read as a row of bars slamming to full on every beat. + */ +export function vuLevel(rms) { + if (!(rms > 0)) return 0; + const db = 20 * Math.log10(rms); + return Math.max(0, Math.min(1, (db - VU_FLOOR_DB) / -VU_FLOOR_DB)); +} diff --git a/tests/js/vu-scale.test.mjs b/tests/js/vu-scale.test.mjs new file mode 100644 index 00000000..fbfb260a --- /dev/null +++ b/tests/js/vu-scale.test.mjs @@ -0,0 +1,146 @@ +// How much of a lane meter a real stem actually lights. +// +// The bug this exists to prevent came back as a complaint, not a failure: the +// meters worked, moved with the audio and were simply unreadable, because they +// were linear in amplitude. Nothing could catch that, because nothing asserted +// what fraction of the bar normal playback uses. +// +// So this measures exactly that, against the levels separated stems really sit +// at, and keeps the old linear formula around as the control. A scale that +// leaves the median lane in the bottom tenth of its meter fails here. +// +// Run: node tests/js/vu-scale.test.mjs + +import { VU_FLOOR_DB, vuLevel } from "../../static/js/vuScale.js"; + +let pass = 0; +let fail = 0; +const check = (name, cond, detail = "") => { + if (cond) { + pass++; + console.log(`PASS ${name}`); + } else { + fail++; + console.log(`FAIL ${name}${detail ? " -- " + detail : ""}`); + } +}; + +const dbToRms = (db) => Math.pow(10, db / 20); +const pct = (rms) => vuLevel(rms) * 100; + +// The linear scale this replaced, kept as the control so the comparison below +// is a measurement rather than an assertion about the past. +const linear = (rms) => Math.min(1, rms * 2.5) * 100; + +// ── 1. The ends behave ────────────────────────────────────────────────────── +{ + check("silence reads empty", vuLevel(0) === 0); + check("a negative or bogus level reads empty", vuLevel(-1) === 0 && vuLevel(NaN) === 0); + check("full scale fills the bar", vuLevel(1) === 1); + check("anything above full scale is clamped, not overflowed", vuLevel(4) === 1); + check( + "the floor is the zero point", + Math.abs(vuLevel(dbToRms(VU_FLOOR_DB))) < 1e-9, + `${pct(dbToRms(VU_FLOOR_DB)).toFixed(3)}%`, + ); + check("below the floor stays empty", vuLevel(dbToRms(VU_FLOOR_DB - 20)) === 0); +} + +// ── 2. It is monotonic ────────────────────────────────────────────────────── +// A meter that ever goes down as the audio gets louder is worse than no meter. +{ + let ok = true; + let previous = -1; + for (let db = VU_FLOOR_DB; db <= 0; db += 0.25) { + const level = vuLevel(dbToRms(db)); + if (level < previous) ok = false; + previous = level; + } + check("louder never draws shorter", ok); +} + +// ── 3. Real stems use the bar ─────────────────────────────────────────────── +// These are median RMS levels measured from a real StemDeck library: four +// tracks, the vocals, drums, bass and "other" stems of each. They are the +// levels the meter spends its time at, so they are the levels worth testing. +{ + const MEASURED = [ + ["drums, quiet mix", 0.0068], + ["other, quiet mix", 0.0064], + ["vocals, quiet mix", 0.02], + ["bass, quiet mix", 0.021], + ["vocals, loud mix", 0.082], + ["drums, loud mix", 0.034], + ["bass, loud mix", 0.136], + ]; + + let worstNew = 100; + let worstOld = 100; + let worstCase = ""; + for (const [label, rms] of MEASURED) { + if (pct(rms) < worstNew) { + worstNew = pct(rms); + worstCase = label; + } + worstOld = Math.min(worstOld, linear(rms)); + } + + // The complaint was "less than 15% of the bar", and the old scale earned it. + check( + "the scale this replaced really did waste the bar", + worstOld < 15, + `quietest measured stem lit ${worstOld.toFixed(1)}% under the linear scale`, + ); + check( + "every measured stem now lights at least a quarter of its meter", + worstNew >= 25, + `${worstCase} lights ${worstNew.toFixed(1)}%`, + ); + + const median = MEASURED.map(([, rms]) => pct(rms)).sort((a, b) => a - b)[ + Math.floor(MEASURED.length / 2) + ]; + check( + "the median stem sits in the middle of the bar, not the bottom", + median > 40 && median < 80, + `${median.toFixed(1)}%`, + ); +} + +// ── 4. It still leaves headroom ───────────────────────────────────────────── +// A scale that fixes "always empty" by always being full is no better. Normal +// material must not sit pinned at the top, and the loudest thing a stem can be +// must still be distinguishable from merely loud. +{ + check( + "ordinary programme material leaves room above it", + pct(0.1) < 80, + `${pct(0.1).toFixed(1)}% at -20 dBFS`, + ); + check( + "a hot stem and a full-scale one are told apart", + pct(1) - pct(0.35) > 10, + `${pct(0.35).toFixed(1)}% vs ${pct(1).toFixed(1)}%`, + ); + check( + "nothing normal is clipped to full", + pct(0.5) < 100, + `${pct(0.5).toFixed(1)}% at -6 dBFS`, + ); +} + +// ── 5. Quiet is still visibly quiet ───────────────────────────────────────── +// The point is a readable meter, not a flattering one: a stem 20 dB down on +// another has to look 20 dB down. +{ + const loud = pct(0.1); + const quiet = pct(0.01); + check( + "a stem 20 dB down reads clearly lower", + loud - quiet > 25, + `${quiet.toFixed(1)}% vs ${loud.toFixed(1)}%`, + ); +} + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0); From 10abafabc258c51f07268f8267e552de611134f2 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Thu, 27 Aug 2026 18:22:33 +0100 Subject: [PATCH 3/3] Transpose the whole track, or one lane at a time, on one clock 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 --- static/css/daw.css | 133 +++++- static/index.html | 14 + static/js/audioEngine.js | 211 +++++++-- static/js/chunkedAudioEngine.js | 194 +++++++- static/js/i18n.js | 82 ++++ static/js/metronome.js | 7 +- static/js/mixer.js | 162 ++++++- static/js/pitchBus.js | 42 ++ static/js/playbackStems.js | 77 +++ static/js/player.js | 52 +- static/js/state.js | 4 + static/js/transport.js | 85 +++- static/vendor/soundtouch-processor.js | 613 +++++++++++++++++++++--- tests/e2e/transpose.spec.mjs | 331 +++++++++++++ tests/js/audio-routing.test.mjs | 334 +++++++++++++ tests/js/pitch-shift.test.mjs | 657 ++++++++++++++++++++++++++ 16 files changed, 2849 insertions(+), 149 deletions(-) create mode 100644 static/js/pitchBus.js create mode 100644 static/js/playbackStems.js create mode 100644 tests/e2e/transpose.spec.mjs create mode 100644 tests/js/audio-routing.test.mjs create mode 100644 tests/js/pitch-shift.test.mjs diff --git a/static/css/daw.css b/static/css/daw.css index 252b8f11..5ade5561 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -1687,7 +1687,7 @@ input, textarea { font-family: inherit; } .lane-header.mx-row { display: flex; align-items: center; - gap: 6px; + gap: 5px; padding: 0 10px; height: var(--lane-h, 72px); min-height: 72px; @@ -1705,12 +1705,16 @@ input, textarea { font-family: inherit; } .lane-stripe { display: none; } /* Name + VU stacked vertically */ +/* The name and its meter share a fixed column, and the fader takes whatever is + left of the row. So this width is the fader's length: every pixel taken off + here is a pixel the fader gains. 76px left the fader badly compressed once + the key stepper joined the row. */ .lane-name-vu { display: flex; flex-direction: column; justify-content: center; gap: 5px; - width: 76px; + width: 58px; flex-shrink: 0; } @@ -1777,9 +1781,11 @@ input, textarea { font-family: inherit; } } /* VU meter — sits below stem name in .lane-left-col, spans full column width */ +/* 5px read as a hairline rather than a meter: at that height the gradient had + nowhere to show and a moving level was hard to see at a glance. */ .lane-vu.mx-meter { position: relative; - height: 5px; + height: 10px; width: 100%; background: var(--bg); border: 1px solid var(--border); @@ -2383,6 +2389,48 @@ input, textarea { font-family: inherit; } .speed-btn.active:hover { background: rgba(232,200,64,0.16); color: var(--accent); } +/* Transpose stepper (#245). Sits in a .footer-seg so it inherits the frame and + hover behaviour of the speed and click controls beside it; only the readout + between the two buttons is new. */ +.pitch-value { + display: flex; align-items: center; justify-content: center; + min-width: 34px; padding: 0 6px; + border-left: 1px solid var(--border); border-right: 1px solid var(--border); + color: var(--muted); + font-family: var(--font-mono); font-size: 11px; font-weight: 600; line-height: 1; + font-variant-numeric: tabular-nums; /* so +10 and -6 do not shift the buttons */ + transition: color var(--t-fast), background var(--t-fast); +} +/* Lit only when transposed, so "this track is not in its original key" is + visible at a glance rather than needing the number to be read. */ +.pitch-value.active { + background: rgba(232,200,64,0.16); color: var(--accent); +} +.pitch-btn:disabled { + opacity: 0.35; cursor: default; +} +.pitch-btn:disabled:hover { background: transparent; color: var(--muted); } + +/* Reset. Outside the stepper's frame, because it does something to the whole + mix rather than one more step: it returns every mixer lane to the key the + recording is in, which the stepper alone cannot do once lanes have been moved + individually. */ +.pitch-reset { + display: inline-flex; align-items: center; justify-content: center; + width: 28px; height: 28px; padding: 0; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 8px; + color: var(--muted); + font-size: 15px; line-height: 1; + cursor: pointer; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); +} +.pitch-reset:hover:not(:disabled) { + background: var(--panel-3); color: var(--fg); border-color: var(--border); +} +.pitch-reset:disabled { opacity: 0.35; cursor: default; } + .metro-mult-btn.active, .metro-mult-btn.active:hover { background: rgba(74,140,255,0.16); color: #4a8cff; @@ -3366,3 +3414,82 @@ input, textarea { font-family: inherit; } width: 100%; box-sizing: border-box; min-height: 260px; white-space: pre; overflow-wrap: normal; overflow-x: auto; } + +/* ── Per-lane transpose (the K stepper) ──────────────────────────────────── + Sits in the row just left of M, with its arrows stacked above and below the + reading, and drawn from the same kit as M and S: same border, same radius, + same mono face, same width. It reads as one more control in that cluster + rather than as a different kind of thing parked next to it. + + Stacked rather than inline because the lane row only has about 300px, and at + the inline width the fader collapsed to 10px. The vertical form costs 22px + instead of 55px, and the row has the height spare. */ +.lane-key { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 2px; + width: 22px; + flex-shrink: 0; +} +/* The K is the button, and it is the same box as M and S: same size, same + border, same radius, same mono face. Boxing the arrows instead put two + look-alike buttons around a floating letter, which is the opposite of the + family it belongs to -- M and S are each one box with one character in it. */ +.lane-key-value { + width: 22px; + height: 20px; + box-sizing: border-box; + display: inline-flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid var(--border-strong); + border-radius: 5px; + color: var(--muted); + font-family: var(--font-mono); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.05em; + line-height: 1; + font-variant-numeric: tabular-nums; + transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast); +} +/* Lit while the lane is off its own key, the way S lights when soloed. */ +.lane-key.active .lane-key-value { + background: rgba(244, 183, 64, 0.18); + color: var(--accent); + border-color: rgba(244, 183, 64, 0.4); +} + +/* The arrows are the stepper around it, not two more buttons: no border, so + they stay quiet next to the three boxes they sit beside. */ +.lane-key-step { + width: 22px; + height: 11px; + padding: 0; + background: transparent; + border: none; + border-radius: 3px; + color: var(--muted); + font-size: 11px; + font-weight: 700; + font-family: var(--font-mono); + line-height: 1; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color var(--t-fast), background var(--t-fast); +} +.lane-key-step:hover:not(:disabled) { + color: var(--fg); + background: rgba(255, 255, 255, 0.07); +} +.lane-key-step:disabled { opacity: 0.3; cursor: default; } +/* Drums, and anything else that must never be resampled. */ +.lane-key.locked, +.lane-key.unsupported { opacity: 0.35; } +.lane-key.locked .lane-key-step, +.lane-key.unsupported .lane-key-step { cursor: not-allowed; } diff --git a/static/index.html b/static/index.html index a43fa776..c89b30bb 100644 --- a/static/index.html +++ b/static/index.html @@ -742,6 +742,20 @@ + + + +