fea4fcf145
* feat(playback): count-in before playback and exports, redesign transport footer Count-in (#269): one bar of click count-in leads into playback and into audio exports, independent of the running click track (a clean backing track can still get a count-in). The lead-in math is defined once and mirrored between metronome.js and click_render.py, pinned by parity tests on both sides. - Playback: audioEngine schedules stem playback on a future ctx-time start so the count-in clicks land in the silent gap before the song begins; the metronome schedules them through the same clock mapping the running click already uses. - Export: stems are delayed via ffmpeg's adelay and the click WAV is rendered in output coordinates when a count-in is requested, so it isn't re-trimmed by the region -ss like a plain click. Also rebuilds the transport footer around labelled control groups (Transport, Position, Speed, Click Track) instead of a right-click popover: playback speed collapses to three practice presets (0.25x / 0.5x / 1x), the click track gets an on/off toggle and a count-in switch, and the track-info block collapses from four stacked detail rows to one compact line. * fix(ui): hide click-track panel by default before any track is loaded The panel lost its default "hidden" class when it changed from a right-click popover to always-inline (#269 follow-up) -- on a fresh page load, before any track was ever picked, nothing forced it hidden, so "Ready to import a track" showed a full set of live- looking click controls for a track that didn't exist. * polish(ui): footer wave time labels, orphan dividers, visible click-volume readout - Time labels above the footer's mini waveform, matching the main ruler. - Divider marks between control clusters in the footer's controls row, hidden via ResizeObserver when wrapping strands one at the end of a line with nothing after it to separate. - Click volume percentage shown next to the slider again instead of screen-reader-only -- a level you can only learn by hovering isn't one you can reliably match between sessions. - Count-in switched from a checkbox to a press-to-toggle button, matching the click on/off control beside it (both answer "is this on for the next play?", so they read as the same kind of control now). * fix(playback): count-in never armed on the chunked audio engine The chunked engine is the default playback path (engineMode() falls back to "chunked" unless a debug localStorage flag forces "fulldecode") -- but count-in support (play(leadIn), supportsCountIn, a clamped getCurrentTime during the lead-in) was only ever added to audioEngine.js, the full-decode path. Since _armCountIn() bails out whenever eng.supportsCountIn is falsy, count-in silently never armed for any track played through the engine essentially everyone actually uses, and playback started immediately regardless of the toggle. Mirrors the same fix in chunkedAudioEngine.js: play() accepts a leadIn and schedules the first chunk that far in the future (falling back to the existing 10ms/50ms margins when there is no count-in), and getCurrentTime() clamps to the start offset during that gap instead of reading negative. Verified directly against the running engine clock (not just DOM text, which rounds to whole seconds): the position holds at the start offset for the full lead-in and then advances normally, pausing mid-count-in stops cleanly with no phantom scheduled audio, and replaying re-arms a fresh count-in. * polish(ui): align the footer with the lane column, move track info into it The footer's waveform strip ran the full width of the window while the lane waveforms above it start after the 300px stems/mixer panel, so the same position sat at two different x positions in the two strips and neither ruler's ticks lined up with the other's. The footer is now two columns on the studio's own grid. Everything time-related -- the control clusters, the waveform, its ruler and the detection note -- sits in the right column and starts exactly where the lane waveforms start, running flush to the window edge like they do. The track identity (art, title, meta, favourite, Export Mix) moves into the left column under the mixer panel and shares its width and 14px padding, so titles, stem names and the "Mixer" heading share one left edge down the page. That also drops a whole row from the footer: 255px tall where the three stacked tiers were 318px. - The 300px is now --daw-col-w, read by the stems panel, the label cell above it and the footer, instead of being hardcoded in each. - The waveform strip is full-bleed with top/bottom rules rather than a rounded inset panel: a side border would have offset the canvas by its own width, which is exactly the misalignment being fixed. - Both rulers share tickStep(), so a time is labelled at the same x in each. - The export menu opens up and to the right; right-aligned from the left column it would have hung over the sidebar. Grid becomes a press-to-toggle button matching the click and count-in buttons beside it -- click opens the editor and lights it, click again closes it. Its lit state is synced inside toggleBeatGridEditor, the one place every open and close runs through, so Done, Escape and losing the beat grid all leave the button correct. The G shortcut is gone: the button says what it does now, and a single letter bound to a modal editor is easy to hit by accident. * polish(ui): close the footer waveform strip's open left edge The strip carries only top and bottom rules -- side borders were dropped so the canvas would land exactly on the lane waveforms' left edge -- which left its left end open, the two rules stopping in mid-air. Drawn as an outset box-shadow rather than a border-left: a border sits inside the box and would push the canvas a pixel off the alignment it exists to keep. The line falls on the same x as the stems panel's right border, so that seam now runs unbroken from the top of the mixer to the bottom of the strip. * fix(ui): ticking an export option no longer closes the export menu Every interactive element in the export menu called stopPropagation so the document-level dismiss handler would not fire, but the two option checkboxes had no click handler at all -- so ticking one bubbled out and closed the menu under the pointer. That was survivable with one checkbox. This branch adds a second ("Add count-in"), and wanting both is the normal case for practising to a click: the first tick closed the menu, and the second needed it reopened. Guard the panel itself rather than adding a third per-element stopPropagation that the next option added would forget: a click inside a menu is not a click away from it. Nothing depended on the bubble to close the menu -- the export actions close it themselves through enterBusy() -> closePanel(). --------- Co-authored-by: Thales <>
549 lines
19 KiB
Python
549 lines
19 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import wave
|
|
|
|
import numpy as np
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app.core.models import Job
|
|
from app.core.registry import _jobs
|
|
from app.pipeline.click_render import (
|
|
ACCENT_AUTO,
|
|
ACCENT_FREQ,
|
|
ACCENT_OFF,
|
|
CLICK_FREQ,
|
|
cache_key,
|
|
count_in_beats,
|
|
count_in_beats_per_bar,
|
|
is_downbeat,
|
|
render_click_wav,
|
|
render_count_in_wav,
|
|
rescale_beats,
|
|
source_index,
|
|
)
|
|
|
|
JOB = "abcdefabcdef"
|
|
SR = 44100
|
|
|
|
|
|
def _read(path):
|
|
with wave.open(str(path), "rb") as w:
|
|
assert w.getnchannels() == 1
|
|
assert w.getsampwidth() == 2
|
|
sr = w.getframerate()
|
|
data = np.frombuffer(w.readframes(w.getnframes()), dtype="<i2").astype(float) / 32767.0
|
|
return data, sr
|
|
|
|
|
|
def _click_starts(y, sr, threshold=0.2, min_gap=0.04):
|
|
"""Onset time of each click.
|
|
|
|
A click is a 1 kHz sine under a decaying envelope, so it crosses any
|
|
amplitude threshold about ten times per click. Grouping by a refractory
|
|
gap longer than one click (35 ms) collapses those crossings back to one
|
|
onset each.
|
|
|
|
The reported time lags the true start by up to a quarter cycle plus the
|
|
1 ms attack -- roughly 1.3 ms at 1 kHz -- which sets the measurement floor
|
|
for the assertions below.
|
|
"""
|
|
loud = np.flatnonzero(np.abs(y) > threshold)
|
|
if loud.size == 0:
|
|
return np.array([])
|
|
keep = [loud[0]]
|
|
for i in loud[1:]:
|
|
if i - keep[-1] > min_gap * sr:
|
|
keep.append(i)
|
|
return np.array(keep) / sr
|
|
|
|
|
|
def _dominant_freq(y, sr, at, dur=0.01):
|
|
seg = y[int(at * sr) : int((at + dur) * sr)]
|
|
if len(seg) < 8:
|
|
return 0.0
|
|
spec = np.abs(np.fft.rfft(seg * np.hanning(len(seg))))
|
|
return float(np.fft.rfftfreq(len(seg), 1 / sr)[int(np.argmax(spec))])
|
|
|
|
|
|
# --- rescale / index mapping ----------------------------------------------
|
|
|
|
|
|
def test_rescale_identity():
|
|
beats = [0.0, 0.5, 1.0]
|
|
assert rescale_beats(beats, 1.0) == beats
|
|
|
|
|
|
def test_rescale_doubles_by_inserting_midpoints():
|
|
assert rescale_beats([0.0, 0.5, 1.0], 2.0) == [0.0, 0.25, 0.5, 0.75, 1.0]
|
|
|
|
|
|
def test_rescale_halves_by_taking_every_other():
|
|
assert rescale_beats([0.0, 0.5, 1.0, 1.5, 2.0], 0.5) == [0.0, 1.0, 2.0]
|
|
|
|
|
|
def test_source_index_maps_back_to_the_detected_grid():
|
|
"""Bar marks index the detected beats, so accents must be decided there.
|
|
Without this the accent lands on the wrong beat at any rate but 1x."""
|
|
assert [source_index(i, 2.0) for i in range(5)] == [0, None, 1, None, 2]
|
|
assert [source_index(i, 0.5) for i in range(3)] == [0, 2, 4]
|
|
assert [source_index(i, 1.0) for i in range(3)] == [0, 1, 2]
|
|
|
|
|
|
# --- accent decisions ------------------------------------------------------
|
|
|
|
|
|
def test_accent_off_never_accents():
|
|
bars = [{"beat": 0, "beats_per_bar": 4}]
|
|
assert not any(is_downbeat(i, bars, ACCENT_OFF) for i in range(8))
|
|
|
|
|
|
def test_accent_fixed_count():
|
|
assert [is_downbeat(i, [], 3) for i in range(6)] == [True, False, False, True, False, False]
|
|
|
|
|
|
def test_accent_auto_follows_bar_marks():
|
|
bars = [{"beat": 2, "beats_per_bar": 4}]
|
|
assert [is_downbeat(i, bars, ACCENT_AUTO) for i in range(2, 11)] == [
|
|
True,
|
|
False,
|
|
False,
|
|
False,
|
|
True,
|
|
False,
|
|
False,
|
|
False,
|
|
True,
|
|
]
|
|
|
|
|
|
def test_accent_auto_handles_a_meter_change():
|
|
bars = [{"beat": 0, "beats_per_bar": 4}, {"beat": 8, "beats_per_bar": 3}]
|
|
assert is_downbeat(8, bars, ACCENT_AUTO)
|
|
assert is_downbeat(11, bars, ACCENT_AUTO)
|
|
assert not is_downbeat(12, bars, ACCENT_AUTO)
|
|
|
|
|
|
def test_accent_auto_without_marks_is_silent():
|
|
assert not is_downbeat(0, [], ACCENT_AUTO)
|
|
|
|
|
|
def test_accent_ignores_inserted_midpoints():
|
|
"""At x2 the odd entries belong to no detected beat and cannot be downbeats."""
|
|
assert not is_downbeat(source_index(1, 2.0), [{"beat": 0, "beats_per_bar": 4}], ACCENT_AUTO)
|
|
|
|
|
|
# --- count-in --------------------------------------------------------------
|
|
|
|
|
|
_STEADY = [0.5 + i * 0.5 for i in range(16)] # 120 BPM from 0.5 s
|
|
|
|
|
|
def test_count_in_one_bar_of_four():
|
|
"""PI po po po: four clicks, accent on the downbeat, ending one beat before
|
|
the audio so the song enters on the next downbeat."""
|
|
lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}])
|
|
assert lead_in == pytest.approx(2.0)
|
|
assert [a for _, a in clicks] == [True, False, False, False]
|
|
assert [round(o, 3) for o, _ in clicks] == [0.0, 0.5, 1.0, 1.5]
|
|
|
|
|
|
def test_count_in_follows_detected_meter():
|
|
lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 3}])
|
|
assert lead_in == pytest.approx(1.5)
|
|
assert [a for _, a in clicks] == [True, False, False]
|
|
|
|
|
|
def test_count_in_explicit_accent_sets_bar_length():
|
|
_, clicks = count_in_beats(_STEADY, [], accent_mode=4)
|
|
assert len(clicks) == 4
|
|
|
|
|
|
def test_count_in_defaults_to_four_without_marks():
|
|
_, clicks = count_in_beats(_STEADY, [], accent_mode=ACCENT_AUTO)
|
|
assert len(clicks) == 4
|
|
|
|
|
|
def test_count_in_two_bars_accents_each_downbeat():
|
|
lead_in, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], count_bars=2)
|
|
assert lead_in == pytest.approx(4.0)
|
|
assert [a for _, a in clicks] == [True, False, False, False, True, False, False, False]
|
|
|
|
|
|
def test_count_in_still_marks_the_downbeat_when_click_accents_are_off():
|
|
"""A count-in without a '1' is useless, so it accents its downbeat even when
|
|
the running click has accents switched off."""
|
|
_, clicks = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], accent_mode=ACCENT_OFF)
|
|
assert clicks[0][1] is True
|
|
|
|
|
|
def test_count_in_follows_the_rate_multiplier():
|
|
"""x2 corrects a half-time grid, so one bar is bpb clicks of the doubled
|
|
grid -- denser and shorter in wall time, still one musical bar."""
|
|
x2_lead, x2 = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], multiplier=2.0)
|
|
half_lead, half = count_in_beats(_STEADY, [{"beat": 0, "beats_per_bar": 4}], multiplier=0.5)
|
|
assert len(x2) == 4 and x2_lead == pytest.approx(1.0)
|
|
assert len(half) == 4 and half_lead == pytest.approx(4.0)
|
|
|
|
|
|
def test_count_in_empty_when_grid_too_short():
|
|
assert count_in_beats([0.5], [{"beat": 0, "beats_per_bar": 4}]) == (0.0, [])
|
|
|
|
|
|
def test_count_in_empty_when_disabled():
|
|
assert count_in_beats(_STEADY, [], count_bars=0) == (0.0, [])
|
|
|
|
|
|
def test_count_in_tempo_tracks_the_start_position():
|
|
"""A track that speeds up counts in at the local tempo, not the average."""
|
|
beats = [0.0, 1.0, 2.0, 3.0, 3.4, 3.8, 4.2, 4.6] # 60 BPM then 150 BPM
|
|
slow, _ = count_in_beats(beats, [{"beat": 0, "beats_per_bar": 4}], start=0.0)
|
|
fast, _ = count_in_beats(beats, [{"beat": 0, "beats_per_bar": 4}], start=3.4)
|
|
assert slow == pytest.approx(4.0) # 4 x 1.0 s
|
|
assert fast == pytest.approx(1.6) # 4 x 0.4 s
|
|
|
|
|
|
def test_count_in_beats_per_bar_prefers_explicit_then_meter_then_four():
|
|
assert count_in_beats_per_bar([{"beat": 0, "beats_per_bar": 3}], ACCENT_AUTO) == 3
|
|
assert count_in_beats_per_bar([{"beat": 0, "beats_per_bar": 3}], 6) == 6
|
|
assert count_in_beats_per_bar([], ACCENT_AUTO) == 4
|
|
|
|
|
|
# --- rendering -------------------------------------------------------------
|
|
|
|
|
|
def test_render_places_a_click_on_every_beat(tmp_path):
|
|
beats = [0.5 + i * 0.5 for i in range(10)]
|
|
out = render_click_wav(tmp_path / "c.wav", beats, [], duration=7.0)
|
|
y, sr = _read(out)
|
|
starts = _click_starts(y, sr)
|
|
assert len(starts) == len(beats)
|
|
# 3 ms covers the threshold-crossing lag described in _click_starts; a
|
|
# misplaced click would be out by a whole beat, not by milliseconds.
|
|
assert np.abs(starts - np.array(beats)).max() < 0.003, "clicks must land on the beats"
|
|
|
|
|
|
def test_render_spans_the_whole_track_even_when_beats_start_late(tmp_path):
|
|
"""The export's region trim puts -ss before every input, so the click has to
|
|
cover the full duration for the offsets to line up with the stems."""
|
|
out = render_click_wav(tmp_path / "c.wav", [5.0, 5.5, 6.0], [], duration=12.0)
|
|
y, sr = _read(out)
|
|
assert abs(len(y) / sr - 12.0) < 0.01
|
|
|
|
|
|
def test_render_accents_are_higher_pitched_and_louder(tmp_path):
|
|
beats = [0.5 + i * 0.5 for i in range(8)]
|
|
out = render_click_wav(
|
|
tmp_path / "c.wav", beats, [{"beat": 0, "beats_per_bar": 4}], duration=6.0
|
|
)
|
|
y, sr = _read(out)
|
|
assert abs(_dominant_freq(y, sr, beats[0]) - ACCENT_FREQ) < 120
|
|
assert abs(_dominant_freq(y, sr, beats[1]) - CLICK_FREQ) < 120
|
|
peak_at = lambda t: np.abs(y[int(t * sr) : int((t + 0.03) * sr)]).max() # noqa: E731
|
|
assert peak_at(beats[0]) > peak_at(beats[1])
|
|
|
|
|
|
def test_render_respects_the_rate_multiplier(tmp_path):
|
|
beats = [0.5 + i * 0.5 for i in range(8)]
|
|
doubled = render_click_wav(tmp_path / "d.wav", beats, [], duration=6.0, multiplier=2.0)
|
|
halved = render_click_wav(tmp_path / "h.wav", beats, [], duration=6.0, multiplier=0.5)
|
|
yd, sr = _read(doubled)
|
|
yh, _ = _read(halved)
|
|
assert len(_click_starts(yd, sr)) == 15
|
|
assert len(_click_starts(yh, sr)) == 4
|
|
|
|
|
|
def test_render_accent_mode_off_produces_one_voice(tmp_path):
|
|
beats = [0.5 + i * 0.5 for i in range(8)]
|
|
out = render_click_wav(
|
|
tmp_path / "c.wav",
|
|
beats,
|
|
[{"beat": 0, "beats_per_bar": 4}],
|
|
duration=6.0,
|
|
accent_mode=ACCENT_OFF,
|
|
)
|
|
y, sr = _read(out)
|
|
freqs = [_dominant_freq(y, sr, b) for b in beats]
|
|
assert all(abs(f - CLICK_FREQ) < 120 for f in freqs), freqs
|
|
|
|
|
|
def test_render_returns_none_without_beats(tmp_path):
|
|
assert render_click_wav(tmp_path / "c.wav", [], [], duration=5.0) is None
|
|
|
|
|
|
def test_render_returns_none_for_zero_duration(tmp_path):
|
|
assert render_click_wav(tmp_path / "c.wav", [0.0, 0.5], [], duration=0.0) is None
|
|
|
|
|
|
def test_render_never_clips(tmp_path):
|
|
"""Clicks overlap at very fast tempos; the sum must stay in range."""
|
|
beats = [0.1 + i * 0.01 for i in range(200)]
|
|
out = render_click_wav(tmp_path / "c.wav", beats, [], duration=4.0)
|
|
y, _ = _read(out)
|
|
assert np.abs(y).max() <= 1.0
|
|
|
|
|
|
def test_render_leaves_no_temp_file(tmp_path):
|
|
render_click_wav(tmp_path / "c.wav", [0.5, 1.0, 1.5], [], duration=3.0)
|
|
assert list(tmp_path.glob("*.tmp")) == []
|
|
|
|
|
|
def test_render_is_deterministic(tmp_path):
|
|
beats = [0.5 + i * 0.5 for i in range(6)]
|
|
a = render_click_wav(tmp_path / "a.wav", beats, [], duration=5.0)
|
|
b = render_click_wav(tmp_path / "b.wav", beats, [], duration=5.0)
|
|
assert a.read_bytes() == b.read_bytes()
|
|
|
|
|
|
# --- count-in render -------------------------------------------------------
|
|
|
|
|
|
def test_count_in_render_prepends_lead_in(tmp_path):
|
|
"""The count-in clicks occupy the front of the file and the song clicks
|
|
follow, shifted by the lead-in."""
|
|
beats = [0.5 + i * 0.5 for i in range(8)] # 120 BPM
|
|
out = render_count_in_wav(
|
|
tmp_path / "ci.wav",
|
|
beats,
|
|
[{"beat": 0, "beats_per_bar": 4}],
|
|
duration=6.0,
|
|
count_in_bars=1,
|
|
include_click=True,
|
|
)
|
|
assert out is not None
|
|
path, lead_in = out
|
|
assert lead_in == pytest.approx(2.0) # 4 beats x 0.5 s
|
|
y, sr = _read(path)
|
|
# File spans lead-in + song.
|
|
assert abs(len(y) / sr - (lead_in + 6.0)) < 0.02
|
|
starts = _click_starts(y, sr)
|
|
# Four count-in clicks in [0, lead_in), then the song grid shifted by lead_in.
|
|
count_in_hits = starts[starts < lead_in - 0.05]
|
|
assert len(count_in_hits) == 4
|
|
assert count_in_hits[0] < 0.01 # first click at the very start
|
|
# The song's first beat now sounds one lead-in later.
|
|
assert np.any(np.abs(starts - (beats[0] + lead_in)) < 0.01)
|
|
|
|
|
|
def test_count_in_render_downbeat_is_accented(tmp_path):
|
|
beats = [0.5 + i * 0.5 for i in range(8)]
|
|
out = render_count_in_wav(
|
|
tmp_path / "ci.wav",
|
|
beats,
|
|
[{"beat": 0, "beats_per_bar": 4}],
|
|
duration=6.0,
|
|
count_in_bars=1,
|
|
include_click=False,
|
|
)
|
|
path, _ = out
|
|
y, sr = _read(path)
|
|
assert abs(_dominant_freq(y, sr, 0.0) - ACCENT_FREQ) < 150
|
|
|
|
|
|
def test_count_in_render_without_song_click_is_only_the_lead_in(tmp_path):
|
|
"""count-in on, click off: the export gets a count-in and nothing else on
|
|
top of the (clean) stems -- the user's Reaper workflow, in one step."""
|
|
beats = [0.5 + i * 0.5 for i in range(8)]
|
|
out = render_count_in_wav(
|
|
tmp_path / "ci.wav",
|
|
beats,
|
|
[{"beat": 0, "beats_per_bar": 4}],
|
|
duration=6.0,
|
|
count_in_bars=1,
|
|
include_click=False,
|
|
)
|
|
path, lead_in = out
|
|
y, sr = _read(path)
|
|
# Only the four count-in clicks; silence over the song body.
|
|
assert len(_click_starts(y, sr)) == 4
|
|
|
|
|
|
def test_count_in_render_none_when_grid_too_short(tmp_path):
|
|
assert (
|
|
render_count_in_wav(
|
|
tmp_path / "ci.wav", [0.5], [], duration=6.0, count_in_bars=1, include_click=False
|
|
)
|
|
is None
|
|
)
|
|
|
|
|
|
# --- cache key -------------------------------------------------------------
|
|
|
|
|
|
def test_cache_key_separates_edited_grids():
|
|
"""An edited grid must not hit an entry rendered from the detected one."""
|
|
a = cache_key(JOB, [0.0, 0.5], [], 5.0, SR, 1.0, ACCENT_AUTO)
|
|
b = cache_key(JOB, [0.0, 0.6], [], 5.0, SR, 1.0, ACCENT_AUTO)
|
|
assert a != b
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"kwargs",
|
|
[
|
|
{"multiplier": 2.0},
|
|
{"accent_mode": 4},
|
|
{"sample_rate": 48000},
|
|
{"bars": [{"beat": 0, "beats_per_bar": 3}]},
|
|
],
|
|
)
|
|
def test_cache_key_covers_every_render_input(kwargs):
|
|
base = {
|
|
"job_id": JOB,
|
|
"beats": [0.0, 0.5],
|
|
"bars": [],
|
|
"duration": 5.0,
|
|
"sample_rate": SR,
|
|
"multiplier": 1.0,
|
|
"accent_mode": ACCENT_AUTO,
|
|
}
|
|
assert cache_key(**base) != cache_key(**{**base, **kwargs})
|
|
|
|
|
|
# --- export endpoint -------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_registry():
|
|
_jobs.clear()
|
|
yield
|
|
_jobs.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client(tmp_path, monkeypatch):
|
|
from app.api import stems as stems_mod
|
|
|
|
monkeypatch.setattr(stems_mod, "JOBS_DIR", tmp_path)
|
|
monkeypatch.setattr(stems_mod, "_MIXDOWN_CACHE_DIR", tmp_path / "cache" / "mixdown")
|
|
monkeypatch.setattr(stems_mod, "_CLICK_CACHE_DIR", tmp_path / "cache" / "click")
|
|
from app.main import app
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
def _setup_job(tmp_path, with_grid=True):
|
|
job = Job(id=JOB)
|
|
job.status = "done"
|
|
_jobs[job.id] = job
|
|
stems = tmp_path / JOB / "stems"
|
|
stems.mkdir(parents=True, exist_ok=True)
|
|
if with_grid:
|
|
(stems / "beats.json").write_text(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"beats": [0.5, 1.0, 1.5, 2.0],
|
|
"bars": [{"beat": 0, "beats_per_bar": 4}],
|
|
"duration": 3.0,
|
|
}
|
|
),
|
|
encoding="utf-8",
|
|
)
|
|
return stems
|
|
|
|
|
|
def test_click_lane_is_off_by_default(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path)
|
|
assert stems_mod._click_lane(JOB, False, 1.0, ACCENT_AUTO, 0.6) is None
|
|
|
|
|
|
def test_click_lane_renders_when_enabled(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path)
|
|
lane = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6)
|
|
assert lane is not None
|
|
assert lane.path.is_file()
|
|
assert lane.gain == pytest.approx(0.6)
|
|
assert lane.lead_in == 0.0
|
|
assert lane.count_in is False
|
|
|
|
|
|
def test_click_lane_is_none_without_a_beat_grid(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path, with_grid=False)
|
|
assert stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6) is None
|
|
|
|
|
|
def test_click_lane_prefers_user_edits(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
stems = _setup_job(tmp_path)
|
|
detected = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6)
|
|
(stems / "beats.user.json").write_text(
|
|
json.dumps({"beats": [0.25, 0.75, 1.25], "bars": []}), encoding="utf-8"
|
|
)
|
|
edited = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6)
|
|
assert edited is not None
|
|
assert edited[0].name != detected[0].name, "edited grid must render its own click"
|
|
|
|
|
|
def test_click_gain_is_clamped(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path)
|
|
assert stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 99.0)[1] == 4.0
|
|
assert stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, -5.0)[1] == 0.0
|
|
|
|
|
|
def test_mixdown_cache_key_separates_click_from_clean(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path)
|
|
lane = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6)
|
|
clean = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, None)
|
|
clicked = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, lane)
|
|
assert clean != clicked, "a click export must never reuse a clean render"
|
|
|
|
|
|
@pytest.mark.parametrize("bad", ["click_accent=99", "click_accent=-2", "count_in=3", "count_in=-1"])
|
|
def test_mixdown_rejects_out_of_range_click_params(client, tmp_path, bad):
|
|
_setup_job(tmp_path)
|
|
(tmp_path / JOB / "stems" / "drums.wav").write_bytes(b"RIFF")
|
|
r = client.get(f"/api/jobs/{JOB}/mixdown.wav?stems=drums&gains=1.0&click=1&{bad}")
|
|
assert r.status_code == 422
|
|
|
|
|
|
def test_click_lane_count_in_bakes_the_lead_in(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path) # beats 0.5..2.0 at 0.5 s, 4/4
|
|
lane = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1)
|
|
assert lane is not None
|
|
assert lane.count_in is True
|
|
assert lane.lead_in == pytest.approx(2.0) # 4 beats x 0.5 s
|
|
assert lane.path.is_file()
|
|
|
|
|
|
def test_click_lane_count_in_without_click(client, tmp_path):
|
|
"""count-in on, click off still yields a lane -- the count-in only."""
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path)
|
|
lane = stems_mod._click_lane(JOB, False, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1)
|
|
assert lane is not None
|
|
assert lane.count_in is True
|
|
assert lane.lead_in == pytest.approx(2.0)
|
|
|
|
|
|
def test_click_lane_count_in_is_none_without_a_beat_grid(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path, with_grid=False)
|
|
assert stems_mod._click_lane(JOB, False, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1) is None
|
|
|
|
|
|
def test_mixdown_cache_key_separates_count_in_from_plain_click(client, tmp_path):
|
|
from app.api import stems as stems_mod
|
|
|
|
_setup_job(tmp_path)
|
|
plain = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6)
|
|
counted = stems_mod._click_lane(JOB, True, 1.0, ACCENT_AUTO, 0.6, count_in_bars=1)
|
|
a = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, plain)
|
|
b = stems_mod._mixdown_cache_key(JOB, "wav", ["drums"], [1.0], None, None, counted)
|
|
assert a != b, "a count-in export must never reuse a plain click render"
|