Files
rehearshalhub/worker/tests/test_analyzers.py
Steffen Schuhmann f7be1b994d Initial commit: RehearsalHub POC
Full-stack self-hosted band rehearsal platform:

Backend (FastAPI + SQLAlchemy 2.0 async):
- Auth with JWT (register, login, /me, settings)
- Band management with Nextcloud folder integration
- Song management with audio version tracking
- Nextcloud scan to auto-import audio files
- Band membership with link-based invite system
- Song comments
- Audio analysis worker (BPM, key, loudness, waveform)
- Nextcloud activity watcher for auto-import
- WebSocket support for real-time annotation updates
- Alembic migrations (0001–0003)
- Repository pattern, Ruff + mypy configured

Frontend (React 18 + Vite + TypeScript strict):
- Login/register page with post-login redirect
- Home page with band list and creation form
- Band page with member panel, invite link, song list, NC scan
- Song page with waveform player, annotations, comment thread
- Settings page for per-user Nextcloud credentials
- Invite acceptance page (/invite/:token)
- ESLint v9 flat config + TypeScript strict mode

Infrastructure:
- Docker Compose: PostgreSQL, Redis, API, worker, watcher, nginx
- nginx reverse proxy for static files + /api/ proxy
- make check runs all linters before docker compose build

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-28 21:53:03 +01:00

89 lines
3.0 KiB
Python

"""Unit tests for individual analyzers (Essentia mocked, librosa used directly)."""
from unittest.mock import MagicMock, patch
import numpy as np
import pytest
from worker.analyzers.base import AnalysisResult
from worker.analyzers.chroma import ChromaAnalyzer
from worker.analyzers.loudness import LoudnessAnalyzer
from worker.analyzers.mfcc import MFCCAnalyzer
from worker.analyzers.spectral import SpectralAnalyzer
def test_loudness_analyzer_returns_expected_fields(sine_440hz):
audio, sr = sine_440hz
result = LoudnessAnalyzer().analyze(audio, sr)
assert isinstance(result, AnalysisResult)
assert result.analyzer_name == "loudness"
assert "avg_loudness_lufs" in result.fields
assert "peak_loudness_dbfs" in result.fields
assert "energy" in result.fields
assert result.fields["energy"] is not None
assert 0.0 <= result.fields["energy"] <= 1.0
def test_chroma_analyzer_returns_12_dimensions(sine_440hz):
audio, sr = sine_440hz
result = ChromaAnalyzer().analyze(audio, sr)
assert result.analyzer_name == "chroma"
chroma = result.fields["chroma_vector"]
assert chroma is not None
assert len(chroma) == 12
assert all(isinstance(v, float) for v in chroma)
def test_mfcc_analyzer_returns_13_dimensions(sine_440hz):
audio, sr = sine_440hz
result = MFCCAnalyzer().analyze(audio, sr)
assert result.analyzer_name == "mfcc"
mfcc = result.fields["mfcc_mean"]
assert mfcc is not None
assert len(mfcc) == 13
def test_spectral_analyzer_returns_centroid(sine_440hz):
audio, sr = sine_440hz
result = SpectralAnalyzer().analyze(audio, sr)
assert "spectral_centroid" in result.fields
# 440 Hz tone should have centroid near 440 Hz
centroid = result.fields["spectral_centroid"]
assert centroid is not None
assert 300 < centroid < 600
def test_bpm_analyzer_falls_back_to_librosa_when_essentia_unavailable(sine_440hz):
audio, sr = sine_440hz
from worker.analyzers.bpm import BPMAnalyzer
with patch.dict("sys.modules", {"essentia": None, "essentia.standard": None}):
with patch.object(
BPMAnalyzer,
"_essentia_bpm",
side_effect=ImportError("no essentia"),
):
result = BPMAnalyzer().analyze(audio, sr)
assert result.analyzer_name == "bpm"
assert "bpm" in result.fields
# librosa result for a sine wave — rough estimate
assert result.fields["bpm"] is not None
assert result.fields["bpm"] > 0
def test_key_analyzer_returns_none_fields_when_essentia_unavailable(sine_440hz):
audio, sr = sine_440hz
from worker.analyzers.key import KeyAnalyzer
with patch.object(KeyAnalyzer, "analyze", wraps=KeyAnalyzer().analyze):
with patch.dict("sys.modules", {"essentia": None, "essentia.standard": None}):
with patch(
"worker.analyzers.key.__import__",
side_effect=ImportError,
):
result = KeyAnalyzer().analyze(audio, sr)
# When Essentia fails, returns None fields (no crash)
assert result.analyzer_name == "key"