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.
@@ -0,0 +1,2 @@
1
+ ACOUSTID_API_KEY=xxx
2
+ DISCOGS_TOKEN=xxx
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .pytest_cache/
5
+ *.egg-info/
6
+ dist/
7
+ .DS_Store
8
+ .avalon_state.json
9
+ .env
10
+ smoke_test_output/
11
+ scratch/
@@ -0,0 +1 @@
1
+ 3.11
@@ -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.
@@ -0,0 +1,14 @@
1
+ .PHONY: lint test build upload
2
+
3
+ lint:
4
+ uv run ruff check .
5
+
6
+ test:
7
+ uv run --extra test pytest
8
+
9
+ build:
10
+ rm -rf dist
11
+ uv run python -m build
12
+
13
+ upload: build
14
+ uv run twine upload dist/*
@@ -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)