Skip to content
Wouter Van de Wiele edited this page Aug 6, 2026 · 2 revisions

Audio

One class, AudioManager (lib/AUDIO_MANAGER/audio_manager.h/.cpp), owns all audio: internet-radio streaming and baked-in mood-sound clips both play through the same shared AnalogAudioStream (internal DAC, I2S0 → GPIO 25), since only one I2S output exists. It's instantiated once, in BootMode::STREAM only:

// src/main.cpp:574
audioMgr = new AudioManager(io, pin_aud_en, pin_aud_sig);
Boot mode audioMgr Effect
STREAM (default) live AudioManager* radio + mood sounds both work
WEB nullptr no audio hardware allocated at all — MoodManager::setAudioManager(nullptr), every sound/playSound() call is a silent no-op

Built on arduino-audio-tools

  • Helix MP3 decoding, gating the external amplifier via SX1509 pin_aud_en.

Dead code — do not resurrect by accident

Three older audio libraries still compile but are not referenced from main.cpp and are not part of the running system:

Path Status
lib/AUDIO_WEB_DRIVER/ (AudioWebDriver) dead — only remaining reference is lib/DEBUG_CLI, whose begin() call is commented out (src/main.cpp:588)
lib/CAT_AUDIO/audio_driver.cpp/.h (AudioDriver) dead — not instantiated anywhere
lib/AUDIO_DRIVER/ contains only .zip archives, no live source

AudioManager in lib/AUDIO_MANAGER/ is the only live implementation.

Mood sound clips

Clips are compiled-in byte arrays, not files — each sound has a header next to audio_manager.h/.cpp (angry.h, purr.h, calling.h, ...) holding a data_<name>_mp3[] array baked in from an mp3 at build time. playSound() wraps the array in a MemoryStream, decodes it with a fresh MP3DecoderHelix, and copies it out through a dedicated VolumeStream wrapping the same shared _out. There is no SPIFFS involvement and no .mp3 path anywhere in this pipeline.

The Sound enum (audio_manager.h:14-29) is the single source of truth, shared with kSoundNames[] (used by both the GEM menu select and the web dashboard select so neither can drift from it):

enum class Sound {
    Angry, Bored, Calling, Confused, CoolOpera, CoolWobble,
    Cute, Hissing, InnerAngry, Purr, Snore, SoftAngry, Trilling, Wink,
};

Triggers: MoodManager (Purr on Happy/Love, Angry on a 3-shock streak, Bored on long idle — gated by Blackboard.mood_sounds_enabled, a plain on/off toggle with no daily limit or cooldown), the alarm's offline backup clip, AudioCmd::PLAY_CLIP/PLAY_CLIP_FORCE (dashboard/menu), and TestElement's diagnostic Snore cue.

Arbitration: radio always wins

Only one of {radio, clip} is ever active. playSound() refuses outright if radio is currently active (audio_manager.cpp:302-308); playRadio() unconditionally tears down any playing clip first (stopSound() at audio_manager.cpp:155). Both pipelines' AudioTools objects (streams/decoders/players) are heap-allocated lazily on play and destroyed as soon as they're not needed — nothing sits around idle between sounds.

flowchart LR
    subgraph Radio[Radio pipeline - priority source]
        NVS[(NVS station list)] --> URL[ICYStream]
        URL --> SRC[AudioSourceURL]
        SRC --> DEC[MP3DecoderHelix]
        DEC --> PLAYER[AudioPlayer]
    end
    subgraph Clip[Clip pipeline - subordinate source]
        MEM[MemoryStream data_x_mp3] --> CDEC[MP3DecoderHelix]
        CDEC --> ENC[EncodedAudioStream]
        ENC --> VOL[VolumeStream]
    end
    PLAYER --> OUT[AnalogAudioStream I2S0 to DAC GPIO25]
    VOL --> OUT
    OUT --> AMP[Amp, enabled via SX1509 pin_aud_en]

    PLAYER -. "playRadio tears down any playing clip first" .-> VOL
    VOL -. "playSound refused if radio active" .-> PLAYER
Loading
  • No MultiDecoder, MimeDetector, HLS, AAC, or MPEG-TS codec-sniffing exists anywhere in the live code — the radio pipeline is a fixed ICYStream → AudioSourceURL → MP3DecoderHelix → AudioPlayer chain (AudioManager::_createWebPipeline, audio_manager.cpp:116-143), MP3-only.
  • I2S DMA config: 8 buffers × 512 B, set once in AudioManager::begin() (audio_manager.cpp:60-61) and shared by both pipelines — large enough to absorb WiFi interrupt jitter, and clips tolerate the larger buffer fine.
  • Both pipelines fade in/out (_rampWebVolume/_rampClipVolume, 150 ms, 6 steps) and wait FILL_DELAY_MS (80 ms) after enabling the amp before unmuting, to avoid a pop.
  • Radio and clip volumes are tracked independently (_webTargetVolume / _clipTargetVolume) but mirrored into each other on every setRadioVolume()/setClipVolume() call, so a Stop always reads back the level "the volume" was actually just at (audio_manager.cpp:241-255 and :363-376).

Threading model

Unlike a lock-free/atomics design, AudioManager's public control API is blocking: playRadio, stopRadio, radioNext, radioPrevious, setRadioVolume, playSound, stopSound all take _guard (xSemaphoreTake(_guard, portMAX_DELAY)) directly, and several also run a synchronous delay()-based volume ramp. They're called straight from the main Arduino loop()'s Topic::AUDIO command dispatch (src/main.cpp:1056-1107) — not queued as messages to a task.

