Spaces:
Sleeping
Sleeping
update
Browse files- .dockerignore +9 -0
- .gitignore +7 -0
- Dockerfile +3 -8
- README.md +278 -3
- deploy.sh +4 -4
- docker-compose.yml +10 -9
- main.py +4 -292
- openmusic_analysis/__init__.py +5 -0
- openmusic_analysis/analyzers/__init__.py +11 -0
- openmusic_analysis/analyzers/clap.py +303 -0
- openmusic_analysis/analyzers/interfaces.py +37 -0
- openmusic_analysis/analyzers/lyrics.py +331 -0
- openmusic_analysis/analyzers/math.py +26 -0
- openmusic_analysis/api.py +227 -0
- openmusic_analysis/application.py +128 -0
- openmusic_analysis/audio/__init__.py +4 -0
- openmusic_analysis/audio/context.py +61 -0
- openmusic_analysis/audio/decoder.py +156 -0
- openmusic_analysis/audio/windows.py +53 -0
- openmusic_analysis/domain.py +97 -0
- openmusic_analysis/errors.py +33 -0
- openmusic_analysis/experiments/__init__.py +3 -0
- openmusic_analysis/experiments/temporal_similarity.py +62 -0
- openmusic_analysis/registry.py +31 -0
- openmusic_analysis/runtime.py +49 -0
- openmusic_analysis/settings.py +182 -0
- pytest.ini +3 -0
- requirements-dev.txt +4 -0
- requirements.txt +7 -9
- tests/__init__.py +0 -0
- tests/conftest.py +138 -0
- tests/test_api.py +116 -0
- tests/test_audio_analyzers.py +129 -0
- tests/test_benchmark.py +44 -0
- tests/test_decoder.py +31 -0
- tests/test_lyrics.py +64 -0
- tests/test_registry.py +19 -0
- tests/test_temporal_similarity.py +18 -0
- tools/__init__.py +1 -0
- tools/nearest_neighbors.py +192 -0
- tools/profile_analysis.py +53 -0
.dockerignore
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.venv
|
| 3 |
+
.DS_Store
|
| 4 |
+
.pytest_cache
|
| 5 |
+
__pycache__
|
| 6 |
+
*.pyc
|
| 7 |
+
.embedding_cache
|
| 8 |
+
neighbors.json
|
| 9 |
+
neighbors.csv
|
.gitignore
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.DS_Store
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
.pytest_cache/
|
| 5 |
+
.embedding_cache/
|
| 6 |
+
neighbors.json
|
| 7 |
+
neighbors.csv
|
Dockerfile
CHANGED
|
@@ -1,11 +1,10 @@
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
# Системные зависимости
|
| 4 |
-
RUN apt-get update && apt-get install -y \
|
| 5 |
ffmpeg \
|
| 6 |
libsndfile1 \
|
| 7 |
-
|
| 8 |
-
g++ \
|
| 9 |
&& rm -rf /var/lib/apt/lists/*
|
| 10 |
|
| 11 |
WORKDIR /app
|
|
@@ -14,14 +13,10 @@ WORKDIR /app
|
|
| 14 |
COPY requirements.txt .
|
| 15 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 16 |
|
| 17 |
-
# Копируем весь проект
|
| 18 |
COPY . .
|
| 19 |
|
| 20 |
-
# Создаем папку для треков
|
| 21 |
-
RUN mkdir -p tracks
|
| 22 |
-
|
| 23 |
# Порт Hugging Face Spaces
|
| 24 |
EXPOSE 7860
|
| 25 |
|
| 26 |
# Запуск FastAPI
|
| 27 |
-
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
|
|
| 1 |
FROM python:3.11-slim
|
| 2 |
|
| 3 |
# Системные зависимости
|
| 4 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 5 |
ffmpeg \
|
| 6 |
libsndfile1 \
|
| 7 |
+
curl \
|
|
|
|
| 8 |
&& rm -rf /var/lib/apt/lists/*
|
| 9 |
|
| 10 |
WORKDIR /app
|
|
|
|
| 13 |
COPY requirements.txt .
|
| 14 |
RUN pip install --no-cache-dir -r requirements.txt
|
| 15 |
|
|
|
|
| 16 |
COPY . .
|
| 17 |
|
|
|
|
|
|
|
|
|
|
| 18 |
# Порт Hugging Face Spaces
|
| 19 |
EXPOSE 7860
|
| 20 |
|
| 21 |
# Запуск FastAPI
|
| 22 |
+
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
---
|
| 2 |
-
title:
|
| 3 |
emoji: 🎵
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
|
@@ -7,6 +7,281 @@ sdk: docker
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
-
#
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
+
title: OpenMusic Analysis API
|
| 3 |
emoji: 🎵
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: purple
|
|
|
|
| 7 |
pinned: false
|
| 8 |
---
|
| 9 |
|
| 10 |
+
# OpenMusic Music Analysis API
|
| 11 |
|
| 12 |
+
Versioned server-side analysis for separate global audio, temporal audio and lyrics
|
| 13 |
+
representations. The service deliberately does not fuse these embedding spaces.
|
| 14 |
+
|
| 15 |
+
## Architecture
|
| 16 |
+
|
| 17 |
+
```text
|
| 18 |
+
multipart HTTP request
|
| 19 |
+
↓
|
| 20 |
+
validation + temporary-file lifetime
|
| 21 |
+
↓
|
| 22 |
+
MusicAnalysisService
|
| 23 |
+
├── GlobalAudioAnalyzer ───┐
|
| 24 |
+
├── TemporalAudioAnalyzer ─┼── shared CLAP audio encoder
|
| 25 |
+
├── LyricsAnalyzer ─────────── BGE-M3 text encoder
|
| 26 |
+
└── ModelRegistry
|
| 27 |
+
↓
|
| 28 |
+
versioned typed response
|
| 29 |
+
```
|
| 30 |
+
|
| 31 |
+
The HTTP layer validates, calls the application service and serializes. Analyzer
|
| 32 |
+
implementations own preprocessing, inference, aggregation and normalization. Model
|
| 33 |
+
objects are lazy singletons behind an inference semaphore; they are never created per
|
| 34 |
+
request or exposed to the HTTP layer.
|
| 35 |
+
|
| 36 |
+
`AnalysisContext` decodes an upload once to mono float32 PCM at 48 kHz and caches
|
| 37 |
+
additional deterministic in-memory resamples by sample rate. Source-file decoding is
|
| 38 |
+
centralized in ffmpeg and supports mp3, m4a, flac, wav, aac, ogg and opus.
|
| 39 |
+
|
| 40 |
+
## Models
|
| 41 |
+
|
| 42 |
+
| Representation | Model | Exact revision | Dimension | Normalization |
|
| 43 |
+
|---|---|---|---:|---|
|
| 44 |
+
| `audio.global` | `laion/clap-htsat-unfused` | `8fa0f1c6d0433df6e97c127f64b2a1d6c0dcda8a` | 512 | L2 per window, mean, L2 final |
|
| 45 |
+
| `audio.temporal` | `laion/clap-htsat-unfused` | `8fa0f1c6d0433df6e97c127f64b2a1d6c0dcda8a` | 512 per segment | L2 per segment |
|
| 46 |
+
| `lyrics.global` | `BAAI/bge-m3` | `5617a9f61b028005a4858fdac845db406aefb181` | 1024 | L2 per chunk, token-weighted mean, L2 final |
|
| 47 |
+
|
| 48 |
+
CLAP is the existing backend's baseline, now pinned instead of following Hugging Face
|
| 49 |
+
`main`. BGE-M3 was selected for its MIT license, English/Russian and broader
|
| 50 |
+
multilingual support, semantic retrieval training, 1024-dimensional dense space and
|
| 51 |
+
long-document support up to 8192 tokens. Lyrics still use structural chunks so section
|
| 52 |
+
information is not discarded and section embeddings can be exposed later. Compared
|
| 53 |
+
alternatives were multilingual E5 large (good multilingual quality but a 512-token
|
| 54 |
+
limit), multilingual MPNet (older, 128-token sentence/paragraph setup), and GTE
|
| 55 |
+
multilingual base (smaller, long-context, but requires repository remote code).
|
| 56 |
+
|
| 57 |
+
Every registry item declares `model_id`, exact `model_version`, modality, dimension,
|
| 58 |
+
dtype, normalization, license, preprocessing version and full representation config.
|
| 59 |
+
The preprocessing version contains a deterministic hash of all result-affecting config,
|
| 60 |
+
so changing window/hop/chunk settings changes cache identity.
|
| 61 |
+
|
| 62 |
+
## Audio preprocessing
|
| 63 |
+
|
| 64 |
+
The decoder runs ffprobe validation, then one deterministic ffmpeg decode (`-threads 1`)
|
| 65 |
+
to mono little-endian float32 PCM at 48,000 Hz. Future model sample rates are produced
|
| 66 |
+
from that request-scoped PCM with deterministic polyphase resampling.
|
| 67 |
+
|
| 68 |
+
`audio.global` defaults:
|
| 69 |
+
|
| 70 |
+
- 10-second analysis windows;
|
| 71 |
+
- adaptive count: one for short tracks, up to four as duration grows;
|
| 72 |
+
- window starts are uniformly distributed over 10%–90% of the valid start range;
|
| 73 |
+
- short tracks are repeated deterministically to a full model window;
|
| 74 |
+
- every CLAP vector is L2-normalized, vectors are averaged, then normalized again.
|
| 75 |
+
|
| 76 |
+
This prevents a global track vector from effectively describing only the intro.
|
| 77 |
+
Windows longer than CLAP's native 10 seconds are deterministically divided into evenly
|
| 78 |
+
placed 10-second encoder subwindows and aggregated; CLAP never receives a long input
|
| 79 |
+
from which its processor could choose a random crop.
|
| 80 |
+
|
| 81 |
+
`audio.temporal` defaults:
|
| 82 |
+
|
| 83 |
+
- 10-second window and 10-second hop;
|
| 84 |
+
- explicit tail window so the ending is represented;
|
| 85 |
+
- at most 24 segments, with deterministic uniform selection if the candidate count is
|
| 86 |
+
larger;
|
| 87 |
+
- chronological millisecond timestamps and one normalized CLAP vector per segment.
|
| 88 |
+
|
| 89 |
+
The temporal summary reports only representation geometry:
|
| 90 |
+
`number_of_segments`, cosine `mean_adjacent_distance`, `max_adjacent_distance`,
|
| 91 |
+
`trajectory_variance`, and `largest_transition_index`. It does not claim to measure
|
| 92 |
+
emotion, climax or tension.
|
| 93 |
+
|
| 94 |
+
## Lyrics preprocessing
|
| 95 |
+
|
| 96 |
+
`LyricsPreprocessor` applies Unicode NFKC, normalizes line endings, removes control
|
| 97 |
+
characters, LRC metadata/timestamps and pure technical URL lines, while preserving
|
| 98 |
+
punctuation, paragraphs and repeated choruses. It recognizes English and Russian
|
| 99 |
+
section markers such as Verse/Chorus/Bridge and Куплет/Припев/Бридж.
|
| 100 |
+
|
| 101 |
+
Sections are the first chunk boundary. Oversized sections are split by lines and then by
|
| 102 |
+
token IDs, never by arbitrary character count. The default chunk limit is 512 tokens.
|
| 103 |
+
Repeated sections remain repeated and therefore retain their weight. BGE-M3 uses its
|
| 104 |
+
document-side CLS dense representation; chunk vectors are normalized and aggregated by
|
| 105 |
+
token count. The internal chunk/section pipeline is ready for a later `lyrics.sections`
|
| 106 |
+
or `lyrics.temporal` response without changing `lyrics.global`.
|
| 107 |
+
|
| 108 |
+
## Installation and deployment
|
| 109 |
+
|
| 110 |
+
The production image uses Python 3.11. A system ffmpeg/ffprobe is required.
|
| 111 |
+
|
| 112 |
+
```bash
|
| 113 |
+
python3.11 -m venv .venv
|
| 114 |
+
source .venv/bin/activate
|
| 115 |
+
pip install -r requirements.txt
|
| 116 |
+
uvicorn main:app --host 0.0.0.0 --port 7860
|
| 117 |
+
```
|
| 118 |
+
|
| 119 |
+
Or:
|
| 120 |
+
|
| 121 |
+
```bash
|
| 122 |
+
docker compose up --build
|
| 123 |
+
```
|
| 124 |
+
|
| 125 |
+
Docker Compose exposes `http://localhost:8000`; Hugging Face Spaces uses container port
|
| 126 |
+
7860. Model files are cached in the `hf_cache` volume.
|
| 127 |
+
|
| 128 |
+
Device selection is centralized. `OPENMUSIC_DEVICE=auto` chooses CUDA, then Apple MPS,
|
| 129 |
+
then CPU. Set `OPENMUSIC_DEVICE=cpu` for a forced CPU fallback. Output embeddings remain
|
| 130 |
+
float32 regardless of device. `OPENMUSIC_INFERENCE_CONCURRENCY=1` is the safe default
|
| 131 |
+
that avoids duplicate loading and unbounded concurrent GPU work.
|
| 132 |
+
|
| 133 |
+
## API
|
| 134 |
+
|
| 135 |
+
### Models
|
| 136 |
+
|
| 137 |
+
```bash
|
| 138 |
+
curl http://localhost:7860/v1/models
|
| 139 |
+
```
|
| 140 |
+
|
| 141 |
+
`GET /v1/models` is the client source of truth. `loaded` distinguishes an available lazy
|
| 142 |
+
analyzer from one whose model is currently resident.
|
| 143 |
+
|
| 144 |
+
### Analyze a track
|
| 145 |
+
|
| 146 |
+
```bash
|
| 147 |
+
curl -X POST http://localhost:7860/v1/tracks/analyze \
|
| 148 |
+
-F 'audio=@song.flac' \
|
| 149 |
+
-F 'lyrics=[Verse]
|
| 150 |
+
Hello world' \
|
| 151 |
+
-F 'track_id=local:42' \
|
| 152 |
+
-F 'content_identity=sha256:...' \
|
| 153 |
+
-F 'requested_representations=audio.global' \
|
| 154 |
+
-F 'requested_representations=lyrics.global'
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
Repeated fields, a comma-separated value, or a JSON array are accepted for
|
| 158 |
+
`requested_representations`. With no field, both audio representations are calculated,
|
| 159 |
+
plus `lyrics.global` only when non-empty lyrics are supplied. Explicitly requesting
|
| 160 |
+
`lyrics.global` without lyrics is an error.
|
| 161 |
+
|
| 162 |
+
Abbreviated response:
|
| 163 |
+
|
| 164 |
+
```json
|
| 165 |
+
{
|
| 166 |
+
"schema_version": "1",
|
| 167 |
+
"track": {"track_id": "local:42", "content_identity": "sha256:..."},
|
| 168 |
+
"representations": {
|
| 169 |
+
"audio.global": {
|
| 170 |
+
"representation": "audio.global",
|
| 171 |
+
"model_id": "laion/clap-htsat-unfused",
|
| 172 |
+
"model_version": "8fa0f1c6d0433df6e97c127f64b2a1d6c0dcda8a",
|
| 173 |
+
"preprocessing_version": "audio-clap-global-v1.<config-hash>",
|
| 174 |
+
"modality": "audio.global",
|
| 175 |
+
"dimension": 512,
|
| 176 |
+
"dtype": "float32",
|
| 177 |
+
"normalized": true,
|
| 178 |
+
"configuration": {},
|
| 179 |
+
"embedding": [0.01, -0.02],
|
| 180 |
+
"analysis": {"duration_ms": 213000, "windows_used": 4}
|
| 181 |
+
},
|
| 182 |
+
"audio.temporal": {
|
| 183 |
+
"representation": "audio.temporal",
|
| 184 |
+
"model_id": "laion/clap-htsat-unfused",
|
| 185 |
+
"model_version": "8fa0f1c6d0433df6e97c127f64b2a1d6c0dcda8a",
|
| 186 |
+
"preprocessing_version": "audio-clap-temporal-v1.<config-hash>",
|
| 187 |
+
"modality": "audio.temporal",
|
| 188 |
+
"dimension": 512,
|
| 189 |
+
"dtype": "float32",
|
| 190 |
+
"normalized": true,
|
| 191 |
+
"configuration": {},
|
| 192 |
+
"segments": [{"start_ms": 0, "end_ms": 10000, "embedding": [0.01]}],
|
| 193 |
+
"summary": {"number_of_segments": 1, "mean_adjacent_distance": 0.0,
|
| 194 |
+
"max_adjacent_distance": 0.0, "trajectory_variance": 0.0,
|
| 195 |
+
"largest_transition_index": null}
|
| 196 |
+
}
|
| 197 |
+
}
|
| 198 |
+
}
|
| 199 |
+
```
|
| 200 |
+
|
| 201 |
+
Errors never contain stack traces:
|
| 202 |
+
|
| 203 |
+
```json
|
| 204 |
+
{"error": {"code": "AUDIO_DECODE_FAILED", "message": "The uploaded audio could not be decoded."}}
|
| 205 |
+
```
|
| 206 |
+
|
| 207 |
+
`GET /v1/status` reports the selected device and loaded/available representations without
|
| 208 |
+
environment details. `GET /health` is a lightweight container probe.
|
| 209 |
+
|
| 210 |
+
## Configuration
|
| 211 |
+
|
| 212 |
+
| Variable | Default |
|
| 213 |
+
|---|---:|
|
| 214 |
+
| `OPENMUSIC_GLOBAL_WINDOW_SECONDS` | `10` |
|
| 215 |
+
| `OPENMUSIC_GLOBAL_WINDOWS` | `4` |
|
| 216 |
+
| `OPENMUSIC_TEMPORAL_WINDOW_SECONDS` | `10` |
|
| 217 |
+
| `OPENMUSIC_TEMPORAL_HOP_SECONDS` | `10` |
|
| 218 |
+
| `OPENMUSIC_TEMPORAL_MAX_SEGMENTS` | `24` |
|
| 219 |
+
| `OPENMUSIC_CLAP_BATCH_SIZE` | `4` |
|
| 220 |
+
| `OPENMUSIC_LYRICS_CHUNK_TOKENS` | `512` |
|
| 221 |
+
| `OPENMUSIC_MAX_UPLOAD_BYTES` | `104857600` |
|
| 222 |
+
| `OPENMUSIC_MAX_LYRICS_CHARACTERS` | `100000` |
|
| 223 |
+
| `OPENMUSIC_MAX_AUDIO_SECONDS` | `1800` |
|
| 224 |
+
| `OPENMUSIC_REQUEST_TIMEOUT_SECONDS` | `300` |
|
| 225 |
+
|
| 226 |
+
Changing result-affecting representation configuration changes its generated
|
| 227 |
+
`preprocessing_version` and benchmark cache key.
|
| 228 |
+
|
| 229 |
+
## Benchmark tools
|
| 230 |
+
|
| 231 |
+
Place optional UTF-8 lyrics beside audio using the same stem (`song.flac` + `song.txt`).
|
| 232 |
+
|
| 233 |
+
```bash
|
| 234 |
+
python tools/nearest_neighbors.py ./tracks \
|
| 235 |
+
--representation audio.global --top-k 10 --output reports/audio-global.json
|
| 236 |
+
|
| 237 |
+
python tools/nearest_neighbors.py ./tracks \
|
| 238 |
+
--representation lyrics.global --output reports/lyrics-global.json
|
| 239 |
+
|
| 240 |
+
python tools/nearest_neighbors.py ./tracks \
|
| 241 |
+
--representation audio.temporal --output reports/audio-temporal.json
|
| 242 |
+
```
|
| 243 |
+
|
| 244 |
+
The tool writes human-readable JSON and CSV. Its cache identity includes audio SHA-256,
|
| 245 |
+
lyrics SHA-256, model ID, exact revision, preprocessing version, representation and full
|
| 246 |
+
configuration. Unchanged embeddings are reused. Temporal reports contain two explicitly
|
| 247 |
+
experimental metrics: normalized-time interpolation with mean cosine, and classic DTW
|
| 248 |
+
over cosine distance. Neither is treated as the final similarity definition.
|
| 249 |
+
|
| 250 |
+
Measure the current machine with a representative track:
|
| 251 |
+
|
| 252 |
+
```bash
|
| 253 |
+
python tools/profile_analysis.py song.flac --lyrics song.txt
|
| 254 |
+
```
|
| 255 |
+
|
| 256 |
+
It reports total model startup, each representation latency, peak process RSS and peak
|
| 257 |
+
CUDA allocation when CUDA is available.
|
| 258 |
+
|
| 259 |
+
## Tests
|
| 260 |
+
|
| 261 |
+
```bash
|
| 262 |
+
pip install -r requirements-dev.txt
|
| 263 |
+
pytest
|
| 264 |
+
```
|
| 265 |
+
|
| 266 |
+
Tests use deterministic fake encoders and do not download model weights. They cover API
|
| 267 |
+
selection/errors, global aggregation, temporal segmentation/limits, request-scoped
|
| 268 |
+
decode reuse, English/Russian structured lyrics and long chunking, model registry
|
| 269 |
+
consistency, inference failure sanitization, and both temporal similarity baselines.
|
| 270 |
+
|
| 271 |
+
## Extension points and limitations
|
| 272 |
+
|
| 273 |
+
A future MERT analyzer should implement the audio analyzer protocol, request its sample
|
| 274 |
+
rate from `AnalysisContext`, declare a separate registry item and use explicit names such
|
| 275 |
+
as `audio.mert.global` and `audio.mert.temporal`. It must not be averaged with CLAP. It is
|
| 276 |
+
not enabled in V1 to avoid adding a second large model/dependency path before comparative
|
| 277 |
+
benchmarks exist.
|
| 278 |
+
|
| 279 |
+
- CLAP temporal vectors are a trajectory through CLAP's audio space, not a trained music
|
| 280 |
+
emotion representation.
|
| 281 |
+
- BGE-M3 semantic lyrics similarity is not lyrics emotional similarity.
|
| 282 |
+
- Embeddings from CLAP and BGE-M3 (and future MERT models) are different spaces and must
|
| 283 |
+
never be compared directly with cosine or added together.
|
| 284 |
+
- Temporal interpolation and DTW are baselines for experiments, not validated music
|
| 285 |
+
structure metrics.
|
| 286 |
+
- Exact revisions and preprocessing config make inference reproducible, but very small
|
| 287 |
+
floating-point differences can still occur across PyTorch/device/hardware versions.
|
deploy.sh
CHANGED
|
@@ -16,11 +16,11 @@ echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.
|
|
| 16 |
apt-get update
|
| 17 |
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
| 18 |
|
| 19 |
-
echo "===
|
| 20 |
-
mkdir -p /opt/
|
| 21 |
-
cd /opt/
|
| 22 |
|
| 23 |
-
# Скопируйте
|
| 24 |
|
| 25 |
echo "=== Сборка и запуск ==="
|
| 26 |
docker compose build
|
|
|
|
| 16 |
apt-get update
|
| 17 |
apt-get install -y docker-ce docker-ce-cli containerd.io docker-compose-plugin
|
| 18 |
|
| 19 |
+
echo "=== Подготовка директории OpenMusic Analysis API ==="
|
| 20 |
+
mkdir -p /opt/openmusic-analysis
|
| 21 |
+
cd /opt/openmusic-analysis
|
| 22 |
|
| 23 |
+
# Скопируйте в эту директорию весь репозиторий перед следующим шагом.
|
| 24 |
|
| 25 |
echo "=== Сборка и запуск ==="
|
| 26 |
docker compose build
|
docker-compose.yml
CHANGED
|
@@ -1,18 +1,19 @@
|
|
| 1 |
services:
|
| 2 |
-
|
| 3 |
build: .
|
| 4 |
-
container_name:
|
| 5 |
restart: unless-stopped # автоперезапуск при падении
|
| 6 |
ports:
|
| 7 |
-
- "8000:
|
| 8 |
volumes:
|
| 9 |
-
-
|
| 10 |
-
- hf_cache:/root/.cache/huggingface # кеш моделей между перезапусками
|
| 11 |
environment:
|
| 12 |
- PYTHONUNBUFFERED=1
|
| 13 |
-
-
|
|
|
|
|
|
|
| 14 |
healthcheck:
|
| 15 |
-
test: ["CMD", "curl", "-f", "http://localhost:
|
| 16 |
interval: 30s
|
| 17 |
timeout: 10s
|
| 18 |
retries: 3
|
|
@@ -20,7 +21,7 @@ services:
|
|
| 20 |
deploy:
|
| 21 |
resources:
|
| 22 |
limits:
|
| 23 |
-
memory:
|
| 24 |
logging:
|
| 25 |
driver: "json-file"
|
| 26 |
options:
|
|
@@ -28,4 +29,4 @@ services:
|
|
| 28 |
max-file: "3"
|
| 29 |
|
| 30 |
volumes:
|
| 31 |
-
hf_cache:
|
|
|
|
| 1 |
services:
|
| 2 |
+
openmusic-analysis:
|
| 3 |
build: .
|
| 4 |
+
container_name: openmusic-analysis-api
|
| 5 |
restart: unless-stopped # автоперезапуск при падении
|
| 6 |
ports:
|
| 7 |
+
- "8000:7860"
|
| 8 |
volumes:
|
| 9 |
+
- hf_cache:/root/.cache/huggingface
|
|
|
|
| 10 |
environment:
|
| 11 |
- PYTHONUNBUFFERED=1
|
| 12 |
+
- HF_HOME=/root/.cache/huggingface
|
| 13 |
+
- OPENMUSIC_DEVICE=auto
|
| 14 |
+
- OPENMUSIC_INFERENCE_CONCURRENCY=1
|
| 15 |
healthcheck:
|
| 16 |
+
test: ["CMD", "curl", "-f", "http://localhost:7860/health"]
|
| 17 |
interval: 30s
|
| 18 |
timeout: 10s
|
| 19 |
retries: 3
|
|
|
|
| 21 |
deploy:
|
| 22 |
resources:
|
| 23 |
limits:
|
| 24 |
+
memory: 10G
|
| 25 |
logging:
|
| 26 |
driver: "json-file"
|
| 27 |
options:
|
|
|
|
| 29 |
max-file: "3"
|
| 30 |
|
| 31 |
volumes:
|
| 32 |
+
hf_cache:
|
main.py
CHANGED
|
@@ -1,305 +1,17 @@
|
|
| 1 |
-
import os
|
| 2 |
-
import glob
|
| 3 |
-
import shutil
|
| 4 |
-
import tempfile
|
| 5 |
import logging
|
| 6 |
-
import traceback
|
| 7 |
-
from contextlib import asynccontextmanager
|
| 8 |
|
| 9 |
-
|
| 10 |
-
import torch
|
| 11 |
-
from fastapi import FastAPI, File, UploadFile, HTTPException, Request
|
| 12 |
-
from fastapi.responses import JSONResponse
|
| 13 |
-
from sklearn.metrics.pairwise import cosine_similarity
|
| 14 |
|
| 15 |
-
# ─── Логирование ──────────────────────────────────────────────────────────────
|
| 16 |
|
| 17 |
logging.basicConfig(
|
| 18 |
level=logging.INFO,
|
| 19 |
-
format="%(asctime)s [%(levelname)s] %(message)s",
|
| 20 |
)
|
| 21 |
-
log = logging.getLogger(__name__)
|
| 22 |
|
| 23 |
-
|
| 24 |
|
| 25 |
-
try:
|
| 26 |
-
import imageio_ffmpeg
|
| 27 |
-
from pydub import AudioSegment
|
| 28 |
-
|
| 29 |
-
_ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
| 30 |
-
AudioSegment.converter = _ffmpeg_exe
|
| 31 |
-
os.environ["PATH"] = os.path.dirname(_ffmpeg_exe) + os.pathsep + os.environ.get("PATH", "")
|
| 32 |
-
log.info(f"ffmpeg: {_ffmpeg_exe}")
|
| 33 |
-
except Exception as e:
|
| 34 |
-
log.warning(f"ffmpeg не найден: {e}. MP3 могут не читаться.")
|
| 35 |
-
|
| 36 |
-
# ─── Загрузка аудио ───────────────────────────────────────────────────────────
|
| 37 |
-
|
| 38 |
-
def load_audio(file_path: str, sr: int = 48000) -> np.ndarray:
|
| 39 |
-
"""
|
| 40 |
-
Надёжная загрузка аудио: сначала pydub (поддерживает MP3),
|
| 41 |
-
при ошибке — librosa.
|
| 42 |
-
"""
|
| 43 |
-
try:
|
| 44 |
-
from pydub import AudioSegment
|
| 45 |
-
seg = AudioSegment.from_file(file_path)
|
| 46 |
-
seg = seg.set_frame_rate(sr).set_channels(1)
|
| 47 |
-
samples = np.array(seg.get_array_of_samples(), dtype=np.float32)
|
| 48 |
-
max_val = float(np.iinfo(seg.array_type).max)
|
| 49 |
-
return samples / (max_val + 1e-8)
|
| 50 |
-
except Exception as e1:
|
| 51 |
-
log.warning(f"pydub не смог прочитать {file_path}: {e1}. Пробуем librosa...")
|
| 52 |
-
try:
|
| 53 |
-
import librosa
|
| 54 |
-
y, _ = librosa.load(file_path, sr=sr, mono=True)
|
| 55 |
-
return y.astype(np.float32)
|
| 56 |
-
except Exception as e2:
|
| 57 |
-
raise RuntimeError(f"Не удалось прочитать аудио: pydub={e1} | librosa={e2}")
|
| 58 |
-
|
| 59 |
-
# ─── Модели ───────────────────────────────────────────────────────────────────
|
| 60 |
-
|
| 61 |
-
from transformers import (
|
| 62 |
-
ClapAudioModelWithProjection,
|
| 63 |
-
ClapTextModelWithProjection,
|
| 64 |
-
ClapProcessor,
|
| 65 |
-
AutoTokenizer,
|
| 66 |
-
)
|
| 67 |
-
|
| 68 |
-
log.info("Загрузка CLAP аудио модели...")
|
| 69 |
-
clap_model = ClapAudioModelWithProjection.from_pretrained("laion/clap-htsat-unfused")
|
| 70 |
-
clap_processor = ClapProcessor.from_pretrained("laion/clap-htsat-unfused")
|
| 71 |
-
clap_model.eval()
|
| 72 |
-
log.info("CLAP аудио готов")
|
| 73 |
-
|
| 74 |
-
log.info("Загрузка CLAP текстовой модели...")
|
| 75 |
-
text_model = ClapTextModelWithProjection.from_pretrained("laion/clap-htsat-unfused")
|
| 76 |
-
tokenizer = AutoTokenizer.from_pretrained("laion/clap-htsat-unfused")
|
| 77 |
-
text_model.eval()
|
| 78 |
-
log.info("CLAP текст готов")
|
| 79 |
-
|
| 80 |
-
# ─── Embedding ────────────────────────────────────────────────────────────────
|
| 81 |
-
|
| 82 |
-
def extract_audio_features(file_path: str) -> np.ndarray:
|
| 83 |
-
"""
|
| 84 |
-
CLAP embedding: анализирует 3 фрагмента трека (10%, 40%, 70%)
|
| 85 |
-
и возвращает усреднённый нормализованный вектор 512-dim.
|
| 86 |
-
"""
|
| 87 |
-
audio_np = load_audio(file_path, sr=48000)
|
| 88 |
-
sr = 48000
|
| 89 |
-
chunk_size = 10 * sr
|
| 90 |
-
positions = [0.1, 0.4, 0.7]
|
| 91 |
-
chunks = []
|
| 92 |
-
|
| 93 |
-
for pos in positions:
|
| 94 |
-
start = int(pos * len(audio_np))
|
| 95 |
-
end = start + chunk_size
|
| 96 |
-
if end > len(audio_np):
|
| 97 |
-
end = len(audio_np)
|
| 98 |
-
start = max(0, end - chunk_size)
|
| 99 |
-
chunk = audio_np[start:end]
|
| 100 |
-
if len(chunk) > sr * 2:
|
| 101 |
-
chunks.append(chunk)
|
| 102 |
-
|
| 103 |
-
# Если трек короткий — берём весь целиком
|
| 104 |
-
if not chunks:
|
| 105 |
-
if len(audio_np) < sr * 1:
|
| 106 |
-
raise ValueError("Трек слишком короткий (менее 1 секунды)")
|
| 107 |
-
chunks = [audio_np]
|
| 108 |
-
|
| 109 |
-
embeddings = []
|
| 110 |
-
for chunk in chunks:
|
| 111 |
-
try:
|
| 112 |
-
inputs = clap_processor(
|
| 113 |
-
audios=chunk,
|
| 114 |
-
sampling_rate=48000,
|
| 115 |
-
return_tensors="pt"
|
| 116 |
-
)
|
| 117 |
-
with torch.no_grad():
|
| 118 |
-
emb = clap_model(**inputs).audio_embeds.squeeze().numpy()
|
| 119 |
-
embeddings.append(emb)
|
| 120 |
-
except Exception as e:
|
| 121 |
-
log.warning(f"Ошибка на фрагменте: {e}")
|
| 122 |
-
continue
|
| 123 |
-
|
| 124 |
-
if not embeddings:
|
| 125 |
-
raise RuntimeError("Не удалось получить ни одного embedding")
|
| 126 |
-
|
| 127 |
-
final_emb = np.mean(embeddings, axis=0)
|
| 128 |
-
norm = np.linalg.norm(final_emb)
|
| 129 |
-
if norm > 0:
|
| 130 |
-
final_emb = final_emb / norm
|
| 131 |
-
return final_emb.astype(np.float32)
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
def generate_caption(file_path: str) -> str:
|
| 135 |
-
"""Описание трека на основе акустических характеристик."""
|
| 136 |
-
try:
|
| 137 |
-
import librosa
|
| 138 |
-
y = load_audio(file_path, sr=22050)
|
| 139 |
-
|
| 140 |
-
tempo, _ = librosa.beat.beat_track(y=y, sr=22050)
|
| 141 |
-
tempo = float(np.atleast_1d(tempo)[0])
|
| 142 |
-
rms = float(np.mean(librosa.feature.rms(y=y)))
|
| 143 |
-
zcr = float(np.mean(librosa.feature.zero_crossing_rate(y)))
|
| 144 |
-
chroma = librosa.feature.chroma_stft(y=y, sr=22050)
|
| 145 |
-
chroma_mean = np.mean(chroma, axis=1)
|
| 146 |
-
dominant_note = ["C","C#","D","D#","E","F","F#","G","G#","A","A#","B"][int(np.argmax(chroma_mean))]
|
| 147 |
-
harmonic, _ = librosa.effects.hpss(y)
|
| 148 |
-
harmonic_ratio = float(np.mean(np.abs(harmonic))) / (float(np.mean(np.abs(y))) + 1e-8)
|
| 149 |
-
|
| 150 |
-
tempo_desc = "slow tempo" if tempo < 70 else "moderate tempo" if tempo < 100 else "upbeat tempo" if tempo < 140 else "fast tempo"
|
| 151 |
-
energy_desc = "very quiet and calm" if rms < 0.02 else "soft and gentle" if rms < 0.05 else "moderate energy" if rms < 0.1 else "high energy and loud"
|
| 152 |
-
texture_desc = "with strong percussion and rhythmic elements" if zcr > 0.1 else "with moderate rhythmic elements" if zcr > 0.05 else "smooth and melodic without heavy percussion"
|
| 153 |
-
harmony_desc = "rich harmonic content" if harmonic_ratio > 0.6 else "balanced mix of harmony and rhythm" if harmonic_ratio > 0.3 else "rhythm-driven with minimal harmony"
|
| 154 |
-
|
| 155 |
-
return (
|
| 156 |
-
f"This music has a {tempo_desc} around {int(tempo)} BPM. "
|
| 157 |
-
f"It sounds {energy_desc} {texture_desc}. "
|
| 158 |
-
f"The track features {harmony_desc} in the key of {dominant_note}."
|
| 159 |
-
)
|
| 160 |
-
except Exception as e:
|
| 161 |
-
log.warning(f"generate_caption failed: {e}")
|
| 162 |
-
return "music audio track" # fallback — не падаем
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
def text_to_embedding(text: str) -> np.ndarray:
|
| 166 |
-
"""CLAP embedding из текста."""
|
| 167 |
-
inputs = tokenizer(text, return_tensors="pt", padding=True, truncation=True, max_length=77)
|
| 168 |
-
with torch.no_grad():
|
| 169 |
-
emb = text_model(**inputs).text_embeds.squeeze().numpy()
|
| 170 |
-
norm = np.linalg.norm(emb)
|
| 171 |
-
return (emb / norm if norm > 0 else emb).astype(np.float32)
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
def save_temp_file(file: UploadFile) -> str:
|
| 175 |
-
"""Сохраняет загруженный файл во временное место."""
|
| 176 |
-
suffix = os.path.splitext(file.filename or "audio.mp3")[1].lower() or ".mp3"
|
| 177 |
-
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 178 |
-
shutil.copyfileobj(file.file, tmp)
|
| 179 |
-
return tmp.name
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
def safe_remove(path: str):
|
| 183 |
-
try:
|
| 184 |
-
os.remove(path)
|
| 185 |
-
except Exception:
|
| 186 |
-
pass
|
| 187 |
-
|
| 188 |
-
# ─── Индексирование треков при старте ────────────────────────────────────────
|
| 189 |
-
|
| 190 |
-
TRACK_FILES = glob.glob("tracks/**/*.*", recursive=True)
|
| 191 |
-
TRACK_FILES = [f for f in TRACK_FILES if f.lower().endswith((".mp3", ".wav", ".flac", ".ogg", ".m4a"))]
|
| 192 |
-
|
| 193 |
-
track_features: list[np.ndarray] = []
|
| 194 |
-
available_tracks: list[str] = []
|
| 195 |
-
|
| 196 |
-
for path in TRACK_FILES:
|
| 197 |
-
if not os.path.exists(path):
|
| 198 |
-
log.warning(f"[skip] не найден: {path}")
|
| 199 |
-
continue
|
| 200 |
-
try:
|
| 201 |
-
feat = extract_audio_features(path)
|
| 202 |
-
track_features.append(feat)
|
| 203 |
-
available_tracks.append(path)
|
| 204 |
-
log.info(f"[ok] проиндексирован: {path}")
|
| 205 |
-
except Exception as e:
|
| 206 |
-
log.error(f"[error] {path}: {e}")
|
| 207 |
-
|
| 208 |
-
track_features_matrix = np.array(track_features) if track_features else np.empty((0, 512))
|
| 209 |
-
log.info(f"Индекс готов: {len(available_tracks)} треков")
|
| 210 |
-
|
| 211 |
-
# ─── FastAPI ──────────────────────────────────────────────────────────────────
|
| 212 |
-
|
| 213 |
-
app = FastAPI(title="Audio Embedding API")
|
| 214 |
-
|
| 215 |
-
# Глобальный обработчик всех необработанных исключений
|
| 216 |
-
@app.exception_handler(Exception)
|
| 217 |
-
async def global_exception_handler(request: Request, exc: Exception):
|
| 218 |
-
log.error(f"Необработанная ошибка [{request.url}]: {exc}\n{traceback.format_exc()}")
|
| 219 |
-
return JSONResponse(
|
| 220 |
-
status_code=500,
|
| 221 |
-
content={"detail": f"Внутренняя ошибка сервера: {type(exc).__name__}: {exc}"},
|
| 222 |
-
)
|
| 223 |
-
|
| 224 |
-
# ─── Роуты ──────────────────���────────────────────────────────────────────────
|
| 225 |
-
|
| 226 |
-
@app.post("/embedding/smart")
|
| 227 |
-
async def get_smart_embedding(file: UploadFile = File(...)):
|
| 228 |
-
"""
|
| 229 |
-
Возвращает embedding: 60% аудио CLAP + 40% текстового описания трека.
|
| 230 |
-
Также возвращает caption с описанием характеристик трека.
|
| 231 |
-
"""
|
| 232 |
-
if not file.filename:
|
| 233 |
-
raise HTTPException(status_code=400, detail="Имя файла не указано")
|
| 234 |
-
|
| 235 |
-
tmp_path = save_temp_file(file)
|
| 236 |
-
try:
|
| 237 |
-
audio_emb = extract_audio_features(tmp_path)
|
| 238 |
-
caption = generate_caption(tmp_path)
|
| 239 |
-
text_emb = text_to_embedding(caption)
|
| 240 |
-
|
| 241 |
-
combined = 0.6 * audio_emb + 0.4 * text_emb
|
| 242 |
-
norm = np.linalg.norm(combined)
|
| 243 |
-
combined = (combined / norm if norm > 0 else combined).astype(np.float32)
|
| 244 |
-
|
| 245 |
-
log.info(f"[smart] {file.filename} → caption: {caption}")
|
| 246 |
-
return {"caption": caption, "embedding": combined.tolist()}
|
| 247 |
-
|
| 248 |
-
except HTTPException:
|
| 249 |
-
raise
|
| 250 |
-
except Exception as e:
|
| 251 |
-
log.error(f"[smart] {file.filename}: {e}\n{traceback.format_exc()}")
|
| 252 |
-
raise HTTPException(status_code=500, detail=f"Ошибка обработки: {type(e).__name__}: {e}")
|
| 253 |
-
finally:
|
| 254 |
-
safe_remove(tmp_path)
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
@app.post("/find_similar_tracks/")
|
| 258 |
-
async def find_similar_tracks_route(file: UploadFile = File(...)):
|
| 259 |
-
"""
|
| 260 |
-
Находит похожие треки из локальной библиотеки (папка tracks/).
|
| 261 |
-
"""
|
| 262 |
-
if len(available_tracks) == 0:
|
| 263 |
-
raise HTTPException(status_code=503, detail="Библиотека треков пуста — добавьте файлы в папку tracks/")
|
| 264 |
-
|
| 265 |
-
tmp_path = save_temp_file(file)
|
| 266 |
-
try:
|
| 267 |
-
query_vec = extract_audio_features(tmp_path)
|
| 268 |
-
sims = cosine_similarity([query_vec], track_features_matrix)[0]
|
| 269 |
-
indices = np.argsort(sims)[::-1]
|
| 270 |
-
|
| 271 |
-
results = [
|
| 272 |
-
{
|
| 273 |
-
"rank": int(rank + 1),
|
| 274 |
-
"track": available_tracks[i],
|
| 275 |
-
"similarity": round(float(sims[i]), 4),
|
| 276 |
-
}
|
| 277 |
-
for rank, i in enumerate(indices)
|
| 278 |
-
]
|
| 279 |
-
log.info(f"[search] {file.filename} → top={results[0]['track']} ({results[0]['similarity']})")
|
| 280 |
-
return {"query": file.filename, "results": results}
|
| 281 |
-
|
| 282 |
-
except HTTPException:
|
| 283 |
-
raise
|
| 284 |
-
except Exception as e:
|
| 285 |
-
log.error(f"[search] {file.filename}: {e}\n{traceback.format_exc()}")
|
| 286 |
-
raise HTTPException(status_code=500, detail=f"Ошибка поиска: {type(e).__name__}: {e}")
|
| 287 |
-
finally:
|
| 288 |
-
safe_remove(tmp_path)
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
@app.get("/health")
|
| 292 |
-
async def health():
|
| 293 |
-
"""Проверка состояния сервера."""
|
| 294 |
-
return {
|
| 295 |
-
"status": "ok",
|
| 296 |
-
"indexed_tracks": len(available_tracks),
|
| 297 |
-
"models": ["clap-audio", "clap-text"],
|
| 298 |
-
}
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
# ─── Запуск ───────────────────────────────────────────────────────────────────
|
| 302 |
|
| 303 |
if __name__ == "__main__":
|
| 304 |
import uvicorn
|
|
|
|
| 305 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import logging
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
from openmusic_analysis.api import create_app
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
|
|
|
| 5 |
|
| 6 |
logging.basicConfig(
|
| 7 |
level=logging.INFO,
|
| 8 |
+
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
| 9 |
)
|
|
|
|
| 10 |
|
| 11 |
+
app = create_app()
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
if __name__ == "__main__":
|
| 15 |
import uvicorn
|
| 16 |
+
|
| 17 |
uvicorn.run(app, host="0.0.0.0", port=7860)
|
openmusic_analysis/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenMusic versioned music-analysis backend."""
|
| 2 |
+
|
| 3 |
+
from .application import MusicAnalysisService, build_service
|
| 4 |
+
|
| 5 |
+
__all__ = ["MusicAnalysisService", "build_service"]
|
openmusic_analysis/analyzers/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .clap import ClapAudioEncoder, ClapGlobalAudioAnalyzer, ClapTemporalAudioAnalyzer
|
| 2 |
+
from .lyrics import BGEM3LyricsAnalyzer, BGEM3TextEncoder, LyricsPreprocessor
|
| 3 |
+
|
| 4 |
+
__all__ = [
|
| 5 |
+
"BGEM3LyricsAnalyzer",
|
| 6 |
+
"BGEM3TextEncoder",
|
| 7 |
+
"ClapAudioEncoder",
|
| 8 |
+
"ClapGlobalAudioAnalyzer",
|
| 9 |
+
"ClapTemporalAudioAnalyzer",
|
| 10 |
+
"LyricsPreprocessor",
|
| 11 |
+
]
|
openmusic_analysis/analyzers/clap.py
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import threading
|
| 5 |
+
from typing import Protocol
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
|
| 9 |
+
from openmusic_analysis.audio import AnalysisContext
|
| 10 |
+
from openmusic_analysis.audio.windows import (
|
| 11 |
+
fixed_window,
|
| 12 |
+
global_window_starts,
|
| 13 |
+
temporal_window_starts,
|
| 14 |
+
)
|
| 15 |
+
from openmusic_analysis.domain import (
|
| 16 |
+
GlobalEmbeddingResult,
|
| 17 |
+
ModelMetadata,
|
| 18 |
+
TemporalEmbeddingResult,
|
| 19 |
+
TemporalSegment,
|
| 20 |
+
TemporalSummary,
|
| 21 |
+
)
|
| 22 |
+
from openmusic_analysis.errors import AnalysisError, ModelInferenceError
|
| 23 |
+
from openmusic_analysis.runtime import InferenceGate
|
| 24 |
+
from openmusic_analysis.settings import (
|
| 25 |
+
CLAP_MODEL_ID,
|
| 26 |
+
CLAP_MODEL_REVISION,
|
| 27 |
+
GlobalAudioConfig,
|
| 28 |
+
TemporalAudioConfig,
|
| 29 |
+
config_dict,
|
| 30 |
+
preprocessing_version,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
from .math import finite_float_list, l2_normalize, normalize_rows
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
log = logging.getLogger(__name__)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class AudioEmbeddingEncoder(Protocol):
|
| 40 |
+
dimension: int
|
| 41 |
+
loaded: bool
|
| 42 |
+
|
| 43 |
+
async def encode(self, windows: list[np.ndarray]) -> np.ndarray: ...
|
| 44 |
+
|
| 45 |
+
async def ready(self) -> None: ...
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ClapAudioEncoder:
|
| 49 |
+
"""One shared, lazy CLAP audio encoder used by global and temporal analyzers."""
|
| 50 |
+
|
| 51 |
+
dimension = 512
|
| 52 |
+
sample_rate = 48_000
|
| 53 |
+
|
| 54 |
+
def __init__(self, device: str, gate: InferenceGate, batch_size: int = 4) -> None:
|
| 55 |
+
self.device = device
|
| 56 |
+
self.gate = gate
|
| 57 |
+
self.batch_size = batch_size
|
| 58 |
+
self.loaded = False
|
| 59 |
+
self._load_lock = threading.Lock()
|
| 60 |
+
self._processor = None
|
| 61 |
+
self._model = None
|
| 62 |
+
|
| 63 |
+
async def ready(self) -> None:
|
| 64 |
+
await self.gate.run(self._ensure_loaded)
|
| 65 |
+
|
| 66 |
+
def _ensure_loaded(self) -> None:
|
| 67 |
+
if self.loaded:
|
| 68 |
+
return
|
| 69 |
+
with self._load_lock:
|
| 70 |
+
if self.loaded:
|
| 71 |
+
return
|
| 72 |
+
import torch
|
| 73 |
+
from transformers import ClapAudioModelWithProjection, ClapProcessor
|
| 74 |
+
|
| 75 |
+
log.info("Loading %s at %s on %s", CLAP_MODEL_ID, CLAP_MODEL_REVISION, self.device)
|
| 76 |
+
processor = ClapProcessor.from_pretrained(
|
| 77 |
+
CLAP_MODEL_ID, revision=CLAP_MODEL_REVISION
|
| 78 |
+
)
|
| 79 |
+
model = ClapAudioModelWithProjection.from_pretrained(
|
| 80 |
+
CLAP_MODEL_ID, revision=CLAP_MODEL_REVISION
|
| 81 |
+
)
|
| 82 |
+
if int(model.config.projection_dim) != self.dimension:
|
| 83 |
+
raise RuntimeError("Pinned CLAP projection dimension does not match registry")
|
| 84 |
+
if int(processor.feature_extractor.sampling_rate) != self.sample_rate:
|
| 85 |
+
raise RuntimeError("Pinned CLAP sample rate does not match preprocessing")
|
| 86 |
+
model.to(torch.device(self.device))
|
| 87 |
+
model.eval()
|
| 88 |
+
self._processor = processor
|
| 89 |
+
self._model = model
|
| 90 |
+
self.loaded = True
|
| 91 |
+
|
| 92 |
+
async def encode(self, windows: list[np.ndarray]) -> np.ndarray:
|
| 93 |
+
return await self.gate.run(self._encode_sync, windows)
|
| 94 |
+
|
| 95 |
+
def _encode_sync(self, windows: list[np.ndarray]) -> np.ndarray:
|
| 96 |
+
self._ensure_loaded()
|
| 97 |
+
import torch
|
| 98 |
+
|
| 99 |
+
assert self._processor is not None and self._model is not None
|
| 100 |
+
encoder_windows, groups = _clap_encoder_windows(
|
| 101 |
+
windows, self.sample_rate * 10
|
| 102 |
+
)
|
| 103 |
+
outputs: list[np.ndarray] = []
|
| 104 |
+
for index in range(0, len(encoder_windows), self.batch_size):
|
| 105 |
+
inputs = self._processor(
|
| 106 |
+
audios=encoder_windows[index : index + self.batch_size],
|
| 107 |
+
sampling_rate=self.sample_rate,
|
| 108 |
+
max_length=self.sample_rate * 10,
|
| 109 |
+
truncation="rand_trunc",
|
| 110 |
+
padding="repeatpad",
|
| 111 |
+
return_tensors="pt",
|
| 112 |
+
)
|
| 113 |
+
inputs = {name: value.to(self.device) for name, value in inputs.items()}
|
| 114 |
+
with torch.inference_mode():
|
| 115 |
+
output = self._model(**inputs).audio_embeds
|
| 116 |
+
outputs.append(output.detach().to(dtype=torch.float32, device="cpu").numpy())
|
| 117 |
+
raw = np.concatenate(outputs, axis=0)
|
| 118 |
+
normalized = normalize_rows(raw)
|
| 119 |
+
aggregated = [
|
| 120 |
+
l2_normalize(np.mean(normalized[start:end], axis=0, dtype=np.float64))
|
| 121 |
+
for start, end in groups
|
| 122 |
+
]
|
| 123 |
+
return np.stack(aggregated)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
class ClapGlobalAudioAnalyzer:
|
| 127 |
+
representation = "audio.global"
|
| 128 |
+
input_kind = "audio"
|
| 129 |
+
|
| 130 |
+
def __init__(self, encoder: AudioEmbeddingEncoder, config: GlobalAudioConfig) -> None:
|
| 131 |
+
self.encoder = encoder
|
| 132 |
+
self.config = config
|
| 133 |
+
|
| 134 |
+
@property
|
| 135 |
+
def metadata(self) -> ModelMetadata:
|
| 136 |
+
return ModelMetadata(
|
| 137 |
+
representation=self.representation,
|
| 138 |
+
model_id=CLAP_MODEL_ID,
|
| 139 |
+
model_version=CLAP_MODEL_REVISION,
|
| 140 |
+
modality=self.representation,
|
| 141 |
+
dimension=self.encoder.dimension,
|
| 142 |
+
dtype="float32",
|
| 143 |
+
normalized=True,
|
| 144 |
+
preprocessing_version=preprocessing_version("audio-clap-global-v1", self.config),
|
| 145 |
+
license="Apache-2.0",
|
| 146 |
+
configuration=config_dict(self.config),
|
| 147 |
+
loaded=self.encoder.loaded,
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
async def analyze(self, context: AnalysisContext) -> GlobalEmbeddingResult:
|
| 151 |
+
try:
|
| 152 |
+
waveform = await context.waveform(self.config.sample_rate)
|
| 153 |
+
duration_seconds = waveform.size / self.config.sample_rate
|
| 154 |
+
if duration_seconds < self.config.minimum_audio_seconds:
|
| 155 |
+
raise AnalysisError(
|
| 156 |
+
"AUDIO_TOO_SHORT",
|
| 157 |
+
f"Audio must be at least {self.config.minimum_audio_seconds:g} second(s).",
|
| 158 |
+
status_code=422,
|
| 159 |
+
)
|
| 160 |
+
window_samples = int(round(self.config.window_seconds * self.config.sample_rate))
|
| 161 |
+
starts = global_window_starts(
|
| 162 |
+
waveform.size,
|
| 163 |
+
window_samples,
|
| 164 |
+
self.config.target_windows,
|
| 165 |
+
self.config.coverage_start,
|
| 166 |
+
self.config.coverage_end,
|
| 167 |
+
)
|
| 168 |
+
windows = [fixed_window(waveform, start, window_samples) for start in starts]
|
| 169 |
+
embeddings = normalize_rows(await self.encoder.encode(windows))
|
| 170 |
+
aggregate = l2_normalize(np.mean(embeddings, axis=0, dtype=np.float64))
|
| 171 |
+
metadata = self.metadata
|
| 172 |
+
return GlobalEmbeddingResult(
|
| 173 |
+
**metadata.result_metadata(),
|
| 174 |
+
embedding=finite_float_list(aggregate),
|
| 175 |
+
analysis={
|
| 176 |
+
"duration_ms": int(round(duration_seconds * 1000)),
|
| 177 |
+
"window_start_ms": [
|
| 178 |
+
int(round(start * 1000 / self.config.sample_rate)) for start in starts
|
| 179 |
+
],
|
| 180 |
+
"windows_used": len(starts),
|
| 181 |
+
},
|
| 182 |
+
)
|
| 183 |
+
except AnalysisError:
|
| 184 |
+
raise
|
| 185 |
+
except Exception as exc:
|
| 186 |
+
log.exception("Global CLAP inference failed")
|
| 187 |
+
raise ModelInferenceError(self.representation) from exc
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class ClapTemporalAudioAnalyzer:
|
| 191 |
+
representation = "audio.temporal"
|
| 192 |
+
input_kind = "audio"
|
| 193 |
+
|
| 194 |
+
def __init__(self, encoder: AudioEmbeddingEncoder, config: TemporalAudioConfig) -> None:
|
| 195 |
+
self.encoder = encoder
|
| 196 |
+
self.config = config
|
| 197 |
+
|
| 198 |
+
@property
|
| 199 |
+
def metadata(self) -> ModelMetadata:
|
| 200 |
+
return ModelMetadata(
|
| 201 |
+
representation=self.representation,
|
| 202 |
+
model_id=CLAP_MODEL_ID,
|
| 203 |
+
model_version=CLAP_MODEL_REVISION,
|
| 204 |
+
modality=self.representation,
|
| 205 |
+
dimension=self.encoder.dimension,
|
| 206 |
+
dtype="float32",
|
| 207 |
+
normalized=True,
|
| 208 |
+
preprocessing_version=preprocessing_version(
|
| 209 |
+
"audio-clap-temporal-v1", self.config
|
| 210 |
+
),
|
| 211 |
+
license="Apache-2.0",
|
| 212 |
+
configuration=config_dict(self.config),
|
| 213 |
+
loaded=self.encoder.loaded,
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
async def analyze(self, context: AnalysisContext) -> TemporalEmbeddingResult:
|
| 217 |
+
try:
|
| 218 |
+
waveform = await context.waveform(self.config.sample_rate)
|
| 219 |
+
duration_seconds = waveform.size / self.config.sample_rate
|
| 220 |
+
if duration_seconds < self.config.minimum_audio_seconds:
|
| 221 |
+
raise AnalysisError(
|
| 222 |
+
"AUDIO_TOO_SHORT",
|
| 223 |
+
f"Audio must be at least {self.config.minimum_audio_seconds:g} second(s).",
|
| 224 |
+
status_code=422,
|
| 225 |
+
)
|
| 226 |
+
window_samples = int(round(self.config.window_seconds * self.config.sample_rate))
|
| 227 |
+
hop_samples = int(round(self.config.hop_seconds * self.config.sample_rate))
|
| 228 |
+
starts = temporal_window_starts(
|
| 229 |
+
waveform.size,
|
| 230 |
+
window_samples,
|
| 231 |
+
hop_samples,
|
| 232 |
+
self.config.max_segments,
|
| 233 |
+
)
|
| 234 |
+
windows = [fixed_window(waveform, start, window_samples) for start in starts]
|
| 235 |
+
embeddings = normalize_rows(await self.encoder.encode(windows))
|
| 236 |
+
segments = [
|
| 237 |
+
TemporalSegment(
|
| 238 |
+
start_ms=int(round(start * 1000 / self.config.sample_rate)),
|
| 239 |
+
end_ms=int(
|
| 240 |
+
round(
|
| 241 |
+
min(start + window_samples, waveform.size)
|
| 242 |
+
* 1000
|
| 243 |
+
/ self.config.sample_rate
|
| 244 |
+
)
|
| 245 |
+
),
|
| 246 |
+
embedding=finite_float_list(embedding),
|
| 247 |
+
)
|
| 248 |
+
for start, embedding in zip(starts, embeddings, strict=True)
|
| 249 |
+
]
|
| 250 |
+
summary = _trajectory_summary(embeddings)
|
| 251 |
+
metadata = self.metadata
|
| 252 |
+
return TemporalEmbeddingResult(
|
| 253 |
+
**metadata.result_metadata(), segments=segments, summary=summary
|
| 254 |
+
)
|
| 255 |
+
except AnalysisError:
|
| 256 |
+
raise
|
| 257 |
+
except Exception as exc:
|
| 258 |
+
log.exception("Temporal CLAP inference failed")
|
| 259 |
+
raise ModelInferenceError(self.representation) from exc
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _trajectory_summary(embeddings: np.ndarray) -> TemporalSummary:
|
| 263 |
+
if embeddings.shape[0] > 1:
|
| 264 |
+
adjacent = 1.0 - np.sum(embeddings[:-1] * embeddings[1:], axis=1)
|
| 265 |
+
adjacent = np.clip(adjacent, 0.0, 2.0)
|
| 266 |
+
largest_index: int | None = int(np.argmax(adjacent)) + 1
|
| 267 |
+
mean_adjacent = float(np.mean(adjacent))
|
| 268 |
+
max_adjacent = float(np.max(adjacent))
|
| 269 |
+
else:
|
| 270 |
+
largest_index = None
|
| 271 |
+
mean_adjacent = 0.0
|
| 272 |
+
max_adjacent = 0.0
|
| 273 |
+
centroid = np.mean(embeddings, axis=0, dtype=np.float64)
|
| 274 |
+
variance = float(np.mean(np.sum((embeddings - centroid) ** 2, axis=1)))
|
| 275 |
+
return TemporalSummary(
|
| 276 |
+
number_of_segments=int(embeddings.shape[0]),
|
| 277 |
+
mean_adjacent_distance=mean_adjacent,
|
| 278 |
+
max_adjacent_distance=max_adjacent,
|
| 279 |
+
trajectory_variance=variance,
|
| 280 |
+
largest_transition_index=largest_index,
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _clap_encoder_windows(
|
| 285 |
+
windows: list[np.ndarray], encoder_samples: int
|
| 286 |
+
) -> tuple[list[np.ndarray], list[tuple[int, int]]]:
|
| 287 |
+
"""Map every analysis window to deterministic fixed-size CLAP inputs."""
|
| 288 |
+
encoded: list[np.ndarray] = []
|
| 289 |
+
groups: list[tuple[int, int]] = []
|
| 290 |
+
for window in windows:
|
| 291 |
+
group_start = len(encoded)
|
| 292 |
+
if window.size <= encoder_samples:
|
| 293 |
+
encoded.append(fixed_window(window, 0, encoder_samples))
|
| 294 |
+
else:
|
| 295 |
+
count = max(2, int(np.ceil(window.size / encoder_samples)))
|
| 296 |
+
last_start = window.size - encoder_samples
|
| 297 |
+
starts = np.linspace(0, last_start, count, dtype=np.float64)
|
| 298 |
+
encoded.extend(
|
| 299 |
+
fixed_window(window, int(round(float(start))), encoder_samples)
|
| 300 |
+
for start in starts
|
| 301 |
+
)
|
| 302 |
+
groups.append((group_start, len(encoded)))
|
| 303 |
+
return encoded, groups
|
openmusic_analysis/analyzers/interfaces.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Protocol
|
| 4 |
+
|
| 5 |
+
from openmusic_analysis.audio import AnalysisContext
|
| 6 |
+
from openmusic_analysis.domain import (
|
| 7 |
+
GlobalEmbeddingResult,
|
| 8 |
+
ModelMetadata,
|
| 9 |
+
TemporalEmbeddingResult,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class GlobalAudioAnalyzer(Protocol):
|
| 14 |
+
input_kind: str
|
| 15 |
+
|
| 16 |
+
@property
|
| 17 |
+
def metadata(self) -> ModelMetadata: ...
|
| 18 |
+
|
| 19 |
+
async def analyze(self, context: AnalysisContext) -> GlobalEmbeddingResult: ...
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TemporalAudioAnalyzer(Protocol):
|
| 23 |
+
input_kind: str
|
| 24 |
+
|
| 25 |
+
@property
|
| 26 |
+
def metadata(self) -> ModelMetadata: ...
|
| 27 |
+
|
| 28 |
+
async def analyze(self, context: AnalysisContext) -> TemporalEmbeddingResult: ...
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class LyricsAnalyzer(Protocol):
|
| 32 |
+
input_kind: str
|
| 33 |
+
|
| 34 |
+
@property
|
| 35 |
+
def metadata(self) -> ModelMetadata: ...
|
| 36 |
+
|
| 37 |
+
async def analyze(self, lyrics: str) -> GlobalEmbeddingResult: ...
|
openmusic_analysis/analyzers/lyrics.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import logging
|
| 4 |
+
import re
|
| 5 |
+
import threading
|
| 6 |
+
import unicodedata
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from typing import Protocol
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
|
| 12 |
+
from openmusic_analysis.domain import GlobalEmbeddingResult, ModelMetadata
|
| 13 |
+
from openmusic_analysis.errors import AnalysisError, ModelInferenceError
|
| 14 |
+
from openmusic_analysis.runtime import InferenceGate
|
| 15 |
+
from openmusic_analysis.settings import (
|
| 16 |
+
BGE_MODEL_ID,
|
| 17 |
+
BGE_MODEL_REVISION,
|
| 18 |
+
LyricsConfig,
|
| 19 |
+
config_dict,
|
| 20 |
+
preprocessing_version,
|
| 21 |
+
)
|
| 22 |
+
|
| 23 |
+
from .math import finite_float_list, l2_normalize, normalize_rows
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
log = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
_SECTION_MARKER = re.compile(
|
| 29 |
+
r"^\s*(?:\[|\()?\s*(?P<label>"
|
| 30 |
+
r"verse|chorus|pre[- ]?chorus|bridge|intro|outro|hook|refrain|interlude|"
|
| 31 |
+
r"куплет|припев|предприпев|бридж|вступление|проигрыш|финал"
|
| 32 |
+
r")(?:\s+[^\]\)]*)?\s*(?:\]|\))?\s*:?\s*$",
|
| 33 |
+
re.IGNORECASE,
|
| 34 |
+
)
|
| 35 |
+
_LRC_METADATA = re.compile(r"^\s*\[(?:ar|al|ti|by|offset|re|ve|length):.*\]\s*$", re.I)
|
| 36 |
+
_LRC_TIMESTAMP = re.compile(r"^\s*(?:\[\d{1,3}:\d{2}(?:[.:]\d{1,3})?\])+\s*")
|
| 37 |
+
_PURE_URL = re.compile(r"^\s*https?://\S+\s*$", re.I)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass(frozen=True)
|
| 41 |
+
class LyricsSection:
|
| 42 |
+
index: int
|
| 43 |
+
label: str | None
|
| 44 |
+
text: str
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
@dataclass(frozen=True)
|
| 48 |
+
class PreparedLyrics:
|
| 49 |
+
normalized_text: str
|
| 50 |
+
sections: list[LyricsSection]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@dataclass(frozen=True)
|
| 54 |
+
class LyricsChunk:
|
| 55 |
+
section_index: int
|
| 56 |
+
label: str | None
|
| 57 |
+
text: str
|
| 58 |
+
token_count: int
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class TextEmbeddingEncoder(Protocol):
|
| 62 |
+
dimension: int
|
| 63 |
+
loaded: bool
|
| 64 |
+
|
| 65 |
+
async def ready(self) -> None: ...
|
| 66 |
+
|
| 67 |
+
def count_tokens(self, text: str) -> int: ...
|
| 68 |
+
|
| 69 |
+
def split_tokens(self, text: str, max_tokens: int) -> list[str]: ...
|
| 70 |
+
|
| 71 |
+
async def encode(self, texts: list[str], batch_size: int) -> np.ndarray: ...
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class LyricsPreprocessor:
|
| 75 |
+
"""Conservative cleanup that preserves punctuation, lines and repetitions."""
|
| 76 |
+
|
| 77 |
+
def normalize(self, lyrics: str) -> str:
|
| 78 |
+
value = unicodedata.normalize("NFKC", lyrics)
|
| 79 |
+
value = value.replace("\r\n", "\n").replace("\r", "\n").replace("\ufeff", "")
|
| 80 |
+
value = "".join(
|
| 81 |
+
character
|
| 82 |
+
for character in value
|
| 83 |
+
if character in {"\n", "\t"} or unicodedata.category(character) != "Cc"
|
| 84 |
+
)
|
| 85 |
+
cleaned: list[str] = []
|
| 86 |
+
for raw_line in value.split("\n"):
|
| 87 |
+
line = raw_line.rstrip()
|
| 88 |
+
if _LRC_METADATA.match(line) or _PURE_URL.match(line):
|
| 89 |
+
continue
|
| 90 |
+
line = _LRC_TIMESTAMP.sub("", line)
|
| 91 |
+
cleaned.append(line)
|
| 92 |
+
value = "\n".join(cleaned).strip()
|
| 93 |
+
value = re.sub(r"\n{3,}", "\n\n", value)
|
| 94 |
+
return value
|
| 95 |
+
|
| 96 |
+
def prepare(self, lyrics: str) -> PreparedLyrics:
|
| 97 |
+
normalized = self.normalize(lyrics)
|
| 98 |
+
if not normalized.strip():
|
| 99 |
+
raise AnalysisError(
|
| 100 |
+
"INVALID_LYRICS", "Lyrics must contain non-whitespace text.", status_code=422
|
| 101 |
+
)
|
| 102 |
+
sections: list[LyricsSection] = []
|
| 103 |
+
current_label: str | None = None
|
| 104 |
+
current_lines: list[str] = []
|
| 105 |
+
|
| 106 |
+
def flush() -> None:
|
| 107 |
+
nonlocal current_lines
|
| 108 |
+
text = "\n".join(current_lines).strip()
|
| 109 |
+
if text:
|
| 110 |
+
sections.append(
|
| 111 |
+
LyricsSection(index=len(sections), label=current_label, text=text)
|
| 112 |
+
)
|
| 113 |
+
current_lines = []
|
| 114 |
+
|
| 115 |
+
for line in normalized.split("\n"):
|
| 116 |
+
marker = _SECTION_MARKER.match(line)
|
| 117 |
+
if marker:
|
| 118 |
+
flush()
|
| 119 |
+
current_label = marker.group("label").strip()
|
| 120 |
+
continue
|
| 121 |
+
if not line.strip() and current_lines:
|
| 122 |
+
flush()
|
| 123 |
+
current_label = None
|
| 124 |
+
continue
|
| 125 |
+
if line.strip():
|
| 126 |
+
current_lines.append(line)
|
| 127 |
+
flush()
|
| 128 |
+
if not sections:
|
| 129 |
+
sections = [LyricsSection(index=0, label=None, text=normalized)]
|
| 130 |
+
return PreparedLyrics(normalized_text=normalized, sections=sections)
|
| 131 |
+
|
| 132 |
+
def chunks(
|
| 133 |
+
self,
|
| 134 |
+
prepared: PreparedLyrics,
|
| 135 |
+
encoder: TextEmbeddingEncoder,
|
| 136 |
+
max_tokens: int,
|
| 137 |
+
) -> list[LyricsChunk]:
|
| 138 |
+
chunks: list[LyricsChunk] = []
|
| 139 |
+
for section in prepared.sections:
|
| 140 |
+
prefix = f"[{section.label}]\n" if section.label else ""
|
| 141 |
+
candidate = prefix + section.text
|
| 142 |
+
if encoder.count_tokens(candidate) <= max_tokens:
|
| 143 |
+
chunks.append(
|
| 144 |
+
LyricsChunk(
|
| 145 |
+
section_index=section.index,
|
| 146 |
+
label=section.label,
|
| 147 |
+
text=candidate,
|
| 148 |
+
token_count=max(1, encoder.count_tokens(candidate)),
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
continue
|
| 152 |
+
for piece in _split_section_lines(section.text, encoder, max_tokens, prefix):
|
| 153 |
+
count = max(1, encoder.count_tokens(piece))
|
| 154 |
+
chunks.append(
|
| 155 |
+
LyricsChunk(
|
| 156 |
+
section_index=section.index,
|
| 157 |
+
label=section.label,
|
| 158 |
+
text=piece,
|
| 159 |
+
token_count=count,
|
| 160 |
+
)
|
| 161 |
+
)
|
| 162 |
+
return chunks
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def _split_section_lines(
|
| 166 |
+
text: str,
|
| 167 |
+
encoder: TextEmbeddingEncoder,
|
| 168 |
+
max_tokens: int,
|
| 169 |
+
prefix: str,
|
| 170 |
+
) -> list[str]:
|
| 171 |
+
pieces: list[str] = []
|
| 172 |
+
current: list[str] = []
|
| 173 |
+
for line in text.split("\n"):
|
| 174 |
+
candidate = prefix + "\n".join([*current, line])
|
| 175 |
+
if current and encoder.count_tokens(candidate) > max_tokens:
|
| 176 |
+
pieces.append(prefix + "\n".join(current))
|
| 177 |
+
current = []
|
| 178 |
+
line_candidate = prefix + line
|
| 179 |
+
if encoder.count_tokens(line_candidate) > max_tokens:
|
| 180 |
+
prefix_content_tokens = max(0, encoder.count_tokens(prefix) - 2) if prefix else 0
|
| 181 |
+
piece_limit = max(4, max_tokens - prefix_content_tokens)
|
| 182 |
+
pieces.extend(
|
| 183 |
+
prefix + piece
|
| 184 |
+
for piece in encoder.split_tokens(line, piece_limit)
|
| 185 |
+
if piece.strip()
|
| 186 |
+
)
|
| 187 |
+
else:
|
| 188 |
+
current.append(line)
|
| 189 |
+
if current:
|
| 190 |
+
pieces.append(prefix + "\n".join(current))
|
| 191 |
+
return [piece for piece in pieces if piece.strip()]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
class BGEM3TextEncoder:
|
| 195 |
+
dimension = 1024
|
| 196 |
+
|
| 197 |
+
def __init__(self, device: str, gate: InferenceGate) -> None:
|
| 198 |
+
self.device = device
|
| 199 |
+
self.gate = gate
|
| 200 |
+
self.loaded = False
|
| 201 |
+
self._load_lock = threading.Lock()
|
| 202 |
+
self._tokenizer = None
|
| 203 |
+
self._model = None
|
| 204 |
+
|
| 205 |
+
async def ready(self) -> None:
|
| 206 |
+
await self.gate.run(self._ensure_loaded)
|
| 207 |
+
|
| 208 |
+
def _ensure_loaded(self) -> None:
|
| 209 |
+
if self.loaded:
|
| 210 |
+
return
|
| 211 |
+
with self._load_lock:
|
| 212 |
+
if self.loaded:
|
| 213 |
+
return
|
| 214 |
+
import torch
|
| 215 |
+
from transformers import AutoModel, AutoTokenizer
|
| 216 |
+
|
| 217 |
+
log.info("Loading %s at %s on %s", BGE_MODEL_ID, BGE_MODEL_REVISION, self.device)
|
| 218 |
+
tokenizer = AutoTokenizer.from_pretrained(
|
| 219 |
+
BGE_MODEL_ID, revision=BGE_MODEL_REVISION
|
| 220 |
+
)
|
| 221 |
+
model = AutoModel.from_pretrained(BGE_MODEL_ID, revision=BGE_MODEL_REVISION)
|
| 222 |
+
if int(model.config.hidden_size) != self.dimension:
|
| 223 |
+
raise RuntimeError("Pinned BGE-M3 dimension does not match registry")
|
| 224 |
+
model.to(torch.device(self.device))
|
| 225 |
+
model.eval()
|
| 226 |
+
self._tokenizer = tokenizer
|
| 227 |
+
self._model = model
|
| 228 |
+
self.loaded = True
|
| 229 |
+
|
| 230 |
+
def count_tokens(self, text: str) -> int:
|
| 231 |
+
self._ensure_loaded()
|
| 232 |
+
assert self._tokenizer is not None
|
| 233 |
+
return len(self._tokenizer.encode(text, add_special_tokens=True))
|
| 234 |
+
|
| 235 |
+
def split_tokens(self, text: str, max_tokens: int) -> list[str]:
|
| 236 |
+
self._ensure_loaded()
|
| 237 |
+
assert self._tokenizer is not None
|
| 238 |
+
usable_tokens = max(1, max_tokens - 2)
|
| 239 |
+
token_ids = self._tokenizer.encode(text, add_special_tokens=False)
|
| 240 |
+
return [
|
| 241 |
+
self._tokenizer.decode(
|
| 242 |
+
token_ids[index : index + usable_tokens], skip_special_tokens=True
|
| 243 |
+
)
|
| 244 |
+
for index in range(0, len(token_ids), usable_tokens)
|
| 245 |
+
]
|
| 246 |
+
|
| 247 |
+
async def encode(self, texts: list[str], batch_size: int) -> np.ndarray:
|
| 248 |
+
return await self.gate.run(self._encode_sync, texts, batch_size)
|
| 249 |
+
|
| 250 |
+
def _encode_sync(self, texts: list[str], batch_size: int) -> np.ndarray:
|
| 251 |
+
self._ensure_loaded()
|
| 252 |
+
import torch
|
| 253 |
+
|
| 254 |
+
assert self._tokenizer is not None and self._model is not None
|
| 255 |
+
outputs: list[np.ndarray] = []
|
| 256 |
+
for index in range(0, len(texts), batch_size):
|
| 257 |
+
batch = texts[index : index + batch_size]
|
| 258 |
+
tokens = self._tokenizer(
|
| 259 |
+
batch,
|
| 260 |
+
padding=True,
|
| 261 |
+
truncation=True,
|
| 262 |
+
max_length=8192,
|
| 263 |
+
return_tensors="pt",
|
| 264 |
+
)
|
| 265 |
+
tokens = {name: value.to(self.device) for name, value in tokens.items()}
|
| 266 |
+
with torch.inference_mode():
|
| 267 |
+
hidden = self._model(**tokens).last_hidden_state[:, 0]
|
| 268 |
+
hidden = torch.nn.functional.normalize(hidden, p=2, dim=1)
|
| 269 |
+
outputs.append(hidden.detach().to(dtype=torch.float32, device="cpu").numpy())
|
| 270 |
+
return np.concatenate(outputs, axis=0)
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
class BGEM3LyricsAnalyzer:
|
| 274 |
+
representation = "lyrics.global"
|
| 275 |
+
input_kind = "lyrics"
|
| 276 |
+
|
| 277 |
+
def __init__(
|
| 278 |
+
self,
|
| 279 |
+
encoder: TextEmbeddingEncoder,
|
| 280 |
+
preprocessor: LyricsPreprocessor,
|
| 281 |
+
config: LyricsConfig,
|
| 282 |
+
) -> None:
|
| 283 |
+
self.encoder = encoder
|
| 284 |
+
self.preprocessor = preprocessor
|
| 285 |
+
self.config = config
|
| 286 |
+
|
| 287 |
+
@property
|
| 288 |
+
def metadata(self) -> ModelMetadata:
|
| 289 |
+
return ModelMetadata(
|
| 290 |
+
representation=self.representation,
|
| 291 |
+
model_id=BGE_MODEL_ID,
|
| 292 |
+
model_version=BGE_MODEL_REVISION,
|
| 293 |
+
modality=self.representation,
|
| 294 |
+
dimension=self.encoder.dimension,
|
| 295 |
+
dtype="float32",
|
| 296 |
+
normalized=True,
|
| 297 |
+
preprocessing_version=preprocessing_version("lyrics-bge-m3-v1", self.config),
|
| 298 |
+
license="MIT",
|
| 299 |
+
configuration=config_dict(self.config),
|
| 300 |
+
loaded=self.encoder.loaded,
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
async def analyze(self, lyrics: str) -> GlobalEmbeddingResult:
|
| 304 |
+
try:
|
| 305 |
+
prepared = self.preprocessor.prepare(lyrics)
|
| 306 |
+
await self.encoder.ready()
|
| 307 |
+
chunks = self.preprocessor.chunks(
|
| 308 |
+
prepared, self.encoder, self.config.max_chunk_tokens
|
| 309 |
+
)
|
| 310 |
+
vectors = normalize_rows(
|
| 311 |
+
await self.encoder.encode(
|
| 312 |
+
[chunk.text for chunk in chunks], self.config.batch_size
|
| 313 |
+
)
|
| 314 |
+
)
|
| 315 |
+
weights = np.asarray([chunk.token_count for chunk in chunks], dtype=np.float64)
|
| 316 |
+
aggregate = l2_normalize(np.average(vectors, axis=0, weights=weights))
|
| 317 |
+
metadata = self.metadata
|
| 318 |
+
return GlobalEmbeddingResult(
|
| 319 |
+
**metadata.result_metadata(),
|
| 320 |
+
embedding=finite_float_list(aggregate),
|
| 321 |
+
analysis={
|
| 322 |
+
"section_count": len(prepared.sections),
|
| 323 |
+
"chunk_count": len(chunks),
|
| 324 |
+
"chunk_token_counts": [chunk.token_count for chunk in chunks],
|
| 325 |
+
},
|
| 326 |
+
)
|
| 327 |
+
except AnalysisError:
|
| 328 |
+
raise
|
| 329 |
+
except Exception as exc:
|
| 330 |
+
log.exception("BGE-M3 lyrics inference failed")
|
| 331 |
+
raise ModelInferenceError(self.representation) from exc
|
openmusic_analysis/analyzers/math.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def l2_normalize(vector: np.ndarray) -> np.ndarray:
|
| 7 |
+
value = np.asarray(vector, dtype=np.float32)
|
| 8 |
+
norm = float(np.linalg.norm(value))
|
| 9 |
+
if not np.isfinite(norm) or norm <= 0.0:
|
| 10 |
+
raise ValueError("Cannot normalize a non-finite or zero embedding")
|
| 11 |
+
return np.ascontiguousarray(value / norm, dtype=np.float32)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def normalize_rows(matrix: np.ndarray) -> np.ndarray:
|
| 15 |
+
value = np.asarray(matrix, dtype=np.float32)
|
| 16 |
+
norms = np.linalg.norm(value, axis=1, keepdims=True)
|
| 17 |
+
if not np.isfinite(norms).all() or np.any(norms <= 0.0):
|
| 18 |
+
raise ValueError("Cannot normalize non-finite or zero embeddings")
|
| 19 |
+
return np.ascontiguousarray(value / norms, dtype=np.float32)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def finite_float_list(vector: np.ndarray) -> list[float]:
|
| 23 |
+
value = np.asarray(vector, dtype=np.float32)
|
| 24 |
+
if not np.isfinite(value).all():
|
| 25 |
+
raise ValueError("Embedding contains non-finite values")
|
| 26 |
+
return value.tolist()
|
openmusic_analysis/api.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import tempfile
|
| 8 |
+
from contextlib import asynccontextmanager
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Annotated, Any
|
| 11 |
+
|
| 12 |
+
from fastapi import FastAPI, File, Form, Request, UploadFile
|
| 13 |
+
from fastapi.exceptions import RequestValidationError
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
from openmusic_analysis.application import MusicAnalysisService, build_service
|
| 17 |
+
from openmusic_analysis.domain import AnalysisResponse, ModelsResponse
|
| 18 |
+
from openmusic_analysis.errors import AnalysisError
|
| 19 |
+
from openmusic_analysis.settings import Settings
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
log = logging.getLogger(__name__)
|
| 23 |
+
SUPPORTED_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".aac", ".ogg", ".opus"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def create_app(
|
| 27 |
+
*,
|
| 28 |
+
service: MusicAnalysisService | None = None,
|
| 29 |
+
settings: Settings | None = None,
|
| 30 |
+
) -> FastAPI:
|
| 31 |
+
settings = settings or Settings.from_env()
|
| 32 |
+
service = service or build_service(settings)
|
| 33 |
+
|
| 34 |
+
@asynccontextmanager
|
| 35 |
+
async def lifespan(_: FastAPI):
|
| 36 |
+
if settings.eager_model_loading:
|
| 37 |
+
await service.load_models()
|
| 38 |
+
yield
|
| 39 |
+
|
| 40 |
+
app = FastAPI(
|
| 41 |
+
title="OpenMusic Music Analysis API",
|
| 42 |
+
version="1.0.0",
|
| 43 |
+
lifespan=lifespan,
|
| 44 |
+
)
|
| 45 |
+
app.state.analysis_service = service
|
| 46 |
+
app.state.settings = settings
|
| 47 |
+
|
| 48 |
+
@app.middleware("http")
|
| 49 |
+
async def request_limits(request: Request, call_next: Any):
|
| 50 |
+
content_length = request.headers.get("content-length")
|
| 51 |
+
request_limit = (
|
| 52 |
+
settings.limits.max_upload_bytes
|
| 53 |
+
+ settings.limits.max_lyrics_characters * 4
|
| 54 |
+
+ 1024 * 1024
|
| 55 |
+
)
|
| 56 |
+
if content_length:
|
| 57 |
+
try:
|
| 58 |
+
if int(content_length) > request_limit:
|
| 59 |
+
return _error_response(
|
| 60 |
+
"REQUEST_TOO_LARGE", "Request body exceeds the configured limit.", 413
|
| 61 |
+
)
|
| 62 |
+
except ValueError:
|
| 63 |
+
return _error_response("INVALID_CONTENT_LENGTH", "Invalid Content-Length.", 400)
|
| 64 |
+
try:
|
| 65 |
+
async with asyncio.timeout(settings.limits.request_timeout_seconds):
|
| 66 |
+
return await call_next(request)
|
| 67 |
+
except TimeoutError:
|
| 68 |
+
return _error_response("REQUEST_TIMEOUT", "Analysis timed out.", 504)
|
| 69 |
+
|
| 70 |
+
@app.exception_handler(AnalysisError)
|
| 71 |
+
async def analysis_error_handler(_: Request, exc: AnalysisError):
|
| 72 |
+
return _error_response(exc.code, exc.message, exc.status_code, exc.details)
|
| 73 |
+
|
| 74 |
+
@app.exception_handler(RequestValidationError)
|
| 75 |
+
async def validation_error_handler(_: Request, exc: RequestValidationError):
|
| 76 |
+
missing_audio = any(
|
| 77 |
+
error.get("type") == "missing" and tuple(error.get("loc", ())) == ("body", "audio")
|
| 78 |
+
for error in exc.errors()
|
| 79 |
+
)
|
| 80 |
+
if missing_audio:
|
| 81 |
+
return _error_response("MISSING_AUDIO", "Multipart field 'audio' is required.", 422)
|
| 82 |
+
return _error_response(
|
| 83 |
+
"INVALID_REQUEST",
|
| 84 |
+
"Request validation failed.",
|
| 85 |
+
422,
|
| 86 |
+
{"errors": _safe_validation_errors(exc.errors())},
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
@app.exception_handler(Exception)
|
| 90 |
+
async def unhandled_error_handler(request: Request, exc: Exception):
|
| 91 |
+
log.exception("Unhandled error for %s", request.url.path, exc_info=exc)
|
| 92 |
+
return _error_response("INTERNAL_ERROR", "Internal server error.", 500)
|
| 93 |
+
|
| 94 |
+
@app.get("/v1/models", response_model=ModelsResponse)
|
| 95 |
+
async def models() -> ModelsResponse:
|
| 96 |
+
return ModelsResponse(models=service.registry.models())
|
| 97 |
+
|
| 98 |
+
@app.get("/v1/status")
|
| 99 |
+
async def status() -> dict[str, Any]:
|
| 100 |
+
return {
|
| 101 |
+
"status": "ok",
|
| 102 |
+
"device": service.device,
|
| 103 |
+
"loaded_models": service.registry.loaded_representations(),
|
| 104 |
+
"available_representations": list(service.registry.representations),
|
| 105 |
+
}
|
| 106 |
+
|
| 107 |
+
@app.get("/health")
|
| 108 |
+
async def health() -> dict[str, str]:
|
| 109 |
+
return {"status": "ok"}
|
| 110 |
+
|
| 111 |
+
@app.post("/v1/tracks/analyze", response_model=AnalysisResponse)
|
| 112 |
+
async def analyze_track(
|
| 113 |
+
audio: Annotated[UploadFile, File(description="Audio file")],
|
| 114 |
+
lyrics: Annotated[str | None, Form()] = None,
|
| 115 |
+
track_id: Annotated[str | None, Form()] = None,
|
| 116 |
+
content_identity: Annotated[str | None, Form()] = None,
|
| 117 |
+
requested_representations: Annotated[list[str] | None, Form()] = None,
|
| 118 |
+
) -> AnalysisResponse:
|
| 119 |
+
if lyrics is not None and len(lyrics) > settings.limits.max_lyrics_characters:
|
| 120 |
+
raise AnalysisError(
|
| 121 |
+
"LYRICS_TOO_LARGE",
|
| 122 |
+
"Lyrics exceed the configured character limit.",
|
| 123 |
+
status_code=413,
|
| 124 |
+
)
|
| 125 |
+
suffix = Path(audio.filename or "").suffix.lower()
|
| 126 |
+
if suffix not in SUPPORTED_EXTENSIONS:
|
| 127 |
+
raise AnalysisError(
|
| 128 |
+
"UNSUPPORTED_AUDIO_FORMAT",
|
| 129 |
+
f"Supported extensions: {', '.join(sorted(SUPPORTED_EXTENSIONS))}.",
|
| 130 |
+
status_code=415,
|
| 131 |
+
)
|
| 132 |
+
representations = _parse_representations(requested_representations)
|
| 133 |
+
temp_path = await _store_upload(audio, suffix, settings.limits.max_upload_bytes)
|
| 134 |
+
try:
|
| 135 |
+
return await service.analyze(
|
| 136 |
+
temp_path,
|
| 137 |
+
lyrics=lyrics,
|
| 138 |
+
requested_representations=representations,
|
| 139 |
+
track_id=track_id,
|
| 140 |
+
content_identity=content_identity,
|
| 141 |
+
)
|
| 142 |
+
finally:
|
| 143 |
+
try:
|
| 144 |
+
os.unlink(temp_path)
|
| 145 |
+
except FileNotFoundError:
|
| 146 |
+
pass
|
| 147 |
+
|
| 148 |
+
return app
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
async def _store_upload(upload: UploadFile, suffix: str, max_bytes: int) -> str:
|
| 152 |
+
size = 0
|
| 153 |
+
path: str | None = None
|
| 154 |
+
try:
|
| 155 |
+
with tempfile.NamedTemporaryFile(prefix="openmusic-", suffix=suffix, delete=False) as file:
|
| 156 |
+
path = file.name
|
| 157 |
+
while chunk := await upload.read(1024 * 1024):
|
| 158 |
+
size += len(chunk)
|
| 159 |
+
if size > max_bytes:
|
| 160 |
+
raise AnalysisError(
|
| 161 |
+
"AUDIO_TOO_LARGE",
|
| 162 |
+
"Audio upload exceeds the configured byte limit.",
|
| 163 |
+
status_code=413,
|
| 164 |
+
)
|
| 165 |
+
file.write(chunk)
|
| 166 |
+
if size == 0:
|
| 167 |
+
raise AnalysisError("EMPTY_AUDIO", "Uploaded audio is empty.", status_code=422)
|
| 168 |
+
return path
|
| 169 |
+
except Exception:
|
| 170 |
+
if path:
|
| 171 |
+
try:
|
| 172 |
+
os.unlink(path)
|
| 173 |
+
except FileNotFoundError:
|
| 174 |
+
pass
|
| 175 |
+
raise
|
| 176 |
+
finally:
|
| 177 |
+
await upload.close()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _parse_representations(values: list[str] | None) -> list[str] | None:
|
| 181 |
+
if values is None:
|
| 182 |
+
return None
|
| 183 |
+
parsed: list[str] = []
|
| 184 |
+
for value in values:
|
| 185 |
+
stripped = value.strip()
|
| 186 |
+
if stripped.startswith("["):
|
| 187 |
+
try:
|
| 188 |
+
decoded = json.loads(stripped)
|
| 189 |
+
except json.JSONDecodeError as exc:
|
| 190 |
+
raise AnalysisError(
|
| 191 |
+
"INVALID_REPRESENTATIONS",
|
| 192 |
+
"requested_representations contains invalid JSON.",
|
| 193 |
+
status_code=422,
|
| 194 |
+
) from exc
|
| 195 |
+
if not isinstance(decoded, list) or not all(isinstance(item, str) for item in decoded):
|
| 196 |
+
raise AnalysisError(
|
| 197 |
+
"INVALID_REPRESENTATIONS",
|
| 198 |
+
"requested_representations JSON must be an array of strings.",
|
| 199 |
+
status_code=422,
|
| 200 |
+
)
|
| 201 |
+
parsed.extend(decoded)
|
| 202 |
+
else:
|
| 203 |
+
parsed.extend(part.strip() for part in stripped.split(",") if part.strip())
|
| 204 |
+
return parsed
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def _error_response(
|
| 208 |
+
code: str,
|
| 209 |
+
message: str,
|
| 210 |
+
status_code: int,
|
| 211 |
+
details: dict[str, Any] | None = None,
|
| 212 |
+
) -> JSONResponse:
|
| 213 |
+
body: dict[str, Any] = {"error": {"code": code, "message": message}}
|
| 214 |
+
if details is not None:
|
| 215 |
+
body["error"]["details"] = details
|
| 216 |
+
return JSONResponse(status_code=status_code, content=body)
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def _safe_validation_errors(errors: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 220 |
+
return [
|
| 221 |
+
{
|
| 222 |
+
"type": error.get("type"),
|
| 223 |
+
"location": list(error.get("loc", ())),
|
| 224 |
+
"message": error.get("msg"),
|
| 225 |
+
}
|
| 226 |
+
for error in errors
|
| 227 |
+
]
|
openmusic_analysis/application.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from openmusic_analysis.analyzers import (
|
| 6 |
+
BGEM3LyricsAnalyzer,
|
| 7 |
+
BGEM3TextEncoder,
|
| 8 |
+
ClapAudioEncoder,
|
| 9 |
+
ClapGlobalAudioAnalyzer,
|
| 10 |
+
ClapTemporalAudioAnalyzer,
|
| 11 |
+
LyricsPreprocessor,
|
| 12 |
+
)
|
| 13 |
+
from openmusic_analysis.audio import AnalysisContext, AudioDecoder
|
| 14 |
+
from openmusic_analysis.domain import AnalysisResponse, TrackReference
|
| 15 |
+
from openmusic_analysis.errors import AnalysisError
|
| 16 |
+
from openmusic_analysis.registry import ModelRegistry
|
| 17 |
+
from openmusic_analysis.runtime import DeviceManager, InferenceGate
|
| 18 |
+
from openmusic_analysis.settings import Settings
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
DEFAULT_AUDIO_REPRESENTATIONS = ("audio.global", "audio.temporal")
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class MusicAnalysisService:
|
| 25 |
+
def __init__(
|
| 26 |
+
self,
|
| 27 |
+
*,
|
| 28 |
+
registry: ModelRegistry,
|
| 29 |
+
decoder: AudioDecoder,
|
| 30 |
+
device: str,
|
| 31 |
+
) -> None:
|
| 32 |
+
self.registry = registry
|
| 33 |
+
self.decoder = decoder
|
| 34 |
+
self.device = device
|
| 35 |
+
|
| 36 |
+
async def analyze(
|
| 37 |
+
self,
|
| 38 |
+
source_path: str | Path,
|
| 39 |
+
*,
|
| 40 |
+
lyrics: str | None,
|
| 41 |
+
requested_representations: list[str] | None,
|
| 42 |
+
track_id: str | None,
|
| 43 |
+
content_identity: str | None,
|
| 44 |
+
) -> AnalysisResponse:
|
| 45 |
+
if lyrics is not None and not lyrics.strip():
|
| 46 |
+
raise AnalysisError(
|
| 47 |
+
"INVALID_LYRICS", "Lyrics must contain non-whitespace text.", status_code=422
|
| 48 |
+
)
|
| 49 |
+
requested = self._resolve_representations(requested_representations, lyrics)
|
| 50 |
+
context = AnalysisContext(source_path, self.decoder)
|
| 51 |
+
results = {}
|
| 52 |
+
for representation in requested:
|
| 53 |
+
analyzer = self.registry.analyzer(representation)
|
| 54 |
+
if analyzer.input_kind == "audio":
|
| 55 |
+
result = await analyzer.analyze(context)
|
| 56 |
+
elif analyzer.input_kind == "lyrics":
|
| 57 |
+
if lyrics is None:
|
| 58 |
+
raise AnalysisError(
|
| 59 |
+
"LYRICS_REQUIRED",
|
| 60 |
+
f"Representation '{representation}' requires lyrics.",
|
| 61 |
+
status_code=422,
|
| 62 |
+
)
|
| 63 |
+
result = await analyzer.analyze(lyrics)
|
| 64 |
+
else:
|
| 65 |
+
raise RuntimeError(f"Unknown analyzer input kind: {analyzer.input_kind}")
|
| 66 |
+
results[representation] = result
|
| 67 |
+
return AnalysisResponse(
|
| 68 |
+
track=TrackReference(track_id=track_id, content_identity=content_identity),
|
| 69 |
+
representations=results,
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def _resolve_representations(
|
| 73 |
+
self, requested: list[str] | None, lyrics: str | None
|
| 74 |
+
) -> list[str]:
|
| 75 |
+
if requested is None:
|
| 76 |
+
values = list(DEFAULT_AUDIO_REPRESENTATIONS)
|
| 77 |
+
if lyrics is not None:
|
| 78 |
+
values.append("lyrics.global")
|
| 79 |
+
else:
|
| 80 |
+
values = list(dict.fromkeys(requested))
|
| 81 |
+
if not values:
|
| 82 |
+
raise AnalysisError(
|
| 83 |
+
"INVALID_REPRESENTATIONS",
|
| 84 |
+
"requested_representations must not be empty.",
|
| 85 |
+
status_code=422,
|
| 86 |
+
)
|
| 87 |
+
unsupported = [value for value in values if value not in self.registry.representations]
|
| 88 |
+
if unsupported:
|
| 89 |
+
raise AnalysisError(
|
| 90 |
+
"UNSUPPORTED_REPRESENTATION",
|
| 91 |
+
f"Unsupported representation(s): {', '.join(unsupported)}.",
|
| 92 |
+
status_code=422,
|
| 93 |
+
details={"supported": list(self.registry.representations)},
|
| 94 |
+
)
|
| 95 |
+
return values
|
| 96 |
+
|
| 97 |
+
async def load_models(self) -> None:
|
| 98 |
+
seen: set[int] = set()
|
| 99 |
+
for representation in self.registry.representations:
|
| 100 |
+
analyzer = self.registry.analyzer(representation)
|
| 101 |
+
encoder = getattr(analyzer, "encoder", None)
|
| 102 |
+
if encoder is not None and id(encoder) not in seen:
|
| 103 |
+
seen.add(id(encoder))
|
| 104 |
+
await encoder.ready()
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def build_service(settings: Settings | None = None) -> MusicAnalysisService:
|
| 108 |
+
settings = settings or Settings.from_env()
|
| 109 |
+
device = DeviceManager.select(settings.device)
|
| 110 |
+
gate = InferenceGate(settings.inference_concurrency)
|
| 111 |
+
clap_encoder = ClapAudioEncoder(
|
| 112 |
+
device, gate, batch_size=settings.global_audio.inference_batch_size
|
| 113 |
+
)
|
| 114 |
+
text_encoder = BGEM3TextEncoder(device, gate)
|
| 115 |
+
analyzers = [
|
| 116 |
+
ClapGlobalAudioAnalyzer(clap_encoder, settings.global_audio),
|
| 117 |
+
ClapTemporalAudioAnalyzer(clap_encoder, settings.temporal_audio),
|
| 118 |
+
BGEM3LyricsAnalyzer(text_encoder, LyricsPreprocessor(), settings.lyrics),
|
| 119 |
+
]
|
| 120 |
+
registry = ModelRegistry(analyzers)
|
| 121 |
+
decoder = AudioDecoder(
|
| 122 |
+
ffmpeg_binary=settings.ffmpeg_binary,
|
| 123 |
+
ffprobe_binary=settings.ffprobe_binary,
|
| 124 |
+
canonical_sample_rate=settings.global_audio.sample_rate,
|
| 125 |
+
max_audio_seconds=settings.limits.max_audio_seconds,
|
| 126 |
+
timeout_seconds=settings.limits.decode_timeout_seconds,
|
| 127 |
+
)
|
| 128 |
+
return MusicAnalysisService(registry=registry, decoder=decoder, device=device)
|
openmusic_analysis/audio/__init__.py
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .context import AnalysisContext
|
| 2 |
+
from .decoder import AudioDecoder, AudioMetadata, DecodedAudio
|
| 3 |
+
|
| 4 |
+
__all__ = ["AnalysisContext", "AudioDecoder", "AudioMetadata", "DecodedAudio"]
|
openmusic_analysis/audio/context.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
import math
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from scipy.signal import resample_poly
|
| 9 |
+
|
| 10 |
+
from .decoder import AudioDecoder, AudioMetadata, DecodedAudio
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class AnalysisContext:
|
| 14 |
+
"""Request-scoped decoded-audio and resampling cache."""
|
| 15 |
+
|
| 16 |
+
def __init__(self, source_path: str | Path, decoder: AudioDecoder) -> None:
|
| 17 |
+
self.source_path = Path(source_path)
|
| 18 |
+
self.decoder = decoder
|
| 19 |
+
self._decoded: DecodedAudio | None = None
|
| 20 |
+
self._waveforms: dict[int, np.ndarray] = {}
|
| 21 |
+
self._lock = asyncio.Lock()
|
| 22 |
+
|
| 23 |
+
@property
|
| 24 |
+
def metadata(self) -> AudioMetadata | None:
|
| 25 |
+
return self._decoded.metadata if self._decoded else None
|
| 26 |
+
|
| 27 |
+
async def waveform(self, sample_rate: int) -> np.ndarray:
|
| 28 |
+
cached = self._waveforms.get(sample_rate)
|
| 29 |
+
if cached is not None:
|
| 30 |
+
return cached
|
| 31 |
+
async with self._lock:
|
| 32 |
+
cached = self._waveforms.get(sample_rate)
|
| 33 |
+
if cached is not None:
|
| 34 |
+
return cached
|
| 35 |
+
if self._decoded is None:
|
| 36 |
+
self._decoded = await asyncio.to_thread(self.decoder.decode, self.source_path)
|
| 37 |
+
self._waveforms[self._decoded.sample_rate] = self._decoded.waveform
|
| 38 |
+
if sample_rate not in self._waveforms:
|
| 39 |
+
source = self._decoded.waveform
|
| 40 |
+
source_rate = self._decoded.sample_rate
|
| 41 |
+
waveform = await asyncio.to_thread(
|
| 42 |
+
_resample_deterministic, source, source_rate, sample_rate
|
| 43 |
+
)
|
| 44 |
+
self._waveforms[sample_rate] = waveform
|
| 45 |
+
return self._waveforms[sample_rate]
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _resample_deterministic(
|
| 49 |
+
waveform: np.ndarray, source_rate: int, target_rate: int
|
| 50 |
+
) -> np.ndarray:
|
| 51 |
+
if source_rate == target_rate:
|
| 52 |
+
return waveform
|
| 53 |
+
divisor = math.gcd(source_rate, target_rate)
|
| 54 |
+
result = resample_poly(
|
| 55 |
+
waveform.astype(np.float64, copy=False),
|
| 56 |
+
target_rate // divisor,
|
| 57 |
+
source_rate // divisor,
|
| 58 |
+
window=("kaiser", 5.0),
|
| 59 |
+
padtype="constant",
|
| 60 |
+
)
|
| 61 |
+
return np.ascontiguousarray(result, dtype=np.float32)
|
openmusic_analysis/audio/decoder.py
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import subprocess
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
from openmusic_analysis.errors import AudioDecodeError
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass(frozen=True)
|
| 14 |
+
class AudioMetadata:
|
| 15 |
+
duration_ms: int
|
| 16 |
+
source_sample_rate: int | None
|
| 17 |
+
source_channels: int | None
|
| 18 |
+
source_format: str | None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class DecodedAudio:
|
| 23 |
+
waveform: np.ndarray
|
| 24 |
+
sample_rate: int
|
| 25 |
+
metadata: AudioMetadata
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class AudioDecoder:
|
| 29 |
+
"""The only source-file decoder; analyzers receive canonical in-memory PCM."""
|
| 30 |
+
|
| 31 |
+
def __init__(
|
| 32 |
+
self,
|
| 33 |
+
*,
|
| 34 |
+
ffmpeg_binary: str = "ffmpeg",
|
| 35 |
+
ffprobe_binary: str = "ffprobe",
|
| 36 |
+
canonical_sample_rate: int = 48_000,
|
| 37 |
+
max_audio_seconds: float = 1800.0,
|
| 38 |
+
timeout_seconds: float = 120.0,
|
| 39 |
+
) -> None:
|
| 40 |
+
self.ffmpeg_binary = ffmpeg_binary
|
| 41 |
+
self.ffprobe_binary = ffprobe_binary
|
| 42 |
+
self.canonical_sample_rate = canonical_sample_rate
|
| 43 |
+
self.max_audio_seconds = max_audio_seconds
|
| 44 |
+
self.timeout_seconds = timeout_seconds
|
| 45 |
+
|
| 46 |
+
def decode(self, source_path: str | Path) -> DecodedAudio:
|
| 47 |
+
path = str(source_path)
|
| 48 |
+
probe = self._probe(path)
|
| 49 |
+
if probe["duration"] is not None and probe["duration"] > self.max_audio_seconds:
|
| 50 |
+
raise AudioDecodeError(
|
| 51 |
+
f"Audio duration exceeds the {self.max_audio_seconds:g} second limit."
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
command = [
|
| 55 |
+
self.ffmpeg_binary,
|
| 56 |
+
"-v",
|
| 57 |
+
"error",
|
| 58 |
+
"-nostdin",
|
| 59 |
+
"-threads",
|
| 60 |
+
"1",
|
| 61 |
+
"-i",
|
| 62 |
+
path,
|
| 63 |
+
"-map",
|
| 64 |
+
"0:a:0",
|
| 65 |
+
"-vn",
|
| 66 |
+
"-ac",
|
| 67 |
+
"1",
|
| 68 |
+
"-ar",
|
| 69 |
+
str(self.canonical_sample_rate),
|
| 70 |
+
"-acodec",
|
| 71 |
+
"pcm_f32le",
|
| 72 |
+
"-f",
|
| 73 |
+
"f32le",
|
| 74 |
+
"pipe:1",
|
| 75 |
+
]
|
| 76 |
+
try:
|
| 77 |
+
completed = subprocess.run(
|
| 78 |
+
command,
|
| 79 |
+
check=False,
|
| 80 |
+
stdout=subprocess.PIPE,
|
| 81 |
+
stderr=subprocess.PIPE,
|
| 82 |
+
timeout=self.timeout_seconds,
|
| 83 |
+
)
|
| 84 |
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
| 85 |
+
raise AudioDecodeError() from exc
|
| 86 |
+
if completed.returncode != 0 or not completed.stdout:
|
| 87 |
+
raise AudioDecodeError()
|
| 88 |
+
|
| 89 |
+
waveform = np.frombuffer(completed.stdout, dtype="<f4").copy()
|
| 90 |
+
if waveform.size == 0 or not np.isfinite(waveform).all():
|
| 91 |
+
raise AudioDecodeError()
|
| 92 |
+
actual_duration = waveform.size / self.canonical_sample_rate
|
| 93 |
+
if actual_duration > self.max_audio_seconds:
|
| 94 |
+
raise AudioDecodeError(
|
| 95 |
+
f"Audio duration exceeds the {self.max_audio_seconds:g} second limit."
|
| 96 |
+
)
|
| 97 |
+
metadata = AudioMetadata(
|
| 98 |
+
duration_ms=int(round(actual_duration * 1000)),
|
| 99 |
+
source_sample_rate=probe["sample_rate"],
|
| 100 |
+
source_channels=probe["channels"],
|
| 101 |
+
source_format=probe["format"],
|
| 102 |
+
)
|
| 103 |
+
return DecodedAudio(
|
| 104 |
+
waveform=np.ascontiguousarray(waveform, dtype=np.float32),
|
| 105 |
+
sample_rate=self.canonical_sample_rate,
|
| 106 |
+
metadata=metadata,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
def _probe(self, path: str) -> dict[str, int | float | str | None]:
|
| 110 |
+
command = [
|
| 111 |
+
self.ffprobe_binary,
|
| 112 |
+
"-v",
|
| 113 |
+
"error",
|
| 114 |
+
"-select_streams",
|
| 115 |
+
"a:0",
|
| 116 |
+
"-show_entries",
|
| 117 |
+
"stream=sample_rate,channels,duration:format=format_name,duration",
|
| 118 |
+
"-of",
|
| 119 |
+
"json",
|
| 120 |
+
path,
|
| 121 |
+
]
|
| 122 |
+
try:
|
| 123 |
+
completed = subprocess.run(
|
| 124 |
+
command,
|
| 125 |
+
check=False,
|
| 126 |
+
stdout=subprocess.PIPE,
|
| 127 |
+
stderr=subprocess.PIPE,
|
| 128 |
+
timeout=min(30.0, self.timeout_seconds),
|
| 129 |
+
text=True,
|
| 130 |
+
)
|
| 131 |
+
payload = json.loads(completed.stdout) if completed.returncode == 0 else {}
|
| 132 |
+
except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError) as exc:
|
| 133 |
+
raise AudioDecodeError() from exc
|
| 134 |
+
streams = payload.get("streams") or []
|
| 135 |
+
if not streams:
|
| 136 |
+
raise AudioDecodeError()
|
| 137 |
+
stream = streams[0]
|
| 138 |
+
container = payload.get("format") or {}
|
| 139 |
+
duration_value = stream.get("duration") or container.get("duration")
|
| 140 |
+
try:
|
| 141 |
+
duration = float(duration_value) if duration_value is not None else None
|
| 142 |
+
except (TypeError, ValueError):
|
| 143 |
+
duration = None
|
| 144 |
+
return {
|
| 145 |
+
"duration": duration,
|
| 146 |
+
"sample_rate": _optional_int(stream.get("sample_rate")),
|
| 147 |
+
"channels": _optional_int(stream.get("channels")),
|
| 148 |
+
"format": container.get("format_name"),
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _optional_int(value: object) -> int | None:
|
| 153 |
+
try:
|
| 154 |
+
return int(value) if value is not None else None
|
| 155 |
+
except (TypeError, ValueError):
|
| 156 |
+
return None
|
openmusic_analysis/audio/windows.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import math
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def global_window_starts(
|
| 9 |
+
duration_samples: int,
|
| 10 |
+
window_samples: int,
|
| 11 |
+
target_windows: int,
|
| 12 |
+
coverage_start: float,
|
| 13 |
+
coverage_end: float,
|
| 14 |
+
) -> list[int]:
|
| 15 |
+
if duration_samples <= window_samples:
|
| 16 |
+
return [0]
|
| 17 |
+
duration_in_windows = duration_samples / window_samples
|
| 18 |
+
count = min(target_windows, max(1, int(math.floor(duration_in_windows + 0.5))))
|
| 19 |
+
if count == 1:
|
| 20 |
+
return [int(round(0.5 * (duration_samples - window_samples)))]
|
| 21 |
+
valid_start = duration_samples - window_samples
|
| 22 |
+
fractions = np.linspace(coverage_start, coverage_end, count, dtype=np.float64)
|
| 23 |
+
return [int(round(float(fraction) * valid_start)) for fraction in fractions]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def temporal_window_starts(
|
| 27 |
+
duration_samples: int,
|
| 28 |
+
window_samples: int,
|
| 29 |
+
hop_samples: int,
|
| 30 |
+
max_segments: int,
|
| 31 |
+
) -> list[int]:
|
| 32 |
+
if duration_samples <= window_samples:
|
| 33 |
+
return [0]
|
| 34 |
+
last_start = duration_samples - window_samples
|
| 35 |
+
starts = list(range(0, last_start + 1, hop_samples))
|
| 36 |
+
if starts[-1] != last_start:
|
| 37 |
+
starts.append(last_start)
|
| 38 |
+
if len(starts) <= max_segments:
|
| 39 |
+
return starts
|
| 40 |
+
indices = np.linspace(0, len(starts) - 1, max_segments, dtype=np.float64)
|
| 41 |
+
selected = sorted({starts[int(round(float(index)))] for index in indices})
|
| 42 |
+
return selected
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def fixed_window(waveform: np.ndarray, start: int, length: int) -> np.ndarray:
|
| 46 |
+
chunk = waveform[start : start + length]
|
| 47 |
+
if chunk.size == length:
|
| 48 |
+
return np.ascontiguousarray(chunk, dtype=np.float32)
|
| 49 |
+
if chunk.size == 0:
|
| 50 |
+
return np.zeros(length, dtype=np.float32)
|
| 51 |
+
repeats = math.ceil(length / chunk.size)
|
| 52 |
+
padded = np.tile(chunk, repeats)[:length]
|
| 53 |
+
return np.ascontiguousarray(padded, dtype=np.float32)
|
openmusic_analysis/domain.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class StrictModel(BaseModel):
|
| 9 |
+
model_config = ConfigDict(extra="forbid")
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ModelMetadata(StrictModel):
|
| 13 |
+
representation: str
|
| 14 |
+
model_id: str
|
| 15 |
+
model_version: str
|
| 16 |
+
modality: str
|
| 17 |
+
dimension: int = Field(gt=0)
|
| 18 |
+
dtype: str = "float32"
|
| 19 |
+
normalized: bool
|
| 20 |
+
preprocessing_version: str
|
| 21 |
+
license: str
|
| 22 |
+
configuration: dict[str, Any] = Field(default_factory=dict)
|
| 23 |
+
loaded: bool = False
|
| 24 |
+
|
| 25 |
+
def result_metadata(self) -> dict[str, Any]:
|
| 26 |
+
return self.model_dump(exclude={"loaded", "license"})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class GlobalEmbeddingResult(StrictModel):
|
| 30 |
+
representation: str
|
| 31 |
+
model_id: str
|
| 32 |
+
model_version: str
|
| 33 |
+
modality: str
|
| 34 |
+
preprocessing_version: str
|
| 35 |
+
dimension: int
|
| 36 |
+
dtype: str
|
| 37 |
+
normalized: bool
|
| 38 |
+
configuration: dict[str, Any]
|
| 39 |
+
embedding: list[float]
|
| 40 |
+
analysis: dict[str, Any] = Field(default_factory=dict)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TemporalSegment(StrictModel):
|
| 44 |
+
start_ms: int = Field(ge=0)
|
| 45 |
+
end_ms: int = Field(gt=0)
|
| 46 |
+
embedding: list[float]
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class TemporalSummary(StrictModel):
|
| 50 |
+
number_of_segments: int = Field(ge=0)
|
| 51 |
+
mean_adjacent_distance: float
|
| 52 |
+
max_adjacent_distance: float
|
| 53 |
+
trajectory_variance: float
|
| 54 |
+
largest_transition_index: int | None
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
class TemporalEmbeddingResult(StrictModel):
|
| 58 |
+
representation: str
|
| 59 |
+
model_id: str
|
| 60 |
+
model_version: str
|
| 61 |
+
modality: str
|
| 62 |
+
preprocessing_version: str
|
| 63 |
+
dimension: int
|
| 64 |
+
dtype: str
|
| 65 |
+
normalized: bool
|
| 66 |
+
configuration: dict[str, Any]
|
| 67 |
+
segments: list[TemporalSegment]
|
| 68 |
+
summary: TemporalSummary
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
RepresentationResult = GlobalEmbeddingResult | TemporalEmbeddingResult
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TrackReference(StrictModel):
|
| 75 |
+
track_id: str | None = None
|
| 76 |
+
content_identity: str | None = None
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class AnalysisResponse(StrictModel):
|
| 80 |
+
schema_version: str = "1"
|
| 81 |
+
track: TrackReference
|
| 82 |
+
representations: dict[str, RepresentationResult]
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class ModelsResponse(StrictModel):
|
| 86 |
+
schema_version: str = "1"
|
| 87 |
+
models: list[ModelMetadata]
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class ErrorBody(StrictModel):
|
| 91 |
+
code: str
|
| 92 |
+
message: str
|
| 93 |
+
details: dict[str, Any] | None = None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class ErrorResponse(StrictModel):
|
| 97 |
+
error: ErrorBody
|
openmusic_analysis/errors.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class AnalysisError(Exception):
|
| 7 |
+
def __init__(
|
| 8 |
+
self,
|
| 9 |
+
code: str,
|
| 10 |
+
message: str,
|
| 11 |
+
*,
|
| 12 |
+
status_code: int = 422,
|
| 13 |
+
details: dict[str, Any] | None = None,
|
| 14 |
+
) -> None:
|
| 15 |
+
super().__init__(message)
|
| 16 |
+
self.code = code
|
| 17 |
+
self.message = message
|
| 18 |
+
self.status_code = status_code
|
| 19 |
+
self.details = details
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class AudioDecodeError(AnalysisError):
|
| 23 |
+
def __init__(self, message: str = "The uploaded audio could not be decoded.") -> None:
|
| 24 |
+
super().__init__("AUDIO_DECODE_FAILED", message, status_code=422)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class ModelInferenceError(AnalysisError):
|
| 28 |
+
def __init__(self, representation: str) -> None:
|
| 29 |
+
super().__init__(
|
| 30 |
+
"MODEL_INFERENCE_FAILED",
|
| 31 |
+
f"Inference failed for representation '{representation}'.",
|
| 32 |
+
status_code=500,
|
| 33 |
+
)
|
openmusic_analysis/experiments/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .temporal_similarity import aligned_interpolation_similarity, dtw_cosine_similarity
|
| 2 |
+
|
| 3 |
+
__all__ = ["aligned_interpolation_similarity", "dtw_cosine_similarity"]
|
openmusic_analysis/experiments/temporal_similarity.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
|
| 5 |
+
from openmusic_analysis.analyzers.math import normalize_rows
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def aligned_interpolation_similarity(
|
| 9 |
+
first: np.ndarray, second: np.ndarray, *, points: int = 32
|
| 10 |
+
) -> float:
|
| 11 |
+
"""Interpolate both trajectories to normalized time and average cosine similarity."""
|
| 12 |
+
if points < 2:
|
| 13 |
+
raise ValueError("points must be at least 2")
|
| 14 |
+
first_value = _validate_trajectory(first)
|
| 15 |
+
second_value = _validate_trajectory(second)
|
| 16 |
+
first_aligned = _interpolate(first_value, points)
|
| 17 |
+
second_aligned = _interpolate(second_value, points)
|
| 18 |
+
return float(np.mean(np.sum(first_aligned * second_aligned, axis=1)))
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def dtw_cosine_similarity(first: np.ndarray, second: np.ndarray) -> float:
|
| 22 |
+
"""Classic DTW over cosine distance, converted to an average-path similarity."""
|
| 23 |
+
first_value = _validate_trajectory(first)
|
| 24 |
+
second_value = _validate_trajectory(second)
|
| 25 |
+
distances = np.clip(1.0 - first_value @ second_value.T, 0.0, 2.0)
|
| 26 |
+
rows, columns = distances.shape
|
| 27 |
+
costs = np.full((rows + 1, columns + 1), np.inf, dtype=np.float64)
|
| 28 |
+
lengths = np.zeros((rows + 1, columns + 1), dtype=np.int32)
|
| 29 |
+
costs[0, 0] = 0.0
|
| 30 |
+
for row in range(1, rows + 1):
|
| 31 |
+
for column in range(1, columns + 1):
|
| 32 |
+
candidates = (
|
| 33 |
+
(costs[row - 1, column], lengths[row - 1, column]),
|
| 34 |
+
(costs[row, column - 1], lengths[row, column - 1]),
|
| 35 |
+
(costs[row - 1, column - 1], lengths[row - 1, column - 1]),
|
| 36 |
+
)
|
| 37 |
+
previous_cost, previous_length = min(candidates, key=lambda item: item[0])
|
| 38 |
+
costs[row, column] = previous_cost + distances[row - 1, column - 1]
|
| 39 |
+
lengths[row, column] = previous_length + 1
|
| 40 |
+
average_distance = costs[rows, columns] / max(1, int(lengths[rows, columns]))
|
| 41 |
+
return float(1.0 - average_distance)
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _validate_trajectory(value: np.ndarray) -> np.ndarray:
|
| 45 |
+
array = np.asarray(value, dtype=np.float32)
|
| 46 |
+
if array.ndim != 2 or array.shape[0] == 0 or array.shape[1] == 0:
|
| 47 |
+
raise ValueError("trajectory must be a non-empty [segments, dimension] matrix")
|
| 48 |
+
if not np.isfinite(array).all():
|
| 49 |
+
raise ValueError("trajectory contains non-finite values")
|
| 50 |
+
return normalize_rows(array)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _interpolate(trajectory: np.ndarray, points: int) -> np.ndarray:
|
| 54 |
+
if trajectory.shape[0] == 1:
|
| 55 |
+
return np.repeat(trajectory, points, axis=0)
|
| 56 |
+
source_time = np.linspace(0.0, 1.0, trajectory.shape[0])
|
| 57 |
+
target_time = np.linspace(0.0, 1.0, points)
|
| 58 |
+
result = np.stack(
|
| 59 |
+
[np.interp(target_time, source_time, trajectory[:, dim]) for dim in range(trajectory.shape[1])],
|
| 60 |
+
axis=1,
|
| 61 |
+
)
|
| 62 |
+
return normalize_rows(result)
|
openmusic_analysis/registry.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from collections.abc import Iterable
|
| 4 |
+
from typing import Any
|
| 5 |
+
|
| 6 |
+
from openmusic_analysis.domain import ModelMetadata
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class ModelRegistry:
|
| 10 |
+
"""Source of truth built directly from the configured analyzer instances."""
|
| 11 |
+
|
| 12 |
+
def __init__(self, analyzers: Iterable[Any]) -> None:
|
| 13 |
+
self._analyzers: dict[str, Any] = {}
|
| 14 |
+
for analyzer in analyzers:
|
| 15 |
+
representation = analyzer.metadata.representation
|
| 16 |
+
if representation in self._analyzers:
|
| 17 |
+
raise ValueError(f"Duplicate analyzer for {representation}")
|
| 18 |
+
self._analyzers[representation] = analyzer
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def representations(self) -> tuple[str, ...]:
|
| 22 |
+
return tuple(self._analyzers)
|
| 23 |
+
|
| 24 |
+
def analyzer(self, representation: str) -> Any:
|
| 25 |
+
return self._analyzers[representation]
|
| 26 |
+
|
| 27 |
+
def models(self) -> list[ModelMetadata]:
|
| 28 |
+
return [analyzer.metadata for analyzer in self._analyzers.values()]
|
| 29 |
+
|
| 30 |
+
def loaded_representations(self) -> list[str]:
|
| 31 |
+
return [model.representation for model in self.models() if model.loaded]
|
openmusic_analysis/runtime.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import asyncio
|
| 4 |
+
from collections.abc import Callable
|
| 5 |
+
from typing import Any, TypeVar
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
T = TypeVar("T")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class DeviceManager:
|
| 12 |
+
@staticmethod
|
| 13 |
+
def select(configured: str = "auto") -> str:
|
| 14 |
+
if configured != "auto":
|
| 15 |
+
return configured
|
| 16 |
+
try:
|
| 17 |
+
import torch
|
| 18 |
+
|
| 19 |
+
if torch.cuda.is_available():
|
| 20 |
+
return "cuda"
|
| 21 |
+
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
| 22 |
+
return "mps"
|
| 23 |
+
except ImportError:
|
| 24 |
+
pass
|
| 25 |
+
return "cpu"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
class InferenceGate:
|
| 29 |
+
"""One process-wide bound on model loading and inference concurrency."""
|
| 30 |
+
|
| 31 |
+
def __init__(self, concurrency: int) -> None:
|
| 32 |
+
self._semaphore = asyncio.Semaphore(max(1, concurrency))
|
| 33 |
+
|
| 34 |
+
async def run(self, function: Callable[..., T], *args: Any) -> T:
|
| 35 |
+
task = asyncio.create_task(self._guarded(function, *args))
|
| 36 |
+
try:
|
| 37 |
+
return await asyncio.shield(task)
|
| 38 |
+
except asyncio.CancelledError:
|
| 39 |
+
task.add_done_callback(_consume_background_exception)
|
| 40 |
+
raise
|
| 41 |
+
|
| 42 |
+
async def _guarded(self, function: Callable[..., T], *args: Any) -> T:
|
| 43 |
+
async with self._semaphore:
|
| 44 |
+
return await asyncio.to_thread(function, *args)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _consume_background_exception(task: asyncio.Task[Any]) -> None:
|
| 48 |
+
if not task.cancelled():
|
| 49 |
+
task.exception()
|
openmusic_analysis/settings.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import os
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from typing import Any
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
CLAP_MODEL_ID = "laion/clap-htsat-unfused"
|
| 11 |
+
CLAP_MODEL_REVISION = "8fa0f1c6d0433df6e97c127f64b2a1d6c0dcda8a"
|
| 12 |
+
BGE_MODEL_ID = "BAAI/bge-m3"
|
| 13 |
+
BGE_MODEL_REVISION = "5617a9f61b028005a4858fdac845db406aefb181"
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _env_bool(name: str, default: bool) -> bool:
|
| 17 |
+
value = os.getenv(name)
|
| 18 |
+
if value is None:
|
| 19 |
+
return default
|
| 20 |
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
@dataclass(frozen=True)
|
| 24 |
+
class GlobalAudioConfig:
|
| 25 |
+
sample_rate: int = 48_000
|
| 26 |
+
window_seconds: float = 10.0
|
| 27 |
+
target_windows: int = 4
|
| 28 |
+
coverage_start: float = 0.10
|
| 29 |
+
coverage_end: float = 0.90
|
| 30 |
+
minimum_audio_seconds: float = 1.0
|
| 31 |
+
short_window_padding: str = "repeat_to_window_length"
|
| 32 |
+
selection: str = "uniform_over_valid_start_range"
|
| 33 |
+
aggregation: str = "l2_each_then_mean_then_l2"
|
| 34 |
+
encoder_window_seconds: float = 10.0
|
| 35 |
+
oversize_window_encoding: str = "uniform_10s_subwindows"
|
| 36 |
+
encoder_subwindow_aggregation: str = "l2_each_then_mean_then_l2"
|
| 37 |
+
decoding_version: str = "ffmpeg-mono-f32le-v1"
|
| 38 |
+
resampling: str = "scipy-resample-poly-kaiser5-v1"
|
| 39 |
+
feature_extractor: str = "transformers-clap-4.44.2-fixed-input"
|
| 40 |
+
inference_batch_size: int = 4
|
| 41 |
+
|
| 42 |
+
def __post_init__(self) -> None:
|
| 43 |
+
if (
|
| 44 |
+
self.sample_rate <= 0
|
| 45 |
+
or self.window_seconds <= 0
|
| 46 |
+
or self.target_windows <= 0
|
| 47 |
+
or self.inference_batch_size <= 0
|
| 48 |
+
):
|
| 49 |
+
raise ValueError("Global audio rates, window and count must be positive")
|
| 50 |
+
if not 0 <= self.coverage_start <= self.coverage_end <= 1:
|
| 51 |
+
raise ValueError("Global coverage must satisfy 0 <= start <= end <= 1")
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass(frozen=True)
|
| 55 |
+
class TemporalAudioConfig:
|
| 56 |
+
sample_rate: int = 48_000
|
| 57 |
+
window_seconds: float = 10.0
|
| 58 |
+
hop_seconds: float = 10.0
|
| 59 |
+
max_segments: int = 24
|
| 60 |
+
minimum_audio_seconds: float = 1.0
|
| 61 |
+
short_window_padding: str = "repeat_to_window_length"
|
| 62 |
+
segmentation: str = "sliding_with_tail_coverage_and_uniform_limit"
|
| 63 |
+
encoder_window_seconds: float = 10.0
|
| 64 |
+
oversize_window_encoding: str = "uniform_10s_subwindows"
|
| 65 |
+
encoder_subwindow_aggregation: str = "l2_each_then_mean_then_l2"
|
| 66 |
+
decoding_version: str = "ffmpeg-mono-f32le-v1"
|
| 67 |
+
resampling: str = "scipy-resample-poly-kaiser5-v1"
|
| 68 |
+
feature_extractor: str = "transformers-clap-4.44.2-fixed-input"
|
| 69 |
+
inference_batch_size: int = 4
|
| 70 |
+
|
| 71 |
+
def __post_init__(self) -> None:
|
| 72 |
+
if (
|
| 73 |
+
self.sample_rate <= 0
|
| 74 |
+
or self.window_seconds <= 0
|
| 75 |
+
or self.hop_seconds <= 0
|
| 76 |
+
or self.max_segments <= 0
|
| 77 |
+
or self.inference_batch_size <= 0
|
| 78 |
+
):
|
| 79 |
+
raise ValueError("Temporal audio rates, windows, hop and limit must be positive")
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass(frozen=True)
|
| 83 |
+
class LyricsConfig:
|
| 84 |
+
max_chunk_tokens: int = 512
|
| 85 |
+
batch_size: int = 8
|
| 86 |
+
chunking: str = "section_first_then_token_split"
|
| 87 |
+
aggregation: str = "token_weighted_mean_of_l2_chunks_then_l2"
|
| 88 |
+
unicode_normalization: str = "NFKC"
|
| 89 |
+
pooling: str = "bge-m3-cls"
|
| 90 |
+
model_context_tokens: int = 8192
|
| 91 |
+
|
| 92 |
+
def __post_init__(self) -> None:
|
| 93 |
+
if self.max_chunk_tokens < 4 or self.max_chunk_tokens > self.model_context_tokens:
|
| 94 |
+
raise ValueError("Lyrics chunk tokens must be between 4 and model context")
|
| 95 |
+
if self.batch_size <= 0:
|
| 96 |
+
raise ValueError("Lyrics batch size must be positive")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass(frozen=True)
|
| 100 |
+
class LimitConfig:
|
| 101 |
+
max_upload_bytes: int = 100 * 1024 * 1024
|
| 102 |
+
max_lyrics_characters: int = 100_000
|
| 103 |
+
max_audio_seconds: float = 30 * 60
|
| 104 |
+
request_timeout_seconds: float = 300.0
|
| 105 |
+
decode_timeout_seconds: float = 120.0
|
| 106 |
+
|
| 107 |
+
def __post_init__(self) -> None:
|
| 108 |
+
if min(
|
| 109 |
+
self.max_upload_bytes,
|
| 110 |
+
self.max_lyrics_characters,
|
| 111 |
+
self.max_audio_seconds,
|
| 112 |
+
self.request_timeout_seconds,
|
| 113 |
+
self.decode_timeout_seconds,
|
| 114 |
+
) <= 0:
|
| 115 |
+
raise ValueError("All service limits must be positive")
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@dataclass(frozen=True)
|
| 119 |
+
class Settings:
|
| 120 |
+
device: str = "auto"
|
| 121 |
+
inference_concurrency: int = 1
|
| 122 |
+
ffmpeg_binary: str = "ffmpeg"
|
| 123 |
+
ffprobe_binary: str = "ffprobe"
|
| 124 |
+
eager_model_loading: bool = False
|
| 125 |
+
global_audio: GlobalAudioConfig = GlobalAudioConfig()
|
| 126 |
+
temporal_audio: TemporalAudioConfig = TemporalAudioConfig()
|
| 127 |
+
lyrics: LyricsConfig = LyricsConfig()
|
| 128 |
+
limits: LimitConfig = LimitConfig()
|
| 129 |
+
|
| 130 |
+
def __post_init__(self) -> None:
|
| 131 |
+
if self.inference_concurrency <= 0:
|
| 132 |
+
raise ValueError("Inference concurrency must be positive")
|
| 133 |
+
if self.global_audio.inference_batch_size != self.temporal_audio.inference_batch_size:
|
| 134 |
+
raise ValueError("Global and temporal CLAP analyzers must share a batch size")
|
| 135 |
+
|
| 136 |
+
@classmethod
|
| 137 |
+
def from_env(cls) -> "Settings":
|
| 138 |
+
clap_batch_size = int(os.getenv("OPENMUSIC_CLAP_BATCH_SIZE", "4"))
|
| 139 |
+
global_audio = GlobalAudioConfig(
|
| 140 |
+
window_seconds=float(os.getenv("OPENMUSIC_GLOBAL_WINDOW_SECONDS", "10")),
|
| 141 |
+
target_windows=int(os.getenv("OPENMUSIC_GLOBAL_WINDOWS", "4")),
|
| 142 |
+
inference_batch_size=clap_batch_size,
|
| 143 |
+
)
|
| 144 |
+
temporal_audio = TemporalAudioConfig(
|
| 145 |
+
window_seconds=float(os.getenv("OPENMUSIC_TEMPORAL_WINDOW_SECONDS", "10")),
|
| 146 |
+
hop_seconds=float(os.getenv("OPENMUSIC_TEMPORAL_HOP_SECONDS", "10")),
|
| 147 |
+
max_segments=int(os.getenv("OPENMUSIC_TEMPORAL_MAX_SEGMENTS", "24")),
|
| 148 |
+
inference_batch_size=clap_batch_size,
|
| 149 |
+
)
|
| 150 |
+
lyrics = LyricsConfig(
|
| 151 |
+
max_chunk_tokens=int(os.getenv("OPENMUSIC_LYRICS_CHUNK_TOKENS", "512")),
|
| 152 |
+
batch_size=int(os.getenv("OPENMUSIC_LYRICS_BATCH_SIZE", "8")),
|
| 153 |
+
)
|
| 154 |
+
limits = LimitConfig(
|
| 155 |
+
max_upload_bytes=int(os.getenv("OPENMUSIC_MAX_UPLOAD_BYTES", str(100 * 1024 * 1024))),
|
| 156 |
+
max_lyrics_characters=int(os.getenv("OPENMUSIC_MAX_LYRICS_CHARACTERS", "100000")),
|
| 157 |
+
max_audio_seconds=float(os.getenv("OPENMUSIC_MAX_AUDIO_SECONDS", "1800")),
|
| 158 |
+
request_timeout_seconds=float(os.getenv("OPENMUSIC_REQUEST_TIMEOUT_SECONDS", "300")),
|
| 159 |
+
decode_timeout_seconds=float(os.getenv("OPENMUSIC_DECODE_TIMEOUT_SECONDS", "120")),
|
| 160 |
+
)
|
| 161 |
+
return cls(
|
| 162 |
+
device=os.getenv("OPENMUSIC_DEVICE", "auto"),
|
| 163 |
+
inference_concurrency=max(1, int(os.getenv("OPENMUSIC_INFERENCE_CONCURRENCY", "1"))),
|
| 164 |
+
ffmpeg_binary=os.getenv("OPENMUSIC_FFMPEG", "ffmpeg"),
|
| 165 |
+
ffprobe_binary=os.getenv("OPENMUSIC_FFPROBE", "ffprobe"),
|
| 166 |
+
eager_model_loading=_env_bool("OPENMUSIC_EAGER_MODELS", False),
|
| 167 |
+
global_audio=global_audio,
|
| 168 |
+
temporal_audio=temporal_audio,
|
| 169 |
+
lyrics=lyrics,
|
| 170 |
+
limits=limits,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def config_dict(config: Any) -> dict[str, Any]:
|
| 175 |
+
return asdict(config)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def preprocessing_version(base: str, config: Any) -> str:
|
| 179 |
+
"""Tie a preprocessing version to every result-affecting configuration value."""
|
| 180 |
+
payload = json.dumps(config_dict(config), sort_keys=True, separators=(",", ":"))
|
| 181 |
+
digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:12]
|
| 182 |
+
return f"{base}.{digest}"
|
pytest.ini
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[pytest]
|
| 2 |
+
asyncio_default_fixture_loop_scope = function
|
| 3 |
+
testpaths = tests
|
requirements-dev.txt
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
-r requirements.txt
|
| 2 |
+
httpx==0.27.2
|
| 3 |
+
pytest==8.3.3
|
| 4 |
+
pytest-asyncio==0.24.0
|
requirements.txt
CHANGED
|
@@ -1,13 +1,11 @@
|
|
| 1 |
fastapi==0.115.0
|
| 2 |
uvicorn[standard]==0.30.6
|
| 3 |
-
python-multipart==0.0.
|
|
|
|
|
|
|
| 4 |
torch==2.3.1
|
| 5 |
-
torchaudio==2.3.1
|
| 6 |
transformers==4.44.2
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
imageio-ffmpeg==0.5.1
|
| 12 |
-
soundfile==0.12.1
|
| 13 |
-
accelerate==0.33.0
|
|
|
|
| 1 |
fastapi==0.115.0
|
| 2 |
uvicorn[standard]==0.30.6
|
| 3 |
+
python-multipart==0.0.32
|
| 4 |
+
numpy==1.26.4
|
| 5 |
+
scipy==1.14.1
|
| 6 |
torch==2.3.1
|
|
|
|
| 7 |
transformers==4.44.2
|
| 8 |
+
tokenizers==0.19.1
|
| 9 |
+
huggingface-hub==0.24.6
|
| 10 |
+
safetensors==0.4.5
|
| 11 |
+
sentencepiece==0.2.0
|
|
|
|
|
|
|
|
|
tests/__init__.py
ADDED
|
File without changes
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import hashlib
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
import numpy as np
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from openmusic_analysis.analyzers.clap import (
|
| 10 |
+
ClapGlobalAudioAnalyzer,
|
| 11 |
+
ClapTemporalAudioAnalyzer,
|
| 12 |
+
)
|
| 13 |
+
from openmusic_analysis.analyzers.lyrics import BGEM3LyricsAnalyzer, LyricsPreprocessor
|
| 14 |
+
from openmusic_analysis.application import MusicAnalysisService
|
| 15 |
+
from openmusic_analysis.audio.decoder import AudioMetadata, DecodedAudio
|
| 16 |
+
from openmusic_analysis.errors import AudioDecodeError
|
| 17 |
+
from openmusic_analysis.registry import ModelRegistry
|
| 18 |
+
from openmusic_analysis.settings import GlobalAudioConfig, LyricsConfig, TemporalAudioConfig
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class FakeAudioEncoder:
|
| 22 |
+
dimension = 4
|
| 23 |
+
loaded = True
|
| 24 |
+
|
| 25 |
+
def __init__(self) -> None:
|
| 26 |
+
self.calls: list[list[np.ndarray]] = []
|
| 27 |
+
|
| 28 |
+
async def ready(self) -> None:
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
async def encode(self, windows: list[np.ndarray]) -> np.ndarray:
|
| 32 |
+
self.calls.append(windows)
|
| 33 |
+
rows = []
|
| 34 |
+
for window in windows:
|
| 35 |
+
rows.append(
|
| 36 |
+
[
|
| 37 |
+
float(np.mean(window)),
|
| 38 |
+
float(np.std(window)),
|
| 39 |
+
float(window[0]),
|
| 40 |
+
float(window[-1]) + 1.0,
|
| 41 |
+
]
|
| 42 |
+
)
|
| 43 |
+
return np.asarray(rows, dtype=np.float32)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class FailingAudioEncoder(FakeAudioEncoder):
|
| 47 |
+
async def encode(self, windows: list[np.ndarray]) -> np.ndarray:
|
| 48 |
+
raise RuntimeError("internal model detail must not leak")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class FakeTextEncoder:
|
| 52 |
+
dimension = 6
|
| 53 |
+
loaded = True
|
| 54 |
+
|
| 55 |
+
async def ready(self) -> None:
|
| 56 |
+
return None
|
| 57 |
+
|
| 58 |
+
def count_tokens(self, text: str) -> int:
|
| 59 |
+
return len(text.split()) + 2
|
| 60 |
+
|
| 61 |
+
def split_tokens(self, text: str, max_tokens: int) -> list[str]:
|
| 62 |
+
words = text.split()
|
| 63 |
+
size = max(1, max_tokens - 2)
|
| 64 |
+
return [" ".join(words[index : index + size]) for index in range(0, len(words), size)]
|
| 65 |
+
|
| 66 |
+
async def encode(self, texts: list[str], batch_size: int) -> np.ndarray:
|
| 67 |
+
rows = []
|
| 68 |
+
for text in texts:
|
| 69 |
+
digest = hashlib.sha256(text.encode("utf-8")).digest()
|
| 70 |
+
rows.append([float(value + 1) for value in digest[: self.dimension]])
|
| 71 |
+
return np.asarray(rows, dtype=np.float32)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class FakeDecoder:
|
| 75 |
+
canonical_sample_rate = 10
|
| 76 |
+
|
| 77 |
+
def __init__(self, waveform: np.ndarray | None = None) -> None:
|
| 78 |
+
self.waveform = (
|
| 79 |
+
np.asarray(waveform, dtype=np.float32)
|
| 80 |
+
if waveform is not None
|
| 81 |
+
else np.linspace(-1.0, 1.0, 95, dtype=np.float32)
|
| 82 |
+
)
|
| 83 |
+
self.calls = 0
|
| 84 |
+
|
| 85 |
+
def decode(self, source_path: str | Path) -> DecodedAudio:
|
| 86 |
+
self.calls += 1
|
| 87 |
+
if Path(source_path).read_bytes().startswith(b"bad"):
|
| 88 |
+
raise AudioDecodeError()
|
| 89 |
+
return DecodedAudio(
|
| 90 |
+
waveform=self.waveform,
|
| 91 |
+
sample_rate=self.canonical_sample_rate,
|
| 92 |
+
metadata=AudioMetadata(
|
| 93 |
+
duration_ms=int(round(self.waveform.size * 1000 / self.canonical_sample_rate)),
|
| 94 |
+
source_sample_rate=self.canonical_sample_rate,
|
| 95 |
+
source_channels=1,
|
| 96 |
+
source_format="fake",
|
| 97 |
+
),
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def make_service(
|
| 102 |
+
*,
|
| 103 |
+
waveform: np.ndarray | None = None,
|
| 104 |
+
audio_encoder: FakeAudioEncoder | None = None,
|
| 105 |
+
) -> tuple[MusicAnalysisService, FakeDecoder, FakeAudioEncoder]:
|
| 106 |
+
audio_encoder = audio_encoder or FakeAudioEncoder()
|
| 107 |
+
decoder = FakeDecoder(waveform)
|
| 108 |
+
global_config = GlobalAudioConfig(
|
| 109 |
+
sample_rate=10,
|
| 110 |
+
window_seconds=2.0,
|
| 111 |
+
target_windows=4,
|
| 112 |
+
minimum_audio_seconds=1.0,
|
| 113 |
+
)
|
| 114 |
+
temporal_config = TemporalAudioConfig(
|
| 115 |
+
sample_rate=10,
|
| 116 |
+
window_seconds=2.0,
|
| 117 |
+
hop_seconds=2.0,
|
| 118 |
+
max_segments=4,
|
| 119 |
+
minimum_audio_seconds=1.0,
|
| 120 |
+
)
|
| 121 |
+
analyzers = [
|
| 122 |
+
ClapGlobalAudioAnalyzer(audio_encoder, global_config),
|
| 123 |
+
ClapTemporalAudioAnalyzer(audio_encoder, temporal_config),
|
| 124 |
+
BGEM3LyricsAnalyzer(
|
| 125 |
+
FakeTextEncoder(),
|
| 126 |
+
LyricsPreprocessor(),
|
| 127 |
+
LyricsConfig(max_chunk_tokens=12, batch_size=4),
|
| 128 |
+
),
|
| 129 |
+
]
|
| 130 |
+
service = MusicAnalysisService(
|
| 131 |
+
registry=ModelRegistry(analyzers), decoder=decoder, device="cpu"
|
| 132 |
+
)
|
| 133 |
+
return service, decoder, audio_encoder
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
@pytest.fixture
|
| 137 |
+
def service_bundle():
|
| 138 |
+
return make_service()
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi.testclient import TestClient
|
| 4 |
+
|
| 5 |
+
from openmusic_analysis.api import create_app
|
| 6 |
+
from openmusic_analysis.settings import Settings
|
| 7 |
+
|
| 8 |
+
from .conftest import FailingAudioEncoder, make_service
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def client_for(service) -> TestClient:
|
| 12 |
+
return TestClient(create_app(service=service, settings=Settings()))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def upload(content: bytes = b"valid fake audio"):
|
| 16 |
+
return {"audio": ("track.mp3", content, "audio/mpeg")}
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def test_valid_audio_only_request(service_bundle):
|
| 20 |
+
service, _, _ = service_bundle
|
| 21 |
+
response = client_for(service).post("/v1/tracks/analyze", files=upload())
|
| 22 |
+
assert response.status_code == 200
|
| 23 |
+
assert set(response.json()["representations"]) == {"audio.global", "audio.temporal"}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_audio_and_multilingual_lyrics(service_bundle):
|
| 27 |
+
service, _, _ = service_bundle
|
| 28 |
+
response = client_for(service).post(
|
| 29 |
+
"/v1/tracks/analyze",
|
| 30 |
+
files=upload(),
|
| 31 |
+
data={"lyrics": "[Verse]\nHello world\n\n[Припев]\nПривет, мир!"},
|
| 32 |
+
)
|
| 33 |
+
assert response.status_code == 200
|
| 34 |
+
assert "lyrics.global" in response.json()["representations"]
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_requested_representations_avoid_unrequested_work(service_bundle):
|
| 38 |
+
service, decoder, encoder = service_bundle
|
| 39 |
+
response = client_for(service).post(
|
| 40 |
+
"/v1/tracks/analyze",
|
| 41 |
+
files=upload(),
|
| 42 |
+
data={
|
| 43 |
+
"lyrics": "lyrics that must not be encoded",
|
| 44 |
+
"requested_representations": "audio.global",
|
| 45 |
+
},
|
| 46 |
+
)
|
| 47 |
+
assert response.status_code == 200
|
| 48 |
+
assert list(response.json()["representations"]) == ["audio.global"]
|
| 49 |
+
assert decoder.calls == 1
|
| 50 |
+
assert len(encoder.calls) == 1
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_invalid_file_returns_structured_decode_error(service_bundle):
|
| 54 |
+
service, _, _ = service_bundle
|
| 55 |
+
response = client_for(service).post("/v1/tracks/analyze", files=upload(b"bad bytes"))
|
| 56 |
+
assert response.status_code == 422
|
| 57 |
+
assert response.json()["error"]["code"] == "AUDIO_DECODE_FAILED"
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def test_missing_audio_is_structured(service_bundle):
|
| 61 |
+
service, _, _ = service_bundle
|
| 62 |
+
response = client_for(service).post("/v1/tracks/analyze", data={"lyrics": "text"})
|
| 63 |
+
assert response.status_code == 422
|
| 64 |
+
assert response.json()["error"]["code"] == "MISSING_AUDIO"
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def test_unsupported_representation(service_bundle):
|
| 68 |
+
service, _, _ = service_bundle
|
| 69 |
+
response = client_for(service).post(
|
| 70 |
+
"/v1/tracks/analyze",
|
| 71 |
+
files=upload(),
|
| 72 |
+
data={"requested_representations": "audio.emotion"},
|
| 73 |
+
)
|
| 74 |
+
assert response.status_code == 422
|
| 75 |
+
assert response.json()["error"]["code"] == "UNSUPPORTED_REPRESENTATION"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def test_unsupported_extension(service_bundle):
|
| 79 |
+
service, _, _ = service_bundle
|
| 80 |
+
response = client_for(service).post(
|
| 81 |
+
"/v1/tracks/analyze", files={"audio": ("track.txt", b"data", "text/plain")}
|
| 82 |
+
)
|
| 83 |
+
assert response.status_code == 415
|
| 84 |
+
assert response.json()["error"]["code"] == "UNSUPPORTED_AUDIO_FORMAT"
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def test_explicit_lyrics_representation_requires_lyrics(service_bundle):
|
| 88 |
+
service, _, _ = service_bundle
|
| 89 |
+
response = client_for(service).post(
|
| 90 |
+
"/v1/tracks/analyze",
|
| 91 |
+
files=upload(),
|
| 92 |
+
data={"requested_representations": "lyrics.global"},
|
| 93 |
+
)
|
| 94 |
+
assert response.status_code == 422
|
| 95 |
+
assert response.json()["error"]["code"] == "LYRICS_REQUIRED"
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_invalid_empty_lyrics(service_bundle):
|
| 99 |
+
service, _, _ = service_bundle
|
| 100 |
+
response = client_for(service).post(
|
| 101 |
+
"/v1/tracks/analyze", files=upload(), data={"lyrics": " \n "}
|
| 102 |
+
)
|
| 103 |
+
assert response.status_code == 422
|
| 104 |
+
assert response.json()["error"]["code"] == "INVALID_LYRICS"
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_model_failure_does_not_leak_internal_exception():
|
| 108 |
+
service, _, _ = make_service(audio_encoder=FailingAudioEncoder())
|
| 109 |
+
response = client_for(service).post(
|
| 110 |
+
"/v1/tracks/analyze",
|
| 111 |
+
files=upload(),
|
| 112 |
+
data={"requested_representations": "audio.global"},
|
| 113 |
+
)
|
| 114 |
+
assert response.status_code == 500
|
| 115 |
+
assert response.json()["error"]["code"] == "MODEL_INFERENCE_FAILED"
|
| 116 |
+
assert "internal model detail" not in response.text
|
tests/test_audio_analyzers.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
import numpy as np
|
| 6 |
+
import pytest
|
| 7 |
+
|
| 8 |
+
from openmusic_analysis.analyzers.clap import (
|
| 9 |
+
ClapGlobalAudioAnalyzer,
|
| 10 |
+
ClapTemporalAudioAnalyzer,
|
| 11 |
+
)
|
| 12 |
+
from openmusic_analysis.audio import AnalysisContext
|
| 13 |
+
from openmusic_analysis.settings import GlobalAudioConfig, TemporalAudioConfig
|
| 14 |
+
|
| 15 |
+
from .conftest import FakeAudioEncoder, FakeDecoder
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def context(tmp_path: Path, waveform: np.ndarray) -> AnalysisContext:
|
| 19 |
+
source = tmp_path / "audio.wav"
|
| 20 |
+
source.write_bytes(b"valid")
|
| 21 |
+
return AnalysisContext(source, FakeDecoder(waveform))
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@pytest.mark.asyncio
|
| 25 |
+
async def test_global_is_deterministic_finite_normalized_and_multiwindow(tmp_path):
|
| 26 |
+
waveform = np.linspace(-1, 1, 100, dtype=np.float32)
|
| 27 |
+
encoder = FakeAudioEncoder()
|
| 28 |
+
config = GlobalAudioConfig(
|
| 29 |
+
sample_rate=10, window_seconds=2, target_windows=4, minimum_audio_seconds=1
|
| 30 |
+
)
|
| 31 |
+
analyzer = ClapGlobalAudioAnalyzer(encoder, config)
|
| 32 |
+
first = await analyzer.analyze(context(tmp_path, waveform))
|
| 33 |
+
second = await analyzer.analyze(context(tmp_path, waveform))
|
| 34 |
+
first_vector = np.asarray(first.embedding)
|
| 35 |
+
second_vector = np.asarray(second.embedding)
|
| 36 |
+
assert first.dimension == 4
|
| 37 |
+
assert np.isfinite(first_vector).all()
|
| 38 |
+
assert np.linalg.norm(first_vector) == pytest.approx(1.0, abs=1e-6)
|
| 39 |
+
assert first_vector == pytest.approx(second_vector, abs=1e-7)
|
| 40 |
+
assert first.analysis["windows_used"] == 4
|
| 41 |
+
assert len(encoder.calls[0]) == 4
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@pytest.mark.asyncio
|
| 45 |
+
async def test_global_short_track_uses_one_padded_window(tmp_path):
|
| 46 |
+
encoder = FakeAudioEncoder()
|
| 47 |
+
config = GlobalAudioConfig(
|
| 48 |
+
sample_rate=10, window_seconds=2, target_windows=4, minimum_audio_seconds=0.2
|
| 49 |
+
)
|
| 50 |
+
result = await ClapGlobalAudioAnalyzer(encoder, config).analyze(
|
| 51 |
+
context(tmp_path, np.arange(5, dtype=np.float32))
|
| 52 |
+
)
|
| 53 |
+
assert result.analysis["windows_used"] == 1
|
| 54 |
+
assert encoder.calls[0][0].size == 20
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@pytest.mark.asyncio
|
| 58 |
+
async def test_temporal_segments_are_ordered_and_deterministic(tmp_path):
|
| 59 |
+
waveform = np.linspace(-1, 1, 95, dtype=np.float32)
|
| 60 |
+
config = TemporalAudioConfig(
|
| 61 |
+
sample_rate=10,
|
| 62 |
+
window_seconds=2,
|
| 63 |
+
hop_seconds=2,
|
| 64 |
+
max_segments=20,
|
| 65 |
+
minimum_audio_seconds=1,
|
| 66 |
+
)
|
| 67 |
+
analyzer = ClapTemporalAudioAnalyzer(FakeAudioEncoder(), config)
|
| 68 |
+
first = await analyzer.analyze(context(tmp_path, waveform))
|
| 69 |
+
second = await analyzer.analyze(context(tmp_path, waveform))
|
| 70 |
+
starts = [segment.start_ms for segment in first.segments]
|
| 71 |
+
assert starts == sorted(starts)
|
| 72 |
+
assert starts == [segment.start_ms for segment in second.segments]
|
| 73 |
+
assert first.model_dump() == second.model_dump()
|
| 74 |
+
assert all(segment.start_ms < segment.end_ms for segment in first.segments)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@pytest.mark.asyncio
|
| 78 |
+
async def test_temporal_short_track_has_one_segment(tmp_path):
|
| 79 |
+
config = TemporalAudioConfig(
|
| 80 |
+
sample_rate=10,
|
| 81 |
+
window_seconds=2,
|
| 82 |
+
hop_seconds=1,
|
| 83 |
+
max_segments=4,
|
| 84 |
+
minimum_audio_seconds=0.2,
|
| 85 |
+
)
|
| 86 |
+
result = await ClapTemporalAudioAnalyzer(FakeAudioEncoder(), config).analyze(
|
| 87 |
+
context(tmp_path, np.arange(5, dtype=np.float32))
|
| 88 |
+
)
|
| 89 |
+
assert len(result.segments) == 1
|
| 90 |
+
assert result.segments[0].start_ms == 0
|
| 91 |
+
assert result.segments[0].end_ms == 500
|
| 92 |
+
assert result.summary.largest_transition_index is None
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
@pytest.mark.asyncio
|
| 96 |
+
async def test_temporal_long_track_respects_max_segments_and_tail(tmp_path):
|
| 97 |
+
config = TemporalAudioConfig(
|
| 98 |
+
sample_rate=10,
|
| 99 |
+
window_seconds=2,
|
| 100 |
+
hop_seconds=1,
|
| 101 |
+
max_segments=5,
|
| 102 |
+
minimum_audio_seconds=1,
|
| 103 |
+
)
|
| 104 |
+
result = await ClapTemporalAudioAnalyzer(FakeAudioEncoder(), config).analyze(
|
| 105 |
+
context(tmp_path, np.arange(300, dtype=np.float32))
|
| 106 |
+
)
|
| 107 |
+
assert len(result.segments) == 5
|
| 108 |
+
assert result.segments[0].start_ms == 0
|
| 109 |
+
assert result.segments[-1].end_ms == 30_000
|
| 110 |
+
assert result.summary.number_of_segments == 5
|
| 111 |
+
assert np.isfinite(result.summary.trajectory_variance)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
@pytest.mark.asyncio
|
| 115 |
+
async def test_request_context_decodes_only_once_for_two_analyzers(tmp_path):
|
| 116 |
+
source = tmp_path / "audio.wav"
|
| 117 |
+
source.write_bytes(b"valid")
|
| 118 |
+
decoder = FakeDecoder(np.arange(100, dtype=np.float32))
|
| 119 |
+
analysis_context = AnalysisContext(source, decoder)
|
| 120 |
+
encoder = FakeAudioEncoder()
|
| 121 |
+
await ClapGlobalAudioAnalyzer(
|
| 122 |
+
encoder,
|
| 123 |
+
GlobalAudioConfig(sample_rate=10, window_seconds=2, minimum_audio_seconds=1),
|
| 124 |
+
).analyze(analysis_context)
|
| 125 |
+
await ClapTemporalAudioAnalyzer(
|
| 126 |
+
encoder,
|
| 127 |
+
TemporalAudioConfig(sample_rate=10, window_seconds=2, minimum_audio_seconds=1),
|
| 128 |
+
).analyze(analysis_context)
|
| 129 |
+
assert decoder.calls == 1
|
tests/test_benchmark.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
|
| 3 |
+
from tools.nearest_neighbors import _cache_key, _neighbor_report
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def test_benchmark_cache_key_covers_content_model_and_configuration(tmp_path: Path):
|
| 7 |
+
audio = tmp_path / "track.wav"
|
| 8 |
+
audio.write_bytes(b"first content")
|
| 9 |
+
model = {
|
| 10 |
+
"model_id": "model",
|
| 11 |
+
"model_version": "revision",
|
| 12 |
+
"preprocessing_version": "prep",
|
| 13 |
+
"representation": "audio.global",
|
| 14 |
+
"configuration": {"windows": 4},
|
| 15 |
+
}
|
| 16 |
+
first = _cache_key(audio, None, model)
|
| 17 |
+
changed_model = {**model, "configuration": {"windows": 5}}
|
| 18 |
+
assert _cache_key(audio, None, changed_model) != first
|
| 19 |
+
audio.write_bytes(b"different content")
|
| 20 |
+
assert _cache_key(audio, None, model) != first
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_temporal_neighbor_report_contains_both_experimental_metrics():
|
| 24 |
+
records = [
|
| 25 |
+
{
|
| 26 |
+
"path": "a.wav",
|
| 27 |
+
"representation": {
|
| 28 |
+
"segments": [{"embedding": [1.0, 0.0]}, {"embedding": [0.0, 1.0]}]
|
| 29 |
+
},
|
| 30 |
+
},
|
| 31 |
+
{
|
| 32 |
+
"path": "b.wav",
|
| 33 |
+
"representation": {
|
| 34 |
+
"segments": [
|
| 35 |
+
{"embedding": [1.0, 0.0]},
|
| 36 |
+
{"embedding": [0.7, 0.7]},
|
| 37 |
+
{"embedding": [0.0, 1.0]},
|
| 38 |
+
]
|
| 39 |
+
},
|
| 40 |
+
},
|
| 41 |
+
]
|
| 42 |
+
report = _neighbor_report(records, "audio.temporal", 1)
|
| 43 |
+
assert set(report["tracks"][0]["neighbors"]) == {"aligned", "dtw"}
|
| 44 |
+
assert report["tracks"][0]["neighbors"]["aligned"][0]["track"] == "b.wav"
|
tests/test_decoder.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import wave
|
| 3 |
+
|
| 4 |
+
import numpy as np
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from openmusic_analysis.audio.decoder import AudioDecoder
|
| 8 |
+
from openmusic_analysis.errors import AudioDecodeError
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def test_decoder_rejects_invalid_audio(tmp_path: Path):
|
| 12 |
+
source = tmp_path / "invalid.mp3"
|
| 13 |
+
source.write_bytes(b"not an audio file")
|
| 14 |
+
with pytest.raises(AudioDecodeError):
|
| 15 |
+
AudioDecoder(timeout_seconds=5).decode(source)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_decoder_produces_canonical_mono_float32_pcm(tmp_path: Path):
|
| 19 |
+
source = tmp_path / "valid.wav"
|
| 20 |
+
samples = (np.sin(np.linspace(0, 10, 8_000)) * 20_000).astype("<i2")
|
| 21 |
+
with wave.open(str(source), "wb") as output:
|
| 22 |
+
output.setnchannels(1)
|
| 23 |
+
output.setsampwidth(2)
|
| 24 |
+
output.setframerate(8_000)
|
| 25 |
+
output.writeframes(samples.tobytes())
|
| 26 |
+
decoded = AudioDecoder(canonical_sample_rate=16_000, timeout_seconds=5).decode(source)
|
| 27 |
+
assert decoded.sample_rate == 16_000
|
| 28 |
+
assert decoded.waveform.dtype == np.float32
|
| 29 |
+
assert decoded.waveform.ndim == 1
|
| 30 |
+
assert decoded.waveform.size == pytest.approx(16_000, abs=2)
|
| 31 |
+
assert decoded.metadata.source_sample_rate == 8_000
|
tests/test_lyrics.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import numpy as np
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from openmusic_analysis.analyzers.lyrics import BGEM3LyricsAnalyzer, LyricsPreprocessor
|
| 7 |
+
from openmusic_analysis.errors import AnalysisError
|
| 8 |
+
from openmusic_analysis.settings import LyricsConfig
|
| 9 |
+
|
| 10 |
+
from .conftest import FakeTextEncoder
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def analyzer(max_tokens: int = 12) -> BGEM3LyricsAnalyzer:
|
| 14 |
+
return BGEM3LyricsAnalyzer(
|
| 15 |
+
FakeTextEncoder(),
|
| 16 |
+
LyricsPreprocessor(),
|
| 17 |
+
LyricsConfig(max_chunk_tokens=max_tokens, batch_size=4),
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@pytest.mark.asyncio
|
| 22 |
+
@pytest.mark.parametrize(
|
| 23 |
+
"lyrics",
|
| 24 |
+
[
|
| 25 |
+
"[Verse]\nHello, world!\nI remember you.\n\n[Chorus]\nCome home, come home.",
|
| 26 |
+
"[Куплет 1]\nЯ помню этот день.\n\n[Припев]\nВернись, вернись ко мне!",
|
| 27 |
+
],
|
| 28 |
+
)
|
| 29 |
+
async def test_english_and_russian_are_finite_and_normalized(lyrics):
|
| 30 |
+
result = await analyzer().analyze(lyrics)
|
| 31 |
+
vector = np.asarray(result.embedding)
|
| 32 |
+
assert result.dimension == 6
|
| 33 |
+
assert np.isfinite(vector).all()
|
| 34 |
+
assert np.linalg.norm(vector) == pytest.approx(1.0, abs=1e-6)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_preprocessor_preserves_multiline_sections_punctuation_and_repeated_chorus():
|
| 38 |
+
lyrics = "[ar:metadata]\r\n[Verse]\r\nHello, world!\r\n\r\n[Chorus]\r\nAgain!\r\n[Chorus]\r\nAgain!"
|
| 39 |
+
prepared = LyricsPreprocessor().prepare(lyrics)
|
| 40 |
+
assert "[ar:metadata]" not in prepared.normalized_text
|
| 41 |
+
assert "Hello, world!" in prepared.normalized_text
|
| 42 |
+
assert [section.label.lower() for section in prepared.sections] == [
|
| 43 |
+
"verse",
|
| 44 |
+
"chorus",
|
| 45 |
+
"chorus",
|
| 46 |
+
]
|
| 47 |
+
assert [section.text for section in prepared.sections].count("Again!") == 2
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@pytest.mark.asyncio
|
| 51 |
+
async def test_long_lyrics_use_token_chunks_and_deterministic_aggregation():
|
| 52 |
+
lyrics = "[Verse]\n" + " ".join(f"word{index}" for index in range(80))
|
| 53 |
+
first = await analyzer(max_tokens=10).analyze(lyrics)
|
| 54 |
+
second = await analyzer(max_tokens=10).analyze(lyrics)
|
| 55 |
+
assert first.analysis["chunk_count"] > 1
|
| 56 |
+
assert max(first.analysis["chunk_token_counts"]) <= 10
|
| 57 |
+
assert first.embedding == pytest.approx(second.embedding, abs=1e-7)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@pytest.mark.asyncio
|
| 61 |
+
async def test_empty_lyrics_are_rejected():
|
| 62 |
+
with pytest.raises(AnalysisError) as raised:
|
| 63 |
+
await analyzer().analyze(" \n\t ")
|
| 64 |
+
assert raised.value.code == "INVALID_LYRICS"
|
tests/test_registry.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi.testclient import TestClient
|
| 2 |
+
|
| 3 |
+
from openmusic_analysis.api import create_app
|
| 4 |
+
from openmusic_analysis.settings import Settings
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def test_models_endpoint_matches_actual_analyzers(service_bundle):
|
| 8 |
+
service, _, _ = service_bundle
|
| 9 |
+
response = TestClient(create_app(service=service, settings=Settings())).get("/v1/models")
|
| 10 |
+
assert response.status_code == 200
|
| 11 |
+
endpoint_models = response.json()["models"]
|
| 12 |
+
actual = [model.model_dump(mode="json") for model in service.registry.models()]
|
| 13 |
+
assert endpoint_models == actual
|
| 14 |
+
assert {item["representation"] for item in endpoint_models} == {
|
| 15 |
+
"audio.global",
|
| 16 |
+
"audio.temporal",
|
| 17 |
+
"lyrics.global",
|
| 18 |
+
}
|
| 19 |
+
assert all(len(item["model_version"]) == 40 for item in endpoint_models)
|
tests/test_temporal_similarity.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import pytest
|
| 3 |
+
|
| 4 |
+
from openmusic_analysis.experiments import (
|
| 5 |
+
aligned_interpolation_similarity,
|
| 6 |
+
dtw_cosine_similarity,
|
| 7 |
+
)
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_temporal_similarity_identical_and_length_invariant_baselines():
|
| 11 |
+
trajectory = np.asarray([[1, 0], [0.7, 0.7], [0, 1]], dtype=np.float32)
|
| 12 |
+
stretched = np.asarray(
|
| 13 |
+
[[1, 0], [0.9, 0.3], [0.7, 0.7], [0.3, 0.9], [0, 1]], dtype=np.float32
|
| 14 |
+
)
|
| 15 |
+
assert aligned_interpolation_similarity(trajectory, trajectory) == pytest.approx(1.0)
|
| 16 |
+
assert dtw_cosine_similarity(trajectory, trajectory) == pytest.approx(1.0)
|
| 17 |
+
assert aligned_interpolation_similarity(trajectory, stretched) > 0.95
|
| 18 |
+
assert dtw_cosine_similarity(trajectory, stretched) > 0.9
|
tools/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Developer and benchmarking tools."""
|
tools/nearest_neighbors.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import asyncio
|
| 6 |
+
import csv
|
| 7 |
+
import hashlib
|
| 8 |
+
import json
|
| 9 |
+
import sys
|
| 10 |
+
from itertools import combinations
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
|
| 16 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 17 |
+
|
| 18 |
+
from openmusic_analysis.application import build_service
|
| 19 |
+
from openmusic_analysis.experiments import (
|
| 20 |
+
aligned_interpolation_similarity,
|
| 21 |
+
dtw_cosine_similarity,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
AUDIO_EXTENSIONS = {".mp3", ".m4a", ".flac", ".wav", ".aac", ".ogg", ".opus"}
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def parse_args() -> argparse.Namespace:
|
| 29 |
+
parser = argparse.ArgumentParser(description="Benchmark OpenMusic representation neighbors")
|
| 30 |
+
parser.add_argument("folder", type=Path)
|
| 31 |
+
parser.add_argument(
|
| 32 |
+
"--representation",
|
| 33 |
+
choices=["audio.global", "audio.temporal", "lyrics.global"],
|
| 34 |
+
default="audio.global",
|
| 35 |
+
)
|
| 36 |
+
parser.add_argument("--top-k", type=int, default=5)
|
| 37 |
+
parser.add_argument("--cache-dir", type=Path, default=Path(".embedding_cache"))
|
| 38 |
+
parser.add_argument("--output", type=Path, default=Path("neighbors.json"))
|
| 39 |
+
parser.add_argument(
|
| 40 |
+
"--lyrics-suffix",
|
| 41 |
+
default=".txt",
|
| 42 |
+
help="Sidecar suffix used by lyrics.global (for example song.mp3 + song.txt)",
|
| 43 |
+
)
|
| 44 |
+
return parser.parse_args()
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
async def main() -> None:
|
| 48 |
+
args = parse_args()
|
| 49 |
+
if args.top_k < 1:
|
| 50 |
+
raise SystemExit("--top-k must be at least 1")
|
| 51 |
+
service = build_service()
|
| 52 |
+
metadata = service.registry.analyzer(args.representation).metadata
|
| 53 |
+
files = sorted(
|
| 54 |
+
path for path in args.folder.rglob("*") if path.suffix.lower() in AUDIO_EXTENSIONS
|
| 55 |
+
)
|
| 56 |
+
if not files:
|
| 57 |
+
raise SystemExit(f"No supported audio files found under {args.folder}")
|
| 58 |
+
args.cache_dir.mkdir(parents=True, exist_ok=True)
|
| 59 |
+
records: list[dict[str, Any]] = []
|
| 60 |
+
for audio_path in files:
|
| 61 |
+
lyrics = (
|
| 62 |
+
_read_lyrics(audio_path, args.lyrics_suffix)
|
| 63 |
+
if args.representation == "lyrics.global"
|
| 64 |
+
else None
|
| 65 |
+
)
|
| 66 |
+
if args.representation == "lyrics.global" and lyrics is None:
|
| 67 |
+
print(f"skip {audio_path}: missing lyrics sidecar", file=sys.stderr)
|
| 68 |
+
continue
|
| 69 |
+
key = _cache_key(audio_path, lyrics, metadata.model_dump(exclude={"loaded"}))
|
| 70 |
+
cache_path = args.cache_dir / f"{key}.json"
|
| 71 |
+
if cache_path.exists():
|
| 72 |
+
representation = json.loads(cache_path.read_text(encoding="utf-8"))["representation"]
|
| 73 |
+
else:
|
| 74 |
+
response = await service.analyze(
|
| 75 |
+
audio_path,
|
| 76 |
+
lyrics=lyrics,
|
| 77 |
+
requested_representations=[args.representation],
|
| 78 |
+
track_id=str(audio_path.relative_to(args.folder)),
|
| 79 |
+
content_identity=f"sha256:{_file_hash(audio_path)}",
|
| 80 |
+
)
|
| 81 |
+
representation = response.representations[args.representation].model_dump()
|
| 82 |
+
_atomic_json_write(
|
| 83 |
+
cache_path,
|
| 84 |
+
{"cache_key": key, "source": str(audio_path), "representation": representation},
|
| 85 |
+
)
|
| 86 |
+
records.append({"path": str(audio_path), "representation": representation})
|
| 87 |
+
if len(records) < 2:
|
| 88 |
+
raise SystemExit("At least two analyzable tracks are required")
|
| 89 |
+
report = _neighbor_report(records, args.representation, args.top_k)
|
| 90 |
+
report["representation"] = args.representation
|
| 91 |
+
report["model"] = metadata.model_dump(exclude={"loaded"})
|
| 92 |
+
_atomic_json_write(args.output, report)
|
| 93 |
+
_write_csv(args.output.with_suffix(".csv"), report)
|
| 94 |
+
print(f"wrote {args.output} and {args.output.with_suffix('.csv')}")
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _neighbor_report(
|
| 98 |
+
records: list[dict[str, Any]], representation: str, top_k: int
|
| 99 |
+
) -> dict[str, Any]:
|
| 100 |
+
metrics = ["cosine"] if representation != "audio.temporal" else ["aligned", "dtw"]
|
| 101 |
+
scores: dict[str, dict[tuple[int, int], float]] = {metric: {} for metric in metrics}
|
| 102 |
+
for first_index, second_index in combinations(range(len(records)), 2):
|
| 103 |
+
first = records[first_index]["representation"]
|
| 104 |
+
second = records[second_index]["representation"]
|
| 105 |
+
if representation == "audio.temporal":
|
| 106 |
+
first_value = np.asarray([item["embedding"] for item in first["segments"]])
|
| 107 |
+
second_value = np.asarray([item["embedding"] for item in second["segments"]])
|
| 108 |
+
scores["aligned"][(first_index, second_index)] = aligned_interpolation_similarity(
|
| 109 |
+
first_value, second_value
|
| 110 |
+
)
|
| 111 |
+
scores["dtw"][(first_index, second_index)] = dtw_cosine_similarity(
|
| 112 |
+
first_value, second_value
|
| 113 |
+
)
|
| 114 |
+
else:
|
| 115 |
+
first_value = np.asarray(first["embedding"], dtype=np.float32)
|
| 116 |
+
second_value = np.asarray(second["embedding"], dtype=np.float32)
|
| 117 |
+
scores["cosine"][(first_index, second_index)] = float(first_value @ second_value)
|
| 118 |
+
tracks = []
|
| 119 |
+
for index, record in enumerate(records):
|
| 120 |
+
neighbors: dict[str, list[dict[str, Any]]] = {}
|
| 121 |
+
for metric in metrics:
|
| 122 |
+
ranked = []
|
| 123 |
+
for other_index, other in enumerate(records):
|
| 124 |
+
if other_index == index:
|
| 125 |
+
continue
|
| 126 |
+
pair = tuple(sorted((index, other_index)))
|
| 127 |
+
ranked.append(
|
| 128 |
+
{"track": other["path"], "similarity": scores[metric][pair]}
|
| 129 |
+
)
|
| 130 |
+
neighbors[metric] = sorted(
|
| 131 |
+
ranked, key=lambda item: item["similarity"], reverse=True
|
| 132 |
+
)[:top_k]
|
| 133 |
+
tracks.append({"track": record["path"], "neighbors": neighbors})
|
| 134 |
+
return {"tracks": tracks}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def _cache_key(audio_path: Path, lyrics: str | None, model: dict[str, Any]) -> str:
|
| 138 |
+
payload = {
|
| 139 |
+
"content_hash": _file_hash(audio_path),
|
| 140 |
+
"lyrics_hash": hashlib.sha256((lyrics or "").encode("utf-8")).hexdigest(),
|
| 141 |
+
"model_id": model["model_id"],
|
| 142 |
+
"model_version": model["model_version"],
|
| 143 |
+
"preprocessing_version": model["preprocessing_version"],
|
| 144 |
+
"representation": model["representation"],
|
| 145 |
+
"configuration": model["configuration"],
|
| 146 |
+
}
|
| 147 |
+
encoded = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
| 148 |
+
return hashlib.sha256(encoded).hexdigest()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _file_hash(path: Path) -> str:
|
| 152 |
+
digest = hashlib.sha256()
|
| 153 |
+
with path.open("rb") as file:
|
| 154 |
+
while chunk := file.read(1024 * 1024):
|
| 155 |
+
digest.update(chunk)
|
| 156 |
+
return digest.hexdigest()
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _read_lyrics(audio_path: Path, suffix: str) -> str | None:
|
| 160 |
+
sidecar = audio_path.with_suffix(suffix)
|
| 161 |
+
return sidecar.read_text(encoding="utf-8") if sidecar.exists() else None
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def _atomic_json_write(path: Path, payload: dict[str, Any]) -> None:
|
| 165 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 166 |
+
temporary = path.with_suffix(path.suffix + ".tmp")
|
| 167 |
+
temporary.write_text(
|
| 168 |
+
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 169 |
+
)
|
| 170 |
+
temporary.replace(path)
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _write_csv(path: Path, report: dict[str, Any]) -> None:
|
| 174 |
+
with path.open("w", encoding="utf-8", newline="") as file:
|
| 175 |
+
writer = csv.DictWriter(file, fieldnames=["track", "metric", "rank", "neighbor", "similarity"])
|
| 176 |
+
writer.writeheader()
|
| 177 |
+
for track in report["tracks"]:
|
| 178 |
+
for metric, neighbors in track["neighbors"].items():
|
| 179 |
+
for rank, neighbor in enumerate(neighbors, start=1):
|
| 180 |
+
writer.writerow(
|
| 181 |
+
{
|
| 182 |
+
"track": track["track"],
|
| 183 |
+
"metric": metric,
|
| 184 |
+
"rank": rank,
|
| 185 |
+
"neighbor": neighbor["track"],
|
| 186 |
+
"similarity": neighbor["similarity"],
|
| 187 |
+
}
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
if __name__ == "__main__":
|
| 192 |
+
asyncio.run(main())
|
tools/profile_analysis.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
|
| 4 |
+
import argparse
|
| 5 |
+
import asyncio
|
| 6 |
+
import json
|
| 7 |
+
import resource
|
| 8 |
+
import sys
|
| 9 |
+
import time
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
| 13 |
+
|
| 14 |
+
from openmusic_analysis.application import build_service
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async def main() -> None:
|
| 18 |
+
parser = argparse.ArgumentParser(description="Measure model load and per-representation latency")
|
| 19 |
+
parser.add_argument("audio", type=Path)
|
| 20 |
+
parser.add_argument("--lyrics", type=Path)
|
| 21 |
+
args = parser.parse_args()
|
| 22 |
+
service = build_service()
|
| 23 |
+
lyrics = args.lyrics.read_text(encoding="utf-8") if args.lyrics else None
|
| 24 |
+
measurements = {}
|
| 25 |
+
started = time.perf_counter()
|
| 26 |
+
await service.load_models()
|
| 27 |
+
measurements["model_startup_seconds"] = time.perf_counter() - started
|
| 28 |
+
representations = ["audio.global", "audio.temporal"]
|
| 29 |
+
if lyrics is not None:
|
| 30 |
+
representations.append("lyrics.global")
|
| 31 |
+
for representation in representations:
|
| 32 |
+
started = time.perf_counter()
|
| 33 |
+
await service.analyze(
|
| 34 |
+
args.audio,
|
| 35 |
+
lyrics=lyrics,
|
| 36 |
+
requested_representations=[representation],
|
| 37 |
+
track_id=None,
|
| 38 |
+
content_identity=None,
|
| 39 |
+
)
|
| 40 |
+
measurements[f"{representation}_seconds"] = time.perf_counter() - started
|
| 41 |
+
measurements["peak_process_rss_platform_units"] = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss
|
| 42 |
+
try:
|
| 43 |
+
import torch
|
| 44 |
+
|
| 45 |
+
if torch.cuda.is_available():
|
| 46 |
+
measurements["peak_cuda_memory_bytes"] = torch.cuda.max_memory_allocated()
|
| 47 |
+
except ImportError:
|
| 48 |
+
pass
|
| 49 |
+
print(json.dumps(measurements, indent=2))
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
asyncio.run(main())
|