SDK

AO_Audio

The Active Object that owns the Kode Dot's speaker and microphone, one ES8311 for both: one sound at a time, a worker task for the samples and the self-test.

What it is

AO_Audio owns the sound path of a Kode Dot. One codec chip serves both directions, so the speaker and the microphone have one owner. Other code never talks to the chip. It asks AO_Audio for a tone, a whole sound, a stream or a recording, and AO_Audio handles one request at a time.

It never waits inside an event step. Every call that moves samples runs as a job on its own worker task, audio_worker, and the result comes back as an event. AO_Audio keeps every decision: which job runs, when the codec opens and what a failure means.

The chip, the amplifier and the I2S wiring are on sdk/kdrv/es8311. This page is about the owner above it.

How it is connected

flowchart LR
req["Facade, AO_Console,<br/>AO_Autotest"] -- "AUDIO_*_REQ_SIG" --> ao["AO_Audio<br/>priority 20"]
ao -- "kqp_worker_submit()" --> wk["audio_worker<br/>priority 20"]
wk -- "kdrv_es8311_run()" --> drv["ES8311 + I2S"]
wk -- "AUDIO_JOB_SIG" --> ao
ao -- "AUDIO_DONE_SIG" --> req
pwr["AO_Power"] -- "POWER_SHIP_SIG" --> ao
lp["AO_LowPower"] -- "LOWPOWER_ROUND_SIG" --> ao
class ao,pwr,lp ao
class drv chip
AO_Audio and its worker

AO_Audio in numbers:

ThingValue
Priority20 (KODE_PRIO_AUDIO)
Queue16 events
Stack3072 bytes
ClaimsKBSP_RES_AUDIO

KBSP_RES_AUDIO is one resource: the ES8311 with its ADC (the microphone) and DAC (the speaker), the I2S controller and its five pins. The microphone side and the speaker side share a die, a clock and an enclosure, so two owners could not take turns safely.

The speaker amplifier's enable is pin IO4 of the ESP32-C5, which its firmware drives high at its own boot. Nothing on the P4 owns it, so the amplifier exists only while the C5 runs. The amplifier pops once when the radio link starts: the start resets the C5 and its boot drives the enable high. This is why the audio row runs after the c5 row. With CONFIG_KODE_LOWPOWER the C5 also lowers the enable when the P4 announces power save and raises it again, before anything else, when the P4 announces that it is awake. The P4 does not touch the pin in either case (sdk/kaos/lowpower).

The worker has the same priority as the AO because it must refill a DMA buffer at 48 kHz before the codec empties it; a refill that waits behind a repaint is an audible click. kode_init() starts AO_Audio next to AO_Ir and AO_Nfc. Its start does I2C and builds the channel only: no DMA, no sound, the device left closed.

stateDiagram-v2
[*] --> Audio_ready: codec fitted, worker started
[*] --> Audio_dead: no codec, or no worker
AO_Audio states

Audio_dead says once that there is no codec (or no worker; the codec is there and cannot be driven) and refuses everything with one answer. Open or closed is a property of the device, not a state: every request opens the codec first when it needs to.

The worker

audio_worker is a kqp_worker task with an 8192-byte stack in PSRAM (AUDIO_WORKER_STACK) and a queue of one job (AUDIO_WORKER_DEPTH). It runs kdrv_es8311_run() for one job at a time and posts AUDIO_JOB_SIG with the sequence number and status. The request it reads (wreq) belongs to AO_Audio and is untouched between the submit and the done event. winflight is set at submit and cleared only by the done event with the matching sequence number, so a request that needs the worker is refused while a late job is still running, even after its guard fired.

One activity at a time, no queue

A request that arrives while another activity runs is refused with a log line and leaves no mark on the running one: it writes no wait record, no slot, no path, no counter and never touches the running job's guard. Waiting callers get the refusal through their own result fields. The log level says who is owed the reason:

The codec opens once and stays open

There is no idle close and no timer for one. What must be silent at rest is the converter, and the driver mutes it between sounds: it comes up muted, un-mutes when the first block of real samples is in the ring, and mutes again after the ring drains with a 12 ms ramp.

