breakdig 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.
breakdig/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Find bar-exact drum breaks, acapellas and other sections in your own music library."""
2
+
3
+ __version__ = "0.1.0"
breakdig/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
breakdig/audio.py ADDED
@@ -0,0 +1,107 @@
1
+ """ffmpeg/ffprobe wrappers. Every decode in breakdig goes through here so the
2
+ index and the export see exactly the same timeline for lossy files."""
3
+
4
+ import json
5
+ import shutil
6
+ import subprocess
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+
11
+ FFMPEG_HINT = (
12
+ "ffmpeg was not found on PATH. Install it and open a new terminal:\n"
13
+ " winget install Gyan.FFmpeg (Windows)\n"
14
+ " brew install ffmpeg (macOS)\n"
15
+ " sudo apt install ffmpeg (Debian/Ubuntu)"
16
+ )
17
+
18
+ # Hide the console window ffmpeg would otherwise flash when run from the web UI on Windows.
19
+ _NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
20
+
21
+
22
+ class DecodeError(Exception):
23
+ pass
24
+
25
+
26
+ @dataclass
27
+ class Probe:
28
+ duration: float
29
+ sample_rate: int
30
+ channels: int
31
+ bits: int # 0 when the codec has no fixed bit depth (mp3, aac, vorbis)
32
+
33
+
34
+ def have_ffmpeg() -> bool:
35
+ return shutil.which("ffmpeg") is not None and shutil.which("ffprobe") is not None
36
+
37
+
38
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess:
39
+ try:
40
+ return subprocess.run(cmd, capture_output=True, creationflags=_NO_WINDOW)
41
+ except FileNotFoundError:
42
+ raise DecodeError(FFMPEG_HINT) from None
43
+
44
+
45
+ def _last_line(stderr: bytes) -> str:
46
+ lines = [ln.strip() for ln in stderr.decode("utf-8", "replace").splitlines() if ln.strip()]
47
+ return lines[-1] if lines else "unknown ffmpeg error"
48
+
49
+
50
+ def probe(path: str) -> Probe:
51
+ r = _run(["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries",
52
+ "stream=sample_rate,channels,bits_per_raw_sample,bits_per_sample:format=duration",
53
+ "-of", "json", path])
54
+ if r.returncode != 0:
55
+ raise DecodeError(_last_line(r.stderr))
56
+ info = json.loads(r.stdout or b"{}")
57
+ streams = info.get("streams") or []
58
+ if not streams:
59
+ raise DecodeError("no audio stream")
60
+ s = streams[0]
61
+ bits = 0
62
+ for k in ("bits_per_raw_sample", "bits_per_sample"):
63
+ try:
64
+ bits = bits or int(s.get(k) or 0)
65
+ except ValueError:
66
+ pass
67
+ try:
68
+ duration = float(info.get("format", {}).get("duration") or 0.0)
69
+ except ValueError:
70
+ duration = 0.0
71
+ return Probe(duration, int(s.get("sample_rate") or 0), int(s.get("channels") or 0), bits)
72
+
73
+
74
+ def decode(path: str, start: float | None = None, end: float | None = None,
75
+ info: Probe | None = None) -> np.ndarray:
76
+ """Decode at the source rate and layout to float32 of shape (frames, channels).
77
+
78
+ start and end trim the decoded stream by sample index instead of seeking the
79
+ input. With -ss, the first ~90 ms of an MP3 come out as decoder warm-up and AAC
80
+ no longer lines up with the full decode that bar times were measured on;
81
+ trimming by timestamp is still a few ms off on Vorbis."""
82
+ p = info or probe(path)
83
+ sr, ch = p.sample_rate, p.channels
84
+ cmd = ["ffmpeg", "-v", "error", "-nostdin", "-i", path, "-vn"]
85
+ trim = []
86
+ if start is not None:
87
+ trim.append(f"start_sample={round(start * sr)}")
88
+ if end is not None:
89
+ trim.append(f"end_sample={round(end * sr)}")
90
+ if trim:
91
+ cmd += ["-af", f"atrim={':'.join(trim)},asetpts=PTS-STARTPTS"]
92
+ cmd += ["-ac", str(ch), "-ar", str(sr), "-f", "f32le", "-c:a", "pcm_f32le", "-"]
93
+ r = _run(cmd)
94
+ if r.returncode != 0:
95
+ raise DecodeError(_last_line(r.stderr))
96
+ x = np.frombuffer(r.stdout, dtype="<f4")
97
+ if x.size == 0:
98
+ raise DecodeError("decoded to zero samples")
99
+ return x[: x.size // ch * ch].reshape(-1, ch)
100
+
101
+
102
+ def decode_to_wav(path: str, out_wav: str, sample_rate: int, gain: float) -> None:
103
+ """Decode to a stereo float32 WAV at a fixed rate, scaled by gain."""
104
+ r = _run(["ffmpeg", "-v", "error", "-nostdin", "-y", "-i", path, "-vn", "-ac", "2",
105
+ "-ar", str(sample_rate), "-af", f"volume={gain}", "-c:a", "pcm_f32le", "-rf64", "auto", out_wav])
106
+ if r.returncode != 0:
107
+ raise DecodeError(_last_line(r.stderr))
breakdig/beats.py ADDED
@@ -0,0 +1,62 @@
1
+ """Beat and downbeat tracking with beat_this."""
2
+
3
+ import warnings
4
+
5
+ import numpy as np
6
+
7
+ # beat_this pulls in rotary_embedding_torch, which warns about a torch API rename on import.
8
+ warnings.filterwarnings("ignore", category=FutureWarning, module="rotary_embedding_torch")
9
+
10
+ MIN_DOWNBEATS = 8
11
+
12
+
13
+ class BeatTracker:
14
+ def __init__(self):
15
+ import torch
16
+ from beat_this.inference import Audio2Beats
17
+
18
+ self.device = ("cuda" if torch.cuda.is_available() else "cpu")
19
+ # Audio2Beats is File2Beats minus the file loading; the mix is already decoded.
20
+ self._model = Audio2Beats(checkpoint_path="final0", device=self.device, dbn=False)
21
+
22
+ def __call__(self, mono: np.ndarray, sr: int) -> tuple[np.ndarray, np.ndarray]:
23
+ beats, downbeats = self._model(mono, sr)
24
+ beats = refine(np.asarray(beats, dtype=np.float64), mono, sr)
25
+ downbeats = np.asarray(downbeats, dtype=np.float64)
26
+ # Keep downbeats locked onto the refined beat they came from.
27
+ if len(beats) and len(downbeats):
28
+ nearest = np.abs(downbeats[:, None] - beats[None, :]).argmin(axis=1)
29
+ downbeats = beats[nearest]
30
+ return beats, np.unique(downbeats)
31
+
32
+
33
+ def refine(times: np.ndarray, mono: np.ndarray, sr: int, radius: float = 0.025) -> np.ndarray:
34
+ """Move each beat onto the sharpest energy rise within +-radius seconds.
35
+
36
+ beat_this works on 20 ms frames, which is enough for tempo but audible as a
37
+ flam or a clipped attack when you cut a loop on it. A beat with no clear
38
+ onset nearby is left where it was."""
39
+ hop = max(1, sr // 1000)
40
+ n = len(mono) // hop
41
+ if n == 0 or len(times) == 0:
42
+ return times
43
+ frames = mono[: n * hop].reshape(n, hop).astype(np.float64)
44
+ energy = np.log10((frames ** 2).mean(axis=1) + 1e-10)
45
+ # Rise over 3 ms rather than 1 ms, so a single noisy frame can't win. Before the
46
+ # file counts as silence, so a hit on the very first sample is still an onset.
47
+ padded = np.concatenate([np.full(3, -10.0), energy])
48
+ rise = padded[3:] - padded[:-3]
49
+ out = times.copy()
50
+ fps = sr / hop
51
+ r = int(round(radius * fps))
52
+ for i, t in enumerate(times):
53
+ c = int(round(t * fps))
54
+ lo, hi = max(0, c - r), min(n, c + r + 1)
55
+ if hi - lo < 3:
56
+ continue
57
+ j = lo + int(np.argmax(rise[lo:hi]))
58
+ # A rise of 0.5 in log10 energy is ~5 dB inside 3 ms: a real attack.
59
+ if rise[j] >= 0.5:
60
+ # The rise peaks at the end of the 3 ms window; the attack starts at its beginning.
61
+ out[i] = max(0, j - 3) * hop / sr
62
+ return out
breakdig/cli.py ADDED
@@ -0,0 +1,343 @@
1
+ """breakdig command line."""
2
+
3
+ import argparse
4
+ import json
5
+ import logging
6
+ import sqlite3
7
+ import sys
8
+ import time
9
+ from pathlib import Path
10
+
11
+ from . import __version__, audio
12
+ from .db import Index, default_home
13
+ from .query import SILENT_DB, SORTS, find, get_section, keep_from, parse_bpm, pattern_from, search_tracks
14
+
15
+ log = logging.getLogger("breakdig")
16
+
17
+
18
+ def _need_ffmpeg():
19
+ if not audio.have_ffmpeg():
20
+ sys.exit(audio.FFMPEG_HINT)
21
+
22
+
23
+ def _open(args) -> Index:
24
+ """The index, or exit if --home names a folder that has none, so a typo does not look empty."""
25
+ if args.home is not None and not (Index.home_for(args.home) / "index.sqlite").exists():
26
+ sys.exit(f"breakdig: there is no index at {Index.home_for(args.home)}. Check --home.")
27
+ return Index(args.home)
28
+
29
+
30
+ def _none_found(args, index) -> str:
31
+ st = index.stats()
32
+ if not st["ok"]:
33
+ failed = st["by_status"].get("failed", 0)
34
+ return (f"The index at {index.home} is empty. " + (
35
+ f"{failed} file(s) failed; see breakdig stats --failures, then run breakdig index "
36
+ "--retry-failed <folder>." if failed else "Run: breakdig index <folder>"))
37
+ if getattr(args, "search", None) and not search_tracks(index.ok_tracks(), args.search):
38
+ return f"No indexed track matches --search {args.search!r}."
39
+ return "No matching sections. Try --min-bars 1 or --silence-db 25."
40
+
41
+
42
+ def _sections(args, index):
43
+ if getattr(args, "pick", None):
44
+ return [get_section(index, p) for p in args.pick]
45
+ return find(index, pattern_from(args.only, args.no), min_bars=args.min_bars, bpm=parse_bpm(args.bpm),
46
+ text=args.search, skip_seams=args.skip_seams, sort=args.sort, limit=args.limit,
47
+ silent_db=args.silence_db, cleaner_than=args.cleaner_than)
48
+
49
+
50
+ def _clock(t: float) -> str:
51
+ m, s = divmod(round(t, 3), 60)
52
+ return f"{int(m)}:{s:06.3f}"
53
+
54
+
55
+ def _fit(text: str, width: int) -> str:
56
+ return text if len(text) <= width else text[: width - 1] + "~"
57
+
58
+
59
+ def print_table(sections):
60
+ head = f"{'ID':<11} {'ARTIST - TITLE':<44} {'BARS':>4} {'START':>9} {'END':>9} {'BPM':>6} {'CLEAN':>6}"
61
+ print(head)
62
+ print("-" * len(head))
63
+ for s in sections:
64
+ bpm = f"{s.bpm:.1f}" if s.bpm else "?"
65
+ clean = "-" if s.clean is None else f"{s.clean:.1f}"
66
+ flag = " seam" if s.seam else ""
67
+ print(f"{s.id:<11} {_fit(f'{s.artist} - {s.title}', 44):<44} {s.bars:>4} {_clock(s.start):>9} "
68
+ f"{_clock(s.end):>9} {bpm:>6} {clean:>6}{flag}")
69
+
70
+
71
+ def cmd_index(args):
72
+ _need_ffmpeg()
73
+ from tqdm import tqdm
74
+
75
+ from . import scan
76
+ from .indexer import Abort, Indexer
77
+
78
+ missing = [p for p in args.paths if not Path(p).exists()]
79
+ if missing:
80
+ sys.exit(f"not found: {', '.join(missing)}")
81
+ # The index's own scratch files and preview clips are WAVs too.
82
+ files = scan.walk(args.paths, args.exclude, skip=[Index.home_for(args.home)])
83
+ if not files:
84
+ print(f"No audio files found (looked for {', '.join(sorted(e[1:] for e in scan.AUDIO_EXTS))}).")
85
+ return
86
+ import torch
87
+ if not torch.cuda.is_available():
88
+ print("warning: CUDA is not available, so separation runs on the CPU and is 10-30x slower.\n"
89
+ " For an NVIDIA GPU install the CUDA build of torch (cu126 for drivers older than 580):\n"
90
+ " pip install --force-reinstall --no-deps torch torchvision torchaudio "
91
+ "--index-url https://download.pytorch.org/whl/cu130", file=sys.stderr)
92
+ counts, times = {}, []
93
+ with Index(args.home) as index:
94
+ logfile = index.home / "index.log"
95
+ handler = logging.FileHandler(logfile, encoding="utf-8")
96
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
97
+ log.addHandler(handler)
98
+ log.setLevel(logging.INFO)
99
+ try:
100
+ with Indexer(index, model=args.model, shifts=args.shifts) as indexer:
101
+ print(f"Scanning {len(files)} files...")
102
+ todo, known = indexer.pending(tqdm(files, unit="file", dynamic_ncols=True, leave=False),
103
+ retry_failed=args.retry_failed)
104
+ print(f"{known} already indexed, {len(todo)} to do. Index: {index.home}")
105
+ if todo:
106
+ indexer.load_models()
107
+ t_start = time.perf_counter()
108
+ with tqdm(todo, unit="track", dynamic_ncols=True, disable=not todo) as bar:
109
+ for f in bar:
110
+ bar.set_postfix_str(_fit(Path(f.path).name, 40))
111
+ t0 = time.perf_counter()
112
+ status = indexer.index_file(f)
113
+ counts[status] = counts.get(status, 0) + 1
114
+ if status == "ok":
115
+ times.append(time.perf_counter() - t0)
116
+ log.info("ok %s (%.1fs)", f.path, times[-1])
117
+ elif status == "updated":
118
+ log.info("updated %s (new tags or bytes, same audio)", f.path)
119
+ else:
120
+ _report_problem(bar, index, f, status)
121
+ except (KeyboardInterrupt, Abort, sqlite3.Error) as e:
122
+ done = sum(counts.values())
123
+ note = f" {done} tracks were committed; run the same command again to carry on." if done else ""
124
+ if isinstance(e, KeyboardInterrupt):
125
+ print(f"\nStopped.{note}")
126
+ sys.exit(130)
127
+ sys.exit(f"breakdig: {str(e).rstrip('.')}.{note}")
128
+ elapsed = time.perf_counter() - t_start
129
+ parts = [f"{n} {s}" for s, n in sorted(counts.items())]
130
+ print(f"Done in {elapsed:.0f}s: {', '.join(parts) or 'nothing new'}.")
131
+ if times:
132
+ print(f"Separated tracks took {sum(times) / len(times):.1f}s each on average.")
133
+ if any(s not in ("ok", "updated", "skipped", "duplicate") for s in counts):
134
+ print(f"Details: breakdig stats --failures, or {logfile}")
135
+
136
+
137
+ def _report_problem(bar, index, f, status):
138
+ error = (index.by_key(f.key)["error"] if status != "skipped"
139
+ else "moved or changed since the scan; the next run picks it up")
140
+ bar.write(f"{status}: {f.path}\n {error}")
141
+ log.warning("%s %s: %s", status, f.path, error)
142
+
143
+
144
+ def cmd_find(args):
145
+ with _open(args) as index:
146
+ sections = _sections(args, index)
147
+ if args.json:
148
+ print(json.dumps([s.to_dict() for s in sections], indent=2))
149
+ return
150
+ if not sections:
151
+ print(_none_found(args, index))
152
+ return
153
+ print_table(sections)
154
+ print(f"\n{len(sections)} section(s). CLEAN is the loudest unwanted stem minus the target, "
155
+ "in dB; lower is cleaner.")
156
+
157
+
158
+ def cmd_export(args):
159
+ _need_ffmpeg()
160
+ from .export import export
161
+
162
+ keep = keep_from(args.keep)
163
+ with _open(args) as index:
164
+ sections = _sections(args, index)
165
+ if not sections:
166
+ print(f"Nothing to export. {_none_found(args, index)}")
167
+ return
168
+ results = export(sections, args.out, sp404=args.sp404, keep=keep)
169
+ out, wrote = Path(args.out).resolve(), 0
170
+ for r in results:
171
+ if r.path:
172
+ wrote += 1
173
+ print(f"wrote {r.path}")
174
+ elif r.skipped:
175
+ print(f"skipped {r.section.id} (already in {out})")
176
+ else:
177
+ print(f"failed {r.section.id}: {r.error}")
178
+ print(f"\n{wrote} file(s) in {out}")
179
+ if any(r.error for r in results):
180
+ sys.exit(1)
181
+
182
+
183
+ def cmd_stats(args):
184
+ with _open(args) as index:
185
+ st = index.stats()
186
+ print(f"Index: {index.home}")
187
+ print(f"Tracks: {st['ok']} indexed, {st['audio_seconds'] / 3600:.1f} h of audio, {st['bars']} bars")
188
+ for status, n in sorted(st["by_status"].items()):
189
+ print(f" {status:<10} {n}")
190
+ if st["ok"] and st["index_seconds"]:
191
+ print(f"Speed: {st['index_seconds'] / st['ok']:.1f}s per track, "
192
+ f"{st['audio_seconds'] / st['index_seconds']:.1f}x realtime")
193
+ if args.failures:
194
+ rows = index.failures()
195
+ print("\nNot indexed:" if rows else "\nNo failures.")
196
+ for r in rows:
197
+ print(f" [{r['status']}] {r['path']}\n {r['error']}")
198
+
199
+
200
+ def cmd_ui(args):
201
+ _need_ffmpeg()
202
+ import socket
203
+ import threading
204
+ import webbrowser
205
+
206
+ import uvicorn
207
+
208
+ from .web.app import LOCAL_HOSTS, create_app
209
+
210
+ _open(args).close()
211
+ url = f"http://{f'[{args.host}]' if ':' in args.host else args.host}:{args.port}"
212
+ app = create_app(args.home, host=args.host)
213
+ if args.host not in LOCAL_HOSTS:
214
+ print(f"warning: listening on {args.host}, so anyone who can reach this machine can browse "
215
+ "your index and write exports to any folder.", file=sys.stderr)
216
+ # Checked before the browser opens, or it would open on whatever already holds the port.
217
+ try:
218
+ with socket.create_server((args.host, args.port)):
219
+ pass
220
+ except OSError:
221
+ sys.exit(f"breakdig: port {args.port} is in use. Is breakdig ui already running? "
222
+ "If not, pick another with --port.")
223
+ if not args.no_browser:
224
+ threading.Timer(1.0, webbrowser.open, args=(url,)).start()
225
+ print(f"breakdig UI on {url} (Ctrl+C to stop)")
226
+ # The default proactor loop on Windows prints a traceback each time the browser drops a
227
+ # preview request, which it does on every play.
228
+ loop = "asyncio:SelectorEventLoop" if sys.platform == "win32" else "auto"
229
+ uvicorn.run(app, host=args.host, port=args.port, log_level="warning", loop=loop)
230
+
231
+
232
+ def _positive(text: str) -> int:
233
+ try:
234
+ n = int(text)
235
+ except ValueError:
236
+ raise argparse.ArgumentTypeError(f"expected a whole number, got {text!r}") from None
237
+ if n < 1:
238
+ raise argparse.ArgumentTypeError(f"must be 1 or more, got {n}")
239
+ return n
240
+
241
+
242
+ def _port(text: str) -> int:
243
+ n = _positive(text)
244
+ if n > 65535:
245
+ raise argparse.ArgumentTypeError(f"ports go up to 65535, got {n}")
246
+ return n
247
+
248
+
249
+ def _path(text: str) -> str:
250
+ # cmd.exe reads the \" in "D:\My Music\" as a literal quote, and then the rest of the line
251
+ # can run on into the same argument.
252
+ if '"' in text.rstrip('"'):
253
+ raise argparse.ArgumentTypeError(
254
+ f"{text!r} has a quote in it. Leave out the backslash before a closing quote, "
255
+ r'as in "D:\My Music"')
256
+ return text.rstrip('"')
257
+
258
+
259
+ def _filters(p):
260
+ g = p.add_argument_group("what to look for")
261
+ g.add_argument("--only", metavar="STEMS",
262
+ help="stems that play alone, e.g. drums, vocals, bass,drums (default: drums)")
263
+ g.add_argument("--no", metavar="STEMS", help="stems that must be silent, e.g. drums")
264
+ g.add_argument("--min-bars", type=_positive, default=2, help="shortest section in bars (default 2)")
265
+ g.add_argument("--silence-db", type=float, default=SILENT_DB, metavar="DB",
266
+ help=f"how far below the rest a stem must sit to count as silent (default {SILENT_DB:g}); "
267
+ "20-25 finds more at the cost of some bleed")
268
+ g.add_argument("--cleaner-than", type=float, metavar="DB",
269
+ help="only sections whose CLEAN is at or below this, e.g. -25")
270
+ g.add_argument("--bpm", help="tempo or range, e.g. 90 or 85-100")
271
+ g.add_argument("--search", help="only tracks with every word of this in the artist, title, album or path")
272
+ g.add_argument("--skip-seams", action="store_true",
273
+ help="drop sections that cross a chunk seam in a long file")
274
+ g.add_argument("--sort", choices=sorted(SORTS), default="clean", help="default: clean")
275
+ g.add_argument("--limit", type=_positive, help="at most this many sections")
276
+
277
+
278
+ def build_parser() -> argparse.ArgumentParser:
279
+ p = argparse.ArgumentParser(
280
+ prog="breakdig",
281
+ description="Find bar-exact drum breaks, acapellas and other sections in your own music.")
282
+ p.add_argument("--version", action="version", version=f"breakdig {__version__}")
283
+ home_help = f"index folder (default: $BREAKDIG_HOME or {default_home()})"
284
+ p.add_argument("--home", type=_path, default=None, help=home_help)
285
+ # Also accepted after the command. SUPPRESS keeps the subcommand from resetting a --home given before it.
286
+ common = argparse.ArgumentParser(add_help=False)
287
+ common.add_argument("--home", type=_path, default=argparse.SUPPRESS, help=home_help)
288
+ sub = p.add_subparsers(dest="cmd", required=True)
289
+
290
+ s = sub.add_parser("index", parents=[common], help="separate and profile audio files (resumable)")
291
+ s.add_argument("paths", nargs="+", type=_path, help="folders or files")
292
+ s.add_argument("--model", help="audio-separator model file (default htdemucs_ft.yaml)")
293
+ s.add_argument("--shifts", type=int, default=1,
294
+ help="Demucs random shifts; 2 is slightly cleaner and twice as slow (default 1)")
295
+ s.add_argument("--retry-failed", action="store_true", help="try previously failed files again")
296
+ s.add_argument("--exclude", action="append", default=[], metavar="GLOB",
297
+ help='skip files and folders whose name matches, e.g. "*.removed.*" (repeatable)')
298
+ s.set_defaults(func=cmd_index)
299
+
300
+ s = sub.add_parser("find", parents=[common], help="list matching sections")
301
+ _filters(s)
302
+ s.add_argument("--pick", nargs="+", metavar="ID", help="show these section ids instead of searching")
303
+ s.add_argument("--json", action="store_true", help="print JSON instead of a table")
304
+ s.set_defaults(func=cmd_find)
305
+
306
+ s = sub.add_parser("export", parents=[common], help="cut matching sections from the original files")
307
+ _filters(s)
308
+ s.add_argument("--pick", nargs="+", metavar="ID", help="export these section ids from find, e.g. 12:5-8")
309
+ s.add_argument("--out", required=True, type=_path, help="output folder")
310
+ s.add_argument("--sp404", nargs="?", const="mk2", choices=["mk2", "sx"], metavar="MODEL",
311
+ help="16-bit, BRK_0001.WAV names and an index.csv; 48 kHz for the MKII, "
312
+ "or --sp404 sx for 44.1 kHz on the SX, A and original 404")
313
+ s.add_argument("--keep", metavar="STEMS",
314
+ help="only these stems, separated again from each section, e.g. drums or drums,bass; "
315
+ "seconds per section on a GPU, far longer on CPU")
316
+ s.set_defaults(func=cmd_export)
317
+
318
+ s = sub.add_parser("stats", parents=[common], help="what is in the index")
319
+ s.add_argument("--failures", action="store_true", help="list files that were not indexed and why")
320
+ s.set_defaults(func=cmd_stats)
321
+
322
+ s = sub.add_parser("ui", parents=[common], help="open the web UI")
323
+ s.add_argument("--host", default="127.0.0.1",
324
+ help="address to listen on; anything but this machine lets others on the network in")
325
+ s.add_argument("--port", type=_port, default=8765, help="default 8765")
326
+ s.add_argument("--no-browser", action="store_true", help="start the server without opening a browser tab")
327
+ s.set_defaults(func=cmd_ui)
328
+ return p
329
+
330
+
331
+ def main(argv=None):
332
+ for stream in (sys.stdout, sys.stderr):
333
+ if hasattr(stream, "reconfigure"):
334
+ stream.reconfigure(errors="replace")
335
+ args = build_parser().parse_args(argv)
336
+ try:
337
+ args.func(args)
338
+ except (ValueError, OSError, sqlite3.Error) as e:
339
+ sys.exit(f"breakdig: {e}")
340
+
341
+
342
+ if __name__ == "__main__":
343
+ main()