fix(libmusic): shorter speaker buffer for better reactivity
This commit is contained in:
parent
ff4e9e40cd
commit
dd487fdadb
@ -6,7 +6,7 @@
|
|||||||
"trapos-ui": "0.2.4",
|
"trapos-ui": "0.2.4",
|
||||||
"trapos-ai": "0.8.0",
|
"trapos-ai": "0.8.0",
|
||||||
"trapos-cloud": "0.4.5",
|
"trapos-cloud": "0.4.5",
|
||||||
"trapos-music": "0.2.0",
|
"trapos-music": "0.2.1",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"trapos-sandbox-legacy": "0.3.2",
|
||||||
"trapos": "0.12.7"
|
"trapos": "0.12.7"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,216 +6,220 @@
|
|||||||
-- unit-testable with an injected `fs`. Dependencies are injectable to keep the
|
-- unit-testable with an injected `fs`. Dependencies are injectable to keep the
|
||||||
-- module testable outside the CC runtime (mirrors libccpm/libversion style).
|
-- module testable outside the CC runtime (mirrors libccpm/libversion style).
|
||||||
|
|
||||||
local DEFAULT_DIR = '/data/musics';
|
local DEFAULT_DIR = "/data/musics"
|
||||||
local EXTENSION = '.dfpwm';
|
local EXTENSION = ".dfpwm"
|
||||||
-- Small chunks keep little audio queued ahead of the speaker, so live effect
|
-- 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
|
-- 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
|
-- decode calls per second). One byte of dfpwm decodes to 8 samples, so this is
|
||||||
-- ~0.34s of audio per chunk.
|
-- ~0.34s of audio per chunk.
|
||||||
local CHUNK_SIZE = 2 * 1024;
|
local CHUNK_SIZE = 2 * 1024
|
||||||
|
|
||||||
local function createMusic(opts)
|
local function createMusic(opts)
|
||||||
opts = opts or {};
|
opts = opts or {}
|
||||||
local fsLib = opts.fs or fs;
|
local fsLib = opts.fs or fs
|
||||||
local peripheralLib = opts.peripheral or peripheral;
|
local peripheralLib = opts.peripheral or peripheral
|
||||||
local osLib = opts.os or os;
|
local osLib = opts.os or os
|
||||||
local ioLib = opts.io or io;
|
local ioLib = opts.io or io
|
||||||
local dir = opts.dir or DEFAULT_DIR;
|
local dir = opts.dir or DEFAULT_DIR
|
||||||
|
|
||||||
local api = {};
|
local api = {}
|
||||||
|
|
||||||
-- Returns a sorted list of `{ name = <display>, path = <full path> }` for every
|
-- Returns a sorted list of `{ name = <display>, path = <full path> }` for every
|
||||||
-- `.dfpwm` file in the music directory; `{}` when the directory is missing.
|
-- `.dfpwm` file in the music directory; `{}` when the directory is missing.
|
||||||
function api.listTracks()
|
function api.listTracks()
|
||||||
local tracks = {};
|
local tracks = {}
|
||||||
if not fsLib.isDir(dir) then return tracks; end
|
if not fsLib.isDir(dir) then
|
||||||
for _, entry in ipairs(fsLib.list(dir)) do
|
return tracks
|
||||||
if entry:sub(-#EXTENSION) == EXTENSION then
|
end
|
||||||
tracks[#tracks + 1] = {
|
for _, entry in ipairs(fsLib.list(dir)) do
|
||||||
name = entry:sub(1, #entry - #EXTENSION),
|
if entry:sub(-#EXTENSION) == EXTENSION then
|
||||||
path = dir .. '/' .. entry,
|
tracks[#tracks + 1] = {
|
||||||
};
|
name = entry:sub(1, #entry - #EXTENSION),
|
||||||
end
|
path = dir .. "/" .. entry,
|
||||||
end
|
}
|
||||||
table.sort(tracks, function(a, b) return a.name < b.name; end);
|
end
|
||||||
return tracks;
|
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
|
-- 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.
|
-- whole track has been queued and drained. Errors if no speaker is attached.
|
||||||
function api.play(path)
|
function api.play(path)
|
||||||
local speaker = peripheralLib.find('speaker');
|
local speaker = peripheralLib.find("speaker")
|
||||||
if not speaker then
|
if not speaker then
|
||||||
error('no speaker attached', 0);
|
error("no speaker attached", 0)
|
||||||
end
|
end
|
||||||
|
|
||||||
local dfpwm = opts.dfpwm or require('cc.audio.dfpwm');
|
local dfpwm = opts.dfpwm or require("cc.audio.dfpwm")
|
||||||
local decoder = dfpwm.make_decoder();
|
local decoder = dfpwm.make_decoder()
|
||||||
|
|
||||||
for chunk in ioLib.lines(path, CHUNK_SIZE) do
|
for chunk in ioLib.lines(path, CHUNK_SIZE) do
|
||||||
local buffer = decoder(chunk);
|
local buffer = decoder(chunk)
|
||||||
while not speaker.playAudio(buffer) do
|
while not speaker.playAudio(buffer) do
|
||||||
osLib.pullEvent('speaker_audio_empty');
|
osLib.pullEvent("speaker_audio_empty")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Creates a non-blocking, event-driven player bound to an `eventloop`.
|
-- Creates a non-blocking, event-driven player bound to an `eventloop`.
|
||||||
--
|
--
|
||||||
-- Unlike `api.play`, this never blocks: it feeds the speaker one batch of
|
-- 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
|
-- chunks at a time and yields back to the loop, refilling on each
|
||||||
-- `speaker_audio_empty` event. That keeps a TUI responsive and makes
|
-- `speaker_audio_empty` event. That keeps a TUI responsive and makes
|
||||||
-- pause/stop/track-switching possible while a song is playing.
|
-- pause/stop/track-switching possible while a song is playing.
|
||||||
function api.createPlayer(eventloop)
|
function api.createPlayer(eventloop)
|
||||||
assert(type(eventloop) == 'table', 'bad argument #1 (eventloop expected)');
|
assert(type(eventloop) == "table", "bad argument #1 (eventloop expected)")
|
||||||
|
|
||||||
local dfpwm = opts.dfpwm or require('cc.audio.dfpwm');
|
local dfpwm = opts.dfpwm or require("cc.audio.dfpwm")
|
||||||
|
|
||||||
local player = {};
|
local player = {}
|
||||||
|
|
||||||
local status = 'stopped'; -- 'stopped' | 'playing' | 'paused'
|
local status = "stopped" -- 'stopped' | 'playing' | 'paused'
|
||||||
local handle = nil;
|
local handle = nil
|
||||||
local decoder = nil;
|
local decoder = nil
|
||||||
local pendingBuffer = nil; -- buffer that playAudio refused, replayed first
|
local pendingBuffer = nil -- buffer that playAudio refused, replayed first
|
||||||
local currentPath = nil;
|
local currentPath = nil
|
||||||
local onFinishCb = nil;
|
local onFinishCb = nil
|
||||||
local effect = nil; -- optional libaudio filter applied to each decoded buffer
|
local effect = nil -- optional libaudio filter applied to each decoded buffer
|
||||||
|
|
||||||
local function getSpeaker()
|
local function getSpeaker()
|
||||||
local speaker = peripheralLib.find('speaker');
|
local speaker = peripheralLib.find("speaker")
|
||||||
if not speaker then
|
if not speaker then
|
||||||
error('no speaker attached', 0);
|
error("no speaker attached", 0)
|
||||||
end
|
end
|
||||||
return speaker;
|
return speaker
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Releases the file handle / decoder without touching the speaker queue.
|
-- Releases the file handle / decoder without touching the speaker queue.
|
||||||
local function release()
|
local function release()
|
||||||
if handle then
|
if handle then
|
||||||
handle:close();
|
handle:close()
|
||||||
handle = nil;
|
handle = nil
|
||||||
end
|
end
|
||||||
decoder = nil;
|
decoder = nil
|
||||||
pendingBuffer = nil;
|
pendingBuffer = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Reached the natural end of the track. Leave the player stopped and let
|
-- Reached the natural end of the track. Leave the player stopped and let
|
||||||
-- the caller decide what happens next (e.g. autoplay).
|
-- the caller decide what happens next (e.g. autoplay).
|
||||||
local function finish()
|
local function finish()
|
||||||
release();
|
release()
|
||||||
status = 'stopped';
|
status = "stopped"
|
||||||
currentPath = nil;
|
currentPath = nil
|
||||||
if onFinishCb then
|
if onFinishCb then
|
||||||
onFinishCb();
|
onFinishCb()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Feeds the speaker until its queue is full, then returns to the loop.
|
-- Feeds the speaker a single CHUNK_SIZE chunk per call, then returns to the
|
||||||
-- A no-op unless we are actively playing.
|
-- loop; the next speaker_audio_empty event refills. Keeping only ~one chunk
|
||||||
local function pump()
|
-- queued ahead is what makes pause/effect changes audible almost
|
||||||
if status ~= 'playing' then
|
-- immediately. A no-op unless we are actively playing.
|
||||||
return;
|
local function pump()
|
||||||
end
|
if status ~= "playing" then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
local speaker = getSpeaker();
|
local speaker = getSpeaker()
|
||||||
|
|
||||||
if pendingBuffer then
|
if pendingBuffer then
|
||||||
if not speaker.playAudio(pendingBuffer) then
|
if not speaker.playAudio(pendingBuffer) then
|
||||||
return; -- still full; wait for the next speaker_audio_empty
|
return -- still full; wait for the next speaker_audio_empty
|
||||||
end
|
end
|
||||||
pendingBuffer = nil;
|
pendingBuffer = nil
|
||||||
end
|
return -- one chunk per pump; wait for the next speaker_audio_empty
|
||||||
|
end
|
||||||
|
|
||||||
while true do
|
local chunk = handle:read(CHUNK_SIZE)
|
||||||
local chunk = handle:read(CHUNK_SIZE);
|
if not chunk then
|
||||||
if not chunk then
|
finish()
|
||||||
finish();
|
return
|
||||||
return;
|
end
|
||||||
end
|
|
||||||
|
|
||||||
local buffer = decoder(chunk);
|
local buffer = decoder(chunk)
|
||||||
if effect then
|
if effect then
|
||||||
buffer = effect.process(buffer);
|
buffer = effect.process(buffer)
|
||||||
end
|
end
|
||||||
if not speaker.playAudio(buffer) then
|
if not speaker.playAudio(buffer) then
|
||||||
pendingBuffer = buffer; -- already processed; not re-filtered on retry
|
pendingBuffer = buffer -- already processed; not re-filtered on retry
|
||||||
return;
|
end
|
||||||
end
|
end
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
-- The loop drives refills off this event for the player's whole lifetime.
|
-- The loop drives refills off this event for the player's whole lifetime.
|
||||||
eventloop.register('speaker_audio_empty', pump);
|
eventloop.register("speaker_audio_empty", pump)
|
||||||
|
|
||||||
-- Stops playback immediately: clears the speaker queue and resets state.
|
-- Stops playback immediately: clears the speaker queue and resets state.
|
||||||
function player.stop()
|
function player.stop()
|
||||||
if status ~= 'stopped' then
|
if status ~= "stopped" then
|
||||||
getSpeaker().stop();
|
getSpeaker().stop()
|
||||||
end
|
end
|
||||||
release();
|
release()
|
||||||
status = 'stopped';
|
status = "stopped"
|
||||||
currentPath = nil;
|
currentPath = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Switches to `path` from a clean state and primes the speaker queue.
|
-- Switches to `path` from a clean state and primes the speaker queue.
|
||||||
function player.play(path)
|
function player.play(path)
|
||||||
player.stop();
|
player.stop()
|
||||||
handle = assert(ioLib.open(path, 'rb'), 'cannot open ' .. tostring(path));
|
handle = assert(ioLib.open(path, "rb"), "cannot open " .. tostring(path))
|
||||||
decoder = dfpwm.make_decoder();
|
decoder = dfpwm.make_decoder()
|
||||||
if effect then
|
if effect then
|
||||||
effect.reset(); -- fresh filter state so no tail bleeds across tracks
|
effect.reset() -- fresh filter state so no tail bleeds across tracks
|
||||||
end
|
end
|
||||||
currentPath = path;
|
currentPath = path
|
||||||
status = 'playing';
|
status = "playing"
|
||||||
pump();
|
pump()
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Sets the active audio effect (a libaudio filter instance), or nil for off.
|
-- 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.
|
-- Takes effect on the next decoded buffer; safe to swap mid-playback.
|
||||||
function player.setEffect(filter)
|
function player.setEffect(filter)
|
||||||
effect = filter;
|
effect = filter
|
||||||
end
|
end
|
||||||
|
|
||||||
-- Gapless pause: stop feeding and let already-queued audio drain. The
|
-- Gapless pause: stop feeding and let already-queued audio drain. The
|
||||||
-- decoder/handle/pendingBuffer are kept so resume loses no content.
|
-- decoder/handle/pendingBuffer are kept so resume loses no content.
|
||||||
function player.pause()
|
function player.pause()
|
||||||
if status == 'playing' then
|
if status == "playing" then
|
||||||
status = 'paused';
|
status = "paused"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function player.resume()
|
function player.resume()
|
||||||
if status == 'paused' then
|
if status == "paused" then
|
||||||
status = 'playing';
|
status = "playing"
|
||||||
pump();
|
pump()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function player.togglePause()
|
function player.togglePause()
|
||||||
if status == 'playing' then
|
if status == "playing" then
|
||||||
player.pause();
|
player.pause()
|
||||||
elseif status == 'paused' then
|
elseif status == "paused" then
|
||||||
player.resume();
|
player.resume()
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function player.getStatus()
|
function player.getStatus()
|
||||||
return status;
|
return status
|
||||||
end
|
end
|
||||||
|
|
||||||
function player.getCurrentPath()
|
function player.getCurrentPath()
|
||||||
return currentPath;
|
return currentPath
|
||||||
end
|
end
|
||||||
|
|
||||||
function player.onFinish(cb)
|
function player.onFinish(cb)
|
||||||
assert(type(cb) == 'function', 'bad argument #1 (function expected)');
|
assert(type(cb) == "function", "bad argument #1 (function expected)")
|
||||||
onFinishCb = cb;
|
onFinishCb = cb
|
||||||
end
|
end
|
||||||
|
|
||||||
return player;
|
return player
|
||||||
end
|
end
|
||||||
|
|
||||||
return api;
|
return api
|
||||||
end
|
end
|
||||||
|
|
||||||
return createMusic;
|
return createMusic
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-music",
|
"name": "trapos-music",
|
||||||
"version": "0.2.0",
|
"version": "0.2.1",
|
||||||
"description": "TrapOS music player: dfpwm playback through a speaker",
|
"description": "TrapOS music player: dfpwm playback through a speaker",
|
||||||
"dependencies": ["trapos-core", "trapos-ui"],
|
"dependencies": ["trapos-core", "trapos-ui"],
|
||||||
"files": [
|
"files": [
|
||||||
|
|||||||
@ -1,165 +1,201 @@
|
|||||||
local createLibTest = require('/apis/libtest');
|
local createLibTest = require("/apis/libtest")
|
||||||
local createMusic = require('/apis/libmusic');
|
local createMusic = require("/apis/libmusic")
|
||||||
|
|
||||||
local testlib = createLibTest({ ... });
|
local testlib = createLibTest({ ... })
|
||||||
|
|
||||||
local function fakeFs(dir, entries)
|
local function fakeFs(dir, entries)
|
||||||
return {
|
return {
|
||||||
isDir = function(path) return path == dir; end,
|
isDir = function(path)
|
||||||
list = function(path)
|
return path == dir
|
||||||
if path ~= dir then error('unexpected list: ' .. tostring(path)); end
|
end,
|
||||||
return entries;
|
list = function(path)
|
||||||
end,
|
if path ~= dir then
|
||||||
};
|
error("unexpected list: " .. tostring(path))
|
||||||
|
end
|
||||||
|
return entries
|
||||||
|
end,
|
||||||
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
testlib.test('listTracks filters dfpwm, strips extension and sorts', function()
|
testlib.test("listTracks filters dfpwm, strips extension and sorts", function()
|
||||||
local music = createMusic({
|
local music = createMusic({
|
||||||
dir = '/data/musics',
|
dir = "/data/musics",
|
||||||
fs = fakeFs('/data/musics', { 'Zebra.dfpwm', 'notes.txt', 'Apple.dfpwm' }),
|
fs = fakeFs("/data/musics", { "Zebra.dfpwm", "notes.txt", "Apple.dfpwm" }),
|
||||||
});
|
})
|
||||||
local tracks = music.listTracks();
|
local tracks = music.listTracks()
|
||||||
testlib.assertEquals(#tracks, 2);
|
testlib.assertEquals(#tracks, 2)
|
||||||
testlib.assertEquals(tracks[1].name, 'Apple');
|
testlib.assertEquals(tracks[1].name, "Apple")
|
||||||
testlib.assertEquals(tracks[1].path, '/data/musics/Apple.dfpwm');
|
testlib.assertEquals(tracks[1].path, "/data/musics/Apple.dfpwm")
|
||||||
testlib.assertEquals(tracks[2].name, 'Zebra');
|
testlib.assertEquals(tracks[2].name, "Zebra")
|
||||||
testlib.assertEquals(tracks[2].path, '/data/musics/Zebra.dfpwm');
|
testlib.assertEquals(tracks[2].path, "/data/musics/Zebra.dfpwm")
|
||||||
end);
|
end)
|
||||||
|
|
||||||
testlib.test('listTracks returns empty when directory is missing', function()
|
testlib.test("listTracks returns empty when directory is missing", function()
|
||||||
local music = createMusic({
|
local music = createMusic({
|
||||||
dir = '/data/musics',
|
dir = "/data/musics",
|
||||||
fs = { isDir = function() return false; end },
|
fs = {
|
||||||
});
|
isDir = function()
|
||||||
testlib.assertEquals(#music.listTracks(), 0);
|
return false
|
||||||
end);
|
end,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
testlib.assertEquals(#music.listTracks(), 0)
|
||||||
|
end)
|
||||||
|
|
||||||
-- A speaker whose queue accepts `capacity` buffers before refusing. Tests
|
-- A speaker whose queue accepts `capacity` buffers before refusing. Tests
|
||||||
-- simulate a drained queue by resetting `queued` before firing the captured
|
-- simulate a drained queue by resetting `queued` before firing the captured
|
||||||
-- `speaker_audio_empty` handler.
|
-- `speaker_audio_empty` handler.
|
||||||
local function fakeSpeaker(capacity)
|
local function fakeSpeaker(capacity)
|
||||||
local s = { played = {}, stops = 0, queued = 0, capacity = capacity or 1 };
|
local s = { played = {}, stops = 0, queued = 0, capacity = capacity or 1 }
|
||||||
s.playAudio = function(buffer)
|
s.playAudio = function(buffer)
|
||||||
if s.queued >= s.capacity then
|
if s.queued >= s.capacity then
|
||||||
return false;
|
return false
|
||||||
end
|
end
|
||||||
s.queued = s.queued + 1;
|
s.queued = s.queued + 1
|
||||||
s.played[#s.played + 1] = buffer;
|
s.played[#s.played + 1] = buffer
|
||||||
return true;
|
return true
|
||||||
end;
|
end
|
||||||
s.stop = function()
|
s.stop = function()
|
||||||
s.stops = s.stops + 1;
|
s.stops = s.stops + 1
|
||||||
end;
|
end
|
||||||
return s;
|
return s
|
||||||
end
|
end
|
||||||
|
|
||||||
local function fakeHandle(chunks)
|
local function fakeHandle(chunks)
|
||||||
local index = 0;
|
local index = 0
|
||||||
return {
|
return {
|
||||||
closed = false,
|
closed = false,
|
||||||
read = function(_, _)
|
read = function(_, _)
|
||||||
index = index + 1;
|
index = index + 1
|
||||||
return chunks[index];
|
return chunks[index]
|
||||||
end,
|
end,
|
||||||
close = function(self)
|
close = function(self)
|
||||||
self.closed = true;
|
self.closed = true
|
||||||
end,
|
end,
|
||||||
};
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
-- identity decoder: the played buffers equal the raw chunks for easy asserts
|
-- 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 fakeDfpwm = {
|
||||||
|
make_decoder = function()
|
||||||
|
return function(chunk)
|
||||||
|
return chunk
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
}
|
||||||
|
|
||||||
local function makePlayer(opts)
|
local function makePlayer(opts)
|
||||||
local captured = {};
|
local captured = {}
|
||||||
local eventloop = {
|
local eventloop = {
|
||||||
register = function(event, handler)
|
register = function(event, handler)
|
||||||
captured[event] = handler;
|
captured[event] = handler
|
||||||
return function() end;
|
return function() end
|
||||||
end,
|
end,
|
||||||
};
|
}
|
||||||
local music = createMusic({
|
local music = createMusic({
|
||||||
peripheral = { find = function(name) return name == 'speaker' and opts.speaker or nil; end },
|
peripheral = {
|
||||||
io = { open = function(path) return opts.handles[path]; end },
|
find = function(name)
|
||||||
dfpwm = fakeDfpwm,
|
return name == "speaker" and opts.speaker or nil
|
||||||
});
|
end,
|
||||||
local player = music.createPlayer(eventloop);
|
},
|
||||||
return player, captured, eventloop;
|
io = {
|
||||||
|
open = function(path)
|
||||||
|
return opts.handles[path]
|
||||||
|
end,
|
||||||
|
},
|
||||||
|
dfpwm = fakeDfpwm,
|
||||||
|
})
|
||||||
|
local player = music.createPlayer(eventloop)
|
||||||
|
return player, captured, eventloop
|
||||||
end
|
end
|
||||||
|
|
||||||
testlib.test('play opens the file, primes the speaker and reports playing', function()
|
testlib.test("play opens the file, primes the speaker and reports playing", function()
|
||||||
local speaker = fakeSpeaker(1);
|
local speaker = fakeSpeaker(1)
|
||||||
local handle = fakeHandle({ 'a', 'b' });
|
local handle = fakeHandle({ "a", "b" })
|
||||||
local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
|
local player = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
|
||||||
|
|
||||||
player.play('/song.dfpwm');
|
player.play("/song.dfpwm")
|
||||||
|
|
||||||
testlib.assertEquals(player.getStatus(), 'playing');
|
testlib.assertEquals(player.getStatus(), "playing")
|
||||||
testlib.assertEquals(player.getCurrentPath(), '/song.dfpwm');
|
testlib.assertEquals(player.getCurrentPath(), "/song.dfpwm")
|
||||||
testlib.assertEquals(#speaker.played, 1);
|
testlib.assertEquals(#speaker.played, 1)
|
||||||
testlib.assertEquals(speaker.played[1], 'a');
|
testlib.assertEquals(speaker.played[1], "a")
|
||||||
end);
|
end)
|
||||||
|
|
||||||
testlib.test('back-pressure resumes feeding on speaker_audio_empty', function()
|
testlib.test("one chunk fed per speaker_audio_empty until EOF", function()
|
||||||
local speaker = fakeSpeaker(1);
|
local speaker = fakeSpeaker(1)
|
||||||
local handle = fakeHandle({ 'a', 'b' });
|
local handle = fakeHandle({ "a", "b" })
|
||||||
local player, captured = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
|
local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
|
||||||
|
|
||||||
player.play('/song.dfpwm'); -- 'a' queued, 'b' refused (pending)
|
player.play("/song.dfpwm") -- 'a' queued; nothing read ahead
|
||||||
testlib.assertEquals(#speaker.played, 1);
|
testlib.assertEquals(#speaker.played, 1)
|
||||||
|
|
||||||
speaker.queued = 0; -- simulate the queue draining
|
speaker.queued = 0 -- simulate the queue draining
|
||||||
captured.speaker_audio_empty();
|
captured.speaker_audio_empty()
|
||||||
|
|
||||||
testlib.assertEquals(speaker.played[2], 'b'); -- pending buffer replayed
|
testlib.assertEquals(speaker.played[2], "b") -- next chunk fed, one per event
|
||||||
testlib.assertEquals(player.getStatus(), 'stopped'); -- then EOF
|
testlib.assertEquals(player.getStatus(), "playing") -- not yet at EOF
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('pause stops feeding and resume loses no buffered chunk', function()
|
speaker.queued = 0
|
||||||
local speaker = fakeSpeaker(1);
|
captured.speaker_audio_empty() -- reads nil, hits EOF
|
||||||
local handle = fakeHandle({ 'a', 'b', 'c' });
|
|
||||||
local player, captured = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
|
|
||||||
|
|
||||||
player.play('/song.dfpwm'); -- 'a' queued, 'b' pending
|
testlib.assertEquals(#speaker.played, 2) -- nothing extra queued
|
||||||
player.pause();
|
testlib.assertEquals(player.getStatus(), "stopped")
|
||||||
testlib.assertEquals(player.getStatus(), 'paused');
|
end)
|
||||||
|
|
||||||
speaker.queued = 0;
|
testlib.test("pause stops feeding and resume loses no buffered chunk", function()
|
||||||
captured.speaker_audio_empty(); -- no-op while paused
|
local speaker = fakeSpeaker(1)
|
||||||
testlib.assertEquals(#speaker.played, 1);
|
local handle = fakeHandle({ "a", "b", "c" })
|
||||||
|
local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
|
||||||
|
|
||||||
player.resume();
|
player.play("/song.dfpwm") -- 'a' queued, nothing read ahead
|
||||||
testlib.assertEquals(player.getStatus(), 'playing');
|
player.pause()
|
||||||
testlib.assertEquals(speaker.played[2], 'b'); -- pending 'b' fed, nothing dropped
|
testlib.assertEquals(player.getStatus(), "paused")
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('stop clears the speaker, closes the handle and resets state', function()
|
speaker.queued = 0
|
||||||
local speaker = fakeSpeaker(1);
|
captured.speaker_audio_empty() -- no-op while paused
|
||||||
local handle = fakeHandle({ 'a', 'b' });
|
testlib.assertEquals(#speaker.played, 1)
|
||||||
local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
|
|
||||||
|
|
||||||
player.play('/song.dfpwm');
|
player.resume()
|
||||||
player.stop();
|
testlib.assertEquals(player.getStatus(), "playing")
|
||||||
|
testlib.assertEquals(speaker.played[2], "b") -- resume feeds next chunk, nothing dropped
|
||||||
|
end)
|
||||||
|
|
||||||
testlib.assertEquals(speaker.stops, 1);
|
testlib.test("stop clears the speaker, closes the handle and resets state", function()
|
||||||
testlib.assertTrue(handle.closed, 'handle should be closed');
|
local speaker = fakeSpeaker(1)
|
||||||
testlib.assertEquals(player.getStatus(), 'stopped');
|
local handle = fakeHandle({ "a", "b" })
|
||||||
testlib.assertEquals(player.getCurrentPath(), nil);
|
local player = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
|
||||||
end);
|
|
||||||
|
|
||||||
testlib.test('reaching the end fires onFinish once and stays stopped', function()
|
player.play("/song.dfpwm")
|
||||||
local speaker = fakeSpeaker(10); -- large queue: whole track flows in one pump
|
player.stop()
|
||||||
local handle = fakeHandle({ 'a', 'b' });
|
|
||||||
local player = makePlayer({ speaker = speaker, handles = { ['/song.dfpwm'] = handle } });
|
|
||||||
|
|
||||||
local finishes = 0;
|
testlib.assertEquals(speaker.stops, 1)
|
||||||
player.onFinish(function() finishes = finishes + 1; end);
|
testlib.assertTrue(handle.closed, "handle should be closed")
|
||||||
|
testlib.assertEquals(player.getStatus(), "stopped")
|
||||||
|
testlib.assertEquals(player.getCurrentPath(), nil)
|
||||||
|
end)
|
||||||
|
|
||||||
player.play('/song.dfpwm');
|
testlib.test("reaching the end fires onFinish once and stays stopped", function()
|
||||||
|
local speaker = fakeSpeaker(1)
|
||||||
|
local handle = fakeHandle({ "a", "b" })
|
||||||
|
local player, captured = makePlayer({ speaker = speaker, handles = { ["/song.dfpwm"] = handle } })
|
||||||
|
|
||||||
testlib.assertEquals(finishes, 1);
|
local finishes = 0
|
||||||
testlib.assertEquals(player.getStatus(), 'stopped');
|
player.onFinish(function()
|
||||||
testlib.assertEquals(#speaker.played, 2);
|
finishes = finishes + 1
|
||||||
end);
|
end)
|
||||||
|
|
||||||
testlib.run();
|
player.play("/song.dfpwm") -- 'a' queued
|
||||||
|
|
||||||
|
-- One empty event per remaining chunk, then one more that reads nil (EOF).
|
||||||
|
for _ = 1, 2 do
|
||||||
|
speaker.queued = 0
|
||||||
|
captured.speaker_audio_empty()
|
||||||
|
end
|
||||||
|
|
||||||
|
testlib.assertEquals(finishes, 1)
|
||||||
|
testlib.assertEquals(player.getStatus(), "stopped")
|
||||||
|
testlib.assertEquals(#speaker.played, 2)
|
||||||
|
end)
|
||||||
|
|
||||||
|
testlib.run()
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user