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:
+180
-36
@@ -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}=<silence>`);
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user