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.
- sidecaramel/__init__.py +183 -0
- sidecaramel/__main__.py +464 -0
- sidecaramel/_internal.py +39 -0
- sidecaramel/_library_helpers.py +263 -0
- sidecaramel/art.py +371 -0
- sidecaramel/audio_render.py +802 -0
- sidecaramel/blobs.py +1074 -0
- sidecaramel/check.py +244 -0
- sidecaramel/confirm_gate.py +23 -0
- sidecaramel/connector.py +380 -0
- sidecaramel/consolidate.py +555 -0
- sidecaramel/cuss_flip.py +437 -0
- sidecaramel/data/__init__.py +7 -0
- sidecaramel/data/cusswords.json +37 -0
- sidecaramel/db.py +1842 -0
- sidecaramel/diff.py +240 -0
- sidecaramel/flip_render.py +665 -0
- sidecaramel/flip_writer.py +494 -0
- sidecaramel/gui.py +674 -0
- sidecaramel/gui_plugins.py +73 -0
- sidecaramel/inspect.py +35 -0
- sidecaramel/loops_extract.py +131 -0
- sidecaramel/lrc_cusswords.py +382 -0
- sidecaramel/lyrics.py +544 -0
- sidecaramel/overview.py +740 -0
- sidecaramel/overview_encode.py +429 -0
- sidecaramel/overview_palette.py +509 -0
- sidecaramel/paths.py +36 -0
- sidecaramel/roundtrip_safety.py +632 -0
- sidecaramel/session.py +314 -0
- sidecaramel/stems.py +849 -0
- sidecaramel/stems_encode.py +178 -0
- sidecaramel/tags.py +3548 -0
- sidecaramel/tests/__init__.py +0 -0
- sidecaramel/tests/conftest.py +123 -0
- sidecaramel/tests/test_connector.py +138 -0
- sidecaramel/tests/test_crate_roundtrip.py +74 -0
- sidecaramel/tests/test_crate_volume_paths.py +66 -0
- sidecaramel/tests/test_cross_container.py +136 -0
- sidecaramel/tests/test_cue_loop_roundtrip.py +44 -0
- sidecaramel/tests/test_cuss_flip_slot_detection.py +85 -0
- sidecaramel/tests/test_database_v2_roundtrip.py +197 -0
- sidecaramel/tests/test_extension_points.py +79 -0
- sidecaramel/tests/test_flip_roundtrip.py +76 -0
- sidecaramel/tests/test_import_all_submodules.py +83 -0
- sidecaramel/tests/test_lyrics_art_roundtrip.py +55 -0
- sidecaramel/tests/test_markers2_loop_format.py +111 -0
- sidecaramel/tests/test_markers2_preservation.py +228 -0
- sidecaramel/tests/test_markers2_robustness.py +99 -0
- sidecaramel/tests/test_overview_roundtrip.py +379 -0
- sidecaramel/tests/test_roundtrip_safety_serato.py +55 -0
- sidecaramel/tests/test_serato_audio_roundtrip.py +271 -0
- sidecaramel/tests/test_serato_crate_roundtrip.py +51 -0
- sidecaramel/tests/test_serato_golden.py +325 -0
- sidecaramel/tests/test_stems_chunk_offset.py +257 -0
- sidecaramel/tests/test_stems_encode.py +100 -0
- sidecaramel/tests/test_sylt_lrc_roundtrip.py +105 -0
- sidecaramel/verify.py +211 -0
- sidecaramel/window_api.py +429 -0
- sidecaramel-0.1.0.dist-info/METADATA +421 -0
- sidecaramel-0.1.0.dist-info/RECORD +65 -0
- sidecaramel-0.1.0.dist-info/WHEEL +5 -0
- sidecaramel-0.1.0.dist-info/entry_points.txt +4 -0
- sidecaramel-0.1.0.dist-info/licenses/LICENSE +358 -0
- sidecaramel-0.1.0.dist-info/top_level.txt +1 -0
sidecaramel/__init__.py
ADDED
|
@@ -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
|
+
)
|
sidecaramel/__main__.py
ADDED
|
@@ -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())
|
sidecaramel/_internal.py
ADDED
|
@@ -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
|
+
|