lecturelens-api / tests /test_audio.py
aliSaac510's picture
feat: initial LectureLens API v1.0 β€” full audio/video quality analysis service
75ba57a
Raw
History Blame Contribute Delete
6.62 kB
"""
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}"