sidecaramel 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.
Files changed (65) hide show
  1. sidecaramel/__init__.py +183 -0
  2. sidecaramel/__main__.py +464 -0
  3. sidecaramel/_internal.py +39 -0
  4. sidecaramel/_library_helpers.py +263 -0
  5. sidecaramel/art.py +371 -0
  6. sidecaramel/audio_render.py +802 -0
  7. sidecaramel/blobs.py +1074 -0
  8. sidecaramel/check.py +244 -0
  9. sidecaramel/confirm_gate.py +23 -0
  10. sidecaramel/connector.py +380 -0
  11. sidecaramel/consolidate.py +555 -0
  12. sidecaramel/cuss_flip.py +437 -0
  13. sidecaramel/data/__init__.py +7 -0
  14. sidecaramel/data/cusswords.json +37 -0
  15. sidecaramel/db.py +1842 -0
  16. sidecaramel/diff.py +240 -0
  17. sidecaramel/flip_render.py +665 -0
  18. sidecaramel/flip_writer.py +494 -0
  19. sidecaramel/gui.py +674 -0
  20. sidecaramel/gui_plugins.py +73 -0
  21. sidecaramel/inspect.py +35 -0
  22. sidecaramel/loops_extract.py +131 -0
  23. sidecaramel/lrc_cusswords.py +382 -0
  24. sidecaramel/lyrics.py +544 -0
  25. sidecaramel/overview.py +740 -0
  26. sidecaramel/overview_encode.py +429 -0
  27. sidecaramel/overview_palette.py +509 -0
  28. sidecaramel/paths.py +36 -0
  29. sidecaramel/roundtrip_safety.py +632 -0
  30. sidecaramel/session.py +314 -0
  31. sidecaramel/stems.py +849 -0
  32. sidecaramel/stems_encode.py +178 -0
  33. sidecaramel/tags.py +3548 -0
  34. sidecaramel/tests/__init__.py +0 -0
  35. sidecaramel/tests/conftest.py +123 -0
  36. sidecaramel/tests/test_connector.py +138 -0
  37. sidecaramel/tests/test_crate_roundtrip.py +74 -0
  38. sidecaramel/tests/test_crate_volume_paths.py +66 -0
  39. sidecaramel/tests/test_cross_container.py +136 -0
  40. sidecaramel/tests/test_cue_loop_roundtrip.py +44 -0
  41. sidecaramel/tests/test_cuss_flip_slot_detection.py +85 -0
  42. sidecaramel/tests/test_database_v2_roundtrip.py +197 -0
  43. sidecaramel/tests/test_extension_points.py +79 -0
  44. sidecaramel/tests/test_flip_roundtrip.py +76 -0
  45. sidecaramel/tests/test_import_all_submodules.py +83 -0
  46. sidecaramel/tests/test_lyrics_art_roundtrip.py +55 -0
  47. sidecaramel/tests/test_markers2_loop_format.py +111 -0
  48. sidecaramel/tests/test_markers2_preservation.py +228 -0
  49. sidecaramel/tests/test_markers2_robustness.py +99 -0
  50. sidecaramel/tests/test_overview_roundtrip.py +379 -0
  51. sidecaramel/tests/test_roundtrip_safety_serato.py +55 -0
  52. sidecaramel/tests/test_serato_audio_roundtrip.py +271 -0
  53. sidecaramel/tests/test_serato_crate_roundtrip.py +51 -0
  54. sidecaramel/tests/test_serato_golden.py +325 -0
  55. sidecaramel/tests/test_stems_chunk_offset.py +257 -0
  56. sidecaramel/tests/test_stems_encode.py +100 -0
  57. sidecaramel/tests/test_sylt_lrc_roundtrip.py +105 -0
  58. sidecaramel/verify.py +211 -0
  59. sidecaramel/window_api.py +429 -0
  60. sidecaramel-0.1.0.dist-info/METADATA +421 -0
  61. sidecaramel-0.1.0.dist-info/RECORD +65 -0
  62. sidecaramel-0.1.0.dist-info/WHEEL +5 -0
  63. sidecaramel-0.1.0.dist-info/entry_points.txt +4 -0
  64. sidecaramel-0.1.0.dist-info/licenses/LICENSE +358 -0
  65. sidecaramel-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,183 @@