A separate FreeRTOS task ("audio_mgr", 8 KB stack, configMAX_PRIORITIES - 1, pinned to core 0 — audio_manager.cpp:76-84) owns only the actual byte-pumping: every ~1 ms it takes _guard with a 5 ms timeout and calls _copyStep(), which drives whichever pipeline is active (_webPlayer->copy() or the clip's StreamCopy::copy()) and, for clips, detects end-of-stream and releases the amp itself when a clip finishes naturally (_copyStep, audio_manager.cpp:398-420).

sequenceDiagram
    participant Loop as loop() / AUDIO dispatch
    participant Guard as _guard (mutex)
    participant Task as audio_mgr task (core 0)

    Loop->>Guard: playRadio() / playSound() / setVolume() ...
    Note over Loop,Guard: blocking take, portMAX_DELAY;<br/>some calls also run a synchronous delay()-based ramp
    Guard-->>Loop: released

    loop every ~1 ms
        Task->>Guard: take (5 ms timeout)
        Task->>Task: _copyStep() - webPlayer->copy() or clip StreamCopy::copy()
        Task->>Guard: give
    end
Loading

Because control calls and the copy task both serialize on the same _guard, and StreamCopy::write() retries up to 4×5ms whenever the I2S DMA output is momentarily full, a control call can occasionally block for tens of ms behind an in-flight copy() — an accepted tradeoff, not a bug (see the comment on _createWebPipeline() in audio_manager.cpp:131-142). main.cpp avoids the deeper hazard of calling these blocking accessors while already holding the DataBroker lock — they're always fetched before the db.update/updateSilent lambda that would need them (see audio_manager.cpp-adjacent comments at main.cpp:719-724 and :1117-1122); doing it the other way around would ABBA-deadlock against the ICY metadata callback, which fires from inside copy() (i.e. _guard held) and takes the broker lock. See also Data Broker.

Startup sequence

begin() is gated only on heap settling, not WiFi — decoupled so offline mood-sound clips still work on a device that never gets network:

// src/main.cpp:698-704 (every loop() pass until audioReady)
if (!audioReady && bootMode == BootMode::STREAM) {
    audioMgr->begin();
    audioMgr->setMetadataCallback(onAudioMetadata);
    audioReady = true;
}

Station-list load and playback-state restore (last station/volume/playing flag from NVS) are a separate, later stage still gated on wifi.is_connected(), since it's meaningless without a network (main.cpp:705-717).

Stall recovery

AudioPlayer silently deactivates after ~20 s of a stalled stream with no auto-recovery. loop() polls once a second: if the blackboard says the radio should be playing but isRadioPlaying() says otherwise, it reconnects to the same station (main.cpp:1172-1180).

ICY metadata

onAudioMetadata() (main.cpp:308-320), registered via setMetadataCallback(), copies Title/Name metadata (truncated to 63 chars) into Blackboard.audio_track_title, which the Radio screen scrolls. The buffer is cleared on every station change (main.cpp:1143).

Volume model

The DAC/amp chain produces only noise below ~0.4 driver volume, so the whole UI works in a mapped range (lib/CONSTANTS/rocat_constants.h:82-83):

  • audio_volume_min = 0.4, audio_volume_step = 0.05
  • Every UI (Radio screen, Quick Access, dashboard slider) shows 0–100 % mapped onto driver 0.4 … 1.0.
  • Displayed 0 % == stopped: stepping below the floor issues STOP; stepping up from stopped issues PLAY.
  • Volume is decoupled from what's playing: while radio is active, the volume control sets setRadioVolume(); while stopped, the same control instead sets the level mood-sound clips play at (setClipVolume()) — see AudioCmd::VOLUME handling at main.cpp:1070-1091.

Playback-state persistence

Station index, playing flag and volume are saved to NVS (pb_st, pb_play, pb_volnvs_store.cpp:180-182) debounced by 500 ms (PB_SAVE_DEBOUNCE_MS, main.cpp:692) after the last AUDIO command — NVS commits stall both cores and audibly starved the copy task when saved per keypress. The pending save is flushed synchronously before a shutdown (main.cpp:1187-1192) and immediately (no debounce) on STOP, since nothing is playing to starve at that point.

Alarm Sound

The alarm plays audio when it fires (main.cpp:929-946), gated by Blackboard.alarm_audio_enabled (default true):

  • WiFi connected: plays the configured web-radio station (alarm_station_index) through the normal Topic::AUDIO command path — same as any other PLAY.
  • WiFi not connected: falls back to a looped local clip, audioMgr->playSound((Sound)alarm_backup_sound) (default 2 = Sound::Calling). Clips are one-shot, so loop() re-triggers it every pass the alarm is still ringing and the clip isn't (main.cpp:993-996).
  • Only produces sound in BootMode::STREAMaudioMgr is null in WEB mode, same as every other audio feature.

Fields (lib/DATA_BROKER/data_broker.h:97-99, persisted to NVS): alarm_audio_enabled, alarm_station_index, alarm_backup_sound.

On dismiss, whatever was playing before the alarm fired is restored — the backup clip is stopped, or the pre-alarm station/stopped state is re-issued through Topic::AUDIO (main.cpp:970-997).

Clone this wiki locally