pysacd 0.2.0__py3-none-win_amd64.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.
pysacd/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ """Pure-Python reader for SACD (Scarletbook) ISO images."""
2
+
3
+ from .toc import (
4
+ Area,
5
+ Disc,
6
+ Genre,
7
+ Locale,
8
+ SACDError,
9
+ SACDImage,
10
+ Timecode,
11
+ Track,
12
+ read_toc,
13
+ )
14
+
15
+ __all__ = [
16
+ "Area", "Disc", "Genre", "Locale", "SACDError", "SACDImage",
17
+ "Timecode", "Track", "read_toc",
18
+ ]
19
+ __version__ = "0.2.0"
pysacd/__main__.py ADDED
@@ -0,0 +1,329 @@
1
+ """pysacd command line: `toc` prints SACD TOCs, `convert` extracts tagged audio."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import dataclasses
7
+ import getpass
8
+ import json
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ from . import safetext
13
+ from .toc import Disc, SACDError, read_toc
14
+
15
+
16
+ def _to_json(disc: Disc) -> dict:
17
+ d = dataclasses.asdict(disc)
18
+ d["manufacturer_info"] = disc.manufacturer_info.rstrip(b"\0").hex()
19
+ for area, ad in zip(disc.areas, d["areas"]):
20
+ ad["frame_format_name"] = area.frame_format_name
21
+ ad["speaker_config"] = area.speaker_config
22
+ for track, td in zip(area.tracks, ad["tracks"]):
23
+ td["start_seconds"] = track.start.seconds_float
24
+ td["duration_seconds"] = track.duration.seconds_float
25
+ return d
26
+
27
+
28
+ def print(*args, **kwargs) -> None: # TOC text is untrusted: escape control characters
29
+ import builtins
30
+ builtins.print(*(safetext.for_terminal(str(a), keep_tab_newline=True) for a in args), **kwargs)
31
+
32
+
33
+ def _print(disc: Disc, path: str) -> None:
34
+ print(f"== {path}")
35
+ print(f"Disc: {disc.artist or '?'} - {disc.title or '?'}")
36
+ for k in ("album_title", "album_artist", "album_publisher", "album_copyright"):
37
+ if k in disc.text:
38
+ print(f" {k:16} {disc.text[k]}")
39
+ y, m, day = disc.date
40
+ print(f" version {disc.version[0]}.{disc.version[1]:02d} hybrid={disc.hybrid}"
41
+ f" set {disc.album_sequence_number}/{disc.album_set_size}"
42
+ f" date {y:04d}-{m:02d}-{day:02d}")
43
+ if disc.disc_catalog_number:
44
+ print(f" catalog {disc.disc_catalog_number}")
45
+ genres = [str(g) for g in disc.disc_genres if g.genre > 1]
46
+ if genres:
47
+ print(f" genre {', '.join(genres)}")
48
+ for area in disc.areas:
49
+ print(f"Area {area.kind}: {area.speaker_config}, {area.frame_format_name},"
50
+ f" {len(area.tracks)} tracks, {area.total_playtime}"
51
+ f" (LSN {area.track_start}-{area.track_end})")
52
+ for t in area.tracks:
53
+ who = f" [{t.performer}]" if t.performer else ""
54
+ print(f" {t.number:2d}. {t.duration} {t.title or ''}{who}")
55
+ for w in disc.warnings + [w for a in disc.areas for w in a.warnings]:
56
+ print(f" warning: {w}")
57
+
58
+
59
+ def _toc(args) -> int:
60
+ rc = 0
61
+ out = []
62
+ from . import remote
63
+ for path in args.images:
64
+ try:
65
+ addr = remote.parse_address(path)
66
+ disc = remote.read_remote_toc(*addr)[0] if addr else read_toc(path)
67
+ except (OSError, SACDError) as e:
68
+ print(f"{path}: {e}", file=sys.stderr)
69
+ rc = 1
70
+ continue
71
+ if args.json:
72
+ out.append({"path": path, **_to_json(disc)})
73
+ else:
74
+ _print(disc, path)
75
+ if args.json:
76
+ json.dump(out if len(out) != 1 else out[0], sys.stdout, ensure_ascii=False, indent=2)
77
+ print()
78
+ return rc
79
+
80
+
81
+ def _submit_isrcs(args) -> int:
82
+ from . import isrcsubmit, musicbrainz, remote
83
+ try:
84
+ if args.mb_server:
85
+ musicbrainz.set_server(args.mb_server)
86
+ addr = remote.parse_address(args.image)
87
+ disc = remote.read_remote_toc(*addr)[0] if addr else read_toc(args.image)
88
+ except (OSError, SACDError, musicbrainz.MBError) as e:
89
+ print(f"{args.image}: {e}", file=sys.stderr)
90
+ return 1
91
+ areas = [a for a in (disc.stereo, disc.multichannel) if a is not None
92
+ and (args.area == "both" or (args.area == "multichannel") == a.is_multichannel)]
93
+ rc = 0
94
+ for area in areas:
95
+ if not any(t.isrc for t in area.tracks):
96
+ print(f"{area.speaker_config}: the disc has no ISRCs")
97
+ continue
98
+ try:
99
+ match = musicbrainz.find_release(disc, area, args.mb_contact, args.mb_release, text_search=False)
100
+ if match is None:
101
+ print(f"{area.speaker_config}: no confident MusicBrainz match (try --mb-release)", file=sys.stderr)
102
+ rc = 1
103
+ continue
104
+ isrcsubmit.offer(match, area, args.mb_contact, dry_run=args.dry_run, yes=args.yes, user=args.mb_user)
105
+ except musicbrainz.MBError as e:
106
+ print(f"{area.speaker_config}: {e}", file=sys.stderr)
107
+ rc = 1
108
+ return rc
109
+
110
+
111
+ def _mb_login(args) -> int:
112
+ from . import isrcsubmit, musicbrainz
113
+ try:
114
+ if args.mb_server:
115
+ musicbrainz.set_server(args.mb_server)
116
+ print("Open this URL, allow pysacd to submit ISRCs, then paste the code it shows:\n\n "
117
+ + isrcsubmit.authorize_url(args.client_id) + "\n")
118
+ code = input("code: ")
119
+ secret = args.client_secret or getpass.getpass("client secret: ")
120
+ isrcsubmit.finish_login(args.client_id, secret, code, args.mb_contact)
121
+ print(f"saved the token to {isrcsubmit.TOKEN_FILE} (readable only by you)")
122
+ except (OSError, musicbrainz.MBError) as e:
123
+ print(f"mb-login: {e}", file=sys.stderr)
124
+ return 1
125
+ return 0
126
+
127
+
128
+ class _Version(argparse.Action):
129
+ def __call__(self, parser, namespace, values, option_string=None):
130
+ from . import __version__, _native
131
+ try:
132
+ engine = f"{_native.build_info()} ({_native.library_path()})"
133
+ except (OSError, _native.NativeError) as e:
134
+ engine = f"not available: {e}"
135
+ print(f"pysacd {__version__}\nengine: {engine}")
136
+ parser.exit()
137
+
138
+
139
+ def _bool(ap, name, help):
140
+ ap.add_argument(f"--{name}", dest=name.replace("-", "_"), action=argparse.BooleanOptionalAction,
141
+ default=None, help=help)
142
+
143
+
144
+ def _convert(args) -> int:
145
+ from .convert import CONFIG_PATH, Options, convert
146
+ from .rootcheck import refuse_root
147
+
148
+ if refuse_root("pysacd convert", args.allow_root):
149
+ return 2
150
+
151
+ try:
152
+ opts = Options.from_config(Path(args.config) if args.config else CONFIG_PATH)
153
+ except (ValueError, OSError) as e:
154
+ print(f"config: {e}", file=sys.stderr)
155
+ return 2
156
+ for k, v in vars(args).items():
157
+ if v is not None and hasattr(opts, k):
158
+ setattr(opts, k, v)
159
+ from . import remote
160
+
161
+ rc = 0
162
+ for iso in args.images:
163
+ tmp = None
164
+ try:
165
+ addr = remote.parse_address(iso)
166
+ if addr:
167
+ # Fetch the TOCs and the selected areas from the PS3 into a temporary sparse ISO
168
+ print(f"{iso}: reading from SACD Ripper server", file=sys.stderr)
169
+ def wanted(a, which=opts.areas, dry=opts.dry_run):
170
+ return not dry and ((a.is_stereo and which in ("stereo", "both")) or
171
+ (a.is_multichannel and which in ("multichannel", "both")))
172
+ tmp, _ = remote.fetch_temp(
173
+ *addr, opts.output_dir or ".", wanted,
174
+ progress=remote.RateMeter(lambda m: (sys.stderr.write(m), sys.stderr.flush())))
175
+ rc |= convert(tmp or iso, opts)
176
+ except (OSError, SACDError) as e:
177
+ print(f"{iso}: {e}", file=sys.stderr)
178
+ rc = 1
179
+ finally:
180
+ if tmp:
181
+ Path(tmp).unlink(missing_ok=True)
182
+ return rc
183
+
184
+
185
+ def _ps3sim(args) -> int:
186
+ import ipaddress
187
+
188
+ from .remote import PS3Simulator
189
+ try:
190
+ loopback = ipaddress.ip_address(args.host).is_loopback
191
+ except ValueError:
192
+ loopback = args.host == "localhost"
193
+ if not loopback:
194
+ print(f"warning: listening on {args.host}: anyone who can reach this port can read {args.image}. "
195
+ "The protocol has no authentication or encryption; use only on a trusted network.",
196
+ file=sys.stderr)
197
+ sim = PS3Simulator(args.image, args.host, args.port, rate=args.rate * 1e6, latency=args.latency / 1000,
198
+ drop_after=args.drop_after, fail_lsn=args.fail_lsn, no_disc=args.no_disc,
199
+ idle_timeout=args.idle_timeout,
200
+ log=lambda m: print(m, file=sys.stderr, flush=True))
201
+ host, port = sim.address[0], sim.address[1]
202
+ print(f"serving {args.image} as a SACD Ripper server on {host!s}:{port} (Ctrl-C to stop)", file=sys.stderr)
203
+ try:
204
+ sim.serve_forever()
205
+ except KeyboardInterrupt:
206
+ pass
207
+ finally:
208
+ sim.stop()
209
+ return 0
210
+
211
+
212
+ def main(argv: list[str] | None = None) -> int:
213
+ if argv is None: # run as the command: tracebacks quote exception messages (#47)
214
+ safetext.install_excepthook()
215
+ ap = argparse.ArgumentParser(prog="pysacd", description="SACD ISO tools")
216
+ ap.add_argument("--log-file", help="write a diagnostic log here (default level: debug)")
217
+ ap.add_argument("--log-level", choices=["debug", "info", "warning", "error"],
218
+ help="log detail; without --log-file the log goes to stderr")
219
+ ap.add_argument("--version", action=_Version, nargs=0, help="show the version and how the engine was built")
220
+ sub = ap.add_subparsers(dest="cmd", required=True)
221
+
222
+ t = sub.add_parser("toc", help="print the TOC of SACD ISO images")
223
+ t.add_argument("images", nargs="+")
224
+ t.add_argument("--json", action="store_true", help="emit JSON")
225
+ t.set_defaults(func=_toc)
226
+
227
+ c = sub.add_parser(
228
+ "convert", help="extract tracks to DSF/FLAC/WAV and tag them",
229
+ description="Extract an SACD ISO to tagged files. Defaults come from "
230
+ "~/.config/pysacd/config.toml (same names as the long options, "
231
+ "with underscores); flags override it.",
232
+ formatter_class=argparse.RawDescriptionHelpFormatter,
233
+ epilog="template fields: albumartist album artist title composer date year originalyear\n"
234
+ " label catalognumber barcode tracknumber tracktotal discnumber disctotal\n"
235
+ " disc disc_prefix edition layout channels format\n"
236
+ " e.g. --dir-template '{albumartist}/{year} - {album} [{edition}]'\n"
237
+ " --file-template '{tracknumber:02d} {title}'\n"
238
+ " empty [] / () left by blank fields are removed")
239
+ c.add_argument("images", nargs="+", help="ISO images, or HOST:PORT of a PS3 running SACD Ripper")
240
+ c.add_argument("--config", help=f"config file (default {Path('~/.config/pysacd/config.toml')})")
241
+ c.add_argument("-f", "--format", choices=["dsf", "flac", "wav"], help="output format (default dsf)")
242
+ c.add_argument("-o", "--output-dir", help="root output directory (default .)")
243
+ c.add_argument("--dir-template", help="album directory template, may contain /")
244
+ c.add_argument("--file-template", help="track file name template (no extension)")
245
+ c.add_argument("--stereo-edition", help="{edition} for the stereo area (default 'SACD')")
246
+ c.add_argument("--multichannel-edition", help="{edition} for the multichannel area (default 'SACD {layout}')")
247
+ c.add_argument("-a", "--areas", choices=["stereo", "multichannel", "both"], help="areas to extract (default stereo)")
248
+ c.add_argument("--disc", metavar="N/M",
249
+ help="this is disc N of an M-disc set: sets the disc number tags and {disc}/{disc_prefix}, "
250
+ "overriding the TOC and MusicBrainz (for sets mastered as 1/1, e.g. Live-Evil)")
251
+ c.add_argument("-t", "--tracks", type=lambda s: [int(x) for x in s.split(",")], help="only these tracks, e.g. 1,4,5")
252
+ c.add_argument("-r", "--pcm-rate", type=int, choices=[88200, 176400], help="FLAC/WAV sample rate (default 88200)")
253
+ c.add_argument("--gain-db", type=float, help="PCM gain in dB (default 0; SACD 0 dB = -6 dBFS)")
254
+ c.add_argument("--flac-level", type=int, choices=range(9), metavar="0-8", help="FLAC compression (default 5)")
255
+ c.add_argument("-j", "--jobs", type=int, help="worker threads (default: CPU count)")
256
+ _bool(c, "musicbrainz", "look up the release on MusicBrainz (default on)")
257
+ c.add_argument("--mb-release", help="use this MusicBrainz release id")
258
+ _bool(c, "mb-text-search", "fall back to title/artist search when barcode/catalog fail (default off)")
259
+ c.add_argument("--mb-contact", help="contact (URL or e-mail) for the MusicBrainz User-Agent")
260
+ _bool(c, "submit-isrcs", "offer the disc's ISRCs to the matched MusicBrainz release; asks first, needs `pysacd mb-login` (default off)")
261
+ _bool(c, "cover", "embed front cover from the Cover Art Archive (default on)")
262
+ c.add_argument("--cover-size", choices=["250", "500", "1200", "full"], help="cover size (default 1200)")
263
+ c.add_argument("--cover-file", help="save cover as this file in the album dir ('' to skip)")
264
+ c.add_argument("--id3-version", type=int, choices=[3, 4], help="ID3 version for DSF/WAV (default 3)")
265
+ _bool(c, "portable-names", "replace characters invalid on Windows/SMB (default off)")
266
+ _bool(c, "overwrite", "overwrite existing files (default: skip)")
267
+ c.add_argument("--allow-root", action="store_true",
268
+ help="run even as root (not recommended: disc images are untrusted; see docs/security.md)")
269
+ c.add_argument("-n", "--dry-run", action="store_true", default=None, help="show match and paths only")
270
+ c.set_defaults(func=_convert)
271
+
272
+ i = sub.add_parser(
273
+ "submit-isrcs", help="add the disc's ISRCs to its MusicBrainz release (asks first)",
274
+ description="Submit the ISRCs in a disc's TOC to the matching MusicBrainz release. Only a barcode, "
275
+ "catalog-number or --mb-release match is used; ISRCs already on another recording are "
276
+ "skipped. Sign in once with `pysacd mb-login`, or use --mb-user (HTTP digest).")
277
+ i.add_argument("image", help="ISO image or PS3 address (host:port)")
278
+ i.add_argument("--area", choices=["stereo", "multichannel", "both"], default="both")
279
+ i.add_argument("--mb-release", help="use this MusicBrainz release id")
280
+ i.add_argument("--mb-contact", help="contact (URL or e-mail) for the MusicBrainz User-Agent")
281
+ i.add_argument("--mb-server", help="another MusicBrainz server, e.g. https://test.musicbrainz.org")
282
+ i.add_argument("--mb-user", help="MusicBrainz user name for digest auth (password asked, never stored)")
283
+ i.add_argument("-n", "--dry-run", action="store_true", help="show what would be submitted")
284
+ i.add_argument("-y", "--yes", action="store_true", help="submit without asking")
285
+ i.set_defaults(func=_submit_isrcs)
286
+
287
+ g = sub.add_parser(
288
+ "mb-login", help="sign in to MusicBrainz (OAuth) for ISRC submission",
289
+ description="Out-of-band OAuth2 sign-in with the submit_isrc scope. Register an application at "
290
+ "https://musicbrainz.org/account/applications (type: installed) to get a client id and "
291
+ "secret. The token is saved owner-only under ~/.config/pysacd/.")
292
+ g.add_argument("--client-id", required=True)
293
+ g.add_argument("--client-secret", help="asked for if not given")
294
+ g.add_argument("--mb-contact", help="contact (URL or e-mail) for the MusicBrainz User-Agent")
295
+ g.add_argument("--mb-server", help="another MusicBrainz server, e.g. https://test.musicbrainz.org")
296
+ g.set_defaults(func=_mb_login)
297
+
298
+ s = sub.add_parser(
299
+ "ps3sim", help="serve an ISO like a PS3 running SACD Ripper (for testing network clients)",
300
+ description="Serve an already-decrypted SACD ISO over the SACD Ripper network protocol, "
301
+ "so `sacd_extract -i HOST:PORT` or pysacd can be tested without a PS3.")
302
+ s.add_argument("image")
303
+ s.add_argument("--host", default="127.0.0.1",
304
+ help="listen address (default 127.0.0.1; use 0.0.0.0 only on a trusted network)")
305
+ s.add_argument("--port", type=int, default=2002, help="listen port (default 2002, as on the PS3)")
306
+ s.add_argument("--rate", type=float, default=0, help="throttle to this many MB/s (0 = unlimited)")
307
+ s.add_argument("--latency", type=float, default=0, help="delay each request by this many ms")
308
+ s.add_argument("--drop-after", type=int, default=0, help="close the connection after N reads")
309
+ s.add_argument("--fail-lsn", type=int, help="make reads covering this LSN fail (result -1)")
310
+ s.add_argument("--no-disc", action="store_true", help="fail DISC_OPEN, as with an empty drive")
311
+ s.add_argument("--idle-timeout", type=float, default=60.0,
312
+ help="close a connection idle for this many seconds (default 60; 0 = never)")
313
+ s.set_defaults(func=_ps3sim)
314
+
315
+ args = ap.parse_args(argv)
316
+ from . import log as pslog
317
+ level, path = args.log_level, args.log_file
318
+ if level is None and path is None:
319
+ level, path = pslog.from_env()
320
+ try:
321
+ pslog.setup(level, path, ["pysacd", *(sys.argv[1:] if argv is None else argv)])
322
+ except (OSError, ValueError) as e:
323
+ print(f"logging: {e}", file=sys.stderr)
324
+ return 2
325
+ return args.func(args)
326
+
327
+
328
+ if __name__ == "__main__":
329
+ sys.exit(main())
pysacd/_native.py ADDED
@@ -0,0 +1,320 @@
1
+ """ctypes binding for the native engine (_sacdconv.so/.dylib/.dll, built by `make`)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import logging
7
+ import os
8
+ import sys
9
+ import threading
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from .toc import Area, SACDImage, area_frame_capacity
15
+
16
+ FORMATS = {"dsf": 0, "flac": 1, "wav": 2, "dff": 3, "dff-dst": 4}
17
+ SCAN_SLACK = 128 # sectors read around the needed range (frame context + boundary frames)
18
+
19
+
20
+ class _Output(ctypes.Structure):
21
+ # Must match sc_output_t in csrc/sacdconv.c
22
+ _fields_ = [
23
+ ("format", ctypes.c_int),
24
+ ("path", ctypes.c_char_p),
25
+ ("tc_start", ctypes.c_uint32),
26
+ ("tc_end", ctypes.c_uint32),
27
+ ("head_frame", ctypes.c_int32),
28
+ ("head_bytes", ctypes.c_uint32),
29
+ ("tail_bytes", ctypes.c_uint32),
30
+ ("header", ctypes.c_char_p),
31
+ ("header_len", ctypes.c_uint32),
32
+ ("footer", ctypes.c_char_p),
33
+ ("footer_len", ctypes.c_uint32),
34
+ ("dst_offsets", ctypes.POINTER(ctypes.c_uint64)),
35
+ ("dst_lengths", ctypes.POINTER(ctypes.c_uint32)),
36
+ ("dst_cap", ctypes.c_uint32),
37
+ ("flac_level", ctypes.c_int),
38
+ ("flac_padding", ctypes.c_uint32),
39
+ ("frames_written", ctypes.c_uint32),
40
+ ("frames_missing", ctypes.c_uint32),
41
+ ("dst_errors", ctypes.c_uint32),
42
+ ("dst_count", ctypes.c_uint32),
43
+ ("samples", ctypes.c_uint64),
44
+ ("data_bytes", ctypes.c_uint64),
45
+ ("error", ctypes.c_char * 256),
46
+ ]
47
+
48
+
49
+ # int read_fn(void *ctx, uint32_t lsn, uint32_t count, uint8_t *buf)
50
+ _READ_FN = ctypes.CFUNCTYPE(ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32,
51
+ ctypes.POINTER(ctypes.c_uint8))
52
+ # void log_fn(void *ctx, int level, const char *msg)
53
+ _LOG_FN = ctypes.CFUNCTYPE(None, ctypes.c_void_p, ctypes.c_int, ctypes.c_char_p)
54
+
55
+
56
+ class _Area(ctypes.Structure):
57
+ # Must match sc_area_t in csrc/sacdconv.c
58
+ _fields_ = [
59
+ ("iso_path", ctypes.c_char_p),
60
+ ("sector_size", ctypes.c_int),
61
+ ("data_offset", ctypes.c_int),
62
+ ("scan_lsn_start", ctypes.c_uint32),
63
+ ("scan_lsn_end", ctypes.c_uint32),
64
+ ("channels", ctypes.c_int),
65
+ ("pcm_rate", ctypes.c_int),
66
+ ("gain_db", ctypes.c_double),
67
+ ("threads", ctypes.c_int),
68
+ ("iso_out_path", ctypes.c_char_p),
69
+ ("iso_sectors", ctypes.c_uint32),
70
+ ("cancel", ctypes.c_int),
71
+ ("frames_done", ctypes.c_uint64),
72
+ ("frames_total", ctypes.c_uint64),
73
+ ("sectors_done", ctypes.c_uint64),
74
+ ("sectors_total", ctypes.c_uint64),
75
+ ("error", ctypes.c_char * 256),
76
+ ("read_fn", _READ_FN),
77
+ ("read_ctx", ctypes.c_void_p),
78
+ ("log_fn", _LOG_FN),
79
+ ("log_ctx", ctypes.c_void_p),
80
+ ("log_level", ctypes.c_int),
81
+ ]
82
+
83
+
84
+ class NativeError(RuntimeError):
85
+ pass
86
+
87
+
88
+ log = logging.getLogger("pysacd.engine")
89
+
90
+ # Reads 2048-byte sectors (lsn, count) -> bytes, e.g. from a disc on a network server
91
+ SectorSource = Callable[[int, int], bytes]
92
+
93
+
94
+ _lib = None
95
+ LIBRARY = {"win32": "_sacdconv.dll", "darwin": "_sacdconv.dylib"}.get(sys.platform, "_sacdconv.so")
96
+
97
+
98
+ def _load():
99
+ global _lib
100
+ if _lib is None:
101
+ path = Path(__file__).with_name(LIBRARY)
102
+ if not path.exists():
103
+ raise NativeError(f"{path} not built; run `make` in the pysacd source tree")
104
+ lib = ctypes.CDLL(str(path))
105
+ lib.sacdconv_area_run.argtypes = [ctypes.POINTER(_Area), ctypes.POINTER(_Output), ctypes.c_int]
106
+ lib.sacdconv_area_run.restype = ctypes.c_int
107
+ lib.sacdconv_has_flac.restype = ctypes.c_int
108
+ lib.sacdconv_build_info.restype = ctypes.c_char_p
109
+ _check_abi(lib, path)
110
+ _lib = lib
111
+ return _lib
112
+
113
+
114
+ ABI = 1 # SACDCONV_ABI in csrc/sacdconv.c
115
+
116
+
117
+ def _layout(struct) -> list[int]:
118
+ return [ctypes.sizeof(struct)] + [getattr(struct, name).offset for name, _ in struct._fields_]
119
+
120
+
121
+ def _check_abi(lib, path) -> None:
122
+ """Refuse an engine built from different struct definitions than the ctypes mirrors here:
123
+ a mismatch would make the engine read and write the wrong fields."""
124
+ try:
125
+ abi = lib.sacdconv_abi()
126
+ except AttributeError:
127
+ abi = 0
128
+ if abi != ABI:
129
+ raise NativeError(f"{path} has engine ABI {abi}, this pysacd needs {ABI}; rebuild it (make clean all)")
130
+ lib.sacdconv_layout.argtypes = [ctypes.c_int, ctypes.POINTER(ctypes.c_uint32), ctypes.c_int]
131
+ for which, struct in ((0, _Area), (1, _Output)):
132
+ want = _layout(struct)
133
+ buf = (ctypes.c_uint32 * 64)()
134
+ n = lib.sacdconv_layout(which, buf, 64)
135
+ got = list(buf[:min(n, 64)])
136
+ if got != want:
137
+ raise NativeError(f"{path}: {struct.__name__} layout differs from the engine's "
138
+ f"(engine {got}, pysacd {want}); rebuild it (make clean all)")
139
+
140
+
141
+ def has_flac() -> bool:
142
+ return bool(_load().sacdconv_has_flac())
143
+
144
+
145
+ def library_path() -> str:
146
+ return str(Path(__file__).with_name(LIBRARY))
147
+
148
+
149
+ def build_info() -> str:
150
+ """Compiler and features the engine was built with, for diagnostic logs."""
151
+ return _load().sacdconv_build_info().decode()
152
+
153
+
154
+ @dataclass
155
+ class OutputSpec:
156
+ """One file to produce from an area: frames [tc_start, tc_end)."""
157
+ format: str
158
+ path: str | os.PathLike
159
+ tc_start: int
160
+ tc_end: int
161
+ head_frame: int = -1 # DSF nopad: carry-in from this frame's tail
162
+ head_bytes: int = 0
163
+ tail_bytes: int = 0 # DSF nopad: bytes/channel left for the next file
164
+ header: bytes = b"" # DSDIFF header
165
+ footer: bytes = b"" # DSF ID3 tag / DSDIFF trailing chunks
166
+ flac_level: int = 5
167
+ flac_padding: int = 0
168
+ # results
169
+ frames_written: int = 0
170
+ frames_missing: int = 0
171
+ dst_errors: int = 0
172
+ samples: int = 0
173
+ data_bytes: int = 0
174
+ dst_index: list[tuple[int, int]] = field(default_factory=list) # DSDIFF DST: (offset, length)
175
+
176
+
177
+ @dataclass
178
+ class Progress:
179
+ frames_done: int
180
+ frames_total: int
181
+ sectors_done: int
182
+ sectors_total: int
183
+
184
+
185
+ def _scan_range(area: Area, specs: list[OutputSpec]) -> tuple[int, int]:
186
+ """LSN range covering every frame the outputs need."""
187
+ tracks = area.tracks
188
+ lo_tc = min(min(s.tc_start, s.head_frame if s.head_frame >= 0 else s.tc_start) for s in specs)
189
+ hi_tc = max(s.tc_end for s in specs)
190
+ lo = area.track_start
191
+ hi = area.track_end
192
+ # Track LSNs and timecodes come from the TOC: use them to narrow the scan only when they're
193
+ # consistent (inside the area's audio and in order, as on every real disc). Otherwise scan
194
+ # the whole area rather than miss frames or hand the engine a range outside it (#17).
195
+ lsns = [t.start_lsn for t in tracks]
196
+ starts = [t.start.total_frames for t in tracks]
197
+ if not (all(area.track_start <= x <= area.track_end for x in lsns)
198
+ and lsns == sorted(lsns) and starts == sorted(starts)):
199
+ log.debug("track LSNs or timecodes inconsistent: scanning the whole area")
200
+ return area.track_start, area.track_end
201
+ for i, t in enumerate(tracks):
202
+ if t.start.total_frames <= lo_tc:
203
+ lo = t.start_lsn if i else area.track_start
204
+ nxt = tracks[i + 1] if i + 1 < len(tracks) else None
205
+ if nxt and nxt.start.total_frames >= hi_tc and hi == area.track_end:
206
+ hi = nxt.start_lsn
207
+ return max(area.track_start, lo - SCAN_SLACK), min(area.track_end, hi + SCAN_SLACK)
208
+
209
+
210
+ def run_area(image: SACDImage, iso_path: str | os.PathLike, area: Area | None, specs: list[OutputSpec],
211
+ pcm_rate: int = 88200, gain_db: float = 0.0, threads: int = 0,
212
+ iso_out: str | os.PathLike | None = None,
213
+ progress: Callable[[Progress], None] | None = None, interval: float = 0.5,
214
+ source: SectorSource | None = None) -> None:
215
+ """Produce all outputs from one area in a single pass. Blocking, multithreaded.
216
+
217
+ `progress` is called from this thread every `interval` seconds while the
218
+ engine runs. Raises NativeError on failure; results are stored on the specs.
219
+ With `source`, sectors come from that callable instead of the file at
220
+ iso_path (which is then only a name for messages). Engine diagnostics go to
221
+ the "pysacd.engine" logger.
222
+ """
223
+ lib = _load()
224
+ outs = (_Output * max(1, len(specs)))()
225
+ keep = [] # keep ctypes buffers alive
226
+ for o, s in zip(outs, specs):
227
+ o.format = FORMATS[s.format]
228
+ o.path = os.fsencode(s.path)
229
+ o.tc_start, o.tc_end = s.tc_start, s.tc_end
230
+ o.head_frame, o.head_bytes, o.tail_bytes = s.head_frame, s.head_bytes, s.tail_bytes
231
+ o.header, o.header_len = s.header, len(s.header)
232
+ o.footer, o.footer_len = s.footer, len(s.footer)
233
+ o.flac_level, o.flac_padding = s.flac_level, s.flac_padding
234
+ if s.format == "dff-dst":
235
+ cap = s.tc_end - s.tc_start
236
+ offs, lens = (ctypes.c_uint64 * cap)(), (ctypes.c_uint32 * cap)()
237
+ keep += [offs, lens]
238
+ o.dst_offsets = ctypes.cast(offs, ctypes.POINTER(ctypes.c_uint64))
239
+ o.dst_lengths = ctypes.cast(lens, ctypes.POINTER(ctypes.c_uint32))
240
+ o.dst_cap = cap
241
+
242
+ a = _Area()
243
+ a.iso_path = os.fsencode(iso_path)
244
+ a.sector_size, a.data_offset = image.sector_size, image.data_offset
245
+ if specs:
246
+ assert area is not None, "outputs need an area"
247
+ # Frames the image lacks become silence: never more of it than the area's sectors in this
248
+ # image could hold, however long the TOC says the tracks are (#17)
249
+ capacity = area_frame_capacity(area, image.total_sectors)
250
+ if max(s.tc_end for s in specs) > capacity:
251
+ raise NativeError(f"the outputs need frames up to {max(s.tc_end for s in specs)}, but the area's "
252
+ f"audio in this image (LSN {area.track_start}-{area.track_end}, "
253
+ f"{image.total_sectors} sectors) holds at most {capacity}")
254
+ a.scan_lsn_start, a.scan_lsn_end = _scan_range(area, specs)
255
+ # The range comes from the TOC, which may be hostile: a sector source must only ever be
256
+ # asked for sectors that exist (#11). A file needs no check: pread stops at its end.
257
+ if source is not None and not 0 <= a.scan_lsn_start <= a.scan_lsn_end < image.total_sectors:
258
+ raise NativeError(f"area LSN {a.scan_lsn_start}-{a.scan_lsn_end} lies outside the image "
259
+ f"({image.total_sectors} sectors)")
260
+ a.channels = area.channel_count
261
+ else:
262
+ a.channels = 2
263
+ a.pcm_rate = pcm_rate
264
+ a.gain_db = gain_db
265
+ a.threads = threads
266
+ if iso_out is not None:
267
+ a.iso_out_path = os.fsencode(iso_out)
268
+ a.iso_sectors = image.total_sectors
269
+
270
+ # Callbacks run on engine threads; they must never raise into C.
271
+ def on_log(_ctx, level, msg):
272
+ log.log(level, msg.decode(errors="replace"))
273
+
274
+ def on_read(_ctx, lsn, count, buf):
275
+ try:
276
+ data = source(lsn, count)
277
+ if len(data) != count * 2048:
278
+ raise OSError(f"short read: {len(data)} of {count * 2048} bytes")
279
+ ctypes.memmove(buf, data, len(data))
280
+ return 0
281
+ except Exception as e:
282
+ log.exception("sector source failed at LSN %d (+%d)", lsn, count)
283
+ a.error = str(e).encode(errors="replace")[:255]
284
+ return -1
285
+
286
+ callbacks = [_LOG_FN(on_log)]
287
+ a.log_fn = callbacks[0]
288
+ a.log_level = log.getEffectiveLevel()
289
+ if source is not None:
290
+ if image.sector_size != 2048:
291
+ raise NativeError("a sector source must deliver plain 2048-byte sectors")
292
+ callbacks.append(_READ_FN(on_read))
293
+ a.read_fn = callbacks[1]
294
+ for s in specs:
295
+ log.debug("output %s %s: frames %d-%d%s", s.format, s.path, s.tc_start, s.tc_end,
296
+ f", nopad head {s.head_bytes}@{s.head_frame} tail {s.tail_bytes}" if s.head_frame >= 0 or s.tail_bytes
297
+ else "")
298
+
299
+ result = []
300
+ t = threading.Thread(target=lambda: result.append(lib.sacdconv_area_run(ctypes.byref(a), outs, len(specs))),
301
+ name="engine")
302
+ t.start()
303
+ try:
304
+ while t.is_alive():
305
+ t.join(interval)
306
+ if progress:
307
+ progress(Progress(a.frames_done, a.frames_total, a.sectors_done, a.sectors_total))
308
+ except BaseException:
309
+ a.cancel = 1
310
+ t.join()
311
+ raise
312
+
313
+ for o, s in zip(outs, specs):
314
+ s.frames_written, s.frames_missing, s.dst_errors = o.frames_written, o.frames_missing, o.dst_errors
315
+ s.samples, s.data_bytes = o.samples, o.data_bytes
316
+ if s.format == "dff-dst":
317
+ n = min(o.dst_count, o.dst_cap)
318
+ s.dst_index = [(o.dst_offsets[i], o.dst_lengths[i]) for i in range(n)]
319
+ if result and result[0] != 0:
320
+ raise NativeError(a.error.decode(errors="replace"))
pysacd/_sacdconv.dll ADDED
Binary file