Files
Tha.Les 6eb1741362 perf(pipeline): single-pass streamed peaks + presence (#306)
app/pipeline/audio_stats.py: new scan_stem() does one streamed pass over
a stem WAV via sf.blocks() -- [min, max] per bucket (waveform peaks) and
RMS (stem presence), both from the same blocks. Constant memory: a
block is a few MB even for a 20-minute stereo stem, vs. sf.read()'s full
in-memory load (~420 MB for the same file, done for up to 8 files
back-to-back right after Demucs has already stressed memory -- a
plausible contributor to OOM failures on memory-constrained machines).

collect.compute_stem_peaks now delegates to scan_stem and returns each
stem's RMS from the same pass; peaks.json's format and bucketing are
unchanged (floor-division chunking, matching the old implementation
bucket-for-bucket -- verified by a golden test comparing against the old
sf.read()-then-chunk reference).

runner._run_common now derives stem_presence from that RMS map (moved
out of analyze.compute_stem_presence, which is deleted along with its
separate ffmpeg-downmix decode of every stem) instead of decoding each
stem twice.

Known, accepted delta: presence RMS is now measured over the full
stem at full sample rate, vs. the old ffmpeg-downmixed mono decode
capped at the first 180s. On a real 220s track this shifted some
quiet-stem presence values by up to ~6 points (piano 1->6, other
12->19) -- larger than initially estimated, but a strict accuracy
improvement (whole track, not a 3-minute window), not a regression.

Closes #286
Closes #287

Co-authored-by: Thales <>
2026-07-17 12:02:11 +01:00

171 lines
5.5 KiB
Python

from __future__ import annotations
import json
import struct
import wave
from pathlib import Path
import numpy as np
import pytest
import soundfile as sf
from app.pipeline.collect import _PEAK_POINTS, compute_stem_peaks
def _write_wav(path: Path, samples: list[float], sample_rate: int = 44100) -> None:
"""Write a mono 16-bit PCM WAV file."""
with wave.open(str(path), "w") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(sample_rate)
data = struct.pack(f"<{len(samples)}h", *[int(s * 32767) for s in samples])
wf.writeframes(data)
def test_produces_peaks_json(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
# 1-second sine wave at 440 Hz
sr = 44100
t = np.linspace(0, 1, sr, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 0.5).tolist()
_write_wav(stems_dir / "vocals.wav", samples, sr)
compute_stem_peaks(stems_dir, ["vocals"])
peaks_path = stems_dir / "peaks.json"
assert peaks_path.is_file()
data = json.loads(peaks_path.read_text())
assert "vocals" in data
pts = data["vocals"]
assert len(pts) <= _PEAK_POINTS
assert len(pts) > 0
# each point is [min, max] with min <= 0 <= max (sine wave)
for mn, mx in pts:
assert mn <= mx
assert -1.0 <= mn <= 1.0
assert -1.0 <= mx <= 1.0
def test_multiple_stems(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
for name in ("vocals", "drums", "bass"):
_write_wav(stems_dir / f"{name}.wav", [0.1, -0.1, 0.2, -0.2])
compute_stem_peaks(stems_dir, ["vocals", "drums", "bass"])
data = json.loads((stems_dir / "peaks.json").read_text())
assert set(data.keys()) == {"vocals", "drums", "bass"}
def test_skips_missing_wav(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
# "vocals.wav" intentionally absent
compute_stem_peaks(stems_dir, ["vocals", "drums"])
data = json.loads((stems_dir / "peaks.json").read_text())
assert "drums" in data
assert "vocals" not in data
def test_no_output_when_all_stems_missing(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
compute_stem_peaks(stems_dir, ["vocals", "drums"])
assert not (stems_dir / "peaks.json").exists()
def test_writes_atomically(tmp_path):
"""No partial peaks.json.tmp should survive a successful run."""
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
_write_wav(stems_dir / "vocals.wav", [0.1, -0.1, 0.3])
compute_stem_peaks(stems_dir, ["vocals"])
assert (stems_dir / "peaks.json").is_file()
assert not (stems_dir / "peaks.json.tmp").exists()
def test_non_fatal_on_corrupt_wav(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
(stems_dir / "vocals.wav").write_bytes(b"not a wav file at all")
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
# Should not raise; drums should still be computed
rms_values = compute_stem_peaks(stems_dir, ["vocals", "drums"])
data = json.loads((stems_dir / "peaks.json").read_text())
assert "drums" in data
assert "vocals" not in data
assert "drums" in rms_values
assert "vocals" not in rms_values
# ─── #287: RMS returned from the same streamed pass ──────────────────────────
def test_returns_rms_matching_full_load_reference(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
sr = 44100
t = np.linspace(0, 2, sr * 2, endpoint=False)
samples = (np.sin(2 * np.pi * 440 * t) * 0.6).tolist()
_write_wav(stems_dir / "vocals.wav", samples, sr)
rms_values = compute_stem_peaks(stems_dir, ["vocals"])
reference, _ = sf.read(stems_dir / "vocals.wav", dtype="float32", always_2d=True)
expected_rms = float(np.sqrt(np.mean(reference[:, 0].astype(np.float64) ** 2)))
assert rms_values["vocals"] == pytest.approx(expected_rms, rel=1e-3)
def test_missing_stem_excluded_from_rms(tmp_path):
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
_write_wav(stems_dir / "drums.wav", [0.1, -0.1])
rms_values = compute_stem_peaks(stems_dir, ["vocals", "drums"])
assert "drums" in rms_values
assert "vocals" not in rms_values
def test_peaks_match_full_load_reference(tmp_path):
"""Golden test: the streamed implementation's peaks must match the old
full-load (sf.read + manual chunking) implementation within float
tolerance for a multi-tone signal."""
stems_dir = tmp_path / "stems"
stems_dir.mkdir()
sr = 44100
t = np.linspace(0, 3, sr * 3, endpoint=False)
samples = (0.5 * np.sin(2 * np.pi * 220 * t) + 0.3 * np.sin(2 * np.pi * 1760 * t)).tolist()
_write_wav(stems_dir / "vocals.wav", samples, sr)
compute_stem_peaks(stems_dir, ["vocals"])
actual = json.loads((stems_dir / "peaks.json").read_text())["vocals"]
# Reference: the old sf.read()-then-chunk implementation.
data, _ = sf.read(stems_dir / "vocals.wav", dtype="float32", always_2d=True)
ch = data[:, 0]
n = len(ch)
chunk = max(1, n // _PEAK_POINTS)
expected = []
for i in range(0, n, chunk):
block = ch[i : i + chunk]
expected.append([float(np.min(block)), float(np.max(block))])
expected = expected[:_PEAK_POINTS]
assert len(actual) == len(expected)
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)