-- ============================================================================ -- 1. Configuration & Constants -- ============================================================================ local APP_VERSION_TEXT = "v1.0.2" 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 readU16LE(data, offset) local a, b = string.byte(data, offset, offset + 1) if not a or not b then return nil end return a + b * 256 end local function readS16LE(data, offset) local val = readU16LE(data, offset) if not val then return nil end if val >= 32768 then val = val - 65536 end return val end local function readU32LE(data, offset) local a, b, c, d = string.byte(data, offset, offset + 3) if not a or not b or not c or not d then return nil end return a + b * 256 + c * 65536 + d * 16777216 end local function clampSample8(val) if val > 127 then return 127 end if val < -128 then return -128 end return val end local function decodeWavChunkToSamples(chunk) if type(chunk) ~= "string" or #chunk < 44 then return nil, "wav chunk too short" end if chunk:sub(1, 4) ~= "RIFF" or chunk:sub(9, 12) ~= "WAVE" then return nil, "invalid wav header" end local offset = 13 local audioFormat, channels, sampleRate, bitsPerSample local dataOffset, dataSize while offset + 7 <= #chunk do local chunkId = chunk:sub(offset, offset + 3) local chunkSize = readU32LE(chunk, offset + 4) if not chunkSize then break end local payloadOffset = offset + 8 if chunkId == "fmt " and chunkSize >= 16 then audioFormat = readU16LE(chunk, payloadOffset) channels = readU16LE(chunk, payloadOffset + 2) sampleRate = readU32LE(chunk, payloadOffset + 4) bitsPerSample = readU16LE(chunk, payloadOffset + 14) elseif chunkId == "data" then dataOffset = payloadOffset dataSize = chunkSize break end offset = payloadOffset + chunkSize if chunkSize % 2 == 1 then offset = offset + 1 end end if audioFormat ~= 1 then return nil, "unsupported wav format " .. tostring(audioFormat) end if channels ~= 1 and channels ~= 2 then return nil, "unsupported channels " .. tostring(channels) end if bitsPerSample ~= 8 and bitsPerSample ~= 16 then return nil, "unsupported bits " .. tostring(bitsPerSample) end if not dataOffset or not dataSize or dataOffset + dataSize - 1 > #chunk then return nil, "invalid wav data chunk" end local frameSize = channels * (bitsPerSample / 8) if frameSize <= 0 then return nil, "invalid frame size" end local samples = {} local sampleIndex = 1 local endOffset = dataOffset + dataSize - frameSize for pos = dataOffset, endOffset, frameSize do local mixed = 0 for ch = 0, channels - 1 do local sampleOffset = pos + ch * (bitsPerSample / 8) local sampleValue if bitsPerSample == 8 then local raw = string.byte(chunk, sampleOffset) sampleValue = (raw or 128) - 128 else sampleValue = readS16LE(chunk, sampleOffset) or 0 if sampleValue >= 0 then sampleValue = math.floor(sampleValue / 256) else sampleValue = math.ceil(sampleValue / 256) end end mixed = mixed + sampleValue end samples[sampleIndex] = clampSample8(math.floor(mixed / channels)) sampleIndex = sampleIndex + 1 end return samples, nil, { channels = channels, bitsPerSample = bitsPerSample, sampleRate = sampleRate or 0 } 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 local function pushWavChunkToSpeakers(payload, playSeq) local controller = Peripherals.speakers and Peripherals.speakers[1] or nil if controller and type(controller.speakWavAll) == "function" then local retryCount = 0 while true do if shouldStop(playSeq) then return false end local ok = false local queueSize = -1 local isPlaying = false pcall(function() ok = controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME) if type(controller.speakQueueSize) == "function" then queueSize = tonumber(controller.speakQueueSize()) or -1 end if type(controller.speakIsPlaying) == "function" then isPlaying = controller.speakIsPlaying() and true or false end end) if ok then Utils.log("INFO", string.format("WAV chunk accepted: bytes=%d queue=%d playing=%s", #payload, queueSize, tostring(isPlaying))) return true end retryCount = retryCount + 1 if retryCount == 1 or retryCount % 20 == 0 then Utils.log("WARN", string.format("WAV chunk waiting: bytes=%d queue=%d playing=%s retries=%d", #payload, queueSize, tostring(isPlaying), retryCount)) end if not waitForInterruptibleDelay(playSeq, 0.05) then return false end end end local pending = {} for _, sp in ipairs(Peripherals.speakers or {}) do local name = peripheral.getName(sp) if name then pending[name] = sp end end local retryCount = 0 while next(pending) do if shouldStop(playSeq) then return false end for name, sp in pairs(pending) do local ok = false pcall(function() if type(sp.speakWav) == "function" then ok = sp.speakWav(payload, CONFIG.AUDIO_VOLUME) end end) if ok then pending[name] = nil end end if next(pending) then retryCount = retryCount + 1 if retryCount == 1 or retryCount % 20 == 0 then local pendingCount = 0 for _ in pairs(pending) do pendingCount = pendingCount + 1 end Utils.log("WARN", string.format("WAV chunk waiting on %d speakers: bytes=%d retries=%d", pendingCount, #payload, retryCount)) end else Utils.log("INFO", string.format("WAV chunk accepted by fallback speakers: bytes=%d", #payload)) end if next(pending) and not waitForInterruptibleDelay(playSeq, 0.05) then return false end end return true 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.playWavStreamItem(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 Audio.stop("current") return end local chunk = source() if chunk == nil then break end if type(chunk) == "string" and #chunk > 0 then local samples, err, meta = decodeWavChunkToSamples(chunk) if samples and #samples > 0 then Utils.log("INFO", string.format( "Decoded WAV chunk: bytes=%d samples=%d rate=%d ch=%d bits=%d", #chunk, #samples, tonumber(meta and meta.sampleRate) or 0, tonumber(meta and meta.channels) or 0, tonumber(meta and meta.bitsPerSample) or 0 )) if not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then Audio.stop("current") return end else Utils.log("WARN", "WAV chunk decode failed, falling back to HQ path: " .. tostring(err)) if not pushWavChunkToSpeakers(chunk, playSeq) then Audio.stop("current") return end end end end while areHqSpeakersBusy(Peripherals.speakers) do if shouldStop(playSeq) then Audio.stop("current") return end if not waitForInterruptibleDelay(playSeq, 0.05) then Audio.stop("current") return 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) elseif format == "wav-stream" then return Audio.playWavStreamItem(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, stationVersion = APP_VERSION_TEXT }) 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" or audioFormat == "wav-stream" then local stream = Audio.createStream() State.audioRx.stream = stream if audioFormat == "pcm" then Utils.log("INFO", "Receiving live PCM audio stream") elseif audioFormat == "wav-stream" then Utils.log("INFO", "Receiving live WAV chunk 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, stationVersion = APP_VERSION_TEXT }) 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 .. " " .. APP_VERSION_TEXT) 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 .. " / " .. APP_VERSION_TEXT .. ") " 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() Utils.log("INFO", "StationBroadcast version " .. APP_VERSION_TEXT) 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 )