From 7f3a4655958f160eb3a40933b643befdd77cb6c8 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 30 Aug 2026 17:05:51 +0100
Subject: [PATCH 1/5] Fit the waveform lanes to the panel whenever it changes
size
The lanes were sized once, when a track loaded, and never again. Everything the
panel gained after that became empty space under them rather than taller lanes.
Resizing the window was enough to show it. On a 1366x768 window the panel is
370px and each lane 72px; drag the window to 1200px tall and the panel becomes
802px while the lanes stay at 72, so 432px of it goes unused. The bigger the
display, the more of it the waveforms refused to occupy.
Three things had to change for the lanes to actually follow.
_applyLaneHeight runs again on a resize, from an observer on the wave panel. The
panel is flex: 1 inside a fixed-height column, so its height comes from its
parent and never from the lanes: writing lane heights from that callback cannot
feed it its own output.
.waves-column takes a floor from the stack height that function already
computes. Its natural height is the multitrack's, whose lane height is fixed
when the tracks are created, so without a floor the column stayed 432px however
much room it was given and the lanes distributed across the old size.
And the height maths allows for the separators. It divided the whole panel by
the lane count, but the stack is lanes plus the 2px between them, so it
overshot by exactly that. Invisible for as long as the lanes sat at their 70px
floor and overflowed anyway; it surfaces as a 7px scrollbar the moment they fit.
The streaming path keeps its load-time height deliberately. Its lanes are
WaveSurfer canvases, and setOptions({height}) only re-renders the ones that have
audio, so a lane for a stem the user did not extract keeps the old size: 93, 93,
93, 70, 70, 93 on a six-lane job with two empty, against mixer rows all at 95. A
mixer column out of step with its waveforms is worse than unused space, so the
refit is limited to the path where the SVG overlay is what you see.
Closes #497
---
static/css/daw.css | 9 +++++++--
static/js/player.js | 44 ++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 49 insertions(+), 4 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 14dbd481..138ae4e0 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -2045,10 +2045,15 @@ input, textarea { font-family: inherit; }
.daw.engine-waveforms .stem-waveform-layer {
display: flex !important;
}
-/* waves-column must size naturally now that multitrack is in flow */
+/* waves-column must size naturally now that multitrack is in flow -- but not
+ below the stack height _applyLaneHeight computed. Its natural height is the
+ multitrack's, whose lane height is fixed when the tracks are created, so on
+ its own the column stays whatever size it was at load however much room the
+ panel is given. The var is already kept in step with the panel, so a floor is
+ all that is needed. */
.daw .waves-column {
height: auto !important;
- min-height: 0 !important;
+ min-height: var(--wave-widget-track-stack-h, 0) !important;
}
.loop-region.hidden { display: none !important; }
diff --git a/static/js/player.js b/static/js/player.js
index b284861a..8c2c322c 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -976,17 +976,56 @@ export function buildStripStems() {
}
}
+// The lane count the panel is currently laid out for, and the observer that
+// re-fits it. _applyLaneHeight divides the wave panel's height between the
+// lanes, so it has to run again whenever that height changes -- which it now
+// does on demand, because collapsing a panel (#480) hands its height straight
+// to this one. Without this the lanes keep the size they were given at load and
+// the reclaimed space becomes a gap under them: 141px of it with all three
+// panels collapsed.
+let _laneCount = 0;
+let _laneFitObs = null;
+
+function _watchLaneFit(count) {
+ _laneCount = count;
+ _laneFitObs?.disconnect();
+ const panel = document.querySelector(".daw-wave-panel");
+ if (!panel) return;
+ // The panel is flex: 1 inside a fixed-height column, so its own height comes
+ // from its parent and never from the lanes. Writing lane heights from here
+ // cannot feed the observer its own output.
+ _laneFitObs = new ResizeObserver(() => {
+ // Only where the SVG overlay is the visible waveform. On the streaming path
+ // the lanes are WaveSurfer canvases sized when the tracks are created, and
+ // setOptions only re-renders the ones that have audio: a lane for a stem
+ // the user did not extract keeps its old height and the two columns drift
+ // apart. Measured on a six-lane job with two empty: 93, 93, 93, 70, 70, 93
+ // against mixer rows all at 95. Leaving that path at its load-time height
+ // costs it the reclaimed space and keeps it aligned, which is the better
+ // trade for an opt-out path.
+ if (!document.querySelector(".app")?.classList.contains("engine-waveforms")) return;
+ if (_laneCount > 0) _applyLaneHeight(_laneCount);
+ });
+ _laneFitObs.observe(panel);
+}
+
function _applyLaneHeight(count) {
const wavePanel = document.querySelector(".daw-wave-panel");
const panelH = wavePanel?.clientHeight ?? 0;
+ // The separators between lanes are part of the stack, so the height available
+ // to the lanes themselves is the panel minus them. Dividing the whole panel by
+ // the lane count overshoots by exactly that much, which stayed invisible while
+ // the lanes sat at their floor and overflowed anyway -- and became a 7px
+ // scrollbar the moment collapsing a panel let them actually fill it.
+ const gaps = Math.max(0, count - 1) * WAVEFORM_SEPARATOR_HEIGHT;
const laneH = panelH > 0 && count > 0
- ? Math.max(WAVEFORM_LANE_HEIGHT, Math.floor(panelH / count))
+ ? Math.max(WAVEFORM_LANE_HEIGHT, Math.floor((panelH - gaps) / count))
: WAVEFORM_LANE_HEIGHT;
const appEl = document.querySelector(".app");
appEl?.style.setProperty("--lane-h", `${laneH + 2}px`);
appEl?.style.setProperty(
"--wave-widget-track-stack-h",
- `${count * laneH + (count - 1) * WAVEFORM_SEPARATOR_HEIGHT}px`,
+ `${count * laneH + gaps}px`,
);
return laneH;
}
@@ -1171,6 +1210,7 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
}
const laneH = _applyLaneHeight(orderedNames.length);
+ _watchLaneFit(orderedNames.length);
const mt = Multitrack.create(
orderedNames.map((name, i) => ({
From 282ec6c77b46b618ce215c3b33185ed9eb399fce Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 30 Aug 2026 17:06:15 +0100
Subject: [PATCH 2/5] Let the user put a panel away when they are not using it
Six stems separated and two visible: on a 1366x768 laptop the panels around the
mixer took roughly two thirds of the window and the mixer got what was left
(#480). Making them smaller only moved the number, which is why that issue was
left open after the last round trimmed 116px out of them. The reporter had
already said what the actual problem was:
every one of those panels shows something useful [...] The issue is that
they are all mandatory at all times. I do not need the presence percentages
while I am setting fader levels, and I do not need the waveform while I am
reading the analysis. Right now the app has no way to say that.
So this is a way to say it. Three toggles collapse the analysis header, the
sections bar and the footer timeline, each handing its height straight to the
mixer:
default mixer 370px 5 of 6 lanes scrolls
analysis off mixer 443px 6 of 6 lanes fits
all three off mixer 573px 6 of 6 lanes fits
One click on Analysis is enough. Together they are worth 202px.
They collapse to nothing rather than to a stub, which is only possible because
the controls are somewhere else: a stub tall enough to hold its own control
costs most of what collapsing the smaller panels returns. So all three live in
one row under the composer, sharing the right edge and the exact width of the
two controls above them, from the same --composer-action-w those are sized from.
A longer translation widens all three together instead of leaving the row
ragged. The topbar does not grow: it already carried 27px of slack around a 50px
composer, and the row fits inside it.
Legible means the panel is there, struck through means it is put away. The other
way round -- highlighting the hidden ones -- inverts what a pressed toggle looks
like everywhere else and makes the default state the loud one.
A class on .app and a flag in localStorage each, the same shape as the sidebar
collapse, with no state anywhere else.
This does not decide what the studio shows on first open. Every panel is still
there by default, which is a separate question from whether the user can put one
away.
Closes #480
---
static/css/daw.css | 88 +++++++++++++++++++++++++++++++--
static/index.html | 29 +++++++++++
static/js/i18n.js | 108 +++++++++++++++++++++++++++++++++++++++++
static/js/ui-chrome.js | 46 ++++++++++++++++++
4 files changed, 267 insertions(+), 4 deletions(-)
diff --git a/static/css/daw.css b/static/css/daw.css
index 138ae4e0..37695132 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -42,7 +42,12 @@ input, textarea { font-family: inherit; }
TOPBAR
═══════════════════════════════════ */
.daw-topbar {
- height: 77px;
+ /* Was a flat 77px. The panel-toggle row under the composer needs a second
+ line, and auto height means a translation that wraps grows the bar rather
+ than being clipped. */
+ min-height: 77px;
+ padding-top: 10px;
+ padding-bottom: 10px;
flex-shrink: 0;
background: var(--bg-2);
border-bottom: 1px solid var(--border);
@@ -1687,6 +1692,80 @@ input, textarea { font-family: inherit; }
padding: 0 14px;
}
+/* ── Panel toggles (#480) ── */
+/* A second row under the composer, aligned to its right-hand end so it reads as
+ belonging to the two controls above it. Collapse to nothing, not to a stub:
+ the whole point is the height back, and a stub tall enough to hold a control
+ is most of what the smaller panels are worth. That is only possible because
+ the controls live up here, where the way back is always in the same place.
+
+ This row costs the topbar about 19px, against up to 202px it can return. */
+.daw-composer-stack {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+}
+.daw-composer-stack > .daw-composer { flex: none; }
+/* Exactly as wide as the two controls above it, and sharing their right edge:
+ the same --composer-action-w they are each sized from, so a longer
+ translation widens all three together instead of leaving this one ragged.
+ margin-left:auto does the alignment, since the stack is the composer's width. */
+.daw-panel-toggles {
+ width: calc(var(--composer-action-w) * 2);
+ margin-left: auto;
+ box-sizing: border-box;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 2px;
+ padding: 2px 7px;
+ background: var(--panel);
+ border: 1px solid var(--border-strong);
+ border-radius: 6px;
+ min-width: 0;
+}
+.daw-panel-toggles-label,
+.daw-panel-toggles-sep {
+ font-size: 8px;
+ font-weight: 600;
+ letter-spacing: 0.03em;
+ text-transform: uppercase;
+ color: var(--muted);
+ line-height: 1;
+ white-space: nowrap;
+}
+.daw-panel-toggle {
+ padding: 2px 3px;
+ border: 0;
+ border-radius: 4px;
+ background: none;
+ color: var(--fg-2);
+ font-family: inherit;
+ font-size: 8.5px; font-weight: 600; letter-spacing: 0.03em;
+ text-transform: uppercase;
+ line-height: 1;
+ cursor: pointer;
+ white-space: nowrap;
+ transition: color var(--t-fast), background var(--t-fast), opacity var(--t-fast);
+}
+.daw-panel-toggle:hover { background: var(--panel-2); }
+/* Legible means the panel is there, struck through means it is put away. The
+ other way round -- highlighting the hidden ones -- inverts what a pressed
+ toggle normally looks like, and makes the default state the loud one. */
+.daw-panel-toggle[aria-pressed="true"] { color: var(--fg-2); }
+.daw-panel-toggle[aria-pressed="false"] {
+ color: var(--muted);
+ opacity: 0.6;
+ text-decoration: line-through;
+ text-decoration-thickness: 1px;
+}
+
+.app.panel-analysis-off .daw-track-header { display: none; }
+.app.panel-sections-off .daw-section-ribbon { display: none; }
+.app.panel-timeline-off .footer-wave-region { display: none; }
+
/* ── Waveform header ── */
.daw-wave-header {
display: flex;
@@ -2048,9 +2127,10 @@ input, textarea { font-family: inherit; }
/* waves-column must size naturally now that multitrack is in flow -- but not
below the stack height _applyLaneHeight computed. Its natural height is the
multitrack's, whose lane height is fixed when the tracks are created, so on
- its own the column stays whatever size it was at load however much room the
- panel is given. The var is already kept in step with the panel, so a floor is
- all that is needed. */
+ its own the column stays the size it was at load. Collapsing a panel (#480)
+ hands this panel height it would then leave as a gap under the waveforms:
+ 141px of it with all three collapsed. The var is already kept in step with
+ the panel, so a floor is all that is needed. */
.daw .waves-column {
height: auto !important;
min-height: var(--wave-widget-track-stack-h, 0) !important;
diff --git a/static/index.html b/static/index.html
index 848675a4..7d9f58c8 100644
--- a/static/index.html
+++ b/static/index.html
@@ -29,6 +29,7 @@
+
+
+
+ Click to collapse
+
+ Analysis
+
+ -
+
+ Sections
+
+ -
+
+ Timeline
+
+
+
+
diff --git a/static/js/i18n.js b/static/js/i18n.js
index 884e6749..0598a962 100644
--- a/static/js/i18n.js
+++ b/static/js/i18n.js
@@ -332,6 +332,18 @@ const en = {
"sections.savingAria": "Saving sections",
"mixer.title": "Mixer",
+
+ "panels.analysis": "Analysis",
+
+ "panels.clickToCollapse": "Click to collapse",
+
+ "panels.timeline": "Timeline",
+
+ "panels.analysisTitle": "Show or hide the track analysis",
+
+ "panels.sectionsTitle": "Show or hide the sections bar",
+
+ "panels.timelineTitle": "Show or hide the timeline",
"mixer.hint": "Drag fader · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -885,6 +897,18 @@ const pl = {
"sections.savingAria": "Zapisywanie sekcji",
"mixer.title": "Mikser",
+
+ "panels.analysis": "Analiza",
+
+ "panels.clickToCollapse": "Kliknij, aby zwinąć",
+
+ "panels.timeline": "Oś czasu",
+
+ "panels.analysisTitle": "Pokaż lub ukryj analizę utworu",
+
+ "panels.sectionsTitle": "Pokaż lub ukryj pasek sekcji",
+
+ "panels.timelineTitle": "Pokaż lub ukryj oś czasu",
"mixer.hint": "Przeciągnij suwak · M/S",
"stemsPanel.ariaLabel": "Ścieżki",
@@ -1428,6 +1452,18 @@ const ja = {
"sections.savingAria": "セクションを保存中",
"mixer.title": "ミキサー",
+
+ "panels.analysis": "解析",
+
+ "panels.clickToCollapse": "クリックで折りたたみ",
+
+ "panels.timeline": "タイムライン",
+
+ "panels.analysisTitle": "トラック解析の表示を切り替えます",
+
+ "panels.sectionsTitle": "セクションバーの表示を切り替えます",
+
+ "panels.timelineTitle": "タイムラインの表示を切り替えます",
"mixer.hint": "フェーダーをドラッグ · M/S",
"stemsPanel.ariaLabel": "パート",
@@ -1946,6 +1982,18 @@ const zhHans = {
"sections.savingAria": "正在保存段落",
"mixer.title": "混音台",
+
+ "panels.analysis": "分析",
+
+ "panels.clickToCollapse": "点击可折叠",
+
+ "panels.timeline": "时间轴",
+
+ "panels.analysisTitle": "显示或隐藏音轨分析",
+
+ "panels.sectionsTitle": "显示或隐藏段落栏",
+
+ "panels.timelineTitle": "显示或隐藏时间轴",
"mixer.hint": "拖动推子 · M/S",
"stemsPanel.ariaLabel": "音轨",
@@ -2464,6 +2512,18 @@ const de = {
"sections.savingAria": "Abschnitte werden gespeichert",
"mixer.title": "Mixer",
+
+ "panels.analysis": "Analyse",
+
+ "panels.clickToCollapse": "Zum Einklappen klicken",
+
+ "panels.timeline": "Zeitleiste",
+
+ "panels.analysisTitle": "Track-Analyse ein- oder ausblenden",
+
+ "panels.sectionsTitle": "Abschnittsleiste ein- oder ausblenden",
+
+ "panels.timelineTitle": "Zeitleiste ein- oder ausblenden",
"mixer.hint": "Fader ziehen · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -2993,6 +3053,18 @@ const pt = {
"sections.savingAria": "Salvando seções",
"mixer.title": "Mixer",
+
+ "panels.analysis": "Análise",
+
+ "panels.clickToCollapse": "Clique para recolher",
+
+ "panels.timeline": "Linha do tempo",
+
+ "panels.analysisTitle": "Mostrar ou ocultar a análise da faixa",
+
+ "panels.sectionsTitle": "Mostrar ou ocultar a barra de seções",
+
+ "panels.timelineTitle": "Mostrar ou ocultar a linha do tempo",
"mixer.hint": "Arraste o fader · M/S",
"stemsPanel.ariaLabel": "Stems",
@@ -3524,6 +3596,18 @@ const id = {
"sections.savingAria": "Menyimpan bagian",
"mixer.title": "Mixer",
+
+ "panels.analysis": "Analisis",
+
+ "panels.clickToCollapse": "Klik untuk menciutkan",
+
+ "panels.timeline": "Lini masa",
+
+ "panels.analysisTitle": "Tampilkan atau sembunyikan analisis trek",
+
+ "panels.sectionsTitle": "Tampilkan atau sembunyikan bilah bagian",
+
+ "panels.timelineTitle": "Tampilkan atau sembunyikan lini masa",
"mixer.hint": "Seret fader · M/S",
"stemsPanel.ariaLabel": "Stem",
@@ -4042,6 +4126,18 @@ const fr = {
"sections.savingAria": "Enregistrement des sections",
"mixer.title": "Mixage",
+
+ "panels.analysis": "Analyse",
+
+ "panels.clickToCollapse": "Cliquez pour réduire",
+
+ "panels.timeline": "Chronologie",
+
+ "panels.analysisTitle": "Afficher ou masquer l'analyse du morceau",
+
+ "panels.sectionsTitle": "Afficher ou masquer la barre de sections",
+
+ "panels.timelineTitle": "Afficher ou masquer la chronologie",
"mixer.hint": "Glissez le fader · M/S",
"stemsPanel.ariaLabel": "Pistes",
@@ -4672,6 +4768,18 @@ const es = {
"sections.savingAria": "Guardando secciones",
"mixer.title": "Mezclador",
+
+ "panels.analysis": "Análisis",
+
+ "panels.clickToCollapse": "Haz clic para contraer",
+
+ "panels.timeline": "Línea de tiempo",
+
+ "panels.analysisTitle": "Mostrar u ocultar el análisis de la pista",
+
+ "panels.sectionsTitle": "Mostrar u ocultar la barra de secciones",
+
+ "panels.timelineTitle": "Mostrar u ocultar la línea de tiempo",
"mixer.hint": "Arrastra el fader · M/S",
"stemsPanel.ariaLabel": "Stems",
diff --git a/static/js/ui-chrome.js b/static/js/ui-chrome.js
index faa1f26d..bfc3b671 100644
--- a/static/js/ui-chrome.js
+++ b/static/js/ui-chrome.js
@@ -29,3 +29,49 @@ document.addEventListener("click", (e) => {
setNotifOpen(false);
}
});
+
+// Panel toggles (#480). Each one hides a region that is useful but not useful
+// all the time, and hands its height to the mixer, which is the panel that
+// actually runs short: at 1366x768 with six stems the lane stack needs 432px
+// and gets 370. Analysis is worth 72px and the timeline 93px, so either alone
+// closes that gap.
+//
+// Same shape as the sidebar collapse: a class on .app, a flag in localStorage,
+// no state anywhere else. The lanes re-fit on their own because the wave panel
+// is already watched by a ResizeObserver.
+const PANEL_STORE_PREFIX = "stemdeck.panel.";
+
+function wirePanelToggles() {
+ const app = document.querySelector(".app");
+ const toggles = [...document.querySelectorAll(".daw-panel-toggle[data-panel]")];
+ if (!app || !toggles.length) return;
+
+ const apply = (name, shown) => {
+ app.classList.toggle(`panel-${name}-off`, !shown);
+ for (const btn of toggles) {
+ if (btn.dataset.panel === name) btn.setAttribute("aria-pressed", String(shown));
+ }
+ };
+
+ for (const btn of toggles) {
+ const name = btn.dataset.panel;
+ let shown = true;
+ try {
+ shown = localStorage.getItem(PANEL_STORE_PREFIX + name) !== "0";
+ } catch (e) {
+ console.warn("[panels] could not read stored state:", e);
+ }
+ apply(name, shown);
+ btn.addEventListener("click", () => {
+ const next = app.classList.contains(`panel-${name}-off`);
+ apply(name, next);
+ try {
+ localStorage.setItem(PANEL_STORE_PREFIX + name, next ? "1" : "0");
+ } catch (e) {
+ console.warn("[panels] could not persist state:", e);
+ }
+ });
+ }
+}
+
+wirePanelToggles();
From eeea16f94ef59af4248117b5e3924355ece82dc6 Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 30 Aug 2026 17:18:54 +0100
Subject: [PATCH 3/5] Keep a stem name on the same line as its own waveform
The mixer strip and the waveform lanes are two columns of the same rows, and
nothing made them agree. Resize the window and they walked apart, worst at the
bottom: at 1600x768 after a resize from 900, mixer rows sat 72px apart, waveform
rows 83px apart, and the last pair was 55px out of line. By the last stem the
label was most of a row away from the waveform it names.
They were built from different numbers. The mixer stack is count * --lane-h,
recomputed from the panel height. The waveform column took its height from the
multitrack, whose lane height is fixed when its tracks are created, so after a
resize the column was still the size it was at load and its rows distributed
across that. The computed stack said 432px; the column was 498.
The column is now that same count * --lane-h. The two agree by construction
rather than by coincidence, and the multitrack is no longer the source of truth
for a height it cannot change after the fact.
Two smaller errors in the same place, both of which only ever pushed the same
way. Every mixer row draws a 2px bottom border while the stack counted
count - 1 separators, so the row is now the shared unit and both columns get the
identical total. And the stem name sat 7px above the row's centre line, because
the name and its meter are stacked and centred as a pair while the waveform
beside them is centred on the line itself; the name centres now and the meter
hangs below it.
Zero row drift across twelve window heights from 720 to 1300, including the
range where the lanes overflow rather than fit, which is where the 55px came
from. Name drift is 1px of rounding. All three new tests were checked against
the unfixed build, which fails them.
Closes #498
---
static/css/daw.css | 34 +++++++---
static/js/player.js | 31 ++++-----
tests/e2e/lane-alignment.spec.mjs | 101 ++++++++++++++++++++++++++++++
3 files changed, 141 insertions(+), 25 deletions(-)
create mode 100644 tests/e2e/lane-alignment.spec.mjs
diff --git a/static/css/daw.css b/static/css/daw.css
index 37695132..b25c9210 100644
--- a/static/css/daw.css
+++ b/static/css/daw.css
@@ -1884,9 +1884,17 @@ input, textarea { font-family: inherit; }
display: flex;
flex-direction: column;
justify-content: center;
- gap: 5px;
+ gap: var(--lane-name-vu-gap, 5px);
width: 58px;
flex-shrink: 0;
+ /* Centres the NAME on the row, not the name-and-meter pair. The row centres
+ its children, so a stacked pair puts the name above the centre line by half
+ the meter plus the gap -- and the waveform beside it is centred on that
+ line, so the label reads as sitting too high. A top margin is centred with
+ the item, so this offsets the pair by exactly half of it and the name comes
+ level with its own waveform. Cheap and reversible: the meter simply moves
+ down with it. */
+ margin-top: calc(var(--lane-vu-h, 10px) + var(--lane-name-vu-gap, 5px));
}
/* Stem icon — hidden per user preference */
@@ -1954,9 +1962,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. */
+/* Height shared with .lane-name-vu's centring offset, so the two cannot drift. */
+:root { --lane-vu-h: 10px; }
.lane-vu.mx-meter {
position: relative;
- height: 10px;
+ height: var(--lane-vu-h, 10px);
width: 100%;
background: var(--bg);
border: 1px solid var(--border);
@@ -2124,15 +2134,19 @@ input, textarea { font-family: inherit; }
.daw.engine-waveforms .stem-waveform-layer {
display: flex !important;
}
-/* waves-column must size naturally now that multitrack is in flow -- but not
- below the stack height _applyLaneHeight computed. Its natural height is the
- multitrack's, whose lane height is fixed when the tracks are created, so on
- its own the column stays the size it was at load. Collapsing a panel (#480)
- hands this panel height it would then leave as a gap under the waveforms:
- 141px of it with all three collapsed. The var is already kept in step with
- the panel, so a floor is all that is needed. */
+/* The column is exactly the stack _applyLaneHeight computed, never whatever the
+ multitrack happens to be. Letting the multitrack size it looks reasonable and
+ is where the misalignment came from: its lane height is fixed when the tracks
+ are created, so after a resize the column keeps the old size while the mixer
+ rows beside it follow the new one, and the two walk apart down the stack.
+ Measured at 1600x768 after a resize from 900: the column stayed 498px against
+ a 432px stack, so waveform rows sat 83px apart against the mixer's 72 and the
+ last pair was 55px out of line.
+
+ The mixer stack is count * --lane-h and this is count * --lane-h, so they
+ agree by construction rather than by coincidence. */
.daw .waves-column {
- height: auto !important;
+ height: var(--wave-widget-track-stack-h, auto) !important;
min-height: var(--wave-widget-track-stack-h, 0) !important;
}
diff --git a/static/js/player.js b/static/js/player.js
index 8c2c322c..b2e61aa7 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -1012,22 +1012,23 @@ function _watchLaneFit(count) {
function _applyLaneHeight(count) {
const wavePanel = document.querySelector(".daw-wave-panel");
const panelH = wavePanel?.clientHeight ?? 0;
- // The separators between lanes are part of the stack, so the height available
- // to the lanes themselves is the panel minus them. Dividing the whole panel by
- // the lane count overshoots by exactly that much, which stayed invisible while
- // the lanes sat at their floor and overflowed anyway -- and became a 7px
- // scrollbar the moment collapsing a panel let them actually fill it.
- const gaps = Math.max(0, count - 1) * WAVEFORM_SEPARATOR_HEIGHT;
- const laneH = panelH > 0 && count > 0
- ? Math.max(WAVEFORM_LANE_HEIGHT, Math.floor((panelH - gaps) / count))
- : WAVEFORM_LANE_HEIGHT;
+ // One row is a lane plus its separator, and BOTH columns have to agree on
+ // that number or they drift apart down the stack. They did: every mixer row
+ // draws its own 2px bottom border, so the mixer stack was count * (lane + 2),
+ // while the waveform column was told count * lane + (count - 1) * 2 -- one
+ // separator short. Two pixels over six lanes, which is why a stem name and
+ // its waveform ended up on different lines by the bottom of the mixer.
+ //
+ // So the row is the unit. Divide the panel by the row count, and give the
+ // mixer and the waveform column exactly the same total.
+ const rowH = panelH > 0 && count > 0
+ ? Math.max(WAVEFORM_LANE_HEIGHT + WAVEFORM_SEPARATOR_HEIGHT, Math.floor(panelH / count))
+ : WAVEFORM_LANE_HEIGHT + WAVEFORM_SEPARATOR_HEIGHT;
const appEl = document.querySelector(".app");
- appEl?.style.setProperty("--lane-h", `${laneH + 2}px`);
- appEl?.style.setProperty(
- "--wave-widget-track-stack-h",
- `${count * laneH + gaps}px`,
- );
- return laneH;
+ appEl?.style.setProperty("--lane-h", `${rowH}px`);
+ appEl?.style.setProperty("--wave-widget-track-stack-h", `${count * rowH}px`);
+ // The drawable height inside a row, which is what the multitrack is given.
+ return rowH - WAVEFORM_SEPARATOR_HEIGHT;
}
export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, title = "", peaksPromise = null, hasVideo = false, videoStatus = null) {
diff --git a/tests/e2e/lane-alignment.spec.mjs b/tests/e2e/lane-alignment.spec.mjs
new file mode 100644
index 00000000..a37c16fb
--- /dev/null
+++ b/tests/e2e/lane-alignment.spec.mjs
@@ -0,0 +1,101 @@
+// The mixer strip and the waveform lanes are two columns of the same rows, and
+// a stem's name has to sit on the same line as its own waveform at any window
+// size. Nothing enforced that: the mixer stack is built from --lane-h while the
+// waveform column took its height from the multitrack, whose lane height is
+// fixed when the tracks are created. After a resize the two disagreed and the
+// error accumulated down the stack, so the bottom lane was the worst.
+//
+// These tests are all measurements of that disagreement.
+
+import { test, expect } from "@playwright/test";
+import { openStudio, waitForClickTrack } from "./helpers.mjs";
+
+// Enough sizes to catch a rounding rule that only works for round numbers, and
+// to cross the point where the lanes stop fitting and start overflowing (around
+// 853 here) -- that transition is where the drift used to appear.
+const HEIGHTS = [720, 768, 800, 853, 900, 947, 1000, 1013, 1080, 1150, 1237, 1300];
+
+async function geometry(page) {
+ return page.evaluate(() => {
+ const mid = (el) => {
+ const b = el.getBoundingClientRect();
+ return { top: Math.round(b.top), mid: Math.round(b.top + b.height / 2) };
+ };
+ const mixRows = [...document.querySelectorAll(".mixer-column .lane-header:not(.hidden)")];
+ const waveRows = [...document.querySelectorAll(".stem-waveform-row:not(.hidden)")];
+ return {
+ mixTops: mixRows.map((e) => mid(e).top),
+ waveTops: waveRows.map((e) => mid(e).top),
+ // The label the user actually reads, against the middle of the waveform
+ // it belongs to.
+ nameMids: mixRows.map((e) => {
+ const n = e.querySelector(".mx-name");
+ return n ? mid(n).mid : null;
+ }),
+ waveMids: waveRows.map((e) => mid(e).mid),
+ column: Math.round(document.querySelector(".waves-column").getBoundingClientRect().height),
+ stackVar: getComputedStyle(document.querySelector(".app"))
+ .getPropertyValue("--wave-widget-track-stack-h").trim(),
+ };
+ });
+}
+
+test.describe("lane alignment", () => {
+ test("every stem row lines up with its waveform at any window height", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page);
+
+ const offenders = [];
+ for (const height of HEIGHTS) {
+ await page.setViewportSize({ width: 1600, height });
+ await page.waitForTimeout(450);
+ const g = await geometry(page);
+
+ const rowDrift = g.mixTops.map((t, i) => Math.abs((g.waveTops[i] ?? t) - t));
+ // One pixel of rounding is fine; a row is 72px or more, so anything the
+ // eye can see is far larger.
+ const worstRow = Math.max(...rowDrift);
+ // The name and its waveform share a centre line. Two pixels covers the
+ // rounding in both measurements.
+ const nameDrift = g.nameMids.map((m, i) => (m === null ? 0 : Math.abs((g.waveMids[i] ?? m) - m)));
+ const worstName = Math.max(...nameDrift);
+
+ if (worstRow > 1 || worstName > 2) {
+ offenders.push({ height, worstRow, worstName, rowDrift, nameDrift });
+ }
+ }
+ expect(offenders).toEqual([]);
+ });
+
+ test("the waveform column is the stack the mixer was built from", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 900 });
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page);
+
+ for (const height of [768, 900, 1150]) {
+ await page.setViewportSize({ width: 1600, height });
+ await page.waitForTimeout(450);
+ const g = await geometry(page);
+ // Taking its height from the multitrack instead is what let the two
+ // columns disagree: at 768 after a resize from 900 the column stayed at
+ // 498px against a 432px stack.
+ expect(`${g.column}px`).toBe(g.stackVar);
+ }
+ });
+
+ test("a stem name sits on its waveform centre line, not above it", async ({ page }) => {
+ await page.setViewportSize({ width: 1600, height: 1013 });
+ await openStudio(page, { tauri: true });
+ await waitForClickTrack(page);
+ const g = await geometry(page);
+
+ // The name and its meter are stacked and centred as a pair, which put the
+ // name half a meter above the row's centre while the waveform beside it was
+ // centred on that line. Invisible at 72px rows, obvious once a lane grows.
+ for (const [i, nameMid] of g.nameMids.entries()) {
+ if (nameMid === null) continue;
+ expect(Math.abs(nameMid - g.waveMids[i])).toBeLessThanOrEqual(2);
+ }
+ });
+});
From 5b20b7ff486f54689e57fe115cdece258aade14a Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 30 Aug 2026 17:28:45 +0100
Subject: [PATCH 4/5] Make Song structure a choice about the next import, not a
saved preference
Switched on once, it stayed on: for that import, for every import after it, and
for every session until someone remembered to switch it off. It is stored
server-side, so it survived a restart and applied to anyone else pointed at the
same StemDeck. The pass it enables costs minutes of CPU per import, and a
setting that quietly keeps spending that long after the song it was turned on
for is a cost nobody chose.
It is off at startup now, and off again as soon as the studio loads a song. The
button and the setting move together, because the setting is what the runner
reads: clearing only the button would leave the next import still paying for a
pass nobody asked for.
That alone would have introduced a worse bug than it fixed. The runner read the
setting when it reached the sections stage, which is the last thing the pipeline
does, and there was a comment explaining that this was deliberate: it let a
change apply to the next job without a restart. Correct for a preference, wrong
for a per-import choice. With the toggle clearing itself on the next song, a
user who switched it on, started an import and browsed to another track while
waiting would have lost the pass they asked and waited for, silently: job done,
no sections, no explanation.
So the decision moves to the moment the user makes it. Job carries the flag,
captured at creation from the setting, at all three places a job is created --
URL submit, file upload, and playlist import, where one read covers the batch so
every job in it agrees. The stage reads the job.
The runner tests set the flag on the job rather than patching a setting the
runner no longer reads. Two new ones cover the thing that is easy to regress:
the API captures the setting at submit, and the stage honours the job's flag
even when the live setting says otherwise.
Closes #499
---
app/api/jobs.py | 13 +++++++--
app/api/playlist.py | 6 +++-
app/core/models.py | 6 ++++
app/pipeline/runner.py | 13 +++++----
static/js/main.js | 32 ++++++++++++++++++++-
static/js/player.js | 6 +++-
static/js/state.js | 6 ++++
tests/test_jobs_api.py | 22 +++++++++++++++
tests/test_pipeline_runner.py | 52 ++++++++++++++++++++++++++++-------
9 files changed, 136 insertions(+), 20 deletions(-)
diff --git a/app/api/jobs.py b/app/api/jobs.py
index 33f508f8..f2be8f41 100644
--- a/app/api/jobs.py
+++ b/app/api/jobs.py
@@ -33,7 +33,7 @@
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
from app.core.registry import remove as registry_remove
-from app.core.settings import get_max_duration_sec
+from app.core.settings import get_auto_sections, get_max_duration_sec
from app.core.stems_location import is_relocating
from app.pipeline import jobqueue
from app.pipeline.collect import merge_stem_peaks, presence_for_split
@@ -190,7 +190,15 @@ async def _create_youtube_job(request: Request) -> dict[str, str]:
if not selected:
selected = list(STEM_NAMES)
- job = Job(id=uuid.uuid4().hex[:12], selected_stems=selected, source_url=url)
+ job = Job(
+ id=uuid.uuid4().hex[:12],
+ selected_stems=selected,
+ source_url=url,
+ # Captured now, not when the sections stage is reached: that is the
+ # last thing the pipeline does, and the toggle clears itself as soon
+ # as the user opens another song.
+ auto_sections=get_auto_sections(),
+ )
if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS):
raise HTTPException(status_code=503, detail=_URL_QUEUE_FULL_DETAIL)
jobqueue.enqueue(job.id)
@@ -282,6 +290,7 @@ async def _create_local_job(request: Request) -> dict[str, str]:
title=title,
duration_sec=duration,
source_url=local_source_url,
+ auto_sections=get_auto_sections(),
)
if not registry_register_if_capacity(job, MAX_PENDING_UPLOAD_JOBS):
shutil.rmtree(job_dir, ignore_errors=True)
diff --git a/app/api/playlist.py b/app/api/playlist.py
index bd0ac63b..6a2fd7cd 100644
--- a/app/api/playlist.py
+++ b/app/api/playlist.py
@@ -23,7 +23,7 @@
from app.core.registry import pending_count as registry_pending_count
from app.core.registry import persist as registry_persist
from app.core.registry import register_if_capacity as registry_register_if_capacity
-from app.core.settings import get_max_duration_sec, get_playlist_max_items
+from app.core.settings import get_auto_sections, get_max_duration_sec, get_playlist_max_items
from app.core.stems_location import is_relocating
from app.pipeline import jobqueue
from app.pipeline.download import InvalidPlaylistURL, expand_playlist
@@ -139,6 +139,9 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
if _capacity_left() == 0:
raise HTTPException(status_code=503, detail="Queue is full - wait or cancel a job")
+ # Read once for the batch, so every job in one playlist import agrees, and
+ # captured now rather than when each job reaches its sections stage.
+ auto_sections = get_auto_sections()
created: list[dict[str, Any]] = []
for item in items:
job = Job(
@@ -149,6 +152,7 @@ async def create_playlist_jobs(request: Request) -> dict[str, Any]:
# immediately, instead of a URL until each download starts.
title=item["title"] or None,
thumbnail=item.get("thumbnail"),
+ auto_sections=auto_sections,
)
if not registry_register_if_capacity(job, MAX_PENDING_URL_JOBS):
break # queue filled up mid-loop; report what did land
diff --git a/app/core/models.py b/app/core/models.py
index 593634cb..e6a4d73e 100644
--- a/app/core/models.py
+++ b/app/core/models.py
@@ -49,6 +49,12 @@ class Job:
tempo_stability: int | None = None # 0-100, beat interval consistency
stem_presence: dict[str, int] | None = None # per-stem RMS 0-100
sections: list[dict] | None = None # [{id, name, kind?, start, end, color}]
+ # Whether this job should run the automatic song-structure pass, captured
+ # from the setting when the job is created rather than read when the stage
+ # is reached. The stage runs at the very end of the pipeline, minutes after
+ # submit, and the toggle is a per-import choice that clears itself: reading
+ # it late let a job lose a pass the user had asked and waited for.
+ auto_sections: bool = False
sections_source: Literal["automatic", "manual"] | None = None
tags: list[str] | None = None # YouTube tags + categories, lowercased, max 8
stems: list[dict[str, str]] = field(default_factory=list)
diff --git a/app/pipeline/runner.py b/app/pipeline/runner.py
index 002f1e3e..07603ab4 100644
--- a/app/pipeline/runner.py
+++ b/app/pipeline/runner.py
@@ -14,7 +14,6 @@
from app.core.models import Job, JobCancelled, _set
from app.core.redact import redact
from app.core.registry import persist as persist_registry
-from app.core.settings import get_auto_sections
from app.pipeline.analyze import analyze
from app.pipeline.beatgrid import compute_beat_grid
from app.pipeline.collect import (
@@ -219,11 +218,15 @@ def _run_common(job: Job, source: Path, job_dir: Path) -> None:
# Automatic sections are suggestions and never make an otherwise usable
# separation fail. Cancellation remains authoritative so a user can still
- # stop a long CPU inference pass immediately. The setting is read here, per
- # job, rather than captured at import, so turning the toggle off applies to
- # the next job without a restart.
+ # stop a long CPU inference pass immediately.
+ #
+ # The flag comes from the job, captured when it was created, not from the
+ # setting as it stands now. This stage is the last thing the pipeline does,
+ # so "now" can be many minutes after the user asked -- and the toggle clears
+ # itself on the next song they open. Reading it here let an import silently
+ # lose a pass its owner had already waited for.
_check_cancel(job)
- if get_auto_sections() and job.sections is None and job.duration_sec and job.duration_sec > 0:
+ if job.auto_sections and job.sections is None and job.duration_sec and job.duration_sec > 0:
_set(job, stage="Analyzing song structure...")
try:
sections = detect_sections(job, stems_dir, job.duration_sec)
diff --git a/static/js/main.js b/static/js/main.js
index 338b2bc6..4009ce6c 100644
--- a/static/js/main.js
+++ b/static/js/main.js
@@ -2,6 +2,7 @@ import {
playBtn, loopBtn, multitrack, totalDuration, loopEnabled, loopStart, loopEnd,
setLoopStart, setLoopEnd, selectedStems, saveSelectedStems, stemSelectionReady,
currentJobId, vocalSplitMode, vocalSplitModeReady, setVocalSplitMode,
+ setAutoSectionsResetFn,
} from "./state.js";
import { STEM_NAMES, syncStemNamesFromAPI } from "./constants.js";
import { renderEmptyShell, buildStripStems, downloadCurrentMix, downloadCurrentVideo, downloadAllStemsZip, downloadRegionMix, drawFooterPlaceholder } from "./player.js";
@@ -100,9 +101,38 @@ function wireAutoSectionsToggle() {
}
});
+ // Off is the only state this starts in. It is a choice about the next import,
+ // not a preference: an inference pass costs minutes of CPU, so it should
+ // always be something the user asked for just now rather than something a
+ // previous session, or the previous song, left switched on.
+ //
+ // The server is what the runner actually reads, so turning the button off is
+ // not enough on its own -- the setting has to go with it, or the next import
+ // would still pay for a pass nobody asked for.
+ const forceOff = async () => {
+ if (btn.getAttribute("aria-pressed") !== "true") return;
+ paint(false);
+ try {
+ await fetch("/api/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ auto_sections: false }),
+ });
+ } catch (e) {
+ console.warn("[structure] could not clear the setting:", e);
+ }
+ };
+ setAutoSectionsResetFn(forceOff);
+
+ // Read it once at startup only to find out whether it needs clearing: a
+ // setting left on by an earlier session must not survive into this one.
fetch("/api/settings", { cache: "no-store" })
.then((r) => (r.ok ? r.json() : null))
- .then((d) => d && paint(d.auto_sections))
+ .then((d) => {
+ if (!d) return;
+ paint(d.auto_sections);
+ return forceOff();
+ })
.catch((e) => console.warn("[structure] could not read the setting:", e));
}
diff --git a/static/js/player.js b/static/js/player.js
index b2e61aa7..0a8a3503 100644
--- a/static/js/player.js
+++ b/static/js/player.js
@@ -22,7 +22,7 @@ import {
setLoopEnabled, setLoopStart, setLoopEnd, setMasterVolume,
waveScroll, selectedStems,
footerTitle, footerMeta, footerThumb,
- setFooterWaveDrawFn, setOverviewRerenderFn,
+ setFooterWaveDrawFn, setOverviewRerenderFn, autoSectionsResetFn,
metronome, setMetronome, metronomeEnabled, metronomeVolume, metronomeBeatsPerBar,
exportClickEl, exportClickWrap, exportCountInEl, exportCountInWrap,
setMetronomeHasBars,
@@ -1070,6 +1070,10 @@ export function wireUpAudio(jobId, stems, duration, thumbnail, mixUrl = null, ti
setLoopEnd(0);
loopBtn.classList.remove("active");
loopRegionEl.classList.add("hidden");
+ // Loading a song is the end of whatever the structure toggle was asked to do
+ // for the last one. It costs minutes of CPU per import, so it goes back to
+ // off rather than quietly staying on for the next track.
+ autoSectionsResetFn?.();
// After the loop is cleared, never before: resetWaveZoom redraws the loop
// region, so running it first would paint the previous track's loop against
// this track's duration for a frame. A new track also starts fitted -- the
diff --git a/static/js/state.js b/static/js/state.js
index 2f3636c7..66e86913 100644
--- a/static/js/state.js
+++ b/static/js/state.js
@@ -217,6 +217,12 @@ export function setFooterWaveDrawFn(fn) { footerWaveDrawFn = fn; }
export let overviewRerenderFn = null;
export function setOverviewRerenderFn(fn) { overviewRerenderFn = fn; }
+// Puts the song-structure toggle back to off. Registered by main.js, which owns
+// the button, and called by player.js when the studio loads a track. A callback
+// rather than an import because main.js is the entry point: nothing imports it.
+export let autoSectionsResetFn = null;
+export function setAutoSectionsResetFn(fn) { autoSectionsResetFn = fn; }
+
// Click track. `metronome` is the scheduler bound to the current engine (null
// when the job has no beat grid or the streaming path is in use); the enabled
// flag and volume survive track switches so the user's choice sticks.
diff --git a/tests/test_jobs_api.py b/tests/test_jobs_api.py
index 548bc324..1561044c 100644
--- a/tests/test_jobs_api.py
+++ b/tests/test_jobs_api.py
@@ -758,3 +758,25 @@ def fake_split(job_arg, stems_dir_arg):
assert job.status == "done" # the job itself is untouched
assert job.vocal_split == "error"
assert (stems_dir / "vocal_split_error.txt").is_file()
+
+
+def test_submit_captures_the_song_structure_setting_on_the_job(client, monkeypatch):
+ """The job carries the answer, so a later change cannot reach it.
+
+ The UI clears this toggle as soon as the user opens another song, and the
+ stage that reads it runs at the very end of the pipeline. If the flag were
+ not captured here, an import could lose a pass that was switched on when it
+ was submitted.
+ """
+ from app.core import registry
+
+ monkeypatch.setattr("app.api.jobs.get_auto_sections", lambda: True)
+ r = client.post("/api/jobs", json={"url": "https://youtu.be/dQw4w9WgXcQ"})
+ assert r.status_code == 200
+ assert registry.get(r.json()["job_id"]).auto_sections is True
+
+ # And the off case, so this is not just asserting a default.
+ monkeypatch.setattr("app.api.jobs.get_auto_sections", lambda: False)
+ r = client.post("/api/jobs", json={"url": "https://youtu.be/oHg5SJYRHA0"})
+ assert r.status_code == 200
+ assert registry.get(r.json()["job_id"]).auto_sections is False
diff --git a/tests/test_pipeline_runner.py b/tests/test_pipeline_runner.py
index 525709ed..013d06cc 100644
--- a/tests/test_pipeline_runner.py
+++ b/tests/test_pipeline_runner.py
@@ -451,7 +451,7 @@ def _common_stage_patches(job_dir: Path, sections):
def test_common_pipeline_stores_automatic_section_suggestions(tmp_path: Path):
- job = Job(id="abcdefabc111", duration_sec=60.0)
+ job = Job(id="abcdefabc111", duration_sec=60.0, auto_sections=True)
job_dir = tmp_path / job.id
stems_dir = job_dir / "stems"
stems_dir.mkdir(parents=True)
@@ -477,7 +477,6 @@ def test_common_pipeline_stores_automatic_section_suggestions(tmp_path: Path):
patches[6],
patches[7],
patches[8] as detect,
- patch("app.pipeline.runner.get_auto_sections", return_value=True),
):
_run_common(job, job_dir / "source.wav", job_dir)
@@ -490,10 +489,11 @@ def test_common_pipeline_stores_automatic_section_suggestions(tmp_path: Path):
def test_common_pipeline_skips_sections_when_the_user_turned_them_off(tmp_path: Path):
"""The toggle must stop the inference pass, not just hide its result.
- The setting is read per job rather than captured at import, so switching it
- off applies to the next import without restarting the server.
+ The flag is captured on the job when it is created, not read when this
+ stage is reached: the stage is the last thing the pipeline does, and the
+ toggle clears itself as soon as the user opens another song.
"""
- job = Job(id="abcdefabc116", duration_sec=60.0)
+ job = Job(id="abcdefabc116", duration_sec=60.0, auto_sections=False)
job_dir = tmp_path / job.id
(job_dir / "stems").mkdir(parents=True)
@@ -508,7 +508,6 @@ def test_common_pipeline_skips_sections_when_the_user_turned_them_off(tmp_path:
patches[6],
patches[7],
patches[8] as detect,
- patch("app.pipeline.runner.get_auto_sections", return_value=False),
):
_run_common(job, job_dir / "source.wav", job_dir)
@@ -518,7 +517,7 @@ def test_common_pipeline_skips_sections_when_the_user_turned_them_off(tmp_path:
def test_common_pipeline_keeps_section_failure_nonfatal(tmp_path: Path, caplog):
- job = Job(id="abcdefabc112", duration_sec=60.0)
+ job = Job(id="abcdefabc112", duration_sec=60.0, auto_sections=True)
job_dir = tmp_path / job.id
(job_dir / "stems").mkdir(parents=True)
patches = _common_stage_patches(job_dir, RuntimeError("model unavailable"))
@@ -533,7 +532,6 @@ def test_common_pipeline_keeps_section_failure_nonfatal(tmp_path: Path, caplog):
patches[6],
patches[7],
patches[8],
- patch("app.pipeline.runner.get_auto_sections", return_value=True),
caplog.at_level("ERROR", logger="stemdeck.pipeline"),
):
_run_common(job, job_dir / "source.wav", job_dir)
@@ -543,7 +541,7 @@ def test_common_pipeline_keeps_section_failure_nonfatal(tmp_path: Path, caplog):
def test_common_pipeline_preserves_section_cancellation(tmp_path: Path):
- job = Job(id="abcdefabc113", duration_sec=60.0)
+ job = Job(id="abcdefabc113", duration_sec=60.0, auto_sections=True)
job_dir = tmp_path / job.id
(job_dir / "stems").mkdir(parents=True)
patches = _common_stage_patches(job_dir, JobCancelled())
@@ -558,7 +556,6 @@ def test_common_pipeline_preserves_section_cancellation(tmp_path: Path):
patches[6],
patches[7],
patches[8],
- patch("app.pipeline.runner.get_auto_sections", return_value=True),
pytest.raises(JobCancelled),
):
_run_common(job, job_dir / "source.wav", job_dir)
@@ -610,3 +607,38 @@ def test_metadata_includes_sections_and_source(tmp_path: Path):
meta = _json.loads((job_dir / "metadata.json").read_text(encoding="utf-8"))
assert meta["sections"] == [{"id": "auto-001"}]
assert meta["sections_source"] == "automatic"
+
+
+def test_section_flag_is_captured_at_submit_not_at_the_sections_stage(tmp_path: Path):
+ """Turning the toggle off mid-import must not rob the running job.
+
+ The sections stage is the last thing the pipeline does, minutes after the
+ user pressed the button, and the toggle now clears itself the moment they
+ open another song. Reading the setting here would have let an import
+ silently lose a pass its owner had already asked and waited for, so the
+ answer is the one captured on the job at creation.
+ """
+ job = Job(id="abcdefabc117", duration_sec=60.0, auto_sections=True)
+ job_dir = tmp_path / job.id
+ (job_dir / "stems").mkdir(parents=True)
+
+ suggested = [{"id": "auto-001", "kind": "verse"}]
+ patches = _common_stage_patches(job_dir, suggested)
+ with (
+ patches[0],
+ patches[1],
+ patches[2],
+ patches[3],
+ patches[4],
+ patches[5],
+ patches[6],
+ patches[7],
+ patches[8] as detect,
+ # The setting says off, the way it would after the user opened another
+ # song while this import was still running.
+ patch("app.core.settings.get_auto_sections", return_value=False),
+ ):
+ _run_common(job, job_dir / "source.wav", job_dir)
+
+ detect.assert_called_once()
+ assert job.sections == suggested
From 039f239603fb612ec5b4f6cbb629d641cde5a13b Mon Sep 17 00:00:00 2001
From: Thales <>
Date: Sun, 30 Aug 2026 17:30:32 +0100
Subject: [PATCH 5/5] Cover the Song structure toggle clearing itself
The backend half of that change is tested: the flag is captured at submit, and
the stage honours the job's flag over the live setting. The browser half was
not, and it is the half that regresses quietly -- a button that looks off while
the setting behind it is still on would leave the next import paying for a pass
nobody asked for.
So both assertions check both. Checked against a build with the reset removed,
which fails them.
---
tests/e2e/song-structure-toggle.spec.mjs | 60 ++++++++++++++++++++++++
1 file changed, 60 insertions(+)
create mode 100644 tests/e2e/song-structure-toggle.spec.mjs
diff --git a/tests/e2e/song-structure-toggle.spec.mjs b/tests/e2e/song-structure-toggle.spec.mjs
new file mode 100644
index 00000000..c0493f09
--- /dev/null
+++ b/tests/e2e/song-structure-toggle.spec.mjs
@@ -0,0 +1,60 @@
+// The Song structure toggle is a choice about the next import, not a saved
+// preference. It enables a CPU pass measured in minutes, so it has to be
+// something the user asked for just now rather than something an earlier
+// session, or the previous song, left switched on.
+//
+// The button and the server setting have to move together. The setting is what
+// the runner reads, so a button that merely looks off would leave the next
+// import still paying for a pass nobody asked for -- which is why every
+// assertion here checks both.
+
+import { test, expect } from "@playwright/test";
+import { openStudio, seedLibrary, stubExportEndpoints, stubUpdateCheck, JOB_ID } from "./helpers.mjs";
+
+const button = (page) => page.locator("#autoSectionsBtn");
+
+const setting = (page) =>
+ page.evaluate(() =>
+ fetch("/api/settings", { cache: "no-store" }).then((r) => r.json()).then((d) => d.auto_sections));
+
+const putSettingOn = (page) =>
+ page.evaluate(() =>
+ fetch("/api/settings", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ auto_sections: true }),
+ }).then((r) => r.json()));
+
+test.describe("song structure toggle", () => {
+ test("starts off, and clears a setting an earlier session left on", async ({ page }) => {
+ await seedLibrary(page);
+ await stubExportEndpoints(page);
+ await stubUpdateCheck(page);
+ await page.goto("/", { waitUntil: "domcontentloaded" });
+
+ // Leave it on the way a previous session would have.
+ await putSettingOn(page);
+ expect(await setting(page)).toBe(true);
+
+ await page.reload({ waitUntil: "domcontentloaded" });
+ await expect(button(page)).toHaveAttribute("aria-pressed", "false");
+ // The button being off is not the point on its own: the setting behind it
+ // is what the next import would have read.
+ await expect.poll(() => setting(page)).toBe(false);
+ });
+
+ test("switching it on holds until a song is opened, then clears", async ({ page }) => {
+ await openStudio(page, { tauri: true });
+ await expect(button(page)).toHaveAttribute("aria-pressed", "false");
+
+ await button(page).click();
+ await expect(button(page)).toHaveAttribute("aria-pressed", "true");
+ // It has to survive long enough to be used: this is the state an import
+ // submitted right now would capture.
+ await expect.poll(() => setting(page)).toBe(true);
+
+ await page.locator(`.cat-item[data-id="${JOB_ID}"]`).first().click();
+ await expect(button(page)).toHaveAttribute("aria-pressed", "false");
+ await expect.poll(() => setting(page)).toBe(false);
+ });
+});