diff --git a/.gitignore b/.gitignore index 01b196b..b944ee0 100644 --- a/.gitignore +++ b/.gitignore @@ -7,5 +7,9 @@ tmp/ *.egg build htmlcov +*.egg-info/ /.venv/ +/dist/ +/build/ +/*.so diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 0000000..ba2778d --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,6 @@ +[settings] +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 diff --git a/README.md b/README.md index ada0452..ba53c6a 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,16 @@ # pyspeex-noise -Noise suppression using speex. +Noise suppression and automatic gain control using speex. + +``` python +from pyspeex_noise import AudioProcessor + +auto_gain = 4000 +noise_suppression = -30 +audio_processor = AudioProcessor(auto_gain, noise_suppression) + +# Process 10ms chunks of 16-bit mono PCM @16Khz +while audio := get_10ms_of_audio(): + assert len(audio) == 160 * 2 # 160 samples + clean_audio = audio_processor.Process10ms(audio).audio +``` diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 0000000..261cf49 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,5 @@ +[mypy] +ignore_missing_imports = true + +[mypy-setuptools.*] +ignore_missing_imports = True diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..22a70d0 --- /dev/null +++ b/pylintrc @@ -0,0 +1,37 @@ +[MESSAGES CONTROL] +disable= + format, + abstract-method, + cyclic-import, + duplicate-code, + global-statement, + import-outside-toplevel, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unnecessary-pass, + unused-argument, + broad-except, + too-many-nested-blocks, + invalid-name, + unused-import, + fixme, + useless-super-delegation, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + import-error, + consider-using-with + +[FORMAT] +expected-line-ending-format=LF diff --git a/pyspeex_noise/__init__.py b/pyspeex_noise/__init__.py index 25b6714..5700add 100644 --- a/pyspeex_noise/__init__.py +++ b/pyspeex_noise/__init__.py @@ -1 +1,4 @@ -from speex_noise_cpp import AudioProcessor +"""Noise suppression and auto gain with speex.""" +from speex_noise_cpp import AudioProcessor # pylint: disable=E0611 + +__all__ = ["AudioProcessor"] diff --git a/requirements_dev.txt b/requirements_dev.txt index b6e651e..f30f57c 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,2 +1,8 @@ -pytest +black==22.12.0 +flake8==6.0.0 +isort==5.11.3 +mypy==0.991 +pylint==2.15.9 +pytest==8.2.2 pybind11 +build diff --git a/samples/noise.wav b/samples/noise.wav new file mode 100644 index 0000000..1c56de4 Binary files /dev/null and b/samples/noise.wav differ diff --git a/samples/noise_clean.wav b/samples/noise_clean.wav new file mode 100644 index 0000000..cd2e4b6 Binary files /dev/null and b/samples/noise_clean.wav differ diff --git a/samples/speech.wav b/samples/speech.wav new file mode 100644 index 0000000..62297fb Binary files /dev/null and b/samples/speech.wav differ diff --git a/samples/speech_clean.wav b/samples/speech_clean.wav new file mode 100644 index 0000000..56d6743 Binary files /dev/null and b/samples/speech_clean.wav differ diff --git a/script/format b/script/format new file mode 100755 index 0000000..5873170 --- /dev/null +++ b/script/format @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" +_MODULE_DIR = _PROGRAM_DIR / "pyspeex_noise" +_TESTS_DIR = _PROGRAM_DIR / "tests" + +_FORMAT_DIRS = [_MODULE_DIR, _TESTS_DIR] + +context = venv.EnvBuilder().ensure_directories(_VENV_DIR) +subprocess.check_call([context.env_exe, "-m", "black"] + _FORMAT_DIRS) +subprocess.check_call([context.env_exe, "-m", "isort"] + _FORMAT_DIRS) diff --git a/script/lint b/script/lint new file mode 100755 index 0000000..c6db1b6 --- /dev/null +++ b/script/lint @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" +_MODULE_DIR = _PROGRAM_DIR / "pyspeex_noise" +_TESTS_DIR = _PROGRAM_DIR / "tests" + +_LINT_DIRS = [_MODULE_DIR, _TESTS_DIR] + +context = venv.EnvBuilder().ensure_directories(_VENV_DIR) +subprocess.check_call([context.env_exe, "-m", "black"] + _LINT_DIRS + ["--check"]) +subprocess.check_call([context.env_exe, "-m", "isort"] + _LINT_DIRS + ["--check"]) +subprocess.check_call([context.env_exe, "-m", "flake8"] + _LINT_DIRS) +subprocess.check_call([context.env_exe, "-m", "pylint"] + _LINT_DIRS) +subprocess.check_call([context.env_exe, "-m", "mypy"] + _LINT_DIRS) diff --git a/script/package b/script/package new file mode 100755 index 0000000..0d19ac2 --- /dev/null +++ b/script/package @@ -0,0 +1,11 @@ +#!/usr/bin/env python3 +import subprocess +import venv +from pathlib import Path + +_DIR = Path(__file__).parent +_PROGRAM_DIR = _DIR.parent +_VENV_DIR = _PROGRAM_DIR / ".venv" + +context = venv.EnvBuilder().ensure_directories(_VENV_DIR) +subprocess.check_call([context.env_exe, "-m", "build", "--sdist", "--wheel"]) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..0076bbf --- /dev/null +++ b/setup.cfg @@ -0,0 +1,22 @@ +[flake8] +# To work with Black +max-line-length = 88 +# E501: line too long +# W503: Line break occurred before a binary operator +# E203: Whitespace before ':' +# D202 No blank lines allowed after function docstring +# W504 line break after binary operator +ignore = + E501, + W503, + E203, + D202, + W504 + +[isort] +multi_line_output = 3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True +line_length=88 +indent = " " diff --git a/tests/noise.wav b/tests/noise.wav new file mode 100644 index 0000000..1c56de4 Binary files /dev/null and b/tests/noise.wav differ diff --git a/tests/test_audio_processor.py b/tests/test_audio_processor.py index 2404020..b518626 100644 --- a/tests/test_audio_processor.py +++ b/tests/test_audio_processor.py @@ -1,8 +1,53 @@ +import array +import math +import statistics +import wave +from pathlib import Path + from pyspeex_noise import AudioProcessor +_DIR = Path(__file__).parent + +SAMPLES_10MS = 160 +BYTES_10MS = SAMPLES_10MS * 2 + + +def _get_energy(chunk: bytes): + """RMS""" + chunk_array = array.array("h", chunk) + energy = -math.sqrt(sum(x**2 for x in chunk_array) / len(chunk_array)) + debiased_energy = math.sqrt( + sum((x + energy) ** 2 for x in chunk_array) / len(chunk_array) + ) + + return debiased_energy + -def test_audio_processor(): +def test_no_processing(): + """Test that audio is not changed if no auto gain or noise suppression is applied.""" audio_processor = AudioProcessor(0, 0) data_in = bytes(320) result = audio_processor.Process10ms(data_in) assert result.audio == data_in + + +def test_noise_suppression(): + """Test default settings on a noisy file.""" + audio_processor = AudioProcessor(4000, -30) + noisy_energy = [] + clean_energy = [] + + with wave.open(str(_DIR / "noise.wav"), "rb") as wav_file: + assert wav_file.getframerate() == 16000 + assert wav_file.getsampwidth() == 2 + assert wav_file.getnchannels() == 1 + + chunk = wav_file.readframes(SAMPLES_10MS) + while len(chunk) == BYTES_10MS: + clean_chunk = audio_processor.Process10ms(chunk).audio + noisy_energy.append(_get_energy(chunk)) + clean_energy.append(_get_energy(clean_chunk)) + chunk = wav_file.readframes(SAMPLES_10MS) + + # A lot less energy + assert (statistics.mean(noisy_energy) / statistics.mean(clean_energy)) > 30