-- libmusic: list and play dfpwm music tracks from a directory. -- -- A factory: `local createMusic = require('/apis/libmusic'); local music = createMusic();` -- -- Non-UI logic lives here so the `music` program stays thin and the listing is -- 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'; -- 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 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; local api = {}; -- Returns a sorted list of `{ name = , 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 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 -- 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 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 -- 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 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 -- 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(); 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 -- 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 return createMusic;