kxmWebwe commited on
Commit
8494801
·
1 Parent(s): bff0fe1

lirics+testing

Browse files
Dockerfile CHANGED
@@ -3,6 +3,7 @@ FROM python:3.11-slim
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/*
 
3
  # Системные зависимости
4
  RUN apt-get update && apt-get install -y --no-install-recommends \
5
  ffmpeg \
6
+ libchromaprint-tools \
7
  libsndfile1 \
8
  curl \
9
  && rm -rf /var/lib/apt/lists/*
README.md CHANGED
@@ -156,8 +156,10 @@ Hello world' \
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
 
@@ -204,6 +206,66 @@ Errors never contain stack traces:
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
 
@@ -218,7 +280,22 @@ environment details. `GET /health` is a lightweight container probe.
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` |
@@ -226,6 +303,13 @@ environment details. `GET /health` is a lightweight container probe.
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`).
 
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` when lyrics are supplied or resolved by the fallback pipeline.
160
+ Optional `title`, `artist`, `album` and `isrc` fields improve automatic lookup. Explicitly
161
+ requesting `lyrics.global` fails with `fallback_errors` only when every enabled source
162
+ is exhausted.
163
 
164
  Abbreviated response:
165
 
 
206
  {"error": {"code": "AUDIO_DECODE_FAILED", "message": "The uploaded audio could not be decoded."}}
207
  ```
208
 
209
+ ### Rank tracks by audio similarity
210
+
211
+ `POST /v1/tracks/rank-similar` accepts one `target_audio` file and repeated `tracks`
212
+ files. Every file is embedded with `audio.global`; candidates are returned in descending
213
+ cosine-similarity order. Optional repeated `track_ids` must correspond to `tracks` in
214
+ the same order. Lyrics are resolved independently for the target and every candidate;
215
+ they do not affect the audio ranking.
216
+
217
+ ```bash
218
+ curl -X POST http://localhost:7860/v1/tracks/rank-similar \
219
+ -F 'target_audio=@target.flac' \
220
+ -F 'target_track_id=target' \
221
+ -F 'target_title=Target song' -F 'target_artist=Target artist' \
222
+ -F 'tracks=@candidate-a.flac' -F 'track_ids=a' \
223
+ -F 'track_titles=Candidate A' -F 'track_artists=Artist A' \
224
+ -F 'tracks=@candidate-b.flac' -F 'track_ids=b' \
225
+ -F 'track_titles=Candidate B' -F 'track_artists=Artist B'
226
+ ```
227
+
228
+ The response preserves each candidate's original index and filename:
229
+
230
+ ```json
231
+ {
232
+ "schema_version": "1",
233
+ "representation": "audio.global",
234
+ "target": {
235
+ "track_id": "target",
236
+ "filename": "target.flac",
237
+ "lyrics": {"text": "...", "source": "lrclib", "metadata": {}, "errors": []}
238
+ },
239
+ "tracks": [
240
+ {
241
+ "track_id": "b",
242
+ "filename": "candidate-b.flac",
243
+ "original_index": 1,
244
+ "similarity": 0.91,
245
+ "lyrics": {
246
+ "text": "...",
247
+ "source": "asr.openai",
248
+ "metadata": {"title": "Candidate B", "artist": "Artist B"},
249
+ "errors": [
250
+ {"source": "lrclib", "code": "LYRICS_NOT_FOUND",
251
+ "message": "LRCLIB request failed with HTTP 404."}
252
+ ]
253
+ }
254
+ }
255
+ ]
256
+ }
257
+ ```
258
+
259
+ Repeated optional fields `track_lyrics`, `track_titles`, `track_artists`, `track_albums`
260
+ and `track_isrcs` must contain exactly one value per `tracks` file when present. Use an
261
+ empty value to request fallback resolution for a particular candidate.
262
+
263
+ Lyrics fallback order is: request field, embedded audio tags, LRCLIB, Musixmatch,
264
+ AcoustID/Chromaprint identification followed by another provider lookup, and finally
265
+ ASR. Disabled sources are skipped; attempted failures are returned in `lyrics.errors`.
266
+ ASR is disabled by default because it can incur cost and singing transcription is less
267
+ reliable than catalog lyrics.
268
+
269
  `GET /v1/status` reports the selected device and loaded/available representations without
270
  environment details. `GET /health` is a lightweight container probe.
271
 
 
280
  | `OPENMUSIC_TEMPORAL_MAX_SEGMENTS` | `24` |
281
  | `OPENMUSIC_CLAP_BATCH_SIZE` | `4` |
282
  | `OPENMUSIC_LYRICS_CHUNK_TOKENS` | `512` |
283
+ | `OPENMUSIC_LYRICS_FALLBACK_ENABLED` | `true` |
284
+ | `OPENMUSIC_LRCLIB_ENABLED` | `true` |
285
+ | `OPENMUSIC_LRCLIB_MIN_INTERVAL_SECONDS` | `0.25` |
286
+ | `OPENMUSIC_MUSIXMATCH_API_KEY` | unset |
287
+ | `OPENMUSIC_ACOUSTID_API_KEY` | unset |
288
+ | `OPENMUSIC_FPCALC` | `fpcalc` |
289
+ | `OPENMUSIC_LYRICS_ASR_PROVIDER` | `none` |
290
+ | `OPENMUSIC_OPENAI_API_KEY` | unset |
291
+ | `OPENMUSIC_OPENAI_TRANSCRIPTION_MODEL` | `gpt-4o-mini-transcribe` |
292
+ | `OPENMUSIC_LYRICS_ASR_COMMAND` | unset |
293
+ | `OPENMUSIC_VOCAL_SEPARATOR_COMMAND` | unset |
294
+ | `OPENMUSIC_HTTP_USER_AGENT` | project URL user agent |
295
+ | `OPENMUSIC_LYRICS_FALLBACK_TIMEOUT` | `30` |
296
  | `OPENMUSIC_MAX_UPLOAD_BYTES` | `104857600` |
297
+ | `OPENMUSIC_MAX_SIMILARITY_REQUEST_BYTES` | `524288000` |
298
+ | `OPENMUSIC_MAX_SIMILARITY_TRACKS` | `50` |
299
  | `OPENMUSIC_MAX_LYRICS_CHARACTERS` | `100000` |
300
  | `OPENMUSIC_MAX_AUDIO_SECONDS` | `1800` |
301
  | `OPENMUSIC_REQUEST_TIMEOUT_SECONDS` | `300` |
 
303
  Changing result-affecting representation configuration changes its generated
304
  `preprocessing_version` and benchmark cache key.
305
 
306
+ Set `OPENMUSIC_LYRICS_ASR_PROVIDER=openai` together with `OPENMUSIC_OPENAI_API_KEY`
307
+ to enable cloud transcription. For a self-hosted engine, use `command` and configure
308
+ `OPENMUSIC_LYRICS_ASR_COMMAND`; `{input}` is replaced with a prepared mono MP3 path and
309
+ the command must write only recognized text to stdout. An optional separator command
310
+ receives `{input}` and `{output}` placeholders and must write a vocal-only WAV to
311
+ `{output}`. Commands are parsed as argument lists and are never executed through a shell.
312
+
313
  ## Benchmark tools
314
 
315
  Place optional UTF-8 lyrics beside audio using the same stem (`song.flac` + `song.txt`).
openmusic_analysis/api.py CHANGED
@@ -14,8 +14,15 @@ 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
 
@@ -48,11 +55,14 @@ def create_app(
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:
@@ -115,6 +125,10 @@ def create_app(
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(
@@ -122,13 +136,8 @@ def create_app(
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:
@@ -138,6 +147,13 @@ def create_app(
138
  requested_representations=representations,
139
  track_id=track_id,
140
  content_identity=content_identity,
 
 
 
 
 
 
 
141
  )
142
  finally:
143
  try:
@@ -145,9 +161,185 @@ def create_app(
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
 
14
  from fastapi.responses import JSONResponse
15
 
16
  from openmusic_analysis.application import MusicAnalysisService, build_service
17
+ from openmusic_analysis.domain import (
18
+ AnalysisResponse,
19
+ ModelsResponse,
20
+ SimilarityMatch,
21
+ SimilarityResponse,
22
+ SimilarityTrackReference,
23
+ )
24
  from openmusic_analysis.errors import AnalysisError
25
+ from openmusic_analysis.lyrics_resolution import LyricsLookupInput
26
  from openmusic_analysis.settings import Settings
27
 
28
 
 
55
  @app.middleware("http")
56
  async def request_limits(request: Request, call_next: Any):
57
  content_length = request.headers.get("content-length")
58
+ if request.url.path == "/v1/tracks/rank-similar":
59
+ request_limit = settings.limits.max_similarity_request_bytes
60
+ else:
61
+ request_limit = (
62
+ settings.limits.max_upload_bytes
63
+ + settings.limits.max_lyrics_characters * 4
64
+ + 1024 * 1024
65
+ )
66
  if content_length:
67
  try:
68
  if int(content_length) > request_limit:
 
125
  track_id: Annotated[str | None, Form()] = None,
126
  content_identity: Annotated[str | None, Form()] = None,
127
  requested_representations: Annotated[list[str] | None, Form()] = None,
128
+ title: Annotated[str | None, Form()] = None,
129
+ artist: Annotated[str | None, Form()] = None,
130
+ album: Annotated[str | None, Form()] = None,
131
+ isrc: Annotated[str | None, Form()] = None,
132
  ) -> AnalysisResponse:
133
  if lyrics is not None and len(lyrics) > settings.limits.max_lyrics_characters:
134
  raise AnalysisError(
 
136
  "Lyrics exceed the configured character limit.",
137
  status_code=413,
138
  )
139
+ _validate_audio_extension(audio)
140
  suffix = Path(audio.filename or "").suffix.lower()
 
 
 
 
 
 
141
  representations = _parse_representations(requested_representations)
142
  temp_path = await _store_upload(audio, suffix, settings.limits.max_upload_bytes)
143
  try:
 
147
  requested_representations=representations,
148
  track_id=track_id,
149
  content_identity=content_identity,
150
+ lyrics_metadata=LyricsLookupInput(
151
+ title=title,
152
+ artist=artist,
153
+ album=album,
154
+ isrc=isrc,
155
+ filename=audio.filename,
156
+ ),
157
  )
158
  finally:
159
  try:
 
161
  except FileNotFoundError:
162
  pass
163
 
164
+ @app.post("/v1/tracks/rank-similar", response_model=SimilarityResponse)
165
+ async def rank_similar_tracks(
166
+ target_audio: Annotated[UploadFile, File(description="Target audio file")],
167
+ tracks: Annotated[list[UploadFile], File(description="Candidate audio files")],
168
+ target_track_id: Annotated[str | None, Form()] = None,
169
+ track_ids: Annotated[list[str] | None, Form()] = None,
170
+ target_lyrics: Annotated[str | None, Form()] = None,
171
+ track_lyrics: Annotated[list[str] | None, Form()] = None,
172
+ target_title: Annotated[str | None, Form()] = None,
173
+ target_artist: Annotated[str | None, Form()] = None,
174
+ target_album: Annotated[str | None, Form()] = None,
175
+ target_isrc: Annotated[str | None, Form()] = None,
176
+ track_titles: Annotated[list[str] | None, Form()] = None,
177
+ track_artists: Annotated[list[str] | None, Form()] = None,
178
+ track_albums: Annotated[list[str] | None, Form()] = None,
179
+ track_isrcs: Annotated[list[str] | None, Form()] = None,
180
+ ) -> SimilarityResponse:
181
+ if not tracks:
182
+ raise AnalysisError(
183
+ "MISSING_CANDIDATE_TRACKS",
184
+ "At least one candidate track is required.",
185
+ status_code=422,
186
+ )
187
+ if len(tracks) > settings.limits.max_similarity_tracks:
188
+ raise AnalysisError(
189
+ "TOO_MANY_CANDIDATE_TRACKS",
190
+ f"At most {settings.limits.max_similarity_tracks} candidate tracks are allowed.",
191
+ status_code=413,
192
+ )
193
+ aligned_fields = {
194
+ "track_ids": track_ids,
195
+ "track_lyrics": track_lyrics,
196
+ "track_titles": track_titles,
197
+ "track_artists": track_artists,
198
+ "track_albums": track_albums,
199
+ "track_isrcs": track_isrcs,
200
+ }
201
+ for field_name, values in aligned_fields.items():
202
+ _validate_aligned_field(field_name, values, len(tracks))
203
+ if target_lyrics is not None:
204
+ _validate_lyrics_size(
205
+ target_lyrics,
206
+ settings.limits.max_lyrics_characters,
207
+ field="target_lyrics",
208
+ )
209
+ for index, value in enumerate(track_lyrics or []):
210
+ _validate_lyrics_size(
211
+ value,
212
+ settings.limits.max_lyrics_characters,
213
+ field="track_lyrics",
214
+ index=index,
215
+ )
216
+
217
+ _validate_audio_extension(target_audio)
218
+ for track in tracks:
219
+ _validate_audio_extension(track)
220
+
221
+ temp_paths: list[str] = []
222
+ try:
223
+ target_path = await _store_upload(
224
+ target_audio,
225
+ Path(target_audio.filename or "").suffix.lower(),
226
+ settings.limits.max_upload_bytes,
227
+ )
228
+ temp_paths.append(target_path)
229
+ candidate_paths: list[str] = []
230
+ for track in tracks:
231
+ path = await _store_upload(
232
+ track,
233
+ Path(track.filename or "").suffix.lower(),
234
+ settings.limits.max_upload_bytes,
235
+ )
236
+ candidate_paths.append(path)
237
+ temp_paths.append(path)
238
+
239
+ ranked = await service.rank_similar_audio(target_path, candidate_paths)
240
+ target_lyrics_resolution = await service.resolve_lyrics(
241
+ target_path,
242
+ provided_lyrics=target_lyrics,
243
+ supplied_metadata=LyricsLookupInput(
244
+ title=target_title,
245
+ artist=target_artist,
246
+ album=target_album,
247
+ isrc=target_isrc,
248
+ filename=target_audio.filename,
249
+ ),
250
+ )
251
+ candidate_lyrics = []
252
+ for index, path in enumerate(candidate_paths):
253
+ candidate_lyrics.append(
254
+ await service.resolve_lyrics(
255
+ path,
256
+ provided_lyrics=_optional_at(track_lyrics, index),
257
+ supplied_metadata=LyricsLookupInput(
258
+ title=_optional_at(track_titles, index),
259
+ artist=_optional_at(track_artists, index),
260
+ album=_optional_at(track_albums, index),
261
+ isrc=_optional_at(track_isrcs, index),
262
+ filename=tracks[index].filename,
263
+ ),
264
+ )
265
+ )
266
+ return SimilarityResponse(
267
+ target=SimilarityTrackReference(
268
+ track_id=target_track_id,
269
+ filename=target_audio.filename or "target",
270
+ lyrics=target_lyrics_resolution,
271
+ ),
272
+ tracks=[
273
+ SimilarityMatch(
274
+ original_index=index,
275
+ track_id=track_ids[index] if track_ids is not None else None,
276
+ filename=tracks[index].filename or f"track-{index}",
277
+ similarity=similarity,
278
+ lyrics=candidate_lyrics[index],
279
+ )
280
+ for index, similarity in ranked
281
+ ],
282
+ )
283
+ finally:
284
+ for path in temp_paths:
285
+ try:
286
+ os.unlink(path)
287
+ except FileNotFoundError:
288
+ pass
289
+
290
  return app
291
 
292
 
293
+ def _validate_audio_extension(upload: UploadFile) -> None:
294
+ suffix = Path(upload.filename or "").suffix.lower()
295
+ if suffix not in SUPPORTED_EXTENSIONS:
296
+ raise AnalysisError(
297
+ "UNSUPPORTED_AUDIO_FORMAT",
298
+ f"Supported extensions: {', '.join(sorted(SUPPORTED_EXTENSIONS))}.",
299
+ status_code=415,
300
+ )
301
+
302
+
303
+ def _validate_aligned_field(
304
+ field_name: str, values: list[str] | None, expected_count: int
305
+ ) -> None:
306
+ if values is not None and len(values) != expected_count:
307
+ code = "INVALID_TRACK_IDS" if field_name == "track_ids" else "INVALID_TRACK_FIELDS"
308
+ raise AnalysisError(
309
+ code,
310
+ f"{field_name} count must match the number of candidate tracks.",
311
+ status_code=422,
312
+ details={"field": field_name, "expected": expected_count, "actual": len(values)},
313
+ )
314
+
315
+
316
+ def _optional_at(values: list[str] | None, index: int) -> str | None:
317
+ if values is None:
318
+ return None
319
+ value = values[index].strip()
320
+ return value or None
321
+
322
+
323
+ def _validate_lyrics_size(
324
+ value: str,
325
+ max_characters: int,
326
+ *,
327
+ field: str,
328
+ index: int | None = None,
329
+ ) -> None:
330
+ if len(value) <= max_characters:
331
+ return
332
+ details: dict[str, Any] = {"field": field, "max_characters": max_characters}
333
+ if index is not None:
334
+ details["index"] = index
335
+ raise AnalysisError(
336
+ "LYRICS_TOO_LARGE",
337
+ "Lyrics exceed the configured character limit.",
338
+ status_code=413,
339
+ details=details,
340
+ )
341
+
342
+
343
  async def _store_upload(upload: UploadFile, suffix: str, max_bytes: int) -> str:
344
  size = 0
345
  path: str | None = None
openmusic_analysis/application.py CHANGED
@@ -2,6 +2,8 @@ from __future__ import annotations
2
 
3
  from pathlib import Path
4
 
 
 
5
  from openmusic_analysis.analyzers import (
6
  BGEM3LyricsAnalyzer,
7
  BGEM3TextEncoder,
@@ -11,8 +13,14 @@ from openmusic_analysis.analyzers import (
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
@@ -28,10 +36,12 @@ class MusicAnalysisService:
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,
@@ -41,12 +51,28 @@ class MusicAnalysisService:
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:
@@ -54,19 +80,28 @@ class MusicAnalysisService:
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(
@@ -103,6 +138,53 @@ class MusicAnalysisService:
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()
@@ -125,4 +207,14 @@ def build_service(settings: Settings | None = None) -> MusicAnalysisService:
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)
 
 
 
 
 
 
 
 
 
 
 
2
 
3
  from pathlib import Path
4
 
5
+ import numpy as np
6
+
7
  from openmusic_analysis.analyzers import (
8
  BGEM3LyricsAnalyzer,
9
  BGEM3TextEncoder,
 
13
  LyricsPreprocessor,
14
  )
15
  from openmusic_analysis.audio import AnalysisContext, AudioDecoder
16
+ from openmusic_analysis.domain import (
17
+ AnalysisResponse,
18
+ LyricsResolution,
19
+ ResolvedTrackMetadata,
20
+ TrackReference,
21
+ )
22
  from openmusic_analysis.errors import AnalysisError
23
+ from openmusic_analysis.lyrics_resolution import LyricsLookupInput, LyricsResolver
24
  from openmusic_analysis.registry import ModelRegistry
25
  from openmusic_analysis.runtime import DeviceManager, InferenceGate
26
  from openmusic_analysis.settings import Settings
 
36
  registry: ModelRegistry,
37
  decoder: AudioDecoder,
38
  device: str,
39
+ lyrics_resolver: LyricsResolver | None = None,
40
  ) -> None:
41
  self.registry = registry
42
  self.decoder = decoder
43
  self.device = device
44
+ self.lyrics_resolver = lyrics_resolver
45
 
46
  async def analyze(
47
  self,
 
51
  requested_representations: list[str] | None,
52
  track_id: str | None,
53
  content_identity: str | None,
54
+ lyrics_metadata: LyricsLookupInput | None = None,
55
  ) -> AnalysisResponse:
56
  if lyrics is not None and not lyrics.strip():
57
  raise AnalysisError(
58
  "INVALID_LYRICS", "Lyrics must contain non-whitespace text.", status_code=422
59
  )
60
+ should_resolve_lyrics = requested_representations is None or (
61
+ "lyrics.global" in requested_representations
62
+ )
63
+ lyrics_resolution: LyricsResolution | None = None
64
+ effective_lyrics = lyrics
65
+ if lyrics is not None or should_resolve_lyrics:
66
+ lyrics_resolution = await self.resolve_lyrics(
67
+ source_path,
68
+ provided_lyrics=lyrics,
69
+ supplied_metadata=lyrics_metadata,
70
+ )
71
+ effective_lyrics = lyrics_resolution.text
72
+
73
+ requested = self._resolve_representations(
74
+ requested_representations, effective_lyrics
75
+ )
76
  context = AnalysisContext(source_path, self.decoder)
77
  results = {}
78
  for representation in requested:
 
80
  if analyzer.input_kind == "audio":
81
  result = await analyzer.analyze(context)
82
  elif analyzer.input_kind == "lyrics":
83
+ if effective_lyrics is None:
84
  raise AnalysisError(
85
  "LYRICS_REQUIRED",
86
  f"Representation '{representation}' requires lyrics.",
87
  status_code=422,
88
+ details={
89
+ "fallback_errors": [
90
+ error.model_dump()
91
+ for error in (
92
+ lyrics_resolution.errors if lyrics_resolution else []
93
+ )
94
+ ]
95
+ },
96
  )
97
+ result = await analyzer.analyze(effective_lyrics)
98
  else:
99
  raise RuntimeError(f"Unknown analyzer input kind: {analyzer.input_kind}")
100
  results[representation] = result
101
  return AnalysisResponse(
102
  track=TrackReference(track_id=track_id, content_identity=content_identity),
103
  representations=results,
104
+ lyrics=lyrics_resolution,
105
  )
106
 
107
  def _resolve_representations(
 
138
  seen.add(id(encoder))
139
  await encoder.ready()
140
 
141
+ async def rank_similar_audio(
142
+ self,
143
+ target_source_path: str | Path,
144
+ candidate_source_paths: list[str | Path],
145
+ ) -> list[tuple[int, float]]:
146
+ """Rank candidates by cosine similarity in the global CLAP space."""
147
+ analyzer = self.registry.analyzer("audio.global")
148
+ target = await analyzer.analyze(AnalysisContext(target_source_path, self.decoder))
149
+ target_embedding = np.asarray(target.embedding, dtype=np.float32)
150
+
151
+ scores: list[tuple[int, float]] = []
152
+ for index, source_path in enumerate(candidate_source_paths):
153
+ candidate = await analyzer.analyze(AnalysisContext(source_path, self.decoder))
154
+ candidate_embedding = np.asarray(candidate.embedding, dtype=np.float32)
155
+ similarity = float(np.dot(target_embedding, candidate_embedding))
156
+ scores.append((index, float(np.clip(similarity, -1.0, 1.0))))
157
+
158
+ return sorted(scores, key=lambda item: (-item[1], item[0]))
159
+
160
+ async def resolve_lyrics(
161
+ self,
162
+ source_path: str | Path,
163
+ *,
164
+ provided_lyrics: str | None = None,
165
+ supplied_metadata: LyricsLookupInput | None = None,
166
+ ) -> LyricsResolution:
167
+ metadata = supplied_metadata or LyricsLookupInput()
168
+ if provided_lyrics and provided_lyrics.strip():
169
+ return LyricsResolution(
170
+ text=provided_lyrics,
171
+ source="request",
172
+ metadata=ResolvedTrackMetadata(
173
+ title=metadata.title,
174
+ artist=metadata.artist,
175
+ album=metadata.album,
176
+ isrc=metadata.isrc,
177
+ duration_seconds=metadata.duration_seconds,
178
+ ),
179
+ )
180
+ if self.lyrics_resolver is None:
181
+ return LyricsResolution()
182
+ return await self.lyrics_resolver.resolve(
183
+ source_path,
184
+ provided_lyrics=provided_lyrics,
185
+ supplied_metadata=metadata,
186
+ )
187
+
188
 
189
  def build_service(settings: Settings | None = None) -> MusicAnalysisService:
190
  settings = settings or Settings.from_env()
 
207
  max_audio_seconds=settings.limits.max_audio_seconds,
208
  timeout_seconds=settings.limits.decode_timeout_seconds,
209
  )
210
+ lyrics_resolver = LyricsResolver(
211
+ settings.lyrics_fallback,
212
+ ffmpeg_binary=settings.ffmpeg_binary,
213
+ ffprobe_binary=settings.ffprobe_binary,
214
+ )
215
+ return MusicAnalysisService(
216
+ registry=registry,
217
+ decoder=decoder,
218
+ device=device,
219
+ lyrics_resolver=lyrics_resolver,
220
+ )
openmusic_analysis/domain.py CHANGED
@@ -100,15 +100,55 @@ class TrackReference(StrictModel):
100
  content_identity: str | None = None
101
 
102
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
103
  class AnalysisResponse(StrictModel):
104
  schema_version: str = "1"
105
  track: TrackReference
106
  representations: dict[str, RepresentationResult]
 
107
 
108
 
109
- class ModelsResponse(StrictModel):
 
 
 
 
 
 
 
 
 
 
 
110
  schema_version: str = "1"
111
- models: list[ModelMetadata]
 
 
112
 
113
 
114
  class ErrorBody(StrictModel):
 
100
  content_identity: str | None = None
101
 
102
 
103
+ class ModelsResponse(StrictModel):
104
+ schema_version: str = "1"
105
+ models: list[ModelMetadata]
106
+
107
+
108
+ class LyricsSourceError(StrictModel):
109
+ source: str
110
+ code: str
111
+ message: str
112
+
113
+
114
+ class ResolvedTrackMetadata(StrictModel):
115
+ title: str | None = None
116
+ artist: str | None = None
117
+ album: str | None = None
118
+ isrc: str | None = None
119
+ duration_seconds: FiniteFloat | None = Field(default=None, gt=0)
120
+
121
+
122
+ class LyricsResolution(StrictModel):
123
+ text: str | None = None
124
+ source: str | None = None
125
+ metadata: ResolvedTrackMetadata = Field(default_factory=ResolvedTrackMetadata)
126
+ errors: list[LyricsSourceError] = Field(default_factory=list)
127
+
128
+
129
  class AnalysisResponse(StrictModel):
130
  schema_version: str = "1"
131
  track: TrackReference
132
  representations: dict[str, RepresentationResult]
133
+ lyrics: LyricsResolution | None = None
134
 
135
 
136
+ class SimilarityTrackReference(StrictModel):
137
+ track_id: str | None = None
138
+ filename: str
139
+ lyrics: LyricsResolution = Field(default_factory=LyricsResolution)
140
+
141
+
142
+ class SimilarityMatch(SimilarityTrackReference):
143
+ original_index: int = Field(ge=0)
144
+ similarity: FiniteFloat = Field(ge=-1.0, le=1.0)
145
+
146
+
147
+ class SimilarityResponse(StrictModel):
148
  schema_version: str = "1"
149
+ representation: str = "audio.global"
150
+ target: SimilarityTrackReference
151
+ tracks: list[SimilarityMatch]
152
 
153
 
154
  class ErrorBody(StrictModel):
openmusic_analysis/lyrics_resolution.py ADDED
@@ -0,0 +1,744 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ import logging
6
+ import math
7
+ import os
8
+ import shlex
9
+ import subprocess
10
+ import tempfile
11
+ import threading
12
+ import time
13
+ import uuid
14
+ from dataclasses import dataclass
15
+ from pathlib import Path
16
+ from typing import Any
17
+ from urllib.error import HTTPError, URLError
18
+ from urllib.parse import urlencode
19
+ from urllib.request import Request, urlopen
20
+
21
+ from openmusic_analysis.domain import (
22
+ LyricsResolution,
23
+ LyricsSourceError,
24
+ ResolvedTrackMetadata,
25
+ )
26
+ from openmusic_analysis.settings import LyricsFallbackConfig
27
+
28
+
29
+ log = logging.getLogger(__name__)
30
+ _LYRICS_TAGS = ("lyrics", "unsyncedlyrics", "syncedlyrics", "uslt", "sylt", "©lyr")
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class LyricsLookupInput:
35
+ title: str | None = None
36
+ artist: str | None = None
37
+ album: str | None = None
38
+ isrc: str | None = None
39
+ duration_seconds: float | None = None
40
+ filename: str | None = None
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class _ProviderResult:
45
+ text: str | None
46
+ error: LyricsSourceError | None
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class _TranscriptionResult:
51
+ text: str | None
52
+ source: str | None
53
+ errors: list[LyricsSourceError]
54
+
55
+
56
+ class LyricsResolver:
57
+ """Resolve lyrics through deterministic fallbacks without hiding failed sources."""
58
+
59
+ def __init__(
60
+ self,
61
+ config: LyricsFallbackConfig,
62
+ *,
63
+ ffmpeg_binary: str = "ffmpeg",
64
+ ffprobe_binary: str = "ffprobe",
65
+ ) -> None:
66
+ self.config = config
67
+ self.ffmpeg_binary = ffmpeg_binary
68
+ self.ffprobe_binary = ffprobe_binary
69
+ self._lrclib_lock = threading.Lock()
70
+ self._last_lrclib_request = 0.0
71
+
72
+ async def resolve(
73
+ self,
74
+ source_path: str | Path,
75
+ *,
76
+ provided_lyrics: str | None = None,
77
+ supplied_metadata: LyricsLookupInput | None = None,
78
+ ) -> LyricsResolution:
79
+ try:
80
+ return await asyncio.to_thread(
81
+ self._resolve_sync,
82
+ Path(source_path),
83
+ provided_lyrics,
84
+ supplied_metadata or LyricsLookupInput(),
85
+ )
86
+ except Exception:
87
+ log.exception("Unexpected lyrics resolution failure")
88
+ return LyricsResolution(
89
+ errors=[
90
+ _source_error(
91
+ "resolver",
92
+ "INTERNAL_ERROR",
93
+ "Lyrics resolution failed unexpectedly.",
94
+ )
95
+ ]
96
+ )
97
+
98
+ def _resolve_sync(
99
+ self,
100
+ source_path: Path,
101
+ provided_lyrics: str | None,
102
+ supplied_metadata: LyricsLookupInput,
103
+ ) -> LyricsResolution:
104
+ errors: list[LyricsSourceError] = []
105
+ supplied_metadata = _clean_metadata(supplied_metadata)
106
+ if provided_lyrics and provided_lyrics.strip():
107
+ return _resolution(provided_lyrics, "request", supplied_metadata, errors)
108
+
109
+ embedded_text, embedded_metadata, embedded_error = self._embedded(source_path)
110
+ metadata = _merge_metadata(supplied_metadata, embedded_metadata)
111
+ filename_path = Path(supplied_metadata.filename or source_path.name)
112
+ metadata = _merge_metadata(metadata, _metadata_from_filename(filename_path))
113
+ if embedded_error:
114
+ errors.append(embedded_error)
115
+ if embedded_text:
116
+ return _resolution(embedded_text, "embedded", metadata, errors)
117
+ if embedded_error is None:
118
+ errors.append(
119
+ _source_error(
120
+ "embedded",
121
+ "LYRICS_NOT_FOUND",
122
+ "Audio tags contain no lyrics.",
123
+ )
124
+ )
125
+ if not self.config.enabled:
126
+ return _resolution(None, None, metadata, errors)
127
+
128
+ text = self._lookup_providers(metadata, errors, suffix="")
129
+ if text:
130
+ return _resolution(text[0], text[1], metadata, errors)
131
+
132
+ fingerprint_metadata = self._fingerprint(source_path, errors)
133
+ if fingerprint_metadata is not None:
134
+ enriched = _merge_metadata(fingerprint_metadata, metadata)
135
+ if enriched != metadata:
136
+ metadata = enriched
137
+ text = self._lookup_providers(metadata, errors, suffix="_after_fingerprint")
138
+ if text:
139
+ return _resolution(text[0], text[1], metadata, errors)
140
+
141
+ asr = self._transcribe(source_path)
142
+ errors.extend(asr.errors)
143
+ if asr.text:
144
+ return _resolution(asr.text, asr.source, metadata, errors)
145
+ return _resolution(None, None, metadata, errors)
146
+
147
+ def _embedded(
148
+ self, source_path: Path
149
+ ) -> tuple[str | None, LyricsLookupInput, LyricsSourceError | None]:
150
+ command = [
151
+ self.ffprobe_binary,
152
+ "-v",
153
+ "error",
154
+ "-show_entries",
155
+ "format=duration:stream=duration:format_tags:stream_tags",
156
+ "-of",
157
+ "json",
158
+ str(source_path),
159
+ ]
160
+ try:
161
+ completed = subprocess.run(
162
+ command,
163
+ check=False,
164
+ stdout=subprocess.PIPE,
165
+ stderr=subprocess.PIPE,
166
+ timeout=self.config.timeout_seconds,
167
+ text=True,
168
+ )
169
+ if completed.returncode != 0:
170
+ raise RuntimeError("ffprobe could not read audio tags")
171
+ payload = json.loads(completed.stdout)
172
+ except (OSError, subprocess.TimeoutExpired, json.JSONDecodeError, RuntimeError) as exc:
173
+ log.info("Embedded lyrics lookup failed: %s", exc)
174
+ return None, LyricsLookupInput(), _source_error(
175
+ "embedded", "METADATA_READ_FAILED", "Audio tags could not be read."
176
+ )
177
+
178
+ tags: dict[str, str] = {}
179
+ if not isinstance(payload, dict):
180
+ return None, LyricsLookupInput(), _source_error(
181
+ "embedded", "INVALID_METADATA", "ffprobe returned invalid metadata."
182
+ )
183
+ streams = payload.get("streams")
184
+ containers = [payload.get("format") or {}]
185
+ if isinstance(streams, list):
186
+ containers.extend(item for item in streams if isinstance(item, dict))
187
+ for container in containers:
188
+ for name, value in (container.get("tags") or {}).items():
189
+ if value is not None:
190
+ tags.setdefault(str(name).lower(), str(value).strip())
191
+ duration = _positive_float((payload.get("format") or {}).get("duration"))
192
+ if duration is None and isinstance(streams, list) and streams:
193
+ first_stream = streams[0] if isinstance(streams[0], dict) else {}
194
+ duration = _positive_float(first_stream.get("duration"))
195
+ metadata = LyricsLookupInput(
196
+ title=_first(tags, "title"),
197
+ artist=_first(tags, "artist", "album_artist", "albumartist"),
198
+ album=_first(tags, "album"),
199
+ isrc=_first(tags, "isrc", "tsrc"),
200
+ duration_seconds=duration,
201
+ )
202
+ text = _first_lyrics(tags)
203
+ return _clean_lyrics(text), metadata, None
204
+
205
+ def _lookup_providers(
206
+ self,
207
+ metadata: LyricsLookupInput,
208
+ errors: list[LyricsSourceError],
209
+ *,
210
+ suffix: str,
211
+ ) -> tuple[str, str] | None:
212
+ if self.config.lrclib_enabled:
213
+ result = self._lrclib(metadata, source=f"lrclib{suffix}")
214
+ if result.error:
215
+ errors.append(result.error)
216
+ if result.text:
217
+ return result.text, "lrclib"
218
+ if self.config.musixmatch_api_key:
219
+ result = self._musixmatch(metadata, source=f"musixmatch{suffix}")
220
+ if result.error:
221
+ errors.append(result.error)
222
+ if result.text:
223
+ return result.text, "musixmatch"
224
+ return None
225
+
226
+ def _lrclib(self, metadata: LyricsLookupInput, *, source: str) -> _ProviderResult:
227
+ if not metadata.title or not metadata.artist:
228
+ return _ProviderResult(
229
+ None,
230
+ _source_error(
231
+ source,
232
+ "METADATA_REQUIRED",
233
+ "Title and artist are required for LRCLIB lookup.",
234
+ ),
235
+ )
236
+ query: dict[str, str] = {
237
+ "track_name": metadata.title,
238
+ "artist_name": metadata.artist,
239
+ }
240
+ if metadata.album:
241
+ query["album_name"] = metadata.album
242
+ if metadata.duration_seconds:
243
+ query["duration"] = str(int(round(metadata.duration_seconds)))
244
+ try:
245
+ with self._lrclib_lock:
246
+ elapsed = time.monotonic() - self._last_lrclib_request
247
+ remaining = self.config.lrclib_min_interval_seconds - elapsed
248
+ if remaining > 0:
249
+ time.sleep(remaining)
250
+ try:
251
+ payload = self._json_request(
252
+ f"{self.config.lrclib_base_url.rstrip('/')}/api/get?{urlencode(query)}"
253
+ )
254
+ finally:
255
+ self._last_lrclib_request = time.monotonic()
256
+ text = _clean_lyrics(payload.get("plainLyrics"))
257
+ if not text:
258
+ return _ProviderResult(
259
+ None,
260
+ _source_error(source, "LYRICS_NOT_FOUND", "LRCLIB returned no lyrics."),
261
+ )
262
+ return _ProviderResult(text, None)
263
+ except HTTPError as exc:
264
+ code = "LYRICS_NOT_FOUND" if exc.code == 404 else "HTTP_ERROR"
265
+ return _ProviderResult(
266
+ None, _source_error(source, code, f"LRCLIB request failed with HTTP {exc.code}.")
267
+ )
268
+ except (
269
+ OSError,
270
+ URLError,
271
+ ValueError,
272
+ TypeError,
273
+ AttributeError,
274
+ json.JSONDecodeError,
275
+ ):
276
+ return _ProviderResult(
277
+ None,
278
+ _source_error(source, "REQUEST_FAILED", "LRCLIB request failed."),
279
+ )
280
+
281
+ def _musixmatch(self, metadata: LyricsLookupInput, *, source: str) -> _ProviderResult:
282
+ if not metadata.isrc and not (metadata.title and metadata.artist):
283
+ return _ProviderResult(
284
+ None,
285
+ _source_error(
286
+ source,
287
+ "METADATA_REQUIRED",
288
+ "ISRC or title and artist are required for Musixmatch lookup.",
289
+ ),
290
+ )
291
+ query = {"apikey": self.config.musixmatch_api_key or ""}
292
+ if metadata.isrc:
293
+ query["track_isrc"] = metadata.isrc
294
+ else:
295
+ query["q_track"] = metadata.title or ""
296
+ query["q_artist"] = metadata.artist or ""
297
+ url = (
298
+ f"{self.config.musixmatch_base_url.rstrip('/')}/matcher.lyrics.get?"
299
+ f"{urlencode(query)}"
300
+ )
301
+ try:
302
+ payload = self._json_request(url)
303
+ header = ((payload.get("message") or {}).get("header") or {})
304
+ status = int(header.get("status_code", 0))
305
+ lyrics = (
306
+ (((payload.get("message") or {}).get("body") or {}).get("lyrics") or {})
307
+ .get("lyrics_body")
308
+ )
309
+ text = _clean_lyrics(lyrics)
310
+ if status != 200 or not text:
311
+ return _ProviderResult(
312
+ None,
313
+ _source_error(
314
+ source,
315
+ "LYRICS_NOT_FOUND" if status in {0, 404} else "HTTP_ERROR",
316
+ f"Musixmatch returned status {status}.",
317
+ ),
318
+ )
319
+ return _ProviderResult(text, None)
320
+ except (
321
+ OSError,
322
+ URLError,
323
+ ValueError,
324
+ TypeError,
325
+ AttributeError,
326
+ json.JSONDecodeError,
327
+ HTTPError,
328
+ ):
329
+ return _ProviderResult(
330
+ None,
331
+ _source_error(source, "REQUEST_FAILED", "Musixmatch request failed."),
332
+ )
333
+
334
+ def _fingerprint(
335
+ self, source_path: Path, errors: list[LyricsSourceError]
336
+ ) -> LyricsLookupInput | None:
337
+ if not self.config.acoustid_api_key:
338
+ return None
339
+ try:
340
+ completed = subprocess.run(
341
+ [self.config.fpcalc_binary, "-json", str(source_path)],
342
+ check=False,
343
+ stdout=subprocess.PIPE,
344
+ stderr=subprocess.PIPE,
345
+ timeout=self.config.timeout_seconds,
346
+ text=True,
347
+ )
348
+ if completed.returncode != 0:
349
+ raise RuntimeError("fpcalc failed")
350
+ fingerprint = json.loads(completed.stdout)
351
+ body = urlencode(
352
+ {
353
+ "client": self.config.acoustid_api_key,
354
+ "duration": fingerprint["duration"],
355
+ "fingerprint": fingerprint["fingerprint"],
356
+ "meta": "recordings releasegroups compress",
357
+ }
358
+ ).encode("ascii")
359
+ payload = self._json_request(
360
+ f"{self.config.acoustid_base_url.rstrip('/')}/lookup",
361
+ data=body,
362
+ content_type="application/x-www-form-urlencoded",
363
+ )
364
+ results = payload.get("results") or []
365
+ best_result = (
366
+ max(results, key=lambda item: float(item.get("score", 0)))
367
+ if results
368
+ else {}
369
+ )
370
+ recordings = best_result.get("recordings")
371
+ if not recordings:
372
+ errors.append(
373
+ _source_error(
374
+ "acoustid", "TRACK_NOT_IDENTIFIED", "AcoustID found no recording."
375
+ )
376
+ )
377
+ return None
378
+ recording = recordings[0]
379
+ artists = recording.get("artists") or []
380
+ releasegroups = recording.get("releasegroups") or []
381
+ return LyricsLookupInput(
382
+ title=_clean_value(recording.get("title")),
383
+ artist=_clean_value(artists[0].get("name")) if artists else None,
384
+ album=_clean_value(releasegroups[0].get("title")) if releasegroups else None,
385
+ duration_seconds=_positive_float(fingerprint.get("duration")),
386
+ )
387
+ except (
388
+ OSError,
389
+ subprocess.TimeoutExpired,
390
+ RuntimeError,
391
+ KeyError,
392
+ ValueError,
393
+ TypeError,
394
+ AttributeError,
395
+ IndexError,
396
+ ):
397
+ errors.append(
398
+ _source_error("acoustid", "IDENTIFICATION_FAILED", "AcoustID lookup failed.")
399
+ )
400
+ return None
401
+
402
+ def _transcribe(self, source_path: Path) -> _TranscriptionResult:
403
+ if self.config.asr_provider == "none":
404
+ return _TranscriptionResult(None, None, [])
405
+ prepared_paths: list[Path] = []
406
+ errors: list[LyricsSourceError] = []
407
+ input_path = source_path
408
+ if self.config.vocal_separator_command:
409
+ try:
410
+ input_path = self._run_vocal_separator(source_path)
411
+ prepared_paths.append(input_path)
412
+ except Exception:
413
+ errors.append(
414
+ _source_error(
415
+ "vocal_separator",
416
+ "SEPARATION_FAILED",
417
+ "Vocal separation failed; ASR will use the original audio.",
418
+ )
419
+ )
420
+ try:
421
+ compressed = self._compress_for_asr(input_path)
422
+ prepared_paths.append(compressed)
423
+ if self.config.asr_provider == "openai":
424
+ result = self._openai_transcribe(compressed)
425
+ else:
426
+ result = self._command_transcribe(compressed)
427
+ if result.error:
428
+ errors.append(result.error)
429
+ return _TranscriptionResult(
430
+ result.text,
431
+ f"asr.{self.config.asr_provider}" if result.text else None,
432
+ errors,
433
+ )
434
+ except (OSError, subprocess.TimeoutExpired, RuntimeError, KeyError, ValueError):
435
+ errors.append(
436
+ _source_error(
437
+ "asr.prepare",
438
+ "AUDIO_PREPARATION_FAILED",
439
+ "Audio could not be prepared for transcription.",
440
+ )
441
+ )
442
+ return _TranscriptionResult(
443
+ None,
444
+ None,
445
+ errors,
446
+ )
447
+ finally:
448
+ for path in prepared_paths:
449
+ try:
450
+ os.unlink(path)
451
+ except FileNotFoundError:
452
+ pass
453
+
454
+ def _run_vocal_separator(self, source_path: Path) -> Path:
455
+ descriptor, output_name = tempfile.mkstemp(prefix="openmusic-vocals-", suffix=".wav")
456
+ os.close(descriptor)
457
+ output_path = Path(output_name)
458
+ os.unlink(output_path)
459
+ try:
460
+ command = _configured_command(
461
+ self.config.vocal_separator_command or "",
462
+ input_path=source_path,
463
+ output_path=output_path,
464
+ )
465
+ completed = subprocess.run(
466
+ command,
467
+ check=False,
468
+ stdout=subprocess.PIPE,
469
+ stderr=subprocess.PIPE,
470
+ timeout=max(self.config.timeout_seconds, 300.0),
471
+ )
472
+ except Exception:
473
+ try:
474
+ os.unlink(output_path)
475
+ except FileNotFoundError:
476
+ pass
477
+ raise
478
+ invalid_output = (
479
+ completed.returncode != 0
480
+ or not output_path.exists()
481
+ or output_path.stat().st_size == 0
482
+ )
483
+ if invalid_output:
484
+ try:
485
+ os.unlink(output_path)
486
+ except FileNotFoundError:
487
+ pass
488
+ raise RuntimeError("Vocal separator failed")
489
+ return output_path
490
+
491
+ def _compress_for_asr(self, source_path: Path) -> Path:
492
+ descriptor, output_name = tempfile.mkstemp(prefix="openmusic-asr-", suffix=".mp3")
493
+ os.close(descriptor)
494
+ output_path = Path(output_name)
495
+ try:
496
+ completed = subprocess.run(
497
+ [
498
+ self.ffmpeg_binary,
499
+ "-v",
500
+ "error",
501
+ "-nostdin",
502
+ "-i",
503
+ str(source_path),
504
+ "-vn",
505
+ "-ac",
506
+ "1",
507
+ "-ar",
508
+ "16000",
509
+ "-b:a",
510
+ "64k",
511
+ "-y",
512
+ str(output_path),
513
+ ],
514
+ check=False,
515
+ stdout=subprocess.PIPE,
516
+ stderr=subprocess.PIPE,
517
+ timeout=max(self.config.timeout_seconds, 120.0),
518
+ )
519
+ except Exception:
520
+ try:
521
+ os.unlink(output_path)
522
+ except FileNotFoundError:
523
+ pass
524
+ raise
525
+ invalid_output = (
526
+ completed.returncode != 0
527
+ or not output_path.exists()
528
+ or output_path.stat().st_size == 0
529
+ )
530
+ if invalid_output:
531
+ try:
532
+ os.unlink(output_path)
533
+ except FileNotFoundError:
534
+ pass
535
+ raise RuntimeError("ASR audio preparation failed")
536
+ return output_path
537
+
538
+ def _openai_transcribe(self, source_path: Path) -> _ProviderResult:
539
+ boundary = f"openmusic-{uuid.uuid4().hex}"
540
+ file_data = source_path.read_bytes()
541
+ body = b"".join(
542
+ [
543
+ _multipart_field(boundary, "model", self.config.openai_transcription_model),
544
+ _multipart_file(boundary, "file", source_path.name, "audio/mpeg", file_data),
545
+ f"--{boundary}--\r\n".encode("ascii"),
546
+ ]
547
+ )
548
+ request = Request(
549
+ f"{self.config.openai_base_url.rstrip('/')}/audio/transcriptions",
550
+ data=body,
551
+ headers={
552
+ "Authorization": f"Bearer {self.config.openai_api_key}",
553
+ "Content-Type": f"multipart/form-data; boundary={boundary}",
554
+ "User-Agent": self.config.http_user_agent,
555
+ },
556
+ method="POST",
557
+ )
558
+ try:
559
+ with urlopen(request, timeout=max(self.config.timeout_seconds, 120.0)) as response:
560
+ payload = json.loads(response.read().decode("utf-8"))
561
+ text = _clean_lyrics(payload.get("text"))
562
+ if not text:
563
+ return _ProviderResult(
564
+ None,
565
+ _source_error("asr.openai", "EMPTY_TRANSCRIPTION", "ASR returned no text."),
566
+ )
567
+ return _ProviderResult(text, None)
568
+ except (
569
+ HTTPError,
570
+ URLError,
571
+ OSError,
572
+ TypeError,
573
+ AttributeError,
574
+ json.JSONDecodeError,
575
+ ):
576
+ return _ProviderResult(
577
+ None,
578
+ _source_error("asr.openai", "REQUEST_FAILED", "OpenAI ASR request failed."),
579
+ )
580
+
581
+ def _command_transcribe(self, source_path: Path) -> _ProviderResult:
582
+ command = _configured_command(
583
+ self.config.local_asr_command or "",
584
+ input_path=source_path,
585
+ output_path=None,
586
+ )
587
+ completed = subprocess.run(
588
+ command,
589
+ check=False,
590
+ stdout=subprocess.PIPE,
591
+ stderr=subprocess.PIPE,
592
+ timeout=max(self.config.timeout_seconds, 300.0),
593
+ text=True,
594
+ )
595
+ text = _clean_lyrics(completed.stdout)
596
+ if completed.returncode != 0 or not text:
597
+ return _ProviderResult(
598
+ None,
599
+ _source_error("asr.command", "COMMAND_FAILED", "Local ASR command failed."),
600
+ )
601
+ return _ProviderResult(text, None)
602
+
603
+ def _json_request(
604
+ self,
605
+ url: str,
606
+ *,
607
+ data: bytes | None = None,
608
+ content_type: str | None = None,
609
+ ) -> dict[str, Any]:
610
+ headers = {"User-Agent": self.config.http_user_agent, "Accept": "application/json"}
611
+ if content_type:
612
+ headers["Content-Type"] = content_type
613
+ request = Request(url, data=data, headers=headers, method="POST" if data else "GET")
614
+ with urlopen(request, timeout=self.config.timeout_seconds) as response:
615
+ return json.loads(response.read().decode("utf-8"))
616
+
617
+
618
+ def _resolution(
619
+ text: str | None,
620
+ source: str | None,
621
+ metadata: LyricsLookupInput,
622
+ errors: list[LyricsSourceError],
623
+ ) -> LyricsResolution:
624
+ return LyricsResolution(
625
+ text=_clean_lyrics(text),
626
+ source=source,
627
+ metadata=ResolvedTrackMetadata(
628
+ title=metadata.title,
629
+ artist=metadata.artist,
630
+ album=metadata.album,
631
+ isrc=metadata.isrc,
632
+ duration_seconds=metadata.duration_seconds,
633
+ ),
634
+ errors=errors,
635
+ )
636
+
637
+
638
+ def _merge_metadata(primary: LyricsLookupInput, fallback: LyricsLookupInput) -> LyricsLookupInput:
639
+ return LyricsLookupInput(
640
+ title=primary.title or fallback.title,
641
+ artist=primary.artist or fallback.artist,
642
+ album=primary.album or fallback.album,
643
+ isrc=primary.isrc or fallback.isrc,
644
+ duration_seconds=primary.duration_seconds or fallback.duration_seconds,
645
+ filename=primary.filename or fallback.filename,
646
+ )
647
+
648
+
649
+ def _clean_metadata(metadata: LyricsLookupInput) -> LyricsLookupInput:
650
+ return LyricsLookupInput(
651
+ title=_clean_value(metadata.title),
652
+ artist=_clean_value(metadata.artist),
653
+ album=_clean_value(metadata.album),
654
+ isrc=_clean_value(metadata.isrc),
655
+ duration_seconds=_positive_float(metadata.duration_seconds),
656
+ filename=_clean_value(metadata.filename),
657
+ )
658
+
659
+
660
+ def _source_error(source: str, code: str, message: str) -> LyricsSourceError:
661
+ return LyricsSourceError(source=source, code=code, message=message)
662
+
663
+
664
+ def _first(values: dict[str, str], *names: str) -> str | None:
665
+ for name in names:
666
+ value = _clean_value(values.get(name))
667
+ if value:
668
+ return value
669
+ return None
670
+
671
+
672
+ def _first_lyrics(values: dict[str, str]) -> str | None:
673
+ direct = _first(values, *_LYRICS_TAGS)
674
+ if direct:
675
+ return direct
676
+ for name, value in values.items():
677
+ if name.startswith(("lyrics-", "uslt-", "sylt-")):
678
+ cleaned = _clean_value(value)
679
+ if cleaned:
680
+ return cleaned
681
+ return None
682
+
683
+
684
+ def _metadata_from_filename(source_path: Path) -> LyricsLookupInput:
685
+ parts = source_path.stem.split(" - ", maxsplit=1)
686
+ if len(parts) != 2:
687
+ return LyricsLookupInput()
688
+ artist, title = (part.strip() for part in parts)
689
+ if not artist or not title:
690
+ return LyricsLookupInput()
691
+ return LyricsLookupInput(title=title, artist=artist)
692
+
693
+
694
+ def _clean_value(value: object) -> str | None:
695
+ if value is None:
696
+ return None
697
+ cleaned = str(value).strip()
698
+ return cleaned or None
699
+
700
+
701
+ def _clean_lyrics(value: object) -> str | None:
702
+ cleaned = _clean_value(value)
703
+ if not cleaned:
704
+ return None
705
+ return cleaned.replace("\r\n", "\n").replace("\r", "\n")
706
+
707
+
708
+ def _positive_float(value: object) -> float | None:
709
+ try:
710
+ result = float(value)
711
+ except (TypeError, ValueError):
712
+ return None
713
+ return result if math.isfinite(result) and result > 0 else None
714
+
715
+
716
+ def _configured_command(
717
+ template: str,
718
+ *,
719
+ input_path: Path,
720
+ output_path: Path | None,
721
+ ) -> list[str]:
722
+ values = {"input": str(input_path), "output": str(output_path) if output_path else ""}
723
+ return [part.format_map(values) for part in shlex.split(template)]
724
+
725
+
726
+ def _multipart_field(boundary: str, name: str, value: str) -> bytes:
727
+ return (
728
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"\r\n\r\n'
729
+ f"{value}\r\n"
730
+ ).encode("utf-8")
731
+
732
+
733
+ def _multipart_file(
734
+ boundary: str,
735
+ name: str,
736
+ filename: str,
737
+ content_type: str,
738
+ value: bytes,
739
+ ) -> bytes:
740
+ header = (
741
+ f'--{boundary}\r\nContent-Disposition: form-data; name="{name}"; '
742
+ f'filename="{filename}"\r\nContent-Type: {content_type}\r\n\r\n'
743
+ ).encode("utf-8")
744
+ return header + value + b"\r\n"
openmusic_analysis/settings.py CHANGED
@@ -99,6 +99,8 @@ class LyricsConfig:
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
@@ -107,6 +109,8 @@ class LimitConfig:
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,
@@ -115,6 +119,40 @@ class LimitConfig:
115
  raise ValueError("All service limits must be positive")
116
 
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
  @dataclass(frozen=True)
119
  class Settings:
120
  device: str = "auto"
@@ -125,6 +163,7 @@ class Settings:
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:
@@ -153,11 +192,51 @@ class Settings:
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"))),
@@ -167,6 +246,7 @@ class Settings:
167
  global_audio=global_audio,
168
  temporal_audio=temporal_audio,
169
  lyrics=lyrics,
 
170
  limits=limits,
171
  )
172
 
 
99
  @dataclass(frozen=True)
100
  class LimitConfig:
101
  max_upload_bytes: int = 100 * 1024 * 1024
102
+ max_similarity_request_bytes: int = 500 * 1024 * 1024
103
+ max_similarity_tracks: int = 50
104
  max_lyrics_characters: int = 100_000
105
  max_audio_seconds: float = 30 * 60
106
  request_timeout_seconds: float = 300.0
 
109
  def __post_init__(self) -> None:
110
  if min(
111
  self.max_upload_bytes,
112
+ self.max_similarity_request_bytes,
113
+ self.max_similarity_tracks,
114
  self.max_lyrics_characters,
115
  self.max_audio_seconds,
116
  self.request_timeout_seconds,
 
119
  raise ValueError("All service limits must be positive")
120
 
121
 
122
+ @dataclass(frozen=True)
123
+ class LyricsFallbackConfig:
124
+ enabled: bool = True
125
+ lrclib_enabled: bool = True
126
+ lrclib_base_url: str = "https://lrclib.net"
127
+ lrclib_min_interval_seconds: float = 0.25
128
+ musixmatch_api_key: str | None = None
129
+ musixmatch_base_url: str = "https://api.musixmatch.com/ws/1.1"
130
+ acoustid_api_key: str | None = None
131
+ acoustid_base_url: str = "https://api.acoustid.org/v2"
132
+ fpcalc_binary: str = "fpcalc"
133
+ asr_provider: str = "none"
134
+ openai_api_key: str | None = None
135
+ openai_base_url: str = "https://api.openai.com/v1"
136
+ openai_transcription_model: str = "gpt-4o-mini-transcribe"
137
+ local_asr_command: str | None = None
138
+ vocal_separator_command: str | None = None
139
+ http_user_agent: str = (
140
+ "OpenMusicAnalysis/1.0 "
141
+ "(https://huggingface.co/spaces/kxmWebwe/trackembeddingapi)"
142
+ )
143
+ timeout_seconds: float = 30.0
144
+
145
+ def __post_init__(self) -> None:
146
+ if self.asr_provider not in {"none", "openai", "command"}:
147
+ raise ValueError("Lyrics ASR provider must be none, openai or command")
148
+ if self.timeout_seconds <= 0 or self.lrclib_min_interval_seconds < 0:
149
+ raise ValueError("Lyrics fallback time values are invalid")
150
+ if self.asr_provider == "openai" and not self.openai_api_key:
151
+ raise ValueError("OPENMUSIC_OPENAI_API_KEY is required for OpenAI ASR")
152
+ if self.asr_provider == "command" and not self.local_asr_command:
153
+ raise ValueError("OPENMUSIC_LYRICS_ASR_COMMAND is required for command ASR")
154
+
155
+
156
  @dataclass(frozen=True)
157
  class Settings:
158
  device: str = "auto"
 
163
  global_audio: GlobalAudioConfig = GlobalAudioConfig()
164
  temporal_audio: TemporalAudioConfig = TemporalAudioConfig()
165
  lyrics: LyricsConfig = LyricsConfig()
166
+ lyrics_fallback: LyricsFallbackConfig = LyricsFallbackConfig()
167
  limits: LimitConfig = LimitConfig()
168
 
169
  def __post_init__(self) -> None:
 
192
  )
193
  limits = LimitConfig(
194
  max_upload_bytes=int(os.getenv("OPENMUSIC_MAX_UPLOAD_BYTES", str(100 * 1024 * 1024))),
195
+ max_similarity_request_bytes=int(
196
+ os.getenv(
197
+ "OPENMUSIC_MAX_SIMILARITY_REQUEST_BYTES", str(500 * 1024 * 1024)
198
+ )
199
+ ),
200
+ max_similarity_tracks=int(os.getenv("OPENMUSIC_MAX_SIMILARITY_TRACKS", "50")),
201
  max_lyrics_characters=int(os.getenv("OPENMUSIC_MAX_LYRICS_CHARACTERS", "100000")),
202
  max_audio_seconds=float(os.getenv("OPENMUSIC_MAX_AUDIO_SECONDS", "1800")),
203
  request_timeout_seconds=float(os.getenv("OPENMUSIC_REQUEST_TIMEOUT_SECONDS", "300")),
204
  decode_timeout_seconds=float(os.getenv("OPENMUSIC_DECODE_TIMEOUT_SECONDS", "120")),
205
  )
206
+ lyrics_fallback = LyricsFallbackConfig(
207
+ enabled=_env_bool("OPENMUSIC_LYRICS_FALLBACK_ENABLED", True),
208
+ lrclib_enabled=_env_bool("OPENMUSIC_LRCLIB_ENABLED", True),
209
+ lrclib_base_url=os.getenv("OPENMUSIC_LRCLIB_BASE_URL", "https://lrclib.net"),
210
+ lrclib_min_interval_seconds=float(
211
+ os.getenv("OPENMUSIC_LRCLIB_MIN_INTERVAL_SECONDS", "0.25")
212
+ ),
213
+ musixmatch_api_key=os.getenv("OPENMUSIC_MUSIXMATCH_API_KEY"),
214
+ musixmatch_base_url=os.getenv(
215
+ "OPENMUSIC_MUSIXMATCH_BASE_URL",
216
+ "https://api.musixmatch.com/ws/1.1",
217
+ ),
218
+ acoustid_api_key=os.getenv("OPENMUSIC_ACOUSTID_API_KEY"),
219
+ acoustid_base_url=os.getenv(
220
+ "OPENMUSIC_ACOUSTID_BASE_URL", "https://api.acoustid.org/v2"
221
+ ),
222
+ fpcalc_binary=os.getenv("OPENMUSIC_FPCALC", "fpcalc"),
223
+ asr_provider=os.getenv("OPENMUSIC_LYRICS_ASR_PROVIDER", "none"),
224
+ openai_api_key=os.getenv("OPENMUSIC_OPENAI_API_KEY"),
225
+ openai_base_url=os.getenv(
226
+ "OPENMUSIC_OPENAI_BASE_URL", "https://api.openai.com/v1"
227
+ ),
228
+ openai_transcription_model=os.getenv(
229
+ "OPENMUSIC_OPENAI_TRANSCRIPTION_MODEL", "gpt-4o-mini-transcribe"
230
+ ),
231
+ local_asr_command=os.getenv("OPENMUSIC_LYRICS_ASR_COMMAND"),
232
+ vocal_separator_command=os.getenv("OPENMUSIC_VOCAL_SEPARATOR_COMMAND"),
233
+ http_user_agent=os.getenv(
234
+ "OPENMUSIC_HTTP_USER_AGENT",
235
+ "OpenMusicAnalysis/1.0 "
236
+ "(https://huggingface.co/spaces/kxmWebwe/trackembeddingapi)",
237
+ ),
238
+ timeout_seconds=float(os.getenv("OPENMUSIC_LYRICS_FALLBACK_TIMEOUT", "30")),
239
+ )
240
  return cls(
241
  device=os.getenv("OPENMUSIC_DEVICE", "auto"),
242
  inference_concurrency=max(1, int(os.getenv("OPENMUSIC_INFERENCE_CONCURRENCY", "1"))),
 
246
  global_audio=global_audio,
247
  temporal_audio=temporal_audio,
248
  lyrics=lyrics,
249
+ lyrics_fallback=lyrics_fallback,
250
  limits=limits,
251
  )
252
 
tests/test_api.py CHANGED
@@ -1,10 +1,14 @@
1
  from __future__ import annotations
2
 
 
 
3
  import numpy as np
4
  from fastapi.testclient import TestClient
5
 
6
  from openmusic_analysis.api import create_app
7
- from openmusic_analysis.settings import Settings, TemporalAudioConfig
 
 
8
 
9
  from .conftest import FailingAudioEncoder, make_service
10
 
@@ -138,3 +142,139 @@ def test_temporal_api_response_uses_adjacent_transition_index():
138
  assert len(temporal["segments"]) == 15
139
  assert temporal["summary"]["number_of_segments"] == 15
140
  assert 0 <= temporal["summary"]["largest_transition_index"] <= 13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from __future__ import annotations
2
 
3
+ from pathlib import Path
4
+
5
  import numpy as np
6
  from fastapi.testclient import TestClient
7
 
8
  from openmusic_analysis.api import create_app
9
+ from openmusic_analysis.audio.decoder import AudioMetadata, DecodedAudio
10
+ from openmusic_analysis.domain import LyricsResolution, LyricsSourceError
11
+ from openmusic_analysis.settings import LimitConfig, Settings, TemporalAudioConfig
12
 
13
  from .conftest import FailingAudioEncoder, make_service
14
 
 
142
  assert len(temporal["segments"]) == 15
143
  assert temporal["summary"]["number_of_segments"] == 15
144
  assert 0 <= temporal["summary"]["largest_transition_index"] <= 13
145
+
146
+
147
+ class ContentDecoder:
148
+ canonical_sample_rate = 10
149
+
150
+ def __init__(self) -> None:
151
+ self.calls = 0
152
+
153
+ def decode(self, source_path) -> DecodedAudio:
154
+ self.calls += 1
155
+ content = source_path.read_bytes()
156
+ if content in {b"target", b"same"}:
157
+ waveform = np.linspace(-1.0, 1.0, 95, dtype=np.float32)
158
+ elif content == b"reversed":
159
+ waveform = np.linspace(1.0, -1.0, 95, dtype=np.float32)
160
+ else:
161
+ waveform = np.full(95, 3.0, dtype=np.float32)
162
+ return DecodedAudio(
163
+ waveform=waveform,
164
+ sample_rate=self.canonical_sample_rate,
165
+ metadata=AudioMetadata(
166
+ duration_ms=9500,
167
+ source_sample_rate=10,
168
+ source_channels=1,
169
+ source_format="fake",
170
+ ),
171
+ )
172
+
173
+
174
+ class FakeLyricsResolver:
175
+ async def resolve(self, source_path, *, provided_lyrics=None, supplied_metadata=None):
176
+ content = Path(source_path).read_bytes()
177
+ if content == b"same":
178
+ return LyricsResolution(
179
+ text="Resolved candidate lyrics",
180
+ source="lrclib",
181
+ errors=[
182
+ LyricsSourceError(
183
+ source="embedded",
184
+ code="LYRICS_NOT_FOUND",
185
+ message="No embedded lyrics.",
186
+ )
187
+ ],
188
+ )
189
+ return LyricsResolution(
190
+ errors=[
191
+ LyricsSourceError(
192
+ source="lrclib",
193
+ code="LYRICS_NOT_FOUND",
194
+ message="No provider match.",
195
+ )
196
+ ]
197
+ )
198
+
199
+
200
+ def test_rank_similar_tracks_returns_descending_global_audio_similarity(service_bundle):
201
+ service, _, encoder = service_bundle
202
+ decoder = ContentDecoder()
203
+ service.decoder = decoder
204
+ service.lyrics_resolver = FakeLyricsResolver()
205
+ response = client_for(service).post(
206
+ "/v1/tracks/rank-similar",
207
+ files=[
208
+ ("target_audio", ("target.mp3", b"target", "audio/mpeg")),
209
+ ("tracks", ("different.mp3", b"different", "audio/mpeg")),
210
+ ("tracks", ("same.mp3", b"same", "audio/mpeg")),
211
+ ("tracks", ("reversed.mp3", b"reversed", "audio/mpeg")),
212
+ ],
213
+ data={
214
+ "target_track_id": "target-id",
215
+ "target_lyrics": "Lyrics supplied for target",
216
+ "track_ids": ["different-id", "same-id", "reversed-id"],
217
+ },
218
+ )
219
+ assert response.status_code == 200
220
+ body = response.json()
221
+ assert body["representation"] == "audio.global"
222
+ assert body["target"]["track_id"] == "target-id"
223
+ assert body["target"]["filename"] == "target.mp3"
224
+ assert body["target"]["lyrics"]["text"] == "Lyrics supplied for target"
225
+ assert body["target"]["lyrics"]["source"] == "request"
226
+ assert body["tracks"][0]["track_id"] == "same-id"
227
+ assert body["tracks"][0]["similarity"] == 1.0
228
+ assert body["tracks"][0]["lyrics"]["text"] == "Resolved candidate lyrics"
229
+ assert body["tracks"][0]["lyrics"]["source"] == "lrclib"
230
+ assert body["tracks"][0]["lyrics"]["errors"][0]["source"] == "embedded"
231
+ assert [item["similarity"] for item in body["tracks"]] == sorted(
232
+ [item["similarity"] for item in body["tracks"]], reverse=True
233
+ )
234
+ assert decoder.calls == 4
235
+ assert len(encoder.calls) == 4
236
+
237
+
238
+ def test_analyze_automatically_adds_lyrics_representation(service_bundle):
239
+ service, _, _ = service_bundle
240
+ service.lyrics_resolver = FakeLyricsResolver()
241
+ response = client_for(service).post(
242
+ "/v1/tracks/analyze",
243
+ files=upload(b"same"),
244
+ data={"title": "Candidate", "artist": "Artist"},
245
+ )
246
+ assert response.status_code == 200
247
+ body = response.json()
248
+ assert "lyrics.global" in body["representations"]
249
+ assert body["lyrics"]["text"] == "Resolved candidate lyrics"
250
+ assert body["lyrics"]["source"] == "lrclib"
251
+
252
+
253
+ def test_rank_similar_tracks_validates_track_id_count(service_bundle):
254
+ service, _, _ = service_bundle
255
+ response = client_for(service).post(
256
+ "/v1/tracks/rank-similar",
257
+ files=[
258
+ ("target_audio", ("target.mp3", b"target", "audio/mpeg")),
259
+ ("tracks", ("one.mp3", b"one", "audio/mpeg")),
260
+ ("tracks", ("two.mp3", b"two", "audio/mpeg")),
261
+ ],
262
+ data={"track_ids": ["only-one-id"]},
263
+ )
264
+ assert response.status_code == 422
265
+ assert response.json()["error"]["code"] == "INVALID_TRACK_IDS"
266
+
267
+
268
+ def test_rank_similar_tracks_respects_candidate_limit(service_bundle):
269
+ service, _, _ = service_bundle
270
+ settings = Settings(limits=LimitConfig(max_similarity_tracks=1))
271
+ response = TestClient(create_app(service=service, settings=settings)).post(
272
+ "/v1/tracks/rank-similar",
273
+ files=[
274
+ ("target_audio", ("target.mp3", b"target", "audio/mpeg")),
275
+ ("tracks", ("one.mp3", b"one", "audio/mpeg")),
276
+ ("tracks", ("two.mp3", b"two", "audio/mpeg")),
277
+ ],
278
+ )
279
+ assert response.status_code == 413
280
+ assert response.json()["error"]["code"] == "TOO_MANY_CANDIDATE_TRACKS"
tests/test_lyrics_resolution.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from openmusic_analysis.domain import LyricsSourceError
6
+ from openmusic_analysis.lyrics_resolution import (
7
+ LyricsLookupInput,
8
+ LyricsResolver,
9
+ _ProviderResult,
10
+ )
11
+ from openmusic_analysis.settings import LyricsFallbackConfig
12
+
13
+
14
+ class PipelineResolver(LyricsResolver):
15
+ def __init__(self) -> None:
16
+ super().__init__(
17
+ LyricsFallbackConfig(
18
+ lrclib_enabled=True,
19
+ musixmatch_api_key="test-key",
20
+ acoustid_api_key="test-key",
21
+ asr_provider="command",
22
+ local_asr_command="fake {input}",
23
+ )
24
+ )
25
+
26
+ def _embedded(self, source_path):
27
+ return (
28
+ None,
29
+ LyricsLookupInput(duration_seconds=180),
30
+ LyricsSourceError(
31
+ source="embedded",
32
+ code="LYRICS_NOT_FOUND",
33
+ message="No embedded lyrics.",
34
+ ),
35
+ )
36
+
37
+ def _lrclib(self, metadata, *, source):
38
+ if metadata.title == "Identified title":
39
+ return _ProviderResult("Resolved lyrics", None)
40
+ return _ProviderResult(
41
+ None,
42
+ LyricsSourceError(
43
+ source=source,
44
+ code="METADATA_REQUIRED",
45
+ message="Metadata missing.",
46
+ ),
47
+ )
48
+
49
+ def _musixmatch(self, metadata, *, source):
50
+ return _ProviderResult(
51
+ None,
52
+ LyricsSourceError(
53
+ source=source,
54
+ code="LYRICS_NOT_FOUND",
55
+ message="No Musixmatch result.",
56
+ ),
57
+ )
58
+
59
+ def _fingerprint(self, source_path, errors):
60
+ return LyricsLookupInput(title="Identified title", artist="Identified artist")
61
+
62
+ def _transcribe(self, source_path):
63
+ raise AssertionError("ASR must not run after provider resolution")
64
+
65
+
66
+ def test_provided_lyrics_short_circuit_every_fallback(tmp_path: Path):
67
+ source = tmp_path / "track.mp3"
68
+ source.write_bytes(b"audio")
69
+ resolver = PipelineResolver()
70
+ result = resolver._resolve_sync(
71
+ source,
72
+ "Provided lyrics",
73
+ LyricsLookupInput(title="Title", artist="Artist"),
74
+ )
75
+ assert result.text == "Provided lyrics"
76
+ assert result.source == "request"
77
+ assert result.errors == []
78
+
79
+
80
+ def test_fingerprint_enriches_metadata_and_provider_errors_are_preserved(tmp_path: Path):
81
+ source = tmp_path / "track.mp3"
82
+ source.write_bytes(b"audio")
83
+ result = PipelineResolver()._resolve_sync(source, None, LyricsLookupInput())
84
+ assert result.text == "Resolved lyrics"
85
+ assert result.source == "lrclib"
86
+ assert result.metadata.title == "Identified title"
87
+ assert result.metadata.artist == "Identified artist"
88
+ assert [error.source for error in result.errors] == [
89
+ "embedded",
90
+ "lrclib",
91
+ "musixmatch",
92
+ ]