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>
This commit is contained in:
Steffen Schuhmann
2026-03-28 21:53:03 +01:00
commit f7be1b994d
139 changed files with 12743 additions and 0 deletions

0
worker/tests/__init__.py Normal file
View File

22
worker/tests/conftest.py Normal file
View File

@@ -0,0 +1,22 @@
"""Worker test fixtures."""
import numpy as np
import pytest
@pytest.fixture
def sine_440hz():
"""A 5-second 440Hz sine wave at 44100 Hz — usable as mock audio input."""
sr = 44100
t = np.linspace(0, 5.0, sr * 5, endpoint=False)
audio = (np.sin(2 * np.pi * 440 * t)).astype(np.float32)
return audio, sr
@pytest.fixture
def short_audio():
"""1 second of white noise."""
sr = 44100
rng = np.random.default_rng(42)
audio = rng.uniform(-0.5, 0.5, sr).astype(np.float32)
return audio, sr

View File

@@ -0,0 +1,137 @@
"""Tests for range analysis pipeline — Essentia mocked out."""
import uuid
from datetime import datetime, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import numpy as np
import pytest
from worker.analyzers.base import AnalysisResult
from worker.pipeline.analyse_range import run_range_analysis, slice_audio
def test_slice_audio_correct_samples():
sr = 44100
audio = np.ones(sr * 10, dtype=np.float32) # 10 seconds
sliced = slice_audio(audio, sr, start_ms=2000, end_ms=5000)
expected_len = int(3.0 * sr) # 3 seconds
assert abs(len(sliced) - expected_len) <= sr * 0.01 # within 10ms
def test_slice_audio_preserves_content():
sr = 44100
audio = np.arange(sr * 10, dtype=np.float32)
sliced = slice_audio(audio, sr, start_ms=0, end_ms=1000)
assert sliced[0] == 0.0
assert len(sliced) == sr
@pytest.mark.asyncio
async def test_run_range_analysis_merges_results():
"""All analyzers are mocked — tests the merge + DB write logic."""
from worker.analyzers.bpm import BPMAnalyzer
from worker.analyzers.key import KeyAnalyzer
from worker.analyzers.loudness import LoudnessAnalyzer
audio = np.sin(np.linspace(0, 2 * np.pi * 5, 44100 * 5)).astype(np.float32)
sr = 44100
version_id = uuid.uuid4()
annotation_id = uuid.uuid4()
mock_session = AsyncMock()
mock_session.get = AsyncMock(return_value=None)
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
result_mock = AsyncMock()
result_mock.scalar_one_or_none.return_value = None
mock_session.execute.return_value = result_mock
with (
patch.object(
BPMAnalyzer,
"analyze",
return_value=AnalysisResult("bpm", {"bpm": 120.0, "bpm_confidence": 0.9}),
),
patch.object(
KeyAnalyzer,
"analyze",
return_value=AnalysisResult("key", {"key": "A minor", "scale": "minor", "key_confidence": 0.8}),
),
patch.object(
LoudnessAnalyzer,
"analyze",
return_value=AnalysisResult(
"loudness",
{"avg_loudness_lufs": -18.0, "peak_loudness_dbfs": -6.0, "energy": 0.5},
),
),
):
result = await run_range_analysis(
audio=audio,
sample_rate=sr,
version_id=version_id,
annotation_id=annotation_id,
start_ms=0,
end_ms=5000,
session=mock_session,
)
assert result["bpm"] == 120.0
assert result["key"] == "A minor"
assert result["avg_loudness_lufs"] == -18.0
mock_session.add.assert_called_once()
@pytest.mark.asyncio
async def test_run_range_analysis_handles_analyzer_failure():
"""If one analyzer raises, others should still run."""
from worker.analyzers.bpm import BPMAnalyzer
from worker.analyzers.chroma import ChromaAnalyzer
audio = np.ones(44100, dtype=np.float32)
mock_session = AsyncMock()
result_mock = AsyncMock()
result_mock.scalar_one_or_none.return_value = None
mock_session.execute.return_value = result_mock
mock_session.add = MagicMock()
mock_session.commit = AsyncMock()
with (
patch.object(BPMAnalyzer, "analyze", side_effect=RuntimeError("Essentia not available")),
patch.object(
ChromaAnalyzer,
"analyze",
return_value=AnalysisResult("chroma", {"chroma_vector": [0.1] * 12}),
),
):
result = await run_range_analysis(
audio=audio,
sample_rate=44100,
version_id=uuid.uuid4(),
annotation_id=uuid.uuid4(),
start_ms=0,
end_ms=1000,
session=mock_session,
)
# bpm should be None (failed), chroma should be present
assert result.get("bpm") is None
assert result.get("chroma_vector") == [0.1] * 12
@pytest.mark.asyncio
async def test_run_range_analysis_empty_slice_raises():
audio = np.array([], dtype=np.float32)
with pytest.raises(ValueError, match="Empty audio slice"):
await run_range_analysis(
audio=audio,
sample_rate=44100,
version_id=uuid.uuid4(),
annotation_id=uuid.uuid4(),
start_ms=0,
end_ms=0,
session=AsyncMock(),
)

View File

@@ -0,0 +1,88 @@
"""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"

View File

@@ -0,0 +1,49 @@
"""Tests for waveform peak extraction (no Essentia required)."""
import json
import numpy as np
import pytest
from worker.pipeline.waveform import extract_peaks, generate_waveform_file, peaks_to_json
def test_extract_peaks_returns_correct_length(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=500)
assert len(peaks) == 500
def test_extract_peaks_normalized_between_0_and_1(sine_440hz):
audio, sr = sine_440hz
peaks = extract_peaks(audio, num_points=200)
assert all(0.0 <= p <= 1.0 for p in peaks)
assert max(peaks) == pytest.approx(1.0, abs=0.01)
def test_extract_peaks_empty_audio():
audio = np.array([], dtype=np.float32)
peaks = extract_peaks(audio, num_points=100)
assert len(peaks) == 100
assert all(p == 0.0 for p in peaks)
def test_peaks_to_json_valid_structure(sine_440hz):
audio, _ = sine_440hz
peaks = extract_peaks(audio)
json_str = peaks_to_json(peaks)
data = json.loads(json_str)
assert data["version"] == 2
assert data["channels"] == 1
assert len(data["data"]) == len(peaks)
@pytest.mark.asyncio
async def test_generate_waveform_file_writes_json(tmp_path, sine_440hz):
audio, _ = sine_440hz
output = str(tmp_path / "waveform.json")
await generate_waveform_file(audio, output)
with open(output) as f:
data = json.load(f)
assert data["version"] == 2
assert len(data["data"]) == 1000