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 codec answers at 0x18 (KBSP_I2C_ADDR_CODEC) on the shared I2C bus for control, and audio moves over I2S0 on five pins:
| Signal | P4 pin | Direction |
|---|---|---|
| MCLK | GPIO36 | P4 to codec |
| SCLK | GPIO34 | P4 to codec |
| LRCK | GPIO37 | P4 to codec |
| DSDIN | GPIO38 | P4 to codec |
| ASDOUT | GPIO33 | Codec 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:
- Audio works while the radio link between the P4 and the C5 is down. The amplifier needs the C5 running, not linked.
- In a firmware built with
KODEOS_C5_HOLD, which keeps the C5 in reset until something needs it, AO_Audio asks AO_Radio for the C5 before the first sound of a boot and waitsKODE_C5_BOOT_MS(600 ms). An app built without the option has the amplifier on from boot. - Starting the radio link resets the C5, and the reset briefly cuts the amplifier: the speaker pops. The production audio row runs after the
c5row for this reason. - A C5 running any other image leaves the amplifier off. The codec plays, no sound comes out, and every call still returns
KODE_OK, because the P4 cannot see the pin. Seesdk/kdrv/c5.
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:
do_open()sets the hold before it applies settings, so the device opens silent.- 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.
- 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. - The sound plays.
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.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
| Thing | Value |
|---|---|
| Codec | ES8311, mono, I2C 0x18. 0xFD reads 0x83 and 0xFE reads 0x11 (KDRV_ES8311_ID1, KDRV_ES8311_ID2) |
| Format | 48 kHz, 16-bit, mono (KDRV_ES8311_RATE_HZ, KDRV_ES8311_BITS) |
| MCLK | 12.288 MHz, 256 x 48 kHz |
| Microphone | ZTS6211E analog MEMS, MIC1P and MIC1N, analog gain 0 to 42 dB in 6 dB steps |
| Amplifier | NS4150B, mono class-D, enable on C5 IO4 |
| Speaker | YN1811-0801WD, 8 Ω, resonance 1.3 kHz, J9 |
| Output filter corner | About 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 law | Level = register / 2 - 95.5 dB over the whole byte, so 0xBF is 0 dB |
| Tone frequencies | KTONE_HZ_MIN 400 Hz to KTONE_HZ_MAX 3400 Hz; a request is snapped to one of eight, 1020 to 1957 Hz |
| Buffer | 30 s (KDRV_ES8311_BUF_SECONDS), 96 KB a second, in PSRAM |
| Stream ring | 200 ms of frames (KDRV_ES8311_STREAM_FRAMES), PSRAM |
| Audio DMA | 6 descriptors of 240 frames, 256-frame blocks (5.33 ms) |
| Measured | Music 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().
| Field | Where | What it is |
|---|---|---|
present, open, busy | driver state | RO. The chip answered; analog up and channels enabled; a job is with the worker (the owner answers this one) |
rate, bits, mclk_mult | driver state | RO. 48000, 16, 256 |
vol | driver state | 0 to 100, applied at the next open. 100 is the speaker's cap |
mic_db | driver state | 0 to 42, rounded down to a 6 dB step, applied at the next open |
out_mute, in_mute | driver state | Mute the DAC, mute the ADC |
dac_vol_db | 0x32 | RO. register / 2 - 95.5 in tenths of a dB. Volume 100 reads -110 |
adc_vol_db | 0x17 | RO. Same law on the capture side |
adc_scale_db | 0x16 [2:0] | The ADC scale, 0 to 42 dB in 6 dB steps |
pga | 0x14 [3:0] | The true analog gain, raw |
dmic | 0x14 [6] | RO. Must read 0, the microphone is analog |
hpsw | 0x13 [4] | Headphone switch. The codec library forces 1 at open |
dac2adc | 0x44 | The internal loopback. Must read 0x08 |
drc_en | 0x34 [7] | RO. Compressor, off |
ramprate | 0x37 [7:4] | DAC volume ramp rate |
reg00 to reg0a | 0x00 to 0x0A | Raw. Reset, the clock manager 0x01 to 0x08, serial port in 0x09 and out 0x0A |
reg0d, reg0e, reg12, reg13, reg14 | 0x0D, 0x0E, 0x12 to 0x14 | Raw. Analog power up and down, DAC enable, the headphone switch, microphone select and PGA |
reg16, reg17 | 0x16, 0x17 | Raw. ADC scale and ADC volume |
reg31, reg37, reg44, reg45 | 0x31, 0x37, 0x44, 0x45 | Raw. DAC mute (bits 6 and 5), ramp rate, loopback, general purpose |
reg32, reg34 | 0x32, 0x34 | Raw, RO. DAC volume and compressor, closed for the speaker's cap |
chd1, chd2, chver | 0xFD, 0xFE, 0xFF | RO. 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
| Call | What 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:
| Job | What it does |
|---|---|
TONE | Emits one of the eight tones; captures nothing. Settle blocks first after a fresh open, then the drain |
FLOOR | Captures 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 |
PROBE | Emits 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, RECORD | Mono 16-bit frames from or into a buffer the caller keeps for the job's length |
STREAM | Plays 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
| Module | What it does |
|---|---|
ktone | Eight 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 limits | Tone 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.h | The one-bin kernel, shared with kimu_energy in sdk/kdrv/qmi8658a |
kwav | kwav_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 |
kring | The 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
| File | What it does |
|---|---|
include/kdrv_es8311.h, src/kdrv_es8311.c | The codec: bring-up, the volume curve, the jobs, the windows, the sound sequence, the buffer, raw registers |
include/kes_fields.h, src/kes_fields.c | The field table and the settings an open applies |
include/ktone.h, src/ktone.c | The tone generator and the detector |
include/kgoertzel.h | The Goertzel kernel, header only |
include/kwav.h, src/kwav.c | WAV header writer and parser |
include/kring.h, src/kring.c | The 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
components/kdrv/audio: the driver and its helpers.kdrv_es8311.h: the jobs, the stream and the speaker's cap.components/kaos/audio: AO_Audio, the owner, and its worker.