A close would power down the analog blocks into an amplifier the C5 keeps enabled, which is a pop. So the first sound of a boot opens the device and nothing closes it except two events.

AO_Power publishes POWER_SHIP_SIG before it opens the battery switch (ship mode); AO_Audio hushes the converter in that same step, with one masked register write, and asks the worker to close. If a sound is still running, it only mutes.

The second event is a round of the board's rest. With CONFIG_KODE_LOWPOWER, AO_Audio joins the rounds of AO_LowPower as audio (sdk/kaos/lowpower). On a round, a codec that is open is hushed and closed by the same close job, and the round is answered when the worker says the codec is closed. A sound that plays, a recording, a stream or the production row answers the round busy (audio playing, audio recording, audio test, audio busy), and a close the worker would not take is busy audio close refused. A close that the driver starts and then fails is answered ready all the same, with a warning, because the board can sleep with a codec open at rest. AO_Audio restores nothing when the board wakes: the first sound after it opens the codec as it always does. So it listens to the rounds and not to LOWPOWER_STATE_SIG.

Sound length

Each sound is about 49 ms longer than its buffer: the drain (37 ms) and the ramp (12 ms) run on the worker, not in AO_Audio's step. The end of a play means the speaker has heard it, because the driver drains a whole DMA ring of silence before it answers.

A tagged play reports its end

A play with gen other than 0 gets exactly one published AUDIO_DONE_SIG (AudioDoneEvt: gen, rc), however it ended: samples ran out, the worker failed, the guard fired or the request was refused. An Active Object cannot sleep on a semaphore, so the event is how one learns that a goodbye sound has finished before it asks AO_LowPower for OFF with kode_power_off(). A play tagged 0 publishes nothing.

The stream

The stream holds the codec from open to close. AUDIO_STREAM_OPEN_REQ_SIG empties the ring, opens the device if needed, starts the stream job and answers when it runs. The frames never pass through AO_Audio; the writer puts them straight into the driver's ring. The guard is stopped while a stream plays and re-armed around the final drain by AUDIO_STREAM_CLOSE_REQ_SIG. Every other sound and a second stream are refused meanwhile.

Recording to a file

A recording to a file arrives as one request with an already resolved path, since the console owns the current directory. The samples land in the driver's buffer, a WAV header goes in front, and the whole image goes out through AO_Storage's whole-buffer write, the only write that carries a file this large. The buffer stays until STORAGE_WRITE_RSP_SIG answers.

The self-test

test component=audio needs nobody at the bench, because the speaker and the microphone share a small board.

  1. Step 1 captures one window with the converter open and writing zeros and judges the microphone alone. A perfectly flat floor is a dead ADC or bus, a railed input is a dead input, and a floor above the ceiling means the bench is too loud and the row refuses to judge. It records the floor of all eight detector bins once.
  2. Step 2 draws two different frequencies from the hardware random generator, emits them in turn and passes an attempt only if each tone is above the other seven candidates and above step 1's floor at that frequency. Three attempts are allowed, each with a fresh pair.

When step 1 passes and step 2 fails, the row names the speaker or the microphone, since it cannot tell them apart. The row touches no filesystem.

Specifications

Signals

