clapback-cli 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,45 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ .venv/
8
+ venv/
9
+ ENV/
10
+ .eggs/
11
+ *.egg-info/
12
+ *.egg
13
+
14
+ # uv — `ADR-0005` point 7. The workspace has exactly one lock, at the root, and it
15
+ # is committed. A lock never ships in a wheel: what `clapback-embed`'s dependents
16
+ # resolve comes from its `dependencies` ranges, so committing this cannot pin them.
17
+ # What it does pin is development, CI and the deployed image, which previously
18
+ # resolved fresh on every Docker build.
19
+
20
+ # IDE
21
+ .idea/
22
+ .vscode/
23
+ *.swp
24
+ *.swo
25
+
26
+ # Testing
27
+ .pytest_cache/
28
+ .coverage
29
+ htmlcov/
30
+
31
+ # Docker
32
+ data/
33
+
34
+ # Environment
35
+ .env
36
+ .env.local
37
+
38
+ # OS
39
+ .DS_Store
40
+ Thumbs.db
41
+
42
+ # Package venvs and exported model artifacts (614 MB, produced by scripts/export_models.py)
43
+ packages/*/.venv/
44
+ *.onnx
45
+ *.onnx.data
@@ -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.
@@ -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,94 @@
1
+ # clapback
2
+
3
+ Search your own music by description, and find duplicates across formats and masters.
4
+
5
+ ```bash
6
+ pip install clapback-cli
7
+
8
+ clapback index ~/Music
9
+ clapback search "dreamy ambient with piano"
10
+ clapback duplicates
11
+ ```
12
+
13
+ ## What it does
14
+
15
+ **Search by description.** CLAP puts audio and text in one space, so "something
16
+ slow with brushed drums" is a query rather than a keyword match against filenames
17
+ you may never have typed.
18
+
19
+ **Find near-duplicates.** Two rips of one recording measure 0.9972–0.9995 under
20
+ this pipeline; genuinely different music sits far below. That gap is what makes
21
+ duplicate detection across formats and masters work — a FLAC and a V0 of the same
22
+ master are obvious, and so is the same recording on two different releases.
23
+
24
+ Both run against your own files, offline. There is no account, no key, and
25
+ nothing is sent anywhere.
26
+
27
+ **Contribute, if you want to.** Opt-in and off unless you type it:
28
+
29
+ ```bash
30
+ clapback contribute --dry-run # say what would be sent, send nothing
31
+ clapback contribute
32
+ ```
33
+
34
+ This sends the vectors — never your audio, never filenames, never your library's
35
+ contents. A recording is identified by the SHA256 of its AcoustID fingerprint,
36
+ which is one-way: the corpus learns that somebody has a recording without learning
37
+ which recording it is.
38
+
39
+ Every track is looked up before it is offered, so re-running contributes only
40
+ what is new. That is not politeness about bandwidth — a repeat submission is
41
+ recorded as agreement, and one install agreeing with itself would corrupt the one
42
+ measurement the commons exists to make.
43
+
44
+ Contributing needs `chromaprint`, and only contributing does:
45
+
46
+ ```bash
47
+ brew install chromaprint # or: apt install libchromaprint-tools
48
+ pip install pyacoustid
49
+ ```
50
+
51
+ Without it, indexing, search and duplicates work exactly as well.
52
+
53
+ ## Why `clapback-cli` and not `clapback`
54
+
55
+ The bare name on PyPI belongs to an unrelated package from 2018 that adds clap
56
+ emojis to sentences. The distribution is therefore `clapback-cli`, matching
57
+ `clapback-embed`; the command you type is still `clapback`.
58
+
59
+ ## What it needs
60
+
61
+ `clapback-embed`, which arrives with it, and the ONNX encoders it runs on. Those
62
+ are **614 MB and not bundled** — a package that downloaded them on install would
63
+ be lying about its size. Export them once:
64
+
65
+ ```bash
66
+ pip install 'clapback-embed[export]'
67
+ python -m clapback_embed.scripts.export_models --out ~/.cache/clapback/models
68
+ ```
69
+
70
+ Or point `CLAPBACK_MODEL_DIR` at them if you already have them.
71
+
72
+ ## Where things are kept
73
+
74
+ `~/.clapback/` — a `vectors.npy` and an `index.json`, both yours. Deleting the
75
+ directory loses nothing but the time to rebuild it.
76
+
77
+ If you contribute, `index.json` also holds a `client_id`: a random UUID minted the
78
+ first time you contribute and never before, derived from nothing about you or your
79
+ machine. It exists so the corpus can tell two contributions apart from one client
80
+ retrying. Delete it and you are a new contributor; nothing else changes.
81
+
82
+ ## What it is not
83
+
84
+ Not a player, not a tagger, not a library manager, not a downloader. It does the
85
+ two things a CLAP embedding makes uniquely easy and stops.
86
+
87
+ ## Why it exists
88
+
89
+ It is the reference implementation's first real client, and the argument for it
90
+ is in [`ADR-0009`](../../docs/decisions/ADR-0009-the-tool-is-useful-before-the-corpus-is.md):
91
+ a donation client with no local value has no first contributor, and this project
92
+ has measured proof that passive accumulation does not happen. What the tool does
93
+ locally is the draw; contributing to the [commons](https://clapback.seethroughlab.com)
94
+ is a byproduct of it.
@@ -0,0 +1,58 @@
1
+ [project]
2
+ name = "clapback-cli"
3
+ version = "0.1.0"
4
+ description = "Search your own music by description, and find duplicates across formats and masters"
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+
10
+ # `ADR-0009` point 7: this depends on the published reference implementation and
11
+ # reimplements nothing. A tool that quietly did its own windowing would undo the
12
+ # whole argument for there being one implementation.
13
+ #
14
+ # Upper-bounded at the minor for the reason `ADR-0005` point 4 gives: a change
15
+ # that moves `PIPELINE_VERSION` is at minimum a minor bump and a patch may never
16
+ # move it, so `<0.2` takes fixes and refuses the release that would silently
17
+ # change what every stored vector means.
18
+ #
19
+ # **No faiss, no hnswlib.** `ADR-0009` point 3, measured: a numpy matrix-vector
20
+ # product answers a query in 0.3 ms across 26,000 tracks and 5.5 ms across
21
+ # 500,000. An index buys nothing at personal-library scale and costs a compiled
22
+ # wheel in a tool whose adoption argument is that it installs cleanly.
23
+ dependencies = [
24
+ "clapback-embed>=0.1.0,<0.2",
25
+ "numpy>=1.24.0",
26
+ ]
27
+
28
+ [project.optional-dependencies]
29
+ dev = ["pytest>=7.4.0", "ruff>=0.1.0"]
30
+
31
+ # In this workspace, take the embedder from the checkout rather than the index, so
32
+ # a change to the reference implementation is testable here before it is released.
33
+ # This is development metadata only: `uv` reads it, build backends do not, so the
34
+ # published wheel still depends on `clapback-embed>=0.1.0,<0.2` from PyPI exactly
35
+ # as declared above.
36
+ #
37
+ # It is also not optional. `clapback-embed` is a workspace member, and uv refuses
38
+ # to resolve a member depending on another member without being told which source
39
+ # to use — which is how this arrived as a red build on `main`.
40
+ [tool.uv.sources]
41
+ clapback-embed = { workspace = true }
42
+
43
+ [project.scripts]
44
+ clapback = "clapback_cli.cli:main"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.hatch.build.targets.wheel]
51
+ packages = ["src/clapback_cli"]
52
+
53
+ [tool.ruff]
54
+ line-length = 100
55
+ target-version = "py311"
56
+
57
+ [tool.pytest.ini_options]
58
+ testpaths = ["tests"]
@@ -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"
@@ -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()