fix(libmusic): shorter speaker buffer for better reactivity

This commit is contained in:
Guillaume ARM 2026-06-20 14:32:57 +02:00
parent ff4e9e40cd
commit dd487fdadb
4 changed files with 344 additions and 304 deletions

View File

@ -6,7 +6,7 @@
"trapos-ui": "0.2.4", "trapos-ui": "0.2.4",
"trapos-ai": "0.8.0", "trapos-ai": "0.8.0",
"trapos-cloud": "0.4.5", "trapos-cloud": "0.4.5",
"trapos-music": "0.2.0", "trapos-music": "0.2.1",
"trapos-sandbox-legacy": "0.3.2", "trapos-sandbox-legacy": "0.3.2",
"trapos": "0.12.7" "trapos": "0.12.7"
} }

View File

@ -6,216 +6,220 @@
-- unit-testable with an injected `fs`. Dependencies are injectable to keep the -- unit-testable with an injected `fs`. Dependencies are injectable to keep the
-- module testable outside the CC runtime (mirrors libccpm/libversion style). -- module testable outside the CC runtime (mirrors libccpm/libversion style).
local DEFAULT_DIR = '/data/musics'; local DEFAULT_DIR = "/data/musics"
local EXTENSION = '.dfpwm'; local EXTENSION = ".dfpwm"
-- Small chunks keep little audio queued ahead of the speaker, so live effect -- Small chunks keep little audio queued ahead of the speaker, so live effect
-- and parameter changes are heard almost immediately (at the cost of more -- and parameter changes are heard almost immediately (at the cost of more
-- decode calls per second). One byte of dfpwm decodes to 8 samples, so this is -- decode calls per second). One byte of dfpwm decodes to 8 samples, so this is
-- ~0.34s of audio per chunk. -- ~0.34s of audio per chunk.
local CHUNK_SIZE = 2 * 1024; local CHUNK_SIZE = 2 * 1024
local function createMusic(opts) local function createMusic(opts)
opts = opts or {}; opts = opts or {}
local fsLib = opts.fs or fs; local fsLib = opts.fs or fs
local peripheralLib = opts.peripheral or peripheral; local peripheralLib = opts.peripheral or peripheral
local osLib = opts.os or os; local osLib = opts.os or os
local ioLib = opts.io or io; local ioLib = opts.io or io
local dir = opts.dir or DEFAULT_DIR; local dir = opts.dir or DEFAULT_DIR
local api = {}; local api = {}
-- Returns a sorted list of `{ name = <display>, path = <full path> }` for every -- Returns a sorted list of `{ name = <display>, path = <full path> }` for every
-- `.dfpwm` file in the music directory; `{}` when the directory is missing. -- `.dfpwm` file in the music directory; `{}` when the directory is missing.
function api.listTracks() function api.listTracks()
local tracks = {}; local tracks = {}
if not fsLib.isDir(dir) then return tracks; end if not fsLib.isDir(dir) then
for _, entry in ipairs(fsLib.list(dir)) do return tracks
if entry:sub(-#EXTENSION) == EXTENSION then end
tracks[#tracks + 1] = { for _, entry in ipairs(fsLib.list(dir)) do
name = entry:sub(1, #entry - #EXTENSION), if entry:sub(-#EXTENSION) == EXTENSION then
path = dir .. '/' .. entry, tracks[#tracks + 1] = {
}; name = entry:sub(1, #entry - #EXTENSION),
end path = dir .. "/" .. entry,
end }
table.sort(tracks, function(a, b) return a.name < b.name; end); end
return tracks; end
end table.sort(tracks, function(a, b)
return a.name < b.name
end)
return tracks
end
-- Plays a dfpwm file through the first speaker peripheral, blocking until the -- Plays a dfpwm file through the first speaker peripheral, blocking until the
-- whole track has been queued and drained. Errors if no speaker is attached. -- whole track has been queued and drained. Errors if no speaker is attached.
function api.play(path) function api.play(path)
local speaker = peripheralLib.find('speaker'); local speaker = peripheralLib.find("speaker")
if not speaker then if not speaker then
error('no speaker attached', 0); error("no speaker attached", 0)
end end
local dfpwm = opts.dfpwm or require('cc.audio.dfpwm'); local dfpwm = opts.dfpwm or require("cc.audio.dfpwm")
local decoder = dfpwm.make_decoder(); local decoder = dfpwm.make_decoder()
for chunk in ioLib.lines(path, CHUNK_SIZE) do for chunk in ioLib.lines(path, CHUNK_SIZE) do
local buffer = decoder(chunk); local buffer = decoder(chunk)
while not speaker.playAudio(buffer) do while not speaker.playAudio(buffer) do
osLib.pullEvent('speaker_audio_empty'); osLib.pullEvent("speaker_audio_empty")
end end
end end
end end
-- Creates a non-blocking, event-driven player bound to an `eventloop`. -- Creates a non-blocking, event-driven player bound to an `eventloop`.
-- --
-- Unlike `api.play`, this never blocks: it feeds the speaker one batch of -- Unlike `api.play`, this never blocks: it feeds the speaker one batch of
-- chunks at a time and yields back to the loop, refilling on each -- chunks at a time and yields back to the loop, refilling on each
-- `speaker_audio_empty` event. That keeps a TUI responsive and makes -- `speaker_audio_empty` event. That keeps a TUI responsive and makes
-- pause/stop/track-switching possible while a song is playing. -- pause/stop/track-switching possible while a song is playing.
function api.createPlayer(eventloop) function api.createPlayer(eventloop)
assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)'); assert(type(eventloop) == "table", "bad argument #1 (eventloop expected)")
local dfpwm = opts.dfpwm or require('cc.audio.dfpwm'); local dfpwm = opts.dfpwm or require("cc.audio.dfpwm")
local player = {}; local player = {}
local status = 'stopped'; -- 'stopped' | 'playing' | 'paused' local status = "stopped" -- 'stopped' | 'playing' | 'paused'
local handle = nil; local handle = nil
local decoder = nil; local decoder = nil
local pendingBuffer = nil; -- buffer that playAudio refused, replayed first local pendingBuffer = nil -- buffer that playAudio refused, replayed first
local currentPath = nil; local currentPath = nil
local onFinishCb = nil; local onFinishCb = nil
local effect = nil; -- optional libaudio filter applied to each decoded buffer local effect = nil -- optional libaudio filter applied to each decoded buffer
local function getSpeaker() local function getSpeaker()
local speaker = peripheralLib.find('speaker'); local speaker = peripheralLib.find("speaker")
if not speaker then if not speaker then
error('no speaker attached', 0); error("no speaker attached", 0)
end end
return speaker; return speaker
end end
-- Releases the file handle / decoder without touching the speaker queue. -- Releases the file handle / decoder without touching the speaker queue.
local function release() local function release()
if handle then if handle then
handle:close(); handle:close()
handle = nil; handle = nil
end end
decoder = nil; decoder = nil
pendingBuffer = nil; pendingBuffer = nil
end end
-- Reached the natural end of the track. Leave the player stopped and let -- Reached the natural end of the track. Leave the player stopped and let
-- the caller decide what happens next (e.g. autoplay). -- the caller decide what happens next (e.g. autoplay).
local function finish() local function finish()
release(); release()
status = 'stopped'; status = "stopped"
currentPath = nil; currentPath = nil
if onFinishCb then if onFinishCb then
onFinishCb(); onFinishCb()
end end
end end
-- Feeds the speaker until its queue is full, then returns to the loop. -- Feeds the speaker a single CHUNK_SIZE chunk per call, then returns to the
-- A no-op unless we are actively playing. -- loop; the next speaker_audio_empty event refills. Keeping only ~one chunk
local function pump() -- queued ahead is what makes pause/effect changes audible almost
if status ~= 'playing' then -- immediately. A no-op unless we are actively playing.
return; local function pump()
end if status ~= "playing" then
return
end
local speaker = getSpeaker(); local speaker = getSpeaker()
if pendingBuffer then if pendingBuffer then
if not speaker.playAudio(pendingBuffer) then if not speaker.playAudio(pendingBuffer) then
return; -- still full; wait for the next speaker_audio_empty return -- still full; wait for the next speaker_audio_empty
end end
pendingBuffer = nil; pendingBuffer = nil
end return -- one chunk per pump; wait for the next speaker_audio_empty
end
while true do local chunk = handle:read(CHUNK_SIZE)
local chunk = handle:read(CHUNK_SIZE); if not chunk then
if not chunk then finish()
finish(); return
return; end
end
local buffer = decoder(chunk); local buffer = decoder(chunk)
if effect then if effect then
buffer = effect.process(buffer); buffer = effect.process(buffer)
end end
if not speaker.playAudio(buffer) then if not speaker.playAudio(buffer) then
pendingBuffer = buffer; -- already processed; not re-filtered on retry pendingBuffer = buffer -- already processed; not re-filtered on retry
return; end
end end
end
end
-- The loop drives refills off this event for the player's whole lifetime. -- The loop drives refills off this event for the player's whole lifetime.
eventloop.register('speaker_audio_empty', pump); eventloop.register("speaker_audio_empty", pump)
-- Stops playback immediately: clears the speaker queue and resets state. -- Stops playback immediately: clears the speaker queue and resets state.
function player.stop() function player.stop()
if status ~= 'stopped' then if status ~= "stopped" then
getSpeaker().stop(); getSpeaker().stop()
end end
release(); release()
status = 'stopped'; status = "stopped"
currentPath = nil; currentPath = nil
end end
-- Switches to `path` from a clean state and primes the speaker queue. -- Switches to `path` from a clean state and primes the speaker queue.
function player.play(path) function player.play(path)
player.stop(); player.stop()
handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path)); handle = assert(ioLib.open(path, "rb"), "cannot open " .. tostring(path))
decoder = dfpwm.make_decoder(); decoder = dfpwm.make_decoder()
if effect then if effect then
effect.reset(); -- fresh filter state so no tail bleeds across tracks effect.reset() -- fresh filter state so no tail bleeds across tracks
end end
currentPath = path; currentPath = path
status = 'playing'; status = "playing"
pump(); pump()
end end
-- Sets the active audio effect (a libaudio filter instance), or nil for off. -- Sets the active audio effect (a libaudio filter instance), or nil for off.
-- Takes effect on the next decoded buffer; safe to swap mid-playback. -- Takes effect on the next decoded buffer; safe to swap mid-playback.
function player.setEffect(filter) function player.setEffect(filter)
effect = filter; effect = filter
end end
-- Gapless pause: stop feeding and let already-queued audio drain. The -- Gapless pause: stop feeding and let already-queued audio drain. The
-- decoder/handle/pendingBuffer are kept so resume loses no content. -- decoder/handle/pendingBuffer are kept so resume loses no content.
function player.pause() function player.pause()
if status == 'playing' then if status == "playing" then
status = 'paused'; status = "paused"
end end
end end
function player.resume() function player.resume()
if status == 'paused' then if status == "paused" then
status = 'playing'; status = "playing"
pump(); pump()
end end
end end
function player.togglePause() function player.togglePause()
if status == 'playing' then if status == "playing" then
player.pause(); player.pause()
elseif status == 'paused' then elseif status == "paused" then
player.resume(); player.resume()
end end
end end
function player.getStatus() function player.getStatus()
return status; return status
end end
function player.getCurrentPath() function player.getCurrentPath()
return currentPath; return currentPath
end end
function player.onFinish(cb) function player.onFinish(cb)
assert(type(cb) == 'function', 'bad argument #1 (function expected)'); assert(type(cb) == "function", "bad argument #1 (function expected)")
onFinishCb = cb; onFinishCb = cb
end end
return player; return player
end end
return api; return api
end end
return createMusic; return createMusic

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos-music", "name": "trapos-music",
"version": "0.2.0", "version": "0.2.1",
"description": "TrapOS music player: dfpwm playback through a speaker", "description": "TrapOS music player: dfpwm playback through a speaker",
"dependencies": ["trapos-core", "trapos-ui"], "dependencies": ["trapos-core", "trapos-ui"],
"files": [ "files": [

View File

@ -1,165 +1,201 @@
local createLibTest = require('/apis/libtest'); local createLibTest = require("/apis/libtest")
local createMusic = require('/apis/libmusic'); local createMusic = require("/apis/libmusic")
local testlib = createLibTest({ ... }); local testlib = createLibTest({ ... })
local function fakeFs(dir, entries) local function fakeFs(dir, entries)
return { return {
isDir = function(path) return path == dir; end, isDir = function(path)
list = function(path) return path == dir
if path ~= dir then error('unexpected list: ' .. tostring(path)); end end,
return entries; list = function(path)
end, if path ~= dir then
}; error("unexpected list: " .. tostring(path))
end
return entries
end,
}
end end
testlib.test('listTracks filters dfpwm, strips extension and sorts', function() testlib.test("listTracks filters dfpwm, strips extension and sorts", function()
local music = createMusic({ local music = createMusic({
dir = '/data/musics', dir = "/data/musics",
fs = fakeFs('/data/musics', { 'Zebra.dfpwm', 'notes.txt', 'Apple.dfpwm' }), fs = fakeFs("/data/musics", { "Zebra.dfpwm", "notes.txt", "Apple.dfpwm" }),
}); })
local tracks = music.listTracks(); local tracks = music.listTracks()
testlib.assertEquals(#tracks, 2); testlib.assertEquals(#tracks, 2)
testlib.assertEquals(tracks[1].name, 'Apple'); testlib.assertEquals(tracks[1].name, "Apple")
testlib.assertEquals(tracks[1].path, '/data/musics/Apple.dfpwm'); testlib.assertEquals(tracks[1].path, "/data/musics/Apple.dfpwm")
testlib.assertEquals(tracks[2].name, 'Zebra'); testlib.assertEquals(tracks[2].name, "Zebra")
testlib.assertEquals(tracks[2].path, '/data/musics/Zebra.dfpwm'); testlib.assertEquals(tracks[2].path, "/data/musics/Zebra.dfpwm")
end); end)
testlib.test('listTracks returns empty when directory is missing', function() testlib.test("listTracks returns empty when directory is missing", function()
local music = createMusic({ local music = createMusic({
dir = '/data/musics', dir = "/data/musics",
fs = { isDir = function() return false; end }, fs = {
}); isDir = function()
testlib.assertEquals(#music.listTracks(), 0); return false
end); end,
},
})
testlib.assertEquals(#music.listTracks(), 0)
end)
-- A speaker whose queue accepts `capacity` buffers before refusing. Tests -- A speaker whose queue accepts `capacity` buffers before refusing. Tests
-- simulate a drained queue by resetting `queued` before firing the captured -- simulate a drained queue by resetting `queued` before firing the captured
-- `speaker_audio_empty` handler. -- `speaker_audio_empty` handler.
local function fakeSpeaker(capacity) local function fakeSpeaker(capacity)
local s = { played = {}, stops = 0, queued = 0, capacity = capacity or 1 }; local s = { played = {}, stops = 0, queued = 0, capacity = capacity or 1 }
s.playAudio = function(buffer) s.playAudio = function(buffer)
if s.queued >= s.capacity then if s.queued >= s.capacity then
return false; return false
end end
s.queued = s.queued + 1; s.queued = s.queued + 1
s.played[#s.played + 1] = buffer; s.played[#s.played + 1] = buffer
return true; return true
end; end
s.stop = function() s.stop = function()
s.stops = s.stops + 1; s.stops = s.stops + 1
end; end
return s; return s
end end
local function fakeHandle(chunks) local function fakeHandle(chunks)
local index = 0; local index = 0
return { return {
closed = false, closed = false,
read = function(_, _) read = function(_, _)
index = index + 1; index = index + 1
return chunks[index]; return chunks[index]
end, end,
close = function(self) close = function(self)
self.closed = true; self.closed = true
end, end,
}; }
end end
-- identity decoder: the played buffers equal the raw chunks for easy asserts -- identity decoder: the played buffers equal the raw chunks for easy asserts
local fakeDfpwm = { make_decoder = function() return function(chunk) return chunk; end; end }; local fakeDfpwm = {
make_decoder = function()
return function(chunk)
return chunk
end
end,
}
local function makePlayer(opts) local function makePlayer(opts)
local captured = {}; local captured = {}
local eventloop = { local eventloop = {
register = function(event, handler) register = function(event, handler)
captured[event] = handler; captured[event] = handler
return function() end; return function() end
end, end,
}; }
local music = createMusic({ local music = createMusic({
peripheral = { find = function(name) return name == 'speaker' and opts.speaker or nil; end }, peripheral = {
io = { open = function(path) return opts.handles[path]; end }, find = function(name)
dfpwm = fakeDfpwm, return name == "speaker" and opts.speaker or nil
}); end,
local player = music.createPlayer(eventloop); },
return player, captured, eventloop; io = {
open = function(path)
return opts.handles[path]
end,
},
dfpwm = fakeDfpwm,
})
local player = music.createPlayer(eventloop)
return player, captured, eventloop
end end
testlib.test('play opens the file, primes the speaker and reports playing', function() testlib.test("play opens the file, primes the speaker and reports playing", function()
local speaker = fakeSpeaker(1); local speaker = fakeSpeaker(1)
local handle = fakeHandle({ 'a', 'b' }); local handle = fakeHandle({ "a", "b" })
local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); local player = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
player.play('/song.dfpwm'); player.play("/song.dfpwm")
testlib.assertEquals(player.getStatus(), 'playing'); testlib.assertEquals(player.getStatus(), "playing")
testlib.assertEquals(player.getCurrentPath(), '/song.dfpwm'); testlib.assertEquals(player.getCurrentPath(), "/song.dfpwm")
testlib.assertEquals(#speaker.played, 1); testlib.assertEquals(#speaker.played, 1)
testlib.assertEquals(speaker.played[1], 'a'); testlib.assertEquals(speaker.played[1], "a")
end); end)
testlib.test('back-pressure resumes feeding on speaker_audio_empty', function() testlib.test("one chunk fed per speaker_audio_empty until EOF", function()
local speaker = fakeSpeaker(1); local speaker = fakeSpeaker(1)
local handle = fakeHandle({ 'a', 'b' }); local handle = fakeHandle({ "a", "b" })
local player, captured = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
player.play('/song.dfpwm'); -- 'a' queued, 'b' refused (pending) player.play("/song.dfpwm") -- 'a' queued; nothing read ahead
testlib.assertEquals(#speaker.played, 1); testlib.assertEquals(#speaker.played, 1)
speaker.queued = 0; -- simulate the queue draining speaker.queued = 0 -- simulate the queue draining
captured.speaker_audio_empty(); captured.speaker_audio_empty()
testlib.assertEquals(speaker.played[2], 'b'); -- pending buffer replayed testlib.assertEquals(speaker.played[2], "b") -- next chunk fed, one per event
testlib.assertEquals(player.getStatus(), 'stopped'); -- then EOF testlib.assertEquals(player.getStatus(), "playing") -- not yet at EOF
end);
testlib.test('pause stops feeding and resume loses no buffered chunk', function() speaker.queued = 0
local speaker = fakeSpeaker(1); captured.speaker_audio_empty() -- reads nil, hits EOF
local handle = fakeHandle({ 'a', 'b', 'c' });
local player, captured = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
player.play('/song.dfpwm'); -- 'a' queued, 'b' pending testlib.assertEquals(#speaker.played, 2) -- nothing extra queued
player.pause(); testlib.assertEquals(player.getStatus(), "stopped")
testlib.assertEquals(player.getStatus(), 'paused'); end)
speaker.queued = 0; testlib.test("pause stops feeding and resume loses no buffered chunk", function()
captured.speaker_audio_empty(); -- no-op while paused local speaker = fakeSpeaker(1)
testlib.assertEquals(#speaker.played, 1); local handle = fakeHandle({ "a", "b", "c" })
local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
player.resume(); player.play("/song.dfpwm") -- 'a' queued, nothing read ahead
testlib.assertEquals(player.getStatus(), 'playing'); player.pause()
testlib.assertEquals(speaker.played[2], 'b'); -- pending 'b' fed, nothing dropped testlib.assertEquals(player.getStatus(), "paused")
end);
testlib.test('stop clears the speaker, closes the handle and resets state', function() speaker.queued = 0
local speaker = fakeSpeaker(1); captured.speaker_audio_empty() -- no-op while paused
local handle = fakeHandle({ 'a', 'b' }); testlib.assertEquals(#speaker.played, 1)
local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
player.play('/song.dfpwm'); player.resume()
player.stop(); testlib.assertEquals(player.getStatus(), "playing")
testlib.assertEquals(speaker.played[2], "b") -- resume feeds next chunk, nothing dropped
end)
testlib.assertEquals(speaker.stops, 1); testlib.test("stop clears the speaker, closes the handle and resets state", function()
testlib.assertTrue(handle.closed, 'handle should be closed'); local speaker = fakeSpeaker(1)
testlib.assertEquals(player.getStatus(), 'stopped'); local handle = fakeHandle({ "a", "b" })
testlib.assertEquals(player.getCurrentPath(), nil); local player = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
end);
testlib.test('reaching the end fires onFinish once and stays stopped', function() player.play("/song.dfpwm")
local speaker = fakeSpeaker(10); -- large queue: whole track flows in one pump player.stop()
local handle = fakeHandle({ 'a', 'b' });
local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
local finishes = 0; testlib.assertEquals(speaker.stops, 1)
player.onFinish(function() finishes = finishes + 1; end); testlib.assertTrue(handle.closed, "handle should be closed")
testlib.assertEquals(player.getStatus(), "stopped")
testlib.assertEquals(player.getCurrentPath(), nil)
end)
player.play('/song.dfpwm'); testlib.test("reaching the end fires onFinish once and stays stopped", function()
local speaker = fakeSpeaker(1)
local handle = fakeHandle({ "a", "b" })
local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
testlib.assertEquals(finishes, 1); local finishes = 0
testlib.assertEquals(player.getStatus(), 'stopped'); player.onFinish(function()
testlib.assertEquals(#speaker.played, 2); finishes = finishes + 1
end); end)
testlib.run(); player.play("/song.dfpwm") -- 'a' queued
-- One empty event per remaining chunk, then one more that reads nil (EOF).
for _ = 1, 2 do
speaker.queued = 0
captured.speaker_audio_empty()
end
testlib.assertEquals(finishes, 1)
testlib.assertEquals(player.getStatus(), "stopped")
testlib.assertEquals(#speaker.played, 2)
end)
testlib.run()