feat(audio): 实现流式WAV解码器并优化音频播放逻辑

将应用版本更新至v1.0.48;新增WAV头部解析与流式解码逻辑,替换原有的单块解码方案;添加扬声器speakWav兼容性检测,无支持设备时输出错误日志;优化WAV解码错误处理与日志输出,仅首次打印音频格式信息。
This commit is contained in:
2026-08-03 09:37:00 +08:00
parent e89b82a8b4
commit 5fb81e3998
+177 -13
View File
@@ -1,6 +1,6 @@
-- 1. Configuration & Constants -- 1. Configuration & Constants
local APP_VERSION_TEXT = "v1.0.47" local APP_VERSION_TEXT = "v1.0.48"
local CONFIG = { local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group", CLOUD_URL = "ws://stabadmin.fse-media.group",
@@ -1350,6 +1350,152 @@ local function decodeWavChunkToSamples(chunk)
} }
end 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) local function areHqSpeakersBusy(speakers)
for _, sp in ipairs(speakers) do for _, sp in ipairs(speakers) do
local playing = false local playing = false
@@ -1515,6 +1661,18 @@ local function pushWavChunkToSpeakers(payload, playSeq)
end end
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 = {} local pending = {}
for _, sp in ipairs(Peripherals.speakers or {}) do for _, sp in ipairs(Peripherals.speakers or {}) do
local name = peripheral.getName(sp) local name = peripheral.getName(sp)
@@ -1670,6 +1828,8 @@ function Audio.playWavStreamItem(item)
Utils.log("INFO", "Live WAV prebuffer ready: chunks=" .. tostring(streamObj.count())) Utils.log("INFO", "Live WAV prebuffer ready: chunks=" .. tostring(streamObj.count()))
end end
local wavDecoder = createWavStreamDecoder()
while true do while true do
if shouldStop(playSeq) then if shouldStop(playSeq) then
Audio.stop("current") Audio.stop("current")
@@ -1679,24 +1839,28 @@ function Audio.playWavStreamItem(item)
local chunk = source() local chunk = source()
if chunk == nil then break end if chunk == nil then break end
if type(chunk) == "string" and #chunk > 0 then 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 if samples and #samples > 0 then
Utils.log("INFO", string.format( if meta and not wavDecoder.logged then
"Decoded WAV chunk: bytes=%d samples=%d rate=%d ch=%d bits=%d", wavDecoder.logged = true
#chunk, Utils.log("INFO", string.format(
#samples, "WAV stream header: rate=%d ch=%d bits=%d",
tonumber(meta and meta.sampleRate) or 0, tonumber(meta and meta.sampleRate) or 0,
tonumber(meta and meta.channels) or 0, tonumber(meta and meta.channels) or 0,
tonumber(meta and meta.bitsPerSample) or 0 tonumber(meta and meta.bitsPerSample) or 0
)) ))
end
local playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false) local playOk = playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq, false)
if not playOk then if not playOk then
Audio.stop("current") Audio.stop("current")
return return
end end
else elseif err and err ~= "need header" then
Utils.log("WARN", "WAV chunk decode failed, falling back to HQ path: " .. tostring(err)) Utils.log("ERROR", "WAV stream decode failed: " .. tostring(err))
if not pushWavChunkToSpeakers(chunk, playSeq) then Audio.stop("current")
return
elseif err == "need header" then
if not waitForInterruptibleDelay(playSeq, 0.01) then
Audio.stop("current") Audio.stop("current")
return return
end end