226 lines
6.2 KiB
Lua
226 lines
6.2 KiB
Lua
-- 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 = <display>, path = <full 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
|