feat: 重构音频系统,移除外部依赖并新增WAV格式支持

- 移除install和update脚本中的aukit/austream下载配置与目标,不再部署辅助脚本
- 替换station_broadcast.lua的aukit库为CC内置dfpwm模块,重写音频播放逻辑
- 添加原生扬声器API支持,支持WAV格式音频播放
- 重写云端服务器音频处理逻辑,支持直接传输WAV文件无需预转换
- 新增WAV文件时长解析、音频序列合成等辅助功能
This commit is contained in:
2026-07-31 19:29:38 +08:00
parent e4b4fa5cdb
commit c73bceaec7
5 changed files with 320 additions and 120 deletions
+103 -80
View File
@@ -177,21 +177,23 @@ function sendAudioControl(stationName, msg, fallbackWs) {
}
}
async function sendDfpwmBufferToStation(stationName, fallbackWs, task) {
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
const targetWs = getStationWs(stationName, fallbackWs);
if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
const buf = task.buffer;
const durationSec = buf.length / 6000;
const durationSec = Number(task.durationSec || 0);
const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase();
const startMsg = {
type: 'audio_start',
stationName,
audioId,
byteLength: buf.length,
sampleRate: 48000,
channels: 1,
sampleRate: Number(task.sampleRate || 48000),
channels: Number(task.channels || 1),
audioFormat,
priority: Number(task.priority || 0),
interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
source: task.source || 'unknown'
@@ -254,29 +256,73 @@ function listMusicTracks() {
return Array.from(out).sort((a, b) => a.localeCompare(b));
}
function getDfpwmBufferByBaseName(baseName) {
function getWavDurationSec(buffer) {
if (!Buffer.isBuffer(buffer) || buffer.length < 44) return 0;
if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return 0;
let offset = 12;
let sampleRate = 0;
let channels = 0;
let bitsPerSample = 0;
let dataSize = 0;
while (offset + 8 <= buffer.length) {
const chunkId = buffer.toString('ascii', offset, offset + 4);
const chunkSize = buffer.readUInt32LE(offset + 4);
const dataOffset = offset + 8;
if (dataOffset + chunkSize > buffer.length) break;
if (chunkId === 'fmt ' && chunkSize >= 16) {
channels = buffer.readUInt16LE(dataOffset + 2);
sampleRate = buffer.readUInt32LE(dataOffset + 4);
bitsPerSample = buffer.readUInt16LE(dataOffset + 14);
} else if (chunkId === 'data') {
dataSize = chunkSize;
break;
}
offset = dataOffset + chunkSize + (chunkSize % 2);
}
if (!sampleRate || !channels || !bitsPerSample || !dataSize) return 0;
return dataSize / (sampleRate * channels * (bitsPerSample / 8));
}
function getPlaybackAudioByBaseName(baseName) {
const base = normalizeBaseName(baseName);
if (!base) throw new Error('Invalid track name');
const wavCandidates = [
path.join(audioGeneralDir, `${base}.wav`),
path.join(audioStationDir, `${base}.wav`)
];
for (const p of wavCandidates) {
if (fs.existsSync(p)) {
const buffer = fs.readFileSync(p);
return {
buffer,
audioFormat: 'wav',
durationSec: getWavDurationSec(buffer) || 0
};
}
}
const dfCandidates = [
path.join(audioGeneralDir, `${base}.dfpwm`),
path.join(audioStationDir, `${base}.dfpwm`)
];
for (const p of dfCandidates) {
if (fs.existsSync(p)) return fs.readFileSync(p);
if (fs.existsSync(p)) {
const buffer = fs.readFileSync(p);
return {
buffer,
audioFormat: 'dfpwm',
durationSec: buffer.length / 6000
};
}
}
const wavCandidates = [
path.join(audioGeneralDir, `${base}.wav`),
path.join(audioStationDir, `${base}.wav`)
];
const wavPath = wavCandidates.find(p => fs.existsSync(p)) || null;
if (!wavPath) throw new Error(`Audio not found: ${base}`);
const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm');
if (!fs.existsSync(dfpwmPath)) convertToDfpwm(wavPath);
if (!fs.existsSync(dfpwmPath)) throw new Error(`DFPWM build failed: ${base}`);
return fs.readFileSync(dfpwmPath);
throw new Error(`Audio not found: ${base}`);
}
function stopStationMusic(stationName, fallbackWs) {
@@ -357,7 +403,7 @@ async function processStationAudioQueue(stationName, fallbackWs) {
try {
if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart);
const result = await sendDfpwmBufferToStation(cleanStation, fallbackWs, task);
const result = await sendAudioBufferToStation(cleanStation, fallbackWs, task);
if (task.consoleStreamEnd) {
if (result.cancelled) {
broadcastToConsole(task.consoleStreamEnd);
@@ -767,6 +813,25 @@ function buildConcatListFile(seq) {
return listPath;
}
function buildSequenceWavBuffer(seq) {
const listPath = buildConcatListFile(seq);
if (!listPath) return null;
const outWavPath = path.join(tmpDir, `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`);
try {
execSync(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listPath}" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
const buffer = fs.readFileSync(outWavPath);
return {
buffer,
audioFormat: 'wav',
durationSec: getWavDurationSec(buffer) || 0
};
} finally {
try { fs.unlinkSync(listPath); } catch {}
try { fs.unlinkSync(outWavPath); } catch {}
}
}
function runFfmpegRtmpPush(streamName, seq) {
const state = getOrCreateHlsStreamState(streamName);
const listPath = buildConcatListFile(seq);
@@ -1412,52 +1477,8 @@ function handlePlaySequence(ws, data) {
log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`);
// Process files sequentially
let combinedBuffer = [];
for (const file of seq) {
if (file === '__DELAY_1__') {
const silence = Buffer.alloc(6000, 0xAA);
combinedBuffer.push(silence);
continue;
}
let foundPath = null;
const candidates = [
path.join(audioStationDir, `${file}.dfpwm`),
path.join(audioGeneralDir, `${file}.dfpwm`),
path.join(audioStationDir, `${file}.wav`),
path.join(audioGeneralDir, `${file}.wav`)
];
for (const p of candidates) {
if (fs.existsSync(p)) {
foundPath = p;
break;
}
}
if (foundPath) {
let dfpwmPath = foundPath;
if (foundPath.endsWith('.wav')) {
dfpwmPath = foundPath.replace(/\.wav$/, '.dfpwm');
if (!fs.existsSync(dfpwmPath)) {
convertToDfpwm(foundPath);
}
}
if (fs.existsSync(dfpwmPath)) {
combinedBuffer.push(fs.readFileSync(dfpwmPath));
} else {
log('WARN', `Could not get DFPWM for: ${file}`);
}
} else {
log('WARN', `Audio missing on server: ${file}`);
}
}
if (combinedBuffer.length > 0) {
const finalBuffer = Buffer.concat(combinedBuffer);
const builtAudio = buildSequenceWavBuffer(seq);
if (builtAudio) {
const priority = Number.isFinite(Number(data.priority))
? Number(data.priority)
: (String(data.category || '').toLowerCase() === 'station' ? 0 : 1);
@@ -1467,7 +1488,9 @@ function handlePlaySequence(ws, data) {
const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
enqueueStationAudioTask(cleanStation, ws, {
buffer: finalBuffer,
buffer: builtAudio.buffer,
audioFormat: builtAudio.audioFormat,
durationSec: builtAudio.durationSec,
priority,
interruptMode,
source,
@@ -1476,6 +1499,8 @@ function handlePlaySequence(ws, data) {
consoleStreamStart: { type: 'stream_start', station: cleanStation, seq: seq, streamName, hlsUrl },
consoleStreamEnd: { type: 'stream_end', station: cleanStation }
});
} else {
log('WARN', `Unable to build WAV sequence for ${cleanStation}`);
}
}
@@ -2171,18 +2196,12 @@ function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) {
].join(';');
execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
convertToDfpwm(outWavPath);
const dfpwmPath = outWavPath.replace(/\.wav$/i, '.dfpwm');
if (!fs.existsSync(dfpwmPath)) throw new Error('DFPWM build failed');
const dfpwmBuf = fs.readFileSync(dfpwmPath);
const durationSec = dfpwmBuf.length / 6000;
const wavBuf = fs.readFileSync(outWavPath);
const durationSec = getWavDurationSec(wavBuf) || 0;
return {
broadcastId,
wavPath: outWavPath,
dfpwmPath,
durationSec
};
}
@@ -2254,10 +2273,10 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm');
if (!fs.existsSync(dfpwmPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (dfpwm)' });
if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' });
const dfpwmBuf = fs.readFileSync(dfpwmPath);
const wavBuf = fs.readFileSync(wavPath);
const durationSec = getWavDurationSec(wavBuf) || 0;
const priority = urgency === 'emergency' ? 2 : 0;
const interruptMode = urgency === 'emergency' ? 'all' : 'none';
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
@@ -2277,7 +2296,9 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
continue;
}
enqueueStationAudioTask(name, ws, {
buffer: dfpwmBuf,
buffer: wavBuf,
audioFormat: 'wav',
durationSec,
priority,
interruptMode,
source,
@@ -2305,9 +2326,9 @@ app.post('/api/music/start', express.json(), (req, res) => {
if (!track) return res.status(400).json({ success: false, message: 'track required' });
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
let dfpwmBuf;
let playbackAudio;
try {
dfpwmBuf = getDfpwmBufferByBaseName(track);
playbackAudio = getPlaybackAudioByBaseName(track);
} catch (e) {
return res.status(404).json({ success: false, message: e?.message || String(e) });
}
@@ -2338,7 +2359,9 @@ app.post('/api/music/start', express.json(), (req, res) => {
}
enqueueStationAudioTask(name, currentWs, {
buffer: dfpwmBuf,
buffer: playbackAudio.buffer,
audioFormat: playbackAudio.audioFormat,
durationSec: playbackAudio.durationSec,
priority: -1,
interruptMode: 'none',
source: 'music',
+2 -6
View File
@@ -1,6 +1,4 @@
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/commit/e4b4fa5cdb2bc42279fd87098fef0d27a90e23d5/station_broadcast.lua"
local function fetch(url)
if not http then
@@ -66,9 +64,7 @@ local function downloadTo(url, path)
end
local targets = {
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }
}
for _, t in ipairs(targets) do
+49 -1
View File
@@ -10,6 +10,8 @@ local COLORS = {
SUCCESS = colors.green
}
local dfpwm = require "cc.audio.dfpwm"
-- Debug output function
local function debugPrint(level, message)
local timestamp = os.date("%H:%M:%S")
@@ -21,6 +23,52 @@ local function debugPrint(level, message)
term.setTextColor(oldColor)
end
local function playDfpwmFile(path, volume)
local speakers = { peripheral.find("speaker") }
if #speakers == 0 then
debugPrint("ERROR", "No speakers found")
return false
end
local file = fs.open(path, "rb")
if not file then
debugPrint("ERROR", "Cannot open audio file: " .. path)
return false
end
local decoder = dfpwm.make_decoder()
local controller = speakers[1]
while true do
local chunk = file.read(16 * 1024)
if not chunk then break end
local samples = decoder(chunk)
if controller and type(controller.playAudioAll) == "function" then
while not controller.playAudioAll(samples, volume) do
os.pullEvent("speaker_audio_empty")
end
else
local pending = {}
for _, sp in ipairs(speakers) do
pending[peripheral.getName(sp)] = sp
end
while next(pending) do
for name, sp in pairs(pending) do
if sp.playAudio(samples, volume) then
pending[name] = nil
end
end
if next(pending) then
os.pullEvent("speaker_audio_empty")
end
end
end
end
file.close()
return true
end
-- Add random audio playback function
local function playRandomAudio()
local audioFiles = {"aqtx", "jzxy"}
@@ -29,7 +77,7 @@ local function playRandomAudio()
if fs.exists(path) then
debugPrint("INFO", "Attempting to play: "..path)
shell.run("austream "..path.." volume=3.0")
playDfpwmFile(path, 3.0)
debugPrint("INFO", "Finished playing: "..randomFile)
else
debugPrint("ERROR", "Audio file not found: "..path)
+157 -20
View File
@@ -14,7 +14,7 @@ local CONFIG = {
DEBUG = true
}
local aukit = require "aukit"
local dfpwm = require "cc.audio.dfpwm"
local COLORS = {
INFO = colors.white,
@@ -527,12 +527,14 @@ end
function Audio.enqueueDfpwm(dfpwmData)
if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
Audio.enqueueItem({ dfpwmData = dfpwmData, priority = 1 })
Audio.enqueueItem({ audioData = dfpwmData, audioFormat = "dfpwm", priority = 1 })
end
function Audio.enqueueItem(item)
if type(item) ~= "table" then return end
if type(item.dfpwmData) ~= "string" and type(item.dfpwmData) ~= "table" then return end
item.audioData = item.audioData or item.dfpwmData
item.audioFormat = tostring(item.audioFormat or (item.dfpwmData and "dfpwm") or "dfpwm"):lower()
if type(item.audioData) ~= "string" and type(item.audioData) ~= "table" then return end
item.priority = tonumber(item.priority) or 0
local idx = 1
while idx <= #State.audioQueue do
@@ -578,6 +580,19 @@ local function shouldStop(playSeq)
return State.interruptSeq ~= playSeq
end
local function waitForInterruptibleDelay(playSeq, seconds)
local timer = os.startTimer(seconds)
while true do
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
if ev[1] == "timer" and ev[2] == timer then
return true
end
end
end
local function playChunkOnSpeakers(samples, volume, playSeq)
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
local controller = Peripherals.speakers[1]
@@ -639,6 +654,82 @@ local function playChunkOnSpeakers(samples, volume, playSeq)
return true
end
local function areHqSpeakersBusy(speakers)
for _, sp in ipairs(speakers) do
local playing = false
local queued = 0
pcall(function()
if type(sp.speakIsPlaying) == "function" then
playing = sp.speakIsPlaying() and true or false
end
if type(sp.speakQueueSize) == "function" then
queued = tonumber(sp.speakQueueSize()) or 0
end
end)
if playing or queued > 0 then return true end
end
return false
end
local function playHqAudioItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
return
end
local playSeq = State.interruptSeq
local payload = item.audioData
if type(payload) ~= "string" or #payload == 0 then return end
local format = tostring(item.audioFormat or "file"):lower()
local controller = Peripherals.speakers[1]
local called = false
if controller then
local ok, result = pcall(function()
if format == "wav" and type(controller.speakWavAll) == "function" then
return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
elseif type(controller.speakFileAll) == "function" then
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result ~= false then
called = true
end
end
if not called then
for _, sp in ipairs(Peripherals.speakers) do
local ok, result = pcall(function()
if format == "wav" and type(sp.speakWav) == "function" then
return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
elseif type(sp.speakFile) == "function" then
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result ~= false then
called = true
end
end
end
if not called then
Utils.log("ERROR", "HQ speaker API not available for format " .. format)
return
end
while areHqSpeakersBusy(Peripherals.speakers) do
if shouldStop(playSeq) then
Audio.stop("current")
return
end
if not waitForInterruptibleDelay(playSeq, 0.1) then
Audio.stop("current")
return
end
end
end
function Audio.playDfpwmItem(item)
if not Peripherals.speakers or #Peripherals.speakers == 0 then
Utils.log("WARN", "No speakers found; cannot play audio")
@@ -646,23 +737,42 @@ function Audio.playDfpwmItem(item)
end
local playSeq = State.interruptSeq
local source = item.dfpwmData
local source = item.audioData
-- Support both static string data and streaming functions
if type(source) == "table" and source.read then
source = source.read
end
local ok, iter = pcall(aukit.stream.dfpwm, source, 48000, 1, false)
if not ok then
Utils.log("ERROR", "Failed to create audio stream: " .. tostring(iter))
return
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
for chunk in iter do
local decoder = dfpwm.make_decoder()
while true do
if shouldStop(playSeq) then break end
if not playChunkOnSpeakers(chunk[1], CONFIG.AUDIO_VOLUME, playSeq) then break end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decoder(chunk)
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
break
end
end
end
end
function Audio.playItem(item)
local format = tostring(item.audioFormat or "dfpwm"):lower()
if format == "wav" or format == "file" then
return playHqAudioItem(item)
end
return Audio.playDfpwmItem(item)
end
function Audio.playerLoop()
@@ -674,7 +784,7 @@ function Audio.playerLoop()
State.currentPlaying = item
State.currentPriority = tonumber(item.priority) or 0
local ok, err = pcall(Audio.playDfpwmItem, item)
local ok, err = pcall(Audio.playItem, item)
if not ok then
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
end
@@ -927,6 +1037,23 @@ function Network.syncFiles()
end
function Network.messageLoop()
local function finishAudioRx()
if not State.audioRx then return end
local rx = State.audioRx
if rx.stream then
rx.stream.finish()
else
Audio.enqueueItem({
audioData = table.concat(rx.chunks or {}),
audioFormat = rx.audioFormat or "wav",
priority = rx.priority,
source = rx.source,
audioId = rx.audioId
})
end
State.audioRx = nil
end
while true do
local event, url, msg = os.pullEvent()
if event == "websocket_message" and url == CONFIG.CLOUD_URL then
@@ -937,6 +1064,7 @@ function Network.messageLoop()
pcall(State.audioRx.stream.finish)
end
local priority = tonumber(data.priority) or 0
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
local interruptMode = tostring(data.interruptMode or "")
if interruptMode == "" then
if priority >= 2 then interruptMode = "all"
@@ -950,27 +1078,31 @@ function Network.messageLoop()
Audio.stop("current", priority)
end
local stream = Audio.createStream()
State.audioRx = {
audioId = data.audioId,
expectedBytes = tonumber(data.byteLength) or 0,
receivedBytes = 0,
stream = stream,
priority = priority,
source = data.source or data.category
source = data.source or data.category,
audioFormat = audioFormat
}
-- Start playing immediately by enqueuing the stream
if audioFormat == "dfpwm" then
local stream = Audio.createStream()
State.audioRx.stream = stream
Audio.enqueueItem({
dfpwmData = stream,
audioData = stream,
audioFormat = audioFormat,
priority = priority,
source = State.audioRx.source,
audioId = data.audioId
})
else
State.audioRx.chunks = {}
end
elseif data.type == "audio_end" then
if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
State.audioRx.stream.finish()
State.audioRx = nil
finishAudioRx()
end
elseif data.type == "audio_stop" then
local scope = tostring(data.scope or "")
@@ -1001,14 +1133,19 @@ function Network.messageLoop()
-- Memory protection: limit to 10MB per audio item
if State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
Utils.log("ERROR", "Audio item too large, discarding")
if State.audioRx.stream then
State.audioRx.stream.finish()
end
State.audioRx = nil
else
if State.audioRx.stream then
State.audioRx.stream.push(msg)
else
table.insert(State.audioRx.chunks, msg)
end
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
State.audioRx.stream.finish()
State.audioRx = nil
finishAudioRx()
end
end
end
+2 -6
View File
@@ -1,6 +1,4 @@
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/commit/e4b4fa5cdb2bc42279fd87098fef0d27a90e23d5/station_broadcast.lua"
local function fetch(url)
if not http then
@@ -66,9 +64,7 @@ local function downloadTo(url, path)
end
local targets = {
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }
}
for _, t in ipairs(targets) do