diff --git a/.gitignore b/.gitignore index b6e4761..4df61b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# Generated audio outputs +data/filtered.wav + # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] diff --git a/Audio.py b/Audio.py deleted file mode 100644 index b13d439..0000000 --- a/Audio.py +++ /dev/null @@ -1,28 +0,0 @@ -#import all libraries -import matplotlib.pyplot as plt -import numpy as np -import pandas as pd - -#import to read/write wav audio files -from scipy.io.wavfile import read -from scipy.io.wavfile import write - -#declare global variables -song_path = './data/Faded.wav' - -def audio_2_array(path): - a = read(path) - return np.array(a[1],dtype=float) - -def array_2_audio(audio_array,filename = 'test',samplerate= 44100): - a_file = str('./data/' + filename + '.wav') - scaled = np.int16(audio_array/np.max(np.abs(audio_array)) * 32767) - write(a_file, samplerate, scaled) - return 0 - -song = audio_2_array(song_path) -array_2_audio(song) - -#Reverse song - @@$$ -rev_song = np.flip(song) -array_2_audio(rev_song,filename='rev_audio') \ No newline at end of file diff --git a/README.md b/README.md index 5e9247c..f6d969a 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,62 @@ # Py_Audio -Project for interacting with audio files using python -## Major part Covered - -- Importing a wav audio file to python interface. -- Converting audio file to a numpy array. -- Reversing array by using python list functions. -- Packing reversed array back to wav file :) +A modular Python package for audio processing — read, transform, analyse, and +filter WAV files with a simple API built on NumPy and SciPy. +## Features -## New functions to be worked on - - - Using Audio Files with a Spectrum Analyzer tool - - Use BandPass filer to capture FFT frequency in wav Audio +| Module | Functions | Description | +|--------|-----------|-------------| +| `py_audio.io` | `read_audio`, `write_audio` | WAV file I/O with float64 conversion | +| `py_audio.effects` | `reverse_audio`, `change_speed`, `change_volume` | Time/amplitude effects | +| `py_audio.analysis` | `compute_fft`, `spectrum_analysis` | FFT spectrum analysis | +| `py_audio.filters` | `bandpass_filter` | Butterworth bandpass filter | + +## Quick Start + +```bash +pip install -r requirements.txt +``` + +```python +from py_audio import read_audio, write_audio, reverse_audio +from py_audio import spectrum_analysis, bandpass_filter + +# Read a WAV file +sample_rate, audio = read_audio("data/Faded.wav") + +# Reverse the audio +reversed_audio = reverse_audio(audio) +write_audio("data/rev_audio.wav", reversed_audio, sample_rate) + +# Bandpass filter (200 Hz – 4000 Hz) +filtered = bandpass_filter(audio, sample_rate, low_freq=200, high_freq=4000) +write_audio("data/filtered.wav", filtered, sample_rate) + +# Spectrum analysis +info = spectrum_analysis("data/Faded.wav") +print(f"Peak frequency: {info['peak_frequency']:.1f} Hz") +``` + +See `example.py` for a full runnable demo. + +## Running Tests + +```bash +pip install pytest +python -m pytest tests/ -v +``` + +## Project Structure + +``` +py_audio/ # Core package + __init__.py # Public API + io.py # WAV read/write + effects.py # Audio effects + analysis.py # FFT & spectrum analysis + filters.py # Digital filters +tests/ # Unit tests +example.py # Demo script +requirements.txt # Dependencies +``` diff --git a/data/rev_audio.wav b/data/rev_audio.wav index 70a1492..1699e7e 100644 Binary files a/data/rev_audio.wav and b/data/rev_audio.wav differ diff --git a/example.py b/example.py new file mode 100644 index 0000000..2c23f8a --- /dev/null +++ b/example.py @@ -0,0 +1,46 @@ +"""Example usage of the py_audio package. + +Demonstrates reading a WAV file, applying effects and filters, +running spectrum analysis, and writing results back to disk. +""" + +from py_audio import ( + bandpass_filter, + read_audio, + reverse_audio, + spectrum_analysis, + write_audio, +) + + +def main() -> None: + source = "./data/Faded.wav" + + # --- Read the original audio --- + sample_rate, audio = read_audio(source) + print(f"Loaded: {source}") + print(f" Sample rate : {sample_rate} Hz") + print(f" Samples : {audio.shape[0]}") + print(f" Duration : {audio.shape[0] / sample_rate:.2f} s") + print() + + # --- Reverse the audio --- + reversed_audio = reverse_audio(audio) + write_audio("./data/rev_audio.wav", reversed_audio, sample_rate) + print("Saved reversed audio -> data/rev_audio.wav") + + # --- Apply a bandpass filter (200 Hz – 4000 Hz) --- + filtered = bandpass_filter(audio, sample_rate, low_freq=200, high_freq=4000) + write_audio("./data/filtered.wav", filtered, sample_rate) + print("Saved filtered audio -> data/filtered.wav") + + # --- Spectrum analysis --- + info = spectrum_analysis(source) + print() + print("Spectrum analysis:") + print(f" Peak frequency : {info['peak_frequency']:.1f} Hz") + print(f" Duration : {info['duration']:.2f} s") + + +if __name__ == "__main__": + main() diff --git a/py_audio/__init__.py b/py_audio/__init__.py new file mode 100644 index 0000000..ae4ff3b --- /dev/null +++ b/py_audio/__init__.py @@ -0,0 +1,26 @@ +"""py_audio – A modular Python package for audio processing. + +Provides WAV I/O, effects, spectrum analysis, and digital filtering +built on NumPy and SciPy. + +Quick start:: + + from py_audio import read_audio, write_audio, reverse_audio + from py_audio import spectrum_analysis, bandpass_filter +""" + +from py_audio.analysis import compute_fft, spectrum_analysis +from py_audio.effects import change_speed, change_volume, reverse_audio +from py_audio.filters import bandpass_filter +from py_audio.io import read_audio, write_audio + +__all__ = [ + "read_audio", + "write_audio", + "reverse_audio", + "change_speed", + "change_volume", + "compute_fft", + "spectrum_analysis", + "bandpass_filter", +] diff --git a/py_audio/analysis.py b/py_audio/analysis.py new file mode 100644 index 0000000..60fe1c6 --- /dev/null +++ b/py_audio/analysis.py @@ -0,0 +1,82 @@ +"""Spectrum analysis utilities for audio signals.""" + +from typing import Any, Dict, Tuple + +import numpy as np +from numpy.typing import NDArray + +from py_audio.io import read_audio + + +def compute_fft( + data: NDArray[np.float64], + sample_rate: int, +) -> Tuple[NDArray[np.float64], NDArray[np.float64]]: + """Compute the single-sided FFT magnitude spectrum of an audio signal. + + For stereo (2-D) input the channels are averaged to mono before the + FFT is computed. + + Args: + data: Audio samples (1-D mono or 2-D stereo). + sample_rate: Sample rate in Hz. + + Returns: + A tuple ``(frequencies, magnitudes)`` where both are 1-D numpy + arrays covering frequencies from 0 Hz up to the Nyquist frequency. + + Raises: + ValueError: If *data* is empty. + """ + if data.size == 0: + raise ValueError("Cannot compute FFT of an empty array.") + + # Mix to mono if stereo. + if data.ndim == 2: + data = data.mean(axis=1) + + n = len(data) + fft_vals = np.fft.rfft(data) + magnitudes = np.abs(fft_vals) * (2.0 / n) + frequencies = np.fft.rfftfreq(n, d=1.0 / sample_rate) + + return frequencies, magnitudes + + +def spectrum_analysis(path: str) -> Dict[str, Any]: + """Read a WAV file and return its spectral characteristics. + + Args: + path: Path to a WAV file. + + Returns: + A dictionary with the following keys: + + * ``frequencies`` – 1-D array of FFT bin frequencies (Hz). + * ``magnitudes`` – 1-D array of magnitude values. + * ``sample_rate`` – Sample rate of the file (Hz). + * ``duration`` – Duration of the audio in seconds. + * ``peak_frequency`` – Frequency (Hz) with the highest magnitude, + excluding the DC component at index 0. + """ + sample_rate, data = read_audio(path) + + num_samples = data.shape[0] + duration = num_samples / sample_rate + + frequencies, magnitudes = compute_fft(data, sample_rate) + + # Find the peak frequency, excluding DC (index 0). + if len(magnitudes) > 1: + peak_index = np.argmax(magnitudes[1:]) + 1 + peak_frequency = float(frequencies[peak_index]) + else: + peak_frequency = 0.0 + + return { + "frequencies": frequencies, + "magnitudes": magnitudes, + "sample_rate": sample_rate, + "duration": duration, + "peak_frequency": peak_frequency, + } diff --git a/py_audio/effects.py b/py_audio/effects.py new file mode 100644 index 0000000..0650fb8 --- /dev/null +++ b/py_audio/effects.py @@ -0,0 +1,72 @@ +"""Audio effects: reverse, speed change, and volume adjustment.""" + +import numpy as np +from numpy.typing import NDArray +from scipy.signal import resample + + +def reverse_audio(data: NDArray[np.float64]) -> NDArray[np.float64]: + """Reverse an audio array along the time axis. + + Works for both mono (1-D) and stereo (2-D, shape ``(samples, channels)``) + arrays. + + Args: + data: Audio samples as a numpy array. + + Returns: + A new array with samples in reverse order. + """ + return np.flip(data, axis=0) + + +def change_speed( + data: NDArray[np.float64], + factor: float, +) -> NDArray[np.float64]: + """Change playback speed by resampling. + + A *factor* greater than 1 speeds up the audio (fewer output samples); + a *factor* less than 1 slows it down (more output samples). + + .. note:: + Very large factors may reduce the audio to just a few samples, + which is unlikely to be musically useful. + + Args: + data: Audio samples as a numpy array (1-D or 2-D). + factor: Speed multiplier. Must be positive. + + Returns: + Resampled audio array. + + Raises: + ValueError: If *factor* is not positive. + """ + if factor <= 0: + raise ValueError("Speed factor must be positive.") + + num_samples = data.shape[0] + new_length = int(round(num_samples / factor)) + if new_length == 0: + new_length = 1 + + return resample(data, new_length, axis=0) + + +def change_volume( + data: NDArray[np.float64], + gain_db: float, +) -> NDArray[np.float64]: + """Apply a gain adjustment in decibels. + + Args: + data: Audio samples as a numpy array. + gain_db: Gain to apply in dB. Positive values amplify, + negative values attenuate. + + Returns: + Gain-adjusted audio array. + """ + multiplier = 10.0 ** (gain_db / 20.0) + return data * multiplier diff --git a/py_audio/filters.py b/py_audio/filters.py new file mode 100644 index 0000000..39dceee --- /dev/null +++ b/py_audio/filters.py @@ -0,0 +1,50 @@ +"""Digital audio filters built on scipy's signal processing toolkit.""" + +import numpy as np +from numpy.typing import NDArray +from scipy.signal import butter, sosfilt + + +def bandpass_filter( + data: NDArray[np.float64], + sample_rate: int, + low_freq: float, + high_freq: float, + order: int = 5, +) -> NDArray[np.float64]: + """Apply a Butterworth bandpass filter to audio data. + + Uses second-order sections (``sos``) for numerical stability. + Works with both mono (1-D) and stereo (2-D) arrays. For stereo + input each channel is filtered independently. + + Args: + data: Audio samples as a numpy array. + sample_rate: Sample rate in Hz. + low_freq: Lower cutoff frequency in Hz. + high_freq: Upper cutoff frequency in Hz. + order: Filter order (default 5). + + Returns: + Filtered audio as a numpy array with the same shape as *data*. + + Raises: + ValueError: If the frequency parameters are invalid. + """ + nyquist = sample_rate / 2.0 + + if low_freq <= 0 or high_freq <= 0: + raise ValueError("Cutoff frequencies must be positive.") + if low_freq >= high_freq: + raise ValueError("low_freq must be less than high_freq.") + if high_freq >= nyquist: + raise ValueError( + f"high_freq ({high_freq} Hz) must be below the Nyquist " + f"frequency ({nyquist} Hz)." + ) + + low = low_freq / nyquist + high = high_freq / nyquist + + sos = butter(order, [low, high], btype="band", output="sos") + return sosfilt(sos, data, axis=0) diff --git a/py_audio/io.py b/py_audio/io.py new file mode 100644 index 0000000..44b65f2 --- /dev/null +++ b/py_audio/io.py @@ -0,0 +1,55 @@ +"""Audio I/O utilities for reading and writing WAV files.""" + +from typing import Tuple + +import numpy as np +from numpy.typing import NDArray +from scipy.io.wavfile import read, write + + +def read_audio(path: str) -> Tuple[int, NDArray[np.float64]]: + """Read a WAV file and return its contents as a float64 numpy array. + + Args: + path: Path to the WAV file. + + Returns: + A tuple of (sample_rate, data) where data is a float64 numpy array. + Mono audio is returned as a 1-D array; stereo as a 2-D array with + shape (num_samples, num_channels). + + Raises: + FileNotFoundError: If the file does not exist. + """ + sample_rate, data = read(path) + return sample_rate, data.astype(np.float64, copy=False) + + +def write_audio( + path: str, + data: NDArray[np.float64], + sample_rate: int = 44100, +) -> None: + """Normalize audio data to int16 range and write it to a WAV file. + + The data is scaled so that its peak absolute value maps to 32767. + If the data is all zeros, zeros are written directly. + + Args: + path: Destination file path (should end with ``.wav``). + data: Audio samples as a numpy array (1-D mono or 2-D stereo). + sample_rate: Sample rate in Hz (default 44100). + + Raises: + ValueError: If *data* is empty. + """ + if data.size == 0: + raise ValueError("Cannot write an empty audio array.") + + peak = np.max(np.abs(data)) + if peak == 0: + scaled = np.zeros_like(data, dtype=np.int16) + else: + scaled = np.int16(data / peak * 32767) + + write(path, sample_rate, scaled) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9c4fe04 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy>=1.20.0 +scipy>=1.8.0 +matplotlib>=3.3.0 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_analysis.py b/tests/test_analysis.py new file mode 100644 index 0000000..387ef54 --- /dev/null +++ b/tests/test_analysis.py @@ -0,0 +1,71 @@ +"""Tests for py_audio.analysis — FFT and spectrum analysis.""" + +import os +import tempfile + +import numpy as np +import pytest + +from py_audio.analysis import compute_fft, spectrum_analysis +from py_audio.io import write_audio + + +class TestComputeFFT: + """Tests for compute_fft.""" + + def test_known_sine(self): + """A pure sine wave should have its peak at the expected frequency.""" + sr = 8000 + freq = 440.0 + t = np.arange(0, 1.0, 1.0 / sr) + data = np.sin(2 * np.pi * freq * t) + + frequencies, magnitudes = compute_fft(data, sr) + peak_idx = np.argmax(magnitudes[1:]) + 1 # skip DC + detected_freq = frequencies[peak_idx] + + assert detected_freq == pytest.approx(freq, abs=sr / len(data)) + + def test_stereo_input(self): + """Stereo input should be mixed to mono before FFT.""" + sr = 8000 + t = np.arange(0, 0.5, 1.0 / sr) + mono = np.sin(2 * np.pi * 200 * t) + stereo = np.column_stack([mono, mono]) + + freqs_m, mags_m = compute_fft(mono, sr) + freqs_s, mags_s = compute_fft(stereo, sr) + + np.testing.assert_array_almost_equal(freqs_m, freqs_s) + np.testing.assert_array_almost_equal(mags_m, mags_s) + + def test_empty_raises(self): + with pytest.raises(ValueError, match="empty"): + compute_fft(np.array([]), 44100) + + +class TestSpectrumAnalysis: + """Tests for spectrum_analysis.""" + + def test_returns_expected_keys(self): + """Result dict should have all expected keys.""" + sr = 16000 + t = np.arange(0, 0.5, 1.0 / sr) + data = np.sin(2 * np.pi * 1000 * t) + + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + path = f.name + try: + write_audio(path, data, sample_rate=sr) + result = spectrum_analysis(path) + assert set(result.keys()) == { + "frequencies", + "magnitudes", + "sample_rate", + "duration", + "peak_frequency", + } + assert result["sample_rate"] == sr + assert result["duration"] == pytest.approx(0.5, abs=1.0 / sr) + finally: + os.unlink(path) diff --git a/tests/test_effects.py b/tests/test_effects.py new file mode 100644 index 0000000..c7b9465 --- /dev/null +++ b/tests/test_effects.py @@ -0,0 +1,63 @@ +"""Tests for py_audio.effects — audio effects.""" + +import numpy as np +import pytest + +from py_audio.effects import change_speed, change_volume, reverse_audio + + +class TestReverseAudio: + """Tests for reverse_audio.""" + + def test_reverses_mono(self): + data = np.array([1.0, 2.0, 3.0, 4.0, 5.0]) + result = reverse_audio(data) + np.testing.assert_array_equal(result, [5.0, 4.0, 3.0, 2.0, 1.0]) + + def test_reverses_stereo(self): + data = np.array([[1.0, 10.0], [2.0, 20.0], [3.0, 30.0]]) + result = reverse_audio(data) + expected = np.array([[3.0, 30.0], [2.0, 20.0], [1.0, 10.0]]) + np.testing.assert_array_equal(result, expected) + + +class TestChangeSpeed: + """Tests for change_speed.""" + + def test_speed_up(self): + data = np.ones(1000) + result = change_speed(data, factor=2.0) + assert len(result) == 500 + + def test_slow_down(self): + data = np.ones(1000) + result = change_speed(data, factor=0.5) + assert len(result) == 2000 + + def test_invalid_factor_raises(self): + with pytest.raises(ValueError, match="positive"): + change_speed(np.ones(10), factor=-1.0) + + def test_zero_factor_raises(self): + with pytest.raises(ValueError, match="positive"): + change_speed(np.ones(10), factor=0.0) + + +class TestChangeVolume: + """Tests for change_volume.""" + + def test_gain_positive(self): + data = np.array([1.0, -1.0]) + result = change_volume(data, gain_db=6.0) + # +6 dB ≈ 2× amplitude + assert result[0] == pytest.approx(10 ** (6.0 / 20.0), rel=1e-6) + + def test_gain_zero(self): + data = np.array([1.0, 0.5, -0.5]) + result = change_volume(data, gain_db=0.0) + np.testing.assert_array_almost_equal(result, data) + + def test_gain_negative(self): + data = np.array([1.0]) + result = change_volume(data, gain_db=-20.0) + assert result[0] == pytest.approx(0.1, rel=1e-6) diff --git a/tests/test_filters.py b/tests/test_filters.py new file mode 100644 index 0000000..5fdf8b7 --- /dev/null +++ b/tests/test_filters.py @@ -0,0 +1,53 @@ +"""Tests for py_audio.filters — digital audio filters.""" + +import numpy as np +import pytest + +from py_audio.filters import bandpass_filter + + +class TestBandpassFilter: + """Tests for bandpass_filter.""" + + def test_passes_in_band_signal(self): + """A sine wave within the passband should survive the filter.""" + sr = 8000 + t = np.arange(0, 1.0, 1.0 / sr) + data = np.sin(2 * np.pi * 500 * t) # 500 Hz in band + + filtered = bandpass_filter(data, sr, low_freq=200, high_freq=1000) + # Energy should be mostly preserved (> 50% of original) + assert np.std(filtered) > 0.5 * np.std(data) + + def test_attenuates_out_of_band(self): + """A sine wave outside the passband should be attenuated.""" + sr = 8000 + t = np.arange(0, 1.0, 1.0 / sr) + data = np.sin(2 * np.pi * 100 * t) # 100 Hz, below passband + + filtered = bandpass_filter(data, sr, low_freq=500, high_freq=2000) + # Energy should be significantly reduced + assert np.std(filtered) < 0.3 * np.std(data) + + def test_invalid_freq_order(self): + with pytest.raises(ValueError, match="less than"): + bandpass_filter(np.ones(100), 8000, low_freq=2000, high_freq=500) + + def test_freq_above_nyquist(self): + with pytest.raises(ValueError, match="Nyquist"): + bandpass_filter(np.ones(100), 8000, low_freq=100, high_freq=5000) + + def test_negative_freq(self): + with pytest.raises(ValueError, match="positive"): + bandpass_filter(np.ones(100), 8000, low_freq=-10, high_freq=500) + + def test_stereo_input(self): + """Stereo array should retain its shape after filtering.""" + sr = 8000 + t = np.arange(0, 0.5, 1.0 / sr) + stereo = np.column_stack([ + np.sin(2 * np.pi * 300 * t), + np.cos(2 * np.pi * 300 * t), + ]) + filtered = bandpass_filter(stereo, sr, low_freq=100, high_freq=1000) + assert filtered.shape == stereo.shape diff --git a/tests/test_io.py b/tests/test_io.py new file mode 100644 index 0000000..bbdbf93 --- /dev/null +++ b/tests/test_io.py @@ -0,0 +1,84 @@ +"""Tests for py_audio.io — WAV read/write utilities.""" + +import os +import tempfile + +import numpy as np +import pytest + +from py_audio.io import read_audio, write_audio + + +class TestReadAudio: + """Tests for read_audio.""" + + def test_reads_wav_file(self): + """Round-trip: write then read back and verify.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + path = f.name + try: + data = np.array([0.0, 0.5, -0.5, 1.0, -1.0], dtype=np.float64) + write_audio(path, data, sample_rate=16000) + sr, result = read_audio(path) + assert sr == 16000 + assert result.dtype == np.float64 + assert len(result) == 5 + finally: + os.unlink(path) + + def test_file_not_found(self): + """Raise an error for a missing file.""" + with pytest.raises(FileNotFoundError): + read_audio("/nonexistent/path.wav") + + +class TestWriteAudio: + """Tests for write_audio.""" + + def test_normalizes_to_int16(self): + """Peak value maps to 32767 in the written file.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + path = f.name + try: + data = np.array([0.0, 2.0, -2.0], dtype=np.float64) + write_audio(path, data, sample_rate=44100) + sr, result = read_audio(path) + assert sr == 44100 + # Peak should map to ±32767 + assert np.max(np.abs(result)) == pytest.approx(32767.0, abs=1) + finally: + os.unlink(path) + + def test_all_zeros(self): + """Writing all-zero data should not error.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + path = f.name + try: + data = np.zeros(100, dtype=np.float64) + write_audio(path, data, sample_rate=44100) + sr, result = read_audio(path) + assert sr == 44100 + assert np.all(result == 0) + finally: + os.unlink(path) + + def test_empty_array_raises(self): + """Writing an empty array should raise ValueError.""" + with pytest.raises(ValueError): + write_audio("out.wav", np.array([], dtype=np.float64)) + + def test_stereo_round_trip(self): + """Stereo data should survive a write/read round-trip.""" + with tempfile.NamedTemporaryFile(suffix=".wav", delete=False) as f: + path = f.name + try: + data = np.column_stack([ + np.sin(np.linspace(0, 2 * np.pi, 1000)), + np.cos(np.linspace(0, 2 * np.pi, 1000)), + ]) + write_audio(path, data, sample_rate=22050) + sr, result = read_audio(path) + assert sr == 22050 + assert result.shape == (1000, 2) + finally: + os.unlink(path)