SignalDirectionMeaning
AUDIO_TONE_REQ_SIGinAudioToneEvt: emit one tone, frequency snapped to a detector bin
AUDIO_FIELD_REQ_SIGinAudioFieldReqEvt: read, write or dump one codec field. Answered by the published AUDIO_FIELD_SIG
AUDIO_INFO_REQ_SIGinWhat the path is and what it is set to. Answered inside the step
AUDIO_TEST_REQ_SIGinRun the production row
AUDIO_PLAY_REQ_SIG, AUDIO_REC_REQ_SIGinAudioBufEvt: a whole sound for a waiting task, with buf, frames, rc, done, gen
AUDIO_REC_FILE_REQ_SIGinRecord, then put the WAV on a medium
AUDIO_STREAM_OPEN_REQ_SIG, AUDIO_STREAM_CLOSE_REQ_SIGinAudioBufEvt with no buffer
AUDIO_JOB_SIGinKqpWorkerDoneEvt, posted from the worker
AUDIO_GUARD_SIGselfOne-shot guard of a job that never answers
CONFIG_REQ_SIGinThe keys vol, mic_db and mute
POWER_SHIP_SIGinThe battery switch is about to open (ship mode): hush, then close
LOWPOWER_ROUND_SIGinWith CONFIG_KODE_LOWPOWER: a round of the board's rest. Close the codec, or answer busy
STORAGE_WRITE_RSP_SIGinHow the recording's write ended
C5_RUNNING_SIGinWith KODEOS_C5_HOLD: the C5 has booted, submit the pending open
AUDIO_DONE_SIGoutAudioDoneEvt: one tagged play ended
AUDIO_FIELD_SIGoutOne codec field's value
AUDIO_TEST_SIGoutAudioTestEvt: phases AUDIO_TEST_MIC, _BIN, _TONE, _PROBE; flags AUDIO_TF_PASS, _STUCK, _CLIP, _LOUD, _LOOP. Levels are tenths of dBFS
AUDIO_FILE_RSP_SIGoutHow a recording to a file ended
CONFIG_ANS_SIGoutThe value of a key
TEST_RESULT_SIGoutThe verdict, named audio
C5_RUN_REQ_SIGoutWith KODEOS_C5_HOLD: asked once per boot before the first open

Numbers

ThingValue
Sample formatMono 16-bit at 48 kHz (KODE_AUDIO_RATE_HZ). The stream refuses any other rate. Two channels are played as their mean
Tone range400 to 3400 Hz (KTONE_HZ_MIN, KTONE_HZ_MAX), snapped to one of eight detector bins. The facade refuses outside it; amplitude 1 to 100 %
Volume0 to 100. 100 is the speaker's cap, 11 dB under the codec's full scale, applied by the driver to every sound
Microphone gain0 to 42 dB in steps of 6, rounded down
Stream buffer200 ms ahead of the speaker (KODE_AUDIO_STREAM_FRAMES)
Guard45 s (KODE_AUDIO_GUARD_MS), one-shot, armed per job
Self-testWindow 86 ms, settle 32 ms, 3 tries of 2 tones, console budget about 6.2 s
First sound with KODEOS_C5_HOLDWaits KODE_C5_BOOT_MS, 600 ms, for the C5 to boot and enable the amplifier

Stored settings, verbs and options

ThingValue
vol0 to 100, default 100 (KODE_AUDIO_VOL_DEFAULT). Applies at once and is stored; the driver pushes it at the next open
mic_db0 to 42 in multiples of 6, default 24 (KODE_AUDIO_MIC_DB_DEFAULT). Off the grid is >err code=5 msg=value and nothing is stored
muteon or off, default off. The DAC mute
Consoleaudio, audio_help, audio_tone, audio_rec ms= file=, config for the three keys, test component=audio
Facade callskode_audio_tone(), kode_audio_volume(), kode_audio_mic_gain() post and are safe from an AO. kode_audio_info(), _play(), _record(), _record_file() and the three _stream_*() calls block and never run in an AO
OptionsKODEOS_C5_HOLD holds the C5 in reset until something needs it. Off by default

Gotchas

Example code

This plays a 1 kHz tone, waits for it to end, then a 2 kHz tone, at 40 % volume. You hear two beeps, a second apart.

/*
 * audio_beeps.c - 2026-10-28 - from Kode with love
 *
 * Asks AO_Audio, the owner of the Kode Dot's speaker, for two tones at 40 %
 * volume: 1 kHz, then 2 kHz. You hear two short beeps, one second apart.
 * A tone is refused while another plays, so the app waits between them.
 */
#include "kode.h"
 
/* Bring the system up, play the two tones, then idle. */
void app_main(void)
{
    kode_init();
    kode_audio_volume(40);
 
    kode_audio_tone(1000, 300, 50);
    kode_delay_ms(1000);
    kode_audio_tone(2000, 300, 50);
 
    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