Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 18 of 56

I2S: audio that only moves by DMA

The S3's I2S has no CPU FIFO at all — samples reach the wire only through the DMA crossbar. That single fact shapes the whole API, including gapless playback and capture that runs underneath it.

No FIFO, so no polled path

Two controllers, I2S0 and I2S1. Unlike the UART, neither has a CPU-accessible FIFO: data flows only through GDMA. So bringing a port up claims a GDMA channel, and that is a heavyweight, once-per-port resource — which is why acquisition works slightly differently here:

The first Acquire of a port opens it at the given configuration and claims its channel. Later Acquire calls reuse it as-is — they do not re-open the port and do not inherit a new configuration. To change the audio format on a port you already hold, call Reconfigure (which re-claims the channel). This is the opposite of UART, where every Acquire re-applies the full state.

procedure Acquire
  (S           : in out Session;
   Port        : I2S_Port;
   Sample_Rate : Positive     := 16_000;
   Bits        : Sample_Bits  := Bits_16;      --  Bits_8 | 16 | 24 | 32
   Mode        : I2S_Mode     := Standard;     --  Standard | PDM
   Bclk, Ws, Dout, Din, Mclk : ESP32S3.GPIO.Optional_Pin := No_Pin);

Every pin is optional, so a link routes only what it uses — omit Din for a TX-only DAC, omit Dout for an RX-only microphone. Mclk drives a codec's master-clock input and exists only on I2S0; leave it unrouted for codecs that clock from BCLK.

Typed sample buffers

type PCM_8  is array (Natural range <>) of Interfaces.Integer_8;
type PCM_16 is array (Natural range <>) of Interfaces.Integer_16;
type PCM_32 is array (Natural range <>) of Interfaces.Integer_32;

The element type fixes the on-wire width, so the driver derives the byte count itself — no caller-side * 2 — and the typed Write/Read/Transfer check the buffer's width against the port's configured Bits. They are signed two's-complement, as PCM is. A PCM_32 buffer carries both 24- and 32-bit samples, since both occupy a 32-bit slot. For already-framed bytes or an opaque bit pattern there are *_Raw primitives, including DMA_Buffer overloads with the usual alignment and size preconditions.

Standard and PDM are the same buffers

I2S_Mode selects what sits between the buffer and the wire:

The DMA still moves ordinary PCM either way, so your transfer calls are unchanged — only the on-wire format differs.

The PDM converters high-pass filter, removing DC. A constant level does not survive a PDM round trip, so do not write a self-test that expects one to.

Gapless playback

Three escalating options, all built on the GDMA behaviour from the previous step:

CallWhat it gives you
Write / Read / Transfer One blocking buffer, up to 4095 bytes. Transfer is full duplex — shift out and capture simultaneously, same length.
Start_Continuous A self-looping descriptor replays one buffer forever with no gap and no CPU involvement. The buffer must stay valid, live in internal SRAM, and should hold a whole number of wave periods so the wrap is seamless. Stop ends it.
Start_Stream + Await_Half Gapless double-buffered streaming: the two halves of one buffer loop forever, and Await_Half tells you which half the hardware has finished so you can refill the other. This is how you play audio longer than a buffer.

Capturing while playing

Read drives the receive path as a transaction. When a continuous transmit is already running, use Capture instead — it fills a buffer without disturbing the TX path, so recording can run underneath playback. There is a streaming mirror of it too, the receive counterpart of Start_Stream.

Self-test without wiring

procedure Enable_Loopback (S : Session; Pad : ESP32S3.GPIO.Pin_Id);

TX and RX share WS and BCK internally through the hardware SIG_LOOPBACK bit, with the data line looped through one pad, so ./x run esp32s3_i2s_loopback proves the real DMA path in both directions byte-exact with nothing attached. Configured_Bits reports the width the held port is currently set to, which is what the typed transfers check against.

I2S: audio that only moves by DMA · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 18 of 56

