From 4d4bdf8564bef24a81c1df32e69805fd07c710b2 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Sat, 20 Jun 2026 13:45:28 +0200 Subject: [PATCH] feat(music): add interactive event-driven player --- packages/trapos-boot/startup/boot.lua | 1 + packages/trapos-music/apis/libmusic.lua | 142 ++++++++++++++++ packages/trapos-music/ccpm.json | 3 +- packages/trapos-music/programs/music.lua | 203 +++++++++++++++++++++-- packages/trapos-music/tests/libmusic.lua | 128 ++++++++++++++ 5 files changed, 460 insertions(+), 17 deletions(-) diff --git a/packages/trapos-boot/startup/boot.lua b/packages/trapos-boot/startup/boot.lua index d267265..05de728 100644 --- a/packages/trapos-boot/startup/boot.lua +++ b/packages/trapos-boot/startup/boot.lua @@ -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) diff --git a/packages/trapos-music/apis/libmusic.lua b/packages/trapos-music/apis/libmusic.lua index 6121d8d..6729dd6 100644 --- a/packages/trapos-music/apis/libmusic.lua +++ b/packages/trapos-music/apis/libmusic.lua @@ -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 diff --git a/packages/trapos-music/ccpm.json b/packages/trapos-music/ccpm.json index 2efe60e..4c80260 100644 --- a/packages/trapos-music/ccpm.json +++ b/packages/trapos-music/ccpm.json @@ -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": [] } diff --git a/packages/trapos-music/programs/music.lua b/packages/trapos-music/programs/music.lua index 311ad9b..0810524 100644 --- a/packages/trapos-music/programs/music.lua +++ b/packages/trapos-music/programs/music.lua @@ -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(); diff --git a/packages/trapos-music/tests/libmusic.lua b/packages/trapos-music/tests/libmusic.lua index 2640d55..5047d94 100644 --- a/packages/trapos-music/tests/libmusic.lua +++ b/packages/trapos-music/tests/libmusic.lua @@ -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();