Skip to content

Migrate from ESP8266Audio to BackgroundAudio - #11272

Draft
vidplace7 wants to merge 3 commits into
developfrom
ESP32-BackgroundAudio
Draft

Migrate from ESP8266Audio to BackgroundAudio#11272
vidplace7 wants to merge 3 commits into
developfrom
ESP32-BackgroundAudio

Conversation

@vidplace7

Copy link
Copy Markdown
Member

🤖

This pull request introduces a major refactor of the audio playback system, replacing the ESP8266Audio-based RTTTL playback with a new, dependency-free, asynchronous PCM generator and playback thread. The new implementation is more memory-efficient, testable, and fixes several subtle bugs in the previous approach. The public API remains mostly unchanged, but text-to-speech support is dropped for now. The most important changes are summarized below.

Audio playback system refactor:

  • Replaces the ESP8266Audio RTTTL playback with a new RtttlPcm class, which parses RTTTL strings and generates square-wave PCM samples directly, matching the pitch and timing of the old implementation but without external library dependencies. [1] [2]
  • Introduces a new AudioThread class that manages asynchronous playback, amplifier power control, and DMA feeding. Playback is now non-blocking, and the amplifier is only powered when needed, reducing power usage and avoiding audible pops. [1] [2]
  • Removes text-to-speech support (readAloud()), as the new system does not include a speech synthesizer due to resource constraints and symbol conflicts.

API and code structure improvements:

  • Adds direct support for tone-list melodies (ToneDuration), allowing system sounds to be played without converting them to RTTTL strings. [1] [2]
  • Moves the definition of ToneDuration to audio/RtttlPcm.h for better encapsulation and to support both RTTTL and direct tone playback. [1] [2]

Bug fixes and safety improvements:

  • Fixes several subtle bugs in RTTTL parsing and playback, including proper bounds checking, rest handling, and amplifier control to avoid pops and power leakage. [1] [2]

Overall, these changes modernize the audio system, reduce dependencies, and improve reliability and maintainability.

@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 499d4169-af50-4dc6-a5b0-2fdafab7d51d

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ESP32-BackgroundAudio

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

⚡ Try this PR in the Web Flasher

Flash this PR in the Web Flasher

firmware commit boards expires

Warning

This is an automated, unreviewed CI test build. Back up your device configuration
before flashing, and only flash devices you are able to recover.

Supported boards built by this PR (31)
Device Board Platform
Crowpanel Adv 3.5 TFT elecrow-adv-35-tft esp32-s3
Heltec HT62 heltec-ht62-esp32c3-sx1262 esp32-c3
Heltec Mesh Node 096 heltec-mesh-node-t096 nrf52840
Heltec Mesh Node T1 heltec-mesh-node-t1 nrf52840
Heltec Mesh Node T114 heltec-mesh-node-t114 nrf52840
Heltec V3 heltec-v3 esp32-s3
Heltec V4 heltec-v4 esp32-s3
Meshnology W10 meshnology_w10 esp32-s3
Meshnology W12 meshnology_w12 esp32-s3
Raspberry Pi Pico pico rp2040
Raspberry Pi Pico W picow rp2040
RAK WisMesh Pocket V3 rak_wismesh_pocket nrf52840
RAK WisMesh Pod rak_wismesh_pod nrf52840
RAK WisMesh Repeater Mini V2 rak_wismesh_repeater_mini nrf52840
RAK WisMesh Tag rak_wismeshtag nrf52840
RAK WisBlock 11200 rak11200 esp32
RAK WisBlock 11310 rak11310 rp2040
RAK3312 rak3312 esp32-s3
RAK WisBlock 4631 rak4631 nrf52840
Seeed SenseCAP Mesh-Tracker-X1 seeed_mesh_tracker_X1 nrf52840
Seeed Wio Tracker L1 seeed_wio_tracker_L1 nrf52840
Seeed Xiao NRF52840 Kit seeed_xiao_nrf52840_kit nrf52840
Seeed Xiao ESP32-S3 seeed-xiao-s3 esp32-s3
Station G2 station-g2 esp32-s3
Station G3 station-g3 esp32-s3
LILYGO T-Deck t-deck-tft esp32-s3
LILYGO T-Echo t-echo nrf52840
LILYGO T-Echo Plus t-echo-plus nrf52840
LILYGO T-Impulse Plus t-impulse-plus nrf52840
LilyGo T3-C6 tlora-c6 esp32-c6
Seeed SenseCAP T1000-E tracker-t1000-e nrf52840