I2S: audio that only moves by DMA

The S3's I2S has no CPU FIFO at all — samples reach the wire only through the DMA crossbar. That single fact shapes the whole API, including gapless playback and capture that runs underneath it.

No FIFO, so no polled path

Two controllers, I2S0 and I2S1. Unlike the UART, neither has a CPU-accessible FIFO: data flows only through GDMA. So bringing a port up claims a GDMA channel, and that is a heavyweight, once-per-port resource — which is why acquisition works slightly differently here:

The first Acquire of a port opens it at the given configuration and claims its channel. Later Acquire calls reuse it as-is — they do not re-open the port and do not inherit a new configuration. To change the audio format on a port you already hold, call Reconfigure (which re-claims the channel). This is the opposite of UART, where every Acquire re-applies the full state.

procedure Acquire
  (S           : in out Session;
   Port        : I2S_Port;
   Sample_Rate : Positive     := 16_000;
   Bits        : Sample_Bits  := Bits_16;      --  Bits_8 | 16 | 24 | 32
   Mode        : I2S_Mode     := Standard;     --  Standard | PDM
   Bclk, Ws, Dout, Din, Mclk : ESP32S3.GPIO.Optional_Pin := No_Pin);

Every pin is optional, so a link routes only what it uses — omit Din for a TX-only DAC, omit Dout for an RX-only microphone. Mclk drives a codec's master-clock input and exists only on I2S0; leave it unrouted for codecs that clock from BCLK.

Typed sample buffers

type PCM_8  is array (Natural range <>) of Interfaces.Integer_8;
type PCM_16 is array (Natural range <>) of Interfaces.Integer_16;
type PCM_32 is array (Natural range <>) of Interfaces.Integer_32;

The element type fixes the on-wire width, so the driver derives the byte count itself — no caller-side * 2 — and the typed Write/Read/Transfer check the buffer's width against the port's configured Bits. They are signed two's-complement, as PCM is. A PCM_32 buffer carries both 24- and 32-bit samples, since both occupy a 32-bit slot. For already-framed bytes or an opaque bit pattern there are *_Raw primitives, including DMA_Buffer overloads with the usual alignment and size preconditions.

Standard and PDM are the same buffers

I2S_Mode selects what sits between the buffer and the wire:

The DMA still moves ordinary PCM either way, so your transfer calls are unchanged — only the on-wire format differs.

The PDM converters high-pass filter, removing DC. A constant level does not survive a PDM round trip, so do not write a self-test that expects one to.

Gapless playback

Three escalating options, all built on the GDMA behaviour from the previous step:

CallWhat it gives you
Write / Read / Transfer One blocking buffer, up to 4095 bytes. Transfer is full duplex — shift out and capture simultaneously, same length.
Start_Continuous A self-looping descriptor replays one buffer forever with no gap and no CPU involvement. The buffer must stay valid, live in internal SRAM, and should hold a whole number of wave periods so the wrap is seamless. Stop ends it.
Start_Stream + Await_Half Gapless double-buffered streaming: the two halves of one buffer loop forever, and Await_Half tells you which half the hardware has finished so you can refill the other. This is how you play audio longer than a buffer.

Capturing while playing

Read drives the receive path as a transaction. When a continuous transmit is already running, use Capture instead — it fills a buffer without disturbing the TX path, so recording can run underneath playback. There is a streaming mirror of it too, the receive counterpart of Start_Stream.

Self-test without wiring

procedure Enable_Loopback (S : Session; Pad : ESP32S3.GPIO.Pin_Id);

TX and RX share WS and BCK internally through the hardware SIG_LOOPBACK bit, with the data line looped through one pad, so ./x run esp32s3_i2s_loopback proves the real DMA path in both directions byte-exact with nothing attached. Configured_Bits reports the width the held port is currently set to, which is what the typed transfers check against.

I2S: audio that only moves by DMA · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 18 of 56

I2S: audio that only moves by DMA

