From e64b7ada841500ca4ad66040d96477045ed0b438 Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:10:58 -0400 Subject: [PATCH 1/7] feat: replace libsndfile/mutagen with pydub/tinytag libsndfile is very limited in what codecs it can read. pydub supports everything ffmpeg does, and given that users will need pydub anyways for any type of lossless codec, removing a dependency and using one solution for all codecs is simpler. This also replaces mutagen with tinytag, which is simpler to use and also helpfully returns the bitrate and codec name. Signed-off-by: gryffyn --- drcheck/audio.py | 183 ++++++++---------------------------------- drcheck/cli.py | 35 +++----- drcheck/formatters.py | 13 ++- drcheck/parallel.py | 2 +- 4 files changed, 49 insertions(+), 184 deletions(-) diff --git a/drcheck/audio.py b/drcheck/audio.py index 177d7f0..3cbd7e7 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 @@ -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..ae84902 100644 --- a/drcheck/formatters.py +++ b/drcheck/formatters.py @@ -28,6 +28,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 +40,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 +54,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 +187,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}") 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, From d60e4759e3656b661d2e839fae3bdb6cf784a06e Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:11:17 -0400 Subject: [PATCH 2/7] test: update tests to use pydub Signed-off-by: gryffyn --- tests/test_audio.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index b6c450f..46dc11e 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, @@ -44,7 +45,11 @@ def create_test_wav( 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) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(filepath, format="wav") def create_test_flac( @@ -62,7 +67,11 @@ def create_test_flac( 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) + wav_io.seek(0) + sound = AudioSegment.from_wav(wav_io) + sound.export(filepath, format="flac") class TestAudioData: From b7c7810712ab1c33621e2d1b98fddedba6ed6452 Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:13:45 -0400 Subject: [PATCH 3/7] chore: update dependencies Signed-off-by: gryffyn --- pyproject.toml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) 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", ] From 3acd26dad2579c20c5cc90b568d661fa8e8fb9a9 Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:14:02 -0400 Subject: [PATCH 4/7] docs: update README Signed-off-by: gryffyn --- README.md | 51 +++++++++++++++++++++------------------------------ 1 file changed, 21 insertions(+), 30 deletions(-) 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) ``` From a7d3ed1fb5c9627df249fbeb4c42e1ae03927c24 Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:16:12 -0400 Subject: [PATCH 5/7] docs: fix reference to libsndfile in comment Signed-off-by: gryffyn --- drcheck/audio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/drcheck/audio.py b/drcheck/audio.py index 3cbd7e7..ba1df85 100644 --- a/drcheck/audio.py +++ b/drcheck/audio.py @@ -74,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 From 65dd7089281a6fa1b015c8e2d4fc4d8ec94045c4 Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:23:53 -0400 Subject: [PATCH 6/7] chore: migrate extract_album_art to use tinytag Signed-off-by: gryffyn --- drcheck/formatters.py | 82 ++++++++++++------------------------------- 1 file changed, 23 insertions(+), 59 deletions(-) diff --git a/drcheck/formatters.py b/drcheck/formatters.py index ae84902..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__ @@ -526,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 From 01f683d0df99a9a58dc9d19a812cd9d1620503a6 Mon Sep 17 00:00:00 2001 From: gryffyn Date: Sun, 5 Apr 2026 23:43:51 -0400 Subject: [PATCH 7/7] test: fix tests (tests assume int16 samples) Signed-off-by: gryffyn --- tests/test_audio.py | 24 ++++++++++++++++++------ tests/test_cli.py | 16 ++++++++++++---- tests/test_parallel.py | 17 ++++++++++++----- 3 files changed, 42 insertions(+), 15 deletions(-) diff --git a/tests/test_audio.py b/tests/test_audio.py index 46dc11e..7ff4835 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -37,8 +37,10 @@ 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]) @@ -46,10 +48,11 @@ def create_test_wav( audio = audio.reshape(-1, 1) wav_io = io.BytesIO() - wav.write(wav_io, sample_rate, audio) + 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( @@ -59,8 +62,10 @@ 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]) @@ -68,10 +73,11 @@ def create_test_flac( audio = audio.reshape(-1, 1) wav_io = io.BytesIO() - wav.write(wav_io, sample_rate, audio) + 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: @@ -207,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