libavalon 0.0.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libavalon-0.0.1/.env.sample +2 -0
- libavalon-0.0.1/.gitignore +11 -0
- libavalon-0.0.1/.python-version +1 -0
- libavalon-0.0.1/LICENSE +7 -0
- libavalon-0.0.1/Makefile +14 -0
- libavalon-0.0.1/PKG-INFO +117 -0
- libavalon-0.0.1/README.md +98 -0
- libavalon-0.0.1/avalon/__init__.py +1 -0
- libavalon-0.0.1/avalon/analysis/__init__.py +0 -0
- libavalon-0.0.1/avalon/analysis/essentia_analyzer.py +140 -0
- libavalon-0.0.1/avalon/analysis/model_cache.py +140 -0
- libavalon-0.0.1/avalon/cli.py +413 -0
- libavalon-0.0.1/avalon/constants.py +137 -0
- libavalon-0.0.1/avalon/conversion/__init__.py +0 -0
- libavalon-0.0.1/avalon/conversion/converter.py +159 -0
- libavalon-0.0.1/avalon/models.py +53 -0
- libavalon-0.0.1/avalon/pathing.py +83 -0
- libavalon-0.0.1/avalon/pipeline.py +251 -0
- libavalon-0.0.1/avalon/state.py +65 -0
- libavalon-0.0.1/avalon/tagging/__init__.py +0 -0
- libavalon-0.0.1/avalon/tagging/analysis_blob.py +155 -0
- libavalon-0.0.1/avalon/tagging/cover_art.py +90 -0
- libavalon-0.0.1/avalon/tagging/tag_writer.py +249 -0
- libavalon-0.0.1/avalon/watcher.py +117 -0
- libavalon-0.0.1/pyproject.toml +44 -0
- libavalon-0.0.1/scripts/benchmark_analyze.py +86 -0
- libavalon-0.0.1/tests/test_analysis_blob.py +146 -0
- libavalon-0.0.1/tests/test_cli.py +126 -0
- libavalon-0.0.1/tests/test_pathing.py +105 -0
- libavalon-0.0.1/tests/test_pipeline.py +131 -0
- libavalon-0.0.1/tests/test_tag_writer.py +170 -0
- libavalon-0.0.1/uv.lock +804 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.11
|
libavalon-0.0.1/LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright 2026 Brian Abelson
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
libavalon-0.0.1/Makefile
ADDED
libavalon-0.0.1/PKG-INFO
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: libavalon
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Standalone audio analysis, tagging, and organization CLI
|
|
5
|
+
Author: abelsonlive
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Requires-Python: <3.12,>=3.10
|
|
9
|
+
Requires-Dist: essentia-tensorflow==2.1b6.dev1110
|
|
10
|
+
Requires-Dist: ffmpeg-python>=0.2.0
|
|
11
|
+
Requires-Dist: mutagen>=1.48.0
|
|
12
|
+
Requires-Dist: numpy<2
|
|
13
|
+
Requires-Dist: requests>=2.31.0
|
|
14
|
+
Requires-Dist: watchdog>=6.0.0
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest-cov>=4.0.0; extra == 'test'
|
|
17
|
+
Requires-Dist: pytest>=8.0.0; extra == 'test'
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# avalon
|
|
21
|
+
|
|
22
|
+
Analyzes, tags, and organizes a music library:
|
|
23
|
+
- BPM/key extraction, mood/genre/energy descriptors via Essentia
|
|
24
|
+
- ID3/Vorbis/MP4 tag normalization
|
|
25
|
+
- cover art, format conversion
|
|
26
|
+
|
|
27
|
+
Runs once over a folder or as a watching daemon. MusicBrainz/Discogs
|
|
28
|
+
reconciliation is left to Picard.
|
|
29
|
+
|
|
30
|
+
## Requirements
|
|
31
|
+
|
|
32
|
+
- Python 3.10–3.11 (see the `essentia-tensorflow` pin in `pyproject.toml` for why)
|
|
33
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
34
|
+
- `ffmpeg` on `PATH` — `brew install ffmpeg` / `apt install ffmpeg`
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
git clone <repository-url> && cd avalon
|
|
40
|
+
uv sync
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
OR
|
|
44
|
+
|
|
45
|
+
```shell
|
|
46
|
+
pip install libavalon
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
First run downloads Essentia's models (~26.5MB) to `~/.cache/avalon/models/`.
|
|
50
|
+
|
|
51
|
+
## Usage
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# tag in place
|
|
55
|
+
uv run avalon analyze ~/Music/Downloads --recursive
|
|
56
|
+
|
|
57
|
+
# reorganize into {artist}/{album}/{title}.{ext}
|
|
58
|
+
uv run avalon analyze ~/Music/Downloads --recursive --dest ~/Music/Library
|
|
59
|
+
|
|
60
|
+
# convert lossless sources, cap bit depth/sample rate (lossy sources untouched)
|
|
61
|
+
uv run avalon analyze ~/Music/Downloads --dest ~/Music/Library \
|
|
62
|
+
--convert-lossless-to aiff --max-bit-depth 16 --max-sample-rate 48000
|
|
63
|
+
|
|
64
|
+
# watch continuously, -v so you can see it working (backfills on startup)
|
|
65
|
+
uv run avalon watch ~/Music/Downloads --dest ~/Music/Library -v
|
|
66
|
+
|
|
67
|
+
# backfill a large library faster with 8 concurrent worker processes
|
|
68
|
+
uv run avalon analyze ~/Music/Downloads --recursive --dest ~/Music/Library --workers 8
|
|
69
|
+
|
|
70
|
+
# see what's actually in a file's tags
|
|
71
|
+
uv run avalon inspect ~/Music/Library/Artist/Album/01\ -\ Title.aiff
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Full flag list: `avalon analyze --help` / `avalon watch --help`.
|
|
75
|
+
|
|
76
|
+
## How it works
|
|
77
|
+
|
|
78
|
+
```mermaid
|
|
79
|
+
flowchart TD
|
|
80
|
+
src[source file]
|
|
81
|
+
src --> analyze[essentia analysis]
|
|
82
|
+
src --> conv{convert?}
|
|
83
|
+
conv -->|yes| ffmpeg
|
|
84
|
+
conv -->|no| copy[copy in place]
|
|
85
|
+
analyze --> write[write tags + art]
|
|
86
|
+
ffmpeg --> write
|
|
87
|
+
copy --> write
|
|
88
|
+
write --> out[output file]
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Analysis runs against the original file, before any conversion. Canonical
|
|
92
|
+
fields (title/artist/album/genre/bpm/key) only fill in when missing —
|
|
93
|
+
nothing gets overwritten unless you pass `--force-reanalyze`.
|
|
94
|
+
|
|
95
|
+
`--workers N` runs analysis in N separate worker processes instead of one
|
|
96
|
+
at a time — each has its own Essentia/TensorFlow session, so results never
|
|
97
|
+
cross between files. Destination-path collisions (e.g. two files with
|
|
98
|
+
missing tags both falling back to the same `Unknown Artist/Unknown Album`
|
|
99
|
+
path) are still resolved from a single process before any work is handed
|
|
100
|
+
to a worker, so numbering stays correct under `--workers` too.
|
|
101
|
+
|
|
102
|
+
## Tags
|
|
103
|
+
|
|
104
|
+
Two avalon-owned tags per file: a short headline (`bpm:128;key:Am;camelot:8A;
|
|
105
|
+
energy:0.71;genre:Techno`, in COMM/DESCRIPTION/desc, configurable via
|
|
106
|
+
`--headline-tag`/`--headline-format`) and an extended tag with the full
|
|
107
|
+
descriptor roster (`TXXX:AVALON_ANALYSIS` / a Vorbis field / an MP4 atom).
|
|
108
|
+
|
|
109
|
+
MusicBrainz/Discogs/AcoustID reconciliation isn't handled by avalon — run
|
|
110
|
+
[Picard](https://picard.musicbrainz.org/) over the library separately for that.
|
|
111
|
+
|
|
112
|
+
## Development
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
uv sync --extra test
|
|
116
|
+
uv run pytest
|
|
117
|
+
```
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# avalon
|
|
2
|
+
|
|
3
|
+
Analyzes, tags, and organizes a music library:
|
|
4
|
+
- BPM/key extraction, mood/genre/energy descriptors via Essentia
|
|
5
|
+
- ID3/Vorbis/MP4 tag normalization
|
|
6
|
+
- cover art, format conversion
|
|
7
|
+
|
|
8
|
+
Runs once over a folder or as a watching daemon. MusicBrainz/Discogs
|
|
9
|
+
reconciliation is left to Picard.
|
|
10
|
+
|
|
11
|
+
## Requirements
|
|
12
|
+
|
|
13
|
+
- Python 3.10–3.11 (see the `essentia-tensorflow` pin in `pyproject.toml` for why)
|
|
14
|
+
- [uv](https://docs.astral.sh/uv/)
|
|
15
|
+
- `ffmpeg` on `PATH` — `brew install ffmpeg` / `apt install ffmpeg`
|
|
16
|
+
|
|
17
|
+
## Install
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
git clone <repository-url> && cd avalon
|
|
21
|
+
uv sync
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
OR
|
|
25
|
+
|
|
26
|
+
```shell
|
|
27
|
+
pip install libavalon
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
First run downloads Essentia's models (~26.5MB) to `~/.cache/avalon/models/`.
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
# tag in place
|
|
36
|
+
uv run avalon analyze ~/Music/Downloads --recursive
|
|
37
|
+
|
|
38
|
+
# reorganize into {artist}/{album}/{title}.{ext}
|
|
39
|
+
uv run avalon analyze ~/Music/Downloads --recursive --dest ~/Music/Library
|
|
40
|
+
|
|
41
|
+
# convert lossless sources, cap bit depth/sample rate (lossy sources untouched)
|
|
42
|
+
uv run avalon analyze ~/Music/Downloads --dest ~/Music/Library \
|
|
43
|
+
--convert-lossless-to aiff --max-bit-depth 16 --max-sample-rate 48000
|
|
44
|
+
|
|
45
|
+
# watch continuously, -v so you can see it working (backfills on startup)
|
|
46
|
+
uv run avalon watch ~/Music/Downloads --dest ~/Music/Library -v
|
|
47
|
+
|
|
48
|
+
# backfill a large library faster with 8 concurrent worker processes
|
|
49
|
+
uv run avalon analyze ~/Music/Downloads --recursive --dest ~/Music/Library --workers 8
|
|
50
|
+
|
|
51
|
+
# see what's actually in a file's tags
|
|
52
|
+
uv run avalon inspect ~/Music/Library/Artist/Album/01\ -\ Title.aiff
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Full flag list: `avalon analyze --help` / `avalon watch --help`.
|
|
56
|
+
|
|
57
|
+
## How it works
|
|
58
|
+
|
|
59
|
+
```mermaid
|
|
60
|
+
flowchart TD
|
|
61
|
+
src[source file]
|
|
62
|
+
src --> analyze[essentia analysis]
|
|
63
|
+
src --> conv{convert?}
|
|
64
|
+
conv -->|yes| ffmpeg
|
|
65
|
+
conv -->|no| copy[copy in place]
|
|
66
|
+
analyze --> write[write tags + art]
|
|
67
|
+
ffmpeg --> write
|
|
68
|
+
copy --> write
|
|
69
|
+
write --> out[output file]
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Analysis runs against the original file, before any conversion. Canonical
|
|
73
|
+
fields (title/artist/album/genre/bpm/key) only fill in when missing —
|
|
74
|
+
nothing gets overwritten unless you pass `--force-reanalyze`.
|
|
75
|
+
|
|
76
|
+
`--workers N` runs analysis in N separate worker processes instead of one
|
|
77
|
+
at a time — each has its own Essentia/TensorFlow session, so results never
|
|
78
|
+
cross between files. Destination-path collisions (e.g. two files with
|
|
79
|
+
missing tags both falling back to the same `Unknown Artist/Unknown Album`
|
|
80
|
+
path) are still resolved from a single process before any work is handed
|
|
81
|
+
to a worker, so numbering stays correct under `--workers` too.
|
|
82
|
+
|
|
83
|
+
## Tags
|
|
84
|
+
|
|
85
|
+
Two avalon-owned tags per file: a short headline (`bpm:128;key:Am;camelot:8A;
|
|
86
|
+
energy:0.71;genre:Techno`, in COMM/DESCRIPTION/desc, configurable via
|
|
87
|
+
`--headline-tag`/`--headline-format`) and an extended tag with the full
|
|
88
|
+
descriptor roster (`TXXX:AVALON_ANALYSIS` / a Vorbis field / an MP4 atom).
|
|
89
|
+
|
|
90
|
+
MusicBrainz/Discogs/AcoustID reconciliation isn't handled by avalon — run
|
|
91
|
+
[Picard](https://picard.musicbrainz.org/) over the library separately for that.
|
|
92
|
+
|
|
93
|
+
## Development
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
uv sync --extra test
|
|
97
|
+
uv run pytest
|
|
98
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
File without changes
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Runs the full Essentia extraction roster against audio files.
|
|
2
|
+
|
|
3
|
+
One embedding pass (discogs-effnet) feeds every classifier head, so
|
|
4
|
+
breadth is cheap -- this is the "extract as much information as we can
|
|
5
|
+
from essentia" implementation, not a curated subset.
|
|
6
|
+
|
|
7
|
+
Loading a TensorFlow graph has real fixed overhead (~1-2s each); with 14
|
|
8
|
+
classifier heads plus the embedding extractor, reloading all of them for
|
|
9
|
+
every file measured at ~25s/track. Loading them once and reusing the
|
|
10
|
+
loaded graphs across files drops that to ~1.1s/track (measured), which is
|
|
11
|
+
the difference between a usable batch tool and one that takes days on a
|
|
12
|
+
real library. `EssentiaAnalyzer` is therefore a reusable object, not a
|
|
13
|
+
stateless function: construct one per CLI run and call `.analyze()` per
|
|
14
|
+
file.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
|
|
24
|
+
from avalon.analysis import model_cache
|
|
25
|
+
from avalon.constants import to_camelot
|
|
26
|
+
from avalon.models import Label, TrackAnalysis
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
_ANALYSIS_SAMPLE_RATE = 44100
|
|
31
|
+
_EMBEDDING_SAMPLE_RATE = 16000
|
|
32
|
+
_TOP_N_BY_MODEL = {"genre_discogs400": 3, "mtg_jamendo_moodtheme": 5}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _clean_label(raw: str) -> str:
|
|
36
|
+
""" "Electronic---Techno" -> "Electronic / Techno" for readability."""
|
|
37
|
+
return raw.replace("---", " / ")
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EssentiaAnalyzer:
|
|
41
|
+
"""Loads every model once; call `analyze(path)` per file."""
|
|
42
|
+
|
|
43
|
+
def __init__(self) -> None:
|
|
44
|
+
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
|
|
45
|
+
|
|
46
|
+
import essentia
|
|
47
|
+
import essentia.standard as es
|
|
48
|
+
|
|
49
|
+
essentia.log.warningActive = False
|
|
50
|
+
essentia.log.infoActive = False
|
|
51
|
+
|
|
52
|
+
self._es = es
|
|
53
|
+
embedding_pb, embedding_output = model_cache.get_embedding_model()
|
|
54
|
+
self._embedding_algo = es.TensorflowPredictEffnetDiscogs(
|
|
55
|
+
graphFilename=embedding_pb, output=embedding_output
|
|
56
|
+
)
|
|
57
|
+
self._classifiers: dict[str, tuple[object, model_cache.ModelMeta]] = {}
|
|
58
|
+
for spec in model_cache.CLASSIFIER_HEADS:
|
|
59
|
+
meta = model_cache.get_classifier(spec)
|
|
60
|
+
algo = es.TensorflowPredict2D(
|
|
61
|
+
graphFilename=meta.pb_path,
|
|
62
|
+
input=meta.input_name,
|
|
63
|
+
output=meta.output_name,
|
|
64
|
+
)
|
|
65
|
+
self._classifiers[spec.name] = (algo, meta)
|
|
66
|
+
|
|
67
|
+
def analyze(self, path: str) -> TrackAnalysis:
|
|
68
|
+
es = self._es
|
|
69
|
+
|
|
70
|
+
rhythm_audio = es.MonoLoader(filename=path, sampleRate=_ANALYSIS_SAMPLE_RATE)()
|
|
71
|
+
bpm, _beats, bpm_confidence, _estimates, _intervals = es.RhythmExtractor2013(
|
|
72
|
+
method="multifeature"
|
|
73
|
+
)(rhythm_audio)
|
|
74
|
+
key, scale, key_strength = es.KeyExtractor()(rhythm_audio)
|
|
75
|
+
dynamic_complexity, loudness = es.DynamicComplexity()(rhythm_audio)
|
|
76
|
+
|
|
77
|
+
embedding_audio = es.Resample(
|
|
78
|
+
inputSampleRate=_ANALYSIS_SAMPLE_RATE,
|
|
79
|
+
outputSampleRate=_EMBEDDING_SAMPLE_RATE,
|
|
80
|
+
quality=4,
|
|
81
|
+
)(rhythm_audio)
|
|
82
|
+
embeddings = self._embedding_algo(embedding_audio)
|
|
83
|
+
|
|
84
|
+
binary_probs: dict[str, float] = {}
|
|
85
|
+
categorical: dict[str, tuple[str, float]] = {}
|
|
86
|
+
multilabel: dict[str, list[Label]] = {}
|
|
87
|
+
|
|
88
|
+
for spec in model_cache.CLASSIFIER_HEADS:
|
|
89
|
+
algo, meta = self._classifiers[spec.name]
|
|
90
|
+
track_scores = np.mean(algo(embeddings), axis=0)
|
|
91
|
+
|
|
92
|
+
if spec.kind == "binary":
|
|
93
|
+
binary_probs[spec.name] = float(
|
|
94
|
+
track_scores[meta.classes.index(spec.positive_label)]
|
|
95
|
+
)
|
|
96
|
+
elif spec.kind == "categorical":
|
|
97
|
+
idx = int(np.argmax(track_scores))
|
|
98
|
+
categorical[spec.name] = (meta.classes[idx], float(track_scores[idx]))
|
|
99
|
+
elif spec.kind == "multilabel":
|
|
100
|
+
top_n = _TOP_N_BY_MODEL[spec.name]
|
|
101
|
+
order = np.argsort(track_scores)[::-1][:top_n]
|
|
102
|
+
multilabel[spec.name] = [
|
|
103
|
+
Label(
|
|
104
|
+
name=_clean_label(meta.classes[i]),
|
|
105
|
+
confidence=float(track_scores[i]),
|
|
106
|
+
)
|
|
107
|
+
for i in order
|
|
108
|
+
]
|
|
109
|
+
else:
|
|
110
|
+
raise ValueError(f"Unknown model kind: {spec.kind!r}")
|
|
111
|
+
|
|
112
|
+
gender_label, gender_confidence = categorical["gender"]
|
|
113
|
+
timbre_label, timbre_confidence = categorical["timbre"]
|
|
114
|
+
|
|
115
|
+
return TrackAnalysis(
|
|
116
|
+
bpm=float(bpm),
|
|
117
|
+
bpm_confidence=float(bpm_confidence),
|
|
118
|
+
key=key,
|
|
119
|
+
scale=scale,
|
|
120
|
+
camelot=to_camelot(key, scale),
|
|
121
|
+
key_strength=float(key_strength),
|
|
122
|
+
loudness=float(loudness),
|
|
123
|
+
dynamic_complexity=float(dynamic_complexity),
|
|
124
|
+
mood_aggressive=binary_probs["mood_aggressive"],
|
|
125
|
+
mood_happy=binary_probs["mood_happy"],
|
|
126
|
+
mood_sad=binary_probs["mood_sad"],
|
|
127
|
+
mood_relaxed=binary_probs["mood_relaxed"],
|
|
128
|
+
mood_party=binary_probs["mood_party"],
|
|
129
|
+
danceability=binary_probs["danceability"],
|
|
130
|
+
mood_acoustic=binary_probs["mood_acoustic"],
|
|
131
|
+
mood_electronic=binary_probs["mood_electronic"],
|
|
132
|
+
voice_probability=binary_probs["voice_instrumental"],
|
|
133
|
+
gender=gender_label,
|
|
134
|
+
gender_confidence=gender_confidence,
|
|
135
|
+
tonal_probability=binary_probs["tonal_atonal"],
|
|
136
|
+
timbre=timbre_label,
|
|
137
|
+
timbre_confidence=timbre_confidence,
|
|
138
|
+
genres=multilabel["genre_discogs400"],
|
|
139
|
+
mood_themes=multilabel["mtg_jamendo_moodtheme"],
|
|
140
|
+
)
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Downloads and caches the Essentia pretrained models avalon uses.
|
|
2
|
+
|
|
3
|
+
Each model's input/output tensor names and class label order come from its
|
|
4
|
+
`.json` sidecar at run time rather than being hardcoded: node names vary
|
|
5
|
+
per model (e.g. genre_discogs400 uses a different input node than the
|
|
6
|
+
mood/character heads), and -- more importantly -- class order is *not*
|
|
7
|
+
consistent (`mood_sad` is `["non_sad", "sad"]` while `mood_happy` is
|
|
8
|
+
`["happy", "non_happy"]`). Trusting the declared schema avoids silently
|
|
9
|
+
inverted probabilities.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import logging
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
|
|
21
|
+
from avalon.constants import MODEL_CACHE_DIRNAME
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
_BASE_URL = "https://essentia.upf.edu/models"
|
|
26
|
+
_EMBEDDING_SUBDIR = "feature-extractors/discogs-effnet"
|
|
27
|
+
_EMBEDDING_STEM = "discogs-effnet-bs64-1"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True, slots=True)
|
|
31
|
+
class ModelSpec:
|
|
32
|
+
"""One classifier head. `kind` drives how essentia_analyzer reads its
|
|
33
|
+
output vector:
|
|
34
|
+
binary -> single probability of `positive_label`
|
|
35
|
+
categorical -> whichever of `labels` scores highest, plus its confidence
|
|
36
|
+
multilabel -> independent (sigmoid) scores, no single "positive" class
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
name: str
|
|
40
|
+
subdir: str
|
|
41
|
+
kind: str
|
|
42
|
+
positive_label: str | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
CLASSIFIER_HEADS: tuple[ModelSpec, ...] = (
|
|
46
|
+
ModelSpec("danceability", "danceability", "binary", positive_label="danceable"),
|
|
47
|
+
ModelSpec("mood_acoustic", "mood_acoustic", "binary", positive_label="acoustic"),
|
|
48
|
+
ModelSpec(
|
|
49
|
+
"mood_aggressive", "mood_aggressive", "binary", positive_label="aggressive"
|
|
50
|
+
),
|
|
51
|
+
ModelSpec(
|
|
52
|
+
"mood_electronic", "mood_electronic", "binary", positive_label="electronic"
|
|
53
|
+
),
|
|
54
|
+
ModelSpec("mood_happy", "mood_happy", "binary", positive_label="happy"),
|
|
55
|
+
ModelSpec("mood_sad", "mood_sad", "binary", positive_label="sad"),
|
|
56
|
+
ModelSpec("mood_relaxed", "mood_relaxed", "binary", positive_label="relaxed"),
|
|
57
|
+
ModelSpec("mood_party", "mood_party", "binary", positive_label="party"),
|
|
58
|
+
ModelSpec(
|
|
59
|
+
"voice_instrumental", "voice_instrumental", "binary", positive_label="voice"
|
|
60
|
+
),
|
|
61
|
+
ModelSpec("tonal_atonal", "tonal_atonal", "binary", positive_label="tonal"),
|
|
62
|
+
ModelSpec("gender", "gender", "categorical"),
|
|
63
|
+
ModelSpec("timbre", "timbre", "categorical"),
|
|
64
|
+
ModelSpec("genre_discogs400", "genre_discogs400", "multilabel"),
|
|
65
|
+
ModelSpec("mtg_jamendo_moodtheme", "mtg_jamendo_moodtheme", "multilabel"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True, slots=True)
|
|
70
|
+
class ModelMeta:
|
|
71
|
+
"""Parsed `.json` sidecar: local file path plus the schema bits needed
|
|
72
|
+
to run inference generically."""
|
|
73
|
+
|
|
74
|
+
pb_path: str
|
|
75
|
+
input_name: str
|
|
76
|
+
output_name: str
|
|
77
|
+
classes: tuple[str, ...]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _cache_dir() -> Path:
|
|
81
|
+
path = Path.home() / ".cache" / MODEL_CACHE_DIRNAME / "models"
|
|
82
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
83
|
+
return path
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _download(url: str, dest: Path) -> None:
|
|
87
|
+
if dest.exists():
|
|
88
|
+
return
|
|
89
|
+
logger.info("Downloading model file %s", url)
|
|
90
|
+
response = requests.get(url, timeout=60)
|
|
91
|
+
response.raise_for_status()
|
|
92
|
+
tmp = dest.with_suffix(dest.suffix + ".part")
|
|
93
|
+
tmp.write_bytes(response.content)
|
|
94
|
+
tmp.rename(dest)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fetch(subdir: str, filename_stem: str) -> tuple[Path, dict]:
|
|
98
|
+
cache = _cache_dir()
|
|
99
|
+
pb_dest = cache / f"{filename_stem}.pb"
|
|
100
|
+
json_dest = cache / f"{filename_stem}.json"
|
|
101
|
+
base = f"{_BASE_URL}/{subdir}/{filename_stem}"
|
|
102
|
+
_download(f"{base}.pb", pb_dest)
|
|
103
|
+
_download(f"{base}.json", json_dest)
|
|
104
|
+
return pb_dest, json.loads(json_dest.read_text())
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _output_by_purpose(outputs: list[dict], purpose: str) -> str:
|
|
108
|
+
for out in outputs:
|
|
109
|
+
if out.get("output_purpose") == purpose:
|
|
110
|
+
return out["name"]
|
|
111
|
+
return outputs[0]["name"]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_embedding_model() -> tuple[str, str]:
|
|
115
|
+
"""Downloads (if needed) the shared discogs-effnet embedding extractor.
|
|
116
|
+
|
|
117
|
+
Returns (pb_path, embedding_output_node_name).
|
|
118
|
+
"""
|
|
119
|
+
pb_path, meta = _fetch(_EMBEDDING_SUBDIR, _EMBEDDING_STEM)
|
|
120
|
+
output_name = _output_by_purpose(meta["schema"]["outputs"], "embeddings")
|
|
121
|
+
return str(pb_path), output_name
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def get_classifier(spec: ModelSpec) -> ModelMeta:
|
|
125
|
+
filename_stem = f"{spec.name}-discogs-effnet-1"
|
|
126
|
+
pb_path, meta = _fetch(f"classification-heads/{spec.subdir}", filename_stem)
|
|
127
|
+
schema = meta["schema"]
|
|
128
|
+
return ModelMeta(
|
|
129
|
+
pb_path=str(pb_path),
|
|
130
|
+
input_name=schema["inputs"][0]["name"],
|
|
131
|
+
output_name=_output_by_purpose(schema["outputs"], "predictions"),
|
|
132
|
+
classes=tuple(meta["classes"]),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def prefetch_all() -> None:
|
|
137
|
+
"""Downloads every model avalon needs, up front (~26.5MB total)."""
|
|
138
|
+
get_embedding_model()
|
|
139
|
+
for spec in CLASSIFIER_HEADS:
|
|
140
|
+
get_classifier(spec)
|