The S3's I2S has no CPU FIFO at all — samples reach the wire only through the DMA crossbar. That single fact shapes the whole API, including gapless playback and capture that runs underneath it.

No FIFO, so no polled path

Two controllers, I2S0 and I2S1. Unlike the UART, neither has a CPU-accessible FIFO: data flows only through GDMA. So bringing a port up claims a GDMA channel, and that is a heavyweight, once-per-port resource — which is why acquisition works slightly differently here:

The first Acquire of a port opens it at the given configuration and claims its channel. Later Acquire calls reuse it as-is — they do not re-open the port and do not inherit a new configuration. To change the audio format on a port you already hold, call Reconfigure (which re-claims the channel). This is the opposite of UART, where every Acquire re-applies the full state.

procedure Acquire
  (S           : in out Session;
   Port        : I2S_Port;
   Sample_Rate : Positive     := 16_000;
   Bits        : Sample_Bits  := Bits_16;      --  Bits_8 | 16 | 24 | 32
   Mode        : I2S_Mode     := Standard;     --  Standard | PDM
   Bclk, Ws, Dout, Din, Mclk : ESP32S3.GPIO.Optional_Pin := No_Pin);

Every pin is optional, so a link routes only what it uses — omit Din for a TX-only DAC, omit Dout for an RX-only microphone. Mclk drives a codec's master-clock input and exists only on I2S0; leave it unrouted for codecs that clock from BCLK.

Typed sample buffers

type PCM_8  is array (Natural range <>) of Interfaces.Integer_8;
type PCM_16 is array (Natural range <>) of Interfaces.Integer_16;
type PCM_32 is array (Natural range <>) of Interfaces.Integer_32;

The element type fixes the on-wire width, so the driver derives the byte count itself — no caller-side * 2 — and the typed Write/Read/Transfer check the buffer's width against the port's configured Bits. They are signed two's-complement, as PCM is. A PCM_32 buffer carries both 24- and 32-bit samples, since both occupy a 32-bit slot. For already-framed bytes or an opaque bit pattern there are *_Raw primitives, including DMA_Buffer overloads with the usual alignment and size preconditions.

Standard and PDM are the same buffers

I2S_Mode selects what sits between the buffer and the wire:

The DMA still moves ordinary PCM either way, so your transfer calls are unchanged — only the on-wire format differs.

The PDM converters high-pass filter, removing DC. A constant level does not survive a PDM round trip, so do not write a self-test that expects one to.

Gapless playback

Three escalating options, all built on the GDMA behaviour from the previous step:

CallWhat it gives you
Write / Read / Transfer One blocking buffer, up to 4095 bytes. Transfer is full duplex — shift out and capture simultaneously, same length.
Start_Continuous A self-looping descriptor replays one buffer forever with no gap and no CPU involvement. The buffer must stay valid, live in internal SRAM, and should hold a whole number of wave periods so the wrap is seamless. Stop ends it.
Start_Stream + Await_Half Gapless double-buffered streaming: the two halves of one buffer loop forever, and Await_Half tells you which half the hardware has finished so you can refill the other. This is how you play audio longer than a buffer.

Capturing while playing

Read drives the receive path as a transaction. When a continuous transmit is already running, use Capture instead — it fills a buffer without disturbing the TX path, so recording can run underneath playback. There is a streaming mirror of it too, the receive counterpart of Start_Stream.

Self-test without wiring

procedure Enable_Loopback (S : Session; Pad : ESP32S3.GPIO.Pin_Id);

TX and RX share WS and BCK internally through the hardware SIG_LOOPBACK bit, with the data line looped through one pad, so ./x run esp32s3_i2s_loopback proves the real DMA path in both directions byte-exact with nothing attached. Configured_Bits reports the width the held port is currently set to, which is what the typed transfers check against.

I2S: audio that only moves by DMA · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 18 of 56

I2S: audio that only moves by DMA

