- conftest.py: isolated async test fixtures with temp DB and directories - test_waveform.py: waveform generation, downsampling, caching, SVG output - test_api.py: full API endpoint tests (health, models, clips CRUD, waveform endpoint, admin cache clearing) - test_analysis.py: BPM detection, key detection, clip analysis - test_models.py: request validation, model registry, prompt building - Fixed off-by-one in waveform.py downsampling (linspace upper bound) - All 40 tests passing
117 lines
4.0 KiB
Python
117 lines
4.0 KiB
Python
"""Tests for audio analysis module."""
|
|
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
import numpy as np
|
|
|
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src"))
|
|
|
|
from analysis import analyze_clip, detect_bpm, detect_key, MAJOR_PROFILE, MINOR_PROFILE, KEY_NAMES
|
|
|
|
|
|
class TestKeyProfiles:
|
|
"""Verify the Krumhansl-Schmuckler key profiles are well-formed."""
|
|
|
|
def test_major_profile_has_12_entries(self):
|
|
assert len(MAJOR_PROFILE) == 12
|
|
|
|
def test_minor_profile_has_12_entries(self):
|
|
assert len(MINOR_PROFILE) == 12
|
|
|
|
def test_key_names_has_24_entries(self):
|
|
assert len(KEY_NAMES) == 24
|
|
# 12 major + 12 minor
|
|
majors = [k for k in KEY_NAMES if "major" in k]
|
|
minors = [k for k in KEY_NAMES if "minor" in k]
|
|
assert len(majors) == 12
|
|
assert len(minors) == 12
|
|
|
|
|
|
class TestDetectBpm:
|
|
"""BPM detection tests."""
|
|
|
|
def test_detect_bpm_on_silent_file_raises(self, tmp_path):
|
|
"""A file that's not valid audio should raise an error from librosa."""
|
|
fake_audio = tmp_path / "silent.mp3"
|
|
fake_audio.write_bytes(b"\x00" * 1024)
|
|
with pytest.raises(Exception):
|
|
# librosa will fail on invalid audio data
|
|
detect_bpm(str(fake_audio))
|
|
|
|
@pytest.mark.skipif(
|
|
subprocess.run(["ffmpeg", "-version"], capture_output=True).returncode != 0,
|
|
reason="ffmpeg not available"
|
|
)
|
|
def test_detect_bpm_on_generated_audio(self, tmp_path):
|
|
"""Generate a simple 120 BPM click track and verify BPM detection."""
|
|
mp3_path = tmp_path / "test_bpm.mp3"
|
|
# Generate a 5-second audio file with ffmpeg
|
|
# Using a sine wave at 440Hz — BPM detection should return a numeric value
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y", "-f", "lavfi",
|
|
"-i", "sine=frequency=440:duration=5",
|
|
"-q:a", "9", str(mp3_path)
|
|
],
|
|
capture_output=True, check=True,
|
|
)
|
|
bpm = detect_bpm(str(mp3_path))
|
|
# BPM detection on a pure sine might not be perfectly accurate,
|
|
# but it should return a positive float
|
|
# BPM detection on a pure sine returns 0.0 (no beats)
|
|
# This is expected — a pure tone has no rhythmic content
|
|
assert isinstance(bpm, float)
|
|
assert bpm >= 0.0
|
|
|
|
|
|
class TestDetectKey:
|
|
"""Key detection tests."""
|
|
|
|
@pytest.mark.skipif(
|
|
subprocess.run(["ffmpeg", "-version"], capture_output=True).returncode != 0,
|
|
reason="ffmpeg not available"
|
|
)
|
|
def test_detect_key_returns_valid_key_name(self, tmp_path):
|
|
mp3_path = tmp_path / "test_key.mp3"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y", "-f", "lavfi",
|
|
"-i", "sine=frequency=440:duration=5",
|
|
"-q:a", "9", str(mp3_path)
|
|
],
|
|
capture_output=True, check=True,
|
|
)
|
|
key = detect_key(str(mp3_path))
|
|
assert key in KEY_NAMES, f"Detected key '{key}' not in KEY_NAMES"
|
|
|
|
|
|
class TestAnalyzeClip:
|
|
"""Full analysis integration test."""
|
|
|
|
def test_analyze_missing_file_raises(self, tmp_path):
|
|
with pytest.raises(FileNotFoundError):
|
|
analyze_clip(str(tmp_path / "nonexistent.mp3"))
|
|
|
|
@pytest.mark.skipif(
|
|
subprocess.run(["ffmpeg", "-version"], capture_output=True).returncode != 0,
|
|
reason="ffmpeg not available"
|
|
)
|
|
def test_analyze_clip_returns_bpm_and_key(self, tmp_path):
|
|
mp3_path = tmp_path / "test_analyze.mp3"
|
|
subprocess.run(
|
|
[
|
|
"ffmpeg", "-y", "-f", "lavfi",
|
|
"-i", "sine=frequency=440:duration=5",
|
|
"-q:a", "9", str(mp3_path)
|
|
],
|
|
capture_output=True, check=True,
|
|
)
|
|
result = analyze_clip(str(mp3_path))
|
|
assert "bpm" in result
|
|
assert "key" in result
|
|
assert isinstance(result["bpm"], float)
|
|
assert result["bpm"] >= 0.0 # Pure sine has no beats, BPM may be 0
|
|
assert result["key"] in KEY_NAMES |