Source code for sndfileio.sndfileio

from __future__ import annotations
import os as _os
import sys as _sys
from importlib.util import find_spec as _find_spec


try:
    import soundfile as _soundfile
except (IOError, ImportError) as e:
    if 'sphinx' in _sys.modules:
        from sphinx.ext.autodoc.mock import _MockObject
        _soundfile = _MockObject()
    else:
        raise e

from . import util
from .datastructs import SndInfo, SndWriter

from typing import TYPE_CHECKING
if TYPE_CHECKING:
    from .datastructs import sample_t
    from typing import Iterator, Callable, Type
    import numpy as np


__all__ = [
    "sndread",
    "sndread_chunked",
    "sndget",
    "sndinfo",
    "sndwrite",
    "sndwrite_like",
    "sndwrite_chunked",
    "sndwrite_chunked_like",
    "mp3write",
    "SndInfo",
    "SndWriter"
]


class FormatNotSupported(Exception):
    pass


def _is_package_installed(pkg):
    return _find_spec(pkg) is not None


class SndfileError(IOError):
    pass


def _normalize_path(path: str) -> str:
    return _os.path.expanduser(path)


[docs] def sndread(path: str, start: float = 0, end: float = 0) -> sample_t: """ Read a soundfile as a numpy array. This is a float array defined between -1 and 1, independently of the format of the soundfile Args: path: the path to read start: the time to start reading end: the time to end reading (0=until the end) Returns: a tuple (samples:ndarray[dtype=float], sr: int) Example ~~~~~~~ :: # Normalize and save as flac from sndfileio import sndread, sndwrite samples, sr = sndread("in.wav") maxvalue = max(samples.max(), -samples.min()) samples *= 1/maxvalue sndwrite(samples, sr, "out.flac") """ path = _normalize_path(path) if not _os.path.exists(path): raise IOError(f"File not found: {path}") backend = _get_backend(path) if not backend: raise RuntimeError(f"No backend available to read {path}") return backend.read(path, start=start, end=end)
[docs] def sndread_chunked(path: str, chunksize: int = 2048, start: float = 0., stop: float = 0. ) -> Iterator[np.ndarray]: """ Read a soundfile in chunks Args: path: the path to read chunksize: the chunksize, in samples start: time to skip before reading stop: time to stop reading (0=end of file) Returns: a generator yielding numpy arrays (float64) of at most `chunksize` frames Example ~~~~~~~ .. code:: >>> with sndwrite_chunked("out.flac", 44100) as writer: ... for buf in sndread_chunked("in.flac"): ... # do some processing, like changing the gain ... buf *= 0.5 ... writer.write(buf) """ path = _normalize_path(path) backend = _get_backend(path, key=lambda backend: backend.can_read_chunked) if backend: return backend.read_chunked(path, chunksize, start=start, stop=stop) else: raise SndfileError("chunked reading is not supported by the " "available backends")
[docs] def sndinfo(path: str) -> SndInfo: """ Get info about a soundfile. Returns a :class:`SndInfo` Args: path: the path to a soundfile Returns: a :class:`SndInfo` (attributes: **samplerate**: `int`, *nframes*: `int`, *channels*: `int`, **encoding**: `str`, **fileformat**: `str`, **metadata**: `dict`) Example ~~~~~~~ :: >>> from sndfileio import * >>> info = sndinfo("sndfile.wav") >>> print(f"Duration: {info.duration}s, samplerate: {info.samplerate}") Duration: 0.4s, samplerate: 44100 """ path = _normalize_path(path) backend = _get_backend(path) if not backend: raise FormatNotSupported("sndinfo: no backend supports this filetype") return backend.getinfo(path)
[docs] def sndget(path: str, start: float = 0, end: float = 0) -> tuple[np.ndarray, SndInfo]: """ Read a soundfile and its metadata Args: path: the path to read start: the time to start reading end: the time to end reading (0=until the end) Returns: a tuple (samples: np.ndarray, :class:`SndInfo`) Example ~~~~~~~ :: # Normalize and save as flac, keeping the metadata from sndfileio import * samples, info = sndget("in.wav") maxvalue = max(samples.max(), -samples.min()) samples *= 1/maxvalue sndwrite("out.flac", samples, info.samplerate, metadata=info.metadata) """ path = _normalize_path(path) backend = _get_backend(path) if not backend: raise RuntimeError(f"No backend available to read {path}") return backend.read_with_info(path, start=start, end=end)
def _resolve_encoding(outfile: str, fileformat='', encoding='', samples: np.ndarray | None = None ) -> tuple[str, str]: if not fileformat: fileformat = util.fileformat_from_ext(_os.path.splitext(outfile)[1]) if not fileformat: raise ValueError(f"Unknown format for file: {outfile}") if encoding == 'auto': if samples: encoding = util.guess_encoding(samples, fileformat) else: encoding = util.default_encoding(fileformat) elif encoding == 'default' or encoding is None: encoding = util.default_encoding(fileformat) return fileformat, encoding
[docs] def sndwrite(outfile: str, samples: np.ndarray, sr: int, encoding='default', fileformat='', normalize_if_clipping=True, metadata: dict[str, str] | None = None, **options ) -> None: """ Write all samples to a soundfile. Args: outfile: The name of the outfile. the extension will determine the file-format. The formats supported depend on the available backends. samples: Array-like. the actual samples. The shape determines the number of channels of outfile. For 1 channel, ``shape=(nframes,)`` or ``shape=(nframes, 1)``. For multichannel audio, ``shape=(nframes, nchannels)``. sr: Sampling-rate encoding: one of "pcm16", "pcm24", "pcm32", "pcm64", "float32", "float64", "auto" to guess an encoding and "default" to use the default encoding for the given format. fileformat: if given, will override the format indicated by the extension normalize_if_clipping: prevent clipping by normalizing samples before writing. This only makes sense when writing pcm data metadata: a dict of str: str, with possible keys 'title', 'comment', 'artist', 'album', 'tracknumber', 'software' (the creator of a soundfile) options: available options depend on the fileformat. .. admonition:: Options **mp3**: - `bitrate`: bitrate in Kb/s (int, default=160) - `quality`: 1-7, 1 is highest and 7 is fastest, default=3 For lossless formats, like `wav`, `aif`, `flac`, etc., **there are no extra options** .. note:: Not all file formats support all encodings. Raises :class:`SndfileError` if the format does not support the given encoding. If set to 'auto', an encoding will be selected based on the file-format and on the data. The bitdepth of the data is measured, and if the file-format supports it, it will be used. For bitdepths of 8, 16 and 24 bits, a PCM encoding will be used. For a bitdepth of 32 bits, a FLOAT encoding will be used, or the next lower supported encoding. If 'default' is given as encoding, the default encoding for the format is used ======= ================= Format Default encoding ======= ================= wav float32 aif float32 flac pcm24 mp3 pcm16 ======= ================= Example ~~~~~~~ :: # Normalize and save as flac >>> samples, sr = sndread("sndfile.wav") >>> maxvalue = max(samples.max(), -samples.min()) >>> samples *= 1/maxvalue >>> sndwrite("out.flac", samples, sr) """ outfile = _normalize_path(outfile) fileformat, encoding = _resolve_encoding(outfile, samples=samples, fileformat=fileformat, encoding=encoding) if encoding.startswith('pcm') and normalize_if_clipping: clipping = ((samples > 1).any() or (samples < -1).any()) if clipping: maxvalue = max(samples.max(), abs(samples.min())) samples = samples / maxvalue backend = _get_write_backend(fileformat) if not backend: raise SndfileError(f"No backend found to support the given format: {fileformat}") writer = backend.writer(sr=sr, outfile=outfile, encoding=encoding, metadata=metadata, fileformat=fileformat, **options) if not writer: raise SndfileError(f"Could not write to {outfile} with backend {backend.name}") return writer.write(samples)
[docs] def sndwrite_chunked(outfile: str, sr: int, encoding='auto', fileformat='', metadata: dict[str, str] | None = None, **options ) -> SndWriter: """ Opens a file for writing and returns a SndWriter The :meth:`~SndWriter.write` method of the returned :class:`SndWriter` can be called to write samples to the file Raises SndfileError if the format does not support the given encoding. Not all file formats support all encodings. If set to 'auto', an encoding will be selected based on the file-format and on the data. The bitdepth of the data is measured, and if the file-format supports it, it will be used. For bitdepths of 8, 16 and 24 bits, a PCM encoding will be used. For a bitdepth of 32 bits, a FLOAT encoding will be used, or the next lower supported encoding Args: outfile: The name of the outfile. the extension will determine the file-format. The formats supported depend on the available backends. sr: Sampling-rate encoding: one of 'auto', 'pcm16', 'pcm24', 'pcm32', 'float32', 'float64'. fileformat: needed only if the format cannot be determined from the extension (for example, if saving to an outfile with a non-traditional extension) metadata: a dict ``{str: str}`` with possible keys: 'comment', 'title', 'artist', 'album', 'tracknumber', 'software' (the creator of a soundfile) options: available options depend on the fileformat. For mp3, options are: bitrate (int, default=128) and quality (1-7, where 1 is highest and 7 is fastest, default=2) For non-destructive formats, like wav, aif, flac, etc., there are no extra options Returns: a :class:`~sndfileio.SndWriter`, whose method :meth:`~sndfileio.SndWriter.write` can be called to write samples Example ~~~~~~~ :: from snfileio import * with sndwrite_chunked("out.flac", 44100) as writer: for buf in sndread_chunked("in.flac"): # do some processing, like changing the gain buf *= 0.5 writer.write(buf) """ outfile = _normalize_path(outfile) fileformat, encoding = _resolve_encoding(outfile, fileformat=fileformat, encoding=encoding) backends = [backend for backend in _get_backends() if backend.can_write_chunked and fileformat in backend.filetypes_write] if not backends: raise SndfileError(f"No backend found to support the format {fileformat}") backend = min(backends, key=lambda backend: backend.priority) return backend.writer(outfile, sr, encoding, metadata=metadata, fileformat=fileformat, **options)
[docs] def sndwrite_like(outfile: str, samples: np.ndarray, likefile: str, sr=0, metadata: dict[str, str] | None = None ) -> None: """ Write samples to outfile with samplerate/fileformat/encoding taken from likefile Args: outfile: the file to write to samples: the samples to write likefile: the file to use as a reference for sr, format and encoding sr: sample rate can be overridden metadata: a dict {str: str}, overrides metadata in `likefile`. Metadata is not merged, so if metadata is given, it substitutes the metadata in `likefile` completely. In order to merge it, do that beforehand If None is passed, the metadata in `likefile` is written to `outfile` .. note:: The fileformat is always determined by `likefile`, even if the extension of `outfile` would result in a different format. For example, if `likefile` has a flac format but outfile has a .wav extension, the resulting file will be written in flac format. Example ~~~~~~~ :: # Read a file, apply a fade-in of 0.5 seconds, save it import numpy as np from sndfileio import * samples, sr = sndread("stereo.wav") fadesize = int(0.5*sr) ramp = np.linspace(0, 1, fadesize)) samples[:fadesize, 0] *= ramp samples[:fadesize, 1] *= ramp sndwrite_like(samples, "stereo.wav", "out.wav") """ outfile = _normalize_path(outfile) info = sndinfo(likefile) sndwrite(outfile=outfile, samples=samples, sr=sr or info.samplerate, fileformat=info.fileformat, encoding=info.encoding, metadata=metadata or info.metadata)
[docs] def sndwrite_chunked_like(outfile: str, likefile: str, sr=0, metadata: dict[str, str] | None = None ) -> SndWriter: """ Create a SndWriter with samplerate/format/encoding of the source file Args: outfile: the file to open for writing likefile: the file to use as reference sr: samplerate can be overridden metadata: a dict {str: str}, overrides metadata in `likefile`. Metadata is not merged, so if metadata is given, it substitutes the metadata in `likefile` completely. In order to merge it, do that beforehand If None is passed, the metadata in `likefile` is written to `outfile` Returns: a :class:`SndWriter`. Call :meth:`~SndWriter.write` on it to write to the file """ info = sndinfo(likefile) return sndwrite_chunked(outfile=outfile, sr=sr or info.samplerate, encoding=info.encoding, metadata=metadata or info.metadata)
[docs] def mp3write(outfile: str, samples: np.ndarray, sr: int, bitrate=224, quality=3 ) -> None: """ Write all samples to outfile as mp3 This is the same as:: sndwrite(outfile, samples, sr, bitrate=224, quality=3) But in this case the arguments are explictely listed instead of being part of ``**options`` Args: outfile: the outfile to write to samples: the samples to write (float samples in the range -1:1). They will be converted to int16 so any values outside the given range will clip sr: the samplerate bitrate: the bitrate to use, in Kb/s quality: the quality, a value between 1-7 (where 1 is highest and 7 is fastest) .. note:: To write samples in chunks use sndwrite_chunked. `bitrate` and `quality` can be passed as **options. """ outfile = _normalize_path(outfile) mp3backend = _BACKENDS['lameenc'] if not mp3backend.is_available(): raise RuntimeError("lameenc backend is not available") writer = mp3backend.writer(outfile=outfile, sr=sr, encoding='pcm16', fileformat='mp3', bitrate=bitrate, quality=quality) writer.write(samples)
############################################ # # BACKENDS # ############################################ def _asbytes(s: str | bytes) -> bytes: if isinstance(s, bytes): return s return s.encode('ascii') # class _PySndfileWriter(SndWriter): # _keyTable = { # 'comment': 'SF_STR_COMMENT', # 'title': 'SF_STR_TITLE', # 'artist': 'SF_STR_ARTIST', # 'album': 'SF_STR_ALBUM', # 'tracknumber': 'SF_STR_TRACKNUMBER', # 'software': 'SF_STR_SOFTWARE' # } # def _open_file(self, channels: int) -> None: # if self.fileformat not in self.filetypes: # raise ValueError(f"Format {self.fileformat} not supported by this backend") # sndformat = self._backend.get_sndfile_format(self.fileformat, self.encoding) # self._file = self._backend.PySndfile(self.outfile, "w", sndformat, channels, self.sr) # if self.metadata: # for k, v in self.metadata.items(): # key = self._keyTable[k] # self._file.set_string(key, _asbytes(v)) # def write(self, frames: np.ndarray) -> None: # if not self._file: # nchannels = util.numchannels(frames) # if self.encoding == 'auto': # self.encoding = util.guess_encoding(frames, self.fileformat) # elif self.encoding == 'default': # self.encoding = util.default_encoding(self.fileformat) # self._open_file(nchannels) # self._file.write_frames(frames) # def close(self): # if self._file is None: # raise IOError("Can't close, since this file was never open") # self._file.writeSync() # del self._file # self._file = None class Backend: def __init__(self, priority: int, filetypes_read: list[str], filetypes_write: list[str], can_read_chunked: bool, can_write_chunked: bool, name: str, supports_metadata: bool): self.priority = priority self.filetypes_read = filetypes_read self.filetypes_write = filetypes_write self.can_read_chunked = can_read_chunked self.can_write_chunked = can_write_chunked self.supports_metadata = supports_metadata self.name = name self._backend = None self._writer: Type[SndWriter] | None = None def read_with_info(self, path: str, start=0., end=0. ) -> tuple[np.ndarray, SndInfo]: return NotImplemented def read(self, path: str, start=0., end=0.) -> sample_t: samples, info = self.read_with_info(path=path, start=start, end=end) return samples, info.samplerate def read_chunked(self, path: str, chunksize=2048, start=0., stop=0. ) -> Iterator[np.ndarray]: return NotImplemented def getinfo(self, path: str) -> SndInfo: """ Get info about the soundfile given. Returns a SndInfo structure """ return NotImplemented def is_available(self) -> bool: """ Is this backend available? """ return _is_package_installed(self.name) def writer(self, outfile: str, sr: int, encoding: str, fileformat: str, bitrate=0, metadata: dict[str, str] | None = None, **options ) -> SndWriter: """ Open outfile for write with the given properties Args: sr: samplerate outfile: the file to write encoding: the encoding used (pcm16, float32, etc) fileformat: the file format bitrate: used when writing to a compressed file. metadata: if given, a dict str: str. Allowed keys are: *title*, *comment*, *artist*, *tracknumber*, *album*, *software* Returns: a :class:`SndWriter` """ pass if self._writer is None: raise SndfileError(f"The backend {self.name} does not support writing") return self._writer(backend=self, sr=sr, outfile=outfile, encoding=encoding, fileformat=fileformat, bitrate=bitrate, metadata=metadata, **options) def check_write(self, fileformat: str, encoding: str) -> None: """ Check if we can write to outfile with the given encoding """ if encoding not in util.encodings_for_format[fileformat]: raise ValueError("Encoding not supported") if fileformat not in self.filetypes_write: raise ValueError(f"The given format {fileformat} is not supported by the " f"{self.name} backend") def dump(self) -> None: """ Dump information about this backend """ print(f"Backend: {self.name} (available: {self.is_available()}, priority: {self.priority})") if self.filetypes_read: readtypes = ", ".join(self.filetypes_read) print(f" read types : {readtypes}") if self.filetypes_write: writetypes = ", ".join(self.filetypes_write) print(f" write types: {writetypes}") ok, notok = "OK", "--" readchunked = ok if self.can_read_chunked else notok writechunked = ok if self.can_write_chunked else notok print(f" sndread_chunked: {readchunked} sndwrite_chunked: {writechunked}") class _Lameenc(Backend): def __init__(self, priority: int): super().__init__(priority=priority, filetypes_read=[], filetypes_write=['mp3'], can_read_chunked=False, can_write_chunked=True, name='lameenc', supports_metadata=False) def writer(self, outfile: str, sr: int, encoding: str, fileformat: str, metadata: dict[str, str] | None = None, **options ) -> SndWriter: from . import backend_lameenc bitrate = options.pop('bitrate', 160) quality = options.pop('quality', 3) return backend_lameenc.LameencWriter(outfile=outfile, sr=sr, bitrate=bitrate, quality=quality) class _SoundfileWriter(SndWriter): def _open_file(self, channels: int) -> None: if self.fileformat not in self.filetypes: raise ValueError(f"Format {self.fileformat} not supported by this backend") fmt, subtype = _Soundfile.get_format_and_subtype(self.fileformat, self.encoding) self._file = _soundfile.SoundFile(self.outfile, "w", format=fmt, subtype=subtype, channels=channels, samplerate=self.sr) if self.metadata: for key, value in self.metadata.items(): setattr(self._file, key, value) def write(self, frames: np.ndarray) -> None: if not self._file: nchannels = util.numchannels(frames) if self.encoding == 'auto': self.encoding = util.guess_encoding(frames, self.fileformat) elif self.encoding == 'default': self.encoding = util.default_encoding(self.fileformat) self._open_file(nchannels) assert self._file is not None self._file.write(frames) def close(self): if self._file is None: return self._file.close() self._file = None class _Soundfile(Backend): """ A backend based on soundfile (https://pysoundfile.readthedocs.io) """ # TODO: support metadata (either PR or via # https://github.com/thebigmunch/audio-metadata) subtype_to_encoding = { 'PCM_24': 'pcm24', 'PCM_16': 'pcm16', 'PCM_32': 'pcm32', 'PCM_64': 'pcm64', 'FLOAT': 'float32', 'DOUBLE': 'float64', 'VORBIS': 'vorbis', 'MPEG_LAYER_III': '' } encoding_to_subtype = { 'pcm16': 'PCM_16', 'pcm24': 'PCM_24', 'float32': 'FLOAT', 'float64': 'DOUBLE', } format_to_fileformat = { 'WAV': 'wav', 'WAVEX': 'wav', 'AIFF': 'aiff', 'FLAC': 'flac', 'OGG': 'ogg', 'MP3': 'mp3' } # 'comment', 'title', 'artist', # 'album', 'tracknumber', 'software' metadata_keys = {'comment', 'title', 'artist', 'album', 'tracknumber', 'software'} def __init__(self, priority: int): super().__init__( priority=priority, filetypes_read=["aif", "aiff", "wav", "flac", "ogg", "mp3"], filetypes_write=["aif", "aiff", "wav", "flac", "mp3"], can_read_chunked=True, can_write_chunked=True, name='soundfile', supports_metadata=True ) self._writer = _SoundfileWriter def read_with_info(self, path: str, start: float = 0, end: float = 0) -> tuple[np.ndarray, SndInfo]: snd = _soundfile.SoundFile(path, 'r') info = self._getinfo(snd) samples = self._read(snd, start=start, end=end) return samples, info def _read(self, snd: _soundfile.SoundFile, start: float = 0, end: float = 0) -> np.ndarray: sr: int = snd.samplerate samp0 = int(start*sr) samp1 = int(end*sr) if end > 0 else snd.frames if samp0: snd.seek(samp0) return snd.read(samp1 - samp0) def read_chunked(self, path: str, chunksize=2048, start=0., stop=0. ) -> Iterator[np.ndarray]: snd = _soundfile.SoundFile(path, 'r') sr = snd.samplerate if start: snd.seek(int(start*snd.samplerate)) firstframe = int(sr * start) lastframe = snd.frames if stop == 0 else int(sr*stop) for pos, nframes in util.chunks(0, lastframe - firstframe, chunksize): yield snd.read(nframes) def getinfo(self, path: str) -> SndInfo: return self._getinfo(_soundfile.SoundFile(path, 'r')) def _getinfo(self, snd: _soundfile.SoundFile, ) -> SndInfo: encoding = _Soundfile.subtype_to_encoding.get(snd.subtype, '') fileformat = _Soundfile.format_to_fileformat.get(snd.format, '') ext = _os.path.splitext(snd.name)[1] if ext == '.ogg' or ext == '.mp3': meta = util.tinytagMetadata(snd.name) bitrate = meta.pop('bitrate', 0) else: meta = {k: v for k in self.metadata_keys if (v := getattr(snd, k, ''))} bitrate = 0 return SndInfo(samplerate=snd.samplerate, nframes=snd.frames, channels=snd.channels, encoding=encoding, fileformat=fileformat, metadata=meta, bitrate=bitrate) @staticmethod def get_format_and_subtype(fmt: str, encoding='' ) -> tuple[str, str]: soundfile_fmt = { 'wav': 'WAVEX', 'aif': 'AIFF', 'aiff': 'AIFF', 'flac': 'FLAC', 'mp3': 'MP3', 'ogg': 'OGG' }.get(fmt) if not soundfile_fmt: raise ValueError(f"Format {fmt} not supported") if fmt == 'ogg': subformat = 'Vorbis' elif fmt == 'mp3': subformat = 'MPEG_LAYER_III' else: subformat = _Soundfile.encoding_to_subtype.get(encoding) if not subformat: raise ValueError(f"Encoding {encoding} not supported for {fmt}") return soundfile_fmt, subformat # class _PySndfile(Backend): # """ # A backend based in pysndfile # """ # _keyTable = {'SF_STR_COMMENT': 'comment', # 'SF_STR_TITLE': 'title', # 'SF_STR_ARTIST': 'artist', # 'SF_STR_ALBUM': 'album', # 'SF_STR_TRACKNUMBER': 'tracknumber', # 'SF_STR_SOFTWARE': 'software'} # # _writer = _PySndfileWriter # def __init__(self, priority: int): # super().__init__( # priority = priority, # filetypes_read= ["aif", "aiff", "wav", "flac", "ogg", "wav64", "caf", "raw"], # filetypes_write = ["aif", "aiff", "wav", "flac", "ogg", "wav64", "caf", "raw"], # can_read_chunked = True, # can_write_chunked = True, # name = 'pysndfile', # supports_metadata= True # ) # self._writer = _PySndfileWriter # import pysndfile # self.pysndfile = pysndfile # def read_with_info(self, path: str, start: float = 0, end: float = 0) -> tuple[np.ndarray, SndInfo]: # snd = self.pysndfile.PySndfile(path) # info = self._getinfo(snd) # samples = self._read(snd, start=start, end=end) # return samples, info # def _read(self, snd: pysndfile.PySndfile, start=0., end=0.) -> np.ndarray: # sr: int = snd.samplerate() # samp_start = int(start * sr) # samp_end = int(end * sr) if end > 0 else snd.frames() # if samp_start: # snd.seek(samp_start) # return snd.read_frames(samp_end - samp_start) # def read_chunked(self, path: str, chunksize=2048, start: float = 0., stop: float = 0. # ) -> Iterator[np.ndarray]: # snd = self.pysndfile.PySndfile(path) # sr = snd.samplerate() # if start: # snd.seek(int(start*snd.samplerate())) # firstframe = int(sr * start) # lastframe = snd.frames() if stop == 0 else int(sr*stop) # for pos, nframes in util.chunks(0, lastframe - firstframe, chunksize): # yield snd.read_frames(nframes) # def getinfo(self, path: str) -> SndInfo: # return self._getinfo(self.pysndfile.PySndfile(path)) # def _getinfo(self, snd: pysndfile.PySndfile) -> SndInfo: # metadataraw: dict[str, bytes] = snd.get_strings() # if metadataraw: # metadata, extrainfo = {}, {} # for k, v in metadataraw.items(): # if k not in self._keyTable: # extrainfo[k] = v # else: # metadata[self._keyTable[k]] = v # else: # metadata, extrainfo = None, None # return SndInfo(snd.samplerate(), snd.frames(), snd.channels(), # snd.encoding_str(), snd.major_format_str(), # metadata=metadata, extrainfo=extrainfo) # def get_sndfile_format(self, fileformat: str, encoding: str) -> int: # """ # Construct a pysndfile format id from fileformat and encoding # Args: # fileformat: the fileformat, one of 'wav', 'aiff', etc # encoding: one of *pcmXX* or *floatXX* (where *XX *is the number of bits/sample, # one of 16, 24, 32, 64) # Returns: # the pysndfile format id # """ # assert fileformat in self.filetypes_read # fmt, bits = encoding[:-2], int(encoding[-2:]) # assert fmt in ('pcm', 'float') and bits in (8, 16, 24, 32, 64) # if fileformat == 'aif': # fileformat = 'aiff' # return self.pysndfile.construct_format(fileformat, f"{fmt}{bits}") # def detect_format(self, path: str) -> Optional[str]: # f = self.pysndfile.PySndfile(path) # return self.pysndfile.fileformat_id_to_name.get(f.format()) # ---------------------------------------------------------------- class _Miniaudio(Backend): def __init__(self, priority): super().__init__( priority=priority, filetypes_read= ['mp3', 'ogg'], filetypes_write = [], can_read_chunked = True, can_write_chunked = False, name = 'miniaudio', supports_metadata=False ) def getinfo(self, path: str) -> SndInfo: from . import backend_miniaudio ext = _os.path.splitext(path)[1] if ext == '.mp3': return backend_miniaudio.mp3info(path) elif ext == '.ogg': return backend_miniaudio.ogginfo(path) else: raise RuntimeError(f"Unsupported format {ext}") def read_with_info(self, path: str, start=0., end=0.) -> tuple[np.ndarray, SndInfo]: return self.read(path, start, end)[0], self.getinfo(path) def read(self, path: str, start: float = 0., end: float = 0.) -> sample_t: from . import backend_miniaudio return backend_miniaudio.mp3read(path, start=start, end=end) def read_chunked(self, path: str, chunksize=2048, start: float = 0., stop: float = 0. ) -> Iterator[np.ndarray]: from . import backend_miniaudio ext = _os.path.splitext(path)[1] if ext == '.mp3': return backend_miniaudio.mp3read_chunked(path, chunksize=chunksize, start=start, stop=stop) else: raise FormatNotSupported(f'chunked reading is not supported for {ext}') _BACKENDS: dict[str, Backend] = { 'soundfile': _Soundfile(priority=0), 'lameenc': _Lameenc(priority=10), # 'miniaudio': _Miniaudio(priority=10), } _cache = {} def report_backends(): for b in _BACKENDS.values(): if b.is_available(): b.dump() else: print(f"Backend {b.name} NOT available") def _get_backends() -> list[Backend]: backends = _cache.get('backends') if not backends: backends = [b for b in _BACKENDS.values() if b.is_available()] backends.sort(key=lambda b: b.priority) _cache['backends'] = backends return backends def _get_backend(path='', key: Callable[[Backend], bool] | None = None ) -> Backend | None: """ Get available backends to read/write the file given Args: path: the file to read/write key: a function (backend) -> bool signaling if the backend is suitable for a specific task Example ~~~~~~~ :: # Get available backends which can read in chunks >>> backend = _get_backend('file.flac', ... key=lambda backend:backend.can_read_chunked()) """ filetype = util.detect_format(path) if not filetype: raise ValueError(f"File type for {path} not supported") backends = _get_backends() backends = [b for b in backends if filetype in b.filetypes_read] if key: backends = [b for b in backends if key(b)] return backends[0] if backends else None def _get_write_backend(fileformat: str) -> Backend | None: backends = _get_backends() return next((b for b in backends if fileformat in b.filetypes_write), None)