The S3's I2S has no CPU FIFO at all — samples reach the wire only through the DMA crossbar. That single fact shapes the whole API, including gapless playback and capture that runs underneath it.

No FIFO, so no polled path

Two controllers, I2S0 and I2S1. Unlike the UART, neither has a CPU-accessible FIFO: data flows only through GDMA. So bringing a port up claims a GDMA channel, and that is a heavyweight, once-per-port resource — which is why acquisition works slightly differently here:

The first Acquire of a port opens it at the given configuration and claims its channel. Later Acquire calls reuse it as-is — they do not re-open the port and do not inherit a new configuration. To change the audio format on a port you already hold, call Reconfigure (which re-claims the channel). This is the opposite of UART, where every Acquire re-applies the full state.

procedure Acquire
  (S           : in out Session;
   Port        : I2S_Port;
   Sample_Rate : Positive     := 16_000;
   Bits        : Sample_Bits  := Bits_16;      --  Bits_8 | 16 | 24 | 32
   Mode        : I2S_Mode     := Standard;     --  Standard | PDM
   Bclk, Ws, Dout, Din, Mclk : ESP32S3.GPIO.Optional_Pin := No_Pin);

Every pin is optional, so a link routes only what it uses — omit Din for a TX-only DAC, omit Dout for an RX-only microphone. Mclk drives a codec's master-clock input and exists only on I2S0; leave it unrouted for codecs that clock from BCLK.

Typed sample buffers

type PCM_8  is array (Natural range <>) of Interfaces.Integer_8;
type PCM_16 is array (Natural range <>) of Interfaces.Integer_16;
type PCM_32 is array (Natural range <>) of Interfaces.Integer_32;

The element type fixes the on-wire width, so the driver derives the byte count itself — no caller-side * 2 — and the typed Write/Read/Transfer check the buffer's width against the port's configured Bits. They are signed two's-complement, as PCM is. A PCM_32 buffer carries both 24- and 32-bit samples, since both occupy a 32-bit slot. For already-framed bytes or an opaque bit pattern there are *_Raw primitives, including DMA_Buffer overloads with the usual alignment and size preconditions.

Standard and PDM are the same buffers

I2S_Mode selects what sits between the buffer and the wire:

The DMA still moves ordinary PCM either way, so your transfer calls are unchanged — only the on-wire format differs.

The PDM converters high-pass filter, removing DC. A constant level does not survive a PDM round trip, so do not write a self-test that expects one to.

Gapless playback

Three escalating options, all built on the GDMA behaviour from the previous step:

CallWhat it gives you
Write / Read / Transfer One blocking buffer, up to 4095 bytes. Transfer is full duplex — shift out and capture simultaneously, same length.
Start_Continuous A self-looping descriptor replays one buffer forever with no gap and no CPU involvement. The buffer must stay valid, live in internal SRAM, and should hold a whole number of wave periods so the wrap is seamless. Stop ends it.
Start_Stream + Await_Half Gapless double-buffered streaming: the two halves of one buffer loop forever, and Await_Half tells you which half the hardware has finished so you can refill the other. This is how you play audio longer than a buffer.

Capturing while playing

Read drives the receive path as a transaction. When a continuous transmit is already running, use Capture instead — it fills a buffer without disturbing the TX path, so recording can run underneath playback. There is a streaming mirror of it too, the receive counterpart of Start_Stream.

Self-test without wiring

procedure Enable_Loopback (S : Session; Pad : ESP32S3.GPIO.Pin_Id);

TX and RX share WS and BCK internally through the hardware SIG_LOOPBACK bit, with the data line looped through one pad, so ./x run esp32s3_i2s_loopback proves the real DMA path in both directions byte-exact with nothing attached. Configured_Bits reports the width the held port is currently set to, which is what the typed transfers check against.

I2S: audio that only moves by DMA · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 18 of 56

I2S: audio that only moves by DMA

