Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 5 additions & 1 deletion app/api/playlist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions app/core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
13 changes: 8 additions & 5 deletions app/pipeline/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand Down
111 changes: 105 additions & 6 deletions static/css/daw.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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; }
Expand Down
29 changes: 29 additions & 0 deletions static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
<div class="daw-sep"></div>

<!-- Composer pill -->
<div class="daw-composer-stack">
<form id="job-form" class="daw-composer">

<!-- URL zone (JS uses .url-wrap for drag events) -->
Expand Down Expand Up @@ -122,6 +123,34 @@
</button>
</form>

<!-- Panel toggles (#480). Every panel around the mixer is useful and
none of them is useful all the time, so the fix is a way to put one
away rather than a smaller default. They sit under the composer's
right-hand end, beneath the two controls that decide what an import
produces, because these decide what the studio shows: both are
choices about the layout rather than about a track. Collapsing to
zero is only possible because the way back is always here; a
control on each panel would need that panel to keep a stub, and the
stub costs most of what collapsing the smaller ones returns. -->
<div class="daw-panel-toggles">
<span class="daw-panel-toggles-label" data-i18n="panels.clickToCollapse">Click to collapse</span>
<button class="daw-panel-toggle" type="button" data-panel="analysis" aria-pressed="true"
title="Show or hide the track analysis" data-i18n-title="panels.analysisTitle" data-i18n-aria-label="panels.analysisTitle">
<span data-i18n="panels.analysis">Analysis</span>
</button>
<span class="daw-panel-toggles-sep" aria-hidden="true">-</span>
<button class="daw-panel-toggle" type="button" data-panel="sections" aria-pressed="true"
title="Show or hide the sections bar" data-i18n-title="panels.sectionsTitle" data-i18n-aria-label="panels.sectionsTitle">
<span data-i18n="sections.title">Sections</span>
</button>
<span class="daw-panel-toggles-sep" aria-hidden="true">-</span>
<button class="daw-panel-toggle" type="button" data-panel="timeline" aria-pressed="true"
title="Show or hide the timeline" data-i18n-title="panels.timelineTitle" data-i18n-aria-label="panels.timelineTitle">
<span data-i18n="panels.timeline">Timeline</span>
</button>
</div>
</div><!-- /.daw-composer-stack -->

<!-- Notification bell -->
<div class="daw-notif-wrap" style="position:relative;flex-shrink:0;">
<button class="daw-iconbtn daw-notif-btn" id="notifBtn" title="Notifications" type="button" aria-label="Notifications" aria-expanded="false" data-i18n-title="notif.bell" data-i18n-aria-label="notif.bell">
Expand Down
Loading
Loading