C64 READY.

Docs / SID 6581/8580 (src/sid-voice.js + src/sid-worklet.js) — Architecture Overview

SID 6581/8580 (src/sid-voice.js + src/sid-worklet.js) — Architecture Overview

How the emulator synthesises sound: three voices (oscillator + waveform DAC + ADSR envelope), the analog filter, the master-volume DAC / digi path, and the two ways register state reaches the audio: the audio-worklet ring (what you hear) and the main-thread shadow voices (what $D41B/$D41C reads return, cycle-exact).

File Role
src/sid-voice.js One SIDVoice — oscillator, waveform generator, noise LFSR, ADSR envelope, OSC3 readback. Pure DSP, no I/O. Shared by the worklet and the shadow.
src/sid-worklet.js SIDChip (3 voices + filter + mixing + output DAC) and SIDProcessor (the AudioWorkletProcessor: SAB ring transport + sample-rate decimation).
src/machine.js SIDProxy (the $D400-$D7FF device on the bus, mirrored every 32 bytes), the SAB ring producer (_sidWrite), the shadow voices, paddle POT sample-and-hold, model sync.

Scope: a PAL 6581/8580 SID. Goal is demo fidelity — correct combined waveforms, ring-mod/sync, $D418 volume-DAC digis, ADSR timing matched to reSID/Lorenz test programs, and cycle-exact OSC3/ENV3 readback for demos that poll voice 3. See also the offline A/B harness investigation/sid-offline-render.mjs.


1. Big picture

 MAIN THREAD                                              AUDIO WORKLET THREAD
 ───────────                                              ────────────────────
 CPU writes $D400-$D418
        │
        ▼
   Memory ──► SIDProxy.write(reg,val)  ($D400 page)
        │
        ├─► _sidWrite(reg,val)
        │      ├─ push {sidCycleCounter, reg|val<<8} ──► SharedArrayBuffer ring ──┐
        │      └─ mirror reg → shadow v1/v2/v3 (write)                            │
        │                                                                         │
        │   _runMasterCycle()  (once per master cycle, ~985 kHz)                  │
        │      ├─ shadow v1.clockPhaseOnly()   (sync/ring source only)            │
        │      ├─ shadow v2.clockPhaseOnly()                                      │
        │      └─ shadow v3.clockCore()+outputStageOsc3()  serves $D41B/$D41C     │
        │                                                                         ▼
   CPU reads $D41B/$D41C ◄── shadowV3.readOsc3()/.env3         SIDProcessor.process()  (128-sample blocks)
   CPU reads $D419/$D41A ◄── potX/Y sampled (512-cy S&H)         _drainRing → pend ring
                                                                  _applyDueEvents (cycle-stamped)
                                                                  per SID cycle: SIDChip.clock()
                                                                    3 voices → filter → DAC → tanh
                                                                  outLp (15.9 kHz) + boxcar decimate
                                                                       │
                                                                       ▼  masterGain (user vol) → AudioContext → speakers

