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 either the chip's own DAC or an external I2S one, and hands the design a volume, a track title and two levels it can put on screen.

Where the sound comes out

OutputPinsWhat you get
Internal DACOne: GPIO 25 or 268-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.
External I2S DACThree: BCLK, LRCLK, DINProper 16-bit stereo. A MAX98357A or a PCM5102.

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.
Decode on its own coreonPuts the decoder on core 0 and leaves LVGL core 1. Sharing one loop stutters the UI on every buffer refill. Turn it off only on a single-core chip.

Stations

The Stations panel on the Data tab is a table of what the radio plays: a name, an address and a genre. It is compiled into the firmware, and a Play audio action can point at a row by name rather than repeating a URL.

It lives at project level rather than inside a widget because three separate things use it - a list draws it, Play references a row, and a stored index restores it at boot.

http, not https

Use http where a station offers both. A TLS handshake wants roughly 40 KB of heap, and after the decoder, the buffer, the WiFi stack and the LVGL draw buffer there is not 40 KB spare on a board without PSRAM. The designer warns rather than refuses, because some stations are https-only and a warning that says what it costs is more use than a rule.

This is a fact about this payload, not a general position: a public radio stream carries no credentials, so plain http leaks nothing. It is not a reason to relax anything that does.

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

Actions

ActionWhat it does
Play audioPlays a station from the list, a fixed address, an address held in a Text variable, 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.

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

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.