Files
pipu-music/services/recommender/tests/test_features.py
T
mrleo1nidandClaude Opus 5.5 ef29b85320
ci / api / api (push) Successful in 27s
ci / recommender / recommender (push) Successful in 1m0s
ci / docker / docker (образы собираются — их сервер собирает сам при обновлении) (push) Successful in 1m26s
feat(rec): audio analyzer and My Wave v1 engine (M4)
- migration 00006: track features and embeddings (pgvector, HNSW),
  analysis queue filled by a trigger on new tracks, wave sessions and
  impressions, recommender profiles, model versions
- analyzer basic-1 (ADR-014): ffmpeg decode, three 30 s excerpts,
  256-d timbre/rhythm embedding, tempo by onset autocorrelation, key,
  loudness, energy, danceability, acousticness, intro length; worker
  with SKIP LOCKED claims, retries and re-analysis on version change
- wave engine v1: candidates (session/taste similarity, fresh,
  liked artists, rediscover, popular, exploration, seed), filters
  (dislikes, played in 3 h, already served), heuristic score, activity
  and mood fit from contexts.yaml with relaxing thresholds, MMR with
  one track per artist, exploration share, session vector updated by
  reactions and a change of course after three skips
- POST /internal/wave/batch with the catalog index cached in memory
  and the session kept in Redis

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 12:20:55 +03:00

57 lines
2.2 KiB
Python

import numpy as np
from pipu_rec.analysis.features import EMBEDDING_DIM, SAMPLE_RATE, analyze, estimate_tempo, segments
def metronome(bpm: float, seconds: float, freq: float = 1000.0) -> np.ndarray:
t = np.arange(int(seconds * SAMPLE_RATE)) / SAMPLE_RATE
period = 60.0 / bpm
phase = t % period
click = np.sin(2 * np.pi * freq * t) * np.exp(-phase * 60)
return (0.5 * click).astype(np.float32)
def test_tempo_of_a_metronome() -> None:
for bpm in (90, 128, 170):
f = analyze(metronome(bpm, 40))
assert f.bpm is not None
# двойной / половинный темп допустим для щелчков, но для этих значений приор держит исходный
assert abs(f.bpm - bpm) < 2.5, (bpm, f.bpm)
def test_no_rhythm_in_a_steady_tone() -> None:
t = np.arange(SAMPLE_RATE * 20) / SAMPLE_RATE
tone = (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
f = analyze(tone)
assert f.bpm is None or f.bpm_confidence < 0.3
assert f.key == "A"
assert f.embedding.shape == (EMBEDDING_DIM,)
assert abs(float(np.linalg.norm(f.embedding)) - 1) < 1e-4
def test_louder_and_busier_means_more_energy() -> None:
quiet = (metronome(80, 30) * 0.05).astype(np.float32)
loud = metronome(160, 30)
noise = np.random.default_rng(1).normal(0, 0.2, len(loud)).astype(np.float32)
assert analyze(loud + noise).energy > analyze(quiet).energy
def test_similar_sounds_have_closer_embeddings() -> None:
a = analyze(metronome(120, 30, 1000))
b = analyze(metronome(124, 30, 1100))
rng = np.random.default_rng(2)
c = analyze(rng.normal(0, 0.3, SAMPLE_RATE * 30).astype(np.float32))
assert float(a.embedding @ b.embedding) > float(a.embedding @ c.embedding)
def test_segments_cover_long_tracks() -> None:
long = np.zeros(SAMPLE_RATE * 300, dtype=np.float32)
parts = segments(long)
assert len(parts) == 3
assert all(len(p) == SAMPLE_RATE * 30 for p in parts)
assert len(segments(np.zeros(SAMPLE_RATE * 60, dtype=np.float32))) == 1
def test_estimate_tempo_needs_signal() -> None:
assert estimate_tempo(np.zeros(100, dtype=np.float32)) == (None, 0.0)