clapback-cli 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,8 @@
1
+ """clapback — search your own music by description, and find duplicates.
2
+
3
+ `ADR-0009`: the tool must be worth running with the corpus empty. Everything it
4
+ does locally needs no network, no account and no fingerprinting.
5
+ """
6
+
7
+ __all__ = ["__version__"]
8
+ __version__ = "0.1.0"
clapback_cli/cli.py ADDED
@@ -0,0 +1,282 @@
1
+ """`clapback` — search your own music by description, and find duplicates.
2
+
3
+ `ADR-0009` point 8 of `ADR-0001` is the brief: **the tool must be worth running
4
+ with the corpus empty.** So everything here works offline, against your own
5
+ files, with the commons unreachable. Contributing is something it can also do.
6
+
7
+ clapback index ~/Music
8
+ clapback search "dreamy ambient with piano"
9
+ clapback duplicates
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import sys
16
+ from pathlib import Path
17
+
18
+ from .corpus import DEFAULT_BASE_URL as DEFAULT_CORPUS_URL
19
+ from .store import Store
20
+
21
+ #: What we will try to embed. `clapback-embed` decodes through soundfile and
22
+ #: librosa; anything they refuse is skipped with a line rather than a traceback,
23
+ #: because one unreadable file in a library of 20,000 must not end the run.
24
+ AUDIO_SUFFIXES = {".flac", ".mp3", ".m4a", ".ogg", ".opus", ".wav", ".aiff", ".aif", ".wma"}
25
+
26
+ #: `ADR-0009` point 8. Two rips of one recording measure 0.9972–0.9995 under this
27
+ #: pipeline, and genuinely different music sits far below; 0.995 is inside that
28
+ #: band and adjustable, because "duplicate" is partly a judgement — a remaster is
29
+ #: a different master and sometimes a different recording.
30
+ DEFAULT_DUPLICATE_THRESHOLD = 0.995
31
+
32
+
33
+ def _embedder():
34
+ """Import lazily, so `--help` and a missing model do not look like the same failure."""
35
+ try:
36
+ import clapback_embed
37
+ except ImportError:
38
+ sys.exit("clapback-embed is not installed. pip install clapback")
39
+ return clapback_embed
40
+
41
+
42
+ def cmd_index(args: argparse.Namespace) -> int:
43
+ embed = _embedder()
44
+ store = Store(args.home).load()
45
+ known = store.known()
46
+
47
+ files = [
48
+ p for p in sorted(Path(args.directory).rglob("*"))
49
+ if p.suffix.lower() in AUDIO_SUFFIXES and p.is_file()
50
+ ]
51
+ print(f"{len(files):,} audio files under {args.directory}")
52
+
53
+ added = skipped = failed = 0
54
+ for path in files:
55
+ key = str(path.resolve())
56
+ stat = path.stat()
57
+ prior = known.get(key)
58
+ # Re-embedding a file that has not changed costs seconds of CPU for an
59
+ # identical vector. mtime and size together are enough: a file edited in
60
+ # place without changing either is not a case worth slowing every run for.
61
+ if prior and prior.mtime == stat.st_mtime and prior.size == stat.st_size:
62
+ skipped += 1
63
+ continue
64
+ try:
65
+ vector = embed.embed_file(str(path))
66
+ except embed.ArtifactsMissing:
67
+ sys.exit(
68
+ "The ONNX encoders are missing. They are 614 MB and not bundled — "
69
+ "export them once with clapback-embed's scripts/export_models.py, "
70
+ "or set CLAPBACK_MODEL_DIR to where they already are."
71
+ )
72
+ except Exception as exc: # noqa: BLE001 - one bad file must not end the run
73
+ print(f" skipped {path.name}: {exc}")
74
+ failed += 1
75
+ continue
76
+ store.add(key, stat.st_mtime, stat.st_size, vector)
77
+ added += 1
78
+ if added % 50 == 0:
79
+ print(f" {added:,} embedded")
80
+
81
+ store.pipeline_version = embed.PIPELINE_VERSION
82
+ store.save()
83
+ print(f"indexed {added:,} · unchanged {skipped:,} · unreadable {failed:,}")
84
+ print(f"store: {store.home}")
85
+ return 0
86
+
87
+
88
+ def cmd_search(args: argparse.Namespace) -> int:
89
+ embed = _embedder()
90
+ store = Store(args.home).load()
91
+ if not len(store.vectors):
92
+ sys.exit("Nothing indexed yet. Try: clapback index ~/Music")
93
+
94
+ query = embed.embed_text(args.description)
95
+ for i, score in store.nearest(query, args.limit):
96
+ print(f"{score:.4f} {store.entries[i].path}")
97
+ return 0
98
+
99
+
100
+ def cmd_duplicates(args: argparse.Namespace) -> int:
101
+ import numpy as np
102
+
103
+ store = Store(args.home).load()
104
+ n = len(store.vectors)
105
+ if n < 2:
106
+ sys.exit("Need at least two indexed tracks.")
107
+
108
+ # The full n×n similarity matrix, which at a personal library's scale is
109
+ # cheaper than being clever: 20,000 tracks is a 1.6 GB float32 matrix, so it
110
+ # goes in blocks rather than all at once.
111
+ seen: set[tuple[int, int]] = set()
112
+ block = 2000
113
+ for start in range(0, n, block):
114
+ sims = store.vectors[start : start + block] @ store.vectors.T
115
+ for local, row in enumerate(sims):
116
+ i = start + local
117
+ for j in np.nonzero(row >= args.threshold)[0]:
118
+ j = int(j)
119
+ if i < j:
120
+ seen.add((i, j))
121
+
122
+ if not seen:
123
+ print(f"No pairs at or above {args.threshold}.")
124
+ return 0
125
+ print(f"{len(seen):,} pair(s) at or above {args.threshold}:\n")
126
+ for i, j in sorted(seen, key=lambda p: -float(store.vectors[p[0]] @ store.vectors[p[1]])):
127
+ score = float(store.vectors[i] @ store.vectors[j])
128
+ print(f"{score:.4f}")
129
+ print(f" {store.entries[i].path}")
130
+ print(f" {store.entries[j].path}")
131
+ return 0
132
+
133
+
134
+ #: This tool's own counter, and nothing more. `ADR-0006` points 2 and 3 took
135
+ #: `analysis_version` out of the key and left it as a recorded column, so it is no
136
+ #: longer a claim about whether two vectors are comparable — `pipeline_version` is.
137
+ #: A new client therefore starts at 1 rather than pretending to share Familiar's
138
+ #: history, which is what the number used to imply.
139
+ ANALYSIS_VERSION = 1
140
+
141
+ #: How long to wait between writes. The server rate-limits contributions and the
142
+ #: client backs off on 429; pacing just means it rarely has to.
143
+ CONTRIBUTE_PACE_SECONDS = 0.15
144
+
145
+
146
+ def cmd_contribute(args: argparse.Namespace) -> int:
147
+ """`ADR-0009` point 6 — the byproduct, and the only reason the corpus grows.
148
+
149
+ Everything this tool does locally works with the commons unreachable and this
150
+ command never run. That is `ADR-0001` point 8 and it is the whole argument:
151
+ a donation client with no local value has no first contributor.
152
+ """
153
+ import time
154
+
155
+ from .corpus import Corpus, CorpusError
156
+ from .fingerprint import FingerprintUnavailable, fingerprint_file, hash_fingerprint
157
+
158
+ embed = _embedder()
159
+ store = Store(args.home).load()
160
+ if not len(store.vectors):
161
+ sys.exit("Nothing indexed yet. Try: clapback index ~/Music")
162
+
163
+ # **A store indexed by a different pipeline cannot be contributed.** Since
164
+ # `ADR-0006` phase 4 the pipeline identity is half the corpus key, so sending
165
+ # these vectors under the installed embedder's identity would assert that a
166
+ # pipeline produced vectors it did not. Re-indexing is the honest fix and it
167
+ # is the one the record chose (`ADR-0006` point 5: recomputed, not relabelled).
168
+ if store.pipeline_version and store.pipeline_version != embed.PIPELINE_VERSION:
169
+ sys.exit(
170
+ f"This store was indexed by {store.pipeline_version}\n"
171
+ f"and the installed embedder is {embed.PIPELINE_VERSION}.\n"
172
+ "Contributing would key these vectors to a pipeline that did not produce "
173
+ "them. Re-index first: clapback index <directory>"
174
+ )
175
+
176
+ corpus = Corpus(args.url)
177
+ pipeline_version = embed.PIPELINE_VERSION
178
+ # The checkpoint is already the first component of the pipeline identity, so
179
+ # taking it from there keeps the two from ever disagreeing about one fact.
180
+ clap_model_version = pipeline_version.split("+")[0]
181
+
182
+ entries = store.entries[: args.limit] if args.limit else store.entries
183
+ print(f"{len(entries):,} indexed track(s)")
184
+ print(f"corpus: {corpus.base_url}")
185
+ print(f"pipeline: {pipeline_version}")
186
+
187
+ if args.dry_run:
188
+ need = sum(1 for e in entries if not e.fingerprint_hash)
189
+ print("\ndry run — nothing will be sent.")
190
+ print(f"{need:,} would need fingerprinting first.")
191
+ return 0
192
+
193
+ client_id = store.ensure_client_id()
194
+ print(f"client: {client_id}\n")
195
+
196
+ sent = present = unfingerprintable = missing = 0
197
+ try:
198
+ # `entries` is a prefix of `store.entries`, so the loop index addresses
199
+ # the matching row of `store.vectors` directly. Looking the entry up by
200
+ # value instead would be quadratic, and would pick the wrong vector for
201
+ # two entries that happen to compare equal.
202
+ for idx, entry in enumerate(entries):
203
+ n = idx + 1
204
+ if not entry.fingerprint_hash:
205
+ if not Path(entry.path).exists():
206
+ missing += 1
207
+ continue
208
+ try:
209
+ entry.fingerprint_hash = hash_fingerprint(fingerprint_file(entry.path))
210
+ except FingerprintUnavailable as exc:
211
+ # Point 5: a missing chromaprint is a plain statement, not a
212
+ # traceback, and it is fatal only because nothing downstream
213
+ # can proceed without it.
214
+ if "not installed" in str(exc):
215
+ store.save()
216
+ sys.exit(f"\n{exc}")
217
+ unfingerprintable += 1
218
+ continue
219
+
220
+ try:
221
+ if corpus.has(entry.fingerprint_hash, pipeline_version):
222
+ present += 1
223
+ continue
224
+ corpus.contribute(
225
+ fingerprint_hash=entry.fingerprint_hash,
226
+ embedding=[float(x) for x in store.vectors[idx]],
227
+ pipeline_version=pipeline_version,
228
+ clap_model_version=clap_model_version,
229
+ analysis_version=ANALYSIS_VERSION,
230
+ client_id=client_id,
231
+ )
232
+ sent += 1
233
+ time.sleep(CONTRIBUTE_PACE_SECONDS)
234
+ except CorpusError as exc:
235
+ store.save()
236
+ sys.exit(f"\nstopped at {n:,}: {exc}")
237
+
238
+ if n % 25 == 0:
239
+ # Save as we go: fingerprints cost a subprocess each, and an
240
+ # interrupted run must not throw that away.
241
+ store.save()
242
+ print(f" {n:,}/{len(entries):,} · contributed {sent:,} · already there {present:,}")
243
+ finally:
244
+ store.save()
245
+
246
+ print(
247
+ f"\ncontributed {sent:,} · already in corpus {present:,} · "
248
+ f"no fingerprint {unfingerprintable:,} · file gone {missing:,}"
249
+ )
250
+ return 0
251
+
252
+
253
+ def main() -> None:
254
+ p = argparse.ArgumentParser(prog="clapback", description=__doc__.split("\n")[0])
255
+ p.add_argument("--home", type=Path, default=None, help="store directory (default ~/.clapback)")
256
+ sub = p.add_subparsers(dest="command", required=True)
257
+
258
+ ix = sub.add_parser("index", help="embed a directory of audio into the local store")
259
+ ix.add_argument("directory")
260
+ ix.set_defaults(func=cmd_index)
261
+
262
+ se = sub.add_parser("search", help="find tracks matching a description")
263
+ se.add_argument("description")
264
+ se.add_argument("--limit", type=int, default=10)
265
+ se.set_defaults(func=cmd_search)
266
+
267
+ du = sub.add_parser("duplicates", help="find near-duplicates across formats and masters")
268
+ du.add_argument("--threshold", type=float, default=DEFAULT_DUPLICATE_THRESHOLD)
269
+ du.set_defaults(func=cmd_duplicates)
270
+
271
+ co = sub.add_parser("contribute", help="send your embeddings to the commons (opt-in)")
272
+ co.add_argument("--url", default=DEFAULT_CORPUS_URL, help="corpus base URL")
273
+ co.add_argument("--limit", type=int, default=0, help="stop after this many tracks")
274
+ co.add_argument("--dry-run", action="store_true", help="say what would be sent, send nothing")
275
+ co.set_defaults(func=cmd_contribute)
276
+
277
+ args = p.parse_args()
278
+ raise SystemExit(args.func(args))
279
+
280
+
281
+ if __name__ == "__main__":
282
+ main()
clapback_cli/corpus.py ADDED
@@ -0,0 +1,133 @@
1
+ """Talking to the commons over HTTP, and only over HTTP.
2
+
3
+ `ADR-0005` point 12: the API is the only way in. Every guarantee the corpus makes
4
+ — revocation, quotas, the row ceiling, agreement recording — is code on the write
5
+ path, so a client that reached the database directly would be a second write path
6
+ with none of them.
7
+
8
+ `urllib` rather than `httpx` or `requests` on purpose. This package's argument is
9
+ that it is small enough to install next to anything; two calls against a JSON API
10
+ do not justify a dependency, and the one place that matters — retrying a 429 — is
11
+ a loop either way.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ DEFAULT_BASE_URL = "https://clapback.seethroughlab.com"
22
+
23
+ #: The server rate-limits contributions. Backing off politely is the difference
24
+ #: between a slow client and a client the operator has to block, and a long run
25
+ #: will meet this: Familiar's backfill of 26,431 tracks took roughly 80 minutes
26
+ #: of paced lookups.
27
+ _RETRY_DELAYS = (2.0, 5.0, 15.0)
28
+
29
+
30
+ class CorpusError(RuntimeError):
31
+ """The corpus could not be reached, or refused something it should not have."""
32
+
33
+
34
+ class Corpus:
35
+ def __init__(self, base_url: str = DEFAULT_BASE_URL, timeout: float = 15.0) -> None:
36
+ self.base_url = base_url.rstrip("/")
37
+ self.timeout = timeout
38
+
39
+ def _request(self, method: str, path: str, body: dict | None = None) -> tuple[int, dict | None]:
40
+ data = json.dumps(body).encode() if body is not None else None
41
+ req = urllib.request.Request(
42
+ f"{self.base_url}{path}",
43
+ data=data,
44
+ method=method,
45
+ headers={
46
+ "Content-Type": "application/json",
47
+ "Accept": "application/json",
48
+ # Say who is calling. Not identity — `ADR-0004` point 1 keeps that
49
+ # to `client_id` in the body — but an operator reading logs should
50
+ # be able to tell this tool from a browser.
51
+ "User-Agent": "clapback-cli",
52
+ },
53
+ )
54
+ try:
55
+ with urllib.request.urlopen(req, timeout=self.timeout) as resp:
56
+ raw = resp.read()
57
+ return resp.status, (json.loads(raw) if raw else None)
58
+ except urllib.error.HTTPError as exc:
59
+ raw = exc.read()
60
+ try:
61
+ payload = json.loads(raw) if raw else None
62
+ except json.JSONDecodeError:
63
+ payload = None
64
+ return exc.code, payload
65
+ except urllib.error.URLError as exc:
66
+ raise CorpusError(f"{self.base_url} is unreachable: {exc.reason}") from exc
67
+ except TimeoutError as exc:
68
+ raise CorpusError(f"{self.base_url} timed out after {self.timeout}s") from exc
69
+
70
+ def health(self) -> bool:
71
+ status, _ = self._request("GET", "/health")
72
+ return status == 200
73
+
74
+ def has(self, fingerprint_hash: str, pipeline_version: str) -> bool:
75
+ """Whether the corpus already holds this recording from this pipeline.
76
+
77
+ **Asked before every contribution, and that is not an optimisation.** A
78
+ repeat POST of a vector that is already there increments
79
+ `contributor_count` and records a `submission_agreement` row, so a client
80
+ that re-sent its library would manufacture evidence of one installation
81
+ independently agreeing with itself — which is precisely the measurement
82
+ `ADR-0008` is built on. Familiar's backfill learned this the same way.
83
+ """
84
+ # The pipeline identity is `+`-joined, and `+` means a space in a query
85
+ # string. `ADR-0006`'s Implementation block records what an unescaped one
86
+ # costs: a 404 that looks exactly like the recording being absent.
87
+ from urllib.parse import quote
88
+
89
+ status, _ = self._request(
90
+ "GET",
91
+ f"/v1/embeddings/{fingerprint_hash}?pipeline_version={quote(pipeline_version, safe='')}",
92
+ )
93
+ if status == 200:
94
+ return True
95
+ if status == 404:
96
+ return False
97
+ raise CorpusError(f"lookup returned {status}")
98
+
99
+ def contribute(
100
+ self,
101
+ *,
102
+ fingerprint_hash: str,
103
+ embedding: list[float],
104
+ pipeline_version: str,
105
+ clap_model_version: str,
106
+ analysis_version: int,
107
+ client_id: str,
108
+ ) -> str:
109
+ """POST one embedding. Returns a short word describing what happened."""
110
+ body = {
111
+ "fingerprint_hash": fingerprint_hash,
112
+ "embedding": embedding,
113
+ "pipeline_version": pipeline_version,
114
+ "clap_model_version": clap_model_version,
115
+ "analysis_version": analysis_version,
116
+ "client_id": client_id,
117
+ }
118
+ for attempt, delay in enumerate((*_RETRY_DELAYS, None)):
119
+ status, payload = self._request("POST", "/v1/embeddings", body)
120
+ if status in (200, 201):
121
+ return "contributed"
122
+ if status == 429 and delay is not None:
123
+ time.sleep(delay)
124
+ continue
125
+ if status == 422:
126
+ detail = (payload or {}).get("detail")
127
+ raise CorpusError(f"the corpus refused the submission as malformed: {detail}")
128
+ if status == 507 or (status == 403 and "ceiling" in str(payload).lower()):
129
+ # `ADR-0004` point 9's row ceiling. A refusal here is the corpus
130
+ # working, not failing — stop rather than hammering it.
131
+ raise CorpusError("the corpus is full and is refusing writes (ADR-0004 point 9)")
132
+ raise CorpusError(f"contribute returned {status}: {payload}")
133
+ raise CorpusError("rate limited repeatedly; try again later")
@@ -0,0 +1,127 @@
1
+ """The corpus key's other half: `ADR-0010`.
2
+
3
+ `fingerprint_hash` is SHA256 of the AcoustID fingerprint **as chromaprint returned
4
+ it** — the base64 ASCII string — and of nothing else. The rule exists because it
5
+ was broken: Familiar hashed whatever its column happened to hold, and that column
6
+ held the same fingerprint in two encodings (14,284 hex-escaped against 11,364
7
+ raw, measured 2026-09-10), both of which are live keys in the corpus today.
8
+
9
+ So the rule is "hash what you computed, not what you stored", and this module is
10
+ where this tool computes it. Nothing here reads a database, which is the point:
11
+ the value goes from chromaprint into `sha256` without passing through storage, so
12
+ there is no encoding for storage to apply.
13
+
14
+ `canonical()` exists anyway, for the case where a fingerprint *has* been through
15
+ something. It is the one place that knows what a re-encoding looks like.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import shutil
22
+ import subprocess
23
+ import sys
24
+
25
+
26
+ class FingerprintUnavailable(RuntimeError):
27
+ """chromaprint is missing or refused the file.
28
+
29
+ `ADR-0009` point 5: the local half of this tool — index, search, duplicates —
30
+ works without chromaprint and must never be made to depend on it. Only talking
31
+ to the corpus needs a fingerprint, so this is raised there and nowhere else.
32
+ """
33
+
34
+
35
+ def canonical(fingerprint: str | bytes) -> bytes:
36
+ """The bytes to hash, whatever shape the fingerprint arrives in.
37
+
38
+ A fingerprint from chromaprint is already canonical and passes through. The
39
+ one transformation undone here is Postgres's hex output format — a `text`
40
+ column that once held `bytea` renders as `\\x` followed by hex, and hashing
41
+ that string keys the row to a fact about somebody's schema history rather
42
+ than about the recording. `ADR-0010` point 2.
43
+
44
+ The check is deliberately narrow. `\\x` plus an even number of hex digits
45
+ that decode to printable ASCII is not something a chromaprint fingerprint can
46
+ be — its alphabet is base64 and it never begins with a backslash — so this
47
+ cannot misfire on a real fingerprint, and anything it does not recognise is
48
+ left alone rather than guessed at.
49
+ """
50
+ if isinstance(fingerprint, bytes):
51
+ raw = fingerprint
52
+ else:
53
+ raw = fingerprint.encode()
54
+
55
+ if raw.startswith(b"\\x") and len(raw) % 2 == 0:
56
+ body = raw[2:]
57
+ try:
58
+ decoded = bytes.fromhex(body.decode("ascii"))
59
+ except (ValueError, UnicodeDecodeError):
60
+ return raw
61
+ # Only accept the decode if it produced something that looks like a
62
+ # fingerprint rather than arbitrary bytes that happened to be valid hex.
63
+ if decoded and all(32 <= b < 127 for b in decoded):
64
+ return decoded
65
+ return raw
66
+
67
+
68
+ def hash_fingerprint(fingerprint: str | bytes) -> str:
69
+ """SHA256 of the canonical fingerprint, hex-digested — the corpus key.
70
+
71
+ One-way on purpose: contributing says "I have this recording" without saying
72
+ which recording it is, which is what lets somebody contribute from a library
73
+ they would rather not publish. It is also why no server-side migration could
74
+ ever repair a bad key — the corpus never learns the fingerprint, so only a
75
+ client holding it can compute a different hash for the same recording.
76
+ """
77
+ return hashlib.sha256(canonical(fingerprint)).hexdigest()
78
+
79
+
80
+ def fingerprint_file(path: str) -> str:
81
+ """The AcoustID fingerprint of one file, exactly as chromaprint gives it.
82
+
83
+ Run out of process for the reason `ADR-0009` point 5 gives: chromaprint is a
84
+ C library that crashes rather than raises on some malformed inputs, and a
85
+ segfault in a library of 20,000 files must cost one file rather than the run.
86
+ A crashed child is a non-zero exit code here.
87
+ """
88
+ if shutil.which("fpcalc") is None:
89
+ try:
90
+ import acoustid # noqa: F401
91
+ except ImportError as exc:
92
+ raise FingerprintUnavailable(
93
+ "chromaprint is not installed, so this tool cannot talk to the corpus. "
94
+ "Install it (`brew install chromaprint`, `apt install libchromaprint-tools`) "
95
+ "and `pip install pyacoustid`. Indexing, search and duplicates do not need it."
96
+ ) from exc
97
+
98
+ try:
99
+ result = subprocess.run(
100
+ [
101
+ sys.executable,
102
+ "-c",
103
+ (
104
+ "import acoustid, json, sys; "
105
+ "d, f = acoustid.fingerprint_file(sys.argv[1]); "
106
+ "print(json.dumps(f.decode() if isinstance(f, bytes) else f))"
107
+ ),
108
+ path,
109
+ ],
110
+ capture_output=True,
111
+ text=True,
112
+ timeout=60,
113
+ check=False,
114
+ )
115
+ except subprocess.TimeoutExpired as exc:
116
+ raise FingerprintUnavailable(f"fingerprinting timed out: {path}") from exc
117
+
118
+ if result.returncode != 0 or not result.stdout.strip():
119
+ detail = (result.stderr or "").strip().splitlines()
120
+ raise FingerprintUnavailable(detail[-1] if detail else f"exit {result.returncode}")
121
+
122
+ import json
123
+
124
+ value = json.loads(result.stdout.strip())
125
+ if not isinstance(value, str) or not value:
126
+ raise FingerprintUnavailable(f"chromaprint returned nothing usable for {path}")
127
+ return value
clapback_cli/store.py ADDED
@@ -0,0 +1,124 @@
1
+ """Where the vectors live: a directory the user owns, holding two files.
2
+
3
+ `ADR-0009` point 4 — "the store is a local file the user owns, and nothing leaves
4
+ the machine by default". Point 3 rules out an index, so this is deliberately not
5
+ a database: brute force needs every vector in memory anyway, and a `.npy` loads
6
+ into exactly that with no query layer in between.
7
+
8
+ Two files rather than one because they change at different rates and for
9
+ different reasons. `vectors.npy` is 2 KB per track and rewritten whole;
10
+ `index.json` is small, human-readable, and the thing you would look at to answer
11
+ "did it index the file I think it did".
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import dataclasses
17
+ import json
18
+ import uuid
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ import numpy as np
23
+
24
+ DEFAULT_HOME = Path.home() / ".clapback"
25
+
26
+
27
+ @dataclass
28
+ class Entry:
29
+ path: str
30
+ mtime: float
31
+ size: int
32
+ #: The corpus key for this recording, cached after the first time chromaprint
33
+ #: is asked. `None` means "not fingerprinted", which is the normal state: the
34
+ #: local half of this tool never needs one (`ADR-0009` point 5), so the cost
35
+ #: is only paid by somebody who contributes.
36
+ #:
37
+ #: Cached rather than recomputed because fingerprinting spawns a process per
38
+ #: file, and a contribute run that is interrupted should not start over.
39
+ fingerprint_hash: str | None = None
40
+
41
+
42
+ class Store:
43
+ """Vectors and the files they came from, kept in step by position."""
44
+
45
+ def __init__(self, home: Path | None = None) -> None:
46
+ self.home = Path(home) if home else DEFAULT_HOME
47
+ self.vectors_path = self.home / "vectors.npy"
48
+ self.index_path = self.home / "index.json"
49
+ self.vectors: np.ndarray = np.zeros((0, 512), dtype=np.float32)
50
+ self.entries: list[Entry] = []
51
+ self.pipeline_version: str | None = None
52
+ #: `ADR-0004` point 1: an opaque per-install identifier, generated once and
53
+ #: never derived from anything about the machine or its owner. It exists so
54
+ #: the corpus can tell two submissions from one client retrying, which is
55
+ #: the distinction `contributor_count` cannot make.
56
+ self.client_id: str | None = None
57
+
58
+ def load(self) -> Store:
59
+ if self.index_path.exists():
60
+ data = json.loads(self.index_path.read_text())
61
+ # Tolerate keys this version does not know: a store written by a
62
+ # newer clapback must not make an older one delete the library.
63
+ fields = {f.name for f in dataclasses.fields(Entry)}
64
+ self.entries = [
65
+ Entry(**{k: v for k, v in e.items() if k in fields})
66
+ for e in data.get("entries", [])
67
+ ]
68
+ self.pipeline_version = data.get("pipeline_version")
69
+ self.client_id = data.get("client_id")
70
+ if self.vectors_path.exists():
71
+ self.vectors = np.load(self.vectors_path)
72
+ # A store whose two halves disagree is worse than an empty one: every
73
+ # result would be attributed to the wrong file. Rebuilding is cheap
74
+ # relative to explaining a wrong answer.
75
+ if len(self.entries) != len(self.vectors):
76
+ self.entries, self.vectors = [], np.zeros((0, 512), dtype=np.float32)
77
+ return self
78
+
79
+ def save(self) -> None:
80
+ self.home.mkdir(parents=True, exist_ok=True)
81
+ np.save(self.vectors_path, self.vectors)
82
+ self.index_path.write_text(
83
+ json.dumps(
84
+ {
85
+ "pipeline_version": self.pipeline_version,
86
+ "client_id": self.client_id,
87
+ "entries": [e.__dict__ for e in self.entries],
88
+ },
89
+ indent=1,
90
+ )
91
+ )
92
+
93
+ def ensure_client_id(self) -> str:
94
+ """The identifier this install contributes under, minted on first use.
95
+
96
+ Deliberately not minted at `index` time. A store that has only ever been
97
+ searched locally has no reason to carry an identifier, and `ADR-0009`
98
+ point 4 is that nothing leaves the machine by default — including the fact
99
+ that this install exists.
100
+ """
101
+ if not self.client_id:
102
+ self.client_id = str(uuid.uuid4())
103
+ return self.client_id
104
+
105
+ def known(self) -> dict[str, Entry]:
106
+ return {e.path: e for e in self.entries}
107
+
108
+ def add(self, path: str, mtime: float, size: int, vector: list[float]) -> None:
109
+ self.entries.append(Entry(path=path, mtime=mtime, size=size))
110
+ v = np.asarray(vector, dtype=np.float32).reshape(1, -1)
111
+ self.vectors = np.vstack([self.vectors, v]) if len(self.vectors) else v
112
+
113
+ def nearest(self, query: np.ndarray, limit: int) -> list[tuple[int, float]]:
114
+ """Cosine similarity against everything, sorted.
115
+
116
+ The vectors are unit length, so a dot product *is* the cosine — no
117
+ normalisation, no distance-to-similarity conversion, and nothing to get
118
+ the sign of wrong.
119
+ """
120
+ if not len(self.vectors):
121
+ return []
122
+ sims = self.vectors @ np.asarray(query, dtype=np.float32)
123
+ top = np.argsort(-sims)[:limit]
124
+ return [(int(i), float(sims[i])) for i in top]
@@ -0,0 +1,108 @@
1
+ Metadata-Version: 2.5
2
+ Name: clapback-cli
3
+ Version: 0.1.0
4
+ Summary: Search your own music by description, and find duplicates across formats and masters
5
+ License-Expression: MIT
6
+ License-File: LICENSE
7
+ Requires-Python: >=3.11
8
+ Requires-Dist: clapback-embed<0.2,>=0.1.0
9
+ Requires-Dist: numpy>=1.24.0
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=7.4.0; extra == 'dev'
12
+ Requires-Dist: ruff>=0.1.0; extra == 'dev'
13
+ Description-Content-Type: text/markdown
14
+
15
+ # clapback
16
+
17
+ Search your own music by description, and find duplicates across formats and masters.
18
+
19
+ ```bash
20
+ pip install clapback-cli
21
+
22
+ clapback index ~/Music
23
+ clapback search "dreamy ambient with piano"
24
+ clapback duplicates
25
+ ```
26
+
27
+ ## What it does
28
+
29
+ **Search by description.** CLAP puts audio and text in one space, so "something
30
+ slow with brushed drums" is a query rather than a keyword match against filenames
31
+ you may never have typed.
32
+
33
+ **Find near-duplicates.** Two rips of one recording measure 0.9972–0.9995 under
34
+ this pipeline; genuinely different music sits far below. That gap is what makes
35
+ duplicate detection across formats and masters work — a FLAC and a V0 of the same
36
+ master are obvious, and so is the same recording on two different releases.
37
+
38
+ Both run against your own files, offline. There is no account, no key, and
39
+ nothing is sent anywhere.
40
+
41
+ **Contribute, if you want to.** Opt-in and off unless you type it:
42
+
43
+ ```bash
44
+ clapback contribute --dry-run # say what would be sent, send nothing
45
+ clapback contribute
46
+ ```
47
+
48
+ This sends the vectors — never your audio, never filenames, never your library's
49
+ contents. A recording is identified by the SHA256 of its AcoustID fingerprint,
50
+ which is one-way: the corpus learns that somebody has a recording without learning
51
+ which recording it is.
52
+
53
+ Every track is looked up before it is offered, so re-running contributes only
54
+ what is new. That is not politeness about bandwidth — a repeat submission is
55
+ recorded as agreement, and one install agreeing with itself would corrupt the one
56
+ measurement the commons exists to make.
57
+
58
+ Contributing needs `chromaprint`, and only contributing does:
59
+
60
+ ```bash
61
+ brew install chromaprint # or: apt install libchromaprint-tools
62
+ pip install pyacoustid
63
+ ```
64
+
65
+ Without it, indexing, search and duplicates work exactly as well.
66
+
67
+ ## Why `clapback-cli` and not `clapback`
68
+
69
+ The bare name on PyPI belongs to an unrelated package from 2018 that adds clap
70
+ emojis to sentences. The distribution is therefore `clapback-cli`, matching
71
+ `clapback-embed`; the command you type is still `clapback`.
72
+
73
+ ## What it needs
74
+
75
+ `clapback-embed`, which arrives with it, and the ONNX encoders it runs on. Those
76
+ are **614 MB and not bundled** — a package that downloaded them on install would
77
+ be lying about its size. Export them once:
78
+
79
+ ```bash
80
+ pip install 'clapback-embed[export]'
81
+ python -m clapback_embed.scripts.export_models --out ~/.cache/clapback/models
82
+ ```
83
+
84
+ Or point `CLAPBACK_MODEL_DIR` at them if you already have them.
85
+
86
+ ## Where things are kept
87
+
88
+ `~/.clapback/` — a `vectors.npy` and an `index.json`, both yours. Deleting the
89
+ directory loses nothing but the time to rebuild it.
90
+
91
+ If you contribute, `index.json` also holds a `client_id`: a random UUID minted the
92
+ first time you contribute and never before, derived from nothing about you or your
93
+ machine. It exists so the corpus can tell two contributions apart from one client
94
+ retrying. Delete it and you are a new contributor; nothing else changes.
95
+
96
+ ## What it is not
97
+
98
+ Not a player, not a tagger, not a library manager, not a downloader. It does the
99
+ two things a CLAP embedding makes uniquely easy and stops.
100
+
101
+ ## Why it exists
102
+
103
+ It is the reference implementation's first real client, and the argument for it
104
+ is in [`ADR-0009`](../../docs/decisions/ADR-0009-the-tool-is-useful-before-the-corpus-is.md):
105
+ a donation client with no local value has no first contributor, and this project
106
+ has measured proof that passive accumulation does not happen. What the tool does
107
+ locally is the draw; contributing to the [commons](https://clapback.seethroughlab.com)
108
+ is a byproduct of it.
@@ -0,0 +1,10 @@
1
+ clapback_cli/__init__.py,sha256=eNj8LihuI82SU1SG6uGzJ0814wvVKSTmaIB3tIrT0uU,274
2
+ clapback_cli/cli.py,sha256=IpmzaIgrRkBNKE8vXxoJVCwGlQr-edleiWHnftyi6vA,11580
3
+ clapback_cli/corpus.py,sha256=COFGJevTo8Mv061q3rSEzuy5uEJuXWNasYIJ8LhQVwY,5751
4
+ clapback_cli/fingerprint.py,sha256=t_O56nQvcOqN3pBJ65K72nnwA0HmD2kOa8FbHXNfscY,5356
5
+ clapback_cli/store.py,sha256=iZVjbqrSeto4OYUm6WFxl2z-SVBuIeHgQ8PWaHjhhwk,5221
6
+ clapback_cli-0.1.0.dist-info/METADATA,sha256=RhlhsrmHtZo0ClOMeperwm8M9lUOSrcFrwdp9fTgEBo,4033
7
+ clapback_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
8
+ clapback_cli-0.1.0.dist-info/entry_points.txt,sha256=pDpKDWoHwPBQj3dSLDXYv7nwh7UkA05uVphRHT0_Ncs,51
9
+ clapback_cli-0.1.0.dist-info/licenses/LICENSE,sha256=6Tfw3KwvxvDfednEoMB-s180r3b0xff9qWj57iXpiGs,1068
10
+ clapback_cli-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,2 @@
1
+ [console_scripts]
2
+ clapback = clapback_cli.cli:main
@@ -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.