Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 24 additions & 10 deletions src/samplerdisc/fs/emu3.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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))
Expand Down Expand Up @@ -785,15 +798,16 @@ def _declared_run(
start, length = struct.unpack_from("<II", head, OFF_BANK_SAMPLE_START)
return start, start + SAMPLE_AREA_PREAMBLE + length

def _eiv(self, image: SectorImage, banks: list[_Bank]):
def _eiv(self, image: SectorImage, offset: int, banks: list[_Bank]):
"""Locate E-IV sample directories and bind them to banks by address.

Returns ``(tags, bound)`` where ``bound`` maps a bank's ``start`` to
the ``(base, entries)`` proven to live at the address that start
predicts. A bank absent from the map has no confirmed samples and
predicts. Every address here is relative to ``offset``, the filesystem
origin, so a bank absent from the map has no confirmed samples and
stays listed with its note.
"""
tags = _eiv_scan(image)
tags = _eiv_scan(image, offset)
bases, corroborated = _eiv_bases(tags, _eiv_chains(_eiv_entries(tags)))
if not corroborated:
return tags, {}
Expand Down Expand Up @@ -855,7 +869,7 @@ def volumes(self, image: SectorImage, offset: int) -> 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)
Expand Down
18 changes: 16 additions & 2 deletions tests/test_akai_fs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions tests/test_emu3.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,41 @@ 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
assert origin.backend.name == "emu3"
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 ---------------------------------------------------


Expand Down Expand Up @@ -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.

Expand Down
38 changes: 38 additions & 0 deletions tests/test_extract.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from __future__ import annotations

import os
import struct
import wave

Expand All @@ -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
Expand Down Expand Up @@ -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 ---------------------------------------------------------


Expand Down
Loading