diff --git a/pyproject.toml b/pyproject.toml index a8cc4fb..8fedff0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "speech-prep" -version = "0.1.0" +dynamic = ["version"] # Tell build tools that version is determined dynamically description = "Audio preprocessing toolkit for speech-to-text applications using ffmpeg" readme = "README.md" requires-python = ">=3.9" @@ -33,9 +33,13 @@ Repository = "https://github.com/dimdasci/speech-prep" Issues = "https://github.com/dimdasci/speech-prep/issues" [build-system] -requires = ["hatchling"] +requires = ["hatchling", "hatch-vcs"] build-backend = "hatchling.build" +[tool.hatch.version] +source = "vcs" +path = "src/speech_prep/_version.py" + [tool.ruff] target-version = "py39" line-length = 88 @@ -107,4 +111,5 @@ dev = [ "pytest-mock>=3.11.1", "pydub>=0.25.1", "pre-commit>=4.2.0", + "hatch-vcs>=0.5.0", ] diff --git a/scripts/dev.sh b/scripts/dev.sh index fbc6238..e06ff82 100755 --- a/scripts/dev.sh +++ b/scripts/dev.sh @@ -12,6 +12,7 @@ function usage() { echo " fix - Fix linting issues automatically" echo " check-all - Run all checks" echo " tests - Run tests" + echo " hooks - Run all pre-commit hooks on all files" echo "" exit 1 } @@ -51,6 +52,10 @@ case "$1" in echo "🧪 Running tests..." uv run pytest tests/ ;; + hooks) + echo "🪝 Running all pre-commit hooks on all files..." + uv run pre-commit run --all-files + ;; *) usage ;; diff --git a/src/speech_prep/__init__.py b/src/speech_prep/__init__.py index bc089c1..8aab3ac 100644 --- a/src/speech_prep/__init__.py +++ b/src/speech_prep/__init__.py @@ -5,6 +5,7 @@ including silence detection and removal, speed adjustment, and format conversion. """ +from ._version import __version__ from .core import SoundFile from .exceptions import ( AudioPropertiesError, @@ -14,8 +15,6 @@ SpeechPrepError, ) -__version__ = "0.1.0" - __all__ = [ "SoundFile", "SpeechPrepError", @@ -23,4 +22,5 @@ "FileValidationError", "AudioPropertiesError", "SilenceDetectionError", + "__version__", ] diff --git a/src/speech_prep/_version.py b/src/speech_prep/_version.py new file mode 100644 index 0000000..d48ec81 --- /dev/null +++ b/src/speech_prep/_version.py @@ -0,0 +1,4 @@ +"""Version information.""" + +# This file is automatically generated by hatch-vcs +__version__ = "0.0.0" # This will be replaced during build diff --git a/src/speech_prep/core.py b/src/speech_prep/core.py index 11eb271..a49055f 100644 --- a/src/speech_prep/core.py +++ b/src/speech_prep/core.py @@ -41,9 +41,7 @@ def __init__( raise FileNotFoundError(f"Audio file not found: {self.path}") try: - duration, self.format, file_size = get_audio_properties(self.path) - self.duration = float(duration) - self.file_size = int(file_size) + self.duration, self.file_size, self.format = get_audio_properties(self.path) except Exception as e: raise SpeechPrepError(f"Failed to extract metadata: {e}") from e diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..1aad255 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +"""Integration tests for speech-prep package.""" diff --git a/tests/integration/test_sound_file_integration.py b/tests/integration/test_sound_file_integration.py new file mode 100644 index 0000000..857ec37 --- /dev/null +++ b/tests/integration/test_sound_file_integration.py @@ -0,0 +1,127 @@ +"""Integration tests for SoundFile class using real files and no mocks.""" + +import shutil + +import pytest + +from speech_prep.core import SoundFile +from speech_prep.exceptions import SpeechPrepError + +# Skip the entire module if ffmpeg is not installed +pytestmark = pytest.mark.skipif( + shutil.which("ffmpeg") is None, reason="ffmpeg is required for integration tests" +) + + +@pytest.fixture +def check_audio_file_exists(audio_file_with_silence): + """Ensure the test audio file exists before running tests.""" + file_path, _ = audio_file_with_silence + if not file_path.exists(): + pytest.skip(f"Test audio file {file_path} not found") + return file_path + + +class TestSoundFileIntegrationWithRealFiles: + """ + Integration tests for SoundFile class using real files and no mocks. + + These tests verify that the SoundFile class works correctly with real audio files + and the actual implementations of its dependencies. + """ + + def test_soundfile_initialization(self, check_audio_file_exists): + """Test SoundFile initialization with a real audio file.""" + # Get the real audio file + file_path = check_audio_file_exists + + # Create a SoundFile instance without any mocks + sound_file = SoundFile(file_path) + + # Verify basic properties + assert sound_file.path == file_path + assert sound_file.duration > 0 + assert isinstance(sound_file.format, str) + assert sound_file.file_size > 0 + + # Verify silence detection + assert isinstance(sound_file.silence_periods, list) + + # Print debug info + print(f"File: {file_path}") + print(f"Duration: {sound_file.duration}") + print(f"Format: {sound_file.format}") + print(f"File size: {sound_file.file_size}") + print(f"Silence periods: {len(sound_file.silence_periods)}") + + def test_full_processing_pipeline(self, check_audio_file_exists, temp_dir): + """ + Test a complete processing pipeline with real operations. + + This test performs: + 1. SoundFile initialization + 2. Silence stripping + 3. Speed adjustment + 4. Format conversion + + No functions are mocked, so this is a true integration test. + """ + # Get the real audio file + input_path = check_audio_file_exists + + # Define paths for outputs + stripped_path = temp_dir / "real_stripped.wav" + sped_path = temp_dir / "real_sped.wav" + converted_path = temp_dir / "real_final.mp3" + + # Create initial SoundFile + original = SoundFile(input_path) + print(f"Original file: {original}") + + # 1. Strip silence + stripped = original.strip(stripped_path) + assert stripped is not None, "Strip operation failed" + assert stripped.path.exists(), "Stripped file doesn't exist" + assert ( + stripped.duration <= original.duration + ), "Stripped file should be shorter or equal" + print(f"Stripped file: {stripped}") + + # 2. Adjust speed + speed_factor = 1.5 + sped = stripped.speed(sped_path, speed_factor) + assert sped is not None, "Speed operation failed" + assert sped.path.exists(), "Sped file doesn't exist" + # Duration should be approximately 1/speed_factor of the original + # Allow for some tolerance due to encoding differences + expected_duration = stripped.duration / speed_factor + tolerance = 0.2 # 20% tolerance + assert ( + abs(sped.duration - expected_duration) <= expected_duration * tolerance + ), f"Speed adjustment incorrect: {sped.duration} vs {expected_duration}" + print(f"Sped file: {sped}") + + # 3. Convert format + converted = sped.convert(converted_path, audio_bitrate="192k") + assert converted is not None, "Convert operation failed" + assert converted.path.exists(), "Converted file doesn't exist" + assert converted.format.lower() == "mp3", "Format conversion failed" + print(f"Converted file: {converted}") + + # Verify final file properties + assert converted.duration > 0, "Final file has no duration" + assert converted.file_size > 0, "Final file has no size" + + def test_error_handling_with_real_files(self, temp_dir): + """Test error handling with real but problematic files.""" + # Create an empty file (which will cause processing errors) + empty_file = temp_dir / "empty.wav" + empty_file.touch() + + # Attempt to create a SoundFile from the empty file + with pytest.raises(SpeechPrepError) as exc_info: + SoundFile(empty_file) + + assert "Failed to extract metadata" in str( + exc_info.value + ), "Empty file should raise metadata extraction error" diff --git a/tests/test_core.py b/tests/test_core.py index 7be9a1a..f8bfffb 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -52,7 +52,7 @@ def test_init_silence_detection_error( # Mock get_audio_properties to return test values mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(5.0, "wav", 10000), + return_value=(5.0, 10000, "wav"), ) # Should not raise an exception, but should set silence_periods to empty list @@ -74,7 +74,7 @@ def test_equality_same_path( # Mock dependencies to ensure consistent behavior mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(5.0, "wav", 10000), + return_value=(5.0, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -97,7 +97,7 @@ def test_equality_different_path(self, mocker: MockFixture, temp_dir: Path) -> N # Mock dependencies to ensure consistent behavior mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(5.0, "wav", 10000), + return_value=(5.0, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -118,7 +118,7 @@ def test_equality_non_soundfile( # Mock dependencies to ensure consistent behavior mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(5.0, "wav", 10000), + return_value=(5.0, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -145,7 +145,7 @@ def test_str_basic_info( # Mock dependencies to return consistent values mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -172,7 +172,7 @@ def test_str_no_silence_periods( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -200,7 +200,7 @@ def test_str_few_silence_periods( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -231,7 +231,7 @@ def test_str_many_silence_periods( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -278,7 +278,7 @@ def test_strip_success( # Mock dependencies for the input file mocker.patch( "speech_prep.core.get_audio_properties", - side_effect=[(duration, "wav", 10000), (3.0, "wav", 8000)], + side_effect=[(duration, 10000, "wav"), (3.0, 8000, "wav")], ) mocker.patch( "speech_prep.core.detect_silence", @@ -327,7 +327,7 @@ def test_strip_no_silence( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -362,7 +362,7 @@ def test_strip_error( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -411,7 +411,7 @@ def test_convert_success( # Mock dependencies for the input file mocker.patch( "speech_prep.core.get_audio_properties", - side_effect=[(duration, "wav", 10000), (duration, "mp3", 8000)], + side_effect=[(duration, 10000, "wav"), (duration, 8000, "mp3")], ) mocker.patch( "speech_prep.core.detect_silence", @@ -454,7 +454,7 @@ def test_convert_error( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -501,8 +501,8 @@ def test_speed_success( mocker.patch( "speech_prep.core.get_audio_properties", side_effect=[ - (duration, "wav", 10000), - (duration / speed_factor, "wav", 8000), + (duration, 10000, "wav"), + (duration / speed_factor, 8000, "wav"), ], ) mocker.patch( @@ -550,7 +550,7 @@ def test_speed_error( # Mock dependencies mocker.patch( "speech_prep.core.get_audio_properties", - return_value=(duration, "wav", 10000), + return_value=(duration, 10000, "wav"), ) mocker.patch( "speech_prep.core.detect_silence", @@ -610,13 +610,13 @@ def mock_process_file(*args, **kwargs): # Mock get_audio_properties to return different values for different files def mock_get_properties(file_path): if str(file_path).endswith("stripped.wav"): - return 3.0, "wav", 8000 + return 3.0, 8000, "wav" elif str(file_path).endswith("sped.wav"): - return 2.0, "wav", 7000 + return 2.0, 7000, "wav" elif str(file_path).endswith("final.mp3"): - return 2.0, "mp3", 6000 + return 2.0, 6000, "mp3" else: - return 5.0, "wav", 10000 + return 5.0, 10000, "wav" mocker.patch( "speech_prep.core.get_audio_properties", side_effect=mock_get_properties diff --git a/uv.lock b/uv.lock index e7c3832..e4b624d 100644 --- a/uv.lock +++ b/uv.lock @@ -50,6 +50,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 }, ] +[[package]] +name = "hatch-vcs" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hatchling" }, + { name = "setuptools-scm" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/4cc743d38adbee9d57d786fa496ed1daadb17e48589b6da8fa55717a0746/hatch_vcs-0.5.0.tar.gz", hash = "sha256:0395fa126940340215090c344a2bf4e2a77bcbe7daab16f41b37b98c95809ff9", size = 11424 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5f/48/1f85cee4b7b4f40b9b814b1febbc661bda6ced9649e410a0b74f6e415dd0/hatch_vcs-0.5.0-py3-none-any.whl", hash = "sha256:b49677dbdc597460cc22d01b27ab3696f5e16a21ecf2700fb01bc28e1f2a04a7", size = 8507 }, +] + +[[package]] +name = "hatchling" +version = "1.27.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "packaging" }, + { name = "pathspec" }, + { name = "pluggy" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "trove-classifiers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/8a/cc1debe3514da292094f1c3a700e4ca25442489731ef7c0814358816bb03/hatchling-1.27.0.tar.gz", hash = "sha256:971c296d9819abb3811112fc52c7a9751c8d381898f36533bb16f9791e941fd6", size = 54983 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/e7/ae38d7a6dfba0533684e0b2136817d667588ae3ec984c1a4e5df5eb88482/hatchling-1.27.0-py3-none-any.whl", hash = "sha256:d3a2f3567c4f926ea39849cdf924c7e99e6686c9c8e288ae1037c8fa2a5d937b", size = 75794 }, +] + [[package]] name = "identify" version = "2.6.12" @@ -59,6 +88,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/cd/18f8da995b658420625f7ef13f037be53ae04ec5ad33f9b718240dcfd48c/identify-2.6.12-py2.py3-none-any.whl", hash = "sha256:ad9672d5a72e0d2ff7c5c8809b62dfa60458626352fb0eb7b55e69bdc45334a2", size = 99145 }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -309,13 +350,38 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/d0/6902c0d017259439d6fd2fd9393cea1cfe30169940118b007d5e0ea7e954/ruff-0.12.1-py3-none-win_arm64.whl", hash = "sha256:78ad09a022c64c13cc6077707f036bab0fac8cd7088772dcd1e5be21c5002efc", size = 10691209 }, ] +[[package]] +name = "setuptools" +version = "80.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/18/5d/3bf57dcd21979b887f014ea83c24ae194cfcd12b9e0fda66b957c69d1fca/setuptools-80.9.0.tar.gz", hash = "sha256:f36b47402ecde768dbfafc46e8e4207b4360c654f1f3bb84475f0a28628fb19c", size = 1319958 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/dc/17031897dae0efacfea57dfd3a82fdd2a2aeb58e0ff71b77b87e44edc772/setuptools-80.9.0-py3-none-any.whl", hash = "sha256:062d34222ad13e0cc312a4c02d73f059e86a4acbfbdea8f8f76b28c99f306922", size = 1201486 }, +] + +[[package]] +name = "setuptools-scm" +version = "8.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.10'" }, + { name = "packaging" }, + { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions", marker = "python_full_version < '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b9/19/7ae64b70b2429c48c3a7a4ed36f50f94687d3bfcd0ae2f152367b6410dff/setuptools_scm-8.3.1.tar.gz", hash = "sha256:3d555e92b75dacd037d32bafdf94f97af51ea29ae8c7b234cf94b7a5bd242a63", size = 78088 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/ac/8f96ba9b4cfe3e4ea201f23f4f97165862395e9331a424ed325ae37024a8/setuptools_scm-8.3.1-py3-none-any.whl", hash = "sha256:332ca0d43791b818b841213e76b1971b7711a960761c5bea5fc5cdb5196fbce3", size = 43935 }, +] + [[package]] name = "speech-prep" -version = "0.1.0" source = { editable = "." } [package.dev-dependencies] dev = [ + { name = "hatch-vcs" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pydub" }, @@ -329,6 +395,7 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ + { name = "hatch-vcs", specifier = ">=0.5.0" }, { name = "mypy", specifier = ">=1.8.0" }, { name = "pre-commit", specifier = ">=4.2.0" }, { name = "pydub", specifier = ">=0.25.1" }, @@ -376,6 +443,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 }, ] +[[package]] +name = "trove-classifiers" +version = "2025.5.9.12" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/04/1cd43f72c241fedcf0d9a18d0783953ee301eac9e5d9db1df0f0f089d9af/trove_classifiers-2025.5.9.12.tar.gz", hash = "sha256:7ca7c8a7a76e2cd314468c677c69d12cc2357711fcab4a60f87994c1589e5cb5", size = 16940 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/ef/c6deb083748be3bcad6f471b6ae983950c161890bf5ae1b2af80cc56c530/trove_classifiers-2025.5.9.12-py3-none-any.whl", hash = "sha256:e381c05537adac78881c8fa345fd0e9970159f4e4a04fcc42cfd3129cca640ce", size = 14119 }, +] + [[package]] name = "typing-extensions" version = "4.14.0" @@ -398,3 +474,12 @@ sdist = { url = "https://files.pythonhosted.org/packages/56/2c/444f465fb2c65f40c wheels = [ { url = "https://files.pythonhosted.org/packages/f3/40/b1c265d4b2b62b58576588510fc4d1fe60a86319c8de99fd8e9fec617d2c/virtualenv-20.31.2-py3-none-any.whl", hash = "sha256:36efd0d9650ee985f0cad72065001e66d49a6f24eb44d98980f630686243cf11", size = 6057982 }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276 }, +]