Build artifacts expire on 2026-08-28. Updated for becefff.

@vidplace7 vidplace7 added the enhancement New feature or request label Jul 28, 2026
@vidplace7
vidplace7 requested a review from thebentern July 28, 2026 15:10
@vidplace7
vidplace7 force-pushed the ESP32-BackgroundAudio branch from 39b18a2 to a710f58 Compare July 28, 2026 15:11
@vidplace7
vidplace7 force-pushed the ESP32-BackgroundAudio branch from a710f58 to a80181d Compare July 28, 2026 15:13
The 8x128 DMA ring held 46ms of audio and was fed from the cooperative
main loop, so any stall longer than that drained it and auto_clear
played silence - audible as stuttering exactly when a notification
redraws the screen. Worst on MUI builds: the tft task holds spiLock
across whole LVGL cycles (render + flush), and on shared-SPI boards
like T-Deck the main loop then blocks behind it on every radio op.

- Deepen the ring to 16x256 (185ms; 16KB DMA RAM held only during
  playback), falling back to the old geometry when DMA RAM is tight.
- Preload the ring with real samples before enabling the channel, via
  a begin()/preloadFrames()/start() split. Startup latency no longer
  scales with ring depth: a click is audible after a 10ms amp lead-in
  instead of after a full ring of silence, and playback starts at
  maximum ring depth - right when the notification redraw begins.
