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>
This commit is contained in:
1 parent
976ffb574b
commit
ef29b85320
20 files changed
+1530
-13
No files matched your search
@@ -15,7 +15,8 @@ COPY services/recommender/ ./
|
||||
RUN --mount=type=cache,target=/root/.cache/uv uv sync --frozen --no-dev
|
||||
|
||||
FROM python:3.12-slim
|
||||
RUN useradd --system --uid 10001 pipu
|
||||
# ffmpeg декодирует аудио для анализатора.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ffmpeg && rm -rf /var/lib/apt/lists/* && useradd --system --uid 10001 pipu
|
||||
COPY --from=build /app /app
|
||||
ARG VERSION=0.0.0-dev
|
||||
ENV PATH=/app/.venv/bin:$PATH PIPU_VERSION=${VERSION} PYTHONUNBUFFERED=1
|
||||
|
||||
Whitespace-only changes.
@@ -0,0 +1,206 @@
|
||||
"""Аудиопризнаки трека на numpy: тембр (эмбеддинг), темп, громкость, энергия, тональность.
|
||||
|
||||
Версия `basic-1` — без нейросетевых моделей (ADR-014): работает на любом CPU без TensorFlow.
|
||||
Нейросетевые признаки Essentia (жанры, настроения, valence/arousal) — следующая версия анализатора;
|
||||
смена `ANALYZER_VER` ставит пересчёт.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
ANALYZER_VER = "basic-1"
|
||||
SAMPLE_RATE = 22050
|
||||
N_FFT = 2048
|
||||
HOP = 512
|
||||
N_MELS = 64
|
||||
EMBEDDING_DIM = 256
|
||||
SEGMENT_S = 30.0
|
||||
|
||||
Float = NDArray[np.float32]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Features:
|
||||
bpm: float | None
|
||||
bpm_confidence: float
|
||||
key: str | None
|
||||
scale: str | None
|
||||
loudness_lufs: float
|
||||
energy: float
|
||||
danceability: float
|
||||
valence: float
|
||||
acousticness: float
|
||||
intro_ms: int
|
||||
embedding: Float # (256,), L2-нормирован
|
||||
|
||||
|
||||
def segments(samples: Float, sr: int = SAMPLE_RATE, seconds: float = SEGMENT_S) -> list[Float]:
|
||||
"""Три фрагмента по 30 с — начало (после первых 10%), середина, конец; короткий трек — целиком."""
|
||||
n = int(seconds * sr)
|
||||
if len(samples) <= 3 * n:
|
||||
return [samples]
|
||||
total = len(samples)
|
||||
starts = [int(total * 0.1), total // 2 - n // 2, total - n - int(total * 0.05)]
|
||||
return [samples[s : s + n] for s in starts]
|
||||
|
||||
|
||||
def _stft_mag(x: Float) -> Float:
|
||||
if len(x) < N_FFT:
|
||||
x = np.pad(x, (0, N_FFT - len(x)))
|
||||
frames = 1 + (len(x) - N_FFT) // HOP
|
||||
idx = np.arange(N_FFT)[None, :] + HOP * np.arange(frames)[:, None]
|
||||
window = np.hanning(N_FFT).astype(np.float32)
|
||||
return np.abs(np.fft.rfft(x[idx] * window, axis=1)).astype(np.float32) # (frames, bins)
|
||||
|
||||
|
||||
def _mel_filterbank(sr: int = SAMPLE_RATE) -> Float:
|
||||
def hz_to_mel(f: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
return 2595.0 * np.log10(1.0 + f / 700.0)
|
||||
|
||||
def mel_to_hz(m: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
return 700.0 * (10 ** (m / 2595.0) - 1.0)
|
||||
|
||||
bins = N_FFT // 2 + 1
|
||||
freqs = np.linspace(0, sr / 2, bins)
|
||||
mel_points = mel_to_hz(np.linspace(hz_to_mel(np.array(30.0)), hz_to_mel(np.array(sr / 2)), N_MELS + 2))
|
||||
fb = np.zeros((N_MELS, bins), dtype=np.float32)
|
||||
for m in range(N_MELS):
|
||||
lo, mid, hi = mel_points[m], mel_points[m + 1], mel_points[m + 2]
|
||||
up = (freqs - lo) / (mid - lo)
|
||||
down = (hi - freqs) / (hi - mid)
|
||||
fb[m] = np.maximum(0, np.minimum(up, down))
|
||||
return fb
|
||||
|
||||
|
||||
_MEL = _mel_filterbank()
|
||||
|
||||
# Профили тональностей Крумханзла.
|
||||
_MAJOR = np.array([6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88])
|
||||
_MINOR = np.array([6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17])
|
||||
_NOTES = ["C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"]
|
||||
|
||||
|
||||
def _log_mel(mag: Float) -> Float:
|
||||
"""log-mel в дБ с полом на −60 дБ от максимума: шум в пустых полосах не превращается в «атаки»."""
|
||||
mel = mag**2 @ _MEL.T
|
||||
return (10 * np.log10(mel + 1e-6 * float(mel.max()) + 1e-12)).astype(np.float32)
|
||||
|
||||
|
||||
def estimate_tempo(onset: Float, sr: int = SAMPLE_RATE) -> tuple[float | None, float]:
|
||||
"""Темп по автокорреляции огибающей онсетов, 60–200 BPM с мягким приором к 120.
|
||||
Возвращает (bpm, уверенность 0..1); без выраженного ритма — (None, 0)."""
|
||||
fps = sr / HOP
|
||||
env = onset - onset.mean()
|
||||
# Ровный звук без атак: огибающая — численный шум, «ритма» в нём нет.
|
||||
if len(env) < int(fps * 4) or float(onset.std()) < 0.5:
|
||||
return None, 0.0
|
||||
ac = np.correlate(env, env, mode="full")[len(env) - 1 :]
|
||||
ac /= ac[0] + 1e-9
|
||||
lags = np.arange(len(ac))
|
||||
bpm_of = 60.0 * fps / np.maximum(lags, 1)
|
||||
ok = (bpm_of >= 60) & (bpm_of <= 200)
|
||||
if not np.any(ok):
|
||||
return None, 0.0
|
||||
prior = np.exp(-0.5 * (np.log2(bpm_of / 120.0) / 0.9) ** 2)
|
||||
weighted = np.where(ok, ac * prior, -np.inf)
|
||||
lag = int(np.argmax(weighted))
|
||||
# Уточнение по параболе вокруг пика.
|
||||
if 1 <= lag < len(ac) - 1:
|
||||
a, b, c = ac[lag - 1], ac[lag], ac[lag + 1]
|
||||
denom = a - 2 * b + c
|
||||
shift = 0.5 * (a - c) / denom if denom != 0 else 0.0
|
||||
else:
|
||||
shift = 0.0
|
||||
bpm = 60.0 * fps / (lag + shift)
|
||||
# Ясность пульса: насколько пик выше типичного уровня автокорреляции в диапазоне темпов.
|
||||
base = float(np.median(ac[ok]))
|
||||
confidence = float(np.clip((ac[lag] - base) / (1 - base + 1e-9), 0, 1))
|
||||
if confidence < 0.1:
|
||||
return None, confidence
|
||||
return round(float(bpm), 1), confidence
|
||||
|
||||
|
||||
def _key(mag: Float, sr: int = SAMPLE_RATE) -> tuple[str | None, str | None, float]:
|
||||
"""Тональность по хромаграмме; возвращает (нота, mode, уверенность)."""
|
||||
freqs = np.linspace(0, sr / 2, mag.shape[1])
|
||||
valid = (freqs > 60) & (freqs < 5000)
|
||||
pitch = np.round(12 * np.log2(freqs[valid] / 440.0) + 69).astype(int) % 12
|
||||
energy = mag[:, valid].sum(axis=0)
|
||||
chroma = np.bincount(pitch, weights=energy, minlength=12)
|
||||
if chroma.sum() <= 0:
|
||||
return None, None, 0.0
|
||||
chroma = chroma / chroma.sum()
|
||||
best = (-2.0, 0, "major")
|
||||
for shift in range(12):
|
||||
for name, prof in (("major", _MAJOR), ("minor", _MINOR)):
|
||||
r = float(np.corrcoef(chroma, np.roll(prof, shift))[0, 1])
|
||||
if r > best[0]:
|
||||
best = (r, shift, name)
|
||||
r, shift, scale = best
|
||||
return _NOTES[shift], scale, max(r, 0.0)
|
||||
|
||||
|
||||
def analyze(samples: Float, sr: int = SAMPLE_RATE) -> Features:
|
||||
"""Признаки трека по моно-сигналу sr Гц (float32, −1..1)."""
|
||||
if sr != SAMPLE_RATE:
|
||||
raise ValueError(f"expected {SAMPLE_RATE} Hz")
|
||||
parts = segments(samples, sr)
|
||||
mags = [_stft_mag(p) for p in parts]
|
||||
mag = np.concatenate(mags, axis=0)
|
||||
logmel = _log_mel(mag) # (frames, mels)
|
||||
|
||||
# Огибающая онсетов — положительный прирост log-mel; темп по каждому фрагменту, берём самый уверенный.
|
||||
tempos = []
|
||||
for m in mags:
|
||||
lm = _log_mel(m)
|
||||
onset = np.maximum(np.diff(lm, axis=0), 0).sum(axis=1).astype(np.float32)
|
||||
tempos.append(estimate_tempo(onset, sr))
|
||||
bpm, bpm_conf = max(tempos, key=lambda t: t[1])
|
||||
flux = np.maximum(np.diff(logmel, axis=0), 0).mean() if len(logmel) > 1 else 0.0
|
||||
|
||||
rms = np.sqrt(np.mean(samples.astype(np.float64) ** 2) + 1e-12)
|
||||
loudness = float(-0.691 + 20 * np.log10(rms)) # без K-фильтра: близко к LUFS для музыки
|
||||
|
||||
centroid = float((mag * np.arange(mag.shape[1])).sum() / (mag.sum() + 1e-9) / mag.shape[1])
|
||||
brightness = float(np.clip(centroid * 4, 0, 1))
|
||||
loud_part = np.clip((loudness + 30) / 22, 0, 1)
|
||||
energy = float(np.clip(0.5 * loud_part + 0.3 * np.clip(flux / 3, 0, 1) + 0.2 * brightness, 0, 1))
|
||||
danceability = float(np.clip(bpm_conf * 1.6, 0, 1)) if bpm else 0.0
|
||||
key, scale, key_conf = _key(mag, sr)
|
||||
mode = 1.0 if scale == "major" else -1.0 if scale == "minor" else 0.0
|
||||
valence = float(np.clip(0.45 + 0.2 * mode * key_conf + 0.35 * (brightness - 0.4), 0, 1))
|
||||
# Акустичность: мало высоких частот и плавная динамика.
|
||||
acousticness = float(np.clip(1 - brightness * 1.4 - flux / 6, 0, 1))
|
||||
|
||||
# Вступление: время, пока громкость первого фрагмента не дойдёт до половины медианной.
|
||||
first = np.sqrt((mags[0] ** 2).mean(axis=1))
|
||||
above = np.nonzero(first >= 0.5 * np.median(first))[0]
|
||||
intro_ms = int(above[0] * HOP / sr * 1000) if len(above) else 0
|
||||
|
||||
# Эмбеддинг: форма спектра (среднее без общей громкости), разброс, динамика по полосам, ритмичность.
|
||||
mean = logmel.mean(axis=0)
|
||||
shape = mean - mean.mean()
|
||||
std = logmel.std(axis=0)
|
||||
diff = np.diff(logmel, axis=0) if len(logmel) > 1 else np.zeros((1, N_MELS), np.float32)
|
||||
delta = np.abs(diff).mean(axis=0)
|
||||
band_flux = np.maximum(diff, 0).std(axis=0)
|
||||
emb = np.concatenate([shape / 10, std / 10, delta / 3, band_flux / 3]).astype(np.float32)
|
||||
emb = emb / (np.linalg.norm(emb) + 1e-9)
|
||||
|
||||
return Features(
|
||||
bpm=bpm,
|
||||
bpm_confidence=round(bpm_conf, 3),
|
||||
key=key,
|
||||
scale=scale,
|
||||
loudness_lufs=round(loudness, 2),
|
||||
energy=round(energy, 3),
|
||||
danceability=round(danceability, 3),
|
||||
valence=round(valence, 3),
|
||||
acousticness=round(acousticness, 3),
|
||||
intro_ms=intro_ms,
|
||||
embedding=emb,
|
||||
)
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Воркер аудиоанализа: берёт задачи из analysis_jobs, считает признаки и пишет их в БД."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import time
|
||||
from concurrent.futures import Future, ProcessPoolExecutor
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import psycopg
|
||||
|
||||
from pipu_rec.analysis.features import ANALYZER_VER, SAMPLE_RATE, Features, analyze
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
MAX_ATTEMPTS = 3
|
||||
MAX_DURATION_S = 15 * 60
|
||||
IDLE_SLEEP_S = 10.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Job:
|
||||
track_id: str
|
||||
path: str
|
||||
|
||||
|
||||
def decode(path: str, ffmpeg: str = "ffmpeg") -> np.ndarray:
|
||||
"""Моно float32 22 050 Гц; путь — отдельный аргумент, без shell."""
|
||||
out = subprocess.run(
|
||||
[
|
||||
ffmpeg,
|
||||
"-v",
|
||||
"error",
|
||||
"-t",
|
||||
str(MAX_DURATION_S),
|
||||
"-i",
|
||||
path,
|
||||
"-ac",
|
||||
"1",
|
||||
"-ar",
|
||||
str(SAMPLE_RATE),
|
||||
"-f",
|
||||
"f32le",
|
||||
"-",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=300,
|
||||
)
|
||||
if out.returncode != 0:
|
||||
raise RuntimeError(out.stderr.decode(errors="replace").strip()[:500] or "ffmpeg failed")
|
||||
samples = np.frombuffer(out.stdout, dtype=np.float32)
|
||||
if samples.size < SAMPLE_RATE:
|
||||
raise RuntimeError("audio is shorter than 1 s")
|
||||
return samples
|
||||
|
||||
|
||||
def analyze_file(path: str, ffmpeg: str) -> Features:
|
||||
"""Выполняется в процессе пула."""
|
||||
return analyze(decode(path, ffmpeg))
|
||||
|
||||
|
||||
def vector_literal(v: np.ndarray) -> str:
|
||||
return "[" + ",".join(f"{x:.6f}" for x in v.tolist()) + "]"
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(
|
||||
self, dsn: str, library_root: str, host_root: str, workers: int, ffmpeg: str = "ffmpeg"
|
||||
) -> None:
|
||||
self.dsn = dsn
|
||||
self.library_root = Path(library_root)
|
||||
self.host_root = Path(host_root)
|
||||
self.workers = max(1, workers)
|
||||
self.ffmpeg = ffmpeg
|
||||
|
||||
def resolve(self, source_id: str, source_type: str, local_path: str | None, rel_path: str) -> str:
|
||||
"""Путь к файлу внутри контейнера анализатора — те же точки монтирования, что у api."""
|
||||
if source_type == "local":
|
||||
return str(self.host_root.joinpath(local_path or "", rel_path))
|
||||
return str(self.library_root.joinpath(source_id, rel_path))
|
||||
|
||||
def claim(self, conn: psycopg.Connection[Any], limit: int) -> list[Job]:
|
||||
"""Забирает задачи; SKIP LOCKED — несколько анализаторов не возьмут одну и ту же."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
UPDATE analysis_jobs j SET status = 'running', attempts = attempts + 1, updated_at = now()
|
||||
WHERE j.track_id IN (
|
||||
SELECT a.track_id FROM analysis_jobs a
|
||||
JOIN tracks t ON t.id = a.track_id
|
||||
JOIN media_files f ON f.id = t.primary_file_id
|
||||
JOIN library_sources s ON s.id = f.source_id AND s.status = 'mounted'
|
||||
WHERE a.status = 'queued'
|
||||
ORDER BY a.updated_at
|
||||
LIMIT %s
|
||||
FOR UPDATE OF a SKIP LOCKED)
|
||||
RETURNING j.track_id::text
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
conn.commit()
|
||||
if not rows:
|
||||
return []
|
||||
files = conn.execute(
|
||||
"""
|
||||
SELECT t.id::text, s.id::text, s.type::text, s.local_path, f.rel_path
|
||||
FROM tracks t
|
||||
JOIN media_files f ON f.id = t.primary_file_id
|
||||
JOIN library_sources s ON s.id = f.source_id
|
||||
WHERE t.id = ANY(%s::uuid[])
|
||||
""",
|
||||
([r[0] for r in rows],),
|
||||
).fetchall()
|
||||
return [Job(track_id=r[0], path=self.resolve(r[1], r[2], r[3], r[4])) for r in files]
|
||||
|
||||
def save(self, conn: psycopg.Connection[Any], track_id: str, f: Features) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO track_features (track_id, analyzer_ver, bpm, bpm_confidence, key, scale,
|
||||
loudness_lufs, energy, danceability, valence, acousticness, intro_ms, analyzed_at)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())
|
||||
ON CONFLICT (track_id) DO UPDATE SET analyzer_ver = EXCLUDED.analyzer_ver, bpm = EXCLUDED.bpm,
|
||||
bpm_confidence = EXCLUDED.bpm_confidence, key = EXCLUDED.key, scale = EXCLUDED.scale,
|
||||
loudness_lufs = EXCLUDED.loudness_lufs, energy = EXCLUDED.energy,
|
||||
danceability = EXCLUDED.danceability, valence = EXCLUDED.valence,
|
||||
acousticness = EXCLUDED.acousticness, intro_ms = EXCLUDED.intro_ms, analyzed_at = now()
|
||||
""",
|
||||
(
|
||||
track_id,
|
||||
ANALYZER_VER,
|
||||
f.bpm,
|
||||
f.bpm_confidence,
|
||||
f.key,
|
||||
f.scale,
|
||||
f.loudness_lufs,
|
||||
f.energy,
|
||||
f.danceability,
|
||||
f.valence,
|
||||
f.acousticness,
|
||||
f.intro_ms,
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO track_embeddings (track_id, audio, updated_at) VALUES (%s, %s::vector, now())
|
||||
ON CONFLICT (track_id) DO UPDATE SET audio = EXCLUDED.audio, updated_at = now()
|
||||
""",
|
||||
(track_id, vector_literal(f.embedding)),
|
||||
)
|
||||
conn.execute(
|
||||
"UPDATE analysis_jobs SET status = 'done', error = NULL, updated_at = now() WHERE track_id = %s",
|
||||
(track_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def fail(self, conn: psycopg.Connection[Any], track_id: str, err: str) -> None:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE analysis_jobs SET status = CASE WHEN attempts >= %s THEN 'failed' ELSE 'queued' END,
|
||||
error = %s, updated_at = now()
|
||||
WHERE track_id = %s
|
||||
""",
|
||||
(MAX_ATTEMPTS, err[:500], track_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def requeue_stale(self, conn: psycopg.Connection[Any]) -> None:
|
||||
"""Задачи, брошенные упавшим воркером, и признаки старой версии анализатора — снова в очередь."""
|
||||
conn.execute(
|
||||
"UPDATE analysis_jobs SET status = 'queued' "
|
||||
"WHERE status = 'running' AND updated_at < now() - interval '30 minutes'"
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE analysis_jobs a SET status = 'queued', attempts = 0, updated_at = now()
|
||||
FROM track_features f WHERE f.track_id = a.track_id AND f.analyzer_ver <> %s AND a.status = 'done'
|
||||
""",
|
||||
(ANALYZER_VER,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def run_forever(self) -> None:
|
||||
log.info("analyzer started: workers=%d version=%s", self.workers, ANALYZER_VER)
|
||||
with psycopg.connect(self.dsn) as conn, ProcessPoolExecutor(self.workers) as pool:
|
||||
self.requeue_stale(conn)
|
||||
while True:
|
||||
jobs = self.claim(conn, self.workers * 2)
|
||||
if not jobs:
|
||||
time.sleep(IDLE_SLEEP_S)
|
||||
continue
|
||||
futures: list[tuple[Job, Future[Features]]] = [
|
||||
(j, pool.submit(analyze_file, j.path, self.ffmpeg)) for j in jobs
|
||||
]
|
||||
for job, fut in futures:
|
||||
try:
|
||||
self.save(conn, job.track_id, fut.result(timeout=600))
|
||||
except Exception as exc:
|
||||
log.warning("analysis failed: track=%s err=%s", job.track_id, exc)
|
||||
conn.rollback()
|
||||
self.fail(conn, job.track_id, str(exc))
|
||||
@@ -10,6 +10,8 @@ from fastapi import Depends, FastAPI, Header, HTTPException, status
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from pipu_rec.settings import Settings
|
||||
from pipu_rec.wave.engine import MODEL_VER
|
||||
from pipu_rec.wave.service import BatchRequest, BatchResponse, WaveService
|
||||
|
||||
Check = Callable[[], Awaitable[None]]
|
||||
|
||||
@@ -18,7 +20,9 @@ log = logging.getLogger(__name__)
|
||||
CHECK_TIMEOUT_S = 2.0
|
||||
|
||||
|
||||
def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> FastAPI:
|
||||
def create_app(
|
||||
settings: Settings, checks: dict[str, Check] | None = None, wave: WaveService | None = None
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title="pipu-recommender", version=settings.version, docs_url=None, redoc_url=None)
|
||||
checks = checks or {}
|
||||
|
||||
@@ -54,6 +58,13 @@ def create_app(settings: Settings, checks: dict[str, Check] | None = None) -> Fa
|
||||
|
||||
@app.get("/internal/status", dependencies=[Depends(require_token)])
|
||||
async def internal_status() -> dict[str, object]:
|
||||
return {"version": settings.version, "models": [], "ranker": "heuristic-v1"}
|
||||
return {"version": settings.version, "models": [], "ranker": MODEL_VER}
|
||||
|
||||
@app.post("/internal/wave/batch", dependencies=[Depends(require_token)])
|
||||
async def wave_batch(req: BatchRequest) -> BatchResponse:
|
||||
if wave is None:
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail="wave.unavailable")
|
||||
# Сборка — numpy и синхронные запросы к БД: в пуле потоков, чтобы не блокировать цикл событий.
|
||||
return await asyncio.to_thread(wave.batch, req)
|
||||
|
||||
return app
|
||||
@@ -1,15 +1,17 @@
|
||||
"""Точка входа: `pipu-rec serve` (онлайн-API) или `pipu-rec analyze` (воркер аудиоанализа)."""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
import psycopg
|
||||
import redis.asyncio as aioredis
|
||||
import uvicorn
|
||||
|
||||
from pipu_rec.analysis.worker import Worker
|
||||
from pipu_rec.api.app import create_app
|
||||
from pipu_rec.settings import Settings
|
||||
from pipu_rec.wave.contexts import load_contexts
|
||||
from pipu_rec.wave.service import WaveService
|
||||
|
||||
log = logging.getLogger("pipu_rec")
|
||||
|
||||
@@ -24,19 +26,19 @@ def _serve(settings: Settings) -> None:
|
||||
async def check_redis() -> None:
|
||||
await rdb.ping()
|
||||
|
||||
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis})
|
||||
wave = WaveService(settings.database_url, settings.redis_url, load_contexts(settings.contexts_path))
|
||||
app = create_app(settings, {"postgres": check_postgres, "redis": check_redis}, wave)
|
||||
uvicorn.run(app, host=settings.http_host, port=settings.http_port, log_level=settings.log_level)
|
||||
|
||||
|
||||
def _analyze(settings: Settings, workers: int) -> None:
|
||||
# Аудиоанализ (Essentia) появится в M4; сейчас воркер только держит процесс живым.
|
||||
log.info("analyzer started: workers=%d version=%s (no-op until M4)", workers, settings.version)
|
||||
|
||||
async def idle() -> None:
|
||||
while True:
|
||||
await asyncio.sleep(3600)
|
||||
|
||||
asyncio.run(idle())
|
||||
Worker(
|
||||
settings.database_url,
|
||||
settings.library_root,
|
||||
settings.library_host_root,
|
||||
workers,
|
||||
settings.ffmpeg_path,
|
||||
).run_forever()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
|
||||
@@ -13,4 +13,8 @@ class Settings(BaseSettings):
|
||||
redis_url: str = Field(default="redis://redis:6379/0")
|
||||
internal_token: str = Field(default="", description="Общий секрет для вызовов из api")
|
||||
log_level: str = "info"
|
||||
library_root: str = "/library"
|
||||
library_host_root: str = "/library-host"
|
||||
ffmpeg_path: str = "ffmpeg"
|
||||
contexts_path: str = Field(default="", description="contexts.yaml; пусто — встроенный")
|
||||
version: str = Field(default="0.0.0-dev", alias="PIPU_VERSION")
|
||||
Whitespace-only changes.
@@ -0,0 +1,73 @@
|
||||
"""Профили занятий и настроений: соответствие трека контексту (context_fit)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib import resources
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import yaml
|
||||
from numpy.typing import NDArray
|
||||
from pydantic import BaseModel, model_validator
|
||||
|
||||
FEATURES = ("bpm", "energy", "danceability", "valence", "acousticness")
|
||||
|
||||
|
||||
class Rule(BaseModel):
|
||||
feature: str
|
||||
min: float | None = None
|
||||
max: float | None = None
|
||||
sigma: float
|
||||
half_time: bool = False
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check(self) -> Rule:
|
||||
if self.feature not in FEATURES:
|
||||
raise ValueError(f"unknown feature {self.feature!r}; known: {', '.join(FEATURES)}")
|
||||
if self.min is None and self.max is None:
|
||||
raise ValueError("rule needs min or max")
|
||||
if self.sigma <= 0:
|
||||
raise ValueError("sigma must be positive")
|
||||
return self
|
||||
|
||||
|
||||
class Context(BaseModel):
|
||||
icon: str
|
||||
rules: list[Rule]
|
||||
|
||||
|
||||
class Contexts(BaseModel):
|
||||
activities: dict[str, Context]
|
||||
moods: dict[str, Context]
|
||||
|
||||
|
||||
def load_contexts(path: str = "") -> Contexts:
|
||||
"""contexts.yaml из пути или встроенный; ошибка в файле — ошибка запуска (Pydantic)."""
|
||||
raw = (
|
||||
Path(path).read_text("utf-8")
|
||||
if path
|
||||
else resources.files(__package__).joinpath("contexts.yaml").read_text("utf-8")
|
||||
)
|
||||
return Contexts.model_validate(yaml.safe_load(raw))
|
||||
|
||||
|
||||
def _bell(x: NDArray[np.float64], rule: Rule) -> NDArray[np.float64]:
|
||||
lo = -np.inf if rule.min is None else rule.min
|
||||
hi = np.inf if rule.max is None else rule.max
|
||||
dist = np.where(x < lo, lo - x, np.where(x > hi, x - hi, 0.0))
|
||||
return np.asarray(np.exp(-0.5 * (dist / rule.sigma) ** 2))
|
||||
|
||||
|
||||
def fit(features: dict[str, NDArray[np.float64]], rules: list[Rule]) -> NDArray[np.float64]:
|
||||
"""Произведение «колоколов» по правилам; NaN (признака нет) — нейтрально (1).
|
||||
half_time: для темпа засчитывается и половинный (бег под 85 BPM — тот же шаг, что 170)."""
|
||||
n = len(next(iter(features.values())))
|
||||
out = np.ones(n)
|
||||
for rule in rules:
|
||||
x = features[rule.feature]
|
||||
known = ~np.isnan(x)
|
||||
b = _bell(np.nan_to_num(x), rule)
|
||||
if rule.half_time:
|
||||
b = np.maximum(b, _bell(np.nan_to_num(x) * 2, rule))
|
||||
out *= np.where(known, b, 1.0)
|
||||
return out
|
||||
@@ -0,0 +1,56 @@
|
||||
# Занятия и настроения «Моей волны» (docs/recommendations.md#64-занятия-и-настроения).
|
||||
# Каждое правило — «колокол» по признаку трека: внутри [min, max] — 1, снаружи спадает по Гауссу с sigma.
|
||||
# Признак, которого у трека нет (ещё не проанализирован), не штрафует.
|
||||
# Изменения применяются после перезапуска recommender, пересборка образа не нужна (CONTEXTS_PATH).
|
||||
activities:
|
||||
running:
|
||||
icon: directions_run
|
||||
rules:
|
||||
- {feature: bpm, min: 150, max: 185, sigma: 12, half_time: true}
|
||||
- {feature: energy, min: 0.7, sigma: 0.12}
|
||||
- {feature: danceability, min: 0.5, sigma: 0.2}
|
||||
workout:
|
||||
icon: fitness_center
|
||||
rules:
|
||||
- {feature: energy, min: 0.75, sigma: 0.1}
|
||||
- {feature: bpm, min: 110, max: 180, sigma: 20, half_time: true}
|
||||
waking_up:
|
||||
icon: wb_sunny
|
||||
rules:
|
||||
- {feature: energy, min: 0.3, max: 0.7, sigma: 0.12}
|
||||
- {feature: valence, min: 0.5, sigma: 0.15}
|
||||
focus:
|
||||
icon: headphones
|
||||
rules:
|
||||
- {feature: energy, min: 0.3, max: 0.6, sigma: 0.12}
|
||||
- {feature: acousticness, min: 0.3, sigma: 0.25}
|
||||
commute:
|
||||
icon: directions_car
|
||||
rules:
|
||||
- {feature: energy, min: 0.4, max: 0.8, sigma: 0.15}
|
||||
chill:
|
||||
icon: weekend
|
||||
rules:
|
||||
- {feature: energy, max: 0.5, sigma: 0.12}
|
||||
sleep:
|
||||
icon: bedtime
|
||||
rules:
|
||||
- {feature: energy, max: 0.35, sigma: 0.1}
|
||||
- {feature: bpm, max: 95, sigma: 12}
|
||||
moods:
|
||||
energetic:
|
||||
icon: bolt
|
||||
rules:
|
||||
- {feature: energy, min: 0.7, sigma: 0.12}
|
||||
happy:
|
||||
icon: sentiment_satisfied
|
||||
rules:
|
||||
- {feature: valence, min: 0.6, sigma: 0.12}
|
||||
calm:
|
||||
icon: spa
|
||||
rules:
|
||||
- {feature: energy, max: 0.45, sigma: 0.12}
|
||||
sad:
|
||||
icon: rainy
|
||||
rules:
|
||||
- {feature: valence, max: 0.4, sigma: 0.12}
|
||||
@@ -0,0 +1,306 @@
|
||||
"""Сборка пачки «Моей волны» (v1: эвристика). Чистые функции над массивами — без БД и сети."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from pipu_rec.wave.contexts import FEATURES, Rule, fit
|
||||
|
||||
MODEL_VER = "heuristic-v1"
|
||||
|
||||
Vec = NDArray[np.float32]
|
||||
|
||||
|
||||
@dataclass
|
||||
class CatalogIndex:
|
||||
"""Каталог в памяти: строка i — трек ids[i]."""
|
||||
|
||||
ids: list[str]
|
||||
artist: list[str] # основной исполнитель
|
||||
emb: Vec # (n, d), стандартизирован и нормирован; нулевой — нет анализа
|
||||
has_emb: NDArray[np.bool_]
|
||||
features: dict[str, NDArray[np.float64]] # NaN — нет признака
|
||||
popularity: NDArray[np.float64] # 0..1
|
||||
pos: dict[str, int] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self.pos = {t: i for i, t in enumerate(self.ids)}
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self.ids)
|
||||
|
||||
@staticmethod
|
||||
def build(
|
||||
ids: list[str],
|
||||
artist: list[str],
|
||||
raw_emb: list[Vec | None],
|
||||
features: dict[str, list[float | None]],
|
||||
plays: list[int],
|
||||
dim: int = 256,
|
||||
) -> CatalogIndex:
|
||||
n = len(ids)
|
||||
emb = np.zeros((n, dim), dtype=np.float32)
|
||||
has = np.zeros(n, dtype=bool)
|
||||
for i, e in enumerate(raw_emb):
|
||||
if e is not None:
|
||||
emb[i], has[i] = e, True
|
||||
if has.sum() >= 2:
|
||||
# Стандартизация по библиотеке: косинус сравнивает отличия треков, а не общий «звук библиотеки».
|
||||
mu = emb[has].mean(axis=0)
|
||||
sd = emb[has].std(axis=0) + 1e-6
|
||||
emb[has] = (emb[has] - mu) / sd
|
||||
emb[has] /= np.linalg.norm(emb[has], axis=1, keepdims=True) + 1e-9
|
||||
feats = {
|
||||
k: np.array([np.nan if v is None else v for v in features.get(k, [None] * n)], dtype=np.float64)
|
||||
for k in FEATURES
|
||||
}
|
||||
p = np.log1p(np.array(plays, dtype=np.float64))
|
||||
pop = p / p.max() if n and p.max() > 0 else np.zeros(n)
|
||||
return CatalogIndex(ids=ids, artist=artist, emb=emb, has_emb=has, features=feats, popularity=pop)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Profile:
|
||||
"""Вкус пользователя: оценки треков и производные."""
|
||||
|
||||
scores: dict[str, float] # implicit_score > 0 — нравится
|
||||
disliked: set[str]
|
||||
disliked_artists: set[str]
|
||||
recent: set[str] # сыграны за последние 3 часа
|
||||
played: set[str] # слышал когда-либо
|
||||
liked_recent: set[str] # лайкнутые, игравшие за 24 ч — штраф
|
||||
rediscover: set[str] # любимые, не игравшие > 60 дней
|
||||
exploration: float = 0.15
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""Состояние сессии волны (Redis)."""
|
||||
|
||||
vec: list[float] | None = None
|
||||
served: list[str] = field(default_factory=list)
|
||||
recent_artists: list[str] = field(default_factory=list)
|
||||
skips_in_row: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Pick:
|
||||
id: str
|
||||
source: str
|
||||
score: float
|
||||
|
||||
|
||||
# Вклад реакции в сессионный вектор: s ← normalize(s + η·r·e).
|
||||
REACTION_WEIGHT = {"complete": 1.0, "like": 2.0, "skip_early": -1.0, "dislike": -2.0, "skip": -0.5}
|
||||
ETA = 0.3
|
||||
|
||||
|
||||
def taste_centroids(index: CatalogIndex, profile: Profile, max_k: int = 6, seed: int = 0) -> Vec:
|
||||
"""Центроиды вкуса: k-means по эмбеддингам треков с положительной оценкой (веса = оценка).
|
||||
k растёт с историей: 1 центроид на ~8 любимых треков, не больше max_k."""
|
||||
rows = [(index.pos[t], s) for t, s in profile.scores.items() if s > 0 and t in index.pos]
|
||||
rows = [(i, s) for i, s in rows if index.has_emb[i]]
|
||||
if not rows:
|
||||
return np.zeros((0, index.emb.shape[1]), dtype=np.float32)
|
||||
x = index.emb[[i for i, _ in rows]]
|
||||
w = np.array([s for _, s in rows])
|
||||
k = int(min(max_k, max(1, len(rows) // 8)))
|
||||
rng = np.random.default_rng(seed)
|
||||
centers = x[rng.choice(len(x), size=k, replace=False)]
|
||||
for _ in range(10):
|
||||
assign = np.argmax(x @ centers.T, axis=1)
|
||||
for c in range(k):
|
||||
m = assign == c
|
||||
if m.any():
|
||||
v = (x[m] * w[m, None]).sum(axis=0)
|
||||
centers[c] = v / (np.linalg.norm(v) + 1e-9)
|
||||
return centers.astype(np.float32)
|
||||
|
||||
|
||||
def update_session(
|
||||
index: CatalogIndex, session: Session, feedback: list[tuple[str, str]], centroids: Vec
|
||||
) -> None:
|
||||
"""Обновляет сессионный вектор и счётчик скипов по реакциям на выданные треки.
|
||||
Три скипа подряд — «смена курса»: вектор переводится к наименее похожему центроиду вкуса."""
|
||||
d = index.emb.shape[1]
|
||||
vec = np.array(session.vec, dtype=np.float32) if session.vec else np.zeros(d, dtype=np.float32)
|
||||
for track_id, outcome in feedback:
|
||||
i = index.pos.get(track_id)
|
||||
r = REACTION_WEIGHT.get(outcome)
|
||||
if r is None:
|
||||
continue
|
||||
session.skips_in_row = session.skips_in_row + 1 if outcome in ("skip", "skip_early", "dislike") else 0
|
||||
if i is None or not index.has_emb[i]:
|
||||
continue
|
||||
vec = vec + ETA * r * index.emb[i]
|
||||
norm = float(np.linalg.norm(vec))
|
||||
vec = vec / norm if norm > 0 else vec
|
||||
if session.skips_in_row >= 3 and len(centroids):
|
||||
sims = centroids @ vec if np.any(vec) else np.zeros(len(centroids))
|
||||
vec = centroids[int(np.argmin(sims))].copy()
|
||||
session.skips_in_row = 0
|
||||
session.vec = vec.tolist() if np.any(vec) else None
|
||||
|
||||
|
||||
def _top(scores: NDArray[np.float64], mask: NDArray[np.bool_], k: int) -> NDArray[np.intp]:
|
||||
idx = np.nonzero(mask)[0]
|
||||
if len(idx) <= k:
|
||||
return idx
|
||||
part = idx[np.argpartition(-scores[idx], k)[:k]]
|
||||
return part
|
||||
|
||||
|
||||
def build_batch(
|
||||
index: CatalogIndex,
|
||||
profile: Profile,
|
||||
session: Session,
|
||||
rules: list[Rule],
|
||||
n: int = 5,
|
||||
exclude: set[str] | None = None,
|
||||
seed_ids: list[str] | None = None,
|
||||
rng: np.random.Generator | None = None,
|
||||
) -> list[Pick]:
|
||||
"""Кандидаты → фильтры → оценка → соответствие контексту → MMR с правилами разнообразия."""
|
||||
rng = rng or np.random.default_rng()
|
||||
size = len(index)
|
||||
if size == 0:
|
||||
return []
|
||||
centroids = taste_centroids(index, profile)
|
||||
svec = np.array(session.vec, dtype=np.float32) if session.vec else None
|
||||
|
||||
# 2. Фильтры.
|
||||
excluded = profile.disliked | profile.recent | set(session.served) | (exclude or set())
|
||||
allowed = np.array(
|
||||
[
|
||||
t not in excluded and a not in profile.disliked_artists
|
||||
for t, a in zip(index.ids, index.artist, strict=True)
|
||||
]
|
||||
)
|
||||
if not allowed.any():
|
||||
allowed = np.array([t not in profile.disliked for t in index.ids])
|
||||
if not allowed.any():
|
||||
return []
|
||||
|
||||
# Сходства со вкусом и сессией.
|
||||
sim_taste = (index.emb @ centroids.T).max(axis=1) if len(centroids) else np.zeros(size)
|
||||
sim_session = index.emb @ svec if svec is not None else np.zeros(size)
|
||||
sim_taste = np.where(index.has_emb, sim_taste, 0.0)
|
||||
sim_session = np.where(index.has_emb, sim_session, 0.0)
|
||||
|
||||
# Привязанность к исполнителю — сглаженное среднее оценок его треков.
|
||||
totals: dict[str, list[float]] = {}
|
||||
for t, s in profile.scores.items():
|
||||
i = index.pos.get(t)
|
||||
if i is not None:
|
||||
totals.setdefault(index.artist[i], []).append(s)
|
||||
affinity = {a: sum(v) / (len(v) + 2) for a, v in totals.items()}
|
||||
artist_aff = np.array([np.tanh(affinity.get(a, 0.0) / 3) for a in index.artist])
|
||||
|
||||
# 1. Источники кандидатов.
|
||||
sources: dict[int, str] = {}
|
||||
|
||||
def add(idxs: NDArray[np.intp] | list[int], source: str) -> None:
|
||||
for i in idxs:
|
||||
sources.setdefault(int(i), source)
|
||||
|
||||
if seed_ids:
|
||||
seeds = [index.pos[s] for s in seed_ids if s in index.pos and index.has_emb[index.pos[s]]]
|
||||
if seeds:
|
||||
q = index.emb[seeds].mean(axis=0)
|
||||
add(_top((index.emb @ q).astype(np.float64), allowed & index.has_emb, 100), "seed")
|
||||
query = np.maximum(sim_session, sim_taste) if svec is not None or len(centroids) else None
|
||||
if query is not None:
|
||||
# «Новое» помечаем раньше audio_sim: неслышанный трек рядом со вкусом считается исследованием.
|
||||
never = np.array([t not in profile.played for t in index.ids])
|
||||
add(_top(sim_taste + 0.3 * sim_session, allowed & index.has_emb & never, 50), "fresh")
|
||||
add(_top(query, allowed & index.has_emb, 150), "audio_sim")
|
||||
liked_artists = {a for a, v in affinity.items() if v > 0.3}
|
||||
if liked_artists:
|
||||
by_artist = [int(i) for i in np.nonzero(allowed)[0] if index.artist[i] in liked_artists]
|
||||
add(rng.permutation(by_artist)[:60].tolist() if by_artist else [], "artist")
|
||||
redis_ = [index.pos[t] for t in profile.rediscover if t in index.pos and allowed[index.pos[t]]]
|
||||
add(rng.permutation(redis_)[:30].tolist() if redis_ else [], "rediscover")
|
||||
add(_top(index.popularity + rng.random(size) * 0.3, allowed, 30), "popular")
|
||||
# Холодный старт и исследование: случайные треки из разных частей библиотеки.
|
||||
add(rng.permutation(np.nonzero(allowed)[0])[:40].tolist(), "explore")
|
||||
|
||||
cand = np.array(sorted(sources))
|
||||
# 3. Оценка v1.
|
||||
score = (
|
||||
0.35 * sim_session[cand]
|
||||
+ 0.25 * sim_taste[cand]
|
||||
+ 0.15 * artist_aff[cand]
|
||||
+ 0.05 * index.popularity[cand]
|
||||
+ 0.02 * rng.random(len(cand)) # разбивает ничьи, особенно при холодном старте
|
||||
)
|
||||
liked_recent = np.array([index.ids[i] in profile.liked_recent for i in cand])
|
||||
score = score - 0.2 * liked_recent
|
||||
|
||||
# 4. Соответствие контексту; если отсеялось слишком много — порог ослабляется.
|
||||
if rules:
|
||||
f = fit({k: v[cand] for k, v in index.features.items()}, rules)
|
||||
# Нужен запас для разнообразия, но не больше 50 и не больше четверти пула.
|
||||
enough = min(50, max(3 * n, len(cand) // 4))
|
||||
for threshold in (0.35, 0.15, 0.0):
|
||||
keep = f >= threshold
|
||||
if keep.sum() >= enough or threshold == 0.0:
|
||||
break
|
||||
cand, score, f = cand[keep], score[keep], f[keep]
|
||||
score = score + 0.5 * f # контекст важнее вкуса, когда выбран явно
|
||||
if len(cand) == 0:
|
||||
return []
|
||||
|
||||
# 5. MMR + правила: 1 трек исполнителя на пачку, не больше 2 на последние 10; доля «нового».
|
||||
picks: list[Pick] = []
|
||||
chosen: list[int] = []
|
||||
recent_artists = session.recent_artists[-10:]
|
||||
want_fresh = max(0, round(n * profile.exploration))
|
||||
order = list(range(len(cand)))
|
||||
lam = 0.75
|
||||
while len(picks) < n and order:
|
||||
best_j, best_v = -1, -np.inf
|
||||
for j in order:
|
||||
i = int(cand[j])
|
||||
a = index.artist[i]
|
||||
if any(index.artist[c] == a for c in chosen) or recent_artists.count(a) >= 2:
|
||||
continue
|
||||
redundancy = max((float(index.emb[i] @ index.emb[c]) for c in chosen), default=0.0)
|
||||
v = lam * float(score[j]) - (1 - lam) * redundancy
|
||||
if v > best_v:
|
||||
best_j, best_v = j, v
|
||||
if best_j < 0:
|
||||
# Правила разнообразия не дают набрать пачку — ослабляем их, но не повторяем треки.
|
||||
if recent_artists:
|
||||
recent_artists = []
|
||||
continue
|
||||
if chosen:
|
||||
chosen_artists = {index.artist[c] for c in chosen}
|
||||
rest = [j for j in order if index.artist[int(cand[j])] in chosen_artists]
|
||||
if not rest:
|
||||
break
|
||||
best_j = max(rest, key=lambda j: score[j])
|
||||
else:
|
||||
break
|
||||
i = int(cand[best_j])
|
||||
chosen.append(i)
|
||||
order.remove(best_j)
|
||||
picks.append(Pick(id=index.ids[i], source=sources[i], score=round(float(score[best_j]), 4)))
|
||||
|
||||
# Исследование: «новых» (неслышанных) меньше доли exploration — заменяем ими слабейшие выборы.
|
||||
fresh_left = sorted(
|
||||
(j for j in order if sources[int(cand[j])] == "fresh"), key=lambda j: score[j], reverse=True
|
||||
)
|
||||
while sum(p.source == "fresh" for p in picks) < want_fresh and fresh_left:
|
||||
j = fresh_left.pop(0)
|
||||
i = int(cand[j])
|
||||
others = [k for k, p in enumerate(picks) if p.source != "fresh"]
|
||||
if not others:
|
||||
break
|
||||
weakest = min(others, key=lambda k: picks[k].score)
|
||||
if any(index.artist[index.pos[p.id]] == index.artist[i] for k, p in enumerate(picks) if k != weakest):
|
||||
continue
|
||||
picks[weakest] = Pick(id=index.ids[i], source="fresh", score=round(float(score[j]), 4))
|
||||
return picks
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Сервис «Моей волны»: собирает пачку из каталога, профиля, сессии и контекста."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Literal
|
||||
|
||||
import numpy as np
|
||||
import psycopg
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from pipu_rec.wave.contexts import Contexts, Rule
|
||||
from pipu_rec.wave.engine import MODEL_VER, Pick, build_batch, taste_centroids, update_session
|
||||
from pipu_rec.wave.store import IndexHolder, SessionStore, load_profile, seed_tracks
|
||||
|
||||
MAX_SERVED_MEMORY = 300
|
||||
|
||||
Outcome = Literal["complete", "skip", "skip_early", "like", "dislike"]
|
||||
|
||||
|
||||
class Feedback(BaseModel):
|
||||
track_id: str
|
||||
outcome: Outcome
|
||||
|
||||
|
||||
class Seed(BaseModel):
|
||||
type: Literal["track", "album", "artist"]
|
||||
id: str
|
||||
|
||||
|
||||
class BatchRequest(BaseModel):
|
||||
user_id: str
|
||||
session_id: str
|
||||
activity: str | None = None
|
||||
mood: str | None = None
|
||||
seed: Seed | None = None
|
||||
exclude_ids: list[str] = Field(default_factory=list, max_length=1000)
|
||||
feedback: list[Feedback] = Field(default_factory=list, max_length=200)
|
||||
n: int = Field(default=5, ge=1, le=20)
|
||||
|
||||
|
||||
class BatchTrack(BaseModel):
|
||||
id: str
|
||||
source: str
|
||||
score: float
|
||||
|
||||
|
||||
class BatchResponse(BaseModel):
|
||||
tracks: list[BatchTrack]
|
||||
model_ver: str
|
||||
|
||||
|
||||
class WaveService:
|
||||
def __init__(self, dsn: str, redis_url: str, contexts: Contexts) -> None:
|
||||
self.dsn = dsn
|
||||
self.index = IndexHolder(dsn)
|
||||
self.sessions = SessionStore(redis_url)
|
||||
self.contexts = contexts
|
||||
|
||||
def rules(self, activity: str | None, mood: str | None) -> list[Rule]:
|
||||
"""Занятие и настроение комбинируются — пересечение профилей; неизвестный ключ игнорируется."""
|
||||
rules: list[Rule] = []
|
||||
if activity and activity in self.contexts.activities:
|
||||
rules += self.contexts.activities[activity].rules
|
||||
if mood and mood in self.contexts.moods:
|
||||
rules += self.contexts.moods[mood].rules
|
||||
return rules
|
||||
|
||||
def batch(self, req: BatchRequest) -> BatchResponse:
|
||||
index = self.index.get()
|
||||
with psycopg.connect(self.dsn) as conn:
|
||||
profile = load_profile(conn, req.user_id)
|
||||
seeds = seed_tracks(conn, req.seed.type, req.seed.id) if req.seed else None
|
||||
session = self.sessions.get(req.session_id)
|
||||
update_session(
|
||||
index, session, [(f.track_id, f.outcome) for f in req.feedback], taste_centroids(index, profile)
|
||||
)
|
||||
picks: list[Pick] = build_batch(
|
||||
index,
|
||||
profile,
|
||||
session,
|
||||
self.rules(req.activity, req.mood),
|
||||
n=req.n,
|
||||
exclude=set(req.exclude_ids),
|
||||
seed_ids=seeds,
|
||||
rng=np.random.default_rng(),
|
||||
)
|
||||
session.served = (session.served + [p.id for p in picks])[-MAX_SERVED_MEMORY:]
|
||||
artists = [index.artist[index.pos[p.id]] for p in picks]
|
||||
session.recent_artists = (session.recent_artists + artists)[-10:]
|
||||
self.sessions.put(req.session_id, session)
|
||||
return BatchResponse(
|
||||
tracks=[BatchTrack(id=p.id, source=p.source, score=p.score) for p in picks], model_ver=MODEL_VER
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
"""Загрузка каталога, профиля и сессии волны из PostgreSQL / Redis."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import asdict
|
||||
from typing import Any, cast
|
||||
|
||||
import numpy as np
|
||||
import psycopg
|
||||
import redis
|
||||
|
||||
from pipu_rec.wave.engine import CatalogIndex, Profile, Session
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
INDEX_TTL_S = 300
|
||||
SESSION_TTL_S = 12 * 3600
|
||||
HALF_LIFE_DAYS = 90.0
|
||||
|
||||
|
||||
def parse_vector(text: str | None) -> np.ndarray | None:
|
||||
"""pgvector в текстовом виде «[0.1,0.2,...]»."""
|
||||
if not text:
|
||||
return None
|
||||
return np.array(text.strip("[]").split(","), dtype=np.float32)
|
||||
|
||||
|
||||
def load_index(conn: psycopg.Connection[Any]) -> CatalogIndex:
|
||||
"""Доступные треки: основной файл в подключённом хранилище и на месте."""
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT t.id::text,
|
||||
COALESCE((SELECT ta.artist_id::text FROM track_artists ta
|
||||
WHERE ta.track_id = t.id AND ta.role = 'main' ORDER BY ta.position LIMIT 1),
|
||||
al.album_artist_id::text),
|
||||
e.audio::text, f.bpm, f.energy, f.danceability, f.valence, f.acousticness,
|
||||
COALESCE((SELECT sum(play_count) FROM user_track_stats s WHERE s.track_id = t.id), 0)
|
||||
FROM tracks t
|
||||
JOIN albums al ON al.id = t.album_id
|
||||
JOIN media_files mf ON mf.id = t.primary_file_id AND mf.missing_since IS NULL
|
||||
JOIN library_sources src ON src.id = mf.source_id AND src.status = 'mounted'
|
||||
LEFT JOIN track_embeddings e ON e.track_id = t.id
|
||||
LEFT JOIN track_features f ON f.track_id = t.id
|
||||
"""
|
||||
).fetchall()
|
||||
feats: dict[str, list[float | None]] = {
|
||||
k: [] for k in ("bpm", "energy", "danceability", "valence", "acousticness")
|
||||
}
|
||||
for r in rows:
|
||||
for k, v in zip(feats, r[3:8], strict=True):
|
||||
feats[k].append(None if v is None else float(v))
|
||||
return CatalogIndex.build(
|
||||
ids=[r[0] for r in rows],
|
||||
artist=[r[1] for r in rows],
|
||||
raw_emb=[parse_vector(r[2]) for r in rows],
|
||||
features=feats,
|
||||
plays=[int(r[8]) for r in rows],
|
||||
)
|
||||
|
||||
|
||||
class IndexHolder:
|
||||
"""Каталог в памяти; перечитывается раз в INDEX_TTL_S в фоне, не блокируя чтение."""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
self.dsn = dsn
|
||||
self._index: CatalogIndex | None = None
|
||||
self._loaded = 0.0
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def get(self) -> CatalogIndex:
|
||||
if self._index is None:
|
||||
self.refresh()
|
||||
elif time.monotonic() - self._loaded > INDEX_TTL_S and self._lock.acquire(blocking=False):
|
||||
threading.Thread(target=self._refresh_locked, daemon=True).start()
|
||||
assert self._index is not None
|
||||
return self._index
|
||||
|
||||
def refresh(self) -> None:
|
||||
with self._lock:
|
||||
self._load()
|
||||
|
||||
def _refresh_locked(self) -> None:
|
||||
try:
|
||||
self._load()
|
||||
finally:
|
||||
self._lock.release()
|
||||
|
||||
def _load(self) -> None:
|
||||
started = time.monotonic()
|
||||
with psycopg.connect(self.dsn) as conn:
|
||||
self._index = load_index(conn)
|
||||
self._loaded = time.monotonic()
|
||||
log.info(
|
||||
"catalog index loaded: tracks=%d analysed=%d in %.0f ms",
|
||||
len(self._index),
|
||||
int(self._index.has_emb.sum()),
|
||||
(self._loaded - started) * 1000,
|
||||
)
|
||||
|
||||
|
||||
def load_profile(conn: psycopg.Connection[Any], user_id: str) -> Profile:
|
||||
"""implicit_score из агрегатов с затуханием по давности; лайк +3, дизлайк — исключение."""
|
||||
now = time.time()
|
||||
scores: dict[str, float] = {}
|
||||
played: set[str] = set()
|
||||
last: dict[str, float] = {}
|
||||
for tid, plays, complete, skips, early, last_at in conn.execute(
|
||||
"""
|
||||
SELECT track_id::text, play_count, complete_count, skip_count, early_skip_count, last_played_at
|
||||
FROM user_track_stats WHERE user_id = %s
|
||||
""",
|
||||
(user_id,),
|
||||
):
|
||||
played.add(tid)
|
||||
raw = complete * 1.0 + max(plays - complete, 0) * 0.7 - early * 1.0 - max(skips - early, 0) * 0.2
|
||||
ts = last_at.timestamp() if last_at else now
|
||||
last[tid] = ts
|
||||
scores[tid] = raw * math.exp(-math.log(2) * (now - ts) / 86400 / HALF_LIFE_DAYS)
|
||||
disliked: set[str] = set()
|
||||
liked: set[str] = set()
|
||||
for tid, reaction in conn.execute(
|
||||
"SELECT track_id::text, reaction::text FROM track_reactions WHERE user_id = %s", (user_id,)
|
||||
):
|
||||
if reaction == "like":
|
||||
liked.add(tid)
|
||||
scores[tid] = scores.get(tid, 0.0) + 3.0
|
||||
else:
|
||||
disliked.add(tid)
|
||||
scores.pop(tid, None)
|
||||
disliked_artists = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT artist_id::text FROM artist_reactions WHERE user_id = %s AND reaction = 'dislike'",
|
||||
(user_id,),
|
||||
)
|
||||
}
|
||||
recent = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT track_id::text FROM play_events
|
||||
WHERE user_id = %s AND type = 'play_start' AND occurred_at > now() - interval '3 hours'
|
||||
""",
|
||||
(user_id,),
|
||||
)
|
||||
}
|
||||
row = conn.execute("SELECT exploration FROM user_profiles_rec WHERE user_id = %s", (user_id,)).fetchone()
|
||||
exploration = float(row[0]) if row else 0.15
|
||||
if len(played) < 30:
|
||||
exploration = max(exploration, 0.4) # холодный старт: больше исследования на первых треках
|
||||
day = 86400
|
||||
return Profile(
|
||||
scores=scores,
|
||||
disliked=disliked,
|
||||
disliked_artists=disliked_artists,
|
||||
recent=recent,
|
||||
played=played,
|
||||
liked_recent={t for t in liked if now - last.get(t, 0) < day},
|
||||
rediscover={t for t, s in scores.items() if s > 1.5 and now - last.get(t, now) > 60 * day},
|
||||
exploration=exploration,
|
||||
)
|
||||
|
||||
|
||||
def seed_tracks(conn: psycopg.Connection[Any], seed_type: str, seed_id: str) -> list[str]:
|
||||
"""Треки, от которых запущена волна: трек, альбом или исполнитель."""
|
||||
queries = {
|
||||
"track": "SELECT %s::uuid::text",
|
||||
"album": "SELECT id::text FROM tracks WHERE album_id = %s",
|
||||
"artist": "SELECT track_id::text FROM track_artists WHERE artist_id = %s",
|
||||
}
|
||||
q = queries.get(seed_type)
|
||||
if not q:
|
||||
return []
|
||||
return [r[0] for r in conn.execute(q, (seed_id,))]
|
||||
|
||||
|
||||
class SessionStore:
|
||||
def __init__(self, url: str) -> None:
|
||||
self.rdb = redis.Redis.from_url(url, socket_connect_timeout=2, socket_timeout=2)
|
||||
|
||||
def get(self, session_id: str) -> Session:
|
||||
raw = cast(bytes | None, self.rdb.get(f"wave:session:{session_id}"))
|
||||
if not raw:
|
||||
return Session()
|
||||
data = json.loads(raw)
|
||||
return Session(**data)
|
||||
|
||||
def put(self, session_id: str, s: Session) -> None:
|
||||
self.rdb.set(f"wave:session:{session_id}", json.dumps(asdict(s)), ex=SESSION_TTL_S)
|
||||
@@ -9,6 +9,8 @@ dependencies = [
|
||||
"pydantic-settings>=2.9,<3",
|
||||
"psycopg[binary]>=3.2,<4",
|
||||
"redis>=6.2,<7",
|
||||
"numpy>=2.2,<3",
|
||||
"pyyaml>=6.0,<7",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -20,6 +22,7 @@ dev = [
|
||||
"httpx>=0.28,<1",
|
||||
"ruff>=0.12,<1",
|
||||
"mypy>=1.16,<2",
|
||||
"types-pyyaml>=6.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
@@ -35,6 +38,8 @@ target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF"]
|
||||
# Комментарии и докстринги на русском: «с», «у», «−» — не опечатки в латинице.
|
||||
ignore = ["RUF001", "RUF002", "RUF003"]
|
||||
|
||||
[tool.mypy]
|
||||
strict = true
|
||||
|
||||
@@ -57,3 +57,10 @@ def test_internal_status_requires_token() -> None:
|
||||
r = c.get("/internal/status", headers={"Authorization": "Bearer secret"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["version"] == "1.2.3"
|
||||
|
||||
|
||||
def test_wave_batch_requires_token_and_service() -> None:
|
||||
body = {"user_id": "u", "session_id": "s"}
|
||||
assert client().post("/internal/wave/batch", json=body).status_code == 401
|
||||
r = client().post("/internal/wave/batch", json=body, headers={"Authorization": "Bearer secret"})
|
||||
assert r.status_code == 503
|
||||
@@ -0,0 +1,142 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from pipu_rec.wave.contexts import load_contexts
|
||||
from pipu_rec.wave.engine import CatalogIndex, Profile, Session, build_batch, taste_centroids, update_session
|
||||
|
||||
|
||||
def library(n_per_cluster: int = 20, seed: int = 0) -> CatalogIndex:
|
||||
"""Три «жанра» — кластера эмбеддингов; в кластере 5 исполнителей по 4 трека."""
|
||||
rng = np.random.default_rng(seed)
|
||||
centers = rng.normal(size=(3, 32))
|
||||
ids, artists, embs = [], [], []
|
||||
feats: dict[str, list[float | None]] = {
|
||||
"bpm": [],
|
||||
"energy": [],
|
||||
"danceability": [],
|
||||
"valence": [],
|
||||
"acousticness": [],
|
||||
}
|
||||
for c in range(3):
|
||||
for k in range(n_per_cluster):
|
||||
ids.append(f"t{c}-{k}")
|
||||
artists.append(f"a{c}-{k // 4}")
|
||||
embs.append((centers[c] + 0.3 * rng.normal(size=32)).astype(np.float32))
|
||||
feats["bpm"].append([90.0, 128.0, 172.0][c])
|
||||
feats["energy"].append([0.2, 0.5, 0.85][c])
|
||||
feats["danceability"].append(0.7)
|
||||
feats["valence"].append(0.5)
|
||||
feats["acousticness"].append(None)
|
||||
return CatalogIndex.build(ids, artists, embs, feats, plays=[1] * len(ids), dim=32)
|
||||
|
||||
|
||||
def profile(**kw: object) -> Profile:
|
||||
base = dict(
|
||||
scores={},
|
||||
disliked=set(),
|
||||
disliked_artists=set(),
|
||||
recent=set(),
|
||||
played=set(),
|
||||
liked_recent=set(),
|
||||
rediscover=set(),
|
||||
exploration=0.0,
|
||||
)
|
||||
base.update(kw)
|
||||
return Profile(**base) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def cluster(track_id: str) -> int:
|
||||
return int(track_id[1])
|
||||
|
||||
|
||||
def test_cold_start_returns_a_diverse_batch() -> None:
|
||||
idx = library()
|
||||
picks = build_batch(idx, profile(), Session(), [], n=5, rng=np.random.default_rng(1))
|
||||
assert len(picks) == 5
|
||||
assert len({p.id for p in picks}) == 5
|
||||
assert len({idx.artist[idx.pos[p.id]] for p in picks}) == 5, "one track per artist in a batch"
|
||||
|
||||
|
||||
def test_taste_pulls_towards_liked_cluster() -> None:
|
||||
idx = library()
|
||||
liked = {f"t2-{k}": 3.0 for k in range(16)}
|
||||
picks = build_batch(
|
||||
idx, profile(scores=liked, played=set(liked)), Session(), [], n=5, rng=np.random.default_rng(2)
|
||||
)
|
||||
assert sum(cluster(p.id) == 2 for p in picks) >= 4
|
||||
|
||||
|
||||
def test_dislikes_recent_and_served_are_excluded() -> None:
|
||||
idx = library(5)
|
||||
p = profile(disliked={"t0-0"}, disliked_artists={"a1-0"}, recent={"t2-0"})
|
||||
s = Session(served=["t2-1"])
|
||||
for seed in range(5):
|
||||
ids = {x.id for x in build_batch(idx, p, s, [], n=5, rng=np.random.default_rng(seed))}
|
||||
assert not ids & {"t0-0", "t2-0", "t2-1"}
|
||||
assert not any(idx.artist[idx.pos[i]] == "a1-0" for i in ids), "disliked artist"
|
||||
|
||||
|
||||
def test_running_context_prefers_fast_energetic_tracks() -> None:
|
||||
idx = library()
|
||||
running = load_contexts().activities["running"].rules
|
||||
picks = build_batch(idx, profile(), Session(), running, n=5, rng=np.random.default_rng(3))
|
||||
assert all(cluster(p.id) == 2 for p in picks), [p.id for p in picks]
|
||||
|
||||
|
||||
def test_half_time_counts_for_running() -> None:
|
||||
from pipu_rec.wave.contexts import fit
|
||||
|
||||
running = load_contexts().activities["running"].rules
|
||||
f = fit(
|
||||
{
|
||||
"bpm": np.array([85.0, 120.0]),
|
||||
"energy": np.array([0.8, 0.8]),
|
||||
"danceability": np.array([0.7, 0.7]),
|
||||
"valence": np.array([np.nan, np.nan]),
|
||||
"acousticness": np.array([np.nan, np.nan]),
|
||||
},
|
||||
running,
|
||||
)
|
||||
assert f[0] > 0.9 > f[1]
|
||||
|
||||
|
||||
def test_session_vector_follows_reactions_and_changes_course() -> None:
|
||||
idx = library()
|
||||
liked = {f"t0-{k}": 3.0 for k in range(10)} | {f"t2-{k}": 3.0 for k in range(10)}
|
||||
cents = taste_centroids(idx, profile(scores=liked))
|
||||
s = Session()
|
||||
update_session(idx, s, [("t1-0", "like"), ("t1-1", "complete")], cents)
|
||||
assert s.vec is not None
|
||||
without = build_batch(idx, profile(scores=liked), Session(), [], n=5, rng=np.random.default_rng(4))
|
||||
picks = build_batch(idx, profile(scores=liked), s, [], n=5, rng=np.random.default_rng(4))
|
||||
assert sum(cluster(p.id) == 1 for p in picks) > sum(cluster(p.id) == 1 for p in without)
|
||||
assert sum(cluster(p.id) == 1 for p in picks) >= 2, "the session pulls the wave towards its reactions"
|
||||
|
||||
update_session(idx, s, [("t1-2", "skip_early"), ("t1-3", "skip_early"), ("t1-4", "dislike")], cents)
|
||||
assert s.skips_in_row == 0, "course changed after three skips"
|
||||
vec = np.array(s.vec)
|
||||
assert float(vec @ idx.emb[idx.pos["t1-0"]]) < 0.5
|
||||
|
||||
|
||||
def test_exploration_brings_unheard_tracks() -> None:
|
||||
idx = library()
|
||||
heard = {f"t2-{k}" for k in range(20)}
|
||||
liked = {t: 3.0 for t in heard}
|
||||
picks = build_batch(
|
||||
idx,
|
||||
profile(scores=liked, played=heard, exploration=0.4),
|
||||
Session(),
|
||||
[],
|
||||
n=5,
|
||||
rng=np.random.default_rng(5),
|
||||
)
|
||||
assert sum(p.source == "fresh" for p in picks) >= 2
|
||||
|
||||
|
||||
def test_bad_context_file_is_rejected(tmp_path) -> None: # type: ignore[no-untyped-def]
|
||||
bad = tmp_path / "c.yaml"
|
||||
bad.write_text(
|
||||
"activities: {x: {icon: a, rules: [{feature: tempo, min: 1, sigma: 1}]}}\nmoods: {}\n", "utf-8"
|
||||
)
|
||||
with pytest.raises(ValueError):
|
||||
load_contexts(str(bad))
|
||||
@@ -0,0 +1,56 @@
|
||||
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)
|
||||
Generated
+34
@@ -207,6 +207,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.5.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/95/b0/c7453d0b6e2073c3264468b106ee1563750cecc910965e67357e3698c83e/numpy-2.5.4.tar.gz", hash = "sha256:9a94cf751c9ad8ebaa835bcd3d40dacf8534ad086b88c38029b65123c7999d2a", size = 20866315, upload-time = "2026-10-10T20:05:31.422Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/97/ba2074e92b7befea137e77ea8471e768bbd87c339b7e8c9f5a931949f977/numpy-2.5.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c6342f54c67093cae5c0227eb0eb772fdb79f2a2c37a6eb278b9909ee06aa356", size = 17001609, upload-time = "2026-10-10T20:02:40.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/a9/bac826765e971d8e16e2064e9ac7525fd69b40ac17c905033a7f5442023f/numpy-2.5.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b11e8fda06a7d69f15ebf542660b74466c2e51094800c1fb794f47ad4faeef17", size = 12015718, upload-time = "2026-10-10T20:02:43.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/2f/5ea3570fcb8ccd0882bea99436a513b2c85dad8f774a2057849130a8fb99/numpy-2.5.4-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:9cb18a327b49c5c337f972b03682f6a49855525faaf3c0d3e9c96cd0fd8880a8", size = 5451717, upload-time = "2026-10-10T20:02:46.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/f2/b4fc1bafca03868220b5eaf729d2f21ebd7d7b151c0f9e144fe212bbca35/numpy-2.5.4-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:aec3fc4b32ff82421274f5d205c559c51c840c8df66a78efd7f3612dd005a26a", size = 6789926, upload-time = "2026-10-10T20:02:48.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/96/8319e2457ae4333c62c815c7006b869a4f60985c1e01024c2f8c6c040fe5/numpy-2.5.4-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe4d21ab149f15e4e6043dfb0de87e6e5f34ac176cde83060e9802981fca2ac2", size = 15695312, upload-time = "2026-10-10T20:02:50.115Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/a3/c799c62e19c337e6d3770b08e475887fb30ce8477d3c09efca6b2f0228a6/numpy-2.5.4-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbde6962867ee75b48b0ee29b2b9372ec5d617799dbaf38e82dc0596f2f7738a", size = 16727283, upload-time = "2026-10-10T20:02:53.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/39/6b/3604e53fb00314d0dc1b94ec9125a1484f649c0a17480b1f0f0c7a9d6250/numpy-2.5.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:381a7a3d2e65e64c0ec302795ab9dc12bb1e73f150904699c153716177eebdaf", size = 17047890, upload-time = "2026-10-10T20:02:56.038Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/7a/e8b58a5289a0d464c52885de47c35a935cdd70c03a4c3ab94a5126416dd0/numpy-2.5.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b89d0aaae2fe498c648f4c4795c084db535af5bd98ef942b2a3681fb74ce8645", size = 18485839, upload-time = "2026-10-10T20:02:59.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c9/47094f597015009f310b8c900def59065ef1ff5a6fe7b51fc65ec58ec2c6/numpy-2.5.4-cp312-cp312-win32.whl", hash = "sha256:9968ab7e49b93ac6e1c3b2239732183152c9150f16308d30b66a372cffe3483c", size = 6138936, upload-time = "2026-10-10T20:03:01.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/33/fefe62073dc8acfd0f2b9ed7c003af2f50aa61555e113e6db02b8f79f145/numpy-2.5.4-cp312-cp312-win_amd64.whl", hash = "sha256:a7b1b6353e36a7e50de2973a38d705c88ee93adcf120673cee7f45a4a3fa223a", size = 12573091, upload-time = "2026-10-10T20:03:04.349Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/07/161270b0c2eec56e4c905f6d6d22e1b836887b2cb189d3f5820aa588e9dd/numpy-2.5.4-cp312-cp312-win_arm64.whl", hash = "sha256:aa1cce2ff3f8d953de38b76bf44602caeb69f101430208f64a10067f7cb4b1d3", size = 10521630, upload-time = "2026-10-10T20:03:06.767Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "opentelemetry-api"
|
||||
version = "1.45.1"
|
||||
@@ -243,8 +262,10 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "numpy" },
|
||||
{ name = "psycopg", extra = ["binary"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "redis" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
@@ -255,13 +276,16 @@ dev = [
|
||||
{ name = "mypy" },
|
||||
{ name = "pytest" },
|
||||
{ name = "ruff" },
|
||||
{ name = "types-pyyaml" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.115,<1" },
|
||||
{ name = "numpy", specifier = ">=2.2,<3" },
|
||||
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2,<4" },
|
||||
{ name = "pydantic-settings", specifier = ">=2.9,<3" },
|
||||
{ name = "pyyaml", specifier = ">=6.0,<7" },
|
||||
{ name = "redis", specifier = ">=6.2,<7" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.34,<1" },
|
||||
]
|
||||
@@ -272,6 +296,7 @@ dev = [
|
||||
{ name = "mypy", specifier = ">=1.16,<2" },
|
||||
{ name = "pytest", specifier = ">=8.4,<9" },
|
||||
{ name = "ruff", specifier = ">=0.12,<1" },
|
||||
{ name = "types-pyyaml", specifier = ">=6.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -478,6 +503,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/93/181df62699d9b5aca1d87e5cfda131dac87e3cdaebf668662f7206ca3470/starlette-1.9.0-py3-none-any.whl", hash = "sha256:fd3f159936e882d06c1108f84688f7cda2cf85fc6a592a50edbd175e882c2ddc", size = 80335, upload-time = "2026-10-18T15:50:31.526Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-pyyaml"
|
||||
version = "6.0.12.20260906"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/90/6e/abec85b9013db5b934b0280a6dd104904d84f7bcbaab2e2f3def87ac7463/types_pyyaml-6.0.12.20260906.tar.gz", hash = "sha256:f59c1cc05010b833d2d72287bbaa72610106b28d42d89a907313117faba85212", size = 18649, upload-time = "2026-09-06T06:35:35.362Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/fc0644b7ddcfb969e95845837143cb5173ddd6e06ee4ba5fc493cd9329b7/types_pyyaml-6.0.12.20260906-py3-none-any.whl", hash = "sha256:bca893ff0d51df5c9053137d5d0e6ccd36e939a196356f1d5c16372422f5137b", size = 21282, upload-time = "2026-09-06T06:35:34.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
|
||||
Reference in new issue
Block a user