1
+ """sidecaramel — DJ-audio metadata toolkit.
2
+
3
+ Alpha, not thoroughly tested — use at your own risk.
4
+
5
+ Read + write Serato DJ Pro tag formats, embedded lyrics (timed
6
+ reads + untimed writes), and embedded cover-art across MP3 / WAV /
7
+ AIFF / MP4 / M4A / M4V / FLAC / OGG.
8
+
9
+ Architectural layering:
10
+ container-agnostic top ← ``*_any()`` dispatchers, take a
11
+ file path, route by extension.
12
+ format-correct bottom ← per-format encoders/parsers,
13
+ roundtrip-validated against the
14
+ maintainer's own Serato library
15
+ (see README "Known limitations" —
16
+ this is author-claim, not yet
17
+ third-party-corroborated).
18
+
19
+ Public sub-modules:
20
+
21
+ tags — Serato Markers2 + Markers_ V1 + BeatGrid + Autotags
22
+ + Analysis encoders + writers + parsers +
23
+ dispatchers.
24
+ blobs — Decoder + harvest() (one call returns every Serato
25
+ blob on a file regardless of container). Renamed
26
+ from ``inspect`` in 0.1.0 to avoid shadowing the
27
+ stdlib ``inspect`` module; an ``inspect`` alias
28
+ still imports but emits DeprecationWarning.
29
+ flip_writer — Serato FLIP / JUMP / CENSOR action authoring.
30
+ lyrics — Embedded lyrics: USLT / SYLT / ©lyr / Vorbis
31
+ LYRICS read. Untimed write (``write_lyrics``,
32
+ container-aware). Timed write: ``write_sylt``
33
+ (ID3 SYLT — MP3/WAV/AIFF) and the container-agnostic
34
+ ``.lrc`` sidecar (``read_lrc_sidecar`` /
35
+ ``write_lrc_sidecar``).
36
+ art — Embedded cover-art: APIC / covr / FLAC Picture /
37
+ Vorbis METADATA_BLOCK_PICTURE read + write.
38
+ db — Serato ``database V2`` reader and writer; ``.crate``
39
+ reader and writer.
40
+ session — History ``*.session`` file reader.
41
+ paths — Path-resolution helpers using Serato DBs / History.
42
+ check — Runtime "is Serato running?" guard. macOS via
43
+ ``pgrep``; Windows via ``tasklist``; other hosts
44
+ raise ``SeratoCheckUnavailableError``.
45
+
46
+ CLI: ``python -m sidecaramel <subcommand>`` or ``sidecaramel <sub>``.
47
+ Subcommands: read, cues, loops, bpm, lyrics, art, blobs (alias:
48
+ inspect), overview, overview-encode, overview-roundtrip.
49
+
50
+ License: GPL-2.0-or-later (matches mutagen, the sole runtime
51
+ dependency). Format support is reverse-engineered.
52
+ Not affiliated with, endorsed by, or sponsored by Serato Limited.
53
+ """
54
+
55
+ __version__ = "0.1.0"
56
+
57
+
58
+ # Curated public API — common entry points. Sub-modules are
59
+ # importable directly for the full surface.
60
+ from sidecaramel.tags import ( # noqa: F401
61
+ # High-level reads
62
+ read_serato_metadata,
63
+ read_loops,
64
+ read_track_color,
65
+ read_bpm_lock,
66
+ read_serato_playcount,
67
+ harvest_serato_blobs,
68
+ # Container-aware writers (all confirm-gated)
69
+ write_serato_markers2_any,
70
+ write_serato_beatgrid_any,
71
+ write_serato_autotags_any,
72
+ write_serato_analysis_any,
73
+ write_serato_vorbis_blob,
74
+ write_serato_markers_full_mp3,
75
+ write_serato_markers_full_mp4,
76
+ write_serato_markers_v1_mp3,
77
+ write_serato_markers_v1_mp4,
78
+ write_serato_markers2,
79
+ write_serato_markers2_mp4,
80
+ write_serato_geob,
81
+ # Encoders (pure)
82
+ build_markers2_inner,
83
+ build_beatgrid,
84
+ build_autotags,
85
+ build_analysis_blob,
86
+ encode_cue_entry,
87
+ encode_loop_entry,
88
+ encode_color_entry,
89
+ encode_bpmlock_entry,
90
+ encode_flip_entry,
91
+ encode_markers_v1_inner,
92
+ encode_markers_v1_mp3_inner,
93
+ pack_serato_markers2,
94
+ # Parsers
95
+ parse_serato_markers2_full,
96
+ parse_serato_markers_v1_full,
97
+ parse_serato_autotags,
98
+ # Constants
99
+ SERATO_ANALYSIS_DEFAULT_BLOB,
100
+ SERATO_CUE_COLOR_PALETTE,
101
+ )
102
+
103
+ __all__ = [
104
+ "read_serato_metadata",
105
+ "read_loops",
106
+ "read_track_color",
107
+ "read_bpm_lock",
108
+ "read_serato_playcount",
109
+ "harvest_serato_blobs",
110
+ "write_serato_markers2_any",
111
+ "write_serato_beatgrid_any",
112
+ "write_serato_autotags_any",
113
+ "write_serato_analysis_any",
114
+ "write_serato_vorbis_blob",
115
+ "write_serato_markers_full_mp3",
116
+ "write_serato_markers_full_mp4",
117
+ "write_serato_markers_v1_mp3",
118
+ "write_serato_markers_v1_mp4",
119
+ "write_serato_markers2",
120
+ "write_serato_markers2_mp4",
121
+ "write_serato_geob",
122
+ "build_markers2_inner",
123
+ "build_beatgrid",
124
+ "build_autotags",
125
+ "build_analysis_blob",
126
+ "encode_cue_entry",
127
+ "encode_loop_entry",
128
+ "encode_color_entry",
129
+ "encode_bpmlock_entry",
130
+ "encode_flip_entry",
131
+ "encode_markers_v1_inner",
132
+ "encode_markers_v1_mp3_inner",
133
+ "pack_serato_markers2",
134
+ "parse_serato_markers2_full",
135
+ "parse_serato_markers_v1_full",
136
+ "parse_serato_autotags",
137
+ "SERATO_ANALYSIS_DEFAULT_BLOB",
138
+ "SERATO_CUE_COLOR_PALETTE",
139
+ # Lyrics
140
+ "read_embedded_lyrics",
141
+ "read_embedded_lyrics_with_format",
142
+ "read_id3_lyrics",
143
+ "read_synced_lyrics",
144
+ "read_synced_lyrics_as_lrc",
145
+ "is_lrc_text",
146
+ "write_uslt",
147
+ "write_sylt",
148
+ "lrc_sidecar_path",
149
+ "read_lrc_sidecar",
150
+ "write_lrc_sidecar",
151
+ # Cover-art
152
+ "read_cover_art",
153
+ "read_all_cover_art",
154
+ "write_cover_art",
155
+ "clear_cover_art",
156
+ ]
157
+
158
+
159
+ # Lyrics + art re-exports for the top-level public API.
160
+ from sidecaramel.lyrics import ( # noqa: F401
161
+ read_embedded_lyrics,
162
+ read_embedded_lyrics_with_format,
163
+ read_id3_lyrics,
164
+ read_synced_lyrics,
165
+ read_synced_lyrics_as_lrc,
166
+ is_lrc_text,
167
+ write_uslt,
168
+ write_sylt,
169
+ lrc_sidecar_path,
170
+ read_lrc_sidecar,
171
+ write_lrc_sidecar,
172
+ )
173
+ from sidecaramel.art import ( # noqa: F401
174
+ read_cover_art,
175
+ read_all_cover_art,
176
+ write_cover_art,
177
+ clear_cover_art,
178
+ )
179
+ from sidecaramel.overview import ( # noqa: F401
180
+ render_overview,
181
+ render_overview_for_path,
182
+ grayscale_palette,
183
+ )
@@ -0,0 +1,464 @@
1
+ """sidecaramel CLI entry point.
2
+
3
+ Run via:
4
+ python -m sidecaramel <subcommand> [args]
5
+ or (after pip install):
6
+ sidecaramel <subcommand> [args]
7
+
8
+ Subcommands:
9
+ read Dump everything Serato + lyrics + art knows about a file
10
+ as JSON.
11
+ cues List cue points (idx / pos_ms / color / label).
12
+ loops List loops.
13
+ bpm Print BPM + beatgrid-terminal-position.
14
+ lyrics Print embedded lyrics (auto-detect timed vs untimed).
15
+ art Extract embedded cover-art to a file (or print info).
16
+ inspect Hex-preview every Serato blob on the file (debug).
17
+
18
+ Examples:
19
+ sidecaramel read track.mp3 --json
20
+ sidecaramel cues track.m4a
21
+ sidecaramel lyrics track.flac
22
+ sidecaramel art track.mp3 --out cover.jpg
23
+ sidecaramel bpm track.wav
24
+ """
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import json
29
+ import os
30
+ import sys
31
+
32
+
33
+ def _cmd_read(args) -> int:
34
+ import sidecaramel
35
+ path = args.path
36
+ meta = sidecaramel.read_serato_metadata(path) or {}
37
+ out: dict = {
38
+ "path": path,
39
+ "bpm": meta.get("bpm"),
40
+ "first_cue_sec": meta.get("first_cue"),
41
+ "cues": meta.get("cues", []),
42
+ "loops": meta.get("loops", []),
43
+ "color": meta.get("color"),
44
+ "bpm_lock": meta.get("bpm_lock"),
45
+ }
46
+ # Lyrics
47
+ from sidecaramel.lyrics import read_embedded_lyrics_with_format
48
+ lyr = read_embedded_lyrics_with_format(path)
49
+ out["lyrics"] = {"present": lyr is not None,
50
+ "format": lyr[1] if lyr else None,
51
+ "char_count": len(lyr[0]) if lyr else 0}
52
+ # Art
53
+ from sidecaramel.art import read_cover_art
54
+ art = read_cover_art(path)
55
+ out["cover_art"] = {"present": art is not None,
56
+ "mime": art[0] if art else None,
57
+ "byte_count": len(art[1]) if art else 0}
58
+ if args.json:
59
+ # Cues/loops contain RGB tuples — convert for JSON
60
+ def _serialize(o):
61
+ if isinstance(o, tuple):
62
+ return list(o)
63
+ return str(o)
64
+ print(json.dumps(out, indent=2, default=_serialize))
65
+ else:
66
+ print(f"path: {path}")
67
+ print(f"bpm: {out['bpm']}")
68
+ print(f"cues: {len(out['cues'])}")
69
+ print(f"loops: {len(out['loops'])}")
70
+ print(f"color: {out['color']}")
71
+ print(f"bpm_lock: {out['bpm_lock']}")
72
+ print(f"lyrics: "
73
+ f"{'yes (' + out['lyrics']['format'] + ', ' + str(out['lyrics']['char_count']) + ' chars)' if out['lyrics']['present'] else 'no'}")
74
+ print(f"cover_art: "
75
+ f"{'yes (' + out['cover_art']['mime'] + ', ' + str(out['cover_art']['byte_count']) + ' B)' if out['cover_art']['present'] else 'no'}")
76
+ return 0
77
+
78
+
79
+ def _cmd_cues(args) -> int:
80
+ import sidecaramel
81
+ meta = sidecaramel.read_serato_metadata(args.path) or {}
82
+ cues = meta.get("cues", [])
83
+ if not cues:
84
+ print(f"no cues in {args.path}", file=sys.stderr)
85
+ return 1
86
+ for c in cues:
87
+ ms = c["pos_ms"]
88
+ sec = ms / 1000.0
89
+ rgb = c["color"]
90
+ label = c.get("label") or ""
91
+ print(f" cue[{c['idx']}] {sec:>7.3f}s ({ms:>7} ms) "
92
+ f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x} {label}")
93
+ return 0
94
+
95
+
96
+ def _cmd_loops(args) -> int:
97
+ import sidecaramel
98
+ meta = sidecaramel.read_serato_metadata(args.path) or {}
99
+ loops = meta.get("loops", [])
100
+ if not loops:
101
+ print(f"no loops in {args.path}", file=sys.stderr)
102
+ return 1
103
+ for l in loops:
104
+ s = l["start_ms"] / 1000.0
105
+ e = l["end_ms"] / 1000.0
106
+ rgb = l["color"]
107
+ locked = "🔒" if l.get("locked") else ""
108
+ label = l.get("label") or ""
109
+ print(f" loop[{l['idx']}] {s:>7.3f} → {e:>7.3f}s "
110
+ f"#{rgb[0]:02x}{rgb[1]:02x}{rgb[2]:02x} {locked} {label}")
111
+ return 0
112
+
113
+
114
+ def _cmd_bpm(args) -> int:
115
+ import sidecaramel
116
+ meta = sidecaramel.read_serato_metadata(args.path) or {}
117
+ bpm = meta.get("bpm")
118
+ if bpm is None:
119
+ print(f"no bpm in {args.path}", file=sys.stderr)
120
+ return 1
121
+ print(f"{bpm:.4f}")
122
+ return 0
123
+
124
+
125
+ def _cmd_lyrics(args) -> int:
126
+ from sidecaramel.lyrics import (read_embedded_lyrics,
127
+ read_synced_lyrics_as_lrc)
128
+ if args.lrc:
129
+ text = read_synced_lyrics_as_lrc(args.path)
130
+ if text is None:
131
+ print(f"no synced lyrics in {args.path}", file=sys.stderr)
132
+ return 1
133
+ else:
134
+ text = read_embedded_lyrics(args.path)
135
+ if text is None:
136
+ print(f"no lyrics in {args.path}", file=sys.stderr)
137
+ return 1
138
+ print(text)
139
+ return 0
140
+
141
+
142
+ def _cmd_art(args) -> int:
143
+ from sidecaramel.art import read_cover_art
144
+ art = read_cover_art(args.path)
145
+ if art is None:
146
+ print(f"no cover-art in {args.path}", file=sys.stderr)
147
+ return 1
148
+ mime, data = art
149
+ if args.out:
150
+ with open(args.out, "wb") as f:
151
+ f.write(data)
152
+ print(f"wrote {len(data)} B {mime} → {args.out}")
153
+ else:
154
+ print(f"mime: {mime}")
155
+ print(f"bytes: {len(data)}")
156
+ return 0
157
+
158
+
159
+ def _cmd_inspect(args) -> int:
160
+ from sidecaramel.blobs import harvest
161
+ blobs = harvest(args.path)
162
+ if not blobs:
163
+ print(f"no Serato blobs in {args.path}", file=sys.stderr)
164
+ return 1
165
+ for desc, payload, src in blobs:
166
+ preview = (payload[:48].hex(" ") if payload else "(empty)")
167
+ if len(payload) > 48:
168
+ preview += " ..."
169
+ print(f" {desc:<26} {len(payload):>6} B {preview}")
170
+ return 0
171
+
172
+
173
+ def _cmd_overview(args) -> int:
174
+ from sidecaramel.overview import (render_overview_for_path,
175
+ compare_against_reference)
176
+ from sidecaramel.blobs import harvest
177
+
178
+ if args.compare:
179
+ # Comparison mode: render via mode + diff against reference
180
+ blob = None
181
+ for desc, payload, _ in harvest(args.path):
182
+ if desc == "Serato Overview" and payload:
183
+ blob = payload
184
+ break
185
+ if blob is None:
186
+ print(f"no Serato Overview blob in {args.path}",
187
+ file=sys.stderr)
188
+ return 1
189
+ result = compare_against_reference(
190
+ blob, args.compare, mode=args.mode)
191
+ if result is None:
192
+ print("comparison failed (Pillow missing or blob bad)",
193
+ file=sys.stderr)
194
+ return 1
195
+ print(f"mode: {result['mode']}")
196
+ print(f"mean RGB error: {result['mean_rgb_error']:.2f} "
197
+ f"(0=perfect, 255=opposite)")
198
+ print()
199
+ print("=== by column-spread band ===")
200
+ for band, st in result["by_spread_band"].items():
201
+ print(f" {band:<20} n={st['n']:>5} "
202
+ f"err={st['mean_rgb_error']:.2f}")
203
+ print()
204
+ print("=== by byte zone ===")
205
+ for zone, st in result["by_byte_zone"].items():
206
+ print(f" {zone:<15} n={st['n']:>5} "
207
+ f"err={st['mean_rgb_error']:.2f}")
208
+ print()
209
+ print(f"rendered: {result['rendered_image_path']}")
210
+ print(f"diff heatmap: {result['diff_image_path']}")
211
+ return 0
212
+
213
+ # Render mode (no comparison)
214
+ if not args.out:
215
+ print("--out required for render", file=sys.stderr)
216
+ return 2
217
+ ok = render_overview_for_path(args.path, args.out,
218
+ mode=args.mode, scale=args.scale)
219
+ if not ok:
220
+ print(f"render failed (no Overview blob or PIL missing)",
221
+ file=sys.stderr)
222
+ return 1
223
+ print(f"wrote {args.mode} BMP → {args.out}")
224
+ return 0
225
+
226
+
227
+ def _cmd_overview_encode(args) -> int:
228
+ """Encode an audio file → 3842-byte Serato Overview blob."""
229
+ from sidecaramel.overview_encode import build_overview_blob_for_path
230
+ blob = build_overview_blob_for_path(args.path)
231
+ if len(blob) != 3842:
232
+ print(f"unexpected blob size: {len(blob)}", file=sys.stderr)
233
+ return 1
234
+ with open(args.out, "wb") as f:
235
+ f.write(blob)
236
+ print(f"wrote {len(blob)}-byte Overview blob → {args.out}")
237
+ return 0
238
+
239
+
240
+ def _cmd_overview_roundtrip(args) -> int:
241
+ """Encode audio + compare against in-file Serato blob. Optional
242
+ PNG diff panel. Never writes to the audio file (audio-safety policy safe)."""
243
+ from sidecaramel.overview_encode import (
244
+ build_overview_blob_for_path, diff_blobs)
245
+ from sidecaramel.tags import harvest_serato_blobs
246
+
247
+ # Find in-file Serato Overview blob
248
+ ref = None
249
+ for desc, payload, _src in harvest_serato_blobs(args.path):
250
+ if desc == "Serato Overview":
251
+ if hasattr(payload, "data"):
252
+ ref = bytes(payload.data)
253
+ else:
254
+ ref = bytes(payload)
255
+ break
256
+
257
+ ours = build_overview_blob_for_path(args.path)
258
+ print(f"OURS: {len(ours)} B "
259
+ f"first8={ours[:8].hex()}")
260
+
261
+ if ref is None:
262
+ print("REF: no in-file Serato Overview blob to compare "
263
+ "against (file isn't Serato-analysed yet). Encode "
264
+ "succeeded; can't diff.")
265
+ return 0
266
+
267
+ print(f"REF: {len(ref)} B first8={ref[:8].hex()}")
268
+ report = diff_blobs(ours, ref)
269
+ if report.get("bytes_equal"):
270
+ print("→ BYTE-EQUAL ✓")
271
+ else:
272
+ print(f"→ {report['n_diff']}/{report['total_bytes']} bytes "
273
+ f"differ ({report['diff_pct']}%), "
274
+ f"{report['n_cols_with_diff']}/240 cols affected")
275
+ print(f" max col diff: {report['max_col_diff']}/16, "
276
+ f"mean: {report['mean_col_diff']}")
277
+ print(" top mismatches (ours → ref):")
278
+ for m in report["top_mismatches"][:5]:
279
+ print(f" {m['ours']:3d} → {m['ref']:3d} ×{m['count']}")
280
+
281
+ if args.render_diff:
282
+ try:
283
+ from PIL import Image, ImageDraw, ImageFont
284
+ from sidecaramel.overview import render_overview
285
+ except ImportError:
286
+ print("Pillow missing — skipping --render-diff",
287
+ file=sys.stderr)
288
+ return 0
289
+ import tempfile
290
+ # Vertical block-wise render: time runs top→bottom, each
291
+ # 16-byte chunk = one horizontal row. Strip 48 wide × 720
292
+ # tall (scale 3 from 16×240 native).
293
+ STRIP_W, STRIP_H = 48, 720
294
+ def _render(blob):
295
+ tmp = tempfile.NamedTemporaryFile(
296
+ suffix=".bmp", delete=False).name
297
+ render_overview(blob, tmp, mode="rgb332", scale=1)
298
+ img = (Image.open(tmp).convert("RGB")
299
+ .transpose(Image.TRANSPOSE)
300
+ .resize((STRIP_W, STRIP_H), Image.NEAREST))
301
+ os.remove(tmp)
302
+ return img
303
+ ref_img = _render(ref)
304
+ ours_img = _render(ours)
305
+
306
+ try:
307
+ font = ImageFont.truetype(
308
+ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 12)
309
+ except Exception:
310
+ font = ImageFont.load_default()
311
+
312
+ PAD = 12
313
+ INNER_GAP = 8
314
+ TITLE_H = 36
315
+ SUB_LABEL = 22
316
+ panel_w = STRIP_W * 2 + INNER_GAP + PAD * 2
317
+ panel_h = TITLE_H + STRIP_H + SUB_LABEL + PAD
318
+ panel = Image.new("RGB", (panel_w, panel_h), (20, 20, 20))
319
+ d = ImageDraw.Draw(panel)
320
+ d.text((PAD, 8),
321
+ f"{os.path.basename(args.path)} · vertical (block-wise)",
322
+ fill=(255, 255, 255), font=font)
323
+ panel.paste(ref_img, (PAD, TITLE_H))
324
+ panel.paste(ours_img,
325
+ (PAD + STRIP_W + INNER_GAP, TITLE_H))
326
+ sub_y = TITLE_H + STRIP_H + 4
327
+ d.text((PAD + (STRIP_W - 24) // 2, sub_y), "REF",
328
+ fill=(180, 220, 180), font=font)
329
+ d.text((PAD + STRIP_W + INNER_GAP + (STRIP_W - 32) // 2,
330
+ sub_y), "OURS",
331
+ fill=(180, 180, 255), font=font)
332
+ panel.save(args.render_diff)
333
+ print(f" side-by-side panel → {args.render_diff}")
334
+ return 0
335
+
336
+
337
+ def build_parser() -> argparse.ArgumentParser:
338
+ p = argparse.ArgumentParser(
339
+ prog="sidecaramel",
340
+ description="Read + write Serato DJ Pro tag formats and "
341
+ "embedded lyrics / cover-art across MP3, WAV, AIFF, "
342
+ "MP4, M4A, M4V, FLAC, OGG.",
343
+ formatter_class=argparse.RawDescriptionHelpFormatter,
344
+ epilog=(
345
+ "Examples:\n"
346
+ " sidecaramel read track.mp3 --json\n"
347
+ " sidecaramel cues track.m4a\n"
348
+ " sidecaramel loops track.flac\n"
349
+ " sidecaramel bpm track.wav\n"
350
+ " sidecaramel lyrics track.mp3\n"
351
+ " sidecaramel lyrics track.mp3 --lrc\n"
352
+ " sidecaramel art track.flac --out cover.jpg\n"
353
+ " sidecaramel inspect track.m4a\n"
354
+ ),
355
+ )
356
+ sub = p.add_subparsers(dest="cmd", required=True)
357
+
358
+ sp = sub.add_parser("read", help="Dump full metadata (Serato + "
359
+ "lyrics + art) for a file.")
360
+ sp.add_argument("path")
361
+ sp.add_argument("--json", action="store_true",
362
+ help="Print as machine-readable JSON.")
363
+ sp.set_defaults(func=_cmd_read)
364
+
365
+ sp = sub.add_parser("cues", help="List cue points.")
366
+ sp.add_argument("path")
367
+ sp.set_defaults(func=_cmd_cues)
368
+
369
+ sp = sub.add_parser("loops", help="List loops.")
370
+ sp.add_argument("path")
371
+ sp.set_defaults(func=_cmd_loops)
372
+
373
+ sp = sub.add_parser("bpm", help="Print Serato BPM.")
374
+ sp.add_argument("path")
375
+ sp.set_defaults(func=_cmd_bpm)
376
+
377
+ sp = sub.add_parser("lyrics", help="Print embedded lyrics.")
378
+ sp.add_argument("path")
379
+ sp.add_argument("--lrc", action="store_true",
380
+ help="Output as LRC (timed) if synced lyrics "
381
+ "available.")
382
+ sp.set_defaults(func=_cmd_lyrics)
383
+
384
+ sp = sub.add_parser("art", help="Extract / inspect embedded cover-art.")
385
+ sp.add_argument("path")
386
+ sp.add_argument("--out", help="Write the image bytes to this path.")
387
+ sp.set_defaults(func=_cmd_art)
388
+
389
+ # Canonical subcommand name from 0.1.0 onwards: `blobs`.
390
+ # `inspect` registered below as a deprecated alias for one minor
391
+ # — same handler.
392
+ sp = sub.add_parser("blobs",
393
+ help="Hex-preview every Serato blob on the file.")
394
+ sp.add_argument("path")
395
+ sp.set_defaults(func=_cmd_inspect)
396
+
397
+ sp = sub.add_parser("inspect",
398
+ help="DEPRECATED — use `blobs` instead. "
399
+ "Same behaviour; alias for one minor.")
400
+ sp.add_argument("path")
401
+ sp.set_defaults(func=_cmd_inspect)
402
+
403
+ sp = sub.add_parser("overview",
404
+ help="Render the Serato Overview waveform as a "
405
+ "BMP, or diff it against a reference image.")
406
+ sp.add_argument("path")
407
+ sp.add_argument("--out",
408
+ help="Output BMP path (required unless --compare).")
409
+ sp.add_argument("--mode",
410
+ choices=("grayscale", "color", "alpha", "hsl",
411
+ "rgb332", "serato_hue", "serato_palette",
412
+ "column_agg", "column_tornado"),
413
+ default="serato_palette",
414
+ help="Render mode (default: serato_palette). "
415
+ "serato_palette = 40-byte hand-tuned LUT "
416
+ "with additive R=bass G=mid B=treble "
417
+ "anchors; serato_hue = interpolated "
418
+ "spectral gradient; rgb332 = structural bit "
419
+ "decode; grayscale = darkness ramp; "
420
+ "color/alpha/hsl = legacy experimental.")
421
+ sp.add_argument("--scale", type=int, default=4,
422
+ help="Nearest-neighbor upscale factor "
423
+ "(default: 4).")
424
+ sp.add_argument("--compare",
425
+ help="Path to a reference image; runs pixel-diff "
426
+ "+ prints per-band/per-zone error stats.")
427
+ sp.set_defaults(func=_cmd_overview)
428
+
429
+ # ---- overview-encode: audio → blob.bin ------------------------
430
+ sp = sub.add_parser(
431
+ "overview-encode",
432
+ help="Encode an audio file to a 3842-byte Serato Overview "
433
+ "blob (pixel-similar v1 encoder).")
434
+ sp.add_argument("path")
435
+ sp.add_argument("--out", required=True,
436
+ help="Output .bin path (3842 bytes).")
437
+ sp.set_defaults(func=_cmd_overview_encode)
438
+
439
+ # ---- overview-roundtrip: encode + diff vs Serato's blob -------
440
+ sp = sub.add_parser(
441
+ "overview-roundtrip",
442
+ help="Encode the audio + diff against the Serato-written "
443
+ "Overview blob already in the file. Read-only on the "
444
+ "audio file. read-only.")
445
+ sp.add_argument("path")
446
+ sp.add_argument("--render-diff",
447
+ help="Optional output PNG path; renders REF + "
448
+ "OURS side-by-side as a comparison panel.")
449
+ sp.set_defaults(func=_cmd_overview_roundtrip)
450
+
451
+ return p
452
+
453
+
454
+ def main(argv: list[str] | None = None) -> int:
455
+ parser = build_parser()
456
+ args = parser.parse_args(argv)
457
+ if not os.path.isfile(args.path):
458
+ print(f"file not found: {args.path}", file=sys.stderr)
459
+ return 2
460
+ return args.func(args)
461
+
462
+
463
+ if __name__ == "__main__":
464
+ sys.exit(main())
@@ -0,0 +1,39 @@
1
+ """sidecaramel._internal — shared B5 confirm-gate.
2
+
3
+ Used by Serato writers (sidecaramel.tags) AND lyric writers
4
+ (sidecaramel.tags.write_uslt / write_text_tag). Lives under
5
+ sidecaramel because the policy-0 audio-write policy
6
+ originated in Serato scope.
7
+
8
+ Phase C physical-split 2026-06-07.
9
+ """
10
+
11
+ # =====================================================================
12
+ # WRITERS — Serato GEOB / Markers2 / lyrics tags
13
+ # =====================================================================
14
+ # All Serato + lyric tag-WRITER logic funnels through here. The
15
+ # command-line entry points are thin CLI wrappers over these
16
+ # primitives, never a second implementation.
17
+ #
18
+ # B5 (2026-05-15): every audio-modifying writer requires an
19
+ # explicit `confirm=True` kwarg. Default `confirm=False` raises so
20
+ # no caller can accidentally overwrite user audio. LRC writes have
21
+ # their own gate: `lrc_is_writable()` distinguishes
22
+ # pipeline-generated (overwritable) from plain user-original LRC
23
+ # (preserve). No confirm-gate for cleanup_sidecar_artifacts or
24
+ # write_lrc — those are the INTENDED side effects of the cleanup
25
+ # / pipeline-output conventions.
26
+
27
+ def _require_confirm(confirm: bool, fn_name: str,
28
+ path: str) -> None:
29
+ """Gate every audio-modifying writer. Raises RuntimeError when
30
+ `confirm` is not True."""
31
+ if not confirm:
32
+ raise RuntimeError(
33
+ f"audio-modifying write requires confirm=True; "
34
+ f"function={fn_name} path={path!r}. "
35
+ f"Pass confirm=True to acknowledge that this will modify "
36
+ f"the audio file."
37
+ )
38
+
39
+