Merge PR #316: WAV/MP3 playback for Lua apps

oumike. Closes #315 (pisti87's request).

Two conflicts, both resolved by keeping BOTH sides rather than choosing:

  - sdRuntimeLifecycleBusy() gained an audio-playback source here and a web
    reader source in #317. They are independent consumers of the same card and
    both have to gate the mount lifecycle. The reader's self-exclusion is kept:
    it calls this from its own task while holding the card and would otherwise
    deadlock against itself.
  - The Lua harness caps table needed sd_list from #312 as well as the audio
    flags, and the test order needed the wardrive suite from #324 as well as
    audio_api.

Built on all eight S3 envs and both ESP32-P4 targets.
This commit is contained in:
Kaj Schittecat
2026-08-27 10:49:59 +02:00
25 changed files with 3984 additions and 60 deletions
+851 -34
View File
@@ -19,6 +19,20 @@
#include <cstdlib>
#include <cmath>
#include <cerrno> // chat-store write diagnostics surface errno (ENFILE vs ENOSPC vs EIO)
#if CAP_LUA_AUDIO
static void* wadaMp3Scratch();
#define MINIMP3_ONLY_MP3
#define MINIMP3_NO_SIMD
#define MINIMP3_SCRATCH ((mp3dec_scratch_t*)wadaMp3Scratch())
#define MINIMP3_IMPLEMENTATION
#include "../../lib/minimp3/minimp3.h"
#undef MINIMP3_IMPLEMENTATION
#undef MINIMP3_SCRATCH
#undef MINIMP3_NO_SIMD
#undef MINIMP3_ONLY_MP3
static void* s_wada_mp3_scratch = nullptr;
static void* wadaMp3Scratch() { return s_wada_mp3_scratch; }
#endif
#if defined(ESP32)
#include <time.h>
#include <SPIFFS.h>
@@ -52,6 +66,7 @@
#include <esp_core_dump.h> // detect/read/erase the panic coredump for the crash-report export
#include <freertos/FreeRTOS.h>
#include <freertos/task.h> // xTaskGetCurrentTaskHandleForCPU / pcTaskGetName — Task-WDT crash self-record
#include <freertos/queue.h>
#else
// Tanmatsu's 16M.csv has no coredump partition yet — stub so the crash-export compiles + links.
#include <esp_err.h>
@@ -843,6 +858,56 @@ static inline void tileFetchPendingDec() {
// below are shared; T-Deck and the pager each get their own I2S install/
// tone/WAV functions, since the pager additionally drives an ES8311 codec
// over I2C that the T-Deck's plain MAX98357A DAC doesn't have.
#if CAP_AUDIO_STREAM
static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); }
static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); }
static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) {
char tag[4];
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false;
const uint32_t riff_size = wavRd32(f);
const uint64_t riff_end = 8u + (uint64_t)riff_size;
if (riff_size < 4 || riff_end > f.size() || riff_end > 0xFFFFFFFFu) return false;
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false;
uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false;
while ((uint64_t)f.position() + 8u <= riff_end) {
if (f.read((uint8_t*)tag,4)!=4) break;
uint32_t csz = wavRd32(f);
if (!memcmp(tag,"fmt ",4)) {
if (csz < 16) return false;
fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f);
const uint64_t skip = (uint64_t)(csz - 16) + (csz & 1u);
const uint64_t next = (uint64_t)f.position() + skip;
if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false;
hf = true;
} else if (!memcmp(tag,"data",4)) {
if ((uint64_t)f.position() + csz > riff_end) return false;
dlen = csz; hd = true; break;
} else {
const uint64_t skip = (uint64_t)csz + (csz & 1u);
const uint64_t next = (uint64_t)f.position() + skip;
if (next > riff_end || (skip && !f.seek((uint32_t)next))) return false;
}
}
if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000)
return false;
if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen;
return true;
}
#endif
#if CAP_LUA_AUDIO
static volatile bool s_lua_audio_active = false;
static volatile uint32_t s_lua_audio_storage_pending = 0;
static volatile bool s_lua_audio_storage_active = false;
static inline bool luaAudioActive() {
return __atomic_load_n(&s_lua_audio_active, __ATOMIC_ACQUIRE);
}
static inline bool luaAudioStorageBusy() {
return __atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0 ||
__atomic_load_n(&s_lua_audio_storage_active, __ATOMIC_ACQUIRE);
}
#endif
#if defined(HELTEC_LORA_V4_R8) || defined(HAS_THINKNODE_M9)
static bool fmSdTryMount(); // V4-R8/M9 microSD — fwd decl (defined in the mount-helper block below; sdRestoreRun needs it)
#endif
@@ -999,29 +1064,6 @@ static void pagerPlayToneRaw(int freq, int ms) {
// amp. Supports PCM 16-bit, mono or stereo (downmixed to the mono amp), sample
// rate read from the header; capped to ~6 s so a stray big file can't hold the
// notify task. Any problem -> false, and the caller falls back to the chime.
static uint32_t wavRd32(File& f){ uint8_t b[4]; if(f.read(b,4)!=4) return 0; return (uint32_t)b[0]|((uint32_t)b[1]<<8)|((uint32_t)b[2]<<16)|((uint32_t)b[3]<<24); }
static uint16_t wavRd16(File& f){ uint8_t b[2]; if(f.read(b,2)!=2) return 0; return (uint16_t)(b[0]|(b[1]<<8)); }
static bool wavParse(File& f, uint16_t* pch, uint32_t* prate, uint32_t* pdata) {
char tag[4];
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"RIFF",4)) return false;
wavRd32(f);
if (f.read((uint8_t*)tag,4)!=4 || memcmp(tag,"WAVE",4)) return false;
uint16_t fmt=0, ch=0, bits=0; uint32_t rate=0, dlen=0; bool hf=false, hd=false;
while (f.available() >= 8) {
if (f.read((uint8_t*)tag,4)!=4) break;
uint32_t csz = wavRd32(f);
if (!memcmp(tag,"fmt ",4)) {
fmt = wavRd16(f); ch = wavRd16(f); rate = wavRd32(f); wavRd32(f); wavRd16(f); bits = wavRd16(f);
if (csz > 16) f.seek(f.position() + (csz - 16));
hf = true;
} else if (!memcmp(tag,"data",4)) { dlen = csz; hd = true; break; }
else f.seek(f.position() + csz + (csz & 1));
}
if (!hf || !hd || fmt != 1 || bits != 16 || (ch != 1 && ch != 2) || rate < 8000 || rate > 48000)
return false;
if (pch) *pch = ch; if (prate) *prate = rate; if (pdata) *pdata = dlen;
return true;
}
static bool wavOpen(const char* prefpath, File& f) {
if (!prefpath || !prefpath[0]) return false;
fs::FS* fsp = &SPIFFS; const char* fp = prefpath;
@@ -1313,6 +1355,9 @@ static void tanBeep(); // I2S notification tick; defined far below (with the C
#endif
// Play the platform's notification chime. Caller checks the buzzer/sound pref.
static inline void uiPlaySlot(int slot) {
#if CAP_LUA_AUDIO
if (luaAudioActive()) return;
#endif
#if defined(HAS_TDECK_GT911)
tdeckPlayNotifySlot(slot);
#elif defined(HAS_TDISPLAY_P4)
@@ -1332,6 +1377,9 @@ static inline void uiPlayMention() { uiPlaySlot(TOUCH_SND_MEN); }
// Preview an arbitrary WAV file (not yet saved to a slot) -- used by the
// sound picker's "play" button before the user commits to a choice.
static inline void uiPreviewWavFile(const char* path) {
#if CAP_LUA_AUDIO
if (luaAudioActive()) return;
#endif
#if defined(HAS_TDECK_GT911)
tdeckPreviewWavFile(path);
#elif defined(TLORA_PAGER)
@@ -20152,8 +20200,10 @@ static void fmOpenStorage(fs::FS* fs, const char* store, const char* path) {
}
static void fmInternalClickCb(lv_event_t* e) {
if (lv_event_get_code(e) != LV_EVENT_CLICKED) return;
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
fmOpenStorage(&FFat, "Internal", "/"); // the internal FAT data partition (locfd / 'storage')
#if defined(HAS_TANMATSU)
fmOpenStorage(&FFat, "Internal", "/"); // internal FAT data partition (locfd)
#elif defined(HAS_TDISPLAY_P4)
fmOpenStorage(&LittleFS, "Internal", "/"); // internal LittleFS 'storage' partition
#else
fmOpenStorage(&SPIFFS, "Internal", "/");
#endif
@@ -21671,6 +21721,9 @@ static uint64_t s_tan_sd_size = 0;
static uint32_t s_tan_sd_retry_after = 0; // backoff so an absent/cold card isn't re-probed every render
static bool tanSdTryMount() {
if (s_tan_sd_mounted) return true;
#if CAP_LUA_AUDIO
if (luaAudioStorageBusy()) return false;
#endif
if (millis() < s_tan_sd_retry_after) return false;
#if defined(HAS_TDISPLAY_P4)
// Hot-insert path (no-op when main.cpp already mounted at boot): 20 MHz like the boot ladder,
@@ -39793,6 +39846,11 @@ static void tanKbBacklightTick(bool off = false) {
if (v != s_last) { s_last = v; bsp_input_set_backlight_brightness(v); }
}
static uint8_t s_volume_pct = 70;
static SemaphoreHandle_t s_tan_audio_mutex = nullptr;
static SemaphoreHandle_t tanAudioMutex() {
if (!s_tan_audio_mutex) s_tan_audio_mutex = xSemaphoreCreateMutex();
return s_tan_audio_mutex;
}
// The audio subsystem (ES8156 codec + I2S) is brought up by bsp_device_initialize()
// at boot — so here we only set the codec volume and toggle the speaker amplifier.
static void applyVolume(uint8_t pct) {
@@ -39809,11 +39867,13 @@ static void applyVolume(uint8_t pct) {
// which leaves every descriptor zeroed once played out, and the tone stops cleanly.
static void tanBeep() {
if (s_volume_pct == 0) return;
SemaphoreHandle_t mutex = tanAudioMutex();
if (!mutex || xSemaphoreTake(mutex, 0) != pdTRUE) return;
static uint32_t s_last_beep = 0; // throttle: holding the slider ramps fast, but
if (millis() - s_last_beep < 200) return; // don't machine-gun a tone on every repeat step
if (millis() - s_last_beep < 200) { xSemaphoreGive(mutex); return; } // don't machine-gun a tone on every repeat step
s_last_beep = millis();
i2s_chan_handle_t h = nullptr;
if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) return;
if (bsp_audio_get_i2s_handle(&h) != ESP_OK || !h) { xSemaphoreGive(mutex); return; }
const int rate = 44100, freq = 880;
const int tone = rate * 30 / 1000; // ~30 ms tone …
const int total = tone + rate * 50 / 1000; // … then ~50 ms silence (> the DMA ring) flushes the loop
@@ -39832,6 +39892,7 @@ static void tanBeep() {
}
size_t wr = 0;
i2s_channel_write(h, buf, (size_t)n * 2 * sizeof(int16_t), &wr, 200 / portTICK_PERIOD_MS);
xSemaphoreGive(mutex);
}
#elif defined(HAS_TDISPLAY_P4)
// T-Display P4: the RM69A10 AMOLED has no backlight — brightness is the panel's own DCS
@@ -47303,18 +47364,22 @@ static bool uiDataFsReady() {
}
#endif
#if defined(HAS_TANMATSU) || defined(HAS_TDISPLAY_P4)
// Tanmatsu + T-Display P4: prefer the microSD card. On the Tanmatsu the internal FFat 'locfd'
// loses frequently-rewritten data (broken FAT metadata; see the tile-cache notes); on the
// T-Display P4 SD-first keeps history on the same medium the DataStore adopts. Mirror the
// T-Deck's /meshcomod root; fall back to FFat only when no card is present. Both are mounted at
// boot in main.cpp (g_sd_ok / g_fs_ok). (The P4 used to fall into the #else SPIFFS branch below —
// no SPIFFS partition exists there, so history was never persisted and vanished on every reboot.)
// Tanmatsu + T-Display P4: use each board's mounted internal data partition
// (Tanmatsu FFat 'locfd', P4 LittleFS 'storage'), with SD_MMC as fallback.
// #167: hot UI data (chat history) lives on the INTERNAL LittleFS -- SD write bursts
// electrically disturb this board's AMOLED, and the P4's internal FAT layer is broken
// (see the storage note in tdisplay_p4/main/main.cpp). SD = degraded fallback only;
// tiles keep using the SD via their own selector.
extern bool g_fs_ok;
if (g_fs_ok) { s_ui_data_fs = &LittleFS; s_ui_data_root[0] = '\0'; return true; }
if (g_fs_ok) {
#if defined(HAS_TANMATSU)
s_ui_data_fs = &FFat;
#else
s_ui_data_fs = &LittleFS;
#endif
s_ui_data_root[0] = '\0';
return true;
}
extern bool g_sd_ok;
if (g_sd_ok) {
SD_MMC.mkdir("/meshcomod");
@@ -47441,6 +47506,752 @@ fs::FS* luaHostAppFs() { return uiDataFsReady() ? s_ui_data_fs : nullptr; }
void luaHostAppPath(char* out, size_t cap, const char* rel) {
snprintf(out, cap, "%s%s", s_ui_data_root, rel); // SD-rooted stores prefix /meshcomod
}
#if CAP_LUA_AUDIO
namespace {
enum class LuaAudioCommandKind : uint8_t { Play, Pause, Resume, Stop, Release };
enum class LuaAudioState : uint8_t { Stopped, Playing, Paused, Ended, Error };
enum class LuaAudioRunResult : uint8_t { Ended, Stopped, Replaced, Released, Error };
enum class LuaAudioTaskState : uint8_t { Stopped, Starting, Running, Stopping };
enum class LuaAudioFormat : uint8_t { Wav, Mp3 };
struct LuaAudioCommand {
LuaAudioCommandKind kind = LuaAudioCommandKind::Stop;
fs::FS* fs = nullptr;
uint32_t owner = 0;
LuaAudioFormat format = LuaAudioFormat::Wav;
char path[224] = "";
char shown[192] = "";
char source[8] = "";
};
struct LuaAudioStatus {
uint32_t owner = 0;
LuaAudioState state = LuaAudioState::Stopped;
char path[192] = "";
char source[8] = "";
char format[8] = "";
char error[40] = "";
};
struct LuaAudioSinkSession {
uint32_t output_rate = 0;
float gain = 1.0f;
bool open = false;
#if defined(HAS_TANMATSU)
i2s_chan_handle_t handle = nullptr;
SemaphoreHandle_t mutex = nullptr;
#endif
};
class LuaAudioStorageLease {
public:
LuaAudioStorageLease() {
__atomic_store_n(&s_lua_audio_storage_active, true, __ATOMIC_RELEASE);
if (__atomic_load_n(&s_lua_audio_storage_pending, __ATOMIC_ACQUIRE) != 0)
__atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL);
}
~LuaAudioStorageLease() {
__atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE);
}
};
static QueueHandle_t s_lua_audio_queue = nullptr;
static TaskHandle_t s_lua_audio_task = nullptr;
static LuaAudioTaskState s_lua_audio_task_state = LuaAudioTaskState::Stopped;
static portMUX_TYPE s_lua_audio_mux = portMUX_INITIALIZER_UNLOCKED;
static LuaAudioStatus s_lua_audio_status;
static void luaAudioCopy(char* out, size_t cap, const char* value) {
if (!cap) return;
snprintf(out, cap, "%s", value ? value : "");
}
static void luaAudioSetTrackStatus(const LuaAudioCommand& command, LuaAudioState state,
const char* error = nullptr) {
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_status.owner = command.owner;
s_lua_audio_status.state = state;
luaAudioCopy(s_lua_audio_status.path, sizeof s_lua_audio_status.path, command.shown);
luaAudioCopy(s_lua_audio_status.source, sizeof s_lua_audio_status.source, command.source);
luaAudioCopy(s_lua_audio_status.format, sizeof s_lua_audio_status.format,
command.format == LuaAudioFormat::Mp3 ? "mp3" : "wav");
luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error);
portEXIT_CRITICAL(&s_lua_audio_mux);
}
static void luaAudioSetState(uint32_t owner, LuaAudioState state, const char* error = nullptr) {
portENTER_CRITICAL(&s_lua_audio_mux);
if (s_lua_audio_status.owner == owner) {
s_lua_audio_status.state = state;
luaAudioCopy(s_lua_audio_status.error, sizeof s_lua_audio_status.error, error);
}
portEXIT_CRITICAL(&s_lua_audio_mux);
}
static bool luaAudioSinkBegin(uint32_t source_rate, LuaAudioSinkSession* sink,
const char** error) {
if (!sink) return false;
const int volume = (int)touchPrefsGetSoundVolume();
if (volume <= 0) { if (error) *error = "muted"; return false; }
#if defined(HAS_TDECK_GT911)
if (s_notify_playing) { if (error) *error = "busy"; return false; }
if (!tdeckAudioInstallRate((int)source_rate)) {
if (error) *error = "audio unavailable";
return false;
}
sink->output_rate = source_rate;
sink->gain = (float)volume / 100.0f;
#elif defined(TLORA_PAGER)
if (s_notify_playing) { if (error) *error = "busy"; return false; }
board.setAmpEnabled(true);
if (!pagerAudioInstallRate((int)source_rate)) {
board.setAmpEnabled(false);
if (error) *error = "audio unavailable";
return false;
}
s_pager_codec.setVolumePercent((uint8_t)volume);
s_pager_codec.setMute(false);
sink->output_rate = source_rate;
#elif defined(HAS_TDISPLAY_P4)
if (!p4AudioStreamBegin()) {
if (error) *error = "audio unavailable";
return false;
}
sink->output_rate = p4AudioStreamRate();
sink->gain = (float)volume / 100.0f;
#elif defined(HAS_TANMATSU)
sink->mutex = tanAudioMutex();
if (!sink->mutex || xSemaphoreTake(sink->mutex, pdMS_TO_TICKS(300)) != pdTRUE) {
if (error) *error = "busy";
return false;
}
if (bsp_audio_get_i2s_handle(&sink->handle) != ESP_OK || !sink->handle) {
xSemaphoreGive(sink->mutex);
sink->mutex = nullptr;
if (error) *error = "audio unavailable";
return false;
}
applyVolume((uint8_t)volume);
sink->output_rate = 44100;
#endif
sink->open = sink->output_rate != 0;
return sink->open;
}
static bool luaAudioSinkWrite(LuaAudioSinkSession* sink, const int16_t* samples, size_t frames) {
if (!sink || !sink->open || !samples || !frames) return false;
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER)
size_t written = 0;
const size_t bytes = frames * sizeof(int16_t);
return i2s_write(kI2sPort, samples, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK &&
written == bytes;
#elif defined(HAS_TDISPLAY_P4)
return p4AudioStreamWrite(samples, frames);
#elif defined(HAS_TANMATSU)
static int16_t stereo[256 * 2];
if (frames > 256) return false;
for (size_t i = 0; i < frames; ++i)
stereo[2 * i] = stereo[2 * i + 1] = samples[i];
size_t written = 0;
const size_t bytes = frames * 2 * sizeof(int16_t);
return i2s_channel_write(sink->handle, stereo, bytes, &written, pdMS_TO_TICKS(300)) == ESP_OK &&
written == bytes;
#endif
}
static void luaAudioSinkEnd(LuaAudioSinkSession* sink) {
if (!sink || !sink->open) return;
#if defined(HAS_TDECK_GT911)
i2s_zero_dma_buffer(kI2sPort);
i2s_driver_uninstall(kI2sPort);
#elif defined(TLORA_PAGER)
pagerAudioUninstall();
board.setAmpEnabled(false);
#elif defined(HAS_TDISPLAY_P4)
p4AudioStreamEnd();
#elif defined(HAS_TANMATSU)
static const int16_t silence[256 * 2] = {};
for (int i = 0; i < 16; ++i) {
size_t written = 0;
i2s_channel_write(sink->handle, silence, sizeof silence, &written, pdMS_TO_TICKS(300));
}
if (sink->mutex) xSemaphoreGive(sink->mutex);
sink->handle = nullptr;
sink->mutex = nullptr;
#endif
sink->open = false;
}
static bool luaAudioControlMatches(const LuaAudioCommand& control, uint32_t owner) {
return control.kind == LuaAudioCommandKind::Play || control.owner == owner;
}
static bool luaAudioPollControl(const LuaAudioCommand& command,
LuaAudioCommand* replacement,
LuaAudioRunResult* result) {
LuaAudioCommand control;
if (xQueueReceive(s_lua_audio_queue, &control, 0) != pdTRUE ||
!luaAudioControlMatches(control, command.owner)) return false;
if (control.kind == LuaAudioCommandKind::Play) {
if (replacement) *replacement = control;
if (result) *result = LuaAudioRunResult::Replaced;
return true;
}
if (control.kind == LuaAudioCommandKind::Stop) {
if (result) *result = LuaAudioRunResult::Stopped;
return true;
}
if (control.kind == LuaAudioCommandKind::Release) {
if (result) *result = LuaAudioRunResult::Released;
return true;
}
if (control.kind != LuaAudioCommandKind::Pause) return false;
luaAudioSetState(command.owner, LuaAudioState::Paused);
for (;;) {
if (xQueueReceive(s_lua_audio_queue, &control, portMAX_DELAY) != pdTRUE) continue;
if (!luaAudioControlMatches(control, command.owner)) continue;
if (control.kind == LuaAudioCommandKind::Resume) {
luaAudioSetState(command.owner, LuaAudioState::Playing);
return false;
}
if (control.kind == LuaAudioCommandKind::Play) {
if (replacement) *replacement = control;
if (result) *result = LuaAudioRunResult::Replaced;
return true;
}
if (control.kind == LuaAudioCommandKind::Release) {
if (result) *result = LuaAudioRunResult::Released;
return true;
}
if (control.kind == LuaAudioCommandKind::Stop) {
if (result) *result = LuaAudioRunResult::Stopped;
return true;
}
}
}
static LuaAudioRunResult luaAudioRunTrack(const LuaAudioCommand& command,
LuaAudioCommand* replacement) {
LuaAudioStorageLease storage_lease;
File file = command.fs ? command.fs->open(command.path, "r") : File();
if (!file || file.isDirectory()) {
if (file) file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found");
return LuaAudioRunResult::Error;
}
uint16_t channels = 0;
uint32_t source_rate = 0, data_len = 0;
if (!wavParse(file, &channels, &source_rate, &data_len) ||
data_len < (uint32_t)channels * sizeof(int16_t)) {
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format");
return LuaAudioRunResult::Error;
}
LuaAudioSinkSession sink;
const char* sink_error = nullptr;
__atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE);
if (!luaAudioSinkBegin(source_rate, &sink, &sink_error)) {
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error,
sink_error ? sink_error : "audio unavailable");
return LuaAudioRunResult::Error;
}
luaAudioSetTrackStatus(command, LuaAudioState::Playing);
LuaAudioRunResult result = LuaAudioRunResult::Ended;
const char* run_error = nullptr;
bool interrupted = false;
uint32_t remaining = data_len;
uint64_t resample_phase = 0;
int16_t input[256];
int16_t output[256];
size_t output_count = 0;
const uint32_t frame_bytes = (uint32_t)channels * sizeof(int16_t);
while (remaining >= frame_bytes && !interrupted) {
if (luaAudioPollControl(command, replacement, &result)) break;
size_t want = sizeof input;
if (want > remaining) want = remaining;
want -= want % frame_bytes;
const int got = file.read((uint8_t*)input, want);
if (got <= 0) {
result = LuaAudioRunResult::Error;
run_error = "read failed";
break;
}
const size_t frames = (size_t)got / frame_bytes;
for (size_t i = 0; i < frames; ++i) {
int32_t sample = channels == 2
? ((int32_t)input[2 * i] + input[2 * i + 1]) / 2
: input[i];
sample = (int32_t)((float)sample * sink.gain);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
resample_phase += sink.output_rate;
while (resample_phase >= source_rate) {
output[output_count++] = (int16_t)sample;
resample_phase -= source_rate;
if (output_count == sizeof output / sizeof output[0]) {
if (!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
interrupted = true;
break;
}
output_count = 0;
}
}
if (interrupted) break;
}
remaining -= (uint32_t)got;
}
if (!interrupted && result == LuaAudioRunResult::Ended && output_count &&
!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
}
luaAudioSinkEnd(&sink);
file.close();
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
if (result == LuaAudioRunResult::Ended)
luaAudioSetState(command.owner, LuaAudioState::Ended);
else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released)
luaAudioSetState(command.owner, LuaAudioState::Stopped);
else if (result == LuaAudioRunResult::Error)
luaAudioSetState(command.owner, LuaAudioState::Error, run_error ? run_error : "playback failed");
return result;
}
struct LuaMp3Work {
mp3dec_t decoder;
mp3dec_scratch_t scratch;
uint8_t input[16 * 1024];
mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME];
};
static uint32_t luaAudioReadLe32(const uint8_t* value) {
return (uint32_t)value[0] | ((uint32_t)value[1] << 8) |
((uint32_t)value[2] << 16) | ((uint32_t)value[3] << 24);
}
static uint32_t luaAudioMp3DataEnd(File& file) {
uint32_t end = (uint32_t)file.size();
uint8_t footer[128];
if (end >= 128 && file.seek(end - 128) && file.read(footer, 3) == 3 &&
!memcmp(footer, "TAG", 3)) {
end -= 128;
}
if (end >= 32 && file.seek(end - 32) && file.read(footer, 32) == 32 &&
!memcmp(footer, "APETAGEX", 8)) {
const uint32_t version = luaAudioReadLe32(footer + 8);
const uint32_t tag_size = luaAudioReadLe32(footer + 12);
const uint32_t flags = luaAudioReadLe32(footer + 20);
const uint64_t total_size = (uint64_t)tag_size + ((flags & 0x80000000u) ? 32u : 0u);
if ((version == 1000 || version == 2000) && tag_size >= 32 && total_size <= end)
end -= (uint32_t)total_size;
}
file.seek(0);
return end;
}
static LuaAudioRunResult luaAudioRunMp3Track(const LuaAudioCommand& command,
LuaAudioCommand* replacement) {
LuaAudioStorageLease storage_lease;
File file = command.fs ? command.fs->open(command.path, "r") : File();
if (!file || file.isDirectory()) {
if (file) file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "not found");
return LuaAudioRunResult::Error;
}
LuaMp3Work* work = (LuaMp3Work*)heap_caps_malloc(
sizeof(LuaMp3Work), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT);
if (!work) {
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "out of memory");
return LuaAudioRunResult::Error;
}
s_wada_mp3_scratch = &work->scratch;
mp3dec_init(&work->decoder);
const uint32_t data_end = luaAudioMp3DataEnd(file);
if (!data_end) {
s_wada_mp3_scratch = nullptr;
heap_caps_free(work);
file.close();
luaAudioSetTrackStatus(command, LuaAudioState::Error, "unsupported format");
return LuaAudioRunResult::Error;
}
__atomic_store_n(&s_lua_audio_active, true, __ATOMIC_RELEASE);
luaAudioSetTrackStatus(command, LuaAudioState::Playing);
LuaAudioRunResult result = LuaAudioRunResult::Ended;
const char* run_error = nullptr;
LuaAudioSinkSession sink;
uint32_t source_rate = 0;
uint64_t resample_phase = 0;
int16_t output[256];
size_t output_count = 0;
size_t input_start = 0, input_count = 0;
bool eof = false, decoded_any = false, interrupted = false;
while (!interrupted) {
if (luaAudioPollControl(command, replacement, &result)) break;
if (!eof && input_count < 4096) {
if (input_start && input_start + input_count + 4096 > sizeof work->input) {
memmove(work->input, work->input + input_start, input_count);
input_start = 0;
}
size_t room = sizeof work->input - (input_start + input_count);
const uint32_t position = (uint32_t)file.position();
const uint32_t remaining = position < data_end ? data_end - position : 0;
if (room > remaining) room = remaining;
const int got = room ? file.read(work->input + input_start + input_count, room) : 0;
if (got > 0) {
input_count += (size_t)got;
eof = file.position() >= data_end;
} else if (file.position() < data_end) {
result = LuaAudioRunResult::Error;
run_error = "read failed";
break;
} else {
eof = true;
}
}
if (!input_count) {
if (!decoded_any) {
result = LuaAudioRunResult::Error;
run_error = "unsupported format";
}
break;
}
mp3dec_frame_info_t info = {};
const int samples = mp3dec_decode_frame(&work->decoder,
work->input + input_start, (int)input_count, work->pcm, &info);
if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) {
result = LuaAudioRunResult::Error;
run_error = "decode failed";
break;
}
if (info.frame_bytes > 0) {
input_start += (size_t)info.frame_bytes;
input_count -= (size_t)info.frame_bytes;
if (!input_count) input_start = 0;
} else if (eof) {
if (!decoded_any) {
result = LuaAudioRunResult::Error;
run_error = "unsupported format";
}
break;
} else if (input_start + input_count == sizeof work->input) {
result = LuaAudioRunResult::Error;
run_error = "decode failed";
break;
} else {
continue;
}
if (samples <= 0) continue;
if (info.layer != 3 || (info.channels != 1 && info.channels != 2) ||
info.hz < 8000 || info.hz > 48000) {
result = LuaAudioRunResult::Error;
run_error = "unsupported format";
break;
}
if (!sink.open) {
const char* sink_error = nullptr;
if (!luaAudioSinkBegin((uint32_t)info.hz, &sink, &sink_error)) {
result = LuaAudioRunResult::Error;
run_error = sink_error ? sink_error : "audio unavailable";
break;
}
source_rate = (uint32_t)info.hz;
} else if (source_rate != (uint32_t)info.hz) {
result = LuaAudioRunResult::Error;
run_error = "sample rate changed";
break;
}
decoded_any = true;
for (int i = 0; i < samples; ++i) {
int32_t sample = info.channels == 2
? ((int32_t)work->pcm[2 * i] + work->pcm[2 * i + 1]) / 2
: work->pcm[i];
sample = (int32_t)((float)sample * sink.gain);
if (sample > 32767) sample = 32767;
else if (sample < -32768) sample = -32768;
resample_phase += sink.output_rate;
while (resample_phase >= source_rate) {
output[output_count++] = (int16_t)sample;
resample_phase -= source_rate;
if (output_count == sizeof output / sizeof output[0]) {
if (!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
interrupted = true;
break;
}
output_count = 0;
}
}
if (interrupted) break;
}
}
if (!interrupted && result == LuaAudioRunResult::Ended && output_count &&
!luaAudioSinkWrite(&sink, output, output_count)) {
result = LuaAudioRunResult::Error;
run_error = "output failed";
}
luaAudioSinkEnd(&sink);
s_wada_mp3_scratch = nullptr;
heap_caps_free(work);
file.close();
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
if (result == LuaAudioRunResult::Ended)
luaAudioSetState(command.owner, LuaAudioState::Ended);
else if (result == LuaAudioRunResult::Stopped || result == LuaAudioRunResult::Released)
luaAudioSetState(command.owner, LuaAudioState::Stopped);
else if (result == LuaAudioRunResult::Error)
luaAudioSetState(command.owner, LuaAudioState::Error,
run_error ? run_error : "playback failed");
return result;
}
static void luaAudioWorker(void*) {
ulTaskNotifyTake(pdTRUE, portMAX_DELAY);
bool release = false;
while (!release) {
LuaAudioCommand command;
if (xQueueReceive(s_lua_audio_queue, &command, portMAX_DELAY) != pdTRUE) continue;
if (command.kind == LuaAudioCommandKind::Release) {
luaAudioSetState(command.owner, LuaAudioState::Stopped);
break;
}
if (command.kind == LuaAudioCommandKind::Stop) {
luaAudioSetState(command.owner, LuaAudioState::Stopped);
continue;
}
if (command.kind != LuaAudioCommandKind::Play) continue;
for (;;) {
LuaAudioCommand replacement;
const LuaAudioRunResult result = command.format == LuaAudioFormat::Mp3
? luaAudioRunMp3Track(command, &replacement)
: luaAudioRunTrack(command, &replacement);
if (result == LuaAudioRunResult::Replaced) {
command = replacement;
continue;
}
release = result == LuaAudioRunResult::Released;
break;
}
}
__atomic_store_n(&s_lua_audio_active, false, __ATOMIC_RELEASE);
__atomic_store_n(&s_lua_audio_storage_pending, 0u, __ATOMIC_RELEASE);
__atomic_store_n(&s_lua_audio_storage_active, false, __ATOMIC_RELEASE);
xQueueReset(s_lua_audio_queue);
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_task = nullptr;
s_lua_audio_task_state = LuaAudioTaskState::Stopped;
portEXIT_CRITICAL(&s_lua_audio_mux);
vTaskDelete(nullptr);
}
static bool luaAudioReturnError(char* error, size_t cap, const char* message) {
luaAudioCopy(error, cap, message);
return false;
}
static bool luaAudioPathEndsWith(const char* path, const char* suffix) {
if (!path || !suffix) return false;
const size_t path_len = strlen(path), suffix_len = strlen(suffix);
if (path_len < suffix_len) return false;
path += path_len - suffix_len;
for (size_t i = 0; i < suffix_len; ++i) {
char a = path[i], b = suffix[i];
if (a >= 'A' && a <= 'Z') a = (char)(a - 'A' + 'a');
if (b >= 'A' && b <= 'Z') b = (char)(b - 'A' + 'a');
if (a != b) return false;
}
return true;
}
} // namespace
bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown, const char* source,
uint32_t owner, char* error, size_t error_cap) {
if (!fs) return luaAudioReturnError(error, error_cap, "no storage");
if (!path || !path[0] || strlen(path) >= 224)
return luaAudioReturnError(error, error_cap, "bad path");
if (!g_lv.task || g_lv.task->isBuzzerQuiet() || touchPrefsGetSoundVolume() == 0)
return luaAudioReturnError(error, error_cap, "muted");
File probe = fs->open(path, "r");
if (!probe || probe.isDirectory()) {
if (probe) probe.close();
return luaAudioReturnError(error, error_cap, "not found");
}
uint16_t channels = 0;
uint32_t rate = 0, data_len = 0;
LuaAudioFormat format;
bool supported = false;
if (luaAudioPathEndsWith(shown, ".wav")) {
format = LuaAudioFormat::Wav;
supported = wavParse(probe, &channels, &rate, &data_len) && data_len > 0;
} else if (luaAudioPathEndsWith(shown, ".mp3")) {
format = LuaAudioFormat::Mp3;
supported = probe.size() > 0;
}
probe.close();
if (!supported) return luaAudioReturnError(error, error_cap, "unsupported format");
if (!s_lua_audio_queue) s_lua_audio_queue = xQueueCreate(4, sizeof(LuaAudioCommand));
if (!s_lua_audio_queue) return luaAudioReturnError(error, error_cap, "out of memory");
bool start_task = false;
portENTER_CRITICAL(&s_lua_audio_mux);
if (s_lua_audio_task_state == LuaAudioTaskState::Stopped) {
s_lua_audio_task_state = LuaAudioTaskState::Starting;
start_task = true;
} else if (s_lua_audio_task_state != LuaAudioTaskState::Running) {
portEXIT_CRITICAL(&s_lua_audio_mux);
return luaAudioReturnError(error, error_cap, "busy");
}
portEXIT_CRITICAL(&s_lua_audio_mux);
TaskHandle_t task = nullptr;
if (start_task) {
if (xTaskCreate(luaAudioWorker, "lua-audio", 8192, nullptr, 3, &task) != pdPASS) {
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_task_state = LuaAudioTaskState::Stopped;
portEXIT_CRITICAL(&s_lua_audio_mux);
return luaAudioReturnError(error, error_cap, "out of memory");
}
portENTER_CRITICAL(&s_lua_audio_mux);
s_lua_audio_task = task;
s_lua_audio_task_state = LuaAudioTaskState::Running;
portEXIT_CRITICAL(&s_lua_audio_mux);
}
LuaAudioCommand command;
command.kind = LuaAudioCommandKind::Play;
command.fs = fs;
command.owner = owner;
command.format = format;
luaAudioCopy(command.path, sizeof command.path, path);
luaAudioCopy(command.shown, sizeof command.shown, shown);
luaAudioCopy(command.source, sizeof command.source, source);
__atomic_fetch_add(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL);
if (xQueueSend(s_lua_audio_queue, &command, 0) != pdPASS) {
__atomic_fetch_sub(&s_lua_audio_storage_pending, 1u, __ATOMIC_ACQ_REL);
if (start_task) xTaskNotifyGive(task);
return luaAudioReturnError(error, error_cap, "busy");
}
luaAudioSetTrackStatus(command, LuaAudioState::Playing);
if (start_task) xTaskNotifyGive(task);
return true;
}
bool luaHostAudioPause(uint32_t owner, bool pause) {
LuaAudioState state;
TaskHandle_t task;
LuaAudioTaskState task_state;
portENTER_CRITICAL(&s_lua_audio_mux);
state = s_lua_audio_status.owner == owner ? s_lua_audio_status.state : LuaAudioState::Stopped;
task = s_lua_audio_task;
task_state = s_lua_audio_task_state;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (!task || task_state != LuaAudioTaskState::Running ||
(pause ? state != LuaAudioState::Playing : state != LuaAudioState::Paused)) return false;
LuaAudioCommand command;
command.kind = pause ? LuaAudioCommandKind::Pause : LuaAudioCommandKind::Resume;
command.owner = owner;
return xQueueSend(s_lua_audio_queue, &command, 0) == pdPASS;
}
bool luaHostAudioStop(uint32_t owner, bool release) {
TaskHandle_t task;
LuaAudioTaskState task_state;
portENTER_CRITICAL(&s_lua_audio_mux);
task = s_lua_audio_task;
task_state = s_lua_audio_task_state;
if (release && task && task_state == LuaAudioTaskState::Running)
s_lua_audio_task_state = LuaAudioTaskState::Stopping;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (!task || task_state == LuaAudioTaskState::Stopped) {
luaAudioSetState(owner, LuaAudioState::Stopped);
return true;
}
if (task_state != LuaAudioTaskState::Running) return false;
LuaAudioCommand command;
command.kind = release ? LuaAudioCommandKind::Release : LuaAudioCommandKind::Stop;
command.owner = owner;
const BaseType_t queued = release
? xQueueSendToFront(s_lua_audio_queue, &command, pdMS_TO_TICKS(100))
: xQueueSend(s_lua_audio_queue, &command, 0);
if (queued != pdPASS) {
if (release) {
portENTER_CRITICAL(&s_lua_audio_mux);
if (s_lua_audio_task_state == LuaAudioTaskState::Stopping)
s_lua_audio_task_state = LuaAudioTaskState::Running;
portEXIT_CRITICAL(&s_lua_audio_mux);
}
return false;
}
if (!release) return true;
for (int i = 0; i < 160; ++i) {
vTaskDelay(pdMS_TO_TICKS(5));
portENTER_CRITICAL(&s_lua_audio_mux);
task = s_lua_audio_task;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (!task) return true;
}
return false;
}
void luaHostAudioStatus(uint32_t owner, char* state, size_t state_cap,
char* path, size_t path_cap, char* source, size_t source_cap,
char* format, size_t format_cap, char* error, size_t error_cap) {
LuaAudioStatus status;
portENTER_CRITICAL(&s_lua_audio_mux);
status = s_lua_audio_status;
portEXIT_CRITICAL(&s_lua_audio_mux);
if (status.owner != owner) status = LuaAudioStatus();
const char* state_name = "stopped";
if (status.state == LuaAudioState::Playing) state_name = "playing";
else if (status.state == LuaAudioState::Paused) state_name = "paused";
else if (status.state == LuaAudioState::Ended) state_name = "ended";
else if (status.state == LuaAudioState::Error) state_name = "error";
luaAudioCopy(state, state_cap, state_name);
luaAudioCopy(path, path_cap, status.path);
luaAudioCopy(source, source_cap, status.source);
luaAudioCopy(format, format_cap, status.format);
luaAudioCopy(error, error_cap, status.error);
}
#endif
#if CAP_LUA_SD_LIST
// Return the physical removable card, never the app-data filesystem. Mounting
// stays in UITask so Lua cannot bypass shared-bus coordination or create a
@@ -52997,10 +53808,16 @@ static bool sdRuntimeLifecycleBusy() {
s_sdinfo_busy ||
touchPrefsIoBusy();
#if defined(MULTI_TRANSPORT_COMPANION)
// The web reader and the Lua audio player are independent SD consumers, so
// both gate the mount lifecycle. The reader excludes ITSELF: it calls this
// from its own task while holding the card, and would otherwise deadlock.
const bool reader_is_caller = s_reader_sd_busy &&
s_reader_sd_owner == xTaskGetCurrentTaskHandle();
busy = busy || (s_reader_sd_busy && !reader_is_caller);
#endif
#if CAP_LUA_AUDIO
busy = busy || luaAudioStorageBusy();
#endif
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER)
busy = busy || s_notify_playing;
#endif