انتقل إلى المحتوى الرئيسي

I2S, DMA, and Volume

Digital audio, from zero

Sound is a pressure wave; digital audio is that wave sampled 44,100 times per second, each sample a 16-bit signed integer per channel. Playing audio means delivering those samples to a DAC at exactly that rate: too slow and the output stutters or drops out (an underrun), too fast is impossible because the DAC sets the pace.

I2S (Inter-IC Sound) is the three-wire serial protocol that moves samples to audio hardware: a bit clock (BCLK), a word-select line marking left/right (LRC or WS), and the data line (DIN). The ESP32-S3 has an I2S peripheral that shifts samples out autonomously; firmware never bit-bangs audio.

DMA (direct memory access) is what makes it hands-off. The I2S peripheral is given a chain of memory descriptors; it streams them to the wire and interrupts only when a descriptor empties. The CPU's whole job is refilling descriptors faster than the hardware drains them.

Safi's output stage

The MAX98357A on the other end of the three wires is a DAC and 3 W class-D amplifier in one chip: I2S in, speaker out, no configuration registers at all (gain is a pin strap). It was chosen for exactly that bluntness; there is no I2C init sequence to get wrong.

Driver setup (components/audio_player/src/i2s_output.c) uses ESP-IDF's modern channel API:

chan_cfg.dma_desc_num = 8; // descriptors in the chain
chan_cfg.dma_frame_num = 512; // frames per descriptor
chan_cfg.auto_clear = true; // underrun plays silence, not stale data

That is 8 x 512 frames x 4 bytes = 16 KB of DMA buffering, about 93 ms of audio. The sizing is a straight latency/safety trade: enough depth that a scheduling hiccup of tens of milliseconds is inaudible, small enough that pause and volume feel instant (both act on the next write, so worst-case response is the buffered 93 ms).

auto_clear deserves its sentence: on underrun the hardware would otherwise loop whatever bytes it last saw, which sounds like a machine gun. Clearing to silence turns a failure into a quiet gap.

Blocking writes are the clock

The single most important line in the audio engine is the write:

i2s_channel_write(handle, pcm, size, &written, timeout);

When the DMA chain is full, this call blocks until space frees, and space frees at exactly the sample rate. That property, verified on the bench at 176,621 bytes/s sustained (44,100 x 2 x 2 = 176,400, plus measurement jitter), is what paces the whole pipeline; no other component contains timing. The decode loop upstream simply runs until this call stops it.

Why a 200 ms timeout instead of forever: a wedged I2S driver should surface as a counted underrun and a log line, not as a silently frozen audio task. A partial write increments the underrun counter that flows to the dashboard as telemetry.

Sample rate changes

Files and stations are usually 44.1 kHz but not always. On format detection, if the rate differs from the current one, the driver disables the channel, reconfigures the clock, and re-enables (i2s_set_sample_rate). It happens between frames, before the first PCM of the new source, so nothing audible is lost.

Software volume

The MAX98357A has no volume register, so volume is applied to the samples themselves, in the audio task, just before the I2S write.

The curve. Human loudness perception is logarithmic: half the amplitude does not sound half as loud. A linear 0-100 knob wastes its top half on imperceptible changes and crams everything audible into the bottom. Safi maps the knob exponentially across roughly 30 dB:

gain = 10 ^ ((volume/100 - 1) * 1.5) // 100 -> 1.0, 50 -> 0.178, 0 -> mute

The arithmetic. Scaling every sample with floating point would burn cycles the decoder needs, so the gain is precomputed once per volume change into Q15 fixed point (gain x 32768, an integer), and the per-sample hot loop is a 32-bit multiply and a shift:

out = (int16_t)(((int32_t)sample * gain_q15) >> 15);

Q15 means "15 fractional bits": unity gain is 32768, and the shift divides it back out. One multiply, one shift, no float unit contention, numerically exact for attenuation. Volume 0 short-circuits to memset(0) and 100 skips the loop entirely.

The knob feels linear, the code costs nothing, and the DAC never knows anything happened: that is the whole trick, and it is the same trick every digital mixer uses.