settag 0.1.0__py3-none-any.whl
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.
- settag/__init__.py +3 -0
- settag/__main__.py +4 -0
- settag/analysis_worker.py +328 -0
- settag/analyzer.py +356 -0
- settag/catalog.py +167 -0
- settag/cli/__init__.py +12 -0
- settag/cli/args.py +300 -0
- settag/cli/commands.py +616 -0
- settag/cli/render.py +468 -0
- settag/config.py +78 -0
- settag/hashing.py +219 -0
- settag/hygiene.py +419 -0
- settag/journal.py +501 -0
- settag/model_store.py +192 -0
- settag/plans.py +562 -0
- settag/policy.py +128 -0
- settag/records.py +229 -0
- settag/scanner.py +45 -0
- settag/state.py +422 -0
- settag/tags.py +972 -0
- settag/tasks.py +45 -0
- settag/taxonomy.py +52 -0
- settag/tui/__init__.py +30 -0
- settag/tui/app.py +1729 -0
- settag/tui/entries.py +121 -0
- settag/tui/hygiene.py +462 -0
- settag/tui/screens.py +334 -0
- settag/tui/style.py +323 -0
- settag/tui/table.py +239 -0
- settag/workflow.py +891 -0
- settag-0.1.0.dist-info/METADATA +688 -0
- settag-0.1.0.dist-info/RECORD +36 -0
- settag-0.1.0.dist-info/WHEEL +4 -0
- settag-0.1.0.dist-info/entry_points.txt +2 -0
- settag-0.1.0.dist-info/licenses/LICENSE +661 -0
- settag-0.1.0.dist-info/licenses/THIRD_PARTY_NOTICES.md +115 -0
settag/__init__.py
ADDED
settag/__main__.py
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from contextlib import suppress
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from multiprocessing import get_context
|
|
7
|
+
from multiprocessing.connection import Connection
|
|
8
|
+
from multiprocessing.process import BaseProcess
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from threading import Lock
|
|
11
|
+
from typing import Any, Protocol, cast
|
|
12
|
+
|
|
13
|
+
from settag.analyzer import EssentiaGenreAnalyzer, EssentiaTaskAnalyzer
|
|
14
|
+
from settag.policy import AudioSample
|
|
15
|
+
from settag.tasks import AnalysisTask, ordered_tasks
|
|
16
|
+
from settag.workflow import (
|
|
17
|
+
AnalysisBatch,
|
|
18
|
+
CancelCallback,
|
|
19
|
+
ProgressCallback,
|
|
20
|
+
analyze_paths,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
AnalyzerFactory = Callable[[Path, tuple[AnalysisTask, ...], AudioSample], Any]
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ProcessContext(Protocol):
|
|
27
|
+
def Pipe(self, duplex: bool = True) -> tuple[Connection, Connection]: ...
|
|
28
|
+
|
|
29
|
+
def Process(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
target: Callable[..., object],
|
|
33
|
+
args: tuple[object, ...],
|
|
34
|
+
name: str,
|
|
35
|
+
daemon: bool,
|
|
36
|
+
) -> BaseProcess: ...
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class AnalysisWorkerError(RuntimeError):
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True)
|
|
44
|
+
class _AnalyzeRequest:
|
|
45
|
+
path: Path
|
|
46
|
+
top: int
|
|
47
|
+
threshold: float
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class _AnalysisResult:
|
|
52
|
+
batch: AnalysisBatch
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class _AnalysisError:
|
|
57
|
+
error_type: str
|
|
58
|
+
message: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class _Shutdown:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _create_analyzer(
|
|
67
|
+
model_dir: Path,
|
|
68
|
+
tasks: tuple[AnalysisTask, ...],
|
|
69
|
+
sample: AudioSample,
|
|
70
|
+
) -> EssentiaGenreAnalyzer | EssentiaTaskAnalyzer:
|
|
71
|
+
if tasks == ("genre",):
|
|
72
|
+
return EssentiaGenreAnalyzer(model_dir, sample=sample)
|
|
73
|
+
return EssentiaTaskAnalyzer(model_dir, tasks, sample=sample)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _analysis_worker_main(
|
|
77
|
+
connection: Connection,
|
|
78
|
+
model_dir: Path,
|
|
79
|
+
tasks: tuple[AnalysisTask, ...],
|
|
80
|
+
sample: AudioSample,
|
|
81
|
+
analyzer_factory: AnalyzerFactory,
|
|
82
|
+
) -> None:
|
|
83
|
+
analyzer: Any | None = None
|
|
84
|
+
try:
|
|
85
|
+
while True:
|
|
86
|
+
try:
|
|
87
|
+
request = connection.recv()
|
|
88
|
+
except EOFError:
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
if isinstance(request, _Shutdown):
|
|
92
|
+
return
|
|
93
|
+
if not isinstance(request, _AnalyzeRequest):
|
|
94
|
+
response: _AnalysisResult | _AnalysisError = _AnalysisError(
|
|
95
|
+
"RuntimeError",
|
|
96
|
+
f"Analyzer worker received an invalid request: {type(request).__name__}",
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
try:
|
|
100
|
+
if analyzer is None:
|
|
101
|
+
analyzer = analyzer_factory(model_dir, tasks, sample)
|
|
102
|
+
batch = analyze_paths(
|
|
103
|
+
(request.path,),
|
|
104
|
+
analyzer=analyzer,
|
|
105
|
+
top=request.top,
|
|
106
|
+
threshold=request.threshold,
|
|
107
|
+
)
|
|
108
|
+
response = _AnalysisResult(batch)
|
|
109
|
+
except Exception as error:
|
|
110
|
+
response = _AnalysisError(type(error).__name__, str(error))
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
connection.send(response)
|
|
114
|
+
except (BrokenPipeError, EOFError, OSError):
|
|
115
|
+
return
|
|
116
|
+
finally:
|
|
117
|
+
connection.close()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class SubprocessAnalysisLoader:
|
|
121
|
+
"""Run serial analysis in one persistent spawned process.
|
|
122
|
+
|
|
123
|
+
Textual still calls this loader from its thread worker. That thread blocks
|
|
124
|
+
only on IPC while native Essentia/TensorFlow work runs in another process,
|
|
125
|
+
keeping the UI event loop independent from the analyzer's GIL and CPU use.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(
|
|
129
|
+
self,
|
|
130
|
+
model_dir: Path,
|
|
131
|
+
tasks: Sequence[AnalysisTask],
|
|
132
|
+
*,
|
|
133
|
+
top: int,
|
|
134
|
+
threshold: float,
|
|
135
|
+
sample: AudioSample = "full",
|
|
136
|
+
analyzer_factory: AnalyzerFactory = _create_analyzer,
|
|
137
|
+
context: ProcessContext | None = None,
|
|
138
|
+
poll_interval: float = 0.05,
|
|
139
|
+
shutdown_timeout: float = 5.0,
|
|
140
|
+
) -> None:
|
|
141
|
+
selected = ordered_tasks(tasks)
|
|
142
|
+
if not selected:
|
|
143
|
+
raise ValueError("analysis worker requires at least one task")
|
|
144
|
+
if poll_interval <= 0:
|
|
145
|
+
raise ValueError("analysis worker poll interval must be positive")
|
|
146
|
+
if shutdown_timeout < 0:
|
|
147
|
+
raise ValueError("analysis worker shutdown timeout cannot be negative")
|
|
148
|
+
|
|
149
|
+
self.model_dir = model_dir.expanduser().resolve()
|
|
150
|
+
self.tasks = selected
|
|
151
|
+
self.top = top
|
|
152
|
+
self.threshold = threshold
|
|
153
|
+
self.sample = sample
|
|
154
|
+
self._analyzer_factory = analyzer_factory
|
|
155
|
+
self._context = context or cast(ProcessContext, get_context("spawn"))
|
|
156
|
+
self._poll_interval = poll_interval
|
|
157
|
+
self._shutdown_timeout = shutdown_timeout
|
|
158
|
+
self._connection: Connection | None = None
|
|
159
|
+
self._process: BaseProcess | None = None
|
|
160
|
+
self._lock = Lock()
|
|
161
|
+
self._closed = False
|
|
162
|
+
|
|
163
|
+
def __call__(
|
|
164
|
+
self,
|
|
165
|
+
paths: Sequence[Path],
|
|
166
|
+
on_progress: ProgressCallback,
|
|
167
|
+
should_cancel: CancelCallback,
|
|
168
|
+
) -> AnalysisBatch:
|
|
169
|
+
planned = []
|
|
170
|
+
failures = []
|
|
171
|
+
selected_paths = tuple(paths)
|
|
172
|
+
cancelled = False
|
|
173
|
+
|
|
174
|
+
with self._lock:
|
|
175
|
+
if self._closed:
|
|
176
|
+
raise AnalysisWorkerError("Analyzer worker is closed")
|
|
177
|
+
self._ensure_started()
|
|
178
|
+
|
|
179
|
+
for index, path in enumerate(selected_paths, start=1):
|
|
180
|
+
if should_cancel():
|
|
181
|
+
cancelled = True
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
response = self._analyze(path)
|
|
185
|
+
if isinstance(response, _AnalysisError):
|
|
186
|
+
raise AnalysisWorkerError(f"{response.error_type}: {response.message}")
|
|
187
|
+
|
|
188
|
+
planned.extend(response.batch.planned)
|
|
189
|
+
failures.extend(response.batch.failures)
|
|
190
|
+
on_progress(index, len(selected_paths), path)
|
|
191
|
+
if should_cancel():
|
|
192
|
+
cancelled = True
|
|
193
|
+
break
|
|
194
|
+
|
|
195
|
+
return AnalysisBatch(
|
|
196
|
+
planned=tuple(planned),
|
|
197
|
+
failures=tuple(failures),
|
|
198
|
+
cancelled=cancelled,
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
def start(self) -> None:
|
|
202
|
+
"""Start the lightweight worker before a terminal UI owns the process."""
|
|
203
|
+
with self._lock:
|
|
204
|
+
if self._closed:
|
|
205
|
+
raise AnalysisWorkerError("Analyzer worker is closed")
|
|
206
|
+
self._ensure_started()
|
|
207
|
+
|
|
208
|
+
def close(self) -> None:
|
|
209
|
+
with self._lock:
|
|
210
|
+
if self._closed:
|
|
211
|
+
return
|
|
212
|
+
self._closed = True
|
|
213
|
+
connection = self._connection
|
|
214
|
+
process = self._process
|
|
215
|
+
self._connection = None
|
|
216
|
+
self._process = None
|
|
217
|
+
|
|
218
|
+
if connection is not None:
|
|
219
|
+
if process is not None and process.is_alive():
|
|
220
|
+
with suppress(BrokenPipeError, EOFError, OSError):
|
|
221
|
+
connection.send(_Shutdown())
|
|
222
|
+
connection.close()
|
|
223
|
+
|
|
224
|
+
if process is None:
|
|
225
|
+
return
|
|
226
|
+
process.join(self._shutdown_timeout)
|
|
227
|
+
if process.is_alive():
|
|
228
|
+
process.terminate()
|
|
229
|
+
process.join()
|
|
230
|
+
process.close()
|
|
231
|
+
|
|
232
|
+
def __enter__(self) -> SubprocessAnalysisLoader:
|
|
233
|
+
return self
|
|
234
|
+
|
|
235
|
+
def __exit__(self, *_args: object) -> None:
|
|
236
|
+
self.close()
|
|
237
|
+
|
|
238
|
+
def _ensure_started(self) -> None:
|
|
239
|
+
if self._process is not None:
|
|
240
|
+
if self._process.is_alive():
|
|
241
|
+
return
|
|
242
|
+
exit_code = self._process.exitcode
|
|
243
|
+
self._discard_worker()
|
|
244
|
+
raise AnalysisWorkerError(
|
|
245
|
+
f"Analyzer worker stopped unexpectedly (exit code {exit_code})"
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
parent_connection, child_connection = self._context.Pipe(duplex=True)
|
|
249
|
+
process = self._context.Process(
|
|
250
|
+
target=_analysis_worker_main,
|
|
251
|
+
args=(
|
|
252
|
+
child_connection,
|
|
253
|
+
self.model_dir,
|
|
254
|
+
self.tasks,
|
|
255
|
+
self.sample,
|
|
256
|
+
self._analyzer_factory,
|
|
257
|
+
),
|
|
258
|
+
name="settag-analyzer",
|
|
259
|
+
daemon=True,
|
|
260
|
+
)
|
|
261
|
+
try:
|
|
262
|
+
process.start()
|
|
263
|
+
except Exception:
|
|
264
|
+
parent_connection.close()
|
|
265
|
+
child_connection.close()
|
|
266
|
+
raise
|
|
267
|
+
child_connection.close()
|
|
268
|
+
self._connection = parent_connection
|
|
269
|
+
self._process = process
|
|
270
|
+
|
|
271
|
+
def _analyze(self, path: Path) -> _AnalysisResult | _AnalysisError:
|
|
272
|
+
connection = self._connection
|
|
273
|
+
process = self._process
|
|
274
|
+
assert connection is not None
|
|
275
|
+
assert process is not None
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
connection.send(
|
|
279
|
+
_AnalyzeRequest(
|
|
280
|
+
path=path.expanduser().resolve(),
|
|
281
|
+
top=self.top,
|
|
282
|
+
threshold=self.threshold,
|
|
283
|
+
)
|
|
284
|
+
)
|
|
285
|
+
except (BrokenPipeError, EOFError, OSError) as error:
|
|
286
|
+
self._raise_worker_failure(process, error)
|
|
287
|
+
|
|
288
|
+
while True:
|
|
289
|
+
try:
|
|
290
|
+
if connection.poll(self._poll_interval):
|
|
291
|
+
response = connection.recv()
|
|
292
|
+
break
|
|
293
|
+
except (BrokenPipeError, EOFError, OSError) as error:
|
|
294
|
+
self._raise_worker_failure(process, error)
|
|
295
|
+
if not process.is_alive():
|
|
296
|
+
self._raise_worker_failure(process)
|
|
297
|
+
|
|
298
|
+
if not isinstance(response, (_AnalysisResult, _AnalysisError)):
|
|
299
|
+
raise AnalysisWorkerError(
|
|
300
|
+
f"Analyzer worker returned an invalid response: {type(response).__name__}"
|
|
301
|
+
)
|
|
302
|
+
return response
|
|
303
|
+
|
|
304
|
+
def _raise_worker_failure(
|
|
305
|
+
self,
|
|
306
|
+
process: BaseProcess,
|
|
307
|
+
error: BaseException | None = None,
|
|
308
|
+
) -> None:
|
|
309
|
+
exit_code = process.exitcode
|
|
310
|
+
self._discard_worker(terminate=process.is_alive())
|
|
311
|
+
detail = f": {error}" if error is not None else ""
|
|
312
|
+
raise AnalysisWorkerError(
|
|
313
|
+
f"Analyzer worker stopped unexpectedly (exit code {exit_code}){detail}"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def _discard_worker(self, *, terminate: bool = False) -> None:
|
|
317
|
+
connection = self._connection
|
|
318
|
+
process = self._process
|
|
319
|
+
self._connection = None
|
|
320
|
+
self._process = None
|
|
321
|
+
if connection is not None:
|
|
322
|
+
connection.close()
|
|
323
|
+
if process is None:
|
|
324
|
+
return
|
|
325
|
+
if terminate and process.is_alive():
|
|
326
|
+
process.terminate()
|
|
327
|
+
process.join()
|
|
328
|
+
process.close()
|
settag/analyzer.py
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib.metadata
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Iterator, Sequence
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from settag.catalog import DISCOGS519_MAEST, MODEL_SPECS_BY_TASK, ModelSpec
|
|
17
|
+
from settag.model_store import (
|
|
18
|
+
installed_manifest,
|
|
19
|
+
installed_task_manifests,
|
|
20
|
+
require_models,
|
|
21
|
+
require_task_models,
|
|
22
|
+
)
|
|
23
|
+
from settag.policy import AudioSample, Prediction, rank_predictions, sample_audio
|
|
24
|
+
from settag.tasks import AnalysisTask, ordered_tasks
|
|
25
|
+
from settag.taxonomy import readable_label
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AnalyzerError(RuntimeError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_TENSORFLOW_STARTUP_NOISE = (
|
|
33
|
+
re.compile(
|
|
34
|
+
rb"WARNING: All log messages before absl::InitializeLog\(\) is called "
|
|
35
|
+
rb"are written to STDERR\r?\n"
|
|
36
|
+
),
|
|
37
|
+
re.compile(
|
|
38
|
+
rb"I\d{4} [^\r\n]*mlir_graph_optimization_pass\.cc:\d+\] "
|
|
39
|
+
rb"MLIR V1 optimization pass is not enabled"
|
|
40
|
+
rb"(?: in compiling SavedModel\.)?\r?\n"
|
|
41
|
+
),
|
|
42
|
+
re.compile(
|
|
43
|
+
rb"\d{4}-\d{2}-\d{2} [^\r\n]*profile_utils/cpu_utils\.cc:\d+\] "
|
|
44
|
+
rb"Failed to get CPU frequency: 0 Hz\r?\n"
|
|
45
|
+
),
|
|
46
|
+
re.compile(
|
|
47
|
+
rb"W\d{4} [^\r\n]*op_level_cost_estimator\.cc:\d+\] "
|
|
48
|
+
rb"Invalid device specifications for CPU:[^\r\n]*\r?\n"
|
|
49
|
+
),
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EssentiaGenreAnalyzer:
|
|
54
|
+
def __init__(
|
|
55
|
+
self,
|
|
56
|
+
model_dir: Path,
|
|
57
|
+
spec: ModelSpec = DISCOGS519_MAEST,
|
|
58
|
+
*,
|
|
59
|
+
sample: AudioSample = "full",
|
|
60
|
+
) -> None:
|
|
61
|
+
require_models(model_dir, spec)
|
|
62
|
+
self.model_dir = model_dir
|
|
63
|
+
self.spec = spec
|
|
64
|
+
self.sample = sample
|
|
65
|
+
|
|
66
|
+
metadata_path = spec.path(model_dir, "classifier_metadata")
|
|
67
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
68
|
+
labels = metadata.get("classes")
|
|
69
|
+
if not isinstance(labels, list) or not all(isinstance(item, str) for item in labels):
|
|
70
|
+
raise AnalyzerError(f"Invalid classifier metadata: {metadata_path}")
|
|
71
|
+
self.labels: list[str] = labels
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
# Imported lazily: essentia/TensorFlow is heavy, and a missing or broken
|
|
75
|
+
# install must surface as AnalyzerError rather than an import failure at
|
|
76
|
+
# startup, so commands that need no model keep working.
|
|
77
|
+
import essentia.standard as standard # noqa: PLC0415
|
|
78
|
+
from essentia import Pool, log # noqa: PLC0415
|
|
79
|
+
|
|
80
|
+
MonoLoader = vars(standard)["MonoLoader"]
|
|
81
|
+
TensorflowPredict = vars(standard)["TensorflowPredict"]
|
|
82
|
+
TensorflowPredictMAEST = vars(standard)["TensorflowPredictMAEST"]
|
|
83
|
+
except (ImportError, KeyError) as error:
|
|
84
|
+
raise AnalyzerError(
|
|
85
|
+
"Essentia TensorFlow bindings are unavailable. Run `uv sync` "
|
|
86
|
+
"or install the `essentia-tensorflow` dependency."
|
|
87
|
+
) from error
|
|
88
|
+
|
|
89
|
+
# TensorflowPredictMAEST otherwise emits one internal network warning
|
|
90
|
+
# per patch, which can flood stderr with hundreds of lines per track.
|
|
91
|
+
log.infoActive = False
|
|
92
|
+
log.warningActive = False
|
|
93
|
+
|
|
94
|
+
self._pool_type = Pool
|
|
95
|
+
self._loader_type = MonoLoader
|
|
96
|
+
self._embedding_model = TensorflowPredictMAEST(
|
|
97
|
+
graphFilename=str(spec.path(model_dir, "embedding")),
|
|
98
|
+
output=spec.embedding_output,
|
|
99
|
+
)
|
|
100
|
+
self._classifier_model = TensorflowPredict(
|
|
101
|
+
graphFilename=str(spec.path(model_dir, "classifier")),
|
|
102
|
+
inputs=[spec.classifier_input],
|
|
103
|
+
outputs=[spec.classifier_output],
|
|
104
|
+
)
|
|
105
|
+
self.model_manifest = installed_manifest(model_dir, spec)
|
|
106
|
+
self.backend_version = _package_version("essentia-tensorflow")
|
|
107
|
+
self._tensorflow_startup_pending = True
|
|
108
|
+
|
|
109
|
+
def analyze(self, path: Path) -> list[Prediction]:
|
|
110
|
+
if self._tensorflow_startup_pending:
|
|
111
|
+
with _filter_tensorflow_startup_stderr():
|
|
112
|
+
predictions = self._analyze(path)
|
|
113
|
+
self._tensorflow_startup_pending = False
|
|
114
|
+
return predictions
|
|
115
|
+
return self._analyze(path)
|
|
116
|
+
|
|
117
|
+
def _analyze(self, path: Path) -> list[Prediction]:
|
|
118
|
+
audio = self._loader_type(
|
|
119
|
+
filename=str(path),
|
|
120
|
+
sampleRate=self.spec.sample_rate,
|
|
121
|
+
resampleQuality=4,
|
|
122
|
+
)()
|
|
123
|
+
return self._predict_audio(audio)
|
|
124
|
+
|
|
125
|
+
def _predict_audio(self, audio: Any) -> list[Prediction]:
|
|
126
|
+
# Sampling happens here rather than in `_analyze` so the shared-decode path
|
|
127
|
+
# can hand the same full array to both models: MAEST narrows it, EffNet does
|
|
128
|
+
# not. MAEST is the only expensive one, and the mood/instrument taxonomies
|
|
129
|
+
# want whole-track averaging (see the EVIDENCE_LIMIT note in `policy`).
|
|
130
|
+
embeddings = self._embedding_model(
|
|
131
|
+
sample_audio(audio, strategy=self.sample, sample_rate=self.spec.sample_rate)
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
pool = self._pool_type()
|
|
135
|
+
pool.set(self.spec.classifier_input, embeddings)
|
|
136
|
+
output: dict[str, Any] = self._classifier_model(pool)
|
|
137
|
+
raw = np.asarray(output[self.spec.classifier_output], dtype=float)
|
|
138
|
+
|
|
139
|
+
if raw.size % len(self.labels) != 0:
|
|
140
|
+
raise AnalyzerError(
|
|
141
|
+
f"Classifier returned {raw.size} values for {len(self.labels)} labels"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
activations = raw.reshape(-1, len(self.labels)).mean(axis=0)
|
|
145
|
+
return rank_predictions(self.labels, activations.tolist())
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class EssentiaEffnetAnalyzer:
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
model_dir: Path,
|
|
152
|
+
tasks: Sequence[AnalysisTask],
|
|
153
|
+
) -> None:
|
|
154
|
+
selected = tuple(task for task in ordered_tasks(tasks) if task != "genre")
|
|
155
|
+
if not selected:
|
|
156
|
+
raise ValueError("EffNet analyzer requires mood-theme or instrument")
|
|
157
|
+
require_task_models(model_dir, selected)
|
|
158
|
+
self.model_dir = model_dir
|
|
159
|
+
self.tasks = selected
|
|
160
|
+
self.model_manifests = installed_task_manifests(model_dir, selected)
|
|
161
|
+
self.backend_version = _package_version("essentia-tensorflow")
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
# Lazy for the same reason as above.
|
|
165
|
+
import essentia.standard as standard # noqa: PLC0415
|
|
166
|
+
from essentia import log # noqa: PLC0415
|
|
167
|
+
|
|
168
|
+
MonoLoader = vars(standard)["MonoLoader"]
|
|
169
|
+
TensorflowPredict2D = vars(standard)["TensorflowPredict2D"]
|
|
170
|
+
TensorflowPredictEffnetDiscogs = vars(standard)["TensorflowPredictEffnetDiscogs"]
|
|
171
|
+
except (ImportError, KeyError) as error:
|
|
172
|
+
raise AnalyzerError(
|
|
173
|
+
"Essentia TensorFlow bindings are unavailable. Run `uv sync` "
|
|
174
|
+
"or install the `essentia-tensorflow` dependency."
|
|
175
|
+
) from error
|
|
176
|
+
|
|
177
|
+
log.infoActive = False
|
|
178
|
+
log.warningActive = False
|
|
179
|
+
self._loader_type = MonoLoader
|
|
180
|
+
embedding_spec = MODEL_SPECS_BY_TASK[selected[0]]
|
|
181
|
+
self._sample_rate = embedding_spec.sample_rate
|
|
182
|
+
self._embedding_model = TensorflowPredictEffnetDiscogs(
|
|
183
|
+
graphFilename=str(embedding_spec.path(model_dir, "embedding")),
|
|
184
|
+
output=embedding_spec.embedding_output,
|
|
185
|
+
)
|
|
186
|
+
self._heads: dict[AnalysisTask, tuple[Any, list[str], str]] = {}
|
|
187
|
+
for task in selected:
|
|
188
|
+
spec = MODEL_SPECS_BY_TASK[task]
|
|
189
|
+
metadata_path = spec.path(model_dir, "classifier_metadata")
|
|
190
|
+
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
|
191
|
+
source_labels = metadata.get("classes")
|
|
192
|
+
if not isinstance(source_labels, list) or not all(
|
|
193
|
+
isinstance(item, str) for item in source_labels
|
|
194
|
+
):
|
|
195
|
+
raise AnalyzerError(f"Invalid classifier metadata: {metadata_path}")
|
|
196
|
+
labels = [readable_label(item) for item in source_labels]
|
|
197
|
+
outputs = metadata.get("schema", {}).get("outputs", [])
|
|
198
|
+
output_name = next(
|
|
199
|
+
(
|
|
200
|
+
item.get("name")
|
|
201
|
+
for item in outputs
|
|
202
|
+
if isinstance(item, dict) and item.get("output_purpose") == "predictions"
|
|
203
|
+
),
|
|
204
|
+
spec.classifier_output,
|
|
205
|
+
)
|
|
206
|
+
if not isinstance(output_name, str):
|
|
207
|
+
raise AnalyzerError(f"Invalid classifier output metadata: {metadata_path}")
|
|
208
|
+
self._heads[task] = (
|
|
209
|
+
TensorflowPredict2D(
|
|
210
|
+
graphFilename=str(spec.path(model_dir, "classifier")),
|
|
211
|
+
output=output_name,
|
|
212
|
+
),
|
|
213
|
+
labels,
|
|
214
|
+
output_name,
|
|
215
|
+
)
|
|
216
|
+
self._tensorflow_startup_pending = True
|
|
217
|
+
|
|
218
|
+
def analyze_tasks(self, path: Path) -> dict[AnalysisTask, list[Prediction]]:
|
|
219
|
+
if self._tensorflow_startup_pending:
|
|
220
|
+
with _filter_tensorflow_startup_stderr():
|
|
221
|
+
predictions = self._analyze_tasks(path)
|
|
222
|
+
self._tensorflow_startup_pending = False
|
|
223
|
+
return predictions
|
|
224
|
+
return self._analyze_tasks(path)
|
|
225
|
+
|
|
226
|
+
def _analyze_tasks(self, path: Path) -> dict[AnalysisTask, list[Prediction]]:
|
|
227
|
+
audio = self._loader_type(
|
|
228
|
+
filename=str(path),
|
|
229
|
+
sampleRate=self._sample_rate,
|
|
230
|
+
resampleQuality=4,
|
|
231
|
+
)()
|
|
232
|
+
return self._predict_audio(audio)
|
|
233
|
+
|
|
234
|
+
def _predict_audio(self, audio: Any) -> dict[AnalysisTask, list[Prediction]]:
|
|
235
|
+
embeddings = self._embedding_model(audio)
|
|
236
|
+
results: dict[AnalysisTask, list[Prediction]] = {}
|
|
237
|
+
for task, (model, labels, _) in self._heads.items():
|
|
238
|
+
raw = np.asarray(model(embeddings), dtype=float)
|
|
239
|
+
if raw.size % len(labels) != 0:
|
|
240
|
+
raise AnalyzerError(
|
|
241
|
+
f"{task} classifier returned {raw.size} values for {len(labels)} labels"
|
|
242
|
+
)
|
|
243
|
+
activations = raw.reshape(-1, len(labels)).mean(axis=0)
|
|
244
|
+
results[task] = rank_predictions(labels, activations.tolist())
|
|
245
|
+
return results
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
class EssentiaTaskAnalyzer:
|
|
249
|
+
"""Load only the explicitly selected task families and expose one task result map."""
|
|
250
|
+
|
|
251
|
+
def __init__(
|
|
252
|
+
self,
|
|
253
|
+
model_dir: Path,
|
|
254
|
+
tasks: Sequence[AnalysisTask],
|
|
255
|
+
*,
|
|
256
|
+
sample: AudioSample = "full",
|
|
257
|
+
) -> None:
|
|
258
|
+
self.tasks = ordered_tasks(tasks)
|
|
259
|
+
if not self.tasks:
|
|
260
|
+
raise ValueError("at least one analysis task is required")
|
|
261
|
+
self.sample = sample
|
|
262
|
+
self._genre = (
|
|
263
|
+
EssentiaGenreAnalyzer(model_dir, sample=sample) if "genre" in self.tasks else None
|
|
264
|
+
)
|
|
265
|
+
effnet_tasks = tuple(task for task in self.tasks if task != "genre")
|
|
266
|
+
self._effnet = EssentiaEffnetAnalyzer(model_dir, effnet_tasks) if effnet_tasks else None
|
|
267
|
+
manifests: dict[AnalysisTask, dict[str, object]] = {}
|
|
268
|
+
if self._genre is not None:
|
|
269
|
+
manifests["genre"] = self._genre.model_manifest
|
|
270
|
+
if self._effnet is not None:
|
|
271
|
+
manifests.update(self._effnet.model_manifests)
|
|
272
|
+
self.model_manifests = manifests
|
|
273
|
+
self.backend_version = (
|
|
274
|
+
self._genre.backend_version
|
|
275
|
+
if self._genre is not None
|
|
276
|
+
else self._effnet.backend_version
|
|
277
|
+
if self._effnet is not None
|
|
278
|
+
else "unknown"
|
|
279
|
+
)
|
|
280
|
+
if self._genre is not None and self._effnet is not None:
|
|
281
|
+
if self._genre.spec.sample_rate != self._effnet._sample_rate:
|
|
282
|
+
raise AnalyzerError(
|
|
283
|
+
"MAEST and EffNet require different sample rates; "
|
|
284
|
+
"a shared audio decode is not possible"
|
|
285
|
+
)
|
|
286
|
+
self._loader_type = self._genre._loader_type
|
|
287
|
+
self._sample_rate = self._genre.spec.sample_rate
|
|
288
|
+
else:
|
|
289
|
+
self._loader_type = None
|
|
290
|
+
self._sample_rate = None
|
|
291
|
+
self._tensorflow_startup_pending = True
|
|
292
|
+
|
|
293
|
+
def analyze_tasks(self, path: Path) -> dict[AnalysisTask, list[Prediction]]:
|
|
294
|
+
if self._genre is not None and self._effnet is not None:
|
|
295
|
+
if self._tensorflow_startup_pending:
|
|
296
|
+
with _filter_tensorflow_startup_stderr():
|
|
297
|
+
predictions = self._analyze_shared_audio(path)
|
|
298
|
+
self._tensorflow_startup_pending = False
|
|
299
|
+
return predictions
|
|
300
|
+
return self._analyze_shared_audio(path)
|
|
301
|
+
|
|
302
|
+
results: dict[AnalysisTask, list[Prediction]] = {}
|
|
303
|
+
if self._genre is not None:
|
|
304
|
+
results["genre"] = self._genre.analyze(path)
|
|
305
|
+
if self._effnet is not None:
|
|
306
|
+
results.update(self._effnet.analyze_tasks(path))
|
|
307
|
+
return results
|
|
308
|
+
|
|
309
|
+
def _analyze_shared_audio(
|
|
310
|
+
self,
|
|
311
|
+
path: Path,
|
|
312
|
+
) -> dict[AnalysisTask, list[Prediction]]:
|
|
313
|
+
assert self._genre is not None
|
|
314
|
+
assert self._effnet is not None
|
|
315
|
+
assert self._loader_type is not None
|
|
316
|
+
assert self._sample_rate is not None
|
|
317
|
+
audio = self._loader_type(
|
|
318
|
+
filename=str(path),
|
|
319
|
+
sampleRate=self._sample_rate,
|
|
320
|
+
resampleQuality=4,
|
|
321
|
+
)()
|
|
322
|
+
return {
|
|
323
|
+
"genre": self._genre._predict_audio(audio),
|
|
324
|
+
**self._effnet._predict_audio(audio),
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@contextmanager
|
|
329
|
+
def _filter_tensorflow_startup_stderr() -> Iterator[None]:
|
|
330
|
+
"""Remove known harmless TensorFlow startup lines from native stderr."""
|
|
331
|
+
sys.stderr.flush()
|
|
332
|
+
saved_stderr = os.dup(2)
|
|
333
|
+
try:
|
|
334
|
+
with tempfile.TemporaryFile() as captured:
|
|
335
|
+
os.dup2(captured.fileno(), 2)
|
|
336
|
+
try:
|
|
337
|
+
yield
|
|
338
|
+
finally:
|
|
339
|
+
sys.stderr.flush()
|
|
340
|
+
os.dup2(saved_stderr, 2)
|
|
341
|
+
captured.seek(0)
|
|
342
|
+
output = captured.read()
|
|
343
|
+
for pattern in _TENSORFLOW_STARTUP_NOISE:
|
|
344
|
+
output = pattern.sub(b"", output)
|
|
345
|
+
while output:
|
|
346
|
+
written = os.write(saved_stderr, output)
|
|
347
|
+
output = output[written:]
|
|
348
|
+
finally:
|
|
349
|
+
os.close(saved_stderr)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _package_version(name: str) -> str:
|
|
353
|
+
try:
|
|
354
|
+
return importlib.metadata.version(name)
|
|
355
|
+
except importlib.metadata.PackageNotFoundError:
|
|
356
|
+
return "unknown"
|