• Lessons, and rev2

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    Some things that cost me time and might save you some:

    • A ~40 s wait for an IP address wasn't the firmware. Link came up at 3 s, DHCP answered at 39.5 s. The UniFi switch port wasn't in Edge mode, so STP held it for ~30 s. Edge mode: 5-6 s.
    • Check your RCA footprint. My downloaded footprint treated the pins as unnumbered holes, and my hand-fixed pad assignment was wrong. Grounding one output silenced both channels. It also explained part of the hum.
    • Host-test your protocol code, but know the tests share your assumptions. The S/PDIF tests were green and the decoder was wrong.
    • When an ESP32 audio task misbehaves, look at the CPU first. Per-task run-time stats, logged only when an underrun happens, found more causes than any scope.

    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.

  • Volume done properly, and does it sound any good?

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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:

    • 32-bit output. Sources still produce 16-bit PCM, but mute, test tones and volume are applied in float and written as 32-bit I2S slots. Attenuation keeps 24-bit precision.
    • Volume in whole dB. The slider runs from off and -39 dB to 0 dB in 1 dB steps, ramped over 10 ms so changes don't zipper.
    • Per-source trims. DR's radio is mastered far hotter than TV sound. A radio trim and a TV trim (0 to -40 dB) on the settings page match them once and then stay put.
    • Starts muted. After power-on the box is silent until you unmute. After a crash or watchdog reset, it restores the mute state it had, kept in an RTC_NOINIT word, so a reboot in the middle of a film doesn't mute it.

    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.

  • Hacking the TV remote's volume out of the light

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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.

  • BRRRRRRRRRRRRRRR

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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.

  • Two sources, one clock, too little RAM

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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.

  • The ring buffer that slowly fell behind

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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.

  • Decoding S/PDIF with a remote-control peripheral

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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.

  • First sound

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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 hardware, and the parts that fought back

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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:

    • Octal PSRAM eats GPIO35-37. The DevKitC header exposes them. On an N16R8 module they belong to the PSRAM. Espressif's docs say so; I had to read them twice.
    • The clone's RGB LED is on GPIO48, like a genuine v1.0, even though it's marked v1.1. That freed GPIO38 for the DAC's soft-mute pin in rev2.
    • My first OLED NACKed every I2C transaction. SDA, SCL and reset all scoped fine. The module selects SPI or I2C with solder resistors, and it shipped wired for SPI. I swapped it for a real I2C module.
    • A TOSLINK pinout scare. A third-party footprint looked mirrored against the datasheet. I checked the housing's positioning lock and light shutter against the drawing instead of trusting pad numbers. The footprint was right. A torch into the receiver showed its PWM on the scope. Lesson: verify connectors by mechanical keying, not by comparing libraries.

  • One box, two sources, zero buttons

    Carsten Bøgh Poulsen • 4 hours ago • 0 comments

    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.

    • No source selector. TV is never a button. When the TV makes sound, it plays. When it stops, the radio returns. The UI only shows what is playing.
    • Volume works for every source. S/PDIF has no volume concept, and I didn't want to depend on a TV menu setting. So the box scales the samples itself, after both sources meet.
    • No Wi-Fi. The living room has a network socket, so the board uses a W5500 over SPI. One less thing to drop out.
    • No app. The ESP32 serves plain HTML, CSS and JS. Add it to the iPhone home screen and it looks like an app.
    • The channel list is data. Name plus stream URL in a table in firmware. Adding a channel is one line, not new UI code.

    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.