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 ADDED
@@ -0,0 +1,3 @@
1
+ """Safe, analysis-first audio metadata tagging."""
2
+
3
+ __version__ = "0.1.0"
settag/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from settag.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
@@ -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"