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.
@@ -0,0 +1,3 @@
1
+ """Convert vintage sampler CD-ROM images to uncompressed WAV."""
2
+
3
+ __version__ = "0.5.0"
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")