feat(music): add interactive event-driven player
This commit is contained in:
parent
98eff109fc
commit
4d4bdf8564
@ -43,6 +43,7 @@ shell.run("/programs/motd.lua")
|
||||
|
||||
if periphemu then
|
||||
periphemu.create("top", "modem")
|
||||
periphemu.create("left", "speaker")
|
||||
end
|
||||
|
||||
local function reportBootEventLoopError(eventName, err)
|
||||
|
||||
@ -56,6 +56,148 @@ local function createMusic(opts)
|
||||
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;
|
||||
end
|
||||
|
||||
|
||||
@ -6,7 +6,8 @@
|
||||
"files": [
|
||||
"apis/libmusic.lua",
|
||||
"programs/music.lua",
|
||||
"data/musics/GrillControl.dfpwm"
|
||||
"data/musics/GrillControl.dfpwm",
|
||||
"data/musics/trap-dirty-techno.dfpwm"
|
||||
],
|
||||
"autostart": []
|
||||
}
|
||||
|
||||
@ -50,7 +50,63 @@ local Button = ui.Button;
|
||||
local Box = ui.Box;
|
||||
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()
|
||||
return Box({
|
||||
@ -66,30 +122,113 @@ local function Header()
|
||||
color = colors.white,
|
||||
bgColor = colors.red,
|
||||
onClick = function(tui)
|
||||
tui.exitUI('cancel');
|
||||
tui.exitUI('quit');
|
||||
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 _, height = term.getSize();
|
||||
local visible = math.max(1, height - LIST_CHROME);
|
||||
local items = {};
|
||||
for _, track in ipairs(tracks) do
|
||||
items[#items + 1] = Button(track.name, {
|
||||
onClick = function(tui)
|
||||
selected = track;
|
||||
tui.exitUI('play');
|
||||
end,
|
||||
});
|
||||
for offset = 1, visible do
|
||||
local index = scrollOffset + offset;
|
||||
if index > #tracks then
|
||||
break;
|
||||
end
|
||||
items[#items + 1] = TrackRow(index);
|
||||
end
|
||||
return List({
|
||||
gap = 1,
|
||||
padding = 1,
|
||||
children = items,
|
||||
});
|
||||
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()
|
||||
return Box({
|
||||
direction = 'column',
|
||||
@ -98,19 +237,51 @@ local function App()
|
||||
Box({
|
||||
flex = 1,
|
||||
border = true,
|
||||
title = 'Choose a track',
|
||||
title = 'Tracks',
|
||||
children = {
|
||||
TrackList(),
|
||||
},
|
||||
}),
|
||||
NowPlaying(),
|
||||
Controls(),
|
||||
Footer(),
|
||||
},
|
||||
});
|
||||
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);
|
||||
|
||||
if selected then
|
||||
print('Playing ' .. selected.name .. '...');
|
||||
music.play(selected.path);
|
||||
print('Done.');
|
||||
end
|
||||
player.stop();
|
||||
|
||||
@ -34,4 +34,132 @@ testlib.test('listTracks returns empty when directory is missing', function()
|
||||
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;
|
||||
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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user