Files
kithara-studio/backend/tests/test_analysis.py
T
rin.kagamine 6c1e1b4509 Add test suite for Lyria Studio backend
- 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
2026-05-31 08:26:01 -04:00

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