From dd487fdadb9488f87b9c06f47a4e87d45adad307 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Sat, 20 Jun 2026 14:32:57 +0200 Subject: [PATCH] fix(libmusic): shorter speaker buffer for better reactivity --- packages/index.json | 2 +- packages/trapos-music/apis/libmusic.lua | 356 ++++++++++++----------- packages/trapos-music/ccpm.json | 2 +- packages/trapos-music/tests/libmusic.lua | 288 ++++++++++-------- 4 files changed, 344 insertions(+), 304 deletions(-) diff --git a/packages/index.json b/packages/index.json index dc42660..d8c2782 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,7 +6,7 @@ "trapos-ui": "0.2.4", "trapos-ai": "0.8.0", "trapos-cloud": "0.4.5", - "trapos-music": "0.2.0", + "trapos-music": "0.2.1", "trapos-sandbox-legacy": "0.3.2", "trapos": "0.12.7" } diff --git a/packages/trapos-music/apis/libmusic.lua b/packages/trapos-music/apis/libmusic.lua index ebbe908..ac97d5d 100644 --- a/packages/trapos-music/apis/libmusic.lua +++ b/packages/trapos-music/apis/libmusic.lua @@ -6,216 +6,220 @@ -- unit-testable with an injected `fs`. Dependencies are injectable to keep the -- module testable outside the CC runtime (mirrors libccpm/libversion style). -local DEFAULT_DIR = '/data/musics'; -local EXTENSION = '.dfpwm'; +local DEFAULT_DIR = "/data/musics" +local EXTENSION = ".dfpwm" -- 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 -- decode calls per second). One byte of dfpwm decodes to 8 samples, so this is -- ~0.34s of audio per chunk. -local CHUNK_SIZE = 2 * 1024; +local CHUNK_SIZE = 2 * 1024 local function createMusic(opts) - opts = opts or {}; - local fsLib = opts.fs or fs; - local peripheralLib = opts.peripheral or peripheral; - local osLib = opts.os or os; - local ioLib = opts.io or io; - local dir = opts.dir or DEFAULT_DIR; + opts = opts or {} + local fsLib = opts.fs or fs + local peripheralLib = opts.peripheral or peripheral + local osLib = opts.os or os + local ioLib = opts.io or io + local dir = opts.dir or DEFAULT_DIR - local api = {}; + local api = {} - -- Returns a sorted list of `{ name = , path = }` for every - -- `.dfpwm` file in the music directory; `{}` when the directory is missing. - function api.listTracks() - local tracks = {}; - if not fsLib.isDir(dir) then return tracks; end - for _, entry in ipairs(fsLib.list(dir)) do - if entry:sub(-#EXTENSION) == EXTENSION then - tracks[#tracks + 1] = { - name = entry:sub(1, #entry - #EXTENSION), - path = dir .. '/' .. entry, - }; - end - end - table.sort(tracks, function(a, b) return a.name < b.name; end); - return tracks; - end + -- Returns a sorted list of `{ name = , path = }` for every + -- `.dfpwm` file in the music directory; `{}` when the directory is missing. + function api.listTracks() + local tracks = {} + if not fsLib.isDir(dir) then + return tracks + end + for _, entry in ipairs(fsLib.list(dir)) do + if entry:sub(-#EXTENSION) == EXTENSION then + tracks[#tracks + 1] = { + name = entry:sub(1, #entry - #EXTENSION), + path = dir .. "/" .. entry, + } + 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 - -- whole track has been queued and drained. Errors if no speaker is attached. - function api.play(path) - local speaker = peripheralLib.find('speaker'); - if not speaker then - error('no speaker attached', 0); - end + -- 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. + function api.play(path) + local speaker = peripheralLib.find("speaker") + if not speaker then + error("no speaker attached", 0) + end - local dfpwm = opts.dfpwm or require('cc.audio.dfpwm'); - local decoder = dfpwm.make_decoder(); + local dfpwm = opts.dfpwm or require("cc.audio.dfpwm") + local decoder = dfpwm.make_decoder() - for chunk in ioLib.lines(path, CHUNK_SIZE) do - local buffer = decoder(chunk); - while not speaker.playAudio(buffer) do - osLib.pullEvent('speaker_audio_empty'); - end - end - end + for chunk in ioLib.lines(path, CHUNK_SIZE) do + local buffer = decoder(chunk) + while not speaker.playAudio(buffer) do + osLib.pullEvent("speaker_audio_empty") + end + end + end - -- Creates a non-blocking, event-driven player bound to an `eventloop`. - -- - -- 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 - -- `speaker_audio_empty` event. That keeps a TUI responsive and makes - -- pause/stop/track-switching possible while a song is playing. - function api.createPlayer(eventloop) - assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)'); + -- Creates a non-blocking, event-driven player bound to an `eventloop`. + -- + -- 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 + -- `speaker_audio_empty` event. That keeps a TUI responsive and makes + -- pause/stop/track-switching possible while a song is playing. + function api.createPlayer(eventloop) + 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 handle = nil; - local decoder = nil; - local pendingBuffer = nil; -- buffer that playAudio refused, replayed first - local currentPath = nil; - local onFinishCb = nil; - local effect = nil; -- optional libaudio filter applied to each decoded buffer + local status = "stopped" -- 'stopped' | 'playing' | 'paused' + local handle = nil + local decoder = nil + local pendingBuffer = nil -- buffer that playAudio refused, replayed first + local currentPath = nil + local onFinishCb = nil + local effect = nil -- optional libaudio filter applied to each decoded buffer - local function getSpeaker() - local speaker = peripheralLib.find('speaker'); - if not speaker then - error('no speaker attached', 0); - end - return speaker; - end + local function getSpeaker() + local speaker = peripheralLib.find("speaker") + if not speaker then + error("no speaker attached", 0) + end + return speaker + end - -- Releases the file handle / decoder without touching the speaker queue. - local function release() - if handle then - handle:close(); - handle = nil; - end - decoder = nil; - pendingBuffer = nil; - end + -- Releases the file handle / decoder without touching the speaker queue. + local function release() + if handle then + handle:close() + handle = nil + end + decoder = nil + pendingBuffer = nil + end - -- Reached the natural end of the track. Leave the player stopped and let - -- the caller decide what happens next (e.g. autoplay). - local function finish() - release(); - status = 'stopped'; - currentPath = nil; - if onFinishCb then - onFinishCb(); - end - end + -- Reached the natural end of the track. Leave the player stopped and let + -- the caller decide what happens next (e.g. autoplay). + local function finish() + release() + status = "stopped" + currentPath = nil + if onFinishCb then + onFinishCb() + end + end - -- Feeds the speaker until its queue is full, then returns to the loop. - -- A no-op unless we are actively playing. - local function pump() - if status ~= 'playing' then - return; - end + -- Feeds the speaker a single CHUNK_SIZE chunk per call, then returns to the + -- loop; the next speaker_audio_empty event refills. Keeping only ~one chunk + -- queued ahead is what makes pause/effect changes audible almost + -- immediately. A no-op unless we are actively playing. + local function pump() + if status ~= "playing" then + return + end - local speaker = getSpeaker(); + local speaker = getSpeaker() - if pendingBuffer then - if not speaker.playAudio(pendingBuffer) then - return; -- still full; wait for the next speaker_audio_empty - end - pendingBuffer = nil; - end + if pendingBuffer then + if not speaker.playAudio(pendingBuffer) then + return -- still full; wait for the next speaker_audio_empty + end + pendingBuffer = nil + return -- one chunk per pump; wait for the next speaker_audio_empty + end - while true do - local chunk = handle:read(CHUNK_SIZE); - if not chunk then - finish(); - return; - end + local chunk = handle:read(CHUNK_SIZE) + if not chunk then + finish() + return + end - local buffer = decoder(chunk); - if effect then - buffer = effect.process(buffer); - end - if not speaker.playAudio(buffer) then - pendingBuffer = buffer; -- already processed; not re-filtered on retry - return; - end - end - end + local buffer = decoder(chunk) + if effect then + buffer = effect.process(buffer) + end + if not speaker.playAudio(buffer) then + pendingBuffer = buffer -- already processed; not re-filtered on retry + end + end - -- The loop drives refills off this event for the player's whole lifetime. - eventloop.register('speaker_audio_empty', pump); + -- The loop drives refills off this event for the player's whole lifetime. + eventloop.register("speaker_audio_empty", pump) - -- Stops playback immediately: clears the speaker queue and resets state. - function player.stop() - if status ~= 'stopped' then - getSpeaker().stop(); - end - release(); - status = 'stopped'; - currentPath = nil; - end + -- Stops playback immediately: clears the speaker queue and resets state. + function player.stop() + if status ~= "stopped" then + getSpeaker().stop() + end + release() + status = "stopped" + currentPath = nil + end - -- Switches to `path` from a clean state and primes the speaker queue. - function player.play(path) - player.stop(); - handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path)); - decoder = dfpwm.make_decoder(); - if effect then - effect.reset(); -- fresh filter state so no tail bleeds across tracks - end - currentPath = path; - status = 'playing'; - pump(); - end + -- Switches to `path` from a clean state and primes the speaker queue. + function player.play(path) + player.stop() + handle = assert(ioLib.open(path, "rb"), "cannot open " .. tostring(path)) + decoder = dfpwm.make_decoder() + if effect then + effect.reset() -- fresh filter state so no tail bleeds across tracks + end + currentPath = path + status = "playing" + pump() + end - -- 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. - function player.setEffect(filter) - effect = filter; - end + -- 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. + function player.setEffect(filter) + effect = filter + end - -- Gapless pause: stop feeding and let already-queued audio drain. The - -- decoder/handle/pendingBuffer are kept so resume loses no content. - function player.pause() - if status == 'playing' then - status = 'paused'; - end - end + -- Gapless pause: stop feeding and let already-queued audio drain. The + -- decoder/handle/pendingBuffer are kept so resume loses no content. + function player.pause() + if status == "playing" then + status = "paused" + end + end - function player.resume() - if status == 'paused' then - status = 'playing'; - pump(); - end - end + function player.resume() + if status == "paused" then + status = "playing" + pump() + end + end - function player.togglePause() - if status == 'playing' then - player.pause(); - elseif status == 'paused' then - player.resume(); - end - end + function player.togglePause() + if status == "playing" then + player.pause() + elseif status == "paused" then + player.resume() + end + end - function player.getStatus() - return status; - end + function player.getStatus() + return status + end - function player.getCurrentPath() - return currentPath; - end + function player.getCurrentPath() + return currentPath + end - function player.onFinish(cb) - assert(type(cb) == 'function', 'bad argument #1 (function expected)'); - onFinishCb = cb; - end + function player.onFinish(cb) + assert(type(cb) == "function", "bad argument #1 (function expected)") + onFinishCb = cb + end - return player; - end + return player + end - return api; + return api end -return createMusic; +return createMusic diff --git a/packages/trapos-music/ccpm.json b/packages/trapos-music/ccpm.json index ca46fbb..ac1a8e6 100644 --- a/packages/trapos-music/ccpm.json +++ b/packages/trapos-music/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-music", - "version": "0.2.0", + "version": "0.2.1", "description": "TrapOS music player: dfpwm playback through a speaker", "dependencies": ["trapos-core", "trapos-ui"], "files": [ diff --git a/packages/trapos-music/tests/libmusic.lua b/packages/trapos-music/tests/libmusic.lua index 5047d94..c897e9c 100644 --- a/packages/trapos-music/tests/libmusic.lua +++ b/packages/trapos-music/tests/libmusic.lua @@ -1,165 +1,201 @@ -local createLibTest = require('/apis/libtest'); -local createMusic = require('/apis/libmusic'); +local createLibTest = require("/apis/libtest") +local createMusic = require("/apis/libmusic") -local testlib = createLibTest({ ... }); +local testlib = createLibTest({ ... }) local function fakeFs(dir, entries) - return { - isDir = function(path) return path == dir; end, - list = function(path) - if path ~= dir then error('unexpected list: ' .. tostring(path)); end - return entries; - end, - }; + return { + isDir = function(path) + return path == dir + end, + list = function(path) + if path ~= dir then + error("unexpected list: " .. tostring(path)) + end + return entries + end, + } end -testlib.test('listTracks filters dfpwm, strips extension and sorts', function() - local music = createMusic({ - dir = '/data/musics', - fs = fakeFs('/data/musics', { 'Zebra.dfpwm', 'notes.txt', 'Apple.dfpwm' }), - }); - local tracks = music.listTracks(); - testlib.assertEquals(#tracks, 2); - testlib.assertEquals(tracks[1].name, 'Apple'); - testlib.assertEquals(tracks[1].path, '/data/musics/Apple.dfpwm'); - testlib.assertEquals(tracks[2].name, 'Zebra'); - testlib.assertEquals(tracks[2].path, '/data/musics/Zebra.dfpwm'); -end); +testlib.test("listTracks filters dfpwm, strips extension and sorts", function() + local music = createMusic({ + dir = "/data/musics", + fs = fakeFs("/data/musics", { "Zebra.dfpwm", "notes.txt", "Apple.dfpwm" }), + }) + local tracks = music.listTracks() + testlib.assertEquals(#tracks, 2) + testlib.assertEquals(tracks[1].name, "Apple") + testlib.assertEquals(tracks[1].path, "/data/musics/Apple.dfpwm") + testlib.assertEquals(tracks[2].name, "Zebra") + testlib.assertEquals(tracks[2].path, "/data/musics/Zebra.dfpwm") +end) -testlib.test('listTracks returns empty when directory is missing', function() - local music = createMusic({ - dir = '/data/musics', - fs = { isDir = function() return false; end }, - }); - testlib.assertEquals(#music.listTracks(), 0); -end); +testlib.test("listTracks returns empty when directory is missing", function() + local music = createMusic({ + dir = "/data/musics", + fs = { + isDir = function() + return false + end, + }, + }) + testlib.assertEquals(#music.listTracks(), 0) +end) -- A speaker whose queue accepts `capacity` buffers before refusing. Tests -- simulate a drained queue by resetting `queued` before firing the captured -- `speaker_audio_empty` handler. local function fakeSpeaker(capacity) - local s = { played = {}, stops = 0, queued = 0, capacity = capacity or 1 }; - s.playAudio = function(buffer) - if s.queued >= s.capacity then - return false; - end - s.queued = s.queued + 1; - s.played[#s.played + 1] = buffer; - return true; - end; - s.stop = function() - s.stops = s.stops + 1; - end; - return s; + local s = { played = {}, stops = 0, queued = 0, capacity = capacity or 1 } + s.playAudio = function(buffer) + if s.queued >= s.capacity then + return false + end + s.queued = s.queued + 1 + s.played[#s.played + 1] = buffer + return true + end + s.stop = function() + s.stops = s.stops + 1 + end + return s end local function fakeHandle(chunks) - local index = 0; - return { - closed = false, - read = function(_, _) - index = index + 1; - return chunks[index]; - end, - close = function(self) - self.closed = true; - end, - }; + local index = 0 + return { + closed = false, + read = function(_, _) + index = index + 1 + return chunks[index] + end, + close = function(self) + self.closed = true + end, + } end -- 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 captured = {}; - local eventloop = { - register = function(event, handler) - captured[event] = handler; - return function() end; - end, - }; - local music = createMusic({ - peripheral = { find = function(name) return name == 'speaker' and opts.speaker or nil; end }, - io = { open = function(path) return opts.handles[path]; end }, - dfpwm = fakeDfpwm, - }); - local player = music.createPlayer(eventloop); - return player, captured, eventloop; + local captured = {} + local eventloop = { + register = function(event, handler) + captured[event] = handler + return function() end + end, + } + local music = createMusic({ + peripheral = { + find = function(name) + return name == "speaker" and opts.speaker or nil + end, + }, + io = { + open = function(path) + return opts.handles[path] + end, + }, + dfpwm = fakeDfpwm, + }) + local player = music.createPlayer(eventloop) + return player, captured, eventloop end -testlib.test('play opens the file, primes the speaker and reports playing', function() - local speaker = fakeSpeaker(1); - local handle = fakeHandle({ 'a', 'b' }); - local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); +testlib.test("play opens the file, primes the speaker and reports playing", function() + local speaker = fakeSpeaker(1) + local handle = fakeHandle({ "a", "b" }) + 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.getCurrentPath(), '/song.dfpwm'); - testlib.assertEquals(#speaker.played, 1); - testlib.assertEquals(speaker.played[1], 'a'); -end); + testlib.assertEquals(player.getStatus(), "playing") + testlib.assertEquals(player.getCurrentPath(), "/song.dfpwm") + testlib.assertEquals(#speaker.played, 1) + testlib.assertEquals(speaker.played[1], "a") +end) -testlib.test('back-pressure resumes feeding on speaker_audio_empty', function() - local speaker = fakeSpeaker(1); - local handle = fakeHandle({ 'a', 'b' }); - local player, captured = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); +testlib.test("one chunk fed per speaker_audio_empty until EOF", function() + local speaker = fakeSpeaker(1) + local handle = fakeHandle({ "a", "b" }) + local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } }) - player.play('/song.dfpwm'); -- 'a' queued, 'b' refused (pending) - testlib.assertEquals(#speaker.played, 1); + player.play("/song.dfpwm") -- 'a' queued; nothing read ahead + testlib.assertEquals(#speaker.played, 1) - speaker.queued = 0; -- simulate the queue draining - captured.speaker_audio_empty(); + speaker.queued = 0 -- simulate the queue draining + captured.speaker_audio_empty() - testlib.assertEquals(speaker.played[2], 'b'); -- pending buffer replayed - testlib.assertEquals(player.getStatus(), 'stopped'); -- then EOF -end); + testlib.assertEquals(speaker.played[2], "b") -- next chunk fed, one per event + testlib.assertEquals(player.getStatus(), "playing") -- not yet at EOF -testlib.test('pause stops feeding and resume loses no buffered chunk', function() - local speaker = fakeSpeaker(1); - local handle = fakeHandle({ 'a', 'b', 'c' }); - local player, captured = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); + speaker.queued = 0 + captured.speaker_audio_empty() -- reads nil, hits EOF - player.play('/song.dfpwm'); -- 'a' queued, 'b' pending - player.pause(); - testlib.assertEquals(player.getStatus(), 'paused'); + testlib.assertEquals(#speaker.played, 2) -- nothing extra queued + testlib.assertEquals(player.getStatus(), "stopped") +end) - speaker.queued = 0; - captured.speaker_audio_empty(); -- no-op while paused - testlib.assertEquals(#speaker.played, 1); +testlib.test("pause stops feeding and resume loses no buffered chunk", function() + local speaker = fakeSpeaker(1) + local handle = fakeHandle({ "a", "b", "c" }) + local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } }) - player.resume(); - testlib.assertEquals(player.getStatus(), 'playing'); - testlib.assertEquals(speaker.played[2], 'b'); -- pending 'b' fed, nothing dropped -end); + player.play("/song.dfpwm") -- 'a' queued, nothing read ahead + player.pause() + testlib.assertEquals(player.getStatus(), "paused") -testlib.test('stop clears the speaker, closes the handle and resets state', function() - local speaker = fakeSpeaker(1); - local handle = fakeHandle({ 'a', 'b' }); - local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); + speaker.queued = 0 + captured.speaker_audio_empty() -- no-op while paused + testlib.assertEquals(#speaker.played, 1) - player.play('/song.dfpwm'); - player.stop(); + player.resume() + testlib.assertEquals(player.getStatus(), "playing") + testlib.assertEquals(speaker.played[2], "b") -- resume feeds next chunk, nothing dropped +end) - testlib.assertEquals(speaker.stops, 1); - testlib.assertTrue(handle.closed, 'handle should be closed'); - testlib.assertEquals(player.getStatus(), 'stopped'); - testlib.assertEquals(player.getCurrentPath(), nil); -end); +testlib.test("stop clears the speaker, closes the handle and resets state", function() + local speaker = fakeSpeaker(1) + local handle = fakeHandle({ "a", "b" }) + local player = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } }) -testlib.test('reaching the end fires onFinish once and stays stopped', function() - local speaker = fakeSpeaker(10); -- large queue: whole track flows in one pump - local handle = fakeHandle({ 'a', 'b' }); - local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } }); + player.play("/song.dfpwm") + player.stop() - local finishes = 0; - player.onFinish(function() finishes = finishes + 1; end); + testlib.assertEquals(speaker.stops, 1) + 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); - testlib.assertEquals(player.getStatus(), 'stopped'); - testlib.assertEquals(#speaker.played, 2); -end); + local finishes = 0 + player.onFinish(function() + finishes = finishes + 1 + 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()