diff --git a/README.md b/README.md index c9d3489..1ad10bc 100644 --- a/README.md +++ b/README.md @@ -127,7 +127,6 @@ These discs are also the only place in this project where the correct output is - **`CUES` chunks in NRG** are not parsed; only `CUEX`. No disc using the older form was to hand to check the layout against. - **An audio CD with no cue sheet cannot be split into tracks.** `samplerdisc info` tells you when a disc's content looks like Red Book audio, and `extract --assume-audio-cd` writes the whole stream as one WAV, but the track boundaries live in a cue, not in the bytes. - **AIFF-C, and 8-bit AIFF, are refused rather than read.** AIFF-C may be compressed, and compressed data written out as PCM plays as noise while reporting nothing wrong. 8-bit AIFF is signed where 8-bit WAV is unsigned, so carrying it would change every sample value rather than reorder its bytes — which is the one thing this tool does not do ([ADR-0024](docs/adr/0024-the-aiff-twin-is-converted-and-deduplicated.md)). -- **An E-mu loop that spans the whole sample is only refused where it starts at frame 0.** The format writes the sample's own bounds into the loop pointers when nothing set them, and several discs write them with a small inset instead — frame 6 to seven frames from the end. The `Ditto Drums` ESI-32 disc does it on **934 of its 948** samples, so those WAVs carry a `smpl` chunk telling a DAW to loop the entire file. A DAW that ignores `smpl` is unaffected, and the audio is right either way. - **EXS24 and HALion instruments are kept, not read.** `--keep-originals` writes the `.exs` and `.fxp` files out byte for byte; turning them into a playable instrument is ConvertWithMoss's job. - **AKAI S900, floppy images and the DD partition.** Use [akaiutil](https://sourceforge.net/projects/akaiutil/). diff --git a/docs/README.md b/docs/README.md index 4a95067..a753f45 100644 --- a/docs/README.md +++ b/docs/README.md @@ -61,6 +61,7 @@ All of that vanishes into a working parser. Six months on, the code says *what* | D19 | An AKAI payload must be the file its entry placed, and the S3000 header is 192 bytes ([ADR-0027](adr/0027-a-payload-must-be-the-file-its-entry-placed.md)) | done | | D20 | The partitions eight short AKAI images displaced, found by an anchored search ([ADR-0028](adr/0028-a-displaced-partition-is-anchored-quantised-and-floored.md)) | done | | D21 | An E-mu record is closed by the channel it declares, and found by either one ([ADR-0029](adr/0029-a-record-is-closed-by-the-channel-it-declares.md)) | done | +| D23 | The E-mu whole-extent "no loop" is refused at both ends, not only at frame 0 ([ADR-0030](adr/0030-the-whole-extent-no-loop-is-refused-at-both-ends.md)) | done | Across the local collection, by listing: 74 of 82 images claimed, 3 096 volumes, 132 802 files, 98 061 of them samples. The AKAI discs are 44 of those images and 86 177 of those files, read across 314 partitions — 275 where the disk's table puts them and 39 displaced by blocks the rip lost. Before D15 they were 14 670 files, because only the partition at the origin was read. @@ -74,7 +75,6 @@ Across the local collection, by listing: 74 of 82 images claimed, 3 096 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)). -- **An E-mu whole-extent loop is only refused where it starts at frame 0.** The format writes the sample's own bounds into the loop pointers when nothing set them, and on several discs it writes them with a small inset instead — frame 6 to seven frames from the end. `ditto-drums` does it on **934 of its 948** samples, `eiiix-1` on 460 and `eiiix-2` on 320, and those come out as a WAV telling a DAW to loop the whole file. Pre-dates D21 and was left out of it deliberately, so that the loop counts move for one reason at a time ([formats/emu3.md](formats/emu3.md), [ADR-0029](adr/0029-a-record-is-closed-by-the-channel-it-declares.md)). - **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. - **The `.mds` track table is unread.** One real pair now reads end to end, but geometry is sniffed from the `.mdf` rather than taken from the descriptor, so a multi-track or offset image would be read from byte 0. What the one specimen's descriptor holds is written down in [formats/mdx.md](formats/mdx.md) without being relied on. diff --git a/docs/adr/0030-the-whole-extent-no-loop-is-refused-at-both-ends.md b/docs/adr/0030-the-whole-extent-no-loop-is-refused-at-both-ends.md new file mode 100644 index 0000000..312a6a6 --- /dev/null +++ b/docs/adr/0030-the-whole-extent-no-loop-is-refused-at-both-ends.md @@ -0,0 +1,73 @@ +# ADR-0030 · The whole-extent "no loop" is refused at both ends + +**Status:** accepted · 2026-08-24 + +## Context + +[Issue #41](https://github.com/bmxcode/samplerdisc/issues/41). `sample/emu3.py` refuses a loop spanning a sample's whole declared extent, because that span is the E-mu format's *"no loop"*: the sampler fills the four loop pointers with the sample's own bounds when nothing set them, and emitting it writes a `smpl` chunk telling a DAW to loop the entire file. The guard was `a == 0 and b >= extent − FULL_EXTENT_SLACK` — sixteen frames of slack at the **end** and an exact zero required at the start. + +The discs do not write those bounds at frame 0. They write them **inset by a small fixed amount at both ends**: `loop_start = start + C1`, `loop_end = end − C2` for a per-disc constant of a handful of bytes. `ditto-drums` writes `(12, 12)` — frame 6 to six frames from the end — on 898 of its records; the two EIIIX discs write `(4, 4)`; `esi32-gm`, `protozoa`, `eiv-vitous` and `eiv-analogia` write `(12, 10)`. So the frame-0 guard never fired, and every one of these discs shipped a loop over the whole file — **934 of `ditto-drums`'s 948 records** most visibly, a disc of drum kits whose every WAV asked a DAW to loop the entire hit. + +This was left out of [ADR-0029](0029-a-record-is-closed-by-the-channel-it-declares.md)/D21 deliberately, so that the loop counts moved for one reason at a time. It is D23's alone. + +Widening the guard to `a <= FULL_EXTENT_SLACK` is the obvious move and is **not** obviously right. A sustained organ or string can legitimately loop over nearly its whole length, and a rule that deletes those to catch drum hits destroys real loop points to hide a heuristic. What separates a filled-in "no loop" from a real whole-extent loop had to be measured, not assumed. + +## What the discs show + +Measured against all ten reference discs. The join and shape oracle of [formats/emu3.md](../formats/emu3.md) is the wrong instrument here, exactly as the issue warned: a whole-extent loop starts within a few frames of 0 and ends within a few of the last, so there is almost no audio before its start and none after its end for the windowed correlation to work on. Two measurements that **do** apply separate the populations on every disc. + +**End energy.** The RMS in a 64-frame window at the loop end, as a fraction of the sample's peak, is below 15 % on 70–100 % of the inset population — the loop ends in silence — where a real loop ends that quietly on only 13–33 %. A loop is not a loop when it ends in silence. + +**Uniqueness of the splice.** Where a record is loud enough at both ends to score, the join splices seamlessly where a random start against the same end does not — the signature of a chosen loop point — on 0–11 % of the inset population against 33–56 % of the loops this project already ships. The right-hand column is that same measurement on the disc's real loops: the calibration of what a real loop looks like on this instrument, and the inset population sits far below it every time. + +| Disc | refused | inset ends quiet | inset uniquely-splices | real-loop control | +|---|---:|---:|---:|---:| +| `esi32-gm` | 428 | 70 % | 11 % | 37 % | +| `protozoa` | 1 762 | 72 % | 10 % | 37 % | +| `eiiix-1` | 472 | 98 % | 1 % | 49 % | +| `eiiix-2` | 372 | 97 % | 2 % | 41 % | +| `emu-classics` | 302 | 88 % | 3 % | 36 % | +| `vintage` | 107 | 82 % | 5 % | 46 % | +| `ditto-drums` | 934 | 100 % | 0 % | 43 % | +| `eiv-analogia` | 443 | 97 % | 1 % | 33 % | +| `eiv-studio` | 337 | 87 % | 4 % | 41 % | +| `eiv-vitous` | 628 | 100 % | — | 56 % | + +**Structure agrees with content.** The inset bounds are the record's own extent inset by a *fixed* constant — the signature of an auto-filled field. A hand-set loop start is at an arbitrary musical position: on the loops this project already ships, the byte inset from the record's start is a scattered 44, 136, 11 204, 52 536, never a fixed few. So the population the guard refuses is also the one the structure marks as unauthored. + +**`eiv-vitous` and `eiv-studio` are the check that matters.** [ADR-0025](0025-the-loop-is-decoded-the-root-key-is-not.md) validated their loops by the shape test at +0.68 and +0.86, and those validated loops are the *real-loop* column above — they are the `narrow` population, and they are **kept**. What is refused on those two discs is a separate whole-extent population that ends in silence. `eiv-analogia`'s loops never had independent evidence — ADR-0025 recorded that only 34 scored and those showed nothing — so refusing 443 of its 449 takes nothing that was ever established. + +## Decision + +**Refuse a loop whose bounds lie within `FULL_EXTENT_SLACK` frames of both ends, not only the start-0 case.** One line in `sample/emu3.py`: + +```python +if a <= FULL_EXTENT_SLACK and b >= extent - FULL_EXTENT_SLACK: + continue +``` + +`FULL_EXTENT_SLACK` stays 16 frames: every inset observed is at most 12 frames (`d_start` of 24 bytes) at the start and 7 at the end, so 16 covers them with margin. The rule fires only when a loop is within the slack of **both** bounds, so a real loop that merely begins near the front, or merely ends near the back, is untouched — the only records at risk are those whose loop *is* the record's own extent. + +The rule is **structural** — the loop is the record's own bounds — and the measurement justifies it rather than living in the parser. This is the same discipline as everywhere else in the project ([ADR-0004](0004-detect-by-signature.md), [ADR-0025](0025-the-loop-is-decoded-the-root-key-is-not.md)): a content test decides what a rule should be, and the shipped rule reads declared structure. + +## Alternatives rejected + +**Leave the guard at `a == 0`.** Ships the bug on ten discs, `ditto-drums` worst of all. + +**A content guard: refuse a whole-extent loop only where its end is in silence.** This keeps the loud-ended inset loops, which is attractive on `esi32-gm` and `protozoa` where ~10 % of the refused population splices uniquely. Rejected: it puts an RMS measurement of the audio into the shipping parser, which is exactly the coupling [ADR-0025](0025-the-loop-is-decoded-the-root-key-is-not.md)'s "watch for: the gates loosening" warns against — a loop decode that depends on the loudness of the sample is a decode that fails silently and differently on the next disc. And the loud-ended remainder is not clearly real: it splices seamlessly at random starts nearly as often as at the whole-extent bound, which is a sustained tone that would splice anywhere, not a chosen loop point. + +**Refuse only bounds matching each disc's dominant `(C1, C2)` inset.** More precise on paper. Rejected: it needs a per-disc constant fitted from the data and carried in the parser, and the constants — `(4, 4)`, `(12, 10)`, `(12, 12)` — are close enough to a real near-whole loop's that the fit buys nothing the simple both-ends slack does not. One structural rule beats ten fitted ones. + +**Widen the end slack instead / raise `FULL_EXTENT_SLACK`.** Misreads the bug. The end already had its slack; it was the start that was pinned to exactly 0. Sixteen frames is enough at both ends and there is no evidence for more. + +## Consequences + +**Good.** The whole-extent "no loop" stops being emitted on all ten discs. `ditto-drums` goes from 948 loops to 14, `eiv-analogia` from 449 to 6, `eiv-vitous` from 826 to 198 — the drum and no-loop discs shed almost all of theirs, and the melodic discs keep most: `emu-classics` 1 435 → 1 133, `vintage` 953 → 846. + +**Good, and asserted.** No audio moved. `read_file` and the offset arithmetic are untouched, so every per-disc payload digest in `tests/test_discs.py` is unchanged, and the sample and stereo counts with them. Only the loop-count column moved, on every disc, and the suite pins the new numbers. + +**Bad, and the headline.** Every EIII/ESI and E-IV loop count moves. Anyone who extracted an E-mu disc before this got a `smpl` chunk looping the whole file on a large fraction of its samples — harmless where a DAW ignores `smpl`, wrong where it does not. The audio was always right; the loop metadata was not. + +**Stated cost.** On `esi32-gm` and `protozoa` about 10 % of the refused records splice uniquely and could conceivably be a near-whole-extent loop the disc intended. They are refused with the rest because they are structurally the record's own bounds inset by the same fixed constant, and separating them would need the content test this project keeps out of the parser. This is the one place the rule may take a real loop, and it is recorded rather than hidden. + +**Watch for.** A generation that writes its "no loop" some other way — a different inset, or a genuine frame-0-to-last span — is not caught by this and gets a whole-file loop with nothing to say so. The rule catches the form ten discs write, not every form the format could. diff --git a/docs/adr/README.md b/docs/adr/README.md index dac792b..14b944f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -35,3 +35,4 @@ If you find yourself writing an ADR with no rejected alternative, you are writin | [0027](0027-a-payload-must-be-the-file-its-entry-placed.md) | An AKAI payload must be the file its entry placed, and its header length is the generation the entry declares | Closing #23 as not reproducing; dropping the name comparison; solving for the header length at runtime; branching on the id and valid bytes; sniffing the header pattern; an allow-list of valid bytes; never relaxing the flag; writing a mismatch under the payload's name; a `--force` flag; a third result shape; fixing the mid-partition damage here | | [0028](0028-a-displaced-partition-is-anchored-quantised-and-floored.md) | A displaced AKAI partition is searched for from its declared position, in the container's unit, and never inside one already read | Leaving them unread; scanning for the signature; filtering a scan by file yield; searching in AKAI blocks; truncating an overlapping recovery; moving the present partition instead; refusing a whole disc for one clash; relaxing the floor for `Alpha Dance I`; searching forward; per-file provenance; fixing #35 here | | [0029](0029-a-record-is-closed-by-the-channel-it-declares.md) | An E-mu record is closed by the channel it declares, and found by either one | Falling back to `end_L` only where `end_R` fails; `max(end_L, end_R)` unconditionally; widening the signature alone; bounding the extent by the next record found; fixing the whole-extent loop guard here | +| [0030](0030-the-whole-extent-no-loop-is-refused-at-both-ends.md) | The whole-extent "no loop" is refused within the slack of both ends, not only frame 0 | Leaving the guard at `a == 0`; a content guard on end silence; matching each disc's fixed inset; widening the end slack instead | diff --git a/docs/formats/emu3.md b/docs/formats/emu3.md index ff28982..38bf60f 100644 --- a/docs/formats/emu3.md +++ b/docs/formats/emu3.md @@ -352,7 +352,30 @@ The **record newly found** rows are the right-declared and zeroed-right-set reco The scan for a better end that an earlier revision reported — a peak within ±2 frames of the declared end on 10% of `esi32-gm`'s records against about 3% for chance — was measuring the 92-byte shortfall, and is not repeated. -**934 of `ditto-drums`'s 948 loops span the whole sample**, starting at frame 6 and ending seven frames from the end. That is the format's "no loop" — the sample's own bounds, written with a small inset — and the guard here refuses a whole-extent loop only where it starts at exactly 0. It is not new and not this deliverable's to fix: `eiiix-1` ships 460 of them and `eiiix-2` 320, and folding a second loop rule in would move the loop counts for two unrelated reasons at once. +### The whole-extent "no loop" is written with a small inset, at both ends + +A loop spanning the sample's whole declared extent is the format's **"no loop"**: the sampler fills the four loop pointers with the sample's own bounds when nothing set them, and emitting it writes a `smpl` chunk telling a DAW to loop the entire file. The bounds are not written at exactly frame 0 and the last frame — they are **inset by a small fixed amount at both ends**, `loop_start = start + C1` and `loop_end = end − C2` for a per-disc constant of a handful of bytes. `ditto-drums` writes `(12, 12)` on 898 of its records — frame 6 to six frames from the end — the two EIIIX discs write `(4, 4)`, and `esi32-gm`, `protozoa`, `eiv-vitous` and `eiv-analogia` write `(12, 10)`. A real loop's start is at an arbitrary musical position instead: on the loops this project already ships, the byte inset from the record's own start is a scattered 44, 136, 11 204, 52 536, never a fixed few. + +Refusing only the frame-0 form shipped a loop over the entire file on **934 of `ditto-drums`'s 948 records**, and on nine other discs besides. Whether that inset population is a filled-in "no loop" or a real loop that happens to span the sample had to be measured — a sustained organ or string can legitimately loop over nearly its whole length, and deleting those to catch drum hits would destroy real loop points. It was measured against all ten reference discs, and the population is a "no loop" on every one. + +The join and shape oracle above is the wrong instrument here, exactly as expected: a whole-extent loop starts within a few frames of 0 and ends within a few of the last, so there is almost no audio before its start and none after its end for the windowed correlation to work on. Two measurements that **do** apply to it separate the populations cleanly. The **end energy** — the RMS in a 64-frame window at the loop end, as a fraction of the sample's peak — is below 15 % on 70–100 % of the inset population, where a real loop ends that quietly on only 13–33 %: a loop is not a loop when it ends in silence. And where a record is loud enough at both ends to score, the join's **uniqueness** — that it splices where a random start against the same end does not, the signature of a chosen loop point — holds on 0–11 % of the inset population against 33–56 % of the real loops. + +| Disc | refused | inset ends quiet | inset uniquely-splices | real-loop control | +|---|---:|---:|---:|---:| +| `esi32-gm` | 428 | 70 % | 11 % | 37 % | +| `protozoa` | 1 762 | 72 % | 10 % | 37 % | +| `eiiix-1` | 472 | 98 % | 1 % | 49 % | +| `eiiix-2` | 372 | 97 % | 2 % | 41 % | +| `emu-classics` | 302 | 88 % | 3 % | 36 % | +| `vintage` | 107 | 82 % | 5 % | 46 % | +| `ditto-drums` | 934 | 100 % | 0 % | 43 % | +| `eiv-analogia` | 443 | 97 % | 1 % | 33 % | +| `eiv-studio` | 337 | 87 % | 4 % | 41 % | +| `eiv-vitous` | 628 | 100 % | — | 56 % | + +The last column is the same uniqueness measured on the loops this project already ships on that disc — the calibration of what a real loop looks like on this instrument — and the inset population sits far below it every time. `eiv-vitous` and `eiv-studio` are the check that matters most: [ADR-0025](../adr/0025-the-loop-is-decoded-the-root-key-is-not.md) validated their loops by the shape test at +0.68 and +0.86, and those validated loops are the *real-loop* column here — they are kept. What is refused on those two discs is a separate whole-extent population that ends in silence. `eiv-analogia`'s loops never had independent evidence — ADR-0025 recorded that only 34 scored and those showed nothing — so refusing 443 of its 449 whole-extent "loops" takes nothing that was ever established. + +So the guard refuses a loop whose bounds lie within `FULL_EXTENT_SLACK` frames of **both** ends, not only the start-0 case ([ADR-0030](../adr/0030-the-whole-extent-no-loop-is-refused-at-both-ends.md)). It is a structural rule — the loop is the record's own extent — justified by content, not a content test in the parser. It does not claim to catch every "no loop" a future generation might write, and on `esi32-gm` and `protozoa` a minority of the refused records (about 10 %) splice uniquely and could conceivably be a near-whole-extent loop the disc intended; they are refused with the rest because they are structurally the record's own bounds inset by that same fixed constant, and telling them apart would need a content measurement this project keeps out of the parser ([ADR-0025](../adr/0025-the-loop-is-decoded-the-root-key-is-not.md)). ### There is no root key @@ -577,18 +600,18 @@ Samples carrying a loop, of those totals: | Disc | Samples | With a loop | | |---|---|---|---| -| `esi32-gm` | 2 635 | 1 778 | 67% | -| `protozoa` | 6 595 | 5 244 | 80% | -| `eiiix-1` | 1 248 | 1 215 | 97% | -| `eiiix-2` | 1 337 | 1 264 | 95% | -| `emu-classics` | 1 516 | 1 435 | 95% | -| `vintage` | 993 | 953 | 96% | -| `ditto-drums` | 948 | 948 | 100% | -| `eiv-analogia` | 449 | 449 | 100% | -| `eiv-studio` | 2 822 | 2 551 | 90% | -| `eiv-vitous` | 828 | 826 | 100% | - -**16 663 of 19 371.** `esi32-gm`'s 5% and `protozoa`'s 29% were what a 92-byte-short extent looked like from the loop side: those discs declare a loop end that runs to the very end of the audio, so losing 46 frames off the end lost the loop with them. `ditto-drums`'s 100% is not as good as it looks — 934 of its 948 span the whole sample, which is the format's "no loop" written with a small inset, and the guard here does not yet catch that form. +| `esi32-gm` | 2 635 | 1 350 | 51% | +| `protozoa` | 6 595 | 3 482 | 53% | +| `eiiix-1` | 1 248 | 743 | 60% | +| `eiiix-2` | 1 337 | 892 | 67% | +| `emu-classics` | 1 516 | 1 133 | 75% | +| `vintage` | 993 | 846 | 85% | +| `ditto-drums` | 948 | 14 | 1% | +| `eiv-analogia` | 449 | 6 | 1% | +| `eiv-studio` | 2 822 | 2 214 | 78% | +| `eiv-vitous` | 828 | 198 | 24% | + +**10 878 of 19 371.** These are D23's numbers: the whole-extent "no loop" is now refused at both ends rather than only where it starts at frame 0, so the loop-over-the-whole-file that every disc writes with a small fixed inset stops being emitted — `ditto-drums` from 948 to 14, `eiv-analogia` from 449 to 6, `eiv-vitous` from 826 to 198 ([ADR-0030](../adr/0030-the-whole-extent-no-loop-is-refused-at-both-ends.md), and "The whole-extent 'no loop'" above). The D21 revision's figures were `esi32-gm` 1 778, `protozoa` 5 244, `eiiix-1` 1 215, `eiiix-2` 1 264, `emu-classics` 1 435, `vintage` 953, `ditto-drums` 948, `eiv-analogia` 449, `eiv-studio` 2 551, `eiv-vitous` 826 — every one of them counting the no-loops. The **sample** counts, the stereo counts and every payload digest are unchanged across D23: only loop emission moved, which is what says the read path was not touched. 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. @@ -624,6 +647,7 @@ Each of the seven EIII/ESI discs lists one index bank with a note and no samples - A record may declare its one channel on the **right** on an EIII disc too, not only on E-IV. Scan for 92 at `+26` as well as at `+22`, or `vintage`'s `Juno Synths` reads as an empty bank. - A bank's declared run is an independent check on the record extent, and it was already in this doc reading as a loose fit. If the last record of a bank stops one 92-byte header short of `0x30 + 74 + 0x34`, the extent is wrong, not the run. - 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. +- The whole-extent "no loop" is written **inset by a fixed few bytes at both ends**, not at frame 0. Refusing only the start-0 case shipped a loop over the entire file on 934 of `ditto-drums`'s 948 records. It ends in silence and carries no uniquely-splicing loop point, and is refused within `FULL_EXTENT_SLACK` of both bounds (ADR-0030). - There is no root key in the sample record. No byte tracks the note in the sample's name above chance, and `+58` — the field that looks most like one — is the sample rate. ## What `protozoa` taught, in one place diff --git a/src/samplerdisc/sample/emu3.py b/src/samplerdisc/sample/emu3.py index 3d86e32..553df34 100644 --- a/src/samplerdisc/sample/emu3.py +++ b/src/samplerdisc/sample/emu3.py @@ -63,6 +63,24 @@ class NotASample(_NotASample): #: sampler fills the pointers with the sample's own bounds when nothing set #: them. Emitting it would tell a DAW to loop the entire file, which is not #: what the disc means and is worse than saying nothing. +#: +#: The bounds are written **inset by a small fixed amount at both ends**, not at +#: exactly the record's start and end -- ``loop_start = start + C1``, +#: ``loop_end = end - C2`` for a per-disc constant of a handful of bytes: +#: ``ditto-drums`` writes ``(12, 12)`` on 898 of its records (frame 6 to six +#: frames from the end), the EIIIX discs ``(4, 4)``, `esi32-gm` and `protozoa` +#: ``(12, 10)``. So the guard allows the same slack at the **start** as at the +#: end. Measured against all ten reference discs, the whole-extent population is +#: a filled-in "no loop" and not a real loop that happens to span the sample: +#: 70-100 % of it ends in silence (the loop end sits below 15 % of the sample's +#: peak) where a real loop does so only 13-33 % of the time, its bounds are the +#: record's own extent inset by that fixed constant where a real loop's start is +#: at an arbitrary musical position, and it carries a uniquely-splicing loop +#: point on 0.2-11 % of records where a real loop does on 33-56 %. The join and +#: uniqueness are the shape/join oracle of docs/formats/emu3.md; the end-energy +#: is what carries the discs the oracle has no power on -- a whole-extent loop +#: has almost no audio before its start, so the windowed correlation cannot +#: score it (ADR-0030). FULL_EXTENT_SLACK = 16 #: The channel pointer sets, in the order they are tried. @@ -230,7 +248,12 @@ def _loops(pointers: dict[str, int], frames: int) -> tuple[SampleLoop, ...]: extent = min((end - start) // 2, frames) if b - a < MIN_LOOP_FRAMES: continue - if a == 0 and b >= extent - FULL_EXTENT_SLACK: + # The whole-extent "no loop", refused at both ends. Several discs write + # those bounds inset by a fixed few bytes rather than at exactly frame 0, + # so the start carries the same slack as the end -- see FULL_EXTENT_SLACK + # and ADR-0030. Refusing the start-0 case alone shipped a loop over the + # entire file on 934 of `ditto-drums`'s 948 records. + if a <= FULL_EXTENT_SLACK and b >= extent - FULL_EXTENT_SLACK: continue return (SampleLoop(start=a, end=b),) return () diff --git a/tests/test_discs.py b/tests/test_discs.py index 756ec12..24353e8 100644 --- a/tests/test_discs.py +++ b/tests/test_discs.py @@ -577,6 +577,15 @@ def _smpl(payload: bytes) -> tuple[int, list[tuple[int, int]]]: #: was measured is a gate that has come loose, and one disappearing is the #: decode silently failing on a disc nobody looked at. #: +#: **D23 moved every loop count and no digest.** The whole-extent "no loop" is +#: now refused at both ends, not only where it starts at frame 0, so the loops +#: over the entire file that ten discs wrote with a small fixed inset stop being +#: emitted: `ditto-drums` 948 -> 14, `eiiix-1` 1 215 -> 743, `eiv-vitous` +#: 826 -> 198, `eiv-analogia` 449 -> 6. The measurement is in ADR-0030 and the +#: "Loop points" section of the format doc; the discs' *sample* counts, stereo +#: counts and every payload digest are unchanged, which is what says only loop +#: emission moved and the read path did not (ADR-0030). +#: #: The stereo counts are D18's pin and they are the records whose own pointer #: block declares two channels *and* closes the left one where the right one #: opens (ADR-0026). Under D21's extent they are the same four numbers on the @@ -599,16 +608,16 @@ def _smpl(payload: bytes) -> tuple[int, list[tuple[int, int]]]: #: the ones D21 was measured against. `ditto-drums` is the whole shape of the #: bug on one disc: 74 samples before, 948 after. _EMU3 = { - "esi32-gm": (93_077_504, 10, 2635, 1778, 28, "35a6c6edfc6f292a"), - "protozoa": (131_690_496, 16, 6595, 5244, 8, "3530e1e972f100ab"), - "eiiix-1": (304_128_000, 46, 1248, 1215, 601, "c73db948bf4f61cb"), - "eiiix-2": (304_435_200, 46, 1337, 1264, 592, "8bfcddf6d8dbd806"), - "emu-classics": (526_723_072, 22, 1516, 1435, 185, "e1398c11a9e7cb02"), - "vintage": (527_030_272, 16, 993, 953, 2, "fbcc378dd173f96c"), - "ditto-drums": (308_121_600, 48, 948, 948, 0, "3c756586711810de"), - "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"), + "esi32-gm": (93_077_504, 10, 2635, 1350, 28, "35a6c6edfc6f292a"), + "protozoa": (131_690_496, 16, 6595, 3482, 8, "3530e1e972f100ab"), + "eiiix-1": (304_128_000, 46, 1248, 743, 601, "c73db948bf4f61cb"), + "eiiix-2": (304_435_200, 46, 1337, 892, 592, "8bfcddf6d8dbd806"), + "emu-classics": (526_723_072, 22, 1516, 1133, 185, "e1398c11a9e7cb02"), + "vintage": (527_030_272, 16, 993, 846, 2, "fbcc378dd173f96c"), + "ditto-drums": (308_121_600, 48, 948, 14, 0, "3c756586711810de"), + "eiv-analogia": (293_912_576, 12, 449, 6, 279, "5d8faa38572914cb"), + "eiv-studio": (399_077_376, 230, 2822, 2214, 320, "8802808655deea30"), + "eiv-vitous": (532_443_136, 44, 828, 198, 828, "66c179be5b78cbd2"), } diff --git a/tests/test_emu3.py b/tests/test_emu3.py index 71982c3..13ed71b 100644 --- a/tests/test_emu3.py +++ b/tests/test_emu3.py @@ -636,6 +636,45 @@ def test_a_loop_spanning_the_whole_extent_is_not_a_loop(): assert _sample(5000, (0, 5000)).loops == () +def test_a_whole_extent_loop_inset_a_few_frames_is_still_not_a_loop(): + """The format writes its "no loop" bounds inset by a fixed few bytes at + *both* ends, not at exactly frame 0 (ADR-0030). + + `ditto-drums` writes ``(12, 12)`` bytes -- frame 6 to six frames from the + end -- on 898 of its records, the EIIIX discs ``(4, 4)``, `esi32-gm` and + `protozoa` ``(12, 10)``. Refusing only the frame-0 form shipped a loop over + the entire file on 934 of `ditto-drums`'s 948 records. Measured across all + ten reference discs the inset whole-extent population is a filled-in "no + loop" -- it ends in silence and carries no uniquely-splicing loop point -- + so the guard carries the same slack at the start as at the end. + """ + frames = 5000 + # extent is frames - 1 here (the end pointer names the last word), so the + # far end must clear extent - FULL_EXTENT_SLACK. + assert _sample(frames, (2, frames - 8)).loops == () # EIIIX-style (4, 4) + assert _sample(frames, (6, frames - 7)).loops == () # ditto-style (12, 12) + + +def test_a_near_whole_loop_inset_past_the_slack_is_kept(): + """The guard is targeted: a loop whose start clears the slack, or whose end + stops short of it, is a real loop and survives. + + Only a span within FULL_EXTENT_SLACK of *both* bounds is the "no loop"; a + genuine sustain loop that begins well inside the sample, or ends well short + of it, is the `narrow` population the measurement calibrated against and is + emitted unchanged. + """ + frames = 5000 + + def bounds(loop): + return [(s.start, s.end) for s in _sample(frames, loop).loops] + + # start past the slack, end at the extent: a real loop. + assert bounds((200, frames - 7)) == [(200, frames - 7)] + # start inside the slack but end well short of the extent: also a real loop. + assert bounds((6, 3000)) == [(6, 3000)] + + def test_a_loop_shorter_than_the_floor_is_dropped(): assert _sample(5000, (1200, 1200 + MIN_LOOP_FRAMES - 1)).loops == () assert _sample(5000, (1200, 1200 + MIN_LOOP_FRAMES)).loops != ()