beets-clapback 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.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.5
2
+ Name: beets-clapback
3
+ Version: 0.1.0
4
+ Summary: beets plugin: look up, embed, and contribute CLAP embeddings to the clapback commons — absubmit reborn
5
+ Project-URL: Homepage, https://clapback.seethroughlab.com
6
+ Project-URL: Repository, https://github.com/seethroughlab/clapback
7
+ Project-URL: Decisions, https://github.com/seethroughlab/clapback/tree/main/docs/decisions
8
+ Author-email: Jeff Crouse <jeff@seethroughlab.com>
9
+ License: MIT License
10
+
11
+ Copyright (c) 2026 Jeff Crouse
12
+
13
+ Permission is hereby granted, free of charge, to any person obtaining a copy
14
+ of this software and associated documentation files (the "Software"), to deal
15
+ in the Software without restriction, including without limitation the rights
16
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
17
+ copies of the Software, and to permit persons to whom the Software is
18
+ furnished to do so, subject to the following conditions:
19
+
20
+ The above copyright notice and this permission notice shall be included in all
21
+ copies or substantial portions of the Software.
22
+
23
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
24
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
25
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
26
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
27
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
28
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
29
+ SOFTWARE.
30
+ License-File: LICENSE
31
+ Keywords: acoustid,audio,beets,clap,embeddings,music
32
+ Classifier: Development Status :: 4 - Beta
33
+ Classifier: Intended Audience :: End Users/Desktop
34
+ Classifier: License :: OSI Approved :: MIT License
35
+ Classifier: Programming Language :: Python :: 3
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Topic :: Multimedia :: Sound/Audio :: Analysis
39
+ Requires-Python: >=3.11
40
+ Requires-Dist: beets>=2.0
41
+ Requires-Dist: clapback-client<0.2,>=0.1.0
42
+ Requires-Dist: clapback-embed<0.2,>=0.1.0
43
+ Provides-Extra: dev
44
+ Requires-Dist: pyacoustid>=1.3; extra == 'dev'
45
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
46
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
47
+ Description-Content-Type: text/markdown
48
+
49
+ # beets-clapback
50
+
51
+ Look up, embed, and — if you say so — contribute CLAP embeddings for your beets library to the
52
+ [clapback](https://clapback.seethroughlab.com) commons. `absubmit` reborn, with a payload that can
53
+ actually be reconciled across contributors.
54
+
55
+ ```bash
56
+ pip install beets-clapback
57
+ ```
58
+
59
+ ```yaml
60
+ plugins: chroma clapback
61
+
62
+ clapback:
63
+ contribute: no # opt-in. Turn it on when you have read "what leaves the machine".
64
+ ```
65
+
66
+ ```bash
67
+ beet clapback # every track: look up, else embed; contribute if enabled
68
+ beet clapback artist:Autechre # any beets query
69
+ beet clapback -p # say what would happen, touch nothing
70
+ beet clapback-search "dreamy ambient with piano"
71
+ ```
72
+
73
+ ## What it does
74
+
75
+ For each track: hash the AcoustID fingerprint the `chroma` plugin already stored (or compute one),
76
+ ask the commons whether it holds an embedding for that recording from the reference pipeline, and
77
+ if so take it — the model does not run. If not, embed locally with
78
+ [`clapback-embed`](https://pypi.org/project/clapback-embed/), keep the vector, and contribute it
79
+ back only when `contribute: yes`.
80
+
81
+ Two flexible attributes land on each item, so you can query them like anything else:
82
+
83
+ | field | values |
84
+ |---|---|
85
+ | `clapback_hash` | the corpus key — SHA256 of the fingerprint |
86
+ | `clapback_status` | `found` · `contributed` · `local` · `unfingerprinted` · `unembedded` |
87
+
88
+ ```bash
89
+ beet ls clapback_status:local # embedded here, not yet in the commons
90
+ beet ls -a clapback_status:found # albums the commons already had
91
+ ```
92
+
93
+ The vectors live in `clapback/` under your beets config directory, so `clapback-search` works
94
+ offline over everything embedded or fetched. Delete that directory and nothing is lost but time.
95
+
96
+ ## What it needs
97
+
98
+ - **`chroma`** enabled, or `chromaprint` installed so this plugin can fingerprint itself. `chroma`
99
+ is the better answer — it stores the fingerprint once and every plugin benefits.
100
+ - **The ONNX encoders** for `clapback-embed`, 614 MB, not bundled:
101
+ ```bash
102
+ pip install 'clapback-embed[export]'
103
+ python -m clapback_embed.scripts.export_models --out ~/.cache/clapback/models
104
+ ```
105
+ Without them, lookups still work; embedding does not, and the plugin says so.
106
+
107
+ ## What leaves the machine
108
+
109
+ Nothing until `contribute: yes`. Then, per track the commons did not already hold: a 512-float
110
+ vector and a one-way hash. **Never audio, never paths, never tags.** The hash cannot be reversed
111
+ into the fingerprint, and the fingerprint is not the audio.
112
+
113
+ The first contribution mints a random `client_id` — a UUID, derived from nothing about you or
114
+ your machine — and stores it at `clapback/client_id` under your config directory. It lets the
115
+ commons tell two contributions apart from one client retrying. Delete it and you are a new
116
+ contributor; nothing else changes.
117
+
118
+ ## Options
119
+
120
+ ```yaml
121
+ clapback:
122
+ url: https://clapback.seethroughlab.com
123
+ contribute: no # send vectors the commons lacks
124
+ auto: no # run on every import (embedding is minutes per album; off by default)
125
+ pace: 0.15 # seconds between contributions
126
+ ```
127
+
128
+ ## Why this exists
129
+
130
+ beets shipped `acousticbrainz` and `absubmit` for years. Both were deprecated when AcousticBrainz
131
+ shut down, and the suggested replacement was to compute locally and keep the results. This is the
132
+ other half back — with a difference that matters: AcousticBrainz pooled *estimates* (bpm, key,
133
+ mood) that could never be reconciled between contributors. An embedding from a pinned pipeline
134
+ can be. When two people contribute the same recording, the commons can say whether they agree.
135
+ The reasoning is in
136
+ [`ADR-0011`](https://github.com/seethroughlab/clapback/blob/main/docs/decisions/ADR-0011-the-commons-is-what-other-tools-plug-into.md).
@@ -0,0 +1,5 @@
1
+ beetsplug/clapback.py,sha256=LKCouRJ7XhMYQILSavlho3WOyY4IcW-sqgU_8H4vPX0,14517
2
+ beets_clapback-0.1.0.dist-info/METADATA,sha256=TiwanZzFZ22HtwqOSzQ1zJMJx9QnLRS5h0IW76V1icw,6230
3
+ beets_clapback-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
4
+ beets_clapback-0.1.0.dist-info/licenses/LICENSE,sha256=6Tfw3KwvxvDfednEoMB-s180r3b0xff9qWj57iXpiGs,1068
5
+ beets_clapback-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jeff Crouse
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
beetsplug/clapback.py ADDED
@@ -0,0 +1,346 @@
1
+ """beets-clapback — look up, embed, and (if you say so) contribute.
2
+
3
+ `ADR-0011` point 5's first integration, and `absubmit` reborn with a better
4
+ payload. beets shipped `acousticbrainz` and `absubmit` for years; both were
5
+ deprecated when AcousticBrainz died, and the suggested replacement was to compute
6
+ locally and keep the results. This is the other half back.
7
+
8
+ beet clapback # every track: look up, else embed; contribute if enabled
9
+ beet clapback -p # say what would happen, touch nothing
10
+ beet clapback artist:Autechre
11
+ beet clapback-search "dreamy ambient with piano"
12
+
13
+ What it stores on each item, as flexible attributes you can query and see:
14
+
15
+ clapback_hash the corpus key — SHA256 of the AcoustID fingerprint
16
+ clapback_status found | contributed | local | unfingerprinted | unembedded
17
+
18
+ The embeddings themselves live in a sidecar store under your beets config
19
+ directory, keyed by item id, so `clapback-search` works offline over what has
20
+ been embedded or fetched.
21
+
22
+ What leaves the machine, and only when `contribute: yes`: a 512-float vector and
23
+ a one-way hash. Never audio, never paths, never tags. The hash cannot be reversed
24
+ into the fingerprint, and the fingerprint is not the audio.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import json
30
+ import os
31
+ from pathlib import Path
32
+
33
+ from beets import config as beets_config
34
+ from beets import plugins, ui
35
+ from beets.exceptions import UserError
36
+ from clapback_client import (
37
+ Corpus,
38
+ CorpusError,
39
+ FingerprintUnavailable,
40
+ ensure_client_id,
41
+ fingerprint_file,
42
+ hash_fingerprint,
43
+ )
44
+
45
+ #: The flexible attributes this plugin writes. Named so they group in `beet ls`.
46
+ HASH_FIELD = "clapback_hash"
47
+ STATUS_FIELD = "clapback_status"
48
+
49
+
50
+ def _embedder():
51
+ """`clapback-embed`, imported lazily so `beet` starts fast and a missing model
52
+ is reported by the command that needs it rather than at plugin load."""
53
+ import clapback_embed
54
+
55
+ return clapback_embed
56
+
57
+
58
+ class _Store:
59
+ """Vectors by item id, in two files under the beets config directory.
60
+
61
+ The same shape as the reference client's store — a `.npy` of rows and a
62
+ small JSON index — because it is the smallest thing that answers a search:
63
+ a text query is one dot product against every row, and a `.npy` loads into
64
+ exactly the array that needs. Not the beets database, which stores flexible
65
+ attributes as strings and would make `beet ls` print 512 floats.
66
+ """
67
+
68
+ def __init__(self, home: Path) -> None:
69
+ self.home = home
70
+ self.vectors_path = home / "vectors.npy"
71
+ self.index_path = home / "index.json"
72
+ self.ids: list[int] = []
73
+ self.pipeline_version: str | None = None
74
+ self._vectors = None
75
+
76
+ def load(self) -> _Store:
77
+ import numpy as np
78
+
79
+ if self.index_path.exists():
80
+ data = json.loads(self.index_path.read_text())
81
+ self.ids = [int(i) for i in data.get("ids", [])]
82
+ self.pipeline_version = data.get("pipeline_version")
83
+ if self.vectors_path.exists():
84
+ self._vectors = np.load(self.vectors_path)
85
+ else:
86
+ self._vectors = np.zeros((0, 512), dtype=np.float32)
87
+ if len(self.ids) != len(self._vectors):
88
+ # Two halves that disagree attribute every result to the wrong track.
89
+ self.ids, self._vectors = [], np.zeros((0, 512), dtype=np.float32)
90
+ return self
91
+
92
+ def save(self) -> None:
93
+ import numpy as np
94
+
95
+ self.home.mkdir(parents=True, exist_ok=True)
96
+ np.save(self.vectors_path, self._vectors)
97
+ self.index_path.write_text(
98
+ json.dumps({"pipeline_version": self.pipeline_version, "ids": self.ids}, indent=1)
99
+ )
100
+
101
+ def __contains__(self, item_id: int) -> bool:
102
+ return item_id in self.ids
103
+
104
+ def put(self, item_id: int, vector: list[float]) -> None:
105
+ import numpy as np
106
+
107
+ v = np.asarray(vector, dtype=np.float32).reshape(1, -1)
108
+ if item_id in self.ids:
109
+ self._vectors[self.ids.index(item_id)] = v
110
+ return
111
+ self.ids.append(item_id)
112
+ self._vectors = np.vstack([self._vectors, v]) if len(self._vectors) else v
113
+
114
+ def get(self, item_id: int) -> list[float] | None:
115
+ if item_id not in self.ids:
116
+ return None
117
+ return [float(x) for x in self._vectors[self.ids.index(item_id)]]
118
+
119
+ def nearest(self, query, limit: int) -> list[tuple[int, float]]:
120
+ import numpy as np
121
+
122
+ if not len(self._vectors):
123
+ return []
124
+ sims = self._vectors @ np.asarray(query, dtype=np.float32)
125
+ top = np.argsort(-sims)[:limit]
126
+ return [(self.ids[int(i)], float(sims[i])) for i in top]
127
+
128
+
129
+ class ClapbackPlugin(plugins.BeetsPlugin):
130
+ def __init__(self) -> None:
131
+ super().__init__()
132
+ self.config.add(
133
+ {
134
+ "url": "https://clapback.seethroughlab.com",
135
+ # `ADR-0011` point 6: opt-in, and a second setting from lookup.
136
+ # A plugin in somebody else's software has less standing to
137
+ # assume consent, not more.
138
+ "contribute": False,
139
+ # Run on import. Off by default because embedding is minutes per
140
+ # album on a laptop and an import should not silently become that.
141
+ "auto": False,
142
+ "pace": 0.15,
143
+ }
144
+ )
145
+ if self.config["auto"].get(bool):
146
+ self.register_listener("album_imported", self._on_album_imported)
147
+ self.register_listener("item_imported", self._on_item_imported)
148
+
149
+ # --- where things live --------------------------------------------------
150
+
151
+ def _home(self) -> Path:
152
+ return Path(beets_config.config_dir()) / "clapback"
153
+
154
+ def _client_id(self) -> str:
155
+ """Minted on first contribution and never before — `ADR-0009` point 4
156
+ covers the fact that an install exists. Stored as a file the user can
157
+ find and delete; deleting it makes them a new contributor."""
158
+ return ensure_client_id(self._home() / "client_id")
159
+
160
+ # --- the commands -------------------------------------------------------
161
+
162
+ def commands(self) -> list[ui.Subcommand]:
163
+ run = ui.Subcommand(
164
+ "clapback", help="look up or embed tracks; contribute if enabled"
165
+ )
166
+ run.parser.add_option(
167
+ "-p", "--pretend", action="store_true", help="say what would happen, touch nothing"
168
+ )
169
+ run.parser.add_option(
170
+ "-f", "--force", action="store_true", help="re-process tracks already done"
171
+ )
172
+ run.func = self._cmd_run
173
+
174
+ search = ui.Subcommand(
175
+ "clapback-search", help="find tracks matching a description"
176
+ )
177
+ search.parser.add_option("-n", "--limit", type="int", default=10)
178
+ search.func = self._cmd_search
179
+ return [run, search]
180
+
181
+ def _cmd_run(self, lib, opts, args) -> None:
182
+ items = list(lib.items(args))
183
+ if not items:
184
+ raise UserError("no tracks match")
185
+ self._process(lib, items, pretend=opts.pretend, force=opts.force)
186
+
187
+ def _cmd_search(self, lib, opts, args) -> None:
188
+ description = " ".join(args).strip()
189
+ if not description:
190
+ raise UserError("give a description: beet clapback-search \"dreamy ambient\"")
191
+ embed = _embedder()
192
+ store = _Store(self._home()).load()
193
+ if not store.ids:
194
+ raise UserError("nothing embedded yet — run: beet clapback")
195
+ query = embed.embed_text(description)
196
+ for item_id, score in store.nearest(query, opts.limit):
197
+ item = lib.get_item(item_id)
198
+ if item is not None:
199
+ ui.print_(f"{score:.4f} {item}")
200
+
201
+ # --- import hooks -------------------------------------------------------
202
+
203
+ def _on_album_imported(self, lib, album) -> None:
204
+ self._process(lib, list(album.items()), pretend=False, force=False)
205
+
206
+ def _on_item_imported(self, lib, item) -> None:
207
+ self._process(lib, [item], pretend=False, force=False)
208
+
209
+ # --- the work -----------------------------------------------------------
210
+
211
+ def _process(self, lib, items, *, pretend: bool, force: bool) -> None:
212
+ """Look up, else embed; contribute if enabled. Over these items."""
213
+ import time
214
+
215
+ contribute = self.config["contribute"].get(bool)
216
+ pace = self.config["pace"].as_number()
217
+ corpus = Corpus(self.config["url"].as_str())
218
+
219
+ embed = _embedder()
220
+ pipeline_version = embed.PIPELINE_VERSION
221
+ store = _Store(self._home()).load()
222
+ if store.pipeline_version and store.pipeline_version != pipeline_version:
223
+ # A store from a different pipeline cannot be searched alongside
224
+ # this one, and its vectors cannot be contributed under this
225
+ # identity (`ADR-0006` point 5: recomputed, not relabelled).
226
+ raise UserError(
227
+ f"the local store was built by {store.pipeline_version}\n"
228
+ f"and the installed embedder is {pipeline_version}.\n"
229
+ "Delete the store to re-embed under the new pipeline:\n"
230
+ f" {self._home()}"
231
+ )
232
+ store.pipeline_version = pipeline_version
233
+
234
+ client_id = None
235
+ found = contributed = local = unfingerprinted = unembedded = skipped = 0
236
+ try:
237
+ for n, item in enumerate(items, start=1):
238
+ if not force and item.get(STATUS_FIELD) in ("found", "contributed") and item.id in store:
239
+ skipped += 1
240
+ continue
241
+
242
+ # 1. The key. beets' `chroma` plugin stores the fingerprint as
243
+ # chromaprint returned it — measured 2026-09-13 on 99 tracks,
244
+ # 99 byte-identical — but it goes through `hash_fingerprint`
245
+ # regardless, because `ADR-0010`'s defect came from a column.
246
+ fp = item.get("acoustid_fingerprint")
247
+ if not fp:
248
+ try:
249
+ fp = fingerprint_file(os.fsdecode(item.path))
250
+ except FingerprintUnavailable as exc:
251
+ self._log.info("{0}: no fingerprint ({1})", item, exc)
252
+ unfingerprinted += 1
253
+ if not pretend:
254
+ item[STATUS_FIELD] = "unfingerprinted"
255
+ item.store()
256
+ continue
257
+ key = hash_fingerprint(fp)
258
+
259
+ if pretend:
260
+ ui.print_(f"would process: {item}")
261
+ continue
262
+
263
+ # 2. Look up before anything else. A hit means the commons has
264
+ # done the work; it also means we must not POST, because a
265
+ # repeat submission is recorded as agreement (`ADR-0008`).
266
+ try:
267
+ row = corpus.lookup(key, pipeline_version)
268
+ except CorpusError as exc:
269
+ self._log.warning("corpus unreachable: {0}", exc)
270
+ row = None
271
+ corpus_ok = False
272
+ else:
273
+ corpus_ok = True
274
+
275
+ if row is not None:
276
+ store.put(item.id, row["embedding"])
277
+ item[HASH_FIELD] = key
278
+ item[STATUS_FIELD] = "found"
279
+ item.store()
280
+ found += 1
281
+ continue
282
+
283
+ # 3. Embed locally — unless a previous run already did, in which
284
+ # case the vector is in the store and the model does not run.
285
+ # This is the path a user takes when they turn `contribute`
286
+ # on after indexing: everything they already embedded goes
287
+ # out without being recomputed.
288
+ vector = store.get(item.id)
289
+ if vector is None:
290
+ try:
291
+ vector = [float(x) for x in embed.embed_file(os.fsdecode(item.path))]
292
+ except embed.ArtifactsMissing:
293
+ raise UserError(
294
+ "the ONNX encoders are missing — they are 614 MB and not bundled. "
295
+ "Export them once with clapback-embed's scripts/export_models.py, "
296
+ "or set CLAPBACK_MODEL_DIR to where they already are."
297
+ ) from None
298
+ except Exception as exc: # noqa: BLE001 - one bad file must not end the run
299
+ self._log.info("{0}: could not embed ({1})", item, exc)
300
+ unembedded += 1
301
+ item[STATUS_FIELD] = "unembedded"
302
+ item.store()
303
+ continue
304
+ store.put(item.id, vector)
305
+ item[HASH_FIELD] = key
306
+
307
+ # 4. Contribute, if and only if asked.
308
+ if contribute and corpus_ok:
309
+ if client_id is None:
310
+ client_id = self._client_id()
311
+ try:
312
+ corpus.contribute(
313
+ fingerprint_hash=key,
314
+ embedding=vector,
315
+ pipeline_version=pipeline_version,
316
+ client_id=client_id,
317
+ )
318
+ except CorpusError as exc:
319
+ self._log.warning("{0}: not contributed ({1})", item, exc)
320
+ item[STATUS_FIELD] = "local"
321
+ local += 1
322
+ else:
323
+ item[STATUS_FIELD] = "contributed"
324
+ contributed += 1
325
+ time.sleep(pace)
326
+ else:
327
+ item[STATUS_FIELD] = "local"
328
+ local += 1
329
+ item.store()
330
+
331
+ if n % 25 == 0:
332
+ store.save()
333
+ self._log.info("{0}/{1}", n, len(items))
334
+ finally:
335
+ if not pretend:
336
+ store.save()
337
+
338
+ if pretend:
339
+ return
340
+ summary = (
341
+ f"found in corpus {found} · contributed {contributed} · local only {local} · "
342
+ f"no fingerprint {unfingerprinted} · unembeddable {unembedded} · already done {skipped}"
343
+ )
344
+ if not contribute and local:
345
+ summary += "\n(contribution is off; set clapback.contribute: yes to send local vectors)"
346
+ ui.print_(summary)