fix(audio): 修复线缆 token 死锁(holder=CE-01) + 同组 approaching 等优先级饿死 + 线缆太长前端主机听不到
1. releaseCableToken(L688): 去掉 audioId 严格匹配, 只要 stationName 对就释放 修复: CE-01 因 audioId 在 setTimeout/release 分支里 miss-match, token 永远不释放, 导致同组其它站 approaching 100% 被 deny reason=lower_priority holder=CE-01 2. acquireCableToken(L549): 新增 equal-prio-timeout 抢占, 同一线缆分组多站连续 approaching(p=2) 时不会再出现 holder 永远持住的死锁: 旧 token 持有超过 max(30s, durHint+30s) 后, 等优先级新请求可强制切换 (日志 WARN: equal-prio switch) 3. Cable token watchdog(每20s扫描): holder 站点所有连接断开立刻强制释放 (holder_offline); 最长持有 10min 兜底强制释放 (max_hold_timeout). 彻底避免 音乐循环/循环临时广播 崩溃后 token 泄漏永远释放不了的情况 4. 线缆太长前端主机听不到 (架构修复): 旧方案: sendAudioBufferToStation / sendLiveWavChunksToStation 里全部 WS 消息 (audio_start + audio_chunk + audio_end) 只发给站点唯一 masterWs 连接, 若 master 物理位置在线缆尾部, 中间节点断链, 线缆最前面主机就完全收不到音频 新方案: 新增 sendWsToAllConnectionsOfStation + sendBinaryToAllConnectionsOfStation (L503-L520), 所有 (audio_start/end, cable_token_granted, audio_preempt) JSON 消息 及所有 binary WAV 分块, 广播到该站点 getAllStationConnections 返回的 *所有* 已连接 WS node, 每台主机独立从云端服务器拿一份流, 不再依赖线缆上 master 转发 对 10 站长线缆场景, 只要 node 还连着云端, 就肯定能收到音频, 彻底避免前端 主机因线缆中段节点断网/掉电听不到音频的故障 端到端单元测试 5/5 PASS: A releaseCableToken audioId 不严格匹配 PASS B p=2 approaching dur=8s age=70s 时 equal-prio 切换 PASS B2 p=2 age=5s 时不抢占 PASS (防抖动) C watchdog holder_offline 检测后立刻释放 PASS D 同站 4 个 node 同时都收到 audio_start 消息 PASS (前端主机不漏消息) v1.0.114
This commit is contained in:
+151
-73
@@ -500,10 +500,29 @@ function getCableGroupByStationName(stationName) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendWsToAllConnectionsOfStation(stationName, msgObj) {
|
||||||
|
const clean = String(stationName || '').trim();
|
||||||
|
if (!clean) return 0;
|
||||||
|
const jsonStr = (typeof msgObj === 'string') ? msgObj : JSON.stringify(msgObj);
|
||||||
|
let sent = 0;
|
||||||
|
for (const ws of getAllStationConnections(clean)) {
|
||||||
|
try { ws.send(jsonStr); sent++; } catch (e) {}
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendBinaryToAllConnectionsOfStation(stationName, buf) {
|
||||||
|
let sent = 0;
|
||||||
|
for (const ws of getAllStationConnections(stationName)) {
|
||||||
|
try { ws.send(buf); sent++; } catch (e) {}
|
||||||
|
}
|
||||||
|
return sent;
|
||||||
|
}
|
||||||
|
|
||||||
function sendWsToStationMaster(stationName, msgObj) {
|
function sendWsToStationMaster(stationName, msgObj) {
|
||||||
const { ws } = getStationMaster(stationName);
|
const { ws } = getStationMaster(stationName);
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return false;
|
if (!ws || ws.readyState !== WebSocket.OPEN) return 0;
|
||||||
try { ws.send(JSON.stringify(msgObj)); return true; } catch { return false; }
|
try { ws.send(JSON.stringify(msgObj)); return 1; } catch { return 0; }
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendStationGroupState(stationName) {
|
function sendStationGroupState(stationName) {
|
||||||
@@ -527,11 +546,12 @@ function sendStationGroupState(stationName) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acquireCableToken(cableGroupId, stationName, audioId, priority, cancelToken) {
|
async function acquireCableToken(cableGroupId, stationName, audioId, priority, cancelToken, taskDurationHintSec) {
|
||||||
const cgId = String(cableGroupId || '').trim();
|
const cgId = String(cableGroupId || '').trim();
|
||||||
const stName = String(stationName || '').trim();
|
const stName = String(stationName || '').trim();
|
||||||
const audId = String(audioId || '');
|
const audId = String(audioId || '');
|
||||||
const prio = Number(priority || 0);
|
const prio = Number(priority || 0);
|
||||||
|
const durHint = Number(taskDurationHintSec || 0) || 0;
|
||||||
|
|
||||||
if (!cgId) return { ok: true, reason: 'no_cable_group' };
|
if (!cgId) return { ok: true, reason: 'no_cable_group' };
|
||||||
if (!stName) return { ok: false, reason: 'no_station' };
|
if (!stName) return { ok: false, reason: 'no_station' };
|
||||||
@@ -543,9 +563,10 @@ async function acquireCableToken(cableGroupId, stationName, audioId, priority, c
|
|||||||
holderAudioId: audId,
|
holderAudioId: audId,
|
||||||
holderPriority: prio,
|
holderPriority: prio,
|
||||||
acquiredAt: Date.now(),
|
acquiredAt: Date.now(),
|
||||||
|
durationHintSec: durHint,
|
||||||
cancelToken: cancelToken || null
|
cancelToken: cancelToken || null
|
||||||
});
|
});
|
||||||
sendWsToStationMaster(stName, {
|
sendWsToAllConnectionsOfStation(stName, {
|
||||||
type: 'cable_token_granted',
|
type: 'cable_token_granted',
|
||||||
cableGroupId: cgId,
|
cableGroupId: cgId,
|
||||||
stationName: stName,
|
stationName: stName,
|
||||||
@@ -566,6 +587,7 @@ async function acquireCableToken(cableGroupId, stationName, audioId, priority, c
|
|||||||
holderAudioId: audId,
|
holderAudioId: audId,
|
||||||
holderPriority: prio,
|
holderPriority: prio,
|
||||||
acquiredAt: Date.now(),
|
acquiredAt: Date.now(),
|
||||||
|
durationHintSec: durHint,
|
||||||
cancelToken: cancelToken || null
|
cancelToken: cancelToken || null
|
||||||
});
|
});
|
||||||
return { ok: true, reason: 'same_station_updated' };
|
return { ok: true, reason: 'same_station_updated' };
|
||||||
@@ -573,15 +595,14 @@ async function acquireCableToken(cableGroupId, stationName, audioId, priority, c
|
|||||||
|
|
||||||
// Higher priority can preempt lower
|
// Higher priority can preempt lower
|
||||||
if (prio > existing.holderPriority) {
|
if (prio > existing.holderPriority) {
|
||||||
sendWsToStationMaster(existing.holderStationName, {
|
sendWsToAllConnectionsOfStation(existing.holderStationName, {
|
||||||
type: 'audio_preempt',
|
type: 'audio_preempt',
|
||||||
reason: 'higher_priority',
|
reason: 'higher_priority',
|
||||||
byStationName: stName,
|
byStationName: stName,
|
||||||
priority: prio,
|
priority: prio,
|
||||||
audioId: audId
|
audioId: audId
|
||||||
});
|
});
|
||||||
// Send denied to previous holder
|
sendWsToAllConnectionsOfStation(existing.holderStationName, {
|
||||||
sendWsToStationMaster(existing.holderStationName, {
|
|
||||||
type: 'cable_token_denied',
|
type: 'cable_token_denied',
|
||||||
cableGroupId: cgId,
|
cableGroupId: cgId,
|
||||||
stationName: existing.holderStationName,
|
stationName: existing.holderStationName,
|
||||||
@@ -596,9 +617,10 @@ async function acquireCableToken(cableGroupId, stationName, audioId, priority, c
|
|||||||
holderAudioId: audId,
|
holderAudioId: audId,
|
||||||
holderPriority: prio,
|
holderPriority: prio,
|
||||||
acquiredAt: Date.now(),
|
acquiredAt: Date.now(),
|
||||||
|
durationHintSec: durHint,
|
||||||
cancelToken: cancelToken || null
|
cancelToken: cancelToken || null
|
||||||
});
|
});
|
||||||
sendWsToStationMaster(stName, {
|
sendWsToAllConnectionsOfStation(stName, {
|
||||||
type: 'cable_token_granted',
|
type: 'cable_token_granted',
|
||||||
cableGroupId: cgId,
|
cableGroupId: cgId,
|
||||||
stationName: stName,
|
stationName: stName,
|
||||||
@@ -607,8 +629,52 @@ async function acquireCableToken(cableGroupId, stationName, audioId, priority, c
|
|||||||
return { ok: true, reason: 'preempted_lower' };
|
return { ok: true, reason: 'preempted_lower' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lower or equal priority: deny
|
// Equal priority: preempt if existing token has run beyond (durHint + EQUAL_PRIO_GRACE_SEC).
|
||||||
sendWsToStationMaster(stName, {
|
// This solves "holder=CE-01" starve forever because both approaching are p=2 equal priority.
|
||||||
|
const EQUAL_PRIO_GRACE_SEC = 30;
|
||||||
|
if (prio === existing.holderPriority) {
|
||||||
|
const ageMs = Date.now() - (existing.acquiredAt || 0);
|
||||||
|
const existingDurHintSec = Number(existing.durationHintSec || 0);
|
||||||
|
const deadlineMs = Math.max(EQUAL_PRIO_GRACE_SEC, (existingDurHintSec + EQUAL_PRIO_GRACE_SEC)) * 1000;
|
||||||
|
if (ageMs >= deadlineMs) {
|
||||||
|
log('WARN', `Cable token equal-prio switch: group=${cgId} old=${existing.holderStationName} age=${Math.round(ageMs/1000)}s new=${stName}`);
|
||||||
|
sendWsToAllConnectionsOfStation(existing.holderStationName, {
|
||||||
|
type: 'audio_preempt',
|
||||||
|
reason: 'equal_prio_timeout',
|
||||||
|
byStationName: stName,
|
||||||
|
priority: prio,
|
||||||
|
audioId: audId
|
||||||
|
});
|
||||||
|
sendWsToAllConnectionsOfStation(existing.holderStationName, {
|
||||||
|
type: 'cable_token_denied',
|
||||||
|
cableGroupId: cgId,
|
||||||
|
stationName: existing.holderStationName,
|
||||||
|
reason: 'equal_prio_timeout',
|
||||||
|
holderStationName: stName,
|
||||||
|
holderPriority: prio
|
||||||
|
});
|
||||||
|
if (existing.cancelToken) existing.cancelToken.cancelled = true;
|
||||||
|
await delay(200);
|
||||||
|
audioCableTokens.set(cgId, {
|
||||||
|
holderStationName: stName,
|
||||||
|
holderAudioId: audId,
|
||||||
|
holderPriority: prio,
|
||||||
|
acquiredAt: Date.now(),
|
||||||
|
durationHintSec: durHint,
|
||||||
|
cancelToken: cancelToken || null
|
||||||
|
});
|
||||||
|
sendWsToAllConnectionsOfStation(stName, {
|
||||||
|
type: 'cable_token_granted',
|
||||||
|
cableGroupId: cgId,
|
||||||
|
stationName: stName,
|
||||||
|
audioId: audId
|
||||||
|
});
|
||||||
|
return { ok: true, reason: 'equal_prio_timeout' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lower or equal-but-not-expired priority: deny
|
||||||
|
sendWsToAllConnectionsOfStation(stName, {
|
||||||
type: 'cable_token_denied',
|
type: 'cable_token_denied',
|
||||||
cableGroupId: cgId,
|
cableGroupId: cgId,
|
||||||
stationName: stName,
|
stationName: stName,
|
||||||
@@ -622,17 +688,38 @@ async function acquireCableToken(cableGroupId, stationName, audioId, priority, c
|
|||||||
function releaseCableToken(cableGroupId, stationName, audioId) {
|
function releaseCableToken(cableGroupId, stationName, audioId) {
|
||||||
const cgId = String(cableGroupId || '').trim();
|
const cgId = String(cableGroupId || '').trim();
|
||||||
const stName = String(stationName || '').trim();
|
const stName = String(stationName || '').trim();
|
||||||
const audId = String(audioId || '');
|
|
||||||
if (!cgId) return false;
|
if (!cgId) return false;
|
||||||
const existing = audioCableTokens.get(cgId);
|
const existing = audioCableTokens.get(cgId);
|
||||||
if (!existing) return false;
|
if (!existing) return false;
|
||||||
if (existing.holderStationName !== stName) return false;
|
if (stName && existing.holderStationName !== stName) return false;
|
||||||
if (audId && existing.holderAudioId && existing.holderAudioId !== audId) return false;
|
|
||||||
if (existing.cancelToken) existing.cancelToken.cancelled = true;
|
if (existing.cancelToken) existing.cancelToken.cancelled = true;
|
||||||
audioCableTokens.delete(cgId);
|
audioCableTokens.delete(cgId);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cable token watchdog: force-release tokens whose holder went offline, or that are stale
|
||||||
|
// beyond a generous safety bound (music loops can legitimately be long but they should
|
||||||
|
// release via stop API, watchdog is only the last line of defense against leaks).
|
||||||
|
const CABLE_TOKEN_MAX_HOLD_MS = 10 * 60 * 1000; // 10 minutes hard cap
|
||||||
|
function runCableTokenWatchdog() {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [cgId, tok] of Array.from(audioCableTokens.entries())) {
|
||||||
|
if (!tok) { audioCableTokens.delete(cgId); continue; }
|
||||||
|
const st = tok.holderStationName ? stations[tok.holderStationName] : null;
|
||||||
|
const connected = !!(st && Array.isArray(st.nodes) && st.nodes.some(n => n.ws && n.ws.readyState === WebSocket.OPEN));
|
||||||
|
const age = now - (tok.acquiredAt || 0);
|
||||||
|
let release = false;
|
||||||
|
let reason = '';
|
||||||
|
if (!connected) { release = true; reason = 'holder_offline'; }
|
||||||
|
else if (age >= CABLE_TOKEN_MAX_HOLD_MS) { release = true; reason = 'max_hold_timeout'; }
|
||||||
|
if (release) {
|
||||||
|
log('WARN', `Cable token watchdog release: group=${cgId} holder=${tok.holderStationName || ''} age=${Math.round(age/1000)}s reason=${reason}`);
|
||||||
|
if (tok.cancelToken) try { tok.cancelToken.cancelled = true; } catch {}
|
||||||
|
audioCableTokens.delete(cgId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function getLocalStationScriptVersion() {
|
function getLocalStationScriptVersion() {
|
||||||
try {
|
try {
|
||||||
if (!fs.existsSync(stationScriptPath)) return '';
|
if (!fs.existsSync(stationScriptPath)) return '';
|
||||||
@@ -994,25 +1081,26 @@ function sendAudioControl(stationName, msg, fallbackWs) {
|
|||||||
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
||||||
const { ws: targetWs } = getStationMaster(stationName);
|
const { ws: targetWs } = getStationMaster(stationName);
|
||||||
const masterWs = targetWs || (fallbackWs && fallbackWs.readyState === WebSocket.OPEN ? fallbackWs : null);
|
const masterWs = targetWs || (fallbackWs && fallbackWs.readyState === WebSocket.OPEN ? fallbackWs : null);
|
||||||
if (!masterWs) throw new Error(`Station "${stationName}" not connected`);
|
const allTargets = getAllStationConnections(stationName);
|
||||||
|
if (!masterWs && allTargets.length === 0) throw new Error(`Station "${stationName}" not connected`);
|
||||||
|
|
||||||
const cableGrp = getCableGroupByStationName(stationName);
|
const cableGrp = getCableGroupByStationName(stationName);
|
||||||
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
const prio = Number(task.priority || 0);
|
const prio = Number(task.priority || 0);
|
||||||
|
const durationSec = Number(task.durationSec || 0);
|
||||||
|
|
||||||
if (cableGrp) {
|
if (cableGrp) {
|
||||||
const res = await acquireCableToken(cableGrp.id, stationName, audioId, prio, task.cancelToken || null);
|
const res = await acquireCableToken(cableGrp.id, stationName, audioId, prio, task.cancelToken || null, durationSec);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Cable token denied: ${res.reason || 'unknown'} (holder=${res.holderStationName || 'none'} p=${res.holderPriority ?? 0})`);
|
throw new Error(`Cable token denied: ${res.reason || 'unknown'} (holder=${res.holderStationName || 'none'} p=${res.holderPriority ?? 0})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const buf = task.buffer || null;
|
const buf = task.buffer || null;
|
||||||
const durationSec = Number(task.durationSec || 0);
|
|
||||||
const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase();
|
const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase();
|
||||||
const streamUrl = task.streamUrl ? String(task.streamUrl) : null;
|
const streamUrl = task.streamUrl ? String(task.streamUrl) : null;
|
||||||
|
|
||||||
const startMsg = {
|
const startMsg = JSON.stringify({
|
||||||
type: 'audio_start',
|
type: 'audio_start',
|
||||||
stationName,
|
stationName,
|
||||||
audioId,
|
audioId,
|
||||||
@@ -1026,15 +1114,18 @@ async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
|||||||
interruptMode: task.interruptMode || getInterruptModeForPriority(prio),
|
interruptMode: task.interruptMode || getInterruptModeForPriority(prio),
|
||||||
source: task.source || 'unknown',
|
source: task.source || 'unknown',
|
||||||
cableGroupId: cableGrp?.id || ''
|
cableGroupId: cableGrp?.id || ''
|
||||||
};
|
});
|
||||||
|
sendWsToAllConnectionsOfStation(stationName, startMsg);
|
||||||
|
|
||||||
masterWs.send(JSON.stringify(startMsg));
|
const releaseToken = () => {
|
||||||
|
if (cableGrp) try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
|
||||||
|
};
|
||||||
|
|
||||||
if (streamUrl) {
|
if (streamUrl) {
|
||||||
if (cableGrp && durationSec > 0) {
|
if (cableGrp && durationSec > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => releaseToken(), Math.max(100, Math.floor(durationSec * 1000) + 500));
|
||||||
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
|
} else if (cableGrp) {
|
||||||
}, Math.max(100, Math.floor(durationSec * 1000) + 500));
|
releaseToken();
|
||||||
}
|
}
|
||||||
return { audioId, cancelled: false, durationSec, cableGroupId: cableGrp?.id || '' };
|
return { audioId, cancelled: false, durationSec, cableGroupId: cableGrp?.id || '' };
|
||||||
}
|
}
|
||||||
@@ -1045,49 +1136,36 @@ async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (buf.length <= SINGLE_SEND_LIMIT) {
|
if (buf.length <= SINGLE_SEND_LIMIT) {
|
||||||
if (token && token.cancelled) {
|
if (token && token.cancelled) { releaseToken(); return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' }; }
|
||||||
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
|
sendBinaryToAllConnectionsOfStation(stationName, buf);
|
||||||
return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' };
|
|
||||||
}
|
|
||||||
masterWs.send(buf);
|
|
||||||
} else {
|
} else {
|
||||||
for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
|
for (let offset = 0; offset < buf.length; offset += CHUNK_SIZE) {
|
||||||
if (token && token.cancelled) {
|
if (token && token.cancelled) { releaseToken(); return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' }; }
|
||||||
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
|
|
||||||
return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' };
|
|
||||||
}
|
|
||||||
const end = Math.min(offset + CHUNK_SIZE, buf.length);
|
const end = Math.min(offset + CHUNK_SIZE, buf.length);
|
||||||
masterWs.send(buf.slice(offset, end));
|
sendBinaryToAllConnectionsOfStation(stationName, buf.slice(offset, end));
|
||||||
const isInitial = offset < CHUNK_SIZE * 4;
|
const isInitial = offset < CHUNK_SIZE * 4;
|
||||||
await delay(isInitial ? 20 : 60);
|
await delay(isInitial ? 20 : 60);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (token && token.cancelled) {
|
if (token && token.cancelled) { releaseToken(); return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' }; }
|
||||||
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
|
|
||||||
return { audioId, cancelled: true, durationSec, cableGroupId: cableGrp?.id || '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
masterWs.send(JSON.stringify({
|
sendWsToAllConnectionsOfStation(stationName, {
|
||||||
type: 'audio_end',
|
type: 'audio_end',
|
||||||
stationName,
|
stationName,
|
||||||
audioId,
|
audioId,
|
||||||
durationSec
|
durationSec
|
||||||
}));
|
});
|
||||||
|
|
||||||
if (cableGrp && durationSec > 0) {
|
if (cableGrp && durationSec > 0) {
|
||||||
setTimeout(() => {
|
setTimeout(() => releaseToken(), Math.max(100, Math.floor(durationSec * 1000) + 500));
|
||||||
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
|
} else {
|
||||||
}, Math.max(100, Math.floor(durationSec * 1000) + 500));
|
releaseToken();
|
||||||
} else if (cableGrp) {
|
|
||||||
releaseCableToken(cableGrp.id, stationName, audioId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { audioId, cancelled: false, durationSec, cableGroupId: cableGrp?.id || '' };
|
return { audioId, cancelled: false, durationSec, cableGroupId: cableGrp?.id || '' };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cableGrp) {
|
releaseToken();
|
||||||
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
|
|
||||||
}
|
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1118,7 +1196,8 @@ function buildWavChunkFromPcm(buffer, sampleRate = 48000, channels = 1, bitsPerS
|
|||||||
async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
||||||
const { ws: targetWs } = getStationMaster(stationName);
|
const { ws: targetWs } = getStationMaster(stationName);
|
||||||
const masterWs = targetWs || (fallbackWs && fallbackWs.readyState === WebSocket.OPEN ? fallbackWs : null);
|
const masterWs = targetWs || (fallbackWs && fallbackWs.readyState === WebSocket.OPEN ? fallbackWs : null);
|
||||||
if (!masterWs) throw new Error(`Station "${stationName}" not connected`);
|
const allTargets = getAllStationConnections(stationName);
|
||||||
|
if (!masterWs && allTargets.length === 0) throw new Error(`Station "${stationName}" not connected`);
|
||||||
|
|
||||||
const cableGrp = getCableGroupByStationName(stationName);
|
const cableGrp = getCableGroupByStationName(stationName);
|
||||||
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||||
@@ -1126,19 +1205,22 @@ async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|||||||
const durationSec = Number(task.durationSec || 0);
|
const durationSec = Number(task.durationSec || 0);
|
||||||
|
|
||||||
if (cableGrp) {
|
if (cableGrp) {
|
||||||
const res = await acquireCableToken(cableGrp.id, stationName, audioId, prio, task.cancelToken || null);
|
const res = await acquireCableToken(cableGrp.id, stationName, audioId, prio, task.cancelToken || null, durationSec || (task.loop ? 0 : 120));
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Cable token denied: ${res.reason || 'unknown'} (holder=${res.holderStationName || 'none'} p=${res.holderPriority ?? 0})`);
|
throw new Error(`Cable token denied: ${res.reason || 'unknown'} (holder=${res.holderStationName || 'none'} p=${res.holderPriority ?? 0})`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourcePath = String(task.sourcePath || '').trim();
|
const sourcePath = String(task.sourcePath || '').trim();
|
||||||
|
const releaseToken = () => {
|
||||||
|
if (cableGrp) try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
|
||||||
|
};
|
||||||
if (!sourcePath) {
|
if (!sourcePath) {
|
||||||
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
|
releaseToken();
|
||||||
throw new Error('Live WAV sourcePath required');
|
throw new Error('Live WAV sourcePath required');
|
||||||
}
|
}
|
||||||
if (!fs.existsSync(sourcePath)) {
|
if (!fs.existsSync(sourcePath)) {
|
||||||
if (cableGrp) releaseCableToken(cableGrp.id, stationName, audioId);
|
releaseToken();
|
||||||
throw new Error(`Live WAV source missing: ${sourcePath}`);
|
throw new Error(`Live WAV source missing: ${sourcePath}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1150,7 +1232,7 @@ async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|||||||
const chunkDurationSec = 0.25;
|
const chunkDurationSec = 0.25;
|
||||||
const chunkBytes = Math.max(4096, Math.floor(sampleRate * channels * (bitsPerSample / 8) * chunkDurationSec));
|
const chunkBytes = Math.max(4096, Math.floor(sampleRate * channels * (bitsPerSample / 8) * chunkDurationSec));
|
||||||
|
|
||||||
masterWs.send(JSON.stringify({
|
sendWsToAllConnectionsOfStation(stationName, {
|
||||||
type: 'audio_start',
|
type: 'audio_start',
|
||||||
stationName,
|
stationName,
|
||||||
audioId,
|
audioId,
|
||||||
@@ -1163,12 +1245,12 @@ async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|||||||
interruptMode: task.interruptMode || getInterruptModeForPriority(prio),
|
interruptMode: task.interruptMode || getInterruptModeForPriority(prio),
|
||||||
source: task.source || 'unknown',
|
source: task.source || 'unknown',
|
||||||
cableGroupId: cableGrp?.id || ''
|
cableGroupId: cableGrp?.id || ''
|
||||||
}));
|
});
|
||||||
|
|
||||||
const ffmpegArgs = ['-hide_banner', '-loglevel', 'error'];
|
const ffmpegArgs = ['-hide_banner', '-loglevel', 'error'];
|
||||||
if (loop) ffmpegArgs.push('-stream_loop', '-1');
|
if (loop) ffmpegArgs.push('-stream_loop', '-1');
|
||||||
ffmpegArgs.push('-re', '-i', sourcePath, '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', String(channels), '-ar', String(sampleRate), 'pipe:1');
|
ffmpegArgs.push('-re', '-i', sourcePath, '-f', 's16le', '-acodec', 'pcm_s16le', '-ac', String(channels), '-ar', String(sampleRate), 'pipe:1');
|
||||||
log('INFO', `Live WAV chunk stream start for ${stationName}: ${path.basename(sourcePath)}${loop ? ' (loop)' : ''} chunk=${chunkDurationSec}s`);
|
log('INFO', `Live WAV chunk stream start for ${stationName}: ${path.basename(sourcePath)}${loop ? ' (loop)' : ''} chunk=${chunkDurationSec}s liveTargets=${allTargets.length}`);
|
||||||
|
|
||||||
return await new Promise((resolve, reject) => {
|
return await new Promise((resolve, reject) => {
|
||||||
let stderr = '';
|
let stderr = '';
|
||||||
@@ -1176,12 +1258,6 @@ async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|||||||
let proc = null;
|
let proc = null;
|
||||||
let pcmPending = Buffer.alloc(0);
|
let pcmPending = Buffer.alloc(0);
|
||||||
|
|
||||||
const releaseToken = () => {
|
|
||||||
if (cableGrp) {
|
|
||||||
try { releaseCableToken(cableGrp.id, stationName, audioId); } catch {}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const finish = (result, error) => {
|
const finish = (result, error) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
@@ -1192,15 +1268,12 @@ async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const { ws: endWs } = getStationMaster(stationName);
|
sendWsToAllConnectionsOfStation(stationName, {
|
||||||
if (endWs && endWs.readyState === WebSocket.OPEN) {
|
type: 'audio_end',
|
||||||
endWs.send(JSON.stringify({
|
stationName,
|
||||||
type: 'audio_end',
|
audioId,
|
||||||
stationName,
|
durationSec: result.durationSec
|
||||||
audioId,
|
});
|
||||||
durationSec: result.durationSec
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
if (cableGrp && !loop && durationSec > 0) {
|
if (cableGrp && !loop && durationSec > 0) {
|
||||||
setTimeout(() => releaseToken(), Math.max(100, Math.floor(durationSec * 1000) + 500));
|
setTimeout(() => releaseToken(), Math.max(100, Math.floor(durationSec * 1000) + 500));
|
||||||
@@ -1228,15 +1301,14 @@ async function sendLiveWavChunksToStation(stationName, fallbackWs, task) {
|
|||||||
|
|
||||||
const flushChunk = (pcmChunk) => {
|
const flushChunk = (pcmChunk) => {
|
||||||
if (!pcmChunk || !pcmChunk.length) return;
|
if (!pcmChunk || !pcmChunk.length) return;
|
||||||
const { ws: fws } = getStationMaster(stationName);
|
const alive = getAllStationConnections(stationName);
|
||||||
const sendWs = fws || masterWs;
|
if (alive.length === 0) {
|
||||||
if (!sendWs || sendWs.readyState !== WebSocket.OPEN) {
|
|
||||||
cancelStream();
|
cancelStream();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sendWs.send(buildWavChunkFromPcm(pcmChunk, sampleRate, channels, bitsPerSample));
|
const framed = buildWavChunkFromPcm(pcmChunk, sampleRate, channels, bitsPerSample);
|
||||||
|
sendBinaryToAllConnectionsOfStation(stationName, framed);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
finish(null, err);
|
finish(null, err);
|
||||||
}
|
}
|
||||||
@@ -4421,6 +4493,12 @@ 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}`); }
|
try { ensureSilenceWav(); } catch (e) { log('WARN', `ensureSilenceWav failed at startup: ${e.message}`); }
|
||||||
|
try {
|
||||||
|
const hCableWatchdog = setInterval(() => {
|
||||||
|
try { runCableTokenWatchdog(); } catch (e) { log('WARN', `cable token watchdog error: ${e.message}`); }
|
||||||
|
}, 20 * 1000);
|
||||||
|
if (typeof hCableWatchdog.unref === 'function') hCableWatchdog.unref();
|
||||||
|
} catch {}
|
||||||
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,6 +1,6 @@
|
|||||||
|
|
||||||
-- 1. Configuration & Constants
|
-- 1. Configuration & Constants
|
||||||
local APP_VERSION_TEXT = "v1.0.113"
|
local APP_VERSION_TEXT = "v1.0.114"
|
||||||
|
|
||||||
local CONFIG = {
|
local CONFIG = {
|
||||||
CLOUD_URL = "ws://stabadmin.fse-media.group",
|
CLOUD_URL = "ws://stabadmin.fse-media.group",
|
||||||
|
|||||||
Reference in New Issue
Block a user