- VOCAL PRESENCE
+ GLOBAL VOCALS
+ —
+
+
+
+ LEAD VOCALS
+ —
+
+
+ BACKING VOCALS
—
@@ -596,118 +608,15 @@
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}) == {}