- Feed from a small per-playback task (4KB stack, priority 2, pinned
  to loopTask's core) that touches only the generator and the I2S
  channel, so display/radio spiLock contention cannot starve it. Falls
  back to main-loop feeding if task creation fails. This is not
  upstream's task coming back: that one exists to maintain
  availableForWrite() from ISR notifications, the machinery
  MeshtasticI2SOut deliberately avoids.
- Warn with the measured stall length whenever the feed gap exceeds
  what the ring holds - a proven dropout, attributable in tester logs.

Validated on T-Deck MUI (DEFCON build): full ringtone with nag over
live mesh traffic, clean audio, zero dropout warnings.
Without it this file's setup()/loop() definitions get plain C++
linkage while portduino's main.cpp expects Arduino.h's declarations,
and the coverage build fails with 'undefined reference to setup/loop'
(other suites include it transitively via meshUtils.h/TestUtil.h).
Fixes the failing test-native CI job on this PR; suite runs 30/30.
@Ixitxachitl

Ixitxachitl commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

This is a much better end state than what's on develop — dropping the ESP8266Audio dependency and getting the generator under native test is the right call, and the beginTones() path removing the tone→RTTTL round-trip neatly deletes a whole class of bug.

I'd been working the same problem from the other direction (fixing the existing AudioGeneratorRTTTL path rather than replacing it) and got to a working branch before I found this PR. Yours supersedes it, so I'm not going to open a competing one. A couple of things from mine that might still be useful:

The boot melody still doesn't play on I2S boards

playTones() only takes the I2S path when audioThread is already constructed:

if (moduleConfig.external_notification.use_i2s_as_buzzer && audioThread) {
    audioThread->beginTones(tone_durations, (size_t)size);
    return;
}

But playStartMelody() runs at main.cpp:852 and audioThread = new AudioThread() at :1034. At boot the guard is false, so the call falls through to the PIN_BUZZER path — which T-Deck, T-Watch S3, T-LoRa Pager, Cardputer Adv and Meshnology W10 don't have. The start melody has therefore never been audible on those boards.

This is pre-existing on develop, not something this PR introduces — just flagging it since it's the one gap I found that the rewrite doesn't already close.

I have a one-commit fix on a branch off becefffcb: park the tones in a static buffer when audioThread is null, and hand them over from a small buzzOnAudioThreadReady() called from setup(). I deliberately did not just move the AudioThread construction earlier — your constructor comment makes it clear the placement is load-bearing, so the handover goes after lateInitVariant() instead, once the amp enable pin is actually an output.

Confirmed on a T-Deck (t-deck-tft) and an M5Stack Cardputer Adv: the start melody is now audible on boot, which it never has been on either board.

One judgement call in there that's yours to make: dreamcatcher defines both HAS_I2S and PIN_BUZZER 48, so today its boot chime falls through to the piezo. With the melody queued it plays over I2S instead. That seems like what use_i2s_as_buzzer is asking for, but it is a behaviour change for that board and I didn't want to make it silently.

Happy to open it as a PR against ESP32-BackgroundAudio, or just paste the diff here — whichever is less disruptive.

Things I checked that turned out fine

Mentioning these only so you know they've had a second pair of eyes: the octave 4–7 clamp initially looked like a gap to me, but beginTones() passes raw Hz straight to startNote() so system tones never touch the table, and test_octave_clamped_low_and_high makes the intent explicit. RtttlPcm also independently fixes the samplesSent << 10 overflow and the b#7 out-of-bounds index I'd hit, plus the dotted-note-before-octave case I'd missed entirely.

Hardware

I've flashed this branch on two in-tree boards — a T-Deck (t-deck-tft) and an M5Stack Cardputer Adv. Both build and run fine, and system tones and user ringtones sound correct on both.

I also have a T-Watch Ultra on an out-of-tree variant of my own; not something I'm proposing here, but it's a third differently-wired board on the same path (different DAC pinout, no MCLK) so I'm happy to run this there too if that's a data point you want.

The gap I can't cover: none of my boards define AUDIO_AMP_ENABLE, so the amp power-down, the pop avoidance and the drain-before-cut behaviour really want someone with a T-LoRa Pager or Meshnology W10.

@Ixitxachitl

Ixitxachitl commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Separate topic from my last comment: text-to-speech, and a way to keep it for ~16KB.

What goes away

The PR description says "text-to-speech support is dropped for now," which undersells it slightly — what disappears is user-visible:

  • the Read Aloud entry in the message-action menu (MenuHandler::messageResponseMenu)
  • TestAnnounce in the test menu
  • ESP8266SAM from the lib_deps of every I2S variant

So a T-Deck or Cardputer Adv owner on develop today has a menu item that reads the newest message aloud, and after this it's gone. Might be entirely the right call — SAM sounds like a 1982 answering machine — but it's a shipping feature leaving the UI rather than an internal API change, so it seemed worth its own thread.

espeak-ng isn't the way back

Just to close this off, since BackgroundAudio already vendors it: I counted the actual array bytes rather than trusting file sizes.

table bytes language-specific?
phoneme/phondata.h 554,739 no
dict/en_dict.h 168,203 yes
phoneme/phontab.h 58,651 no
phoneme/phonindex.h 43,315 no
phoneme/intonations.h 2,311 no
total 827,219

Your ~947KB figure is about right. Worth noting the dict/ tree is 76MB across ~100 languages but only one is ever compiled, so "just ship en-US" is already what happens — the bulk is phondata, which is shared phoneme data and can't be trimmed by dropping files. On the Cardputer that leaves 911,353 bytes free against 827KB of data alone, i.e. ~84KB for the entire synthesizer. It does not fit, and shrinking phondata means re-running espeak-ng's data compiler on every update.

Why naively re-adding SAM fails

I tried it: re-added ESP8266Audio + ESP8266SAM and wrote a shim from AudioOutput to MeshtasticI2SOut. Link fails:

multiple definition of `mad_timer_negate' ...  BackgroundAudio/libmad/timer.c.o
multiple definition of `SetSpeed'        ...  BackgroundAudio/libespeak-ng/setlengths.c.o
multiple definition of `speed'           ...  BackgroundAudio/libespeak-ng/synthesize.c.o

Your SetSpeed/speed clash is real and fires immediately — espeak-ng is linked, because PIO compiles all of BackgroundAudio's src/. And there's a second one you didn't mention: libmad is duplicated between ESP8266Audio and BackgroundAudio, so those two can never coexist, SAM or not.

What does work

Both blockers dissolve if SAM stops being a library dependency:

  1. Vendor SAM into src/audio/sam/. It's ~180KB of GPL-3 source, same licence as the firmware, and upstream is effectively frozen.
  2. Rename its two colliding globalsSetSpeedsamSetSpeed, speedsamSpeed, both in sam.c. That's the entire espeak overlap.
  3. Drop ESP8266Audio entirely. SAM only ever used AudioOutput for begin/SetRate/SetChannels/ConsumeSample, so a 10-line abstract class replaces it and the libmad duplication goes away with the dependency.

A ~35-line adapter then bridges that interface to MeshtasticI2SOut::writeFrames(), and readAloud() comes back roughly as it was. The menu entry returns behind a new MESHTASTIC_ENABLE_TTS flag rather than HAS_I2S, so it's opt-in per variant instead of automatic on every I2S board.

Measured on m5stack-cardputer-adv, against this branch as the baseline:

RAM Flash
this branch 136,192 2,430,983 (72.7%)
+ vendored SAM TTS 136,408 2,447,583 (73.2%)
delta +216 B +16,600 B

16.6KB of flash and 216 bytes of RAM, and I've flashed it on a Cardputer Adv and used Read Aloud — it works.

I went ahead and built the pull-style version, since the blocking one wasn't really proposable against this architecture. It's on feat/backgroundaudio-sam-tts, two commits on top of becefffcb.

What it does

SamPcm wraps the vendored SAM in the same pull contract as RtttlPcmbegin() / generate() / done() / reset() — so speech feeds through your existing feeder task instead of a blocking side channel. AudioThread gains a source selector and nothing else; RtttlPcm and the feeder loop are untouched.

The commits are split so the two decisions can be judged separately:

  1. Vendor SAM (src/audio/sam/) — kept byte-identical to upstream apart from four edits, so it stays diffable: SetSpeed/speed renamed, the AudioOutput dependency swapped for a 4-method abstract class, one <> include changed to "". That last bit is what actually kills the libmad collision — ESP8266Audio never enters the link. Added to the trunk ignore list so reformatting doesn't bury those four edits in a few thousand whitespace changes.
  2. Wire it upreadAloud() and the Read Aloud menu entry return behind a new MESHTASTIC_ENABLE_TTS, opt-in per variant rather than automatic on every HAS_I2S board. Currently set on t-deck and m5stack-cardputer-adv.

The design constraint, since it shaped the result

SAM can't be made resumable — SAMMain() renders a whole utterance in one call and emits every sample through a callback, with no suspension point. Buffering it up front isn't an option either: 254 characters is a few hundred KB of PCM and the boards without PSRAM have nowhere to put it.

So the render runs on its own task pushing into a 4KB ring (~186ms) and generate() drains it. Memory is capped at the ring regardless of utterance length, and the render task sits at priority 1, below your feeder at 2, so it can never delay a DMA refill.

One subtlety worth flagging for review: your feeder reads generate() == 0 as end-of-stream, which is correct for RtttlPcm but not for a producer-backed source whose ring can be momentarily empty. SamPcm::generate() therefore waits for the renderer rather than reporting completion early, bounded at 250ms so a wedged renderer can't hold the amplifier on.

Cost

flash
this branch 2,430,983 (72.7%)
+ TTS 2,448,007 (73.2%)
delta +17,024 B

Plus 4KB of heap for the ring and a 4KB task stack that only exists while speaking. Against that, BackgroundAudio's own espeak-ng is ~827KB of tables before any code, and doesn't fit the 8MB app partition at all.

Testing

Read Aloud works on both boards I have:

board env partition used
M5Stack Cardputer Adv m5stack-cardputer-adv 3,342,336 2,448,007 (73.2%) no PSRAM
T-Deck t-deck-tft 6,553,600 3,835,935 (58.5%) PSRAM

The Cardputer is the one that matters — no PSRAM is exactly the case the ring design exists for, and it's the tighter partition of the two. The UI also stays responsive during an utterance, which the old blocking Say() never allowed; that was the point of reshaping it rather than just re-adding it.

To be clear about what this is

You closed this decision already, and the ~947KB figure was right for espeak. My only real claim is that "espeak costs ~947KB" and "TTS costs ~947KB" turn out to be different statements, and 17KB seemed worth putting on the table before the feature disappears.

Entirely your call whether it's worth carrying vendored SAM for speech that sounds like a 1982 answering machine. If you'd rather TTS just go, say so and I'll drop it — no argument from me. If you want it, happy to open it as a PR against this branch, rebase it onto whatever this becomes, or hand it over however suits.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants