fix(audio): 修复终点站/下一站漏播 + 广播卡顿跳音 + 音频wav多变体查找

- 新增 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_<ts>_<rand>-seg-<n>.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
This commit is contained in:
2026-08-17 18:34:15 +08:00
parent 1179130656
commit 0335e245e1
2 changed files with 181 additions and 37 deletions
+180 -36
View File
@@ -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}"`); 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) { function resolveStationAudioCode(rawCode, rawName) {
const code1 = String(rawCode || '').trim().toLowerCase().replace(/_/g, '-'); const code1 = String(rawCode || '').trim().toLowerCase().replace(/_/g, '-');
const name = String(rawName || '').trim(); const name = String(rawName || '').trim();
@@ -1971,16 +2074,13 @@ function resolveStationAudioCode(rawCode, rawName) {
const v = String(c).trim().toLowerCase().replace(/_/g, '-'); const v = String(c).trim().toLowerCase().replace(/_/g, '-');
return v && v !== 'unk' && v !== 'unknown' ? v : null; return v && v !== 'unk' && v !== 'unknown' ? v : null;
}; };
// Level 1: raw destCode (already provided)
let result = pick(code1); let result = pick(code1);
let levelHit = result ? 1 : 0; let levelHit = result ? 1 : 0;
// Level 2: extract from destination name string (parens / dash / pattern)
if (!result && name) { if (!result && name) {
const extracted = getStationCodeFromName(name); const extracted = getStationCodeFromName(name);
result = pick(extracted); result = pick(extracted);
if (result) levelHit = 2; if (result) levelHit = 2;
} }
// Level 3: fuzzy match station name to registered stations and use their code
if (!result && name) { if (!result && name) {
const reg = findStationByFlexibleName(name); const reg = findStationByFlexibleName(name);
if (reg && reg.code) { if (reg && reg.code) {
@@ -2072,33 +2172,34 @@ function findFileCaseInsensitive(dir, filename) {
return null; return null;
} }
function resolveSequenceFiles(seq) { function resolveSequenceFiles(seq, label) {
ensureSilenceWav(); ensureSilenceWav();
const dirsToSearch = [
audioStationDir,
audioLineDir,
audioVoiceDir,
...getBeepAudioDirs()
];
const files = []; const files = [];
const missing = [];
const resolved = [];
for (const item of seq) { for (const item of seq) {
if (item === '__DELAY_1__') { if (item === '__DELAY_1__') {
files.push(silence1sWavPath); files.push(silence1sWavPath);
resolved.push(`${item}=<silence>`);
continue; continue;
} }
const dirsToSearch = [ const found = findAudioFileSmart(dirsToSearch, item, '.wav');
audioStationDir,
audioLineDir,
audioVoiceDir,
...getBeepAudioDirs()
];
let found = null;
for (const dir of dirsToSearch) {
found = findFileCaseInsensitive(dir, `${item}.wav`);
if (found) break;
}
if (found) { if (found) {
files.push(found); files.push(found);
resolved.push(`${item}=${found}`);
} else { } 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); 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; return files;
} }
function buildSequenceWavFile(seq) { function buildSequenceWavFile(seq, label) {
const files = resolveSequenceFiles(seq); const files = resolveSequenceFiles(seq, label || 'cc-wav-build');
if (files.length === 0) return null; if (files.length === 0) return null;
const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`; const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`;
@@ -2142,7 +2248,7 @@ function buildSequenceWavFile(seq) {
function runFfmpegRtmpPush(streamName, seq) { function runFfmpegRtmpPush(streamName, seq) {
const state = getOrCreateHlsStreamState(streamName); const state = getOrCreateHlsStreamState(streamName);
const files = resolveSequenceFiles(seq); const files = resolveSequenceFiles(seq, `rtmp:${streamName}`);
if (files.length === 0) { if (files.length === 0) {
state.status = 'idle'; state.status = 'idle';
state.lastError = 'No playable wav files in sequence'; 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); const dir = path.join(localHlsDir, streamName);
if (!fs.existsSync(dir)) return dir; if (!fs.existsSync(dir)) { return; }
const entries = fs.readdirSync(dir, { withFileTypes: true }); const now = Date.now();
let entries;
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
for (const ent of entries) { for (const ent of entries) {
const p = path.join(dir, ent.name); if (!ent.isFile()) continue;
if (ent.isDirectory()) { const name = ent.name;
fs.rmSync(p, { recursive: true, force: true }); if (!/^(b_[0-9]+_.*|index\.m3u8)$/.test(name) && !/\.ts$/.test(name)) continue;
} else { 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 {} 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) { function runFfmpegLocalHlsBuild(streamName, seq) {
const state = getOrCreateHlsStreamState(streamName); const state = getOrCreateHlsStreamState(streamName);
const listPath = buildConcatListFile(seq); const listPath = buildConcatListFile(seq, streamName);
if (!listPath) { if (!listPath) {
state.status = 'idle'; state.status = 'idle';
state.lastError = 'No playable wav files in sequence'; state.lastError = 'No playable wav files in sequence';
@@ -2344,11 +2483,11 @@ function runFfmpegLocalHlsBuild(streamName, seq) {
state.status = 'building'; state.status = 'building';
state.lastStartAt = Date.now(); state.lastStartAt = Date.now();
const outDir = clearLocalHlsStreamDir(streamName); const { dir: outDir, prefix } = clearLocalHlsStreamDir(streamName);
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true }); if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
const playlistPath = path.join(outDir, 'index.m3u8'); 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 = [ const args = [
'-hide_banner', '-hide_banner',
'-loglevel', '-loglevel',
@@ -2723,7 +2862,7 @@ function handleTrainApproaching(ws, data) {
const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK'); const lineCode = data.lineCode || (stationCode ? stationCode.split('-')[0] : 'UNK');
const destCode = resolveStationAudioCode(data.destCode, data.destination) || '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({ ws.send(JSON.stringify({
type: 'train_approaching_ack', 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('ja-fc1', `ja-${finalPlatform}`, 'ja-fc2', `ja-${destCode}`, 'ja-fc3');
seq.push('ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}`, 'ch-fc4'); 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); enqueueHlsPush(stationName, seq);
const audioId = `approaching-${stationCode}-${Date.now()}`; const audioId = `approaching-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq); const built = buildSequenceWavFile(seq, `approaching:${stationName}`);
if (built) { if (built) {
log('INFO', `Pre-arrival audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`); log('INFO', `Pre-arrival audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`);
enqueueStationAudioTask(stationName, ws, { enqueueStationAudioTask(stationName, ws, {
@@ -2804,11 +2944,12 @@ function handleTrainAlert(ws, data) {
} else { } else {
return; return;
} }
log('DEBUG', `[Alert] audio tokens for ${stationName} (train=${trainId}): [${seq.join(', ')}]`);
enqueueHlsPush(stationName, seq); enqueueHlsPush(stationName, seq);
const audioId = `alert-${stationCode}-${Date.now()}`; const audioId = `alert-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq); const built = buildSequenceWavFile(seq, `alert:${stationName}`);
if (built) { if (built) {
log('INFO', `Alert audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`); log('INFO', `Alert audio queued for ${stationName}: ${audioId} (${built.durationSec.toFixed(2)}s)`);
enqueueStationAudioTask(stationName, ws, { enqueueStationAudioTask(stationName, ws, {
@@ -2888,11 +3029,12 @@ function handleTrainArrival(ws, data) {
`ch-${finalPlatform}`, 'ch-zt', `ch-${lineCode}`, 'ch-to', `ch-${destCode}`, 'ch-warn' `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); enqueueHlsPush(stationName, seq);
const audioId = `arrival-${stationCode}-${Date.now()}`; const audioId = `arrival-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq); const built = buildSequenceWavFile(seq, `arrival:${stationName}`);
if (built) { if (built) {
enqueueStationAudioTask(stationName, ws, { enqueueStationAudioTask(stationName, ws, {
audioFormat: built.audioFormat, audioFormat: built.audioFormat,
@@ -2956,11 +3098,12 @@ function handleTrainDeparture(ws, data) {
'ch-fc1', `ch-${finalPlatform}`, 'ch-fc2', 'ch-fc3', `ch-${destCode}` '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); enqueueHlsPush(stationName, seq);
const audioId = `departure-${stationCode}-${Date.now()}`; const audioId = `departure-${stationCode}-${Date.now()}`;
const built = buildSequenceWavFile(seq); const built = buildSequenceWavFile(seq, `departure:${stationName}`);
if (built) { if (built) {
enqueueStationAudioTask(stationName, ws, { enqueueStationAudioTask(stationName, ws, {
audioFormat: built.audioFormat, audioFormat: built.audioFormat,
@@ -4277,6 +4420,7 @@ app.use((err, req, res, next) => {
server.listen(PORT, () => { server.listen(PORT, () => {
loadData(); loadData();
try { ensureSilenceWav(); } catch (e) { log('WARN', `ensureSilenceWav failed at startup: ${e.message}`); }
if (!audioVersion) audioVersion = String(Date.now()); if (!audioVersion) audioVersion = String(Date.now());
if (!Array.isArray(versions) || versions.length === 0) { if (!Array.isArray(versions) || versions.length === 0) {
const snap = snapshotFiles(); const snap = snapshotFiles();
+1 -1
View File
@@ -1,6 +1,6 @@
-- 1. Configuration & Constants -- 1. Configuration & Constants
local APP_VERSION_TEXT = "v1.0.111" local APP_VERSION_TEXT = "v1.0.112"
local CONFIG = { local CONFIG = {
CLOUD_URL = "ws://stabadmin.fse-media.group", CLOUD_URL = "ws://stabadmin.fse-media.group",