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.
- clapback_cli-0.1.0/.gitignore +45 -0
- clapback_cli-0.1.0/LICENSE +21 -0
- clapback_cli-0.1.0/PKG-INFO +108 -0
- clapback_cli-0.1.0/README.md +94 -0
- clapback_cli-0.1.0/pyproject.toml +58 -0
- clapback_cli-0.1.0/src/clapback_cli/__init__.py +8 -0
- clapback_cli-0.1.0/src/clapback_cli/cli.py +282 -0
- clapback_cli-0.1.0/src/clapback_cli/corpus.py +133 -0
- clapback_cli-0.1.0/src/clapback_cli/fingerprint.py +127 -0
- clapback_cli-0.1.0/src/clapback_cli/store.py +124 -0
- clapback_cli-0.1.0/tests/test_cli.py +77 -0
- clapback_cli-0.1.0/tests/test_contribute.py +166 -0
- clapback_cli-0.1.0/tests/test_fingerprint.py +80 -0
- clapback_cli-0.1.0/tests/test_store.py +71 -0
|
@@ -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()
|