Three time-multiplexed SID voices with 8-bit ADSR envelopes, sync and ring modulation, controlled via a flat parallel register interface, with 8-bit PWM audio output. Designed for a Tiny Tapeout 1x2 tile on the IHP SG13G2 130nm process at 24 MHz.
- Overview
- Architecture
- Pin Mapping
- Register Reference
- Write Interface Protocol
- Audio Output and PWM
- Audio Recovery Filter
- Usage Guide
- Design Constraints
This design implements a triple-voice sound synthesizer inspired by the MOS 6581/8580 SID chip from the Commodore 64. Three independent voices share a single compute pipeline via time-multiplexing, each providing four classic waveform types (sawtooth, triangle, pulse, noise), a full 8-bit ADSR amplitude envelope with exponential decay, hard sync, and ring modulation in a Tiny Tapeout 1x2 tile.
A host microcontroller (Arduino, RP2040, ESP32, etc.) writes per-voice
control registers through a simple flat parallel interface using 8-bit
data and a rising-edge write strobe. The three voice outputs are mixed
and output as an 8-bit PWM signal on uo_out[0] at ~94.1 kHz, requiring
only a passive RC low-pass filter to produce analog audio.
I did not get the filters to work as analog macros. So: no filters.
- Three independent voices, time-multiplexed through one shared pipeline
- Four waveforms per voice: sawtooth, triangle, pulse (variable width), noise
- AND-combining of simultaneous waveforms (SID-compatible)
- Hard sync modulation (circular: V0←V2, V1←V0, V2←V1)
- Ring modulation (XOR sync source MSB into triangle)
- 8-bit ADSR envelope per voice (256 amplitude levels, piecewise exponential decay)
- Per-voice ADSR parameters (attack, decay, sustain, release)
- 4-state envelope FSM (IDLE, ATTACK, DECAY, SUSTAIN) + releasing flag
- 16 SID-accurate envelope rate settings from ~2.3 ms to ~8 s full traverse (matching MOS 6581/8580)
- SID-compatible sustain mapping (nibble duplication: N → 0xNN)
- 16-bit frequency register, 24-bit phase accumulator (~0.06 Hz resolution, matching original C64 SID)
- 15-bit LFSR noise generator, accumulator-clocked from voice 0
- 3-voice mixer with 10-bit accumulator and ÷4 scaling
- 4-bit volume
- Single 8-bit PWM audio output on uo_out[0] (~94.1 kHz carrier at 24 MHz)
- Flat parallel write interface
- Mod-6 pipeline: 1 MHz effective per voice at 6 MHz voice clock (24 MHz ÷4)
| File | Description |
|---|---|
src/tt_um_sid.v |
Top-level: register banks, voice pipeline, mixer, filter, pin mapping |
src/pwm_audio.v |
8-bit PWM audio output (255-clock period) |
Signal flow:
-
The host writes per-voice registers (frequency, pulse width, waveform, attack/decay, sustain/release) via the flat parallel interface. All registers are per-voice. A rising edge on
ui_in[7]latches the data. -
A ÷4 clock divider produces a 6 MHz clock enable from the 24 MHz system clock. A mod-6 slot counter (gated by the 6 MHz enable) cycles through 6 slots. Slots 0-2 compute voices 0-2 respectively, slot 4 latches the mixer output and preloads voice 0's pipeline registers, and slots 3 and 5 are idle/preload slots. Each voice is updated once per 6-slot frame (1 MHz effective per voice).
-
Each voice's 24-bit phase accumulator advances by the 16-bit frequency register value (zero-extended to 24 bits) every frame. This provides ~0.06 Hz frequency resolution, matching the original C64 SID accumulator geometry. Hard sync resets the accumulator when the sync source voice's MSB has a rising edge.
-
The waveform generator derives sawtooth, triangle, pulse, and noise outputs from the accumulator state and a shared 15-bit LFSR (clocked from voice 0's accumulator bit 19). Selected waveforms are AND-combined into an 8-bit value. Ring modulation XORs the sync source's accumulator MSB into the triangle waveform's fold bit.
-
The 8-bit waveform is multiplied by the 8-bit ADSR envelope to produce a 16-bit product; the upper 8 bits are taken as the voice output.
-
The mixer accumulates three 8-bit voice outputs into a 10-bit accumulator over 3 slots, then divides by 4 (right-shift by 2) to produce an 8-bit mix sample for the PWM module.
-
pwm_audioconverts the filtered sample into a PWM signal at ~94.1 kHz (running at full 24 MHz). An external RC low-pass filter recovers analog audio.
| Pin | Signal | Description |
|---|---|---|
ui_in[4:0] |
reg_addr |
Register address (0--24) |
ui_in[6:5] |
-- | Unused |
ui_in[7] |
wr_en |
Write enable (rising-edge triggered) |
| Pin | Signal | Description |
|---|---|---|
uio_in[7:0] |
wr_data |
8-bit write data. All 8 pins are inputs. |
| Pin | Signal | Description |
|---|---|---|
uo_out[7] |
pwm_out |
PWM audio output (filtered or bypass). Connect to RC filter. |
uo_out[6:0] |
-- | Tied low. |
All uio pins are configured as inputs (uio_oe = 0x00).
Seven registers per voice (voice_sel 0--2), plus four filter/volume registers (voice_sel 3).
Bit: 7 6 5 4 3 2 1 0
[ freq_lo[7:0] ]
Bit: 7 6 5 4 3 2 1 0
[ freq_hi[7:0] ]
The 16-bit frequency register {freq_hi, freq_lo} is zero-extended and added
to the 24-bit phase accumulator each voice cycle (1 MHz effective rate,
24 MHz ÷4 ÷6 slots). The oscillator frequency is:
f_out = freq_reg × 1,000,000 / 16,777,216 ≈ freq_reg × 0.0596 Hz
Frequency calculation:
freq_reg = round(desired_Hz × 16,777,216 / 1,000,000)
≈ desired_Hz × 16.777
Resolution: ~0.06 Hz. Range: 0.06 Hz (reg=1) to ~3906 Hz (reg=65535). This matches the original C64 SID accumulator geometry (24-bit acc, 16-bit freq reg). Higher audio frequencies are produced as harmonics of the waveform generators.
| freq_reg | freq_hi | freq_lo | Output Frequency | Note |
|---|---|---|---|---|
| 0 | 0x00 | 0x00 | 0 Hz | Silence |
| 1 | 0x00 | 0x01 | ~0.06 Hz | Lowest pitch |
| 4396 | 0x11 | 0x2C | ~262 Hz | ~C4 |
| 7382 | 0x1C | 0xD6 | ~440 Hz | ~A4 |
| 35112 | 0x89 | 0x28 | ~2,093 Hz | ~C7 |
| 65535 | 0xFF | 0xFF | ~3,906 Hz | Maximum fundamental |
Bit: 7 6 5 4 3 2 1 0
[ pw_lo[7:0] ]
Bit: 7 6 5 4 3 2 1 0
[ (unused) ][ pw_hi[3:0] ]
The 12-bit pulse width {pw_hi, pw_lo} sets the pulse waveform duty cycle
by comparison with the accumulator upper 12 bits (acc[23:12] >= pw):
pw = 0x000: Pulse always high (near DC)pw = 0x800: ~50% duty cycle (square wave)pw = 0xFFF: Narrow pulse (~0.02% duty)
Bit: 7 6 5 4 3 2 1 0
[noise][pulse][sawtooth][triangle][test][ring][sync][gate]
| Bit | Name | Description |
|---|---|---|
| 0 | gate |
Set to 1 to start a note (attack). Clear to 0 to release. |
| 1 | sync |
Hard sync: resets phase accumulator on sync source voice MSB rising edge. Circular routing: V0←V2, V1←V0, V2←V1. |
| 2 | ring |
Ring modulation: XORs sync source voice accumulator MSB into triangle waveform fold bit, producing bell-like timbres. |
| 3 | test |
Forces oscillator accumulator to 0 while held. |
| 4 | triangle |
Enable triangle waveform. |
| 5 | sawtooth |
Enable sawtooth waveform. |
| 6 | pulse |
Enable pulse waveform (duty cycle set by reg 2). |
| 7 | noise |
Enable noise waveform (shared 15-bit LFSR, accumulator-clocked from voice 0). |
When multiple waveform bits are set, their outputs are bitwise AND-combined (starting from 0xFF, each enabled waveform ANDs its value). This matches the real SID's behavior where simultaneous waveforms produce a bitwise AND of their individual outputs.
Bit: 7 6 5 4 3 2 1 0
[ decay_rate[3:0] ][ attack_rate[3:0] ]
| Field | Bits | Description |
|---|---|---|
attack_rate |
[3:0] |
How fast the envelope rises from 0 to 255 |
decay_rate |
[7:4] |
How fast the envelope falls from 255 to sustain level (with exponential decay) |
Bit: 7 6 5 4 3 2 1 0
[ release_rate[3:0] ][sustain_level[3:0]]
| Field | Bits | Description |
|---|---|---|
sustain_level |
[3:0] |
Sustain amplitude (0--15). The 8-bit envelope holds at {sustain_level, sustain_level} (nibble duplication: 0x00, 0x11, ..., 0xFF), matching the original SID. |
release_rate |
[7:4] |
How fast the envelope falls to 0 after gate off (with exponential decay) |
The filter is not implemented. Only the volume is present.
Bit: 7 6 5 4 3 2 1 0
[V3OFF][HP ][BP ][LP ][ filt_vol[3:0] ]
| Field | Bits | Description |
|---|---|---|
filt_vol |
[3:0] |
Master volume (0--15). Post-ADC digital shift-add |
[7:4] |
unused |
The ADSR uses per-voice 15-bit rate counters matching the original MOS 6581/8580 SID. Each of the 16 rate settings selects a non-power-of-2 period from a lookup table. The rate counter decrements each voice cycle (1 MHz); when it reaches zero, a rate tick fires and the counter reloads. The 8-bit envelope steps by ±1 per tick (attack) or per tick gated by the exponential counter (decay/release), so a full linear 0→255 traverse takes 256 rate ticks.
| Rate | Period (cycles) | Full Traverse (256 ticks) | SID Equivalent |
|---|---|---|---|
| 0 | 9 | ~2.3 ms | 2 ms Attack |
| 1 | 32 | ~8.2 ms | 8 ms Attack |
| 2 | 63 | ~16.1 ms | 16 ms Attack |
| 3 | 95 | ~24.3 ms | 24 ms Attack |
| 4 | 149 | ~38.1 ms | 38 ms Attack |
| 5 | 220 | ~56.3 ms | 56 ms Attack |
| 6 | 267 | ~68.4 ms | 68 ms Attack |
| 7 | 313 | ~80.1 ms | 80 ms Attack |
| 8 | 392 | ~100 ms | 100 ms Attack |
| 9 | 977 | ~250 ms | 250 ms Attack |
| 10 | 1,954 | ~500 ms | 500 ms Attack |
| 11 | 3,126 | ~800 ms | 800 ms Attack |
| 12 | 3,907 | ~1.0 s | 1 s Attack |
| 13 | 11,720 | ~3.0 s | 3 s Attack |
| 14 | 19,532 | ~5.0 s | 5 s Attack |
| 15 | 31,251 | ~8.0 s | 8 s Attack |
Formula: traverse_time = 256 × period / 1,000,000 seconds.
These are the same 16 period values used by the real MOS 6581/8580 SID, providing the full range from 2 ms to 8 s for attack and (with exponential counter) 6 ms to 24 s for decay/release.
During decay and release, a secondary 5-bit exponential counter acts as a post-divider on the rate counter ticks, matching the original SID behavior. The exponential counter decrements on each rate tick; an envelope step only occurs when both the rate counter and exponential counter reach zero. The exponential counter reloads from a breakpoint-based period table:
| Envelope ≥ | Expo period | Effective divisor |
|---|---|---|
| 93 | 1 | ×1 (full speed) |
| 54 | 2 | ×2 |
| 26 | 4 | ×4 |
| 14 | 8 | ×8 |
| 6 | 16 | ×16 |
| < 6 | 30 | ×30 |
Attack bypasses the exponential counter entirely (always linear). The breakpoints and divisors match the original MOS 6581/8580 SID, including the ×30 slowdown at very low envelope values (env < 6) that the original implements via its exponential counter.
The 4-bit sustain register maps to an 8-bit level by nibble duplication, matching the original SID:
| Sustain (4-bit) | Envelope (8-bit) | Sustain (4-bit) | Envelope (8-bit) | |
|---|---|---|---|---|
| 0 | 0x00 (0) | 8 | 0x88 (136) | |
| 1 | 0x11 (17) | 9 | 0x99 (153) | |
| 2 | 0x22 (34) | 10 | 0xAA (170) | |
| 3 | 0x33 (51) | 11 | 0xBB (187) | |
| 4 | 0x44 (68) | 12 | 0xCC (204) | |
| 5 | 0x55 (85) | 13 | 0xDD (221) | |
| 6 | 0x66 (102) | 14 | 0xEE (238) | |
| 7 | 0x77 (119) | 15 | 0xFF (255) |
The envelope uses a 4-state FSM with a separate releasing flag:
gate ON env=255 env ≤ sustain
IDLE ──────────► ATTACK ──────────► DECAY ──────────► SUSTAIN
▲ │ │ │
│ │ gate OFF │ gate OFF │ gate OFF
│ ▼ ▼ ▼
└──── env=0 ◄── RELEASING ◄──────────────────────────────
- IDLE: Envelope is 0. Waits for gate rising edge to transition to ATTACK.
- ATTACK: Envelope increments toward 255 at the attack rate (linear). Transitions to DECAY when envelope reaches 255.
- DECAY: Envelope decrements toward sustain level with exponential decay. Transitions to SUSTAIN when envelope reaches
{sustain_level, sustain_level}. - SUSTAIN: Envelope holds at sustain level.
- RELEASING: Gate off sets the
releasingflag from any active state. Envelope decrements toward 0 with exponential decay. When envelope reaches 0, transitions to IDLE.
Comparison with original SID: The original uses 3 states (ATTACK,
DECAY_SUSTAIN, RELEASE) without a separate IDLE state. Our 4-state FSM
with an explicit releasing flag is functionally equivalent but avoids the
SID's ADSR delay bug, where retriggering can cause the envelope to stall
for up to ~33 ms when the new rate counter period is less than the current
counter value.
The register interface uses a simple parallel bus with rising-edge write strobe. No SPI or I2C protocol is needed.
To write one register:
- Set
ui_in[2:0]= register address (0-6) - Set
ui_in[4:3]= voice select (0, 1, or 2) - Set
uio_in[7:0]= data byte - Pulse
ui_in[7]high for at least one clock cycle - Return
ui_in[7]low before the next write
The register latches on the rising edge of ui_in[7]. The minimum write
cycle is 3 clock cycles (125 ns at 24 MHz): one to set up address/data,
one with WE high, one with WE low.
┌───────┐ ┌───────┐
ui_in[7] │ │ │ │
───────────┘ └─────────────────────────┘ └─────
^ ^
write latched write latched
ui_in[4:0] < voice | addr > < voice | addr >
uio_in < data byte > < data byte >
// Write an 8-bit value to a SID register
// addr: 0-6 voice: 0-3 (3=filter) data: 0-255
void sid_write(uint8_t addr, uint8_t data, uint8_t voice) {
uint8_t ui = (addr & 0x07) | ((voice & 0x03) << 3);
set_ui_in(ui); // address + voice, WE=0
set_uio_in(data); // data byte
set_ui_in(ui | 0x80); // assert WE (rising edge triggers write)
set_ui_in(ui); // deassert WE
}The pwm_audio module converts the 8-bit mixer output into a
pulse-width modulated signal. A free-running 8-bit counter cycles from
0 to 254 (period = 255 clocks). The output is high when the counter is
less than the sample value:
pwm_out = (count < sample) ? 1 : 0
| Parameter | Value |
|---|---|
| Input resolution | 8 bits (unsigned, 0--255) |
| Output | 1-bit PWM on uo_out[0] |
| PWM period | 255 clocks |
| PWM frequency @ 24 MHz | ~94.1 kHz |
| Duty cycle range | 0% (sample=0) to 100% (sample=255) |
| Audio bandwidth | Up to ~23.5 kHz (Nyquist) |
The PWM output on uo_out[0] swings between 0 and VDD. A second-order
passive RC low-pass filter recovers the analog audio signal. The ~94.1 kHz
PWM carrier is well above the 20 kHz audio band.
uo_out[0] ---[R1]---+---[R2]---+---[Cac]---> Audio Out
| |
[C1] [C2]
| |
GND GND
| R (per stage) | C (per stage) | Per-stage fc | Combined rolloff | @ 94 kHz |
|---|---|---|---|---|
| 3.3 kΩ | 2.2 nF | ~22 kHz | -12 dB/oct (2nd order) | ~-25 dB |
- Cac = 1 µF ceramic -- DC blocking capacitor after the filter.
- For driving low-impedance loads (headphones), add a unity-gain op-amp buffer after the filter.
- A third-order (three-stage, same values) filter can be added for better carrier rejection.
MCU TT Chip Audio
----------- ---------------- -------
GPIO (D0) --------> ui_in[0] addr[0]
GPIO (D1) --------> ui_in[1] addr[1]
GPIO (D2) --------> ui_in[2] addr[2]
GPIO (D3) --------> ui_in[3] voice[0]
GPIO (D4) --------> ui_in[4] voice[1]
GPIO (WE) --------> ui_in[7] write enable
GPIO (D5-12)--------> uio_in[7:0] data bus
uo_out[0] --[3.3k]--+--[3.3k]--+--[1uF]--> amp
| |
[2.2nF] [2.2nF]
| |
GND GND
// freq_reg = round(440 * 16777216 / 1000000) = 7382 (0x1CD6)
sid_write(0, 0xD6, 0); // freq_lo
sid_write(1, 0x1C, 0); // freq_hi
sid_write(4, 0x00, 0); // attack=0 (fastest), decay=0
sid_write(5, 0x0F, 0); // sustain=15 (max), release=0
sid_write(6, 0x21, 0); // sawtooth + gate ON
delay(500); // hold note for 500 ms
sid_write(6, 0x20, 0); // gate OFF (release begins)// C4 ≈ 262 Hz → freq_reg = round(262 * 16777216 / 1000000) = 4396 (0x112C)
sid_write(0, 0x2C, 0); sid_write(1, 0x11, 0);
sid_write(4, 0x00, 0); sid_write(5, 0x0F, 0);
sid_write(6, 0x21, 0); // Voice 0: sawtooth C4
// E4 ≈ 330 Hz → freq_reg = round(330 * 16777216 / 1000000) = 5536 (0x15A0)
sid_write(0, 0xA0, 1); sid_write(1, 0x15, 1);
sid_write(4, 0x00, 1); sid_write(5, 0x0F, 1);
sid_write(6, 0x11, 1); // Voice 1: triangle E4
// G4 ≈ 392 Hz → freq_reg = round(392 * 16777216 / 1000000) = 6577 (0x19B1)
sid_write(0, 0xB1, 2); sid_write(1, 0x19, 2);
sid_write(2, 0x00, 2); sid_write(3, 0x08, 2); // pw = 0x800 (50%)
sid_write(4, 0x00, 2); sid_write(5, 0x0F, 2);
sid_write(6, 0x41, 2); // Voice 2: pulse G4sid_write(0, freq_reg & 0xFF, v);
sid_write(1, freq_reg >> 8, v);
sid_write(2, 0x00, v); sid_write(3, 0x08, v); // pw = 0x800, 50% duty
sid_write(4, 0x00, v); // instant attack/decay
sid_write(5, 0x0F, v); // max sustain, instant release
sid_write(6, 0x41, v); // pulse + gatesid_write(0, 0xFF, v); // high freq noise
sid_write(4, 0xA0, v); // instant attack, rate-10 decay
sid_write(5, 0xA0, v); // sustain=0, rate-10 release
sid_write(6, 0x81, v); // noise + gate
// Naturally decays to silencesid_write(0, freq_reg & 0xFF, v);
sid_write(1, freq_reg >> 8, v);
sid_write(4, 0x8A, v); // attack=10 (~524ms), decay=8 (~524ms)
sid_write(5, 0x8C, v); // sustain=12, release=8
sid_write(6, 0x11, v); // triangle + gate// Voice 0: modulator at higher frequency
// 931 Hz → freq_reg = round(931 * 16777216 / 1000000) = 15619 (0x3D03)
sid_write(0, 0x03, 0); sid_write(1, 0x3D, 0); // ~931 Hz modulator
sid_write(6, 0x11, 0); // triangle + gate (modulator)
// Voice 1: carrier with ring mod enabled
// C4 ≈ 262 Hz → freq_reg = 4396 (0x112C)
sid_write(0, 0x2C, 1); sid_write(1, 0x11, 1); // ~262 Hz carrier (C4)
sid_write(4, 0x50, 1); // instant attack, rate-5 decay
sid_write(5, 0x54, 1); // sustain=4, rate-5 release
sid_write(6, 0x15, 1); // triangle + ring + gatefreq_reg = round(Hz × 16,777,216 / 1,000,000)
| Note | Hz | freq_reg | freq_hi | freq_lo |
|---|---|---|---|---|
| C3 | 130.8 | 2194 | 0x08 | 0x92 |
| D3 | 146.8 | 2463 | 0x09 | 0x9F |
| E3 | 164.8 | 2765 | 0x0A | 0xCD |
| G3 | 196.0 | 3289 | 0x0C | 0xD9 |
| A3 | 220.0 | 3691 | 0x0E | 0x6B |
| C4 | 261.6 | 4389 | 0x11 | 0x25 |
| E4 | 329.6 | 5530 | 0x15 | 0x9A |
| G4 | 392.0 | 6577 | 0x19 | 0xB1 |
| A4 | 440.0 | 7382 | 0x1C | 0xD6 |
| C5 | 523.3 | 8779 | 0x22 | 0x4B |
| A5 | 880.0 | 14764 | 0x39 | 0xAC |
| C6 | 1046.5 | 17558 | 0x44 | 0x96 |
| C7 | 2093.0 | 35112 | 0x89 | 0x28 |
After power-on or chip reset (rst_n asserted low), all registers are
cleared to zero. All voices are silent. No initialization sequence is
required.
To silence the output at any time:
- Clear the gate bit:
sid_write(6, waveform & 0xFE, voice) - Set frequency to 0:
sid_write(0, 0, v) - Set the test bit:
sid_write(6, 0x08, v)
| Parameter | Value |
|---|---|
| Target technology | IHP SG13G2 130nm SiGe BiCMOS |
| Tile size | Tiny Tapeout 1x2 |
| Core supply (VDD) | 1.2V |
| I/O supply (VDDIO) | 3.3V |
| System clock | 24 MHz (41.7 ns period) |
| Voice pipeline clock | 6 MHz (÷4 clock enable) |
| Pipeline | Mod-6 slot counter (1 MHz effective per voice) |
| Core utilization | ~77% (with CTS clock tree) |
| Voice count | 3 (time-multiplexed) |
| Frequency resolution | ~0.06 Hz (16-bit freq, 24-bit acc, 1 MHz effective) |
| Envelope depth | 8-bit (256 levels, exponential decay) |
| ADSR rate counters | 15-bit per-voice (16 SID-accurate periods, 5-bit exponential counter) |
| Noise generator | 15-bit LFSR, accumulator-clocked from voice 0 |
| Voice output | 8-bit (8×8 waveform×envelope product, upper byte) |
| PWM output frequency | ~94.1 kHz |
| Audio bandwidth | Up to ~47 kHz (Nyquist) |
| Write interface speed | 1 register per 125 ns (3 clocks) |