Files
CC-StationBroadcast/station_broadcast.lua
T
Henry_Du 218aaabb50 fix(station): remove arrival and departure preview audio flow
Delete the station-side arrival/departure announcement requests and drop the server handlers for train arrival, train departure, and play_sequence messages so preview audio no longer interrupts live music.
2026-07-31 21:53:12 +08:00

1436 lines
50 KiB
Lua

-- ============================================================================
-- 1. Configuration & Constants
-- ============================================================================
local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group",
API_URL = "http://stabadmin.fse-media.group",
SYNC_INTERVAL = 30,
AUDIO_BASE = "Audio/",
AUDIO_STATION = "Audio/Station/",
DISK_AUDIO_BASE = "disk/Audio/",
DISK_AUDIO_STATION = "disk/Audio/Station/",
AUDIO_VOLUME = 3,
DEBUG = true
}
local dfpwm = require "cc.audio.dfpwm"
local COLORS = {
INFO = colors.white,
WARN = colors.yellow,
WARNING = colors.yellow,
ERROR = colors.red,
DEBUG = colors.cyan,
SUCCESS = colors.green
}
-- ============================================================================
-- 2. State Management
-- ============================================================================
local State = {
ws = nil,
stationName = "Unknown",
stationCode = "UNK",
stationPlatform = "1",
registered = false,
stationList = {},
currentTrain = nil,
logs = {},
logScrollOffset = 0,
audioRx = nil,
audioQueue = {},
currentPlaying = nil,
currentPriority = -1,
interruptSeq = 0
}
-- ============================================================================
-- 3. Peripherals Setup
-- ============================================================================
local Peripherals = {}
function Peripherals.init()
-- Station Peripheral
Peripherals.station = peripheral.wrap("back", "train_station")
or error("Train station peripheral required on 'back'")
-- Monitors
local monitors = { peripheral.find("monitor") }
if #monitors == 0 then error("At least one monitor is required") end
-- Assign monitors
Peripherals.primaryMonitor = monitors[1]
Peripherals.primaryMonitor.setTextScale(0.5)
-- Speakers
Peripherals.speakers = { peripheral.find("speaker") }
end
-- ============================================================================
-- 4. Utility Functions
-- ============================================================================
local Utils = {}
function Utils.log(level, message)
local timestamp = os.date("%H:%M:%S")
local entryText = string.format("[%s][%s] %s", timestamp, level, message)
local entry = { level = level, text = entryText }
table.insert(State.logs, 1, entry)
if #State.logs > 100 then table.remove(State.logs) end
-- If user is scrolling history, increment offset to stay on same items
if State.logScrollOffset > 0 then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 1))
end
end
function Utils.getStationCode(name)
if not name then return nil end
name = tostring(name)
local code = nil
local codeInParens = name:match("[%[%(]([%w%d%-%/%s]+)[%]%)]")
if codeInParens then
local firstPart = codeInParens:match("[^/]+")
if firstPart then
code = firstPart:match("^%s*(.-)%s*$")
end
end
if not code then
code = name:match("[%w%d]+%-[%w%d]+") or name:match("[%a]+[%d]+")
end
if not code then
local cleanName = name:match("^%s*(.-)%s*$"):lower()
if State.stationList and State.stationList[cleanName] then
code = cleanName
end
end
if code then
return tostring(code):lower() -- Always return lowercase for consistent comparison
end
return nil
end
function Utils.getPlatformNumber(name, default)
if not name then return default end
local p = name:match("[Pp]latform%s*([%d]+[a-zA-Z]?)") or name:match("[Tt]rack%s*([%d]+[a-zA-Z]?)")
if p then return p end
return name:match("([%d]+[a-zA-Z]?)$") or default
end
function Utils.getBroadcastStationCode(name)
if not name then return "UNK" end
name = name:match("^%s*(.-)%s*$")
name = name:gsub("%s+", "_")
if name == "" then return "UNK" end
return name
end
function Utils.isSameTrain(id1, id2)
if not id1 or not id2 then return false end
if id1 == id2 then return true end
-- Consist Car Match: "CK 1 01" vs "CK 1 02"
local function getConsist(id)
local clean = id:gsub("^%[.-%]%s*", "")
local p = {}
for s in clean:gmatch("%S+") do table.insert(p, s) end
if #p >= 2 then
if #p[1] <= 4 and tonumber(p[2]) then
return p[1] .. " " .. p[2]
end
end
return p[1] or clean
end
local c1 = getConsist(id1)
local c2 = getConsist(id2)
if c1 == c2 then return true end
-- Fuzzy match: remove type prefix and compare content
local _, name1, num1 = Utils.parseTrainId(id1)
local _, name2, num2 = Utils.parseTrainId(id2)
-- If both have numbers, compare name and number
if num1 ~= "" and num2 ~= "" then
return name1 == name2 and num1 == num2
end
-- Fallback to comparing just the main content/name
return name1 == name2
end
function Utils.parseTrainId(trainId)
if not trainId then return "unknown", "" end
-- Generic match: [Type]Content
local typeStr, content = trainId:match("^%[(.-)%](.+)$")
if typeStr then
-- Check for Express types
if typeStr == "Ltd.Exp" or typeStr == "Express" then
local name, num = content:match("^(.+) No%.(%d+)$")
if name then
return "express", name, num
else
return "express", content, ""
end
elseif typeStr == "Local" then
return "regular", "Local", content
else
return "regular", typeStr, content
end
end
return "unknown", "Unknown", trainId
end
function Utils.getDestination(schedule)
if not schedule then return "Unknown" end
-- Handle both wrapped and unwrapped schedule structure
local entries = schedule.entries or (type(schedule) == "table" and schedule)
if not entries or type(entries) ~= "table" then return "Unknown" end
if #entries == 0 then
local count = 0
for _ in pairs(entries) do count = count + 1 end
if count > 0 then
local list = {}
for k, v in pairs(entries) do
local n = tonumber(k)
if n then list[n] = v end
end
entries = list
end
end
local dest = "Unknown"
local maxIdx = 0
for k, _ in pairs(entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
for i = 1, maxIdx do
local entry = entries[i]
if entry then
local inst = entry.instruction
if inst then
if inst.id == "create:destination" then
dest = (inst.data and inst.data.text) or inst.text or dest
end
if inst.id == "create:station" then
local sName = (inst.data and inst.data.text) or inst.text
if sName then
dest = sName
end
end
end
end
end
return dest
end
function Utils.getBaseName(name)
if not name then return "" end
name = tostring(name):lower()
name = name:gsub("%s*[Pp]latform%s*[%d%a]+", ""):gsub("%s*[Tt]rack%s*[%d%a]+", "")
name = name:gsub("%s*[%[%(].-[%]%)]%s*", " ")
name = name:gsub("%s+%d+$", "")
return name:match("^%s*(.-)%s*$")
end
function Utils.isFuzzyMatch(str, pattern)
if not str or not pattern then return false end
str = tostring(str):lower():match("^%s*(.-)%s*$")
pattern = tostring(pattern):lower():match("^%s*(.-)%s*$")
if str == pattern then return true end
-- 1. Handle Create Mod Wildcards (*)
if pattern:find("*") then
local p = pattern:gsub("([%^%$%(%)%%%.%[%]%+%-%?])", "%%%1")
p = p:gsub("%%%*", ".*")
local ok, res = pcall(function() return str:match("^" .. p .. "$") end)
if ok and res then return true end
end
-- 2. Base name comparison
local b1 = Utils.getBaseName(str)
local b2 = Utils.getBaseName(pattern)
if b1 ~= "" and b2 ~= "" then
if b1 == b2 then return true end
-- Substring match for base names
if #b1 >= 4 and #b2 >= 4 then
if b1:find(b2, 1, true) or b2:find(b1, 1, true) then
return true
end
end
end
-- 3. Alphanumeric only comparison
local a1 = str:gsub("[^%w%d]", "")
local a2 = pattern:gsub("[^%w%d]", "")
if a1 ~= "" and a2 ~= "" and (a1 == a2 or a1:find(a2, 1, true) or a2:find(a1, 1, true)) then
return true
end
return false
end
function Utils.getStopTime(schedule, stationName)
if not schedule or not schedule.entries or not stationName then return nil end
local target = tostring(stationName):lower():match("^%s*(.-)%s*$")
if target == "" then return nil end
local targetCode = Utils.getStationCode(target)
local targetBase = Utils.getBaseName(target)
local targetAlpha = target:gsub("[^%w%d]", "")
Utils.log("DEBUG", string.format("STATION TARGET: '%s' (Code: %s, Base: %s, Alpha: %s)", target, tostring(targetCode), targetBase, targetAlpha))
local function extractTime(data)
if not data then return nil end
local val = tonumber(data.value) or tonumber(data.seconds) or tonumber(data.s)
or tonumber(data.time) or tonumber(data.duration) or tonumber(data.delay)
local unit = tonumber(data.time_unit) or tonumber(data.unit) or 1
if val then
if unit == 0 then return val / 20
elseif unit == 1 then return val
elseif unit == 2 then return val * 60
end
return val
end
local ticks = tonumber(data.ticks) or tonumber(data.tick)
if ticks then return ticks / 20 end
return nil
end
local function findTimeInConditions(conditions)
if not conditions then return nil end
for i, item in ipairs(conditions) do
if type(item) == "table" then
local cid = tostring(item.id or ""):lower()
if cid:find("delay") or cid:find("wait") or cid:find("idle") or cid:find("pause") then
local s = extractTime(item.data)
if s and s > 0 then
Utils.log("DEBUG", string.format("Found Time in Condition [%d]: %.1fs (ID: %s)", i, s, cid))
return s
end
end
for k, sub in pairs(item) do
if type(sub) == "table" then
local s = findTimeInConditions({sub})
if s then return s end
end
end
end
end
return nil
end
local maxIdx = 0
if schedule.entries then
for k, _ in pairs(schedule.entries) do
if type(k) == "number" and k > maxIdx then maxIdx = k end
end
end
Utils.log("DEBUG", string.format("--- Schedule Scan Start (MaxIdx: %d) ---", maxIdx))
local matchFoundIndex = nil
for i = 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local function scanRecursive(obj, depth)
if depth > 5 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local entryText = v:lower()
local entryCode = Utils.getStationCode(entryText)
local entryAlpha = entryText:gsub("[^%w%d]", "")
local entryBase = Utils.getBaseName(entryText)
Utils.log("DEBUG", string.format(" [%d] SCAN '%s': '%s' (Code: %s)", i, k, entryText, tostring(entryCode)))
local isMatch = (target == entryText)
or (targetCode and entryCode and targetCode == entryCode)
or (targetAlpha ~= "" and entryAlpha ~= "" and (targetAlpha == entryAlpha or targetAlpha:find(entryAlpha, 1, true) or entryAlpha:find(targetAlpha, 1, true)))
or (targetBase ~= "" and entryBase ~= "" and (targetBase == entryBase or targetBase:find(entryBase, 1, true) or entryBase:find(targetBase, 1, true)))
or Utils.isFuzzyMatch(target, entryText)
if isMatch then
Utils.log("DEBUG", string.format(" >>> MATCH FOUND AT ENTRY %d (via field '%s')", i, k))
return true
end
elseif type(v) == "table" then
if scanRecursive(v, depth + 1) then return true end
end
end
return false
end
if scanRecursive(entry, 0) then
matchFoundIndex = i
local s = findTimeInConditions(entry.conditions)
if s then
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM CONDITIONS: %.1fs", s))
return s
end
if entry.instruction and entry.instruction.data then
local s = extractTime(entry.instruction.data)
if s then
Utils.log("DEBUG", string.format(" >>> TIME RESOLVED FROM INSTRUCTION: %.1fs", s))
return s
end
end
break
end
end
end
if matchFoundIndex then
Utils.log("DEBUG", string.format(" >>> Station matched at [%d], scanning ahead for time...", matchFoundIndex))
for i = matchFoundIndex + 1, maxIdx do
local entry = schedule.entries[i]
if entry then
local isOtherStation = false
local function checkIsStation(obj, depth)
if depth > 2 or not obj then return false end
for k, v in pairs(obj) do
if type(v) == "string" then
local lk = tostring(k):lower()
if lk:find("text") or lk:find("station") or lk:find("destination") then return true end
elseif type(v) == "table" then
if checkIsStation(v, depth + 1) then return true end
end
end
return false
end
if checkIsStation(entry, 0) then break end
local s = findTimeInConditions(entry.conditions)
if s then
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER ENTRY [%d]: %.1fs", i, s))
return s
end
if entry.instruction and entry.instruction.data then
local s = extractTime(entry.instruction.data)
if s then
Utils.log("DEBUG", string.format(" >>> TIME FOUND AT LATER INSTRUCTION [%d]: %.1fs", i, s))
return s
end
end
end
end
end
Utils.log("DEBUG", "--- Scan End: No Time Resolved ---")
return nil
end
-- ============================================================================
-- 5. Audio System
-- ============================================================================
local Audio = {}
function Audio.createStream()
local chunks = {}
local finished = false
local stream = {}
stream.push = function(chunk)
if type(chunk) ~= "string" then return end
table.insert(chunks, chunk)
os.queueEvent("audio_chunk")
end
stream.finish = function()
finished = true
os.queueEvent("audio_chunk")
end
stream.read = function()
while #chunks == 0 do
if finished then return nil end
os.pullEvent("audio_chunk")
end
return table.remove(chunks, 1)
end
return stream
end
function Audio.loadStationList()
State.stationList = {}
if not fs.exists("list.txt") then
Utils.log("WARN", "list.txt not found")
return
end
local file = fs.open("list.txt", "r")
if file then
local count = 0
local line = file.readLine()
while line do
line = line:gsub("%s+$", "") -- Trim trailing \r\n and spaces
if line ~= "" then
local codes, files = line:match("^([^=]+)==(.+)$")
if codes and files then
local list = {}
for f in files:gmatch("[^,]+") do
local cleanName = f:match("^%s*(.-)%s*$"):gsub("%.wav$", ""):gsub("%.dfpwm$", "")
if cleanName ~= "" then
table.insert(list, cleanName)
end
end
-- Map all codes to this list
local allCodes = {}
for code in codes:gmatch("[^/]+") do
local cleanCode = code:match("^%s*(.-)%s*$"):lower()
if cleanCode ~= "" then
table.insert(allCodes, cleanCode)
State.stationList[cleanCode] = list
end
end
if #allCodes > 1 then
local fullRawCode = codes:match("^%s*(.-)%s*$"):lower()
State.stationList[fullRawCode] = list
local dashCode = table.concat(allCodes, "-"):lower()
State.stationList[dashCode] = list
end
count = count + 1
end
end
line = file.readLine()
end
file.close()
Utils.log("INFO", string.format("Station list loaded: %d entries", count))
end
end
function Audio.enqueueDfpwm(dfpwmData)
if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
Audio.enqueueItem({ audioData = dfpwmData, audioFormat = "dfpwm", priority = 1 })
end
function Audio.enqueueItem(item)
if type(item) ~= "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
local p = tonumber(State.audioQueue[idx].priority) or 0
if item.priority > p then break end
idx = idx + 1
end
table.insert(State.audioQueue, idx, item)
os.queueEvent("audio_queue")
end
function Audio.stop(mode, flushBelow)
State.interruptSeq = State.interruptSeq + 1
if State.audioRx and State.audioRx.stream then
pcall(State.audioRx.stream.finish)
end
State.audioRx = nil
if Peripherals.speakers then
for _, sp in ipairs(Peripherals.speakers) do
pcall(function()
if type(sp.speakStop) == "function" then sp.speakStop()
else sp.stop() end
end)
end
end
if mode == "all" then
State.audioQueue = {}
elseif flushBelow ~= nil then
local minP = tonumber(flushBelow) or 0
local kept = {}
for _, it in ipairs(State.audioQueue) do
local p = tonumber(it.priority) or 0
if p >= minP then table.insert(kept, it) end
end
State.audioQueue = kept
end
os.queueEvent("audio_interrupt", State.interruptSeq)
end
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]
if controller and type(controller.playAudioAll) == "function" then
while true do
if shouldStop(playSeq) then return false end
local ok = false
pcall(function()
ok = controller.playAudioAll(samples, volume)
end)
if ok then break end
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
end
else
local pending = {}
for _, sp in ipairs(Peripherals.speakers) do
local name = peripheral.getName(sp)
if name then pending[name] = sp end
end
while next(pending) do
if shouldStop(playSeq) then return false end
for name, sp in pairs(pending) do
local ok = false
pcall(function()
ok = sp.playAudio(samples, volume)
end)
if ok then pending[name] = nil end
end
if next(pending) then
local ev = { os.pullEvent() }
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
return false
end
end
end
end
local waiting = {}
for _, sp in ipairs(Peripherals.speakers) do
local name = peripheral.getName(sp)
if name then waiting[name] = true end
end
while next(waiting) do
if shouldStop(playSeq) then return false end
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] == "speaker_audio_empty" and ev[2] and waiting[ev[2]] then
waiting[ev[2]] = nil
end
end
return true
end
local function decodePcmS8Chunk(chunk)
if type(chunk) ~= "string" or #chunk == 0 then return nil end
local samples = {}
for i = 1, #chunk do
local b = string.byte(chunk, i)
if b >= 128 then
b = b - 256
end
samples[i] = b
end
return samples
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 downloadAudioBytes(url)
if type(url) ~= "string" or url == "" then return nil, nil, "empty url" end
local ok, resp = pcall(function()
return http.get(url, nil, true)
end)
if not ok or not resp then
return nil, nil, "http.get failed"
end
local body = resp.readAll()
resp.close()
if type(body) ~= "string" or #body == 0 then
return nil, nil, "empty response body"
end
local lower = url:lower()
local ext = lower:match("%.([%a%d]+)$")
return body, ext or "file", nil
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()
if format == "stream" then
local bytes, ext, err = downloadAudioBytes(payload)
if not bytes then
Utils.log("ERROR", "Failed to download stream audio: " .. tostring(err))
return
end
Utils.log("INFO", "Downloaded stream audio: " .. tostring(#bytes) .. " bytes")
payload = bytes
if ext == "wav" then format = "wav"
elseif ext == "mp3" then format = "mp3"
elseif ext == "ogg" then format = "ogg"
else format = "file" end
end
local controller = Peripherals.speakers[1]
local called = false
local loopEnabled = item.loop and true or false
if controller then
pcall(function()
if type(controller.setLoopingAll) == "function" then
controller.setLoopingAll(loopEnabled)
end
end)
local ok, result = pcall(function()
if format == "hls" and type(controller.speakHLSAll) == "function" then
return controller.speakHLSAll(payload, CONFIG.AUDIO_VOLUME)
elseif format == "wav" and type(controller.speakWavAll) == "function" then
return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
elseif format == "mp3" and type(controller.speakMp3All) == "function" then
return controller.speakMp3All(payload, CONFIG.AUDIO_VOLUME)
elseif format == "ogg" and type(controller.speakOggAll) == "function" then
return controller.speakOggAll(payload, CONFIG.AUDIO_VOLUME)
elseif type(controller.speakFileAll) == "function" then
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result then
called = true
end
end
if not called then
for _, sp in ipairs(Peripherals.speakers) do
pcall(function()
if type(sp.setLooping) == "function" then
sp.setLooping(loopEnabled)
end
end)
local ok, result = pcall(function()
if format == "hls" and type(sp.speakHLS) == "function" then
return sp.speakHLS(payload, CONFIG.AUDIO_VOLUME)
elseif format == "wav" and type(sp.speakWav) == "function" then
return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
elseif format == "mp3" and type(sp.speakMp3) == "function" then
return sp.speakMp3(payload, CONFIG.AUDIO_VOLUME)
elseif format == "ogg" and type(sp.speakOgg) == "function" then
return sp.speakOgg(payload, CONFIG.AUDIO_VOLUME)
elseif type(sp.speakFile) == "function" then
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
end
end)
if ok and result then
called = true
end
end
end
if not called then
Utils.log("ERROR", "HQ speaker did not accept audio 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")
return
end
local playSeq = State.interruptSeq
local source = item.audioData
-- Support both static string data and streaming functions
if type(source) == "table" and source.read then
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
local decoder = dfpwm.make_decoder()
while true do
if shouldStop(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.playPcmItem(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 source = item.audioData
if type(source) == "table" and source.read then
source = source.read
end
if type(source) ~= "function" then
local data = source
source = function()
local chunk = data
data = nil
return chunk
end
end
while true do
if shouldStop(playSeq) then break end
local chunk = source()
if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then
local samples = decodePcmS8Chunk(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" or format == "stream" or format == "hls" then
return playHqAudioItem(item)
elseif format == "pcm" then
return Audio.playPcmItem(item)
end
return Audio.playDfpwmItem(item)
end
function Audio.playerLoop()
while true do
if #State.audioQueue == 0 then
os.pullEvent("audio_queue")
else
local item = table.remove(State.audioQueue, 1)
State.currentPlaying = item
State.currentPriority = tonumber(item.priority) or 0
local ok, err = pcall(Audio.playItem, item)
if not ok then
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
end
State.currentPlaying = nil
State.currentPriority = -1
end
end
end
function Utils.getChineseNumberAudio(val)
local num = tonumber(val)
if num then
local seq = {}
if num < 10 then
table.insert(seq, "ch-" .. num)
elseif num == 10 then
table.insert(seq, "ch-10")
elseif num > 10 and num < 20 then
table.insert(seq, "ch-10")
table.insert(seq, "ch-" .. (num % 10))
elseif num >= 20 and num < 100 then
local ten = math.floor(num / 10)
local unit = num % 10
table.insert(seq, "ch-" .. ten)
table.insert(seq, "ch-10")
if unit > 0 then
table.insert(seq, "ch-" .. unit)
end
else
table.insert(seq, "ch-" .. num)
end
return seq
else
-- Handle Alphanumeric (e.g., "1A", "2B")
local digit, letter = tostring(val):match("^(%d+)([a-zA-Z])$")
if digit and letter then
local seq = Utils.getChineseNumberAudio(digit)
table.insert(seq, letter:upper()) -- e.g. "A", "B"
return seq
end
return {tostring(val)}
end
end
function Utils.getStationAudio(code, langPrefix)
if not code then return nil end
local files = State.stationList[code]
if files then
local p = langPrefix:lower()
for _, f in ipairs(files) do
if f:lower():find("^" .. p) then
return f
end
end
end
-- Fallback to hardcoded pattern
return langPrefix .. code
end
-- ============================================================================
-- 6. Network System
-- ============================================================================
local Network = {}
function Network.connect()
if State.ws then return end
Utils.log("INFO", "Connecting to cloud...")
State.ws = http.websocket(CONFIG.CLOUD_URL)
if State.ws then
Utils.log("SUCCESS", "Connected")
Network.register()
else
Utils.log("ERROR", "Connection failed")
end
end
function Network.send(data)
if State.ws then
local ok, err = pcall(function()
State.ws.send(textutils.serializeJSON(data))
end)
if not ok then
Utils.log("ERROR", "Failed to send WS message: " .. tostring(err))
end
end
end
function Network.register()
State.stationName = (Peripherals.station.getStationName() or "Unknown"):match("^%s*(.-)%s*$") -- Trim
-- Try to get a clean code from name, fallback to broadcast format
State.stationCode = Utils.getStationCode(State.stationName) or Utils.getBroadcastStationCode(State.stationName)
State.stationPlatform = Utils.getPlatformNumber(State.stationName, "1")
Network.send({
type = "station_register",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform
})
end
function Network.syncFiles()
-- Sync only config file (list.txt) periodically
while true do
local resp = http.get(CONFIG.API_URL .. "/list.txt")
if not resp then
resp = http.get(CONFIG.API_URL .. "/uploads/list.txt")
end
if resp then
local content = resp.readAll()
resp.close()
if content and #content > 0 then
local currentList = ""
if fs.exists("list.txt") then
local f = fs.open("list.txt", "r")
currentList = f.readAll()
f.close()
end
if currentList ~= content then
local f = fs.open("list.txt", "w")
f.write(content)
f.close()
Audio.loadStationList()
end
end
end
os.sleep(CONFIG.SYNC_INTERVAL)
end
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,
loop = rx.loop and true or false,
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
local ok, data = pcall(textutils.unserializeJSON, msg)
if ok and type(data) == "table" then
if data.type == "audio_start" then
if State.audioRx and State.audioRx.stream then
pcall(State.audioRx.stream.finish)
end
local priority = tonumber(data.priority) or 0
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil
local interruptMode = tostring(data.interruptMode or "")
if interruptMode == "" then
if priority >= 2 then interruptMode = "all"
elseif priority >= 1 then interruptMode = "ifLower"
else interruptMode = "none" end
end
if interruptMode == "all" then
Audio.stop("all")
elseif interruptMode == "ifLower" and priority > (State.currentPriority or -1) then
Audio.stop("current", priority)
end
State.audioRx = {
audioId = data.audioId,
expectedBytes = tonumber(data.byteLength) or 0,
receivedBytes = 0,
priority = priority,
loop = data.loop and true or false,
source = data.source or data.category,
audioFormat = audioFormat
}
if (audioFormat == "stream" or audioFormat == "hls") and streamUrl and streamUrl ~= "" then
Audio.enqueueItem({
audioData = streamUrl,
audioFormat = audioFormat,
priority = priority,
loop = State.audioRx.loop,
source = State.audioRx.source,
audioId = data.audioId
})
State.audioRx = nil
elseif audioFormat == "dfpwm" or audioFormat == "pcm" then
local stream = Audio.createStream()
State.audioRx.stream = stream
if audioFormat == "pcm" then
Utils.log("INFO", "Receiving live PCM audio stream")
end
Audio.enqueueItem({
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
finishAudioRx()
end
elseif data.type == "audio_stop" then
local scope = tostring(data.scope or "")
if scope == "music" then
local kept = {}
for _, it in ipairs(State.audioQueue) do
if tostring(it.source or "") ~= "music" then
table.insert(kept, it)
end
end
State.audioQueue = kept
if State.audioRx and tostring(State.audioRx.source or "") == "music" then
State.audioRx = nil
end
if State.currentPlaying and tostring(State.currentPlaying.source or "") == "music" then
Audio.stop("current")
end
else
local mode = tostring(data.mode or "current")
local flushBelow = data.flushBelow
Audio.stop(mode, flushBelow)
end
end
else
if State.audioRx then
-- Memory protection: limit to 10MB per audio item
local isLiveStream = State.audioRx.stream and (tonumber(State.audioRx.expectedBytes) or 0) <= 0
if (not isLiveStream) and 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
finishAudioRx()
end
end
end
end
elseif event == "websocket_closed" then
Utils.log("WARN", "WS Closed")
State.ws = nil
State.audioRx = nil -- Clear partial download
os.sleep(5)
end
end
end
function Network.heartbeat()
while true do
if State.ws then
Network.send({
type = "heartbeat",
stationName = State.stationName,
stationCode = State.stationCode,
stationPlatform = State.stationPlatform
})
end
os.sleep(10)
end
end
-- ============================================================================
-- 7. Display System
-- ============================================================================
local Display = {}
function Display.render()
local mon = Peripherals.primaryMonitor
local w, h = mon.getSize()
mon.clear()
mon.setCursorPos(1,1)
mon.setTextColor(COLORS.INFO)
mon.write("STATION: " .. State.stationName)
mon.setCursorPos(1,2)
mon.write("TIME: " .. os.date("%H:%M:%S"))
local function classifyAudioSource(source, priority)
local s = tostring(source or ""):lower()
if s == "train" or s == "station" or s == "broadcast" then return "Station Announcement" end
if s ~= "" then return "Special Announcement(" .. tostring(source) .. ")" end
if (tonumber(priority) or 0) >= 2 then return "Special Announcement" end
return "Special Announcement"
end
local function trunc(str)
str = tostring(str or "")
if #str > w then return str:sub(1, w) end
return str
end
mon.setCursorPos(1,3)
if State.currentPlaying then
local p = tonumber(State.currentPlaying.priority) or 0
local kind = classifyAudioSource(State.currentPlaying.source, p)
mon.setTextColor(COLORS.SUCCESS)
mon.write(trunc("AUDIO: " .. kind .. " Playing (P=" .. tostring(p) .. ", Q=" .. tostring(#State.audioQueue) .. ")"))
elseif State.audioRx then
local p = tonumber(State.audioRx.priority) or 0
local kind = classifyAudioSource(State.audioRx.source, p)
local rx = tonumber(State.audioRx.receivedBytes) or 0
local total = tonumber(State.audioRx.expectedBytes) or 0
local pct = ""
if total > 0 then pct = " " .. tostring(math.floor((rx / total) * 100)) .. "%" end
mon.setTextColor(COLORS.DEBUG)
mon.write(trunc("AUDIO: " .. kind .. " Receiving" .. pct .. " (Q=" .. tostring(#State.audioQueue) .. ")"))
else
mon.setTextColor(COLORS.INFO)
mon.write(trunc("AUDIO: FREE (Q=" .. tostring(#State.audioQueue) .. ")"))
end
-- Current Train
if State.currentTrain then
mon.setCursorPos(1,4)
mon.setTextColor(COLORS.SUCCESS)
mon.write("CURRENT TRAIN:")
mon.setTextColor(COLORS.INFO)
mon.setCursorPos(1,5)
mon.write(State.currentTrain.id)
mon.setCursorPos(1,6)
mon.write("Dest: " .. State.currentTrain.dest)
-- Countdown
if State.currentTrain.departureTime then
local remaining = math.max(0, State.currentTrain.departureTime - os.epoch("utc") / 1000)
mon.setCursorPos(1, 8)
if remaining > 0 then
mon.setTextColor(COLORS.WARNING)
mon.write(string.format("Departing in: %02d:%02d", math.floor(remaining/60), math.floor(remaining%60)))
else
mon.setTextColor(COLORS.ERROR)
mon.write("Departing...")
end
end
else
mon.setCursorPos(1,4)
mon.setTextColor(COLORS.WARNING)
mon.write("NO TRAIN AT PLATFORM")
end
-- Logs
local y = h - 3
for i = 1, 3 do
local logIdx = i + State.logScrollOffset
local entry = State.logs[logIdx]
if entry then
mon.setCursorPos(1, y + i - 1)
mon.setTextColor(COLORS[entry.level] or colors.white)
mon.write(entry.text:sub(1, w))
end
end
end
function Display.renderTerminal()
local w, h = term.getSize()
-- Header
term.setCursorPos(1, 1)
term.setBackgroundColor(colors.gray)
term.setTextColor(colors.white)
term.clearLine()
local title = " Station Broadcast Console (" .. State.stationName .. ") "
term.setCursorPos(math.floor((w - #title)/2) + 1, 1)
term.write(title)
term.setBackgroundColor(colors.black)
term.setCursorPos(1, 2)
term.setTextColor(colors.gray)
term.write(string.rep("-", w))
-- Logs
local logStart = 3
local maxLogs = h - logStart + 1
for i = 1, maxLogs do
local logIdx = i + State.logScrollOffset
term.setCursorPos(1, logStart + i - 1)
term.clearLine()
local entry = State.logs[logIdx]
if entry then
term.setTextColor(COLORS[entry.level] or colors.white)
term.write(entry.text:sub(1, w - 1))
end
end
-- Scroll Indicator
if State.logScrollOffset > 0 then
term.setCursorPos(w, logStart)
term.setTextColor(colors.yellow)
term.write("^")
end
if #State.logs > State.logScrollOffset + maxLogs then
term.setCursorPos(w, h)
term.setTextColor(colors.yellow)
term.write("v")
end
end
-- ============================================================================
-- 7.5 Input Handling
-- ============================================================================
local Input = {}
function Input.listener()
Utils.log("DEBUG", "Input listener started")
local monitorName = peripheral.getName(Peripherals.primaryMonitor)
while true do
local event, param1, param2, param3 = os.pullEvent()
local _, h = term.getSize()
local maxLogs = h - 5 + 1
if event == "mouse_scroll" then
local direction = param1
if direction == -1 then -- Scroll Up
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
elseif direction == 1 then -- Scroll Down
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
elseif event == "monitor_scroll" and param1 == monitorName then
local direction = param2
if direction == -1 then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
elseif direction == 1 then
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
elseif event == "monitor_touch" and param1 == monitorName then
-- Simple touch to scroll: top half of log area scrolls up, bottom half scrolls down
local _, monH = Peripherals.primaryMonitor.getSize()
local y = param3
if y >= monH - 3 then
if y == monH - 3 then -- Top line of log area
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - 3))
elseif y == monH then -- Bottom line of log area
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
end
Display.renderTerminal()
Display.render()
end
elseif event == "key" then
if param1 == keys.up then
State.logScrollOffset = math.min(State.logScrollOffset + 1, math.max(0, #State.logs - maxLogs))
elseif param1 == keys.down then
State.logScrollOffset = math.max(0, State.logScrollOffset - 1)
elseif param1 == keys.pageUp then
State.logScrollOffset = math.min(State.logScrollOffset + 10, math.max(0, #State.logs - maxLogs))
elseif param1 == keys.pageDown then
State.logScrollOffset = math.max(0, State.logScrollOffset - 10)
elseif param1 == keys.home then
State.logScrollOffset = 0
elseif param1 == keys["end"] then
State.logScrollOffset = math.max(0, #State.logs - maxLogs)
end
Display.renderTerminal()
Display.render()
end
end
end
-- ============================================================================
-- 8. Main Logic
-- ============================================================================
local Main = {}
function Main.loop()
while true do
local hasTrain = Peripherals.station.isTrainPresent()
if hasTrain then
if not State.currentTrain then
-- Train just arrived
local id = Peripherals.station.getTrainName()
local schedule = Peripherals.station.getSchedule()
local dest = Utils.getDestination(schedule)
local now = os.epoch("utc") / 1000
local stationTime = Utils.getStopTime(schedule, State.stationName) or 60
State.currentTrain = {
id = id,
trainType = Utils.parseTrainId(id),
schedule = schedule,
dest = dest,
arrivalTime = now,
departureTime = now + stationTime,
platform = Utils.getPlatformNumber(State.stationName, "1")
}
end
else
if State.currentTrain then
State.currentTrain = nil
end
end
Display.render()
Display.renderTerminal()
os.sleep(0.5)
end
end
-- ============================================================================
-- 9. Bootstrap
-- ============================================================================
Peripherals.init()
Audio.loadStationList()
parallel.waitForAny(
function()
while true do
if not State.ws then Network.connect() end
os.sleep(5)
end
end,
Audio.playerLoop,
Network.messageLoop,
Network.heartbeat,
Network.syncFiles,
Input.listener,
Main.loop
)