cuepoint 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.
- cuepoint/__init__.py +0 -0
- cuepoint/__main__.py +391 -0
- cuepoint/anlz.py +313 -0
- cuepoint/audio.py +138 -0
- cuepoint/extract.py +93 -0
- cuepoint/features.py +243 -0
- cuepoint/fingerprint.py +311 -0
- cuepoint/library.py +266 -0
- cuepoint/listen.py +316 -0
- cuepoint/mix.py +509 -0
- cuepoint/paths.py +66 -0
- cuepoint/server.py +136 -0
- cuepoint/static/fonts/OFL-Anton.txt +93 -0
- cuepoint/static/fonts/OFL-DMSans.txt +93 -0
- cuepoint/static/fonts/anton-latin-ext.woff2 +0 -0
- cuepoint/static/fonts/anton-latin.woff2 +0 -0
- cuepoint/static/fonts/dmsans-latin-ext.woff2 +0 -0
- cuepoint/static/fonts/dmsans-latin.woff2 +0 -0
- cuepoint/static/index.html +498 -0
- cuepoint-0.1.0.dist-info/METADATA +167 -0
- cuepoint-0.1.0.dist-info/RECORD +25 -0
- cuepoint-0.1.0.dist-info/WHEEL +5 -0
- cuepoint-0.1.0.dist-info/entry_points.txt +2 -0
- cuepoint-0.1.0.dist-info/licenses/LICENSE +21 -0
- cuepoint-0.1.0.dist-info/top_level.txt +1 -0
cuepoint/__init__.py
ADDED
|
File without changes
|
cuepoint/__main__.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Cuepoint's command line.
|
|
2
|
+
|
|
3
|
+
cuepoint setup first run: scan, measure, then serve
|
|
4
|
+
cuepoint doctor check ffmpeg, rekordbox, data folder
|
|
5
|
+
|
|
6
|
+
python -m cuepoint build rebuild the database from rekordbox
|
|
7
|
+
python -m cuepoint extract compute features for new tracks
|
|
8
|
+
python -m cuepoint tracks [query] search the library
|
|
9
|
+
python -m cuepoint show <query> one track's structure and key
|
|
10
|
+
python -m cuepoint next <query> what to play after this record
|
|
11
|
+
python -m cuepoint set <query> chain a whole set from one opener
|
|
12
|
+
python -m cuepoint serve the web UI, at localhost:8765
|
|
13
|
+
python -m cuepoint index build the audio-recognition index
|
|
14
|
+
python -m cuepoint listen live: hear what's playing, suggest next
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
import sys
|
|
23
|
+
import time
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
from . import library, mix
|
|
27
|
+
|
|
28
|
+
from . import paths
|
|
29
|
+
|
|
30
|
+
DB = None # resolved by paths.db_path() at call time
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --------------------------------------------------------------------------
|
|
34
|
+
# presentation
|
|
35
|
+
# --------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
def clock(ms: int) -> str:
|
|
38
|
+
s = max(0, ms) // 1000
|
|
39
|
+
return f"{s // 60}:{s % 60:02d}"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def bar(score: float, width: int = 10) -> str:
|
|
43
|
+
filled = int(round(score * width))
|
|
44
|
+
return "#" * filled + "." * (width - filled)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def find(crate: dict, query: str) -> list:
|
|
48
|
+
"""Tracks whose title or filename contains `query`, case-insensitively."""
|
|
49
|
+
q = query.lower().strip()
|
|
50
|
+
hits = [t for t in crate.values()
|
|
51
|
+
if q in t.title.lower() or q in t.filename.lower()]
|
|
52
|
+
hits.sort(key=lambda t: (len(t.title), t.title))
|
|
53
|
+
return hits
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve(crate: dict, query: str):
|
|
57
|
+
"""Pick the single track a query refers to, or explain why it can't."""
|
|
58
|
+
if query.isdigit() and int(query) in crate:
|
|
59
|
+
return crate[int(query)]
|
|
60
|
+
hits = find(crate, query)
|
|
61
|
+
if not hits:
|
|
62
|
+
sys.exit(f"No track matches {query!r}. Try: python -m cuepoint tracks")
|
|
63
|
+
if len(hits) > 1 and hits[0].title.lower() != query.lower().strip():
|
|
64
|
+
print(f"{len(hits)} tracks match {query!r}; using the first:")
|
|
65
|
+
for t in hits[:6]:
|
|
66
|
+
print(f" {t.id:>10} {t.title[:58]}")
|
|
67
|
+
print()
|
|
68
|
+
return hits[0]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def describe(blend, index: int | None = None) -> None:
|
|
72
|
+
"""One recommendation, in the form a DJ would want to read it."""
|
|
73
|
+
t = blend.track
|
|
74
|
+
head = f"{index:>2}. " if index is not None else " "
|
|
75
|
+
ratio = ""
|
|
76
|
+
if blend.ratio != 1.0:
|
|
77
|
+
ratio = " half-time" if blend.ratio == 2.0 else " double-time"
|
|
78
|
+
print(f"{head}{bar(blend.score)} {blend.score:.2f} {t.title[:52]}")
|
|
79
|
+
print(f" {t.tempo:.1f} BPM {t.key[0]:>4s} ({t.key[1]}){ratio}")
|
|
80
|
+
print(f" out at {clock(blend.cue_out_ms)} ({blend.from_phrase.role})"
|
|
81
|
+
f" -> in at {clock(blend.cue_in_ms)} ({blend.to_phrase.role})"
|
|
82
|
+
f" {blend.bars} bars pitch {blend.stretch * 100:+.1f}%")
|
|
83
|
+
name, value = blend.weakest()
|
|
84
|
+
detail = " ".join(f"{k} {v:.2f}" for k, v in blend.parts.items())
|
|
85
|
+
print(f" {detail}")
|
|
86
|
+
if value < 0.6:
|
|
87
|
+
print(f" watch the {name}")
|
|
88
|
+
print()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# --------------------------------------------------------------------------
|
|
92
|
+
# commands
|
|
93
|
+
# --------------------------------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def cmd_build(args) -> None:
|
|
96
|
+
print(f"Building {args.db} from {library.REKORDBOX}")
|
|
97
|
+
library.build(args.db)
|
|
98
|
+
print("\nNow run: python -m cuepoint extract")
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def cmd_extract(args) -> None:
|
|
102
|
+
from . import extract
|
|
103
|
+
extract.run(args.db, force=args.force)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def cmd_tracks(args) -> None:
|
|
107
|
+
con = library.connect(args.db)
|
|
108
|
+
crate = mix.load_all(con)
|
|
109
|
+
hits = find(crate, args.query) if args.query else sorted(
|
|
110
|
+
crate.values(), key=lambda t: t.title)
|
|
111
|
+
print(f"{len(hits)} of {len(crate)} mixable tracks\n")
|
|
112
|
+
for t in hits[:args.limit]:
|
|
113
|
+
print(f" {t.id:>10} {t.tempo:6.1f} {t.key[1]:>3s} {t.title[:56]}")
|
|
114
|
+
if len(hits) > args.limit:
|
|
115
|
+
print(f"\n ... and {len(hits) - args.limit} more")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cmd_show(args) -> None:
|
|
119
|
+
con = library.connect(args.db)
|
|
120
|
+
crate = mix.load_all(con)
|
|
121
|
+
t = resolve(crate, args.query)
|
|
122
|
+
print(f"{t.title}")
|
|
123
|
+
print(f"{t.tempo:.1f} BPM key {t.key[0]} ({t.key[1]}) "
|
|
124
|
+
f"{clock(t.duration_ms)} id {t.id}\n")
|
|
125
|
+
print(f" {'bar':>5} {'time':>6} {'role':<10} {'bars':>4} "
|
|
126
|
+
f"{'energy':>7} {'vocal':>6} {'kick':>6}")
|
|
127
|
+
for p in t.phrases:
|
|
128
|
+
print(f" {p.start_bar:>5} {clock(p.start_ms):>6} {p.role:<10} "
|
|
129
|
+
f"{p.bars:>4} {p.energy_db:>6.1f}dB {p.vocal:>6.2f} "
|
|
130
|
+
f"{p.kick_hz:>5.0f}Hz")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def cmd_next(args) -> None:
|
|
134
|
+
con = library.connect(args.db)
|
|
135
|
+
crate = mix.load_all(con)
|
|
136
|
+
t = resolve(crate, args.query)
|
|
137
|
+
print(f"Playing: {t.title}")
|
|
138
|
+
print(f" {t.tempo:.1f} BPM key {t.key[0]} ({t.key[1]})\n")
|
|
139
|
+
recs = mix.recommend(t, crate, limit=args.limit)
|
|
140
|
+
if not recs:
|
|
141
|
+
print("Nothing in the crate is within pitch range of this record.")
|
|
142
|
+
return
|
|
143
|
+
for i, blend in enumerate(recs, 1):
|
|
144
|
+
describe(blend, i)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def cmd_set(args) -> None:
|
|
148
|
+
"""Chain recommendations into a full set, never repeating a record.
|
|
149
|
+
|
|
150
|
+
Greedy: at each step take the best remaining blend. A greedy walk is the
|
|
151
|
+
honest model of how the night actually goes -- you choose the next record
|
|
152
|
+
knowing what is on the deck, not by planning two hours ahead.
|
|
153
|
+
"""
|
|
154
|
+
con = library.connect(args.db)
|
|
155
|
+
crate = mix.load_all(con)
|
|
156
|
+
current = resolve(crate, args.query)
|
|
157
|
+
|
|
158
|
+
played = {current.id}
|
|
159
|
+
total = 0.0
|
|
160
|
+
print(f"Set from: {current.title}\n")
|
|
161
|
+
print(f" 1. {current.title[:56]}")
|
|
162
|
+
print(f" {current.tempo:.1f} BPM {current.key[1]} (opener)\n")
|
|
163
|
+
|
|
164
|
+
for n in range(2, args.length + 1):
|
|
165
|
+
recs = mix.recommend(current, crate, limit=1, exclude=played)
|
|
166
|
+
if not recs:
|
|
167
|
+
print(" (nothing left in range -- set ends here)")
|
|
168
|
+
break
|
|
169
|
+
blend = recs[0]
|
|
170
|
+
total += blend.score
|
|
171
|
+
print(f" {n:>4}. {blend.track.title[:56]}")
|
|
172
|
+
print(f" {blend.track.tempo:.1f} BPM {blend.track.key[1]} "
|
|
173
|
+
f"blend {blend.score:.2f} {bar(blend.score)}")
|
|
174
|
+
print(f" out {clock(blend.cue_out_ms)} "
|
|
175
|
+
f"({blend.from_phrase.role}) -> in {clock(blend.cue_in_ms)} "
|
|
176
|
+
f"({blend.to_phrase.role}), {blend.bars} bars, "
|
|
177
|
+
f"pitch {blend.stretch * 100:+.1f}%\n")
|
|
178
|
+
played.add(blend.track.id)
|
|
179
|
+
current = blend.track
|
|
180
|
+
|
|
181
|
+
if len(played) > 1:
|
|
182
|
+
print(f" {len(played)} tracks, mean blend "
|
|
183
|
+
f"{total / (len(played) - 1):.2f}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_index(args) -> None:
|
|
187
|
+
from . import fingerprint
|
|
188
|
+
fingerprint.build(args.db)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def cmd_listen(args) -> None:
|
|
192
|
+
"""Watch what the room is playing and keep a suggestion on screen."""
|
|
193
|
+
from . import fingerprint, listen as listener_mod
|
|
194
|
+
con = library.connect(args.db)
|
|
195
|
+
crate = mix.load_all(con)
|
|
196
|
+
try:
|
|
197
|
+
index = fingerprint.Index()
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
sys.exit("No audio index. Run: python -m cuepoint index")
|
|
200
|
+
|
|
201
|
+
if args.list_devices:
|
|
202
|
+
for i, name in listener_mod.devices():
|
|
203
|
+
print(f" [{i}] {name}")
|
|
204
|
+
return
|
|
205
|
+
|
|
206
|
+
lis = listener_mod.Listener(index, device=args.device).start()
|
|
207
|
+
dev = listener_mod.devices()
|
|
208
|
+
name = next((n for i, n in dev if i == lis.device), "?")
|
|
209
|
+
print(f"Listening on [{lis.device}] {name} (ctrl-c to stop)\n")
|
|
210
|
+
|
|
211
|
+
last = None
|
|
212
|
+
try:
|
|
213
|
+
while True:
|
|
214
|
+
time.sleep(1.0)
|
|
215
|
+
s = lis.state
|
|
216
|
+
if s.error:
|
|
217
|
+
print(f"\r{s.error}", flush=True)
|
|
218
|
+
break
|
|
219
|
+
if s.track_id is None:
|
|
220
|
+
print("\r listening...".ljust(78), end="", flush=True)
|
|
221
|
+
last = None
|
|
222
|
+
continue
|
|
223
|
+
t = crate.get(s.track_id)
|
|
224
|
+
if t is None:
|
|
225
|
+
continue
|
|
226
|
+
if s.track_id != last:
|
|
227
|
+
last = s.track_id
|
|
228
|
+
print("\r".ljust(78))
|
|
229
|
+
print(f" NOW {t.title[:56]}")
|
|
230
|
+
print(f" {t.tempo:.1f} BPM {t.key[0]} ({t.key[1]})"
|
|
231
|
+
f" confidence {s.confidence:.2f}")
|
|
232
|
+
for i, b in enumerate(mix.recommend(t, crate, limit=3), 1):
|
|
233
|
+
print(f" {i}. {b.score:.2f} {b.track.title[:44]}"
|
|
234
|
+
f" in at {clock(b.cue_in_ms)} ({b.to_role})")
|
|
235
|
+
print()
|
|
236
|
+
fix = "exact" if s.settled else "approx"
|
|
237
|
+
print(f"\r {clock(s.playhead_ms)} / {clock(t.duration_ms)}"
|
|
238
|
+
f" [{fix}] pitch {(s.rate - 1) * 100:+.1f}%".ljust(78),
|
|
239
|
+
end="", flush=True)
|
|
240
|
+
except KeyboardInterrupt:
|
|
241
|
+
pass
|
|
242
|
+
finally:
|
|
243
|
+
lis.stop()
|
|
244
|
+
print("\nStopped.")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
# --------------------------------------------------------------------------
|
|
248
|
+
# first run
|
|
249
|
+
# --------------------------------------------------------------------------
|
|
250
|
+
|
|
251
|
+
def _check_ffmpeg() -> tuple[bool, str]:
|
|
252
|
+
from . import audio
|
|
253
|
+
exe = shutil.which("ffmpeg") or audio.FFMPEG
|
|
254
|
+
if not (exe and Path(exe).exists()):
|
|
255
|
+
return False, "not found on PATH"
|
|
256
|
+
try:
|
|
257
|
+
out = subprocess.run([exe, "-version"], capture_output=True, text=True,
|
|
258
|
+
timeout=15).stdout.splitlines()[0]
|
|
259
|
+
return True, out[:60]
|
|
260
|
+
except Exception as exc: # noqa: BLE001
|
|
261
|
+
return False, f"found but would not run ({exc})"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_doctor(args) -> None:
|
|
265
|
+
"""Everything Cuepoint needs, and whether this machine has it."""
|
|
266
|
+
from . import paths
|
|
267
|
+
ok = True
|
|
268
|
+
|
|
269
|
+
print("Cuepoint check\n")
|
|
270
|
+
|
|
271
|
+
have, detail = _check_ffmpeg()
|
|
272
|
+
ok &= have
|
|
273
|
+
print(f" ffmpeg {'OK ' if have else 'MISSING'} {detail}")
|
|
274
|
+
if not have:
|
|
275
|
+
print(" install it: brew install ffmpeg (macOS)")
|
|
276
|
+
print(" winget install ffmpeg (Windows)")
|
|
277
|
+
|
|
278
|
+
rb = library.find_rekordbox()
|
|
279
|
+
ok &= rb is not None
|
|
280
|
+
print(f" rekordbox {'OK ' if rb else 'MISSING'} {rb or 'no collection found'}")
|
|
281
|
+
if rb is None:
|
|
282
|
+
for d in library.rekordbox_dirs():
|
|
283
|
+
print(f" looked in {d}")
|
|
284
|
+
print(" override with CUEPOINT_REKORDBOX=/path/to/rekordbox")
|
|
285
|
+
|
|
286
|
+
d = paths.data_dir()
|
|
287
|
+
print(f"\n data folder {d}")
|
|
288
|
+
db = paths.db_path()
|
|
289
|
+
n_feat = len(list(paths.features_dir().glob('*.npz'))) if paths.features_dir().exists() else 0
|
|
290
|
+
print(f" database {'present' if db.exists() else 'not built yet'}")
|
|
291
|
+
print(f" features {n_feat} tracks measured")
|
|
292
|
+
|
|
293
|
+
if db.exists():
|
|
294
|
+
con = library.connect(args.db)
|
|
295
|
+
total, = con.execute("SELECT COUNT(*) FROM tracks").fetchone()
|
|
296
|
+
usable, = con.execute(
|
|
297
|
+
"SELECT COUNT(*) FROM tracks WHERE analyzed=1 AND present=1").fetchone()
|
|
298
|
+
con.close()
|
|
299
|
+
print(f" collection {total} known, {usable} with audio + analysis")
|
|
300
|
+
|
|
301
|
+
print("\n" + ("Ready. Run: cuepoint serve" if ok and db.exists()
|
|
302
|
+
else "Run: cuepoint setup" if ok else "Fix the items above first."))
|
|
303
|
+
sys.exit(0 if ok else 1)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def cmd_setup(args) -> None:
|
|
307
|
+
"""Build, measure, and open the UI -- the whole first run, in order."""
|
|
308
|
+
from . import paths
|
|
309
|
+
have, detail = _check_ffmpeg()
|
|
310
|
+
if not have:
|
|
311
|
+
sys.exit(f"ffmpeg is required but was {detail}.\n"
|
|
312
|
+
" macOS: brew install ffmpeg\n"
|
|
313
|
+
" Windows: winget install ffmpeg")
|
|
314
|
+
if library.find_rekordbox() is None:
|
|
315
|
+
sys.exit("No rekordbox collection found. Run `cuepoint doctor` for details.")
|
|
316
|
+
|
|
317
|
+
paths.ensure()
|
|
318
|
+
print(f"Data folder: {paths.data_dir()}\n")
|
|
319
|
+
print("[1/3] Reading the rekordbox collection...")
|
|
320
|
+
cmd_build(args)
|
|
321
|
+
print("\n[2/3] Measuring each track (cached -- later runs only touch new ones)...")
|
|
322
|
+
cmd_extract(args)
|
|
323
|
+
print("\n[3/3] Starting the web UI...")
|
|
324
|
+
cmd_serve(args)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def cmd_serve(args) -> None:
|
|
328
|
+
from . import server
|
|
329
|
+
server.serve(args.db, args.port)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def main(argv=None) -> None:
|
|
333
|
+
ap = argparse.ArgumentParser(
|
|
334
|
+
prog="cuepoint", description=__doc__,
|
|
335
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
336
|
+
ap.add_argument("--db", default=DB, help="database path")
|
|
337
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
338
|
+
|
|
339
|
+
sub.add_parser("build", help="rebuild from rekordbox").set_defaults(
|
|
340
|
+
func=cmd_build)
|
|
341
|
+
|
|
342
|
+
p = sub.add_parser("extract", help="compute missing features")
|
|
343
|
+
p.add_argument("--force", action="store_true", help="redo every track")
|
|
344
|
+
p.set_defaults(func=cmd_extract)
|
|
345
|
+
|
|
346
|
+
p = sub.add_parser("tracks", help="search the library")
|
|
347
|
+
p.add_argument("query", nargs="?", default="")
|
|
348
|
+
p.add_argument("-n", "--limit", type=int, default=40)
|
|
349
|
+
p.set_defaults(func=cmd_tracks)
|
|
350
|
+
|
|
351
|
+
p = sub.add_parser("show", help="one track's structure")
|
|
352
|
+
p.add_argument("query")
|
|
353
|
+
p.set_defaults(func=cmd_show)
|
|
354
|
+
|
|
355
|
+
p = sub.add_parser("next", help="what to play next")
|
|
356
|
+
p.add_argument("query")
|
|
357
|
+
p.add_argument("-n", "--limit", type=int, default=8)
|
|
358
|
+
p.set_defaults(func=cmd_next)
|
|
359
|
+
|
|
360
|
+
p = sub.add_parser("set", help="chain a whole set")
|
|
361
|
+
p.add_argument("query")
|
|
362
|
+
p.add_argument("-n", "--length", type=int, default=10)
|
|
363
|
+
p.set_defaults(func=cmd_set)
|
|
364
|
+
|
|
365
|
+
p = sub.add_parser("index", help="build the audio-recognition index")
|
|
366
|
+
p.set_defaults(func=cmd_index)
|
|
367
|
+
|
|
368
|
+
p = sub.add_parser("listen", help="live recognition + suggestions")
|
|
369
|
+
p.add_argument("-d", "--device", type=int, default=None,
|
|
370
|
+
help="audio input index (default: loopback if present)")
|
|
371
|
+
p.add_argument("--list-devices", action="store_true")
|
|
372
|
+
p.set_defaults(func=cmd_listen)
|
|
373
|
+
|
|
374
|
+
sub.add_parser("doctor", help="check ffmpeg, rekordbox and the data folder"
|
|
375
|
+
).set_defaults(func=cmd_doctor)
|
|
376
|
+
|
|
377
|
+
p = sub.add_parser("setup", help="first run: build, measure, then serve")
|
|
378
|
+
p.add_argument("--force", action="store_true", help="redo every track")
|
|
379
|
+
p.add_argument("-p", "--port", type=int, default=8765)
|
|
380
|
+
p.set_defaults(func=cmd_setup)
|
|
381
|
+
|
|
382
|
+
p = sub.add_parser("serve", help="run the web UI")
|
|
383
|
+
p.add_argument("-p", "--port", type=int, default=8765)
|
|
384
|
+
p.set_defaults(func=cmd_serve)
|
|
385
|
+
|
|
386
|
+
args = ap.parse_args(argv)
|
|
387
|
+
args.func(args)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
if __name__ == "__main__":
|
|
391
|
+
main()
|