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
|
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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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": []
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 TrackList()
|
local function TrackRow(index)
|
||||||
local items = {};
|
local track = tracks[index];
|
||||||
for _, track in ipairs(tracks) do
|
local isCursor = index == selectedIndex;
|
||||||
items[#items + 1] = Button(track.name, {
|
local isPlaying = index == playingIndex;
|
||||||
onClick = function(tui)
|
local label = (isPlaying and '> ' or ' ') .. track.name;
|
||||||
selected = track;
|
return Button(label, {
|
||||||
tui.exitUI('play');
|
color = isCursor and colors.black or colors.white,
|
||||||
|
bgColor = isCursor and colors.white or colors.black,
|
||||||
|
onClick = function()
|
||||||
|
playIndex(index);
|
||||||
end,
|
end,
|
||||||
});
|
});
|
||||||
|
end
|
||||||
|
|
||||||
|
local function TrackList()
|
||||||
|
local _, height = term.getSize();
|
||||||
|
local visible = math.max(1, height - LIST_CHROME);
|
||||||
|
local items = {};
|
||||||
|
for offset = 1, visible do
|
||||||
|
local index = scrollOffset + offset;
|
||||||
|
if index > #tracks then
|
||||||
|
break;
|
||||||
|
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
|
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user