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 committed 2026-08-27 10:49:59 +02:00
commit 65ea09ea89
25 files changed
+3984 -60

No files matched your search

+104
View File
@@ -0,0 +1,104 @@
-- Manual WAV/MP3 transport test for wada.audio.
-- SD test file: /Music/test.mp3
-- App-local file: /apps/audio_test.d/test.mp3 on the active app storage.
local ui, sys, timer = wada.ui, wada.sys, wada.timer
local audio = wada.audio
local C = ui.colors
local SD_PATH = "sd:/Music/test.mp3"
local APP_PATH = "test.mp3"
local app = {}
local source = APP_PATH
local source_line, state_line, detail_line, command_line
local function show_command(ok, err)
command_line:set(ok and "Command: accepted" or ("Command: " .. tostring(err or "rejected")))
command_line:color(ok and C.good or C.bad)
end
local function refresh()
if not audio then return end
local status = audio.status()
state_line:set("State: " .. tostring(status.state))
state_line:color(status.state == "error" and C.bad or
status.state == "playing" and C.good or C.text)
local detail = "Source: " .. tostring(status.source or "-") ..
" Format: " .. tostring(status.format or "-")
if status.error then detail = detail .. " Error: " .. tostring(status.error) end
detail_line:set(detail)
detail_line:color(status.error and C.bad or C.sub)
end
function app.on_open(w, h)
local caps = sys.caps()
source = APP_PATH
if caps.audio_sd and wada.sd and wada.sd.list then
local entries = wada.sd.list("/")
if entries then source = SD_PATH end
end
ui.label("Audio playback test", 6, 4, 14, C.accent)
ui.label("audio=" .. tostring(caps.audio) ..
" wav=" .. tostring(caps.audio_wav) ..
" mp3=" .. tostring(caps.audio_mp3), 6, 24, 12, C.sub)
source_line = ui.label("File: " .. source, 6, 44, 12, C.text)
source_line:width(w - 12)
state_line = ui.label("State: stopped", 6, 64, 12, C.text)
detail_line = ui.label("Source: - Format: -", 6, 82, 12, C.sub)
detail_line:width(w - 12)
command_line = ui.label("Command: ready", 6, 100, 12, C.sub)
command_line:width(w - 12)
if not caps.audio or not audio then
command_line:set("Command: wada.audio unavailable")
command_line:color(C.bad)
return
end
local gap = 5
local button_w = math.max(54, (w - 12 - gap * 3) // 4)
local x = 6
ui.button("Play", x, 116, button_w, 30, function()
local ok, err = audio.play(source)
show_command(ok, err)
refresh()
end)
x = x + button_w + gap
ui.button("Pause", x, 116, button_w, 30, function()
show_command(audio.pause())
refresh()
end)
x = x + button_w + gap
ui.button("Resume", x, 116, button_w, 30, function()
show_command(audio.resume())
refresh()
end)
x = x + button_w + gap
ui.button("Stop", x, 116, button_w, 30, function()
show_command(audio.stop())
refresh()
end)
if caps.audio_sd then
ui.button("Switch SD / app storage", 6, 150, math.min(w - 12, 190), 26, function()
source = source == SD_PATH and APP_PATH or SD_PATH
source_line:set("File: " .. source)
command_line:set("Command: source changed")
command_line:color(C.sub)
end)
end
timer.every(200)
refresh()
end
function app.on_tick()
refresh()
end
function app.on_close()
if audio then audio.stop() end
end
return app
+121 -2
View File
@@ -47,6 +47,13 @@ local cfg = {} -- per-scenario device config
local widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
local drawlog = { text = {}, circles = {}, ops = 0 }
local storekv = {}
local audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
local audio_log = {}
local function audio_host_close()
audio_state.state = "stopped"
audio_log[#audio_log + 1] = "release"
end
local function mkcanvas(w, h)
checkint(w, "canvas w"); checkint(h, "canvas h")
@@ -123,7 +130,12 @@ local function build_wada()
wada.sys.beep = function() return false end
wada.sys.caps = function() return { sdk_ext = cfg.caps.sdk_ext, keyboard = cfg.caps.keyboard,
touch = cfg.caps.touch, sd = true, compass = cfg.caps.compass,
accel = cfg.caps.accel, discover = cfg.caps.discover } end
accel = cfg.caps.accel, discover = cfg.caps.discover,
sd_list = cfg.caps.sd_list or false,
audio = cfg.caps.audio or false,
audio_wav = cfg.caps.audio or false,
audio_mp3 = cfg.caps.audio or false,
audio_sd = cfg.caps.audio_sd or false } end
if cfg.caps.sdk_ext then
wada.sys.battery = function() return { mv = 3900, pct = 70, charging = false } end
wada.sys.gps = function() return cfg.gps and cfg.gps() or nil end
@@ -153,6 +165,56 @@ local function build_wada()
wada.timer.every = function(ms) checkint(ms, "timer.every"); if ms < 33 then ms = 33 end; widgets.timer_ms = ms end
wada.timer.stop = function() widgets.timer_ms = nil end
if cfg.caps.audio then
wada.audio = {}
wada.audio.play = function(path)
assert(type(path) == "string", "audio.play path must be a string")
local source = "app"
if path:sub(1, 3) == "sd:" then
if not cfg.caps.audio_sd then return nil, "no sd" end
local card_path = path:sub(4)
if card_path:sub(1, 1) ~= "/" or card_path:find("//", 1, true) or
card_path:find("/../", 1, true) or card_path:sub(-3) == "/.." or
card_path:sub(-2) == "/." or card_path:sub(-1) == "/" then
return nil, "bad path"
end
source = "sd"
elseif #path == 0 or #path > 32 or path:sub(1, 1) == "." or
not path:match("^[A-Za-z0-9._-]+$") then
return nil, "bad path"
end
local format = path:lower():sub(-4)
if format ~= ".wav" and format ~= ".mp3" then return nil, "unsupported format" end
audio_state = { state = "playing", path = path, source = source,
format = format:sub(2), error = nil }
audio_log[#audio_log + 1] = "play:" .. path
return true
end
wada.audio.pause = function()
if audio_state.state ~= "playing" then return false end
audio_state.state = "paused"; audio_log[#audio_log + 1] = "pause"; return true
end
wada.audio.resume = function()
if audio_state.state ~= "paused" then return false end
audio_state.state = "playing"; audio_log[#audio_log + 1] = "resume"; return true
end
wada.audio.stop = function()
if audio_state.state ~= "playing" and audio_state.state ~= "paused" then return false end
audio_state.state = "stopped"; audio_log[#audio_log + 1] = "stop"; return true
end
wada.audio.status = function()
local out = {}
for key, value in pairs(audio_state) do out[key] = value end
return out
end
end
if cfg.caps.sd_list then
wada.sd = { list = function(path)
assert(path == "/", "mock SD only exposes the root")
return {}
end }
end
return wada
end
@@ -186,6 +248,8 @@ local function reset_world()
widgets = { canvases = 0, labels = 0, buttons = 0, scroll = false, timer_ms = nil }
labels, buttons, toasts, drawlog = {}, {}, {}, { text = {}, circles = {}, ops = 0 }
clock_ms = 1000
audio_state = { state = "stopped", path = "", source = "", format = "", error = nil }
audio_log = {}
end
-- simulated magnetometer: Earth field 0.45 G at true heading `deg` (device frame ==
@@ -740,6 +804,61 @@ scenarios.tanmatsu = function()
if app.on_close then guarded(BUDGET, app.on_close) end
end
scenarios.audio_api = function()
cfg = { w = 320, h = 196,
caps = { sdk_ext = true, keyboard = true, touch = false,
audio = true, audio_sd = true, sd_list = true } }
wada = build_wada()
local caps = wada.sys.caps()
assert(caps.audio and caps.audio_wav and caps.audio_mp3 and caps.audio_sd,
"audio capability flags do not match the WAV/MP3 contract")
assert(wada.audio and wada.audio.play and wada.audio.pause and wada.audio.resume and
wada.audio.stop and wada.audio.status, "wada.audio API is incomplete")
assert(wada.audio.play("track.wav"))
local status = wada.audio.status()
assert(status.state == "playing" and status.path == "track.wav" and
status.source == "app" and status.format == "wav", "app-storage play status is wrong")
assert(wada.audio.pause() and wada.audio.status().state == "paused", "pause failed")
assert(wada.audio.resume() and wada.audio.status().state == "playing", "resume failed")
assert(wada.audio.play("next.wav") and wada.audio.status().path == "next.wav",
"a second play must replace the active track")
assert(wada.audio.play("sd:/Music/card.wav"))
assert(wada.audio.status().source == "sd", "sd: source was not reported")
local value, err = wada.audio.play("../escape.wav")
assert(value == nil and err == "bad path", "app sandbox traversal was accepted")
value, err = wada.audio.play("sd:/Music/../escape.wav")
assert(value == nil and err == "bad path", "SD traversal was accepted")
assert(wada.audio.play("track.mp3") and wada.audio.status().format == "mp3",
"MP3 playback was not accepted or reported")
value, err = wada.audio.play("track.flac")
assert(value == nil and err == "unsupported format", "unknown audio format was accepted")
assert(wada.audio.stop() and wada.audio.status().state == "stopped", "stop failed")
assert(wada.audio.play("close.wav"))
audio_host_close()
assert(wada.audio.status().state == "stopped" and audio_log[#audio_log] == "release",
"host close did not release playback")
print(" transport + storage sandbox: PASS (" .. #audio_log .. " host calls)")
if APP_PATH:match("audio_test%.lua$") then
local app = load_app()
assert(guarded(BUDGET, app.on_open, cfg.w, cfg.h))
assert(#buttons == 5, "audio test app did not create all transport/source buttons")
buttons[1].fn(); assert(wada.audio.status().state == "playing", "Play button failed")
buttons[2].fn(); assert(wada.audio.status().state == "paused", "Pause button failed")
buttons[3].fn(); assert(wada.audio.status().state == "playing", "Resume button failed")
buttons[4].fn(); assert(wada.audio.status().state == "stopped", "Stop button failed")
buttons[5].fn(); buttons[1].fn()
assert(wada.audio.status().source == "app", "source switch did not select app storage")
guarded(BUDGET, app.on_tick)
guarded(BUDGET, app.on_close)
assert(wada.audio.status().state == "stopped", "test app close did not stop playback")
print(" scripts/audio_test.lua UI: PASS")
end
end
-- instruction cost of one heavy tick (redraw forced every tick by spinning the heading)
scenarios.cost = function()
cfg = { w = 320, h = 196, caps = { sdk_ext = true, keyboard = true, touch = false, compass = true, accel = true },
@@ -770,7 +889,7 @@ end
local order = APP_PATH:find("/wardrive/", 1, true)
and { "wardrive_utf8" }
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "cost" }
or { "declination", "align_nofix", "bearings_absolute", "m9", "r8", "v4", "pager", "pager_portrait_jumbo", "tanmatsu", "audio_api", "cost" }
for _, name in ipairs(order) do
if SCENARIO == "all" or SCENARIO == name then
print("== " .. name)
+13 -2
View File
@@ -22,6 +22,7 @@ Usage:
scripts/sideload_app.py --port /dev/cu.wchusbserial10 deploy/apps/gpscompass/1.0
scripts/sideload_app.py --port ... --reboot deploy/apps/gpscompass/1.0/gpscompass.lua
scripts/sideload_app.py --port ... --dest /lang deploy/apps/lang/11/de.lang
scripts/sideload_app.py --port ... --remote /apps/audio_test.d/test.mp3 ~/Music/test.mp3
Pass an app VERSION directory to send its <id>.lua + <id>.json, or individual
files. --reboot restarts the device afterwards so the drawer/Store rescan
@@ -152,6 +153,7 @@ def main():
ap.add_argument("--port", required=True)
ap.add_argument("--baud", type=int, default=115200)
ap.add_argument("--dest", default="/apps", help="/apps (default) or /lang")
ap.add_argument("--remote", help="exact remote path for one file, including /apps/<id>.d/<name>")
ap.add_argument("--reboot", action="store_true", help="reboot the device afterwards")
args = ap.parse_args()
@@ -170,10 +172,19 @@ def main():
else:
sys.exit("not found: " + p)
if args.remote:
if len(files) != 1:
sys.exit("--remote requires exactly one input file")
if not args.remote.startswith(("/apps/", "/lang/")):
sys.exit("--remote must begin with /apps/ or /lang/")
s = open_port(args.port, args.baud)
try:
for f in files:
push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f)))
if args.remote:
push(s, files[0], args.remote)
else:
for f in files:
push(s, f, "%s/%s" % (args.dest.rstrip("/"), os.path.basename(f)))
if args.reboot:
s.write(b"reboot\n")
s.flush()