Source code for minim.audio

"""
Audio file objects
==================
.. moduleauthor:: Benjamin Ye <GitHub: bbye98>

This module provides convenient Python objects to keep track of audio
file handles and metadata, and convert between different audio formats.
"""

import base64
import datetime
from importlib.util import find_spec
from io import BytesIO
import logging
import pathlib
import re
import subprocess
from typing import Any, Union
import urllib
import warnings

from mutagen import id3, flac, mp3, mp4, oggflac, oggopus, oggvorbis, wave

from . import utility, FOUND_FFMPEG
from .qobuz import _parse_performers

if FOUND_FFMPEG:
    from . import FFMPEG_CODECS
if FOUND_PILLOW := find_spec("PIL") is not None:
    from PIL import Image

__all__ = [
    "Audio",
    "FLACAudio",
    "MP3Audio",
    "MP4Audio",
    "OggAudio",
    "WAVEAudio",
]


class _ID3:
    """
    ID3 metadata container handler for MP3 and WAVE audio files.

    .. attention::

       This class should *not* be instantiated manually. Instead, use
       :class:`MP3Audio` or :class:`WAVEAudio` to process metadata for
       MP3 and WAVE audio files, respectively.

    Parameters
    ----------
    filename : `str`
        Audio filename.

    tags : `mutagen.id3.ID3`
        ID3 metadata.
    """

    _FIELDS = {
        # field: (ID3 frame, base class, typecasting function)
        "album": ("TALB", "text", None),
        "album_artist": ("TPE2", "text", None),
        "artist": ("TPE1", "text", None),
        "comment": ("COMM", "text", None),
        "compilation": ("TCMP", "text", lambda x: str(int(x))),
        "composer": ("TCOM", "text", None),
        "copyright": ("TCOP", "text", None),
        "date": ("TDRC", "text", None),
        "genre": ("TCON", "text", None),
        "isrc": ("TSRC", "text", None),
        "lyrics": ("USLT", "text", None),
        "tempo": ("TBPM", "text", str),
        "title": ("TIT2", "text", None),
    }

    def __init__(self, filename: str, tags: id3.ID3) -> None:
        """
        Create an ID3 tag handler.
        """
        self._filename = filename
        self._tags = tags
        self._from_file()

    def _from_file(self) -> None:
        """
        Get metadata from the ID3 tags embedded in the audio file.
        """
        for field, (frame, base, _) in self._FIELDS.items():
            value = self._tags.getall(frame)
            if value:
                value = (
                    [sv for v in value for sv in getattr(v, base)]
                    if len(value) > 1
                    else getattr(value[0], base)
                )
                if list not in self._FIELDS_TYPES[field]:
                    value = utility.format_multivalue(
                        value, False, primary=True
                    )
                    if not isinstance(value, self._FIELDS_TYPES[field]):
                        try:
                            value = self._FIELDS_TYPES[field][0](value)
                        except ValueError:
                            logging.warning()
                            continue
                else:
                    if not isinstance(value[0], self._FIELDS_TYPES[field]):
                        try:
                            value = [
                                self._FIELDS_TYPES[field][0](v) for v in value
                            ]
                        except ValueError:
                            continue
                    if len(value) == 1:
                        value = value[0]
            else:
                value = None
            setattr(self, field, value)

        if "TPOS" in self._tags:
            disc_number = getattr(self._tags.get("TPOS"), "text")[0]
            if "/" in disc_number:
                self.disc_number, self.disc_count = (
                    int(d) for d in disc_number.split("/")
                )
            else:
                self.disc_number = int(disc_number)
                self.disc_count = None
        else:
            self.disc_number = self.disc_count = None

        if "TRCK" in self._tags:
            track_number = getattr(self._tags.get("TRCK"), "text")[0]
            if "/" in track_number:
                self.track_number, self.track_count = (
                    int(t) for t in track_number.split("/")
                )
            else:
                self.track_number = int(track_number)
                self.track_count = None
        else:
            self.track_number = self.track_count = None

        artwork = self._tags.getall("APIC")
        if artwork:
            self.artwork = artwork[0]
            if self.artwork.type != 3 and len(artwork) > 1:
                for p in artwork:
                    if p.type == 3:
                        self.artwork = p
                        break
            self._artwork_format = self.artwork.mime.split("/")[1]
            self.artwork = self.artwork.data
        else:
            self.artwork = self._artwork_format = None

    def write_metadata(self) -> None:
        """
        Write metadata to file.
        """
        for field, (frame, base, func) in self._FIELDS.items():
            value = getattr(self, field)
            if value:
                value = utility.format_multivalue(
                    value, self._multivalue, sep=self._sep
                )
                self._tags.add(
                    getattr(id3, frame)(
                        **{base: func(value) if func else value}
                    )
                )

        if "TXXX:comment" in self._tags:
            self._tags.delall("TXXX:comment")

        if disc_number := getattr(self, "disc_number", None):
            disc = str(disc_number)
            if disc_count := getattr(self, "disc_count", None):
                disc += f"/{disc_count}"
            self._tags.add(id3.TPOS(text=disc))

        if track_number := getattr(self, "track_number", None):
            track = str(track_number)
            if track_count := getattr(self, "track_count", None):
                track += f"/{track_count}"
            self._tags.add(id3.TRCK(text=track))

        if self.artwork:
            IMAGE_FORMATS = dict.fromkeys(
                ["jpg", "jpeg", "jpe", "jif", "jfif", "jfi"], "image/jpeg"
            ) | {"png": "image/png"}

            if isinstance(self.artwork, str):
                with (
                    urllib.request.urlopen(self.artwork)
                    if "http" in self.artwork
                    else open(self.artwork, "rb")
                ) as f:
                    self.artwork = f.read()
            self._tags.add(
                id3.APIC(
                    data=self.artwork, mime=IMAGE_FORMATS[self._artwork_format]
                )
            )

        self._tags.save()


