description: "Audit DPCM/drum sampling — drum mapping, sample conversion, DMC constraints" argument-hint: "[--focus <dims>]"
DPCM / Drum-Sampling Audit
Audit the DPCM/drum subsystem — how MIDI General-MIDI percussion notes become DPCM
samples, how .wav is converted/packed into NES 1-bit delta data, and how the DMC
channel is driven. This subsystem owns everything under dpcm_sampler/ plus the
DMC-facing edges of the channel pipeline and the CA65 exporter.
Shared protocol (layout, dedup, finding format): .claude/commands/_audit-common.md.
Severity rubric: .claude/commands/_audit-severity.md. Do not restate them.
Hardware claims must cite docs/APU_DMC_REFERENCE.md (register map, the
$C000 + A*64 address formula, the (L*16)+1 length formula, 64-byte address
alignment, the 0–127 output-level clamp, the $FFFF→$8000 address-wrap quirk) and
docs/NES_DMA_REFERENCE.md (DMC DMA steals 3–4 CPU cycles per byte and is the source
of the controller/$2007/$4015 extra-read glitch). Prefer those docs over
re-deriving APU behavior from source.
A dropped or out-of-range drum sample removes audible content, so per
_audit-severity.md it is at least MEDIUM, and HIGH when it silently strips a hit
that the MIDI clearly intended.
Sprint note: a large bug-fixing pass (commits
be4d2bd…8225696) closed most of the issues this audit used to lead with (#64–#74, #140). The dimensions below describe the current (fixed) behavior and ask you to verify the fix holds up under edge cases, rather than re-discovering the original bugs. #76 remains genuinely open (Dimension 6). #75 (thelength_regrounding) was closed on GitHub, then regressed, and was re-fixed on this tree as #295 (commitd392ef6) —_place_samplenow ceils, so Dimension 4's item is fixed (#295); verify it holds.
Parameters (from $ARGUMENTS)
--focus <dims>— comma-separated dimension numbers (e.g.--focus 1,4). Default: all.
Extra Per-Finding Field
- Hardware ref: the
docs/APU_DMC_REFERENCE.md/docs/NES_DMA_REFERENCE.mdsection backing any hardware claim (omit for pure-software findings).
Dimensions
Dimension 1: Drum-note → sample mapping & coverage
The GM-note maps live in dpcm_sampler/drum_engine.py
(DEFAULT_MIDI_DRUM_MAPPING lines 8-56, ADVANCED_MIDI_DRUM_MAPPING lines 58-78).
Mapping resolution is in EnhancedDrumMapper._resolve_dpcm_sample_name and
map_drums (dpcm_sampler/enhanced_drum_mapper.py):
- Fixed (#73/D-10, verify):
DEFAULT_MIDI_DRUM_MAPPINGnow covers the full GM percussion range 35–81 with generic role names (kick/snare/tom/cymbal/etc, not just 7 notes).ADVANCED_MIDI_DRUM_MAPPINGstill only hand-tunes velocity splits for notes 36 and 38; every other note relies on theDEFAULT_MIDI_DRUM_MAPPINGfallback by design (see the trailing comment atdpcm_sampler/drum_engine.py:75-77). Confirm a mid-range note (e.g. 47, mid tom) still resolves to a real sample name and notNone. - Fixed (verify):
_resolve_dpcm_sample_name(dpcm_sampler/enhanced_drum_mapper.py:390-418) no longer stops at the first candidate — it tries the velocity-split name, then the advanced"primary"name, then theDEFAULT_MIDI_DRUM_MAPPINGrole name, in that order, and only returnsNone(→ noise fallback) if none of the three exist inself.sample_index. Verify this cascade actually reaches the index for a name likekick_softthat legitimately isn't present (falls through to"kick"then to the default role name) rather than silently dropping. - Velocity-0 note-offs are correctly skipped
(
if e.get('velocity', 0) == 0: continue,enhanced_drum_mapper.py:254-255). _get_advanced_sample(enhanced_drum_mapper.py:374-388) selects byvelocity_ranges; its result is now just one candidate in the fallback chain above rather than a hard commit — confirm a nonexistent velocity-split name no longer kills the whole event, only that one candidate.
Dimension 2: dpcm_index.json schema integrity
The index is loaded in the same three places, still against two different shapes:
EnhancedDrumMapper._load_sample_index(dpcm_sampler/enhanced_drum_mapper.py:211-224) passes each entry assample_datatoDPCMSampleManager.allocate_sample(dpcm_sampler/dpcm_sample_manager.py:15-65), which readssample_data.get('length', 1024)(line 34),sample_data.get('data', [])(line 55), andsample_data.get('frequency', 33144)(line 58).- Still true (this is not a bug that was "fixed", just the current shape of the
data): the real
dpcm_index.jsonentries only containidandfilename(verify:python -c "import json; print(list(json.load(open('dpcm_index.json')).values())[0])"). Solength,data, andfrequencystill always fall back to defaults on real input. What did change (#70/#71, see Dimension 7) is that the sample manager now uses one consistent accounting formula for those defaults instead of two divergent ones, and the now-dead similarity/dedup code that also depended ondatawas removed outright rather than left silently inert. Assess whether relying on constant placeholder size/frequency for every sample is still an acceptable simplification or worth back-filling from the real.dmcfiles at index-generation time. - The packer path moved:
dpcm_sampler/generate_dpcm_index.py:load_dpcm_index_into_packer(lines 38-79) is now the single shared call site (used from bothmain.py:run_export~lines 317-345 andmain.py:run_full_pipeline~lines 625-670) and readssample.get('pitch', 15)(line 75) andsample['filename'](line 66) —pitchis still absent from the shipped index. Confirmid/filenameremain the only keys any consumer can rely on, and thatgenerate_dpcm_index(dpcm_sampler/generate_dpcm_index.py:82-102) is still the sole writer (it emits exactlyid+filename).
Dimension 3: DPCM conversion correctness (1-bit delta)
dpcm_sampler/dpcm_converter.py does WAV→PCM→delta→packed-bits. This file has not
changed in the recent fix sprint — the following are still open, unverified claims:
delta_encode(lines 34-42) walks a ±1 step counter, butdpcm_compress(lines 45-66) then re-derives the bit purely fromencoded[i] > encoded[i-1]. Check that the two stages agree and that the bit polarity matches hardware: perdocs/APU_DMC_REFERENCE.md, a1bit adds 2 to the output level and0subtracts 2 (the engine never sets a level, only nudges ±2). A constant-input run encodes all-zero bits ⇒ the level ramps down on playback — verify the start-level assumption (prev = 0x40, line 36).- Bit packing order (line 63):
byte |= (bits[i+j] << j)packs LSB-first. Confirm against the DMC shifter order indocs/APU_DMC_REFERENCE.md("Reader → Buffer → Shifter") — wrong bit order plays the sample bit-reversed (audible garbage). - Resampling:
convert_wav_to_unsigned_pcm(line 7) usesnp.interplinear resampling to a fixedsample_rate=8000, independent of the DMC rate index written elsewhere (pitch/$4010). Flag any mismatch between the conversion rate and the playback rate index that would pitch-shift samples.
Dimension 4: Sample size / address / DMC range constraints
dpcm_sampler/dpcm_packer.py computes the $4012/$4013 register values:
- Fixed (#75 → regressed → #295/DP-01) — verify it holds:
_place_sample(lines 77-98) computesdpcm_address_val = (start_address - 0xC000) // 64(line 78) anddpcm_length_val = max(0, (sample['size'] + 14) // 16)(line 88). Verify againstdocs/APU_DMC_REFERENCE.md: address =$C000 + A*64, length =(L*16)+1bytes.length_regnow uses ceiling division ((size + 14) // 16= ceil((size-1)/16)) so asizenot of the form16k+1still gets its full tail read: the engine reads(length_reg*16)+1bytes and the.align 64gap after each sample makes the few extra bytes safe zero-pad, not neighbouring data. This is the flooring bug originally closed as #75 that regressed and was re-fixed here as #295 (commitd392ef6); confirm the ceiling still holds and thatsizestays bounded to 4081 so the 8-bit register can't overflow. - Fixed (verify): the 4081-byte oversized-sample path no longer aborts the pack.
DpcmPacker.add_sample(lines 13-47) now truncates to 4081 bytes whentruncate=True(lines 31-36) instead of always raisingValueError— and the shared call siteload_dpcm_index_into_packer(dpcm_sampler/generate_dpcm_index.py:72-77) always passestruncate=True, so in practice a too-long sample is clamped, not fatal (#68).add_samplestill raises if a caller explicitly passestruncate=False; confirm no current call site does that.dpcm_converter.dpcm_compress(line 66) independently truncates withdmc_bytes[:4081]at conversion time — the two truncation points are consistent (both clamp to 4081), not contradictory. START_ADDR = 0xC000,BANK_SIZE = 8192(lines 5-6), and the 60-bank ceiling (OverflowErrorat line 70). Check.align 64ingenerate_assembly(line 100) keeps every sample 64-byte aligned, and whether anything guards the$FFFF→$8000address-wrap quirk documented indocs/APU_DMC_REFERENCE.md(a sample bleeding past$FFFFplays garbage from$8000).- Fixed, verify no regression (#140): the packer used to receive the entire
1923-sample catalog regardless of what a song used, overflowing the 60-bank
budget and silencing percussion on every drummed song. It's now filtered via
get_dpcm_sample_ids_from_frames(dpcm_sampler/generate_dpcm_index.py:105-117, reads framenote = sample_id + 1) passed assample_ids=intoload_dpcm_index_into_packer, so only samples the exported song actually references get packed.generate_assembly(lines 91-147) now emits sparse lookup tables sized tomax_id + 1with$00placeholders for unpacked ids (lines 123-146) — verify those placeholder slots are provably unreachable (no frame indexes an id that wasn't packed) rather than merely "usually" unreachable.
Dimension 5: DMC level handling & DMA-timing implications
- Fixed, verify no regression (#72/D-09): the DMC output level (
$4011)CMD_DMC_LEVEL($87) emitter path was removed entirely (commit5c032d2) — no stage ever produceddmc_level, so the branch was dead. Confirm it hasn't been re-added; if it is re-added for the$4011non-linear-mixer trick (docs/APU_DMC_REFERENCE.md§6), re-check the level is clamped to the 7-bit 0–127 range$4011accepts before emission. - Silence init:
docs/APU_DMC_REFERENCE.mdsays init should write$00to$4011so the DMC counter doesn't muffle Triangle/Noise via the non-linear mixer. Live on the bytecode path:nes/audio_engine.asm:128writesLDA #$00/STA $4011ataudio_init. (The direct-exportinit_music/resetomits it — open gap #348/NH-HW-1; nes/mmc3_init.asm, which also had this write, was deleted as dead code, #203.) The live DPCM trigger is@write_dpcm(nes/audio_engine.asm, ~line 512), which writes$4010→$4012→$4013then toggles$4015(disable-then-enable-with-DMC), matchingdocs/APU_DMC_REFERENCE.md; re-verify this order is still correct if the trigger routine changes. (The oldseq_cmd_dpcm_play/seq_cmd_instrumentcopies innes/project_builder.pywere removed as dead code — #314/EXP-12; only the livefetch_sequence_byteremains.) - DMA cost:
docs/NES_DMA_REFERENCE.mdnotes each DMC DMA steals 3–4 CPU cycles and a heavy drum catalog fires constantly, delaying OAM DMA and corrupting side-effect reads ($4016/$2007/$4015). This subsystem can't avoid the cost, but flag if the generated engine/docs omit the mandatory DPCM-safe controller-read warning, or if rapid back-to-back triggers are emitted with no awareness of the cycle budget.
Dimension 6: Config robustness (DrumMapperConfig)
dpcm_sampler/enhanced_drum_mapper.py defines DrumPatternConfig (line 12),
SampleManagerConfig (line 53), DrumMapperConfig (line 95) with validate() /
to_file / from_file (line 163):
- Still open (#76/D-13):
from_file(lines 163-191) doesDrumPatternConfig(**config_data.get('pattern_detection', {}))(line 170) and the equivalent forSampleManagerConfig(line 172) — an unexpected/renamed key in the JSON raisesTypeError(not caught; onlyFileNotFoundErrorandjson.JSONDecodeErrorare handled, lines 188-191). No commit in the recent sprint touched this. Note the current blast radius: the CLI--configflag that used to feedmain.py:load_config(main.py:462-466) into this path was intentionally removed (main.py:772-774, #13) because nothing wired it toassign_tracks_to_nes_channels— so todayfrom_fileis reachable only via direct API use (and is exercised bytests/test_drum_mapper_config.py), not the CLI. Still worth fixing/hardening since it's public API surface. validate()(DrumMapperConfig.validate, line 110) enforces weight sums ≈ 1 and ranges.EnhancedDrumMapper.__init__(line 196) callsself.config.validate()unconditionally on whatever config object it holds, so a config passed in afterDrumMapperConfig.from_file(...)is validated before use, so long as it's routed throughEnhancedDrumMapper.__init__— confirm there's no path that uses afrom_file-loaded config directly without going through that constructor.SampleManagerConfig.memory_limitis bounded 1KB–16KB andmax_samples1–64 (lines 77-80), butDPCMSampleManager.__init__defaults (max_samples=16, memory_limit=4096) are declared independently (dpcm_sampler/dpcm_sample_manager.py:5). In the current codebase the only production instantiation site isEnhancedDrumMapper.__init__(enhanced_drum_mapper.py:203-206), which always passes the validated config values through — direct unvalidated construction only happens intests/test_dpcm_sample_manager.py. Low residual risk; flag only if a new production call site constructsDPCMSampleManagerdirectly.
Dimension 7: Sample-manager dedup & lifecycle
dpcm_sampler/dpcm_sample_manager.py (130 lines):
- Fixed, verify (#69/D-06):
allocate_sample(lines 15-65) now assigns ids from a monotonicself._next_idcounter (line 13, incremented at line 51) instead oflen(self.active_samples), so an evicted id is never handed out again to a later allocation. Confirmdpcm_eventsemitted before an eviction still resolve to the sample that was live when they were created (i.e. nothing re-keys already-emitted events). - Fixed, verify (#70/D-07): memory accounting is now unified —
allocate_samplechecksself._get_total_memory() + sample_size > self.memory_limitup front (line 42, accounting for the pending sample before it's inserted) and_get_total_memory(lines 120-130) sumss['metadata']['size'], the same fieldallocate_samplepopulates (line 57) — previously these used two different formulas (one of which,len(data)//8, was always 0 for real index data)._optimize_sample_bank(lines 67-111) now triggers on memory pressure alone, not just the sample-count limit (condition at lines 79-81). Verify a few-but-large-samples scenario (smallmax_samples, largemetadata.size) still evicts correctly. - Removed as dead code, not "fixed to use real data" (#71/D-08):
_find_similar_sample/_calculate_sample_similarityno longer exist in this file — they were deleted (commit5c032d2) rather than repaired, since the underlyingdataarrays are always empty for real index entries (Dimension 2) and the comparison was permanently inert. If similarity-based dedup is reintroduced, it needs real waveform data from the index to do anything useful.
Dimension 8: Channel-pipeline integration (noise vs DMC)
tracker/track_mapper.py:assign_tracks_to_nes_channels(midi_events, dpcm_index_path)
(line 179; called from main.py:run_map and the full pipeline):
- It calls
map_drums_to_dpcm(line 244) and routes results:nes_tracks['dpcm'] = dpcm_events(line 246-247, still overwrites any prior dpcm assignment) andnoise_eventsonly land onnoiseif it's still empty (lines 249-251). Fixed, but only partially (#74/D-11): whennoiseis already occupied, the drum noise-fallback events are still discarded (this is physically unavoidable — NES has one Noise channel, perdocs/APU_NOISE_REFERENCE.md) — but this is no longer silent: aprint(...)warning now reports how many events were dropped and why (tracker/track_mapper.py:253-259). Verify the count in the warning matcheslen(noise_events)exactly and that this is the only discard path for these events. - The hardcoded default
'dpcm_index.json'path means a missing file still raises inside_load_sample_index(enhanced_drum_mapper.py:216-219,FileNotFoundError). This is not caught in the standalonemapsubcommand (main.py:run_map, lines 104-112; the mapper call is line 110) — that path still crashes with a raw traceback (#256), unlike every other step-by-step guard inmain.py(load_json_stage, #120).run_full_pipelinefares better only because its whole body is wrapped in one outertry/except Exception(main.py:~992-997) that turns any such crash into a clean[ERROR] Pipeline failed: ...message — but the pipeline still aborts entirely rather than degrading to a drumless build, in contrast to the DPCM packer path (main.py:run_export/run_full_pipeline), which explicitly handles a missing index file gracefully ("No dpcm_index.json found, skipping"). Assess whetherrun_mapshould get the same guard as the packer path. - Fixed, verify (#9, #66, #67, #200/D-14, #254):
dpcm_eventscarry{frame, sample_id, velocity}; the frame-generation stage (nes/emulator_core.py:188-235) remaps the raw catalogsample_ids a song actually references to a dense, song-local0..N-1range (dense_id_of, line 218) and encodesnote = min(255, dense_id + 1)(line 227; 0 stays the rest sentinel), emitting adpcm_sample_map(dense_id → catalog_id) side table (lines 232-235) so the pack stage can resolve the real sample files. This replaced both the old 0-95 tone-note clamp (#67) and the rawmin(255, sample_id + 1)that aliased every catalog id ≥ 255 onto note 255 (#200/D-14); theMAX_SAFE_SAMPLE_ID = 254guard that used to pre-empt the remap and route all shipped-catalog drums to noise is also gone (#254). The exporter (exporter/exporter_ca65.py) applies the same byte-ceiling for the'dpcm'channel and the generated trigger routine (nes/project_builder.py) recovers the sample vianote - 1. Confirm the round-trip holds when a song references near 254 distinct drums (dense_id + 1must stay ≤ 255), not merely when a raw catalog id is near 254.
Skeptical checklist
- [ ] Does an unmapped/rare GM drum note (e.g. 47, mid tom) still resolve through
DEFAULT_MIDI_DRUM_MAPPING, or does it now silently fail some other way? - [ ] Does the velocity → primary → default fallback chain in
_resolve_dpcm_sample_nameever return a name that isn't actually inself.sample_index(a logic gap in the finalfor name in candidatesloop)? - [ ] Do
length/data/frequencyever come from the real index, or always defaults — and does that still matter now that the dead similarity/dedup code that depended ondatahas been removed? - [ ] Does
length_reg = max(0, (size+14)//16)(ceiling) read the full sample tail for a sample not sized16k+1(#75 regressed, re-fixed as #295 — verify)? - [ ] Does
DrumMapperConfig.from_filestill raise an uncaughtTypeErroron a stray config key (#76, unfixed)? - [ ] Can an evicted sample id still be reused now that
_next_idis monotonic — or is there an edge case (overflow, reset) that reintroduces reuse? - [ ] Is the memory limit actually enforced end-to-end now that both call sites use
metadata['size'], including the up-frontpending_sizecheck? - [ ] When a real noise track exists, are drum noise-fallback hits still discarded — and is the new warning message accurate?
- [ ] Does the
dpcmchannel's dense-remapped id survive correctly intomusic.asmwhen a song references near 254 distinct drums (dense_id + 1must stay ≤ 255)? - [ ] Does packing only referenced samples (#140) ever leave a frame pointing at an
id that wasn't packed (a
$00placeholder misread as a real sample)?
For every hardware claim, re-open the cited docs/APU_DMC_REFERENCE.md /
docs/NES_DMA_REFERENCE.md line and confirm before reporting. Attempt to disprove
each finding (per _audit-common.md) before including it.
Output
Write the report to: docs/audits/AUDIT_DPCM_<TODAY>.md (YYYY-MM-DD).
Structure:
- Summary — finding counts by severity, the highest-risk drop/round-trip issues.
- Findings — base format from
_audit-common.mdplus theHardware reffield.
Then suggest:
/audit-publish docs/audits/AUDIT_DPCM_<TODAY>.md
</content>Expert Next.js App Router
Developpement
Un skill qui transforme Claude en expert Next.js App Router.
Générateur de README
Developpement
Crée des README.md professionnels et complets pour vos projets.
Rédacteur de Documentation API
Developpement
Génère de la documentation API complète au format OpenAPI/Swagger.