Documentation / Hardware

Audio and stations

Playing a stream, a station list, and meters driven by the audio itself.

Hardware › Audio turns the board into a player. It decodes an MP3 stream off the network, drives the chip's own DAC, an external I2S one or an ES8311 codec, and hands the design a volume, a track title and two levels it can put on screen. A board preset with a speaker fills in the wiring; switching it on is one tick box, and nothing is added to the firmware until it is. The same tab has the microphone: its level, a recording, and sound sent to a web address that can answer out loud.

Where the sound comes out

OutputPinsWhat you get
Internal DACOne: GPIO 25 or 26, plus the amplifier's enable if the board has one8-bit mono, straight out of the chip. Fine for a kitchen radio or an alert. Classic ESP32 only - the S2, S3 and C3 have no DAC. The LCDWIKI E32R28T's amplifier is enabled by pulling GPIO 4 low, which its preset does.
External I2S DACThree: BCLK, LRCLK, DINProper 16-bit stereo. A MAX98357A or a PCM5102.
ES8311 codecFour: MCLK, BCLK, LRCK, DSDIN, plus the I2C bus it is set up over and, optionally, the speaker amplifier's enable16-bit mono into the small amplifier most ESP32-S3 display boards pair it with. The export loads the codec's registers at boot (Espressif's own sequence), then switches the amplifier on - high or low, whichever that board's amplifier wants.

On a board with an ES8311

The Waveshare ESP32-S3-Touch-LCD-3.5B preset brings the ES8311 already wired: I2S on GPIO 44, 13, 15 and 16, the codec at 0x18 on the board's I2C bus, and the amplifier's enable on the TCA9554 expander. The codec's master clock runs at 256 times the sample rate, which holds for every rate a stream arrives at, so it is set up once and the I2C bus is left to touch afterwards. The board's microphone goes into the same codec: see The microphone.

Speaker gain on an ES8311

A small speaker on an ES8311 can be quiet at full volume. Hardware › Audio › Speaker gain, shown when the output is an ES8311, is a slider from 0 to +24 dB: the codec’s own amplification, on top of the volume. Too high and loud sounds distort. A preset may set it for you: the Waveshare ESP32-C6-Touch-AMOLED-2.16 starts at +12 dB and the ESP32-C6-Touch-LCD-2.8 at +15 dB, both chosen by ear.

To change it while the board runs, there is the value Speaker gain (0 to 100 % of the 24 dB, two-way, so a slider bound to it sets it) and the action Set speaker gain, with the same choices as the microphone’s: a widget’s value, a fixed level, or louder or quieter by a step. A change made at run time lasts until the board restarts. Arduino and ESP-IDF; MicroPython and ESPHome refuse the action.

On a Cheap Yellow Display

There is no choice to make, and it is worth knowing why. The board brings out exactly one free pair of general-purpose pins - CN1 - and an external I2S DAC needs three. So audio is the internal DAC on GPIO 26, which is where the onboard SC8002B amplifier and the 2-pin JST speaker connector already are. The preset fills it in for you.

That also settles two other things. The ceiling is an 8-bit mono DAC, so a stream above about 128 kbps costs CPU and heap without sounding better; and the codec should be MP3, because the AAC decoder wants around 45 KB of working memory that a board with no PSRAM does not have spare.

The pin that is not free

The internal DAC is driven through I2S, and I2S maps its left channel to DAC2 (GPIO 26) and its right to DAC1 (GPIO 25). The usual Arduino audio library enables both channels when you ask for internal-DAC output.

On a CYD, GPIO 25 is the touch controller's SPI clock. Left at the default, pressing Play puts a DMA-driven analog waveform on the touch clock line. The generated lw_audio.cpp enables one channel only, and says so in a comment where somebody editing it would otherwise undo it.

Settings

SettingDefaultWhat it does
CodecMP3MP3, AAC or WAV. The designer warns if AAC is chosen on a board without PSRAM.
Buffer16 KBThe ring buffer between the network and the decoder. Too small and the stream stutters on every hiccup; too large and there is no heap left to decode with.
Volume60 %The level it starts at. Store it as a setting if it should survive a reboot.
Decoding: on the other core (on its own task on a single-core chip)onGives the decoder a task of its own. On a dual-core chip - the ESP32 and ESP32-S3 - that task has core 0 and LVGL keeps core 1, so a buffer refill never stutters the UI. An ESP32-S2, C3, C5 or C6 has one core: there the decoder is a higher-priority task on it, so it goes first whenever it has work and the UI is drawn in between. Switched off, decoding happens inside the UI loop, between redraws.

Stations, and building a radio

What a radio plays is a list of objects on the Data tab, with a field each for the name, the address and the genre. The Internet Radio widget says which list, and which field is which - that is set in the Inspector. Dropping one onto a blank project does the rest: audio is switched on and a Stations list is made with one station in it, so it plays on the first build.

The first Internet Radio’s list is the player’s station table. A Play audio action set to a station picks an item from it, Next or previous station steps through it, and Play audio set to toggle with no address of its own starts its first station. Stations your own code adds with var_Stations_add() are part of it too.

A radio built from a blank project is usually: an Internet Radio, a Play/Stop button (toggle), Next and Previous buttons, a slider bound both ways to the player’s volume, and labels bound to the station, the genre and the track title - see Binding to the player. The radio also sends events a design can act on:

EventSent when
Station chosenA station was tapped in the list. Going back to the player screen is the usual thing to do.
Started playingSound is coming out, whatever started it.
StoppedThe player stopped, by Stop or because the stream ended.
Track changedThe stream sent a new title. Many stations never do.
Stream failedA stream could not be reached, or dropped and could not be got back.

The last four follow the player rather than a button, so a design reacts to what actually happened: a stream that dies is Stopped and Stream failed even though nobody pressed anything.

http and https stations

A station at an https:// address plays, and so does one whose http:// address sends the radio on to an https one, which most now do: the radio follows up to five such hops, whatever each is written as. A playlist (.pls, .m3u) at an https address is fetched the same way.

A secure connection costs memory: roughly 40 KB while it is being made. A board with PSRAM has it. On a board without, the decoder, the buffer, the WiFi stack and the LVGL draw buffer may not leave that much, the station does not start, and the radio's status says No secure connection to the station - so the designer warns about an https station on such a board, rather than refusing it, and http is the address to use where a station offers both.

The certificate of an https station is not checked: the board carries no list of authorities to check one against. What comes down the connection is a public broadcast and carries no credentials, which is a fact about this payload, not a general position. It is not a reason to relax anything that does carry them.

How far it has been tested: https stations and an https playlist have played on an ESP32-S3 board with PSRAM. No board without PSRAM has tried one.

Playlist links

Half the URLs people call "the stream" are really .pls or .m3u playlists. The firmware follows one to the first stream inside it, which is the difference between a station working and "it just does not play".

Stations served by a web server

Classic radio servers (Icecast, SHOUTcast) send a stream as one long answer. A station behind an ordinary web server - Nightride, for one - may send it in chunks instead, each with a small header of its own, and the audio library reads the raw connection with no idea those headers are there. They land in the MP3 data, the station's song-title text is found in the wrong place, and the result is a stream that arrives at full speed and still stutters and crackles.

The firmware avoids the question by asking for streams the older HTTP/1.0 way, which a server is not allowed to answer in chunks. So both kinds play; if you edit lw_audio.cpp, keep the useHTTP10() call where it is.

A radio of your own: the Radio icon

The Internet Radio widget is a station list. To lay a radio out yourself, drop a Radio icon instead: it holds the same station list and fields, shows one small mark in the colour of what the player is doing, and leaves the screen to you. Then:

ForUse
The station's name, the trackA label bound to a variable that reads Audio > Station name or Track title.
Play, stop, next, previousButtons with Play audio (Toggle play and stop) and Next or previous station.
Choosing a station from a roller or a dropdownBind the icon's Station to the roller's Selected index: the station is played when the line changes.
A switch that plays and stopsBind the icon's Playing to the switch's Checked.
A meter, the volumeA bar on Audio > Level; a slider on Audio > Volume.

A bound Station or Playing is acted on when it changes, not held against the player: a station that cannot be reached is asked for once, not ten times a second, and nothing plays for the value a design starts with unless Playing starts true.

Files from an SD card

The same player plays files. Give the Internet Radio (or the Radio icon) a folder under Also plays the MP3 files in - /music, or / for the whole card - and when the board starts, every .mp3 in that folder joins the station list after the stations the design has. From then on a file is a station: it is in the list, a touch plays it, Next or previous station steps to it, the volume and the level meters are the same ones. A design with no stations at all is an MP3 player; one with both is a radio that also plays what is on its card. The board needs an SD card among its devices (Hardware › Devices) and audio set to decode MP3. It is free on every plan, as the Internet Radio is.

SettingWhat it does
Also plays the MP3 files inThe folder. Its own files, and those in the folders inside it three deep, so a card laid out Artist/Album/01 Song.mp3 works as it is. Empty for none.
File typesMP3 (the default), MP3 and WAV, or MP3, WAV, AAC and FLAC, told apart by the ending of the name. AAC means the raw .aac kind; .m4a is not offered. AAC and FLAC each add a decoder, tens of KB of flash and of memory while one plays, so a board without PSRAM may not have room for a FLAC file.
Shuffle, repeat all and seekOff by default. On, it adds the controls below. Starts shuffled and Repeat all (on by default) say how the player starts.
When a file endsPlay the next in the list (after the last, the first), Play it again, or Stop.
Listed asWhat the list says under a file that is in the folder itself, where a station has its genre. A file in a folder of its own is listed as that folder instead: Pink Floyd/The Wall.

What a file is called

In the list, and as the player's Station name, a file is its name without the ending, with spaces where the name has underlines. Files are in the order of their addresses, capitals aside, so an album stays together and 01, 02, 03 play in order. Hidden files are left out, which is also the ._ twin a Mac leaves beside every file it copies.

What an MP3 file says of itself is the player's Track title: its artist and title, read from the file's ID3 tag (the older kind at the end of a file and versions 2.2, 2.3 and 2.4 at its front, in any of the four ways a tag writes text) and shown as Artist - Title, however long that is. A file with no tag has no title, so put the station name somewhere too. The tag's picture is stepped over, not shown. Only an MP3's tag is read: a WAV or FLAC file shows no title.

Playing, stopping and carrying on

Play audio set to toggle is play and pause for a file: stopped part-way, the next press carries on from where it stopped. Choosing the file in the list, or stepping to it with Next or Previous, begins it at its beginning. Two more values say where a file has got to - see Binding to the player - so a bar under the title is one binding.

Shuffle, repeat all and seek

Switch on Shuffle, repeat all and seek on the Internet Radio (or the Radio icon) and the player gains:

Seeking works in MP3 and AAC files. A WAV or FLAC file plays from its beginning only: Play after a stop, and a seek, start it again from the start. A design that reads these while the setting is off fails validation, with a message that says to switch it on. Arduino, compiled, not yet run on a board.

An SD card swapped while the board runs

In a design that uses the card while it runs (the player, a Stream to card log on the card, or a variable that reads the value below), the board checks the card about once a second. Pull the card and a file that is playing stops, the list empties of its files and the player says “The SD card was taken out”. Put a card back, the same or another, and its files are found and listed again, without a restart. Pulling an SPI card costs the screen one stall of about half a second, once.

The SD card present (1) or out (0) value (group SD card on the Data tab) says which, for a label or an icon. It is on wherever the design has a card, and a design that uses none of this is exported exactly as before. Arduino on an ESP32 only; the other exports refuse it with a sentence. Ran on an ES3C35P with a four-line SD card slot; an SPI card has been compiled but not run on a board.

How many, and what is not read

With hundreds of files in it the list still costs what a short one does: it makes only the rows its box can show and moves them under the names as you scroll.

Sounds of the design

A click, a chime, a pop: a short sound kept in the design and played from the board's own flash, with no SD card and no network. It is free, on the Arduino export, for an ESP32 with audio switched on.

  1. Open the Assets tab at the bottom and press Add sound. Choose a WAV or an MP3 of up to 256 KB. It joins the list under the images, where its name can be changed and the arrow beside it plays it.
  2. On a button, a screen or an automation, add the action Play audio, set Play to A sound of the design and choose it under Sound.
  3. Generate the code and upload it, and upload the filesystem: the sounds are files in the export's data/snd/ folder, and they go onto the board with the filesystem image, as images kept on the filesystem do. The export's README says how for PlatformIO and for the Arduino IDE. Skip it and each sound is silent, the serial port names the missing file, and the player's Last problem value says "A sound is not on the board".

WAV or MP3. A WAV starts the moment it is asked for and costs almost nothing to play, so it is the choice for anything short; one second of 16-bit sound at 22,050 Hz is 44 KB. An MP3 is about a tenth of that and starts a decoder (29 KB of memory) each time. Either may be mono or stereo.

What a sound does to what is playing. There is one player. A sound cuts off whatever was playing, another sound or a station, and a station stays stopped afterwards: play it again from the event that follows if it should come back. A sound asked for while another is still waiting to start replaces it.

The call returns at once. The file is opened and played by the audio task (or, with On its own task switched off, by the next pass of the loop), so a button does not wait for the filesystem. In your own code it is audio_play_sound(AUDIO_SOUND_<NAME>), with the names listed in lw_audio.h.

The sounds are on the same filesystem as the images when those are on flash, and on LittleFS otherwise. The Problems tab counts them against the filesystem partition and says when they do not fit. In Preview the browser plays the file itself. A design published to the gallery carries its sounds, as it carries its pictures, so only add sounds you have the right to hand on.

Stations from a playlist

The stations do not have to be typed in. Give the Internet Radio (or the Radio icon) a playlist under Takes more stations from, and the board fetches it once it is on a network and puts every station in it into the list, after the ones the design has. A playlist is a text file of names and addresses - .m3u, .m3u8 or .pls - and there are a great many of them: a radio directory's download, a list somebody keeps on GitHub, one you write yourself and put on any web server. Change the file and the board has the new stations at its next start, with nothing exported again. It is free on every plan, as the Internet Radio is.

Takes more stations fromWhat it does
A playlist at an addressOne playlist, fetched when the board starts. Its address goes under Its address.
The playlist chosen in a listMany playlists - one for each genre, say - and whichever is chosen is the one whose stations are in the radio. Choosing another takes the last one's stations out and puts the new one's in.

A list of genres

  1. On the Data tab add a list of objects - call it Genres - with two text fields, Name and Playlist, and an item for each playlist: what to call it, and its address.
  2. On the Internet Radio set Takes more stations from to The playlist chosen in a list and choose Genres under List of playlists. The address field is found by its name.
  3. Give people a way to choose. A Data list that shows Genres chooses an item when it is tapped (the Data list is part of Pro). On any plan, two buttons with the action Choose list item - The next one and The one before - step through them, and a label on a variable that reads the chosen item's Name says which it is.
  4. Nothing is chosen when the board starts, so nothing is fetched until something is. For a radio that starts with stations in it, add an automation: On startup, Choose list item, The first.

A station that is playing goes on playing while another genre is looked through, and its name stays on the screen. Go back to its genre and its row is lit again.

Two settings worth changing for a radio like this. The station list's own stations stay at the top of the list whichever playlist is chosen - a dropped Internet Radio comes with one - so for a radio that lists only the chosen genre's, delete them on the Data tab. And a playlist's names are as long as whoever wrote them made them: set the radio's Row height to 0 and each row is as tall as what it says, where a row of a set height ends a long name in dots.

The address

An http:// or https:// address of the file itself. For a file on GitHub, the address of its page - github.com/…/blob/main/…, which is what the browser shows and what people copy - is understood, and so is the one its Raw button leads to. A server that answers “go elsewhere” is followed.

What is read from it

What is left out, and how many fit

How far it has been tested: a list of sixteen genres, each a playlist kept on GitHub, has run on an ESP32-S3 board with PSRAM: the playlists were fetched and their stations listed and played, in rows as tall as what they say. The part that reads a playlist is held to some 1,400 of them on a PC. No board without PSRAM, and no classic ESP32, has fetched one.

Actions

ActionWhat it does
Play audioPlays a station from the list, a fixed address, an address held in a Text variable, or a sound of the design; or toggles between playing and stopped.
Stop audioStops and frees the decoder.
Next or previous stationSteps through the list, wrapping - Next on the last goes back to the first.
Set volumeFrom a slider, arc or bar; a fixed level; or a step up or down. A widget's own range maps onto 0-100 %, so a slider set 0-255 still means full at the top.
BeepWith The speaker chosen: a tone or a tune made by the player and mixed into whatever is playing, with no buzzer. See Beep through the speaker.

Binding to the player

A binding can read the player directly, with no variable in between - pick From the player as the binding's source. A level is a reading rather than something anything computes with, so making you declare a variable to show one would be ceremony.

ValueTypeNotes
PlayingBoolean
Track titleTextFrom the stream's ICY metadata. Many servers never send one, so this can stay empty - design for that.
Station nameTextThe row's name, shown immediately rather than waiting for the stream.
BitrateInteger, kbps
VolumeInteger, %Two-way. A slider bound to this is the volume control: it sets the level and follows it when something else changes it.
Level, left / rightInteger, %Taken off the samples on their way out, so a bar bound to one shows the actual audio.
Station numberIntegerWhich row is playing, from 0. -1 when none is.
How far through the fileInteger, %0 to 100, for a bar. 0 while a station plays: a stream has no end to be part-way to. Only in a design that plays files from a card.
Position in the file (set it to seek)Integer, %Two-way. The same as How far through the file, and writing it seeks: a slider bound to it both ways is a seek bar. Only with Shuffle, repeat all and seek on.
Shuffle, Repeat allBooleanTwo-way. A switch bound to either is its button. Only with Shuffle, repeat all and seek on.
Playing a file from the cardBooleanFor what should show for a file and not for a station. Only in such a design.
Fetching a playlistBooleanOn from when a playlist is asked for until its stations are in the list, or it could not be fetched. For a spinner, or a line that says “Loading”. Only in a design that takes stations from a playlist.
Stations from the playlistIntegerHow many the last playlist put in the list. Only in such a design.
Last problemText

Volume is the only one a control can drive back; the rest are measurements, so the direction choice only appears where it means something.

Why the meters are real

The audio library hands its output one frame at a time and keeps no record of it, so the export subclasses that output and keeps a decaying peak per channel on the way past. It costs about twenty lines and it is the difference between a VU meter and an animation: bind a bar to Level, left and it moves with the music, not with a timer.

The preview does the same thing from the other end. It never fetches the station - it synthesises its own music in the browser and taps the output with an analyser - so the meters in the designer are also driven by real samples, and each station sounds different because its name seeds the tune.

A phone, over Bluetooth

On the original ESP32 the board can be a Bluetooth speaker: a phone pairs with it and plays through this same output. Nothing on this page changes - the volume is the same volume, and Level, left and right follow the phone's sound as they follow a station, so a VU meter built for the radio is one for the phone. Whichever started last has the speaker: a phone that starts playing stops a station, and a station that is started asks the phone to pause.

The other way round, the board can play to a Bluetooth speaker. Everything on this page then goes to the speaker or to this output, whichever the action Choose the audio output last chose - a station, a card's files, a sound - and the choice is kept through a restart. While no speaker is connected the sound comes out of the board as before. The volume and the meters are this output's, so they work either way. A CYD's own 8-bit converter is then the fallback, and the speaker gets the full sixteen bits.

The microphone

Hardware › Audio › Microphone has a switch of its own, because a sound-level meter has a microphone and no speaker. Switched on, the export adds lw_mic.h and lw_mic.cpp: a task that reads the microphone all the time, so there is always a level to show, and that sends what it hears one of three ways when an action asks.

MicrophonePinsNotes
ES8311 microphoneOne: ASDOUT, the data line back from the codecThe microphone side of the codec the speaker uses. The clocks and the I2C bus are the output's, set just above it - and it needs that codec chosen as the output whether or not the output is switched on. Gain is the codec's own, 0 to 42 dB in 6 dB steps.
ES7210 microphonesOne: SDOUT1, the data line from the chipA microphone chip of its own beside an ES8311 that plays, on that codec's clock pins and I2C bus at an address of its own (0x40 unless the board straps it otherwise). Its inputs 1 and 2 are read - Channel is MIC1, MIC2 or both added together, which is what a pair side by side wants. Inputs 3 and 4 are left off: on boards that wire the speaker back into one of them as an echo reference, that is the one. Gain is each input's own amplifier, 0 to 36 dB. It needs the ES8311 chosen as the output, switched on or not.
I2S microphoneThree: SCK, WS, SDAn INMP441, an ICS-43434 or similar on pins of its own. It needs an I2S port to itself, so on a chip with one port (an S2, C3, C5 or C6) it cannot be used while the audio output is on. Channel is which half of the frame it answers in, set by its L/R pin - if it is silent, that is the first thing to change.

The presets for the Waveshare ESP32-S3-Touch-LCD-3.5B and the LCDWIKI ES3C35P bring the data pin with them (GPIO 14 and GPIO 16), so on those boards it is one tick box. So does the Waveshare ESP32-S3-Touch-LCD-3.49, whose two microphones are on an ES7210 (GPIO 6).

Where the sound goes

ActionWhat it does
Microphone to speakerPlays what the microphone hears through the speaker, at the player's volume. It needs a codec that carries both directions and the audio output switched on. On a small board the two are centimetres apart, so expect it to howl above a low volume.
Record to a fileA WAV file, 16 kHz and 16-bit mono, on the board's own flash or on an SD card. Start, stop, or one button that does both; it stops by itself at Stop after, or when the space runs out.
Play a recordingPlays a WAV file back through the speaker.
Send to a serviceListens for a number of seconds and POSTs the sound to a web address as it is heard. Text the address answers with can be shown; sound it answers with is played.
Set microphone sensitivityHow much the microphone is amplified, as 0 to 100 % of what its kind can do (0 to 42 dB on an ES8311, 0 to 36 dB on an ES7210, 0 to 30 dB on an I2S microphone). From a slider, arc or bar; a fixed level; or a step either way. It starts where Gain on the Audio tab puts it, and a change lasts until the board restarts.

A slider bound both ways to Microphone sensitivity does the same with no event to wire, exactly as one bound to the player's volume is the volume control.

One thing at a time: starting any of them finishes whichever was running, and a stream that is playing is stopped first, so what is recorded or sent is the room and not the radio.

What a design can read

These are offered wherever the player's values are - as a variable's source, or straight from a binding - whenever the microphone is switched on.

ValueTypeNotes
Microphone levelInteger, %How loud it hears, on a level meter's scale: 0 to 100 covers the top 48 dB, so every 10 is about 5 dB. Speech is a tenth of full scale or less, and a bar drawn to the voltage would hardly move. Bind a bar to it and set the sensitivity so speech fills about two thirds.
Microphone sensitivityInteger, %Where the sensitivity is set. Writable: see the action above.
Recording, Sending to a service, Microphone on the speakerBooleanTrue from the moment the button is pressed, so one button can show both states.
Length of the recordingInteger, sHow long the recording or the send has run. It stays at the last one's length.
Reply from the serviceTextWhat the address answered, however long. Empty until an answer comes back.
Reply statusIntegerThe HTTP status: 200 is success, 0 means none has finished, below 0 it never arrived.
Microphone problemTextWhy the last one stopped early - no WiFi, the filesystem is full - or empty. Worth a label of its own.

Sending to a service, and a spoken answer

The board sends one request: a POST whose body is a WAV file (Content-Type: audio/wav, 16 kHz, 16-bit mono) of exactly the length you chose. The length is fixed up front so the samples can go as they are heard, without the recording ever being held in memory - which is what lets a board with no PSRAM send ten seconds of sound. Stop early sends what there is at once and fills the rest with silence.

What comes back decides what happens next:

The address is one you run: the board posts a WAV and reads an answer, and what does the listening and the talking is up to whatever is behind it. Speech services want a key and a request shape of their own, and neither belongs in firmware. A relay is a page of code:

// node relay.js - then point the action at http://<this computer>:8080/hear
const http = require('http');
http.createServer((req, res) => {
  const parts = [];
  req.on('data', (d) => parts.push(d));
  req.on('end', () => {
    const wav = Buffer.concat(parts);              // 16 kHz, 16-bit mono
    // ...hand wav to whatever listens, and get its answer...
    const text = 'I heard ' + ((wav.length - 44) / 32000).toFixed(1) + ' seconds.';
    res.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
    res.end(text);                                 // shown by "Reply from the service"
    // A spoken answer instead:
    //   res.writeHead(200, { 'Content-Type': 'audio/mpeg',
    //                        'X-Reply-Text': encodeURIComponent(text) });
    //   res.end(mp3);
  });
}).listen(8080);

Both http:// and https:// addresses work. Over https the connection is encrypted but the server's certificate is not checked - the firmware carries no list of authorities to check one against - so it is privacy on the wire and not proof of who answered. It also costs about 40 KB of heap for the handshake, which a board with no PSRAM may not have to spare. The design needs a WiFi Setup widget, or it has no network to send over.

Recordings

A second of sound is 32 KB. A 4 MB board's filesystem holds under a megabyte, which is about twenty-five seconds; an SD card holds hours, and Keep it on chooses between them. The Check tab says how many seconds the flash has room for, and a recording stops with "the filesystem is full" in Microphone problem rather than overrunning.

The length in a WAV file's header is written first, for the whole time asked for. A recording stopped early has it corrected on a card, on FFat and on SPIFFS. LittleFS cannot change the first bytes of a file without writing the rest again, so there the header is left saying the length asked for: players read to the end of the file and stop, but the length they show beforehand is the one you set, not the one you recorded.

One port, two directions

A codec has one set of clock pins, so its microphone and its speaker are one I2S port. With the ES8311 microphone on - or an ES7210 beside it, which runs on the same clocks - the port is opened once at boot and stays open - a microphone that only worked while music played would be no microphone - and the player writes to it rather than opening its own. While a stream plays, the port runs at the stream's rate: the level still moves, but a recording or a send would be at the wrong speed, so starting one stops the stream, and starting a stream ends one.

Wake words and spoken commands

The board can listen for a wake word and then for a command, on the chip, with Espressif’s ESP-SR. Under Hardware › Audio › Microphone, tick Listen for a wake word and commands (the field is called Wake words and spoken commands). The microphone has to be switched on first.

What it needs, all at once:

SettingWhat it does
Wake wordHi ESP. It is the only wake word in the model file the Arduino core ships, so there is no other to choose.
CommandsUp to 40 phrases, said after the wake word, in English. Letters, spaces and apostrophes only, two to 63 characters: write numbers as words (“turn on light two”). Keep them short and unlike each other. Add a command adds a row; the bin button removes one.

Say “Hi ESP”, then a command. It is one command for each wake word: after a command, or after a few seconds of silence, the board waits for the wake word again. Two automation triggers react: Wake word heard, and Command heard with the Command to wait for. Each can run any action, so a command can change a screen, set a variable or switch an output.

Flash layout. The speech model, 3.3 MB, sits in a partition of its own, so the design uses the Arduino core’s own ESP SR 16M partition scheme and the app slot is 3 MB. In the Arduino IDE choose ESP SR 16M under Tools › Partition Scheme (the README the export writes says so); with PlatformIO the export flashes the model for you. Arduino only, compiled; nothing has been heard on a board yet, and Preview does not run the triggers.

In the preview

The preview listens to nothing and sends nothing - a design under test should not post to anybody's server. The level is a slider in the drawer, under Simulated inputs; a recording runs its time and stops at its limit; and a send takes the seconds you gave it, then reports status 200 and a stand-in line of text, so the widgets bound to each of those can be seen working before there is a board.

Why the files start with lw_

The generated support files are lw_wifi, lw_audio and lw_config rather than the obvious names. The sketch folder is on the compiler’s include path, and Windows and macOS have case-insensitive filesystems - so a file called wifi.h sitting beside the sketch is what #include <WiFi.h> finds, before the Arduino core’s. Its include guard has already fired by then, so the real header is never read and the compiler says 'WiFi' was not declared in this scope about a line that looks perfectly correct.

What it costs

On a board with no PSRAM everything comes out of the same 320 KB: the MP3 decoder wants about 29 KB, the ring buffer whatever you set, the WiFi stack around 40 KB, and the LVGL draw buffer its own share. It fits, with room to be careless in one place only. The designer adds up the first three and warns when they get tight, and the free heap is worth putting on a settings screen - it is the number you want when it starts misbehaving an hour in.