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..fcb64625 100644 --- a/static/css/daw.css +++ b/static/css/daw.css @@ -622,11 +622,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 +638,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 +647,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 +671,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 +680,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 +1215,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 +1236,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 +1262,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 +1422,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 +1504,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 +2090,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 +2202,27 @@ 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; - 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. */ - 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 identity, in the footer's timeline gutter ── */ +.track-panel-head { display: flex; align-items: flex-start; gap: 10px; min-width: 0; } -.footer-row-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 { - 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); } -.footer-row-actions .daw-fav-btn:hover { background: var(--panel-3); } -.footer-row-actions .footer-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. */ + 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 +2230,47 @@ 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; - gap: 10px 16px; - padding: 12px 18px 14px 0; + display: flex; align-items: flex-start; flex-wrap: nowrap; + 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 { display: flex; flex-direction: column; gap: 6px; - min-width: 0; + flex: none; } .footer-group-label { display: flex; align-items: center; gap: 7px; @@ -2218,14 +2280,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 +2294,28 @@ 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, 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 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; + display: flex; flex-direction: column; } .footer-wave-panel { border-top: 1px solid var(--border); @@ -2286,13 +2363,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 +2379,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; } +/* 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; @@ -2601,7 +2678,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 +2688,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 +2700,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 +2721,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 +2739,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 +3424,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..848675a4 100644 --- a/static/index.html +++ b/static/index.html @@ -174,23 +174,17 @@ - - +
- - - - - + 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..a1f83ea8 100644 --- a/static/js/transport.js +++ b/static/js/transport.js @@ -554,31 +554,6 @@ 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(); -} - // ─── Wire transport buttons ─── export function wireTransportButtons() { @@ -588,7 +563,6 @@ export function wireTransportButtons() { loopBtn.addEventListener("click", toggleLoop); wireLoopDrag(); wireLoopInputs(); - wireFooterDividers(); 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}) == {}