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 +19 -0
- pysacd/__main__.py +329 -0
- pysacd/_native.py +320 -0
- pysacd/_sacdconv.dll +0 -0
- pysacd/convert.py +467 -0
- pysacd/extract.py +852 -0
- pysacd/isrcsubmit.py +271 -0
- pysacd/log.py +168 -0
- pysacd/musicbrainz.py +354 -0
- pysacd/remote.py +640 -0
- pysacd/rootcheck.py +44 -0
- pysacd/safeio.py +55 -0
- pysacd/safetext.py +158 -0
- pysacd/sbcompat.py +733 -0
- pysacd/tagging.py +257 -0
- pysacd/toc.py +645 -0
- pysacd-0.2.0.dist-info/METADATA +106 -0
- pysacd-0.2.0.dist-info/RECORD +25 -0
- pysacd-0.2.0.dist-info/WHEEL +5 -0
- pysacd-0.2.0.dist-info/entry_points.txt +3 -0
- pysacd-0.2.0.dist-info/licenses/COPYING +339 -0
- pysacd-0.2.0.dist-info/licenses/licenses/README.md +12 -0
- pysacd-0.2.0.dist-info/licenses/licenses/libFLAC-COPYING.Xiph +29 -0
- pysacd-0.2.0.dist-info/licenses/licenses/libogg-COPYING +28 -0
- pysacd-0.2.0.dist-info/top_level.txt +1 -0
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
|