class _VorbisComment:
    """
    Vorbis comment handler for FLAC and Ogg audio files.

    .. attention::

       This class should *not* be instantiated manually. Instead, use
       :class:`FLACAudio` or :class:`OggAudio` to process metadata for
       FLAC and Ogg audio files, respectively.

    Parameters
    ----------
    filename : `str`
        Audio filename.

    tags : `mutagen.id3.ID3`
        ID3 metadata.
    """

    _FIELDS = {
        # field: (Vorbis comment key, typecasting function)
        "album": ("album", None),
        "album_artist": ("albumartist", None),
        "artist": ("artist", None),
        "comment": ("description", None),
        "composer": ("composer", None),
        "copyright": ("copyright", None),
        "date": ("date", None),
        "genre": ("genre", None),
        "isrc": ("isrc", None),
        "lyrics": ("lyrics", None),
        "tempo": ("bpm", str),
        "title": ("title", None),
    }
    _FIELDS_SPECIAL = {
        "compilation": ("compilation", lambda x: str(int(x))),
        "disc_number": ("discnumber", str),
        "disc_count": ("disctotal", str),
        "track_number": ("tracknumber", str),
        "track_count": ("tracktotal", str),
    }

    def __init__(self, filename: str, tags: id3.ID3) -> None:
        """
        Create a Vorbis comment handler.
        """
        self._filename = filename
        self._tags = tags
        self._from_file()

    def _from_file(self) -> None:
        """
        Get metadata from the tags embedded in the FLAC audio file.
        """
        for field, (key, _) in self._FIELDS.items():
            value = self._tags.get(key)
            if value:
                if list not in self._FIELDS_TYPES[field]:
                    value = utility.format_multivalue(
                        value, False, primary=True
                    )
                    if type(value) not in self._FIELDS_TYPES[field]:
                        try:
                            value = self._FIELDS_TYPES[field][0](value)
                        except ValueError:
                            continue
                else:
                    if type(value[0]) not in self._FIELDS_TYPES[field]:
                        try:
                            value = [
                                self._FIELDS_TYPES[field][0](v) for v in value
                            ]
                        except ValueError:
                            continue
                    if len(value) == 1:
                        value = value[0]
            else:
                value = None
            setattr(self, field, value)

        self.compilation = (
            bool(int(self._tags.get("compilation")[0]))
            if "compilation" in self._tags
            else None
        )

        if "discnumber" in self._tags:
            disc_number = self._tags.get("discnumber")[0]
            if "/" in disc_number:
                self.disc_number, self.disc_count = (
                    int(d) for d in disc_number.split("/")
                )
            else:
                self.disc_number = int(disc_number)
                self.disc_count = self._tags.get("disctotal")
                if self.disc_count:
                    self.disc_count = int(self.disc_count[0])
        else:
            self.disc_number = self.disc_count = None

        if "tracknumber" in self._tags:
            track_number = self._tags.get("tracknumber")[0]
            if "/" in track_number:
                self.track_number, self.track_count = (
                    int(t) for t in track_number.split("/")
                )
            else:
                self.track_number = int(track_number)
                self.track_count = self._tags.get("tracktotal")
                if self.track_count:
                    self.track_count = int(self.track_count[0])
        else:
            self.track_number = self.track_count = None

        if hasattr(self._handle, "pictures") and self._handle.pictures:
            self.artwork = self._handle.pictures[0].data
            self._artwork_format = self._handle.pictures[0].mime.split("/")[1]
        elif "metadata_block_picture" in self._tags:
            IMAGE_FILE_SIGS = {
                "jpg": b"\xff\xd8\xff\xe0\x00\x10\x4a\x46\x49\x46\x00\x01",
                "png": b"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a",
            }
            self.artwork = base64.b64decode(
                self._tags["metadata_block_picture"][0].encode()
            )
            for img_fmt, file_sig in IMAGE_FILE_SIGS.items():
                if file_sig in self.artwork:
                    self.artwork = self.artwork[
                        re.search(file_sig, self.artwork).span()[0] :
                    ]
                    self._artwork_format = img_fmt
        else:
            self.artwork = self._artwork_format = None

    def write_metadata(self) -> None:
        """
        Write metadata to file.
        """
        for field, (key, func) in (
            self._FIELDS | self._FIELDS_SPECIAL
        ).items():
            value = getattr(self, field)
            if value:
                value = utility.format_multivalue(
                    value, self._multivalue, sep=self._sep
                )
                self._tags[key] = func(value) if func else value

        if self.artwork:
            artwork = flac.Picture()
            artwork.type = id3.PictureType.COVER_FRONT
            artwork.mime = f"image/{self._artwork_format}"
            if isinstance(self.artwork, str):
                with (
                    urllib.request.urlopen(self.artwork)
                    if "http" in self.artwork
                    else open(self.artwork, "rb")
                ) as f:
                    self.artwork = f.read()
            artwork.data = self.artwork
            try:
                self._handle.clear_pictures()
                self._handle.add_picture(artwork)
            except ValueError:
                self._tags["metadata_block_picture"] = base64.b64encode(
                    artwork.write()
                ).decode()

        self._handle.save()


