mirror of
https://github.com/ALLFATHER-BV/wadamesh.git
synced 2026-08-28 21:08:22 +00:00
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:
@@ -0,0 +1,156 @@
|
||||
# Testing Lua audio playback
|
||||
|
||||
This guide tests issue #315 with a real WAV or MP3. The examples use the
|
||||
T-Deck environment and the provided `scripts/audio_test.lua` app.
|
||||
|
||||
## Build and flash
|
||||
|
||||
Build and upload the branch as usual:
|
||||
|
||||
```sh
|
||||
pio run -e LilyGo_TDeck_companion_radio_touch
|
||||
pio run -e LilyGo_TDeck_companion_radio_touch -t upload
|
||||
```
|
||||
|
||||
Keep the serial monitor available for `[LUAAPP]` or storage errors:
|
||||
|
||||
```sh
|
||||
pio device monitor -b 115200
|
||||
```
|
||||
|
||||
## Quick MP3 test from SD
|
||||
|
||||
1. Use a FAT32 SD card and create these paths on it:
|
||||
|
||||
```text
|
||||
/Music/test.mp3
|
||||
/meshcomod/apps/audio_test.lua
|
||||
```
|
||||
|
||||
2. Copy `scripts/audio_test.lua` from this repository to the second path. Rename
|
||||
your MP3 to `test.mp3`, or edit `SD_PATH` near the top of the Lua file.
|
||||
3. Insert the card before boot, then reboot the T-Deck.
|
||||
4. Enable **Settings > Sound** and set volume above zero.
|
||||
5. Open **Apps > audio_test**. The screen should report
|
||||
`audio=true`, `wav=true`, and `mp3=true`.
|
||||
6. Press **Play**. Expected status:
|
||||
|
||||
```text
|
||||
State: playing
|
||||
Source: sd Format: mp3
|
||||
```
|
||||
|
||||
7. Exercise **Pause**, **Resume**, and **Stop**. Pressing **Play** while already
|
||||
playing must restart/replace the current track without overlapping it.
|
||||
8. Start playback and leave the app. Audio must stop immediately when the app
|
||||
closes.
|
||||
|
||||
The direct SD API used by the test is:
|
||||
|
||||
```lua
|
||||
local ok, err = wada.audio.play("sd:/Music/test.mp3")
|
||||
```
|
||||
|
||||
`sd:` paths are absolute from the physical card root. Empty segments,
|
||||
backslashes, trailing slashes, `.` and `..` are rejected.
|
||||
|
||||
## Test app-local/internal storage
|
||||
|
||||
A plain filename is resolved inside the current app's private directory. For
|
||||
`audio_test.lua`, the logical files are:
|
||||
|
||||
```text
|
||||
/apps/audio_test.lua
|
||||
/apps/audio_test.d/test.mp3
|
||||
```
|
||||
|
||||
On T-Deck and Pager, internal app storage is SPIFFS. The least destructive way
|
||||
to populate it is the repository's serial uploader:
|
||||
|
||||
1. Power off, remove the SD card, and reboot. This forces T-Deck app storage to
|
||||
the internal SPIFFS backend.
|
||||
2. Make a short internal-storage fixture. T-Deck has a 3.375 MB SPIFFS partition
|
||||
shared with settings and history, so use the full song for SD testing and a
|
||||
small clip here:
|
||||
|
||||
```sh
|
||||
ffmpeg -y -i "/path/to/your/file.mp3" \
|
||||
-t 10 -ac 1 -ar 22050 -b:a 48k \
|
||||
/tmp/wadamesh-audio-test.mp3
|
||||
```
|
||||
|
||||
3. Find the serial port with `pio device list`.
|
||||
4. Install `pyserial` once if needed:
|
||||
|
||||
```sh
|
||||
python3 -m pip install pyserial
|
||||
```
|
||||
|
||||
5. Upload the app and short MP3. Replace the port as needed:
|
||||
|
||||
```sh
|
||||
python3 scripts/sideload_app.py \
|
||||
--port /dev/cu.usbmodem101 \
|
||||
--remote /apps/audio_test.lua \
|
||||
scripts/audio_test.lua
|
||||
|
||||
python3 scripts/sideload_app.py \
|
||||
--port /dev/cu.usbmodem101 \
|
||||
--remote /apps/audio_test.d/test.mp3 \
|
||||
--reboot \
|
||||
/tmp/wadamesh-audio-test.mp3
|
||||
```
|
||||
|
||||
6. Open **Apps > audio_test** after reboot. With no card inserted, the app
|
||||
selects `test.mp3`; press **Play**.
|
||||
7. Expected status is `Source: app Format: mp3`.
|
||||
|
||||
The app-local API is simply:
|
||||
|
||||
```lua
|
||||
local ok, err = wada.audio.play("test.mp3")
|
||||
```
|
||||
|
||||
Tanmatsu uses internal FFat and T-Display P4 uses internal LittleFS. The same
|
||||
serial destination `/apps/audio_test.d/test.mp3` follows the active internal
|
||||
backend, so no Lua code changes are needed. Direct `sd:` playback is shown only
|
||||
when `wada.sys.caps().audio_sd` is true.
|
||||
|
||||
Do not use `uploadfs` on a device with data you need to keep: a filesystem-image
|
||||
upload replaces the internal storage partition, including settings and history.
|
||||
|
||||
## Error and lifecycle checks
|
||||
|
||||
- Disable Sound, then press **Play**: expect `Command: muted`.
|
||||
- Rename the file or remove the card: expect `not found` or `no sd`.
|
||||
- Try a non-WAV/MP3 extension: expect `unsupported format`.
|
||||
- Remove the card during SD playback only after the baseline test. Playback must
|
||||
enter `error` or stop without rebooting; reinserting the card should allow a
|
||||
later play after the normal SD recovery delay.
|
||||
- Receive a mesh notification during playback: it must not start a second audio
|
||||
stream over the media track.
|
||||
- Repeatedly open/play/close the app: each close must release the worker, codec,
|
||||
amplifier, file handle, and storage lease.
|
||||
|
||||
## Host decoder regression test
|
||||
|
||||
The standalone test validates the pinned decoder and the same rolling 16 KB
|
||||
input algorithm used by firmware:
|
||||
|
||||
```sh
|
||||
c++ -std=c++17 -O2 test/test_minimp3_decoder.cpp -o /tmp/test_minimp3_decoder
|
||||
/tmp/test_minimp3_decoder /path/to/your/file.mp3
|
||||
```
|
||||
|
||||
A successful run prints decoded frames, samples per channel, sample rate,
|
||||
channels, and bitrate changes. The implementation has also been checked with
|
||||
CBR, VBR, leading ID3v2, trailing ID3v1, and trailing APEv2 metadata.
|
||||
|
||||
## Supported formats
|
||||
|
||||
- WAV: PCM, 16-bit, mono or stereo, 8-48 kHz.
|
||||
- MP3: MPEG Layer III, mono or stereo, 8-48 kHz, CBR or VBR, with common ID3 and
|
||||
APEv2 metadata.
|
||||
|
||||
Stereo is downmixed to the device's mono speaker path. `status()` reports
|
||||
`stopped`, `playing`, `paused`, `ended`, or `error`.
|
||||
@@ -54,6 +54,13 @@ ed25519 (bundled in lib/ed25519)
|
||||
Orson Peters / orlp — public domain (Creative Commons Zero / zlib)
|
||||
https://github.com/orlp/ed25519
|
||||
|
||||
minimp3 (bundled in lib/minimp3)
|
||||
lieff/minimp3 contributors — CC0 1.0 Universal
|
||||
https://github.com/lieff/minimp3
|
||||
Pinned at ea99364f61c14656440e8d77e9c233ccf3124633. The bundled header has
|
||||
a small opt-in scratch-storage hook so the decoder workspace can live in
|
||||
PSRAM; default upstream behavior is unchanged when that hook is not used.
|
||||
|
||||
AsyncElegantOTA (vendored in arch/esp32/AsyncElegantOTA)
|
||||
Copyright (c) Ayush Sharma — MIT License
|
||||
|
||||
|
||||
@@ -42,6 +42,10 @@ the transports, …) are dropped from the lib via `-DMC_VENDORED_TOUCH_APP` so t
|
||||
aren't compiled twice. The build is byte-identical to the original in-tree
|
||||
meshcomod firmware.
|
||||
|
||||
Lua apps use the on-device SDK described in [LUA_APPS.md](LUA_APPS.md). For
|
||||
WAV/MP3 playback, including a ready-to-sideload transport test app and exact SD
|
||||
and internal-storage paths, see [AUDIO_PLAYBACK_TESTING.md](AUDIO_PLAYBACK_TESTING.md).
|
||||
|
||||
## Build
|
||||
|
||||
[PlatformIO](https://platformio.org/) pulls the core fork and all libraries
|
||||
|
||||
@@ -16,8 +16,10 @@ One Lua file plus a small manifest. Apps talk to the firmware through the `wada.
|
||||
API — `wada.ui` (widgets, colours, a text prompt), `wada.sys`, `wada.store`
|
||||
(persistence), `wada.timer`, and on the larger boards `wada.fs`, `wada.net`,
|
||||
`wada.crypto` and the writable half of `wada.mesh`. Supported boards also expose
|
||||
read-only SD directory metadata through `wada.sd`; file contents and writes stay
|
||||
inaccessible. There is no general-purpose filesystem or network access; the API
|
||||
asynchronous WAV/MP3 playback through `wada.audio`; plain filenames come from the
|
||||
app sandbox on internal flash, SD, or SD_MMC. Read-only physical-SD directory
|
||||
metadata is available through `wada.sd`, and direct card playback uses an explicit
|
||||
`sd:/...` path. There is no general-purpose filesystem or network access; the API
|
||||
is the whole surface, which is what makes reviewing tractable. It is documented at
|
||||
[wadamesh.com/sdk.html](https://wadamesh.com/sdk.html).
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
{
|
||||
"id": "sdktest",
|
||||
"name": "SDK Test",
|
||||
"ver": "1.6",
|
||||
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages."
|
||||
"ver": "1.7",
|
||||
"desc": "Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private file access, audio API, read-only SD listing, crypto against published RFC vectors, channel discovery, and buttons that TRANSMIT test messages."
|
||||
},
|
||||
{
|
||||
"id": "2048",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
{"id":"sdktest","name":"SDK Test","ver":"1.7","desc":"Developer tool. Checks the extended Lua SDK on this board: capabilities, clock, battery, GPS, private files, audio API, read-only SD listing, crypto vectors, packet identity, channels, and opt-in transmit calls."}
|
||||
@@ -0,0 +1,293 @@
|
||||
-- SDK self-test. Exercises the extended SDK so the results can be read off the
|
||||
-- screen instead of inferred from a build log. Published to the store as a
|
||||
-- developer/bench tool.
|
||||
--
|
||||
-- 1.2 adds wada.crypto (checked against published RFC vectors, so a PASS here is
|
||||
-- real evidence and not just "it returned something"), wada.mesh.channels, and
|
||||
-- wada.mesh.send_dm.
|
||||
-- 1.3 adds the discovery surface (wada.mesh.discover / discovered), packet
|
||||
-- identity in rx_log, exact micro-degree coordinates, wada.ui.input and the
|
||||
-- windowed wada.fs.read.
|
||||
-- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root
|
||||
-- metadata, and a nested directory listing when the card contains one.
|
||||
-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback
|
||||
-- stays manual so opening the bench test never emits sound unexpectedly.
|
||||
-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the
|
||||
-- firmware has it. That call was added because of the 1.3 bug below: an app
|
||||
-- could ask how tall a line is but not how wide, so laying out rows meant
|
||||
-- guessing whether text would wrap. Falls back to the 1.4 estimate on older
|
||||
-- firmware, so it still lays out correctly there.
|
||||
-- 1.4 fixes rows drawing on top of one another. label:width() turns wrapping
|
||||
-- on, so any line wider than the screen became two lines while the caller still
|
||||
-- advanced by one, and the next row landed on top. Reported with a photo of the
|
||||
-- channels line sitting across the contacts line. row() now owns the cursor and
|
||||
-- advances by the height the text actually needs.
|
||||
local ui, sys, store, timer = wada.ui, wada.sys, wada.store, wada.timer
|
||||
local C = ui.colors
|
||||
|
||||
local app = {}
|
||||
local rows, keyline, sendline, dmline, msgline = {}, nil, nil, nil, nil
|
||||
local discline, inputline = nil, nil
|
||||
local W = 300
|
||||
|
||||
-- row() owns the vertical cursor. Every caller used to pass a y and then add a
|
||||
-- hand-picked constant, which is only correct while the text fits on one line.
|
||||
-- An app cannot measure rendered text, so the wrapped line count is estimated
|
||||
-- from a deliberately pessimistic character width: over-estimating costs a
|
||||
-- little whitespace, under-estimating overlaps the next row.
|
||||
local LH, GAP = 14, 3
|
||||
local cy = 4
|
||||
local function row(text, color, extra_gap)
|
||||
local l = ui.label(text, 6, cy, 12, color or C.text)
|
||||
l:width(W - 12)
|
||||
rows[#rows + 1] = l
|
||||
local n
|
||||
if ui.text_lines then
|
||||
n = ui.text_lines(tostring(text), W - 12, 12) -- measured: exact
|
||||
else
|
||||
local cpl = math.max(16, (W - 12) // 7) -- older firmware: estimate
|
||||
n = 0
|
||||
for seg in (tostring(text) .. "\n"):gmatch("(.-)\n") do
|
||||
n = n + math.max(1, math.ceil(#seg / cpl))
|
||||
end
|
||||
end
|
||||
cy = cy + math.max(1, n or 1) * LH + GAP + (extra_gap or 0)
|
||||
return l
|
||||
end
|
||||
local function yn(v) return v and "yes" or "NO" end
|
||||
|
||||
function app.on_open(w, h)
|
||||
W = w or 300
|
||||
ui.scroll(true)
|
||||
cy = 4
|
||||
local y
|
||||
|
||||
local c = sys.caps()
|
||||
row("caps: sdk_ext=" .. yn(c.sdk_ext) .. " kbd=" .. yn(c.keyboard) ..
|
||||
" touch=" .. yn(c.touch) .. " sd=" .. yn(c.sd), C.accent)
|
||||
row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) ..
|
||||
" input=" .. yn(c.input) ..
|
||||
" rx_identity=" .. yn(c.rx_identity), C.accent)
|
||||
row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) ..
|
||||
" mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent)
|
||||
if c.audio then
|
||||
local api_ok = wada.audio and type(wada.audio.play) == "function" and
|
||||
type(wada.audio.pause) == "function" and
|
||||
type(wada.audio.resume) == "function" and
|
||||
type(wada.audio.stop) == "function" and
|
||||
type(wada.audio.status) == "function"
|
||||
local status = api_ok and wada.audio.status() or nil
|
||||
api_ok = api_ok and type(status) == "table" and type(status.state) == "string"
|
||||
row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"),
|
||||
api_ok and C.good or C.bad)
|
||||
else
|
||||
row("wada.audio: unavailable on this board", C.sub)
|
||||
end
|
||||
row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"),
|
||||
ui.text_lines and C.good or C.sub)
|
||||
-- crypto: published test vectors, so this is checkable rather than merely alive
|
||||
if wada.crypto then
|
||||
local hex = wada.crypto.hex
|
||||
local k20 = string.rep(string.char(0x0b), 20)
|
||||
local checks = {
|
||||
{ "sha256('abc')", hex(wada.crypto.sha256("abc")),
|
||||
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" },
|
||||
{ "sha1('abc')", hex(wada.crypto.sha1("abc")),
|
||||
"a9993e364706816aba3e25717850c26c9cd0d89d" },
|
||||
{ "hmac_sha1 RFC2202#1", hex(wada.crypto.hmac_sha1(k20, "Hi There")),
|
||||
"b617318655057264e28bc0b6fb378c8ef146be00" },
|
||||
{ "hmac_sha256 RFC4231#1", hex(wada.crypto.hmac_sha256(k20, "Hi There")),
|
||||
"b0344c61d8db38535ca8afceaf0bf12b881dc200c9833da726e9376c2e32cff7" },
|
||||
}
|
||||
local allok = true
|
||||
for _, t in ipairs(checks) do
|
||||
local ok = (t[2] == t[3])
|
||||
if not ok then allok = false end
|
||||
row("crypto " .. t[1] .. ": " .. (ok and "PASS" or ("FAIL got " .. tostring(t[2]):sub(1, 16))),
|
||||
ok and C.good or C.bad)
|
||||
end
|
||||
-- The point of having it in C: this loop would blow the instruction budget in Lua.
|
||||
local t0 = sys.millis()
|
||||
for _ = 1, 200 do wada.crypto.hmac_sha1(k20, "Hi There") end
|
||||
row(string.format("crypto: 200 x hmac_sha1 in %d ms%s", sys.millis() - t0,
|
||||
allok and "" or " (VECTORS FAILED)"), allok and C.good or C.bad)
|
||||
else
|
||||
row("wada.crypto: MISSING", C.bad)
|
||||
end
|
||||
|
||||
-- channel discovery
|
||||
local chans = wada.mesh.channels and wada.mesh.channels() or nil
|
||||
if chans then
|
||||
local names = table.concat(chans, ", ")
|
||||
row("channels(): " .. #chans .. " " .. names:sub(1, 60), C.text)
|
||||
else
|
||||
row("wada.mesh.channels: MISSING", C.bad)
|
||||
end
|
||||
|
||||
if not c.sdk_ext then
|
||||
row("extended SDK is OFF on this board - stopping here.", C.sub)
|
||||
return
|
||||
end
|
||||
|
||||
-- contacts, so send_dm has a target to name. 1.3 also checks the pubkey field.
|
||||
local cts = wada.mesh.contacts()
|
||||
local first = cts[1] and cts[1].name or nil
|
||||
row("contacts: " .. #cts .. (first and (" first=" .. first) or ""), C.text)
|
||||
if cts[1] then
|
||||
local pk = cts[1].pubkey
|
||||
row("contacts[1].pubkey: " .. tostring(pk) ..
|
||||
(type(pk) == "string" and #pk == 8 and " PASS" or " FAIL"),
|
||||
(type(pk) == "string" and #pk == 8) and C.good or C.bad)
|
||||
end
|
||||
|
||||
local me = wada.mesh.self()
|
||||
row("self: " .. tostring(me.name) .. " pubkey=" .. tostring(me.pubkey), C.text)
|
||||
-- Exact coordinates. Lua's floats here are 32-bit, so an app that logs a track
|
||||
-- must use the _e6 integers; this proves they are present and consistent.
|
||||
local fix = sys.gps()
|
||||
if fix then
|
||||
local drift = math.abs(fix.lat_e6 / 1e6 - fix.lat)
|
||||
row(string.format("gps: %d,%d e6 alt %dm %d sats drift %.6f %s",
|
||||
fix.lat_e6, fix.lon_e6, fix.alt_m or 0, fix.sats, drift,
|
||||
drift < 0.001 and "PASS" or "FAIL"), drift < 0.001 and C.good or C.bad)
|
||||
else
|
||||
row("gps: no fix (normal indoors)", C.sub)
|
||||
end
|
||||
|
||||
-- rx_log identity: adverts carry a real public key, addressed frames carry
|
||||
-- one-byte hashes, everything else carries nothing. All three are correct.
|
||||
local log = wada.mesh.rx_log()
|
||||
local withpk, withsrc = 0, 0
|
||||
for _, r in ipairs(log) do
|
||||
if r.pubkey then withpk = withpk + 1 end
|
||||
if r.src then withsrc = withsrc + 1 end
|
||||
end
|
||||
row(string.format("rx_log: %d frames, %d with a pubkey (adverts), %d with src/dst",
|
||||
#log, withpk, withsrc), #log > 0 and C.text or C.sub)
|
||||
-- fs: windowed read. Writes once (the 1/sec limit means one write per open).
|
||||
if wada.fs then
|
||||
local probe = "0123456789abcdef"
|
||||
wada.fs.write("sdktest.bin", probe)
|
||||
local part, total = wada.fs.read("sdktest.bin", 4, 4)
|
||||
local ok = (part == "4567" and total == #probe)
|
||||
row("fs.read(name,4,4): " .. tostring(part) .. " total=" .. tostring(total) ..
|
||||
(ok and " PASS" or " FAIL"), ok and C.good or C.bad)
|
||||
end
|
||||
|
||||
-- Physical SD listing is read-only and independently feature-detected. A
|
||||
-- supported board with no inserted card is a normal bench state, not a test
|
||||
-- failure; API shape and path rejection can still be checked separately.
|
||||
if c.sd_list then
|
||||
if not wada.sd or not wada.sd.list then
|
||||
row("wada.sd.list: MISSING despite caps", C.bad)
|
||||
else
|
||||
local guard_ok, guard_failure = true, nil
|
||||
local bad_paths = { "/..", "/.", "/a/../b", "/a//b", "/a/", "relative", "/a\\b" }
|
||||
for _, path in ipairs(bad_paths) do
|
||||
local rejected, baderr = wada.sd.list(path)
|
||||
if rejected ~= nil or baderr ~= "bad path" then
|
||||
guard_ok = false
|
||||
guard_failure = path .. " -> " .. tostring(baderr)
|
||||
break
|
||||
end
|
||||
end
|
||||
row("sd traversal guard: " .. (guard_ok and "PASS" or ("FAIL " .. guard_failure)),
|
||||
guard_ok and C.good or C.bad)
|
||||
|
||||
local entries, err = wada.sd.list("/")
|
||||
if not entries then
|
||||
local note = err == "busy" and " (retry later)" or " (no readable card)"
|
||||
row("sd.list('/'): " .. tostring(err) .. note, C.sub)
|
||||
else
|
||||
local metadata_ok, first_dir = true, nil
|
||||
for _, entry in ipairs(entries) do
|
||||
if type(entry.name) ~= "string" or
|
||||
(entry.type ~= "file" and entry.type ~= "dir") or
|
||||
type(entry.size) ~= "number" or type(entry.mtime) ~= "number" then
|
||||
metadata_ok = false
|
||||
end
|
||||
if not first_dir and entry.type == "dir" then first_dir = entry.name end
|
||||
end
|
||||
row("sd.list('/'): " .. #entries .. " entries, metadata " ..
|
||||
(metadata_ok and "PASS" or "FAIL") ..
|
||||
(entries.truncated and " (truncated)" or ""),
|
||||
metadata_ok and C.good or C.bad)
|
||||
if first_dir then
|
||||
local nested, nested_err = wada.sd.list("/" .. first_dir)
|
||||
row("sd.list('/" .. first_dir .. "'): " ..
|
||||
(nested and (#nested .. " entries PASS") or ("FAIL " .. tostring(nested_err))),
|
||||
nested and C.good or C.bad)
|
||||
else
|
||||
row("sd nested listing: no directory on card to test", C.sub)
|
||||
end
|
||||
end
|
||||
end
|
||||
else
|
||||
row("wada.sd.list: unavailable on this board", C.sub)
|
||||
end
|
||||
|
||||
sendline = row("mesh.send: not tried yet", C.sub)
|
||||
dmline = row("mesh.send_dm: not tried yet", C.sub)
|
||||
msgline = row("on_message: waiting (needs the read permissions)", C.sub)
|
||||
y = cy + 4
|
||||
ui.button("Send to Public", 6, y, 120, 30, function()
|
||||
local ok, err = wada.mesh.send("Public", "wadamesh SDK self-test")
|
||||
sendline:set("mesh.send: " .. tostring(ok) .. " " .. tostring(err))
|
||||
sendline:color(ok and C.good or C.bad)
|
||||
end)
|
||||
ui.button("DM first contact", 132, y, 130, 30, function()
|
||||
if not first then dmline:set("mesh.send_dm: no contacts to target"); dmline:color(C.bad); return end
|
||||
local ok, err = wada.mesh.send_dm(first, "wadamesh SDK self-test (DM)")
|
||||
dmline:set("mesh.send_dm -> " .. first .. ": " .. tostring(ok) .. " " .. tostring(err))
|
||||
dmline:color(ok and C.good or C.bad)
|
||||
end)
|
||||
cy = y + 38
|
||||
discline = row("mesh.discover: not tried yet", C.sub)
|
||||
inputline = row("ui.input: not tried yet", C.sub)
|
||||
-- Probing TRANSMITS and makes every neighbour reply, so it is a button, not
|
||||
-- something this app does on open.
|
||||
y = cy + 4
|
||||
ui.button("Probe", 6, y, 90, 30, function()
|
||||
local tag, err = wada.mesh.discover()
|
||||
if not tag then
|
||||
discline:set("mesh.discover: " .. tostring(err)); discline:color(C.bad); return
|
||||
end
|
||||
discline:set("mesh.discover: sent, waiting for replies..."); discline:color(C.text)
|
||||
end)
|
||||
ui.button("Results", 102, y, 90, 30, function()
|
||||
local hits = wada.mesh.discovered()
|
||||
if #hits == 0 then
|
||||
discline:set("discovered(): nothing yet - probe, then wait a few seconds")
|
||||
discline:color(C.sub); return
|
||||
end
|
||||
local h = hits[1]
|
||||
discline:set(string.format("discovered(): %d first %s snr %.1f/%.1f %s",
|
||||
#hits, h.name or h.pubkey, h.snr, h.their_snr, h.direct and "direct" or (h.hops .. "h")))
|
||||
discline:color(C.good)
|
||||
end)
|
||||
y = y + 34
|
||||
ui.button("Beep", 6, y, 70, 30, function() sys.beep() end)
|
||||
ui.button("Input", 82, y, 90, 30, function()
|
||||
ui.input("Type anything", "hello", function(text)
|
||||
inputline:set("ui.input -> " .. (text and ("'" .. text .. "'") or "cancelled"))
|
||||
inputline:color(text and C.good or C.sub)
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
function app.on_input(ev)
|
||||
if ev.type == "key" and keyline then
|
||||
keyline:set("keys: got '" .. tostring(ev.key) .. "'")
|
||||
keyline:color(C.good)
|
||||
end
|
||||
end
|
||||
|
||||
-- Proves the kind field and that DMs/rooms reach an app at all, not just channels.
|
||||
function app.on_message(m)
|
||||
if not msgline then return end
|
||||
msgline:set("on_message: kind=" .. tostring(m.kind) .. " from=" .. tostring(m.sender) ..
|
||||
" ch=" .. tostring(m.channel) .. " text=" .. tostring(m.text):sub(1, 20))
|
||||
msgline:color(C.good)
|
||||
end
|
||||
|
||||
return app
|
||||
+21
-2
@@ -141,6 +141,7 @@
|
||||
<a href="#net">wada.net</a>
|
||||
<a href="#store">wada.store</a>
|
||||
<a href="#fs">wada.fs</a>
|
||||
<a href="#audio">wada.audio</a>
|
||||
<a href="#sd">wada.sd</a>
|
||||
<a href="#sys">wada.sys</a>
|
||||
<a href="#timer">wada.timer</a>
|
||||
@@ -441,6 +442,24 @@ end</code></pre>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="audio">
|
||||
<h2>wada.audio <span class="chip">audio</span></h2>
|
||||
<div class="row"><div class="prose">
|
||||
<p>Asynchronous audio playback from the current app's private storage. A plain name such as <code>track.wav</code> resolves inside the same app folder as <a href="#fs">wada.fs</a>, whether that folder lives on internal flash, SD, or SD_MMC. Check <code>wada.sys.caps().audio</code> and <code>audio_wav</code> before using it.</p>
|
||||
<table class="api">
|
||||
<tr><th>Call</th><th>Returns / does</th></tr>
|
||||
<tr><td>wada.audio.play(name)</td><td>Starts or replaces playback and returns <code>true</code>, or <code>nil, error</code>. Plain names use app-private storage. On boards with <code>caps().audio_sd</code>, <code>sd:/Music/track.wav</code> reads directly from the physical card.</td></tr>
|
||||
<tr><td>wada.audio.pause()</td><td>Pauses an active track. Returns whether the command was accepted.</td></tr>
|
||||
<tr><td>wada.audio.resume()</td><td>Resumes a paused track. Returns whether the command was accepted.</td></tr>
|
||||
<tr><td>wada.audio.stop()</td><td>Stops the active track. Returns whether the command was accepted.</td></tr>
|
||||
<tr><td>wada.audio.status()</td><td><code>{state, path, source, format, error}</code>. State is <code>stopped</code>, <code>playing</code>, <code>paused</code>, <code>ended</code>, or <code>error</code>; <code>error</code> is present only after a playback failure.</td></tr>
|
||||
</table>
|
||||
<p>Supported formats are PCM WAV (16-bit mono or stereo at 8–48 kHz) and MPEG Layer III MP3, including CBR, VBR, and ID3-tagged files. Stereo is mixed to mono on the device. Check <code>audio_wav</code> or <code>audio_mp3</code> rather than assuming a format exists on older firmware.</p>
|
||||
<p>Playback follows the user's Sound switch and volume, never blocks the Lua callback, and stops automatically when the app closes. A second <code>play()</code> replaces the current track. Playlist ordering belongs to the app, so there are no ambiguous host-side <code>next()</code> or <code>previous()</code> calls.</p>
|
||||
<p>Plain names follow the same sandbox rules as <code>wada.fs</code>. Explicit <code>sd:</code> paths follow <a href="#sd">wada.sd</a> path validation. Expected errors include <code>bad path</code>, <code>no storage</code>, <code>no sd</code>, <code>not found</code>, <code>busy</code>, <code>muted</code>, and <code>unsupported format</code>.</p>
|
||||
</div></div>
|
||||
</section>
|
||||
|
||||
<section class="doc" id="sd">
|
||||
<h2>wada.sd <span class="chip">sd_list</span></h2>
|
||||
<div class="row"><div class="prose">
|
||||
@@ -466,7 +485,7 @@ end</code></pre>
|
||||
<tr><td>wada.sys.datetime()</td><td><code>{year, month, day, hour, min, sec, wday}</code> in local time. <code>wday</code> is 0 for Sunday.</td></tr>
|
||||
<tr><td>wada.sys.tr(s)</td><td>Translate <code>s</code> through the device's active language, using the same table the firmware's own interface uses. Returns <code>s</code> unchanged when there is no translation, so it is always safe to wrap a string. Add your keys to <code>deploy/apps/lang/<code>.lang</code> alongside the firmware's. Older firmware has no <code>sys.tr</code>: write <code>local tr = sys.tr or function(x) return x end</code> and call <code>tr()</code>.</td></tr>
|
||||
<tr><td>wada.sys.beep()</td><td>Short beep on boards with a buzzer; silent elsewhere, and silent when the user has sound off.</td></tr>
|
||||
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span> below, and <code>sd_list</code> before using <code>wada.sd</code>. The rest are feature flags for calls added after the extended SDK first shipped, so an app can degrade on older firmware instead of erroring.</td></tr>
|
||||
<tr><td>wada.sys.caps()</td><td><code>{sdk_ext, keyboard, touch, sd, sd_list, audio, audio_wav, audio_mp3, audio_sd, compass, accel, discover, input, rx_identity, list, packets, sensors, map, measure}</code>. Check <code>sdk_ext</code> before using anything marked <span class="chip">ext</span>, <code>sd_list</code> before using <code>wada.sd</code>, and <code>audio</code> plus the format flag before using <code>wada.audio</code>. <code>audio_sd</code> means direct <code>sd:</code> paths are available; plain audio names use app storage and do not require it.</td></tr>
|
||||
<tr><td>wada.sys.battery() <span class="chip">ext</span></td><td><code>{mv, pct, charging}</code>.</td></tr>
|
||||
<tr><td>wada.sys.env() <span class="chip">sensors</span></td><td><code>{temp_c, humidity, pressure_hpa, alt_m}</code>, or <code>nil</code>. A field is present only when the hardware actually reported it, so you can tell "no humidity sensor" from "0% humidity". Gated on <code>caps().sensors</code> — does this board <i>have</i> the sensor rail — and <b>not</b> on <code>sdk_ext</code>, which is a memory gate. Those are different questions: the plain Heltec V4 has the Expansion Kit but not the extended SDK, and most boards with the extended SDK have no sensors at all.</td></tr>
|
||||
<tr><td>wada.sys.gps() <span class="chip">ext</span></td><td><code>{lat, lon, lat_e6, lon_e6, sats, alt_m, time, speed_kmh, course}</code>, or <code>nil</code> with no fix — which is the normal indoor case, so handle it. <code>lat_e6</code>/<code>lon_e6</code> are exact micro-degrees; <code>lat</code>/<code>lon</code> are single-precision floats, so <a href="#discover">log the integers</a>. <code>alt_m</code> is metres. <code>time</code> is satellite time, absent until the receiver has decoded the date. <code>speed_kmh</code> and <code>course</code> (degrees clockwise from north) appear only on boards whose GPS provider reports them, and <code>course</code> only while actually moving — a stationary receiver has no course, so it is absent rather than 0. Treat those last three as optional.</td></tr>
|
||||
@@ -587,7 +606,7 @@ end</code></pre>
|
||||
<div class="row"><div class="prose">
|
||||
<p>Apps run in a restricted environment. These are removed: <code>io</code>, <code>os</code>, <code>require</code>, <code>dofile</code>, and loading new chunks at runtime. Available: <code>math</code>, <code>string</code>, <code>table</code>, and the usual <code>pairs</code>, <code>ipairs</code>, <code>select</code>, <code>pcall</code>, <code>tostring</code>, <code>tonumber</code>.</p>
|
||||
<p>Memory comes from a capped pool in PSRAM, so an app that allocates without bound fails its own allocation rather than starving the radio or the UI. There is an <a href="#budget">instruction budget</a> on every callback for the same reason.</p>
|
||||
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above — <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>wada.mesh.send_dm</code>, <code>wada.mesh.discover</code>, <code>sys.battery</code>, <code>sys.gps</code> — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming. <code>wada.sd</code> additionally needs a physical card interface, reported by <code>caps().sd_list</code>.</p>
|
||||
<p><b>The extended calls are not on every board.</b> Anything marked <span class="chip">ext</span> above — <code>wada.fs</code>, <code>wada.mesh.send</code>, <code>wada.mesh.send_dm</code>, <code>wada.mesh.discover</code>, <code>sys.battery</code>, <code>sys.gps</code> — needs a board with the memory to carry it, so the Heltec V4 keeps its RAM for the mesh instead. Call <code>wada.sys.caps().sdk_ext</code> and degrade gracefully rather than assuming. <code>wada.sd</code> additionally needs a physical card interface, reported by <code>caps().sd_list</code>. <code>wada.audio</code> instead follows <code>caps().audio</code>: storage may be internal, but the device still needs a stream-capable speaker path.</p>
|
||||
<p>Rate limits are part of the contract, not a rainy-day guard: <code>wada.fs</code> writes are about one per second with a 32 KB file cap, and <code>wada.mesh.send</code> has a 5 second floor and a 180-character limit. They return <code>false, "too fast"</code> rather than throwing, and hitting them is expected — handle it.</p>
|
||||
<p>None of this makes a hostile app safe, which is why the catalog is curated. It makes an <i>honest</i> app that has a bug survivable: it gets closed, and the device keeps carrying traffic.</p>
|
||||
</div></div>
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
CC0 1.0 Universal
|
||||
|
||||
Statement of Purpose
|
||||
|
||||
The laws of most jurisdictions throughout the world automatically confer
|
||||
exclusive Copyright and Related Rights (defined below) upon the creator and
|
||||
subsequent owner(s) (each and all, an "owner") of an original work of
|
||||
authorship and/or a database (each, a "Work").
|
||||
|
||||
Certain owners wish to permanently relinquish those rights to a Work for the
|
||||
purpose of contributing to a commons of creative, cultural and scientific
|
||||
works ("Commons") that the public can reliably and without fear of later
|
||||
claims of infringement build upon, modify, incorporate in other works, reuse
|
||||
and redistribute as freely as possible in any form whatsoever and for any
|
||||
purposes, including without limitation commercial purposes. These owners may
|
||||
contribute to the Commons to promote the ideal of a free culture and the
|
||||
further production of creative, cultural and scientific works, or to gain
|
||||
reputation or greater distribution for their Work in part through the use and
|
||||
efforts of others.
|
||||
|
||||
For these and/or other purposes and motivations, and without any expectation
|
||||
of additional consideration or compensation, the person associating CC0 with a
|
||||
Work (the "Affirmer"), to the extent that he or she is an owner of Copyright
|
||||
and Related Rights in the Work, voluntarily elects to apply CC0 to the Work
|
||||
and publicly distribute the Work under its terms, with knowledge of his or her
|
||||
Copyright and Related Rights in the Work and the meaning and intended legal
|
||||
effect of CC0 on those rights.
|
||||
|
||||
1. Copyright and Related Rights. A Work made available under CC0 may be
|
||||
protected by copyright and related or neighboring rights ("Copyright and
|
||||
Related Rights"). Copyright and Related Rights include, but are not limited
|
||||
to, the following:
|
||||
|
||||
i. the right to reproduce, adapt, distribute, perform, display, communicate,
|
||||
and translate a Work;
|
||||
|
||||
ii. moral rights retained by the original author(s) and/or performer(s);
|
||||
|
||||
iii. publicity and privacy rights pertaining to a person's image or likeness
|
||||
depicted in a Work;
|
||||
|
||||
iv. rights protecting against unfair competition in regards to a Work,
|
||||
subject to the limitations in paragraph 4(a), below;
|
||||
|
||||
v. rights protecting the extraction, dissemination, use and reuse of data in
|
||||
a Work;
|
||||
|
||||
vi. database rights (such as those arising under Directive 96/9/EC of the
|
||||
European Parliament and of the Council of 11 March 1996 on the legal
|
||||
protection of databases, and under any national implementation thereof,
|
||||
including any amended or successor version of such directive); and
|
||||
|
||||
vii. other similar, equivalent or corresponding rights throughout the world
|
||||
based on applicable law or treaty, and any national implementations thereof.
|
||||
|
||||
2. Waiver. To the greatest extent permitted by, but not in contravention of,
|
||||
applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and
|
||||
unconditionally waives, abandons, and surrenders all of Affirmer's Copyright
|
||||
and Related Rights and associated claims and causes of action, whether now
|
||||
known or unknown (including existing as well as future claims and causes of
|
||||
action), in the Work (i) in all territories worldwide, (ii) for the maximum
|
||||
duration provided by applicable law or treaty (including future time
|
||||
extensions), (iii) in any current or future medium and for any number of
|
||||
copies, and (iv) for any purpose whatsoever, including without limitation
|
||||
commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes
|
||||
the Waiver for the benefit of each member of the public at large and to the
|
||||
detriment of Affirmer's heirs and successors, fully intending that such Waiver
|
||||
shall not be subject to revocation, rescission, cancellation, termination, or
|
||||
any other legal or equitable action to disrupt the quiet enjoyment of the Work
|
||||
by the public as contemplated by Affirmer's express Statement of Purpose.
|
||||
|
||||
3. Public License Fallback. Should any part of the Waiver for any reason be
|
||||
judged legally invalid or ineffective under applicable law, then the Waiver
|
||||
shall be preserved to the maximum extent permitted taking into account
|
||||
Affirmer's express Statement of Purpose. In addition, to the extent the Waiver
|
||||
is so judged Affirmer hereby grants to each affected person a royalty-free,
|
||||
non transferable, non sublicensable, non exclusive, irrevocable and
|
||||
unconditional license to exercise Affirmer's Copyright and Related Rights in
|
||||
the Work (i) in all territories worldwide, (ii) for the maximum duration
|
||||
provided by applicable law or treaty (including future time extensions), (iii)
|
||||
in any current or future medium and for any number of copies, and (iv) for any
|
||||
purpose whatsoever, including without limitation commercial, advertising or
|
||||
promotional purposes (the "License"). The License shall be deemed effective as
|
||||
of the date CC0 was applied by Affirmer to the Work. Should any part of the
|
||||
License for any reason be judged legally invalid or ineffective under
|
||||
applicable law, such partial invalidity or ineffectiveness shall not
|
||||
invalidate the remainder of the License, and in such case Affirmer hereby
|
||||
affirms that he or she will not (i) exercise any of his or her remaining
|
||||
Copyright and Related Rights in the Work or (ii) assert any associated claims
|
||||
and causes of action with respect to the Work, in either case contrary to
|
||||
Affirmer's express Statement of Purpose.
|
||||
|
||||
4. Limitations and Disclaimers.
|
||||
|
||||
a. No trademark or patent rights held by Affirmer are waived, abandoned,
|
||||
surrendered, licensed or otherwise affected by this document.
|
||||
|
||||
b. Affirmer offers the Work as-is and makes no representations or warranties
|
||||
of any kind concerning the Work, express, implied, statutory or otherwise,
|
||||
including without limitation warranties of title, merchantability, fitness
|
||||
for a particular purpose, non infringement, or the absence of latent or
|
||||
other defects, accuracy, or the present or absence of errors, whether or not
|
||||
discoverable, all to the greatest extent permissible under applicable law.
|
||||
|
||||
c. Affirmer disclaims responsibility for clearing rights of other persons
|
||||
that may apply to the Work or any use thereof, including without limitation
|
||||
any person's Copyright and Related Rights in the Work. Further, Affirmer
|
||||
disclaims responsibility for obtaining any necessary consents, permissions
|
||||
or other rights required for any use of the Work.
|
||||
|
||||
d. Affirmer understands and acknowledges that Creative Commons is not a
|
||||
party to this document and has no duty or obligation with respect to this
|
||||
CC0 or use of the Work.
|
||||
|
||||
For more information, please see
|
||||
<http://creativecommons.org/publicdomain/zero/1.0/>
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# minimp3
|
||||
|
||||
This directory vendors `minimp3.h` from
|
||||
[lieff/minimp3](https://github.com/lieff/minimp3) at commit
|
||||
`ea99364f61c14656440e8d77e9c233ccf3124633` under CC0-1.0.
|
||||
|
||||
Pinned upstream and local file hashes:
|
||||
|
||||
- upstream `minimp3.h`: `57e437c5c1f0e8b243885d3929c8973b5e6c778451e0100ab4251d19915cb3ad`
|
||||
- local `minimp3.h` with the hook below: `3f59a7f29de636ca0db0aab8a5b86739c43ac8bf9ac902010e7220f783c0ae0a`
|
||||
- `LICENSE`: `6a1ee543e5282cd9061881edf462e6fdab181f328da71fc2c9a6950a80e94d01`
|
||||
|
||||
Wadamesh adds one opt-in `MINIMP3_SCRATCH` hook around the decoder's local
|
||||
scratch object. The Lua audio worker uses it to place the roughly 16 KB decoder
|
||||
workspace in PSRAM instead of its FreeRTOS task stack. Builds that do not define
|
||||
the hook retain upstream behavior.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
|
||||
@@ -230,6 +230,13 @@ bool DataStore::mkdirRooted(FILESYSTEM* fs, const char* dir) {
|
||||
return fs->exists(p) || fs->mkdir(p);
|
||||
}
|
||||
|
||||
File DataStore::openWriteRootedFlatSafe(FILESYSTEM* fs, const char* filename) {
|
||||
// Do not pass create=true here. Arduino's VFS implementation interprets it
|
||||
// as "mkdir every parent first", but SPIFFS is flat and returns ENOTSUP for
|
||||
// mkdir while still accepting slash-containing file keys.
|
||||
return fs->open(_rp(filename), "w");
|
||||
}
|
||||
|
||||
bool DataStore::removeRooted(FILESYSTEM* fs, const char* filename) {
|
||||
return fs->remove(_rp(filename));
|
||||
}
|
||||
|
||||
@@ -109,6 +109,7 @@ public:
|
||||
File openAppend(FILESYSTEM* fs, const char* filename); // create if missing
|
||||
bool fileExists(FILESYSTEM* fs, const char* filename);
|
||||
bool mkdirRooted(FILESYSTEM* fs, const char* dir); // true if it exists afterwards
|
||||
File openWriteRootedFlatSafe(FILESYSTEM* fs, const char* filename); // SPIFFS accepts slash keys but no mkdir
|
||||
bool removeRooted(FILESYSTEM* fs, const char* filename);
|
||||
bool renameFile(FILESYSTEM* fs, const char* from, const char* to);
|
||||
#endif
|
||||
|
||||
+46
-13
@@ -5445,7 +5445,7 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
// opens the file, "fadd <off> <len> <sum> <base64>" lines append to it
|
||||
// (self-checking, see cliPutChunk), "fend" closes it.
|
||||
// Same physical-access trust level as "rm" and "erase" above, with a
|
||||
// NARROWER scope: only the two sideload directories the Store writes.
|
||||
// NARROWER scope: Store files plus one app's private <id>.d directory.
|
||||
cliPutBegin(&cli_command[5]);
|
||||
} else if (memcmp(cli_command, "fadd ", 5) == 0) {
|
||||
cliPutChunk(&cli_command[5]);
|
||||
@@ -5470,24 +5470,51 @@ void MyMesh::checkCLIRescueCmd() {
|
||||
// "Error: ...".
|
||||
void MyMesh::cliPutBegin(const char* path) {
|
||||
if (_cli_put) { _cli_put.close(); _cli_put_len = 0; }
|
||||
_cli_put_ended = false;
|
||||
const char* dir = nullptr;
|
||||
if (memcmp(path, "/apps/", 6) == 0) dir = "/apps";
|
||||
else if (memcmp(path, "/lang/", 6) == 0) dir = "/lang";
|
||||
if (!dir) { Serial.println("Error: path must be /apps/<name> or /lang/<name>"); return; }
|
||||
const char* name = path + 6;
|
||||
const size_t nlen = strlen(name);
|
||||
if (nlen == 0 || nlen > 40) { Serial.println("Error: bad file name length"); return; }
|
||||
for (size_t i = 0; i < nlen; i++) {
|
||||
const char c = name[i];
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') ||
|
||||
c == '.' || c == '_' || c == '-';
|
||||
const char* slash = strchr(name, '/');
|
||||
const char* leaf = slash ? slash + 1 : name;
|
||||
const size_t dir_len = slash ? (size_t)(slash - name) : 0;
|
||||
const size_t leaf_len = strlen(leaf);
|
||||
if (!leaf_len || leaf_len > 40 || (slash && leaf_len > 32) ||
|
||||
leaf[0] == '.' || strchr(leaf, '/')) {
|
||||
Serial.println("Error: bad file name");
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i < leaf_len; i++) {
|
||||
const char c = leaf[i];
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
|
||||
if (!ok) { Serial.println("Error: file name may only use A-Z a-z 0-9 . _ -"); return; }
|
||||
}
|
||||
if (name[0] == '.') { Serial.println("Error: file name may not start with '.'"); return; }
|
||||
|
||||
char app_data_dir[48] = "";
|
||||
if (slash) {
|
||||
// The only nested destination is /apps/<id>.d/<file>, matching wada.fs.
|
||||
if (strcmp(dir, "/apps") != 0 || dir_len < 3 || dir_len > 25 ||
|
||||
name[dir_len - 2] != '.' || name[dir_len - 1] != 'd' || name[0] == '.') {
|
||||
Serial.println("Error: nested path must be /apps/<id>.d/<name>");
|
||||
return;
|
||||
}
|
||||
for (size_t i = 0; i + 2 < dir_len; i++) {
|
||||
const char c = name[i];
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '_' || c == '-';
|
||||
if (!ok) { Serial.println("Error: bad app id"); return; }
|
||||
}
|
||||
snprintf(app_data_dir, sizeof app_data_dir, "/apps/%.*s", (int)dir_len, name);
|
||||
}
|
||||
FILESYSTEM* fs = _store->getHotDataFS();
|
||||
if (!fs) { Serial.println("Error: storage not ready"); return; }
|
||||
if (!_store->mkdirRooted(fs, dir)) { Serial.printf("Error: cannot create %s\n", dir); return; }
|
||||
_cli_put = _store->openWrite(fs, path);
|
||||
// Hierarchical filesystems create these directories. SPIFFS returns ENOTSUP
|
||||
// but accepts the complete slash-containing path as a flat file key.
|
||||
_store->mkdirRooted(fs, dir);
|
||||
if (app_data_dir[0]) _store->mkdirRooted(fs, app_data_dir);
|
||||
_cli_put = _store->openWriteRootedFlatSafe(fs, path);
|
||||
if (!_cli_put) { Serial.printf("Error: cannot open %s for writing\n", path); return; }
|
||||
_cli_put_len = 0;
|
||||
Serial.printf("ok fput %s\n", path);
|
||||
@@ -5518,7 +5545,7 @@ void MyMesh::cliPutChunk(const char* args) {
|
||||
if ((s & 0xFFFF) != (sum & 0xFFFF)) { Serial.printf("Error: checksum at %u\n", (unsigned)_cli_put_len); return; }
|
||||
if (olen && _cli_put.write(buf, olen) != olen) {
|
||||
Serial.println("Error: write failed (card full?)");
|
||||
_cli_put.close(); _cli_put_len = 0;
|
||||
_cli_put.close(); _cli_put_len = 0; _cli_put_ended = false;
|
||||
return;
|
||||
}
|
||||
_cli_put_len += olen;
|
||||
@@ -5526,9 +5553,15 @@ void MyMesh::cliPutChunk(const char* args) {
|
||||
}
|
||||
|
||||
void MyMesh::cliPutEnd() {
|
||||
if (!_cli_put) { Serial.println("Error: no file open (fput first)"); return; }
|
||||
if (!_cli_put) {
|
||||
if (_cli_put_ended) Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_last_len);
|
||||
else Serial.println("Error: no file open (fput first)");
|
||||
return;
|
||||
}
|
||||
_cli_put.close();
|
||||
Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_len);
|
||||
_cli_put_last_len = _cli_put_len;
|
||||
_cli_put_ended = true;
|
||||
Serial.printf("ok fend %u bytes\n", (unsigned)_cli_put_last_len);
|
||||
_cli_put_len = 0;
|
||||
}
|
||||
#endif // ESP32
|
||||
|
||||
@@ -1309,6 +1309,8 @@ private:
|
||||
// Serial sideload state ("fput" / "fadd" / "fend"): the file being written.
|
||||
File _cli_put;
|
||||
uint32_t _cli_put_len = 0;
|
||||
uint32_t _cli_put_last_len = 0;
|
||||
bool _cli_put_ended = false;
|
||||
void cliPutBegin(const char* path);
|
||||
void cliPutChunk(const char* args);
|
||||
void cliPutEnd();
|
||||
|
||||
+122
-1
@@ -36,6 +36,18 @@ extern bool luaHostScreenOn(); // false = display asleep; app tick
|
||||
extern void luaHostKeepAwake(bool on); // hold the screen + ticks for a measuring app // notification chime; false = no sounder / muted
|
||||
extern fs::FS* luaHostAppFs(); // /apps storage root FS (may be null)
|
||||
extern void luaHostAppPath(char* out, size_t cap, const char* rel); // prefixes the store root
|
||||
#if CAP_LUA_AUDIO
|
||||
extern bool luaHostAudioPlay(fs::FS* fs, const char* path, const char* shown,
|
||||
const char* source, uint32_t owner,
|
||||
char* error, size_t error_cap);
|
||||
extern bool luaHostAudioPause(uint32_t owner, bool pause);
|
||||
extern bool luaHostAudioStop(uint32_t owner, bool release);
|
||||
extern 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);
|
||||
#endif
|
||||
#if CAP_LUA_SD_LIST
|
||||
extern fs::FS* luaHostSdFs(bool* busy); // mounted physical SD, or null
|
||||
extern bool luaHostSdReadFailed(); // failed open was a dead card
|
||||
@@ -185,10 +197,12 @@ struct Host {
|
||||
AppTimer timers[kMaxTimers];
|
||||
bool in_lua = false; // re-entrancy guard (dismiss from inside a callback)
|
||||
bool want_close = false;
|
||||
uint32_t generation = 0; // resource ownership across close/reopen
|
||||
char id[24] = "";
|
||||
char title[32] = "";
|
||||
};
|
||||
Host* s_h = nullptr;
|
||||
uint32_t s_host_generation = 0;
|
||||
|
||||
char s_bar_title[40]; // appPageBegin keeps the pointer — must outlive the page
|
||||
|
||||
@@ -799,12 +813,17 @@ int sysBeep(lua_State* L) { lua_pushboolean(L, luaHostBeep()); return 1; }
|
||||
// rather than assume: the extended SDK is absent on low-resource boards (see
|
||||
// CAP_LUA_SDK_EXT in device_caps.h), and a store app runs on all of them.
|
||||
int sysCaps(lua_State* L) {
|
||||
lua_createtable(L, 0, 14);
|
||||
lua_createtable(L, 0, 20);
|
||||
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "sdk_ext");
|
||||
lua_pushboolean(L, CAP_KEYBOARD); lua_setfield(L, -2, "keyboard");
|
||||
lua_pushboolean(L, CAP_TOUCH); lua_setfield(L, -2, "touch");
|
||||
lua_pushboolean(L, CAP_SD); lua_setfield(L, -2, "sd");
|
||||
lua_pushboolean(L, CAP_LUA_SD_LIST); lua_setfield(L, -2, "sd_list");
|
||||
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio");
|
||||
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_wav");
|
||||
lua_pushboolean(L, CAP_LUA_AUDIO); lua_setfield(L, -2, "audio_mp3");
|
||||
lua_pushboolean(L, CAP_LUA_AUDIO && CAP_LUA_SD_LIST);
|
||||
lua_setfield(L, -2, "audio_sd");
|
||||
// Feature flags for the calls added after the first extended SDK shipped, so
|
||||
// an app can degrade instead of erroring on firmware that predates them.
|
||||
lua_pushboolean(L, CAP_LUA_SDK_EXT); lua_setfield(L, -2, "discover"); // wada.mesh.discover
|
||||
@@ -1598,6 +1617,93 @@ int sdList(lua_State* L) {
|
||||
#endif
|
||||
#endif // CAP_LUA_SDK_EXT
|
||||
|
||||
#if CAP_LUA_AUDIO
|
||||
static bool audioSafeName(const char* name, size_t len) {
|
||||
if (!name || len == 0 || len > 32 || name[0] == '.') return false;
|
||||
for (size_t i = 0; i < len; ++i) {
|
||||
const char c = name[i];
|
||||
const bool ok = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
|
||||
(c >= '0' && c <= '9') || c == '.' || c == '_' || c == '-';
|
||||
if (!ok) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static int audioError(lua_State* L, const char* error) {
|
||||
lua_pushnil(L);
|
||||
lua_pushstring(L, error);
|
||||
return 2;
|
||||
}
|
||||
|
||||
// wada.audio.play(name) resolves inside /apps/<id>.d on the board's active
|
||||
// storage backend. An explicit sd:/... path is available only with sd_list.
|
||||
int audioPlay(lua_State* L) {
|
||||
if (!s_h) return audioError(L, "closed");
|
||||
size_t requested_len = 0;
|
||||
const char* requested = luaL_checklstring(L, 1, &requested_len);
|
||||
fs::FS* fs = nullptr;
|
||||
char path[224] = "";
|
||||
const char* source = "app";
|
||||
|
||||
if (requested_len >= 3 && !memcmp(requested, "sd:", 3)) {
|
||||
#if CAP_LUA_SD_LIST
|
||||
const char* card_path = requested + 3;
|
||||
const size_t card_len = requested_len - 3;
|
||||
if (!sdSafePath(card_path, card_len, path, sizeof path)) return audioError(L, "bad path");
|
||||
bool busy = false;
|
||||
fs = luaHostSdFs(&busy);
|
||||
if (!fs) return audioError(L, busy ? "busy" : "no sd");
|
||||
source = "sd";
|
||||
#else
|
||||
return audioError(L, "no sd");
|
||||
#endif
|
||||
} else {
|
||||
if (!audioSafeName(requested, requested_len)) return audioError(L, "bad path");
|
||||
fs = luaHostAppFs();
|
||||
if (!fs) return audioError(L, "no storage");
|
||||
char rel[96];
|
||||
snprintf(rel, sizeof rel, "/apps/%s.d/%s", s_h->id, requested);
|
||||
luaHostAppPath(path, sizeof path, rel);
|
||||
}
|
||||
|
||||
char error[40] = "";
|
||||
if (!luaHostAudioPlay(fs, path, requested, source, s_h->generation, error, sizeof error))
|
||||
return audioError(L, error[0] ? error : "playback failed");
|
||||
lua_pushboolean(L, 1);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int audioPause(lua_State* L) {
|
||||
lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, true));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int audioResume(lua_State* L) {
|
||||
lua_pushboolean(L, s_h && luaHostAudioPause(s_h->generation, false));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int audioStop(lua_State* L) {
|
||||
lua_pushboolean(L, s_h && luaHostAudioStop(s_h->generation, false));
|
||||
return 1;
|
||||
}
|
||||
|
||||
int audioStatus(lua_State* L) {
|
||||
char state[12], path[192], source[8], format[8], error[40];
|
||||
luaHostAudioStatus(s_h ? s_h->generation : 0,
|
||||
state, sizeof state, path, sizeof path,
|
||||
source, sizeof source, format, sizeof format,
|
||||
error, sizeof error);
|
||||
lua_createtable(L, 0, 5);
|
||||
lua_pushstring(L, state); lua_setfield(L, -2, "state");
|
||||
lua_pushstring(L, path); lua_setfield(L, -2, "path");
|
||||
lua_pushstring(L, source); lua_setfield(L, -2, "source");
|
||||
lua_pushstring(L, format); lua_setfield(L, -2, "format");
|
||||
if (error[0]) { lua_pushstring(L, error); lua_setfield(L, -2, "error"); }
|
||||
return 1;
|
||||
}
|
||||
#endif
|
||||
|
||||
void storePath(char* out, size_t cap) {
|
||||
char rel[40];
|
||||
snprintf(rel, sizeof rel, "/apps/%s.sav", s_h->id);
|
||||
@@ -2210,6 +2316,16 @@ void openWada(lua_State* L) {
|
||||
lua_setfield(L, -2, "sd");
|
||||
#endif
|
||||
|
||||
#if CAP_LUA_AUDIO
|
||||
lua_newtable(L); // wada.audio (app storage + optional SD)
|
||||
lua_pushcfunction(L, audioPlay); lua_setfield(L, -2, "play");
|
||||
lua_pushcfunction(L, audioPause); lua_setfield(L, -2, "pause");
|
||||
lua_pushcfunction(L, audioResume); lua_setfield(L, -2, "resume");
|
||||
lua_pushcfunction(L, audioStop); lua_setfield(L, -2, "stop");
|
||||
lua_pushcfunction(L, audioStatus); lua_setfield(L, -2, "status");
|
||||
lua_setfield(L, -2, "audio");
|
||||
#endif
|
||||
|
||||
lua_newtable(L); // wada.mesh (read-only)
|
||||
lua_pushcfunction(L, meshContacts); lua_setfield(L, -2, "contacts");
|
||||
lua_pushcfunction(L, meshRxLog); lua_setfield(L, -2, "rx_log");
|
||||
@@ -2435,6 +2551,9 @@ void hostTeardown() {
|
||||
Host* h = s_h;
|
||||
if (!h) return;
|
||||
s_h = nullptr; // bindings see "closed" from here on
|
||||
#if CAP_LUA_AUDIO
|
||||
luaHostAudioStop(h->generation, true);
|
||||
#endif
|
||||
// A wada.ui.input dialog lives on lv_layer_top, so it would outlive the app
|
||||
// that opened it. Drop it before the state goes, not after.
|
||||
luaHostTextPromptDismiss();
|
||||
@@ -2503,6 +2622,8 @@ bool luaAppLaunch(const char* id, const char* title, const char* src, size_t len
|
||||
|
||||
Host* h = new Host();
|
||||
h->heap.cap = kHeapCap;
|
||||
h->generation = ++s_host_generation;
|
||||
if (!h->generation) h->generation = ++s_host_generation;
|
||||
snprintf(h->id, sizeof h->id, "%s", id);
|
||||
snprintf(h->title, sizeof h->title, "%s", title ? title : id);
|
||||
|
||||
|
||||
+851
-34
@@ -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
|
||||
|
||||
@@ -273,6 +273,15 @@
|
||||
#define CAP_SOUND_FILES 0
|
||||
#endif
|
||||
|
||||
// Sustained PCM output for media playback. This is deliberately independent
|
||||
// of storage: Lua app files may live on internal flash, SD, or SD_MMC.
|
||||
#if defined(HAS_TDECK_GT911) || defined(TLORA_PAGER) || \
|
||||
defined(HAS_TDISPLAY_P4) || defined(HAS_TANMATSU)
|
||||
#define CAP_AUDIO_STREAM 1
|
||||
#else
|
||||
#define CAP_AUDIO_STREAM 0
|
||||
#endif
|
||||
|
||||
// ---- On-device web browser (the "Web" reader app) ---------------------------
|
||||
// The reader fetches pages over on-device HTTPS, and a TLS handshake needs ~30 KB of
|
||||
// free INTERNAL heap. Only the 8 MB-PSRAM boards (T-Deck, Tanmatsu, ThinkNode M9, RAK
|
||||
@@ -294,6 +303,14 @@
|
||||
#define CAP_LUA_APPS 1
|
||||
#endif
|
||||
|
||||
#ifndef CAP_LUA_AUDIO
|
||||
#if CAP_LUA_APPS && CAP_AUDIO_STREAM
|
||||
#define CAP_LUA_AUDIO 1
|
||||
#else
|
||||
#define CAP_LUA_AUDIO 0
|
||||
#endif
|
||||
#endif
|
||||
|
||||
// ---- Extended Lua SDK ------------------------------------------------------
|
||||
// The BASE SDK (drawing, timers, key/value store, read-only mesh + radio stats,
|
||||
// http_get) is board-agnostic and ships everywhere CAP_LUA_APPS is on.
|
||||
|
||||
@@ -340,6 +340,8 @@ static const char kLuaSrc_sdktest[] = R"WADALUA(-- SDK self-test. Exercises the
|
||||
-- windowed wada.fs.read.
|
||||
-- 1.6 adds wada.sd.list: capability reporting, traversal rejection, root
|
||||
-- metadata, and a nested directory listing when the card contains one.
|
||||
-- 1.7 adds storage-neutral wada.audio capability and API-shape checks. Playback
|
||||
-- stays manual so opening the bench test never emits sound unexpectedly.
|
||||
-- 1.5 uses wada.ui.text_lines() to measure instead of estimating, where the
|
||||
-- firmware has it. That call was added because of the 1.3 bug below: an app
|
||||
-- could ask how tall a line is but not how wide, so laying out rows meant
|
||||
@@ -396,6 +398,21 @@ function app.on_open(w, h)
|
||||
row("caps: sd_list=" .. yn(c.sd_list) .. " discover=" .. yn(c.discover) ..
|
||||
" input=" .. yn(c.input) ..
|
||||
" rx_identity=" .. yn(c.rx_identity), C.accent)
|
||||
row("caps: audio=" .. yn(c.audio) .. " wav=" .. yn(c.audio_wav) ..
|
||||
" mp3=" .. yn(c.audio_mp3) .. " audio_sd=" .. yn(c.audio_sd), C.accent)
|
||||
if c.audio then
|
||||
local api_ok = wada.audio and type(wada.audio.play) == "function" and
|
||||
type(wada.audio.pause) == "function" and
|
||||
type(wada.audio.resume) == "function" and
|
||||
type(wada.audio.stop) == "function" and
|
||||
type(wada.audio.status) == "function"
|
||||
local status = api_ok and wada.audio.status() or nil
|
||||
api_ok = api_ok and type(status) == "table" and type(status.state) == "string"
|
||||
row("wada.audio API: " .. (api_ok and ("PASS (" .. status.state .. ")") or "FAIL"),
|
||||
api_ok and C.good or C.bad)
|
||||
else
|
||||
row("wada.audio: unavailable on this board", C.sub)
|
||||
end
|
||||
row("layout: " .. (ui.text_lines and "measured (ui.text_lines)" or "estimated (older firmware)"),
|
||||
ui.text_lines and C.good or C.sub)
|
||||
-- crypto: published test vectors, so this is checkable rather than merely alive
|
||||
@@ -1803,7 +1820,7 @@ static const LuaBuiltinApp kLuaBuiltin[] = {
|
||||
{ "monitor", "RF Monitor", "1.3", kLuaSrc_monitor },
|
||||
{ "airtime", "Airtime", "1.4", kLuaSrc_airtime },
|
||||
{ "snake", "Snake", "1.0", kLuaSrc_snake },
|
||||
{ "sdktest", "SDK Test", "1.6", kLuaSrc_sdktest },
|
||||
{ "sdktest", "SDK Test", "1.7", kLuaSrc_sdktest },
|
||||
{ "2048", "2048", "1.2", kLuaSrc_2048 },
|
||||
{ "wardrive", "Wardrive", "1.1", kLuaSrc_wardrive },
|
||||
{ "nearby", "Nearby", "1.0", kLuaSrc_nearby },
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
#include <assert.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
static void* testMp3Scratch();
|
||||
#define MINIMP3_ONLY_MP3
|
||||
#define MINIMP3_NO_SIMD
|
||||
#define MINIMP3_SCRATCH ((mp3dec_scratch_t*)testMp3Scratch())
|
||||
#define MINIMP3_IMPLEMENTATION
|
||||
#include "../lib/minimp3/minimp3.h"
|
||||
|
||||
static mp3dec_scratch_t g_scratch;
|
||||
static void* testMp3Scratch() { return &g_scratch; }
|
||||
|
||||
struct DecodeBuffer {
|
||||
uint8_t input[16 * 1024];
|
||||
mp3d_sample_t pcm[MINIMP3_MAX_SAMPLES_PER_FRAME];
|
||||
};
|
||||
|
||||
static uint32_t readLe32(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 long mp3DataEnd(FILE* file) {
|
||||
assert(fseek(file, 0, SEEK_END) == 0);
|
||||
long end = ftell(file);
|
||||
assert(end >= 0);
|
||||
uint8_t footer[128];
|
||||
if (end >= 128 && fseek(file, end - 128, SEEK_SET) == 0 &&
|
||||
fread(footer, 1, 3, file) == 3 && !memcmp(footer, "TAG", 3)) {
|
||||
end -= 128;
|
||||
}
|
||||
if (end >= 32 && fseek(file, end - 32, SEEK_SET) == 0 &&
|
||||
fread(footer, 1, 32, file) == 32 && !memcmp(footer, "APETAGEX", 8)) {
|
||||
const uint32_t version = readLe32(footer + 8);
|
||||
const uint32_t tag_size = readLe32(footer + 12);
|
||||
const uint32_t flags = readLe32(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 <= (uint64_t)end)
|
||||
end -= (long)total_size;
|
||||
}
|
||||
rewind(file);
|
||||
return end;
|
||||
}
|
||||
|
||||
static bool decodeFile(const char* path) {
|
||||
FILE* file = fopen(path, "rb");
|
||||
if (!file) {
|
||||
fprintf(stderr, "%s: could not open\n", path);
|
||||
return false;
|
||||
}
|
||||
const long data_end = mp3DataEnd(file);
|
||||
if (data_end <= 0) { fclose(file); return false; }
|
||||
|
||||
DecodeBuffer buffer;
|
||||
mp3dec_t decoder;
|
||||
mp3dec_init(&decoder);
|
||||
size_t input_start = 0, input_count = 0;
|
||||
uint64_t samples_total = 0;
|
||||
int frames = 0, sample_rate = 0, channels = 0, bitrate_changes = 0;
|
||||
int last_bitrate = 0;
|
||||
bool eof = false, ok = true;
|
||||
|
||||
while (ok) {
|
||||
if (!eof && input_count < 4096) {
|
||||
if (input_start && input_start + input_count + 4096 > sizeof buffer.input) {
|
||||
memmove(buffer.input, buffer.input + input_start, input_count);
|
||||
input_start = 0;
|
||||
}
|
||||
size_t room = sizeof buffer.input - (input_start + input_count);
|
||||
const long position = ftell(file);
|
||||
const size_t remaining = position < data_end ? (size_t)(data_end - position) : 0;
|
||||
if (room > remaining) room = remaining;
|
||||
const size_t got = fread(buffer.input + input_start + input_count, 1, room, file);
|
||||
if (got > 0) {
|
||||
input_count += got;
|
||||
eof = ftell(file) >= data_end;
|
||||
}
|
||||
else if (ferror(file)) ok = false;
|
||||
else eof = true;
|
||||
}
|
||||
|
||||
if (!ok || !input_count) break;
|
||||
|
||||
mp3dec_frame_info_t info = {};
|
||||
const int samples = mp3dec_decode_frame(&decoder, buffer.input + input_start,
|
||||
(int)input_count, buffer.pcm, &info);
|
||||
if (info.frame_bytes < 0 || (size_t)info.frame_bytes > input_count) {
|
||||
ok = false;
|
||||
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) {
|
||||
break;
|
||||
} else if (input_start + input_count == sizeof buffer.input) {
|
||||
ok = false;
|
||||
break;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (samples <= 0) continue;
|
||||
if (info.layer != 3 || (info.channels != 1 && info.channels != 2) ||
|
||||
info.hz < 8000 || info.hz > 48000) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (!sample_rate) {
|
||||
sample_rate = info.hz;
|
||||
channels = info.channels;
|
||||
} else if (sample_rate != info.hz) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
if (last_bitrate && last_bitrate != info.bitrate_kbps) ++bitrate_changes;
|
||||
last_bitrate = info.bitrate_kbps;
|
||||
samples_total += (uint64_t)samples;
|
||||
++frames;
|
||||
}
|
||||
|
||||
fclose(file);
|
||||
if (!ok || frames == 0 || samples_total == 0) {
|
||||
fprintf(stderr, "%s: decode failed after %d frames\n", path, frames);
|
||||
return false;
|
||||
}
|
||||
printf("%s: %d frames, %llu samples/channel, %d Hz, %d channel(s), %d bitrate changes\n",
|
||||
path, frames, (unsigned long long)samples_total, sample_rate, channels,
|
||||
bitrate_changes);
|
||||
return true;
|
||||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 2) {
|
||||
fprintf(stderr, "usage: %s file.mp3 [file.mp3 ...]\n", argv[0]);
|
||||
return 2;
|
||||
}
|
||||
for (int i = 1; i < argc; ++i) assert(decodeFile(argv[i]));
|
||||
return 0;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ bool p4AudioReady() {
|
||||
if (s_ready) return true;
|
||||
if (s_failed) return false;
|
||||
if (!s_mtx) s_mtx = xSemaphoreCreateMutex();
|
||||
if (!s_mtx) return false;
|
||||
xSemaphoreTake(s_mtx, portMAX_DELAY);
|
||||
if (s_ready || s_failed) { xSemaphoreGive(s_mtx); return s_ready; }
|
||||
|
||||
@@ -125,6 +126,9 @@ bool p4AudioReady() {
|
||||
if (i2s_channel_init_std_mode(s_tx, &std_cfg) != ESP_OK ||
|
||||
i2s_channel_enable(s_tx) != ESP_OK) {
|
||||
printf("[P4AUDIO] I2S init failed\n");
|
||||
i2s_channel_disable(s_tx);
|
||||
i2s_del_channel(s_tx);
|
||||
s_tx = nullptr;
|
||||
s_failed = true;
|
||||
xSemaphoreGive(s_mtx);
|
||||
return false;
|
||||
@@ -139,7 +143,7 @@ void p4AudioTone(int freq_hz, int duration_ms, int amplitude) {
|
||||
if (amplitude <= 0 || freq_hz <= 0 || duration_ms <= 0) return;
|
||||
if (!p4AudioReady()) return;
|
||||
if (amplitude > 30000) amplitude = 30000;
|
||||
xSemaphoreTake(s_mtx, portMAX_DELAY);
|
||||
if (xSemaphoreTake(s_mtx, 0) != pdTRUE) return;
|
||||
const int total = P4A_RATE * duration_ms / 1000;
|
||||
const int fade = P4A_RATE * 4 / 1000; // 4 ms in/out fade kills the click
|
||||
static int16_t buf[256 * 2]; // stereo frames, chunked
|
||||
@@ -163,4 +167,36 @@ void p4AudioTone(int freq_hz, int duration_ms, int amplitude) {
|
||||
xSemaphoreGive(s_mtx);
|
||||
}
|
||||
|
||||
uint32_t p4AudioStreamRate() { return P4A_RATE; }
|
||||
|
||||
bool p4AudioStreamBegin() {
|
||||
if (!p4AudioReady()) return false;
|
||||
return xSemaphoreTake(s_mtx, portMAX_DELAY) == pdTRUE;
|
||||
}
|
||||
|
||||
bool p4AudioStreamWrite(const int16_t* samples, size_t frames) {
|
||||
if (!samples || !frames || !s_tx) return false;
|
||||
static int16_t stereo[256 * 2];
|
||||
while (frames) {
|
||||
const size_t count = frames > 256 ? 256 : frames;
|
||||
for (size_t i = 0; i < count; ++i)
|
||||
stereo[2 * i] = stereo[2 * i + 1] = samples[i];
|
||||
size_t written = 0;
|
||||
const size_t bytes = count * 2 * sizeof(int16_t);
|
||||
if (i2s_channel_write(s_tx, stereo, bytes, &written, pdMS_TO_TICKS(300)) != ESP_OK ||
|
||||
written != bytes) return false;
|
||||
samples += count;
|
||||
frames -= count;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void p4AudioStreamEnd() {
|
||||
if (!s_mtx) return;
|
||||
static const int16_t silence[256 * 2] = {};
|
||||
size_t written = 0;
|
||||
i2s_channel_write(s_tx, silence, sizeof silence, &written, pdMS_TO_TICKS(300));
|
||||
xSemaphoreGive(s_mtx);
|
||||
}
|
||||
|
||||
#endif // HAS_TDISPLAY_P4
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// DOUT=10. Fixed 16 kHz / 16-bit stereo — plenty for notification chimes, tiny buffers.
|
||||
// All calls are safe from any task; init is lazy on first use (~ms, I²C + I2S bring-up).
|
||||
#include <stdint.h>
|
||||
#include <stddef.h>
|
||||
|
||||
// Bring the codec + I2S up (idempotent). Returns false if the codec never answered.
|
||||
bool p4AudioReady();
|
||||
@@ -12,3 +13,10 @@ bool p4AudioReady();
|
||||
// Blocking sine tone: `amplitude` is the 16-bit peak (0..~30000; the UI uses pct*130).
|
||||
// No-op (fast) when amplitude <= 0 or the codec is absent.
|
||||
void p4AudioTone(int freq_hz, int duration_ms, int amplitude);
|
||||
|
||||
// Exclusive fixed-rate PCM stream used by media playback. Samples are mono;
|
||||
// the backend duplicates them to the codec's stereo I2S slots.
|
||||
uint32_t p4AudioStreamRate();
|
||||
bool p4AudioStreamBegin();
|
||||
bool p4AudioStreamWrite(const int16_t* samples, size_t frames);
|
||||
void p4AudioStreamEnd();
|
||||
|
||||
Reference in New Issue
Block a user