ESP32-S3 decodes TV optical S/PDIF in software with the RMT peripheral, streams Danish DR radio over Ethernet, and feeds Beolab 4000s.
To make the experience fit your profile, pick a username and tell us what interests you.
We found and based on your interests.
Some things that cost me time and might save you some:
Rev2 of the PCB is drawn: a separate MIC5205 LDO with a ferrite bead for the DAC's analog supply, the DAC's soft-mute pin on GPIO38 with a pull-down so it stays silent through boot, a TSOP38438 IR receiver on GPIO4 so the box gets a dedicated remote of its own, the RCA footprints corrected, and the OLED reset line dropped (the 4-pin module doesn't have one).
Next up is the real test: the living room. After that, rev2 boards, a cleaner DAC supply, and a remote that isn't the TV's.
The Beolabs want far less level than a PCM5102A delivers. A comfortable TV level was around -32 dB of digital attenuation. With 16-bit samples that throws away about 5 bits. So:
Does it sound good? I ran an A/B test: roughly the same music from my PC and from the RadioStreamer, through the same speakers. I couldn't tell them apart, and I now think my earlier impressions were expectation bias. A scope FFT of a 1 kHz tone showed nothing above the scope's floor. Proper measurement would need a 24-bit audio interface and REW.
TV sound was about 10 dB quieter than the radio, and the TV remote did nothing useful because the volume lived on my phone. Then I found the LG setting Optical Out device: LG Sound Sync. With it, the TV sends a fixed level and passes the remote's volume and mute in the S/PDIF channel-status bits.
The format came from a thread on audiosciencereview.com about a Raspberry Pi Pico + PCM5102A LG DAC (based on elehobica's pico_spdif_rx): channel-status bytes 7 and 8 form a 16-bit word, volume 0-100 in bits 4-10, mute in bit 11.
The decoder now assembles the 192-bit channel-status block, using the B preamble to find the block start. Pressing volume down on the remote, I watched it log 26, 25, 23. Gain is (volume/100)² times a per-source trim, the same square law as the Pico project. Both the LG remote and the Apple TV's Siri remote now control the Beolabs. When the TV plays, the web volume slider greys out and shows the remote's value instead.
No HDMI-CEC, no IR receiver, no extra wire. Just a few bits that were already in the light.
After playing fine for a while, the output once turned into a continuous BRRR. Mute, test tones and channel changes did nothing. Unplugging Ethernet did nothing. Only a reboot fixed it. I spent a day on theories: corrupted I2S DMA descriptors, the DAC's PLL losing lock, ringing on BCK. I added diagnostics for each.
Then I caught it with a serial logger running. After a TLS read timeout left damaged data in the buffer, the MP3 decoder found a false sync word, returned "invalid frame header", and consumed nothing. The next sync search found the same spot. 123,000 errors in 7 minutes. The decode task never blocked, so it pinned core 0, the watchdog fired 88 times, and the lower-priority network task starved, so fresh data could never arrive to break the loop. The web server ran at a higher priority, which is why the UI still answered.
The BRRR itself was most likely 300 log lines per second of UART traffic coupling into the analog output.
The fix is three lines of intent: on an error that consumed nothing, skip one byte past the false sync; log decode errors at most once a second; yield after 32 errors in a row. It has not recurred. Lesson: when the symptom ignores every control you have, look for a task that stopped yielding.
A related lesson from an earlier night: opening a serial monitor on a misbehaving board can reset it through the USB auto-reset circuit, destroying exactly the state you wanted to look at.
Getting TV and radio to coexist took longer than either source alone.
Clocks. The TV is 48 kHz on its own crystal, the radio 44.1 kHz on ours. I2S is reclocked whenever the source changes. For the TV, decoded samples go in 2 ms blocks through a buffer held at 4-12 blocks; outside that band one frame is dropped or repeated. Total added latency is about 40 ms, so lip sync is fine. My first version corrected on every block, which after a dropout meant a repeated frame every 2 ms for seconds: an audible buzz made entirely by the drift correction. It is now limited to one correction per 40 ms, and a real underrun re-primes the buffer instead.
Switching. The TV takes over on non-silent PCM (any sample beyond about -60 dBFS) and releases after 10 s of silence or 300 ms without signal. Meanwhile the radio stays connected and keeps the newest ~4 s of compressed audio, so it resumes instantly.
RAM. Starting the TOSLINK decoder made every TLS handshake fail with MBEDTLS_ERR_SSL_ALLOC_FAILED, while the heap reported 248 KB free. Moving big buffers to PSRAM fixed TLS but slowed the TOSLINK path to 38k of 48k frames/s: its 64 KB buffer carries about 8 MB/s each way, and PSRAM traffic competes with cache. So the TOSLINK buffers went back to internal RAM, and mbedTLS went to PSRAM instead (CONFIG_MBEDTLS_EXTERNAL_MEM_ALLOC).
That also fixed a mystery I hadn't connected: sporadic "spi transmit failed" from the W5500. Every failure was a frame wrapping the end of the W5500's 16 KB RX ring. The driver reads it in two pieces, the second into an unaligned address, so the SPI driver needs a DMA-capable bounce buffer. Internal DMA RAM had dipped to 207 bytes during the TLS handshake. Now it never drops below about 29 KB.
Flash. Saving a volume change to NVS stalls everything running from flash on both cores. With a slider sending a value every 200 ms, that was audible on the TV. Settings now save 3 s after the last change, and never while the TV is playing.
TV sound worked, but every 70-90 s it distorted for 5-10 s. Not the network (it happened with Ethernet unplugged). Not pulse measurement (error chunks showed clean SHORT/LONG/SYNC clusters). Not dropped data (every subframe was exactly 64 UI, in the right B/M/W order). The errors sat exactly at DMA chunk boundaries.
In DMA mode, the IDF RMT driver uses the receive buffer as a ring of nodes and gets one interrupt per completed node. If that interrupt is delayed past the next node, about 0.43 ms, two completions merge into one interrupt. The driver hands over one node and advances its index by one. From then on it lags the DMA by a node. Each merge adds another node of lag. When the lag wraps around to the node the DMA is writing, the driver delivers half-written data, and you hear it.
With 9 nodes in the ring, roughly 9 merged interrupts per wrap explained the 70-90 s period. An earlier version had only 2 nodes and produced 10-50 s "waves" of distortion, which fits too.
The fix is blunt but effective. When parity errors show up in two chunks within 20 ms, the decoder restarts the RMT receive, which puts the DMA and the driver back on node 0. Each event now costs 2 parity errors instead of about 1,900. I also create the RMT channel from the decode task on core 1, so its interrupts land on the quiet core instead of the busy network core. Events dropped from every ~80 s to every ~4-5 minutes, and so far I haven't been able to hear one.
The RMT peripheral was built for IR remotes. In receive mode it records each pulse as a duration in ticks. At 40 MHz, a 48 kHz S/PDIF stream gives three pulse widths:
Pulse | Unit intervals | Duration | RMT ticks at 40 MHz |
|---|---|---|---|
SHORT | 1 UI | ~163 ns | ~6.5 |
LONG | 2 UI | ~326 ns | ~13 |
SYNC | 3 UI | ~488 ns | ~19.5 |
The decoder doesn't know the sample rate in advance. At startup it collects 2000 pulse widths, takes the 20th percentile as one UI (SHORT pulses dominate), and derives the thresholds. A calibration is rejected unless LONG lands between 10 and 21 ticks and SHORT is about half of it, because noise on a dangling input once calibrated as "208 kHz".
I wrote the protocol layer (biphase-mark, preambles, subframes, parity) as portable C with no ESP-IDF dependency and tested it on the PC with synthetic pulse streams before the TOSLINK cables even arrived. All tests passed. On the real TV, 75% of subframes failed parity.
The bug was in my model, so the tests shared it. A preamble is not one long pulse. It is 8 UI of pulses with three shapes: B = 3,1,1,3, M = 3,3,1,1, W = 3,2,1,2. I treated the first 3-UI pulse as the whole preamble and decoded the rest as data. Every M subframe got an extra bit and every W subframe aborted. Fixing the tests first (they now fail against the old decoder), then the decoder, also gave me left/right for free: W means right.
Then throughput. About 4 million pulses per second must be classified and decoded, and the task kept up with less than half. What got it there: 240 MHz, -O2 for just the two decode components, a batch feed function so the per-pulse path inlines, __builtin_parity, and later IRAM placement via linker fragments, because the two cores share one instruction cache and the radio's TLS and MP3 code kept evicting the decode loop.
Result: 48,000 frames/s from the LG, about one parity error per million frames.
The PCM5102A came up first, with a firmware test tone: different square-wave frequencies on left and right. The scope confirmed amplitude against the datasheet's 2.1 Vrms full scale and clean channel separation. I had two false alarms on the way, both mine: a ground clip on an RCA signal pin, and the same mistake on a second board.
Then the radio. DR has a public v5 API that lists every channel's stream URLs. They are plain HTTPS Icecast streams: MP3, 128 kbps CBR, 44.1 kHz, no auth. The ESP32 fetches them with esp_http_client and decodes with libhelix-mp3 from the ESP Component Registry. Every connect logs one MAINDATA_UNDERFLOW, because joining a live MP3 stream mid-way means the first frame refers to bit-reservoir data you never got. That is normal.
The first night it stopped and never came back: no reconnect logic at all. It also stuttered, because network read, decode and I2S write ran in one task with a 4 KB cushion. Fix: a network task that only drains the socket into a 128 KB stream buffer in PSRAM (about 8 s of audio) and reconnects forever, and a decode task that plays at its own pace.
There was also a hum from power-on until the stream started. With XSMT tied high the DAC never mutes, and I only started I2S when the first MP3 frame arrived. Until then BCK, LRCK and DIN were floating GPIOs. Starting I2S first thing in app_main, feeding zeros, cured it.
The carrier PCB (KiCad) takes an ESP32-S3 DevKitC-1 clone in sockets. Sheets: ESP32, Ethernet, Sound Coder. The pin plan puts Ethernet on one header row and audio on the other. The W5500 goes through the GPIO matrix rather than the IOMUX SPI pins, which caps SPI at 40 MHz. The radio needs a tiny fraction of that.
The first schematic had a TI DIR9001 S/PDIF receiver. I removed it. The ESP32-S3's RMT peripheral can timestamp edges, and others (notably netham45's esp32-spdif) had shown software S/PDIF is possible. So the optical input is now just a Cliff FCR684205R TOSLINK receiver into GPIO42, with 100 nF on its supply and 22 pF on its output.
Things that bit me:
The brief I gave myself was short. Play the LG TV and DR radio through the Beolab 4000s, from one board, controlled from an iPhone. Then I wrote down what it should not do, which turned out to be more useful.
The PCM5102A DAC has no control bus at all, just a few config pins. That decided the architecture early: both sources must converge into one output stage that does volume, mute and source switching in software, before I2S.
Create an account to leave a comment. Already have an account? Log In.
Become a member to follow this project and never miss any updates