feat(music): add interactive event-driven player

This commit is contained in:
Guillaume ARM 2026-06-20 13:45:28 +02:00
parent 98eff109fc
commit 4d4bdf8564
5 changed files with 460 additions and 17 deletions

View File

@ -43,6 +43,7 @@ shell.run("/programs/motd.lua")
if periphemu then if periphemu then
periphemu.create("top", "modem") periphemu.create("top", "modem")
periphemu.create("left", "speaker")
end end
local function reportBootEventLoopError(eventName, err) local function reportBootEventLoopError(eventName, err)

View File

@ -56,6 +56,148 @@ local function createMusic(opts)
end 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)');
local dfpwm = opts.dfpwm or require('cc.audio.dfpwm');
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 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
-- 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
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
while true do
local chunk = handle:read(CHUNK_SIZE);
if not chunk then
finish();
return;
end
local buffer = decoder(chunk);
if not speaker.playAudio(buffer) then
pendingBuffer = buffer;
return;
end
end
end
-- 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
-- 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();
currentPath = path;
status = 'playing';
pump();
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.togglePause()
if status == 'playing' then
player.pause();
elseif status == 'paused' then
player.resume();
end
end
function player.getStatus()
return status;
end
function player.getCurrentPath()
return currentPath;
end
function player.onFinish(cb)
assert(type(cb) == 'function', 'bad argument #1 (function expected)');
onFinishCb = cb;
end
return player;
end
return api; return api;
end end

View File

@ -6,7 +6,8 @@
"files": [ "files": [
"apis/libmusic.lua", "apis/libmusic.lua",
"programs/music.lua", "programs/music.lua",
"data/musics/GrillControl.dfpwm" "data/musics/GrillControl.dfpwm",
"data/musics/trap-dirty-techno.dfpwm"
], ],
"autostart": [] "autostart": []
} }

View File

