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:
commit
65ea09ea89
25 files changed
+3984
-60
No files matched your search
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user