""" LectureLens — Tests: Audio Analyzer Run with: pytest tests/test_audio.py -v """ from __future__ import annotations import struct import wave from pathlib import Path import numpy as np import pytest SAMPLE_FILES = Path(__file__).parent / "sample_files" # ── Helpers ──────────────────────────────────────────────────────────────────── def make_wav(path: Path, duration: float = 3.0, sr: int = 44100, amplitude: float = 0.3) -> Path: """Generate a simple sine-wave WAV file for testing.""" t = np.linspace(0, duration, int(sr * duration), endpoint=False) samples = (np.sin(2 * np.pi * 440 * t) * amplitude * 32767).astype(np.int16) with wave.open(str(path), "w") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sr) wf.writeframes(samples.tobytes()) return path def make_clipped_wav(path: Path, duration: float = 2.0, sr: int = 44100) -> Path: """Generate a WAV that clips (amplitude > 1.0 before clamping).""" t = np.linspace(0, duration, int(sr * duration), endpoint=False) # amplitude = 1.5 → clips after int16 conversion raw = np.sin(2 * np.pi * 440 * t) * 1.5 samples = np.clip(raw * 32767, -32768, 32767).astype(np.int16) with wave.open(str(path), "w") as wf: wf.setnchannels(1) wf.setsampwidth(2) wf.setframerate(sr) wf.writeframes(samples.tobytes()) return path # ── Setup ────────────────────────────────────────────────────────────────────── @pytest.fixture(scope="module") def clean_wav(tmp_path_factory) -> Path: p = tmp_path_factory.mktemp("audio") / "clean.wav" return make_wav(p, duration=5.0, amplitude=0.3) @pytest.fixture(scope="module") def clipped_wav(tmp_path_factory) -> Path: p = tmp_path_factory.mktemp("audio") / "clipped.wav" return make_clipped_wav(p) # ── Clipping ─────────────────────────────────────────────────────────────────── def test_no_clipping_in_clean_audio(clean_wav): from app.analyzers.audio_analyzer import count_clipped_samples clips = count_clipped_samples(clean_wav) assert clips == 0, f"Expected 0 clipped samples, got {clips}" def test_clipping_detected_in_hot_audio(clipped_wav): from app.analyzers.audio_analyzer import count_clipped_samples clips = count_clipped_samples(clipped_wav) assert clips > 0, "Expected clipped samples to be detected" # ── SNR ──────────────────────────────────────────────────────────────────────── def test_snr_returns_positive_value(clean_wav): from app.analyzers.audio_analyzer import calculate_snr snr = calculate_snr(clean_wav) assert snr is not None assert snr >= 0, f"SNR should be non-negative, got {snr}" # ── Silence detection ────────────────────────────────────────────────────────── def test_no_silence_in_continuous_audio(clean_wav): from app.analyzers.audio_analyzer import detect_silence segs = detect_silence(clean_wav, noise_db=-40, min_duration=1.0) # A sine wave has no silence assert isinstance(segs, list) def test_silence_detected_in_padded_audio(tmp_path): """WAV with 3 seconds of silence in the middle should trigger detection.""" import soundfile as sf sr = 16_000 tone = np.sin(2 * np.pi * 440 * np.linspace(0, 2, sr * 2)).astype(np.float32) * 0.4 silence = np.zeros(sr * 4, dtype=np.float32) audio = np.concatenate([tone, silence, tone]) path = tmp_path / "silence_test.wav" sf.write(str(path), audio, sr) from app.analyzers.audio_analyzer import detect_silence segs = detect_silence(path, noise_db=-40, min_duration=1.0) assert len(segs) >= 1, "Expected at least one silence segment" durations = [s.end - s.start for s in segs] assert max(durations) > 3.0, f"Expected silence > 3s, got max {max(durations):.1f}s" # ── Alert engine (audio) ─────────────────────────────────────────────────────── def test_alert_generated_for_low_loudness(): from app.schemas import AudioMetrics from app.alert_engine import generate_alerts metrics = AudioMetrics(integrated_loudness_lufs=-28.0) alerts = generate_alerts(metrics, "audio", thresholds_path="thresholds.yaml") kpis = [a.kpi for a in alerts] assert "integrated_loudness_lufs" in kpis def test_no_alerts_for_good_audio(): from app.schemas import AudioMetrics from app.alert_engine import generate_alerts metrics = AudioMetrics( integrated_loudness_lufs=-14.0, true_peak_dbtp=-2.0, clipped_samples_count=0, snr_db=30.0, loudness_range_lu=8.0, dnsmos_ovrl=4.0, ) alerts = generate_alerts(metrics, "audio", thresholds_path="thresholds.yaml") assert len(alerts) == 0, f"Expected no alerts, got: {alerts}" def test_critical_alert_for_clipping(): from app.schemas import AudioMetrics from app.alert_engine import generate_alerts metrics = AudioMetrics(clipped_samples_count=500) alerts = generate_alerts(metrics, "audio", thresholds_path="thresholds.yaml") critical = [a for a in alerts if a.kpi == "clipped_samples_count"] assert len(critical) >= 1 # ── Score ────────────────────────────────────────────────────────────────────── def test_audio_score_range(): from app.schemas import AudioMetrics from app.alert_engine import compute_audio_score m = AudioMetrics( integrated_loudness_lufs=-14.0, true_peak_dbtp=-2.0, clipped_samples_count=0, snr_db=30.0, dnsmos_ovrl=4.2, ) score = compute_audio_score(m) assert 0.0 <= score <= 1.0, f"Score out of range: {score}" assert score > 0.7, f"Expected high score for good audio, got {score}"