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/css/daw.css b/static/css/daw.css
index 14dbd481..b25c9210 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;
@@ -1805,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 */
@@ -1875,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);
@@ -2045,10 +2134,20 @@ 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 */
+/* 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;
- min-height: 0 !important;
+ height: var(--wave-widget-track-stack-h, auto) !important;
+ min-height: var(--wave-widget-track-stack-h, 0) !important;
}
.loop-region.hidden { display: none !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 @@