feat(trapos-music): add music player package
This commit is contained in:
parent
dd778d0c29
commit
22b51d94ee
@ -12,7 +12,7 @@ trapos *args: check-install stage
|
|||||||
argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
|
argv+=(--rom /Applications/CraftOS-PC.app/Contents/Resources)
|
||||||
fi
|
fi
|
||||||
argv+=(--mount-ro "/trapos=$repo")
|
argv+=(--mount-ro "/trapos=$repo")
|
||||||
for dir in apis programs daemons startup tests; do
|
for dir in apis programs daemons startup tests musics; do
|
||||||
if [ -d "$repo/.stage/$dir" ]; then
|
if [ -d "$repo/.stage/$dir" ]; then
|
||||||
argv+=(--mount-ro "/$dir=$repo/.stage/$dir")
|
argv+=(--mount-ro "/$dir=$repo/.stage/$dir")
|
||||||
fi
|
fi
|
||||||
@ -79,7 +79,7 @@ trapos-exec code: stage
|
|||||||
printf '%s\n' 'status.close()'
|
printf '%s\n' 'status.close()'
|
||||||
printf '%s\n' 'os.shutdown()'
|
printf '%s\n' 'os.shutdown()'
|
||||||
} > "$runner"
|
} > "$runner"
|
||||||
mount_arg=(--mount-ro "/trapos=$repo" --mount-ro "/apis=$repo/.stage/apis" --mount-ro "/programs=$repo/.stage/programs" --mount-ro "/daemons=$repo/.stage/daemons" --mount-ro "/startup=$repo/.stage/startup" --mount-ro "/tests=$repo/.stage/tests" --mount-ro "/headless=$stage_dir")
|
mount_arg=(--mount-ro "/trapos=$repo" --mount-ro "/apis=$repo/.stage/apis" --mount-ro "/programs=$repo/.stage/programs" --mount-ro "/daemons=$repo/.stage/daemons" --mount-ro "/startup=$repo/.stage/startup" --mount-ro "/tests=$repo/.stage/tests" --mount-ro "/musics=$repo/.stage/musics" --mount-ro "/headless=$stage_dir")
|
||||||
craftos --directory "$data_dir" --headless "${rom_arg[@]}" "${mount_arg[@]}" --exec "shell.run('/headless/exec.lua')" >"$tmp" 2>&1 &
|
craftos --directory "$data_dir" --headless "${rom_arg[@]}" "${mount_arg[@]}" --exec "shell.run('/headless/exec.lua')" >"$tmp" 2>&1 &
|
||||||
pid="$!"
|
pid="$!"
|
||||||
( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
|
( sleep "$timeout_seconds"; kill -TERM "$pid" >/dev/null 2>&1 ) &
|
||||||
|
|||||||
@ -3,8 +3,8 @@
|
|||||||
# '/apis/x') and ccpm installs files flat, but sources now live under
|
# '/apis/x') and ccpm installs files flat, but sources now live under
|
||||||
# packages/<pkg>/{apis,programs,daemons,startup,tests}. This merges them back
|
# packages/<pkg>/{apis,programs,daemons,startup,tests}. This merges them back
|
||||||
# into a gitignored .stage/ that the dev/test recipes mount as /apis, /programs,
|
# into a gitignored .stage/ that the dev/test recipes mount as /apis, /programs,
|
||||||
# /daemons, /startup, /tests. Regenerated on every run so it always reflects the
|
# /daemons, /startup, /tests, /musics. Regenerated on every run so it always
|
||||||
# working tree.
|
# reflects the working tree.
|
||||||
stage:
|
stage:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
@ -12,7 +12,7 @@ stage:
|
|||||||
cd "$repo"
|
cd "$repo"
|
||||||
stage="$repo/.stage"
|
stage="$repo/.stage"
|
||||||
rm -rf "$stage"
|
rm -rf "$stage"
|
||||||
for dir in apis programs daemons startup tests; do
|
for dir in apis programs daemons startup tests musics; do
|
||||||
for pkgdir in packages/*/"$dir"; do
|
for pkgdir in packages/*/"$dir"; do
|
||||||
[ -d "$pkgdir" ] || continue
|
[ -d "$pkgdir" ] || continue
|
||||||
mkdir -p "$stage/$dir"
|
mkdir -p "$stage/$dir"
|
||||||
|
|||||||
@ -6,6 +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.1.0",
|
||||||
"trapos-sandbox-legacy": "0.3.2",
|
"trapos-sandbox-legacy": "0.3.2",
|
||||||
"trapos": "0.12.6"
|
"trapos": "0.12.6"
|
||||||
}
|
}
|
||||||
|
|||||||
62
packages/trapos-music/apis/libmusic.lua
Normal file
62
packages/trapos-music/apis/libmusic.lua
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
-- 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 = '/musics';
|
||||||
|
local EXTENSION = '.dfpwm';
|
||||||
|
local CHUNK_SIZE = 16 * 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
|
||||||
|
|
||||||
|
return api;
|
||||||
|
end
|
||||||
|
|
||||||
|
return createMusic;
|
||||||
12
packages/trapos-music/ccpm.json
Normal file
12
packages/trapos-music/ccpm.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "trapos-music",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "TrapOS music player: dfpwm playback through a speaker",
|
||||||
|
"dependencies": ["trapos-core", "trapos-ui"],
|
||||||
|
"files": [
|
||||||
|
"apis/libmusic.lua",
|
||||||
|
"programs/music.lua",
|
||||||
|
"musics/GrillControl.dfpwm"
|
||||||
|
],
|
||||||
|
"autostart": []
|
||||||
|
}
|
||||||
6496
packages/trapos-music/musics/GrillControl.dfpwm
Normal file
6496
packages/trapos-music/musics/GrillControl.dfpwm
Normal file
File diff suppressed because one or more lines are too long
116
packages/trapos-music/programs/music.lua
Normal file
116
packages/trapos-music/programs/music.lua
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
local createVersion = require('/apis/libversion');
|
||||||
|
|
||||||
|
local command = ...;
|
||||||
|
|
||||||
|
local function printUsage()
|
||||||
|
print('music usage:');
|
||||||
|
print();
|
||||||
|
print('\t\t\tmusic');
|
||||||
|
print('\t\t\tmusic version');
|
||||||
|
print('\t\t\tmusic help');
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'version' or command == '-version' or command == '--version' then
|
||||||
|
print('v' .. createVersion().forSelf());
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command == 'help' or command == '-help' or command == '--help' then
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if command ~= nil and command ~= '' then
|
||||||
|
printUsage();
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
local createMusic = require('/apis/libmusic');
|
||||||
|
local music = createMusic();
|
||||||
|
|
||||||
|
local tracks = music.listTracks();
|
||||||
|
if #tracks == 0 then
|
||||||
|
print('No music found in /musics');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if not peripheral.find('speaker') then
|
||||||
|
print('No speaker attached');
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
local createEventLoop = require('/apis/eventloop');
|
||||||
|
local createTui = require('/apis/libtui');
|
||||||
|
|
||||||
|
local eventloop = createEventLoop();
|
||||||
|
local ui = createTui(eventloop);
|
||||||
|
|
||||||
|
local Text = ui.Text;
|
||||||
|
local Button = ui.Button;
|
||||||
|
local Box = ui.Box;
|
||||||
|
local List = ui.List;
|
||||||
|
|
||||||
|
local selected = nil;
|
||||||
|
|
||||||
|
local function Header()
|
||||||
|
return Box({
|
||||||
|
direction = 'row',
|
||||||
|
bgColor = colors.gray,
|
||||||
|
children = {
|
||||||
|
Text('Music', {
|
||||||
|
flex = 1,
|
||||||
|
color = colors.white,
|
||||||
|
bgColor = colors.gray,
|
||||||
|
}),
|
||||||
|
Button('X', {
|
||||||
|
color = colors.white,
|
||||||
|
bgColor = colors.red,
|
||||||
|
onClick = function(tui)
|
||||||
|
tui.exitUI('cancel');
|
||||||
|
end,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
end
|
||||||
|
|
||||||
|
local function TrackList()
|
||||||
|
local items = {};
|
||||||
|
for _, track in ipairs(tracks) do
|
||||||
|
items[#items + 1] = Button(track.name, {
|
||||||
|
onClick = function(tui)
|
||||||
|
selected = track;
|
||||||
|
tui.exitUI('play');
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
end
|
||||||
|
return List({
|
||||||
|
gap = 1,
|
||||||
|
padding = 1,
|
||||||
|
children = items,
|
||||||
|
});
|
||||||
|
end
|
||||||
|
|
||||||
|
local function App()
|
||||||
|
return Box({
|
||||||
|
direction = 'column',
|
||||||
|
children = {
|
||||||
|
Header(),
|
||||||
|
Box({
|
||||||
|
flex = 1,
|
||||||
|
border = true,
|
||||||
|
title = 'Choose a track',
|
||||||
|
children = {
|
||||||
|
TrackList(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
end
|
||||||
|
|
||||||
|
ui.render(App);
|
||||||
|
|
||||||
|
if selected then
|
||||||
|
print('Playing ' .. selected.name .. '...');
|
||||||
|
music.play(selected.path);
|
||||||
|
print('Done.');
|
||||||
|
end
|
||||||
37
packages/trapos-music/tests/libmusic.lua
Normal file
37
packages/trapos-music/tests/libmusic.lua
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
local createLibTest = require('/apis/libtest');
|
||||||
|
local createMusic = require('/apis/libmusic');
|
||||||
|
|
||||||
|
local testlib = createLibTest({ ... });
|
||||||
|
|
||||||
|
local function fakeFs(dir, entries)
|
||||||
|
return {
|
||||||
|
isDir = function(path) return path == dir; end,
|
||||||
|
list = function(path)
|
||||||
|
if path ~= dir then error('unexpected list: ' .. tostring(path)); end
|
||||||
|
return entries;
|
||||||
|
end,
|
||||||
|
};
|
||||||
|
end
|
||||||
|
|
||||||
|
testlib.test('listTracks filters dfpwm, strips extension and sorts', function()
|
||||||
|
local music = createMusic({
|
||||||
|
dir = '/musics',
|
||||||
|
fs = fakeFs('/musics', { 'Zebra.dfpwm', 'notes.txt', 'Apple.dfpwm' }),
|
||||||
|
});
|
||||||
|
local tracks = music.listTracks();
|
||||||
|
testlib.assertEquals(#tracks, 2);
|
||||||
|
testlib.assertEquals(tracks[1].name, 'Apple');
|
||||||
|
testlib.assertEquals(tracks[1].path, '/musics/Apple.dfpwm');
|
||||||
|
testlib.assertEquals(tracks[2].name, 'Zebra');
|
||||||
|
testlib.assertEquals(tracks[2].path, '/musics/Zebra.dfpwm');
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.test('listTracks returns empty when directory is missing', function()
|
||||||
|
local music = createMusic({
|
||||||
|
dir = '/musics',
|
||||||
|
fs = { isDir = function() return false; end },
|
||||||
|
});
|
||||||
|
testlib.assertEquals(#music.listTracks(), 0);
|
||||||
|
end);
|
||||||
|
|
||||||
|
testlib.run();
|
||||||
Loading…
Reference in New Issue
Block a user