M5UnitPoEP4HDMI (M5GFX 0.2.29) reaches the LT8912B through lgfx::i2c on a
port / pins given in its config. When the sketch leaves them unset they are
taken from In_I2C; a partly set config is completed from In_I2C with a
warning. M5GFX 0.2.29 is required for the new config fields.
BMI270_Class::_upload_file returned true even when writing INIT_ADDR_0 or
INIT_DATA_ADDR failed, so begin() marked the IMU as initialized with no
config loaded. It now returns false and begin() reports imu_spec_none.
PCF8563_Class::setTimerIRQ rounded with (msec + 500) / 1000, which wraps for
msec above UINT32_MAX - 500 and disables the timer instead of saturating.
Speaker_SD_wav_file takes the card pins from M5Unified (M5.getPin): 4-bit
SD_MMC where the data lines D1-D3 are wired (Tab5), SPI elsewhere, with the
same mountCard() as the MP3 example. It also retries the mount from loop() so
a card inserted after start is picked up, and logs each file it plays.
WebRadio: on ESP-Hosted boards (Tab5) the radio is only brought up by the
first mode change, so disconnect() before it logged "ESP-Hosted link not yet
up" and "STA disconnect failed" on every start. softAPdisconnect() is dropped;
the sketch never enables the AP.
Take the SD pins from M5Unified (M5.getPin) instead of relying on the board
variant defaults and CS on G4. Boards whose data lines D1-D3 are wired
(hasSDMMC, e.g. Tab5) mount through SD_MMC in 4-bit mode; the others keep
SPI with the pins from the table, and boards without an entry keep the old
behaviour (core default SPI pins, CS G4).
The file source becomes AudioFileSourceFS on a fs::FS pointer so the rest of
the sketch does not care which filesystem is mounted.
Checked on Tab5 (SD_MMC 4-bit, generic esp32p4 board) and CoreS3SE (SPI).
1-bit SD_MMC is deliberately not used: on CoreS3 the SD lines are shared with
the display SPI bus.
With the generic esp32p4 board selected, the Arduino core defaults ESP-Hosted
to SDIO pins that do not reach the C6 on Tab5, so esp_wifi_init fails.
Call hostedSetPins() with the Tab5 wiring before ESP-Hosted is initialized.
The HAL symbols are referenced weakly so builds that never use WiFi/BLE do
not pull esp32-hal-hosted.o in (measured: a strong reference adds ~2.5KB of
flash and drags esp_timer system init, i.e. a timer task at boot; the weak
reference adds a null check only). Include order does not matter.
Tab5X is assumed to share the wiring (not verified on hardware).
ESP-IDF builds are unaffected (pins come from Kconfig there).
The MP3, WebRadio and Bluetooth examples carried their own copies of the level meter / FFT / waveform display, the header text handling and the button handling, and the two ESP8266Audio examples their own AudioOutput wrapper. They now share AudioUI.h (header lines also written to the log and cleared on a track change, meters, waveform locked to a rising edge and scaled to the display width, volume bar, button and touch handling: a touch on the virtual button strip belongs to the buttons, one elsewhere acts as BtnA, both in one click sequence) and AudioOutputM5Speaker.h. Each example folder holds an identical copy, since an Arduino sketch cannot include files outside its folder.
MP3_with_ESP8266Audio also plays *.wav (AudioGeneratorWAV), decodes in its own task so that drawing never starves the decoder, and skips files that cannot be opened or decoded.
Also fixed on the way: APP_CPU_NUM is not defined on ESP32-P4; the FFT bar height overflowed int32 on a 720 px tall display; the background shade used a left shift of a value that can go negative; Bluetooth clears the header on a track change and on the disconnect event; WebRadio station steps from the UI add up while the decoder is busy; the display works from its own copy of each queued buffer instead of reading the producer buffers.
Speaker examples that generate or stream audio at runtime used three
buffers in rotation so that the one being refilled could not be the one
the speaker task was still reading. They now refill a buffer only after
the release callback has reported it free, tracking each buffer by
pointer with an atomic busy flag (release on the callback side, acquire
on the waiting side):
- Speak_with_AquesTalk, Speaker_SD_wav_file, Bluetooth_with_ESP32A2DP:
two buffers. Bluetooth uses fixed-size storage instead of per-packet
heap blocks (the callback and the display compare against addresses
that must not change), truncates an oversized packet, and shows a zero
waveform after a stop instead of clearing buffers the task may read.
- WebRadio/MP3 (ESP8266Audio wrappers): three buffers in rotation - two
would satisfy the handshake, the third keeps one buffer of slack
against decoder and network jitter. The callback is registered from an
explicit setup() after M5.begin(), before anything plays. stop() lets
the queued buffers play out rather than cutting them, so every buffer
comes back through the callback.
- Zero-length requests are skipped before marking a buffer busy.
Basic/Microphone draws the block the callback reports as filled (atomic
mailbox holding the newest block) instead of a block assumed to be two
behind the record position, and clears the mailbox across a Mic restart.
Fixes found while verifying the examples on hardware:
- Bluetooth: the phone's AVRCP volume now drives the speaker (the library
applied it inside the audio_data_callback this example overrides) and
a local button change is reported back with set_volume(). The example
builds on the current ESP32-A2DP (set_stream_reader(nullptr, false)
replaces the removed is_i2s_output; works on 1.7.x too).
- WebRadio: WiFi modem sleep off - with it on the stream buffer drained
at about 2 KB/s while the link could deliver far more. Buffer 5 KB ->
32 KB, filled before decoding starts (triggering the library's own
first read, which replaces rather than adds) and refilled below 1/8.
Stations that cannot be opened or stop before two seconds of audio
came out are skipped with a note; a stream that ends after that moves
on to the next station; when every station failed in a row the task
waits before going round again. The decode task owns the station
number and the UI requests absolute numbers, so a selection is never
lost to an auto-skip. Station list refreshed (the first one returned
404, several others no longer answer). WIFI_SSID/WIFI_PASS may be
passed from the build.
- Speaker_SD_wav_file plays every *.wav in the root of the card;
MP3_with_ESP8266Audio plays every *.mp3 in /mp3, one after another.
Register a function that the playback/capture task calls once it has
finished with a buffer given to playRaw()/playWav()/tone() or record():
from then on the caller may overwrite (Speaker) or read (Mic) it. With
it, two buffers used alternately are enough for runtime-generated
audio; the playRaw notes said three were needed, which was conservative
even before (waiting for isPlaying(channel) < 2 gives the same
guarantee for a single producer), so the notes now say so.
Contract (see the header):
- Per request, not per memory region: a queued request or a re-triggered
tone using the same data keeps it in use. For playWav() the pointer is
the PCM chunk inside the wav.
- Requests discarded unplayed do not call back: queued ones replaced by
stop()/stop_current_sound, and everything dropped by end(). The one
being played when stop() cuts it does. Delivery follows the slot
release, so it can arrive after isPlaying()/isRecording() dropped:
track buffers by pointer, not by counting.
- Task context, never an ISR; the sound may still be in the I2S queue.
- play*()/record() may be called from within: they never wait there and
never start the lifecycle (an end() may be tearing the task down), so
they return false if no slot is free. A Mic rate change from within is
refused before it is committed.
- Set or clear only before the first request or after end() returned.
Supporting changes:
- play*() answers true only when the request was queued (it used to say
true for a speaker that could not start and for an empty request; a
producer marking a buffer busy on true would then wait forever).
- Speaker: the task handle is atomic (read from every play*() caller),
and end() is serialized with begin() under the same lock so a teardown
cannot slip in between task creation and handle publication.
- _slot_occupied loads with acquire, so a producer polling isPlaying()
is ordered after the task's last read of the buffer.
Verified on ESP32-S3: two-buffer ping-pong driven from the callback and
refilling from inside the callback, 0 overlaps with the task reading the
buffer; three-buffer and isPlaying-wait forms unchanged.
The header only needs i2c_port_t / I2C_NUM_x. ESP-IDF 6.x marks
driver/i2c.h as EOL and emits a CRITICAL WARNING pragma on every
include, which repeats for every translation unit that includes
M5Unified.hpp; the header is scheduled for removal in v7. Include
driver/i2c_master.h when available (IDF 5.2+), fall back to
driver/i2c.h for IDF 4.4 (Arduino core 2.x).
setExtOutput(false) wrote BUS_OUT_EN and BOOST_EN in one two-byte transaction, so
BUS_OUT_EN dropped while the bus was still at 5V. BUS_OUT_EN=0 turns the bus
input-side switch on; with the bus still at 5V that switch never enters reverse
blocking and USB VBUS keeps feeding BUS_OUT. The 5V stayed on after the disable,
and without a battery the TS reading (a divider of BUS_OUT) stayed above 2.0V so
every later setExtOutput(true) was cancelled until a power cycle.
- On BUS_OUT_EN 1->0: write BOOST_EN=0 first, wait 200 ms for the bus to
discharge (measured boundary: 20 ms loaded, 80 ms unloaded), re-read the
expander and clear only the requested bit. On an I2C failure re-read the latch
and recover from the actual state, retrying with read-back up to 3 times.
- Other paths write one register at a time, BOOST_EN before the output enable
when turning on and after it when turning off, and stop if the first write fails.
- Serialize all AW9523 output-port writers in M5Unified (setExtOutput,
setUsbOutput, Power_Class::begin, the speaker amplifier enable) with a mutex
and run the no-battery protection check inside the same critical section.
- Protection check: skip it when BUS_OUT_EN is already 1 (the 5V on TS is our own
output), treat I2C failures and invalid ADC values as unsafe, and when it reads
unsafe release the lock and re-check every 20 ms for up to 1 s so a re-enable
right after a disable is not cancelled by the stale TS reading. External bus
power keeps TS high, so it is still cancelled. Requests carry a generation
number so an enable that is still waiting yields to a later disable.
Two record() callers could claim the same slot (their plain metadata
writes then race, and the capture task can pair one callers buffer with
the others length), and a record() racing a lifecycle change could
publish a request into a port that was just torn down while still
returning true. The default-rate record() overloads also read
_cfg.sample_rate outside any lock, and the capture task read it again
after startup, so a queued record() with a new rate could retune the
clock under a request already being captured.
All request publishing now happens under a dedicated _rec_lock, and
every lifecycle path joins the same order: record(), end() and the
public begin() each take _rec_lock before _begin_lock. The requested
sample rate is validated and committed inside _begin_raw() under
_begin_lock: a rate of 0, or one whose product with the effective
over_sampling would wrap uint32_t, is rejected without poisoning the
stored config. The default-rate overloads pass 0 (keep the current
rate) instead of reading the config outside the lock, and an explicit
sample_rate of 0 is rejected up front. The capture task uses
_rec_sample_rate - fixed before the task is created - instead of
re-reading _cfg.sample_rate, and _calc_rec_rate() clamps over_sampling
to the same 1..8 range the task itself uses.
The locks cover one begin plus one claim attempt. When both slots are
busy, or a rate change still has recordings draining, the caller
releases the locks, waits outside them, and retries, so end() never
waits behind a full queue or a stalled capture. The lock itself is an
unfair CAS: contending callers have no arrival order, which is
documented at the retry site.
Requests carry a wrap-safe sequence number stamped under _rec_lock,
and the capture task consumes the pending slot with the older stamp.
Slot identity cannot express publish order - a freed slot can be
refilled while the other still holds an earlier request, and cursor
parity schemes desynchronize across the tasks idle transitions. The
tasks two slot reads are not one snapshot, so an empty slot 0 is
re-read once slot 1 is known pending before the sequence compare. The
capture task itself takes no locks; slots still hand over through the
length release/acquire protocol.
begin() now reports honest results on every board: it waits for the
capture task to actually enable the I2S channel (previously only
boards with a post-start callback did, so an _i2s_start() failure left
record() returning true for requests that could never complete), and
it fails cleanly when the task semaphore cannot be allocated. The
teardown handshake is restructured: the capture task acknowledges its
I2S cleanup through a dedicated atomic, parks itself with vTaskSuspend
instead of self-deleting, and _end_locked() deletes it only after the
ack and the park - notifying a self-deleting task raced against its
TCB teardown, and the ack (rather than eTaskGetState alone) matters
because ESP-IDF 4.0-4.2 report an indefinite notification wait as
eSuspended, which would otherwise pass the check before cleanup ran. _task_running and _task_handle are atomics,
and the raw rx_update wait is bounded, with its timeout failing the
begin instead of starting the channel on an unlatched clock. A
persistently failing I2S read backs off one tick per retry so a
high-priority capture task cannot starve the stop request. The
effective rate is capped below 2^31 so the divider search never wraps
internally, and the slot-order compare uses an unsigned half-range
test with no signed-conversion dependence.
The callbacks run while these non-recursive locks are held; the
contract now spells out that they must not call record() either.
config() and setSampleRate() remain unsynchronized and are documented
as stopped-port operations, getters included. The locks are
per-instance: two Mic_Class instances driving the same I2S port stay
unsupported (the driver state is port-global).
Same family as the Speaker slot state machine fix (#295); the Mic side
keeps a writer lock plus sequence stamps instead of per-slot CAS
because its slots have a single consumer.