SDK

ES8311 audio codec

The one chip behind the microphone and the speaker of a Kode Dot: wiring, the 48 kHz format, the speaker's volume cap, the pop-free sound sequence.

What it is

A Kode Dot hears and speaks through one chip. The ES8311 is a mono codec: it turns the signal of the microphone into numbers, and numbers into a voltage for the speaker. The microphone is an analog MEMS capsule, the ZTS6211E. The speaker is an 8 Ω YN1811-0801WD, driven through an NS4150B class-D amplifier that sits between the codec and the speaker.

Because the microphone and the speaker share one codec, they share one I2C address, one I2S bus and one clock. The board has one audio device, and one owner, AO_Audio, serves both directions. Every sound is 48 kHz, 16-bit, mono. There is no other format and no resampler.

The driver kdrv_es8311 wraps Espressif's esp_codec_dev and adds what the board needs: an identity check, the pop-free sound sequence, a cap on the speaker's level, a field table for the console, and the helpers that play a tone and detect it again for the production test.

How it is connected

flowchart LR
subgraph board["On the Kode Dot"]
  ao["AO_Audio"] -- "kdrv_es8311" --> p4["ESP32-P4"]
  p4 -- "I2C 0x18" --> codec["ES8311"]
  p4 -- "I2S: MCLK, SCLK, LRCK,<br/>DSDIN, ASDOUT" --> codec
  mic["ZTS6211E<br/>microphone"] -- "MIC1P, MIC1N" --> codec
  codec -- "DAC" --> amp["NS4150B"]
  amp --> spk["YN1811 speaker, J9"]
  c5["ESP32-C5 IO4"] -- "PA_CTRL, enable" --> amp
end
class ao ao
class p4,codec,amp,c5 chip
The audio path on the Kode Dot

The codec answers at 0x18 (KBSP_I2C_ADDR_CODEC) on the shared I2C bus for control, and audio moves over I2S0 on five pins:

SignalP4 pinDirection
MCLKGPIO36P4 to codec
SCLKGPIO34P4 to codec
LRCKGPIO37P4 to codec
DSDINGPIO38P4 to codec
ASDOUTGPIO33Codec to P4

The DVT pin swap

This is the production map. The Kode Dot DVT schematic has MCLK and ASDOUT swapped, which would let the codec's ASDOUT drive GPIO36, a boot strap of the P4.

A DVT board needs the rework KD-DVT-002 to make the swap. A DVT board without it sends MCLK to the pin where the codec drives ASDOUT, so the codec gets no clock and kodeOS audio does nothing. TBD: which board revisions leave the factory with the rework fitted.

The amplifier enable

The amplifier's enable is a pin of the ESP32-C5. PA_CTRL is C5 IO4 (KBSP_C5_IO_PA_CTRL). The C5's firmware sets it high when it boots, and R16 (10 kΩ) pulls it down while the C5 is in reset. Four consequences:

The owner and its worker

AO_Audio owns the codec (KBSP_RES_AUDIO) at priority 20. Everything that moves samples blocks, so none of it runs in the AO's step. The AO owns a worker task with an 8 KB stack in PSRAM (AUDIO_WORKER_STACK) at the same priority, and runs kdrv_es8311_run() on it as one job at a time.

The AO decides when to open, close, play or record; the worker runs the job and posts the result back. The driver keeps no task, queue or callback. The AO holds one activity slot, not a queue: a request that arrives while another is in flight is refused with a log line.

Settings live with the field table and survive a close, so a beep after one comes back at the volume the last caller set. They are stored under the vol (default 100), mic_db (default 24) and mute keys.

Format and clock

Why 48 kHz and MCLK at 256 times the rate. Both are measured. At 16 kHz the output carries an image at 15 kHz at -3.8 dBc, plainly audible, and two thirds of the output's RMS is out of band. At 48 kHz every image is at or below -52 dBc and the response is flat within 0.3 dB. The datasheet specifies the reconstruction filter only at 48 kHz, and a different board with the same two parts behaves the same way.

MCLK is 256 x 48 kHz = 12.288 MHz (KDRV_ES8311_MCLK_MULT). The codec library restarts the I2S bus at every open with its own multiple, so the driver passes 256 in the I2S channel, the open call and the chip's divider.

Any mismatch desynchronises the chip's divider from the real clock and puts a steady false tone, at an eighteenth of the sample rate, into every recording. For many rate-times-384 products the chip driver has no coefficient row at all, and the open then fails without sound or error.

The I2S bus carries two slots for a mono chip. The codec library turns a request for one channel into two. Playback copies each mono frame into both slots. Capture keeps the left slot and discards the right one, which is safe only because the codec's internal loopback is off.