The S3's I2S has no CPU FIFO at all — samples reach the wire only through the DMA crossbar. That single fact shapes the whole API, including gapless playback and capture that runs underneath it.

No FIFO, so no polled path

Two controllers, I2S0 and I2S1. Unlike the UART, neither has a CPU-accessible FIFO: data flows only through GDMA. So bringing a port up claims a GDMA channel, and that is a heavyweight, once-per-port resource — which is why acquisition works slightly differently here:

The first Acquire of a port opens it at the given configuration and claims its channel. Later Acquire calls reuse it as-is — they do not re-open the port and do not inherit a new configuration. To change the audio format on a port you already hold, call Reconfigure (which re-claims the channel). This is the opposite of UART, where every Acquire re-applies the full state.

procedure Acquire
  (S           : in out Session;
   Port        : I2S_Port;
   Sample_Rate : Positive     := 16_000;
   Bits        : Sample_Bits  := Bits_16;      --  Bits_8 | 16 | 24 | 32
   Mode        : I2S_Mode     := Standard;     --  Standard | PDM
   Bclk, Ws, Dout, Din, Mclk : ESP32S3.GPIO.Optional_Pin := No_Pin);

Every pin is optional, so a link routes only what it uses — omit Din for a TX-only DAC, omit Dout for an RX-only microphone. Mclk drives a codec's master-clock input and exists only on I2S0; leave it unrouted for codecs that clock from BCLK.

Typed sample buffers

type PCM_8  is array (Natural range <>) of Interfaces.Integer_8;
type PCM_16 is array (Natural range <>) of Interfaces.Integer_16;
type PCM_32 is array (Natural range <>) of Interfaces.Integer_32;

The element type fixes the on-wire width, so the driver derives the byte count itself — no caller-side * 2 — and the typed Write/Read/Transfer check the buffer's width against the port's configured Bits. They are signed two's-complement, as PCM is. A PCM_32 buffer carries both 24- and 32-bit samples, since both occupy a 32-bit slot. For already-framed bytes or an opaque bit pattern there are *_Raw primitives, including DMA_Buffer overloads with the usual alignment and size preconditions.

Standard and PDM are the same buffers

I2S_Mode selects what sits between the buffer and the wire:

The DMA still moves ordinary PCM either way, so your transfer calls are unchanged — only the on-wire format differs.

The PDM converters high-pass filter, removing DC. A constant level does not survive a PDM round trip, so do not write a self-test that expects one to.

Gapless playback

Three escalating options, all built on the GDMA behaviour from the previous step:

CallWhat it gives you
Write / Read / Transfer One blocking buffer, up to 4095 bytes. Transfer is full duplex — shift out and capture simultaneously, same length.
Start_Continuous A self-looping descriptor replays one buffer forever with no gap and no CPU involvement. The buffer must stay valid, live in internal SRAM, and should hold a whole number of wave periods so the wrap is seamless. Stop ends it.
Start_Stream + Await_Half Gapless double-buffered streaming: the two halves of one buffer loop forever, and Await_Half tells you which half the hardware has finished so you can refill the other. This is how you play audio longer than a buffer.

Capturing while playing

Read drives the receive path as a transaction. When a continuous transmit is already running, use Capture instead — it fills a buffer without disturbing the TX path, so recording can run underneath playback. There is a streaming mirror of it too, the receive counterpart of Start_Stream.

Self-test without wiring

procedure Enable_Loopback (S : Session; Pad : ESP32S3.GPIO.Pin_Id);

TX and RX share WS and BCK internally through the hardware SIG_LOOPBACK bit, with the data line looped through one pad, so ./x run esp32s3_i2s_loopback proves the real DMA path in both directions byte-exact with nothing attached. Configured_Bits reports the width the held port is currently set to, which is what the typed transfers check against.

I2S: audio that only moves by DMA · Bare-Metal Ada on the ESP32-S3
Bare-Metal Ada on the ESP32-S3 A step-by-step guide to running Ada on the ESP32-S3 with no ESP-IDF, no FreeRTOS, and no Python.