[docs] class Audio: r""" Generic audio file handler. Subclasses for specific audio containers or formats include * :class:`FLACAudio` for audio encoded using the Free Lossless Audio Codec (FLAC), * :class:`MP3Audio` for audio encoded and stored in the MPEG Audio Layer III (MP3) format, * :class:`MP4Audio` for audio encoded in the Advanced Audio Coding (AAC) format, encoded using the Apple Lossless Audio Codec (ALAC), or stored in a MPEG-4 Part 14 (MP4, M4A) container, * :class:`OggAudio` for Opus or Vorbis audio stored in an Ogg file, and * :class:`WAVEAudio` for audio encoded using linear pulse-code modulation (LPCM) and in the Waveform Audio File Format (WAVE). .. note:: This class can instantiate a specific file handler from the list above for an audio file by examining its file extension. However, there may be instances when this detection fails, especially when the audio codec and format combination is rarely seen. As such, it is always best to directly use one of the subclasses above to create a file handler for your audio file when its audio codec and format are known. Parameters ---------- file : `str` or `pathlib.Path` Audio filename or path. pattern : `tuple`, keyword-only, optional Regular expression search pattern and the corresponding metadata field(s). .. container:: **Valid values**: The supported metadata fields are * :code:`"artist"` for the track artist, * :code:`"title"` for the track title, and * :code:`"track_number"` for the track number. **Examples**: * :code:`("(.*) - (.*)", ("artist", "title"))` matches filenames like "Taylor Swift - Cruel Summer.flac". * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches filenames like "04 - The Man.m4a". * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches filenames like "13 You Need to Calm Down.mp3". multivalue : `bool` Determines whether multivalue tags are supported. If :code:`False`, the items in `value` are concatenated using the separator(s) specified in `sep`. sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` Separator(s) to use to concatenate multivalue tags. If a :code:`str` is provided, it is used to concatenate all values. If a :code:`tuple` is provided, the first :code:`str` is used to concatenate the first :math:`n - 1` values, and the second :code:`str` is used to append the final value. Attributes ---------- album : `str` Album title. album_artist : `str` or `list` Album artist(s). artist : `str` or `list` Artist(s). artwork : `bytes` or `str` Byte-representation of, URL leading to, or filename of file containing the cover artwork. bit_depth : `int` Bits per sample. bitrate : `int` Bitrate in bytes per second (B/s). channel_count : `int` Number of audio channels. codec : `str` Audio codec. comment : `str` Comment(s). compilation : `bool` Whether the album is a compilation of songs by various artists. composer : `str` or `list` Composers, lyrics, and/or writers. copyright : `str` Copyright information. date : `str` Release date. disc_number : `int` Disc number. disc_count : `int` Total number of discs. genre : `str` or `list` Genre. isrc : `str` International Standard Recording Code (ISRC). lyrics : `str` Lyrics. sample_rate : `int` Sample rate in Hz. tempo : `int` Tempo in beats per minute (bpm). title : `str` Track title. track_number : `int` Track number. track_count : `int` Total number of tracks. """ _FIELDS_TYPES = { "_artwork_format": (str,), "album": (str,), "album_artist": (str, list), "artist": (str, list), "artwork": (bytes, str), "comment": (str,), "compilation": (bool,), "composer": (str, list), "copyright": (str,), "date": (str,), "disc_number": (int,), "disc_count": (int,), "genre": (str, list), "isrc": (str,), "lyrics": (str,), "tempo": (int,), "title": (str,), "track_number": (int,), "track_count": (int,), } def __init__( self, file: Union[str, pathlib.Path], *, pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, sep: Union[str, list[str]] = (", ", " & "), ) -> None: """ Instantiate an audio file handler. """ self._file = pathlib.Path(file).resolve() self._pattern = pattern self._multivalue = multivalue self._sep = sep def __new__(cls, *args, **kwargs) -> None: """ Create an audio file handler. Parameters ---------- file : `str` or `pathlib.Path` Audio file. """ if cls == Audio: file = kwargs.get("file") if file is None: file = args[0] file = pathlib.Path(file) if not file.is_file(): raise FileNotFoundError(f"'{file}' not found.") ext = file.suffix[1:].lower() for a in Audio.__subclasses__(): if ext in a._EXTENSIONS: return a(*args, **kwargs) raise TypeError(f"'{file}' has an unsupported audio format.") return super(Audio, cls).__new__(cls) def _from_filename(self) -> None: """ Get track information from the filename. """ if self._pattern: groups = re.findall(self._pattern[0], self._file.stem) if groups: missing = tuple( k in {"artist", "title", "track_number"} and getattr(self, k) is None for k in self._pattern[1] ) for flag, attr, val in zip( missing, self._pattern[1], groups[0] ): if flag: setattr(self, attr, self._FIELDS_TYPES[attr][0](val))
[docs] def convert( self, codec: str, container: str = None, options: str = None, *, filename: str = None, preserve: bool = True, ) -> None: """ Convert the current audio file to another format. .. admonition:: Software dependency Requires `FFmpeg <https://ffmpeg.org/>`_. .. note:: The audio file handler is automatically updated to reflect the new audio file format. For example, converting a FLAC audio file to an ALAC audio file will change the file handler from a :class:`FLACAudio` object to an :class:`MP4Audio` object. Parameters ---------- codec : `str` New audio codec or coding format. .. container:: **Valid values**: * :code:`"aac"`, :code:`"m4a"`, :code:`"mp4"`, or :code:`"mp4a"` for lossy AAC audio. * :code:`"alac"` for lossless ALAC audio. * :code:`"flac"` for lossless FLAC audio. * :code:`"mp3"` for lossy MP3 audio. * :code:`"ogg"` or :code:`"opus"` for lossy Opus audio * :code:`"vorbis"` for lossy Vorbis audio. * :code:`"lpcm"`, :code:`"wav"`, or :code:`"wave"` for lossless LPCM audio. container : `str`, optional New audio file container. If not specified, the best container is determined based on `codec`. .. container:: **Valid values**: * :code:`"flac"` for a FLAC audio container, which only supports FLAC audio. * :code:`"m4a"`, :code:`"mp4"`, or :code:`"mp4a"` for a MP4 audio container, which supports AAC and ALAC audio. * :code:`"mp3"` for a MP3 audio container, which only supports MP3 audio. * :code:`"ogg"` for an Ogg audio container, which supports FLAC, Opus, and Vorbis audio. * :code:`"wav"` or :code:`"wave"` for an WAVE audio container, which only supports LPCM audio. options : `str`, optional FFmpeg command-line options, excluding the input and output files, the :code:`-y` flag (to overwrite files), and the :code:`-c:v copy` argument (to preserve cover art for containers that support it). .. container:: **Defaults**: * AAC audio: :code:`"-c:a aac -b:a 256k"` (or :code:`"-c:a libfdk_aac -b:a 256k"` if FFmpeg was compiled with :code:`--enable-libfdk-aac`) * ALAC audio: :code:`"-c:a alac"` * FLAC audio: :code:`"-c:a flac"` * MP3 audio: :code:`"-c:a libmp3lame -q:a 0"` * Opus audio: :code:`"-c:a libopus -b:a 256k -vn"` * Vorbis audio: :code:`"-c:a vorbis -strict experimental -vn"` (or :code:`"-c:a libvorbis -vn"` if FFmpeg was compiled with :code:`--enable-libvorbis`) * WAVE audio: :code:`"-c:a pcm_s16le"` or :code:`"-c:a pcm_s24le"`, depending on the bit depth of the original audio file. filename : `str`, keyword-only, optional Filename of the converted audio file. If not provided, the filename of the original audio file, but with the appropriate new extension appended, is used. preserve : `bool`, keyword-only, default: :code:`True` Determines whether the original audio file is kept. """ if not FOUND_FFMPEG: emsg = ( "Audio conversion is unavailable because FFmpeg was not found." ) raise RuntimeError(emsg) _codec = ( codec.capitalize() if codec in {"opus", "vorbis"} else codec.upper() ) codec = codec.lower() if codec in {"m4a", "mp4", "mp4a"}: codec = "aac" elif codec == "ogg": codec = "opus" elif codec in "wave": codec = "lpcm" if container: container = container.lower() if container == "m4a": container = "mp4" elif container == "wave": container = "wav" try: acls = next( a for a in Audio.__subclasses__() if codec in a._CODECS and container in a._EXTENSIONS ) except StopIteration: emsg = ( f"{_codec} audio is incompatible with " f"the {container.upper()} container." ) raise RuntimeError(emsg) else: try: acls = next( a for a in Audio.__subclasses__() if codec in a._CODECS ) container = acls._EXTENSIONS[0] except StopIteration: raise RuntimeError(f"The '{_codec}' codec is not supported.") if ("mp4" if codec == "aac" else codec) in self.codec and isinstance( self, acls ): wmsg = ( f"'{self._file}' already has {_codec} " f"audio in a {container.upper()} container. " "Re-encoding may lead to quality degradation from " "generation loss." ) logging.warning(wmsg) ext = f".{acls._EXTENSIONS[0]}" if filename is None: filename = self._file.with_suffix(ext) else: if isinstance(filename, str): if "/" not in filename: filename = f"{self._file.parent}/{filename}" filename = pathlib.Path(filename).resolve() if filename.suffix != ext: filename = filename.with_suffix(ext) filename.parent.mkdir(parents=True, exist_ok=True) if self._file == filename: filename = filename.with_stem(f"{filename.stem}_") if options is None: if codec == "lpcm": options = acls._CODECS[codec]["ffmpeg"].format( self.bit_depth if hasattr(self, "bit_depth") else 16 ) else: options = acls._CODECS[codec]["ffmpeg"] subprocess.run( f'ffmpeg -y -i "{self._file}" {options} -loglevel error ' f'-stats "{filename}"', shell=True, ) if not preserve: self._file.unlink() obj = acls(filename) self.__class__ = obj.__class__ self.__dict__ = obj.__dict__ | { key: value for (key, value) in self.__dict__.items() if key in self._FIELDS_TYPES }
[docs] def set_metadata_using_itunes( self, data: dict[str, Any], *, album_data: dict[str, Any] = None, artwork_size: Union[int, str] = 1400, artwork_format: str = "jpg", overwrite: bool = False, ) -> None: """ Populate tags using data retrieved from the iTunes Search API. Parameters ---------- data : `dict` Information about the track in JSON format obtained using the iTunes Search API via :meth:`minim.itunes.SearchAPI.search` or :meth:`minim.itunes.SearchAPI.lookup`. album_data : `dict`, keyword-only, optional Information about the track's album in JSON format obtained using the iTunes Search API via :meth:`minim.itunes.SearchAPI.search` or :meth:`minim.itunes.SearchAPI.lookup`. If not provided, album artist and copyright information is unavailable. artwork_size : `int` or `str`, keyword-only, default: :code:`1400` Resized artwork size in pixels. If :code:`artwork_size="raw"`, the uncompressed high-resolution image is retrieved, regardless of size. artwork_format : `str`, keyword-only, :code:`{"jpg", "png"}` Artwork file format. If :code:`artwork_size="raw"`, the file format of the uncompressed high-resolution image takes precedence. overwrite : `bool`, keyword-only, default: :code:`False` Determines whether existing metadata should be overwritten. """ if self.album is None or overwrite: self.album = data["collectionName"] if self.artist is None or overwrite: self.artist = data["artistName"] if self.artwork is None or overwrite: self.artwork = data["artworkUrl100"] if self.artwork: if artwork_size == "raw": if "Feature" in self.artwork: self.artwork = ( "https://a5.mzstatic.com/us/r1000/0" f"/{re.search(r'Feature.*?(jpg|png|tif)(?=/|$)', self.artwork)[0]}" ) elif "Music" in self.artwork: self.artwork = ( "https://a5.mzstatic.com/" f"{re.search(r'Music.*?(jpg|png|tif)(?=/|$)', self.artwork)[0]}" ) self._artwork_format = pathlib.Path(self.artwork).suffix[ 1: ] else: self.artwork = self.artwork.replace( "100x100bb.jpg", f"{artwork_size}x{artwork_size}bb.{artwork_format}", ) self._artwork_format = artwork_format with urllib.request.urlopen(self.artwork) as r: self.artwork = r.read() if self._artwork_format == "tif": if FOUND_PILLOW: with Image.open(BytesIO(self.artwork)) as a: with BytesIO() as b: a.save(b, format="png") self.artwork = b.getvalue() self._artwork_format = "png" else: wmsg = ( "The Pillow library is required to process " "TIFF images, but was not found. No artwork " "will be embedded for the current track." ) warnings.warn(wmsg) self.artwork = self._artwork_format = None if self.compilation is None or overwrite: self.compilation = self.album_artist == "Various Artists" if "releaseDate" in data and (self.date is None or overwrite): self.date = data["releaseDate"] if self.disc_number is None or overwrite: self.disc_number = data["discNumber"] if self.disc_count is None or overwrite: self.disc_count = data["discCount"] if self.genre is None or overwrite: self.genre = data["primaryGenreName"] if self.title is None or overwrite: self.title = max(data["trackName"], data["trackCensoredName"]) if self.track_number is None or overwrite: self.track_number = data["trackNumber"] if self.track_count is None or overwrite: self.track_count = data["trackCount"] if album_data: if self.album_artist is None or overwrite: self.album_artist = album_data["artistName"] if self.copyright or overwrite: self.copyright = album_data["copyright"]
[docs] def set_metadata_using_qobuz( self, data: dict[str, Any], *, artwork_size: str = "large", comment: str = None, overwrite: bool = False, ) -> None: """ Populate tags using data retrieved from the Qobuz API. Parameters ---------- data : `dict` Information about the track in JSON format obtained using the Qobuz API via :meth:`minim.qobuz.PrivateAPI.get_track` or :meth:`minim.qobuz.PrivateAPI.search`. artwork_size : `str`, keyword-only, default: :code:`"large"` Artwork size. **Valid values**: :code:`"large"`, :code:`"small"`, or :code:`"thumbnail"`. comment : `str`, keyword-only, optional Comment or description. overwrite : `bool`, keyword-only, default: :code:`False` Determines whether existing metadata should be overwritten. """ if self.album is None or overwrite: self.album = data["album"]["title"] if album_artists := data["album"].get("artists"): album_feat_artist = [ a["name"] for a in album_artists if "featured-artist" in a["roles"] ] if album_feat_artist and "feat." not in self.album: self.album += ( " [feat. {}]" if "(" in self.album else " (feat. {})" ).format( utility.format_multivalue(album_feat_artist, False) ) if data["album"]["version"]: self.album += ( " [{}]" if "(" in self.album else " ({})" ).format(data["album"]["version"]) self.album = self.album.replace(" ", " ") if self.album_artist is None or overwrite: if album_artists := data["album"].get("artists"): album_artist = [ a["name"] for a in album_artists if "main-artist" in a["roles"] ] album_main_artist = data["album"]["artist"]["name"] if album_main_artist in album_artist: if ( i := ( album_artist.index(album_main_artist) if album_main_artist in album_artist else 0 ) ) != 0: album_artist.insert(0, album_artist.pop(i)) self.album_artist = album_artist else: self.album_artist = album_main_artist else: self.album_artist = data["album"]["artist"]["name"] credits = _parse_performers( data["performers"], roles=["MainArtist", "FeaturedArtist", "Composers"], ) if self.artist is None or overwrite: self.artist = ( credits.get("main_artist") or data["performer"]["name"] ) if self.artwork is None or overwrite: if artwork_size not in ( ARTWORK_SIZES := {"large", "small", "thumbnail"} ): emsg = ( f"Invalid artwork size '{artwork_size}'. " f"Valid values: {ARTWORK_SIZES}." ) raise ValueError(emsg) self.artwork = data["album"]["image"][artwork_size] self._artwork_format = pathlib.Path(self.artwork).suffix[1:] if self.comment is None or overwrite: self.comment = comment if self.composer is None or overwrite: self.composer = credits.get("composers") or ( data["composer"]["name"] if hasattr(data, "composer") else None ) if self.copyright is None or overwrite: self.copyright = data["album"].get("copyright") if self.date is None or overwrite: self.date = min( ( datetime.datetime.utcfromtimestamp(dt) if isinstance(dt, int) else ( datetime.datetime.strptime(dt, "%Y-%m-%d") if isinstance(dt, str) else datetime.datetime.max ) ) for dt in ( data.get(k) for k in { "release_date_original", "release_date_download", "release_date_stream", "release_date_purchase", "purchasable_at", "streamable_at", } ) ).strftime("%Y-%m-%dT%H:%M:%SZ") if self.disc_number is None or overwrite: self.disc_number = data["media_number"] if self.disc_count is None or overwrite: self.disc_count = data["album"]["media_count"] if self.genre is None or overwrite: self.genre = data["album"]["genre"]["name"] if self.isrc is None or overwrite: self.isrc = data["isrc"] if self.title is None or overwrite: self.title = data["title"] if ( feat_artist := credits.get("featured_artist") ) and "feat." not in self.title: self.title += ( " [feat. {}]" if "(" in self.title else " (feat. {})" ).format(utility.format_multivalue(feat_artist, False)) if data["version"]: self.title += ( " [{}]" if "(" in self.title else " ({})" ).format(data["version"]) self.title = self.title.replace(" ", " ") if self.track_number is None or overwrite: self.track_number = data["track_number"] if self.track_count is None or overwrite: self.track_count = data["album"]["tracks_count"] if ( data["album"].get("release_type") == "single" and self.album == self.title ): self.album += " - Single" self.album_artist = self.artist = max( self.artist, self.album_artist, key=len )
[docs] def set_metadata_using_spotify( self, data: dict[str, Any], *, audio_features: dict[str, Any] = None, lyrics: Union[str, dict[str, Any]] = None, overwrite: bool = False, ) -> None: """ Populate tags using data retrieved from the Spotify Web API and Spotify Lyrics service. Parameters ---------- data : `dict` Information about the track in JSON format obtained using the Spotify Web API via :meth:`minim.spotify.WebAPI.get_track`. audio_features : `dict`, keyword-only, optional Information about the track's audio features obtained using the Spotify Web API via :meth:`minim.spotify.WebAPI.get_track_audio_features`. If not provided, tempo information is unavailable. lyrics : `str` or `dict`, keyword-only Information about the track's formatted or time-synced lyrics obtained using the Spotify Lyrics service via :meth:`minim.spotify.PrivateLyricsService.get_lyrics`. If not provided, lyrics are unavailable. overwrite : `bool`, keyword-only, default: :code:`False` Determines whether existing metadata should be overwritten. """ if self.album is None or overwrite: self.album = data["album"]["name"] if data["album"]["album_type"] == "single": self.album += " - Single" if self.album_artist is None or overwrite: self.album_artist = [a["name"] for a in data["album"]["artists"]] if self.artist is None or overwrite: self.artist = [a["name"] for a in data["artists"]] if self.artwork is None or overwrite: with urllib.request.urlopen( data["album"]["images"][0]["url"] ) as r: self.artwork = r.read() self._artwork_format = "jpg" if self.compilation is None or overwrite: self.compilation = data["album"]["album_type"] == "compilation" if self.date is None or overwrite: self.date = data["album"]["release_date"] if self.disc_number is None or overwrite: self.disc_number = data["disc_number"] if self.isrc is None or overwrite: self.isrc = data["external_ids"]["isrc"] if (self.lyrics is None or overwrite) and lyrics: self.lyrics = ( lyrics if isinstance(lyrics, str) else "\n".join( line["words"] for line in lyrics["lyrics"]["lines"] ) ) if (self.tempo is None or overwrite) and audio_features: self.tempo = round(audio_features["tempo"]) if self.title is None or overwrite: self.title = data["name"] if self.track_number is None or overwrite: self.track_number = data["track_number"] if self.track_count is None or overwrite: self.track_count = data["album"]["total_tracks"]
[docs] def set_metadata_using_tidal( self, data: dict[str, Any], *, album_data: dict[str, Any] = None, artwork_size: int = 1280, composers: Union[str, list[str], dict[str, Any]] = None, lyrics: dict[str, Any] = None, comment: str = None, overwrite: bool = False, ) -> None: """ Populate tags using data retrieved from the TIDAL API. Parameters ---------- data : `dict` Information about the track in JSON format obtained using the TIDAL API via :meth:`minim.tidal.API.get_track`, :meth:`minim.tidal.API.search`, :meth:`minim.tidal.PrivateAPI.get_track`, or :meth:`minim.tidal.PrivateAPI.search`. album_data : `dict`, keyword-only, optional Information about the track's album in JSON format obtained using the TIDAL API via :meth:`minim.tidal.API.get_album`, :meth:`minim.tidal.API.search`, :meth:`minim.tidal.PrivateAPI.get_album`, or :meth:`minim.tidal.PrivateAPI.search`. If not provided, album artist and disc and track numbering information is unavailable. artwork_size : `int`, keyword-only, default: :code:`1280` Maximum artwork size in pixels. **Valid values**: `artwork_size` should be between :code:`80` and :code:`1280`. composers : `str`, `list`, or `dict`, keyword-only, optional Information about the track's composers in a formatted `str`, a `list`, or a `dict` obtained using the TIDAL API via :meth:`minim.tidal.PrivateAPI.get_track_composers`, :meth:`minim.tidal.PrivateAPI.get_track_contributors`, or :meth:`minim.tidal.PrivateAPI.get_track_credits`. If not provided, songwriting credits are unavailable. lyrics : `str` or `dict`, keyword-only, optional The track's lyrics obtained using the TIDAL API via :meth:`minim.tidal.PrivateAPI.get_track_lyrics`. comment : `str`, keyword-only, optional Comment or description. overwrite : `bool`, keyword-only, default: :code:`False` Determines whether existing metadata should be overwritten. """ if "resource" in data: data = data["resource"] if self.album is None or overwrite: self.album = data["album"]["title"] if (self.comment is None or overwrite) and comment: self.comment = comment if (self.composer is None or overwrite) and composers: COMPOSER_TYPES = {"Composer", "Lyricist", "Writer"} if isinstance(composers, dict): self.composer = sorted( { c["name"] for c in composers["items"] if c["role"] in COMPOSER_TYPES } ) elif isinstance(composers[0], dict): self.composer = sorted( { c["name"] for r in composers for c in r["contributors"] if r["type"] in COMPOSER_TYPES } ) else: self.composer = composers if self.copyright is None or overwrite: self.copyright = data["copyright"] if self.disc_number is None or overwrite: self.disc_number = data["volumeNumber"] if self.isrc is None or overwrite: self.isrc = data["isrc"] if (self.lyrics is None or overwrite) and lyrics: self.lyrics = ( lyrics if isinstance(lyrics, str) else lyrics["lyrics"] ) if self.title is None or overwrite: self.title = data["title"] if self.track_number is None or overwrite: self.track_number = data["trackNumber"] if "artifactType" in data: if self.artist is None or overwrite: self.artist = [a["name"] for a in data["artists"] if a["main"]] if self.artwork is None or overwrite: image_urls = sorted( data["album"]["imageCover"], key=lambda x: x["width"], reverse=True, ) self.artwork = ( image_urls[-1]["url"] if artwork_size < image_urls[-1]["width"] else next( u["url"] for u in image_urls if u["width"] <= artwork_size ) ) self._artwork_format = pathlib.Path(self.artwork).suffix[1:] else: if self.artist is None or overwrite: self.artist = [ a["name"] for a in data["artists"] if a["type"] == "MAIN" ] if self.artwork is None or overwrite: artwork_size = ( 80 if artwork_size < 80 else next( s for s in [1280, 1080, 750, 640, 320, 160, 80] if s <= artwork_size ) ) self.artwork = ( "https://resources.tidal.com/images" f"/{data['album']['cover'].replace('-', '/')}" f"/{artwork_size}x{artwork_size}.jpg" ) self._artwork_format = "jpg" if self.date is None or overwrite: self.date = f"{data['streamStartDate'].split('.')[0]}Z" if album_data: if self.copyright is None or overwrite: self.copyright = album_data["copyright"] if self.disc_count is None or overwrite: self.disc_count = album_data["numberOfVolumes"] if self.track_count is None or overwrite: self.track_count = album_data["numberOfTracks"] if "barcodeId" in album_data: if self.album_artist is None or overwrite: self.album_artist = [ a["name"] for a in album_data["artists"] if a["main"] ] if self.date is None or overwrite: self.date = f"{album_data['releaseDate']}T00:00:00Z" else: if self.album_artist is None or overwrite: self.album_artist = [ a["name"] for a in album_data["artists"] if a["type"] == "MAIN" ]
[docs] class FLACAudio(Audio, _VorbisComment): r""" FLAC audio file handler. .. seealso:: For a full list of attributes and their descriptions, see :class:`Audio`. Parameters ---------- file : `str` or `pathlib.Path` FLAC audio filename or path. pattern : `tuple`, keyword-only, optional Regular expression search pattern and the corresponding metadata field(s). .. container:: **Valid values**: The supported metadata fields are * :code:`"artist"` for the track artist, * :code:`"title"` for the track title, and * :code:`"track_number"` for the track number. **Examples**: * :code:`("(.*) - (.*)", ("artist", "title"))` matches filenames like "Taylor Swift - Fearless.flac". * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches filenames like "03 - Love Story.flac". * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches filenames like "06 You Belong with Me.flac". multivalue : `bool` Determines whether multivalue tags are supported. If :code:`False`, the items in `value` are concatenated using the separator(s) specified in `sep`. sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` Separator(s) to use to concatenate multivalue tags. If a :code:`str` is provided, it is used to concatenate all values. If a :code:`tuple` is provided, the first :code:`str` is used to concatenate the first :math:`n - 1` values, and the second :code:`str` is used to append the final value. """ _CODECS = {"flac": {"ffmpeg": "-c:a flac -c:v copy"}} _EXTENSIONS = ["flac"] def __init__( self, file: Union[str, pathlib.Path], *, pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, sep: Union[str, list[str]] = (", ", " & "), ) -> None: """ Create a FLAC audio file handler. """ Audio.__init__( self, file, pattern=pattern, multivalue=multivalue, sep=sep ) self._handle = flac.FLAC(file) if self._handle.tags is None: self._handle.add_tags() _VorbisComment.__init__(self, self._file.name, self._handle.tags) self._from_filename() self.bit_depth = self._handle.info.bits_per_sample self.bitrate = self._handle.info.bitrate self.channel_count = self._handle.info.channels self.codec = "flac" self.sample_rate = self._handle.info.sample_rate
[docs] class MP3Audio(Audio, _ID3): r""" MP3 audio file handler. .. seealso:: For a full list of attributes and their descriptions, see :class:`Audio`. Parameters ---------- file : `str` or `pathlib.Path` MP3 audio filename or path. pattern : `tuple`, keyword-only, optional Regular expression search pattern and the corresponding metadata field(s). .. container:: **Valid values**: The supported metadata fields are * :code:`"artist"` for the track artist, * :code:`"title"` for the track title, and * :code:`"track_number"` for the track number. **Examples**: * :code:`("(.*) - (.*)", ("artist", "title"))` matches filenames like "Taylor Swift - Red.mp3". * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches filenames like "04 - I Knew You Were Trouble.mp3". * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches filenames like "06 22.mp3". multivalue : `bool` Determines whether multivalue tags are supported. If :code:`False`, the items in `value` are concatenated using the separator(s) specified in `sep`. sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` Separator(s) to use to concatenate multivalue tags. If a :code:`str` is provided, it is used to concatenate all values. If a :code:`tuple` is provided, the first :code:`str` is used to concatenate the first :math:`n - 1` values, and the second :code:`str` is used to append the final value. """ _CODECS = {"mp3": {"ffmpeg": "-c:a libmp3lame -q:a 0 -c:v copy"}} _EXTENSIONS = ["mp3"] def __init__( self, file: Union[str, pathlib.Path], *, pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, sep: Union[str, list[str]] = (", ", " & "), ) -> None: """ Create a MP3 audio file handler. """ _handle = mp3.MP3(file) _handle.tags.filename = str(file) Audio.__init__( self, file, pattern=pattern, multivalue=multivalue, sep=sep ) _ID3.__init__(self, self._file.name, _handle.tags) self._from_filename() self.bit_depth = None self.bitrate = _handle.info.bitrate self.channel_count = _handle.info.channels self.codec = "mp3" self.sample_rate = _handle.info.sample_rate
[docs] class MP4Audio(Audio): r""" MP4 audio file handler. .. seealso:: For a full list of attributes and their descriptions, see :class:`Audio`. Parameters ---------- file : `str` or `pathlib.Path` MP4 audio filename or path. pattern : `tuple`, keyword-only, optional Regular expression search pattern and the corresponding metadata field(s). .. container:: **Valid values**: The supported metadata fields are * :code:`"artist"` for the track artist, * :code:`"title"` for the track title, and * :code:`"track_number"` for the track number. **Examples**: * :code:`("(.*) - (.*)", ("artist", "title"))` matches filenames like "Taylor Swift - Mine.m4a". * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches filenames like "04 - Speak Now.m4a". * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches filenames like "07 The Story of Us.m4a". multivalue : `bool` Determines whether multivalue tags are supported. If :code:`False`, the items in `value` are concatenated using the separator(s) specified in `sep`. sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` Separator(s) to use to concatenate multivalue tags. If a :code:`str` is provided, it is used to concatenate all values. If a :code:`tuple` is provided, the first :code:`str` is used to concatenate the first :math:`n - 1` values, and the second :code:`str` is used to append the final value. """ _CODECS = { "aac": { "ffmpeg": f"-b:a 256k -c:a {FFMPEG_CODECS['aac'] if FOUND_FFMPEG else 'aac'} -c:v copy" }, "alac": {"ffmpeg": "-c:a alac -c:v copy"}, } _EXTENSIONS = ["m4a", "aac", "mp4"] _FIELDS = { # field: Apple iTunes metadata list key "album": "\xa9alb", "album_artist": "aART", "artist": "\xa9ART", "comment": "\xa9cmt", "compilation": "cpil", "composer": "\xa9wrt", "copyright": "cprt", "date": "\xa9day", "genre": "\xa9gen", "lyrics": "\xa9lyr", "tempo": "tmpo", "title": "\xa9nam", } _IMAGE_FORMATS = dict.fromkeys( ["jpg", "jpeg", "jpe", "jif", "jfif", "jfi", 13], mp4.MP4Cover.FORMAT_JPEG, ) | dict.fromkeys(["png", 14], mp4.MP4Cover.FORMAT_PNG) def __init__( self, file: Union[str, pathlib.Path], *, pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, sep: Union[str, list[str]] = (", ", " & "), ) -> None: """ Create a MP4 audio file handler. """ super().__init__(file, pattern=pattern, multivalue=multivalue, sep=sep) self._handle = mp4.MP4(file) self.bit_depth = self._handle.info.bits_per_sample self.bitrate = self._handle.info.bitrate self.channel_count = self._handle.info.channels self.codec = self._handle.info.codec self.sample_rate = self._handle.info.sample_rate self._multivalue = multivalue self._sep = sep self._from_file() self._from_filename() def _from_file(self) -> None: """ Get metadata from the tags embedded in the MP4 audio file. """ for field, key in self._FIELDS.items(): value = self._handle.get(key) if value: if list not in self._FIELDS_TYPES[field]: value = utility.format_multivalue( value, False, primary=True ) if type(value) not in self._FIELDS_TYPES[field]: try: value = self._FIELDS_TYPES[field][0](value) except ValueError: continue else: if type(value[0]) not in self._FIELDS_TYPES[field]: try: value = [ self._FIELDS_TYPES[field][0](v) for v in value ] except ValueError: continue if len(value) == 1: value = value[0] else: value = None setattr(self, field, value) self.isrc = ( self._handle.get("----:com.apple.iTunes:ISRC")[0].decode() if "----:com.apple.iTunes:ISRC" in self._handle else None ) if "disk" in self._handle: self.disc_number, self.disc_count = self._handle.get("disk")[0] else: self.disc_number = self.disc_count = None if "trkn" in self._handle: self.track_number, self.track_count = self._handle.get("trkn")[0] else: self.track_number = self.track_count = None if "covr" in self._handle: self.artwork = utility.format_multivalue( self._handle.get("covr"), False, primary=True ) self._artwork_format = ( str(self._IMAGE_FORMATS[self.artwork.imageformat]) .split(".")[1] .lower() ) self.artwork = bytes(self.artwork) else: self.artwork = self._artwork_format = None
[docs] def write_metadata(self) -> None: """ Write metadata to file. """ for field, key in self._FIELDS.items(): value = getattr(self, field) if value: value = utility.format_multivalue( value, self._multivalue, sep=self._sep ) try: self._handle[key] = value except ValueError: self._handle[key] = [value] if self.isrc: self._handle["----:com.apple.iTunes:ISRC"] = self.isrc.encode() if self.disc_number or self.disc_count: self._handle["disk"] = [ (self.disc_number or 0, self.disc_count or 0) ] if self.track_number or self.track_count: self._handle["trkn"] = [ (self.track_number or 0, self.track_count or 0) ] if self.artwork: if isinstance(self.artwork, str): with ( urllib.request.urlopen(self.artwork) if "http" in self.artwork else open(self.artwork, "rb") ) as f: self.artwork = f.read() self._handle["covr"] = [ mp4.MP4Cover( self.artwork, imageformat=self._IMAGE_FORMATS[self._artwork_format], ) ] self._handle.save()
[docs] class OggAudio(Audio, _VorbisComment): r""" Ogg audio file handler. .. seealso:: For a full list of attributes and their descriptions, see :class:`Audio`. Parameters ---------- file : `str` or `pathlib.Path` Ogg audio filename or path. codec : `str`, optional Audio codec. If not specified, it will be determined automatically. **Valid values**: :code:`"flac"`, :code:`"opus"`, or :code:`"vorbis"`. pattern : `tuple`, keyword-only, optional Regular expression search pattern and the corresponding metadata field(s). .. container:: **Valid values**: The supported metadata fields are * :code:`"artist"` for the track artist, * :code:`"title"` for the track title, and * :code:`"track_number"` for the track number. **Examples**: * :code:`("(.*) - (.*)", ("artist", "title"))` matches filenames like "Taylor Swift - Blank Space.ogg". * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches filenames like "03 - Style.ogg". * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches filenames like "06 Shake It Off.ogg". multivalue : `bool` Determines whether multivalue tags are supported. If :code:`False`, the items in `value` are concatenated using the separator(s) specified in `sep`. sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` Separator(s) to use to concatenate multivalue tags. If a :code:`str` is provided, it is used to concatenate all values. If a :code:`tuple` is provided, the first :code:`str` is used to concatenate the first :math:`n - 1` values, and the second :code:`str` is used to append the final value. """ _CODECS = { "flac": {"ffmpeg": "-c:a flac", "mutagen": oggflac.OggFLAC}, "opus": { "ffmpeg": "-b:a 256k -c:a libopus -vn", "mutagen": oggopus.OggOpus, }, "vorbis": { "ffmpeg": f"-c:a {FFMPEG_CODECS['vorbis'] if FOUND_FFMPEG else 'vorbis -strict experimental'} -vn", "mutagen": oggvorbis.OggVorbis, }, } _EXTENSIONS = ["ogg", "oga", "opus"] def __init__( self, file: Union[str, pathlib.Path], codec: str = None, *, pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, sep: Union[str, list[str]] = (", ", " & "), ) -> None: """ Create an Ogg audio file handler. """ Audio.__init__( self, file, pattern=pattern, multivalue=multivalue, sep=sep ) if codec and codec in self._CODECS: self.codec = codec self._handle = self._CODECS[codec]["mutagen"](file) else: for codec, options in self._CODECS.items(): try: self._handle = options["mutagen"](file) self.codec = codec except Exception: pass else: break if not hasattr(self, "_handle"): raise RuntimeError(f"'{file}' is not a valid Ogg file.") _VorbisComment.__init__(self, self._file.name, self._handle.tags) self._from_filename() self.channel_count = self._handle.info.channels if self.codec == "flac": self.bit_depth = self._handle.info.bits_per_sample self.sample_rate = self._handle.info.sample_rate self.bitrate = ( self.bit_depth * self.channel_count * self.sample_rate ) elif self.codec == "opus": self.bit_depth = self.bitrate = self.sample_rate = None elif self.codec == "vorbis": self.bit_depth = None self.bitrate = self._handle.info.bitrate self.sample_rate = self._handle.info.sample_rate
[docs] class WAVEAudio(Audio, _ID3): r""" WAVE audio file handler. .. seealso:: For a full list of attributes and their descriptions, see :class:`Audio`. Parameters ---------- file : `str` or `pathlib.Path` WAVE audio filename or path. pattern : `tuple`, keyword-only, optional Regular expression search pattern and the corresponding metadata field(s). .. container:: **Valid values**: The supported metadata fields are * :code:`"artist"` for the track artist, * :code:`"title"` for the track title, and * :code:`"track_number"` for the track number. **Examples**: * :code:`("(.*) - (.*)", ("artist", "title"))` matches filenames like "Taylor Swift - Don't Blame Me.wav". * :code:`("(\\d*) - (.*)", ("track_number", "title"))` matches filenames like "05 - Delicate.wav". * :code:`("(\\d*) (.*)", ("track_number", "title"))` matches filenames like "06 Look What You Made Me Do.wav". multivalue : `bool` Determines whether multivalue tags are supported. If :code:`False`, the items in `value` are concatenated using the separator(s) specified in `sep`. sep : `str` or `tuple`, keyword-only, default: :code:`(", ", " & ")` Separator(s) to use to concatenate multivalue tags. If a :code:`str` is provided, it is used to concatenate all values. If a :code:`tuple` is provided, the first :code:`str` is used to concatenate the first :math:`n - 1` values, and the second :code:`str` is used to append the final value. """ _CODECS = {"lpcm": {"ffmpeg": "-c:a pcm_s{0:d}le -c:v copy"}} _EXTENSIONS = ["wav"] def __init__( self, file: Union[str, pathlib.Path], *, pattern: tuple[str, tuple[str]] = None, multivalue: bool = False, sep: Union[str, list[str]] = (", ", " & "), ) -> None: """ Create a WAVE audio file handler. """ _handle = wave.WAVE(file) if _handle.tags is None: _handle.add_tags() _handle.tags.filename = str(file) Audio.__init__( self, file, pattern=pattern, multivalue=multivalue, sep=sep ) _ID3.__init__(self, self._file.name, _handle.tags) self._from_filename() self.bit_depth = _handle.info.bits_per_sample self.bitrate = _handle.info.bitrate self.channel_count = _handle.info.channels self.codec = "lpcm" self.sample_rate = _handle.info.sample_rate