From 0335e245e1625c394866275139d123d17cb40bf8 Mon Sep 17 00:00:00 2001 From: HenryDu8133 <813367384@qq.com> Date: Mon, 17 Aug 2026 18:34:15 +0800 Subject: [PATCH] =?UTF-8?q?fix(audio):=20=E4=BF=AE=E5=A4=8D=E7=BB=88?= =?UTF-8?q?=E7=82=B9=E7=AB=99/=E4=B8=8B=E4=B8=80=E7=AB=99=E6=BC=8F?= =?UTF-8?q?=E6=92=AD=20+=20=E5=B9=BF=E6=92=AD=E5=8D=A1=E9=A1=BF=E8=B7=B3?= =?UTF-8?q?=E9=9F=B3=20+=20=E9=9F=B3=E9=A2=91wav=E5=A4=9A=E5=8F=98?= =?UTF-8?q?=E4=BD=93=E6=9F=A5=E6=89=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 findAudioFileSmart 多变体模糊匹配: ch-/ja- 语言前缀剥离, CE- 线路前缀剥离, 连字符/下划线/空格互转, station-/line-/voice-/stn- 通用前缀补全, 每 token 生成 16+ 文件名变体匹配, 彻底解决 ch-ce-13 命不中 13.wav/station-ch-ce13.wav 漏播 - 新增 getAudioDirIndex 目录文件索引缓存 (mtime+size 失效), 避免每次查找递归读盘 - 补全 buildConcatListFile 致命缺失函数, 之前 local HLS 模式直接 ReferenceError - 重写 clearLocalHlsStreamDir: 只清理 30min 前的旧 TS, 新广播使用唯一前缀 b__-seg-.ts, 新旧广播段永不重名, 修复新广播删/覆盖旧段导致的卡/跳音 - server.listen 启动时预执行 ensureSilenceWav, 避免第一次广播阻塞生成静音段开头卡 - handleApproaching/Arrival/Departure 全链路结构化 DEBUG 日志: Station-Audio-Code (destCode/nextCode 提取链) -> audio tokens 完整序列 -> Sequence resolve 清单 (searched 目录+missing 列表), 以后再漏播四行日志 100% 定位根因 - resolveStationAudioCode 3 级兜底链不变, 确保 destCode/nextCode 正确进入 audio seq --- CloudServer/server.js | 216 +++++++++++++++++++++++++++++++++++------- station_broadcast.lua | 2 +- 2 files changed, 181 insertions(+), 37 deletions(-) diff --git a/CloudServer/server.js b/CloudServer/server.js index 298e1fd..9bd3d5b 100644 --- a/CloudServer/server.js +++ b/CloudServer/server.js @@ -1963,6 +1963,109 @@ function ensureSilenceWav() { execSync(`ffmpeg -y -v quiet -f lavfi -i anullsrc=r=48000:cl=mono -t 1 -c:a pcm_s16le "${silence1sWavPath}"`); } +function listFileBasenamesLower(dir) { + try { + const files = fs.readdirSync(dir, { withFileTypes: true }); + const m = new Map(); + for (const ent of files) { + if (!ent.isFile()) continue; + const full = path.join(dir, ent.name); + const lower = ent.name.toLowerCase(); + m.set(lower, full); + const stem = lower.replace(/\.[^.]+$/, ''); + if (!m.has(stem)) m.set(stem, full); + // Also map hyphen/underscore/space variants + const stemNoDash = stem.replace(/[\s\-_]+/g, ''); + if (stemNoDash !== stem && !m.has(stemNoDash)) m.set(stemNoDash, full); + const stemDash = stem.replace(/[\s_]+/g, '-'); + if (stemDash !== stem && !m.has(stemDash)) m.set(stemDash, full); + const stemUnder = stem.replace(/[\s\-]+/g, '_'); + if (stemUnder !== stem && !m.has(stemUnder)) m.set(stemUnder, full); + } + return m; + } catch { + return new Map(); + } +} + +const _audioFileIndexCache = { version: 0, byDir: new Map() }; +function getAudioDirIndex(dir) { + if (!dir || !fs.existsSync(dir)) return new Map(); + let stat; + try { stat = fs.statSync(dir); } catch { return new Map(); } + const key = `${dir}|${stat.mtimeMs}|${stat.size}`; + const cached = _audioFileIndexCache.byDir.get(key); + if (cached) return cached; + const built = listFileBasenamesLower(dir); + _audioFileIndexCache.byDir.set(key, built); + if (_audioFileIndexCache.byDir.size > 100) _audioFileIndexCache.byDir.clear(); + return built; +} + +function findAudioFileSmart(dirs, tokenBase, ext) { + const wantExt = (ext || '.wav').toLowerCase(); + const cleaned = String(tokenBase || '').trim(); + if (!cleaned) return null; + + const lower = cleaned.toLowerCase(); + const lowerStem = lower.replace(/\.[^.]+$/, ''); + const variants = new Set(); + const baseSeeds = new Set([ + lowerStem, + lowerStem.replace(/[\s_]+/g, '-'), + lowerStem.replace(/[\s\-]+/g, '_'), + lowerStem.replace(/[\s\-_]+/g, '') + ]); + for (const s of Array.from(baseSeeds)) { + const mLang = s.match(/^([a-z]{2})-(.+)$/); + if (mLang) { + const L = mLang[1]; + const inner = mLang[2]; + baseSeeds.add(inner); // strip lang prefix + baseSeeds.add(`${L}${inner.replace(/[\-_]+/g, '')}`); // lang + dashless-inner (chce14) + const compactInner = inner.replace(/[\-_]+/g, ''); + if (compactInner !== inner) { + baseSeeds.add(`${L}-${compactInner}`); // ch-ce14 from ch-ce-14 + baseSeeds.add(`${L}_${compactInner}`); + } + const m2 = inner.match(/^([a-z]+)[\-_](.+)$/); + if (m2) { + baseSeeds.add(`${L}-${m2[2]}`); // ja-13 from ja-ce-13 + baseSeeds.add(`${L}_${m2[2]}`); + baseSeeds.add(m2[2]); // 13 + baseSeeds.add(inner.replace(/[\-_]+/g, '')); // ce13 + } + } + } + + for (const b of baseSeeds) { + variants.add(`${b}${wantExt}`); + variants.add(b); + // Common prefix variants + const prefixes = ['station-', 'line-', 'voice-', 's-', 'l-', 'v-', 'stn-']; + for (const p of prefixes) { + variants.add(`${p}${b}${wantExt}`); + variants.add(`${p}${b}`); + } + } + + for (const dir of dirs) { + if (!dir || !fs.existsSync(dir)) continue; + const idx = getAudioDirIndex(dir); + if (idx.size === 0) continue; + for (const v of variants) { + const found = idx.get(v); + if (found && fs.existsSync(found)) return found; + } + } + + for (const dir of dirs) { + const direct = findFileCaseInsensitive(dir, `${lowerStem}${wantExt}`); + if (direct) return direct; + } + return null; +} + function resolveStationAudioCode(rawCode, rawName) { const code1 = String(rawCode || '').trim().toLowerCase().replace(/_/g, '-'); const name = String(rawName || '').trim(); @@ -1971,16 +2074,13 @@ function resolveStationAudioCode(rawCode, rawName) { const v = String(c).trim().toLowerCase().replace(/_/g, '-'); return v && v !== 'unk' && v !== 'unknown' ? v : null; }; - // Level 1: raw destCode (already provided) let result = pick(code1); let levelHit = result ? 1 : 0; - // Level 2: extract from destination name string (parens / dash / pattern) if (!result && name) { const extracted = getStationCodeFromName(name); result = pick(extracted); if (result) levelHit = 2; } - // Level 3: fuzzy match station name to registered stations and use their code if (!result && name) { const reg = findStationByFlexibleName(name); if (reg && reg.code) { @@ -2072,33 +2172,34 @@ function findFileCaseInsensitive(dir, filename) { return null; } -function resolveSequenceFiles(seq) { +function resolveSequenceFiles(seq, label) { ensureSilenceWav(); + const dirsToSearch = [ + audioStationDir, + audioLineDir, + audioVoiceDir, + ...getBeepAudioDirs() + ]; const files = []; + const missing = []; + const resolved = []; for (const item of seq) { if (item === '__DELAY_1__') { files.push(silence1sWavPath); + resolved.push(`${item}=`); continue; } - const dirsToSearch = [ - audioStationDir, - audioLineDir, - audioVoiceDir, - ...getBeepAudioDirs() - ]; - - let found = null; - for (const dir of dirsToSearch) { - found = findFileCaseInsensitive(dir, `${item}.wav`); - if (found) break; - } + const found = findAudioFileSmart(dirsToSearch, item, '.wav'); if (found) { files.push(found); + resolved.push(`${item}=${found}`); } else { - log('WARN', `HLS audio missing on server (wav required): ${item}`); + const dirList = dirsToSearch.filter(Boolean).join(', '); + log('WARN', `HLS audio missing on server (wav required): token="${item}" searched=[${dirList}]`); + missing.push(item); } } @@ -2106,11 +2207,16 @@ function resolveSequenceFiles(seq) { for (let i = 0; i < HLS_TAIL_SILENCE_SEC; i++) files.push(silence1sWavPath); } + const l = label ? `[${label}] ` : ''; + log((missing.length === 0 ? 'DEBUG' : 'WARN'), + `${l}Sequence resolve: tokens_total=${seq.length} resolved=${files.length - (HLS_TAIL_SILENCE_SEC > 0 ? HLS_TAIL_SILENCE_SEC : 0)} missing=${missing.length} missing_list=[${missing.join(', ')}]` + + (resolved.length <= 16 ? ` resolved=[${resolved.join(' | ')}]` : '')); + return files; } -function buildSequenceWavFile(seq) { - const files = resolveSequenceFiles(seq); +function buildSequenceWavFile(seq, label) { + const files = resolveSequenceFiles(seq, label || 'cc-wav-build'); if (files.length === 0) return null; const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`; @@ -2142,7 +2248,7 @@ function buildSequenceWavFile(seq) { function runFfmpegRtmpPush(streamName, seq) { const state = getOrCreateHlsStreamState(streamName); - const files = resolveSequenceFiles(seq); + const files = resolveSequenceFiles(seq, `rtmp:${streamName}`); if (files.length === 0) { state.status = 'idle'; state.lastError = 'No playable wav files in sequence'; @@ -2314,24 +2420,57 @@ function runFfmpegRtmpPush(streamName, seq) { }); } -function clearLocalHlsStreamDir(streamName) { +function buildConcatListFile(seq, label) { + const files = resolveSequenceFiles(seq, label || 'concat-list'); + if (!Array.isArray(files) || files.length === 0) return null; + const id = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + const listPath = path.join(tempBroadcastDir, `concat-${id}.txt`); + const lines = ['ffconcat version 1.0']; + for (const f of files) { + const abs = path.isAbsolute(f) ? f : path.resolve(f); + lines.push(`file '${abs.replace(/'/g, "'\\''")}'`); + } + try { + fs.writeFileSync(listPath, lines.join('\n') + '\n', 'utf8'); + setTimeout(() => { try { fs.unlinkSync(listPath); } catch {} }, 30 * 60 * 1000); + return listPath; + } catch (e) { + log('ERROR', `buildConcatListFile failed: ${e.message}`); + return null; + } +} + +function clearOldHlsStreamDirFiles(streamName) { const dir = path.join(localHlsDir, streamName); - if (!fs.existsSync(dir)) return dir; - const entries = fs.readdirSync(dir, { withFileTypes: true }); + if (!fs.existsSync(dir)) { return; } + const now = Date.now(); + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; } for (const ent of entries) { - const p = path.join(dir, ent.name); - if (ent.isDirectory()) { - fs.rmSync(p, { recursive: true, force: true }); - } else { + if (!ent.isFile()) continue; + const name = ent.name; + if (!/^(b_[0-9]+_.*|index\.m3u8)$/.test(name) && !/\.ts$/.test(name)) continue; + const p = path.join(dir, name); + let mt = 0; + try { mt = fs.statSync(p).mtimeMs; } catch { continue; } + if (now - mt > 30 * 60 * 1000) { try { fs.unlinkSync(p); } catch {} } } - return dir; +} + +function clearLocalHlsStreamDir(streamName) { + const dir = path.join(localHlsDir, streamName); + if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true }); + try { clearOldHlsStreamDirFiles(streamName); } catch {} + const ts = Date.now(); + const prefix = `b_${ts}_${Math.random().toString(16).slice(2, 6)}`; + return { dir, prefix }; } function runFfmpegLocalHlsBuild(streamName, seq) { const state = getOrCreateHlsStreamState(streamName); - const listPath = buildConcatListFile(seq); + const listPath = buildConcatListFile(seq, streamName); if (!listPath) { state.status = 'idle'; state.lastError = 'No playable wav files in sequence'; @@ -2344,11 +2483,11 @@ function runFfmpegLocalHlsBuild(streamName, seq) { state.status = 'building'; state.lastStartAt = Date.now(); - const outDir = clearLocalHlsStreamDir(streamName); + const { dir: outDir, prefix } = clearLocalHlsStreamDir(streamName); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); const playlistPath = path.join(outDir, 'index.m3u8'); - const segPattern = path.join(outDir, 'seg-%05d.ts'); + const segPattern = path.join(outDir, `${prefix}-seg-%05d.ts`); const args = [ '-hide_banner', '-loglevel', @@ -2723,7 +2862,7 @@ function handleTrainApproaching(ws, data) { const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK'); const destCode = resolveStationAudioCode(data.destCode, data.destination) || 'UNK'; - log('INFO', `Train ${trainId} approaching ${stationName}. Broadcasting pre-arrival...`); + log('INFO', `Train ${trainId} approaching ${stationName}. Broadcasting pre-arrival... destCode=${destCode} platform=${finalPlatform}`); ws.send(JSON.stringify({ type: 'train_approaching_ack', @@ -2736,11 +2875,12 @@ function handleTrainApproaching(ws, data) { seq.push('ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3'); seq.push('ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}`, 'ch-fc4'); + log('DEBUG', `[Approaching] audio tokens for ${stationName} (train=${trainId}): [${seq.join(', ')}]`); enqueueHlsPush(stationName, seq); const audioId = `approaching-${stationCode}-${Date.now()}`; - const built = buildSequenceWavFile(seq); + const built = buildSequenceWavFile(seq, `approaching:${stationName}`); if (built) { log('INFO', `Pre-arrival audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`); enqueueStationAudioTask(stationName, ws, { @@ -2804,11 +2944,12 @@ function handleTrainAlert(ws, data) { } else { return; } + log('DEBUG', `[Alert] audio tokens for ${stationName} (train=${trainId}): [${seq.join(', ')}]`); enqueueHlsPush(stationName, seq); const audioId = `alert-${stationCode}-${Date.now()}`; - const built = buildSequenceWavFile(seq); + const built = buildSequenceWavFile(seq, `alert:${stationName}`); if (built) { log('INFO', `Alert audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`); enqueueStationAudioTask(stationName, ws, { @@ -2888,11 +3029,12 @@ function handleTrainArrival(ws, data) { `ch-${finalPlatform}`, 'ch-zt', `ch-${lineCode}`, 'ch-to', `ch-${destCode}`, 'ch-warn' ); } + log('DEBUG', `[Arrival] audio tokens for ${stationName} (train=${trainId} destCode=${destCode} nextCode=${finalNextCode || 'none'}): [${seq.join(', ')}]`); enqueueHlsPush(stationName, seq); const audioId = `arrival-${stationCode}-${Date.now()}`; - const built = buildSequenceWavFile(seq); + const built = buildSequenceWavFile(seq, `arrival:${stationName}`); if (built) { enqueueStationAudioTask(stationName, ws, { audioFormat: built.audioFormat, @@ -2956,11 +3098,12 @@ function handleTrainDeparture(ws, data) { 'ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}` ); } + log('DEBUG', `[Departure] audio tokens for ${stationName} (train=${trainId} destCode=${destCode} nextCode=${finalNextCode || 'none'}): [${seq.join(', ')}]`); enqueueHlsPush(stationName, seq); const audioId = `departure-${stationCode}-${Date.now()}`; - const built = buildSequenceWavFile(seq); + const built = buildSequenceWavFile(seq, `departure:${stationName}`); if (built) { enqueueStationAudioTask(stationName, ws, { audioFormat: built.audioFormat, @@ -4277,6 +4420,7 @@ app.use((err, req, res, next) => { server.listen(PORT, () => { loadData(); + try { ensureSilenceWav(); } catch (e) { log('WARN', `ensureSilenceWav failed at startup: ${e.message}`); } if (!audioVersion) audioVersion = String(Date.now()); if (!Array.isArray(versions) || versions.length === 0) { const snap = snapshotFiles(); diff --git a/station_broadcast.lua b/station_broadcast.lua index 83a1a6f..09d0d89 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -1,6 +1,6 @@ -- 1. Configuration & Constants -local APP_VERSION_TEXT = "v1.0.111" +local APP_VERSION_TEXT = "v1.0.112" local CONFIG = { CLOUD_URL = "ws://stabadmin.fse-media.group",