# PT32-V A Daisy Pod sampler with five converter eras (PAULA / SP / S950 / MPC60 / CLEAN), tempo-synced grains, four MIDI parts and eight sample slots. Play from the Pod, USB MIDI or DIN MIDI; edit and transfer samples with the browser editor. The shared pool has 16 voice slots, with a CPU admission limit on hardware. ## v33 release candidate — Pod controls The Pod controls **part 1 / MIDI channel 1**. Other parts remain accessible by MIDI and the editor. Click the encoder to switch control banks; LED 1 is red for SAMPLE and green for GRAIN. Switching banks does not change parameters until you move a knob. LED 2 shows whether the grain layer is on. | Control | SAMPLE bank (red) | GRAIN bank (green) | |---|---|---| | Encoder turn | Select sample slot 0–7 | Select PAULA / SP / S950 / MPC60 / CLEAN | | Encoder click | Switch to GRAIN | Switch to SAMPLE | | Button 1 | Hold to play sample at its root note | Same play button | | Button 2 | Toggle grain layer | Cycle HOLD / SCAN / JUMP position mode | | Knob 1 | Sample START | Grain gate, 0.05–4.0, squared response | | Knob 2 | Pitch, −12 to +12 semitones; centre = original pitch | All 12 timing divisions, 1/4 through 1/64 | Enable grains with button 2 in SAMPLE before using the GRAIN bank. Selecting SCAN on the Pod sets forward scan speed if it was zero; gate and division affect timing in TRIG or CLOCK sync. In OFF sync, SIZE and density control timing instead. Empty slots are selectable and silent. Changing sample selection affects the next note; release and press play to audition it. Pitch spans **±12 semitones (±1 octave)**, with unity at the centre. ## Bounds and editor fixes - Live brackets keep their width and slide back inside the clip at the tail. The editor now resolves crossings and the 32-frame minimum like the engine, including clips shorter than 32 frames. Raw offsets survive repeated sweeps. - Large playback steps wrap across any number of forward-loop boundaries. Standard playback folds ping-pong steps back inside the loop. - Grains now respect active forward/ping-pong brackets in POST and PRE/DUAL filter paths, including live edits to a sounding cloud. - With looping off, grain POSITION traverses the material remaining after START, removing the dead travel caused by adding two sample-wide offsets. START still reaches the final sample; a one-shot begun there has almost no audio left. This does not trim trailing silence from an imported recording. - Delayed loop saves retain their original slot. Switching slots during the save delay no longer redirects an edit to the new slot. ## Before filming Run the native tests and editor regressions, then build the firmware: ```sh make -C PT32V/host/test node PT32V/host/test/test_webui.js make -C PT32V/firmware ``` These commands run from the repository root. Firmware output is `PT32V/firmware/build/PT32V.bin` (Daisy bootloader / QSPI application). Hardware audition is still required before calling this release final: hold a note while moving START/brackets, test both loop modes with grains on/off, check all knob endpoints, switch banks while holding play, then release and confirm silence. Check CPU load on the heaviest patch used in the clip. For the Instagram demonstration: play one recognizable phrase, compare the five eras, enable grains, sweep division and short gates, then demonstrate live brackets. See [FEATURE.md](FEATURE.md) for caption and filming checklist. ## Layout ``` core/ DSP core — portable headers, no libDaisy/HAL deps (§11 step 1) sync.h MIDI-clock DPLL + bar-locked grain scheduler (from the spec) dsp.h interpolators, quantiser/compander, amp env, drive filters.h one-poles, SVF, 4-pole ladder [DEV-07], 6th-order Butterworth paula.h PAULA: pulse-domain output + filtered/integrated BLEP table [DEV-01] voice.h the era chain per voice (stages 1-9) + per-voice grain layer part.h part state, routing, 48-slot CC matrix (§6.1) alloc.h shared pool, RSV floors, steal order 1-8 (§6.3) midi.h byte parser: running status, realtime interleave, SPP engine.h 4 parts + pool + clock + sample bank, block renderer host/ desktop harness (WAV in → era chain → WAV out) for A/B tuning test/ native unit tests, NEUROPOD-test-suite style: make -C host/test firmware/ Daisy target (BOOT_QSPI): make -C firmware; CC119=127 -> DFU reflash webui/ WebMIDI editor, NEUROPOD-webapp-v2 styling; open in Chrome ``` ## Borrowed - `host/test` harness style and the bounded MIDI drain pattern come from `NEUROPOD/firmware`; the webui chrome comes from `NEUROPOD/webapp/v2`. - The grain windowing/overlap-compensation approach follows `seed/GranularTimestretchEngine` (equal-power seams, oldest-first steal); the scheduler itself is the spec's bar-locked design, not GTE's free-runner. ## v1 simplifications (beyond the spec's own §13 register) These are deliberate bring-up shortcuts, each revisitable in isolation: - **Inner rate domain**: SP/S950/MPC60 run at system rate; drop-sample reads produce the imaging, stage 7 corners are computed against the record-rate tag. The literal inner-domain + polyphase upsample is not implemented yet. - **PAULA granular** uses the standard chain with 8-bit truncation, not the pulse-domain BLEP. Non-granular PAULA is the real construction. - **BLEP table is linear-phase**, not minimum-phase reconstructed; PAULA voices carry a fixed 8-sample latency as a result. - **FADE steal** starts the incoming note after the 1.5 ms ramp instead of overlapping it on a stand-in slot (note-offs for pending notes are handled). - **MIDI timestamps** are block-granular in firmware (libDaisy parsed events); the engine already accepts sub-block offsets — only the transport is coarse. The host harness exercises sub-block offsets. - **Grain pool** is 8 per voice with no global-32 enforcement yet. - `GLIDE`, `SPREAD`, per-part OMNI defaults, and the S950 input-gain compander drive staging are stubs or defaults. ## Sampler layer (rig-verified 2026-08-25) - **ADSR** per part (CC 109/110/111/112); sustain 0 + short decay = the old drum behaviour, defaults are a playable gate. - **Loop modes** per sample: off / forward / ping-pong, with an equal-power seam crossfade on forward loops (`XFADE`, CC 107). Part-level override on CC 104. Start offset on CC 103. - **8 sample slots** (5 s @ 48 kHz float in SDRAM). SysEx transfer protocol under `F0 7D 50 54`: 0x01 begin / 0x02 data (7-bit packed, seq'd, acked every 8 chunks as flow control) / 0x03 end (checksummed reply) / 0x04 loop set / 0x05 ping. Measured ~104 KB/s over USB MIDI. - **SD persistence**: slots save to `/PT32V/SLOTn.PSM` (checksummed blob, NEUROPOD npsd.h-style) on transfer end and loop set; reload at boot. Slot 0 falls back to a generated pluck when the card is empty. - **webui/** is a ProTracker-themed editor: waveform view with draggable loop markers, WAV decode → 48 kHz mono resample → SysEx upload with progress + checksum verify, ADSR/filter/grain panels, keyboard, clock. - **Record-rate crush** (stage 1 of §2, CC 113): the material reads as if sampled at 4–48 kHz; 127 = off. PAULA defaults to 16.6 kHz (C-3 period), SP to 26.04 kHz — 48 kHz source through Paula is legitimately clean, the Amiga character came from low-rate source material. CC 114 toggles the LED filter. PAULA now honours loop modes (forward = the DMA repeat pointer, like hardware; ping-pong is ours) and MPC60 runs the exact compander (partial morph was adding grit the MPC never had). - **Slot preview readback**: SysEx 0x06 returns slot info (0x72) plus a 400-column min/max envelope (0x73 ×17) so the webapp shows waveforms and loop points for samples loaded from SD, not just local files. - **Live loop brackets + independent START** (real-sampler semantics): CC 103 = start, CC 117 / CC 3 = bracket offsets from START as fractions of sample length. Playing voices re-resolve the loop every block, so held notes follow the knobs; the brackets only capture the playhead once it enters them (a start point past the loop plays the tail out). SysEx 0x04 persists exact points to SD; the CCs are the performance path. In the webapp the green marker is START and the amber pair are the brackets — all three drag live, mirrored by LOOP-L/LOOP-R sliders; exact points auto-persist to SD ~1.5 s after edits settle (no APPLY button — editing is always live, persistence is background). With a loop active and no explicit START, notes begin at the loop bracket. - **Webapp themes**: PROTRACKER (default), ORIGINAL, VAPORWAVE, HACKER, SUNSET — selector in the header, persisted in localStorage. The browser clock uses a skip-on-throttle scheduler: late ticks resync instead of bursting (setInterval bursts railed the unit's DPLL at its 1073 BPM ceiling — if you ever see CLK 1073BPM, something is bursting ticks). ## Amiga authenticity pass (fw v19, rig-verified) Research (Lankila's model, 8bitbubsy's pt2-clone schematic values, and host measurements) found and fixed five gaps: - **Tuning**: PERIOD quantisation now happens in the crush-rate domain, where the pulse train effectively lives. With 48 kHz tags the old code quantised at periods 37-148 (a 12-47 cent grid — measured +-21 cents, broken not vintage); now the drift stays inside the authentic ~8-cent grid (rig: +-3 cents). STRICT clamps in the Amiga domain too. - **Analog models as runtime IIRs** (pt2-clone architecture): the BLEP table is pure sinc; per-voice IIRs implement the A500 rev-6A schematic — RC low-pass 4421 Hz (360R/0.1uF), LED Sallen-Key 3090.5 Hz Q 0.660 (10k/10k/6800p/3900p — NOT a 3200 Hz Butterworth with a Q bump; the spec's "measured boost" was wrong). A 16-tap table can't hold a 3 kHz 2-pole step; measured LED bite went from -2.6 dB to -9.3 dB at 3-6 kHz. - **Output high-pass**: the A500's is 5.1 Hz (1390R/22.33uF) — the master DC blocker was cutting at ~38 Hz and eating mod bass. Now the per-voice output HP defaults to 5.1 Hz and is a CHARACTER control: sweepable to 2 kHz with resonance (CC 24/25). - **AMIGA PAN** [DEV-04]: voices hard-pan L/R by pool-slot parity (CC 26). - **CHARACTER CCs**: 22 PERIOD blend, 23 TUNE +-100 cents, 24 HP FREQ, 25 HP RES, 26 AMIGA PAN. Webapp gets a CHARACTER panel. ## Modulation (fw v13) - **Per-part mod matrix**: 8 slots routing {LFO1, LFO2, ENV1, ENV2, wheel, aftertouch, velocity, keytrack} -> {pitch, cutoff, resonance, level, pan, bits, crush, drive, gate, shape, grain pos, loop brackets}. Lineage: NEUROPOD modslots.h — performance sources route through ordinary slots instead of being hard-wired (wheel's spec lane to SHAPE ships as default slot 0, re-routable). Config over SysEx 0x07 (slot) / 0x08 (LFO) / 0x09 (env); webapp MOD MATRIX panel drives it. - **LFOs**: sine/tri/saw/square/S&H, free 0.05-20 Hz or clock-synced (4 bars .. 1/32), with **swing** on synced mode via NEUROPOD's SwungPhase (pairs of cycles share a span; s=0.75 = 3:1). Rig-measured: swung square gating level shows the 3:1 on/off signature. - **Mod envs**: 2x AD, block-rate, retriggered by note-ons on the part. - Pitch bend was already wired (rig: 2-semi bend ratio 1.1218 vs 1.1225 theoretical); mod wheel and channel aftertouch are matrix sources now. - Grain layer is remotely controllable: CC 20 on/off, CC 21 sync source. Rig-measured 1/16 clock-sync stutter at 128 ms vs 125 ms theoretical. ## Groove + dual filter (fw v22) **Grain groove.** `SWING` (CC 28) shuffles grain pairs from straight to a hard 3:1, using the same 0.5..0.75 convention as the mod LFOs. `GATE RATIO` (CC 29) scales the *offbeat* grain's length independently, so long-short and short-long patterns are both reachable. `OFFSET` (CC 30) shifts the whole cloud +-50% of a period against the clock. Swing needs an even whole number of grains per bar, so it applies to 1/4..1/64 and all four triplets and is ignored on dotted divisions under CLOCK sync; under TRIG/OFF pairs anchor to the note-on and every division swings. Two safeguards worth knowing about: onset shifts are slewed at block rate, and a spawn is refused within a quarter period of the previous one. Together they mean sweeping OFFSET live can drop a grain but can never double-fire one (an audible flam). That guard also closes a pre-existing case where a clock tick nudging the DPLL phase backwards could re-expose an onset that already fired. **Dual filter.** `FILTER POS` (CC 87) selects POST (the classic chain, still bit-identical), PRE, or DUAL. The pre filter has its own `PRE CUTOFF` (CC 88, where 0 means "track CC 108") and `PRE RESO` (CC 89, Q up to 20). The reason PRE exists: crush and REGRIND both decimate *at the source read*, so a filter placed after them only ever hears signal that has already aliased. The pre filter runs at 48 kHz on a clean cubic read and the decimation happens after it, so its resonant peak **folds** to `|hold rate - pre cutoff|`. Sweep the cutoff up and the image sweeps down. This is the only way to get that sound; moving the existing VCF earlier does not produce it, and a filter running *at* the decimated rate cannot either (nothing above its own Nyquist to fold). PAULA folds through its pulse train via a shadow reader, keeping the BLEP construction, period quantisation and STRICT intact. `resonance` and `env_amount` are now cleared by `LoadEra` along with the pre-filter fields, so switching era always lands on a verified starting point. Previously resonance survived into MPC60 and CLEAN but not the other eras, which was invisible only because neither parameter had a CC. **Two fixes that change audible behaviour:** CC 22 (PERIOD) and CC 23 (TUNE) now work on a *held* note — they were consumed only inside `UpdateRatio()`, whose block-rate trigger did not watch them. And `ModDest::kBits` is now actually audible; the quantiser used to be refreshed only when a filter parameter happened to change in the same block. ## CPU budget (fw v22, measured on the Seed) Per-voice mean load of the audio callback, one voice held: | era / layer | mean | |------------------|------| | CLEAN, no grain | ~15% | | PAULA, no grain | ~19% | | PAULA + grain | ~25% | | SP + grain | ~32% | | S950 + grain | ~38% | So a PAULA+grain patch gets about **4 voices** before the callback overruns. An overrun is not a glitch you can ignore: sustained, it wedges the audio DMA and the box stops responding entirely (USB de-enumerates, needs BOOT+RESET). The allocator therefore refuses new voices above ~70% mean load — heavy patches drop a note rather than freezing. Watch the heartbeat's mean-load and overrun bytes when changing anything in the per-sample path. Two things dominated that budget and were fixed in v22: `Drive()` recomputed `1/tanh(g*0.35)` — a constant that depends only on the drive amount — for every sample, costing ~27% of the block budget per voice on PAULA (which defaults to drive 0.1). `DriveShaper` hoists it to block rate and halved PAULA+grain from 45% to 25%. Separately, the per-sample read helpers are pinned with `always_inline`, because adding the v22 pre-filter helpers pushed `Voice::Render` past the compiler's inline budget. ## Known, deliberately unfixed - **OFF-mode SCAN speed** derives its travel from `size_ms / gate`, which speeds up as the gate closes even though gate does not set grain length in that mode. Preserved bit-exact; the swing work passes the period explicitly everywhere else so a parity-dependent gate cannot make SCAN wobble. - **Sub-sample onset accuracy.** `OnsetsEx` computes fractional onset times and the voice floors them, biasing every grain about half a sample late. Both scheduler paths already have the fraction in hand, so recovering it is `g->age = frac; g->pos += frac * g->ratio` whenever it seems worth the one-sample shift to every existing patch. ## Quick start ``` make -C host/test # run the unit suite make -C host && ./host/render in.wav out.wav paula 55 48000 3 make -C firmware # needs arm-none-eabi + libDaisy built ``` Firmware boots parts 1–4 as PAULA/SP/S950/MPC60 on MIDI channels 1–4. Default CC template per §6.1, plus CC 105 = era mode.