Skip to content
Merged
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
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ pymusiclooper -i play --url "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
pymusiclooper play-tagged --path "TRACK_NAME.mp3" --tag-names LOOP_START LOOP_END
```

*Note: if a file already has loop metadata tags (e.g. LOOP_START/LOOP_END or LOOPSTART/LOOPLENGTH), they are used as the first loop choice by all commands. Use `--ignore-tags` to skip them.*

### Export

*Note: batch processing is available for all export subcommands. Simply specify a directory instead of a file as the path to be used.*
Expand All @@ -145,6 +147,10 @@ pymusiclooper export-points --path "/path/to/track.wav" --alt-export-top -1
# (or all audio files in a directory, if a directory path is used instead)
pymusiclooper -i tag --path "TRACK_NAME.mp3" --tag-names LOOP_START LOOP_END

# Losslessly cut everything after the loop end (WAV, FLAC and OGG Vorbis only), keeping 1000 samples past it
# The original format, bit depth and tags are preserved; OGG files are cut without re-encoding
pymusiclooper -i trim --path "TRACK_NAME.wav" --keep-after 1000


# Export the loop points (in samples) of all tracks in a particular directory to a loops.txt file
# (compatible with https://github.com/libertyernie/LoopingAudioConverter/)
Expand Down
144 changes: 129 additions & 15 deletions pymusiclooper/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import librosa
import numpy as np
import scipy.signal
from numba import njit

from pymusiclooper.audio import MLAudio
Expand All @@ -20,6 +21,7 @@ class LoopPair:
note_distance: float
loudness_difference: float
score: float. Defaults to 0.
from_metadata: bool (loop points were read from the file's metadata tags instead of being detected). Defaults to False.
"""

_loop_start_frame_idx: int
Expand All @@ -29,6 +31,7 @@ class LoopPair:
score: float = 0
loop_start: int = 0
loop_end: int = 0
from_metadata: bool = False


def find_best_loop_points(
Expand Down Expand Up @@ -172,13 +175,11 @@ 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.
# Set the exact loop start and end in samples. The frame-level points are refined by lining up
# the waveforms at the loop points, then choosing the seam where they differ the least;
# if the waveforms do not match well enough for that, each point is moved to its nearest zero crossing.
# Avoids audio popping/clicking while looping as much as possible.
mono_playback_audio = mlaudio.playback_audio.mean(axis=1)
for pair in filtered_candidate_pairs:
if mlaudio.trim_offset > 0:
pair._loop_start_frame_idx = int(
Expand All @@ -187,22 +188,24 @@ def find_best_loop_points(
pair._loop_end_frame_idx = int(
mlaudio.apply_trim_offset(pair._loop_end_frame_idx)
)
pair.loop_start = nearest_zero_crossing(
mlaudio.playback_audio,
mlaudio.rate,
mlaudio.frames_to_samples(pair._loop_start_frame_idx)
)
pair.loop_end = nearest_zero_crossing(
pair.loop_start, pair.loop_end = _refine_loop_points(
mlaudio.playback_audio,
mono_playback_audio,
mlaudio.rate,
mlaudio.frames_to_samples(pair._loop_end_frame_idx)
int(mlaudio.frames_to_samples(pair._loop_start_frame_idx)),
int(mlaudio.frames_to_samples(pair._loop_end_frame_idx)),
)

if not filtered_candidate_pairs:
raise LoopNotFoundError(
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 +525,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 All @@ -534,6 +541,113 @@ def _weights(length: int, start: int = 100, stop: int = 1):
return np.geomspace(start, stop, num=length)


# STFT hop length used by the analysis (librosa's default); frame-level loop points are only this precise
_HOP_LENGTH = 512
# How far (in samples) the loop end may be moved to line up the waveforms
_ALIGNMENT_SEARCH_RADIUS = 2 * _HOP_LENGTH
# Length of the audio compared on each side of the loop points when aligning them (in seconds)
_ALIGNMENT_HALF_WINDOW = 0.075
# Below this correlation, the audio around the loop points does not match well enough
# for lining up the waveforms to be reliable (found by comparing both methods on real tracks)
_MIN_ALIGNMENT_CORRELATION = 0.3


def _refine_loop_points(
audio: np.ndarray, mono_audio: np.ndarray, rate: int, loop_start: int, loop_end: int
) -> Tuple[int, int]:
"""Refines frame-level loop points to exact sample positions.

Lines up the waveforms at the loop points, then picks the seam where they differ the least.
If the waveforms do not match well enough, each point is moved to its nearest zero crossing instead.

Args:
audio (np.ndarray): Playback audio, in the shape `(samples, n_channels)`
mono_audio (np.ndarray): The playback audio mixed down to mono, in the shape `(samples,)`
rate (int): Sample rate of the audio
loop_start (int): Approximate loop start in samples
loop_end (int): Approximate loop end in samples

Returns:
Tuple[int, int]: The refined (loop_start, loop_end)
"""
aligned_loop_end, correlation = _align_loop_end(mono_audio, rate, loop_start, loop_end)
if correlation >= _MIN_ALIGNMENT_CORRELATION:
return _best_seam(audio, rate, loop_start, aligned_loop_end)
return (
nearest_zero_crossing(audio, rate, loop_start),
nearest_zero_crossing(audio, rate, loop_end),
)


def _align_loop_end(mono_audio: np.ndarray, rate: int, loop_start: int, loop_end: int) -> Tuple[int, float]:
"""Moves the loop end (within `_ALIGNMENT_SEARCH_RADIUS` samples) to where the waveform around it best matches
the waveform around the loop start, using normalized cross-correlation.

Args:
mono_audio (np.ndarray): Mono playback audio, in the shape `(samples,)`
rate (int): Sample rate of the audio
loop_start (int): Loop start in samples
loop_end (int): Approximate loop end in samples

Returns:
Tuple[int, float]: The aligned loop end, and the correlation of the waveforms at that point (-1 to 1).
The correlation is 0 if there was not enough audio around the loop points to compare.
"""
# The loop end must stay after the loop start
radius = min(_ALIGNMENT_SEARCH_RADIUS, loop_end - loop_start - 1)
half_window = int(_ALIGNMENT_HALF_WINDOW * rate)
n_samples = mono_audio.shape[0]

# Compare as much audio as available on each side, up to half_window
before = min(half_window, loop_start, loop_end - radius)
after = min(half_window, n_samples - loop_start, n_samples - loop_end - radius)
if radius < 0 or before + after < _HOP_LENGTH:
return loop_end, 0.0

reference = mono_audio[loop_start - before:loop_start + after].astype(np.float64)
search = mono_audio[loop_end - before - radius:loop_end + after + radius].astype(np.float64)

# correlation[i] compares the reference with the search window shifted by (i - radius) samples
correlation = scipy.signal.correlate(search, reference, mode="valid", method="fft")
cumulative_energy = np.concatenate(([0.0], np.cumsum(search**2)))
window_energy = cumulative_energy[reference.size:] - cumulative_energy[:-reference.size]
normalized = correlation / np.sqrt(np.maximum(window_energy * np.dot(reference, reference), 1e-20))

best = int(np.argmax(normalized))
return loop_end + best - radius, float(normalized[best])


def _best_seam(audio: np.ndarray, rate: int, loop_start: int, loop_end: int) -> Tuple[int, int]:
"""Shifts both loop points by the same amount (keeping the loop length) to where the waveforms at the
loop start and loop end differ the least, within +/-5ms. The difference at the jump is what causes clicks.

Args:
audio (np.ndarray): Playback audio, in the shape `(samples, n_channels)`
rate (int): Sample rate of the audio
loop_start (int): Loop start in samples
loop_end (int): Loop end in samples

Returns:
Tuple[int, int]: The shifted (loop_start, loop_end)
"""
radius = max(1, rate // 200)
# Compare the two samples before and after each point
offsets = np.arange(-2, 2)
lowest_shift = max(-radius, -(loop_start + offsets[0]))
highest_shift = min(radius, audio.shape[0] - 1 - (loop_end + offsets[-1]))
if lowest_shift > highest_shift:
return loop_start, loop_end

shifts = np.arange(lowest_shift, highest_shift + 1)
idx = shifts[:, np.newaxis] + offsets[np.newaxis, :]
difference = np.abs(audio[loop_start + idx] - audio[loop_end + idx]).sum(axis=(1, 2))
# Prefer the smallest shift among equally good ones
difference += 1e-6 * np.abs(shifts)

shift = int(shifts[np.argmin(difference)])
return loop_start + shift, loop_end + shift


@njit(cache=True)
def nearest_zero_crossing(audio: np.ndarray, rate: int, sample_idx: int) -> int:
"""Implementation of Audacity's `At Zero Crossings` feature. https://manual.audacityteam.org/man/select_menu_at_zero_crossings.html
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
23 changes: 18 additions & 5 deletions pymusiclooper/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ def common_loop_options(f):
@click.option('--approx-loop-position', type=click.FloatRange(min=0), nargs=2, default=None, help='The approximate desired loop start and loop end in seconds. [dim]([cyan]+/-2[/] second search window for each point)[/]')
@click.option("--brute-force", is_flag=True, default=False, help=r"Check the entire audio track instead of just the detected beats. [dim yellow](Warning: may take several minutes to complete.)[/]")
@click.option("--disable-pruning", is_flag=True, default=False, help="Disables filtering of the detected loop points from the initial pass.")
@click.option("--ignore-tags", is_flag=True, default=False, help="Ignore loop points already stored in the file's metadata tags (e.g. LOOP_START/LOOP_END), which are otherwise used as the first choice.")

@functools.wraps(f)
def wrapper_common_options(*args, **kwargs):
Expand Down Expand Up @@ -123,12 +124,13 @@ def play(**kwargs):
start_time = handler.format_time(chosen_loop_pair.loop_start, in_samples=in_samples)
end_time = handler.format_time(chosen_loop_pair.loop_end, in_samples=in_samples)

source = (
"loop points from metadata tags"
if chosen_loop_pair.from_metadata
else f"similarity: {chosen_loop_pair.score:.2%}"
)
rich_console.print(
"\nPlaying with looping active from [green]{}[/] back to [green]{}[/]; similarity: {:.2%}".format(
end_time,
start_time,
chosen_loop_pair.score,
)
f"\nPlaying with looping active from [green]{end_time}[/] back to [green]{start_time}[/]; {source}"
)
rich_console.print("(Press [red]Ctrl+C[/] to stop looping.)")

Expand Down Expand Up @@ -226,6 +228,17 @@ def tag(**kwargs):
run_handler(**kwargs)


@cli_main.command()
@common_path_options
@common_loop_options
@common_export_options
@click.option('--keep-after', type=click.IntRange(min=0), default=0, show_default=True, help="Number of samples to keep after the loop end.")
def trim(**kwargs):
"""Losslessly cut the audio after the loop end, keeping its original format, bit depth and tags. [dim](WAV, FLAC and OGG Vorbis only)[/]"""
kwargs["trim"] = True
run_handler(**kwargs)


def run_handler(**kwargs):
try:
if kwargs.get("url", None) is not None:
Expand Down
3 changes: 3 additions & 0 deletions pymusiclooper/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"--approx-loop-position",
"--brute-force",
"--disable-pruning",
"--ignore-tags",
]
_export_options = ["--output-dir", "--format"]
_batch_options = ["--recursive", "--flatten"]
Expand Down Expand Up @@ -49,6 +50,7 @@ def _option_groups(additional_basic_options=None):
"pymusiclooper tag": _option_groups(["--tag-names", "--tag-offset"]),
"pymusiclooper export-points": _option_groups(["--export-to", "--alt-export-top", "--fmt"]),
"pymusiclooper extend": _option_groups(["--extended-length", "--fade-length", "--disable-fade-out"]),
"pymusiclooper trim": _option_groups(["--keep-after"]),
}
_COMMAND_GROUPS = {
"pymusiclooper": [
Expand All @@ -66,6 +68,7 @@ def _option_groups(additional_basic_options=None):
"split-audio",
"tag",
"extend",
"trim",
],
}
]
Expand Down
Loading
Loading