-
Notifications
You must be signed in to change notification settings - Fork 0
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.
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.
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.
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
- No
MultiDecoder,MimeDetector, HLS, AAC, or MPEG-TS codec-sniffing exists anywhere in the live code — the radio pipeline is a fixedICYStream → AudioSourceURL → MP3DecoderHelix → AudioPlayerchain (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 waitFILL_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 everysetRadioVolume()/setClipVolume()call, so a Stop always reads back the level "the volume" was actually just at (audio_manager.cpp:241-255and:363-376).
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
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.
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).
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).
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).
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 issuesPLAY. - 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()) — seeAudioCmd::VOLUMEhandling atmain.cpp:1070-1091.
Station index, playing flag and volume are saved to NVS (pb_st, pb_play,
pb_vol — nvs_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.
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 normalTopic::AUDIOcommand path — same as any otherPLAY. -
WiFi not connected: falls back to a looped local clip,
audioMgr->playSound((Sound)alarm_backup_sound)(default2=Sound::Calling). Clips are one-shot, soloop()re-triggers it every pass the alarm is still ringing and the clip isn't (main.cpp:993-996). - Only produces sound in
BootMode::STREAM—audioMgris 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).