-- 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 a single CHUNK_SIZE chunk per call, then returns to the -- loop; the next speaker_audio_empty event refills. Keeping only ~one chunk -- queued ahead is what makes pause/effect changes audible almost -- immediately. 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 return -- one chunk per pump; wait for the next speaker_audio_empty end 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 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