From d38601207599d7ace1719973ab95d4c494150fef Mon Sep 17 00:00:00 2001 From: Xehanort88 Date: Sat, 26 Sep 2026 10:34:33 +0100 Subject: [PATCH 1/4] Add --tags-only to skip the loop analysis for tagged files, and a batch summary --tags-only uses only the loop points stored in the file's metadata tags, without analyzing the audio; files without valid loop tags are skipped. Batch runs now end with a summary of the processed files (with loop points from their tags or detected ones), and of the skipped and failed files grouped by reason. --- README.md | 8 ++- pymusiclooper/audio.py | 4 +- pymusiclooper/cli.py | 5 ++ pymusiclooper/console.py | 1 + pymusiclooper/core.py | 33 +++++++++--- pymusiclooper/exceptions.py | 18 ++++++- pymusiclooper/handler.py | 105 +++++++++++++++++++++++++++++++++--- tests/test_cli.py | 29 ++++++++++ tests/test_core.py | 35 ++++++++++++ tests/test_handler.py | 81 ++++++++++++++++++++++++++++ 10 files changed, 300 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 30fd3b7..83ec210 100644 --- a/README.md +++ b/README.md @@ -119,11 +119,11 @@ 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), or is a WAV file with a loop in its sampler (`smpl`) chunk, those loop points are used as the first loop choice by all commands. Use `--ignore-tags` to skip them.* +*Note: if a file already has loop metadata tags (e.g. LOOP_START/LOOP_END or LOOPSTART/LOOPLENGTH), or is a WAV file with a loop in its sampler (`smpl`) chunk, those loop points are used as the first loop choice by all commands. Use `--ignore-tags` to skip them, or `--tags-only` to use only them and skip the (slower) loop analysis; with `--tags-only`, files without valid loop tags are skipped.* ### Export -*Note: batch processing is available for all export subcommands. Simply specify a directory instead of a file as the path to be used.* +*Note: batch processing is available for all export subcommands. Simply specify a directory instead of a file as the path to be used. A summary at the end lists how many files were processed (with loop points from their tags or detected ones), and which were skipped or failed and why.* ```sh # Split the audio track into intro, loop and outro files. @@ -164,6 +164,10 @@ pymusiclooper -i tag --path "TRACK_NAME.ogg" --tag-names LOOPSTART LOOPLENGTH # (compatible with https://github.com/libertyernie/LoopingAudioConverter/) # Note: each line in loop.txt follows the following format: {loop-start} {loop-end} {filename} pymusiclooper -i export-points --path "/path/to/dir/" --export-to txt + +# Trim all the tracks of a directory that already have loop tags, using only their tags (no loop analysis); +# tracks without valid loop tags are skipped and listed in the summary at the end +pymusiclooper trim --path "/path/to/dir/" --tags-only --output-dir "/path/to/trimmed/" ``` ### Miscellaneous diff --git a/pymusiclooper/audio.py b/pymusiclooper/audio.py index 1742e08..a25f0b7 100644 --- a/pymusiclooper/audio.py +++ b/pymusiclooper/audio.py @@ -3,7 +3,7 @@ import librosa import numpy as np -from pymusiclooper.exceptions import AudioLoadError +from pymusiclooper.exceptions import AudioLoadError, NotAudioError class MLAudio: @@ -32,7 +32,7 @@ def __init__(self, filepath: str) -> None: try: raw_audio, sampling_rate = librosa.load(filepath, sr=None, mono=False) except Exception as e: - raise AudioLoadError(f"{os.path.basename(filepath)} could not be loaded. It might not contain valid audio data, or is in an supported format.") from e + raise NotAudioError(f"{os.path.basename(filepath)} could not be loaded. It might not contain valid audio data, or is in an supported format.") from e self.total_duration = librosa.get_duration(y=raw_audio, sr=sampling_rate) diff --git a/pymusiclooper/cli.py b/pymusiclooper/cli.py index 73563ad..b9389c8 100644 --- a/pymusiclooper/cli.py +++ b/pymusiclooper/cli.py @@ -78,9 +78,14 @@ def common_loop_options(f): @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.") + @click.option("--tags-only", is_flag=True, default=False, help="Only use the loop points stored in the file's metadata tags, skipping the loop analysis; files without valid loop tags are skipped. [dim](Much faster for files that are already tagged.)[/]") @functools.wraps(f) def wrapper_common_options(*args, **kwargs): + if kwargs.get("tags_only") and kwargs.get("ignore_tags"): + raise click.UsageError("--tags-only and --ignore-tags cannot be used together.") + if kwargs.get("tags_only") and kwargs.get("approx_loop_position"): + raise click.UsageError("--tags-only and --approx-loop-position cannot be used together.") return f(*args, **kwargs) return wrapper_common_options diff --git a/pymusiclooper/console.py b/pymusiclooper/console.py index 377ad2a..8030dc0 100644 --- a/pymusiclooper/console.py +++ b/pymusiclooper/console.py @@ -13,6 +13,7 @@ "--brute-force", "--disable-pruning", "--ignore-tags", + "--tags-only", ] _export_options = ["--output-dir", "--format"] _batch_options = ["--recursive", "--flatten"] diff --git a/pymusiclooper/core.py b/pymusiclooper/core.py index 71d6c62..93a5cbf 100644 --- a/pymusiclooper/core.py +++ b/pymusiclooper/core.py @@ -44,6 +44,7 @@ def find_loop_pairs( brute_force: bool = False, disable_pruning: bool = False, use_embedded_tags: bool = True, + tags_only: bool = False, ) -> List[LoopPair]: """Finds the best loop points for the track, according to the parameters specified. If the file already has loop points stored in its metadata tags, they are returned as the first choice, @@ -58,13 +59,17 @@ def find_loop_pairs( brute_force (bool, optional): Checks the entire track instead of the detected beats (disclaimer: runtime may be significantly longer). Defaults to False. disable_pruning (bool, optional): Returns all the candidate loop points without filtering. Defaults to False. use_embedded_tags (bool, optional): Places the loop points found in the file's metadata tags (if any) first in the returned list. Ignored if an approximate loop position is specified. Defaults to True. + tags_only (bool, optional): Returns only the loop points found in the file's metadata tags, without analyzing the audio (the other parameters are then ignored). Defaults to False. Raises: - LoopNotFoundError: raised in case no loops were found + LoopNotFoundError: raised in case no loops were found, or with `tags_only`, if the file has no valid loop tags Returns: List[LoopPair]: A list of `LoopPair` objects containing the loop points related data. See the `LoopPair` class for more info. """ + if tags_only: + return [self.read_embedded_loop_pair(required=True)] + embedded_pair = None if use_embedded_tags and approx_loop_start is None and approx_loop_end is None: embedded_pair = self.read_embedded_loop_pair() @@ -97,24 +102,40 @@ def find_loop_pairs( return loop_pairs - def read_embedded_loop_pair(self) -> Optional[LoopPair]: + def read_embedded_loop_pair(self, required: bool = False) -> Optional[LoopPair]: """Reads the loop points stored in the file's metadata tags (e.g. LOOP_START/LOOP_END), auto-detecting the tag names, or else in a WAV file's sampler (smpl) chunk. + Args: + required (bool, optional): Raise a `LoopNotFoundError` explaining why, instead of returning None, + if the file has no valid loop tags. Defaults to False. + Returns: Optional[LoopPair]: A `LoopPair` with `from_metadata=True`, or None if the file has no valid loop tags. """ try: loop_start, loop_end = self.read_tags(None, None) except Exception as e: + if required: + # read_tags raises a ValueError for missing tags, and a TypeError for non-integer values + reason = {ValueError: "missing loop tags", TypeError: "invalid loop tag values"}.get( + type(e), "loop tags could not be read" + ) + # The errors of read_tags already name the file + raise LoopNotFoundError(str(e), reason=reason) from e logging.debug(f"No embedded loop points read from \"{self.filename}\": {e}") return None if not 0 <= loop_start < loop_end <= self.mlaudio.length: - logging.warning( - f"Ignoring embedded loop points of \"{self.filename}\" ({loop_start}, {loop_end}):" - f" outside the audio's range of {self.mlaudio.length} samples." + detail = ( + f"{self.samples_to_ftime(loop_start)} -> {self.samples_to_ftime(loop_end)}" + f" ({loop_start} -> {loop_end} samples); the audio is {self.samples_to_ftime(self.mlaudio.length)}" + f" ({self.mlaudio.length} samples) long" ) + message = f"The embedded loop points of \"{self.filename}\" do not fit in the audio: {detail}." + if required: + raise LoopNotFoundError(message, reason="loop tags do not fit in the audio", detail=detail) + logging.warning(f"{message} Ignoring them.") return None logging.info(f"Found embedded loop points in the metadata tags: {loop_start} -> {loop_end}") @@ -613,7 +634,7 @@ def _read_loop_tags(self, loop_start_tag: str, loop_end_tag: str, is_offset: Opt loop_end = int(audio_file.tags[loop_end_tag][0]) except Exception as e: raise TypeError( - "One of the tags provided has invalid (non-integer or empty) values" + f"One of the loop tags of \"{self.filename}\" has an invalid (non-integer or empty) value." ) from e # Re-order the loop points in case diff --git a/pymusiclooper/exceptions.py b/pymusiclooper/exceptions.py index cda9ebd..17dead8 100644 --- a/pymusiclooper/exceptions.py +++ b/pymusiclooper/exceptions.py @@ -1,8 +1,24 @@ +from typing import Optional + + class LoopNotFoundError(Exception): - def __init__(self, message): + def __init__(self, message, reason: Optional[str] = None, detail: Optional[str] = None): + """ + Args: + message: The full error message. + reason (str, optional): A short reason shared by the files failing the same way (e.g. "missing loop tags"), + used to group them in the batch summary. + detail (str, optional): Details specific to the file, shown next to it in the batch summary. + """ super().__init__(message) + self.reason = reason + self.detail = detail class AudioLoadError(Exception): def __init__(self, message): super().__init__(message) + + +class NotAudioError(AudioLoadError): + """Raised when a file cannot be decoded as audio at all (e.g. a text or image file).""" diff --git a/pymusiclooper/handler.py b/pymusiclooper/handler.py index 2f24a36..4550671 100644 --- a/pymusiclooper/handler.py +++ b/pymusiclooper/handler.py @@ -2,8 +2,9 @@ import os import signal import sys +from collections import Counter from contextlib import contextmanager -from typing import List, Literal, Optional, Tuple +from typing import List, Literal, NamedTuple, Optional, Tuple from rich.progress import MofNCompleteColumn, Progress, SpinnerColumn, TimeElapsedColumn from rich.table import Table @@ -11,7 +12,7 @@ from pymusiclooper.analysis import LoopPair from pymusiclooper.console import rich_console from pymusiclooper.core import MusicLooper -from pymusiclooper.exceptions import AudioLoadError, LoopNotFoundError +from pymusiclooper.exceptions import AudioLoadError, LoopNotFoundError, NotAudioError from pymusiclooper.utils import DEFAULT_OUTPUT_DIRECTORY_NAME # Default number of samples to keep after the loop end when trimming (interactively or with the trim command): enough for players @@ -31,6 +32,7 @@ def __init__( brute_force: bool = False, disable_pruning: bool = False, ignore_tags: bool = False, + tags_only: bool = False, _progressbar: Progress = None, **kwargs, ): @@ -55,6 +57,7 @@ def __init__( brute_force=brute_force, disable_pruning=disable_pruning, use_embedded_tags=not ignore_tags, + tags_only=tags_only, ) self.interactive_mode = "PML_INTERACTIVE_MODE" in os.environ self.in_samples = "PML_DISPLAY_SAMPLES" in os.environ @@ -198,6 +201,7 @@ def __init__( brute_force: bool = False, disable_pruning: bool = False, ignore_tags: bool = False, + tags_only: bool = False, split_audio: bool = False, format: Literal["WAV", "FLAC", "OGG", "MP3"] = "WAV", to_txt: bool = False, @@ -223,6 +227,7 @@ def __init__( brute_force=brute_force, disable_pruning=disable_pruning, ignore_tags=ignore_tags, + tags_only=tags_only, **kwargs, ) self.output_directory = output_dir @@ -241,10 +246,14 @@ def __init__( self.trim = trim self.keep_after = keep_after self._is_autocreated_outdir = False + self.chosen_loop_pair: Optional[LoopPair] = None + # Errors of the exports that are logged instead of raised, reported in the batch summary + self.errors: List[str] = [] def run(self): self.loop_pair_list = self.get_all_loop_pairs() chosen_loop_pair = self.choose_loop_pair(self.interactive_mode) + self.chosen_loop_pair = chosen_loop_pair loop_start = chosen_loop_pair.loop_start loop_end = chosen_loop_pair.loop_end @@ -313,7 +322,7 @@ def split_audio_runner(self, loop_start: int, loop_end: int): rich_console.print(message) # Usually: unknown file format specified; raised by soundfile except ValueError as e: - logging.error(e) + self._report_error(e) def extend_track_runner(self, loop_start: int, loop_end: int): # Add a progress bar since it could take some time to export @@ -346,7 +355,7 @@ def extend_track_runner(self, loop_start: int, loop_end: int): rich_console.print(message) # Usually: unknown file format specified; raised by soundfile except ValueError as e: - logging.error(e) + self._report_error(e) def trim_prompt(self) -> Tuple[bool, int]: """Asks whether to also losslessly trim the audio after the chosen loop end, and how many samples to keep after it.""" @@ -384,7 +393,7 @@ def trim_runner(self, loop_end: int): rich_console.print(message) # Unsupported (lossy) source format except ValueError as e: - logging.error(e) + self._report_error(e) def txt_export_runner(self, loop_start: int, loop_end: int): if self.alt_export_top != 0: @@ -456,6 +465,10 @@ def tag_runner(self, loop_start: int, loop_end: int): else: rich_console.print(message) + def _report_error(self, e: Exception): + logging.error(e) + self.errors.append(str(e)) + def _fmt(self, samples: int): if self.fmt == "seconds": return str(self.musiclooper.samples_to_seconds(samples)) @@ -465,6 +478,18 @@ def _fmt(self, samples: int): return str(samples) +class BatchFileResult(NamedTuple): + """Outcome of processing one file in batch mode.""" + path: str + # "tags" or "detected" (processed with the loop points from the file's tags, or with detected/chosen ones), + # "skipped" (no loop points found, or the audio could not be analyzed), "not_audio" or "failed" (an export failed) + outcome: Literal["tags", "detected", "skipped", "not_audio", "failed"] + messages: Tuple[str, ...] = () + # Short reason shared by the files skipped the same way, to group them in the summary, and the file's details + reason: Optional[str] = None + detail: Optional[str] = None + + class BatchHandler: def __init__( self, @@ -505,6 +530,7 @@ def run(self): else self.clone_file_tree_structure(files, self.output_directory) ) + results = [] with Progress( SpinnerColumn(), *Progress.get_default_columns(), @@ -527,10 +553,60 @@ def run(self): "output_dir": self.output_directory if self.flatten else output_dirs[file_idx] } try: - self._batch_export_helper(**task_kwargs) + results.append(self._batch_export_helper(**task_kwargs)) finally: self._cleanup_empty_created_dirs() + self.print_summary(results) + return results + + def print_summary(self, results: List[BatchFileResult]): + """Prints how many files were processed (and with which loop points), skipped or failed, with the reasons.""" + def with_outcome(outcome): + return [result for result in results if result.outcome == outcome] + + def relpath(result): + return os.path.relpath(result.path, self.directory_path) + + def print_plain(text): + # File names and error messages may contain brackets, which rich would otherwise parse as markup + rich_console.print(text, markup=False, highlight=False) + + n_tags, n_detected = len(with_outcome("tags")), len(with_outcome("detected")) + rich_console.print(f"\n[bold]Batch summary[/] ({len(results)} file{'' if len(results) == 1 else 's'}):") + rich_console.print( + f" [green]Processed: {n_tags + n_detected}[/]" + f" ({n_tags} with the loop points from their tags, {n_detected} with detected loop points)" + ) + + for outcome, label in (("skipped", "[yellow]Skipped"), ("failed", "[red]Failed")): + listed = with_outcome(outcome) + if not listed: + continue + rich_console.print(f" {label}: {len(listed)}[/]") + + groups = {} + for result in listed: + if result.reason is not None: + groups.setdefault(result.reason, []).append(result) + for reason, group in groups.items(): + print_plain(f" {reason[0].upper()}{reason[1:]} ({len(group)}):") + for result in group: + print_plain(f" {relpath(result)}" + (f": {result.detail}" if result.detail else "")) + + for result in listed: + if result.reason is None: + for message in result.messages: + print_plain(f" - {relpath(result)}: {message}") + + not_audio = with_outcome("not_audio") + if not_audio: + extensions = Counter(os.path.splitext(result.path)[1].lower() or "(no extension)" for result in not_audio) + print_plain( + f" Not audio files: {len(not_audio)}" + f" ({', '.join(f'{extension}: {count}' for extension, count in extensions.most_common())})" + ) + def clone_file_tree_structure(self, in_files: List[str], output_directory: str) -> List[str]: common_path = os.path.commonpath(in_files) output_dirs = [ @@ -563,14 +639,27 @@ def get_files_in_directory(dir_path: str, recursive: bool = False) -> List[str]: ) @staticmethod - def _batch_export_helper(**kwargs): + def _batch_export_helper(**kwargs) -> BatchFileResult: + path = kwargs["path"] try: export_handler = LoopExportHandler(**kwargs, batch_mode=True) export_handler.run() + # Skipped files are listed in the summary, so they are only logged in verbose mode + except NotAudioError as e: + logging.info(e) + return BatchFileResult(path, "not_audio", (str(e),)) except (AudioLoadError, LoopNotFoundError) as e: - logging.error(e) + logging.warning(e) + return BatchFileResult( + path, "skipped", (str(e),), reason=getattr(e, "reason", None), detail=getattr(e, "detail", None) + ) except Exception as e: logging.error(e) + return BatchFileResult(path, "failed", (str(e),)) + + if export_handler.errors: + return BatchFileResult(path, "failed", tuple(export_handler.errors)) + return BatchFileResult(path, "tags" if export_handler.chosen_loop_pair.from_metadata else "detected") def _cleanup_empty_created_dirs(self): dirs_to_check = self._created_dirs diff --git a/tests/test_cli.py b/tests/test_cli.py index d96b831..02b386a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -93,6 +93,35 @@ def test_batch_export_skips_non_audio_files(run, track_path, stereo_track_path, assert sorted(line.split()[-1] for line in lines) == ["stereo.wav", "track.wav"] +def test_tags_only_conflicts_with_ignore_tags(run, track_path): + result = run("export-points", "--path", track_path, "--tags-only", "--ignore-tags") + + assert result.exit_code == 2 + assert "cannot be used together" in result.output + + +def test_batch_trim_tags_only_prints_summary(run, track_path, stereo_track_path, tmp_path): + import soundfile as sf + import taglib + + 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) + with taglib.File(str(in_dir / "track.wav"), save_on_exit=True) as audio_file: + audio_file.tags["LOOP_START"] = ["1000"] + audio_file.tags["LOOP_END"] = ["50000"] + + result = run("trim", "--path", in_dir, "--tags-only", "--keep-after", 0, "--output-dir", out_dir) + + assert result.exit_code == 0 + assert [p.name for p in out_dir.iterdir()] == ["track-trimmed.wav"] + assert sf.info(out_dir / "track-trimmed.wav").frames == 50000 + assert "Batch summary" in result.output + assert "Skipped: 1" in result.output + + def test_trim_keeps_recommended_samples_after_loop_end_by_default(run, track_path, tmp_path): import soundfile as sf diff --git a/tests/test_core.py b/tests/test_core.py index ba864ea..e77c41d 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -8,7 +8,9 @@ import taglib from conftest import INTRO_SAMPLES, PATTERN_SAMPLES, SR +from pymusiclooper import core from pymusiclooper.core import MusicLooper +from pymusiclooper.exceptions import LoopNotFoundError LOOP_START = INTRO_SAMPLES LOOP_END = INTRO_SAMPLES + 2 * PATTERN_SAMPLES @@ -164,6 +166,39 @@ def test_no_embedded_tags(track_path): assert MusicLooper(track_path).read_embedded_loop_pair() is None +def test_tags_only_returns_embedded_tags_without_analysis(monkeypatch, flac_track_path): + _write_loop_tags(flac_track_path, LOOP_START + 123, LOOP_END + 123) + + def fail_analysis(*args, **kwargs): + raise AssertionError("the audio should not be analyzed") + monkeypatch.setattr(core, "find_best_loop_points", fail_analysis) + + pairs = MusicLooper(flac_track_path).find_loop_pairs(tags_only=True) + + assert len(pairs) == 1 + assert pairs[0].from_metadata + assert (pairs[0].loop_start, pairs[0].loop_end) == (LOOP_START + 123, LOOP_END + 123) + + +def test_tags_only_without_tags_raises(flac_track_path): + with pytest.raises(LoopNotFoundError, match="No loop start tag"): + MusicLooper(flac_track_path).find_loop_pairs(tags_only=True) + + +def test_tags_only_with_out_of_range_tags_raises(flac_track_path, track): + _write_loop_tags(flac_track_path, LOOP_START, track.size + 1) + + with pytest.raises(LoopNotFoundError, match="do not fit in the audio"): + MusicLooper(flac_track_path).find_loop_pairs(tags_only=True) + + +def test_tags_only_with_invalid_tag_value_raises(flac_track_path): + _write_loop_tags(flac_track_path, LOOP_START, "end") + + with pytest.raises(LoopNotFoundError, match="invalid"): + MusicLooper(flac_track_path).find_loop_pairs(tags_only=True) + + # --- Lossless trim --- diff --git a/tests/test_handler.py b/tests/test_handler.py index 36bcd0d..15e7f92 100644 --- a/tests/test_handler.py +++ b/tests/test_handler.py @@ -3,6 +3,7 @@ import pytest import soundfile as sf from conftest import SR +from rich.text import Text from pymusiclooper import handler from pymusiclooper.analysis import LoopPair @@ -176,6 +177,86 @@ def test_txt_export_message_names_written_file(monkeypatch, track_path, tmp_path assert str(tmp_path / "loops.txt") in messages[0] +def _write_loop_tags(path, loop_start, loop_end): + import taglib + with taglib.File(str(path), save_on_exit=True) as audio_file: + audio_file.tags["LOOP_START"] = [str(loop_start)] + audio_file.tags["LOOP_END"] = [str(loop_end)] + + +@pytest.fixture +def batch_dir(tmp_path, track): + """A directory with a tagged track, an untagged track, a track with tags past its end, and a non-audio file.""" + in_dir = tmp_path / "in" + in_dir.mkdir() + for name in ("tagged.flac", "untagged.flac", "bad_tags.flac"): + sf.write(in_dir / name, track, SR) + _write_loop_tags(in_dir / "tagged.flac", 1000, 50000) + _write_loop_tags(in_dir / "bad_tags.flac", 1000, track.size + 1) + (in_dir / "notes.txt").write_text("not audio") + return in_dir + + +def _run_batch(monkeypatch, in_dir, out_dir, **kwargs): + printed = [] + + def capture(*args, markup=True, **kw): + text = " ".join(map(str, args)) + printed.append(Text.from_markup(text).plain if markup else text) + monkeypatch.setattr(handler.rich_console, "print", capture) + batch_handler = BatchHandler(path=str(in_dir), output_dir=str(out_dir), min_duration_multiplier=0.35, **kwargs) + results = batch_handler.run() + return {os.path.basename(result.path): result for result in results}, "\n".join(printed) + + +def test_batch_tags_only_skips_files_without_valid_tags(monkeypatch, batch_dir, tmp_path): + out_dir = tmp_path / "out" + results, summary = _run_batch(monkeypatch, batch_dir, out_dir, tags_only=True, trim=True) + + assert {name: result.outcome for name, result in results.items()} == { + "tagged.flac": "tags", + "untagged.flac": "skipped", + "bad_tags.flac": "skipped", + "notes.txt": "not_audio", + } + assert os.listdir(out_dir) == ["tagged-trimmed.flac"] + assert sf.info(out_dir / "tagged-trimmed.flac").frames == 50000 + + summary_lines = [line.strip() for line in summary.splitlines()] + assert "Processed: 1 (1 with the loop points from their tags, 0 with detected loop points)" in summary_lines + assert "Skipped: 2" in summary_lines + # Skipped files are grouped by reason + assert summary_lines.index("Missing loop tags (1):") + 1 == summary_lines.index("untagged.flac") + bad_tags_idx = summary_lines.index("Loop tags do not fit in the audio (1):") + 1 + assert summary_lines[bad_tags_idx].startswith("bad_tags.flac: 00:00.045 -> ") + assert "Not audio files: 1 (.txt: 1)" in summary_lines + assert "Failed" not in summary + + +def test_batch_counts_tagged_and_detected_loop_points(monkeypatch, batch_dir, tmp_path): + results, summary = _run_batch(monkeypatch, batch_dir, tmp_path / "out", to_txt=True) + + assert results["tagged.flac"].outcome == "tags" + # Without --tags-only, files without valid loop tags fall back to the detected loop points + assert results["untagged.flac"].outcome == "detected" + assert results["bad_tags.flac"].outcome == "detected" + assert results["notes.txt"].outcome == "not_audio" + assert "Processed: 3 (1 with the loop points from their tags, 2 with detected loop points)" in summary + + +def test_batch_reports_failed_exports(monkeypatch, tmp_path, track): + in_dir = tmp_path / "in" + in_dir.mkdir() + sf.write(in_dir / "lossy.mp3", track, SR, format="MP3") + + results, summary = _run_batch(monkeypatch, in_dir, tmp_path / "out", trim=True) + + assert results["lossy.mp3"].outcome == "failed" + assert "Lossless trimming is only supported" in results["lossy.mp3"].messages[0] + assert "Failed: 1" in summary + assert "- lossy.mp3: Lossless trimming is only supported" in summary + + def test_get_files_in_directory(tmp_path): (tmp_path / "a.wav").touch() (tmp_path / "sub").mkdir() From 4690e798a517a5c40db0c88361b34959e86572bd Mon Sep 17 00:00:00 2001 From: Xehanort88 Date: Sun, 27 Sep 2026 09:35:37 +0100 Subject: [PATCH 2/4] Speed up the candidate loop search and halve the analysis memory The candidate search compares every pair of beats; it now does so with scalar loops over the beats' chroma and loudness instead of allocating temporary arrays for each comparison (about 60x faster, and much faster brute force). Only the loudest bin of each frame of the dB spectrogram was used, so only those are kept, the spectrograms are released as soon as they are no longer needed, and the tuning estimate of the chroma is computed over chunks of frames. The results are unchanged (bit-identical loop points and scores). --- pymusiclooper/analysis.py | 119 ++++++++++++++++++++++++++++---------- tests/test_analysis.py | 59 +++++++++++++++++++ 2 files changed, 147 insertions(+), 31 deletions(-) diff --git a/pymusiclooper/analysis.py b/pymusiclooper/analysis.py index 83db64e..950a9f6 100644 --- a/pymusiclooper/analysis.py +++ b/pymusiclooper/analysis.py @@ -81,7 +81,7 @@ def find_best_loop_points( if approx_loop_start is not None and approx_loop_end is not None: # Skipping the unnecessary beat analysis (in this case) speeds up the analysis runtime by ~2x # and significantly reduces the total memory consumption - chroma, power_db, _, _ = _analyze_audio(mlaudio, skip_beat_analysis=True) + chroma, frame_loudness, _, _ = _analyze_audio(mlaudio, skip_beat_analysis=True) # Set bpm to a general average of 120 bpm = 120.0 @@ -127,14 +127,14 @@ def find_best_loop_points( ) elif brute_force: # Similarly skip beat analysis, as the results will not be used - chroma, power_db, _, _ = _analyze_audio(mlaudio, skip_beat_analysis=True) + chroma, frame_loudness, _, _ = _analyze_audio(mlaudio, skip_beat_analysis=True) bpm = 120.0 beats = np.arange(start=0, stop=chroma.shape[-1], step=1, dtype=int) logging.info(f"Overriding number of frames to check with: {beats.size}") logging.info(f"Estimated iterations required using brute force: {int(beats.size*beats.size*(1-(min_loop_duration/chroma.shape[-1])))}") logging.info("**NOTICE** The program may appear frozen, but processing will continue in the background. This operation may take several minutes to complete.") else: # normal mode of operation - chroma, power_db, bpm, beats = _analyze_audio(mlaudio) + chroma, frame_loudness, bpm, beats = _analyze_audio(mlaudio) logging.info(f"Detected {beats.size} beats at {bpm:.0f} bpm") logging.info( @@ -147,8 +147,13 @@ def find_best_loop_points( # Since numba jitclass cannot be cached, the pair data must be stored temporarily in a list of tuple # (instead of a list of LoopPairs directly) and then loaded into a list of LoopPair objects using list comprehension + # The features of the beat frames only, laid out contiguously for fast comparisons unproc_candidate_pairs = _find_candidate_pairs( - chroma, power_db, beats, min_loop_duration, max_loop_duration + np.ascontiguousarray(chroma[:, beats].T), + frame_loudness[beats], + beats, + min_loop_duration, + max_loop_duration, ) candidate_pairs = [ LoopPair( @@ -226,21 +231,30 @@ def _analyze_audio( skip_beat_analysis (bool, optional): Skips beat analysis if true and returns None for bpm and beats. Defaults to False. Returns: - Tuple[np.ndarray, np.ndarray, float, np.ndarray]: a tuple containing the (chroma spectrogram, power spectrogram in dB, tempo/bpm, frame indices of detected beats) + Tuple[np.ndarray, np.ndarray, float, np.ndarray]: a tuple containing the (chroma spectrogram, + loudness of each frame (the maximum of the perceptually weighted power spectrogram in dB), tempo/bpm, frame indices of detected beats) """ - S = librosa.core.stft(y=mlaudio.audio) - S_power = np.abs(S) ** 2 + # The full-resolution spectrograms are large (several GB for long tracks), + # so each one is released as soon as the next, smaller, representation is computed from it + S_power = np.abs(librosa.core.stft(y=mlaudio.audio)) + S_power **= 2 + # (chroma_stft is not given the sample rate, so it uses librosa's default of 22050 Hz; kept as-is for the tuning) + chroma = librosa.feature.chroma_stft(S=S_power, tuning=_estimate_tuning(S_power, sr=22050)) S_weighed = librosa.core.perceptual_weighting( S=S_power, frequencies=librosa.fft_frequencies(sr=mlaudio.rate) ) + del S_power mel_spectrogram = librosa.feature.melspectrogram( S=S_weighed, sr=mlaudio.rate, n_mels=128, fmax=8000 ) - chroma = librosa.feature.chroma_stft(S=S_power) - power_db = librosa.power_to_db(S_weighed, ref=np.median) + # Only the loudest bin of each frame is compared, and converting to dB preserves the order of the values, + # so the frame maxima are converted instead of the whole spectrogram (with the same reference: its median) + median_power = np.median(S_weighed) + frame_loudness = librosa.power_to_db(S_weighed.max(axis=0), ref=lambda _: median_power) + del S_weighed if skip_beat_analysis: - return chroma, power_db, None, None + return chroma, frame_loudness, None, None try: onset_env = librosa.onset.onset_strength(S=mel_spectrogram) @@ -257,33 +271,61 @@ def _analyze_audio( except Exception as e: raise LoopNotFoundError(f"Beat analysis failed for \"{mlaudio.filename}\". Cannot continue.") from e - return chroma, power_db, bpm, beats + return chroma, frame_loudness, bpm, beats -@njit -def _db_diff(power_db_f1: np.ndarray, power_db_f2: np.ndarray) -> float: - return np.abs(np.max(power_db_f1) - np.max(power_db_f2)) +# Number of frames tracked at a time when estimating the tuning, which bounds the memory it uses +_TUNING_CHUNK_FRAMES = 4096 -@njit -def _norm(a: np.ndarray) -> float: - return np.sqrt(np.sum(np.abs(a) ** 2, axis=0)) +def _estimate_tuning(S_power: np.ndarray, sr: float, bins_per_octave: int = 12) -> float: + """Gives the same result as `librosa.estimate_tuning(S=S_power, sr=sr, bins_per_octave=bins_per_octave)` + (which chroma_stft uses when no tuning is given), while using a fraction of the memory. + + librosa's pitch tracking allocates several arrays the size of the whole spectrogram (several GB for long tracks). + It works on each frame independently, and the tuning estimate does not depend on the order of the detected pitches, + so tracking chunks of frames and pooling the detected pitches gives the same estimate. + + Args: + S_power (np.ndarray): Power spectrogram, in the shape `(1 + n_fft/2, n_frames)` + sr (float): Sample rate used to convert the frequency bins to Hz + bins_per_octave (int, optional): Number of frequency bins per octave. Defaults to 12. + + Returns: + float: The estimated tuning deviation, in fractions of a bin + """ + pitches, magnitudes = [], [] + for start in range(0, S_power.shape[-1], _TUNING_CHUNK_FRAMES): + pitch, magnitude = librosa.piptrack(S=S_power[:, start:start + _TUNING_CHUNK_FRAMES], sr=sr) + # Only frequencies > 0 are detected pitches + detected = pitch > 0 + pitches.append(pitch[detected]) + magnitudes.append(magnitude[detected]) + pitches = np.concatenate(pitches) + magnitudes = np.concatenate(magnitudes) + + # Only the pitches with at least the median magnitude count + threshold = np.median(magnitudes) if pitches.size else 0.0 + return librosa.pitch_tuning(pitches[magnitudes >= threshold], bins_per_octave=bins_per_octave) @njit(cache=True) def _find_candidate_pairs( - chroma: np.ndarray, - power_db: np.ndarray, + beat_chroma: np.ndarray, + beat_loudness: np.ndarray, beats: np.ndarray, min_loop_duration: int, max_loop_duration: int, ) -> List[Tuple[int, int, float, float]]: """Generates a list of all valid candidate loop pairs using combinations of beat indices, - by comparing the notes using the chroma spectrogram and their loudness difference + by comparing the notes using the chroma spectrogram and their loudness difference. + + Every pair of beats is compared, so the distances are computed with scalar loops over the 12 pitch classes + (instead of array operations, which allocate a temporary array for each of the millions of comparisons). Args: - chroma (np.ndarray): The chroma spectrogram - power_db (np.ndarray): The power spectrogram in dB + beat_chroma (np.ndarray): The chroma of each beat frame (float32), in the shape `(n_beats, 12)`, C-contiguous + beat_loudness (np.ndarray): The loudness of each beat frame in dB (float32), in the shape `(n_beats,)` beats (np.ndarray): The frame indices of detected beats min_loop_duration (int): Minimum loop duration (in frames) max_loop_duration (int): Maximum loop duration (in frames) @@ -297,27 +339,42 @@ def _find_candidate_pairs( ## Mainly found through trial and error, ## higher values typically result in the inclusion of musically unrelated beats/notes ACCEPTABLE_NOTE_DEVIATION = 0.0875 - ## Since the _db_diff comparison is takes a perceptually weighted power_db frame, + ## Since the loudness comparison takes the loudest bin of perceptually weighted power frames in dB, ## the difference should be imperceptible (ideally, close to 0) ## Based on trial and error, values higher than ~0.5 have a perceptible ## difference in loudness ACCEPTABLE_LOUDNESS_DIFFERENCE = 0.5 - deviation = _norm(chroma[..., beats] * ACCEPTABLE_NOTE_DEVIATION) - - for idx, loop_end in enumerate(beats): - for loop_start in beats: + n_beats, n_pitch_classes = beat_chroma.shape + + # The note distance accepted for each loop end: the norm of its chroma scaled by ACCEPTABLE_NOTE_DEVIATION + # (in float64, as in the original array-based implementation, so that the results are unchanged) + deviation = np.empty(n_beats) + for idx in range(n_beats): + deviation_squares = 0.0 + for k in range(n_pitch_classes): + value = np.float64(beat_chroma[idx, k]) * ACCEPTABLE_NOTE_DEVIATION + deviation_squares += value * value + deviation[idx] = np.sqrt(deviation_squares) + + for idx in range(n_beats): + loop_end = beats[idx] + for start_idx in range(n_beats): + loop_start = beats[start_idx] loop_length = loop_end - loop_start if loop_length < min_loop_duration: break if loop_length > max_loop_duration: continue - note_distance = _norm(chroma[..., loop_end] - chroma[..., loop_start]) + # Euclidean distance of the chroma vectors (in float32, like the chroma) + distance_squares = np.float32(0.0) + for k in range(n_pitch_classes): + difference = beat_chroma[idx, k] - beat_chroma[start_idx, k] + distance_squares += difference * difference + note_distance = np.sqrt(distance_squares) if note_distance <= deviation[idx]: - loudness_difference = _db_diff( - power_db[..., loop_end], power_db[..., loop_start] - ) + loudness_difference = abs(beat_loudness[idx] - beat_loudness[start_idx]) loop_pair = ( int(loop_start), int(loop_end), diff --git a/tests/test_analysis.py b/tests/test_analysis.py index bcc9fd7..d8c99fd 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -1,6 +1,8 @@ +import librosa import numpy as np import pytest from conftest import INTRO_SAMPLES, PATTERN_SAMPLES, SAMPLE_TOLERANCE, SR, assert_whole_patterns +from numba import njit from pymusiclooper import analysis from pymusiclooper.analysis import ( @@ -143,6 +145,63 @@ def test_truncated_lookbehind_weights_frames_nearest_the_loop_point(): assert score == pytest.approx(weights[-3:].sum() / weights.sum()) +# --- Memory/speed optimized features: must give the same results as the straightforward computations --- + + +@njit +def _reference_norm(a): + return np.sqrt(np.sum(np.abs(a) ** 2, axis=0)) + + +@njit +def _reference_candidate_pairs(chroma, power_db, beats, min_loop_duration, max_loop_duration): + """The original array-based candidate search, on the full chroma and dB spectrograms.""" + candidate_pairs = [] + deviation = _reference_norm(chroma[..., beats] * 0.0875) + for idx, loop_end in enumerate(beats): + for loop_start in beats: + loop_length = loop_end - loop_start + if loop_length < min_loop_duration: + break + if loop_length > max_loop_duration: + continue + note_distance = _reference_norm(chroma[..., loop_end] - chroma[..., loop_start]) + if note_distance <= deviation[idx]: + loudness_difference = np.abs(np.max(power_db[..., loop_end]) - np.max(power_db[..., loop_start])) + if loudness_difference <= 0.5: + candidate_pairs.append((int(loop_start), int(loop_end), note_distance, loudness_difference)) + return candidate_pairs + + +def test_candidate_pairs_match_array_based_search(): + rng = np.random.default_rng(3) + n_frames = 3000 + # A repeating pattern with a little noise, so that many (but not all) beat pairs are candidates + chroma = (np.tile(rng.random((12, 100)), 30) + 0.05 * rng.random((12, n_frames))).astype(np.float32) + power_db = (np.tile(rng.random((40, 100)) * 3, 30) + 0.4 * rng.random((40, n_frames))).astype(np.float32) + beats = np.sort(rng.choice(n_frames, size=600, replace=False)) + + expected = _reference_candidate_pairs(chroma, power_db, beats, 500, 2500) + actual = analysis._find_candidate_pairs( + np.ascontiguousarray(chroma[:, beats].T), power_db.max(axis=0)[beats], beats, 500, 2500 + ) + + assert len(expected) > 100 + assert actual == expected + + +def test_chroma_and_loudness_match_full_spectrogram_computation(monkeypatch, looper): + # Several chunks, including a partial one, even for the short test track + monkeypatch.setattr(analysis, "_TUNING_CHUNK_FRAMES", 37) + + chroma, frame_loudness, _, _ = analysis._analyze_audio(looper.mlaudio, skip_beat_analysis=True) + + S_power = np.abs(librosa.stft(y=looper.mlaudio.audio)) ** 2 + S_weighed = librosa.perceptual_weighting(S=S_power, frequencies=librosa.fft_frequencies(sr=looper.mlaudio.rate)) + np.testing.assert_array_equal(chroma, librosa.feature.chroma_stft(S=S_power)) + np.testing.assert_array_equal(frame_loudness, librosa.power_to_db(S_weighed, ref=np.median).max(axis=0)) + + # --- Sample-level alignment --- From b3982d3bc28d457cede7ca59c7f56de4dede27bf Mon Sep 17 00:00:00 2001 From: Xehanort88 Date: Sun, 27 Sep 2026 09:43:30 +0100 Subject: [PATCH 3/4] Map the chroma to pitch classes with the audio's sample rate chroma_stft was not given the sample rate, so it assumed librosa's default of 22050 Hz: pitch classes were shifted by ~1.5 semitones for 48 kHz audio, and the chroma filter bank weighted the wrong octaves for 44.1 kHz audio. This is a correctness fix: it changes the best detected loop of some tracks, but compared on 151 real tracks it makes no measurable difference to the loop quality overall (one track clearly better, one worse, the rest unchanged or within noise). Beat tracking has the same default, which only scales the bpm used for the length of the scoring window; passing the real rate there gave no better loops, so it is documented and kept. --- pymusiclooper/analysis.py | 11 +++++++++-- tests/test_analysis.py | 15 ++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/pymusiclooper/analysis.py b/pymusiclooper/analysis.py index 950a9f6..7dac80f 100644 --- a/pymusiclooper/analysis.py +++ b/pymusiclooper/analysis.py @@ -238,8 +238,11 @@ def _analyze_audio( # so each one is released as soon as the next, smaller, representation is computed from it S_power = np.abs(librosa.core.stft(y=mlaudio.audio)) S_power **= 2 - # (chroma_stft is not given the sample rate, so it uses librosa's default of 22050 Hz; kept as-is for the tuning) - chroma = librosa.feature.chroma_stft(S=S_power, tuning=_estimate_tuning(S_power, sr=22050)) + # The sample rate maps the frequency bins to pitch classes (librosa's default of 22050 Hz would shift them, + # e.g. by ~1.5 semitones for 48 kHz audio) + chroma = librosa.feature.chroma_stft( + S=S_power, sr=mlaudio.rate, tuning=_estimate_tuning(S_power, sr=mlaudio.rate) + ) S_weighed = librosa.core.perceptual_weighting( S=S_power, frequencies=librosa.fft_frequencies(sr=mlaudio.rate) ) @@ -259,6 +262,10 @@ def _analyze_audio( try: onset_env = librosa.onset.onset_strength(S=mel_spectrogram) + # Not given the sample rate, so the tempo is estimated as if the audio were at 22050 Hz: the beat positions + # are unaffected, but the bpm is scaled by 22050 / rate. The bpm only sets the length of audio compared when + # scoring the loops (see _assess_and_filter_loop_pairs), which was tuned with this scaling; passing the real + # sample rate gave no better loops on real tracks, so it is kept as-is. pulse = librosa.beat.plp(onset_envelope=onset_env) beats_plp = np.flatnonzero(librosa.util.localmax(pulse)) bpm, beats = librosa.beat.beat_track(onset_envelope=onset_env) diff --git a/tests/test_analysis.py b/tests/test_analysis.py index d8c99fd..33b66bb 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -1,6 +1,7 @@ import librosa import numpy as np import pytest +import soundfile as sf from conftest import INTRO_SAMPLES, PATTERN_SAMPLES, SAMPLE_TOLERANCE, SR, assert_whole_patterns from numba import njit @@ -198,10 +199,22 @@ def test_chroma_and_loudness_match_full_spectrogram_computation(monkeypatch, loo S_power = np.abs(librosa.stft(y=looper.mlaudio.audio)) ** 2 S_weighed = librosa.perceptual_weighting(S=S_power, frequencies=librosa.fft_frequencies(sr=looper.mlaudio.rate)) - np.testing.assert_array_equal(chroma, librosa.feature.chroma_stft(S=S_power)) + np.testing.assert_array_equal(chroma, librosa.feature.chroma_stft(S=S_power, sr=looper.mlaudio.rate)) np.testing.assert_array_equal(frame_loudness, librosa.power_to_db(S_weighed, ref=np.median).max(axis=0)) +@pytest.mark.parametrize("rate", [22050, 44100, 48000]) +def test_chroma_uses_the_audio_sample_rate(tmp_path, rate): + # An A4 tone must be analyzed as the pitch class A whatever the sample rate + # (librosa's default of 22050 Hz would shift it to G# for 48 kHz audio) + path = tmp_path / f"a4_{rate}.wav" + sf.write(path, 0.5 * np.sin(2 * np.pi * 440.0 * np.arange(3 * rate) / rate), rate) + + chroma, _, _, _ = analysis._analyze_audio(MusicLooper(str(path)).mlaudio, skip_beat_analysis=True) + + assert np.argmax(chroma.mean(axis=1)) == 9 # pitch classes start at C + + # --- Sample-level alignment --- From d28aebec36d529ce822cfb08e5fb962130db872f Mon Sep 17 00:00:00 2001 From: Xehanort88 Date: Sun, 27 Sep 2026 12:08:41 +0100 Subject: [PATCH 4/4] Update the CHANGELOG and the CLI help images Adds an Unreleased section listing the changes since 3.6.0, regenerates the --help images (with --tags-only), and adds the trim command to CLI_README.md. --- CHANGELOG.md | 31 +++ CLI_README.md | 4 + img/pymusiclooper-export-points.svg | 270 ++++++++++++++------------ img/pymusiclooper-extend.svg | 268 ++++++++++++++------------ img/pymusiclooper-play-tagged.svg | 94 +++++----- img/pymusiclooper-play.svg | 192 +++++++++++-------- img/pymusiclooper-split-audio.svg | 234 +++++++++++++---------- img/pymusiclooper-tag.svg | 256 ++++++++++++++----------- img/pymusiclooper-trim.svg | 281 ++++++++++++++++++++++++++++ img/pymusiclooper.svg | 158 ++++++++-------- 10 files changed, 1132 insertions(+), 656 deletions(-) create mode 100644 img/pymusiclooper-trim.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index e22a05c..c53c6ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,37 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `trim` command: losslessly cut the audio a given number of samples (`--keep-after`, 100 by default) after the loop end, keeping the original format, bit depth and metadata (WAV, FLAC and Ogg Vorbis only). WAV and Ogg Vorbis files are cut without re-encoding +- Interactive mode offers to trim WAV, FLAC and Ogg Vorbis files after the loop end once the loop is chosen (except when printing loop points to the terminal); with `tag`, a single tagged and trimmed copy is written +- Loop points already stored in a file's metadata tags (e.g. LOOP_START/LOOP_END, LOOPSTART/LOOPLENGTH), or in a WAV file's sampler (`smpl`) chunk, are used as the first loop choice by all commands; `--ignore-tags` skips them +- `--tags-only` option: use only the loop points stored in the files' metadata tags, skipping the (slower) loop analysis; files without valid loop tags are skipped +- Batch processing ends with a summary of the processed files (with loop points from their tags or detected ones), and of the skipped and failed files grouped by reason +- `tag` also writes the loop points of WAV files to their sampler (`smpl`) chunk, which many game engines and samplers read +- Test suite, run on Ubuntu and Windows with Python 3.10 and 3.13 + +### Changed + +- Loop points are aligned at the sample level: the loop end is moved to where the waveforms at the two loop points line up, then both points are shifted to where they differ the least, avoiding the faint flam or phase smear of loops off by a few hundred samples (falls back to the nearest zero crossings when the waveforms do not match well enough) +- The chroma (pitch classes) used to compare the notes is computed with the audio's sample rate instead of librosa's default of 22050 Hz, which shifted the pitch classes of 48 kHz audio by ~1.5 semitones. This changes the best detected loop of some tracks, with no measurable effect on the loop quality overall +- The candidate loop search is ~60x faster (much faster brute force), and the analysis uses about half the memory, with unchanged results +- Skipped and non-audio files are no longer logged as errors during batch processing (they are listed in the summary; `--verbose` still logs them) + +### Fixed + +- Mono tracks were played and exported louder than the original +- Longer loops were never preferred among near-identical scores +- Loops starting in the first seconds of a track were underscored +- Interactive mode discarded the choice made after `more`, `all` or `reset` and prompted again +- `extend` with a fade length of 0 crashed +- `export_tags()` without an output directory used the file path as the directory +- The txt export message named `loop.txt` instead of `loops.txt` +- Ctrl+C at the interactive prompt now exits cleanly on Windows + + ## [3.6.0] - 2025-11-01 ### Changed diff --git a/CLI_README.md b/CLI_README.md index 33a0ac9..8b4e925 100644 --- a/CLI_README.md +++ b/CLI_README.md @@ -27,3 +27,7 @@ ## pymusiclooper extend ![`pymusiclooper extend --help`](img/pymusiclooper-extend.svg) + +## pymusiclooper trim + +![`pymusiclooper trim --help`](img/pymusiclooper-trim.svg) diff --git a/img/pymusiclooper-export-points.svg b/img/pymusiclooper-export-points.svg index 199b77e..a69a72b 100644 --- a/img/pymusiclooper-export-points.svg +++ b/img/pymusiclooper-export-points.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + pymusiclooper export-points --help - + - - $ pymusiclooper export-points --help - -Usage:pymusiclooper export-points [OPTIONS]                                                        - - Export the best discovered or chosen loop points to a text file or to the terminal.                 - -╭─ Basic options ──────────────────────────────────────────────────────────────────────────────────╮ -│--path          PATH                    Path to the audio file(s). [mutually exclusive with │ -│--url][at least one required]│ -│--url           URL                     Link to the youtube video (or any stream supported by  │ -│                                           yt-dlp) to extract audio from and use. [mutually │ -│exclusive with --path][at least one required]│ -│--export-to     [stdout|txt]  STDOUT: print the loop points of a track in samples to │ -│                                           the terminal; TXT: export the loop points of a track   │ -│                                           in samples and append to a loop.txt file. [default: │ -│STDOUT]│ -│--alt-export-topINTEGER                 Alternative export format of the top N loop points     │ -│                                           instead of the best detected/chosen point.             │ -│--alt-export-top -1 to export all points.              │ -│--fmt           [samples|seconds|time]  Export loop points formatted as samples (default),     │ -│                                           seconds, or time (mm:ss.sss). [default: SAMPLES]│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Advanced loop options ──────────────────────────────────────────────────────────────────────────╮ -│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]  The minimum loop duration as a multiplier of │ -│                                                     the audio track's total duration. [default: │ -│0.35]│ -│--min-loop-duration      FLOAT RANGE [x>0]  The minimum loop duration in seconds.        │ -│(overrides --min-duration-multiplier if set)│ -│--max-loop-duration      FLOAT RANGE [x>0]  The maximum loop duration in seconds.        │ -│--approx-loop-position   FLOAT RANGE... [x>=0]  The approximate desired loop start and loop  │ -│                                                     end in seconds. (+/-2 second search window │ -│for each point)│ -│--brute-force                                       Check the entire audio track instead of just │ -│                                                     the detected beats. (Warning: may take │ -│several minutes to complete.)│ -│--disable-pruning                                   Disables filtering of the detected loop      │ -│                                                     points from the initial pass.                │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Export options ─────────────────────────────────────────────────────────────────────────────────╮ -│--output-dir-oDIRECTORY  The output directory to use for the exported files.                 │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Batch options ──────────────────────────────────────────────────────────────────────────────────╮ -│--recursive-r  Process directories recursively.                                                │ -│--flatten  -f  Flatten the output directory structure instead of preserving it when using the  │ -│--recursive flag. (Note: files with identical filenames are silently │ -│overwritten.)│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│--help  Show this message and exit.                                                              │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Usage:pymusiclooper export-points[OPTIONS] + +Export the best discovered or chosen loop points to a text file or to the terminal. + +┌─ Basic options ──────────────────────────────────────────────────────────────────────────────────┐ +│--path          PATH                  Path to the audio file(s). [mutually exclusive with │ +│--url][at least one required]│ +│--url           URL                   Link to the youtube video (or any stream supported by │ +│yt-dlp) to extract audio from and use. [mutually │ +│exclusive with --path][at least one required]│ +│--export-to     [stdout|txt]STDOUT: print the loop points of a track in samples to│ +│the terminal; TXT: export the loop points of a track  │ +│in samples and append to a loop.txt file. [default: │ +│STDOUT]│ +│--alt-export-topINTEGER               Alternative export format of the top N loop points    │ +│instead of the best detected/chosen point.            │ +│--alt-export-top -1 to export all points.             │ +│--fmt           [samples|seconds|time]Export loop points formatted as samples (default),    │ +│seconds, or time (mm:ss.sss). [default: SAMPLES]│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Advanced loop options ──────────────────────────────────────────────────────────────────────────┐ +│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]The minimum loop duration as a multiplier of│ +│the audio track's total duration. [default: │ +│0.35]│ +│--min-loop-duration      FLOAT RANGE [x>0]The minimum loop duration in seconds.       │ +│(overrides --min-duration-multiplier if set)│ +│--max-loop-duration      FLOAT RANGE [x>0]The maximum loop duration in seconds.│ +│--approx-loop-position   FLOAT RANGE... [x>=0]The approximate desired loop start and loop │ +│end in seconds. (+/-2 second search window │ +│for each point)│ +│--brute-force            Check the entire audio track instead of just│ +│the detected beats. (Warning: may take │ +│several minutes to complete.)│ +│--disable-pruning        Disables filtering of the detected loop     │ +│points from the initial pass.               │ +│--ignore-tags            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.                   │ +│--tags-only              Only use the loop points stored in the      │ +│file's metadata tags, skipping the loop     │ +│analysis; files without valid loop tags are │ +│skipped. (Much faster for files that are │ +│already tagged.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Export options ─────────────────────────────────────────────────────────────────────────────────┐ +│--output-dir-oDIRECTORYThe output directory to use for the exported files.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Batch options ──────────────────────────────────────────────────────────────────────────────────┐ +│--recursive-rProcess directories recursively.│ +│--flatten  -fFlatten the output directory structure instead of preserving it when using the │ +│--recursive flag. (Note: files with identical filenames are silently │ +│overwritten.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--helpShow this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/img/pymusiclooper-extend.svg b/img/pymusiclooper-extend.svg index 0ca84bc..a9d6733 100644 --- a/img/pymusiclooper-extend.svg +++ b/img/pymusiclooper-extend.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + pymusiclooper extend --help - + - - $ pymusiclooper extend --help - -Usage:pymusiclooper extend [OPTIONS]                                                               - - Create an extended version of the input audio by looping it to a specific length.                   - -╭─ Basic options ──────────────────────────────────────────────────────────────────────────────────╮ -│--path            PATH   Path to the audio file(s). [mutually exclusive with --url][at │ -│least one required]│ -│--url             URL    Link to the youtube video (or any stream supported by yt-dlp) to   │ -│                               extract audio from and use. [mutually exclusive with --path][at │ -│least one required]│ -│*--extended-length FLOAT  Desired length of the extended looped track in seconds. [Must be   │ -│                               longer than the audio's original length.] [required]│ -│--fade-length     FLOAT  Desired length of the loop fade out in seconds. [default: 5]│ -│--disable-fade-out         Extend the track with all its sections (intro/loop/outro) without  │ -│                               fading out. --extended-length will be treated as an 'at least'     │ -│                               constraint.                                                        │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Advanced loop options ──────────────────────────────────────────────────────────────────────────╮ -│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]  The minimum loop duration as a multiplier of │ -│                                                     the audio track's total duration. [default: │ -│0.35]│ -│--min-loop-duration      FLOAT RANGE [x>0]  The minimum loop duration in seconds.        │ -│(overrides --min-duration-multiplier if set)│ -│--max-loop-duration      FLOAT RANGE [x>0]  The maximum loop duration in seconds.        │ -│--approx-loop-position   FLOAT RANGE... [x>=0]  The approximate desired loop start and loop  │ -│                                                     end in seconds. (+/-2 second search window │ -│for each point)│ -│--brute-force                                       Check the entire audio track instead of just │ -│                                                     the detected beats. (Warning: may take │ -│several minutes to complete.)│ -│--disable-pruning                                   Disables filtering of the detected loop      │ -│                                                     points from the initial pass.                │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Export options ─────────────────────────────────────────────────────────────────────────────────╮ -│--output-dir-oDIRECTORY           The output directory to use for the exported files.        │ -│--format    [wav|flac|ogg|mp3]  Audio format to use for the output audio file. [default: │ -│MP3]│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Batch options ──────────────────────────────────────────────────────────────────────────────────╮ -│--recursive-r  Process directories recursively.                                                │ -│--flatten  -f  Flatten the output directory structure instead of preserving it when using the  │ -│--recursive flag. (Note: files with identical filenames are silently │ -│overwritten.)│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│--help  Show this message and exit.                                                              │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Usage:pymusiclooper extend[OPTIONS] + +Create an extended version of the input audio by looping it to a specific length. + +┌─ Basic options ──────────────────────────────────────────────────────────────────────────────────┐ +│--path            PATH Path to the audio file(s). [mutually exclusive with --url][at │ +│least one required]│ +│--url             URL  Link to the youtube video (or any stream supported by yt-dlp) to  │ +│extract audio from and use. [mutually exclusive with --path][at │ +│least one required]│ +│*--extended-length FLOATDesired length of the extended looped track in seconds. [Must be  │ +│longer than the audio's original length.] [required]│ +│--fade-length     FLOATDesired length of the loop fade out in seconds. [default: 5]│ +│--disable-fade-outExtend the track with all its sections (intro/loop/outro) without │ +│fading out. --extended-length will be treated as an 'at least'    │ +│constraint.                                                       │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Advanced loop options ──────────────────────────────────────────────────────────────────────────┐ +│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]The minimum loop duration as a multiplier of│ +│the audio track's total duration. [default: │ +│0.35]│ +│--min-loop-duration      FLOAT RANGE [x>0]The minimum loop duration in seconds.       │ +│(overrides --min-duration-multiplier if set)│ +│--max-loop-duration      FLOAT RANGE [x>0]The maximum loop duration in seconds.│ +│--approx-loop-position   FLOAT RANGE... [x>=0]The approximate desired loop start and loop │ +│end in seconds. (+/-2 second search window │ +│for each point)│ +│--brute-force            Check the entire audio track instead of just│ +│the detected beats. (Warning: may take │ +│several minutes to complete.)│ +│--disable-pruning        Disables filtering of the detected loop     │ +│points from the initial pass.               │ +│--ignore-tags            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.                   │ +│--tags-only              Only use the loop points stored in the      │ +│file's metadata tags, skipping the loop     │ +│analysis; files without valid loop tags are │ +│skipped. (Much faster for files that are │ +│already tagged.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Export options ─────────────────────────────────────────────────────────────────────────────────┐ +│--output-dir-oDIRECTORY         The output directory to use for the exported files.│ +│--format    [wav|flac|ogg|mp3]Audio format to use for the output audio file. [default: │ +│MP3]│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Batch options ──────────────────────────────────────────────────────────────────────────────────┐ +│--recursive-rProcess directories recursively.│ +│--flatten  -fFlatten the output directory structure instead of preserving it when using the │ +│--recursive flag. (Note: files with identical filenames are silently │ +│overwritten.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--helpShow this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/img/pymusiclooper-play-tagged.svg b/img/pymusiclooper-play-tagged.svg index 6858fd1..0871af4 100644 --- a/img/pymusiclooper-play-tagged.svg +++ b/img/pymusiclooper-play-tagged.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - + pymusiclooper play-tagged --help - + - - $ pymusiclooper play-tagged --help - -Usage:pymusiclooper play-tagged [OPTIONS]                                                          - - Skips loop analysis and reads the loop points directly from the tags present in the file.           - -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│*--path                      PATH     Path to the audio file. [required]│ -│--tag-names                 TEXT...  Name of the loop metadata tags to read from, e.g.      │ -│--tag-names LOOP_START LOOP_END  (note: values must be │ -│                                           integers and in sample units). Default: auto-detected. │ -│--tag-offset/--no-tag-offset           Always parse second loop metadata tag as a relative    │ -│                                           length / or as an absolute length. Default:            │ -│                                           auto-detected based on tag name.                       │ -│--help                                 Show this message and exit.                            │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Usage:pymusiclooper play-tagged[OPTIONS] + +Skips loop analysis and reads the loop points directly from the tags present in the file. + +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│*--path                      PATH   Path to the audio file. [required]│ +│--tag-names                 TEXT...Name of the loop metadata tags to read from, e.g.     │ +│--tag-names LOOP_START LOOP_END  (note: values must be│ +│integers and in sample units). Default: auto-detected.│ +│--tag-offset/--no-tag-offsetAlways parse second loop metadata tag as a relative   │ +│length / or as an absolute length. Default:           │ +│auto-detected based on tag name.                      │ +│--help                      Show this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/img/pymusiclooper-play.svg b/img/pymusiclooper-play.svg index 9eee9fb..5ecce98 100644 --- a/img/pymusiclooper-play.svg +++ b/img/pymusiclooper-play.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + pymusiclooper play --help - + - - $ pymusiclooper play --help - -Usage:pymusiclooper play [OPTIONS]                                                                 - - Play an audio file on repeat from the terminal with the best discovered loop points, or a chosen    - point if interactive mode is active.                                                                - -╭─ Basic options ──────────────────────────────────────────────────────────────────────────────────╮ -│--pathPATH  Path to the audio file(s). [mutually exclusive with --url][at least one required]│ -│--url URL   Link to the youtube video (or any stream supported by yt-dlp) to extract audio     │ -│               from and use. [mutually exclusive with --path][at least one required]│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Advanced loop options ──────────────────────────────────────────────────────────────────────────╮ -│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]  The minimum loop duration as a multiplier of │ -│                                                     the audio track's total duration. [default: │ -│0.35]│ -│--min-loop-duration      FLOAT RANGE [x>0]  The minimum loop duration in seconds.        │ -│(overrides --min-duration-multiplier if set)│ -│--max-loop-duration      FLOAT RANGE [x>0]  The maximum loop duration in seconds.        │ -│--approx-loop-position   FLOAT RANGE... [x>=0]  The approximate desired loop start and loop  │ -│                                                     end in seconds. (+/-2 second search window │ -│for each point)│ -│--brute-force                                       Check the entire audio track instead of just │ -│                                                     the detected beats. (Warning: may take │ -│several minutes to complete.)│ -│--disable-pruning                                   Disables filtering of the detected loop      │ -│                                                     points from the initial pass.                │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│--help  Show this message and exit.                                                              │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Usage:pymusiclooper play[OPTIONS] + +Play an audio file on repeat from the terminal with the best discovered loop points, or a chosen  +point if interactive mode is active. + +┌─ Basic options ──────────────────────────────────────────────────────────────────────────────────┐ +│--pathPATHPath to the audio file(s). [mutually exclusive with --url][at least one required]│ +│--url URL Link to the youtube video (or any stream supported by yt-dlp) to extract audio    │ +│from and use. [mutually exclusive with --path][at least one required]│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Advanced loop options ──────────────────────────────────────────────────────────────────────────┐ +│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]The minimum loop duration as a multiplier of│ +│the audio track's total duration. [default: │ +│0.35]│ +│--min-loop-duration      FLOAT RANGE [x>0]The minimum loop duration in seconds.       │ +│(overrides --min-duration-multiplier if set)│ +│--max-loop-duration      FLOAT RANGE [x>0]The maximum loop duration in seconds.│ +│--approx-loop-position   FLOAT RANGE... [x>=0]The approximate desired loop start and loop │ +│end in seconds. (+/-2 second search window │ +│for each point)│ +│--brute-force            Check the entire audio track instead of just│ +│the detected beats. (Warning: may take │ +│several minutes to complete.)│ +│--disable-pruning        Disables filtering of the detected loop     │ +│points from the initial pass.               │ +│--ignore-tags            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.                   │ +│--tags-only              Only use the loop points stored in the      │ +│file's metadata tags, skipping the loop     │ +│analysis; files without valid loop tags are │ +│skipped. (Much faster for files that are │ +│already tagged.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--helpShow this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/img/pymusiclooper-split-audio.svg b/img/pymusiclooper-split-audio.svg index a875ed5..2e34f91 100644 --- a/img/pymusiclooper-split-audio.svg +++ b/img/pymusiclooper-split-audio.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + pymusiclooper split-audio --help - + - - $ pymusiclooper split-audio --help - -Usage:pymusiclooper split-audio [OPTIONS]                                                          - - Split the input audio into intro, loop and outro sections.                                          - -╭─ Basic options ──────────────────────────────────────────────────────────────────────────────────╮ -│--pathPATH  Path to the audio file(s). [mutually exclusive with --url][at least one required]│ -│--url URL   Link to the youtube video (or any stream supported by yt-dlp) to extract audio     │ -│               from and use. [mutually exclusive with --path][at least one required]│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Advanced loop options ──────────────────────────────────────────────────────────────────────────╮ -│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]  The minimum loop duration as a multiplier of │ -│                                                     the audio track's total duration. [default: │ -│0.35]│ -│--min-loop-duration      FLOAT RANGE [x>0]  The minimum loop duration in seconds.        │ -│(overrides --min-duration-multiplier if set)│ -│--max-loop-duration      FLOAT RANGE [x>0]  The maximum loop duration in seconds.        │ -│--approx-loop-position   FLOAT RANGE... [x>=0]  The approximate desired loop start and loop  │ -│                                                     end in seconds. (+/-2 second search window │ -│for each point)│ -│--brute-force                                       Check the entire audio track instead of just │ -│                                                     the detected beats. (Warning: may take │ -│several minutes to complete.)│ -│--disable-pruning                                   Disables filtering of the detected loop      │ -│                                                     points from the initial pass.                │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Export options ─────────────────────────────────────────────────────────────────────────────────╮ -│--output-dir-oDIRECTORY           The output directory to use for the exported files.        │ -│--format    [wav|flac|ogg|mp3]  Audio format to use for the exported split audio files.    │ -│[default: WAV]│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Batch options ──────────────────────────────────────────────────────────────────────────────────╮ -│--recursive-r  Process directories recursively.                                                │ -│--flatten  -f  Flatten the output directory structure instead of preserving it when using the  │ -│--recursive flag. (Note: files with identical filenames are silently │ -│overwritten.)│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│--help  Show this message and exit.                                                              │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Usage:pymusiclooper split-audio[OPTIONS] + +Split the input audio into intro, loop and outro sections. + +┌─ Basic options ──────────────────────────────────────────────────────────────────────────────────┐ +│--pathPATHPath to the audio file(s). [mutually exclusive with --url][at least one required]│ +│--url URL Link to the youtube video (or any stream supported by yt-dlp) to extract audio    │ +│from and use. [mutually exclusive with --path][at least one required]│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Advanced loop options ──────────────────────────────────────────────────────────────────────────┐ +│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]The minimum loop duration as a multiplier of│ +│the audio track's total duration. [default: │ +│0.35]│ +│--min-loop-duration      FLOAT RANGE [x>0]The minimum loop duration in seconds.       │ +│(overrides --min-duration-multiplier if set)│ +│--max-loop-duration      FLOAT RANGE [x>0]The maximum loop duration in seconds.│ +│--approx-loop-position   FLOAT RANGE... [x>=0]The approximate desired loop start and loop │ +│end in seconds. (+/-2 second search window │ +│for each point)│ +│--brute-force            Check the entire audio track instead of just│ +│the detected beats. (Warning: may take │ +│several minutes to complete.)│ +│--disable-pruning        Disables filtering of the detected loop     │ +│points from the initial pass.               │ +│--ignore-tags            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.                   │ +│--tags-only              Only use the loop points stored in the      │ +│file's metadata tags, skipping the loop     │ +│analysis; files without valid loop tags are │ +│skipped. (Much faster for files that are │ +│already tagged.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Export options ─────────────────────────────────────────────────────────────────────────────────┐ +│--output-dir-oDIRECTORY         The output directory to use for the exported files.│ +│--format    [wav|flac|ogg|mp3]Audio format to use for the exported split audio files.   │ +│[default: WAV]│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Batch options ──────────────────────────────────────────────────────────────────────────────────┐ +│--recursive-rProcess directories recursively.│ +│--flatten  -fFlatten the output directory structure instead of preserving it when using the │ +│--recursive flag. (Note: files with identical filenames are silently │ +│overwritten.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--helpShow this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/img/pymusiclooper-tag.svg b/img/pymusiclooper-tag.svg index 927d4b8..920275a 100644 --- a/img/pymusiclooper-tag.svg +++ b/img/pymusiclooper-tag.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + - + pymusiclooper tag --help - + - - $ pymusiclooper tag --help - -Usage:pymusiclooper tag [OPTIONS]                                                                  - - Adds metadata tags of loop points to a copy of the input audio file(s).                             - -╭─ Basic options ──────────────────────────────────────────────────────────────────────────────────╮ -│--path                      PATH     Path to the audio file(s). [mutually exclusive with │ -│--url][at least one required]│ -│--url                       URL      Link to the youtube video (or any stream supported by  │ -│                                           yt-dlp) to extract audio from and use. [mutually │ -│exclusive with --path][at least one required]│ -│*--tag-names                 TEXT...  Name of the loop metadata tags to use, e.g.            │ -│--tag-names LOOP_START LOOP_END [required]│ -│--tag-offset/--no-tag-offset           Always export second loop metadata tag as a relative   │ -│                                           length / or as an absolute length. Default:            │ -│                                           auto-detected based on tag name.                       │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Advanced loop options ──────────────────────────────────────────────────────────────────────────╮ -│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]  The minimum loop duration as a multiplier of │ -│                                                     the audio track's total duration. [default: │ -│0.35]│ -│--min-loop-duration      FLOAT RANGE [x>0]  The minimum loop duration in seconds.        │ -│(overrides --min-duration-multiplier if set)│ -│--max-loop-duration      FLOAT RANGE [x>0]  The maximum loop duration in seconds.        │ -│--approx-loop-position   FLOAT RANGE... [x>=0]  The approximate desired loop start and loop  │ -│                                                     end in seconds. (+/-2 second search window │ -│for each point)│ -│--brute-force                                       Check the entire audio track instead of just │ -│                                                     the detected beats. (Warning: may take │ -│several minutes to complete.)│ -│--disable-pruning                                   Disables filtering of the detected loop      │ -│                                                     points from the initial pass.                │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Export options ─────────────────────────────────────────────────────────────────────────────────╮ -│--output-dir-oDIRECTORY  The output directory to use for the exported files.                 │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Batch options ──────────────────────────────────────────────────────────────────────────────────╮ -│--recursive-r  Process directories recursively.                                                │ -│--flatten  -f  Flatten the output directory structure instead of preserving it when using the  │ -│--recursive flag. (Note: files with identical filenames are silently │ -│overwritten.)│ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│--help  Show this message and exit.                                                              │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ + + +Usage:pymusiclooper tag[OPTIONS] + +Adds metadata tags of loop points to a copy of the input audio file(s). + +┌─ Basic options ──────────────────────────────────────────────────────────────────────────────────┐ +│--path                      PATH   Path to the audio file(s). [mutually exclusive with │ +│--url][at least one required]│ +│--url                       URL    Link to the youtube video (or any stream supported by │ +│yt-dlp) to extract audio from and use. [mutually │ +│exclusive with --path][at least one required]│ +│*--tag-names                 TEXT...Name of the loop metadata tags to use, e.g.           │ +│--tag-names LOOP_START LOOP_END [required]│ +│--tag-offset/--no-tag-offsetAlways export second loop metadata tag as a relative  │ +│length / or as an absolute length. Default:           │ +│auto-detected based on tag name.                      │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Advanced loop options ──────────────────────────────────────────────────────────────────────────┐ +│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]The minimum loop duration as a multiplier of│ +│the audio track's total duration. [default: │ +│0.35]│ +│--min-loop-duration      FLOAT RANGE [x>0]The minimum loop duration in seconds.       │ +│(overrides --min-duration-multiplier if set)│ +│--max-loop-duration      FLOAT RANGE [x>0]The maximum loop duration in seconds.│ +│--approx-loop-position   FLOAT RANGE... [x>=0]The approximate desired loop start and loop │ +│end in seconds. (+/-2 second search window │ +│for each point)│ +│--brute-force            Check the entire audio track instead of just│ +│the detected beats. (Warning: may take │ +│several minutes to complete.)│ +│--disable-pruning        Disables filtering of the detected loop     │ +│points from the initial pass.               │ +│--ignore-tags            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.                   │ +│--tags-only              Only use the loop points stored in the      │ +│file's metadata tags, skipping the loop     │ +│analysis; files without valid loop tags are │ +│skipped. (Much faster for files that are │ +│already tagged.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Export options ─────────────────────────────────────────────────────────────────────────────────┐ +│--output-dir-oDIRECTORYThe output directory to use for the exported files.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Batch options ──────────────────────────────────────────────────────────────────────────────────┐ +│--recursive-rProcess directories recursively.│ +│--flatten  -fFlatten the output directory structure instead of preserving it when using the │ +│--recursive flag. (Note: files with identical filenames are silently │ +│overwritten.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--helpShow this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ diff --git a/img/pymusiclooper-trim.svg b/img/pymusiclooper-trim.svg new file mode 100644 index 0000000..61b49b0 --- /dev/null +++ b/img/pymusiclooper-trim.svg @@ -0,0 +1,281 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + pymusiclooper trim --help + + + + + + + + + + +Usage:pymusiclooper trim[OPTIONS] + +Losslessly cut the audio after the loop end, keeping its original format, bit depth and metadata.  +(WAV, FLAC and OGG Vorbis only) + +┌─ Basic options ──────────────────────────────────────────────────────────────────────────────────┐ +│--path      PATH                Path to the audio file(s). [mutually exclusive with --url]│ +│[at least one required]│ +│--url       URL                 Link to the youtube video (or any stream supported by       │ +│yt-dlp) to extract audio from and use. [mutually exclusive │ +│with --path][at least one required]│ +│--keep-afterINTEGER RANGE [x>=0]Number of samples to keep after the loop end. (A few samples│ +│avoid clicks in players that read slightly past the loop │ +│end, e.g. for resampling; use 0 to cut exactly at the loop │ +│end.)[default: 100]│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Advanced loop options ──────────────────────────────────────────────────────────────────────────┐ +│--min-duration-multiplierFLOAT RANGE [0.0<x<1.0]The minimum loop duration as a multiplier of│ +│the audio track's total duration. [default: │ +│0.35]│ +│--min-loop-duration      FLOAT RANGE [x>0]The minimum loop duration in seconds.       │ +│(overrides --min-duration-multiplier if set)│ +│--max-loop-duration      FLOAT RANGE [x>0]The maximum loop duration in seconds.│ +│--approx-loop-position   FLOAT RANGE... [x>=0]The approximate desired loop start and loop │ +│end in seconds. (+/-2 second search window │ +│for each point)│ +│--brute-force            Check the entire audio track instead of just│ +│the detected beats. (Warning: may take │ +│several minutes to complete.)│ +│--disable-pruning        Disables filtering of the detected loop     │ +│points from the initial pass.               │ +│--ignore-tags            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.                   │ +│--tags-only              Only use the loop points stored in the      │ +│file's metadata tags, skipping the loop     │ +│analysis; files without valid loop tags are │ +│skipped. (Much faster for files that are │ +│already tagged.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Export options ─────────────────────────────────────────────────────────────────────────────────┐ +│--output-dir-oDIRECTORYThe output directory to use for the exported files.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Batch options ──────────────────────────────────────────────────────────────────────────────────┐ +│--recursive-rProcess directories recursively.│ +│--flatten  -fFlatten the output directory structure instead of preserving it when using the │ +│--recursive flag. (Note: files with identical filenames are silently │ +│overwritten.)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--helpShow this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ + + + + diff --git a/img/pymusiclooper.svg b/img/pymusiclooper.svg index 97b75ae..cdab0f3 100644 --- a/img/pymusiclooper.svg +++ b/img/pymusiclooper.svg @@ -1,4 +1,4 @@ - + - - + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + - + pymusiclooper --help - + - - $ pymusiclooper --help - -Usage:pymusiclooper [OPTIONS] COMMAND [ARGS]...                                                    - - A program for repeating music seamlessly and endlessly, by automatically finding the best loop      - points.                                                                                             - -╭─ Options ────────────────────────────────────────────────────────────────────────────────────────╮ -│--debug      -d  Enables debugging mode.                                                       │ -│--verbose    -v  Enables verbose logging output.                                               │ -│--interactive-i  Enables interactive mode to manually preview/choose the desired loop point.   │ -│--samples    -s  Display all the loop points shown in interactive mode in sample points        │ -│                    instead of the default mm:ss.sss format.                                      │ -│--version          Show the version and exit.                                                    │ -│--help             Show this message and exit.                                                   │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Play Commands ──────────────────────────────────────────────────────────────────────────────────╮ -│play        Play an audio file on repeat from the terminal with the best discovered loop        │ -│ points, or a chosen point if interactive mode is active.                            │ -│play-tagged Skips loop analysis and reads the loop points directly from the tags present in the │ -│ file.                                                                               │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─ Export Commands ────────────────────────────────────────────────────────────────────────────────╮ -│export-points Export the best discovered or chosen loop points to a text file or to the         │ -│ terminal.                                                                         │ -│split-audio   Split the input audio into intro, loop and outro sections.                        │ -│tag           Adds metadata tags of loop points to a copy of the input audio file(s).           │ -│extend        Create an extended version of the input audio by looping it to a specific length. │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ - - Full documentation and examples can be found at https://github.com/arkrow/PyMusicLooper             - + + +Usage:pymusiclooper[OPTIONS] COMMAND [ARGS]... + +A program for repeating music seamlessly and endlessly, by automatically finding the best loop  +points. + +┌─ Options ────────────────────────────────────────────────────────────────────────────────────────┐ +│--debug      -dEnables debugging mode.│ +│--verbose    -vEnables verbose logging output.│ +│--interactive-iEnables interactive mode to manually preview/choose the desired loop point.│ +│--samples    -sDisplay all the loop points shown in interactive mode in sample points       │ +│instead of the default mm:ss.sss format.                                     │ +│--version    Show the version and exit.│ +│--help       Show this message and exit.│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Play Commands ──────────────────────────────────────────────────────────────────────────────────┐ +│play       Play an audio file on repeat from the terminal with the best discovered loop       │ +│points, or a chosen point if interactive mode is active.                           │ +│play-taggedSkips loop analysis and reads the loop points directly from the tags present in the│ +│file.                                                                              │ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ +┌─ Export Commands ────────────────────────────────────────────────────────────────────────────────┐ +│export-pointsExport the best discovered or chosen loop points to a text file or to the        │ +│terminal.                                                                        │ +│split-audio  Split the input audio into intro, loop and outro sections.                       │ +│tag          Adds metadata tags of loop points to a copy of the input audio file(s).          │ +│extend       Create an extended version of the input audio by looping it to a specific length.│ +│trim         Losslessly cut the audio after the loop end, keeping its original format, bit    │ +│depth and metadata. (WAV, FLAC and OGG Vorbis only)│ +└──────────────────────────────────────────────────────────────────────────────────────────────────┘ + +Full documentation and examples can be found at https://github.com/arkrow/PyMusicLooper +