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-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"
}

View File

@ -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 = <display>, path = <full 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 = <display>, path = <full 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

View File

@ -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": [

View File

@ -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()