From 71cdeafe4d1ac1ad71e75d3d7d9fe7a94fe6b3ae Mon Sep 17 00:00:00 2001 From: Peter Tos <38345014+bmxcode@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:31:03 +1000 Subject: [PATCH] AKAI: read every partition, from the table the disk declares An AKAI disc is a disk image and the walk stopped at the partition the origin resolved to, so 44 discs listed 448 volumes and 14 670 files of the 2 154 and 68 997 they hold (#22). The disk declares its partitions: a table at 0x4500 of the first one, carrying a count, that many sizes in blocks and the disk's total. All 44 discs have one and on all 44 the sizes sum to the total. A partition is read only where a header sits at the position the table gives and restates the size it was given, and a declared position with no header is skipped rather than searched for. Block numbers count from the partition they are in, so a Volume and a File now carry the offset theirs count from and read_file adds it. Extraction nests under the partition, since volume names repeat. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 12 + docs/README.md | 3 +- ...lume-is-explained-by-the-allocation-map.md | 2 +- ...s-come-from-the-table-the-disc-declares.md | 65 ++++++ docs/adr/README.md | 1 + docs/architecture.md | 2 +- docs/formats/akai-fs.md | 58 ++++- src/samplerdisc/batch.py | 17 +- src/samplerdisc/cli.py | 26 ++- src/samplerdisc/extract.py | 59 ++++- src/samplerdisc/fs/akai.py | 218 ++++++++++++++++-- src/samplerdisc/fs/base.py | 27 +++ tests/fixtures.py | 42 ++++ tests/test_akai_fs.py | 144 ++++++++++++ tests/test_batch.py | 8 +- tests/test_discs.py | 175 ++++++++++++-- tests/test_extract.py | 40 +++- tests/test_keep_originals.py | 10 +- tests/test_loops.py | 8 +- tests/test_stereo.py | 8 +- 20 files changed, 842 insertions(+), 83 deletions(-) create mode 100644 docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md diff --git a/CHANGELOG.md b/CHANGELOG.md index ff6fe8b..fe32e32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ Notable changes to `samplerdisc`. Format-level findings live in [docs/formats/]( ### Added +- **Every partition of an AKAI disc is read, not just the first.** An AKAI disc is a disk image — several partitions laid end to end — and the walk stopped at the one the origin resolved to. Across the 44 AKAI discs on the shelf that is the difference between **448 volumes and 14 670 files** and **2 154 volumes and 68 997 files**; the collection goes from 872 volumes and 56 662 files to **2 578 and 110 989**. `Loop Soup` alone goes from 7 volumes to 60. ([docs/formats/akai-fs.md](docs/formats/akai-fs.md), [ADR-0023](docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md), [#22](https://github.com/bmxcode/samplerdisc/issues/22)) + + The partitions are not guessed at. The disk declares them, in a table at `0x4500` of the first partition: a count, that many sizes in blocks, then the disk's total. All 44 discs carry one and on all 44 the sizes sum to the total. A partition is read only where a header sits at the position the table gives and restates the size the table gave it. + + Of the 44 174 samples this adds, **44 101 (99.83 %)** carry a payload header whose name matches the directory entry that placed them — the same rate as the partitions already being read. Partition 1's numbers do not move on any disc, and are pinned per disc so they cannot. + +- **`list` says how many partitions the disc declares and how many the image holds.** `Kickin' Lunatic Beats 2 CD1` declares eleven and holds one: that image is short of the disc it was made from, and the ten missing partitions were previously an absence with nothing to see. ([ADR-0023](docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md)) + - **Emulator IV discs extract their samples.** All three E-IV discs in the reference collection previously listed their banks with correct names and yielded nothing; they now give **449, 2 822 and 828 samples**. E-IV banks carry no `EMULATOR` header — not one occurrence across 1.2 GB — and are reached through a chained `E3S1` sample directory instead, whose big-endian length is what sizes each sample. ([docs/formats/emu3.md](docs/formats/emu3.md), [ADR-0020](docs/adr/0020-read-e-iv-through-its-sample-directory.md)) [ADR-0015](docs/adr/0015-locate-banks-by-signature.md) held this back deliberately and conditionally, on the grounds that one specimen cannot distinguish a format from that disc's quirks. Three discs from two publishers met the condition, and the third earned its place: two constants that hold perfectly on the two Producer Series discs fail outright on the Miroslav Vitous one, and a two-disc study would have written one of them down as fact. @@ -16,6 +24,10 @@ Notable changes to `samplerdisc`. Format-level findings live in [docs/formats/]( - **The AKAI partition's block allocation map is read.** At `0x70A`, one u16 per block, as many as the partition declares at `0x00`. It is the disc's own record of what every block holds, and it is verified rather than merely plausible: a file's chain length and the size its directory entry declares come from two different structures, and across all 44 AKAI discs they agree for **14 607 of 14 607 files**, exactly. `tests/test_discs.py` asserts that per disc, so an AKAI image that starts decoding wrongly now has something to fail against instead of presenting as a disc with less on it. ([docs/formats/akai-fs.md](docs/formats/akai-fs.md), [ADR-0022](docs/adr/0022-a-volume-is-explained-by-the-allocation-map.md)) +### Changed + +- **AKAI samples extract under their partition**: `out/partition-1/VOLUME 001/…` where it was `out/VOLUME 001/…`, on every AKAI disc including single-partition ones. Volume names repeat across a disc's partitions — nearly every one has a `VOLUME 001` — so a flat layout put two libraries' audio in one directory under `_2` suffixes with nothing saying which was which. The batch manifest keys volumes by partition and name for the same reason. ([ADR-0023](docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md)) + ### Fixed - **An AKAI volume that lists nothing now says why, in the disc's own words.** Ten volumes across three discs listed empty with no explanation, which is exactly the [ADR-0012](docs/adr/0012-a-probe-must-confirm-a-file.md) signature — it reads as an empty volume rather than as a wrong answer. Each now carries a note naming the block and what the partition's allocation map says is in it: file data on `Advance Orchestra` ×4 and the OMI disc, a free block on `Kickin' Lunatic Beats 2 CD1`, and — for four more on that disc — a block the disc says *is* a volume directory and the image has none at. ([#16](https://github.com/bmxcode/samplerdisc/issues/16), [#17](https://github.com/bmxcode/samplerdisc/issues/17), [ADR-0022](docs/adr/0022-a-volume-is-explained-by-the-allocation-map.md)) diff --git a/docs/README.md b/docs/README.md index f8be71c..bd707c6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -54,8 +54,9 @@ All of that vanishes into a working parser. Six months on, the code says *what* | D12 | E-mu `EMU3` backend ([ADR-0014](adr/0014-one-backend-per-on-disc-format.md), [ADR-0015](adr/0015-locate-banks-by-signature.md)) | done | | D13 | Roland `S770 MR25A` backend ([ADR-0016](adr/0016-the-s7xx-hierarchy-is-located-not-walked.md), [ADR-0017](adr/0017-the-stereo-side-marker-is-a-character-class.md), [ADR-0018](adr/0018-the-s7xx-sample-rate-is-measured.md)) | done | | D14 | E-mu Emulator IV bank extraction ([ADR-0020](adr/0020-read-e-iv-through-its-sample-directory.md)) | done | +| D15 | Every partition of an AKAI disc, from the table it declares ([ADR-0023](adr/0023-partitions-come-from-the-table-the-disc-declares.md)) | done | -Across the local collection: 39 of 49 discs, 28 712 samples, 2 864 stereo pairs, 161 audio tracks, 20 skipped entries, 30 s. +Across the local collection, by listing: 71 of 79 images claimed, 2 578 volumes, 110 989 files, 77 620 of them samples. The AKAI discs are 44 of those images and 68 997 of those files, read across 276 partitions — before D15 they were 14 670 files, because only the partition at the origin was read. ## What is not done diff --git a/docs/adr/0022-a-volume-is-explained-by-the-allocation-map.md b/docs/adr/0022-a-volume-is-explained-by-the-allocation-map.md index 310ba4b..0d1c695 100644 --- a/docs/adr/0022-a-volume-is-explained-by-the-allocation-map.md +++ b/docs/adr/0022-a-volume-is-explained-by-the-allocation-map.md @@ -1,6 +1,6 @@ # ADR-0022 · An AKAI volume's emptiness is explained by the partition's allocation map -**Status:** accepted · 2026-08-21 +**Status:** accepted · 2026-08-21 · *amended by [ADR-0023](0023-partitions-come-from-the-table-the-disc-declares.md): "no map, no note" now turns on the header restating its block count, rather than on the count fitting the image, so a partition the image ends inside keeps the map for the blocks it holds.* ## Context diff --git a/docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md b/docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md new file mode 100644 index 0000000..732cfad --- /dev/null +++ b/docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md @@ -0,0 +1,65 @@ +# ADR-0023 · An AKAI disc's partitions come from the table it declares + +**Status:** accepted · 2026-08-21 + +## Context + +An AKAI disc is a **disk image**. The sampler wrote a hard disk of several partitions and the CD is a copy of it, so a disc of 66 000 blocks carries nine partitions of 7680 rather than one. `AkaiBackend.volumes()` walked the partition at the resolved origin and stopped, which is [issue #22](https://github.com/bmxcode/samplerdisc/issues/22): across the 44 AKAI discs on the shelf that listed **448 volumes and 14 670 files** of the **2 154 volumes and 68 997 files** they hold. + +The issue recorded partitions as tiling **at multiples of the size declared at `0x00`** — an observation across 44 discs, not a field anyone had read — and named three discs it did not fit. It also named the obvious unread candidate: the 100 u16 at `0x02` that carry the same ramp on every disc. + +Both questions have answers, and neither is the one the issue expected. + +**The field at `0x02` declares nothing about other partitions.** Its 196 bytes are byte-identical on every disc *and every partition* — `3333 × i`, i = 0…97 — so nothing in it varies with anything. What does vary is the pair after it: the u16 at `0xC6` is the block count at `0x00` plus 47573, and the u16 at `0xC8` is 47, on all 276 partitions measured. That is the header restating its own size, not a table of anyone else's. + +**The table is at `0x4500`**, in the first partition, past the largest allocation map that fits in front of it: a u8 count, a u8 flag, that many u16 partition sizes, then the disk's total in blocks. **All 44 discs carry one, and on all 44 the sizes sum to the total.** `Loop Soup` declares nine partitions — eight of 7680 blocks and a last of 4095, totalling 65 535, which is the ceiling u16 block numbers imply. + +That last entry is what tiling could not have got right. The sizes are **not all equal**: the final partition is a remainder. Tiling reproduces the same partitions everywhere both can be applied, and then invents a fourteenth on `AKAI.S3000.Sound.Library.1` at block 65 535 that the table does not declare. + +The three discs the issue could not explain are explained too, and not by the filesystem. Where a declared partition has no header at its declared position, a header turns up *earlier*, and the slippage accumulates down the disc: `Library.6` by 60 blocks and then 68, `Elektra Vox` from 424 to 2888 over nine partitions. **Every displacement is a whole number of 32 KB MDX blocks**, and every one of those images is `.mdx`. They are incomplete rips, the same finding [#17](https://github.com/bmxcode/samplerdisc/issues/17) reached from inside partition 1; the full table is in [the format doc](../formats/akai-fs.md). + +## Decision + +**Partitions come from the table the disk declares, and the header at each declared position confirms it.** + +Four parts: + +**The table places them.** Each partition begins where the sizes before it end. The table is refused unless its sizes sum to its total, which is the check that tells a table from whatever else could land at a fixed offset — the two are written separately and agree on all 44 discs. + +**The header confirms them.** A partition is read only where the constant field, the size echo at `0xC6` and the tail at `0xC8` all hold *and* the size the header declares is the size the table gave it. Placement and confirmation come from different structures, which is the shape [ADR-0020](0020-read-e-iv-through-its-sample-directory.md) and [ADR-0021](0021-a-bank-owns-the-run-its-header-declares.md) already use: locate by one thing, confirm by another, and never place anything by arithmetic no field agrees with. + +**A declared position with no header is skipped, never searched for.** Because the table gives absolute positions, one missing header costs its own partition and nothing after it — `Best Service Brass Super Section CD1` has an unwritten partition 6 and reads 7, 8 and 9 regardless. + +**Block numbers stay the numbers the directory declares.** A `Volume` and a `File` carry the byte offset their blocks count from, and `read_file` adds it. Rewriting block numbers to be disc-relative would have been less plumbing and would have made every note, every allocation-map lookup and every future check speak in numbers no structure on the disc states. + +Volume names repeat across partitions — nearly every one has a `VOLUME 001` — so extraction writes each volume **under its partition**: `out/partition-2/SOUP 120/`. `list` prints how many partitions the disk declares against how many the image holds. + +## Alternatives rejected + +**Tile at multiples of the first partition's size**, as the issue observed. Rejected on the register [ADR-0021](0021-a-bank-owns-the-run-its-header-declares.md) settled: a field the disc states beats a bound inferred between structures, and here the field exists. The measurement agrees — tiling and the table place the same partitions on every disc — but the two disagree exactly where a rule is worth having: the last partition is a remainder rather than a full size, and tiling puts a fourteenth partition on `Library.1` where the disk declares thirteen. + +**Chain each header's own declared size**, taking the next partition to begin where this one's `0x00` says it ends. Genuinely attractive: it is also a declared field, and measured across the collection it yields **exactly the same 2 154 volumes and 68 997 files**. Rejected because it is a chain — a missing header takes everything after it with it, since the next position is unknown — and because it is one field where the table is a statement about the disk as a whole. It has the same fourteenth-partition fault as tiling. + +**Locate every partition by scanning for the header signature.** This is [ADR-0015](0015-locate-banks-by-signature.md)'s instrument and it recovers 15 006 more files, so it was measured rather than dismissed. Rejected on three counts, each sufficient. The signature is a **sawtooth waveform** — `3333 × i` as PCM — and audio reproduces it: 374 blocks of `Global Trance Mission 2` and 153 of `ProSamples vol.14` match, every one in a block the allocation map calls free. Filtering those with the file-yielding test then *loses* real partitions elsewhere, 94 volumes down to 72 on `Advanced Media Trax 3`. And what it gains comes almost entirely from short images, where the audio is displaced: 7 723 of those files are on `Kickin' Lunatic Beats 2 CD1`, the disc whose partition 1 already extracts nine samples that are not their own ([#23](https://github.com/bmxcode/samplerdisc/issues/23)). Recovering a short image's partitions is deferred to [#25](https://github.com/bmxcode/samplerdisc/issues/25), with the displacements written down. + +**Walk partitions in `probe()`/`find_origin`.** Rejected: a partition is an AKAI notion and origin resolution is shared with every backend, so this would put brand knowledge in the layer [ADR-0003](0003-brand-neutral-pluggable-backends.md) keeps clear of it. `Backend.volumes(image, offset)` already means "walk the filesystem rooted here", and for AKAI the filesystem is all of its partitions. The probe still resolves exactly one origin and is not touched. + +**Rewrite block numbers as disc-relative when the walk yields them.** No plumbing at all: `read_file` would keep working untouched. Rejected because it discards the only numbers the disc actually states. The allocation map is per partition and indexed by the partition's own block numbers, so the ADR-0022 notes and the chain check would both have to undo the rewrite, and a `list` line would name a block no structure on that disc mentions. + +**Nest extraction under the partition only where a disc has more than one.** Backwards compatible, and rejected for making the output layout depend on the disc: a script that finds `VOLUME 001` in one place on one disc and another on the next is worse than one that moved once. + +## Consequences + +**Good.** The 44 AKAI discs go from 448 volumes and 14 670 files to **2 154 and 68 997**; the collection from 872 and 56 662 to **2 578 and 110 989**. Partition 1 does not move on any disc — 448 volumes and 14 670 files before and after, pinned per disc in `tests/test_discs.py`, which is the control on the origin arithmetic. + +**Good.** The new files verify against a structure that did not place them. Of 44 174 samples past the first partition, **44 101 (99.83 %)** carry a payload header whose id, valid byte and name match the directory entry — the same rate as partition 1, whose 19 mismatches include #23's nine. On `Loop Soup` all 3 200 agree exactly, and that is a test. + +**Good.** A short image is now a stated fact: `list` prints eleven partitions declared against one present, where before the other ten were an absence nobody could see. + +**Bad.** Extraction paths change for every AKAI disc, single-partition ones included. Anyone with a script pointing at `out/VOLUME 001` needs `out/partition-1/VOLUME 001`. + +**Bad, and stated plainly.** Roughly fifteen thousand files on short images stay unread, and they are readable — a search would find their partitions ([#25](https://github.com/bmxcode/samplerdisc/issues/25)). That is deliberate: the audio on those images is displaced by the missing blocks, and [#23](https://github.com/bmxcode/samplerdisc/issues/23) is the standing evidence of what displaced audio extracts as. + +**Watch for.** A disc whose first partition is damaged. The table lives there, and nowhere else that has been found, so losing it drops the disc back to reading one partition — quietly, since that is also the honest fallback for a disc that declares no table. + +**Watch for.** `allocation_map` now takes its block count from a header that restates it rather than from what the image can hold, so a partition the image *ends inside* gets the map for the blocks present. That is what stopped two volumes on `ProSamples vol.17` being empty and silent, and it narrows [ADR-0022](0022-a-volume-is-explained-by-the-allocation-map.md)'s "no map, no note" to a count that is absent or unvouched for. diff --git a/docs/adr/README.md b/docs/adr/README.md index 7918e55..4018f6c 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -28,3 +28,4 @@ If you find yourself writing an ADR with no rejected alternative, you are writin | [0020](0020-read-e-iv-through-its-sample-directory.md) | Read E-IV through its `E3S1` sample directory | Waiting for a fourth disc; arithmetic on `start`; a signature walk; an `EMU4` backend; a stereo channel count | | [0021](0021-a-bank-owns-the-run-its-header-declares.md) | An EIII/ESI bank owns the record run its own header declares | Bounding by the directory's `length`; deduplicating headers by address; dropping repeated names; listing the `4k` banks with a note; a looser header match | | [0022](0022-a-volume-is-explained-by-the-allocation-map.md) | An AKAI volume's emptiness is explained by the partition's allocation map | Rejecting type 0; requiring the directory to parse; using the map as an allocation flag; recovering the displaced directories; calling the damage in the note | +| [0023](0023-partitions-come-from-the-table-the-disc-declares.md) | An AKAI disc's partitions come from the table it declares | Tiling at multiples of the first size; chaining each header's own size; locating headers by signature; walking partitions in the probe; rewriting block numbers as disc-relative; nesting extraction only where a disc has several | diff --git a/docs/architecture.md b/docs/architecture.md index a386012..c9914e1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -56,7 +56,7 @@ class Backend: `probe()` runs at every candidate offset during origin detection, so it must be cheap, and specific enough not to match zeros or audio. -`fs/akai.py` walks `Partition → Volume → File` ([akai-fs.md](formats/akai-fs.md)). AKAI caps a partition at 512 MB, so a large disc carries several — walk the table rather than assuming one. `fs/iso9660.py` covers discs whose payload is already WAV or AIFF, which is a meaningful share of the archives; it walks the Joliet supplementary descriptor where a disc carries one, because the primary descriptor's 8.3 names are lossy and on a real disc are not even unique — 61 of Vintage Pro's files share one ([iso9660.md](formats/iso9660.md), [ADR-0019](adr/0019-prefer-joliet-names.md)). +`fs/akai.py` walks `Partition → Volume → File` ([akai-fs.md](formats/akai-fs.md)). A disc is a *disk* of several partitions of 31–63 MB, and the disk lists them in a table it carries at `0x4500` of the first one — the walk reads that table and confirms a header at each position it gives, rather than assuming one partition or multiplying the first one's size out ([ADR-0023](adr/0023-partitions-come-from-the-table-the-disc-declares.md)). Block numbers count from the partition they are in, so a volume and a file carry the offset theirs count from. `fs/iso9660.py` covers discs whose payload is already WAV or AIFF, which is a meaningful share of the archives; it walks the Joliet supplementary descriptor where a disc carries one, because the primary descriptor's 8.3 names are lossy and on a real disc are not even unique — 61 of Vintage Pro's files share one ([iso9660.md](formats/iso9660.md), [ADR-0019](adr/0019-prefer-joliet-names.md)). Damaged input degrades rather than crashing: entries whose start block or size fall outside the image are skipped and logged. Several of these rips have tail damage, and a disc yielding 400 of 420 samples is a good outcome. diff --git a/docs/formats/akai-fs.md b/docs/formats/akai-fs.md index 817852c..1b2f423 100644 --- a/docs/formats/akai-fs.md +++ b/docs/formats/akai-fs.md @@ -23,11 +23,18 @@ Getting index 10 wrong is the classic failure and it is not obvious: `KICKIN B0- | Offset | Contents | |---|---| | `0x00` | u16 LE, **partition size in blocks** — 3840 to 7680 across the 44 discs measured | -| `0x02` | 100 u16, one per volume slot; every disc holds the same ramp of 3333 per entry and nothing reads it | +| `0x02` | 196 bytes of constant, `3333 × i` as u16 LE for i = 0…97. Byte-identical on every disc and every partition; nothing is known to read it | +| `0xC6` | u16 LE, **the size at `0x00` plus 47573** — the header restating its own size | +| `0xC8` | u16 LE, 47 on every header measured. Unexplained | | `0xCA` | **volume directory** — 100 entries of 16 bytes | | `0x70A` | **block allocation map** — one u16 per block, as many as `0x00` declares | +| `0x4500` | **partition table** — the disk's own list of its partitions, in the first partition only | -The size at `0x00` is what bounds the allocation map, and it is much smaller than the disc: a partition of 7680 blocks is 62.9 MB inside an image of 500 MB or more. A large disc carries **several partitions**, laid end to end — see *More than one partition* below. +`0x02`, `0xC6` and `0xC8` all hold on all **276 partitions** of the 44 AKAI discs, and together they are what identifies a partition header. The two size fields matter most: a block count with no echo behind it is not a partition's, which is a firmer test than anything about the image's length. + +**The pattern at `0x02` is a rising sawtooth, and sample data reproduces it.** As 16-bit PCM, `3333 × i` is a saw wave, so audio does match it — 374 blocks of one disc's free space carry a complete header prefix, every one in a block the allocation map calls free. That is why a partition header is *confirmed* where the table says one is, and never scanned for ([ADR-0023](../adr/0023-partitions-come-from-the-table-the-disc-declares.md)). + +The size at `0x00` is what bounds the allocation map, and it is much smaller than the disc: a partition of 7680 blocks is 62.9 MB inside an image of 500 MB or more. A disc carries **several partitions** — see *More than one partition* below. Volume entry, 16 bytes: @@ -67,7 +74,9 @@ At `0x70A`, immediately after the volume directory's hundred slots: **one u16 pe | `0x8000` | seen 14 times on one disc, never under a volume; unidentified | | `0xC000` | last block of a chain | -A file's extent is the chain from its start block to `0xC000`. **That is what verifies the map rather than merely making it plausible**: the chain length and the file size are stated by two different structures, and across all 44 AKAI discs they agree for **14 607 of 14 607 files** — exactly, with no disc disagreeing anywhere. +A file's extent is the chain from its start block to `0xC000`. **That is what verifies the map rather than merely making it plausible**: the chain length and the file size are stated by two different structures, and across all 44 AKAI discs they agree for **14 607 of 14 607 files** in the first partitions — exactly, with no disc disagreeing anywhere. + +Read across all 276 partitions the figure is **68 267 of 68 284**, and the seventeen exceptions are one thing rather than a scatter: every one is a `MULTI FILE` — type `m`, all on `AKAI.S3000.Sound.Library.1` — whose chain runs exactly one block past what its size needs. A multi appears to be allocated a spare block. It is the only kind that disagrees anywhere. The exclusion behind that figure is itself the finding. Five volumes sit on blocks the map calls free, and their files' chains are gone with them; four of those five hold 63 files that read perfectly. These are **deleted volumes**: the blocks went back to the free list, and because the medium is a mastered CD-ROM nothing ever reused them, so the directory and the audio are still there to be read. They are listed with their files like any other volume. @@ -91,11 +100,26 @@ Note the asymmetry: a chain link is positive evidence that the slot was never us ## More than one partition -Partitions are laid **end to end at multiples of the size declared at `0x00`**, and they hold real content. At block `size` on `Advance Orchestra` there is a second partition header — same 3333 ramp, its own volume directory, first volume `01 VA SUS F` — and another at `2 × size`. This holds across nearly every disc measured. +An AKAI disc is a **disk image**: a disk of several partitions laid end to end, mastered onto a CD. `Advance Orchestra` declares 7680 blocks of an image of 66 616, and the rest is eight more partitions. + +**The disk declares them.** At `0x4500` of the first partition — block 2, past the largest allocation map that fits in front of it — is the partition table: + +| Offset | Contents | +|---|---| +| `+0x00` | u8, the **number of partitions** | +| `+0x01` | u8, 0 on 37 discs and 1 on the other 7. Unexplained | +| `+0x02` | that many u16 LE **partition sizes**, in blocks, in order | +| `+2n+2` | u16 LE, the **total blocks on the disk** | + +All 44 AKAI discs carry one, and on all 44 the sizes sum to the total. `Loop Soup` reads `09 00 | 1e00 ×8 | 0fff | ffff`: nine partitions, eight of 7680 blocks and a last one of 4095, totalling 65 535. -**Only the first partition is read today**, so most of these discs list a fraction of what they hold. That is [issue #22](https://github.com/bmxcode/samplerdisc/issues/22) and not a small one: `Advance Orchestra` declares 7680 blocks of an image of 66 616. +That last entry is why the table is worth reading rather than multiplying the first size out. **The sizes are not all equal**: the final partition is a remainder, and the total never exceeded **65 535 blocks** — 512 MB, the ceiling u16 block numbers imply — across every disc measured. Tiling agrees with the table wherever both can be applied, but on `AKAI.S3000.Sound.Library.1` it invents a fourteenth partition at block 65 535 that the table does not declare and that holds nothing. -The tiling is also a usable integrity check on the image, and it is how the damage below was confirmed independently of anything inside partition 1. +A partition's own blocks are numbered from **its** start, so the same block number means a different place in each one, and a file read with the partition term dropped returns another partition's audio rather than an error. + +Where the image holds no header at a position the table declares, it is **skipped and not searched for**. On the discs where that happens the header turns up displaced by a whole number of the container's 32 KB blocks — the image is short of the disc it was made from, which is the fault below and not a filesystem to go hunting through. + +Across the 44 discs the table places 276 partitions holding **2 154 volumes and 68 997 files**, against the 448 volumes and 14 670 files of the first partitions alone. ## An image can be short @@ -108,6 +132,26 @@ Two structures agree on the displacement and neither knows about the other: 131 072 bytes is exactly four MDX blocks, which is a quantity of the *container* and one the AKAI filesystem knows nothing about — that is what identifies the layer at fault. `CD2` of the same pair is short by one such block (its partition 2 is at `size − 4`), and its partition 1 happens to survive it intact. +**The partition table turns that into a measurement anyone can repeat**, and it explains the three discs that looked like a different layout. Where a declared partition has no header at its declared position, searching the image finds one *earlier*, always by a whole number of 32 KB container blocks: + +| Disc | Declared | Present | Displacement, first to last | +|---|---|---|---| +| `AMG - Kickin' Lunatic Beats 2 CD2` | 9 | 1 | 4 blocks, on all eight | +| `Best Service - Alpha Dance I` | 5 | 4 | 4 | +| `AKAI.S3000.Sound.Library.5` | 9 | 3 | 8 … 36 | +| `AMG - Global Trance Mission 2` | 9 | 4 | 8 … 32 | +| `Audio Factory - Classical Wild Takes` | 11 | 6 | 16, on all five | +| `AMG - Kickin' Lunatic Beats 2 CD1` | 11 | 1 | 16 … 336 | +| `AKAI.S3000.Sound.Library.6` | 9 | 1 | 60 … 68 | +| `Back In Time Rrcords - Elektra Vox` | 13 | 1 | 424 … 2888 | +| `AKAI.S3000.Sound.Library.7` | 11 | 1 | 1456 … 7288 | + +Every displacement is a multiple of 4 blocks — 32 768 bytes, one MDX block — and every one of these images is `.mdx`. They also **accumulate**: `Elektra Vox` slips by 424 blocks, then another 64, then 156, and so on down the disc, which is what a rip losing blocks here and there looks like from inside the filesystem. These are not discs laid out differently; they are incomplete rips. Recovering their partitions would mean locating headers by search, which [ADR-0023](../adr/0023-partitions-come-from-the-table-the-disc-declares.md) declines to do and [issue #25](https://github.com/bmxcode/samplerdisc/issues/25) records. + +A missing partition is not always damage, and the two are distinguishable: on the ProSamples discs the declared partitions that are absent have **no header anywhere near** them, because the CD carries only the front of a larger disk or the mastering never wrote them. Displacement is the tell, not absence. + +A short image also shows up in the table's arithmetic alone: `Kickin' Lunatic Beats 2 CD1` declares eleven partitions where the image holds one, and `ProSamples vol.54` declares nine of a 63 488-block disk on a CD of 30 720 blocks — the second of those is not damage, it is a CD carrying only the front of a larger disk. Declared against present is worth printing for that reason: it names the gap without diagnosing it. + The visible consequence inside partition 1 is not only the four empty volumes. **Nine files in `13-TRACK 06` extract audio that is not theirs**: their payload header no longer matches the name the directory gives them, because everything past the first gap has slid. A payload whose header disagrees with its directory entry is a cheap check that would catch this and is not made anywhere yet — [issue #23](https://github.com/bmxcode/samplerdisc/issues/23). Observed volume names, useful as a smoke test that charset and offsets are both right: @@ -130,7 +174,7 @@ Three pieces of evidence settle it on the table above: An early hand decode of `black2black` using the other table produced `KICKIN B0-F1` — a full step out on every key range, and entirely believable. If a name looks *almost* right, suspect this table before suspecting the offsets. -A partition caps at 512 MB, so a large disc carries several. Walk the partition table rather than assuming one partition at the origin. +**512 MB is the disk, not the partition.** Block numbers are u16, so a disk cannot exceed 65 536 blocks, and none of the 44 discs declares a total above 65 535. A single *partition* runs 3840 to 7680 blocks, 31 to 63 MB, and cannot grow much past that while the table stays at its fixed `0x4500`: the allocation map of a 7680-block partition already ends at `0x430A`, 502 bytes short of it. ## What a probe must confirm diff --git a/src/samplerdisc/batch.py b/src/samplerdisc/batch.py index 848896d..d2bdd16 100644 --- a/src/samplerdisc/batch.py +++ b/src/samplerdisc/batch.py @@ -88,14 +88,20 @@ def convert_disc( report.origin = origin.offset out_dir = os.path.join(out_root, safe_name(os.path.splitext(os.path.basename(path))[0])) - volumes: dict[str, dict[str, Any]] = {} + # Keyed by partition *and* name: nearly every partition of an AKAI + # disc has a "VOLUME 001", and keying by name alone reported nine + # volumes' samples as one entry (ADR-0023). + volumes: dict[tuple[int, str], dict[str, Any]] = {} results = extract_disc( image, origin.backend, origin.offset, out_dir, join_stereo, keep_originals ) for result in results: if isinstance(result, Extracted): report.samples += 1 - entry = volumes.setdefault(result.volume, {"name": result.volume, "samples": 0}) + entry = volumes.setdefault( + (result.partition, result.volume), + {"name": result.volume, "partition": result.partition, "samples": 0}, + ) entry["samples"] += 1 elif isinstance(result, Joined): report.stereo_pairs += 1 @@ -103,7 +109,12 @@ def convert_disc( report.originals += 1 elif isinstance(result, Skipped): report.skipped.append( - {"volume": result.volume, "name": result.name, "reason": result.reason} + { + "volume": result.volume, + "partition": result.partition, + "name": result.name, + "reason": result.reason, + } ) report.volumes = list(volumes.values()) except (OSError, ValueError) as exc: diff --git a/src/samplerdisc/cli.py b/src/samplerdisc/cli.py index 0e7f066..fb2d1fb 100644 --- a/src/samplerdisc/cli.py +++ b/src/samplerdisc/cli.py @@ -33,20 +33,40 @@ def cmd_list(args: argparse.Namespace) -> int: if origin is None: print("no recognised filesystem -- try `samplerdisc export-iso`", file=sys.stderr) return 1 + # How the disc is laid out, where the backend has something to say -- + # for AKAI, how many partitions the disk declares against how many this + # image holds, which is how a short rip stops being a silent absence. + describe = getattr(origin.backend, "layout", None) + described = describe(image, origin.offset) if describe is not None else "" + if described: + print(described) volumes = 0 + partitions = 0 kinds: Counter[str] = Counter() + current = 0 for volume in origin.backend.volumes(image, origin.offset): volumes += 1 + if volume.partition and volume.partition != current: + current = volume.partition + partitions += 1 + print(f"\npartition {volume.partition}") + # Volumes sit under their partition where there is one, because + # their names repeat across partitions (ADR-0023). + indent = " " if volume.partition else "" note = f" -- {volume.note}" if volume.note else "" - print(f"{volume.name} (block {volume.start_block}, {len(volume.files)} files){note}") + print( + f"{indent}{volume.name} (block {volume.start_block}, " + f"{len(volume.files)} files){note}" + ) for entry in volume.files: kinds[entry.kind] += 1 if not args.volumes_only: - print(f" {entry.name:<14} {entry.kind:<8} {entry.size:>9} bytes") + print(f"{indent} {entry.name:<14} {entry.kind:<8} {entry.size:>9} bytes") # Kinds are the backend's vocabulary, not ours: AKAI says sample and # program, ISO 9660 says wav and aiff. breakdown = ", ".join(f"{count} {kind}" for kind, count in sorted(kinds.items())) - print(f"\n{volumes} volumes" + (f", {breakdown}" if breakdown else "")) + across = f" across {partitions} partitions" if partitions > 1 else "" + print(f"\n{volumes} volumes{across}" + (f", {breakdown}" if breakdown else "")) return 0 diff --git a/src/samplerdisc/extract.py b/src/samplerdisc/extract.py index 0346125..189c4d4 100644 --- a/src/samplerdisc/extract.py +++ b/src/samplerdisc/extract.py @@ -85,6 +85,10 @@ class Extracted: rate: int frames: int pitch: int + #: Which partition of the disc the volume came from, 0 where the + #: filesystem has no partitions. AKAI volume names repeat across + #: partitions, so the name alone does not identify a volume (ADR-0023). + partition: int = 0 @dataclass @@ -92,6 +96,7 @@ class Skipped: volume: str name: str reason: str + partition: int = 0 @dataclass @@ -102,6 +107,7 @@ class Kept: name: str path: str kind: str + partition: int = 0 @dataclass @@ -113,6 +119,7 @@ class Joined: path: str rate: int frames: int + partition: int = 0 def extract_volume( @@ -146,7 +153,13 @@ def extract_volume( ) with open(kept_path, "wb") as out: out.write(payload) - yield Kept(volume=volume.name, name=entry.name, path=kept_path, kind=entry.kind) + yield Kept( + volume=volume.name, + name=entry.name, + path=kept_path, + kind=entry.kind, + partition=volume.partition, + ) if entry.kind in _AUDIO_FILE_KINDS: # Already an audio file -- an ISO 9660 disc whose payload is plain # WAV or AIFF. Copy it out untouched; there is nothing to decode. @@ -161,18 +174,18 @@ def extract_volume( try: payload = backend.read_file(image, origin, entry) except OSError as exc: # pragma: no cover - filesystem-level failure - yield Skipped(volume.name, entry.name, f"unreadable: {exc}") + yield Skipped(volume.name, entry.name, f"unreadable: {exc}", volume.partition) continue if not payload: - yield Skipped(volume.name, entry.name, "no data on disc") + yield Skipped(volume.name, entry.name, "no data on disc", volume.partition) continue try: sample = _parse_sample(backend, entry, payload) except NotASample as exc: - yield Skipped(volume.name, entry.name, str(exc)) + yield Skipped(volume.name, entry.name, str(exc), volume.partition) continue if sample.frames == 0: - yield Skipped(volume.name, entry.name, "zero-length sample") + yield Skipped(volume.name, entry.name, "zero-length sample", volume.partition) continue if not made: @@ -199,14 +212,15 @@ def extract_volume( rate=sample.rate, frames=sample.frames, pitch=pitch if pitch is not None else 0, + partition=volume.partition, ) if join_stereo: - yield from _join_pairs(volume.name, parsed, out_dir) + yield from _join_pairs(volume, parsed, out_dir) def _join_pairs( - volume_name: str, parsed: dict[str, _Pairable], out_dir: str + volume: Volume, parsed: dict[str, _Pairable], out_dir: str ) -> Iterator[Skipped | Joined]: pairs = find_pairs(list(parsed)) if not pairs: @@ -220,9 +234,10 @@ def _join_pairs( # Different rates means these are not two halves of one sound, # whatever the names say. yield Skipped( - volume_name, + volume.name, pair.base, f"rate mismatch between halves ({left.rate} vs {right.rate})", + volume.partition, ) continue if not made: @@ -245,7 +260,14 @@ def _join_pairs( loops=_wav_loops(left), name=pair.base, ) - yield Joined(volume=volume_name, name=pair.base, path=path, rate=left.rate, frames=frames) + yield Joined( + volume=volume.name, + name=pair.base, + path=path, + rate=left.rate, + frames=frames, + partition=volume.partition, + ) def _parse_sample(backend: Backend, entry, payload: bytes): @@ -274,7 +296,7 @@ def _copy_audio( ) -> Extracted | Skipped: payload = backend.read_file(image, origin, entry) if not payload: - return Skipped(volume.name, entry.name, "no data on disc") + return Skipped(volume.name, entry.name, "no data on disc", volume.partition) stem, suffix = os.path.splitext(os.path.basename(entry.name)) path = unique_path(out_dir, safe_name(stem), suffix.lower() or ".wav") with open(path, "wb") as out: @@ -286,9 +308,24 @@ def _copy_audio( rate=0, frames=0, pitch=0, + partition=volume.partition, ) +def volume_dir(out_root: str, volume: Volume) -> str: + """Where one volume's WAVs go. + + A volume from a partitioned filesystem is written under its partition, + because the name alone does not identify it: nearly every partition of an + AKAI disc has a ``VOLUME 001``, and writing two of them into one directory + puts two libraries' audio side by side with ``unique_path`` suffixes and + nothing to say which is which (ADR-0007, ADR-0023). + """ + if volume.partition: + return os.path.join(out_root, f"partition-{volume.partition}", safe_name(volume.name)) + return os.path.join(out_root, safe_name(volume.name)) + + def extract_disc( image: SectorImage, backend: Backend, @@ -299,7 +336,7 @@ def extract_disc( ) -> Iterator[Extracted | Skipped | Joined | Kept]: """Write every sample on the disc, one directory per volume.""" for volume in backend.volumes(image, origin): - out_dir = os.path.join(out_root, safe_name(volume.name)) + out_dir = volume_dir(out_root, volume) yield from extract_volume( image, backend, origin, volume, out_dir, join_stereo, keep_originals ) diff --git a/src/samplerdisc/fs/akai.py b/src/samplerdisc/fs/akai.py index fd3613b..0fb73e7 100644 --- a/src/samplerdisc/fs/akai.py +++ b/src/samplerdisc/fs/akai.py @@ -7,7 +7,7 @@ from __future__ import annotations import struct -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, NamedTuple from samplerdisc.fs.base import File, Volume, register @@ -99,6 +99,45 @@ FAT_VOLUME_DIR = 0x4000 FAT_CHAIN_END = 0xC000 +#: The disk's **partition table**, at a fixed offset in the header of the +#: partition the origin resolves to. An AKAI disc is a disk image and a disk is +#: several partitions laid end to end, so reading only the one at the origin +#: reads a fifth of the disc (issue #22). +#: +#: +0x00 is a u8, the number of partitions; +0x01 a u8 that is 0 on 37 of the +#: 44 discs and 1 on the other 7 and is unread; +0x02 that many u16 LE +#: partition sizes in blocks, in order; and after them a u16 LE total for the +#: disk. +#: +#: Every one of the 44 AKAI discs declares one, and on every one the sizes sum +#: to the total. That the disc *states* its partitions is the whole reason this +#: is a table walk and not arithmetic on the first partition's size: the sizes +#: are not all equal -- the last is a remainder, 4095 blocks where the rest are +#: 7680 -- and tiling invents a fourteenth partition on +#: `AKAI.S3000.Sound.Library.1` that the table does not declare. +#: See docs/formats/akai-fs.md and ADR-0023. +PARTITION_TABLE_OFFSET = 0x4500 + +#: 196 bytes of constant at 0x02, byte-identical across all 276 partitions of +#: the 44 discs: 3333 x i as u16 LE, i = 0..97, wrapping. Nothing is known to +#: read it and nothing on any disc varies with it, but a partition header +#: carries it, which is what confirms a partition is where the table says. +#: +#: **It must never be scanned for.** As 16-bit PCM it is a rising sawtooth, and +#: sample data reproduces it: 374 blocks of one disc's free space match, every +#: one in a block the allocation map calls free (ADR-0023). +HEADER_PATTERN_OFFSET = 0x02 +HEADER_PATTERN = struct.pack("<98H", *(3333 * step & 0xFFFF for step in range(98))) + +#: The header restates its own size. The u16 at 0xC6 is the block count at 0x00 +#: plus this bias, and the u16 at 0xC8 is 47; both hold on all 276 partitions +#: measured. Two fields of the header agreeing is what lets a candidate be +#: confirmed by the disc rather than by the arithmetic that placed it. +SIZE_ECHO_OFFSET = 0xC6 +SIZE_ECHO_BIAS = 47573 +HEADER_TAIL_OFFSET = 0xC8 +HEADER_TAIL = 47 + #: A volume's file directory is one block. Reading further walks into the next #: block, which is file data, and yields entries assembled from audio. _MAX_FILES = BLOCK_SIZE // FILE_ENTRY_LEN @@ -113,6 +152,95 @@ _PROBE_FILE_ENTRIES = 8 +class Partition(NamedTuple): + """One partition of the disk, as the table declares it. + + ``offset`` is in bytes and relative to the disc origin, which is what a + ``Volume`` and a ``File`` carry so that the block numbers beside them stay + the numbers the directory declares. + """ + + #: Numbered from 1, in the order the table lists them. + index: int + offset: int + blocks: int + + +def partition_table(image: SectorImage, origin: int) -> list[int]: + """The partition sizes the disk declares, or ``[]`` where it declares none. + + The sum is what tells a table from whatever else could land at a fixed + offset: the sizes and the total are written separately and must agree, and + they do on all 44 discs measured. + """ + head = image.read(origin + PARTITION_TABLE_OFFSET, 2) + if len(head) < 2 or head[0] == 0: + return [] + count = head[0] + raw = image.read(origin + PARTITION_TABLE_OFFSET + 2, 2 * (count + 1)) + if len(raw) < 2 * (count + 1): + return [] + values = struct.unpack(f"<{count + 1}H", raw) + sizes = list(values[:count]) + if any(size <= 0 for size in sizes) or sum(sizes) != values[count]: + return [] + return sizes + + +def partition_header(image: SectorImage, offset: int) -> int | None: + """The block count a partition header declares here, or None if there is none. + + Three fields have to hold: the constant pattern, and the two that restate + the size. This confirms a position the table already chose -- it does not + find one. + """ + head = image.read(offset, VOLUME_DIR_OFFSET) + if len(head) < VOLUME_DIR_OFFSET: + return None + pattern = head[HEADER_PATTERN_OFFSET : HEADER_PATTERN_OFFSET + len(HEADER_PATTERN)] + if pattern != HEADER_PATTERN: + return None + (blocks,) = struct.unpack_from(" Iterator[Partition]: + """Every partition of the disk that this image actually holds. + + The table says where each one begins; the header there confirms it. A + declared position the image has no header at is **skipped rather than + searched for**: on the discs where that happens the image is short of the + disc it was made from, and the header it is missing turns up displaced by a + whole number of the container's own 32 KB blocks -- a fault of the rip, not + a filesystem to go hunting through (ADR-0022, ADR-0023, issue #17). + + Because the table gives absolute positions, one missing header costs its + own partition and no other: the walk carries on to the next declared start. + """ + sizes = partition_table(image, origin) + if not sizes: + # No table: read the one partition the origin resolved to, which is + # what this backend did before #22. Every AKAI disc measured declares a + # table, so this is a floor and not a path anything is known to take. + raw = image.read(origin + PARTITION_BLOCKS_OFFSET, 2) + blocks = struct.unpack("= image.size: + continue + if partition_header(image, origin + offset) != size: + continue + yield Partition(index, offset, size) + + def decode_name(raw: bytes) -> str: """Decode a fixed-width AKAI name. Trailing padding is stripped.""" return "".join(CHARSET[b] if b < len(CHARSET) else "?" for b in raw).rstrip() @@ -139,13 +267,19 @@ def allocation_map(image: SectorImage, offset: int) -> list[int]: image, which is the whole point: the map is the disc speaking about itself, so a disc that declares nothing usable gets no map and no notes derived from one, instead of a map invented out of whatever follows the header. + + What makes a count sane is the header restating it: the u16 at 0xC6 is the + block count plus a fixed bias on all 276 partitions measured, so a count + with no echo behind it is not a partition's and gets no map. That is a + firmer test than the image's length, which was the previous one and refused + the map to a partition the image merely *ends inside* -- leaving two volumes + on `ProSamples vol.17` empty with nothing to say, which is the ADR-0012 + signature raised by a short image rather than by a disc declaring nonsense. + Those get the map for the blocks the image holds; ADR-0022's "no map, no + note" holds for a count that is absent or unvouched for. """ - declared = image.read(offset + PARTITION_BLOCKS_OFFSET, 2) - if len(declared) < 2: - return [] - (blocks,) = struct.unpack(" Iterator[Volume]: - header = image.read(offset, VOLUME_DIR_OFFSET + _MAX_VOLUMES * VOLUME_ENTRY_LEN) - max_block = (image.size - offset) // BLOCK_SIZE + """Every volume on the disc, partition by partition. + + A disc is a disk of several partitions and each numbers its blocks from + its own start, so the walk is per partition and every volume and file + carries the offset its block numbers count from (ADR-0023). + """ + for partition in partitions(image, offset): + yield from self._volumes(image, offset, partition) + + def _volumes(self, image: SectorImage, offset: int, partition: Partition) -> Iterator[Volume]: + at = offset + partition.offset + header = image.read(at, VOLUME_DIR_OFFSET + _MAX_VOLUMES * VOLUME_ENTRY_LEN) + # Bounded by the partition, not by the image: a block number here counts + # from this partition's start, so one past its end is not a late file, + # it is the next partition's audio under this partition's name. No + # volume or file on the 44 discs measured points past its own + # partition, so this rejects nothing that is there today. + available = (image.size - at) // BLOCK_SIZE + max_block = min(partition.blocks, available) if partition.blocks else available # Read once for the whole partition: 7680 entries is 15 KB at the # largest seen, against a walk that opens a block per volume anyway. - allocation = allocation_map(image, offset) + allocation = allocation_map(image, at) for index in range(_MAX_VOLUMES): base = VOLUME_DIR_OFFSET + index * VOLUME_ENTRY_LEN entry = header[base : base + VOLUME_ENTRY_LEN] @@ -300,8 +451,13 @@ def volumes(self, image: SectorImage, offset: int) -> Iterator[Volume]: (start,) = struct.unpack(" max_block: continue - volume = Volume(name=name, start_block=start) - volume.files = list(self._files(image, offset, start, max_block)) + volume = Volume( + name=name, + start_block=start, + origin=partition.offset, + partition=partition.index, + ) + volume.files = list(self._files(image, at, partition.offset, start, max_block)) if not volume.files: # A volume that lists nothing has to say why, and the answer # comes from the disc rather than from a judgement about how @@ -313,9 +469,20 @@ def volumes(self, image: SectorImage, offset: int) -> Iterator[Volume]: yield volume def _files( - self, image: SectorImage, origin: int, start_block: int, max_block: int + self, + image: SectorImage, + at: int, + partition_offset: int, + start_block: int, + max_block: int, ) -> Iterator[File]: - directory = image.read(origin + start_block * BLOCK_SIZE, _MAX_FILES * FILE_ENTRY_LEN) + """The files of one volume. ``at`` is where its partition begins. + + ``partition_offset`` is the same position measured from the disc origin + and rides along on every file, because ``read_file`` is handed the disc + origin and a start block that counts from the partition. + """ + directory = image.read(at + start_block * BLOCK_SIZE, _MAX_FILES * FILE_ENTRY_LEN) for index in range(_MAX_FILES): entry = directory[index * FILE_ENTRY_LEN : (index + 1) * FILE_ENTRY_LEN] if len(entry) < FILE_ENTRY_LEN or is_empty_slot(entry): @@ -349,10 +516,31 @@ def _files( size=size, start_block=file_start, raw_type=type_byte, + origin=partition_offset, ) def read_file(self, image: SectorImage, origin: int, entry: File) -> bytes: - return image.read(origin + entry.start_block * BLOCK_SIZE, entry.size) + # entry.origin is where the file's partition begins; its start block is + # relative to that, so the same number in two partitions is two + # different files and dropping the term reads the wrong audio. + return image.read(origin + entry.origin + entry.start_block * BLOCK_SIZE, entry.size) + + def layout(self, image: SectorImage, offset: int) -> str: + """One line on how the disk is divided, for ``list``. + + Declared against present is the interesting pair: `Kickin' Lunatic + Beats 2 CD1` declares eleven partitions and the image holds one, and + that gap is the image being short of the disc rather than the disc + being small (issue #17). Saying it is the difference between a fact and + an absence nobody sees. + """ + declared = partition_table(image, offset) + present = sum(1 for _ in partitions(image, offset)) + if not declared: + return "no partition table -- reading the partition at the origin" + if len(declared) == 1: + return "1 partition" + return f"{len(declared)} partitions declared, {present} present in this image" def original_suffix(self, entry: File) -> str: """Name an original after the machine that wrote it. diff --git a/src/samplerdisc/fs/base.py b/src/samplerdisc/fs/base.py index e704e6c..e426ea6 100644 --- a/src/samplerdisc/fs/base.py +++ b/src/samplerdisc/fs/base.py @@ -27,6 +27,16 @@ class File: #: The filesystem's own type byte, kept so a backend can name the original #: faithfully. 0 where the filesystem has no such concept. raw_type: int = 0 + #: Byte offset, relative to the backend's origin, that ``start_block`` + #: counts from. 0 where the filesystem has one block numbering for the + #: whole disc, which is every backend but AKAI. + #: + #: An AKAI disc is a *disk* of several partitions and a file's start block + #: is relative to the partition it lives in, so the same number means a + #: different place in each one. Keeping the number the directory declares + #: and carrying its base beside it is what lets a note or a chain check + #: still speak in the disc's own terms (ADR-0023). + origin: int = 0 #: Whatever else the directory knew about this file, as ``(key, value)`` #: pairs -- a tuple rather than a dict so ``File`` stays frozen and #: hashable. @@ -53,6 +63,14 @@ class Volume: name: str start_block: int files: list[File] = field(default_factory=list) + #: Byte offset, relative to the backend's origin, that ``start_block`` + #: counts from -- see ``File.origin``. + origin: int = 0 + #: Which partition of the disc this volume came from, numbered from 1. 0 + #: where the filesystem has no partitions. Volume names repeat across an + #: AKAI disc's partitions -- nearly every one has a ``VOLUME 001`` -- so + #: this is what keeps two of them apart in a listing and on disk. + partition: int = 0 #: Why this volume has no files, when that is expected rather than wrong. #: A volume with no files and no note is the signature of a probe that #: matched something it should not have (ADR-0012), so the two cases must @@ -95,6 +113,15 @@ def original_suffix(self, entry: File) -> str: """ ... + def layout(self, image: SectorImage, offset: int) -> str: + """One line describing how the disc is divided, or "". + + Optional. A backend whose filesystem has structure above the volume -- + AKAI's partitions -- says so here, so ``list`` can report it without + knowing what a partition is. + """ + ... + def parse_sample(self, entry: File, payload: bytes): """Turn one file's bytes into something with name/rate/frames/pcm. diff --git a/tests/fixtures.py b/tests/fixtures.py index 64a5c9a..bd55581 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -209,8 +209,14 @@ def akai_partition( FAT_OFFSET, FAT_VOLUME_DIR, FILE_ENTRY_LEN, + HEADER_PATTERN, + HEADER_PATTERN_OFFSET, + HEADER_TAIL, + HEADER_TAIL_OFFSET, NAME_LEN, PARTITION_BLOCKS_OFFSET, + SIZE_ECHO_BIAS, + SIZE_ECHO_OFFSET, VOLUME_DIR_OFFSET, VOLUME_ENTRY_LEN, VOLUME_START_OFFSET, @@ -265,6 +271,12 @@ def slot(index: int, name: str, type_byte: int, start: int) -> None: for block in phantom_directories: allocation[block] = FAT_VOLUME_DIR + # The constant field a real header carries, and the two fields that restate + # the block count. Together they are what says "a partition begins here", + # and the walk reads no partition past the first without them (ADR-0023). + header[HEADER_PATTERN_OFFSET : HEADER_PATTERN_OFFSET + len(HEADER_PATTERN)] = HEADER_PATTERN + struct.pack_into(" None: f"an allocation map for {blocks_total} blocks does not fit the header block" ) struct.pack_into(" None: return bytes(image) +def akai_disc(partitions, *, declared=None, flag: int = 0) -> bytes: + """Lay partition images end to end and write the disk's partition table. + + ``partitions`` are images from ``akai_partition``, in order; pass a run of + zero bytes for a partition the image does not hold. The table goes in the + first one, at the fixed offset a real disc keeps it, and declares each + partition's size in blocks followed by the disk total. + + ``declared`` overrides the sizes written into the table. Declaring more + partitions than are present is how an image short of the disk it was made + from presents -- `Kickin' Lunatic Beats 2 CD1` declares eleven and holds + one -- and the sizes still have to sum to the total, as they do on all 44 + discs measured. + """ + from samplerdisc.fs.akai import BLOCK_SIZE, PARTITION_TABLE_OFFSET + + sizes = [len(part) // BLOCK_SIZE for part in partitions] + listed = list(declared) if declared is not None else sizes + image = bytearray(b"".join(partitions)) + table = bytearray([len(listed), flag]) + table += struct.pack(f"<{len(listed)}H", *listed) + table += struct.pack(" bytes: + """One partition holding one volume with one sample, distinguishable by name.""" + payload = fixtures.akai_sample(file_name, words=64 + seed) + return fixtures.akai_partition([(volume, [(file_name, 0x73, len(payload), payload)])]) + + +def test_the_table_places_every_partition_and_the_header_confirms_it(tmp_path): + """A disc is a disk of partitions, and the disk says how many (ADR-0023). + + Nothing here is inferred from the first partition's size: the table lists + each one, and the walk reads a partition only where a header sits at the + position the table gives. + """ + from samplerdisc.fs.akai import partition_table, partitions + + data = fixtures.akai_disc( + [ + _partition("SOUP 101-103", "KICK 1", 1), + _partition("SOUP 120", "TOY DRMS2", 2), + _partition("SOUP 89-93", "SPAGETTI", 3), + ] + ) + image = image_of(tmp_path, data) + assert partition_table(image, 0) == [512, 512, 512] + assert [(p.index, p.offset, p.blocks) for p in partitions(image, 0)] == [ + (1, 0, 512), + (2, 512 * 8192, 512), + (3, 1024 * 8192, 512), + ] + volumes = list(BACKEND.volumes(image, 0)) + assert [(v.partition, v.name) for v in volumes] == [ + (1, "SOUP 101-103"), + (2, "SOUP 120"), + (3, "SOUP 89-93"), + ] + + +def test_block_numbers_are_relative_to_their_own_partition(tmp_path): + """The trap this deliverable exists to avoid, and it does not raise. + + Every partition numbers its blocks from its own start, so two volumes in + different partitions have the same start block and hold different audio. + Read a file with the partition term dropped and you get plausible bytes + from the wrong partition -- audio, not an error. + """ + first = _partition("VOLUME 001", "KICK 1", 1) + second = _partition("VOLUME 001", "SPAGETTI", 2) + image = image_of(tmp_path, fixtures.akai_disc([first, second])) + volumes = list(BACKEND.volumes(image, 0)) + assert [v.name for v in volumes] == ["VOLUME 001", "VOLUME 001"] + assert volumes[0].start_block == volumes[1].start_block + assert volumes[0].origin == 0 + assert volumes[1].origin == 512 * 8192 + + entry = volumes[1].files[0] + assert entry.origin == 512 * 8192 + payload = BACKEND.read_file(image, 0, entry) + assert payload == second[entry.start_block * 8192 :][: entry.size] + # And the same block in the first partition is a different sample. + assert payload != BACKEND.read_file(image, 0, volumes[0].files[0]) + + +def test_a_declared_partition_the_image_lacks_is_skipped_and_the_walk_goes_on(tmp_path): + """An image short of the disk it was made from loses a partition, not the rest. + + The table gives absolute positions, so a missing header costs its own + partition and nothing after it. Where the header is missing the walk stops + rather than searching: on the discs that do this the header turns up + displaced by a whole number of the container's blocks, which is the rip + being incomplete (ADR-0023, issue #17). + """ + blank = bytes(512 * 8192) + data = fixtures.akai_disc( + [_partition("SOUP 101-103", "KICK 1", 1), blank, _partition("SOUP 89-93", "SPAGETTI", 3)] + ) + image = image_of(tmp_path, data) + volumes = list(BACKEND.volumes(image, 0)) + assert [(v.partition, v.name) for v in volumes] == [(1, "SOUP 101-103"), (3, "SOUP 89-93")] + assert BACKEND.layout(image, 0) == "3 partitions declared, 2 present in this image" + + +def test_a_table_declaring_more_than_the_image_holds_reads_what_is_there(tmp_path): + """`Kickin' Lunatic Beats 2 CD1` declares eleven partitions and holds one.""" + data = fixtures.akai_disc([_partition("SOUP 101-103", "KICK 1", 1)], declared=[512, 512, 512]) + image = image_of(tmp_path, data) + assert [v.partition for v in BACKEND.volumes(image, 0)] == [1] + assert BACKEND.layout(image, 0) == "3 partitions declared, 1 present in this image" + + +def test_a_table_whose_sizes_do_not_sum_is_not_a_table(tmp_path): + """The sum is what tells a table from bytes that happen to land at 0x4500. + + Sizes and total are written separately and agree on all 44 discs measured, + so a disagreement means this is not the structure -- and the disc falls + back to the one partition the origin resolved to rather than to arithmetic. + """ + import struct + + from samplerdisc.fs.akai import PARTITION_TABLE_OFFSET, partition_table + + data = bytearray( + fixtures.akai_disc( + [_partition("SOUP 101-103", "KICK 1", 1), _partition("SOUP 120", "X", 2)] + ) + ) + struct.pack_into(" set[int]: *(size for size, _ in _ROLAND_S7XX.values()), *(size for size, _, _ in _ISO9660.values()), *(size for size, _, _ in _EMU3.values()), - *(size for size, _, _, _ in _AKAI.values()), + *(size for size, _, _, _, _, _ in _AKAI.values()), } @@ -436,7 +436,8 @@ def test_protozoa_gives_each_bank_its_own_records() -> None: #: AKAI discs pinned by size, with the volumes, files and *noted* volumes each -#: must yield. The noted count is pinned as tightly as the file count on +#: must yield, and the partitions its table declares against the partitions +#: this image holds. The noted count is pinned as tightly as the file count on #: purpose: a note is what separates an emptiness the disc accounts for from #: the ADR-0012 signature, so a note appearing where none was measured is a #: backend explaining away something nobody looked at, and one disappearing is @@ -444,28 +445,56 @@ def test_protozoa_gives_each_bank_its_own_records() -> None: #: and #17 together with the four counter-examples -- volumes whose type byte #: is 0 and whose blocks the allocation map calls free, which carry 63 files #: between them and must keep every one. -#: ``label: (size in bytes, volumes, files, volumes carrying a note)``. +#: +#: The two partition columns are the pin on #22, and they differ on purpose: +#: `Kickin' Lunatic Beats 2 CD1` declares eleven partitions and the image holds +#: one, which is the image being short of the disc rather than the disc being +#: small (ADR-0023, issue #17). A present count that climbs to the declared one +#: would mean the walk had started accepting positions with no header at them. +#: ``label: (size in bytes, volumes, files, noted, partitions declared, present)``. _AKAI = { - "AKAI Advance Orchestra Upgrade 97 Vol.1": (545_720_320, 18, 464, 4), - "AMG - Kickin' Lunatic Beats 2 AKAI CD1": (378_443_564, 18, 669, 5), - "AMG - Kickin' Lunatic Beats 2 AKAI CD2": (371_768_845, 20, 1346, 0), - "OMI Universe Of Sounds Vol.1 (Roland S-770,S-750)": (295_837_696, 28, 900, 1), - "Back in Time Records - Big Bang": (269_979_648, 8, 380, 0), - "Best Service ProSamples vol.01 - Hip Hop and R&B Drumloops": (314_882_048, 7, 82, 0), - "Best Service ProSamples vol.19 - Pop Brass": (484_558_848, 3, 139, 0), - "Best Service ProSamples vol.24 - Breakbeat": (505_772_032, 15, 113, 0), + "AKAI Advance Orchestra Upgrade 97 Vol.1": (545_720_320, 88, 2669, 4, 9, 9), + "AMG - Loop Soup AKAI": (542_419_100, 60, 4689, 0, 9, 9), + "AMG - Kickin' Lunatic Beats 2 AKAI CD1": (378_443_564, 18, 669, 5, 11, 1), + "AMG - Kickin' Lunatic Beats 2 AKAI CD2": (371_768_845, 20, 1346, 0, 9, 1), + "OMI Universe Of Sounds Vol.1 (Roland S-770,S-750)": (295_837_696, 69, 1653, 1, 5, 5), + "Back in Time Records - Big Bang": (269_979_648, 137, 2689, 0, 9, 9), + "Best Service ProSamples vol.01 - Hip Hop and R&B Drumloops": (314_882_048, 41, 492, 1, 9, 5), + "Best Service ProSamples vol.19 - Pop Brass": (484_558_848, 12, 533, 0, 9, 5), + "Best Service ProSamples vol.24 - Breakbeat": (505_772_032, 38, 310, 0, 4, 4), +} + +#: What each of those discs held when only the partition at the origin was read +#: (#22). Kept beside the table above rather than deleted: these are the +#: numbers every earlier release reported, and pinning them is what shows the +#: new ones are *additional* partitions rather than the same volumes counted +#: differently. ``label: (volumes, files, noted)`` for partition 1 alone. +_AKAI_FIRST_PARTITION = { + "AKAI Advance Orchestra Upgrade 97 Vol.1": (18, 464, 4), + "AMG - Loop Soup AKAI": (7, 490, 0), + "AMG - Kickin' Lunatic Beats 2 AKAI CD1": (18, 669, 5), + "AMG - Kickin' Lunatic Beats 2 AKAI CD2": (20, 1346, 0), + "OMI Universe Of Sounds Vol.1 (Roland S-770,S-750)": (28, 900, 1), + "Back in Time Records - Big Bang": (8, 380, 0), + "Best Service ProSamples vol.01 - Hip Hop and R&B Drumloops": (7, 82, 0), + "Best Service ProSamples vol.19 - Pop Brass": (3, 139, 0), + "Best Service ProSamples vol.24 - Breakbeat": (15, 113, 0), } @pytest.mark.parametrize("label", sorted(_AKAI)) def test_akai_discs_list_their_volumes_and_files(label: str) -> None: """Pinned where present, skipped where the shelf is bare -- see _pinned_disc().""" - size, volumes_expected, files_expected, noted_expected = _AKAI[label] + from samplerdisc.fs.akai import partition_table, partitions + + size, volumes_expected, files_expected, noted_expected, declared, present = _AKAI[label] with open_image(_pinned_disc(label, size)) as image: origin = find_origin(image) assert origin is not None, f"{label}: no filesystem found" assert origin.backend.name == "akai" assert origin.offset == 0 + assert len(partition_table(image, origin.offset)) == declared + assert len(list(partitions(image, origin.offset))) == present volumes = list(origin.backend.volumes(image, origin.offset)) assert len(volumes) == volumes_expected assert sum(len(v.files) for v in volumes) == files_expected @@ -474,6 +503,79 @@ def test_akai_discs_list_their_volumes_and_files(label: str) -> None: # "the collection has no silent volume" to "this disc has none", which # only a disc whose expected shape is known can ask (ADR-0012). assert all(v.files or v.note for v in volumes), [v.name for v in volumes if not v.files] + # Every partition read is one the table declares, and each volume knows + # which -- a volume with no partition would be one read outside the + # walk, and its block numbers would be relative to nothing (ADR-0023). + assert {v.partition for v in volumes} <= set(range(1, declared + 1)) + assert all(v.partition for v in volumes) + + +@pytest.mark.parametrize("label", sorted(_AKAI_FIRST_PARTITION)) +def test_akai_partition_one_holds_exactly_what_it_did_before(label: str) -> None: + """#22 adds partitions; it must not move a single volume of the first one. + + The whole risk in threading a per-partition origin through the walk is + getting the arithmetic subtly wrong, and the symptom would not be an error + -- it would be volumes and files that still list, from the wrong place. + Partition 1's numbers are the control, because they were measured before + any of this and nothing about it should touch them. + """ + volumes_expected, files_expected, noted_expected = _AKAI_FIRST_PARTITION[label] + with open_image(_pinned_disc(label, _AKAI[label][0])) as image: + origin = find_origin(image) + assert origin is not None + first = [v for v in origin.backend.volumes(image, origin.offset) if v.partition == 1] + assert len(first) == volumes_expected + assert sum(len(v.files) for v in first) == files_expected + assert sum(1 for v in first if v.note) == noted_expected + # Partition 1 begins at the origin, so its blocks need no adjustment. + assert {v.origin for v in first} == {0} + + +def test_loop_soup_reads_all_nine_partitions_and_the_payloads_agree() -> None: + """The disc issue #22 was written against, end to end. + + `Loop Soup` declares nine partitions and the image holds all nine; six + carry volumes and the names run on across them -- partition 1 ends at + `SOUP 115-117` and partition 2 opens at `SOUP 120`, which is what says + this is the disc's own content and not a coincidence of structure. + + Then the strong half: every sample past the first partition is read back + and its **payload header** must carry the name its directory entry gives + it. Two structures written independently, one 8 KB-aligned block apart -- + if the per-partition origin were wrong by so much as a block, the names + would disagree wholesale rather than the read failing. All 3 200 agree. + """ + from samplerdisc.fs.akai import decode_name, partitions + + with open_image( + _pinned_disc("AMG - Loop Soup AKAI", _AKAI["AMG - Loop Soup AKAI"][0]) + ) as image: + origin = find_origin(image) + assert origin is not None + assert len(list(partitions(image, origin.offset))) == 9 + volumes = list(origin.backend.volumes(image, origin.offset)) + opening = {} + for volume in volumes: + opening.setdefault(volume.partition, volume.name) + assert opening[1] == "SOUP 101-103" + assert opening[2] == "SOUP 120" + + checked = 0 + for volume in volumes: + if volume.partition == 1: + continue + for entry in volume.files: + if entry.kind != "sample": + continue + payload = origin.backend.read_file(image, origin.offset, entry) + assert len(payload) == entry.size, f"{volume.name}/{entry.name}: short read" + assert decode_name(payload[3:15]) == entry.name, ( + f"partition {volume.partition} {volume.name}/{entry.name}: payload header " + f"says {decode_name(payload[3:15])!r}" + ) + checked += 1 + assert checked == 3200 def test_akai_unused_slots_are_explained_by_the_allocation_map() -> None: @@ -513,7 +615,11 @@ def test_akai_unused_slots_are_explained_by_the_allocation_map() -> None: with open_image(_pinned_disc(label, _AKAI[label][0])) as image: origin = find_origin(image) assert origin is not None - volumes = {v.name: v for v in origin.backend.volumes(image, origin.offset)} + # Partition 1: these ten volumes were measured there, and a name is + # not unique across a disc's partitions (ADR-0023). + volumes = { + v.name: v for v in origin.backend.volumes(image, origin.offset) if v.partition == 1 + } for name, fragment in wanted.items(): volume = volumes[name] assert not volume.files, f"{label}: {name} unexpectedly lists files" @@ -544,7 +650,9 @@ def test_akai_keeps_the_files_of_a_volume_the_allocation_map_calls_free() -> Non origin = find_origin(image) assert origin is not None allocation = allocation_map(image, origin.offset) - volume = {v.name: v for v in origin.backend.volumes(image, origin.offset)}[name] + volume = { + v.name: v for v in origin.backend.volumes(image, origin.offset) if v.partition == 1 + }[name] assert allocation[volume.start_block] == FAT_FREE, label assert len(volume.files) == count, label assert not volume.note @@ -570,17 +678,38 @@ def test_an_akai_file_chain_is_as_long_as_its_declared_size(path: Path) -> None: exclusion has to be by what the map says and not by a tolerance -- on `ProSamples vol.19` they are 37 of 139 files, so any rate loose enough to pass there would be loose enough to hide a real fault on a larger disc. + + **Per partition**, each against its own map, since #22: a block number + counts from the partition it is in, so checking every file against the + first partition's map would compare the wrong two things and pass or fail + for the wrong reason. Extended to all 276 partitions the agreement is 68 + 267 of 68 284 files, and every one of the 17 exceptions is a `MULTI FILE` + -- kind ``multi``, all on `AKAI.S3000.Sound.Library.1` -- whose chain runs + exactly one block past what its size needs. They are named here rather + than tolerated, so a second kind of disagreement fails. """ - from samplerdisc.fs.akai import BLOCK_SIZE, FAT_FREE, FAT_VOLUME_DIR, allocation_map + from samplerdisc.fs.akai import ( + BLOCK_SIZE, + FAT_FREE, + FAT_VOLUME_DIR, + allocation_map, + partitions, + ) with open_image(path) as image: origin = find_origin(image) if origin is None or origin.backend.name != "akai": return - allocation = allocation_map(image, origin.offset) - assert allocation, f"{path.name}: no allocation map" + maps = { + part.offset: allocation_map(image, origin.offset + part.offset) + for part in partitions(image, origin.offset) + } + assert maps.get(0), f"{path.name}: no allocation map" disagreed = [] for volume in origin.backend.volumes(image, origin.offset): + allocation = maps.get(volume.origin, []) + if not allocation: + continue if volume.start_block < len(allocation) and allocation[volume.start_block] == FAT_FREE: continue for entry in volume.files: @@ -592,8 +721,16 @@ def test_an_akai_file_chain_is_as_long_as_its_declared_size(path: Path) -> None: if allocation[block] >= FAT_VOLUME_DIR: break block = allocation[block] - if length != want: - disagreed.append(f"{volume.name}/{entry.name}: {length} blocks, wanted {want}") + if length == want: + continue + if entry.kind == "multi" and length == want + 1: + # The one exception, and it is the same on all 17: a multi + # is allocated a block more than its size needs. + continue + disagreed.append( + f"partition {volume.partition} {volume.name}/{entry.name} " + f"({entry.kind}): {length} blocks, wanted {want}" + ) assert not disagreed, ( f"{path.name}: {len(disagreed)} files whose chain is not as long as the " f"size their directory declares, first: {disagreed[:3]}" diff --git a/tests/test_extract.py b/tests/test_extract.py index 82fcb72..dc6e3e2 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -89,7 +89,7 @@ def test_extracts_samples_to_playable_wavs(tmp_path): out = tmp_path / "out" results = list(extract_disc(image, BACKEND, 0, str(out))) assert len(results) == 1 - written = out / "VOL 1" / "KICK 1.wav" + written = out / "partition-1" / "VOL 1" / "KICK 1.wav" assert written.exists() with wave.open(str(written)) as w: assert w.getframerate() == 44100 @@ -105,7 +105,7 @@ def test_programs_are_not_written(tmp_path): ) out = tmp_path / "out" list(extract_disc(image, BACKEND, 0, str(out))) - assert sorted(p.name for p in (out / "VOL 1").iterdir()) == ["KICK 1.wav"] + assert sorted(p.name for p in (out / "partition-1" / "VOL 1").iterdir()) == ["KICK 1.wav"] def test_a_damaged_entry_is_skipped_and_the_rest_still_extract(tmp_path): @@ -128,7 +128,7 @@ def test_a_damaged_entry_is_skipped_and_the_rest_still_extract(tmp_path): skipped = [r for r in results if isinstance(r, Skipped)] assert len(skipped) == 1 assert skipped[0].name == "BROKEN" - assert (out / "VOL 1" / "GOOD.wav").exists() + assert (out / "partition-1" / "VOL 1" / "GOOD.wav").exists() def test_each_volume_gets_its_own_directory(tmp_path): @@ -140,5 +140,35 @@ def test_each_volume_gets_its_own_directory(tmp_path): ) out = tmp_path / "out" list(extract_disc(image, BACKEND, 0, str(out))) - assert (out / "VOL 1" / "A.wav").exists() - assert (out / "VOL 2" / "B.wav").exists() + assert (out / "partition-1" / "VOL 1" / "A.wav").exists() + assert (out / "partition-1" / "VOL 2" / "B.wav").exists() + + +def test_volumes_of_one_name_in_two_partitions_do_not_share_a_directory(tmp_path): + """Nearly every partition of an AKAI disc has a 'VOLUME 001' (ADR-0023). + + Written flat, the second one's audio lands beside the first's under + ``unique_path`` suffixes -- two libraries in one directory with nothing + saying which sample came from where. That is not a lost file, it is an + unusable one, which is why the partition is part of the path. + """ + first = fixtures.akai_sample("PIANO C3", words=64) + second = fixtures.akai_sample("PIANO C3", words=128) + data = fixtures.akai_disc( + [ + fixtures.akai_partition([("VOLUME 001", [("PIANO C3", 0x73, len(first), first)])]), + fixtures.akai_partition([("VOLUME 001", [("PIANO C3", 0x73, len(second), second)])]), + ] + ) + path = tmp_path / "two.iso" + path.write_bytes(data) + image = FlatImage(path) + out = tmp_path / "out" + written = [r.path for r in extract_disc(image, BACKEND, 0, str(out))] + assert written == [ + str(out / "partition-1" / "VOLUME 001" / "PIANO C3.wav"), + str(out / "partition-2" / "VOLUME 001" / "PIANO C3.wav"), + ] + # And they are the two different samples, not one written twice. + with wave.open(written[0]) as one, wave.open(written[1]) as two: + assert (one.getnframes(), two.getnframes()) == (64, 128) diff --git a/tests/test_keep_originals.py b/tests/test_keep_originals.py index 4dd505b..fb42919 100644 --- a/tests/test_keep_originals.py +++ b/tests/test_keep_originals.py @@ -57,7 +57,7 @@ def test_originals_are_off_by_default(tmp_path): out = tmp_path / "out" results = list(extract_disc(disc(tmp_path, files), BACKEND, 0, str(out))) assert not any(isinstance(r, Kept) for r in results) - assert not (out / "VOL 1" / ORIGINALS_DIR).exists() + assert not (out / "partition-1" / "VOL 1" / ORIGINALS_DIR).exists() def test_samples_and_programs_are_both_kept(tmp_path): @@ -68,7 +68,7 @@ def test_samples_and_programs_are_both_kept(tmp_path): kept = {r.name: r.kind for r in results if isinstance(r, Kept)} assert kept == {"KICK 1": "sample", "A PROGRAM": "program"} - originals = out / "VOL 1" / ORIGINALS_DIR + originals = out / "partition-1" / "VOL 1" / ORIGINALS_DIR assert (originals / "KICK 1.s1s").read_bytes() == sample assert (originals / "A PROGRAM.s1p").read_bytes() == program @@ -77,7 +77,7 @@ def test_originals_sit_beside_the_wavs_not_among_them(tmp_path): files, _, _ = mixed() out = tmp_path / "out" list(extract_disc(disc(tmp_path, files), BACKEND, 0, str(out), keep_originals=True)) - volume = out / "VOL 1" + volume = out / "partition-1" / "VOL 1" assert sorted(p.name for p in volume.iterdir()) == ["KICK 1.wav", ORIGINALS_DIR] @@ -99,7 +99,7 @@ def test_a_kept_sample_still_parses(tmp_path): files, sample, _ = mixed() out = tmp_path / "out" list(extract_disc(disc(tmp_path, files), BACKEND, 0, str(out), keep_originals=True)) - raw = (out / "VOL 1" / ORIGINALS_DIR / "KICK 1.s1s").read_bytes() + raw = (out / "partition-1" / "VOL 1" / ORIGINALS_DIR / "KICK 1.s1s").read_bytes() assert parse(raw).pcm == parse(sample).pcm @@ -107,5 +107,5 @@ def test_s3000_type_bytes_produce_s3_suffixes(tmp_path): files, _, _ = mixed(sample_type=0xF3, program_type=0xF0) out = tmp_path / "out" list(extract_disc(disc(tmp_path, files), BACKEND, 0, str(out), keep_originals=True)) - originals = out / "VOL 1" / ORIGINALS_DIR + originals = out / "partition-1" / "VOL 1" / ORIGINALS_DIR assert sorted(p.name for p in originals.iterdir()) == ["A PROGRAM.s3p", "KICK 1.s3s"] diff --git a/tests/test_loops.py b/tests/test_loops.py index 0fc8a0b..897256a 100644 --- a/tests/test_loops.py +++ b/tests/test_loops.py @@ -78,7 +78,7 @@ def test_loop_is_written_to_the_smpl_chunk(tmp_path): image = disc_with(tmp_path, fixtures.akai_sample("PAD", words=1000, loop=(600, 950))) out = tmp_path / "out" list(extract_disc(image, BACKEND, 0, str(out))) - smpl = read_smpl(out / "VOL 1" / "PAD.wav") + smpl = read_smpl(out / "partition-1" / "VOL 1" / "PAD.wav") assert smpl is not None # RIFF loop ends are inclusive, AKAI's are exclusive. assert smpl["loops"] == [(600, 949)] @@ -89,7 +89,7 @@ def test_loop_end_stays_inside_the_audio(tmp_path): image = disc_with(tmp_path, fixtures.akai_sample("PAD", words=500, loop=(100, 500))) out = tmp_path / "out" list(extract_disc(image, BACKEND, 0, str(out))) - path = out / "VOL 1" / "PAD.wav" + path = out / "partition-1" / "VOL 1" / "PAD.wav" with wave.open(str(path)) as w: frames = w.getnframes() start, end = read_smpl(path)["loops"][0] @@ -108,13 +108,13 @@ def test_stereo_files_keep_the_loop(tmp_path): ) out = tmp_path / "out" list(extract_disc(FlatImage(path), BACKEND, 0, str(out))) - assert read_smpl(out / "VOL 1" / "stereo" / "PAD.wav")["loops"] == [(600, 949)] + assert read_smpl(out / "partition-1" / "VOL 1" / "stereo" / "PAD.wav")["loops"] == [(600, 949)] def test_an_unlooped_sample_gets_a_root_key_but_no_loop(tmp_path): image = disc_with(tmp_path, fixtures.akai_sample("KICK", words=100, pitch=36), name="KICK") out = tmp_path / "out" list(extract_disc(image, BACKEND, 0, str(out))) - smpl = read_smpl(out / "VOL 1" / "KICK.wav") + smpl = read_smpl(out / "partition-1" / "VOL 1" / "KICK.wav") assert smpl["note"] == 36 assert smpl["loops"] == [] diff --git a/tests/test_stereo.py b/tests/test_stereo.py index cd23e69..dcaf79d 100644 --- a/tests/test_stereo.py +++ b/tests/test_stereo.py @@ -156,7 +156,7 @@ def test_stereo_is_written_and_the_mono_originals_are_kept(tmp_path): results = list(extract_disc(stereo_disc(tmp_path), BACKEND, 0, str(out))) assert sum(isinstance(r, Joined) for r in results) == 1 - volume = out / "VOL 1" + volume = out / "partition-1" / "VOL 1" assert (volume / "PAD -L.wav").exists() assert (volume / "PAD -R.wav").exists() joined = volume / "stereo" / "PAD.wav" @@ -172,20 +172,20 @@ def test_halves_at_different_rates_are_not_joined(tmp_path): assert not any(isinstance(r, Joined) for r in results) reasons = [r.reason for r in results if isinstance(r, Skipped)] assert any("rate mismatch" in r for r in reasons) - assert (out / "VOL 1" / "PAD -L.wav").exists() # mono still written + assert (out / "partition-1" / "VOL 1" / "PAD -L.wav").exists() # mono still written def test_no_stereo_flag_disables_joining(tmp_path): out = tmp_path / "out" results = list(extract_disc(stereo_disc(tmp_path), BACKEND, 0, str(out), join_stereo=False)) assert not any(isinstance(r, Joined) for r in results) - assert not (out / "VOL 1" / "stereo").exists() + assert not (out / "partition-1" / "VOL 1" / "stereo").exists() def test_stereo_channels_match_their_mono_sources(tmp_path): out = tmp_path / "out" list(extract_disc(stereo_disc(tmp_path), BACKEND, 0, str(out))) - volume = out / "VOL 1" + volume = out / "partition-1" / "VOL 1" with wave.open(str(volume / "PAD -L.wav")) as w: left = w.readframes(w.getnframes()) with wave.open(str(volume / "PAD -R.wav")) as w: