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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ pymusiclooper -i play --url "https://www.youtube.com/watch?v=dQw4w9WgXcQ"
pymusiclooper play-tagged --path "TRACK_NAME.mp3" --tag-names LOOP_START LOOP_END
```

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

### Export

Expand All @@ -145,14 +145,18 @@ pymusiclooper export-points --path "/path/to/track.wav" --alt-export-top -1

# Add metadata tags of the best discovered loop points to a copy of the input audio file
# (or all audio files in a directory, if a directory path is used instead)
# WAV files also get the loop points in their sampler (`smpl`) chunk, which many game engines and samplers read
pymusiclooper -i tag --path "TRACK_NAME.mp3" --tag-names LOOP_START LOOP_END

# Losslessly cut everything after the loop end (WAV, FLAC and OGG Vorbis only), keeping 1000 samples past it
# The original format, bit depth and tags are preserved; OGG files are cut without re-encoding
# (100 by default, which avoids clicks in players that read slightly past the loop end; use 0 to cut exactly at it)
# The original format, bit depth and metadata are preserved; WAV and OGG files are cut without re-encoding.
# WAV and OGG files keep all their metadata (e.g. tags, WAV sampler loops and cue points);
# FLAC files keep all theirs (e.g. tags and cover art) except the seek table and cue sheet, which no longer apply
pymusiclooper -i trim --path "TRACK_NAME.wav" --keep-after 1000

# In interactive mode, the other export subcommands also offer to trim WAV, FLAC and OGG Vorbis files
# after the loop is chosen; with `tag`, a single tagged and trimmed copy is written
# after the loop is chosen (except when printing loop points to the terminal); with `tag`, a single tagged and trimmed copy is written
pymusiclooper -i tag --path "TRACK_NAME.ogg" --tag-names LOOPSTART LOOPLENGTH


Expand Down
6 changes: 3 additions & 3 deletions pymusiclooper/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from pymusiclooper.console import _COMMAND_GROUPS, _OPTION_GROUPS, rich_console
from pymusiclooper.core import MusicLooper
from pymusiclooper.exceptions import AudioLoadError, LoopNotFoundError
from pymusiclooper.handler import BatchHandler, LoopExportHandler, LoopHandler
from pymusiclooper.handler import RECOMMENDED_KEEP_AFTER, BatchHandler, LoopExportHandler, LoopHandler
from pymusiclooper.utils import download_audio, get_outputdir, mk_outputdir

# CLI --help styling
Expand Down Expand Up @@ -232,9 +232,9 @@ def tag(**kwargs):
@common_path_options
@common_loop_options
@common_export_options
@click.option('--keep-after', type=click.IntRange(min=0), default=0, show_default=True, help="Number of samples to keep after the loop end.")
@click.option('--keep-after', type=click.IntRange(min=0), default=RECOMMENDED_KEEP_AFTER, show_default=True, help="Number of samples to keep after the loop end. [dim](A few samples avoid clicks in players that read slightly past the loop end, e.g. for resampling; use [cyan]0[/] to cut exactly at the loop end.)[/]")
def trim(**kwargs):
"""Losslessly cut the audio after the loop end, keeping its original format, bit depth and tags. [dim](WAV, FLAC and OGG Vorbis only)[/]"""
"""Losslessly cut the audio after the loop end, keeping its original format, bit depth and metadata. [dim](WAV, FLAC and OGG Vorbis only)[/]"""
kwargs["trim"] = True
run_handler(**kwargs)

Expand Down
75 changes: 59 additions & 16 deletions pymusiclooper/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@
from pymusiclooper.analysis import LoopPair, find_best_loop_points
from pymusiclooper.audio import MLAudio
from pymusiclooper.exceptions import LoopNotFoundError
from pymusiclooper.flac import copy_flac_metadata
from pymusiclooper.ogg import trim_vorbis
from pymusiclooper.playback import PlaybackHandler
from pymusiclooper.wav import read_smpl_loop, trim_wav, write_smpl_loop

# Detected loop points this close to the embedded ones (in seconds) are the same loop, so they are not listed twice
_DUPLICATE_LOOP_TOLERANCE = 0.005

# Lazy-load external libraries when they're needed
soundfile = lazy.load("soundfile")
Expand All @@ -41,7 +46,8 @@ def find_loop_pairs(
use_embedded_tags: bool = True,
) -> 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.
If the file already has loop points stored in its metadata tags, they are returned as the first choice,
and detected loop points matching them (within 5ms) are left out.

Args:
min_duration_multiplier (float, optional): The minimum duration of a loop as a multiplier of track duration. Defaults to 0.35.
Expand Down Expand Up @@ -80,12 +86,20 @@ def find_loop_pairs(
loop_pairs = []

if embedded_pair is not None:
tolerance = self.mlaudio.seconds_to_samples(_DUPLICATE_LOOP_TOLERANCE)
loop_pairs = [
pair
for pair in loop_pairs
if abs(pair.loop_start - embedded_pair.loop_start) > tolerance
or abs(pair.loop_end - embedded_pair.loop_end) > tolerance
]
loop_pairs.insert(0, embedded_pair)

return loop_pairs

def read_embedded_loop_pair(self) -> Optional[LoopPair]:
"""Reads the loop points stored in the file's metadata tags (e.g. LOOP_START/LOOP_END), auto-detecting the tag names.
"""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.

Returns:
Optional[LoopPair]: A `LoopPair` with `from_metadata=True`, or None if the file has no valid loop tags.
Expand Down Expand Up @@ -315,8 +329,10 @@ def trim(
output_dir: Optional[str] = None,
) -> str:
"""Losslessly cuts the audio `keep_after` samples after the loop end, removing the rest of the track.
The original container, bit depth and tags are kept; only WAV, FLAC and Ogg Vorbis files are supported.
Ogg Vorbis files are trimmed at the container level, without re-encoding the audio.
The original container, bit depth and metadata are kept; only WAV, FLAC and Ogg Vorbis files are supported.
WAV and Ogg Vorbis files are trimmed at the container level, without re-encoding the audio, and all their
metadata is copied (e.g. WAV sampler loops and cue points). FLAC audio is re-encoded losslessly, and all its
metadata except the seek table and cue sheet is copied (e.g. tags and cover art).
Returns the path to the trimmed audio file.

Args:
Expand Down Expand Up @@ -357,11 +373,15 @@ def _trim_to(self, output_file_path: str, loop_end: int, keep_after: int):
f"Lossless trimming is only supported for PCM/float WAV, FLAC and Ogg Vorbis files; \"{self.filename}\" is {info.format} ({info.subtype})."
)

# The pages/chunks holding the metadata are copied verbatim, so the metadata is kept as-is
if info.format == "OGG":
# The pages holding the tags are copied verbatim, so the tags are kept as-is
trim_vorbis(self.filepath, output_file_path, loop_end + keep_after)
return
if info.format == "WAV":
trim_wav(self.filepath, output_file_path, loop_end + keep_after)
return

# FLAC audio frames cannot be cut, so the kept samples are re-encoded (losslessly).
# Read the samples in their native representation so that writing them back is bit-exact
dtype = {"FLOAT": "float32", "DOUBLE": "float64"}.get(info.subtype, "int32")
n_frames = min(info.frames, loop_end + keep_after)
Expand All @@ -376,10 +396,16 @@ def _trim_to(self, output_file_path: str, loop_end: int, keep_after: int):
endian=info.endian,
)

self._copy_tags(output_file_path)
try:
copy_flac_metadata(self.filepath, output_file_path)
except ValueError as e:
logging.warning(
f"Could not copy the FLAC metadata blocks of \"{self.filename}\" ({e}); copying its tags only."
)
self._copy_tags(output_file_path)

def _copy_tags(self, dest_filepath: str):
"""Attempts to copy the metadata tags of the source audio file to `dest_filepath`."""
"""Attempts to copy the metadata tags of the source audio file to `dest_filepath`, logging a warning on failure."""
try:
import taglib
original_tags = None
Expand All @@ -389,10 +415,11 @@ def _copy_tags(self, dest_filepath: str):
with taglib.File(dest_filepath, save_on_exit=True) as dest_file:
for tag in original_tags:
dest_file.tags[tag] = original_tags[tag]
except Exception:
# silently ignore errors for now;
# TODO: implement logging for debugging
pass
except Exception as e:
# Tag copying is best-effort: the exported audio is still valid without them
logging.warning(
f"Could not copy the metadata tags of \"{self.filename}\" to \"{os.path.basename(dest_filepath)}\": {e}"
)

def export_txt(
self,
Expand Down Expand Up @@ -496,6 +523,7 @@ def export_tags(
keep_after: int = 0,
) -> Tuple[str]:
"""Adds metadata tags of loop points to a copy of the source audio file.
For WAV files, the loop points are also written to the sampler (smpl) chunk.

Args:
loop_start (int): Loop start in samples.
Expand Down Expand Up @@ -528,18 +556,24 @@ def export_tags(
shutil.copyfile(self.mlaudio.filepath, exported_file_path)

# Handle LOOPLENGTH tag
if self._end_tag_is_offset(loop_end_tag, is_offset):
loop_end = loop_end - loop_start
loop_end_tag_value = loop_end - loop_start if self._end_tag_is_offset(loop_end_tag, is_offset) else loop_end

with taglib.File(exported_file_path, save_on_exit=True) as audio_file:
audio_file.tags[loop_start_tag] = [str(loop_start)]
audio_file.tags[loop_end_tag] = [str(loop_end)]
audio_file.tags[loop_end_tag] = [str(loop_end_tag_value)]

# Game engines and samplers read the loop points of WAV files from their sampler chunk
# (written after the tags, so that saving the tags cannot affect it)
if soundfile.info(exported_file_path).format in ("WAV", "WAVEX"):
write_smpl_loop(exported_file_path, loop_start, loop_end, self.mlaudio.rate)

return str(loop_start), str(loop_end)
return str(loop_start), str(loop_end_tag_value)


def read_tags(self, loop_start_tag: str, loop_end_tag: str, is_offset: Optional[bool] = None) -> Tuple[int, int]:
"""Reads the tags provided from the file and returns the read loop points
"""Reads the tags provided from the file and returns the read loop points.
If both tag names are None, they are auto-detected, and a WAV file without loop tags
has its loop points read from its sampler (smpl) chunk instead.

Args:
loop_start_tag (str): The name of the metadata tag containing the loop_start value
Expand All @@ -549,6 +583,15 @@ def read_tags(self, loop_start_tag: str, loop_end_tag: str, is_offset: Optional[
Returns:
Tuple[int, int]: A tuple containing (loop_start, loop_end)
"""
try:
return self._read_loop_tags(loop_start_tag, loop_end_tag, is_offset)
except ValueError:
smpl_loop = read_smpl_loop(self.filepath) if loop_start_tag is None and loop_end_tag is None else None
if smpl_loop is None:
raise
return smpl_loop

def _read_loop_tags(self, loop_start_tag: str, loop_end_tag: str, is_offset: Optional[bool] = None) -> Tuple[int, int]:
# Workaround for taglib import issues on Apple silicon devices
# Import taglib only when needed to isolate ImportErrors
import taglib
Expand Down
96 changes: 96 additions & 0 deletions pymusiclooper/flac.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
"""Restores the metadata of a FLAC file onto a re-encoded copy of its audio.

A FLAC file is a list of metadata blocks followed by the audio frames. The metadata blocks of the source file
are copied verbatim (tags, pictures such as cover art, application blocks, padding), except for the ones that
describe the audio itself: the stream info (length, MD5 checksum) is taken from the re-encoded copy, and the seek table
and cue sheet are dropped, since they point to positions in the source audio that no longer exist in the copy.
"""

from typing import List, Tuple

_STREAMINFO = 0
_SEEKTABLE = 3
_CUESHEET = 5
_LAST_BLOCK_FLAG = 0x80
_ID3V1_LENGTH = 128


def _id3v2_length(data: bytes) -> int:
"""Returns the length of the ID3v2 tag at the start of the file (some taggers add one to FLAC files), or 0."""
if len(data) < 10 or data[:3] != b"ID3":
return 0
# The tag size is stored as a 28-bit "synchsafe" integer (7 bits per byte)
size = 0
for byte in data[6:10]:
size = (size << 7) | (byte & 0x7F)
has_footer = data[5] & 0x10
return 10 + size + (10 if has_footer else 0)


def _read_metadata(data: bytes) -> Tuple[int, List[Tuple[int, bytes]], int]:
"""Parses the metadata blocks of a FLAC file.

Returns:
Tuple[int, List[Tuple[int, bytes]], int]: (offset of the "fLaC" marker, [(block type, block data)],
offset of the first audio frame)
"""
marker_offset = _id3v2_length(data)
if data[marker_offset:marker_offset + 4] != b"fLaC":
raise ValueError("Not a FLAC file.")

blocks = []
offset = marker_offset + 4
while True:
if offset + 4 > len(data):
raise ValueError("Invalid FLAC file: truncated metadata.")
header = data[offset]
length = int.from_bytes(data[offset + 1:offset + 4], "big")
block_data = data[offset + 4:offset + 4 + length]
if len(block_data) != length:
raise ValueError("Invalid FLAC file: truncated metadata.")
blocks.append((header & 0x7F, block_data))
offset += 4 + length
if header & _LAST_BLOCK_FLAG:
return marker_offset, blocks, offset


def copy_flac_metadata(src_filepath: str, dest_filepath: str):
"""Replaces the metadata of the FLAC file `dest_filepath` with the metadata of `src_filepath`,
except for the blocks describing the audio itself (see the module docstring).
ID3 tags that taggers sometimes add before or after the FLAC data are copied too.

Args:
src_filepath (str): Path to the FLAC file to copy the metadata from.
dest_filepath (str): Path to the FLAC file to write the metadata to, in place.

Raises:
ValueError: if either file is not a valid FLAC file.
"""
with open(src_filepath, "rb") as f:
src = f.read()
with open(dest_filepath, "rb") as f:
dest = f.read()

src_marker_offset, src_blocks, _ = _read_metadata(src)
_, dest_blocks, dest_audio_offset = _read_metadata(dest)

dest_streaminfo = [block_data for block_type, block_data in dest_blocks if block_type == _STREAMINFO]
if not dest_streaminfo:
raise ValueError("Invalid FLAC file: missing stream info.")

blocks = [(_STREAMINFO, dest_streaminfo[0])] + [
(block_type, block_data)
for block_type, block_data in src_blocks
if block_type not in (_STREAMINFO, _SEEKTABLE, _CUESHEET)
]

output = [src[:src_marker_offset], b"fLaC"]
for idx, (block_type, block_data) in enumerate(blocks):
last_block_flag = _LAST_BLOCK_FLAG if idx == len(blocks) - 1 else 0
output += [bytes([last_block_flag | block_type]), len(block_data).to_bytes(3, "big"), block_data]
output.append(dest[dest_audio_offset:])
if src[-_ID3V1_LENGTH:-_ID3V1_LENGTH + 3] == b"TAG":
output.append(src[-_ID3V1_LENGTH:])

with open(dest_filepath, "wb") as f:
f.write(b"".join(output))
10 changes: 8 additions & 2 deletions pymusiclooper/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from pymusiclooper.exceptions import AudioLoadError, LoopNotFoundError
from pymusiclooper.utils import DEFAULT_OUTPUT_DIRECTORY_NAME

# Default number of samples to keep after the loop end when trimming interactively: enough for players
# Default number of samples to keep after the loop end when trimming (interactively or with the trim command): enough for players
# that read slightly past the loop end (e.g. for resampling), while being inaudible (~2 ms at 44.1 kHz)
RECOMMENDED_KEEP_AFTER = 100

Expand Down Expand Up @@ -248,7 +248,13 @@ def run(self):
loop_start = chosen_loop_pair.loop_start
loop_end = chosen_loop_pair.loop_end

if self.interactive_mode and not self.trim and self.musiclooper.supports_lossless_trim():
# Exporting the loop points to the console only prints them, so it does not offer to write a trimmed file
if (
self.interactive_mode
and not self.trim
and not self.to_stdout
and self.musiclooper.supports_lossless_trim()
):
with _hideprogressbar(self._progressbar):
self.trim, self.keep_after = self.trim_prompt()

Expand Down
Loading
Loading