@ -50,7 +50,63 @@ local Button = ui.Button;
local Box = ui.Box; local Box = ui.Box;
local List = ui.List; local List = ui.List;
local selected = nil; local player = music.createPlayer(eventloop);
-- Rows of UI chrome around the track list (header, now-playing, controls,
-- footer, plus the track box border). Used to size the scroll window.
local LIST_CHROME = 6;
local selectedIndex = 1; -- list cursor
local scrollOffset = 0; -- index of the first visible row - 1
local playingIndex = nil; -- track currently loaded in the player
local autoplay = false;
local function clampSelection()
if selectedIndex < 1 then
selectedIndex = 1;
elseif selectedIndex > #tracks then
selectedIndex = #tracks;
end
end
-- Keeps the selected row inside the visible window.
local function ensureVisible()
local _, height = term.getSize();
local visible = math.max(1, height - LIST_CHROME);
if selectedIndex <= scrollOffset then
scrollOffset = selectedIndex - 1;
elseif selectedIndex > scrollOffset + visible then
scrollOffset = selectedIndex - visible;
end
end
local function playIndex(index)
if index < 1 or index > #tracks then
return;
end
playingIndex = index;
selectedIndex = index;
player.play(tracks[index].path);
ensureVisible();
ui.rerender();
end
local function nextIndex()
return (playingIndex or selectedIndex) % #tracks + 1;
end
local function previousIndex()
return ((playingIndex or selectedIndex) - 2) % #tracks + 1;
end
player.onFinish(function()
if autoplay then
playIndex(nextIndex());
else
playingIndex = nil;
end
ui.rerender();
end);
local function Header() local function Header()
return Box({ return Box({
@ -66,30 +122,113 @@ local function Header()
color = colors.white, color = colors.white,
bgColor = colors.red, bgColor = colors.red,
onClick = function(tui) onClick = function(tui)
tui.exitUI('cancel'); tui.exitUI('quit');
end, end,
}), }),
}, },
}); });
end end
local function TrackRow(index)
local track = tracks[index];
local isCursor = index == selectedIndex;
local isPlaying = index == playingIndex;
local label = (isPlaying and '> ' or ' ') .. track.name;
return Button(label, {
color = isCursor and colors.black or colors.white,
bgColor = isCursor and colors.white or colors.black,
onClick = function()
playIndex(index);
end,
});
end
local function TrackList() local function TrackList()
local _, height = term.getSize();
local visible = math.max(1, height - LIST_CHROME);
local items = {}; local items = {};
for _, track in ipairs(tracks) do for offset = 1, visible do
items[#items + 1] = Button(track.name, { local index = scrollOffset + offset;
onClick = function(tui) if index > #tracks then
selected = track; break;
tui.exitUI('play'); end
end, items[#items + 1] = TrackRow(index);
});
end end
return List({ return List({
gap = 1,
padding = 1, padding = 1,
children = items, children = items,
}); });
end end
local function statusLabel()
local s = player.getStatus();
if s == 'playing' then return 'Playing'; end
if s == 'paused' then return 'Paused'; end
return 'Stopped';
end
local function NowPlaying()
local name = playingIndex and tracks[playingIndex].name or '(none)';
return Box({
direction = 'row',
children = {
Text(statusLabel() .. ': ' .. name, { flex = 1 }),
},
});
end
local function Controls()
return Box({
direction = 'row',
gap = 1,
children = {
Button('|<', {
onClick = function()
playIndex(previousIndex());
end,
}),
Button(player.getStatus() == 'playing' and 'Pause' or 'Play', {
onClick = function()
if player.getStatus() == 'stopped' then
playIndex(selectedIndex);
else
player.togglePause();
ui.rerender();
end
end,
}),
Button('Stop', {
onClick = function()
player.stop();
playingIndex = nil;
ui.rerender();
end,
}),
Button('>|', {
onClick = function()
playIndex(nextIndex());
end,
}),
Button('Autoplay: ' .. (autoplay and 'on' or 'off'), {
flex = 1,
color = autoplay and colors.black or colors.white,
bgColor = autoplay and colors.lime or colors.black,
onClick = function()
autoplay = not autoplay;
ui.rerender();
end,
}),
},
});
end
local function Footer()
return Text(
'up/down select enter play space pause s stop n/p next/prev q quit',
{ color = colors.lightGray }
);
end
local function App() local function App()
return Box({ return Box({
direction = 'column', direction = 'column',
@ -98,19 +237,51 @@ local function App()
Box({ Box({
flex = 1, flex = 1,
border = true, border = true,
title = 'Choose a track', title = 'Tracks',
children = { children = {
TrackList(), TrackList(),
}, },
}), }),
NowPlaying(),
Controls(),
Footer(),
}, },
}); });
end end
eventloop.register('key', function(key)
if key == keys.up then
selectedIndex = selectedIndex - 1;
clampSelection();
ensureVisible();
ui.rerender();
elseif key == keys.down then
selectedIndex = selectedIndex + 1;
clampSelection();
ensureVisible();
ui.rerender();
elseif key == keys.enter then
playIndex(selectedIndex);
elseif key == keys.space then
if player.getStatus() == 'stopped' then
playIndex(selectedIndex);
else
player.togglePause();
ui.rerender();
end
elseif key == keys.s then
player.stop();
playingIndex = nil;
ui.rerender();
elseif key == keys.n then
playIndex(nextIndex());
elseif key == keys.p then
playIndex(previousIndex());
elseif key == keys.q then
ui.exitUI('quit');
end
end);
ui.render(App); ui.render(App);
if selected then player.stop();
print('Playing ' .. selected.name .. '...');
music.play(selected.path);
print('Done.');
end

View File

@ -34,4 +34,132 @@ testlib.test('listTracks returns empty when directory is missing', function()
testlib.assertEquals(#music.listTracks(), 0); testlib.assertEquals(#music.listTracks(), 0);
end); 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;
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,
};
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 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;
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 } });
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.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 } });
player.play('/song.dfpwm'); -- 'a' queued, 'b' refused (pending)
testlib.assertEquals(#speaker.played, 1);
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.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.play('/song.dfpwm'); -- 'a' queued, 'b' pending
player.pause();
testlib.assertEquals(player.getStatus(), 'paused');
speaker.queued = 0;
captured.speaker_audio_empty(); -- no-op while paused
testlib.assertEquals(#speaker.played, 1);
player.resume();
testlib.assertEquals(player.getStatus(), 'playing');
testlib.assertEquals(speaker.played[2], 'b'); -- pending 'b' fed, nothing dropped
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 } });
player.play('/song.dfpwm');
player.stop();
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('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 } });
local finishes = 0;
player.onFinish(function() finishes = finishes + 1; end);
player.play('/song.dfpwm');
testlib.assertEquals(finishes, 1);
testlib.assertEquals(player.getStatus(), 'stopped');
testlib.assertEquals(#speaker.played, 2);
end);
testlib.run(); testlib.run();