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.
- clapback_cli/__init__.py +8 -0
- clapback_cli/cli.py +282 -0
- clapback_cli/corpus.py +133 -0
- clapback_cli/fingerprint.py +127 -0
- clapback_cli/store.py +124 -0
- clapback_cli-0.1.0.dist-info/METADATA +108 -0
- clapback_cli-0.1.0.dist-info/RECORD +10 -0
- clapback_cli-0.1.0.dist-info/WHEEL +4 -0
- clapback_cli-0.1.0.dist-info/entry_points.txt +2 -0
- clapback_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
clapback_cli/__init__.py
ADDED
|
@@ -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,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.
|