diff --git a/README.md b/README.md index c6dafc7..33ea6fe 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,22 @@ Beautiful, modern reports with: ## 🚀 Quick Start ### Installation + +#### Requirements + +Requires [FFmpeg](https://ffmpeg.org/). + +```shell +# Ubuntu/Debian: +sudo apt install ffmpeg + +# macOS: +brew install ffmpeg + +# Arch Linux: +sudo pacman -S ffmpeg +``` + #### PyPi ```bash # pip @@ -247,35 +263,17 @@ Or pipe directly: drcheck analyze album/ --format csv > results.csv ``` -## 🎵 Supported Audio Formats +## 🎵 Supported Audio Codecs -**Lossless (via libsndfile):** - FLAC (.flac) - WAV (.wav) - AIFF (.aiff, .aif, .aifc) -- OGG Vorbis (.ogg, .oga) -- Opus (.opus) - -**Lossy (requires pydub + ffmpeg):** +- Vorbis (.ogg, .oga) +- Opus (.opus, .ogg) - MP3 (.mp3) - M4A/AAC (.m4a, .mp4, .aac) - WMA (.wma) - -To enable MP3/M4A support: -```bash -# Install pydub -uv pip install pydub - -# Install ffmpeg (system package) -# Ubuntu/Debian: -sudo apt install ffmpeg - -# macOS: -brew install ffmpeg - -# Arch Linux: -sudo pacman -S ffmpeg -``` +- ... and any other format supported by your build of `ffmpeg`. ## 🔬 Understanding DR Values @@ -292,7 +290,6 @@ The DR (Dynamic Range) scale measures the difference between the loudest and ave ## 📊 Error Handling (v1.1.1) **Smart Error Messages (v1.1.1):** When errors occur, DR Check now provides helpful, actionable guidance: -- **Missing MP3/M4A support?** Shows exact install command - **Corrupted files?** Suggests how to fix them - **Audio too short?** Explains the 6-second minimum requirement @@ -300,19 +297,13 @@ Examples of intelligent error messages with helpful suggestions: ``` ================================================================================ -⚠️ Failed to process 3 of 50 file(s) +⚠️ Failed to process 1 of 50 file(s) ================================================================================ -📋 Missing Lossy Format Support (2 file(s)): - • song1.mp3 - → Install: pip install drcheck[lossy] - → Also ensure ffmpeg is installed on your system - 📋 Audio Too Short (1 file(s)): • intro.wav (need at least 6 seconds for DR analysis) 💡 Tips: - • For MP3/M4A support, install: pip install drcheck[lossy] • DR analysis requires at least 6 seconds of audio (two 3-second blocks for measurement) ``` diff --git a/drcheck/audio.py b/drcheck/audio.py index 177d7f0..ba1df85 100644 --- a/drcheck/audio.py +++ b/drcheck/audio.py @@ -5,74 +5,27 @@ import logging from dataclasses import dataclass -from importlib.util import find_spec from pathlib import Path +from typing import Any import numpy as np -import soundfile as sf -from numpy.typing import NDArray +from pydub import AudioSegment +from tinytag import TinyTag logger = logging.getLogger(__name__) -def _read_tags(filepath: Path) -> tuple[str | None, str | None]: - """ - Read artist and album tags from audio file. - - Args: - filepath: Path to audio file - - Returns: - Tuple of (artist, album) or (None, None) if tags cannot be read - """ - try: - from mutagen._file import File - - audio = File(filepath, easy=True) - - if audio is None: - return None, None - - # Try to get artist and album tags - # Different formats use different tag names, but mutagen.File(easy=True) normalizes them - artist = None - album = None - - if hasattr(audio, "tags") and audio.tags: - # Easy tags interface (works for most formats) - artist_tags = audio.tags.get("artist", []) or audio.tags.get( - "albumartist", [] - ) - album_tags = audio.tags.get("album", []) - - if artist_tags: - artist = ( - artist_tags[0] - if isinstance(artist_tags, list) - else str(artist_tags) - ) - if album_tags: - album = ( - album_tags[0] if isinstance(album_tags, list) else str(album_tags) - ) - - return artist, album - - except Exception as e: - logger.debug(f"Could not read tags from {filepath}: {e}") - return None, None - - @dataclass class AudioData: """Container for decoded audio data and metadata.""" - samples: NDArray[np.float32] | None # Audio samples, shape (samples, channels) + samples: np.ndarray[Any, np.dtype[np.float32]] | None # Audio samples, shape (samples, channels) sample_rate: int channels: int duration_seconds: float filepath: Path bit_depth: int | None = None # Bits per sample (16, 24, 32, etc.) + bit_rate: int | None = None # Bit rate (kb/s) format_name: str | None = None # Format/codec name (FLAC, WAV, etc.) artist: str | None = None # Artist tag album: str | None = None # Album tag @@ -121,7 +74,7 @@ def read_audio_file(filepath: Path | str) -> AudioData: Read an audio file and return decoded audio data. Supports formats: FLAC, WAV, OGG, MP3, M4A, AIFF, and others - supported by libsndfile. + supported by ffmpeg. Args: filepath: Path to audio file @@ -146,46 +99,39 @@ def read_audio_file(filepath: Path | str) -> AudioData: # Get format name from extension format_name = filepath.suffix.upper().lstrip(".") - bit_depth = None - - # Read metadata tags - artist, album = _read_tags(filepath) try: - # Read audio file using soundfile (libsndfile backend) - # This handles FLAC, WAV, OGG, and many others natively - samples, sample_rate = sf.read(filepath, dtype="float32", always_2d=True) - - # Try to get bit depth from file info - try: - info = sf.info(filepath) - # Map soundfile subtypes to bit depths - subtype_map = { - "PCM_16": 16, - "PCM_24": 24, - "PCM_32": 32, - "FLOAT": 32, - "DOUBLE": 64, - } - bit_depth = subtype_map.get(info.subtype, None) - - # Get more accurate format name if available - if hasattr(info, "format"): - format_name = info.format - except Exception: - pass # bit_depth remains None if we can't determine it - - except sf.LibsndfileError as e: - # libsndfile couldn't read it - might be MP3 or M4A - logger.debug(f"libsndfile failed, trying alternative decoder: {e}") - samples, sample_rate, bit_depth_fallback = _read_with_fallback(filepath) - if bit_depth is None: - bit_depth = bit_depth_fallback + tag: TinyTag = TinyTag.get(filepath) + artist = tag.artist + album = tag.album + bit_rate = round(tag.bitrate) + sample_rate = tag.samplerate + channels = tag.channels + + # Load with pydub (uses ffmpeg) + audio = AudioSegment.from_file(str(filepath)) + + # Convert to numpy array + samples: np.ndarray[Any, np.dtype[np.float32]] = np.array(audio.get_array_of_samples(), dtype=np.float32) + + # Get bit depth + bit_depth = audio.sample_width * 8 + + # Normalize to [-1.0, 1.0] range + max_val = 2 ** (audio.sample_width * 8 - 1) + samples = samples / max_val + + # Reshape for multi-channel + if channels > 1: + samples = samples.reshape((-1, channels)) + else: + samples = samples.reshape((-1, 1)) + + logger.debug(f"Decoded with pydub/ffmpeg: {filepath.name}") except Exception as e: raise AudioReadError(f"Failed to read audio file {filepath}: {e}") from e - channels = samples.shape[1] duration = len(samples) / sample_rate logger.info( @@ -198,6 +144,7 @@ def read_audio_file(filepath: Path | str) -> AudioData: channels=channels, duration_seconds=duration, filepath=filepath, + bit_rate=bit_rate, bit_depth=bit_depth, format_name=format_name, artist=artist, @@ -205,58 +152,6 @@ def read_audio_file(filepath: Path | str) -> AudioData: ) -def _read_with_fallback(filepath: Path) -> tuple[NDArray[np.floating], int, int | None]: - """ - Fallback reader for formats not supported by libsndfile (MP3, M4A). - - Uses pydub with ffmpeg backend for decoding. - - Args: - filepath: Path to audio file - - Returns: - Tuple of (samples, sample_rate, bit_depth) - - Raises: - UnsupportedFormatError: If format cannot be decoded - """ - try: - from pydub import AudioSegment - except ImportError: - raise UnsupportedFormatError( - f"Cannot read {filepath.suffix} files. " - "Install pydub and ffmpeg: pip install pydub" - ) - - try: - # Load with pydub (uses ffmpeg) - audio = AudioSegment.from_file(str(filepath)) - - # Convert to numpy array - samples = np.array(audio.get_array_of_samples(), dtype=np.float32) - - # Get bit depth - bit_depth = audio.sample_width * 8 - - # Normalize to [-1.0, 1.0] range - max_val = 2 ** (audio.sample_width * 8 - 1) - samples = samples / max_val - - # Reshape for multi-channel - if audio.channels > 1: - samples = samples.reshape((-1, audio.channels)) - else: - samples = samples.reshape((-1, 1)) - - sample_rate = audio.frame_rate - - logger.debug(f"Decoded with pydub/ffmpeg: {filepath.name}") - return samples, sample_rate, bit_depth - - except Exception as e: - raise UnsupportedFormatError(f"Cannot decode {filepath}: {e}") from e - - def get_supported_extensions() -> set[str]: """ Get set of supported audio file extensions. @@ -264,17 +159,7 @@ def get_supported_extensions() -> set[str]: Returns: Set of lowercase file extensions (including the dot) """ - # Core formats supported by libsndfile - core_formats = {".flac", ".wav", ".aiff", ".aif", ".aifc", ".ogg", ".oga", ".opus"} - - # Formats requiring pydub/ffmpeg - extended_formats = {".mp3", ".m4a", ".mp4", ".aac", ".wma"} - - if find_spec("pydub") is not None: - return core_formats | extended_formats - else: - logger.debug("pydub not available, extended formats disabled") - return core_formats + return {".mp3", ".m4a", ".mp4", ".aac", ".wma", ".flac", ".wav", ".aiff", ".aif", ".aifc", ".ogg", ".oga", ".opus"} def is_supported_file(filepath: Path | str) -> bool: diff --git a/drcheck/cli.py b/drcheck/cli.py index ea74303..f98fe7f 100644 --- a/drcheck/cli.py +++ b/drcheck/cli.py @@ -13,9 +13,6 @@ from drcheck.__version__ import __version__ from drcheck.audio import ( - AudioData, - AudioReadError, - UnsupportedFormatError, find_audio_files, is_supported_file, ) @@ -58,11 +55,11 @@ def format_error_message(filepath: Path, error: str) -> tuple[str, str]: # File not found errors if "not found" in error_lower or "no such file" in error_lower: - return ("File Not Found", f"{filename}") + return "File Not Found", f"{filename}" # Permission/access errors if "permission" in error_lower or "access" in error_lower: - return ("Permission Denied", f"{filename} (check file permissions)") + return "Permission Denied", f"{filename} (check file permissions)" # Format/codec errors if ( @@ -70,13 +67,6 @@ def format_error_message(filepath: Path, error: str) -> tuple[str, str]: or "codec" in error_lower or "unsupported" in error_lower ): - if ext in {".MP3", ".M4A", ".AAC", ".WMA"}: - return ( - "Missing Lossy Format Support", - f"{filename}\n" - f" → Install: pip install drcheck[lossy]\n" - f" → Also ensure ffmpeg is installed on your system", - ) return ( "Unsupported Format", f"{filename} ({ext} format not supported or file corrupted)", @@ -88,7 +78,7 @@ def format_error_message(filepath: Path, error: str) -> tuple[str, str]: or "invalid" in error_lower or "decode" in error_lower ): - return ("Corrupted File", f"{filename} (file appears corrupted or invalid)") + return "Corrupted File", f"{filename} (file appears corrupted or invalid)" # Audio too short errors if "too short" in error_lower: @@ -99,10 +89,10 @@ def format_error_message(filepath: Path, error: str) -> tuple[str, str]: # Memory errors if "memory" in error_lower: - return ("Memory Error", f"{filename} (file too large or insufficient memory)") + return "Memory Error", f"{filename} (file too large or insufficient memory)" # Generic errors - return ("Error", f"{filename}: {error}") + return "Error", f"{filename}: {error}" def show_processing_summary(total_files: int, successful: int, failed: int) -> None: @@ -228,17 +218,9 @@ def analyze( if base_directory is None: base_directory = path.parent else: - ext = path.suffix.upper() - if ext in {".MP3", ".M4A", ".AAC", ".WMA"}: - click.echo( - f"⚠️ Skipping {ext}: {path.name}\n" - f" Install lossy format support: pip install drcheck[lossy]", - err=True, - ) - else: - click.echo( - f"⚠️ Skipping unsupported format: {path.name} ({ext})", err=True - ) + click.echo( + f"⚠️ Skipping unsupported format: {path.name}", err=True + ) elif path.is_dir(): found_files = find_audio_files(path, recursive=recursive) if not found_files: @@ -325,6 +307,7 @@ def progress_callback( result=dr_result, duration=audio_data.duration_seconds, sample_rate=audio_data.sample_rate, + bit_rate=audio_data.bit_rate, channels=audio_data.channels, bit_depth=audio_data.bit_depth, format_name=audio_data.format_name, diff --git a/drcheck/formatters.py b/drcheck/formatters.py index a332852..e7b01b1 100644 --- a/drcheck/formatters.py +++ b/drcheck/formatters.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import Protocol +from tinytag import TinyTag + from drcheck.analysis import DR14Result from drcheck.__version__ import __version__ @@ -28,6 +30,7 @@ class TrackResult: duration: float channel_dr: list[float] sample_rate: int | None = None + bit_rate: int | None = None channels: int | None = None bit_depth: int | None = None format_name: str | None = None @@ -39,6 +42,7 @@ def from_dr14_result( result: DR14Result, duration: float, sample_rate: int | None = None, + bit_rate: int | None = None, channels: int | None = None, bit_depth: int | None = None, format_name: str | None = None, @@ -52,6 +56,7 @@ def from_dr14_result( duration=duration, channel_dr=result.channel_dr.tolist(), sample_rate=sample_rate, + bit_rate=bit_rate, channels=channels, bit_depth=bit_depth, format_name=format_name, @@ -184,14 +189,8 @@ def format_album(self, album: AlbumResult) -> str: if first_track.bit_depth: lines.append(f"Bits per sample: {first_track.bit_depth}") - # Calculate bitrate if we have the info - if first_track.sample_rate and first_track.channels: - bitrate = ( - first_track.sample_rate - * first_track.bit_depth - * first_track.channels - ) / 1000 - lines.append(f"Bitrate: {int(bitrate)} kbps") + if first_track.sample_rate: + lines.append(f"Bitrate: {first_track.bit_rate} kbps") if first_track.format_name: lines.append(f"Codec: {first_track.format_name}") @@ -529,67 +528,29 @@ def extract_album_art(audio_files: list[Path]) -> str | None: except Exception as e: logger.debug(f"Could not read cover image {cover_path}: {e}") - # Try to extract from audio file metadata using mutagen - try: - from mutagen._file import File as MutagenFile - from mutagen.flac import FLAC # type: ignore - from mutagen.id3._frames import APIC # type: ignore - from mutagen.mp3 import MP3 # type: ignore - - for audio_file in audio_files[:3]: # Check first 3 files - try: - audio = MutagenFile(audio_file) - - if audio is None: - continue - - picture_data = None - mime_type = "image/jpeg" - - # Handle FLAC files - if isinstance(audio, FLAC): - if hasattr(audio, "pictures") and audio.pictures: - picture = audio.pictures[0] - picture_data = picture.data - mime_type = getattr(picture, "mime", "image/jpeg") - - # Handle MP3 files - elif isinstance(audio, MP3): - if hasattr(audio, "tags") and audio.tags is not None: - for tag in audio.tags.values(): # type: ignore - if isinstance(tag, APIC): - picture_data = tag.data # type: ignore - mime_type = getattr(tag, "mime", "image/jpeg") - break - - # Handle other formats with pictures tag - elif hasattr(audio, "pictures") and audio.pictures: - picture = audio.pictures[0] - picture_data = picture.data - mime_type = getattr(picture, "mime", "image/jpeg") - - # If we found picture data, try to optimize it - if picture_data: - optimized = _optimize_image_data(picture_data) - if optimized: - optimized_data, optimized_mime = optimized - b64_data = base64.b64encode(optimized_data).decode("utf-8") - logger.info( - f"Extracted and optimized album art from {audio_file.name}" - ) - return f"data:{optimized_mime};base64,{b64_data}" - - # Fall back to unoptimized - b64_data = base64.b64encode(picture_data).decode("utf-8") - logger.info(f"Extracted album art from {audio_file.name} (unoptimized)") - return f"data:{mime_type};base64,{b64_data}" + # Try to extract from audio file metadata using tinytag + for audio_file in audio_files[:3]: + try: + tag: TinyTag = TinyTag.get(audio_file) + image = tag.images.any + if image: + optimized = _optimize_image_data(image.data) + if optimized: + optimized_data, optimized_mime = optimized + b64_data = base64.b64encode(optimized_data).decode("utf-8") + logger.info( + f"Extracted and optimized album art from {audio_file.name}" + ) + return f"data:{optimized_mime};base64,{b64_data}" - except Exception as e: - logger.debug(f"Could not extract art from {audio_file}: {e}") - continue + # Fall back to unoptimized + b64_data = base64.b64encode(image.data).decode("utf-8") + logger.info(f"Extracted album art from {audio_file.name} (unoptimized)") + return f"data:{image.mime_type};base64,{b64_data}" - except ImportError: - logger.debug("mutagen not available for album art extraction") + except Exception as e: + logger.debug(f"Could not extract art from {audio_file}: {e}") + continue logger.debug("No album art found") return None diff --git a/drcheck/parallel.py b/drcheck/parallel.py index f5fbfb6..8713806 100644 --- a/drcheck/parallel.py +++ b/drcheck/parallel.py @@ -10,7 +10,6 @@ from pathlib import Path from typing import Callable -from drcheck import audio from drcheck.analysis import DR14Result, compute_dr14 from drcheck.audio import AudioData, read_audio_file @@ -69,6 +68,7 @@ def _analyze_single_file(task: AnalysisTask) -> AnalysisResult: channels=audio_data.channels, duration_seconds=audio_data.duration_seconds, filepath=audio_data.filepath, + bit_rate=audio_data.bit_rate, bit_depth=audio_data.bit_depth, format_name=audio_data.format_name, artist=audio_data.artist, diff --git a/pyproject.toml b/pyproject.toml index 73fbc01..c7d0642 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "drcheck" -version = "1.1.1" +version = "1.1.0" description = "Dynamic Range Analyzer - Fight the Loudness War" readme = "README.md" requires-python = ">=3.10" @@ -23,9 +23,11 @@ classifiers = [ dependencies = [ "click>=8.1.0", - "mutagen>=1.47.0", + "hatchling>=1.29.0", "numpy>=1.24.0", - "soundfile>=0.12.0", + "pydub>=0.25.1", + "tinytag>=2.2.1", + "audioop-lts; python_version >= '3.13'", ] [project.optional-dependencies] @@ -33,13 +35,10 @@ dependencies = [ html = [ "pillow>=12.1.0", ] -# For MP3/M4A support (requires ffmpeg installed separately) -lossy = [ - "pydub>=0.25.1", -] # For development dev = [ + "scipy>=1.14.0", "pytest>=7.0.0", "pytest-cov>=4.0.0", ] diff --git a/tests/test_audio.py b/tests/test_audio.py index b6c450f..7ff4835 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -4,13 +4,14 @@ Note: These tests use synthetic audio files created on-the-fly. For real file format testing, you'll need actual audio files. """ - +import io import tempfile from pathlib import Path import numpy as np import pytest -import soundfile as sf +import scipy.io.wavfile as wav +from pydub import AudioSegment from drcheck.audio import ( AudioData, @@ -36,15 +37,22 @@ def create_test_wav( num_samples = int(sample_rate * duration) t = np.linspace(0, duration, num_samples) + amplitude = np.iinfo(np.int16).max + # Create simple sine wave - audio = np.sin(2 * np.pi * 440 * t) * 0.5 + audio = amplitude * np.sin(2 * np.pi * 440 * t) * 0.5 if channels == 2: audio = np.column_stack([audio, audio * 0.8]) else: audio = audio.reshape(-1, 1) - sf.write(filepath, audio, sample_rate, subtype="PCM_16") + wav_io = io.BytesIO() + wav.write(wav_io, sample_rate, audio.astype(np.int16)) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(filepath, format="wav") + wav_io.close() def create_test_flac( @@ -54,15 +62,22 @@ def create_test_flac( num_samples = int(sample_rate * duration) t = np.linspace(0, duration, num_samples) + amplitude = np.iinfo(np.int16).max + # Create simple sine wave - audio = np.sin(2 * np.pi * 440 * t) * 0.5 + audio = amplitude * np.sin(2 * np.pi * 440 * t) * 0.5 if channels == 2: audio = np.column_stack([audio, audio * 0.8]) else: audio = audio.reshape(-1, 1) - sf.write(filepath, audio, sample_rate, subtype="PCM_24") + wav_io = io.BytesIO() + wav.write(wav_io, sample_rate, audio.astype(np.int16)) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(filepath, format="flac") + wav_io.close() class TestAudioData: @@ -198,12 +213,18 @@ def test_different_sample_rates(self, temp_audio_dir): wav_file = temp_audio_dir / f"test_{sample_rate}.wav" num_samples = int(sample_rate * 5.0) + amplitude = np.iinfo(np.int16).max t = np.linspace(0, 5.0, num_samples) - audio = (np.sin(2 * np.pi * 440 * t) * 0.5).reshape(-1, 1) - sf.write(wav_file, audio, sample_rate) + audio = (amplitude * np.sin(2 * np.pi * 440 * t) * 0.5).reshape(-1, 1) + wav_io = io.BytesIO() + wav.write(wav_io, sample_rate, audio.astype(np.int16)) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(wav_file, format="wav") audio_data = read_audio_file(wav_file) assert audio_data.sample_rate == sample_rate + wav_io.close() class TestSupportedFormats: diff --git a/tests/test_cli.py b/tests/test_cli.py index 0863fad..016eb80 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,14 +2,15 @@ CLI tests that require actual audio files. Add these to tests/test_cli.py to replace the skipped tests. """ - +import io import tempfile from pathlib import Path import numpy as np import pytest -import soundfile as sf +import scipy.io.wavfile as wav from click.testing import CliRunner +from pydub import AudioSegment from drcheck.__version__ import __version__ from drcheck.cli import cli @@ -41,15 +42,22 @@ def create_test_wav( num_samples = int(sample_rate * duration) t = np.linspace(0, duration, num_samples) + amplitude = np.iinfo(np.int16).max + # Create simple sine wave - audio = np.sin(2 * np.pi * 440 * t) * 0.5 + audio = amplitude * np.sin(2 * np.pi * 440 * t) * 0.5 if channels == 2: audio = np.column_stack([audio, audio * 0.8]) else: audio = audio.reshape(-1, 1) - sf.write(filepath, audio, sample_rate, subtype="PCM_16") + wav_io = io.BytesIO() + wav.write(wav_io, sample_rate, audio.astype(np.int16)) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(filepath, format="wav") + wav_io.close() @pytest.fixture diff --git a/tests/test_parallel.py b/tests/test_parallel.py index 6941db6..3a1fe41 100644 --- a/tests/test_parallel.py +++ b/tests/test_parallel.py @@ -1,16 +1,16 @@ """ Tests for parallel processing module. """ - +import io import tempfile from pathlib import Path import numpy as np import pytest -import soundfile as sf +import scipy.io.wavfile as wav +from pydub import AudioSegment from drcheck.parallel import ( - AnalysisResult, AnalysisTask, ParallelAnalyzer, _analyze_single_file, @@ -25,15 +25,22 @@ def create_test_wav( num_samples = int(sample_rate * duration) t = np.linspace(0, duration, num_samples) + amplitude = np.iinfo(np.int16).max + # Create simple sine wave - audio = np.sin(2 * np.pi * 440 * t) * 0.5 + audio = amplitude * np.sin(2 * np.pi * 440 * t) * 0.5 if channels == 2: audio = np.column_stack([audio, audio * 0.8]) else: audio = audio.reshape(-1, 1) - sf.write(filepath, audio, sample_rate, subtype="PCM_16") + wav_io = io.BytesIO() + wav.write(wav_io, sample_rate, audio.astype(np.int16)) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(filepath, format="wav") + wav_io.close() @pytest.fixture