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
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
17 changes: 12 additions & 5 deletions pymusiclooper/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,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 +30,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,10 +174,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 +201,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 +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 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