From f487b40c8e102dadf2d677e990a298fe1dbd0399 Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 29 Aug 2026 02:29:39 +0100 Subject: [PATCH 1/2] Rebuild the studio's fixed chrome so the mixer gets the room The panels above and below the mixer took roughly two thirds of a 1366x768 window and the mixer got what was left, which was two of six stem lanes (#480). Working through that turned up four separate problems, all in the same few files, so they land together. Analysis header, 147px to 73px (#487). Each metadata card was three stacked text lines; the qualifier (Natural Minor, Peak 3.5 dB, High) now sits on the value's own line, and the presence cards put their label and percentage side by side rather than one over the other. Type came down with it, 22px to 17px and 28px to 18px. Halving it was a question of how many lines a card is, not how big its text is. Both header rows are now grids of the same seven columns, so their dividers land on the same pixels (#488). Six presence stems fill seven columns because Vocals takes two; once the lead/backing split has run it is replaced by two cards and the row becomes a plain 7x1. A flex row and a grid round fractional track widths differently, which is why the top row is a grid now too: it was a pixel out of step at most window widths. Those two new cards had nothing to show. split_vocals never touched stem_presence, so neither half had a figure anywhere. Presence is a stem's RMS as a percentage of the loudest stem in the job and that reference is not stored, only the percentages are, so the obvious fix is decoding all eight stems again to place two of them on a scale the other six already sit on. presence_for_split recovers the reference arithmetically instead: loudest = rms[vocals] / (presence[vocals] / 100) merge_stem_peaks already measured RMS for the new stems in its existing pass and threw it away; it returns it now, and vocals rides along in the same pass. One extra file scanned. With no reference recorded it returns nothing and the cards read "--", which is honest. The footer is one line, always (#489). Its clusters sat in a wrapping flex row, so the click track either shared the line, took its own, or stayed and split its own options across two rows: the footer was never the same shape twice. Nothing wraps and nothing shrinks now, and a window too narrow to hold the row scrolls it sideways. The row is top-aligned too, so every label sits on one line no matter how tall the controls under it are; bottom-aligned, the click track threw its own label a row above the other four. That also settled the dividers. They were hidden when stranded at the end of a wrapped line, but bottom-alignment meant the check never matched and none were ever drawn. With no wrapping none can be stranded, so syncFooterDividers and the is-orphan rule are gone rather than left as logic that cannot fire. Footer height, 212px to 170px. The min-height dated from when the click cluster could wrap and a rigid height would have clipped it. Its content needs 170, so the floor only padded the footer past itself and the surplus pooled as a gap under whichever tier came last. The track identity moved to the bottom of the library sidebar (#490), pinned below the list so it stays put while the folders and Tags scroll. It was holding a 300px column of the footer to answer a different question from everything beside it. Its footer-* classes came with it as track-panel-* and track-chip-*: they no longer live in the footer, and the old names would have misdirected the next reader. The panel matches the footer's height exactly, measured live by syncTrackPanelHeight, because the footer's height is not a constant. The 300px the footer loses stays reserved, because the timeline's left edge has to land on the lane waveforms' left edge for a position to read at the same x in both. Export Mix fills that gutter rather than leaving it empty. The gutter's width is the offset, so the alignment and the button placement are one fact instead of two numbers that have to agree. The rail reads Favorites, Library, Queue, Trash. Queue was below Trash, an active state filed under an archive. Five of six lanes are visible at 1366x768 now, against two before. The mixer still scrolls, so #480 stays open. --- app/api/jobs.py | 10 +- app/pipeline/collect.py | 49 +++++- static/css/daw.css | 242 ++++++++++++++++++++--------- static/index.html | 272 ++++++++++++++++++--------------- static/js/i18n.js | 18 +-- static/js/main.js | 2 +- static/js/player.js | 8 + static/js/transport.js | 44 +++--- tests/test_pipeline_collect.py | 52 ++++++- 9 files changed, 459 insertions(+), 238 deletions(-) diff --git a/app/api/jobs.py b/app/api/jobs.py index 1f67d028..33f508f8 100644 --- a/app/api/jobs.py +++ b/app/api/jobs.py @@ -36,7 +36,7 @@ from app.core.settings import 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 +from app.pipeline.collect import merge_stem_peaks, presence_for_split from app.pipeline.download import InvalidYouTubeURL, validate_youtube_url from app.pipeline.errors import classify_failure from app.pipeline.runner import _pipeline_lock @@ -390,7 +390,13 @@ async def start_vocal_split(job_id: str) -> Response: for name in new_names: if name not in existing: job.stems.append({"name": name, "url": f"/api/jobs/{job_id}/stems/{name}.wav"}) - merge_stem_peaks(stems_dir, new_names) + # "vocals" rides along so presence_for_split can recover the scale the base + # stems were normalised against; without it the two new cards would have no + # percentage to show. + rms_values = merge_stem_peaks(stems_dir, ["vocals", *new_names]) + extra_presence = presence_for_split(rms_values, job.stem_presence) + if extra_presence: + job.stem_presence = {**(job.stem_presence or {}), **extra_presence} job.vocal_split = "done" _set(job, stage="Done") registry_persist(JOBS_DIR) diff --git a/app/pipeline/collect.py b/app/pipeline/collect.py index 81356fa2..cea996d1 100644 --- a/app/pipeline/collect.py +++ b/app/pipeline/collect.py @@ -218,13 +218,18 @@ def compute_stem_peaks(stems_dir: Path, stem_names: list[str]) -> dict[str, floa return rms_values -def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> None: +def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> dict[str, float]: """Add peaks/RMS for newly-produced stems (e.g. the on-demand lead/backing vocal split, #275) into the existing peaks.json instead of recomputing every stem. Best-effort, same as compute_stem_peaks: a failure here only - costs client-side waveform decode for the new stems, never the job.""" + costs client-side waveform decode for the new stems, never the job. + + Returns each scanned stem's RMS, from the same pass, so the caller can + extend stem presence without decoding anything twice (see + presence_for_split).""" path = stems_dir / "peaks.json" peaks: dict[str, list[list[float]]] = {} + rms_values: dict[str, float] = {} if path.is_file(): try: peaks = json.loads(path.read_text(encoding="utf-8")) @@ -236,7 +241,8 @@ def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> None: if not wav.is_file(): continue try: - result, _rms = scan_stem(wav, _PEAK_POINTS) + result, rms = scan_stem(wav, _PEAK_POINTS) + rms_values[name] = rms if result: peaks[name] = result except Exception: @@ -249,6 +255,43 @@ def merge_stem_peaks(stems_dir: Path, new_names: list[str]) -> None: except Exception: logger.warning("could not write peaks.json for %s", stems_dir.name, exc_info=True) + return rms_values + + +def presence_for_split( + rms_values: dict[str, float], + stem_presence: dict[str, int] | None, + *, + reference: str = "vocals", +) -> dict[str, int]: + """Put the lead/backing stems on the same 0-100 scale the pipeline already + recorded for the base six. + + Presence is a stem's RMS as a percentage of the loudest stem in the job, + and that loudest value is not stored anywhere -- only the percentages are. + It can be recovered exactly from any stem whose presence is known: + + loudest = rms[reference] / (presence[reference] / 100) + + So scanning the reference stem alongside the new ones is enough, and the + alternative (re-decoding all eight stems to renormalise) is avoided. + + Returns an empty dict when the reference is missing or silent, which leaves + the new cards reading "--" rather than showing a number derived from + nothing.""" + reference_rms = rms_values.get(reference) + reference_pct = (stem_presence or {}).get(reference) + if not reference_rms or not reference_pct: + return {} + loudest = reference_rms / (reference_pct / 100) + if loudest < 1e-9: + return {} + return { + name: max(0, min(100, round(rms / loudest * 100))) + for name, rms in rms_values.items() + if name != reference + } + def sweep_old_jobs(jobs_dir: Path, ttl_seconds: int | None = None) -> None: """Delete job directories older than `ttl_seconds` (JOB_TTL_SECONDS when diff --git a/static/css/daw.css b/static/css/daw.css index 5acd51b0..421c87ee 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -537,7 +537,10 @@ input, textarea { font-family: inherit; } /* Sidebar body */ .sidebar-body { flex: 1; - padding: 12px; + /* No bottom padding: .track-panel is the last child and has to reach the + window's bottom edge, so its band lines up with the footer's. The panel + carries its own bottom padding instead. */ + padding: 12px 12px 0; display: flex; flex-direction: column; gap: 10px; @@ -622,11 +625,13 @@ input, textarea { font-family: inherit; } gap: 2px; } -/* Thin auto-hide scrollbar shared by the library list, the mixer column and the - waveform panel (both axes) — hidden until the area is hovered/focused, reserves - no visible gutter. `.daw .wave-scroll` overrides the gold bar from waves.css. */ +/* Thin auto-hide scrollbar shared by the library list, the mixer column, the + footer's control row and the waveform panel (both axes) — hidden until the + area is hovered/focused, reserves no visible gutter. `.daw .wave-scroll` + overrides the gold bar from waves.css. */ .daw-lib-list, .mixer-column, +.footer-row-controls, .daw .wave-scroll { /* Firefox: thin overlay-style bar, fades to transparent track */ scrollbar-width: thin; @@ -636,6 +641,8 @@ input, textarea { font-family: inherit; } .daw-lib-list:focus-within, .mixer-column:hover, .mixer-column:focus-within, +.footer-row-controls:hover, +.footer-row-controls:focus-within, .daw .wave-scroll:hover, .daw .wave-scroll:focus-within { scrollbar-color: rgba(148, 163, 184, 0.4) transparent; @@ -643,17 +650,20 @@ input, textarea { font-family: inherit; } /* WebKit (Chrome/Safari/WKWebView): thin thumb, hidden until hover/scroll */ .daw-lib-list::-webkit-scrollbar, .mixer-column::-webkit-scrollbar, +.footer-row-controls::-webkit-scrollbar, .daw .wave-scroll::-webkit-scrollbar { width: 8px; height: 8px; } .daw-lib-list::-webkit-scrollbar-track, .mixer-column::-webkit-scrollbar-track, +.footer-row-controls::-webkit-scrollbar-track, .daw .wave-scroll::-webkit-scrollbar-track { background: transparent; } .daw-lib-list::-webkit-scrollbar-thumb, .mixer-column::-webkit-scrollbar-thumb, +.footer-row-controls::-webkit-scrollbar-thumb, .daw .wave-scroll::-webkit-scrollbar-thumb { background: transparent; border: 2px solid transparent; @@ -664,6 +674,8 @@ input, textarea { font-family: inherit; } .daw-lib-list:focus-within::-webkit-scrollbar-thumb, .mixer-column:hover::-webkit-scrollbar-thumb, .mixer-column:focus-within::-webkit-scrollbar-thumb, +.footer-row-controls:hover::-webkit-scrollbar-thumb, +.footer-row-controls:focus-within::-webkit-scrollbar-thumb, .daw .wave-scroll:hover::-webkit-scrollbar-thumb, .daw .wave-scroll:focus-within::-webkit-scrollbar-thumb { background: rgba(148, 163, 184, 0.4); @@ -671,6 +683,7 @@ input, textarea { font-family: inherit; } } .daw-lib-list::-webkit-scrollbar-thumb:hover, .mixer-column::-webkit-scrollbar-thumb:hover, +.footer-row-controls::-webkit-scrollbar-thumb:hover, .daw .wave-scroll::-webkit-scrollbar-thumb:hover { background: rgba(148, 163, 184, 0.6); background-clip: padding-box; @@ -1205,9 +1218,13 @@ input, textarea { font-family: inherit; } flex-shrink: 0; } -/* Row 1 */ +/* Row 1. A grid rather than a flex row so its column edges land on exactly + the same pixels as the presence grid below it -- flex and grid round + fractional track widths differently, which left the two rows a pixel out of + step at most window widths. */ .daw-info-row { - display: flex; + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); border-bottom: 1px solid var(--border); } @@ -1222,20 +1239,25 @@ input, textarea { font-family: inherit; } border-right: 1px solid var(--border); } -/* Metadata cards */ +/* Metadata cards. + Two lines, not three: the qualifier (Natural Minor, Peak 3.5 dB, High) sits + on the value's own line rather than under it. Six stems only fit on a + 1366x768 laptop if the panels above the mixer stop taking a third of the + window (#480), and a third text row here cost more height than it carried + information. */ .daw-meta-card { - flex: 1; padding: 4px 16px; border-right: 1px solid var(--border); display: flex; flex-direction: column; justify-content: center; - gap: 5px; + gap: 2px; min-width: 0; } .daw-meta-card:last-child { border-right: none; } .meta-card-label { font-size: 10px; + line-height: 1; font-weight: 600; letter-spacing: 0.07em; text-transform: uppercase; @@ -1243,21 +1265,28 @@ input, textarea { font-family: inherit; } } .meta-card-main { display: flex; - align-items: center; + align-items: baseline; gap: 8px; + min-width: 0; } .meta-card-value { - font-size: 22px; + font-size: 17px; font-weight: 700; color: var(--fg); line-height: 1; font-family: var(--font-mono); + flex-shrink: 0; } .meta-card-value.accent { color: var(--accent); } .meta-card-value.stability-high { color: #4caf7d; } .meta-card-sub { font-size: 11px; + line-height: 1; color: var(--muted); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .daw-cover { width: 64px; height: 64px; @@ -1396,33 +1425,52 @@ input, textarea { font-family: inherit; } flex-shrink: 0; } -/* Stem presence cards — row 2 */ +/* Stem presence cards — row 2. One line each: the label and its percentage + read as a pair, and stacking them doubled the row's height for no gain + (#480). */ .stem-presence-panel { display: grid; - grid-template-columns: repeat(6, 1fr); + /* Seven, matching the seven metadata cards above, so the two rows share + their dividers instead of interleaving them. Six presence stems fill + seven columns because Vocals takes two; a completed lead/backing split + replaces that one card with two and the row becomes a plain 7x1. */ + grid-template-columns: repeat(7, minmax(0, 1fr)); } +.stem-card[data-stem="vocals"] { grid-column: span 2; } .stem-card { - padding: 14px 16px; + padding: 8px 12px; border-right: 1px solid var(--border); display: flex; - flex-direction: column; - gap: 6px; + flex-direction: row; + align-items: baseline; + justify-content: space-between; + gap: 8px; + min-width: 0; } .stem-card:last-child { border-right: none; } .stem-card-label { font-size: 10px; + line-height: 1; font-weight: 600; - letter-spacing: 0.07em; + /* Tighter than the metadata labels above: on a side-by-side row these + compete with the percentage for width, and 0.07em truncated + "VOCAL PRESENCE" at 1366px. */ + letter-spacing: 0.03em; text-transform: uppercase; color: var(--muted); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .stem-card-pct { - font-size: 28px; + font-size: 18px; font-weight: 700; font-family: var(--font-mono); line-height: 1; color: var(--fg); transition: color 200ms; + flex-shrink: 0; } .stem-card:not(.inactive)[data-stem="vocals"] .stem-card-pct { color: var(--vocals); } .stem-card:not(.inactive)[data-stem="drums"] .stem-card-pct { color: var(--drums); } @@ -1459,10 +1507,12 @@ input, textarea { font-family: inherit; } .daw-analysis-sub { font-size: 10px; color: var(--muted); margin-top: 2px; } .daw-analysis-divider { width: 1px; background: var(--border); align-self: stretch; } .daw-camelot-ring { - width: 28px; height: 28px; border-radius: 50%; + width: 20px; height: 20px; border-radius: 50%; border: 1.5px solid var(--accent); display: flex; align-items: center; justify-content: center; - font-size: 10px; font-weight: 700; color: var(--accent); + align-self: center; + flex-shrink: 0; + font-size: 9px; font-weight: 700; color: var(--accent); background: rgba(244,183,64,0.08); font-family: var(--font-mono); } @@ -2043,9 +2093,13 @@ input, textarea { font-family: inherit; } /* ── Transport footer ── */ .daw-footer { - /* min-height, not height: the click-track cluster can wrap onto more than - one line in a narrow window, and a rigid height would clip it. */ - min-height: 200px; + /* No height of its own: the two tiers inside decide it, and every pixel not + spent here goes to the mixer, which is the panel actually short of room + (#480). This used to carry min-height: 200px, from when the click-track + cluster could wrap onto a second line and a rigid height would have + clipped it. The controls row no longer wraps, so the floor only padded the + footer past its content -- 30px of it, which pooled as a gap wherever the + tiers were not pushed. */ flex-shrink: 0; border-top: 1px solid var(--border); background: var(--bg-2); @@ -2151,44 +2205,53 @@ input, textarea { font-family: inherit; } display: flex; flex-direction: column; } -/* ── Left column: track identity ── */ -/* Padded like the mixer rows above it (14px), so title, stem names and the - "Mixer" heading all share one left edge down the column. */ -.footer-track { - width: var(--daw-col-w); flex: none; +/* ── Track identity: the bottom panel of the library sidebar ── */ +/* Pinned below .daw-lib-list rather than inside it, so the loaded track stays + put while the folders and the Tags section scroll. flex: none keeps it at its + natural height and lets the list take the rest. + + No overflow clipping: the export menu is absolutely positioned in here and + opens upward past this panel's top edge. The title clamps itself and the meta + line has its own overflow rule, so nothing here needs clipping. */ +.track-panel { + flex: none; box-sizing: border-box; - padding: 12px 14px 14px; - /* Centred, not top-aligned: the identity block is shorter than the control - column beside it, and centring splits the leftover height instead of - leaving it all as a void under the buttons. Stays balanced when the - controls wrap to another line and the footer grows. */ + /* Exactly as tall as the footer beside it, measured live by + syncTrackPanelHeight (transport.js) because the footer grows when the + click cluster wraps. The fallback matches .daw-footer's min-height, so the + panel is the right size on the first paint too. Content is centred rather + than top-aligned: the block is shorter than the band and centring splits + the leftover instead of leaving it all underneath. */ + height: var(--daw-footer-h, 200px); + padding: 12px 2px; + border-top: 1px solid var(--border); display: flex; flex-direction: column; justify-content: center; gap: 8px; - /* No overflow clipping: the export menu is absolutely positioned inside this - column and opens upward past its top edge. The title clamps itself and the - meta line has its own overflow rule, so nothing here needs clipping. */ min-width: 0; } -.footer-track-head { +.track-panel-head { display: flex; align-items: flex-start; gap: 10px; min-width: 0; } -.footer-row-actions { +.track-panel-actions { display: flex; align-items: center; gap: 8px; margin-top: 2px; } /* Boxed like the Export button beside it: both are actions on the track, and a bare icon next to a filled button reads as decoration. */ -.footer-row-actions .daw-fav-btn { +.track-panel-actions .daw-fav-btn { width: 32px; height: 32px; padding: 0; justify-content: center; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 8px; transition: background var(--t-fast), color var(--t-fast); } -.footer-row-actions .daw-fav-btn:hover { background: var(--panel-3); } -.footer-row-actions .footer-chip { height: 32px; } +.track-panel-actions .daw-fav-btn:hover { background: var(--panel-3); } +.track-panel-actions .track-chip { height: 32px; } /* Hairline cluster separator: fades out at both ends so it reads as a seam - between groups rather than a hard rule across the footer. */ + between groups rather than a hard rule across the footer. Every one of these + separates something now that .footer-row-controls never wraps -- the + is-orphan state that used to hide a divider stranded at the end of a wrapped + line went with the wrapping. */ .footer-divider { width: 1px; flex: none; background: linear-gradient( @@ -2196,19 +2259,27 @@ input, textarea { font-family: inherit; } ); } .footer-row-controls .footer-divider { height: 44px; } -/* Stranded at the end of a wrapped line, separating nothing (marked by - syncFooterDividers). Hidden rather than removed: taking its box out would - change the very wrap that was measured. */ -.footer-divider.is-orphan { visibility: hidden; } - +/* Starts at the footer's own left edge. The track identity that used to sit in + front of it now lives in the library sidebar, and leaving its 300px gutter + behind pushed every control into the right of the window with a void beside + it. + + One line, always. Wrapping rearranged the band into a different shape at + every window width -- clusters piling onto a second row, the click track + splitting its own options in half -- so the footer never looked like itself + twice. Nothing here shrinks either (.footer-group is flex: none), so when + the window is too narrow the row scrolls sideways rather than reflowing. */ .footer-row-controls { - display: flex; align-items: flex-end; flex-wrap: wrap; + display: flex; align-items: flex-start; flex-wrap: nowrap; + overflow-x: auto; gap: 10px 16px; - padding: 12px 18px 14px 0; + padding: 12px 18px 14px; } +/* Natural width, never squeezed: a cluster that shrinks re-wraps its own + controls, which is the pile-up the row-level nowrap exists to prevent. */ .footer-group { display: flex; flex-direction: column; gap: 6px; - min-width: 0; + flex: none; } .footer-group-label { display: flex; align-items: center; gap: 7px; @@ -2218,14 +2289,9 @@ input, textarea { font-family: inherit; } /* One control height across every cluster, so the row reads as a single band of controls rather than a ragged stack. */ .footer-group-body { - display: flex; align-items: center; flex-wrap: wrap; gap: 8px; + display: flex; align-items: center; flex-wrap: nowrap; gap: 8px; min-height: 34px; } -/* Click track carries the most controls -- a wide basis so it either shares - the line with the other three clusters or takes a line of its own, rather - than squeezing into a leftover gap and wrapping into a ragged block. */ -.footer-group-click { flex: 1 1 520px; } - .alpha-badge { padding: 1px 5px; border-radius: 4px; background: rgba(74,140,255,0.16); border: 1px solid rgba(74,140,255,0.4); @@ -2237,8 +2303,31 @@ input, textarea { font-family: inherit; } left edge has to land exactly on the lane waveforms' left edge, and a side border would offset the canvas by its own width. Top and bottom rules only, the way the ruler area above the lanes is drawn. */ +/* Bottom tier: the gutter, then the timeline. Two columns rather than one, so + the timeline's left edge lands exactly on the lane waveforms' left edge and + a position reads at the same x in both -- while the 300px that alignment + costs carries Export Mix instead of standing empty. */ .footer-wave-region { - padding: 0 0 14px; flex-shrink: 0; + display: flex; align-items: stretch; + padding: 0 18px 14px 0; flex-shrink: 0; +} +/* Exactly the mixer column's width: that offset is what puts the timeline + beside it on the lanes' x. */ +.footer-export-slot { + width: var(--daw-col-w); flex: none; + box-sizing: border-box; + padding: 0 18px; + /* Centred in the gutter rather than pinned to its left edge: it is the only + thing in this column, so aligning it under Transport left a lopsided gap + on the other side. */ + display: flex; align-items: center; justify-content: center; + /* The export menu is absolutely positioned in here and opens upward past + this slot's top edge, so nothing on this path may clip. */ + min-width: 0; +} +.footer-wave-col { + flex: 1; min-width: 0; + display: flex; flex-direction: column; } .footer-wave-panel { border-top: 1px solid var(--border); @@ -2286,13 +2375,13 @@ input, textarea { font-family: inherit; } } .footer-scrub-fill { display: none; } -.footer-track-info { +.track-panel-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; } /* Two lines, then clipped: a column this narrow would otherwise cut most titles after three or four words. */ -.footer-track-title { +.track-panel-title { min-width: 0; font-size: 13px; font-weight: 600; line-height: 1.35; /* Overrides the nowrap/ellipsis on .daw-track-title, which is written for @@ -2302,14 +2391,14 @@ input, textarea { font-family: inherit; } overflow: hidden; overflow-wrap: anywhere; color: var(--fg); } -.footer-cover { +.track-panel-cover { width: 40px !important; height: 40px !important; border-radius: 7px; flex-shrink: 0; } /* Wraps freely here -- the column is narrow and the whole block is stacked, so the meta reads as a short paragraph rather than a clipped line. */ -.footer-track .daw-track-meta-line { row-gap: 3px; line-height: 1.45; } +.track-panel .daw-track-meta-line { row-gap: 3px; line-height: 1.45; } .footer-art { width: 44px; height: 44px; border-radius: 6px; background: var(--panel-3); flex-shrink: 0; overflow: hidden; @@ -2601,7 +2690,9 @@ input, textarea { font-family: inherit; } 1b). Hidden until it has text, clipped to one line with the full text as a native tooltip. */ .metro-note { - margin-top: 8px; padding-right: 18px; + /* No padding of its own: .footer-wave-region insets the right and the + export gutter the left, so the note already starts on the timeline's x. */ + margin-top: 8px; font-size: 11px; line-height: 1.3; color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } @@ -2609,11 +2700,11 @@ input, textarea { font-family: inherit; } .metro-note.warn { color: var(--danger); } /* Export chip button */ -.footer-chip-wrap { +.track-chip-wrap { position: relative; flex-shrink: 0; display: flex; align-items: center; } -.footer-chip { +.track-chip { display: inline-flex; align-items: center; gap: 6px; background: var(--panel-2); border: 1px solid var(--border-strong); color: var(--fg); border-radius: 8px; font-size: 12px; font-weight: 500; @@ -2621,18 +2712,18 @@ input, textarea { font-family: inherit; } white-space: nowrap; transition: background var(--t-fast); } -.footer-chip:hover { background: var(--panel-3); } -.footer-chip-primary { +.track-chip:hover { background: var(--panel-3); } +.track-chip-primary { background: var(--accent); color: #1a1206; border-color: transparent; } -.footer-chip-primary:hover { background: color-mix(in srgb, var(--accent) 85%, white); } -.footer-chip-primary:disabled { +.track-chip-primary:hover { background: color-mix(in srgb, var(--accent) 85%, white); } +.track-chip-primary:disabled { background: var(--panel-2); color: var(--fg-muted); border-color: var(--border-strong); cursor: not-allowed; opacity: 0.5; } -.footer-chip-panel { +.track-chip-panel { /* Opens up and to the right: the button now lives in the left column, and a right-aligned panel would hang past the footer's left edge over the sidebar. */ @@ -2642,7 +2733,7 @@ input, textarea { font-family: inherit; } min-width: 120px; box-shadow: 0 8px 24px rgba(0,0,0,0.4); } -.footer-chip-panel.hidden { display: none !important; } +.track-chip-panel.hidden { display: none !important; } .chip-panel-item { display: flex; align-items: center; gap: 8px; @@ -2660,8 +2751,8 @@ input, textarea { font-family: inherit; } .export-caret { opacity: 0.8; } /* Primary button busy state: swap the download icon for a spinner. */ .export-spinner { display: none; } -.footer-chip.is-busy .export-dl-icon { display: none; } -.footer-chip.is-busy .export-spinner { display: inline-block; animation: sections-spin 700ms linear infinite; } +.track-chip.is-busy .export-dl-icon { display: none; } +.track-chip.is-busy .export-spinner { display: inline-block; animation: sections-spin 700ms linear infinite; } .chip-panel-export { min-width: 260px; padding: 6px; } @@ -3345,9 +3436,10 @@ input, textarea { font-family: inherit; } /* ── Responsive: hide zoom in small windows ── */ @media (max-width: 900px) { - .daw-info-row { flex-wrap: wrap; } - .daw-track-card { width: 100%; border-right: none; border-bottom: 1px solid var(--border); } - .stem-presence-panel { grid-template-columns: repeat(3, 1fr); } + .daw-info-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .daw-track-card { grid-column: 1 / -1; width: 100%; border-right: none; border-bottom: 1px solid var(--border); } + .stem-presence-panel { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .stem-card[data-stem="vocals"] { grid-column: span 1; } } /* Settings -> Logs. Read-only: paths, sizes and what writes each file. Log diff --git a/static/index.html b/static/index.html index cb724b2e..6857905c 100644 --- a/static/index.html +++ b/static/index.html @@ -174,23 +174,17 @@ - - +
+ + +
@@ -272,9 +320,9 @@
+ +
- -
@@ -288,8 +336,8 @@ LUFS
+
-
@@ -310,23 +358,35 @@ DYNAMIC RANGE
+
-
TEMPO STABILITY
+
-
- VOCAL PRESENCE + GLOBAL VOCALS + +
+ + +
@@ -596,114 +656,10 @@
- - - - + diff --git a/static/js/i18n.js b/static/js/i18n.js index cbdfd981..884e6749 100644 --- a/static/js/i18n.js +++ b/static/js/i18n.js @@ -319,7 +319,7 @@ const en = { "meta.dynamicRange": "Dynamic Range", "meta.tempoStability": "Tempo Stability", - "presence.vocal": "Vocal Presence", + "presence.vocalGlobal": "Global Vocals", "presence.drum": "Drum Intensity", "presence.bass": "Bass Depth", "presence.guitar": "Guitar Presence", @@ -872,7 +872,7 @@ const pl = { "meta.dynamicRange": "Zakres dynamiki", "meta.tempoStability": "Stabilność tempa", - "presence.vocal": "Obecność wokalu", + "presence.vocalGlobal": "Wokal ogólny", "presence.drum": "Intensywność perkusji", "presence.bass": "Głębia basu", "presence.guitar": "Obecność gitary", @@ -1415,7 +1415,7 @@ const ja = { "meta.dynamicRange": "ダイナミックレンジ", "meta.tempoStability": "テンポ安定性", - "presence.vocal": "ボーカルの存在感", + "presence.vocalGlobal": "ボーカル全体", "presence.drum": "ドラムの強さ", "presence.bass": "ベースの深さ", "presence.guitar": "ギターの存在感", @@ -1933,7 +1933,7 @@ const zhHans = { "meta.dynamicRange": "动态范围", "meta.tempoStability": "速度稳定性", - "presence.vocal": "人声强度", + "presence.vocalGlobal": "整体人声", "presence.drum": "鼓组强度", "presence.bass": "贝斯深度", "presence.guitar": "吉他强度", @@ -2451,7 +2451,7 @@ const de = { "meta.dynamicRange": "Dynamikumfang", "meta.tempoStability": "Tempostabilität", - "presence.vocal": "Gesangspräsenz", + "presence.vocalGlobal": "Gesang gesamt", "presence.drum": "Schlagzeugintensität", "presence.bass": "Basstiefe", "presence.guitar": "Gitarrenpräsenz", @@ -2980,7 +2980,7 @@ const pt = { "meta.dynamicRange": "Faixa dinâmica", "meta.tempoStability": "Estabilidade de tempo", - "presence.vocal": "Presença de vocal", + "presence.vocalGlobal": "Vocais globais", "presence.drum": "Intensidade da bateria", "presence.bass": "Profundidade do baixo", "presence.guitar": "Presença da guitarra", @@ -3511,7 +3511,7 @@ const id = { "meta.dynamicRange": "Rentang Dinamis", "meta.tempoStability": "Stabilitas Tempo", - "presence.vocal": "Kehadiran Vokal", + "presence.vocalGlobal": "Vokal Global", "presence.drum": "Intensitas Drum", "presence.bass": "Kedalaman Bass", "presence.guitar": "Kehadiran Gitar", @@ -4029,7 +4029,7 @@ const fr = { "meta.dynamicRange": "Plage dynamique", "meta.tempoStability": "Stabilité du tempo", - "presence.vocal": "Présence de la voix", + "presence.vocalGlobal": "Voix globales", "presence.drum": "Intensité de la batterie", "presence.bass": "Profondeur de la basse", "presence.guitar": "Présence de la guitare", @@ -4659,7 +4659,7 @@ const es = { "meta.dynamicRange": "Rango dinámico", "meta.tempoStability": "Estabilidad del tempo", - "presence.vocal": "Presencia de voz", + "presence.vocalGlobal": "Voces globales", "presence.drum": "Intensidad de batería", "presence.bass": "Profundidad del bajo", "presence.guitar": "Presencia de guitarra", diff --git a/static/js/main.js b/static/js/main.js index bb0b0dd7..338b2bc6 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -481,7 +481,7 @@ function wireFooterControls() { } function closeAllChipPanels() { - document.querySelectorAll(".footer-chip-panel:not(.hidden)").forEach((p) => { + document.querySelectorAll(".track-chip-panel:not(.hidden)").forEach((p) => { p.classList.add("hidden"); p.previousElementSibling?.setAttribute("aria-expanded", "false"); }); diff --git a/static/js/player.js b/static/js/player.js index 760846fc..174bd204 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -179,6 +179,14 @@ function applyStemSelectionFilter(presentNames) { row.classList.toggle("unavailable", !available); row.classList.toggle("hidden", isVocalFamily(stem) ? !order.includes(stem) : false); } + // Presence cards, top row of the track header. Only the vocals family moves: + // the single "Global Vocals" card and the lead/backing pair are alternatives, + // never both, and the panel's column count depends on which is showing. + for (const card of document.querySelectorAll(".stem-presence-panel .stem-card[data-stem]")) { + const stem = card.dataset.stem; + if (!isVocalFamily(stem)) continue; + card.classList.toggle("hidden", !order.includes(stem)); + } } function clearStemSelectionFilter() { diff --git a/static/js/transport.js b/static/js/transport.js index f28d7b05..ec2da0a9 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -554,29 +554,25 @@ function wireLaneScrollSync() { link(waveScroll, mixer); } -// The control clusters wrap when the window is too narrow to hold them side -// by side, which can leave a divider stranded at the end of a line with -// nothing after it to separate. Mark those so CSS can hide them. -// -// Compares bottom edges, not tops: the row is bottom-aligned, so a divider -// and the cluster beside it share a baseline but start at different heights. -function syncFooterDividers() { - const row = document.querySelector(".footer-row-controls"); - if (!row) return; - const bottom = (el) => Math.round(el.getBoundingClientRect().bottom); - for (const divider of row.querySelectorAll(".footer-divider")) { - const next = divider.nextElementSibling; - divider.classList.toggle("is-orphan", !next || bottom(next) !== bottom(divider)); - } -} - -function wireFooterDividers() { - const row = document.querySelector(".footer-row-controls"); - if (!row) return; - // Observing the row catches both window resizes and the controls changing - // width (a track with a click track has more of them than one without). - new ResizeObserver(syncFooterDividers).observe(row); - syncFooterDividers(); +// The track panel at the bottom of the library sidebar reads as one band with +// the footer beside it, so the two have to be exactly as tall as each other. +// The footer's height is not a constant -- the click track's options appear +// only once a beat grid exists, and the metronome note under the timeline wraps +// -- so it is measured and published as a custom property rather than +// duplicated as a number in the stylesheet. +function syncTrackPanelHeight() { + const footer = document.querySelector(".daw-footer"); + const app = document.querySelector(".app"); + if (!footer || !app) return; + const h = Math.round(footer.getBoundingClientRect().height); + if (h > 0) app.style.setProperty("--daw-footer-h", `${h}px`); +} + +function wireTrackPanelHeight() { + const footer = document.querySelector(".daw-footer"); + if (!footer) return; + new ResizeObserver(syncTrackPanelHeight).observe(footer); + syncTrackPanelHeight(); } // ─── Wire transport buttons ─── @@ -588,7 +584,7 @@ export function wireTransportButtons() { loopBtn.addEventListener("click", toggleLoop); wireLoopDrag(); wireLoopInputs(); - wireFooterDividers(); + wireTrackPanelHeight(); wireZoomButtons(); wireLaneScrollSync(); masterFader?.addEventListener("input", () => { diff --git a/tests/test_pipeline_collect.py b/tests/test_pipeline_collect.py index c5f56f03..99f4763a 100644 --- a/tests/test_pipeline_collect.py +++ b/tests/test_pipeline_collect.py @@ -9,7 +9,12 @@ import pytest import soundfile as sf -from app.pipeline.collect import _PEAK_POINTS, compute_stem_peaks +from app.pipeline.collect import ( + _PEAK_POINTS, + compute_stem_peaks, + merge_stem_peaks, + presence_for_split, +) def _write_wav(path: Path, samples: list[float], sample_rate: int = 44100) -> None: @@ -168,3 +173,48 @@ def test_peaks_match_full_load_reference(tmp_path): for (a_min, a_max), (e_min, e_max) in zip(actual, expected, strict=True): assert a_min == pytest.approx(e_min, abs=1e-4) assert a_max == pytest.approx(e_max, abs=1e-4) + + +def test_split_presence_lands_on_the_same_scale_as_the_base_stems(tmp_path): + """The lead/backing cards must be comparable with the six the pipeline + already measured. Presence is RMS against the loudest stem in the job, and + that reference is never stored -- only the percentages are -- so it has to + be recovered from the vocals stem rather than recomputed over every file.""" + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + sr = 44100 + t = np.linspace(0, 1, sr, endpoint=False) + # vocals = lead + backing, exactly as the split produces them. + lead = 0.4 * np.sin(2 * np.pi * 440 * t) + backing = 0.1 * np.sin(2 * np.pi * 660 * t) + _write_wav(stems_dir / "vocals.wav", (lead + backing).tolist(), sr) + _write_wav(stems_dir / "lead_vocals.wav", lead.tolist(), sr) + _write_wav(stems_dir / "backing_vocals.wav", backing.tolist(), sr) + + rms_values = merge_stem_peaks(stems_dir, ["vocals", "lead_vocals", "backing_vocals"]) + # Vocals at 50 means the loudest stem in this job is twice as loud as it. + presence = presence_for_split(rms_values, {"vocals": 50, "drums": 100}) + + assert set(presence) == {"lead_vocals", "backing_vocals"} + loudest = rms_values["vocals"] / 0.5 + for name in ("lead_vocals", "backing_vocals"): + assert presence[name] == round(rms_values[name] / loudest * 100) + # Lead carries most of the vocal, so it must read louder than backing and + # neither may exceed the vocals stem they came from. + assert presence["lead_vocals"] > presence["backing_vocals"] + assert presence["lead_vocals"] <= 50 + + +def test_split_presence_is_empty_without_a_reference(tmp_path): + """No vocals presence recorded (an older job) means there is no scale to + place the new stems on. Returning nothing leaves the cards reading "--", + which is honest; inventing a percentage would not be.""" + stems_dir = tmp_path / "stems" + stems_dir.mkdir() + t = np.linspace(0, 1, 44100, endpoint=False) + _write_wav(stems_dir / "lead_vocals.wav", (0.4 * np.sin(2 * np.pi * 440 * t)).tolist()) + + rms_values = merge_stem_peaks(stems_dir, ["vocals", "lead_vocals"]) + assert presence_for_split(rms_values, None) == {} + assert presence_for_split(rms_values, {"drums": 90}) == {} + assert presence_for_split(rms_values, {"vocals": 0}) == {} From cbeca87fb780ff61376bf221e2569b87abafe9cb Mon Sep 17 00:00:00 2001 From: Thales <> Date: Sat, 29 Aug 2026 02:41:47 +0100 Subject: [PATCH 2/2] Put the track identity back in the footer, at the new dimensions Moving the identity block to the library sidebar freed the footer's column, but the 300px gutter beside the timeline still had to exist: the timeline's left edge lands on the lane waveforms' left edge so a position reads at the same x in both. Export Mix filled it, which left the identity in a sidebar panel whose height had to be measured and mirrored, and put the loaded track's name in the column the user reads to choose a different one. So the identity is back where it started, in the gutter, but in the tier that gutter belongs to rather than as a full-height column beside the controls. The controls keep the left edge they gained, and the block is one tier tall instead of two: cover and title on a line with the favourite, the meta line under them. 79px against the sidebar panel's 141. Export Mix moves to the right of the controls row. It acts on the track rather than on playback, so it sits opposite the clusters instead of filed among them, and its menu now opens toward the middle of the window: anchored left, a 260px menu on the footer's right edge would hang off it. That placement needed the row split. The clusters scroll sideways when the window cannot hold them, and anything after them would scroll off with them, so .footer-clusters takes the overflow and the actions sit outside it. Export Mix stays reachable at any width. The favourite rides the title line rather than going with Export Mix. It marks the track, so it belongs with what it marks. With the sidebar panel gone, syncTrackPanelHeight and --daw-footer-h go too: nothing needs to mirror the footer's height any more. --- static/css/daw.css | 92 ++++++++--------- static/index.html | 218 ++++++++++++++++++++--------------------- static/js/transport.js | 22 ----- 3 files changed, 148 insertions(+), 184 deletions(-) diff --git a/static/css/daw.css b/static/css/daw.css index 421c87ee..fcb64625 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -537,10 +537,7 @@ input, textarea { font-family: inherit; } /* Sidebar body */ .sidebar-body { flex: 1; - /* No bottom padding: .track-panel is the last child and has to reach the - window's bottom edge, so its band lines up with the footer's. The panel - carries its own bottom padding instead. */ - padding: 12px 12px 0; + padding: 12px; display: flex; flex-direction: column; gap: 10px; @@ -2205,48 +2202,22 @@ input, textarea { font-family: inherit; } display: flex; flex-direction: column; } -/* ── Track identity: the bottom panel of the library sidebar ── */ -/* Pinned below .daw-lib-list rather than inside it, so the loaded track stays - put while the folders and the Tags section scroll. flex: none keeps it at its - natural height and lets the list take the rest. - - No overflow clipping: the export menu is absolutely positioned in here and - opens upward past this panel's top edge. The title clamps itself and the meta - line has its own overflow rule, so nothing here needs clipping. */ -.track-panel { - flex: none; - box-sizing: border-box; - /* Exactly as tall as the footer beside it, measured live by - syncTrackPanelHeight (transport.js) because the footer grows when the - click cluster wraps. The fallback matches .daw-footer's min-height, so the - panel is the right size on the first paint too. Content is centred rather - than top-aligned: the block is shorter than the band and centring splits - the leftover instead of leaving it all underneath. */ - height: var(--daw-footer-h, 200px); - padding: 12px 2px; - border-top: 1px solid var(--border); - display: flex; flex-direction: column; justify-content: center; gap: 8px; - min-width: 0; -} +/* ── Track identity, in the footer's timeline gutter ── */ .track-panel-head { display: flex; align-items: flex-start; gap: 10px; min-width: 0; } -.track-panel-actions { - display: flex; align-items: center; gap: 8px; - margin-top: 2px; -} -/* Boxed like the Export button beside it: both are actions on the track, and - a bare icon next to a filled button reads as decoration. */ -.track-panel-actions .daw-fav-btn { - width: 32px; height: 32px; padding: 0; +/* Favourite belongs with what it marks, so it rides the title line rather than + sitting with Export Mix. Boxed, because a bare icon beside the cover art + reads as decoration. */ +.track-panel-head .daw-fav-btn { + flex: none; margin-left: auto; + width: 28px; height: 28px; padding: 0; justify-content: center; background: var(--panel-2); border: 1px solid var(--border-strong); border-radius: 8px; transition: background var(--t-fast), color var(--t-fast); } -.track-panel-actions .daw-fav-btn:hover { background: var(--panel-3); } -.track-panel-actions .track-chip { height: 32px; } - +.track-panel-head .daw-fav-btn:hover { background: var(--panel-3); } /* Hairline cluster separator: fades out at both ends so it reads as a seam between groups rather than a hard rule across the footer. Every one of these separates something now that .footer-row-controls never wraps -- the @@ -2271,10 +2242,30 @@ input, textarea { font-family: inherit; } the window is too narrow the row scrolls sideways rather than reflowing. */ .footer-row-controls { display: flex; align-items: flex-start; flex-wrap: nowrap; - overflow-x: auto; - gap: 10px 16px; + gap: 16px; padding: 12px 18px 14px; } +/* The clusters, and only the clusters, scroll. Favourite and Export Mix sit + outside this box so a window too narrow for the row cannot carry them off + the right-hand edge. */ +.footer-clusters { + flex: 1; min-width: 0; + display: flex; align-items: flex-start; flex-wrap: nowrap; + overflow-x: auto; + gap: 16px; +} +/* Acts on the track, not on playback, so it is pinned opposite the clusters + rather than filed among them. Nudged down to sit on the controls' own line + instead of the labels above them. */ +.footer-track-actions { + flex: none; + display: flex; align-items: center; gap: 8px; + margin-top: 18px; +} +.footer-track-actions .track-chip { height: 32px; } +/* Opens toward the middle of the window: anchored left, a 260px menu on the + footer's right edge would hang off it. */ +.footer-track-actions .track-chip-panel { left: auto; right: 0; } /* Natural width, never squeezed: a cluster that shrinks re-wraps its own controls, which is the pile-up the row-level nowrap exists to prevent. */ .footer-group { @@ -2312,18 +2303,15 @@ input, textarea { font-family: inherit; } padding: 0 18px 14px 0; flex-shrink: 0; } /* Exactly the mixer column's width: that offset is what puts the timeline - beside it on the lanes' x. */ -.footer-export-slot { + beside it on the lanes' x, so the alignment and the block that fills the + gutter are one fact rather than two numbers that have to agree. */ +.footer-track-slot { width: var(--daw-col-w); flex: none; box-sizing: border-box; - padding: 0 18px; - /* Centred in the gutter rather than pinned to its left edge: it is the only - thing in this column, so aligning it under Transport left a lopsided gap - on the other side. */ - display: flex; align-items: center; justify-content: center; - /* The export menu is absolutely positioned in here and opens upward past - this slot's top edge, so nothing on this path may clip. */ + padding: 0 18px 0 18px; + display: flex; flex-direction: column; justify-content: center; gap: 5px; min-width: 0; + overflow: hidden; } .footer-wave-col { flex: 1; min-width: 0; @@ -2396,9 +2384,9 @@ input, textarea { font-family: inherit; } border-radius: 7px; flex-shrink: 0; } -/* Wraps freely here -- the column is narrow and the whole block is stacked, - so the meta reads as a short paragraph rather than a clipped line. */ -.track-panel .daw-track-meta-line { row-gap: 3px; line-height: 1.45; } +/* Wraps freely here -- the gutter is narrow and the block is stacked, so the + meta reads as a short paragraph rather than a clipped line. */ +.footer-track-slot .daw-track-meta-line { row-gap: 2px; line-height: 1.35; } .footer-art { width: 44px; height: 44px; border-radius: 6px; background: var(--panel-3); flex-shrink: 0; overflow: hidden; diff --git a/static/index.html b/static/index.html index 6857905c..848675a4 100644 --- a/static/index.html +++ b/static/index.html @@ -249,54 +249,6 @@
- -
-
-
- - -
-
-
-
-
- -
- - - — stems - - - - - - Extracted -
- - - - -
- -
-
-
@@ -664,6 +616,7 @@ + + +
@@ -789,69 +809,47 @@ -