# 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

```mermaid
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:

| Thing | Value |
| --- | --- |
| Priority | 20 (`KODE_PRIO_AUDIO`) |
| Queue | 16 events |
| Stack | 3072 bytes |
| Claims | `KBSP_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.

```mermaid
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:

- debug for a fire-and-forget play;
- a warning for a request somebody waits on;
- a warning for a tone, since somebody typed it.

### 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

| Signal | Direction | Meaning |
| --- | --- | --- |
| `AUDIO_TONE_REQ_SIG` | in | `AudioToneEvt`: emit one tone, frequency snapped to a detector bin |
| `AUDIO_FIELD_REQ_SIG` | in | `AudioFieldReqEvt`: read, write or dump one codec field. Answered by the published `AUDIO_FIELD_SIG` |
| `AUDIO_INFO_REQ_SIG` | in | What the path is and what it is set to. Answered inside the step |
| `AUDIO_TEST_REQ_SIG` | in | Run the production row |
| `AUDIO_PLAY_REQ_SIG`, `AUDIO_REC_REQ_SIG` | in | `AudioBufEvt`: a whole sound for a waiting task, with `buf`, `frames`, `rc`, `done`, `gen` |
| `AUDIO_REC_FILE_REQ_SIG` | in | Record, then put the WAV on a medium |
| `AUDIO_STREAM_OPEN_REQ_SIG`, `AUDIO_STREAM_CLOSE_REQ_SIG` | in | `AudioBufEvt` with no buffer |
| `AUDIO_JOB_SIG` | in | `KqpWorkerDoneEvt`, posted from the worker |
| `AUDIO_GUARD_SIG` | self | One-shot guard of a job that never answers |
| `CONFIG_REQ_SIG` | in | The keys `vol`, `mic_db` and `mute` |
| `POWER_SHIP_SIG` | in | The battery switch is about to open (ship mode): hush, then close |
| `LOWPOWER_ROUND_SIG` | in | With `CONFIG_KODE_LOWPOWER`: a round of the board's rest. Close the codec, or answer busy |
| `STORAGE_WRITE_RSP_SIG` | in | How the recording's write ended |
| `C5_RUNNING_SIG` | in | With `KODEOS_C5_HOLD`: the C5 has booted, submit the pending open |
| `AUDIO_DONE_SIG` | out | `AudioDoneEvt`: one tagged play ended |
| `AUDIO_FIELD_SIG` | out | One codec field's value |
| `AUDIO_TEST_SIG` | out | `AudioTestEvt`: phases `AUDIO_TEST_MIC`, `_BIN`, `_TONE`, `_PROBE`; flags `AUDIO_TF_PASS`, `_STUCK`, `_CLIP`, `_LOUD`, `_LOOP`. Levels are tenths of dBFS |
| `AUDIO_FILE_RSP_SIG` | out | How a recording to a file ended |
| `CONFIG_ANS_SIG` | out | The value of a key |
| `TEST_RESULT_SIG` | out | The verdict, named `audio` |
| `C5_RUN_REQ_SIG` | out | With `KODEOS_C5_HOLD`: asked once per boot before the first open |

### Numbers

| Thing | Value |
| --- | --- |
| Sample format | Mono 16-bit at 48 kHz (`KODE_AUDIO_RATE_HZ`). The stream refuses any other rate. Two channels are played as their mean |
| Tone range | 400 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 % |
| Volume | 0 to 100. 100 is the speaker's cap, 11 dB under the codec's full scale, applied by the driver to every sound |
| Microphone gain | 0 to 42 dB in steps of 6, rounded down |
| Stream buffer | 200 ms ahead of the speaker (`KODE_AUDIO_STREAM_FRAMES`) |
| Guard | 45 s (`KODE_AUDIO_GUARD_MS`), one-shot, armed per job |
| Self-test | Window 86 ms, settle 32 ms, 3 tries of 2 tones, console budget about 6.2 s |
| First sound with `KODEOS_C5_HOLD` | Waits `KODE_C5_BOOT_MS`, 600 ms, for the C5 to boot and enable the amplifier |

### Stored settings, verbs and options

| Thing | Value |
| --- | --- |
| `vol` | 0 to 100, default 100 (`KODE_AUDIO_VOL_DEFAULT`). Applies at once and is stored; the driver pushes it at the next open |
| `mic_db` | 0 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 |
| `mute` | on or off, default off. The DAC mute |
| Console | `audio`, `audio_help`, `audio_tone`, `audio_rec ms= file=`, `config` for the three keys, `test component=audio` |
| Facade calls | `kode_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 |
| Options | `KODEOS_C5_HOLD` holds the C5 in reset until something needs it. Off by default |

## Gotchas

> **WARNING**
>
> **Your sound clicks at the end.** The mute and the drain cover the codec's transitions, not a buffer whose last sample is not zero. A decay still at 5 % of its peak when the frames run out is a jump at the speaker. Whoever synthesises a buffer owns its release: ramp the last 10 ms to zero.

> **WARNING**
>
> **A second sound is refused while one plays.** There is one activity slot and no queue. `kode_audio_tone()` returns `KODE_OK` once it is posted, and the refusal is a log line inside AO_Audio. Wait for `AUDIO_DONE_SIG`, or for the sound's length plus about 49 ms, before you ask again.

> **NOTE**
>
> **A tone under 400 Hz is refused, not raised.** Below the output filter's corner near 390 Hz the speaker cannot radiate what it is given, and the tone is heat. AO_Audio itself snaps any frequency it receives; the facade and `audio_tone` enforce the range.

> **NOTE**
>
> **Volume 100 is not full scale.** The driver caps it at −11 dB. With a tone playing, `audio field="vol" value=100` then `audio field="dac_vol_db"` reads `-110`, in tenths of a decibel. Zero means the cap is missing.

## 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.

```c
/*
 * 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

- [`components/kaos/audio`](https://github.com/kodediy/kode-sdk/tree/main/components/kaos/audio): `audio_ao.c`, the state machine, the worker, the stream and the self-test.
- [`kode.h`](https://github.com/kodediy/kode-sdk/blob/main/components/kode/include/kode.h): the `kode_audio_*` calls.
- [`components/kdrv/audio`](https://github.com/kodediy/kode-sdk/tree/main/components/kdrv/audio): `kdrv_es8311.c` and the tone detector; see `sdk/kdrv/es8311`.