The speaker's volume cap

The speaker has a volume cap, and volume 100 is under full scale. Volume 100 sets the DAC to -11.0 dB (KDRV_ES8311_OUT_DB_X10_MAX is -110), register 0x32 = 0xA9, and nothing reaches past it.

The speaker may dissipate 0.5 W at the valley of its impedance (5.85 Ω), which is 1.71 Vrms. Full scale at the codec is 1.9 Vrms, and the NS4150B multiplies it by its feedback resistor (220 to 250 kΩ measured, 240 kΩ in its datasheet) over R2/R10.

With R2/R10 as drawn, 120 kΩ, full scale reaches 3.96 Vrms and the cap would need to be -7.3 dB. The driver uses -11.0 dB, a whole register step more, because fitted resistors have measured below their drawn value before. On a 270 kΩ rework full scale gives 1.69 Vrms and 0.49 W, and the cap would go to zero. Only a new measurement moves the cap: R2/R10 as fitted, then a 3 kHz tone at volume 100 measured at the speaker terminals against 1.71 Vrms.

The cap is enforced where every path passes. The driver installs its own volume curve in the codec library at bring-up, with the span of 50 dB the library uses and its top moved down to the cap, so callers still see 0 to 100.

Two raw ways round it are closed: register 0x32 and the dynamic range compressor (drc_en, register 0x34), which levels toward -6 dBFS, above the cap. Both are read only in the field table.

The library also assumes the amplifier runs from 5 V, while the NS4150B here runs from the system rail. The driver tells it the two supplies are equal so it adds no digital gain; at the library's default it added 3.6 dB, volume 100 clipped at the converter and the third harmonic read -5 dBc.

Sounds without a pop

