diff --git a/src/samplerdisc/fs/emu3.py b/src/samplerdisc/fs/emu3.py index 8fdaad2..bf822b0 100644 --- a/src/samplerdisc/fs/emu3.py +++ b/src/samplerdisc/fs/emu3.py @@ -393,10 +393,16 @@ class _EivEntry(NamedTuple): name: str -def _eiv_scan(image: SectorImage) -> dict[int, bytes]: - """Every ``E3S1`` tag in the image, with the bytes that follow it.""" +def _eiv_scan(image: SectorImage, offset: int) -> dict[int, bytes]: + """Every ``E3S1`` tag in the image, with the bytes that follow it. + + Keyed by address relative to ``offset`` -- the filesystem origin -- and + scanned from there, for the reason _bank_headers() is: the addresses feed + reads taken at ``offset + address``, and the filesystem does not always + begin at byte 0 (ADR-0005). + """ found: dict[int, bytes] = {} - position = 0 + position = offset carry = b"" while position < image.size: chunk = image.read(position, _SCAN_CHUNK) @@ -408,7 +414,7 @@ def _eiv_scan(image: SectorImage) -> dict[int, bytes]: at = match.start() window = haystack[at : at + _EIV_WINDOW] if len(window) == _EIV_WINDOW or base + at + len(window) >= image.size: - found[base + at] = window + found[base + at - offset] = window carry = haystack[-_EIV_WINDOW:] position += len(chunk) return found @@ -644,6 +650,13 @@ def _banks(self, image: SectorImage, offset: int) -> list[_Bank]: def _bank_headers(self, image: SectorImage, offset: int) -> list[tuple[int, str]]: """Every bank header on the image, as ``(address, bank name)``. + Addresses are relative to ``offset`` -- the filesystem origin, not the + start of the file -- so that the scan agrees with every read below, + which is taken at ``offset + address``. The scan itself begins at + ``offset``: a header sitting in a pregap or an earlier track ahead of + the filesystem is not this filesystem's, and byte 0 is not where the + filesystem always starts (ADR-0005). + Duplicates are kept. A disc writes the same bank twice -- an older revision left in an unallocated region, or a copy running off the end of the image -- and which of them a directory entry means is decided @@ -654,7 +667,7 @@ def _bank_headers(self, image: SectorImage, offset: int) -> list[tuple[int, str] """ found: list[tuple[int, str]] = [] pattern = re.compile(b"|".join(re.escape(magic) for magic in BANK_MAGICS)) - position = 0 + position = offset carry = b"" while position < image.size: chunk = image.read(position, _SCAN_CHUNK) @@ -666,7 +679,7 @@ def _bank_headers(self, image: SectorImage, offset: int) -> list[tuple[int, str] at = match.start() raw = haystack[at + OFF_BANK_NAME : at + OFF_BANK_NAME + BANK_NAME_LEN] if len(raw) == BANK_NAME_LEN and is_plausible_name(raw): - found.append((base + at, decode_name(raw))) + found.append((base + at - offset, decode_name(raw))) carry = haystack[-64:] position += len(chunk) return sorted(set(found)) @@ -785,15 +798,16 @@ def _declared_run( start, length = struct.unpack_from(" Iterator[Volume]: eiv_tags: dict[int, bytes] = {} eiv_bound: dict[int, tuple[int, list[_EivEntry]]] = {} if any(bank.name not in located for bank in banks): - eiv_tags, eiv_bound = self._eiv(image, banks) + eiv_tags, eiv_bound = self._eiv(image, offset, banks) for bank in banks: volume = Volume(name=bank.name, start_block=bank.start) at = located.get(bank.name) diff --git a/tests/test_akai_fs.py b/tests/test_akai_fs.py index f8ea816..f7e5e49 100644 --- a/tests/test_akai_fs.py +++ b/tests/test_akai_fs.py @@ -114,7 +114,14 @@ def test_probe_rejects_zeros_and_noise(tmp_path): def test_origin_probe_finds_a_partition_behind_a_pregap(tmp_path): - """The whole point of ADR-0005, end to end.""" + """ADR-0005 through the container path. + + This exercises the *container*, not the origin probe: ``NrgImage`` reads the + DAOX track start and hands ``FlatImage`` the range past the 150-sector + pregap, so the probe sees the header at offset 0 of the cooked stream and + resolves there. The origin probe over a pregap left *in* the stream is the + next test. + """ from samplerdisc.container.nrg import NrgImage path = tmp_path / "disc.nrg" @@ -128,7 +135,14 @@ def test_origin_probe_finds_a_partition_behind_a_pregap(tmp_path): def test_origin_probe_finds_a_partition_offset_into_the_image(tmp_path): - """A hybrid disc: something else occupies the first sectors.""" + """ADR-0005 through the origin probe itself. + + A hybrid disc: something else occupies the first sectors, and unlike the + NRG case above the zeros are genuinely in the reported stream. So the origin + must be resolved to a non-zero offset by the probe scanning sectors, not + stripped by the container beforehand -- ``origin.offset == len(padding)`` is + what says the probe did the work. + """ padding = b"\x00" * (8 * 2048) image = image_of(tmp_path, padding + simple_partition()) origin = find_origin(image) diff --git a/tests/test_emu3.py b/tests/test_emu3.py index 9315e97..fb09387 100644 --- a/tests/test_emu3.py +++ b/tests/test_emu3.py @@ -72,6 +72,11 @@ def test_probe_rejects_zeros_and_noise(tmp_path): def test_origin_probe_resolves_to_emu3(tmp_path): + """One half of ADR-0005: the header sits at byte 0 of the cooked stream. + + This is the common case and it resolves at offset 0, so on its own it never + exercises a non-zero origin -- the test below does that. + """ image = image_of(tmp_path, fixtures.emu3_disc(ONE_FOLDER)) origin = find_origin(image) assert origin is not None @@ -79,6 +84,29 @@ def test_origin_probe_resolves_to_emu3(tmp_path): assert origin.offset == 0 +def test_origin_resolves_when_the_pregap_is_inside_the_cooked_stream(tmp_path): + """The other half of ADR-0005: the pregap genuinely in the stream. + + ``test_origin_probe_resolves_to_emu3`` resolves at 0 and so never exercises + a non-zero origin. Here the 150 zeroed sectors really are in the reported + stream -- a hybrid disc or a raw rip -- and the resolved origin must be the + byte the header sits on, not zero. Getting it wrong reads as an empty disc. + """ + pregap = b"\x00" * (150 * 2048) + image = image_of(tmp_path, pregap + fixtures.emu3_disc(ONE_FOLDER), "gap.iso") + origin = find_origin(image) + assert origin is not None + assert origin.backend.name == "emu3" + assert origin.offset == 150 * 2048 + # And the samples resolve from the resolved origin exactly as at offset 0. + # The bug this guards: the bank-header and E-IV scans returned addresses + # relative to the file rather than the origin, so at a non-zero origin the + # banks listed and every one came back empty -- the silent empty-disc + # failure ADR-0005 exists to prevent, reached this time from inside the fs. + banks = {v.name: v for v in origin.backend.volumes(image, origin.offset)} + assert [f.name for f in banks["Proteus1Presets"].files] == ["Piano E0", "Piano A0"] + + # --- the folder table --------------------------------------------------- @@ -575,6 +603,35 @@ def test_an_eiv_bank_reports_only_its_own_samples(tmp_path): assert [f.name for f in volumes["Orchestralcolorz"].files] == ["Strings"] +def test_eiv_samples_resolve_when_the_pregap_is_inside_the_cooked_stream(tmp_path): + """ADR-0005 for the E-IV path, which locates records by a whole-image scan. + + That scan and the ``E3S1`` directory bind records at addresses the reader + later reads at ``offset + address``. Keyed from the file rather than the + origin, a 150-sector pregap left in the stream shifts every record so the + bind lands past it and each bank comes back empty -- a folder that lists and + yields nothing. Here the origin is non-zero and the samples must still come + out. + """ + pregap = b"\x00" * (150 * 2048) + image = image_of(tmp_path, pregap + fixtures.emu3_disc(EIV_FOLDERS, eiv=True), "gap.iso") + origin = find_origin(image) + assert origin is not None + assert origin.backend.name == "emu3" + assert origin.offset == 150 * 2048 + volumes = {v.name: v for v in origin.backend.volumes(image, origin.offset)} + scroggins = volumes["Scroggins Secret"].files + assert [f.name for f in scroggins] == ["Stage Door", "All Nines"] + # The listing alone is not enough here: E-IV records are read from an + # in-memory scan window, so a bank still *lists* under the file-relative + # scan. The PCM is what exposes the bug -- ``read_file`` adds the origin to + # the record address, so a scan keyed from the file read 1 024 zero bytes + # out of the pregap: a silent empty sample of the right length, not an error. + expected = fixtures.stereo_audio_block(frames=512 // 2)[: 512 * 2] + assert origin.backend.read_file(image, origin.offset, scroggins[0]) == expected + assert not any(v.note for v in volumes.values()) + + def test_a_single_sample_eiv_bank_still_binds(tmp_path): """A lone directory entry has no chain invariant, and is not lost for it. diff --git a/tests/test_extract.py b/tests/test_extract.py index dc1aca5..e22ec2b 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import struct import wave @@ -11,6 +12,7 @@ from samplerdisc.extract import Extracted, Skipped, extract_disc, safe_name, unique_path from samplerdisc.fs.akai import SAMPLE_HEADER_LEN, AkaiBackend from samplerdisc.fs.iso9660 import Iso9660Backend +from samplerdisc.fs.roland_s7xx import RolandS7xxBackend from samplerdisc.sample.akai import NotASample, parse from samplerdisc.wav import Loop, write_wav from tests import fixtures @@ -82,6 +84,42 @@ def test_unique_path_avoids_collisions(tmp_path): assert second.endswith("KICK_2.wav") +def _wav_pcm(path: str) -> bytes: + """The raw PCM of a written mono WAV, read back through its own frames.""" + with wave.open(path) as w: + return w.readframes(w.getnframes()) + + +def test_names_differing_only_in_case_extract_to_distinct_files_by_content(tmp_path): + """Two names that differ only in case must not fold into one file (issue #4). + + A case-insensitive filesystem (macOS) resolves ``C_6E`` and ``C_6e`` onto + one path, so ``unique_path`` writes the second under a ``_2`` suffix -- both + survive, and the two libraries' audio stays apart. The hazard the issue + files is a *verifier* that then looks a WAV up by its sanitised name and + reads the wrong one; the guard is to check by content. This pins that end to + end: extraction yields two distinct files, and the audio read back from them + is exactly the two clusters the disc wrote -- neither lost to the fold, + verified as a multiset of PCM rather than by name. It holds on both a + case-insensitive and a case-sensitive filesystem, differing only in whether + the second file wears the ``_2`` suffix. + """ + lower = fixtures.roland_sample("C_6E", (2,)) + upper = fixtures.roland_sample("C_6e", (3,)) + path = tmp_path / "case.iso" + path.write_bytes(fixtures.roland_s7xx_disc([lower, upper])) + out = tmp_path / "out" + results = list(extract_disc(FlatImage(path), RolandS7xxBackend(), 0, str(out))) + + written = [r.path for r in results if isinstance(r, Extracted)] + assert len(written) == 2 + # Two files on disk, not one silently overwriting the other. + assert len({os.path.basename(p) for p in written}) == 2 + assert sorted(_wav_pcm(p) for p in written) == sorted( + [fixtures.roland_cluster(2), fixtures.roland_cluster(3)] + ) + + # --- extraction ---------------------------------------------------------