Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
15 changes: 10 additions & 5 deletions pymusiclooper/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion pymusiclooper/audio.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
19 changes: 12 additions & 7 deletions pymusiclooper/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] + (
Expand Down Expand Up @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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)

Expand Down
9 changes: 4 additions & 5 deletions pymusiclooper/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 8 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,3 +64,11 @@ select = [
# isort
"I",
]

[dependency-groups]
dev = [
"pytest>=9.1.1",
]

[tool.pytest.ini_options]
testpaths = ["tests"]
84 changes: 84 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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"
)
Loading