Additional documents for spinalSynth:
- Introduction
- High-Level Architecture
- Master Clock
- Timing Generators
- Communication Protocol
- DDS Oscillator Architecture
- Waveform Generators
- Oversampling and Decimation
- Audio Sample Format
- I²S Output Interface
- Numeric Formats
- Module Hierarchy
- Confirmed System Parameters
This project implements a compact digital audio synthsizer in SpinalHDL.
The oscillator is based on Direct Digital Synthesis (DDS) using a phase accumulator architecture. The oscillator generates audio waveforms internally using an oversampled DDS engine and outputs stereo audio using the I²S protocol.
The project is intentionally designed to remain:
- compact
- deterministic
- FPGA-friendly
- easy to understand
- easy to simulate
- easy to extend later
- 24-bit DDS phase accumulator
- 480 kHz internal DDS update rate
- 48 kHz stereo audio output
- 16-bit signed audio samples
- Stereo I²S output interface
- Oversampled waveform generation
- Single synchronous 24 MHz clock domain
- Clock-enable based timing architecture
- FPGA-friendly implementation
The project was developed with the heavy usage of AI tools. All the specification documents were created via talking sessions to chatGPT, most of them in voice chat on the mobile with follow ups on the keyboard.
Impementation, debugging and testing was done in VSCode with the free Gemini Extension. Later on, i switched the IDE to Antigravity and started paying for Gemini Access (Gemini Pro, Gemini Flash 3.5)
External Interface (24MHz Clk, Reset, UART Rx)
↓
Synth (Unified Top Module)
↓
┌───────────────────────────────────────────────┐
│ UART Subsystem (synth.uart) │
│ [Uart] │
│ └─ [UartRx] → [Decoder] → [RegisterBank] │
└───────────────┬───────────────────────────────┘
│ config: OscillatorConfig
↓
┌───────────────────────────────────────────────┐
│ Synthesis Engine │
│ [TimingGenerator] (synth.timing) │
│ ↓ │
│ [Oscillator] (synth.oscillator) │
│ ↓ │
│ [Decimator] (synth.output) │
└───────────────┬───────────────────────────────┘
│ (48kHz Samples)
↓
┌───────────────────────────────────────────────┐
│ I2S Transmitter (synth.output) │
│ [BCLK] [LRCLK] [SDATA] │
└───────────────────────────────────────────────┘
↓
Stereo Digital Audio
The complete design operates from a single synchronous master clock.
| Parameter | Value |
|---|---|
| Master clock frequency | 24 MHz |
No internally-generated FPGA clocks shall be used.
All submodules shall operate synchronously from the 24 MHz master clock using clock-enable tick signals.
The TimingGenerator module shall generate two independent clock-enable tick signals.
| Parameter | Value |
|---|---|
| Frequency | 480 kHz |
| Divider | 24 MHz / 50 |
| Purpose | Drive DDS phase accumulator |
The phase accumulator and waveform generation logic shall update on this tick.
| Parameter | Value |
|---|---|
| Frequency | 48 kHz |
| Divider | 24 MHz / 500 |
| Purpose | Generate output audio samples |
The decimator and output audio sample registers shall update on this tick.
The system is controlled via a standard UART interface. An external controller (such as a PC or Microcontroller) sends 3-byte packets to update the internal state of the synthesizer.
| Parameter | Value |
|---|---|
| Baud Rate | 115,200 |
| Data Bits | 8 |
| Parity | None |
| Stop Bits | 1 |
The UartProtocolDecoder expects a 3-byte sequence for every command:
- Command Byte: One byte for the command. (i.e. 0x01 for "write to register")
- Address Byte: Specifies which register to write to.
- Data Byte: The value to be written.
Right now there is only one command.
| Command | Name | Adress Byte | Data Byte |
|---|---|---|---|
0x01 |
WriteRegister |
From Register Map |
1 Byte |
| Address | Register Name | Description | Width |
|---|---|---|---|
0x00 |
FREQ_LOW |
Frequency Word Bits [7:0] | 8 bit |
0x01 |
FREQ_MID |
Frequency Word Bits [15:8] | 8 bit |
0x02 |
FREQ_HIGH |
Frequency Word Bits [23:16] | 8 bit |
0x03 |
WAVE_SEL |
0:Saw, 1:Square, 2:PWM, 3:Triangle, 4:Noise | 3 bit |
0x04 |
PWM_WIDTH |
Duty cycle for PWM waveform | 8 bit |
0x05 |
VOLUME |
Master output volume (Reserved) | 8 bit |
The oscillator shall use a classic DDS architecture.
At every phaseTick:
phase := phase + freqWord
The phase accumulator shall wrap naturally on overflow.
| Parameter | Value |
|---|---|
| Width | 24 bit |
| Type | Unsigned |
Example:
val phase = Reg(UInt(24 bits))| Parameter | Value |
|---|---|
| Width | 24 bit |
| Type | Unsigned |
The frequency word controls oscillator frequency.
Important
Atomic Multi-Byte Update Protocol:
Since the 24-bit frequency word is spread across three 8-bit registers (FREQ_LOW, FREQ_MID, and FREQ_HIGH), updates are buffered atomically to prevent audio glitching:
- Writing to
FREQ_LOW(0x00) stages the lower 8 bits in a temporary shadow register. - Writing to
FREQ_MID(0x01) stages the middle 8 bits in a temporary shadow register. - Writing to
FREQ_HIGH(0x02) commits the entire 24-bit frequency word (High ## MidShadow ## LowShadow) simultaneously to the active synthesis registers in a single clock cycle.
Always write registers in order (FREQ_LOW → FREQ_MID → FREQ_HIGH) to ensure consistent updates.
The DDS frequency equation is:
f = freqWord × updateRate / 2^24
Where:
| Parameter | Value |
|---|---|
| updateRate | 480 kHz |
| phase width | 24 bit |
The minimum frequency step is:
480000 / 16777216 ≈ 0.0286 Hz
The oscillator shall support the following waveforms.
Generated by mapping the upper phase bits to audio amplitude.
Example:
sample = phase[23:8]
Generated using the phase accumulator MSB.
Example:
if phase[23] == 1:
+MAX
else:
-MAX
Generated using a comparator between phase and pulseWidth.
The 8-bit PWM width value shall be expanded internally before comparison with the 24-bit phase accumulator.
The expansion shall be implemented by shifting the PWM value 16 bits to the left to match 24 bits width.
Example:
if phase < pulseWidth:
+MAX
else:
-MAX
| Parameter | Value |
|---|---|
| Width | 8 bit |
| Type | Unsigned |
Generated using reflected phase arithmetic.
To generate the triangle wave, we utilize a "reflected phase" technique based on the 24-bit phase accumulator. The Most Significant Bit (MSB) of the phase acts as a direction indicator: during the first half-cycle (MSB=0), the lower 23 bits create a linear rising ramp, whereas during the second half-cycle (MSB=1), those bits are bitwise inverted to produce a symmetrical falling ramp. This 23-bit result is then right-shifted by 7 bits to normalize it to a 16-bit range and cast to a signed integer (SInt), resulting in a full-swing bipolar waveform that transitions smoothly between peak amplitudes.
Noise generation shall use an LFSR-based pseudo-random generator.
| Parameter | Value |
|---|---|
| Generator type | Fibonacci LFSR |
| Width | 23 bit |
| Polynomial | x^23 + x^18 + 1 |
| Feedback taps | bit 22 XOR bit 17 |
| Update timing | phaseTick |
| Output type | 16-bit signed |
| Reset seed | nonzero fixed value |
An LFSR must never be initialized to zero, as it would stay stuck. We will plan to use a fixed non-zero seed.
The 16-bit signed audio is extracted just by taking the upper 16 bits of the LFSR.
The DDS oscillator shall internally operate at:
480 kHz
while the final audio output sample rate shall be:
48 kHz
This creates an oversampling ratio of:
10×
The implementation shall use simple zero-order decimation.
Every 10th DDS sample shall be captured as the output audio sample.
No interpolation or low-pass filtering shall initially be used.
Example:
if(sampleTick) {
audioSample := oscSample
}
| Parameter | Value |
|---|---|
| Audio width | 16 bit |
| Sample format | Signed |
| Sample rate | 48 kHz |
Example:
val sample = SInt(16 bits)The oscillator is currently mono internally.
The mono signal shall be duplicated to both stereo output channels.
Example:
leftSample = sample
rightSample = sample
The output interface shall use the I²S protocol.
The I²S transmitter shall operate directly from the 24 MHz master clock. The transmitter shall use a cycle-timed state machine architecture.
The required I²S bit clock frequency BCLK is:
48,000 × 2 × 16 = 1.536 MHz
The relationship to the 24 MHz master clock is:
24 MHz / 1.536 MHz = 15.625
Therefore no integer divider exists.
The serializer shall therefore alternate between:
- 15 master-clock cycles
- 16 master-clock cycles
between serialized bit transfers.
The serializer shall use the following repeating 8-step timing subpattern:
16,16,15,16,16,15,16,15
This subpattern contains:
| Interval | Count |
|---|---|
| 16-cycle intervals | 5 |
| 15-cycle intervals | 3 |
Total clocks:
16+16+15+16+16+15+16+15 = 125
Average clocks per bit:
125 / 8 = 15.625
This exactly matches the required average I²S bit timing.
One stereo I²S frame contains:
32 serial bits
because:
- 16 left-channel bits
- 16 right-channel bits
Since:
32 = 4 × 8
the 8-step timing subpattern repeats exactly four times during one complete stereo frame.
Full frame timing:
[16,16,15,16,16,15,16,15] × 4
Total master-clock cycles per stereo frame:
4 × 125 = 500
Stereo frame rate:
24 MHz / 500 = 48 kHz
This produces the exact required audio sample rate.
The serializer shall internally contain:
| Register | Purpose |
|---|---|
| cycleCounter | Current interval countdown |
| patternIndex | Selects 15/16-cycle interval |
| bitCounter | Counts serialized bits |
| shiftRegister | Serialized audio data |
The pattern index shall cycle continuously:
0 → 1 → 2 → ... → 7 → 0
The bit counter shall cycle:
0 → 1 → 2 → ... → 31 → 0
The bit counter determines:
- LRCLK state
- stereo frame boundaries
- sample reload timing
| Parameter | Value |
|---|---|
| Channels | 2 |
| Audio width | 16 bit |
| Sample rate | 48 kHz |
| Bit clock | 1.536 MHz |
| Signal | Description |
|---|---|
| i2s_bclk | Bit clock |
| i2s_lrclk | Left/right word select |
| i2s_sdata | Serial audio data |
The I²S serializer shall:
- shift audio data
- serialize stereo audio samples
- generate LRCLK framing
- output signed 16-bit audio samples
The exact serializer state machine behavior is not yet specified.
| Signal | Type |
|---|---|
| phase | UInt(24 bits) |
| freqWord | UInt(24 bits) |
| pulseWidth | UInt(8 bits) |
| audioSample | SInt(16 bits) |
The design shall use fixed-point arithmetic throughout.
| Bundle | Subfields | Type |
|---|---|---|
| RegisterWrite | address data |
UInt(8 bits) Bits(8 bits) |
| OscillatorConfig | freqWord waveSelect pwmWidth volume |
UInt(24 bits) UInt(3 bits) UInt(8 bits) UInt(8 bits) |
| Waveforms | saw square pwm tri |
SInt(16 bits) SInt(16 bits) SInt(16 bits) SInt(16 bits) |
Synth
├── uart/ (Control Subsystem)
│ └── Uart (Subsystem Wrapper)
│ ├── UartRx
│ ├── UartProtocolDecoder
│ └── RegisterBank
├── common/ (Shared System Types)
│ └── Types
│
├── timing/ (System Control)
│ └── TimingGenerator
│
├── oscillator/ (Core Engine)
│ └── Oscillator
│ ├── Accumulator
│ ├── Generators
│ ├── Noise
│ └── Mux
│
├── mixing/ (Audio Processing)
│ └── Attenuator (Volume Control)
│
└── output/ (Output Pipeline)
├── Decimator
└── I2STransmitter
| Parameter | Value |
|---|---|
| HDL | SpinalHDL |
| Master clock | 24 MHz |
| DDS phase width | 24 bit |
| DDS update rate | 480 kHz |
| Audio sample rate | 48 kHz |
| Audio width | 16 bit signed |
| I²S output | Stereo |
| I²S bit clock | 1.536 MHz |
| Oversampling ratio | 10× |
| Decimation method | Every 10th sample |
| Arithmetic | Fixed-point |
| Waveforms | Saw, Square, PWM, Triangle, Noise |
| Clocking strategy | Single synchronous clock domain |