Two independent consumers of the same register writes:

  1. Audio — writes are cycle-stamped into a lock-free SAB ring and consumed by the worklet, which synthesises and decimates to the host sample rate. Latency ≈ one audio block (~3 ms at 44.1 kHz / 128 samples).
  2. Readback — the main thread also clocks a shadow copy of the three voices in lockstep with the CPU, so $D41B (OSC3) and $D41C (ENV3) reads are cycle-exact (the worklet's millisecond latency would smear the tight read loops demos use for RNG / raster sync / model detection).

The shadow runs only the oscillator + envelope + OSC3 read (no filter, no mixing, no audio DAC) — those are audio-only and the CPU never reads them.

SIDVoice.outputStage() produces two independent per-cycle products — the audio DAC sample (its return) and the OSC3 read pipeline — around the shared feedback both depend on (waveform latch, 6581 SAW→phase pulldown, combined-noise writeback, pulse-rail latch). Each consumer needs only one, so each calls a lean variant that skips the other's dead work — the same "dedicated lean method" idiom as the shadow's clockPhaseOnly(), not a runtime flag:

The shared feedback runs in all three, so each lean variant is byte-identical to outputStage() on the product it keeps — no demo or music change. The internal order _outputPre → _osc3Read → _outputPost is load-bearing: the OSC3 read must see the pre-writeback noise latch and the previous cycle's pulse rail. Locked by test/sid-outputstage-skip-equiv-spec-test.js (both models, 60k cycles, and it asserts the skip genuinely happens). Measured: the shadow's audio-skip trims ~30% off its per-cycle outputStage (main-thread CPU-loop tax), the worklet's OSC3-skip 3–13% (most on 8580 tri/saw, which skips _triSaw12()).


2. Register map ($D400-$D41F, mirrored every 32 bytes)

Per-voice block (voice 1 $D400, voice 2 $D407, voice 3 $D40E):

Offset Reg Bits Meaning
+0 FREQ LO 8 Phase increment low byte
+1 FREQ HI 8 Phase increment high byte (16-bit total)
+2 PW LO 8 Pulse width low byte
+3 PW HI 4 Pulse width high nibble (12-bit total)
+4 CTRL 8 `NOISE PULSE SAW TRI
+5 ATK/DEC 4+4 Attack rate (hi), Decay rate (lo)
+6 SUS/REL 4+4 Sustain level (hi), Release rate (lo)

Global:

Reg Name Bits Meaning
$D415 FC LO 3 Filter cutoff low 3 bits
$D416 FC HI 8 Filter cutoff high 8 bits (11-bit total)
$D417 RES/FILT 4+4 Resonance (hi); route bits (lo): EXT V3 V2 V1
$D418 MODE/VOL `V3OFF(b7) HP(b6) BP(b5) LP(b4)
$D419 POTX 8 Paddle X (read; 512-cycle S&H)
$D41A POTY 8 Paddle Y (read)
$D41B OSC3 8 Voice-3 oscillator MSB readback (read)
$D41C ENV3 8 Voice-3 envelope output readback (read)

CTRL bits decoded in SIDVoice.write / _computeWaveform12: GATE 0x01, SYNC 0x02, RING 0x04, TEST 0x08, TRI 0x10, SAW 0x20, PULSE 0x40, NOISE 0x80. Reg decode for the global block lives in SIDChip.write (sid-worklet.js). All registers except POTX/POTY/OSC3/ENV3 are write-only; reading one returns the SID data-bus value — the last byte written to any SID register (or returned by a readable-register read, which also loads the bus) — which fades to 0 after ~`$1D00 cycles on the 6581 / ~$A2000on the 8580 (reSIDsid.cc, real-chip bitfade/delayfrq0.prgmeasurements).SIDProxy.regs` still records current register values for save-states, but CPU reads no longer serve from it.


3. The oscillator (phase accumulator)

Each voice has a 24-bit phase accumulator (SIDVoice.phase):

prevPhase is saved each clock so the noise-clock edge check works on the MSB transition. (SYNC's MSB-rise is predicted separately by computeSyncPulses / predictMsbRise on the undelayed phase, since that runs before clockCore.)


4. Waveforms (_computeWaveform12)

The waveform DAC produces a 12-bit value (0..4095) from the top bits of phase, exactly like the chip's DAC; the audio path consumes all 12 bits and OSC3 reads the top 8 (>> 4). The four primitives and their exact derivations:

Waveform Exact expression Notes
Triangle (0x10) ((triPhase&0x800000 ? ~triPhase>>11 : triPhase>>11)) & 0xFFE 11 bits left-shifted one (DAC LSB grounded, reSID wave.h); the MSB folds the ramp. triPhase = phase, but RING (0x04) does triPhase ^= ~syncSrc.phase & 0x800000 first — ring-mod inverts the fold against the source MSB (triangle-only effect).
Sawtooth (0x20) (phase >> 12) & 0xFFF Straight rising ramp from the top 12 phase bits.
Pulse (0x40) pulseOut — a one-cycle LATCH of phase >= ((pw & 0x0FFF) << 12) ? 0xFFF : 0x000 Square wave; the PW compare result is delayed one cycle (reSID pulse_output: pushed at the end of the output stage, refreshed immediately by PW writes, and held high every clock under TEST).
Noise (0x80) 8 LFSR taps on bits 11..4 (noiseVal << 4; low 4 bits grounded) See §5.

A voice with no waveform bit set outputs 0 on the 8580; on the 6581 it instead plays the held waveform-DAC latch (oscLatch) through the DAC — the floating-DAC click/DC behaviour covered in §6. Selection priority and combination are resolved in _computeWaveform12.

Combined waveforms

When two or more waveform bits are set, the real chip does not AND them — the analog waveform selector short-circuits bits with neighbour coupling and a DAC threshold, and the 6581 and 8580 tables differ structurally (the 6581's are heavily eroded — mostly zeros with occasional peaks; the 8580's are fuller). We synthesize eight tables (COMBINED_6581 / COMBINED_8580 × ST/PT/PS/PST) with compact independent bit-coupling models fitted against black-box OSC3 table dumps from VICE x64sc reSID (sampler PRG: investigation/build-sid-combo-sampler.mjs, monitor dump: run-vice-combo.mjs, fit scripts kept under investigation/ or /private/tmp). No reSID/libresidfp source, constants, or measured lookup tables are embedded. Residual mean-abs-error in OSC3 bytes vs the VICE reSID reference capture:

ST PT PS PST
6581 1.01 (95.0% exact) 6.73 (88.2%) 4.02 (95.6%) 0.35 (99.6%)
8580 4.80 (79.0%) 7.55 (77.6%) 6.61 (75.7%) 5.08 (79.0%)

(reSID's own tables are measured chip dumps we don't embed; these are this project's independent generated tables calibrated at the aggregate level.)

Routing in _computeWaveform12:


5. Noise LFSR (sid-voice.js)

The whole block is byte-verified against headless VICE x64sc reSID on BOTH chip models: a 16-cycle OSC3 sampler over five scenario windows (slow walk, fast stream, noise+pulse writeback, noise+saw writeback, TEST-held fade) matches 100.0% (investigation/build-sid-noise-sampler.mjs + noise-run.mjs). The register never shifts before the first non-zero frequency write, so the series is deterministic from power-on with no alignment tricks.


6. Envelope generator (ADSR)

A faithful port of modern reSID's cycle-accurate pipelined EnvelopeGenerator (VICE resid/envelope.h+.cc). A three-state machine (state: 1=attack, 2=decay/sustain, 0=release — matching reSID's ATTACK / DECAY_SUSTAIN / RELEASE) with an 8-bit env counter (the per-voice amplitude multiplier) plus a one-cycle readback latch env3: $D41C/ENV3 returns the value sampled at the START of each clock, not the live env — so a CPU read sees the counter as it stood one cycle earlier. This delay, together with the three pipelines below, is exactly what the VICE env_test/* suite measures (all 8 pass; ra_0000 = 0/192 vs the real-HW table, resid-test/envdelay $8011). Three deferred-work pipelines model the extra cycle(s) the chip spends before an effect lands:

The whole block is verified against VICE reSID two ways: the full env_test/* testprog suite passes (all 8; ra_0000 = 0/192 vs the real-HW reference table, resid-test/envdelay = $8011, was $7F93), and a 16-cycle ENV3 sampler over five windows (attack/decay, release, flip-freeze, unlock+wrap, delay bug) gives identical step-value sequences with run lengths within ±1 sample. Absolute edge timing between emulators is not comparable at 1-cycle precision: the rate prescaler has no software reset (unlike the oscillator's TEST bit), so each emulator carries an uncontrollable power-on phase constant.

Voice amplitude = ((WAVE_DAC[out12] − wave_zero)/2048) · (ENV_DAC[env]/255): the digital waveform goes through the model's R-2R DAC (nonlinear on the 6581, identity on the 8580), and the envelope multiplier pivots around the chip's measured wave_zero$380 on the 6581 (NOT mid-scale; reSID voice.cc), $800 on the 8580. The asymmetric 6581 pivot makes envelope ramps pump a waveform-dependent DC (gate thumps, click digis). The envelope amplitude itself passes through its 8-bit model DAC. On the 6581, a voice with no waveform bits selected reuses the held/fading waveform-DAC latch (the same state OSC3 reads) as an approximation of the floating waveform-0 DAC click/DC. The 8580 waveform-0 path remains silent in this model.


7. OSC3 / ENV3 readback ($D41B / $D41C)

The C64 exposes only voice 3 to the CPU. Demos read it heavily (RNG from the NOISE waveform, envelope-following, model detection). Three subtleties:

Served by the shadow voices, not the worklet. SIDProxy.read routes $D41B → shadowV3.readOsc3() and $D41C → shadowV3.env3 — the one-cycle readback latch (the value sampled at the start of the voice's clock, not the live env; see §6), matching the chip's ENV3 read delay. The worklet still publishes OSC3/ENV3 into sidCtrl[2]/[3], but nothing reads those slots — it's a worklet-side debug tap only (the ~3 ms block latency was unusable for cycle-exact reads, which is why the shadow exists). The publish uses the non-mutating readOsc3(), not getOscByte(), so it doesn't perturb the live audio voice's combined-waveform state.


8. SIDChip — mixing, routing & the filter (sid-worklet.js)

SIDChip.clock() runs once per SID cycle and returns one analog sample:

  1. Clock the 3 voices (clockCore() then outputStageAudio() — the DAC sample only; the OSC3 read pipeline is skipped, unused on the audio thread, except for the 6581 floating waveform-DAC latch needed by waveform-0 audio — see §1), collect outputs.

  2. Route each voice ($D417 low nibble) either into the filter sum (filterIn) or straight to the output (out). v3off ($D418 bit 7) mutes voice 3 from the direct path. The EXT-IN route bit ($D417 bit 3) is a no-op — stock C64 grounds the EXT IN pin.

  3. Filter — a 2-integrator state-variable (Chamberlin) filter. This is the single biggest sonic difference between the chips, so the math is per-model:

    Cutoff frequency (11-bit fc from $D415/$D416):

    • 6581FC6581_TABLE[fc], log-interpolated through anchors measured off headless VICE reSID 6581 (noise sweeps via investigation/build-sid-fcsweep.mjs, half-energy proxy inverted through the 8580's known-linear curve): flat ~220-250 Hz through the bottom quarter, then steep — 830 Hz @ 512, 4.3 kHz @ 1024, 10.5 kHz @ 1536, 12.5 kHz top. Lands within ~5% of reSID 0.16's published spline.
    • 8580 → linear 12500 · (fc+1) / 2048 Hz (≈6 Hz…12.5 kHz), per reSID filter.cc set_w0() (w0 = 82355·(fc+1) >> 11).
    • Both clamp to maxC = 1e6 · 0.45 and convert w0 = 2·sin(π·cutHz / 1e6).

    Resonance (4-bit res from $D417 hi nibble):

    • 6581 → the die-derived law damp = max(0.05, 0.5·(15−res)/8) (reSID filter.cc set_Q, ×0.5 topology factor because our Chamberlin SVF overdamps vs reSID's physical integrator — sweep-calibrated). At res=$F the filter self-oscillates, bounded by the piecewise saturator (like the chip's op-amps); the 0.05 floor is the op-amps' small-signal loss so the ring decays (~0.1 s) when unexcited.
    • 8580 → reSID's measured law 1/Q = 2^((4−res)/8) (filter.cc _1024_div_Q_table): Q 0.707 → ~2.59 at res=$F.
    • damp = 1/Q.

    State update per cycle:

    fin = filterIn − filtDcEst              // 6581: deviation from the tracked
                                            // input DC (16 Hz one-pole tracker)
    hp = fin − bp·damp − lp
    bp += w0·hp
    if (6581) bp = sat(bp)                  // piecewise saturator: exactly
    lp += w0·bp                             // linear inside ±1.2, compressing
    if (6581) lp = sat(lp)                  // beyond (op-amps leaving their
    ```                                     // linear region)
    The saturator bounds the res=$F self-oscillation and adds the 6581's
    resonant growl; an amplitude-dependent small-signal loss (damp blends up
    to 0.22 as |bp| < 0.5 when res ≥ ~13) makes note-off resonance tails die
    within ~5-10 ms like VICE's, while driven resonance stays strong. The 8580
    loop is clean/linear.
    
    Two properties are essential. The saturator is **exactly linear in the
    normal range** — a state-shrinking nonlinearity would leak the integrator
    several %/cycle at 985 kHz (a multi-kHz high-pass that guts the 6581
    response). And it saturates around the **operating point, not zero**:
    filter-routed 6581 voices carry standing DC (per-voice offsets + the
    wave_zero pivot's envelope-scaled means), so `filtDcEst` (~16 Hz, same
    corner as the output DC blocker) removes the standing part before the loop
    and re-enters it through the **LP tap** (hardware LP passes DC; BP/HP block
    it), preserving filter-routed digi/click behavior. 8580: linear, untouched.
    
    **Mode** (`$D418` bits 4-6) sums the taps into the output:
    `fout = (LP?lp:0) + (BP?bp:0) + (HP?hp:0)`.
    
  4. Output DAC — see §9.

The filter integrator uses 1e6 for the clock where the rest of the code uses the true PAL 985248 — a ~1.5 % cutoff error, deliberately left (a documented minor inconsistency, not audible against chip-to-chip filter spread).


9. Output DAC & the $D418 digi path

 vdc  = is8580 ? 0 : VOICE_DC_6581 (0.694)        // per-voice standing DC (6581)
 out  = Σ (voice_n + vdc)   (routed dry or into the filter, per $D417)
 dc   = masterVol/15 − 0.5
 raw  = (out/2)·(masterVol/15)                    // volume scales mix INCL. the DCs
        + (is8580 ? dc·0.21 : 0)                  // 8580's small explicit step
 dcOut = raw − dcPrevIn + 0.9998986·dcPrevOut     // DC blocker (~15.9 Hz HP)
 sample = tanh(dcOut)                             // soft saturation (no hard clip)

User master volume (app-level, not $D418)

Distinct from the $D418 master volume above: the Settings ▸ Sound volume slider is a Web Audio GainNode (masterGain in main.js), downstream of the worklet and outside the SID model entirely — it never touches the emulated chip or the DAC math above.

 sidNode (worklet) + drive sounds ─► masterGain ─► AudioContext.destination ─► speakers

10. The worklet transport (SIDProcessor)

The CPU thread and the audio thread share one lock-free SPSC ring in a SharedArrayBuffer:

 Int32 header:  [0]=writeIdx  [1]=readIdx  [2]=OSC3(unused)  [3]=ENV3(unused)
 Ring entries (from byte 16):  per slot = { u32 cycle, u32 packed(reg | val<<8) }
 RING_CAPACITY = 131072 slots   (indices masked & 0x7FFFFFFF)

11. Decimation & reconstruction

process() produces host-rate samples (e.g. 44.1 kHz) from the ~985 kHz SID clock:

The reconstruction chain is extfilt (15.9 kHz) → 3-pole recon (0.42·Fs) → boxcar. The extfilt+boxcar alone attenuate only ~4 dB near Nyquist, so the recon cascade was added to cut the >Nyquist fold-back from sharp $D418 digi edges (~15 dB on the worst alias line, +3 dB digi SNR; see §14). It shapes only the decimation, not the analog model.


12. Machine integration (machine.js)


13. Chip variants — 6581 vs 8580

Aspect 6581 (original NMOS) 8580 (HMOS-II, C64C) — default
Combined waveforms fitted per-chip tables — heavily eroded (mostly-zero, PT loudest) fitted per-chip tables — fuller
Combined-wave index full 12-bit (idx & 0xFFF) — same as 8580 (pitch is model-independent) full 12-bit (idx & 0xFFF)
Pulse-zero w/ other wave 0x000 (bus shorted) 0x000 (bus shorted)
Filter cutoff VICE-measured S-curve (≈220 Hz flat bottom → 4.3 kHz @ 1024 → 12.5 kHz) linear 12500·(fc+1)/2048 Hz (≈6 Hz…12.5 kHz, reSID set_w0)
Filter resonance die law max(0.05, 0.5·(15−res)/8) — self-oscillates at res=$F 1/Q = 2^((4−res)/8) (Q ≤ ~2.59, reSID law)
Filter shape piecewise saturator on bp+lp — linear inside ±1.2, compressing beyond (bounds self-oscillation, adds resonant growl) clean (no saturator)
$D418 DC step (digi) emerges from per-voice DC (digi/tone +5.1 dB, VICE-calibrated) faint explicit step dc·0.21 (−8.9 dB, hardware-true)
Write-only reg read (bus value TTL) ~`$1D00` cycles ~`$A2000` cycles
Waveform/env DACs R-2R, no termination, 2R/R≈2.2 → discontinuities (major-carry drop at $800) terminated, 2R/R=2.0 → ideal identity
wave_zero (env pivot) $380 (asymmetric — thumps) $800 (symmetric)
Per-voice DC into mixer VOICE_DC_6581 = 0.694 none
OSC3 read latency live tri/saw table one cycle late, pulse/noise masks live (_triSawPipe)
Noise TEST-fade fast (first step @35000 cy, then /1000) very slow (@2519864 cy, then /315000)

The default is 8580 (machine.js sidIs8580 = true, matched in the UI). Demos detect the model via the combined-waveform / pulse-zero / OSC3 differences (e.g. lft's Lunatico).


14. Known limitations & open work

Where SID emulation is still approximate or intentionally diverges from hardware — everything else is calibrated against VICE's reSID (see the per-topic sections above) and is byte- or near-byte-exact.


15. Offline testing & the A/B harness (no browser)

investigation/sid-offline-render.mjs renders SID audio to WAV without the browser, AudioContext, or RAF — feed it an event stream and it drives the real SIDChip/SIDProcessor and writes a .wav (plus FFT metrics). It's the fastest way to A/B an audio change. node …/sid-offline-render.mjs help prints the full reference. Essentials:

Examples:

# Record 8 s of a demo's music for both chips
node investigation/sid-offline-render.mjs demo path/to/x.prg --dur=8 --model=6581 --out=/tmp/x6581.wav
# Measure digi aliasing, recon off vs on
node investigation/sid-offline-render.mjs digi-square --digirate=8000 --fft --recon=0
node investigation/sid-offline-render.mjs digi-square --digirate=8000 --fft --recon=3
# Confirm a transport refactor is byte-identical
node investigation/sid-offline-render.mjs digi-sine --tone=1000 --compare=direct,worklet

The harness has no independent audio clock, so it models the mechanism of transport issues (#2) and can A/B fixes, but cannot measure how often drift- induced bursting happens live — that needs the c64Trace.sidDiag counters in the browser.


16. Key invariants & gotchas (quick reference)


master overview (audio data flow §) · machine orchestrator (SIDProxy, ring producer, frame loop) · 6510 CPU (the _runMasterCycle that clocks the shadow).