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 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()