256 lines
11 KiB
Lua
256 lines
11 KiB
Lua
|
|
local aukit = require "aukit"
|
|
|
|
local speakers = {peripheral.find("speaker")}
|
|
if #speakers == 0 then error("No speaker attached") end
|
|
if #speakers == 2 and peripheral.getName(speakers[1]) == "right" and peripheral.getName(speakers[2]) == "left" then speakers = {speakers[2], speakers[1]} end
|
|
|
|
local path = ...
|
|
if not path then error("Usage: austream <file/URL> [arguments for PCM/DFPWM]") end
|
|
local params = {...}
|
|
table.remove(params, 1) -- Remove path
|
|
local v = {}
|
|
if #params > 0 then
|
|
-- Try to parse as Lua table string first (original behavior)
|
|
local paramStr = table.concat(params, " ")
|
|
-- Remove outer braces if they exist to match original austream format
|
|
paramStr = paramStr:gsub("^%s*{%s*", ""):gsub("%s*}%s*$", "")
|
|
|
|
local fn = load("return {" .. paramStr .. "}", "=unserialize", "t", setmetatable({}, {__index = function(_, idx) return idx end}))
|
|
if fn then
|
|
local ok, parsed = pcall(fn)
|
|
if ok and type(parsed) == "table" then
|
|
for k, val in pairs(parsed) do v[k] = val end
|
|
end
|
|
end
|
|
|
|
-- If that fails, try manual string matching
|
|
if not next(v) then
|
|
local matchStr = table.concat(params, ", ")
|
|
if matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]") then
|
|
v.type = matchStr:match("type%s*=%s*['\"]([^'\"]+)['\"]")
|
|
end
|
|
if matchStr:match("volume%s*=%s*([%d%.]+)") then v.volume = tonumber(matchStr:match("volume%s*=%s*([%d%.]+)")) end
|
|
if matchStr:match("streamData%s*=%s*true") then v.streamData = true end
|
|
if matchStr:match("silent%s*=%s*true") then v.silent = true end
|
|
end
|
|
end
|
|
if v.volume == nil then v.volume = 3 end
|
|
if type(v.volume) == "number" then
|
|
if v.volume < 0 then v.volume = 0 end
|
|
if v.volume > 3 then v.volume = 3 end
|
|
end
|
|
local mono = v.mono or #speakers == 1
|
|
aukit.defaultInterpolation = v.interpolation or aukit.defaultInterpolation
|
|
local data
|
|
if path:match("^https?://") then
|
|
local handle, err = http.get(path, nil, true)
|
|
if not handle then error("Could not connect to " .. path .. ": " .. err) end
|
|
local code = handle.getResponseCode()
|
|
if code ~= 200 then handle.close() error("Could not connect to " .. path .. ": HTTP " .. code) end
|
|
if v.streamData then
|
|
local closed = false
|
|
function data()
|
|
if closed then return nil end
|
|
local d = handle.read(48000)
|
|
if not d then handle.close() closed = true return nil end
|
|
return d
|
|
end
|
|
else
|
|
data = handle.readAll()
|
|
handle.close()
|
|
end
|
|
elseif path:match("^wss?://") then
|
|
local ws, err = http.websocket(path)
|
|
if not ws then error("Failed to connect to websocket: " .. tostring(err)) end
|
|
|
|
-- Buffer completely locally to avoid any network stutter
|
|
local fullBuffer = ""
|
|
local isDone = false
|
|
|
|
local function cleanup()
|
|
if not isDone then
|
|
isDone = true
|
|
if ws then ws.close() end
|
|
end
|
|
end
|
|
|
|
-- Initial wait for the first data chunk from the server (which sends a 1s silence chunk on connect)
|
|
local startWait = os.startTimer(1.0)
|
|
while true do
|
|
local event, p1, p2 = os.pullEvent()
|
|
if event == "websocket_message" and p1 == path then
|
|
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
|
-- Once we get the first message, we can break and start playing
|
|
break
|
|
elseif event == "websocket_closed" and p1 == path then
|
|
cleanup()
|
|
break
|
|
elseif event == "timer" and p1 == startWait then
|
|
break
|
|
end
|
|
end
|
|
|
|
-- Since WebSocket connection needs to run alongside aukit stream reading,
|
|
-- we will just use a non-blocking read approach in the data function itself.
|
|
|
|
function data(n)
|
|
n = n or 6000
|
|
|
|
if isDone then
|
|
if #fullBuffer == 0 then return nil end
|
|
else
|
|
-- First, read any available messages without blocking
|
|
-- ws.receive(0) returns immediately. If nil, no message is ready.
|
|
while true do
|
|
local msg = ws.receive(0)
|
|
if msg == nil then break end
|
|
if type(msg) == "string" then
|
|
fullBuffer = fullBuffer .. msg
|
|
end
|
|
end
|
|
|
|
-- If we still don't have enough data, wait for more
|
|
-- but only if we know more might be coming (not isDone)
|
|
if #fullBuffer < n then
|
|
-- We only wait briefly so we don't block the audio thread too long
|
|
-- If the server isn't sending data, it means silence or end of stream
|
|
local timeout = os.startTimer(0.1) -- shorter timeout to keep pulling events
|
|
while #fullBuffer < n do
|
|
local event, p1, p2 = os.pullEvent()
|
|
if event == "websocket_message" and p1 == path then
|
|
if type(p2) == "string" then fullBuffer = fullBuffer .. p2 end
|
|
elseif event == "websocket_closed" and p1 == path then
|
|
cleanup()
|
|
break
|
|
elseif event == "timer" and p1 == timeout then
|
|
-- We hit timeout, we must return whatever we have to keep playing
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if #fullBuffer == 0 and isDone then return nil end
|
|
-- Return DFPWM silence (0xAA) if buffer is empty but stream is alive
|
|
-- This keeps the stream alive without crashing or making noise
|
|
if #fullBuffer == 0 then return string.rep("\xAA", n) end
|
|
|
|
local ret
|
|
if #fullBuffer >= n then
|
|
ret = fullBuffer:sub(1, n)
|
|
fullBuffer = fullBuffer:sub(n + 1)
|
|
else
|
|
ret = fullBuffer
|
|
-- Pad with silence if we don't have enough data to fulfill the request
|
|
ret = ret .. string.rep("\xAA", n - #ret)
|
|
fullBuffer = ""
|
|
end
|
|
return ret
|
|
end
|
|
-- Note: aukit.stream handles the reading internally
|
|
-- We can use a table with a read function for custom streaming
|
|
local obj = {
|
|
read = function(n)
|
|
local d = data(n)
|
|
if not d and isDone then cleanup() end
|
|
return d
|
|
end,
|
|
close = cleanup
|
|
}
|
|
return obj
|
|
elseif path:match("^rednet://%d+") or path:match("^rednet%+%l+://%d+") then
|
|
peripheral.find("modem", rednet.open)
|
|
local proto, id, name = path:match("^rednet%+?(%l*)://(%d+)(/?.*)$")
|
|
id = tonumber(id)
|
|
if proto == "" then proto = nil end
|
|
if path ~= "" and id >= 0 then rednet.send(id, name, proto) end
|
|
function data()
|
|
local i, msg
|
|
repeat i, msg = rednet.receive(proto) until id == -1 or i == id
|
|
return msg
|
|
end
|
|
else
|
|
path = shell.resolve(...)
|
|
local file, err = fs.open(path, "rb")
|
|
if not file then error("Could not open " .. path .. ": " .. err) end
|
|
if v.streamData then
|
|
local closed = false
|
|
function data()
|
|
if closed then return nil end
|
|
local d = file.read(48000)
|
|
if not d then file.close() closed = true return nil end
|
|
return d
|
|
end
|
|
else
|
|
data = file.readAll()
|
|
file.close()
|
|
end
|
|
end
|
|
|
|
|
|
local iter, length
|
|
if type(v.type) == "string" then v.type = v.type:lower() end
|
|
|
|
local isDfpwm = v.type == "dfpwm" or path:lower():match("%.dfpwm$") or path:match("^wss?://")
|
|
if isDfpwm then
|
|
if type(data) == "table" and data.read then
|
|
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
|
else
|
|
iter, length = aukit.stream.dfpwm(data, v.sampleRate, v.channels, mono)
|
|
end
|
|
elseif v.type == "mdfpwm" or path:lower():match("%.mdfpwm$") then iter, length = aukit.stream.mdfpwm(data, mono)
|
|
elseif v.type == "wav" or path:lower():match("%.wav$") then iter, length = aukit.stream.wav(data, mono, v.ignoreHeader)
|
|
elseif v.type == "aiff" or path:lower():match("%.aiff?$") then iter, length = aukit.stream.aiff(data, mono, v.ignoreHeader)
|
|
elseif v.type == "au" or path:lower():match("%.au$") then iter, length = aukit.stream.au(data, mono, v.ignoreHeader)
|
|
elseif v.type == "flac" or path:lower():match("%.flac$") then iter, length = aukit.stream.flac(data, mono)
|
|
elseif v.type == "qoa" or path:lower():match("%.qoa$") then iter, length = aukit.stream.qoa(data, mono)
|
|
elseif v.type == "pcm" or path:lower():match("%.pcm$") or path:lower():match("%.raw$") or path:match("^rednet%+?%l*://") then iter, length = aukit.stream.pcm(data, v.bitDepth, v.dataType, v.channels, v.sampleRate, v.bigEndian, mono)
|
|
else error("Unknown file type. Make sure to add the right file extension to the path/URL.") end
|
|
if length == nil then length = 0 end
|
|
|
|
if not v.silent then
|
|
print("Streaming...")
|
|
local w = term.getSize()
|
|
local y = select(2, term.getCursorPos())
|
|
local fg, bg = colors.toBlit(term.getTextColor()), colors.toBlit(term.getBackgroundColor())
|
|
term.write(("00:00 %s %02d:%02d"):format(("\127"):rep(w - 12), math.floor(length / 60), length % 60))
|
|
aukit.play(iter, function(pos)
|
|
pos = math.min(pos, 5999)
|
|
local p = pos / length
|
|
term.setCursorPos(1, y)
|
|
|
|
-- 计算基本时间字符串
|
|
local timeStr = ("%02d:%02d"):format(math.floor(pos / 60), pos % 60)
|
|
local endTimeStr = ("%02d:%02d"):format(math.floor(length / 60), length % 60)
|
|
|
|
if p > 1 then
|
|
-- 确保空格数量正确
|
|
local spaces = (" "):rep(w - 12)
|
|
local text = timeStr .. " " .. spaces .. "--:--"
|
|
local fgColor = fg:rep(#text)
|
|
local bgColor = bg:rep(6) .. fg:rep(w - 12) .. bg:rep(6)
|
|
-- 确保所有参数长度相等
|
|
if #text == #fgColor and #text == #bgColor then
|
|
term.blit(text, fgColor, bgColor)
|
|
end
|
|
else
|
|
local barWidth = w - 12
|
|
local progress = math.floor(barWidth * p)
|
|
progress = math.max(0, math.min(progress, barWidth)) -- 确保进度在有效范围内
|
|
|
|
local progressBar = (" "):rep(progress) .. ("\127"):rep(barWidth - progress)
|
|
local text = timeStr .. " " .. progressBar .. " " .. endTimeStr
|
|
local fgColor = fg:rep(#text)
|
|
local bgColor = bg:rep(6) .. fg:rep(progress) .. bg:rep(barWidth - progress + 6)
|
|
-- 确保所有参数长度相等
|
|
if #text == #fgColor and #text == #bgColor then
|
|
term.blit(text, fgColor, bgColor)
|
|
end
|
|
end
|
|
end, v.volume, table.unpack(speakers))
|
|
print()
|
|
else
|
|
aukit.play(iter, function() end, v.volume, table.unpack(speakers))
|
|
end
|