A sound starts and ends without a pop. The amplifier is always on, so anything the converter does reaches the speaker. Two things would click: the analog power-down at a close and a step in the signal when a sound ends. The driver never closes the device between sounds (the owner closes it for a round of the board's rest) and mutes the converter around every sound, with a hold of its own that is separate from the out_mute a caller sets. One sound runs these steps:

  1. do_open() sets the hold before it applies settings, so the device opens silent.
  2. The first job after an open writes settle blocks of silence, 6 blocks of 256 frames (32 ms), while the DMA rings fill, the amplifier starts and the DAC's volume ramp finishes.
  3. When its first real block is in the DMA ring, the job calls speak_up(), which un-mutes the converter. The ramp needs samples to flow, so it runs under the silence ahead of the sound.
  4. The sound plays.
  5. drain_silence() writes 7 blocks of zeros (about 37 ms): the whole 6 x 240 frame DMA ring, rounded up, plus one block for the buffer a write was filling. esp_codec_dev_write() returns when bytes are in the ring, not when they have left it.
  6. sound_end() mutes the converter and waits 12 ms (ES_MUTE_SETTLE_MS) for the ramp. The order matters: muting before the drain would ramp the volume down over about 30 ms of audio nobody has heard yet.

The end of a sound costs about 49 ms on the worker. Because of the drain, a play's AUDIO_DONE_SIG means the speaker has heard the sound, which matters to an app that plays a last sound and then asks AO_LowPower for OFF with kode_power_off().

Only two events close the device: a round of the board's rest, and ship mode. For ship mode AO_Audio calls kdrv_es8311_hush() in the step before the BATFET opens and asks the worker to close (sdk/kaos/audio). A _Static_assert checks that the drain covers the DMA ring, since a ring that grew in a later ESP-IDF would clip the tail of every sound without a sign of it.

Rest, loopback and gain

The device rests until the first sound. kdrv_es8311_init() makes no sound and enables nothing. It builds the I2C control interface, reads and checks the identity (the codec library does not), builds both I2S channels without enabling them, assembles the codec object, installs the volume curve, and enables then disables the codec object once so the chip goes to its lowest power state. It does not call esp_codec_dev_open() because that also runs the data interface and disables both channels. A board with no codec logs one line and every later call returns KODE_DENIED; absence is a state, not an error.

The internal loopback stays off. The chip can route its DAC output into its own ADC. The codec library turns that on unless told not to, and an acoustic test would then pass with a dead microphone and a dead speaker, since the signal never leaves the chip. The driver turns it off. Register 0x44 reads 0x08 with the loopback off and 0x58 with it on, and the production row reads it back before it measures anything.

The microphone has two gains in two registers. Register 0x16 is the ADC scale, 0 to 7 in 6 dB steps, which is what the codec library's gain call writes. Register 0x14 low nibble is the true analog front-end gain, and 0x14 bit 6 selects the digital microphone and must read 0. The field table shows 0x16 decoded as adc_scale_db and 0x14 raw as pga.

The microphone hears the speaker. The board is small and the capsule picks up the speaker strongly. Full-duplex use needs echo handling the SDK does not have. The production row turns it to advantage: it proves the speaker and the microphone with no operator and no equipment, by playing a tone and looking for it.

Specifications

The board

ThingValue
CodecES8311, mono, I2C 0x18. 0xFD reads 0x83 and 0xFE reads 0x11 (KDRV_ES8311_ID1, KDRV_ES8311_ID2)
Format48 kHz, 16-bit, mono (KDRV_ES8311_RATE_HZ, KDRV_ES8311_BITS)
MCLK12.288 MHz, 256 x 48 kHz
MicrophoneZTS6211E analog MEMS, MIC1P and MIC1N, analog gain 0 to 42 dB in 6 dB steps
AmplifierNS4150B, mono class-D, enable on C5 IO4
SpeakerYN1811-0801WD, 8 Ω, resonance 1.3 kHz, J9
Output filter cornerAbout 390 Hz with the specified 1.5 nF capacitors (calculated); 607 Hz measured with 1 nF. Below 300 Hz the speaker is not characterised. TBD: a measurement on a unit with 1.5 nF fitted
Volume 100-11.0 dB, register 0x32 = 0xA9
Volume register lawLevel = register / 2 - 95.5 dB over the whole byte, so 0xBF is 0 dB
Tone frequenciesKTONE_HZ_MIN 400 Hz to KTONE_HZ_MAX 3400 Hz; a request is snapped to one of eight, 1020 to 1957 Hz
Buffer30 s (KDRV_ES8311_BUF_SECONDS), 96 KB a second, in PSRAM
Stream ring200 ms of frames (KDRV_ES8311_STREAM_FRAMES), PSRAM
Audio DMA6 descriptors of 240 frames, 256-frame blocks (5.33 ms)
MeasuredMusic at full volume about 310 mW peak and 35 mW average on a 270 kΩ build; a 1 kHz full-scale tone 352 mW, third harmonic -58 dBc

The field table

audio prints and sets these. Register fields are masked read-modify-writes, read back on their own bits. A field marked RO is refused by kdrv_es8311_set().

FieldWhereWhat it is
present, open, busydriver stateRO. The chip answered; analog up and channels enabled; a job is with the worker (the owner answers this one)
rate, bits, mclk_multdriver stateRO. 48000, 16, 256
voldriver state0 to 100, applied at the next open. 100 is the speaker's cap
mic_dbdriver state0 to 42, rounded down to a 6 dB step, applied at the next open
out_mute, in_mutedriver stateMute the DAC, mute the ADC
dac_vol_db0x32RO. register / 2 - 95.5 in tenths of a dB. Volume 100 reads -110
adc_vol_db0x17RO. Same law on the capture side
adc_scale_db0x16 [2:0]The ADC scale, 0 to 42 dB in 6 dB steps
pga0x14 [3:0]The true analog gain, raw
dmic0x14 [6]RO. Must read 0, the microphone is analog
hpsw0x13 [4]Headphone switch. The codec library forces 1 at open
dac2adc0x44The internal loopback. Must read 0x08
drc_en0x34 [7]RO. Compressor, off
ramprate0x37 [7:4]DAC volume ramp rate
reg00 to reg0a0x00 to 0x0ARaw. Reset, the clock manager 0x01 to 0x08, serial port in 0x09 and out 0x0A
reg0d, reg0e, reg12, reg13, reg140x0D, 0x0E, 0x12 to 0x14Raw. Analog power up and down, DAC enable, the headphone switch, microphone select and PGA
reg16, reg170x16, 0x17Raw. ADC scale and ADC volume
reg31, reg37, reg44, reg450x31, 0x37, 0x44, 0x45Raw. DAC mute (bits 6 and 5), ramp rate, loopback, general purpose
reg32, reg340x32, 0x34Raw, RO. DAC volume and compressor, closed for the speaker's cap
chd1, chd2, chver0xFD, 0xFE, 0xFFRO. Identity 0x83, 0x11 and the version

Measured on a Kode Dot straight after boot and before any sound: reg0d reads 252 and reg01 reads 0, the lowest power state the register map has.

The driver

CallWhat it does
kdrv_es8311_init()The bring-up above. Makes no sound. KODE_DENIED on a second call
kdrv_es8311_run(req)Runs one job to its end, blocking. For the owner's worker, one at a time. Jobs: OPEN, CLOSE, TONE, FLOOR, PROBE, PLAY, RECORD, STREAM
kdrv_es8311_result(&out)The last job's result: the window, the frames moved, the tone bin. Read after the job returns and before the next
kdrv_es8311_info(&out), _present(), _is_open()Driver state and constants. No bus, safe from any context
kdrv_es8311_reg_read(reg, &val), kdrv_es8311_reg_write(reg, val)One raw register through the control interface, in every state. KODE_DENIED when the chip is absent, KODE_IO_ERROR when the bus fails. One I2C transaction, safe from an Active Object
kdrv_es8311_get(f, &v), kdrv_es8311_set(f, v), kdrv_es8311_field_info(f), kdrv_es8311_field_by_name(), kdrv_es8311_dump()The field table
kdrv_es8311_buf(&frames), kdrv_es8311_buf_file(frames, &bytes)The owner's PSRAM buffer, allocated on first use and never freed, with room in front for a WAV header. For AO_Audio's thread only
kdrv_es8311_stream_reset(), _stream_write(frames, n, ...), _stream_end()The stream's ring: one writer, one reader, no lock. Stereo is mixed down to mono on the way in
kdrv_es8311_apply_settings(), kdrv_es8311_hush()Push volume, gain and mutes at an open device; silence the converter now with one masked write

The worker jobs, in detail:

JobWhat it does
TONEEmits one of the eight tones; captures nothing. Settle blocks first after a fresh open, then the drain
FLOORCaptures one 4096-frame window while emitting silence, with the device open and the converter writing zeros, so the amplifier is in the same state as during a probe
PROBEEmits a tone and captures a window, then drains. The tail blocks are still the tone, so a slip of one block cannot cut what the detector measures
PLAY, RECORDMono 16-bit frames from or into a buffer the caller keeps for the job's length
STREAMPlays the stream ring a block at a time while the stream is open, silence when it is dry, because a gap in the controller's data is a click. After kdrv_es8311_stream_end() it plays what is left, drains and answers

The helpers

ModuleWhat it does
ktoneEight tone frequencies, each an exact multiple of 48000 / 4096 Hz so a tone lands in one bin and leaks into no other. The span is 1957 / 1020, under an octave, so no second harmonic of one candidate reaches another. The phase is an integer index modulo the window, never a float. The detector is a Goertzel over a 4096-frame window (85.3 ms) at the tone's bin
ktone limitsTone amplitude 8192 of 32768 (-12.04 dBFS). Clipping starts at 32700. A bin must stand 12.0 dB over the loudest other bin and 20.0 dB over the floor at the same bin. The floor refusal ceiling is -20.0 dBFS. The margins are first values; TBD: bay recordings to settle them
kgoertzel.hThe one-bin kernel, shared with kimu_energy in sdk/kdrv/qmi8658a
kwavkwav_build() writes the canonical 44-byte header for 48 kHz, 16-bit, mono. kwav_parse() walks chunks properly, since real files carry LIST and fact chunks before the data, and says which property it refused: not RIFF, truncated, no format, no data, not PCM, wrong rate, wrong width, wrong channels
kringThe stream's lock-free ring of mono frames. Each index has one writer and counts modulo twice the capacity, so a full ring never looks empty

The files

FileWhat it does
include/kdrv_es8311.h, src/kdrv_es8311.cThe codec: bring-up, the volume curve, the jobs, the windows, the sound sequence, the buffer, raw registers
include/kes_fields.h, src/kes_fields.cThe field table and the settings an open applies
include/ktone.h, src/ktone.cThe tone generator and the detector
include/kgoertzel.hThe Goertzel kernel, header only
include/kwav.h, src/kwav.cWAV header writer and parser
include/kring.h, src/kring.cThe stream ring

Gotchas

Example code

Play one beep and read the audio path back. You hear a half-second tone at half volume, and the serial log shows the volume, the microphone gain and whether the device is open.

/*
 * beep.c - 2026-10-28 - from Kode with love
 *
 * Sets the speaker of the Kode Dot to half volume, plays a 1020 Hz tone for
 * half a second, then reads the audio path back and logs it. You hear one
 * beep and the log shows "vol=50 mic_db=24 open=1".
 */
#include "kode.h"
 
/* Beep once, then report what the codec is set to. */
void app_main(void)
{
    kdrv_es8311_info_t info;
 
    kode_init();
    kode_audio_volume(50);
    kode_audio_tone(1020, 500, 30);
    kode_delay_ms(1000);
 
    if (kode_audio_info(&info) == KODE_OK) {
        KLOG_I("beep", "vol=%u mic_db=%u open=%d", (unsigned)info.volume,
               (unsigned)info.mic_db, (int)info.open);
    }
    while (1) {
        kode_delay_ms(1000);
    }
}

Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.

In the SDK