Spaces:
Running
Running
lirics+testing
Browse files- Dockerfile +1 -0
- README.md +86 -2
- openmusic_analysis/api.py +204 -12
- openmusic_analysis/application.py +97 -5
- openmusic_analysis/domain.py +42 -2
- openmusic_analysis/lyrics_resolution.py +744 -0
- openmusic_analysis/settings.py +80 -0
- tests/test_api.py +141 -1
- tests/test_lyrics_resolution.py +92 -0
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`
|
| 160 |
-
`
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 52 |
-
settings.limits.
|
| 53 |
-
|
| 54 |
-
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 58 |
raise AnalysisError(
|
| 59 |
"LYRICS_REQUIRED",
|
| 60 |
f"Representation '{representation}' requires lyrics.",
|
| 61 |
status_code=422,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
)
|
| 63 |
-
result = await analyzer.analyze(
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
schema_version: str = "1"
|
| 111 |
-
|
|
|
|
|
|
|
| 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.
|
|
|
|
|
|
|
| 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 |
+
]
|