diff --git a/CHANGELOG.md b/CHANGELOG.md index e8e213f..4709313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,16 @@ Notable changes to `samplerdisc`. Format-level findings live in [docs/formats/]( ### Added +- **E-mu stereo samples come out as stereo.** The sample record's pointer block declares a channel count, and where it declares two the payload is a **block** split — all of the left channel, then all of the right. Read as one mono stream that is a file twice as long as the sound, with the right channel playing after the left instead of alongside it, and that is what this project wrote for **2 656 of the 14 738** E-mu samples: 28, 601, 592, 8, 279, 320 and 828 across the seven reference discs. `Miroslav Vitous String Ensembles` is all of it — 828 of 828. They are now interleaved into one stereo WAV each. ([docs/formats/emu3.md](docs/formats/emu3.md), [ADR-0026](docs/adr/0026-the-record-declares-the-channel-count.md), [#32](https://github.com/bmxcode/samplerdisc/issues/32)) + + **A channel count is not enough on its own.** 2 721 records declare `start_R` half a payload on, and **65 of them close `end_L` somewhere else** — 19 on `protozoa`, 40 on `eiiix-1`, 6 on `eiiix-2`. Those 65 are not stereo: their halves correlate at 0.01 on fine structure and 0.01 at best lag, which is the score of two unrelated records, against 0.40 and 0.53 for the known-true stereo pairs on the same shelf. On `protozoa` six of them can be identified exactly: the first half of each is, byte for byte, the whole of a one-channel record of the same name in another bank, nothing matches the second half, and `end_L` closes 8 bytes past the halfway point rather than on it. Requiring the left block to close exactly where the right one opens is what separates them, and those 65 stay mono. + + **The obvious measurement is confounded, which is why it took two more instruments.** A single decaying note's two halves both decay and correlate at 0.94 by RMS envelope without being two channels of anything — every disc has a tail of those, named `Piano Db3` and `Glockenspiel D5`. Dividing the envelope by its own trend, and correlating the waveform at a lag, separates them; the twelve name-paired `-L`/`-R` records on `eiv-analogia` served as a positive control that the pointer block knows nothing about. + + **Nothing else moved, and that is asserted.** The SHA-256 of every sample payload per disc is unchanged on all seven, the sample counts are unchanged — one record is one sample, only its shape changed — and all seven loop counts are unchanged: 107, 1 157, 1 260, 1 689, 449, 2 551, 826. The suite now also de-interleaves each stereo sample and requires it to reproduce the disc's two blocks byte for byte, because audio that moved has to be shown to be the same audio. + + **The mono halves are not written alongside**, unlike the `-L`/`-R` pairs `stereo.py` joins. That rule exists because name pairing is a heuristic and the halves are how a user notices a bad guess; here the disc states the channel count and two further fields of the same record confirm it, so there is nothing to hedge — and the half would be one leg of a concatenation, not a sound anyone recorded. + - **E-mu samples carry their loop points.** The 92-byte sample record's undecoded middle is an eight-pointer block — a start, an end, a loop start and a loop end, per channel, as byte offsets from the record's own start — and **8 039 of the 14 738** E-mu samples now come out with those loop points in the WAV's `smpl` chunk: 107, 1 157, 1 260, 1 689, 449, 2 551 and 826 across the seven reference discs. Every other backend has carried this since D7; the E-mu path carried none. ([docs/formats/emu3.md](docs/formats/emu3.md), [ADR-0025](docs/adr/0025-the-loop-is-decoded-the-root-key-is-not.md)) **The loop was established from the audio, not from the structure fitting.** Correlating the waveform at the loop start against the waveform at the loop end gives **+0.64 to +0.86 on six of the seven discs**, against a control — the same loop end with the start put somewhere else — of about zero on all of them. Three publishers and both E-mu generations agree. `eiv-analogia` is the exception and is reported as one: only 34 of its records carry audio loud enough at both ends to score, and those 34 show nothing, so its loops rest on the rule the other six establish. @@ -84,7 +94,7 @@ Notable changes to `samplerdisc`. Format-level findings live in [docs/formats/]( ### Known limits -- **E-mu stereo samples are written as double-length mono.** Roughly **2 700 of the 14 738** E-mu samples declare two channels in their record, stored as a block of left followed by a block of right. Reading the payload as one mono stream concatenates them. The earlier finding that "everything is mono" tested *interleaved* stereo — a sound refutation of the wrong hypothesis, since de-interleaving cannot detect a block split. Measured directly, the two halves of a two-channel record are the same performance, correlating by RMS envelope at 0.99, 0.96 and 0.95 on three discs against 0.13–0.26 for one-channel records. Not fixed here: correcting it moves audio, and this release was scoped to add metadata without touching a byte of payload. ([docs/formats/emu3.md](docs/formats/emu3.md), [ADR-0025](docs/adr/0025-the-loop-is-decoded-the-root-key-is-not.md)) +- **65 E-mu records declare two channels and are not stereo, and only `protozoa` says what they are.** There the second half of a rejected record is another bank's, at the constant shift [ADR-0021](docs/adr/0021-a-bank-owns-the-run-its-header-declares.md) describes. On `eiiix-1`'s 40 and `eiiix-2`'s 6 the halves are measurably not one performance and nothing accounts for the second one. They come out mono, which is the safe answer rather than an explained one. ([docs/formats/emu3.md](docs/formats/emu3.md), [ADR-0026](docs/adr/0026-the-record-declares-the-channel-count.md)) - **The ISO 9660 directory hierarchy is not preserved.** A disc's audio is written flat into one directory per volume, so `PS-34 AIFF …/056_Ballad de Boo/34a-bas-56Dmin.aif` becomes `34a-bas-56Dmin.wav` and the folder that grouped it by tempo is gone. Nothing is lost on these discs — Best Service named every file uniquely, and all thirteen list zero collisions after flattening — but a disc that reused a basename would rely on `unique_path` suffixing it. @@ -94,8 +104,6 @@ Notable changes to `samplerdisc`. Format-level findings live in [docs/formats/]( - **A declared partition the image has no header at is skipped, never searched for.** Nine of the 44 AKAI discs are short of the disc they were made from, and on those the missing header sits *earlier* — displaced by a whole number of the container's own 32 KB blocks, accumulating down the disc, from 4 blocks on `Best Service - Alpha Dance I` to 7 288 on `AKAI.S3000.Sound.Library.7`. Roughly fifteen thousand readable files stay unread on those images, which is deliberate rather than a gap in the walk: a search would find their partitions, and the audio inside a short image is displaced by the same missing blocks that moved the header — the nine wrong files above are the standing evidence of what displaced audio extracts as. `list` prints how many partitions the disc declares against how many the image holds, so the shortfall is a stated fact and not an absence. ([docs/formats/akai-fs.md](docs/formats/akai-fs.md), [ADR-0023](docs/adr/0023-partitions-come-from-the-table-the-disc-declares.md), [#25](https://github.com/bmxcode/samplerdisc/issues/25)) - **100 of `Studio Essentials`'s 230 banks list without extracting.** They have no confirmed sample directory, and carry a note saying so rather than being guessed at. That disc holds 901 `E4P1` presets and preset-only banks are the likely explanation — likely is not established, so it is not claimed. - **An E-mu bank whose `0x34` is damaged will list empty and say the header declares no sample area.** The field bounds the walk now, so the note that follows an empty bank restates that bound rather than corroborating it independently — the note is true about the header and would be wrong about the bank. The alternative was measured and is worse: `protozoa`'s index bank would be credited with 63 of the Phatt banks' records. ([ADR-0021](docs/adr/0021-a-bank-owns-the-run-its-header-declares.md)) -- **Loop points and root key are still absent from E-mu WAVs.** Eight fields in the 92-byte sample header are undecoded and some are very likely those. Decoding them changes the *shared* record parser and would alter every E-mu sample already extracted, so it is its own piece of work rather than a rider on this one. -- **The E-mu sample record has no channel count.** The paired length fields at `+26`/`+30` and `+34`/`+50` look exactly like one — `+34 == 2 × (+30) − 90` on both EIII and E-IV — and measurement says otherwise: de-interleaving any of these payloads as stereo roughly doubles its sample-to-sample delta, the known-good `Piano E0` included. Everything is mono, and stereo pairs are joined by name as on every other format. ## 0.3.0 — 2026-08-20 diff --git a/README.md b/README.md index 2212a6f..ce648fe 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ out/AKAI.S3000.Sound.Library.1/ Extraction writes one WAV per sample, grouped by volume. Where a disc stores stereo as split mono files — the AKAI `-L` / `-R` convention — you also get a joined stereo WAV, and the mono originals are kept alongside it rather than replaced. +Some samples are stereo on the disc itself rather than paired by name: an E-mu record declares its own channel count, and 2 656 of the 14 738 E-mu samples here declare two. Those come out as one stereo WAV under the sample's own name — not in `stereo/`, which means "rebuilt from two files", and with no mono halves to keep, because nothing was guessed at ([ADR-0026](docs/adr/0026-the-record-declares-the-channel-count.md)). + The audio is a byte-for-byte copy: AKAI stores signed 16-bit little-endian PCM and so does WAV, so there is no resampling, no bit-depth change and no dithering anywhere in the process. Loop points, root key and tuning from the disc are written into the WAV's standard `smpl` chunk, so a DAW that understands them picks them up and one that doesn't sees an ordinary WAV. `--keep-originals` additionally writes each sample and program out byte-for-byte as the sampler stored it, into an `original/` folder beside the WAVs. Two reasons to want it: programs hold the key ranges and envelopes, which a WAV cannot carry and which are otherwise left on the disc; and the files are named by the generation that wrote them — `.s3p`/`.s3s` for an S3000 disc, `.s1p`/`.s1s` for an S1000 — which is the shape [ConvertWithMoss](https://github.com/git-moss/ConvertWithMoss) wants for turning programs into a playable instrument. @@ -92,7 +94,9 @@ By filesystem: | ISO 9660 | 15 | 11 601 | — | 0 | | Roland `S770 MR25A` | 5 | 6 392 | 1 341 | 0 | -Every WAV was checked against the disc it came from — **70 of 70 discs match exactly**, comparing multisets of SHA-256 over the PCM per disc rather than going via filenames, so duplicate names cannot mask a mismatch and no path is guessed. 89 125 payloads, zero mismatches. The two audio CDs are not in that count: their tracks are cut from a stream by a cue, so there is no run of bytes on the disc to compare a track against. +"Stereo pairs" counts files joined from an `-L`/`-R` pair by name. E-mu's six are the only ones on those discs, and they are a different and much rarer thing than the 2 656 samples whose record declares two channels. + +Every WAV was checked against the disc it came from — **70 of 70 discs match exactly**, comparing multisets of SHA-256 over the PCM per disc rather than going via filenames, so duplicate names cannot mask a mismatch and no path is guessed. 89 125 payloads, zero mismatches. The E-mu stereo samples are compared with their channels put back the way the disc stored them, since their WAV holds the same bytes interleaved; `tests/test_discs.py` asserts that de-interleaving reproduces the disc's two blocks exactly, per sample, on all seven discs. The two audio CDs are not in that count: their tracks are cut from a stream by a cue, so there is no run of bytes on the disc to compare a track against. 105 082 WAV files were written in all — the samples, the stereo joins and the audio CD tracks. None is unreadable and none is zero-length. **275 are silent for their whole length, and every one of them matches the disc exactly**: 267 are the blank `15G-KIT…Z` slots on `ProSamples vol.15`, six are on a Proteus library that ships `Dead Air` as a sample, and two are on a Roland disc. That is what the discs hold, not something the decoder did. diff --git a/docs/README.md b/docs/README.md index 9ede166..227b964 100644 --- a/docs/README.md +++ b/docs/README.md @@ -57,6 +57,7 @@ All of that vanishes into a working parser. Six months on, the code says *what* | 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 | | D16 | AIFF payloads converted, deduplicated against their WAV twin, and EXS24/HALion instruments kept ([ADR-0024](adr/0024-the-aiff-twin-is-converted-and-deduplicated.md)) | done | | D17 | E-mu loop points in the WAV `smpl` chunk ([ADR-0025](adr/0025-the-loop-is-decoded-the-root-key-is-not.md)) | done | +| D18 | E-mu stereo samples decoded from the record's channel count ([ADR-0026](adr/0026-the-record-declares-the-channel-count.md)) | done | 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 275 partitions — before D15 they were 14 670 files, because only the partition at the origin was read. @@ -64,7 +65,7 @@ Across the local collection, by listing: 71 of 79 images claimed, 2 578 volumes, - **Roland S-550.** `Roland LCD1.iso`/`.nrg` opens `* ROLAND S-550 *` and is a different format from the S-7xx entirely ([ADR-0014](adr/0014-one-backend-per-on-disc-format.md)). Neither archive holds a second specimen, so it stays deferred rather than being reverse-engineered from one disc. - **Ensoniq and Kurzweil backends.** The archives are full of these discs and the containers already open them; each needs a module in `fs/` and nothing else ([ADR-0003](adr/0003-brand-neutral-pluggable-backends.md)). -- **E-mu stereo samples come out as double-length mono.** The sample record's pointer block declares a channel count, and roughly **2 700 of the 14 738** E-mu samples declare two — stored as a block of left followed by a block of right, not interleaved. Reading the payload as one mono stream concatenates the channels. D17 established this and deliberately did not fix it: correcting it moves audio, and that deliverable was scoped to add metadata without touching a byte of payload. It is the largest known defect in the E-mu path ([formats/emu3.md](formats/emu3.md), [ADR-0025](adr/0025-the-loop-is-decoded-the-root-key-is-not.md)). +- **65 records declare two channels and are not stereo.** They put `start_R` half a payload on and then close `end_L` somewhere else, and their halves measure as unrelated audio. Six of `protozoa`'s are explained — their first half is byte for byte a whole one-channel record of the same name in another bank, so the payload is twice the sound — and the rest are not. They come out mono, which is right, but *what they hold* is unestablished on `eiiix-1`'s 40, `eiiix-2`'s 6 and thirteen of `protozoa`'s ([formats/emu3.md](formats/emu3.md), [ADR-0026](adr/0026-the-record-declares-the-channel-count.md)). - **`esi32-gm` and `protozoa` declare a longer extent than their record length gives.** On about 2 200 records of one and 3 800 of the other, `end_L` runs some 45 frames past the payload `+34` produces. Either the reader is 90 bytes short on those samples or the extent field means something else; the splice test does not settle it — a scan for the true loop end peaks within ±2 frames of the declared one on 10% and 20% of records, against about 3% for chance. Those records are refused a loop rather than given a clamped one, and nothing is changed on the strength of an unsettled measurement. - **No root key for E-mu.** It is not in the sample record: no byte of the 92 tracks the note in the sample's own name above chance, over 1 741 named records of `esi32-gm` and 917 of `eiiix-1`. The E3 keeps it in the preset, and presets are not read — see the `E4P1` entry below. E-mu WAVs carry their loop with the RIFF neutral root key of 60 ([ADR-0025](adr/0025-the-loop-is-decoded-the-root-key-is-not.md)). - **`E4P1` presets are not read.** The three E-IV discs carry 916, 901 and 284 of them. On `eiv-studio` 100 of 230 banks have no sample directory and are listed with a note; preset-only banks are the likely explanation, and it is not established. diff --git a/docs/adr/0025-the-loop-is-decoded-the-root-key-is-not.md b/docs/adr/0025-the-loop-is-decoded-the-root-key-is-not.md index b5d5135..2e63620 100644 --- a/docs/adr/0025-the-loop-is-decoded-the-root-key-is-not.md +++ b/docs/adr/0025-the-loop-is-decoded-the-root-key-is-not.md @@ -99,3 +99,5 @@ Measured directly, on the records whose pointers declare two channels, the two h Those samples are stereo, and this project currently writes each as a double-length mono WAV. `eiiix-2`'s one-channel figure of 0.59 is the weakest separation and is left standing rather than explained away; `protozoa` has only 18 two-channel records with enough audio to score. Nothing in D17 changes that — fixing it moves audio, which is the one thing this deliverable must not do. It is written up in [formats/emu3.md](../formats/emu3.md) and left open for a deliverable of its own. The loop frames decoded here survive it unchanged: `(pointer − start) / 2` is a per-channel frame index either way. + +> **D18 acted on this in [ADR-0026](0026-the-record-declares-the-channel-count.md), and every loop count above survived it.** One correction to the paragraph above: the channel count alone selects 2 721 records and 65 of those are not stereo — their own `end_L` contradicts the split — so 2 656 are written as stereo. `protozoa`'s weak 0.55 is those false positives, 19 of its 27. The two records anywhere whose loop end lies past its own channel are both among the 65, which is why 107, 1 157, 1 260, 1 689, 449, 2 551 and 826 are unchanged rather than nearly unchanged. diff --git a/docs/adr/0026-the-record-declares-the-channel-count.md b/docs/adr/0026-the-record-declares-the-channel-count.md new file mode 100644 index 0000000..4c23cbb --- /dev/null +++ b/docs/adr/0026-the-record-declares-the-channel-count.md @@ -0,0 +1,93 @@ +# ADR-0026 · The record declares the channel count, and its own extents confirm it + +**Status:** accepted · 2026-08-21 + +## Context + +[ADR-0025](0025-the-loop-is-decoded-the-root-key-is-not.md) decoded the E-mu sample record's eight-pointer block and found a channel count in it: where `start_R == start_L + P/2` the payload is a **block** split — all of the left channel, then all of the right. It deliberately did not act on that, because acting on it moves audio and D17 was scoped to add metadata without touching a byte of payload. So this project shipped those samples as a mono WAV twice as long as the sound, with the right channel playing after the left instead of alongside it ([#32](https://github.com/bmxcode/samplerdisc/issues/32)). + +It hid for two deliverables because the instrument that looked for it tested the wrong hypothesis. De-interleaving a payload as `LRLR` roughly doubles its sample-to-sample delta, which is what decimating a mono signal does — a sound refutation of *interleaved* stereo, and no evidence at all about a block split, which read as mono is one continuous waveform with a single join in the middle. + +The E-IV discs also pair separate mono records into stereo by name, the way the rest of the collection does ([ADR-0017](0017-the-stereo-side-marker-is-a-character-class.md)). That is a **different mechanism** and a much rarer one: **12** samples across all seven reference discs are name-paired, against 2 656 whose record declares two channels, and no sample is both. Both being real is part of why one hid the other. + +## Decision + +**A two-channel record is one stereo sample: the two blocks are interleaved and written as a single stereo WAV in the volume's own directory. The channel count comes from the record and is confirmed by the record's own extents.** + +### The gate has three conditions, and the third one had to be measured + +``` +start_L == DATA_START and len(payload) % 4 == 0 +start_R == start_L + len(payload) // 2 +end_L + 2 == start_R +``` + +The first two are the channel count ADR-0025 found. **The third is what says the record means it**: the left block must close exactly where the right one opens. 2 721 records across the seven discs satisfy the first two conditions and **65 of them fail the third** — 19 on `protozoa`, 40 on `eiiix-1`, 6 on `eiiix-2` — declaring a left channel that overlaps the right block or stops short of it. + +Those 65 are not stereo, and the split has to reject them. The RMS envelope alone cannot see it, so two sharper instruments were used, with a control on each side: + +| | records | envelope *r* | fine structure *r* | best lag *r* | +|---|---|---|---|---| +| **positive control** — the 6 name-paired `-L`/`-R` pairs on `eiv-analogia` | 6 | 0.954 | **0.402** | **0.532** | +| **negative control** — halves taken from two different records | 200 | 0.014 | **0.006** | **0.008** | +| selected, `esi32-gm` | 28 | 0.995 | 0.671 | 0.684 | +| selected, `protozoa` | 8 | 0.548 | 0.330 | 0.421 | +| selected, `eiiix-1` | 601 | 0.843 | 0.184 | 0.377 | +| selected, `eiiix-2` | 592 | 0.955 | 0.338 | 0.691 | +| selected, `eiv-analogia` | 279 | 0.908 | 0.343 | 0.430 | +| selected, `eiv-studio` | 320 | 0.983 | 0.667 | 0.768 | +| selected, `eiv-vitous` | 828 | 0.755 | 0.433 | 0.303 | +| rejected — `end_L` past `start_R` | 20 | 0.77 | **0.05** | **0.012** | +| rejected — `end_L` short of the split | 45 | 0.06 | **0.012** | **0.023** | + +**Fine structure** is the 64-frame RMS envelope divided by its own 1024-frame trend, so what is correlated is transients rather than the decay; **best lag** is the peak normalised waveform cross-correlation over ±64 samples. Both were needed because the plain envelope is confounded: a single decaying note's two halves both decay and correlate at 0.94 without being two channels of anything, which is the same trap in a new place. + +The positive control is the point. It is twelve records this pointer block knows nothing about, whose stereo-ness is established by an entirely separate mechanism, and the selected set scores with them while the 65 rejects score with two unrelated records. + +`protozoa` is the disc to look at, because six of its rejects can be identified exactly. `Trom B2`, `Trom E3` and `Trom A3` are each written in two banks, and in all six the **first half is byte for byte the whole of a one-channel record of the same name** in `Vintage PresetsX`. Nothing matches the second half. Those payloads are twice their sound, which is why `start_R` lands on `start_L + P/2` at all — by arithmetic, not by declaration — and `end_L` says as much, closing 8 bytes past the halfway point rather than on it. Without the third condition they ship with an unaccounted-for second sound in the right channel. + +The third condition also keeps D17 whole. The only two records on any disc whose declared loop end lies past its own channel — `Mbira A3` and `Mbira F3` on `eiiix-1` — are both rejects, so they stay mono and keep their loops. All seven per-disc loop counts survive the change untouched. + +### The first block is the left channel + +The pointer block is ordered `(start_L, start_R)` and `start_L` addresses the first block. That is structural and it is the whole of the argument. + +The only content evidence available is weak, and it agrees. Of `eiv-analogia`'s twelve name-paired records, all **six** whose name ends `-L` declare their single channel in the left-hand pointer set, and three of the six ending `-R` declare theirs in the right-hand set — nine of twelve consistent, **none contradicting**, *p* ≈ 0.09. It is worth exactly what it is worth, and it is stated here so that nobody later mistakes it for the reason. + +**A swap is inaudible in isolation and wrong forever**, which is why `tests/test_emu3.py` asserts it as a named claim rather than leaving it implicit in a slice index. + +### One-channel records are left alone + +The inverse error — a record that is stereo and declares one channel — was looked for and is not supported. **Not one** of the 12 017 records that do not declare the two-channel shape declares an extent of half its payload, which is the structural signature a hidden block split would leave. The 439 whose halves correlate above 0.9 by envelope show a midpoint z-jump of −0.27 to −0.41: there is no discontinuity where a block join would be, and their names are single decaying notes — `Piano Db3`, `Glockenspiel D5`, `Snare 2`. + +`eiiix-2` is the disc that had to be checked, because the format doc gave it the weakest separation in the table at 0.59. Under the instruments above its high-envelope one-channel records score fine structure **−0.021**, which is the negative control, and its one-channel envelope median re-measures at **0.114** over 603 scored records. The gate is safe in that direction. + +## Alternatives rejected + +**Split on the channel count alone, without the extent check.** What [#32](https://github.com/bmxcode/samplerdisc/issues/32) and the format doc described, and it gives the round 2 721. Rejected on the measurement above: 65 of those records are two unrelated pieces of audio, and writing them as stereo is the failure this project cares most about — a file that opens, plays, and reports nothing wrong. It would also cost two real loops on `eiiix-1`, whose ends then lie past their own channel and must be refused. + +**Write the mono halves alongside the stereo file, as [ADR-0007](0007-emit-mono-and-stereo.md) does.** The obvious precedent, and the one that had to be thought about rather than followed. ADR-0007's argument is specific: name pairing is a **heuristic**, a wrong pairing welds two unrelated sounds together, and the mono originals are how a user notices. Neither half of that transfers. The disc states the channel count in the record and the reader checks it against two further fields of the same record, so there is no guess to hedge against — and the "original" would not be an original: it is the concatenation this deliverable exists to stop writing, a file that is not a sound. Keeping it would also make "one record is one sample" false, and that sentence is load-bearing in three tests. + +**Put the stereo file in `//stereo/`.** It is where the joined files go and it would need no new path logic. Rejected because that directory carries a meaning: *this file was assembled from two others whose names looked like a pair*. A natively-stereo record was never two files, and filing it there would tell a user the pairing had been guessed at when it was read. The directory stays for ADR-0017 joins, and a record that is stereo on the disc is written under its own name beside the mono ones. + +**Decide stereo from the audio rather than from the record.** Correlate the halves per record and split where they agree. Superficially attractive because it is content over declaration, which is this project's discipline everywhere else ([ADR-0004](0004-detect-by-signature.md)). Rejected: it inverts what the discipline actually says. Content beats *declared text* — a filename, a name field — and here the declaration is structure, three fields of one record agreeing with the payload's own length. Worse, the instrument would be the confounded one: an envelope threshold set anywhere useful takes in hundreds of decaying single notes on `protozoa` alone. Measurement's job here was to check the gate, not to be the gate. + +**Take the high-correlation one-channel records as stereo too.** Rejected on the evidence above: no structural support anywhere in 12 082 records, and the acoustic tail is explained by decay. If a disc ever does turn up with the inverse error, the thing to look for is an `end_L` covering half the payload while `start_R` reads zero — that is a real signature, and none exists today. + +**Split at `end_L` rather than at `P/2` where the two disagree.** On the 45 records whose left block is *short* of the split, one could honour `end_L` and pad. Rejected: the halves are unrelated audio, so this would produce a stereo file whose right channel is another sound, differing from the loose gate only in length. Where the record contradicts itself, the answer is not to pick a side. + +**Write the two blocks as separate mono files, `NAME-L` and `NAME-R`.** Lossless, and it would let `stereo.py` re-join them by name. Rejected as a round trip through the weaker mechanism: it would take a channel count the disc states, throw it away into a filename, and hand it to the heuristic ADR-0007 keeps originals against. It also invents names the disc does not carry, which is what [ADR-0017](0017-the-stereo-side-marker-is-a-character-class.md) refused for Roland. + +## Consequences + +**Good.** 2 656 of the 14 738 E-mu samples come out as the sound they are: 28, 601, 592, 8, 279, 320 and 828 across the seven discs, in the order the tables above and in [formats/emu3.md](../formats/emu3.md) use. `eiv-vitous` is all of it — 828 of 828, an orchestral string library that was entirely double-length mono. Their durations halve to the true one, and the CLI's verbose line stops reporting twice the length. + +**Good, and asserted rather than claimed.** The per-disc payload SHA-256 does not move on any of the seven, because `read_file` is untouched and the audio written is a **permutation** of the same bytes — which the suite now checks directly by de-interleaving each stereo sample and comparing it to the two blocks the disc stored. The sample counts do not move either: one record is one sample, and only its channel count changed. + +**Good.** Every loop count survives — 107, 1 157, 1 260, 1 689, 449, 2 551, 826 — and it survives *by construction* rather than by luck. `(pointer − start) / 2` is a per-channel frame index either way, the nesting check ADR-0025 already applies puts every loop inside the left block, and the two records that would have argued are rejects. A stereo sample's loop is now bounded by its channel, which is a tighter check than before. + +**Bad.** `protozoa` falls from 27 stereo samples to 8, and 8 is too few to establish anything on its own — that disc's selected records score 0.33 on fine structure with an *n* of 8. Its stereo rests on the rule the other six discs establish, the same way `eiv-analogia`'s loops rest on the other six in ADR-0025. + +**Bad, accepted.** Four records across `eiv-analogia` and `eiv-studio` have byte-identical halves. They are written as stereo, because the record declares two channels and this decision is that the record is the authority; a dual-mono file is a real thing a sampler can hold, and second-guessing it would mean deciding channel counts by comparing audio, which is the alternative rejected above. + +**Watch for.** A generation that splits into blocks and writes a *third* record shape — one where `end_L` genuinely means something other than the left block's close. It would be refused silently and come out double-length mono, which is exactly the failure this deliverable fixed and exactly as quiet as it was before. The per-disc stereo counts in `tests/test_discs.py` are the tripwire: they are pinned as tightly as the sample counts, so a shape arriving or leaving has something to fail against. diff --git a/docs/adr/README.md b/docs/adr/README.md index 1c112ce..45e0e18 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -31,3 +31,4 @@ If you find yourself writing an ADR with no rejected alternative, you are writin | [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 | | [0024](0024-the-aiff-twin-is-converted-and-deduplicated.md) | Convert AIFF, and drop the twin only when it says nothing new | Writing both trees; never converting; deduplicating by name; preferring the AIFF; merging its metadata into the copied WAV; calling the byte swap a conversion | | [0025](0025-the-loop-is-decoded-the-root-key-is-not.md) | The E-mu loop is decoded from the record; the root key is not there to decode | Deriving the root key from the sample name; no `smpl` chunk without a root key; clamping the loop end as AKAI and Roland do; emitting the second channel's loop as well; trusting the declared extent over the record length | +| [0026](0026-the-record-declares-the-channel-count.md) | The E-mu record declares the channel count, and its own extents confirm it | Splitting on the channel count alone; writing the mono halves alongside; filing it under `stereo/`; deciding stereo from the audio; taking high-correlation one-channel records as stereo; splitting at `end_L`; writing two mono files | diff --git a/docs/architecture.md b/docs/architecture.md index c9914e1..4987dba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -68,6 +68,8 @@ So `sample/` parses a 150-byte header for the rate, the length and the name, and `stereo.py` reconstructs stereo from the `-L`/`-R` naming convention, writing the joined file *alongside* the mono originals rather than replacing them, because the pairing is a name heuristic and heuristics should not be destructive ([ADR-0007](adr/0007-emit-mono-and-stereo.md)). +There is a second route to a stereo file and it belongs one layer down. An E-mu record declares its own channel count, so `sample/emu3.py` interleaves the two blocks it names and hands `extract` a sample that is simply stereo — no naming, no pairing, no originals kept, since nothing was inferred. `stereo.interleave` is shared between the two, which is the only thing they have in common ([ADR-0026](adr/0026-the-record-declares-the-channel-count.md)). + ## When a layer has no answer `export-iso` unwraps any container to a flat image without consulting the filesystem ([ADR-0009](adr/0009-export-iso-escape-hatch.md)). It is what a user gets when their disc is a container we understand holding a filesystem we do not — which, given E-mu and Roland discs sit in the same archives, is the expected case for a while. diff --git a/docs/formats/emu3.md b/docs/formats/emu3.md index cf371e1..5beba02 100644 --- a/docs/formats/emu3.md +++ b/docs/formats/emu3.md @@ -208,17 +208,22 @@ Reading them as pointers explains three things this doc already recorded and cou - **`+34`'s "bias of two" is not a bias.** The pointer addresses the *last word* rather than one past it, so the record ends two bytes further on. - **`+34 == 2 × (+30) − 90`**, reported below without an explanation, is `end_R = end_L + P/2`. -Two record shapes occur, and a reader must handle both. `start_R == start_L + P/2` declares **two channels**, the payload being all of the left then all of the right. `start_R` equal to `start_L`, or zero, declares **one**. Counts per disc: +Two record shapes occur, and a reader must handle both. `start_R == start_L + P/2` declares **two channels**, the payload being all of the left then all of the right; a record that declares a single channel puts 92 in one set and zero, or a copy of the same value, in the other. Either set can be the single one — 542 records on `eiv-studio` and 8 on `eiv-analogia` declare their one channel on the **right**, with the left zeroed. -| Disc | two channels | one channel | neither | -|---|---|---|---| -| `esi32-gm` | 28 | 2 230 | 7 | -| `eiiix-1` | 641 | 380 | 168 | -| `eiiix-2` | 598 | 680 | 55 | -| `protozoa` | 27 | 5 791 | 34 | -| `eiv-analogia` | 279 | 146 | 24 | -| `eiv-studio` | 320 | 1 882 | 620 | -| `eiv-vitous` | 828 | 0 | 0 | +Counts per disc, over every sample the walk yields: + +| Disc | two channels, confirmed | two channels, contradicted | one channel, left set | one channel, right set | neither | +|---|---|---|---|---|---| +| `esi32-gm` | 28 | 0 | 2 230 | 0 | 7 | +| `eiiix-1` | 601 | 40 | 380 | 0 | 168 | +| `eiiix-2` | 592 | 6 | 680 | 0 | 55 | +| `protozoa` | 8 | 19 | 5 791 | 0 | 34 | +| `eiv-analogia` | 279 | 0 | 146 | 8 | 16 | +| `eiv-studio` | 320 | 0 | 1 882 | 542 | 78 | +| `eiv-vitous` | 828 | 0 | 0 | 0 | 0 | +| **total** | **2 656** | **65** | **11 109** | **550** | **358** | + +**"Contradicted" is a shape of its own and it must be rejected**, not counted as stereo: the record declares `start_R` half a payload on and then closes its left channel somewhere else. See "Stereo" below for what those 65 records turn out to hold. The "neither" column is records whose `start_R` is none of the three values — not a right-hand single channel, which is an ordinary record and is counted as one here. An earlier revision of this table folded those 550 into "neither", giving 620 for `eiv-studio` and 24 for `eiv-analogia`. **Do not derive one channel's pointers from the other's.** `loop_start_R == loop_start_L + P/2` holds exactly on `eiv-vitous`'s 828 records and on 1.2% of `esi32-gm`'s. Read the set whose start is 92 and use it. @@ -343,7 +348,7 @@ The EIII rule — `+34` plus a bias of two equals the distance to the next recor `OFF_SAMPLE_HEADER_LEN` is not usable as a validity test either. It reads 92 on most E-IV records and **0 on 547 of `eiv-studio`'s** — those carry 92 at `+26` instead. Requiring it drops a fifth of the disc. It is not needed: the directory already says where the record is and what it is called. -## Stereo: the payload is split, not interleaved — and this was got wrong +## Stereo: the payload is split, not interleaved **A previous revision of this section concluded "everything is mono". That conclusion is wrong, and the way it was wrong is worth more than the answer.** @@ -351,20 +356,82 @@ What it measured was real: de-interleaving a payload as `LRLR` roughly **doubles The pointer block says what that layout is. `start_R == start_L + P/2` is a **block** split — all of the left channel, then all of the right — which de-interleaving cannot detect, because reading a block-split payload as mono gives one continuous waveform with a single join in the middle. -Measured directly, on the records whose own pointers declare two channels, the halves are the same performance. Median RMS-envelope correlation between them, against the same measurement on records declaring one channel: +### The gate has three conditions + +``` +start_L == 92 and P % 4 == 0 +start_R == start_L + P / 2 +end_L + 2 == start_R +``` + +`P` is the payload the record's own length field produces, and `end_L + 2` is where the left channel closes — the pointer names the first byte of the last word, so the extent runs two bytes past it. + +**The third condition is not decoration.** 2 721 records across the seven discs satisfy the first two and **65 fail the third**: 19 on `protozoa`, 40 on `eiiix-1`, 6 on `eiiix-2`. They declare a left channel that overlaps the right block — `protozoa` writes `end_L` exactly 8 bytes past `start_R` on 18 records — or one that stops well short of it, as `eiiix-1`'s `LP Up Stroke` does at 24 766 bytes of a 36 402-byte half. + +Those 65 are not stereo, and `protozoa` says what six of them are. `Trom B2`, `Trom E3` and `Trom A3` are each written in two banks, and in all six records the **first half is byte for byte the whole of a one-channel record of the same name** in `Vintage PresetsX` — 16 756 bytes of `Trom B2` against a 33 512-byte payload. Nothing on the disc matches the second half. The payload is twice the sound, so `start_R` lands on `start_L + P/2` by arithmetic rather than by declaration, and `end_L` is what gives it away: it closes the audio 8 bytes *past* the halfway point instead of on it. + +**2 656 records pass all three**, which is what this project writes as stereo: + +| Disc | stereo | of samples | +|---|---|---| +| `esi32-gm` | 28 | 2 265 | +| `eiiix-1` | 601 | 1 189 | +| `eiiix-2` | 592 | 1 333 | +| `protozoa` | 8 | 5 852 | +| `eiv-analogia` | 279 | 449 | +| `eiv-studio` | 320 | 2 822 | +| `eiv-vitous` | 828 | 828 | + +### Measured directly, the selected halves are one performance + +The instrument matters here, and the obvious one is confounded. Median RMS-envelope correlation between the two halves separates the populations — | | `esi32-gm` | `eiiix-1` | `eiiix-2` | `protozoa` | `analogia` | `studio` | `vitous` | |---|---|---|---|---|---|---|---| -| two channels declared | **0.99** | **0.71** | **0.95** | **0.55** | **0.91** | **0.96** | **0.82** | -| one channel declared | 0.13 | 0.16 | 0.59 | 0.26 | 0.21 | 0.22 | — | +| two channels, confirmed | **0.99** | **0.84** | **0.96** | 0.55 | **0.91** | **0.98** | **0.76** | +| one channel declared | 0.05 | 0.05 | 0.11 | 0.12 | 0.21 | 0.07 | — | -Envelope rather than sample-level correlation, because the two channels of a real recording differ in phase and agree in shape; sample-level correlation on the same records is modest and inconclusive, which is why it was not the instrument. `eiiix-2`'s one-channel figure of 0.59 is the weakest separation and is left standing rather than explained away, and `protozoa` has only 18 two-channel records with enough audio to score. +— but **a single decaying note's two halves both decay, and correlate at 0.94 without being two channels of anything.** On every disc a tail of one-channel records scores above 0.9 on this measure: `Piano Db3`, `Glockenspiel D5`, `Snare 2`. So two sharper instruments were used, each with a control on both sides. -**So roughly 2 700 of the 14 738 samples are stereo, and this project currently writes each as a double-length mono WAV.** That is not fixed here: correcting it moves audio, and [D17](../README.md) was scoped to add metadata without touching a byte of payload. It is open, and it is the largest known defect in the E-mu path. +**Fine structure** is the 64-frame RMS envelope divided by its own 1024-frame trend, so what is correlated is transients rather than the shape of the decay. **Best lag** is the peak normalised waveform cross-correlation over ±64 samples. The **positive control** is the twelve name-paired `-L`/`-R` records on `eiv-analogia` ([ADR-0017](../adr/0017-the-stereo-side-marker-is-a-character-class.md)) — known-true stereo established by a mechanism the pointer block knows nothing about. The **negative control** is halves taken from two different records. -The loop points decoded above survive the fix unchanged. `(pointer − start) / 2` is a per-channel frame index either way: in the double-length mono file it lands in the left block, and in a correctly interleaved stereo file it is the frame number. +| | records | fine structure *r* | best lag *r* | +|---|---|---|---| +| positive control — name-paired pairs, `eiv-analogia` | 6 | **0.402** | **0.532** | +| negative control — two different records | 200 | 0.006 | 0.008 | +| confirmed, `esi32-gm` | 28 | 0.671 | 0.684 | +| confirmed, `eiiix-1` | 601 | 0.184 | 0.377 | +| confirmed, `eiiix-2` | 592 | 0.338 | 0.691 | +| confirmed, `protozoa` | 8 | 0.330 | 0.421 | +| confirmed, `eiv-analogia` | 279 | 0.343 | 0.430 | +| confirmed, `eiv-studio` | 320 | 0.667 | 0.768 | +| confirmed, `eiv-vitous` | 828 | 0.433 | 0.303 | +| **contradicted** — `end_L` past `start_R` | 20 | **0.05** | **0.012** | +| **contradicted** — `end_L` short of the split | 45 | **0.012** | **0.023** | -E-IV discs *also* pair separate mono records into stereo by name, the way the rest of the collection does ([ADR-0017](../adr/0017-the-stereo-side-marker-is-a-character-class.md)). Both things are true, which is part of why one hid the other. +The confirmed records score with the positive control on all seven discs; the 65 the third condition rejects score with the negative control. `protozoa`'s eight are too few to establish anything on their own and rest on the other six discs. + +### The first block is the left channel + +Structural: the pointer block is ordered `(start_L, start_R)` and `start_L` addresses the first block. + +The only content evidence is weak and agrees with it. Of `eiv-analogia`'s twelve name-paired records, all **six** ending `-L` declare their single channel in the left-hand set and three of the six ending `-R` declare theirs in the right-hand set — nine of twelve consistent, **none contradicting**, *p* ≈ 0.09. Recorded for what it is: the decision rests on the field order, not on this ([ADR-0026](../adr/0026-the-record-declares-the-channel-count.md)). + +### A one-channel record that is really stereo does not occur here + +The inverse error was looked for. **Not one of the 12 017 records that do not declare the two-channel shape declares an extent of half its payload**, which is the structural signature it would leave. The 439 whose halves correlate above 0.9 by envelope show a midpoint z-jump of −0.27 to −0.41 — no discontinuity where a block join would be — and they are the decaying single notes above. + +`eiiix-2` is the disc to check, because an earlier revision of this section gave it the weakest separation in the table at 0.59 for one-channel records. Under these instruments its high-envelope one-channel records score fine structure **−0.021**, which is the negative control, and its one-channel envelope median re-measures at **0.114** over 603 scored records. Both figures are recorded; the 0.59 is not reproduced by the measurement here and is not relied on either way. + +### The loop points are unaffected + +`(pointer − start) / 2` is a per-channel frame index either way: in the double-length mono file this format produced before D18 it landed in the left block, and in the interleaved file it is the frame number. All seven per-disc loop counts are unchanged across the fix, which is the check that the two decodes of one pointer block agree. + +The two records anywhere whose declared loop end lies past its own channel — `Mbira A3` and `Mbira F3` on `eiiix-1` — are both among the 65 the third condition rejects, so they stay mono and keep their loops. + +### Name pairing is the other mechanism, and it is the rare one + +E-IV discs *also* pair separate mono records into stereo by name, the way the rest of the collection does ([ADR-0017](../adr/0017-the-stereo-side-marker-is-a-character-class.md)). Both mechanisms are real and they do not overlap: **12** samples across all seven discs are name-paired — six pairs, all on `eiv-analogia` — against **2 656** whose own record declares two channels, and no sample is both. An earlier revision of this doc gave name pairing as the answer to how these discs do stereo, which is the rare case given as the rule. ## The payload is little-endian @@ -390,17 +457,19 @@ Beware the near-miss: comparing "LE from *n*" against "BE from *n+1*" cannot dis | Bank declares (`0x30`, `0x34`) | sample area at 138 243, run of 8 248 316 | | First record | `Piano E0`, rate 12 000, 146 852 bytes of PCM | -Whole-disc listings: +Whole-disc listings, with the samples the record declares stereo: -| Disc | Volumes | Samples | -|---|---|---| -| `esi32-gm` | 10 | 2 265 | -| `eiiix-1` | 46 | 1 189 | -| `eiiix-2` | 46 | 1 333 | -| `protozoa` | 16 | 5 852 | -| `eiv-analogia` | 12 | 449 | -| `eiv-studio` | 230 | 2 822 | -| `eiv-vitous` | 44 | 828 | +| Disc | Volumes | Samples | Stereo | +|---|---|---|---| +| `esi32-gm` | 10 | 2 265 | 28 | +| `eiiix-1` | 46 | 1 189 | 601 | +| `eiiix-2` | 46 | 1 333 | 592 | +| `protozoa` | 16 | 5 852 | 8 | +| `eiv-analogia` | 12 | 449 | 279 | +| `eiv-studio` | 230 | 2 822 | 320 | +| `eiv-vitous` | 44 | 828 | 828 | + +**2 656 of 14 738**, and a stereo sample is still one sample: the counts above do not move when the channel count is read, only the file's shape does. Samples carrying a loop, of those totals: @@ -416,7 +485,7 @@ Samples carrying a loop, of those totals: **8 039 of 14 738.** The two low ones are the discs that declare a loop end past the audio they carry, which is refused rather than clamped. -These are the regression baseline: any change to the shared record parser is a bug if they move. **They are now asserted by `tests/test_discs.py`, pinned by disc size** — a table in a document is a note, not a test, and two of these numbers were wrong for a release with a green suite. The suite also pins the **SHA-256 of every sample payload per disc**, because a count table cannot see a payload that shifted by a byte while staying the same length. +These are the regression baseline: any change to the shared record parser is a bug if they move. **They are now asserted by `tests/test_discs.py`, pinned by disc size** — a table in a document is a note, not a test, and two of these numbers were wrong for a release with a green suite. The suite also pins the **SHA-256 of every sample payload per disc**, because a count table cannot see a payload that shifted by a byte while staying the same length, and the **stereo counts** beside them, because the third condition of the gate is exactly the kind of thing a later simplification removes. On a stereo sample the suite additionally de-interleaves what was written and requires it to reproduce the disc's two blocks byte for byte: the audio moved, and it must be the same audio. `esi32-gm`'s 2 424 and `protozoa`'s 6 788 are what the previous revision of this table gave, and both counted another bank's records; [ADR-0021](../adr/0021-a-bank-owns-the-run-its-header-declares.md) has the accounting. `esi32-gm` is the instructive one: it was believed clean, and its last bank ran to the end of the image and was credited with 193 records belonging to the two banks in front of it. @@ -438,7 +507,10 @@ Each of the four EIII/ESI discs lists one index bank with a note and no samples, - Each folder's bank directory must be bounded by the next folder's start block; they sit two blocks apart on `eiv-studio`. - On E-IV the record's own length field is unusable and the directory's is authoritative. The EIII rule matches 0 of 5 349 consecutive pairs. - An E-IV sample directory can appear twice. Deduplicate by address or every one of its records is listed twice. -- The paired length fields **are** a channel count, and the measurement that said otherwise tested interleaved stereo when the format splits into blocks. Roughly 2 700 samples are stereo and currently come out as double-length mono. +- The paired length fields **are** a channel count, and the measurement that said otherwise tested interleaved stereo when the format splits into blocks. 2 656 samples are stereo. +- A channel count is not enough on its own. 65 records declare `start_R` half a payload on and then close `end_L` somewhere else; their halves are unrelated audio, and on `protozoa` the second half is another bank's record. Require `end_L + 2 == start_R`. +- The two halves of a decaying note correlate at 0.94 by RMS envelope, so that measure cannot tell a stereo pair from a single note. Divide the envelope by its own trend, or correlate the waveform at a lag. +- Name pairing is the *rare* mechanism here, not the rule: 12 samples across seven discs against 2 656 declaring two channels in the record. - The sample record's fields are at `+22`, `+26`, `+30` … `+50`, not at `+24`, `+28`, `+32` … A four-byte stride begun at `+18` straddles two real fields at every step and reads as nine-digit noise. - `+22` is a start pointer, not a header length. It reads 0 where a record declares no left channel, and requiring 92 drops a fifth of `eiv-studio`. - A loop end past the payload must be **refused**, not clamped back the way AKAI and Roland clamp theirs. Clamping turns a splice correlation of +0.86 into −0.10 on `protozoa`'s own records. diff --git a/src/samplerdisc/batch.py b/src/samplerdisc/batch.py index 46e7d26..70d6a61 100644 --- a/src/samplerdisc/batch.py +++ b/src/samplerdisc/batch.py @@ -36,6 +36,11 @@ class DiscReport: origin: int | None = None volumes: list[dict[str, Any]] = field(default_factory=list) samples: int = 0 + #: Samples that were stereo on the disc, of ``samples``. Counted apart + #: from ``stereo_pairs`` because they are different news: one is a channel + #: count the record declared, the other a pairing this tool guessed from + #: two filenames (ADR-0007, ADR-0026). + stereo_samples: int = 0 stereo_pairs: int = 0 originals: int = 0 #: Entries read, understood, and deliberately not written because their @@ -102,6 +107,8 @@ def convert_disc( for result in results: if isinstance(result, Extracted): report.samples += 1 + if result.channels > 1: + report.stereo_samples += 1 entry = volumes.setdefault( (result.partition, result.volume), {"name": result.volume, "partition": result.partition, "samples": 0}, @@ -145,6 +152,7 @@ def write_manifest(path: str, reports: list[DiscReport]) -> None: "converted": sum(1 for r in reports if r.ok), "failed": sum(1 for r in reports if not r.ok), "samples": sum(r.samples for r in reports), + "stereo_samples": sum(r.stereo_samples for r in reports), "stereo_pairs": sum(r.stereo_pairs for r in reports), "audio_tracks": sum(r.audio_tracks for r in reports), "originals": sum(r.originals for r in reports), diff --git a/src/samplerdisc/cli.py b/src/samplerdisc/cli.py index 008dede..c1ea188 100644 --- a/src/samplerdisc/cli.py +++ b/src/samplerdisc/cli.py @@ -102,6 +102,7 @@ def cmd_extract(args: argparse.Namespace) -> int: print("no recognised filesystem -- try `samplerdisc export-iso`", file=sys.stderr) return 1 written = 0 + stereo = 0 joined = 0 kept = 0 skipped = 0 @@ -117,9 +118,15 @@ def cmd_extract(args: argparse.Namespace) -> int: for result in results: if isinstance(result, Extracted): written += 1 + if result.channels > 1: + stereo += 1 if args.verbose: seconds = result.frames / result.rate if result.rate else 0 - print(f" {result.volume}/{result.name} {seconds:.2f}s @ {result.rate} Hz") + channels = "stereo" if result.channels > 1 else "mono" + print( + f" {result.volume}/{result.name} " + f"{seconds:.2f}s @ {result.rate} Hz {channels}" + ) elif isinstance(result, Joined): joined += 1 if args.verbose: @@ -135,6 +142,10 @@ def cmd_extract(args: argparse.Namespace) -> int: skipped += 1 print(f" skipped {result.volume}/{result.name}: {result.reason}", file=sys.stderr) print(f"wrote {written} WAV files to {args.out}") + if stereo: + # Stereo on the disc, not joined here: the record declared two + # channels and no filename was consulted (ADR-0026). + print(f"{stereo} of them were stereo samples") if joined: print(f"joined {joined} stereo pairs (mono originals kept)") if kept: @@ -170,7 +181,9 @@ def cmd_batch(args: argparse.Namespace) -> int: if report.error: print(f" FAILED {label}: {report.error}") else: - extra = f", {report.stereo_pairs} stereo" if report.stereo_pairs else "" + extra = f", {report.stereo_pairs} joined" if report.stereo_pairs else "" + if report.stereo_samples: + extra += f", {report.stereo_samples} stereo" extra += f", {report.originals} originals" if report.originals else "" if report.audio_tracks: extra = f", {report.audio_tracks} audio tracks" diff --git a/src/samplerdisc/extract.py b/src/samplerdisc/extract.py index a907646..e9b8af7 100644 --- a/src/samplerdisc/extract.py +++ b/src/samplerdisc/extract.py @@ -92,6 +92,11 @@ class Extracted: #: filesystem has no partitions. AKAI volume names repeat across #: partitions, so the name alone does not identify a volume (ADR-0023). partition: int = 0 + #: Channels in the file written. 2 where the sample was stereo *on the + #: disc* -- an E-mu record whose pointer block declares two channels + #: (ADR-0026) or a stereo AIFF -- which is a different thing from the + #: ``Joined`` file an -L/-R pair produces, and counted apart from it. + channels: int = 1 @dataclass @@ -217,16 +222,27 @@ def extract_volume( # A format that does not carry root key, tuning or loops writes a plain # WAV rather than a wrong one. pitch = getattr(sample, "pitch", None) + # A format whose sample is stereo on the disc says so; the rest are + # mono, and one channel is the honest default rather than a guess. + channels = getattr(sample, "channels", 1) write_wav( path, sample.pcm, rate=sample.rate, + channels=channels, midi_note=pitch, cents=getattr(sample, "cents", 0.0), loops=_wav_loops(sample), name=sample.name or entry.name, ) - parsed[entry.name] = sample + if channels == 1: + # Only mono files can be halves of an -L/-R pair: ``interleave`` + # takes two mono buffers, and a sample that is already stereo is + # not one side of anything. No sample on the seven E-mu discs is + # both -- 2 656 declare two channels, 12 are name-paired, and the + # two sets do not intersect -- so this changes nothing today and + # is here so it cannot start to (ADR-0026). + parsed[entry.name] = sample yield Extracted( volume=volume.name, name=entry.name, @@ -235,6 +251,7 @@ def extract_volume( frames=sample.frames, pitch=pitch if pitch is not None else 0, partition=volume.partition, + channels=channels, ) for entry in deferred: @@ -373,6 +390,7 @@ def _copy_wav( frames=header.frames if header else 0, pitch=0, partition=volume.partition, + channels=header.channels if header else 1, ), digest, bool(header and header.has_smpl), @@ -444,6 +462,7 @@ def _convert_aiff( frames=sample.frames, pitch=sample.pitch if sample.pitch is not None else 0, partition=volume.partition, + channels=sample.channels, ) diff --git a/src/samplerdisc/sample/emu3.py b/src/samplerdisc/sample/emu3.py index a28ffda..245be2c 100644 --- a/src/samplerdisc/sample/emu3.py +++ b/src/samplerdisc/sample/emu3.py @@ -10,6 +10,13 @@ start. Those become the WAV's smpl chunk. There is no root key anywhere in the 92 bytes -- see ``pitch`` below and ADR-0025. +The same block declares a **channel count**, and where it declares two the +payload is a *block* split -- all of the left channel, then all of the right, +not interleaved. Read as one mono stream that is a file twice as long as the +sound, which is what this project shipped for 2 656 of the 14 738 E-mu samples +until D18. The two halves are interleaved here, in the sample layer, so what +``pcm`` holds is what a WAV data chunk holds either way (ADR-0026). + The endianness is worth a note, because it was got wrong first. Sampling this data at 2048-byte sector boundaries makes it read as big-endian, convincingly and repeatably. It is not: the sample payload starts at an odd byte offset, so @@ -22,6 +29,7 @@ from dataclasses import dataclass from samplerdisc.sample import NotASample as _NotASample +from samplerdisc.stereo import interleave class NotASample(_NotASample): @@ -33,6 +41,17 @@ class NotASample(_NotASample): #: or zeroed on a record declaring a single channel. DATA_START = 92 +#: An end pointer names the first byte of the *last* word rather than one past +#: it, so the extent it closes runs two bytes further. ``RECORD_LEN_BIAS`` in +#: fs/emu3.py is the same fact, seen from the record-length side. +END_POINTER_BIAS = 2 + +#: A stereo payload splits into two equal blocks, so it holds a whole number of +#: frames on both channels only when it divides by four. Every one of the 2 656 +#: records the gate below selects does; the check is here because a payload +#: that did not would be split half a sample out and sound like tape hiss. +STEREO_ALIGNMENT = 4 + #: Shorter than this and it is not a loop worth writing. The guard is the one #: docs/formats/roland-s7xx.md records the need for: without a floor on length #: a loop metric finds pairs a few frames apart in a fade-out and calls them @@ -76,6 +95,11 @@ class Emu3Sample: #: than through a special case (ADR-0025). pitch: int | None = None loops: tuple[SampleLoop, ...] = () + #: 2 where the record's pointer block declares two channels and its own + #: extents agree with the split, 1 otherwise. ``pcm`` is interleaved to + #: match, and ``frames`` counts frames rather than samples, so duration is + #: ``frames / rate`` on either (ADR-0026). + channels: int = 1 @property def duration(self) -> float: @@ -91,21 +115,75 @@ def parse( """Wrap an already-located payload. Raises NotASample if it is unusable.""" if not payload: raise NotASample("no data on disc") - frames = len(payload) // 2 + pointers = pointers or {} + if _is_block_split(pointers, len(payload)): + half = len(payload) // 2 + # The first block is the left channel -- see _is_block_split. + pcm = interleave(payload[:half], payload[half:]) + channels, frames = 2, len(payload) // 4 + else: + channels, frames = 1, len(payload) // 2 + pcm = payload[: frames * 2] if frames == 0: raise NotASample("zero-length sample") return Emu3Sample( name=fallback_name, rate=rate, frames=frames, - pcm=payload[: frames * 2], - loops=_loops(pointers or {}, frames), + pcm=pcm, + channels=channels, + loops=_loops(pointers, frames), ) +def _is_block_split(pointers: dict[str, int], size: int) -> bool: + """Whether this record declares two channels for a payload of ``size``. + + Three conditions, all read off the record rather than measured off the + audio, and the third is the one that took measuring to arrive at. + + ``start_l`` opens the audio, ``start_r`` opens it again half a payload + later -- that pair is the channel count, and reading a payload it declares + as one mono stream is what concatenated the two channels into a + double-length file (ADR-0025 found this; ADR-0026 acts on it). + + **``end_l`` must close the left block exactly where the right one opens.** + 2 721 records across the seven reference discs satisfy the first two + conditions and 65 of them fail this one -- 19 on `protozoa`, 40 on + `eiiix-1`, 6 on `eiiix-2` -- declaring a left channel that overlaps the + right block or stops short of it. They are not stereo: their halves score + 0.01 on fine structure and 0.01 on best-lag correlation, which is the + negative control of two unrelated records, while the 2 656 that pass score + with the known-true stereo pairs ADR-0017 joins by name. Six of + `protozoa`'s are identified exactly: the first half of each is, byte for + byte, the whole of a one-channel record of the same name in another bank, + so the payload is twice the sound and ``start_r`` lands on the halfway + point by arithmetic rather than by declaration. Without this condition + those come out with an unaccounted-for second sound in the right channel, + and two `eiiix-1` records declare a loop that then ends past their own + left channel. + """ + if size % STEREO_ALIGNMENT: + return False + start = pointers.get("start_l", 0) + if start != DATA_START: + return False + split = start + size // 2 + if pointers.get("start_r", 0) != split: + return False + return pointers.get("end_l", 0) + END_POINTER_BIAS == split + + def _loops(pointers: dict[str, int], frames: int) -> tuple[SampleLoop, ...]: """The sustain loop, where the record declares one this audio can carry. + ``frames`` is a count of *frames*, so on a two-channel record it is half + the payload's words. That is the right measure and needs no special case: + ``(pointer - start) / 2`` is a per-channel frame index either way, landing + in the left block of a double-length mono file and on the frame number of + an interleaved one, which is why D18 moved this audio without moving a + single loop point (ADR-0026). + Which channel's pointers to read is decided by the record, not guessed: the set that describes the audio opens at ``DATA_START``. Both sets do on a two-channel record and they name the same loop in each half; exactly one @@ -134,8 +212,9 @@ def _loops(pointers: dict[str, int], frames: int) -> tuple[SampleLoop, ...]: continue if (loop_start - start) % 2 or (loop_end - start) % 2: continue - # The end must be audio this file actually holds. See the docstring: - # clamping is what the other two formats do and what this one cannot. + # The end must be audio this file actually holds -- this channel's, + # on a two-channel record. See the docstring: clamping is what the + # other two formats do and what this one cannot. if loop_end > start + frames * 2: continue a, b = (loop_start - start) // 2, (loop_end - start) // 2 diff --git a/tests/fixtures.py b/tests/fixtures.py index b9249ed..0388fbd 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -878,26 +878,43 @@ def address(frames: int, fraction: int = 0) -> int: return bytes(image) -def _emu3_pointers(head: bytearray, payload_bytes: int, loop) -> None: - """Write a record's left-hand extent and loop pointers. +def _emu3_pointers(head: bytearray, payload_bytes: int, loop, stereo: bool = False) -> None: + """Write a record's extent and loop pointers. Byte offsets from the record's own start, naming the first byte of a word, which is what the reference discs hold -- so the end addresses the *last* word rather than one past it. + + ``stereo`` writes the two-channel form: the payload is a block split, all + of the left channel then all of the right, and both pointer sets are + written -- the right one a mirror of the left, half a payload on. That is + what 2 656 records across the seven reference discs hold (ADR-0026). """ from samplerdisc.fs.emu3 import ( OFF_SAMPLE_END_L, + OFF_SAMPLE_END_R, OFF_SAMPLE_LOOP_END_L, + OFF_SAMPLE_LOOP_END_R, OFF_SAMPLE_LOOP_START_L, + OFF_SAMPLE_LOOP_START_R, + OFF_SAMPLE_START_R, SAMPLE_HEADER_LEN, ) - struct.pack_into(" bytes: """Build a synthetic EMU3 image. @@ -944,6 +962,11 @@ def emu3_disc( A bank given no samples gets a header declaring a zero-length sample area, which is what the index banks on `esi32-gm`, `eiiix-1` and `eiiix-2` do. + ``stereo`` names the samples whose record declares two channels. Their + payload is a block split -- all of the left channel, then all of the right + -- and the frame count given for them is the payload's, so each channel + gets half of it (ADR-0026). + ``eiv`` True builds the Emulator IV shape instead -- no ``EMULATOR`` header, and each bank's samples reached through a chained ``E3S1`` sample directory. The bank slot is 1 MiB, so the allocation unit the reader has to @@ -1051,7 +1074,9 @@ def name16(text: str) -> bytes: head[2:18] = name16(sample_name) struct.pack_into(" bytes: struct.pack_into(" set[int]: *_EXPECT_NO_FILESYSTEM.values(), *(size for size, _ in _ROLAND_S7XX.values()), *(size for size, _, _ in _ISO9660.values()), - *(size for size, _, _, _, _ in _EMU3.values()), + *(size for size, _, _, _, _, _ in _EMU3.values()), *(size for size, _, _, _, _, _ in _AKAI.values()), } @@ -527,9 +528,9 @@ def _smpl(payload: bytes) -> tuple[int, list[tuple[int, int]]]: #: the seven and there was no failing test to say so. #: Labelled by the short names docs/formats/emu3.md uses, which is what the #: measurements there are recorded against. -#: ``label: (size in bytes, volumes, samples)``. -#: ``label: (size in bytes, volumes, samples, samples carrying a loop, the -#: SHA-256 of every sample payload on the disc, concatenated in walk order)``. +#: ``label: (size in bytes, volumes, samples, samples carrying a loop, samples +#: the record declares stereo, the SHA-256 of every sample payload on the disc, +#: concatenated in walk order)``. #: #: The digest is the pin that matters most. D17 decoded the sample record's #: pointer block and taught the E-mu path to write a ``smpl`` chunk, which is @@ -544,21 +545,30 @@ def _smpl(payload: bytes) -> tuple[int, list[tuple[int, int]]]: #: fewest by far -- 107 of 2 265 -- because that disc declares a loop end past #: the audio it carries on almost every record, and those are refused rather #: than clamped (ADR-0025). +#: +#: The stereo counts are D18's pin and they are **not** the count of records +#: satisfying ``start_R == start_L + P/2``, which is 2 721. It is that set +#: minus the 65 whose own ``end_L`` contradicts the split -- 19 on `protozoa`, +#: 40 on `eiiix-1`, 6 on `eiiix-2` -- which measure as unrelated audio and are +#: written mono. The two numbers differing on exactly three discs is what the +#: gate's third condition is for, so a change that loses it fails here rather +#: than shipping `protozoa`'s trombones with another bank's record in the +#: right channel (ADR-0026). _EMU3 = { - "esi32-gm": (93_077_504, 10, 2265, 107, "b7964228d84cfc50"), - "protozoa": (131_690_496, 16, 5852, 1689, "ac4b74a601955ca1"), - "eiiix-1": (304_128_000, 46, 1189, 1157, "c26ee8fb959b3f91"), - "eiiix-2": (304_435_200, 46, 1333, 1260, "2d5d002be060cc52"), - "eiv-analogia": (293_912_576, 12, 449, 449, "5d8faa38572914cb"), - "eiv-studio": (399_077_376, 230, 2822, 2551, "8802808655deea30"), - "eiv-vitous": (532_443_136, 44, 828, 826, "66c179be5b78cbd2"), + "esi32-gm": (93_077_504, 10, 2265, 107, 28, "b7964228d84cfc50"), + "protozoa": (131_690_496, 16, 5852, 1689, 8, "ac4b74a601955ca1"), + "eiiix-1": (304_128_000, 46, 1189, 1157, 601, "c26ee8fb959b3f91"), + "eiiix-2": (304_435_200, 46, 1333, 1260, 592, "2d5d002be060cc52"), + "eiv-analogia": (293_912_576, 12, 449, 449, 279, "5d8faa38572914cb"), + "eiv-studio": (399_077_376, 230, 2822, 2551, 320, "8802808655deea30"), + "eiv-vitous": (532_443_136, 44, 828, 826, 828, "66c179be5b78cbd2"), } @pytest.mark.parametrize("label", sorted(_EMU3)) def test_emu3_discs_list_their_banks_and_samples(label: str) -> None: """Pinned where present, skipped where the shelf is bare -- see _pinned_disc().""" - size, volumes_expected, samples_expected, _, _ = _EMU3[label] + size, volumes_expected, samples_expected, _, _, _ = _EMU3[label] with open_image(_pinned_disc(label, size)) as image: origin = find_origin(image) assert origin is not None, f"{label}: no filesystem found" @@ -573,22 +583,44 @@ def test_emu3_discs_list_their_banks_and_samples(label: str) -> None: assert all(v.files or v.note for v in volumes), [v.name for v in volumes if not v.files] +def _deinterleave(pcm: bytes) -> tuple[bytes, bytes]: + """The two channels of an interleaved buffer, back as they were stored. + + Through ``array`` rather than a byte slice: ``pcm[0::4]`` would take the + low byte of every left sample and leave its high byte behind. No byteswap + is needed on either host order, because this regroups 2-byte units and + never reads their value. + """ + frames = array("h") + frames.frombytes(pcm) + return frames[0::2].tobytes(), frames[1::2].tobytes() + + @pytest.mark.parametrize("label", sorted(_EMU3)) def test_emu3_loops_are_decoded_without_disturbing_the_audio(label: str) -> None: - """The D17 invariant, both halves of it (ADR-0025). - - The payload digest is the whole point: decoding the record's pointer block - must add a ``smpl`` chunk and change nothing else, and a table of sample - counts cannot see a payload that shifted by a byte while staying the same - length. Every loop must also lie inside the audio of its own sample -- - that is the gate that separates a decoded loop from a clamped one. + """The D17 invariant and the D18 one, which are the same invariant + (ADR-0025, ADR-0026). + + The payload digest is the whole point. D17 decoded the record's pointer + block and had to add a ``smpl`` chunk while changing nothing else; D18 acts + on the channel count in that same block, and the audio it writes must be a + **permutation** of the disc's bytes rather than merely the same length. So + two things are asserted at once: the digest of every payload as + ``read_file`` returns it has not moved, and de-interleaving each stereo + sample reproduces the two blocks the disc stored, byte for byte. + + The loop counts are pinned across the change for the same reason. + ``(pointer - start) / 2`` is a per-channel frame index either way, so a + loop that moves is arithmetic that drifted, not a decode that improved. + Every loop must still lie inside its own sample's frames -- which for a + stereo sample now means inside its *channel*, a tighter bound than before. """ - size, _, samples_expected, loops_expected, digest_expected = _EMU3[label] + size, _, samples_expected, loops_expected, stereo_expected, digest_expected = _EMU3[label] with open_image(_pinned_disc(label, size)) as image: origin = find_origin(image) assert origin is not None and origin.backend.name == "emu3" digest = hashlib.sha256() - samples = looped = 0 + samples = looped = stereo = 0 for volume in origin.backend.volumes(image, origin.offset): for entry in volume.samples(): samples += 1 @@ -598,6 +630,15 @@ def test_emu3_loops_are_decoded_without_disturbing_the_audio(label: str) -> None # No root key is stated anywhere in the 92-byte record, on any # of the seven discs. Inventing one is what ADR-0025 refuses. assert sample.pitch is None + if sample.channels == 2: + stereo += 1 + half = len(payload) // 2 + assert sample.frames == half // 2 + assert _deinterleave(sample.pcm) == (payload[:half], payload[half:]), ( + f"{entry.name}: the stereo audio is not the disc's own bytes" + ) + else: + assert sample.pcm == payload[: sample.frames * 2] for loop in sample.loops: assert 0 <= loop.start < loop.end <= sample.frames, ( f"{entry.name}: loop ({loop.start}, {loop.end}) is not " @@ -605,9 +646,10 @@ def test_emu3_loops_are_decoded_without_disturbing_the_audio(label: str) -> None ) looped += bool(sample.loops) assert samples == samples_expected - assert looped == loops_expected + assert looped == loops_expected, f"{label}: D18 moved a loop point" + assert stereo == stereo_expected assert digest.hexdigest()[:16] == digest_expected, ( - f"{label}: sample payloads moved -- D17 must be additive" + f"{label}: sample payloads moved -- the read path must be untouched" ) diff --git a/tests/test_emu3.py b/tests/test_emu3.py index 8cce1fc..79a4abc 100644 --- a/tests/test_emu3.py +++ b/tests/test_emu3.py @@ -570,3 +570,147 @@ def test_the_walk_carries_the_pointers_onto_the_file(tmp_path): assert entry.get("start_l") == DATA_START sample = backend.parse_sample(entry, backend.read_file(image, 0, entry)) assert [(loop.start, loop.end) for loop in sample.loops] == [(1200, 4800)] + + +# --- the channel count (D18, ADR-0026) ----------------------------------- + + +def _stereo_pointers(channel_frames, loop=None, *, end_l=None): + """A record declaring two channels over ``channel_frames`` per channel. + + The payload is twice that: all of the left channel, then all of the right. + ``end_l`` overrides where the left block says it ends, which is the third + condition of the gate and the one 65 records on the shelf fail. + """ + split = DATA_START + channel_frames * 2 + out = { + "start_l": DATA_START, + "end_l": split - 2 if end_l is None else end_l, + "loop_start_l": 0, + "loop_end_l": 0, + "start_r": split, + "end_r": split + channel_frames * 2 - 2, + "loop_start_r": 0, + "loop_end_r": 0, + } + if loop is not None: + start, stop = loop + out["loop_start_l"] = DATA_START + start * 2 + out["loop_end_l"] = DATA_START + stop * 2 + out["loop_start_r"] = split + start * 2 + out["loop_end_r"] = split + stop * 2 + return out + + +def _deinterleave(pcm: bytes) -> tuple[bytes, bytes]: + """Pull the two channels back out of an interleaved buffer, as 16-bit + frames rather than as bytes -- ``pcm[0::4]`` would take the low byte of + every left sample and leave its high byte behind.""" + return ( + b"".join(pcm[i : i + 2] for i in range(0, len(pcm), 4)), + b"".join(pcm[i : i + 2] for i in range(2, len(pcm), 4)), + ) + + +def _blocks(left: bytes, right: bytes, pointers=None, **kwargs): + payload = left + right + return parse_sample( + payload, + rate=22050, + fallback_name="X", + pointers=pointers or _stereo_pointers(len(left) // 2, **kwargs), + ) + + +def test_a_two_channel_record_is_interleaved_not_concatenated(): + """The defect this deliverable exists for: 2 656 of the 14 738 E-mu + samples came out as a mono file twice as long as the sound.""" + sample = _blocks(b"\x01\x00" * 500, b"\x02\x00" * 500) + assert sample.channels == 2 + assert sample.frames == 500 + assert len(sample.pcm) == 2000 + assert sample.duration == 500 / 22050 + + +def test_the_first_block_is_the_left_channel(): + """Asserted rather than assumed, because a swap is inaudible in isolation + and wrong forever. + + The record's pointer block is ordered ``(start_L, start_R)`` and + ``start_L`` addresses the first block, which is the whole of the + structural argument. The only content evidence is weak and agrees: of the + twelve name-paired records on `eiv-analogia`, all six `-L` names populate + the left-hand set and none of them the right (ADR-0026). + """ + sample = _blocks(b"\x11\x11" * 4, b"\x22\x22" * 4) + assert sample.pcm == b"\x11\x11\x22\x22" * 4 + + +def test_the_loop_frames_are_the_same_read_as_stereo_or_as_mono(): + """The neatest check that D17 and D18 agree. + + ``(pointer - start) / 2`` is a per-channel frame index either way: it + lands in the left block of the double-length mono file this used to write, + and on the frame number of the interleaved one it writes now. All seven + per-disc loop counts survive the change untouched. + """ + stereo = _blocks(b"\x01\x00" * 5000, b"\x02\x00" * 5000, loop=(1200, 4800)) + mono = _sample(5000, (1200, 4800)) + assert [(loop.start, loop.end) for loop in stereo.loops] == [(1200, 4800)] + assert stereo.loops == mono.loops + + +def test_a_left_block_that_does_not_end_at_the_split_stays_mono(): + """The third condition of the gate, and the one that took measuring. + + 2 721 records declare ``start_R == start_L + P/2`` and 65 of them declare + a left channel that overlaps the right block or stops short of it -- 19 on + `protozoa`, 40 on `eiiix-1`, 6 on `eiiix-2`. Those 65 are not stereo: + their halves score 0.01 on fine structure and 0.01 on best-lag + correlation, which is two unrelated records, against 0.40 and 0.53 for the + stereo pairs ADR-0017 joins by name. `protozoa`'s trombones are the case + to keep in mind -- the second half of one of those payloads is another + bank's record (ADR-0026). + """ + split = DATA_START + 500 * 2 + overlaps = _blocks(b"\x01\x00" * 500, b"\x02\x00" * 500, end_l=split + 8) + short = _blocks(b"\x01\x00" * 500, b"\x02\x00" * 500, end_l=split - 200) + assert overlaps.channels == 1 and overlaps.frames == 1000 + assert short.channels == 1 and short.frames == 1000 + + +def test_a_payload_that_does_not_divide_by_four_stays_mono(): + """Half a frame out on one channel is not stereo, it is noise. No record + on the seven reference discs is like this; the gate is here so that one + arriving is refused rather than mangled.""" + pointers = _stereo_pointers(501) + sample = parse_sample(b"\x01\x00" * 1001, rate=22050, pointers=pointers) + assert sample.channels == 1 + + +def test_a_one_channel_record_is_untouched(): + sample = _sample(5000, (1200, 4800)) + assert sample.channels == 1 + assert sample.frames == 5000 + assert sample.pcm == b"\x01\x00" * 5000 + + +def test_the_walk_carries_a_two_channel_record_through_to_the_sample(tmp_path): + """End to end: the pointers are read by the filesystem layer and the + channel count is decided in the sample layer, the same split the loop + decode uses.""" + data = fixtures.emu3_disc( + [("Default Folder", [("Bank One ", [("Wide", 22050, 4000)])])], + stereo=("Wide",), + ) + image = image_of(tmp_path, data) + volume = next(v for v in BACKEND.volumes(image, 0) if v.files) + entry = volume.files[0] + payload = BACKEND.read_file(image, 0, entry) + sample = BACKEND.parse_sample(entry, payload) + assert sample.channels == 2 + assert sample.frames == len(payload) // 4 + # The audio is the disc's own bytes, permuted -- nothing resampled and + # nothing dropped. + half = len(payload) // 2 + assert _deinterleave(sample.pcm) == (payload[:half], payload[half:]) diff --git a/tests/test_extract.py b/tests/test_extract.py index 44a986d..dc1aca5 100644 --- a/tests/test_extract.py +++ b/tests/test_extract.py @@ -261,3 +261,41 @@ def _wav_bytes(tmp_path, pcm: bytes, rate: int) -> bytes: path = tmp_path / "w.wav" write_wav(path, pcm, rate=rate) return path.read_bytes() + + +# --- a sample that is stereo on the disc (D18, ADR-0026) ------------------ + + +def test_a_stereo_emu_record_is_written_as_one_stereo_wav(tmp_path): + """Not two files, and not in ``stereo/``. + + ``/stereo/`` means "joined from two mono files whose names looked + like a pair" (ADR-0007, ADR-0017). This record is not a join: the disc + declared two channels in the sample record itself, so the file belongs in + the volume directory under its own name, and there is no mono half to keep + alongside it (ADR-0026). + """ + from samplerdisc.fs.emu3 import Emu3Backend + + path = tmp_path / "emu.iso" + path.write_bytes( + fixtures.emu3_disc( + [("Default Folder", [("Bank One ", [("Wide", 22050, 4000)])])], + stereo=("Wide",), + ) + ) + image = FlatImage(path) + out = tmp_path / "out" + results = [ + r for r in extract_disc(image, Emu3Backend(), 0, str(out)) if isinstance(r, Extracted) + ] + assert len(results) == 1 + assert results[0].channels == 2 + written = out / "Bank One" / "Wide.wav" + assert written.exists() + assert not (out / "Bank One" / "stereo").exists() + with wave.open(str(written)) as w: + assert w.getnchannels() == 2 + assert w.getnframes() == results[0].frames + # Frames, not samples: the duration the CLI prints is the sound's. + assert w.getnframes() * 4 == len(w.readframes(w.getnframes()))