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,
$D418volume-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 harnessinvestigation/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:
- 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).
- 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 audio worklet's
SIDChip.clock()→outputStageAudio()— DAC sample only; skips the OSC3 read while still maintaining the 6581 waveform-DAC latch used by waveform-0 audio; - the main-thread shadow v3 →
outputStageOsc3()— OSC3 read only; skips the DAC sample (the shadow discardsoutputStage()'s return); outputStage()does both (standalone / tests / the lone-voiceclock()).
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):
- Power-up value
$555555(all odd bits set; the chip stores alternate bits inverted). Not cleared by reset —oscinit.prgchecks this. - Per
clock():phase = (phase + freq) & 0xFFFFFF. Output frequency =freq · 985248 / 2²⁴Hz. - TEST bit (
$08) forcesphase = 0and holds it (used to reset the oscillator and to silence it for$D418digis). It also holds the pulse output high regardless of PW (reSIDwave.h) — the rail that test-bit digis and hard-restart routines park a gated voice on. - Hard SYNC (
$02): the voice's phase is zeroed on the cycle its sync source's accumulator MSB rises (bit 23 going 0→1 — NOT the 24-bit wrap, which is half a source period later), with the hardware-verified mutual-sync exception: no sync when the source is itself sync-enabled and its own source's MSB rises the same cycle. Pulses are decided for the whole trio from pre-clock state (computeSyncPulses) and applied inclockCore(), then waveform outputs are computed inoutputStage()— reSID's clock-all → synchronize → output ordering, which also removes the old one-cycle staleness ofv1←v3. The source chain is fixed:v1←v3,v2←v1,v3←v2(makeVoiceTrio). Byte-exact vs VICE reSID over 4096-sample OSC3 series for sync/ring/ring+sync/mutual configurations (investigation/build-sid-syncring-sampler.mjs+syncring-run.mjs). - RING MOD (
$04): the triangle's MSB is substituted with MSB ⊕ **¬**srcMSB (triPhase ^= ~syncSrc.phase & 0x800000, reSID wave.h "MSB EOR NOT sync_source MSB") — ring modulation only affects the triangle waveform, per the chip.
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:
- PT is indexed by the ring-substituted triangle phase
(
(triPhase >> 12) & 0xFFF) — the fold lives inside the table (triInputgeneration), so ring modulation flows through the index MSB like the chip. ST/PS/PST index by the rawphase >> 12. - Pulse low shorts the selector bus to 0 on both chips.
- NOISE+PULSE applies the measured erosion laws after the AND combine:
6581
out < $F00 ? 0 : out & (out<<1) & (out<<2); 8580out < $FC0 ? out & (out<<1) : $FC0. - 6581 accumulator MSB pulldown: combined waveforms that include SAW can
pull the accumulator's top bit low through the waveform output
(
phase &= (out12 << 12) | 0x7FFFFF); the shadow'sclockPhaseOnly()falls back to the fullclock()in exactly those modes so phase equivalence stays exact. - Table reads are pure — no smoothing state is carried between reads, and
getOscByte()mutates nothing.
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.
- 23-bit Fibonacci LFSR, power-up
0x7FFFFE— the all-ones register clocked once when reset releases (reSIDwave.cc reset()). - Shift trigger: rising edge of phase bit 19 (detected from the ADD, so a
hard sync cannot cancel it) — not the full 24-bit wrap, so noise updates
~16× per phase cycle. The shift itself lands 2 cycles later through
shiftPipeline(detect → phase 1 → phase 2, reSIDshift_pipeline); the pipeline only counts down on edge-free cycles, and TEST-rise flushes it. Feedback bit =bit22 ⊕ bit17. - The noise output is a LATCH (
noiseVal), refreshed only when the register shifts / writes back / fades — never recomputed per cycle. Taps bits{20,18,14,11,9,5,2,0}(MSB→LSB), per reSID/noisetest(NOISE_TAPS). Exact extraction (_setNoiseOutput):noiseVal = (lfsr&0x100000)>>13 | (lfsr&0x040000)>>12 | (lfsr&0x004000)>>9 | (lfsr&0x000800)>>7 | (lfsr&0x000200)>>6 | (lfsr&0x000020)>>3 | (lfsr&0x000004)>>1 | (lfsr&0x000001); - TEST bit held stops clocking; the register's SRAM cells gradually fade
toward all-1s. VICE's resid refines reSID 1.0's single
$8000-cycle jump into a measured gradual fade and the dumps prove it: first step after 35000 cycles of TEST, then one step per 1000 (6581; 8580: 2519864 / 315000). Each step lights bit 0 and every set bit pulls its left neighbour up (_shiftregBitfade). - TEST released completes the held shift with the release feedback
bit0 = (bit22|test) ⊕ bit17 = ¬bit17, refreshing the latch. If the previous waveform combined noise with another waveform, the selector output is first flushed into the register (pre-writeback), with empirical per-model exceptions (reSIDdo_pre_writeback: never from noise+pulse on the 6581, only into $9/$E from it on the 8580, never on 6581 tri↔saw swaps). It does fire when writing back INTO plain noise (wf===8): reSID gates that behind#if 0("needs more investigation"), so it is NOT active and we don't gate it either —noiselfsrinit/simple's$F8→$80init dance needs it (real 8580 + VICE =$7F). Intentional VICE-aligned trade-off: firing the writeback makes 13wb_testsuite/noisewritebackX→8 combined-noise cases fail, but VICE 3.10 fails them identically (a shared reSID combined-waveform-model limit; no waveform rule separates them), so reSID fidelity wins here. See_doPreWritebackand §14. - Combined-NOISE zero-clobbering: with NOISE + another waveform the
selector output writes back into the register's tap bits EVERY cycle
(not just on shifts) — except under TEST and except the single cycle
before a pipelined shift lands (
shiftPipeline === 1). A bit can only be cleared, never set, and the output latch degrades immediately (noiseVal &= out12 >> 4). This is what makes combined noise collapse to silence within cycles on real hardware (a TEST pulse or the fade recovers it).
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:
State pipeline (
statePipeline/_stateChange): a GATE write schedules the ATTACK/RELEASE transition a couple of cycles out — the chip doesn't switch immediately. Attack rate correctly takes over on the second cycle of attack; release from attack/decay resolves at its own pipeline offset. On each transition the stored comparatorratePeriodis reloaded from the new state's rate nibble.Envelope pipeline (
envPipeline): a rate/exp match arms the counter step one cycle out; the actualenv±1 lands on the next clock.Exponential pipeline (
expPipeline) + deferred rate-counter reset (resetRateCounter): resolve in reSID's else-if order (exp first, else the rate reset queued last cycle).Rate periods (
RATE_PERIODS[16], cycles per step) come straight from the Yannes/reSID table; the ATK / DEC / REL nibbles index it. A 15-bit prescaler (rateCounter) counts to the selected period before each step:[9, 32, 63, 95, 149, 220, 267, 313, 392, 977, 1954, 3126, 3907, 11720, 19532, 31251](Identical on both chips — the envelope generator is the same silicon block in the 6581 and 8580.) The comparator the prescaler counts up to is the reSID
ratePeriod=RATE_PERIODS − 1(RATE_CMP): on an equality match the counter does not increment — it armsresetRateCounterso the clear lands the next cycle, giving the effective periodcomparator + 1 = RATE_PERIODS. The prescaler counter (rateCounter) is never reset by register writes or gate flips — only by that deferred match-reset — but the comparator itself is reloaded on AD/SR/control writes and state transitions when the current state's rate nibble changes (reSID's storedrate_period). On wrap the counter skips zero ($7FFF → $0001, reSIDenvelope.h). Together these produce the ADSR delay bug: shrinking the period below the running counter stalls the envelope for up to 32767 cycles until the wrap comes back around (verified against VICE reSID: 1916-sample stall, ±1 boot-phase).Attack ramps
env0→255 linearly, then drops into decay. Reaching a step resets the exponential counter even though attack doesn't divide (reSID clears it on every rate match in attack).Decay / Release use an exponential divider:
expCountercounts to a divisor latched whenenvlands on a boundary value —$FF→1, $5D→2, $36→4, $1A→8, $0E→16, $06→30, $00→1(reSIDset_exponential_counter; a mid-segment env value keeps the previous divisor rather than deriving one from ranges). A full A=D=R=15 envelope takes exactly0x7E60cycles (envtime.prg).Decrement pipeline: when the divisor ≠ 1, a rate+exp match doesn't step
envimmediately — it arms a 1-cycle pipeline and the decrement lands on the next clock (reSIDenvelope_pipeline).Counter wrap + freeze-at-zero (reSID
hold_zero): the 8-bitenvcounter wraps in both directions. An attack step atenv=$FFflips to$00; a release/decay step atenv=$00wraps to$FF. Landing on$00(either way) freezes the counter — only GATE 0→1 unlocks it. So a re-gate while env sits at$FFsnaps it to$00and mutes the voice until the next gate-off/on pair (music-routine hard-restart interaction), and a release at env 0 that has been unlocked runs a full$FF-down curve.Sustain is not a separate state: inside decay/sustain the compare
env === (s<<4)|s(the nibble duplicated to 8 bits) is re-evaluated on every exponential tick, and the rate/exponential counters keep running (reSIDenvelope.hDECAY_SUSTAIN). Consequences, both verified against reSID:- Lowering SR while gated resumes decay down to the new level —
writing SR=
$00drains the envelope to 0 at the decay rate (the GoatTracker/JCH hard restart that much of real SID music relies on). - Raising SR above the current env is never honoured — the equality can't match on the way down, so the envelope decays past it to 0.
- Lowering SR while gated resumes decay down to the new level —
writing SR=
GATE 1→0 forces release (via the state pipeline); GATE 0→1 (re-gate) restarts attack from the current level and clears the zero-freeze. reSID quirk: on gate-on the decay register is "accidentally" activated for the first cycle (
state←DECAY, comparator←RATE_CMP[d]), and the attack rate only takes over two cycles later when_stateChangeflips to ATTACK — env_test measures this.
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:
getOscByte()returns the live waveform byte when a waveform is selected, else the held latch (below). It is pure — no combined-table smoothing state is carried between reads.- Waveform-0 latch + bit-fade (
oscLatch,oscFadeCtr): when no waveform is selected the DAC output is held and its bits slowly leak toward 0 over ~3 s on the 6581 (8580 longer) —osc3-wave0.prg/ bit-fade. The 6581 audio path also consumes this latch for waveform-0 output, while the worklet still skips the rest of the OSC3 read pipeline. - 8580 tri/saw pipeline (reSID
tri_saw_pipeline): the 8580 latches the tri/saw table component through an extra clock phase, so when TRI or SAW participates OSC3 shows last cycle's table value masked by THIS cycle's pulse rail and noise latch. Pulse-only / noise-only reads are live on both models; the 6581 is always live. (chipmodel.prg/osc_topbitmeasured the delay with saw; the drift-frequency samplerinvestigation/build-sid-osc-sampler.mjs+osc-run.mjspins the scope: pulse/saw/saw+pulse/tri/noise windows — maxΔ0 vs VICE on both models everywhere the 8580 combined-table fit ceiling doesn't apply.)
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:
Clock the 3 voices (
clockCore()thenoutputStageAudio()— 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.Route each voice (
$D417low nibble) either into the filter sum (filterIn) or straight to the output (out).v3off($D418bit 7) mutes voice 3 from the direct path. The EXT-IN route bit ($D417bit 3) is a no-op — stock C64 grounds the EXT IN pin.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
fcfrom$D415/$D416):- 6581 →
FC6581_TABLE[fc], log-interpolated through anchors measured off headless VICE reSID 6581 (noise sweeps viainvestigation/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) / 2048Hz (≈6 Hz…12.5 kHz), per reSIDfilter.cc set_w0()(w0 = 82355·(fc+1) >> 11). - Both clamp to
maxC = 1e6 · 0.45and convertw0 = 2·sin(π·cutHz / 1e6).
Resonance (4-bit
resfrom$D417hi nibble):- 6581 → the die-derived law
damp = max(0.05, 0.5·(15−res)/8)(reSIDfilter.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)`.- 6581 →
Output DAC — see §9.
The filter integrator uses
1e6for the clock where the rest of the code uses the true PAL985248— 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)
- Soft-clip saturator: the final
tanhruns every SID cycle on the audio thread (~985 kHz), so it is evaluated through a table-interpolatedtanh(tanhApproxinsid-voice.js) rather thanMath.tanh— bounded error ≤3.7e-7 (−128 dBFS), far below the 16-bit floor, so it is inaudible. - Master volume scales the whole mix (so volume changes are audible).
- The digi mechanism: on the 6581, each voice rides at a standing DC
(
VOICE_DC_6581, reSIDvoice_DC) and volume-scaling that total IS the$D418digi — no separate hack term. On the 8580 a small explicit step remains (dc·0.21) — real 8580s (and reSID) show only a faint volume-step response. Both constants are calibrated against VICE x64sc reSID recordings ofinvestigation/build-sid-dc-probe.mjs: 6581 digi/tone = +5.1 dB, 8580 = −8.9 dB. (For loud digis, the 6581 is the hotter chip.) - DC blocker (pole 0.9998986 ≈ 15.9 Hz) removes the standing offset, matching reSID's extfilt high-pass.
tanhcompresses overshoots (3 voices + resonance can exceed ±1) without the aliasing a hard clip would add.
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
- The slider is a position; the gain is tapered.
masterVolume(0..1, persistedc64emu.volume, default 0.7) is the slider position. The gain applied to the node is a square-law perceptual tapervolumeToGain(v) = v·v→ 70% = 0.49 (≈ −6 dB), 50% = 0.25 (−12 dB), 100% = 1.0 (0 dBFS, unity). Linear gain sounded loud across most of the travel; squaring spreads the useful range over the top half. - Why the 0.7 default. With
$D418at 15 the mix above rides to ±1.0 (0 dBFS) through thetanh— authentically hot, i.e. louder than loudness-normalised apps at the same system volume. ~−6 dB of default headroom fixes that without distorting; unity is still one drag away. - One node trims everything. MUTE pins the same
masterGainto 0, and the synthesised drive sounds route through it too — so it governs all output, not just the SID. User-facing behaviour lives in the User Guide's Sound section.
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)
- Producer (
machine.js _sidWrite): writes{sidCycleCounter, reg|val<<8}atwriteIdx, thenAtomics.storeadvanceswriteIdx. It does not check fullness — it overwrites unconditionally (the ring is large enough that the consumer always keeps up). - Consumer (
process()):_drainRing()copies all pending entries into a preallocated typed ring (pendCycle/pendPacked, indexed bypendHead/pendCount);_applyDueEvents()applies those whose cycle stamp is≤ currentCycle(unsigned-delta test), breaking at the first future event. - Cycle-sync + lookahead:
currentCycle(the worklet's clock) andsidCycleCounter(the producer's clock) both free-run and can drift. After init/reset (_needCycleSync) the worklet snapscurrentCycleto about 25 ms before the first pending event. This deliberate jitter buffer gives the main thread time to deliver a frame's burst of SID writes before playback reaches dense$D418sample streams. A coarser desync recovery uses the same lookahead snap whenever the head event is >0.5 s away in either direction (fixes power-cycle / second-load stale bursts).
11. Decimation & reconstruction#
process() produces host-rate samples (e.g. 44.1 kHz) from the ~985 kHz SID
clock:
cyclesPerSample = 985248 / sampleRate(≈22.34). A fractional accumulator (cycleAccum) picks 22 or 23 SID cycles per output sample.- Each SID cycle: apply due events →
SIDChip.clock()→ feed an output-stage one-pole LP at 15.9 kHz (outLpState, reSID extfilt model, run at the SID clock so it pre-attenuates HF before decimation) → then a 3-pole reconstruction cascade at 0.42·Fs (recon1/2/3, ≈18.5 kHz) → accumulate. - Output sample = boxcar average
sum / countof those cycles. - A ~6 ms fade-in after init/reset masks the DC-blocker settling transient (power-on click).
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
$D418digi 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)#
SIDProxyis the$D400-$D7FFdevice.writerecords the byte (data-bus shadow), forwards to the ring, and mirrors voice-register writes to the shadow.readserves POTX/POTY (latched), OSC3/ENV3 (shadowV3); reads of write-only registers return the decaying bus value (last byte written to any SID register or returned by a readable read; TTL$1D00/6581,$A2000/8580).- Shadow voices are created by
makeVoiceTrio()and clocked every_runMasterCycle:v1/v2viaclockPhaseOnly()(only the phase accumulator advances — they exist solely as the sync/ring source chain),v3viaclockCore()+outputStageOsc3()(the OSC3 read only — v3's audio DAC sample is discarded here, so it's skipped; see §1). Byte-identical OSC3/ENV3 at a fraction of the cost. sidCycleCounteris the free-running cycle stamp incremented in_runMasterCycle; it is the time base for ring events.- Paddle POT sample-and-hold:
$D419/$D41AreturnpotXSampled/potYSampled, refreshed every 512 master cycles from the livepaddleX/paddleY(the 8-bit successive-approximation timing — Arkanoid polls this tightly). - Model sync:
setSidModel(is8580)updates the shadow voices;main.jsposts the matchingmodelmessage to the worklet so both stay on the same chip.
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.
- Combined waveforms use synthetic tables. reSID ships measured tables
for the analog combined-waveform DAC; we fit compact independent generators
instead (§4) — audibly close, not bit-exact. One
consequence: 13 combined-noise cases in VICE's
wb_testsuitediffer, but VICE 3.10 fails them identically (a shared reSID combined-waveform-model limit), so this is an accepted trade-off rather than a defect. $D417EXT IN routing (bit 3) is a no-op — no expansion-port audio is mixed into the filter.- Filter clock constant. The filter integrator runs on
1e6rather than the true985248Hz PAL clock — about 1.5 % cutoff drift, below chip-to-chip filter spread. - Combined waveforms use synthetic per-bit fitted tables (Hermit/jsSID coupling
plus 6581 bit thresholds,
_genCombinedBitThresholds), not reSID's measured DAC tables — audibly close, not bit-exact. - Boxcar decimation. The worklet downsamples 985 kHz → 44.1 kHz by boxcar averaging after the reconstruction filter (§11); a full FIR decimator would add roughly 20 dB more stopband rejection.
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:
- Three transports isolate where a difference comes from:
--transport=direct(events at exact cycle — the ideal),worklet(the real ring + typed pending-ring path),burst(deterministic RAF-clump model of #2).workletincludes the live event-lookahead delay, so--compare=direct,workletis useful for spotting transport effects but is not expected to be byte-identical without delay alignment. - Scenes:
digi-square,digi-sine(Mahoney aliasing probe),tone,combined-v3(per-chip combined-waveform probe),trace(a captured[cycle,reg,val]JSON), anddemo— which boots a real C64 headless, loads a.prg/.d64/.sid, runs it, captures every SID write, and renders the music to WAV (.sidauto-detects the model). - Flags:
--model=6581|8580,--dur,--fft(spectrum peaks + in-band SNR15 kHz aliasing energy),
--recon=N/--recon-cut(prototype the reconstruction filter;--recon=0disables it),--out. WAVs default toinvestigation/wav/(gitignored).
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)#
- Two consumers, one source. Audio = SAB ring → worklet. Readback
(
$D41B/$D41C) = main-thread shadow voices. They must stay model-synced (setSidModel). - Shadow v1/v2 are phase-only. They advance just the accumulator (sync/ring
source); v3 gets
clockCore()+outputStageOsc3(). Don't read envelope/OSC from v1/v2. outputStage()has three variants (§1): full,outputStageAudio()(worklet — skips the OSC3 read but maintains the 6581 waveform-0 DAC latch),outputStageOsc3()(shadow v3 — skips the DAC sample). The lean pair must stay byte-identical to full on the product it keeps, and the_outputPre → _osc3Read → _outputPostorder is load-bearing. A test double standing in for a shadow voice needsoutputStageOsc3()too (the_vic2-helpers.jsstubVoice— a stale stub there is how the split's one regression surfaced).getOscByte()is pure (since the per-chip fitted tables landed);readOsc3()remains the hardware-correct clocked-pipeline read.- Phase power-up is
$555555, not 0, and survives reset (oscinit.prg). - Noise LFSR clocks on phase-bit-19 rising edge, not the 24-bit wrap, and
the shift lands 2 cycles late (
shiftPipeline); the output byte is a LATCH refreshed only on shifts/writebacks. TEST holds the register and it fades to 1s (gradual, §5); combined-NOISE clobbers it toward 0 every cycle. - Exponential-envelope periods are chosen by an exact-match on the envelope
counter (
_setExpPeriod: 6/14/26/54/93/255), not a<=compare — the<=cascade survives only as the save-state restore fallback indeserialize. Getting the boundary wrong makes envelopes run ~3 % fast. $D418is the digi path via the volume DC step; the DC blocker is tuned gentle (15.9 Hz) so audio-rate sample streams pass.- Two "master volumes" — don't conflate them (§9).
$D418(0-15) is the chip's volume DAC (emulated, part of the digi path). The Settings ▸ Sound slider is a separate Web AudiomasterGaininmain.js— a perceptualv²taper (default 70% ≈ −6 dB), downstream of the worklet, trimming all output (SID + drive sounds); MUTE shares that node. - Cycle stamps are
sidCycleCounter(free-running, 31-bit-masked in the ring). The worklet'scurrentCycleis a separate clock; they're reconciled by the cycle-sync snaps and the event-lookahead buffer, not kept identical. - POTX/POTY are a 512-cycle sample-and-hold, not live.
- EXT-IN filter route (
$D417bit 3) is a no-op (EXT IN grounded on a stock C64). - Verify audio changes with
investigation/sid-offline-render.mjs(use--recon=3when matching the worklet reconstruction path; account for the live worklet lookahead delay) plustest/sid-*-spec-test.js. Cycle-sync / second-load / power-cycle behaviour has focused tests, but still deserves a browser ear-check after transport changes.
Related docs#
master overview (audio data flow §) ·
machine orchestrator (SIDProxy, ring producer, frame loop) ·
6510 CPU (the _runMasterCycle that clocks the shadow).