kxmWebwe commited on
Commit
330f477
·
1 Parent(s): f2e9be2
.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
- gcc \
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: Track Embedding API
3
  emoji: 🎵
4
  colorFrom: blue
5
  colorTo: purple
@@ -7,6 +7,281 @@ sdk: docker
7
  pinned: false
8
  ---
9
 
10
- # Track Embedding API
11
 
12
- FastAPI service for CLAP audio embeddings.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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/audio-api/tracks
21
- cd /opt/audio-api
22
 
23
- # Скопируйте сюда ваши файлы: main.py, Dockerfile, docker-compose.yml, requirements.txt
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
- audio-api:
3
  build: .
4
- container_name: audio-embedding-api
5
  restart: unless-stopped # автоперезапуск при падении
6
  ports:
7
- - "8000:8000"
8
  volumes:
9
- - ./tracks:/app/tracks # треки хранятся снаружи контейнера
10
- - hf_cache:/root/.cache/huggingface # кеш моделей между перезапусками
11
  environment:
12
  - PYTHONUNBUFFERED=1
13
- - TRANSFORMERS_CACHE=/root/.cache/huggingface
 
 
14
  healthcheck:
15
- test: ["CMD", "curl", "-f", "http://localhost:8000/health"]
16
  interval: 30s
17
  timeout: 10s
18
  retries: 3
@@ -20,7 +21,7 @@ services:
20
  deploy:
21
  resources:
22
  limits:
23
- memory: 6G # CLAP занимает ~2GB, оставляем запас
24
  logging:
25
  driver: "json-file"
26
  options:
@@ -28,4 +29,4 @@ services:
28
  max-file: "3"
29
 
30
  volumes:
31
- hf_cache: # постоянный том для моделей HuggingFace
 
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
- import numpy as np
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
- # ─── ffmpeg ───────────────────────────────────────────────────────────────────
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.9
 
 
4
  torch==2.3.1
5
- torchaudio==2.3.1
6
  transformers==4.44.2
7
- scikit-learn==1.5.1
8
- numpy==1.26.4
9
- librosa==0.10.2
10
- pydub==0.25.1
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())