serato-tools 1.5.0__tar.gz

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.
@@ -0,0 +1,19 @@
1
+ Copyright 2019 Jan Holthuis
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
4
+ this software and associated documentation files (the "Software"), to deal in
5
+ the Software without restriction, including without limitation the rights to
6
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
7
+ of the Software, and to permit persons to whom the Software is furnished to do
8
+ so, subject to the following conditions:
9
+
10
+ The above copyright notice and this permission notice shall be included in all
11
+ copies or substantial portions of the Software.
12
+
13
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19
+ SOFTWARE.
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: serato_tools
3
+ Version: 1.5.0
4
+ Summary: Serato track metadata tags
5
+ Author-email: bvandercar-vt <bvandercar@outlook.com>
6
+ License: Copyright 2019 Jan Holthuis
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
9
+ this software and associated documentation files (the "Software"), to deal in
10
+ the Software without restriction, including without limitation the rights to
11
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
12
+ of the Software, and to permit persons to whom the Software is furnished to do
13
+ so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: mutagen
29
+ Provides-Extra: waveform-drawing
30
+ Requires-Dist: pillow; extra == "waveform-drawing"
31
+ Dynamic: license-file
32
+
33
+ Fork of https://github.com/Holzhaus/serato-tags , which appears to be no longer maintained.
34
+
35
+ # Installation
36
+
37
+ Can install using pip:
38
+
39
+ `pip install git+https://github.com/bvandercar-vt/serato-tags@main`
40
+
41
+ Or add this line to your `requirements.txt`:
42
+
43
+ `git+https://github.com/bvandercar-vt/serato-tags@main`
44
+
45
+ # Serato Tags
46
+
47
+ This repository aims to document the GEOB ID3 tags that the Serato DJ software uses to store its metadata.
48
+ You can also have a look at [this lengthy blog post](https://homepage.ruhr-uni-bochum.de/jan.holthuis/posts/reversing-seratos-geob-tags) that goes into detail how I reversed the contents of the `Serato Markers2` GEOB tag.
49
+
50
+ | Tag | Progress | Contents | Example script
51
+ | -------------------------------------------- | ------------- | -------------------------- | --------------
52
+ | [`Serato Analysis`](docs/serato_analysis.md) | Done | Serato version information |
53
+ | [`Serato Autotags`](docs/serato_autotags.md) | Done | BPM and Gain values | [`serato_autotags.py`](scripts/serato_autotags.py)
54
+ | [`Serato BeatGrid`](docs/serato_beatgrid.md) | Mostly done | Beatgrid Markers | [`serato_beatgrid.py`](scripts/serato_beatgrid.py)
55
+ | [`Serato Markers2`](docs/serato_markers2.md) | Mostly done | Hotcues, Saved Loops, etc. | [`serato_markers2.py`](scripts/serato_markers2.py)
56
+ | [`Serato Markers_`](docs/serato_markers_.md) | Mostly done | Hotcues, Saved Loops, etc. | [`serato_markers_.py`](scripts/serato_markers_.py)
57
+ | [`Serato Offsets_`](docs/serato_offsets_.md) | *Not started* | |
58
+ | [`Serato Overview`](docs/serato_overview.md) | Done | Waveform data | [`serato_overview.py`](scripts/serato_overview.py)
59
+
60
+ The different file/tag formats that Serato uses to store the information are documented in [`docs/fileformats.md`](docs/fileformats.md), a script to dump the tag data can be found at [`scripts/tagdump.py`](scripts/tagdump.py).
@@ -0,0 +1,28 @@
1
+ Fork of https://github.com/Holzhaus/serato-tags , which appears to be no longer maintained.
2
+
3
+ # Installation
4
+
5
+ Can install using pip:
6
+
7
+ `pip install git+https://github.com/bvandercar-vt/serato-tags@main`
8
+
9
+ Or add this line to your `requirements.txt`:
10
+
11
+ `git+https://github.com/bvandercar-vt/serato-tags@main`
12
+
13
+ # Serato Tags
14
+
15
+ This repository aims to document the GEOB ID3 tags that the Serato DJ software uses to store its metadata.
16
+ You can also have a look at [this lengthy blog post](https://homepage.ruhr-uni-bochum.de/jan.holthuis/posts/reversing-seratos-geob-tags) that goes into detail how I reversed the contents of the `Serato Markers2` GEOB tag.
17
+
18
+ | Tag | Progress | Contents | Example script
19
+ | -------------------------------------------- | ------------- | -------------------------- | --------------
20
+ | [`Serato Analysis`](docs/serato_analysis.md) | Done | Serato version information |
21
+ | [`Serato Autotags`](docs/serato_autotags.md) | Done | BPM and Gain values | [`serato_autotags.py`](scripts/serato_autotags.py)
22
+ | [`Serato BeatGrid`](docs/serato_beatgrid.md) | Mostly done | Beatgrid Markers | [`serato_beatgrid.py`](scripts/serato_beatgrid.py)
23
+ | [`Serato Markers2`](docs/serato_markers2.md) | Mostly done | Hotcues, Saved Loops, etc. | [`serato_markers2.py`](scripts/serato_markers2.py)
24
+ | [`Serato Markers_`](docs/serato_markers_.md) | Mostly done | Hotcues, Saved Loops, etc. | [`serato_markers_.py`](scripts/serato_markers_.py)
25
+ | [`Serato Offsets_`](docs/serato_offsets_.md) | *Not started* | |
26
+ | [`Serato Overview`](docs/serato_overview.md) | Done | Waveform data | [`serato_overview.py`](scripts/serato_overview.py)
27
+
28
+ The different file/tag formats that Serato uses to store the information are documented in [`docs/fileformats.md`](docs/fileformats.md), a script to dump the tag data can be found at [`scripts/tagdump.py`](scripts/tagdump.py).
@@ -0,0 +1,25 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "serato_tools"
7
+ version = "1.5.0"
8
+ description = "Serato track metadata tags"
9
+ readme = "README.md"
10
+ authors = [
11
+ { name = "bvandercar-vt", email = "bvandercar@outlook.com" }
12
+ ]
13
+ license = { file = "LICENSE" }
14
+ dependencies = [
15
+ "mutagen"
16
+ ]
17
+ optional-dependencies.waveform_drawing = [
18
+ "pillow"
19
+ ]
20
+
21
+ [tool.setuptools]
22
+ packages = ["serato_tools", "serato_tools.utils"]
23
+
24
+ [tool.setuptools.package-dir]
25
+ serato_tools = "src"
@@ -0,0 +1,60 @@
1
+ Metadata-Version: 2.4
2
+ Name: serato_tools
3
+ Version: 1.5.0
4
+ Summary: Serato track metadata tags
5
+ Author-email: bvandercar-vt <bvandercar@outlook.com>
6
+ License: Copyright 2019 Jan Holthuis
7
+
8
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
9
+ this software and associated documentation files (the "Software"), to deal in
10
+ the Software without restriction, including without limitation the rights to
11
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
12
+ of the Software, and to permit persons to whom the Software is furnished to do
13
+ so, subject to the following conditions:
14
+
15
+ The above copyright notice and this permission notice shall be included in all
16
+ copies or substantial portions of the Software.
17
+
18
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
19
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
20
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
21
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
22
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
23
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
24
+ SOFTWARE.
25
+
26
+ Description-Content-Type: text/markdown
27
+ License-File: LICENSE
28
+ Requires-Dist: mutagen
29
+ Provides-Extra: waveform-drawing
30
+ Requires-Dist: pillow; extra == "waveform-drawing"
31
+ Dynamic: license-file
32
+
33
+ Fork of https://github.com/Holzhaus/serato-tags , which appears to be no longer maintained.
34
+
35
+ # Installation
36
+
37
+ Can install using pip:
38
+
39
+ `pip install git+https://github.com/bvandercar-vt/serato-tags@main`
40
+
41
+ Or add this line to your `requirements.txt`:
42
+
43
+ `git+https://github.com/bvandercar-vt/serato-tags@main`
44
+
45
+ # Serato Tags
46
+
47
+ This repository aims to document the GEOB ID3 tags that the Serato DJ software uses to store its metadata.
48
+ You can also have a look at [this lengthy blog post](https://homepage.ruhr-uni-bochum.de/jan.holthuis/posts/reversing-seratos-geob-tags) that goes into detail how I reversed the contents of the `Serato Markers2` GEOB tag.
49
+
50
+ | Tag | Progress | Contents | Example script
51
+ | -------------------------------------------- | ------------- | -------------------------- | --------------
52
+ | [`Serato Analysis`](docs/serato_analysis.md) | Done | Serato version information |
53
+ | [`Serato Autotags`](docs/serato_autotags.md) | Done | BPM and Gain values | [`serato_autotags.py`](scripts/serato_autotags.py)
54
+ | [`Serato BeatGrid`](docs/serato_beatgrid.md) | Mostly done | Beatgrid Markers | [`serato_beatgrid.py`](scripts/serato_beatgrid.py)
55
+ | [`Serato Markers2`](docs/serato_markers2.md) | Mostly done | Hotcues, Saved Loops, etc. | [`serato_markers2.py`](scripts/serato_markers2.py)
56
+ | [`Serato Markers_`](docs/serato_markers_.md) | Mostly done | Hotcues, Saved Loops, etc. | [`serato_markers_.py`](scripts/serato_markers_.py)
57
+ | [`Serato Offsets_`](docs/serato_offsets_.md) | *Not started* | |
58
+ | [`Serato Overview`](docs/serato_overview.md) | Done | Waveform data | [`serato_overview.py`](scripts/serato_overview.py)
59
+
60
+ The different file/tag formats that Serato uses to store the information are documented in [`docs/fileformats.md`](docs/fileformats.md), a script to dump the tag data can be found at [`scripts/tagdump.py`](scripts/tagdump.py).
@@ -0,0 +1,20 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ serato_tools.egg-info/PKG-INFO
5
+ serato_tools.egg-info/SOURCES.txt
6
+ serato_tools.egg-info/dependency_links.txt
7
+ serato_tools.egg-info/requires.txt
8
+ serato_tools.egg-info/top_level.txt
9
+ src/__init__.py
10
+ src/database_v2.py
11
+ src/serato_autotags.py
12
+ src/serato_beatgrid.py
13
+ src/serato_gain.py
14
+ src/serato_markers2.py
15
+ src/serato_markers_.py
16
+ src/serato_overview.py
17
+ src/tagdump.py
18
+ src/utils/__init__.py
19
+ src/utils/tags.py
20
+ src/utils/ui.py
@@ -0,0 +1,4 @@
1
+ mutagen
2
+
3
+ [waveform_drawing]
4
+ pillow
@@ -0,0 +1 @@
1
+ serato_tools
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
File without changes
@@ -0,0 +1,212 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import io
4
+ import os
5
+ import struct
6
+ import sys
7
+ from typing import Any, Callable, Generator, Iterable, Tuple, TypedDict
8
+
9
+ DATABASE_FILE = os.path.join(os.path.expanduser("~"), "Music\\_Serato_\\database V2")
10
+
11
+
12
+ class DbEntry(TypedDict):
13
+ field: str
14
+ field_name: str
15
+ value: str | int | bool | list["DbEntry"]
16
+ size_bytes: int
17
+
18
+
19
+ FIELDNAMES = {
20
+ # Database
21
+ "vrsn": "Version",
22
+ "otrk": "Track",
23
+ "ttyp": "File Type",
24
+ "pfil": "File Path",
25
+ "tsng": "Song Title",
26
+ "tart": "Artist",
27
+ "talb": "Album",
28
+ "tgen": "Genre",
29
+ "tlen": "Length",
30
+ "tbit": "Bitrate",
31
+ "tsmp": "Sample Rate",
32
+ "tsiz": "Size",
33
+ "tbpm": "BPM",
34
+ "tkey": "Key",
35
+ "tart": "Artist",
36
+ "utme": "File Time",
37
+ "bmis": "Missing",
38
+ "tgrp": "Grouping",
39
+ "tlbl": "Publisher",
40
+ "ttyr": "Year",
41
+ # Serato stuff
42
+ "tadd": "Date added",
43
+ "uadd": "Date added",
44
+ "bbgl": "Beatgrid Locked",
45
+ # Crates
46
+ "osrt": "Sorting",
47
+ "brev": "Reverse Order",
48
+ "ovct": "Column Title",
49
+ "tvcn": "Column Name",
50
+ "tvcw": "Column Width",
51
+ "ptrk": "Track Path",
52
+ }
53
+
54
+
55
+ def _get_type_id(name: str) -> str:
56
+ # vrsn field has no type_id, but contains text
57
+ return "t" if name == "vrsn" else name[0]
58
+
59
+
60
+ ParsedType = Tuple[str, int, Any, bytes]
61
+
62
+
63
+ def parse(fp: io.BytesIO | io.BufferedReader) -> Generator[ParsedType]:
64
+ for header in iter(lambda: fp.read(8), b""):
65
+ assert len(header) == 8
66
+ name_ascii: bytes
67
+ length: int
68
+ name_ascii, length = struct.unpack(">4sI", header)
69
+ name: str = name_ascii.decode("ascii")
70
+ type_id: str = _get_type_id(name)
71
+
72
+ data = fp.read(length)
73
+ assert len(data) == length
74
+
75
+ value: Any
76
+ if type_id == "b":
77
+ value = struct.unpack("?", data)[0]
78
+ elif type_id in ("o", "r"):
79
+ value = tuple(parse(io.BytesIO(data)))
80
+ elif type_id in ("p", "t"):
81
+ value = (data[1:] + b"\00").decode("utf-16")
82
+ elif type_id == "s":
83
+ value = struct.unpack(">H", data)[0]
84
+ elif type_id == "u":
85
+ value = struct.unpack(">I", data)[0]
86
+ else:
87
+ value = data
88
+
89
+ yield name, length, value, data
90
+
91
+
92
+ class ModifyRule(TypedDict):
93
+ field: str
94
+ func: Callable[[str, Any], Any]
95
+
96
+
97
+ def modify(
98
+ fp: io.BytesIO | io.BufferedWriter,
99
+ parsed: Iterable[ParsedType],
100
+ rules: list[ModifyRule] = [],
101
+ ):
102
+ track_filename: str = ""
103
+ for name, length, value, data in parsed:
104
+ name_bytes = name.encode("ascii")
105
+ assert len(name_bytes) == 4
106
+
107
+ type_id: str = _get_type_id(name)
108
+
109
+ if name == "pfil":
110
+ assert isinstance(value, str)
111
+ track_filename = os.path.normpath(value)
112
+
113
+ rule_has_been_done = False
114
+ for rule in rules:
115
+ if name == rule["field"]:
116
+ maybe_new_value = rule["func"](track_filename, value)
117
+ if maybe_new_value is not None:
118
+ assert (
119
+ not rule_has_been_done
120
+ ), f"Should only pass one rule per field (field: {name})"
121
+ rule_has_been_done = True
122
+ value = maybe_new_value
123
+
124
+ if type_id == "b":
125
+ data = struct.pack("?", value)
126
+ elif type_id in ("o", "r"):
127
+ assert isinstance(value, tuple)
128
+ nested_buffer = io.BytesIO()
129
+ modify(nested_buffer, value, rules)
130
+ data = nested_buffer.getvalue()
131
+ elif type_id in ("p", "t"):
132
+ new_data = str(value).encode("utf-16")[2:]
133
+ assert new_data[-1:] == b"\x00"
134
+ data = data[:1] + new_data[:-1]
135
+ elif type_id == "s":
136
+ data = struct.pack(">H", value)
137
+ elif type_id == "u":
138
+ data = struct.pack(">I", value)
139
+
140
+ length = len(data)
141
+
142
+ header = struct.pack(">4sI", name_bytes, length)
143
+ fp.write(header)
144
+ fp.write(data)
145
+
146
+
147
+ def modify_file(file: str, rules: list[ModifyRule]):
148
+ with open(file, "rb") as read_file:
149
+ parsed = list(parse(read_file))
150
+
151
+ output = io.BytesIO()
152
+ modify(output, parsed, rules)
153
+
154
+ with open(file, "wb") as write_file:
155
+ output.seek(0)
156
+ write_file.write(output.read())
157
+
158
+
159
+ def parse_to_objects(fp: io.BytesIO | io.BufferedReader | str) -> Generator[DbEntry]:
160
+ if isinstance(fp, str):
161
+ fp = open(fp, "rb")
162
+
163
+ for name, length, value, data in parse(fp):
164
+ if isinstance(value, tuple):
165
+ try:
166
+ new_val: list[DbEntry] = [
167
+ {
168
+ "field": n,
169
+ "field_name": FIELDNAMES.get(n, "Unknown"),
170
+ "size_bytes": l,
171
+ "value": v,
172
+ }
173
+ for n, l, v, d in value
174
+ ]
175
+ except:
176
+ print(f"error on {value}")
177
+ raise
178
+ value = new_val
179
+ else:
180
+ value = repr(value)
181
+
182
+ yield {
183
+ "field": name,
184
+ "field_name": FIELDNAMES.get(name, "Unknown"),
185
+ "size_bytes": length,
186
+ "value": value,
187
+ }
188
+
189
+
190
+ if __name__ == "__main__":
191
+ import argparse
192
+
193
+ parser = argparse.ArgumentParser()
194
+ parser.add_argument(
195
+ "file",
196
+ type=argparse.FileType("rb"),
197
+ default=open(DATABASE_FILE, "rb"),
198
+ nargs="?",
199
+ )
200
+ args = parser.parse_args()
201
+
202
+ for entry in parse_to_objects(args.file):
203
+ if isinstance(entry["value"], list):
204
+ print(f"{entry['field']} ({entry['field_name']}, {entry['size_bytes']} B)")
205
+ for e in entry["value"]:
206
+ print(
207
+ f" {e['field']} ({e['field_name']}, {e['size_bytes']} B): {e['value']}"
208
+ )
209
+ else:
210
+ print(
211
+ f"{entry['field']} ({entry['field_name']}, {entry['size_bytes']} B): {entry['value']}"
212
+ )
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import io
4
+ import struct
5
+ import sys
6
+
7
+ FMT_VERSION = "BB"
8
+
9
+ GEOB_KEY = "Serato Autotags"
10
+
11
+
12
+ def readbytes(fp: io.BytesIO | io.BufferedReader):
13
+ for x in iter(lambda: fp.read(1), b""):
14
+ if x == b"\00":
15
+ break
16
+ yield x
17
+
18
+
19
+ def parse(fp: io.BytesIO | io.BufferedReader):
20
+ version = struct.unpack(FMT_VERSION, fp.read(2))
21
+ assert version == (0x01, 0x01)
22
+
23
+ for i in range(3):
24
+ data = b"".join(readbytes(fp))
25
+ yield float(data.decode("ascii"))
26
+
27
+
28
+ def dump(bpm: float, autogain: float, gaindb: float):
29
+ data = struct.pack(FMT_VERSION, 0x01, 0x01)
30
+ for value, decimals in ((bpm, 2), (autogain, 3), (gaindb, 3)):
31
+ data += "{:.{}f}".format(value, decimals).encode("ascii")
32
+ data += b"\x00"
33
+ return data
34
+
35
+
36
+ if __name__ == "__main__":
37
+ import argparse
38
+ import configparser
39
+ import subprocess
40
+ import tempfile
41
+
42
+ import mutagen._file
43
+
44
+ from .utils.tags import get_geob, tag_geob
45
+ from .utils.ui import get_text_editor
46
+
47
+ parser = argparse.ArgumentParser()
48
+ parser.add_argument("file")
49
+ parser.add_argument("-e", "--edit", action="store_true")
50
+ args = parser.parse_args()
51
+
52
+ tagfile = mutagen._file.File(args.file)
53
+ if tagfile is not None:
54
+ fp = io.BytesIO(get_geob(tagfile, GEOB_KEY))
55
+ else:
56
+ fp = open(args.file, mode="rb")
57
+
58
+ with fp:
59
+ bpm, autogain, gaindb = parse(fp)
60
+
61
+ if args.edit:
62
+ editor = get_text_editor()
63
+
64
+ with tempfile.NamedTemporaryFile() as f:
65
+ f.write(
66
+ (f"bpm: {bpm}\n" "autogain: {autogain}\n" "gaindb: {gaindb}\n").encode(
67
+ "ascii"
68
+ )
69
+ )
70
+ f.flush()
71
+ status = subprocess.call((editor, f.name))
72
+ f.seek(0)
73
+ output = f.read()
74
+
75
+ if status != 0:
76
+ error_str = (f"Command executation failed with status: {status}",)
77
+ print(error_str, file=sys.stderr)
78
+ raise Exception(error_str)
79
+
80
+ cp = configparser.ConfigParser()
81
+ try:
82
+ cp.read_string("[Autotags]\n" + output.decode())
83
+ bpm = cp.getfloat("Autotags", "bpm")
84
+ autogain = cp.getfloat("Autotags", "autogain")
85
+ gaindb = cp.getfloat("Autotags", "gaindb")
86
+ except Exception:
87
+ print("Invalid input, no changes made", file=sys.stderr)
88
+ raise
89
+
90
+ new_data = dump(bpm, autogain, gaindb)
91
+ if tagfile:
92
+ if tagfile is not None:
93
+ tag_geob(tagfile, GEOB_KEY, new_data)
94
+ tagfile.save()
95
+ else:
96
+ with open(args.file, mode="wb") as fp:
97
+ fp.write(new_data)
98
+ else:
99
+ print(f"BPM: {bpm}")
100
+ print(f"Auto Gain: {autogain}")
101
+ print(f"Gain dB: {gaindb}")
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ import collections
4
+ import io
5
+ import struct
6
+ import sys
7
+
8
+ FMT_VERSION = "BB"
9
+
10
+ GEOB_KEY = "Serato BeatGrid"
11
+
12
+ NonTerminalBeatgridMarker = collections.namedtuple(
13
+ "NonTerminalBeatgridMarker",
14
+ (
15
+ "position",
16
+ "beats_till_next_marker",
17
+ ),
18
+ )
19
+ TerminalBeatgridMarker = collections.namedtuple(
20
+ "TerminalBeatgridMarker",
21
+ (
22
+ "position",
23
+ "bpm",
24
+ ),
25
+ )
26
+
27
+ Footer = collections.namedtuple("Footer", ("unknown",))
28
+
29
+
30
+ def parse(fp: io.BytesIO | io.BufferedReader):
31
+ version = struct.unpack(FMT_VERSION, fp.read(2))
32
+ assert version == (0x01, 0x00)
33
+
34
+ num_markers = struct.unpack(">I", fp.read(4))[0]
35
+ for i in range(num_markers):
36
+ position = struct.unpack(">f", fp.read(4))[0]
37
+ data = fp.read(4)
38
+ if i == num_markers - 1:
39
+ bpm = struct.unpack(">f", data)[0]
40
+ yield TerminalBeatgridMarker(position, bpm)
41
+ else:
42
+ beats_till_next_marker = struct.unpack(">I", data)[0]
43
+ yield NonTerminalBeatgridMarker(position, beats_till_next_marker)
44
+
45
+ # TODO: What's the meaning of the footer byte?
46
+ yield Footer(struct.unpack("B", fp.read(1))[0])
47
+ assert fp.read() == b""
48
+
49
+
50
+ if __name__ == "__main__":
51
+ import argparse
52
+
53
+ import mutagen._file
54
+
55
+ from .utils.tags import get_geob
56
+
57
+ parser = argparse.ArgumentParser()
58
+ parser.add_argument("file")
59
+ args = parser.parse_args()
60
+
61
+ tagfile = mutagen._file.File(args.file)
62
+ if tagfile is not None:
63
+ fp = io.BytesIO(get_geob(tagfile, GEOB_KEY))
64
+ else:
65
+ fp = open(args.file, mode="rb")
66
+
67
+ with fp:
68
+ for marker in parse(fp):
69
+ print(marker)
@@ -0,0 +1,21 @@
1
+ import sys
2
+
3
+ REPLAY_GAIN_GAIN_KEY = "replaygain_SeratoGain_gain"
4
+ REPLAY_GAIN_PEAK_KEY = "replaygain_SeratoGain_peak"
5
+
6
+
7
+ if __name__ == "__main__":
8
+ import argparse
9
+
10
+ import mutagen._file
11
+
12
+ parser = argparse.ArgumentParser()
13
+ parser.add_argument("file")
14
+ args = parser.parse_args()
15
+
16
+ tagfile = mutagen._file.File(args.file)
17
+ if tagfile is not None:
18
+ gain = tagfile.get(REPLAY_GAIN_GAIN_KEY, None)
19
+ peak = tagfile.get(REPLAY_GAIN_PEAK_KEY, None)
20
+ print(f"gain: {gain}")
21
+ print(f"peak: {peak}")