samplerdisc 0.5.1__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.
- samplerdisc/__init__.py +3 -0
- samplerdisc/audiocd.py +212 -0
- samplerdisc/banks.py +83 -0
- samplerdisc/batch.py +260 -0
- samplerdisc/cli.py +450 -0
- samplerdisc/container/__init__.py +1 -0
- samplerdisc/container/base.py +110 -0
- samplerdisc/container/detect.py +74 -0
- samplerdisc/container/flat.py +16 -0
- samplerdisc/container/mdsmdf.py +62 -0
- samplerdisc/container/mdx.py +312 -0
- samplerdisc/container/nrg.py +174 -0
- samplerdisc/container/rawcd.py +79 -0
- samplerdisc/cue.py +108 -0
- samplerdisc/export.py +48 -0
- samplerdisc/extract.py +749 -0
- samplerdisc/fs/__init__.py +17 -0
- samplerdisc/fs/akai.py +781 -0
- samplerdisc/fs/base.py +187 -0
- samplerdisc/fs/emu3.py +1545 -0
- samplerdisc/fs/hfs.py +451 -0
- samplerdisc/fs/iso9660.py +268 -0
- samplerdisc/fs/kurzweil.py +818 -0
- samplerdisc/fs/probe.py +53 -0
- samplerdisc/fs/roland_s7xx.py +472 -0
- samplerdisc/sample/__init__.py +38 -0
- samplerdisc/sample/aiff.py +248 -0
- samplerdisc/sample/akai.py +271 -0
- samplerdisc/sample/emu3.py +259 -0
- samplerdisc/sample/emu_ebl.py +291 -0
- samplerdisc/sample/kurzweil.py +163 -0
- samplerdisc/sample/roland_s7xx.py +113 -0
- samplerdisc/sample/sd2.py +231 -0
- samplerdisc/stereo.py +164 -0
- samplerdisc/wav.py +224 -0
- samplerdisc-0.5.1.dist-info/METADATA +226 -0
- samplerdisc-0.5.1.dist-info/RECORD +40 -0
- samplerdisc-0.5.1.dist-info/WHEEL +4 -0
- samplerdisc-0.5.1.dist-info/entry_points.txt +2 -0
- samplerdisc-0.5.1.dist-info/licenses/LICENSE +21 -0
samplerdisc/__init__.py
ADDED
samplerdisc/audiocd.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Red Book audio CDs.
|
|
2
|
+
|
|
3
|
+
Not a CD-ROM: no filesystem, no partition, nothing to walk. The sectors *are*
|
|
4
|
+
the audio. A raw 2352-byte CD audio sector is already 16-bit 44.1 kHz stereo
|
|
5
|
+
little-endian PCM, so a track becomes a WAV by putting a header in front of it
|
|
6
|
+
-- the same "copy, never convert" guarantee as the sampler path (ADR-0011).
|
|
7
|
+
|
|
8
|
+
These discs sit in the same archives as the sampler CD-ROMs and are easy to
|
|
9
|
+
mistake for one, so recognising them is as useful as converting them.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import struct
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from samplerdisc import cue as cuesheet
|
|
20
|
+
from samplerdisc.wav import write_wav, write_wav_streaming
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Iterator
|
|
24
|
+
|
|
25
|
+
from samplerdisc.container.base import SectorImage
|
|
26
|
+
|
|
27
|
+
#: A raw CD audio sector: 588 stereo frames of 16-bit samples.
|
|
28
|
+
AUDIO_SECTOR_SIZE = 2352
|
|
29
|
+
CDDA_RATE = 44100
|
|
30
|
+
CDDA_CHANNELS = 2
|
|
31
|
+
CDDA_WIDTH = 2
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class AudioTrack:
|
|
36
|
+
number: int
|
|
37
|
+
title: str
|
|
38
|
+
path: str
|
|
39
|
+
frames: int
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def seconds(self) -> float:
|
|
43
|
+
return self.frames / CDDA_RATE
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def detect(image_path: str | os.PathLike[str]) -> cuesheet.CueSheet | None:
|
|
47
|
+
"""Return the cue sheet if ``image_path`` is an audio CD, else None.
|
|
48
|
+
|
|
49
|
+
Requires a cue: without one there is no way to know where tracks begin, and
|
|
50
|
+
nothing in the bytes distinguishes CD audio from any other PCM.
|
|
51
|
+
"""
|
|
52
|
+
sheet = cuesheet.load(image_path)
|
|
53
|
+
if sheet is None or not sheet.all_audio:
|
|
54
|
+
return None
|
|
55
|
+
if os.path.getsize(image_path) % AUDIO_SECTOR_SIZE != 0:
|
|
56
|
+
return None
|
|
57
|
+
return sheet
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
#: Windows sampled across an image when judging whether it holds CD audio, and
|
|
61
|
+
#: how many bytes each. Spread rather than contiguous: a sample CD is mostly
|
|
62
|
+
#: silence between hits, and one window can easily land in a gap.
|
|
63
|
+
_GATE_WINDOWS = 12
|
|
64
|
+
_GATE_WINDOW_BYTES = 1 << 16
|
|
65
|
+
|
|
66
|
+
#: A window quieter than this carries no evidence either way.
|
|
67
|
+
_GATE_SILENCE = 8
|
|
68
|
+
|
|
69
|
+
#: Interleaved stereo makes a lag-1 step a hop between channels and a lag-2
|
|
70
|
+
#: step a move along one channel, so lag-1 differences dominate. Mono data read
|
|
71
|
+
#: as stereo inverts that: lag-1 is a single step in time and lag-2 is two, so
|
|
72
|
+
#: the ratio sits near 0.5. Measured across the reference discs, audio CDs land
|
|
73
|
+
#: at 5.1-14.0 and every sampler disc at or below 1.01 -- including Roland
|
|
74
|
+
#: discs whose payload is smooth enough to fool a plain smoothness test.
|
|
75
|
+
_GATE_STEREO_RATIO = 2.0
|
|
76
|
+
|
|
77
|
+
#: Each channel must also look like a waveform rather than noise. Audio CDs
|
|
78
|
+
#: measure 0.10-0.25 here; uniform noise sits near 1.33.
|
|
79
|
+
_GATE_SMOOTHNESS = 0.75
|
|
80
|
+
|
|
81
|
+
#: Read size when streaming a whole disc to one WAV.
|
|
82
|
+
_WHOLE_DISC_CHUNK = 1 << 22
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _window_stats(buf: bytes) -> tuple[float, float] | None:
|
|
86
|
+
"""(lag-1 / lag-2, lag-2 / mean) for ``buf`` read as 16-bit LE stereo."""
|
|
87
|
+
if len(buf) < 4096:
|
|
88
|
+
return None
|
|
89
|
+
values = struct.unpack(f"<{len(buf) // 2}h", buf[: len(buf) // 2 * 2])
|
|
90
|
+
mean = sum(map(abs, values)) / len(values)
|
|
91
|
+
if mean < _GATE_SILENCE:
|
|
92
|
+
return None
|
|
93
|
+
lag1 = sum(abs(values[i] - values[i - 1]) for i in range(1, len(values))) / (len(values) - 1)
|
|
94
|
+
lag2 = sum(abs(values[i] - values[i - 2]) for i in range(2, len(values))) / (len(values) - 2)
|
|
95
|
+
if lag2 == 0:
|
|
96
|
+
return None
|
|
97
|
+
return lag1 / lag2, lag2 / mean
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _median(values: list[float]) -> float:
|
|
101
|
+
ordered = sorted(values)
|
|
102
|
+
return ordered[len(ordered) // 2]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def looks_like_cd_audio(image: SectorImage) -> bool:
|
|
106
|
+
"""Does this image's stream look like 44.1 kHz 16-bit stereo PCM?
|
|
107
|
+
|
|
108
|
+
This answers a narrower question than "is this an audio CD", and it cannot
|
|
109
|
+
replace a cue sheet: track boundaries are not in the bytes, which is why
|
|
110
|
+
``detect`` still requires one. What it is good for is telling a user whose
|
|
111
|
+
disc yielded no filesystem *why* -- an image of a Red Book disc decodes
|
|
112
|
+
perfectly and then has nothing to walk, which otherwise looks identical to
|
|
113
|
+
a container we got wrong.
|
|
114
|
+
|
|
115
|
+
Deliberately conservative. It is consulted only when no backend claimed the
|
|
116
|
+
disc, and it must not fire on a sampler disc whose payload happens to be
|
|
117
|
+
smooth -- see the constants above for the measured margins.
|
|
118
|
+
"""
|
|
119
|
+
stats = []
|
|
120
|
+
for index in range(_GATE_WINDOWS):
|
|
121
|
+
offset = (image.size // (_GATE_WINDOWS + 1)) * (index + 1)
|
|
122
|
+
offset -= offset % 4
|
|
123
|
+
window = _window_stats(image.read(offset, _GATE_WINDOW_BYTES))
|
|
124
|
+
if window is not None:
|
|
125
|
+
stats.append(window)
|
|
126
|
+
if len(stats) < 3:
|
|
127
|
+
# Too little to judge. Say no rather than guess.
|
|
128
|
+
return False
|
|
129
|
+
return (
|
|
130
|
+
_median([s[0] for s in stats]) > _GATE_STEREO_RATIO
|
|
131
|
+
and _median([s[1] for s in stats]) < _GATE_SMOOTHNESS
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def write_whole_disc(image: SectorImage, out_path: str | os.PathLike[str]) -> int:
|
|
136
|
+
"""Write an image's entire stream as one stereo WAV. Returns frames.
|
|
137
|
+
|
|
138
|
+
For a disc with no filesystem whose content is CD audio. The bytes are
|
|
139
|
+
copied verbatim -- a raw audio sector is already 588 stereo frames of
|
|
140
|
+
signed 16-bit LE PCM, so this is a header in front of the stream and
|
|
141
|
+
nothing else (ADR-0011).
|
|
142
|
+
"""
|
|
143
|
+
frame_bytes = CDDA_CHANNELS * CDDA_WIDTH
|
|
144
|
+
total = (image.size // frame_bytes) * frame_bytes
|
|
145
|
+
|
|
146
|
+
def stream():
|
|
147
|
+
done = 0
|
|
148
|
+
while done < total:
|
|
149
|
+
chunk = image.read(done, min(_WHOLE_DISC_CHUNK, total - done))
|
|
150
|
+
if not chunk:
|
|
151
|
+
# Tail damage. The declared length must still be met, so pad
|
|
152
|
+
# with silence rather than writing a truncated data chunk.
|
|
153
|
+
yield b"\x00" * (total - done)
|
|
154
|
+
return
|
|
155
|
+
done += len(chunk)
|
|
156
|
+
yield chunk
|
|
157
|
+
|
|
158
|
+
write_wav_streaming(
|
|
159
|
+
out_path,
|
|
160
|
+
stream(),
|
|
161
|
+
total_bytes=total,
|
|
162
|
+
rate=CDDA_RATE,
|
|
163
|
+
channels=CDDA_CHANNELS,
|
|
164
|
+
sample_width=CDDA_WIDTH,
|
|
165
|
+
)
|
|
166
|
+
return total // frame_bytes
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def track_name(track: cuesheet.CueTrack) -> str:
|
|
170
|
+
return track.title or f"Track {track.number:02d}"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def extract_tracks(
|
|
174
|
+
image_path: str | os.PathLike[str],
|
|
175
|
+
sheet: cuesheet.CueSheet,
|
|
176
|
+
out_dir: str,
|
|
177
|
+
) -> Iterator[AudioTrack]:
|
|
178
|
+
"""Write each audio track as a stereo WAV."""
|
|
179
|
+
from samplerdisc.extract import safe_name, unique_path
|
|
180
|
+
|
|
181
|
+
size = os.path.getsize(image_path)
|
|
182
|
+
total_sectors = size // AUDIO_SECTOR_SIZE
|
|
183
|
+
os.makedirs(out_dir, exist_ok=True)
|
|
184
|
+
|
|
185
|
+
starts = [t.start_lba for t in sheet.tracks]
|
|
186
|
+
with open(image_path, "rb") as source:
|
|
187
|
+
for index, track in enumerate(sheet.tracks):
|
|
188
|
+
start = starts[index]
|
|
189
|
+
end = starts[index + 1] if index + 1 < len(starts) else total_sectors
|
|
190
|
+
if end <= start or start >= total_sectors:
|
|
191
|
+
continue
|
|
192
|
+
end = min(end, total_sectors)
|
|
193
|
+
source.seek(start * AUDIO_SECTOR_SIZE)
|
|
194
|
+
pcm = source.read((end - start) * AUDIO_SECTOR_SIZE)
|
|
195
|
+
if not pcm:
|
|
196
|
+
continue
|
|
197
|
+
name = track_name(track)
|
|
198
|
+
path = unique_path(out_dir, safe_name(name))
|
|
199
|
+
write_wav(
|
|
200
|
+
path,
|
|
201
|
+
pcm,
|
|
202
|
+
rate=CDDA_RATE,
|
|
203
|
+
channels=CDDA_CHANNELS,
|
|
204
|
+
sample_width=CDDA_WIDTH,
|
|
205
|
+
name=name,
|
|
206
|
+
)
|
|
207
|
+
yield AudioTrack(
|
|
208
|
+
number=track.number,
|
|
209
|
+
title=name,
|
|
210
|
+
path=path,
|
|
211
|
+
frames=len(pcm) // (CDDA_CHANNELS * CDDA_WIDTH),
|
|
212
|
+
)
|
samplerdisc/banks.py
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Loose sample banks in an ordinary directory, converted to WAV.
|
|
2
|
+
|
|
3
|
+
Some E-mu libraries ship not as a disc image but as loose files in the clear:
|
|
4
|
+
an Emulator X / Proteus X bank is a ``.exb`` definition beside a ``SamplePool/``
|
|
5
|
+
of ``.ebl`` sample files. There is no disc, no container and no on-disc
|
|
6
|
+
filesystem to read -- the operating system's own filesystem already presents
|
|
7
|
+
the bytes and the tree -- so this is a *source*, not a container or a
|
|
8
|
+
``Backend`` (ADR-0042). It runs the same verified ``emu_ebl`` decoder the
|
|
9
|
+
on-disc path does, through ``extract.ebl_to_wav``.
|
|
10
|
+
|
|
11
|
+
The ``.exb`` holds the preset key ranges and zone mappings; like the on-disc
|
|
12
|
+
``.exb`` and the E-IV presets it is left to ConvertWithMoss and is not read
|
|
13
|
+
here (ADR-0011). Only the ``.ebl`` sample files are converted.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import TYPE_CHECKING
|
|
21
|
+
|
|
22
|
+
from samplerdisc.extract import Extracted, Skipped, ebl_to_wav, safe_name
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_bank_dirs(root: str) -> list[Path]:
|
|
29
|
+
"""Every directory under ``root`` that directly holds ``.ebl`` files.
|
|
30
|
+
|
|
31
|
+
Returned in stable sorted order. A render/oracle tree or a stray folder
|
|
32
|
+
with no ``.ebl`` is not a bank and is passed over: the presence of the
|
|
33
|
+
sample files is the whole test, so ``.exb``-only or FLAC-only folders never
|
|
34
|
+
qualify.
|
|
35
|
+
"""
|
|
36
|
+
found: list[Path] = []
|
|
37
|
+
for directory, _dirs, files in os.walk(root):
|
|
38
|
+
if any(name.lower().endswith(".ebl") for name in files):
|
|
39
|
+
found.append(Path(directory))
|
|
40
|
+
return sorted(found)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def bank_name(bank_dir: Path) -> str:
|
|
44
|
+
"""The library name for a bank directory.
|
|
45
|
+
|
|
46
|
+
An E-mu bank keeps its samples in a child ``SamplePool/``, so the name that
|
|
47
|
+
means something is the parent (``Proteus 1``), not the pool. A directory
|
|
48
|
+
that holds the ``.ebl`` files directly is named for itself.
|
|
49
|
+
"""
|
|
50
|
+
if bank_dir.name.lower() == "samplepool":
|
|
51
|
+
return bank_dir.parent.name
|
|
52
|
+
return bank_dir.name
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def extract_bank(bank_dir: Path, out_dir: str, volume: str) -> Iterator[Extracted | Skipped]:
|
|
56
|
+
"""Convert every ``.ebl`` in one bank directory to a WAV in ``out_dir``.
|
|
57
|
+
|
|
58
|
+
Files are walked in sorted order for a stable run. Each is decoded by the
|
|
59
|
+
shared ``ebl_to_wav``, which names the output from the sample's own header
|
|
60
|
+
and refuses a bad payload with a reason rather than raising -- one damaged
|
|
61
|
+
``.ebl`` never ends the bank. Unlike the AKAI path there is no stereo
|
|
62
|
+
``-L``/``-R`` pairing and no de-duplication: an ``.ebl`` has no on-disc
|
|
63
|
+
twin (as on the disc EBL path).
|
|
64
|
+
"""
|
|
65
|
+
for path in sorted(bank_dir.glob("*.ebl")):
|
|
66
|
+
try:
|
|
67
|
+
payload = path.read_bytes()
|
|
68
|
+
except OSError as exc: # pragma: no cover - filesystem-level failure
|
|
69
|
+
yield Skipped(volume, path.name, f"unreadable: {exc}")
|
|
70
|
+
continue
|
|
71
|
+
yield ebl_to_wav(payload, path.name, volume, out_dir)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def extract_banks(root: str, out_root: str) -> Iterator[Extracted | Skipped]:
|
|
75
|
+
"""Convert every loose bank found under ``root``.
|
|
76
|
+
|
|
77
|
+
Each bank's WAVs land under ``out_root/<bank name>/`` so several banks share
|
|
78
|
+
an output tree without colliding.
|
|
79
|
+
"""
|
|
80
|
+
for bank_dir in find_bank_dirs(root):
|
|
81
|
+
name = bank_name(bank_dir)
|
|
82
|
+
out_dir = os.path.join(out_root, safe_name(name))
|
|
83
|
+
yield from extract_bank(bank_dir, out_dir, name)
|
samplerdisc/batch.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
"""Convert a directory of disc images in one pass, and record what happened.
|
|
2
|
+
|
|
3
|
+
A collection is the real use case: dozens of images from different sites in
|
|
4
|
+
different containers, some of which will not open. One bad disc must not stop
|
|
5
|
+
the run, and the manifest is how a user finds the ones that need attention.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from dataclasses import asdict, dataclass, field
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import TYPE_CHECKING, Any
|
|
15
|
+
|
|
16
|
+
from samplerdisc import banks
|
|
17
|
+
from samplerdisc.audiocd import detect as detect_audio_cd
|
|
18
|
+
from samplerdisc.audiocd import extract_tracks
|
|
19
|
+
from samplerdisc.container.detect import open_image, sniff
|
|
20
|
+
from samplerdisc.extract import Credited, Extracted, Joined, Kept, Skipped, extract_disc, safe_name
|
|
21
|
+
from samplerdisc.fs.probe import find_origin
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Iterator
|
|
25
|
+
|
|
26
|
+
#: Extensions worth opening. Detection is by signature (ADR-0004), but walking
|
|
27
|
+
#: a directory needs some filter or every README becomes a candidate disc.
|
|
28
|
+
IMAGE_SUFFIXES = {
|
|
29
|
+
".mdx", ".nrg", ".iso", ".img", ".bin", ".mds", ".cdr", ".tao", ".ccd",
|
|
30
|
+
} # fmt: skip
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class DiscReport:
|
|
35
|
+
source: str
|
|
36
|
+
container: str | None = None
|
|
37
|
+
filesystem: str | None = None
|
|
38
|
+
origin: int | None = None
|
|
39
|
+
#: The backend's one line on how the disc is divided, verbatim -- the same
|
|
40
|
+
#: line ``list`` prints, or None where the filesystem has no structure
|
|
41
|
+
#: above the volume. It is here because it is the only place a run over a
|
|
42
|
+
#: collection records that an image is **short of the disc it was made
|
|
43
|
+
#: from**: its samples extract and verify, and the disc has more on it than
|
|
44
|
+
#: the file does (ADR-0028).
|
|
45
|
+
layout: str | None = None
|
|
46
|
+
volumes: list[dict[str, Any]] = field(default_factory=list)
|
|
47
|
+
samples: int = 0
|
|
48
|
+
#: Samples that were stereo on the disc, of ``samples``. Counted apart
|
|
49
|
+
#: from ``stereo_pairs`` because they are different news: one is a channel
|
|
50
|
+
#: count the record declared, the other a pairing this tool guessed from
|
|
51
|
+
#: two filenames (ADR-0007, ADR-0026).
|
|
52
|
+
stereo_samples: int = 0
|
|
53
|
+
stereo_pairs: int = 0
|
|
54
|
+
originals: int = 0
|
|
55
|
+
#: Disc provenance lines written to a ``Credits.txt`` sidecar, from the
|
|
56
|
+
#: E-IV ``Credits``/``E-mu Systems 96`` text banks. 0 unless ``--metadata``
|
|
57
|
+
#: is set or the disc has no such bank (ADR-0043).
|
|
58
|
+
credit_lines: int = 0
|
|
59
|
+
#: Entries read, understood, and deliberately not written because their
|
|
60
|
+
#: audio was already written from another file on the same disc. Counted
|
|
61
|
+
#: apart from ``skipped`` so a clean disc does not read as a damaged one.
|
|
62
|
+
duplicates: int = 0
|
|
63
|
+
#: Entries whose payload is not the file the directory placed there.
|
|
64
|
+
#: Counted apart from ``skipped`` because it is a different fault: the
|
|
65
|
+
#: filesystem and the data have come apart, which on these discs means the
|
|
66
|
+
#: image is short of the disc it was made from (ADR-0027).
|
|
67
|
+
mismatches: int = 0
|
|
68
|
+
#: Samples written whose bytes were displaced inside a partition by blocks
|
|
69
|
+
#: the rip lost and recovered a whole number of container blocks earlier,
|
|
70
|
+
#: each confirmed by its own header (issue #35, ADR-0045). A subset of
|
|
71
|
+
#: ``samples``: says how many of them the image would have had wrong, or
|
|
72
|
+
#: refused, before recovery.
|
|
73
|
+
recovered: int = 0
|
|
74
|
+
audio_tracks: int = 0
|
|
75
|
+
skipped: list[dict[str, object]] = field(default_factory=list)
|
|
76
|
+
error: str | None = None
|
|
77
|
+
|
|
78
|
+
@property
|
|
79
|
+
def ok(self) -> bool:
|
|
80
|
+
return self.error is None and (self.samples > 0 or self.audio_tracks > 0)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def find_images(root: str) -> list[str]:
|
|
84
|
+
"""Every plausible disc image under ``root``, in stable order.
|
|
85
|
+
|
|
86
|
+
Companion files are excluded by simply not listing their suffixes: a
|
|
87
|
+
``.cue`` describes its ``.bin`` and a ``.mdf`` holds data for its ``.mds``,
|
|
88
|
+
so each pair is reached once, through the member that is actually opened.
|
|
89
|
+
"""
|
|
90
|
+
found: list[str] = []
|
|
91
|
+
for directory, _dirs, files in os.walk(root):
|
|
92
|
+
for name in sorted(files):
|
|
93
|
+
if os.path.splitext(name)[1].lower() in IMAGE_SUFFIXES:
|
|
94
|
+
found.append(os.path.join(directory, name))
|
|
95
|
+
return sorted(found)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def convert_disc(
|
|
99
|
+
path: str,
|
|
100
|
+
out_root: str,
|
|
101
|
+
join_stereo: bool = True,
|
|
102
|
+
keep_originals: bool = False,
|
|
103
|
+
metadata: bool = False,
|
|
104
|
+
) -> DiscReport:
|
|
105
|
+
"""Convert one image. Never raises: a failure becomes a report."""
|
|
106
|
+
report = DiscReport(source=path)
|
|
107
|
+
try:
|
|
108
|
+
# An audio CD has no filesystem to find -- the sectors are the audio.
|
|
109
|
+
sheet = detect_audio_cd(path)
|
|
110
|
+
if sheet is not None:
|
|
111
|
+
report.container = "audio-cd"
|
|
112
|
+
report.filesystem = "none (Red Book audio)"
|
|
113
|
+
out_dir = os.path.join(out_root, safe_name(os.path.splitext(os.path.basename(path))[0]))
|
|
114
|
+
for _track in extract_tracks(path, sheet, out_dir):
|
|
115
|
+
report.audio_tracks += 1
|
|
116
|
+
return report
|
|
117
|
+
|
|
118
|
+
report.container = sniff(path)
|
|
119
|
+
with open_image(path) as image:
|
|
120
|
+
origin = find_origin(image)
|
|
121
|
+
if origin is None:
|
|
122
|
+
report.error = "no recognised filesystem"
|
|
123
|
+
return report
|
|
124
|
+
report.filesystem = origin.backend.name
|
|
125
|
+
report.origin = origin.offset
|
|
126
|
+
describe = getattr(origin.backend, "layout", None)
|
|
127
|
+
report.layout = describe(image, origin.offset) if describe is not None else None
|
|
128
|
+
|
|
129
|
+
out_dir = os.path.join(out_root, safe_name(os.path.splitext(os.path.basename(path))[0]))
|
|
130
|
+
# Keyed by partition *and* name: nearly every partition of an AKAI
|
|
131
|
+
# disc has a "VOLUME 001", and keying by name alone reported nine
|
|
132
|
+
# volumes' samples as one entry (ADR-0023).
|
|
133
|
+
volumes: dict[tuple[int, str], dict[str, Any]] = {}
|
|
134
|
+
results = extract_disc(
|
|
135
|
+
image,
|
|
136
|
+
origin.backend,
|
|
137
|
+
origin.offset,
|
|
138
|
+
out_dir,
|
|
139
|
+
join_stereo,
|
|
140
|
+
keep_originals,
|
|
141
|
+
metadata,
|
|
142
|
+
)
|
|
143
|
+
for result in results:
|
|
144
|
+
if isinstance(result, Credited):
|
|
145
|
+
report.credit_lines += result.lines
|
|
146
|
+
elif isinstance(result, Extracted):
|
|
147
|
+
report.samples += 1
|
|
148
|
+
if result.channels > 1:
|
|
149
|
+
report.stereo_samples += 1
|
|
150
|
+
if result.displaced:
|
|
151
|
+
report.recovered += 1
|
|
152
|
+
entry = volumes.setdefault(
|
|
153
|
+
(result.partition, result.volume),
|
|
154
|
+
{"name": result.volume, "partition": result.partition, "samples": 0},
|
|
155
|
+
)
|
|
156
|
+
entry["samples"] += 1
|
|
157
|
+
elif isinstance(result, Joined):
|
|
158
|
+
report.stereo_pairs += 1
|
|
159
|
+
elif isinstance(result, Kept):
|
|
160
|
+
report.originals += 1
|
|
161
|
+
elif isinstance(result, Skipped):
|
|
162
|
+
if result.duplicate:
|
|
163
|
+
report.duplicates += 1
|
|
164
|
+
if result.mismatch:
|
|
165
|
+
report.mismatches += 1
|
|
166
|
+
report.skipped.append(
|
|
167
|
+
{
|
|
168
|
+
"volume": result.volume,
|
|
169
|
+
"partition": result.partition,
|
|
170
|
+
"name": result.name,
|
|
171
|
+
"reason": result.reason,
|
|
172
|
+
"duplicate": result.duplicate,
|
|
173
|
+
"mismatch": result.mismatch,
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
report.volumes = list(volumes.values())
|
|
177
|
+
except (OSError, ValueError) as exc:
|
|
178
|
+
# One unreadable disc must not end the run.
|
|
179
|
+
report.error = str(exc)
|
|
180
|
+
return report
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def convert_bank(bank_dir: str, out_root: str) -> DiscReport:
|
|
184
|
+
"""Convert one loose ``.ebl`` bank directory. Never raises.
|
|
185
|
+
|
|
186
|
+
A loose bank has no container and no on-disc filesystem -- the OS presents
|
|
187
|
+
the files directly (ADR-0042) -- so the report names those layers ``none``
|
|
188
|
+
and records the bank as a single volume. Reported alongside disc images so a
|
|
189
|
+
mixed tree runs in one pass and lands in one manifest.
|
|
190
|
+
"""
|
|
191
|
+
name = banks.bank_name(Path(bank_dir))
|
|
192
|
+
report = DiscReport(source=bank_dir, container="loose-ebl", filesystem="none")
|
|
193
|
+
out_dir = os.path.join(out_root, safe_name(name))
|
|
194
|
+
samples = 0
|
|
195
|
+
try:
|
|
196
|
+
for result in banks.extract_bank(Path(bank_dir), out_dir, name):
|
|
197
|
+
if isinstance(result, Extracted):
|
|
198
|
+
report.samples += 1
|
|
199
|
+
samples += 1
|
|
200
|
+
if result.channels > 1:
|
|
201
|
+
report.stereo_samples += 1
|
|
202
|
+
elif isinstance(result, Skipped):
|
|
203
|
+
report.skipped.append(
|
|
204
|
+
{
|
|
205
|
+
"volume": result.volume,
|
|
206
|
+
"partition": result.partition,
|
|
207
|
+
"name": result.name,
|
|
208
|
+
"reason": result.reason,
|
|
209
|
+
"duplicate": result.duplicate,
|
|
210
|
+
"mismatch": result.mismatch,
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
except OSError as exc: # pragma: no cover - filesystem-level failure
|
|
214
|
+
report.error = str(exc)
|
|
215
|
+
return report
|
|
216
|
+
report.volumes = [{"name": name, "partition": 0, "samples": samples}]
|
|
217
|
+
return report
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def convert_tree(
|
|
221
|
+
root: str,
|
|
222
|
+
out_root: str,
|
|
223
|
+
join_stereo: bool = True,
|
|
224
|
+
keep_originals: bool = False,
|
|
225
|
+
metadata: bool = False,
|
|
226
|
+
) -> Iterator[DiscReport]:
|
|
227
|
+
for path in find_images(root):
|
|
228
|
+
yield convert_disc(path, out_root, join_stereo, keep_originals, metadata)
|
|
229
|
+
# Loose banks are files in the clear, not disc images, so find_images passes
|
|
230
|
+
# them over (.ebl is not an image suffix); they are discovered separately and
|
|
231
|
+
# converted through their own source. A render/oracle tree carries no .ebl
|
|
232
|
+
# and is skipped by construction.
|
|
233
|
+
for bank_dir in banks.find_bank_dirs(root):
|
|
234
|
+
yield convert_bank(str(bank_dir), out_root)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def write_manifest(path: str, reports: list[DiscReport]) -> None:
|
|
238
|
+
payload = {
|
|
239
|
+
"discs": [asdict(report) for report in reports],
|
|
240
|
+
"totals": {
|
|
241
|
+
"discs": len(reports),
|
|
242
|
+
"converted": sum(1 for r in reports if r.ok),
|
|
243
|
+
"failed": sum(1 for r in reports if not r.ok),
|
|
244
|
+
"samples": sum(r.samples for r in reports),
|
|
245
|
+
"stereo_samples": sum(r.stereo_samples for r in reports),
|
|
246
|
+
"stereo_pairs": sum(r.stereo_pairs for r in reports),
|
|
247
|
+
"audio_tracks": sum(r.audio_tracks for r in reports),
|
|
248
|
+
"originals": sum(r.originals for r in reports),
|
|
249
|
+
"credit_lines": sum(r.credit_lines for r in reports),
|
|
250
|
+
"skipped": sum(len(r.skipped) for r in reports),
|
|
251
|
+
"duplicates": sum(r.duplicates for r in reports),
|
|
252
|
+
"mismatches": sum(r.mismatches for r in reports),
|
|
253
|
+
"recovered": sum(r.recovered for r in reports),
|
|
254
|
+
},
|
|
255
|
+
}
|
|
256
|
+
directory = os.path.dirname(os.path.abspath(path))
|
|
257
|
+
os.makedirs(directory, exist_ok=True)
|
|
258
|
+
with open(path, "w", encoding="utf-8") as out:
|
|
259
|
+
json.dump(payload, out, indent=2)
|
|
260
|
+
out.write("\n")
|