diff --git a/station_broadcast.lua b/station_broadcast.lua index 05a3de7..d2a206c 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -1,6 +1,6 @@ -- 1. Configuration & Constants -local APP_VERSION_TEXT = "v1.0.47" +local APP_VERSION_TEXT = "v1.0.48" local CONFIG = { CLOUD_URL = "ws://stabadmin.fse-media.group", @@ -1350,6 +1350,152 @@ local function decodeWavChunkToSamples(chunk) } end +local function parseWavHeader(buf) + if type(buf) ~= "string" or #buf < 44 then + return nil, nil, "wav header too short" + end + if buf:sub(1, 4) ~= "RIFF" or buf:sub(9, 12) ~= "WAVE" then + return nil, nil, "invalid wav header" + end + + local offset = 13 + local audioFormat, channels, sampleRate, bitsPerSample + local dataOffset, dataSize + + while offset + 7 <= #buf do + local chunkId = buf:sub(offset, offset + 3) + local chunkSize = readU32LE(buf, offset + 4) + if not chunkSize then break end + local payloadOffset = offset + 8 + if chunkId == "fmt " and chunkSize >= 16 then + audioFormat = readU16LE(buf, payloadOffset) + channels = readU16LE(buf, payloadOffset + 2) + sampleRate = readU32LE(buf, payloadOffset + 4) + bitsPerSample = readU16LE(buf, 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, nil, "unsupported wav format " .. tostring(audioFormat) + end + if channels ~= 1 and channels ~= 2 then + return nil, nil, "unsupported channels " .. tostring(channels) + end + if bitsPerSample ~= 8 and bitsPerSample ~= 16 then + return nil, nil, "unsupported bits " .. tostring(bitsPerSample) + end + if not dataOffset or not dataSize then + return nil, nil, "wav missing data chunk" + end + + return { + channels = channels, + bitsPerSample = bitsPerSample, + sampleRate = sampleRate or 0, + dataSize = dataSize + }, dataOffset, nil +end + +local function decodePcmBytesToSamples(pcm, channels, bitsPerSample) + if type(pcm) ~= "string" or #pcm == 0 then return {} end + local frameSize = channels * (bitsPerSample / 8) + if frameSize <= 0 then return {} end + local usableLen = #pcm - (#pcm % frameSize) + if usableLen <= 0 then return {} end + + local samples = {} + local sampleIndex = 1 + local endOffset = usableLen - frameSize + 1 + + for pos = 1, 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(pcm, sampleOffset) + sampleValue = (raw or 128) - 128 + else + sampleValue = readS16LE(pcm, 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 +end + +local function createWavStreamDecoder() + local state = { + header = nil, + headerParsed = false, + pending = "", + frameSize = 0, + logged = false + } + + local function decode(chunk) + if type(chunk) ~= "string" or #chunk == 0 then return {}, nil, state.header end + + local buf = state.pending .. chunk + state.pending = "" + + if not state.headerParsed then + local header, dataOffset, err = parseWavHeader(buf) + if err then + state.pending = buf + if #state.pending > 128 * 1024 then + return nil, err, nil + end + return {}, "need header", nil + end + + state.header = header + state.headerParsed = true + state.frameSize = (tonumber(header.channels) or 1) * ((tonumber(header.bitsPerSample) or 16) / 8) + buf = buf:sub(dataOffset) + end + + local frameSize = tonumber(state.frameSize) or 0 + if frameSize <= 0 then return nil, "invalid frame size", state.header end + + local usableLen = #buf - (#buf % frameSize) + local usable = "" + if usableLen > 0 then + usable = buf:sub(1, usableLen) + end + local rest = buf:sub(usableLen + 1) + if rest and rest ~= "" then + state.pending = rest + end + + local samples = decodePcmBytesToSamples( + usable, + tonumber(state.header and state.header.channels) or 1, + tonumber(state.header and state.header.bitsPerSample) or 16 + ) + + return samples, nil, state.header + end + + return { decode = decode } +end + local function areHqSpeakersBusy(speakers) for _, sp in ipairs(speakers) do local playing = false @@ -1515,6 +1661,18 @@ local function pushWavChunkToSpeakers(payload, playSeq) end end + local hasFallback = false + for _, sp in ipairs(Peripherals.speakers or {}) do + if type(sp.speakWav) == "function" then + hasFallback = true + break + end + end + if not hasFallback then + Utils.log("ERROR", "No speaker supports speakWav/speakWavAll for WAV chunk fallback") + return false + end + local pending = {} for _, sp in ipairs(Peripherals.speakers or {}) do local name = peripheral.getName(sp) @@ -1670,6 +1828,8 @@ function Audio.playWavStreamItem(item) Utils.log("INFO", "Live WAV prebuffer ready: chunks=" .. tostring(streamObj.count())) end + local wavDecoder = createWavStreamDecoder() + while true do if shouldStop(playSeq) then Audio.stop("current") @@ -1679,24 +1839,28 @@ function Audio.playWavStreamItem(item) local chunk = source() if chunk == nil then break end if type(chunk) == "string" and #chunk > 0 then - local samples, err, meta = decodeWavChunkToSamples(chunk) + local samples, err, meta = wavDecoder.decode(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 meta and not wavDecoder.logged then + wavDecoder.logged = true + Utils.log("INFO", string.format( + "WAV stream header: rate=%d ch=%d bits=%d", + tonumber(meta and meta.sampleRate) or 0, + tonumber(meta and meta.channels) or 0, + tonumber(meta and meta.bitsPerSample) or 0 + )) + end local playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false) if not playOk 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 + elseif err and err ~= "need header" then + Utils.log("ERROR", "WAV stream decode failed: " .. tostring(err)) + Audio.stop("current") + return + elseif err == "need header" then + if not waitForInterruptibleDelay(playSeq, 0.01) then Audio.stop("current") return end