Step 18 of 56

I2S: audio that only moves by DMA

The S3's I2S has no CPU FIFO at all — samples reach the wire only through the DMA crossbar. That single fact shapes the whole API, including gapless playback and capture that runs underneath it.

No FIFO, so no polled path

Two controllers, I2S0 and I2S1. Unlike the UART, neither has a CPU-accessible FIFO: data flows only through GDMA. So bringing a port up claims a GDMA channel, and that is a heavyweight, once-per-port resource — which is why acquisition works slightly differently here:

The first Acquire of a port opens it at the given configuration and claims its channel. Later Acquire calls reuse it as-is — they do not re-open the port and do not inherit a new configuration. To change the audio format on a port you already hold, call Reconfigure (which re-claims the channel). This is the opposite of UART, where every Acquire re-applies the full state.

procedure Acquire
  (S           : in out Session;
   Port        : I2S_Port;
   Sample_Rate : Positive     := 16_000;
   Bits        : Sample_Bits  := Bits_16;      --  Bits_8 | 16 | 24 | 32
   Mode        : I2S_Mode     := Standard;     --  Standard | PDM
   Bclk, Ws, Dout, Din, Mclk : ESP32S3.GPIO.Optional_Pin := No_Pin);

Every pin is optional, so a link routes only what it uses — omit Din for a TX-only DAC, omit Dout for an RX-only microphone. Mclk drives a codec's master-clock input and exists only on I2S0; leave it unrouted for codecs that clock from BCLK.

Typed sample buffers

type PCM_8  is array (Natural range <>) of Interfaces.Integer_8;
type PCM_16 is array (Natural range <>) of Interfaces.Integer_16;
type PCM_32 is array (Natural range <>) of Interfaces.Integer_32;

The element type fixes the on-wire width, so the driver derives the byte count itself — no caller-side * 2 — and the typed Write/Read/Transfer check the buffer's width against the port's configured Bits. They are signed two's-complement, as PCM is. A PCM_32 buffer carries both 24- and 32-bit samples, since both occupy a 32-bit slot. For already-framed bytes or an opaque bit pattern there are *_Raw primitives, including DMA_Buffer overloads with the usual alignment and size preconditions.

Standard and PDM are the same buffers

I2S_Mode selects what sits between the buffer and the wire:

The DMA still moves ordinary PCM either way, so your transfer calls are unchanged — only the on-wire format differs.

The PDM converters high-pass filter, removing DC. A constant level does not survive a PDM round trip, so do not write a self-test that expects one to.

Gapless playback

Three escalating options, all built on the GDMA behaviour from the previous step:

CallWhat it gives you
Write / Read / Transfer One blocking buffer, up to 4095 bytes. Transfer is full duplex — shift out and capture simultaneously, same length.
Start_Continuous A self-looping descriptor replays one buffer forever with no gap and no CPU involvement. The buffer must stay valid, live in internal SRAM, and should hold a whole number of wave periods so the wrap is seamless. Stop ends it.
Start_Stream + Await_Half Gapless double-buffered streaming: the two halves of one buffer loop forever, and Await_Half tells you which half the hardware has finished so you can refill the other. This is how you play audio longer than a buffer.

Capturing while playing

Read drives the receive path as a transaction. When a continuous transmit is already running, use Capture instead — it fills a buffer without disturbing the TX path, so recording can run underneath playback. There is a streaming mirror of it too, the receive counterpart of Start_Stream.

Self-test without wiring

procedure Enable_Loopback (S : Session; Pad : ESP32S3.GPIO.Pin_Id);

TX and RX share WS and BCK internally through the hardware SIG_LOOPBACK bit, with the data line looped through one pad, so ./x run esp32s3_i2s_loopback proves the real DMA path in both directions byte-exact with nothing attached. Configured_Bits reports the width the held port is currently set to, which is what the typed transfers check against.