From 53dd94b20a9f91620e002695b099cc7a990e2f79 Mon Sep 17 00:00:00 2001 From: Xehanort88 Date: Wed, 23 Sep 2026 13:50:24 +0100 Subject: [PATCH 1/2] Add test suite Tests generate their own audio: an intro followed by a repeated 8-note pattern, so the correct loop points are known in advance and loop detection can be checked for correctness, not just for running. Covers audio loading, loop detection and scoring, zero-crossing snapping, split/extend/tag/txt exports, the interactive picker, batch file discovery and every CLI command. Known bugs are marked as strict xfail, so each fix is flagged until its marker is removed. Also adds pytest as a dev dependency and a CI workflow running the tests on Ubuntu and Windows with Python 3.10 and 3.13. --- .github/workflows/tests.yml | 25 +++++ pyproject.toml | 8 ++ tests/conftest.py | 84 +++++++++++++++++ tests/test_analysis.py | 176 ++++++++++++++++++++++++++++++++++++ tests/test_audio.py | 72 +++++++++++++++ tests/test_cli.py | 93 +++++++++++++++++++ tests/test_core.py | 116 ++++++++++++++++++++++++ tests/test_handler.py | 82 +++++++++++++++++ uv.lock | 110 ++++++++++++++++++++++ 9 files changed, 766 insertions(+) create mode 100644 .github/workflows/tests.yml create mode 100644 tests/conftest.py create mode 100644 tests/test_analysis.py create mode 100644 tests/test_audio.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_core.py create mode 100644 tests/test_handler.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..bfadfed --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + python-version: ["3.10", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up uv + uses: astral-sh/setup-uv@v6 + with: + python-version: ${{ matrix.python-version }} + - name: Run tests + run: uv run pytest diff --git a/pyproject.toml b/pyproject.toml index 9b37b98..1fc9414 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -64,3 +64,11 @@ select = [ # isort "I", ] + +[dependency-groups] +dev = [ + "pytest>=9.1.1", +] + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..ef930f1 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,84 @@ +"""Shared fixtures: synthetic tracks whose correct loop points are known in advance. + +The test track is an intro followed by the same 8-note pattern repeated several times, +so any loop whose length is a whole number of patterns is seamless. +""" + +import numpy as np +import pytest +import soundfile as sf + +SR = 22050 +BEAT_SAMPLES = SR // 2 # 120 bpm +INTRO_NOTES = [50, 53, 57, 59] +PATTERN_NOTES = [60, 64, 67, 72, 69, 65, 62, 67] +PATTERN_REPEATS = 4 + +INTRO_SAMPLES = len(INTRO_NOTES) * BEAT_SAMPLES +PATTERN_SAMPLES = len(PATTERN_NOTES) * BEAT_SAMPLES + +# Loop points are located on ~23ms STFT frames, then nudged to a zero crossing (+/-5ms) +SAMPLE_TOLERANCE = int(0.03 * SR) + + +def _note(midi: int) -> np.ndarray: + t = np.arange(BEAT_SAMPLES) / SR + freq = 440.0 * 2 ** ((midi - 69) / 12) + tone = sum(np.sin(2 * np.pi * freq * h * t) / h for h in range(1, 5)) + attack = np.minimum(1.0, t / 0.005) + return tone * attack * np.exp(-4 * t) + + +def make_track() -> np.ndarray: + notes = INTRO_NOTES + PATTERN_NOTES * PATTERN_REPEATS + PATTERN_NOTES[:1] + y = np.concatenate([_note(n) for n in notes]) + return (0.5 * y / np.max(np.abs(y))).astype(np.float32) + + +@pytest.fixture(scope="session") +def track() -> np.ndarray: + return make_track() + + +@pytest.fixture(scope="session") +def audio_dir(tmp_path_factory): + return tmp_path_factory.mktemp("audio") + + +@pytest.fixture(scope="session") +def track_path(audio_dir, track): + path = audio_dir / "track.wav" + sf.write(path, track, SR, subtype="FLOAT") + return str(path) + + +@pytest.fixture(scope="session") +def stereo_track_path(audio_dir, track): + path = audio_dir / "stereo.wav" + sf.write(path, np.stack([track, 0.8 * track], axis=1), SR, subtype="FLOAT") + return str(path) + + +@pytest.fixture(scope="session") +def silent_path(audio_dir): + path = audio_dir / "silent.wav" + sf.write(path, np.zeros(SR, dtype=np.float32), SR) + return str(path) + + +@pytest.fixture +def flac_track_path(tmp_path, track): + """A fresh FLAC copy per test, since tagging tests write next to it.""" + path = tmp_path / "track.flac" + sf.write(path, track, SR, format="FLAC") + return str(path) + + +def assert_whole_patterns(loop_start: int, loop_end: int): + """Asserts that a loop spans a whole number of repeated patterns.""" + length = loop_end - loop_start + n_patterns = round(length / PATTERN_SAMPLES) + assert n_patterns >= 1, f"loop of {length} samples is shorter than one pattern" + assert abs(length - n_patterns * PATTERN_SAMPLES) <= SAMPLE_TOLERANCE, ( + f"loop of {length} samples is not a multiple of the {PATTERN_SAMPLES}-sample pattern" + ) diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..1682e5c --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,176 @@ +import numpy as np +import pytest +from conftest import INTRO_SAMPLES, SAMPLE_TOLERANCE, SR, assert_whole_patterns + +from pymusiclooper import analysis +from pymusiclooper.analysis import ( + LoopPair, + _calculate_loop_score, + _calculate_subseq_beat_similarity, + _prioritize_duration, + _weights, + nearest_zero_crossing, +) +from pymusiclooper.core import MusicLooper +from pymusiclooper.exceptions import LoopNotFoundError + + +@pytest.fixture(scope="module") +def looper(track_path): + return MusicLooper(track_path) + + +@pytest.fixture(scope="module") +def pairs(looper): + return looper.find_loop_pairs() + + +# --- End-to-end loop detection --- + + +def test_best_loop_spans_whole_patterns(pairs): + best = pairs[0] + assert_whole_patterns(best.loop_start, best.loop_end) + assert best.loop_start >= INTRO_SAMPLES - SAMPLE_TOLERANCE, "loop should not start inside the intro" + + +def test_best_loop_scores_highly(pairs): + assert pairs[0].score > 0.95 + + +def test_pairs_are_sorted_ordered_and_in_bounds(looper, pairs): + scores = [pair.score for pair in pairs[1:]] + assert scores == sorted(scores, reverse=True) + for pair in pairs: + assert 0 <= pair.loop_start < pair.loop_end <= looper.mlaudio.length + + +def test_default_min_duration_is_35_percent_of_track(looper, pairs): + min_samples = 0.35 * looper.mlaudio.length + assert all(p.loop_end - p.loop_start >= min_samples - SAMPLE_TOLERANCE - SR for p in pairs) + + +def test_min_and_max_loop_duration_respected(looper): + pairs = looper.find_loop_pairs(min_loop_duration=5, max_loop_duration=9) + for pair in pairs: + seconds = (pair.loop_end - pair.loop_start) / SR + assert 5 - 0.05 <= seconds <= 9 + 0.05 + assert_whole_patterns(pairs[0].loop_start, pairs[0].loop_end) + + +def test_approx_loop_position_respected(looper): + pairs = looper.find_loop_pairs(approx_loop_start=6.0, approx_loop_end=14.0) + best = pairs[0] + assert abs(best.loop_start / SR - 6.0) <= 2.05 + assert abs(best.loop_end / SR - 14.0) <= 2.05 + assert_whole_patterns(best.loop_start, best.loop_end) + + +def test_disable_pruning_keeps_at_least_as_many_pairs(looper, pairs): + unpruned = looper.find_loop_pairs(disable_pruning=True) + assert len(unpruned) >= len(pairs) + + +def test_impossible_constraints_raise(looper): + with pytest.raises(LoopNotFoundError): + looper.find_loop_pairs(min_loop_duration=60) + + +@pytest.mark.xfail(strict=True, reason="bug: _prioritize_duration runs before loop_start/loop_end are set, so it never reorders anything") +def test_prioritize_duration_sees_real_loop_positions(monkeypatch, looper): + durations_seen = [] + original = analysis._prioritize_duration + + def spy(pair_list): + durations_seen.extend(p.loop_end - p.loop_start for p in pair_list) + return original(pair_list) + + monkeypatch.setattr(analysis, "_prioritize_duration", spy) + looper.find_loop_pairs() + + assert durations_seen, "_prioritize_duration was not called" + assert max(durations_seen) > 0 + + +# --- Scoring --- + + +def _pair(start, end, score, loudness=0.1): + return LoopPair(0, 0, note_distance=0.0, loudness_difference=loudness, score=score, loop_start=start, loop_end=end) + + +def test_prioritize_duration_prefers_longest_among_tied_scores(): + pairs = [_pair(0, 100, 1.0), _pair(0, 300, 1.0), _pair(0, 900, 0.5)] + _prioritize_duration(pairs) + assert pairs[0].loop_end == 300 + + +def test_prioritize_duration_keeps_clearly_better_score_first(): + pairs = [_pair(0, 100, 0.99), _pair(0, 300, 0.90)] + _prioritize_duration(pairs) + assert pairs[0].loop_end == 100 + + +def _random_chroma(n_frames=200, seed=0): + return np.random.default_rng(seed).random((12, n_frames)) + + +def test_identical_sequences_score_one(): + chroma = _random_chroma() + chroma[:, 100:150] = chroma[:, 20:70] + score = _calculate_loop_score(30, 110, chroma, test_duration=20, weights=_weights(20, start=5)) + assert score == pytest.approx(1.0) + + +def test_unrelated_sequences_score_lower(): + chroma = _random_chroma() + chroma[:, 100:150] = chroma[:, 20:70] + matching = _calculate_loop_score(30, 110, chroma, test_duration=20, weights=_weights(20, start=5)) + unrelated = _calculate_loop_score(30, 160, chroma, test_duration=20, weights=_weights(20, start=5)) + assert unrelated < matching + + +@pytest.mark.xfail(strict=True, reason="bug: truncated look-behind windows are zero-padded on the side nearest the loop point") +def test_truncated_lookbehind_weights_frames_nearest_the_loop_point(): + chroma = _random_chroma() + # Only 3 frames exist before b1=3; they match the 3 frames before b2=100 + chroma[:, 0:3] = chroma[:, 97:100] + weights = _weights(20, start=10)[::-1] # heaviest weight on the frame right before the loop point + + score = _calculate_subseq_beat_similarity(3, 100, chroma, -20, weights=weights) + + assert score == pytest.approx(weights[-3:].sum() / weights.sum()) + + +# --- Zero crossings --- + + +def _sine(freq=100.0, seconds=1.0, channels=1): + t = np.arange(int(seconds * SR)) / SR + y = np.sin(2 * np.pi * freq * t) + return np.tile(y[:, np.newaxis], (1, channels)) + + +def _assert_rising_zero_crossing(audio, idx): + assert abs(audio[idx, 0]) < 0.05 + assert audio[idx + 1, 0] > audio[idx, 0] + + +@pytest.mark.parametrize("channels", [1, 2]) +def test_zero_crossing_snaps_to_nearby_rising_crossing(channels): + audio = _sine(channels=channels) + idx = nearest_zero_crossing(audio, SR, 1000) + assert abs(idx - 1000) <= SR // 200 + _assert_rising_zero_crossing(audio, idx) + + +def test_zero_crossing_near_start_of_audio(): + audio = _sine() + idx = nearest_zero_crossing(audio, SR, 30) + assert 0 <= idx <= 30 + SR // 200 + _assert_rising_zero_crossing(audio, idx) + + +def test_zero_crossing_keeps_index_without_a_crossing(): + audio = np.full((SR, 1), 0.8) + assert nearest_zero_crossing(audio, SR, 1000) == 1000 diff --git a/tests/test_audio.py b/tests/test_audio.py new file mode 100644 index 0000000..63c0816 --- /dev/null +++ b/tests/test_audio.py @@ -0,0 +1,72 @@ +import numpy as np +import pytest +import soundfile as sf +from conftest import SR, make_track + +from pymusiclooper.audio import MLAudio +from pymusiclooper.exceptions import AudioLoadError + + +def test_loads_mono(track_path, track): + audio = MLAudio(track_path) + assert audio.rate == SR + assert audio.n_channels == 1 + assert audio.length == track.size + assert audio.playback_audio.shape == (track.size, 1) + assert audio.total_duration == pytest.approx(track.size / SR) + assert audio.filename == "track.wav" + + +def test_loads_stereo(stereo_track_path, track): + audio = MLAudio(stereo_track_path) + assert audio.n_channels == 2 + assert audio.playback_audio.shape == (track.size, 2) + + +def test_analysis_signal_is_normalized_mono(stereo_track_path): + audio = MLAudio(stereo_track_path) + assert audio.audio.ndim == 1 + assert np.max(np.abs(audio.audio)) == pytest.approx(1.0) + + +@pytest.mark.xfail(strict=True, reason="bug: for mono input, to_mono returns the same array, so normalizing it in place also rescales the playback/export audio") +def test_playback_audio_is_untouched(track_path, track): + audio = MLAudio(track_path) + np.testing.assert_array_equal(audio.playback_audio[:, 0], track) + + +def test_silent_file_raises(silent_path): + with pytest.raises(AudioLoadError): + MLAudio(silent_path) + + +def test_non_audio_file_raises(tmp_path): + path = tmp_path / "notes.txt" + path.write_text("not audio") + with pytest.raises(AudioLoadError): + MLAudio(str(path)) + + +def test_leading_silence_is_trimmed_on_frame_boundary(tmp_path): + silence = np.zeros(SR + 1234, dtype=np.float32) + path = tmp_path / "padded.wav" + sf.write(path, np.concatenate([silence, make_track()]), SR, subtype="FLOAT") + + audio = MLAudio(str(path)) + + assert 0 < audio.trim_offset <= silence.size + # apply_trim_offset round-trips through frames, which is only exact + # because librosa trims on hop-length (512 sample) boundaries + assert audio.trim_offset % 512 == 0 + assert audio.frames_to_samples(audio.apply_trim_offset(10)) == ( + audio.frames_to_samples(10) + audio.trim_offset + ) + + +def test_time_conversions(track_path): + audio = MLAudio(track_path) + assert audio.seconds_to_samples(1) == SR + assert audio.samples_to_seconds(SR) == pytest.approx(1.0) + assert audio.frames_to_samples(1) == 512 + assert audio.samples_to_frames(1024) == 2 + assert audio.samples_to_ftime(int(61.5 * SR)) == "01:01.500" diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a4765e1 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,93 @@ +import re +import shutil + +import pytest +from click.testing import CliRunner +from conftest import SR, assert_whole_patterns + +from pymusiclooper import __version__ +from pymusiclooper.cli import cli_main + + +@pytest.fixture +def run(): + def invoke(*args): + result = CliRunner().invoke(cli_main, [str(arg) for arg in args]) + assert result.exception is None or isinstance(result.exception, SystemExit), result.output + return result + return invoke + + +def test_version(run): + result = run("--version") + assert result.exit_code == 0 + assert __version__ in result.output + + +def test_path_is_required(run): + result = run("export-points") + assert result.exit_code != 0 + + +def test_export_points_to_stdout(run, track_path): + result = run("export-points", "--path", track_path) + + assert result.exit_code == 0 + loop_start = int(re.search(r"LOOP_START: (\d+)", result.output).group(1)) + loop_end = int(re.search(r"LOOP_END: (\d+)", result.output).group(1)) + assert_whole_patterns(loop_start, loop_end) + + +def test_export_points_in_seconds(run, track_path): + result = run("export-points", "--path", track_path, "--fmt", "SECONDS") + + loop_start = float(re.search(r"LOOP_START: ([\d.]+)", result.output).group(1)) + loop_end = float(re.search(r"LOOP_END: ([\d.]+)", result.output).group(1)) + assert_whole_patterns(round(loop_start * SR), round(loop_end * SR)) + + +def test_export_points_alt_export_top(run, track_path): + result = run("export-points", "--path", track_path, "--alt-export-top", "3") + + lines = result.output.strip().splitlines() + assert len(lines) == 3 + assert all(len(line.split()) == 5 for line in lines) + + +def test_export_points_to_txt(run, track_path, tmp_path): + run("export-points", "--path", track_path, "--export-to", "TXT", "--output-dir", tmp_path) + + lines = (tmp_path / "loops.txt").read_text().splitlines() + assert len(lines) == 1 + assert lines[0].endswith(" track.wav") + + +def test_split_audio(run, track_path, tmp_path): + run("split-audio", "--path", track_path, "--output-dir", tmp_path) + + assert sorted(p.name for p in tmp_path.iterdir()) == [ + "track.wav-intro.wav", + "track.wav-loop.wav", + "track.wav-outro.wav", + ] + + +def test_extend(run, track_path, tmp_path): + run("extend", "--path", track_path, "--output-dir", tmp_path, "--extended-length", 45, "--format", "WAV") + + assert [p.name for p in tmp_path.iterdir()] == ["track.wav-extended-0m45s.wav"] + + +def test_batch_export_skips_non_audio_files(run, track_path, stereo_track_path, tmp_path): + in_dir = tmp_path / "in" + out_dir = tmp_path / "out" + in_dir.mkdir() + shutil.copy(track_path, in_dir) + shutil.copy(stereo_track_path, in_dir) + (in_dir / "notes.txt").write_text("not audio") + + result = run("export-points", "--path", in_dir, "--export-to", "TXT", "--output-dir", out_dir) + + assert result.exit_code == 0 + lines = (out_dir / "loops.txt").read_text().splitlines() + assert sorted(line.split()[-1] for line in lines) == ["stereo.wav", "track.wav"] diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..7c833eb --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,116 @@ +import os + +import numpy as np +import pytest +import soundfile as sf +import taglib +from conftest import INTRO_SAMPLES, PATTERN_SAMPLES, SR + +from pymusiclooper.core import MusicLooper + +LOOP_START = INTRO_SAMPLES +LOOP_END = INTRO_SAMPLES + 2 * PATTERN_SAMPLES + + +@pytest.fixture(scope="module") +def looper(track_path): + return MusicLooper(track_path) + + +# --- Split / extend --- + + +def test_export_splits_into_intro_loop_outro(looper, track, tmp_path): + looper.export(LOOP_START, LOOP_END, format="WAV", output_dir=str(tmp_path)) + + sections = [ + sf.read(tmp_path / f"track.wav-{name}.wav")[0] for name in ("intro", "loop", "outro") + ] + + assert [s.shape[0] for s in sections] == [LOOP_START, LOOP_END - LOOP_START, track.size - LOOP_END] + np.testing.assert_allclose(np.concatenate(sections), looper.mlaudio.playback_audio[:, 0], atol=1e-4) + + +def test_extend_with_fade_out_reaches_requested_length(looper, tmp_path): + output_path = looper.extend(LOOP_START, LOOP_END, extended_length=60, fade_length=3, format="WAV", output_dir=str(tmp_path)) + + extended, rate = sf.read(output_path) + assert rate == SR + assert extended.shape[0] / SR == pytest.approx(60, abs=0.05) + assert np.max(np.abs(extended[-100:])) < 0.01, "track should fade out to silence" + assert os.path.basename(output_path) == "track.wav-extended-1m00s.wav" + + +def test_extend_without_fade_out_keeps_outro(looper, tmp_path): + output_path = looper.extend(LOOP_START, LOOP_END, extended_length=60, disable_fade_out=True, format="WAV", output_dir=str(tmp_path)) + + extended, _ = sf.read(output_path) + outro = looper.mlaudio.playback_audio[LOOP_END:, 0] + assert extended.shape[0] / SR >= 60 + np.testing.assert_allclose(extended[-outro.size:], outro, atol=1e-4) + + +def test_extend_shorter_than_track_raises(looper, tmp_path): + with pytest.raises(ValueError): + looper.extend(LOOP_START, LOOP_END, extended_length=5, output_dir=str(tmp_path)) + + +@pytest.mark.xfail(strict=True, reason="bug: fade_length=0 slices the whole final loop section (x[-0:]) and fails to broadcast") +def test_extend_with_zero_fade_length(looper, tmp_path): + looper.extend(LOOP_START, LOOP_END, extended_length=60, fade_length=0, format="WAV", output_dir=str(tmp_path)) + + +# --- Loop point text export --- + + +def test_export_txt_appends_lines(looper, tmp_path): + looper.export_txt(LOOP_START, LOOP_END, output_dir=str(tmp_path)) + looper.export_txt(1, 2, output_dir=str(tmp_path)) + + lines = (tmp_path / "loops.txt").read_text().splitlines() + assert lines == [f"{LOOP_START} {LOOP_END} track.wav", "1 2 track.wav"] + + +# --- Metadata tags --- + + +@pytest.mark.parametrize( + ("start_tag", "end_tag", "stored_end"), + [ + ("LOOP_START", "LOOP_END", LOOP_END), + ("LOOPSTART", "LOOPLENGTH", LOOP_END - LOOP_START), + ], +) +def test_tags_roundtrip(flac_track_path, tmp_path, start_tag, end_tag, stored_end): + out_dir = tmp_path / "out" + out_dir.mkdir() + + written = MusicLooper(flac_track_path).export_tags(LOOP_START, LOOP_END, start_tag, end_tag, output_dir=str(out_dir)) + assert written == (str(LOOP_START), str(stored_end)) + + tagged = MusicLooper(str(out_dir / "track-tagged.flac")) + assert tagged.read_tags(start_tag, end_tag) == (LOOP_START, LOOP_END) + assert tagged.read_tags(None, None) == (LOOP_START, LOOP_END), "tags should be auto-detected" + + +def test_read_tags_without_loop_tags_raises(flac_track_path): + with pytest.raises(ValueError): + MusicLooper(flac_track_path).read_tags(None, None) + + +@pytest.mark.xfail(strict=True, reason="bug: export_tags defaults output_dir to the source file path instead of its directory") +def test_export_tags_defaults_to_source_directory(flac_track_path, tmp_path): + MusicLooper(flac_track_path).export_tags(LOOP_START, LOOP_END, "LOOP_START", "LOOP_END") + assert (tmp_path / "track-tagged.flac").exists() + + +def test_extend_copies_source_tags(flac_track_path, tmp_path): + with taglib.File(flac_track_path, save_on_exit=True) as source: + source.tags["TITLE"] = ["Test Track"] + + output_path = MusicLooper(flac_track_path).extend( + LOOP_START, LOOP_END, extended_length=60, format="FLAC", output_dir=str(tmp_path) + ) + + with taglib.File(output_path) as extended: + assert extended.tags["TITLE"] == ["Test Track"] diff --git a/tests/test_handler.py b/tests/test_handler.py new file mode 100644 index 0000000..5f33702 --- /dev/null +++ b/tests/test_handler.py @@ -0,0 +1,82 @@ +import os + +import pytest + +from pymusiclooper import handler +from pymusiclooper.analysis import LoopPair +from pymusiclooper.core import MusicLooper +from pymusiclooper.handler import BatchHandler, LoopExportHandler, LoopHandler + + +def _bare_loop_handler(n_pairs=30): + """A LoopHandler with preset loop pairs, skipping audio loading and analysis.""" + loop_handler = LoopHandler.__new__(LoopHandler) + loop_handler.loop_pair_list = [ + LoopPair(0, 0, note_distance=0.0, loudness_difference=0.0, score=1 - i / 100, loop_start=i, loop_end=i + 1000) + for i in range(n_pairs) + ] + loop_handler.filepath = "track.wav" + loop_handler.in_samples = True + loop_handler._musiclooper = None + loop_handler._progressbar = None + return loop_handler + + +@pytest.fixture +def fake_input(monkeypatch): + """Feeds the given answers to the interactive prompt, in order.""" + def feed(*answers): + remaining = iter(answers) + monkeypatch.setattr(handler.rich_console, "input", lambda *args, **kwargs: next(remaining)) + return feed + + +def test_interactive_selects_entered_index(fake_input): + fake_input("3") + assert _bare_loop_handler().interactive_handler() == 3 + + +def test_interactive_reprompts_on_invalid_input(fake_input): + fake_input("abc", "", "99", "4") + assert _bare_loop_handler().interactive_handler() == 4 + + +@pytest.mark.xfail(strict=True, reason="bug: the choice made after 'more'/'all'/'reset' is discarded and the user is prompted again") +def test_interactive_more_then_select(fake_input): + fake_input("more", "27", "1") + assert _bare_loop_handler().interactive_handler() == 27 + + +def test_choose_loop_pair_defaults_to_best(): + loop_handler = _bare_loop_handler() + assert loop_handler.choose_loop_pair(interactive_mode=False) is loop_handler.loop_pair_list[0] + + +@pytest.mark.xfail(strict=True, reason="bug: success message names loop.txt but the file written is loops.txt") +def test_txt_export_message_names_written_file(monkeypatch, track_path, tmp_path): + export_handler = LoopExportHandler.__new__(LoopExportHandler) + export_handler._musiclooper = MusicLooper(track_path) + export_handler.output_directory = str(tmp_path) + export_handler.alt_export_top = 0 + export_handler.fmt = "samples" + export_handler.batch_mode = False + + messages = [] + monkeypatch.setattr(handler.rich_console, "print", lambda msg, *args, **kwargs: messages.append(msg)) + export_handler.txt_export_runner(100, 200) + + written = os.listdir(tmp_path) + assert written == ["loops.txt"] + assert str(tmp_path / "loops.txt") in messages[0] + + +def test_get_files_in_directory(tmp_path): + (tmp_path / "a.wav").touch() + (tmp_path / "sub").mkdir() + (tmp_path / "sub" / "b.wav").touch() + + flat = BatchHandler.get_files_in_directory(str(tmp_path)) + recursive = BatchHandler.get_files_in_directory(str(tmp_path), recursive=True) + + assert flat == [str(tmp_path / "a.wav")] + assert sorted(recursive) == sorted([str(tmp_path / "a.wav"), str(tmp_path / "sub" / "b.wav")]) diff --git a/uv.lock b/uv.lock index 784e524..6f288ca 100644 --- a/uv.lock +++ b/uv.lock @@ -324,6 +324,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + [[package]] name = "idna" version = "3.11" @@ -333,6 +345,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "joblib" version = "1.5.2" @@ -693,6 +714,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pooch" version = "1.8.2" @@ -745,6 +775,11 @@ dependencies = [ { name = "yt-dlp" }, ] +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + [package.metadata] requires-dist = [ { name = "click-option-group", specifier = ">=0.5.6,<0.6" }, @@ -761,6 +796,9 @@ requires-dist = [ { name = "yt-dlp", specifier = ">=2025.1.26" }, ] +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=9.1.1" }] + [[package]] name = "pytaglib" version = "3.0.1" @@ -789,6 +827,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/87/d9/a81226deee5f687edc5e914b7c0a4f1f2cfd1302fb265be37025cf9b352b/pytaglib-3.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:a65f310df7cd78e4218a7bb581da7775192fc81dfcb2612da1ed93091c8671b9", size = 256377, upload-time = "2025-03-21T22:32:37.669Z" }, ] +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + [[package]] name = "requests" version = "2.32.5" @@ -1122,6 +1178,60 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" }, ] +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0" From a715a60d849d30b44eb33a5ac8b7b3277015c20b Mon Sep 17 00:00:00 2001 From: Xehanort88 Date: Wed, 23 Sep 2026 13:50:34 +0100 Subject: [PATCH 2/2] Fix bugs found by the test suite - Mono tracks were played and exported louder than the original: the analysis-only normalization was applied in place to the audio array that is also used for playback, since to_mono returns its input as-is for mono audio. - Longer loops were never preferred among near-identical scores: _prioritize_duration ran before loop_start/loop_end were set, so every duration it compared was 0. - Loops starting in the first seconds of a track were underscored: the truncated look-behind window was zero-padded on the side nearest the loop point, where the weights are heaviest. - Interactive mode discarded the choice made after 'more', 'all' or 'reset' and prompted again. - extend with fade_length=0 crashed (x[-0:] selects the whole array). - export_tags() without output_dir used the file path as the directory. - The txt export message named loop.txt instead of loops.txt. --- pymusiclooper/analysis.py | 15 ++++++++++----- pymusiclooper/audio.py | 3 ++- pymusiclooper/core.py | 19 ++++++++++++------- pymusiclooper/handler.py | 9 ++++----- tests/test_analysis.py | 2 -- tests/test_audio.py | 1 - tests/test_core.py | 2 -- tests/test_handler.py | 2 -- 8 files changed, 28 insertions(+), 25 deletions(-) diff --git a/pymusiclooper/analysis.py b/pymusiclooper/analysis.py index 6940db2..4745ae9 100644 --- a/pymusiclooper/analysis.py +++ b/pymusiclooper/analysis.py @@ -172,10 +172,6 @@ def find_best_loop_points( mlaudio, chroma, bpm, candidate_pairs, disable_pruning ) - # prefer longer loops for highly similar sequences - if len(filtered_candidate_pairs) > 1: - _prioritize_duration(filtered_candidate_pairs) - # Set the exact loop start and end in samples and adjust them # to the nearest zero crossing. Avoids audio popping/clicking while looping # as much as possible. @@ -203,6 +199,11 @@ def find_best_loop_points( f"No loop points found for {mlaudio.filename} with current parameters." ) + # prefer longer loops for highly similar sequences + # (must run after the loop positions in samples are set, since it compares loop durations) + if len(filtered_candidate_pairs) > 1: + _prioritize_duration(filtered_candidate_pairs) + logging.info( f"Filtered to {len(filtered_candidate_pairs)} best candidate loop points" ) @@ -522,8 +523,12 @@ def _calculate_subseq_beat_similarity( cosine_sim = dot_prod / (np.maximum(b1_norm * b2_norm, 1e-10)) if max_offset < test_length: + # Pad the missing frames on the side farthest from the loop point: + # after the tested frames when looking ahead, before them when looking behind + missing_frames = test_length - max_offset + pad_width = (missing_frames, 0) if test_end_offset < 0 else (0, missing_frames) return np.average( - np.pad(cosine_sim, pad_width=(0, test_length - max_offset), mode="constant", constant_values=0), + np.pad(cosine_sim, pad_width=pad_width, mode="constant", constant_values=0), weights=weights, ) else: diff --git a/pymusiclooper/audio.py b/pymusiclooper/audio.py index 138613b..1742e08 100644 --- a/pymusiclooper/audio.py +++ b/pymusiclooper/audio.py @@ -48,7 +48,8 @@ def __init__(self, filepath: str) -> None: raise AudioLoadError(f"\"{filepath}\" only contains silence and cannot be analyzed.") # Normalize audio channels to between -1.0 and +1.0 before analysis - mono_signal /= np.max(np.abs(mono_signal)) + # (not in-place: for mono input, to_mono returns raw_audio itself, which is also used for playback/export) + mono_signal = mono_signal / np.max(np.abs(mono_signal)) self.audio, self.trim_offset = librosa.effects.trim(mono_signal, top_db=40) self.trim_offset = self.trim_offset[0] diff --git a/pymusiclooper/core.py b/pymusiclooper/core.py index 82819d2..2ca953e 100644 --- a/pymusiclooper/core.py +++ b/pymusiclooper/core.py @@ -206,10 +206,12 @@ def extend( samples_to_fade = min( self.mlaudio.seconds_to_samples(fade_length), final_loop.shape[0] ) - final_loop[-samples_to_fade:] = ( - final_loop[-samples_to_fade:] - * np.linspace(1, 0, samples_to_fade)[:, np.newaxis] - ) + # Guard against x[-0:], which would select the whole section + if samples_to_fade > 0: + final_loop[-samples_to_fade:] = ( + final_loop[-samples_to_fade:] + * np.linspace(1, 0, samples_to_fade)[:, np.newaxis] + ) # Format extended file name with its duration suffixed extended_loop_length = final_loop.shape[0] + ( @@ -272,9 +274,10 @@ def export_txt( loop_end: Union[str, int, float, str], txt_name: str = "loops", output_dir: Optional[str] = None - ): - """Exports the given loop points to a text file named `loop.txt` in append mode with the format: + ) -> str: + """Exports the given loop points to a text file named `loops.txt` in append mode with the format: `{loop_start} {loop_end} {filename}` + Returns the path to the text file. Args: loop_start (Union[int, float, str]): Loop start in samples, seconds or ftime. @@ -290,6 +293,8 @@ def export_txt( with open(out_path, "a") as file: file.write(f"{loop_start} {loop_end} {self.mlaudio.filename}\n") + return out_path + def _find_start_tag( self, @@ -377,7 +382,7 @@ def export_tags( import taglib if output_dir is None: - output_dir = os.path.abspath(self.mlaudio.filepath) + output_dir = os.path.dirname(os.path.abspath(self.mlaudio.filepath)) track_name, file_extension = os.path.splitext(self.mlaudio.filename) diff --git a/pymusiclooper/handler.py b/pymusiclooper/handler.py index 2d4c148..e514d06 100644 --- a/pymusiclooper/handler.py +++ b/pymusiclooper/handler.py @@ -132,11 +132,11 @@ def get_user_input(): preview = False if num_input == "more": - self.interactive_handler(show_top=show_top * 2) + return self.interactive_handler(show_top=show_top * 2) if num_input == "all": - self.interactive_handler(show_top=total_candidates) + return self.interactive_handler(show_top=total_candidates) if num_input == "reset": - self.interactive_handler() + return self.interactive_handler() if num_input[-1] == "p": idx = int(num_input[:-1]) @@ -322,12 +322,11 @@ def txt_export_runner(self, loop_start: int, loop_end: int): if self.alt_export_top != 0: self.alt_export_runner(mode="TXT") else: - self.musiclooper.export_txt( + out_path = self.musiclooper.export_txt( self._fmt(loop_start), self._fmt(loop_end), output_dir=self.output_directory, ) - out_path = os.path.join(self.output_directory, "loop.txt") message = f'Successfully added "{self.musiclooper.filename}" loop points to "{out_path}"' if self.batch_mode: logging.info(message) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 1682e5c..37c34c2 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -76,7 +76,6 @@ def test_impossible_constraints_raise(looper): looper.find_loop_pairs(min_loop_duration=60) -@pytest.mark.xfail(strict=True, reason="bug: _prioritize_duration runs before loop_start/loop_end are set, so it never reorders anything") def test_prioritize_duration_sees_real_loop_positions(monkeypatch, looper): durations_seen = [] original = analysis._prioritize_duration @@ -130,7 +129,6 @@ def test_unrelated_sequences_score_lower(): assert unrelated < matching -@pytest.mark.xfail(strict=True, reason="bug: truncated look-behind windows are zero-padded on the side nearest the loop point") def test_truncated_lookbehind_weights_frames_nearest_the_loop_point(): chroma = _random_chroma() # Only 3 frames exist before b1=3; they match the 3 frames before b2=100 diff --git a/tests/test_audio.py b/tests/test_audio.py index 63c0816..d8b714f 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -29,7 +29,6 @@ def test_analysis_signal_is_normalized_mono(stereo_track_path): assert np.max(np.abs(audio.audio)) == pytest.approx(1.0) -@pytest.mark.xfail(strict=True, reason="bug: for mono input, to_mono returns the same array, so normalizing it in place also rescales the playback/export audio") def test_playback_audio_is_untouched(track_path, track): audio = MLAudio(track_path) np.testing.assert_array_equal(audio.playback_audio[:, 0], track) diff --git a/tests/test_core.py b/tests/test_core.py index 7c833eb..931fa60 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -55,7 +55,6 @@ def test_extend_shorter_than_track_raises(looper, tmp_path): looper.extend(LOOP_START, LOOP_END, extended_length=5, output_dir=str(tmp_path)) -@pytest.mark.xfail(strict=True, reason="bug: fade_length=0 slices the whole final loop section (x[-0:]) and fails to broadcast") def test_extend_with_zero_fade_length(looper, tmp_path): looper.extend(LOOP_START, LOOP_END, extended_length=60, fade_length=0, format="WAV", output_dir=str(tmp_path)) @@ -98,7 +97,6 @@ def test_read_tags_without_loop_tags_raises(flac_track_path): MusicLooper(flac_track_path).read_tags(None, None) -@pytest.mark.xfail(strict=True, reason="bug: export_tags defaults output_dir to the source file path instead of its directory") def test_export_tags_defaults_to_source_directory(flac_track_path, tmp_path): MusicLooper(flac_track_path).export_tags(LOOP_START, LOOP_END, "LOOP_START", "LOOP_END") assert (tmp_path / "track-tagged.flac").exists() diff --git a/tests/test_handler.py b/tests/test_handler.py index 5f33702..61a0ad8 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -41,7 +41,6 @@ def test_interactive_reprompts_on_invalid_input(fake_input): assert _bare_loop_handler().interactive_handler() == 4 -@pytest.mark.xfail(strict=True, reason="bug: the choice made after 'more'/'all'/'reset' is discarded and the user is prompted again") def test_interactive_more_then_select(fake_input): fake_input("more", "27", "1") assert _bare_loop_handler().interactive_handler() == 27 @@ -52,7 +51,6 @@ def test_choose_loop_pair_defaults_to_best(): assert loop_handler.choose_loop_pair(interactive_mode=False) is loop_handler.loop_pair_list[0] -@pytest.mark.xfail(strict=True, reason="bug: success message names loop.txt but the file written is loops.txt") def test_txt_export_message_names_written_file(monkeypatch, track_path, tmp_path): export_handler = LoopExportHandler.__new__(LoopExportHandler) export_handler._musiclooper = MusicLooper(track_path)