cc-libs/packages/trapos-music/apis/libmusic.lua
2026-06-20 14:15:54 +02:00

222 lines
6.6 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 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;