Compare commits
3
Commits
e4b4fa5cdb
...
f530d7e045
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f530d7e045 | ||
|
|
d6e5ce5a93 | ||
|
|
c73bceaec7 |
@@ -1251,7 +1251,7 @@
|
||||
|
||||
async function loadList() {
|
||||
try {
|
||||
const res = await fetch('/api/files/content?path=uploads/list.txt');
|
||||
const res = await fetch('/api/files/content?path=list.txt');
|
||||
const data = await res.json();
|
||||
listData = [];
|
||||
if (data.success && data.content) {
|
||||
@@ -1418,7 +1418,7 @@
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
path: 'uploads/list.txt',
|
||||
path: 'list.txt',
|
||||
content: content + '\n'
|
||||
})
|
||||
});
|
||||
|
||||
@@ -857,7 +857,6 @@
|
||||
// --- State ---
|
||||
let allFiles = [];
|
||||
let currentPath = 'root';
|
||||
let expandedTrains = new Set();
|
||||
let stationCodeMap = {};
|
||||
let broadcastingStations = new Set();
|
||||
|
||||
@@ -951,102 +950,27 @@
|
||||
if (!state.activeTrains || state.activeTrains.length === 0) list.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text-muted);font-size:0.8rem">No active trains</div>';
|
||||
|
||||
state.activeTrains.forEach(tr => {
|
||||
const isExpanded = expandedTrains.has(tr.trainId);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status-item';
|
||||
|
||||
let detailsHtml = '';
|
||||
if (tr.tripLogs && tr.tripLogs.length > 0) {
|
||||
detailsHtml = tr.tripLogs.map(log => {
|
||||
const time = new Date(log.time).toLocaleTimeString();
|
||||
const stCode = stationCodeMap[log.station] || log.station;
|
||||
return `
|
||||
<div class="trip-log">
|
||||
<span>${time}</span>
|
||||
<span>${stCode}</span>
|
||||
<span>${log.action}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
detailsHtml = '<div style="font-style:italic">No trip history</div>';
|
||||
}
|
||||
|
||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="status-main" onclick="toggleTrain('${tr.trainId}')">
|
||||
<div class="status-main">
|
||||
<div class="item-info">
|
||||
<div class="item-title">${tr.trainId}</div>
|
||||
<div class="item-sub">${currentStCode}</div>
|
||||
<div class="item-sub">${currentStCode} <span style="margin-left:8px;color:var(--text-muted)">${tr.status || '-'}</span></div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
|
||||
<div style="margin-bottom:8px;font-weight:600">Trip Logs</div>
|
||||
${detailsHtml}
|
||||
<div style="margin-top:10px;text-align:right">
|
||||
<button class="btn-icon" onclick="event.stopPropagation(); deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
|
||||
<button class="btn-icon" onclick="deleteTrain('${tr.trainId}')" style="display:inline-flex">${ICONS.trash}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(div);
|
||||
});
|
||||
|
||||
// Network Statistics
|
||||
const statsDiv = document.createElement('div');
|
||||
statsDiv.className = 'status-item';
|
||||
const isStatsExpanded = expandedTrains.has('__STATS__');
|
||||
|
||||
let statsHtml = '<div style="padding:10px;text-align:center;color:var(--text-muted)">No data</div>';
|
||||
if (state.travelTimes) {
|
||||
const keys = Object.keys(state.travelTimes).sort();
|
||||
if (keys.length > 0) {
|
||||
statsHtml = keys.map(key => {
|
||||
const times = state.travelTimes[key];
|
||||
const [from, to] = key.split('|');
|
||||
const fromCode = stationCodeMap[from] || from;
|
||||
const toCode = stationCodeMap[to] || to;
|
||||
const avg = Math.ceil(times.reduce((a, b) => a + b, 0) / times.length);
|
||||
return `
|
||||
<div class="trip-log">
|
||||
<span>${fromCode} → ${toCode}</span>
|
||||
<span style="text-align:right; color:var(--success)">${avg}s</span>
|
||||
<span style="text-align:right; font-size:0.75em; opacity:0.7">(${times.length})</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
statsDiv.innerHTML = `
|
||||
<div class="status-main" onclick="toggleTrain('__STATS__')" style="background:var(--bg-body); border-top:1px solid var(--bg-border)">
|
||||
<div class="item-info">
|
||||
<div class="item-title" style="font-size:0.85rem; color:var(--text-muted)">NETWORK STATISTICS</div>
|
||||
</div>
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<button class="btn-icon" onclick="event.stopPropagation(); deleteStats()" style="display:inline-flex" title="Clear Statistics">${ICONS.trash}</button>
|
||||
<div style="color:var(--text-muted)">${isStatsExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="status-details ${isStatsExpanded ? 'open' : ''}" id="details-__STATS__">
|
||||
<div style="display:grid; grid-template-columns: 1fr 40px 30px; gap:10px; margin-bottom:5px; font-weight:600; font-size:0.75rem; color:var(--text-muted)">
|
||||
<span>ROUTE</span>
|
||||
<span style="text-align:right">AVG</span>
|
||||
<span style="text-align:right">CNT</span>
|
||||
</div>
|
||||
${statsHtml}
|
||||
</div>
|
||||
`;
|
||||
list.appendChild(statsDiv);
|
||||
}
|
||||
}
|
||||
|
||||
/* Removed separate Travel Times renderer */
|
||||
|
||||
function showRadioStream() {
|
||||
document.getElementById('file-manager-view').style.display = 'none';
|
||||
document.getElementById('radio-view').style.display = 'flex';
|
||||
@@ -1120,18 +1044,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
function toggleTrain(id) {
|
||||
if (expandedTrains.has(id)) expandedTrains.delete(id);
|
||||
else expandedTrains.add(id);
|
||||
// We need to re-render to update icons/state, but since state updates often,
|
||||
// just toggling the DOM element is smoother until next update
|
||||
const details = document.getElementById(`details-${id}`);
|
||||
if (details) {
|
||||
details.classList.toggle('open');
|
||||
// Force re-render on next update will respect the Set
|
||||
}
|
||||
}
|
||||
|
||||
function addLog(logText) {
|
||||
const container = document.getElementById('console-logs');
|
||||
const div = document.createElement('div');
|
||||
@@ -1334,14 +1246,6 @@
|
||||
await fetch(`/api/trains/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
async function deleteStats() {
|
||||
if (!confirm('Are you sure you want to clear all network travel time statistics?')) return;
|
||||
const res = await fetch('/api/stats', { method: 'DELETE' });
|
||||
const data = await res.json();
|
||||
if (data.success) showToast('Success', 'Network statistics cleared');
|
||||
else showToast('Error', data.message);
|
||||
}
|
||||
|
||||
function triggerUpload() {
|
||||
if (currentPath === 'config') document.getElementById('upload-config').click();
|
||||
else if (currentPath === 'general') document.getElementById('upload-general').click();
|
||||
|
||||
@@ -184,19 +184,6 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.train-details {
|
||||
display: none;
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
padding: 12px 20px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
border-top: 1px solid var(--bg-border);
|
||||
}
|
||||
|
||||
.train-details.open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.trip-log {
|
||||
display: grid;
|
||||
grid-template-columns: 80px 1fr auto;
|
||||
@@ -380,7 +367,6 @@
|
||||
};
|
||||
|
||||
// --- State ---
|
||||
let expandedTrains = new Set();
|
||||
let stationCodeMap = {};
|
||||
let broadcastingStations = new Set();
|
||||
let messageLog = [];
|
||||
@@ -526,27 +512,8 @@
|
||||
list.innerHTML = '<div style="padding:40px;text-align:center;color:var(--text-muted);font-size:0.8rem">暂无活跃列车</div>';
|
||||
} else {
|
||||
state.activeTrains.forEach(tr => {
|
||||
const isExpanded = expandedTrains.has(tr.trainId);
|
||||
const div = document.createElement('div');
|
||||
div.className = 'status-item train-item';
|
||||
div.onclick = () => toggleTrain(tr.trainId);
|
||||
|
||||
let detailsHtml = '';
|
||||
if (tr.tripLogs && tr.tripLogs.length > 0) {
|
||||
detailsHtml = tr.tripLogs.slice().reverse().map(log => {
|
||||
const time = new Date(log.time).toLocaleTimeString();
|
||||
const stCode = stationCodeMap[log.station] || log.station;
|
||||
return `
|
||||
<div class="trip-log">
|
||||
<span>${time}</span>
|
||||
<span style="color:var(--text-primary)">${stCode}</span>
|
||||
<span style="color:${log.action === 'Arrival' ? 'var(--success)' : 'var(--warning)'}">${log.action === 'Arrival' ? '到达' : '出发'}</span>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
} else {
|
||||
detailsHtml = '<div style="font-style:italic">暂无运行历史</div>';
|
||||
}
|
||||
|
||||
const currentStCode = stationCodeMap[tr.currentStation] || tr.currentStation || '-';
|
||||
const statusColor = tr.status === 'At Station' ? 'var(--success)' : 'var(--warning)';
|
||||
@@ -556,13 +523,9 @@
|
||||
<div style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<div class="item-info">
|
||||
<div class="item-title">${tr.trainId} <span style="font-size:0.7rem; color:var(--text-muted); font-weight:normal; margin-left:8px;">${tr.destination || '未知终点'}</span></div>
|
||||
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : '运行中'}</span></div>
|
||||
<div class="item-sub">${currentStCode} <span style="color:${statusColor}; margin-left:8px;">${tr.status === 'At Station' ? '在站' : (tr.status === 'Departed' ? '已发车' : '运行中')}</span></div>
|
||||
</div>
|
||||
<div style="color:var(--text-muted)">${isExpanded ? ICONS.chevronUp : ICONS.chevronDown}</div>
|
||||
</div>
|
||||
<div class="train-details ${isExpanded ? 'open' : ''}" id="details-${tr.trainId}">
|
||||
<div style="margin-bottom:8px;font-weight:600;font-size:0.7rem;text-transform:uppercase;color:var(--text-muted)">运行日志 (Trip Logs)</div>
|
||||
${detailsHtml}
|
||||
<div class="status-dot ${tr.status === 'At Station' ? 'dot-online' : 'dot-warn'}"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -572,15 +535,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTrain(id) {
|
||||
if (expandedTrains.has(id)) expandedTrains.delete(id);
|
||||
else expandedTrains.add(id);
|
||||
const details = document.getElementById(`details-${id}`);
|
||||
if (details) {
|
||||
details.classList.toggle('open');
|
||||
}
|
||||
}
|
||||
|
||||
function updateStationDots() {
|
||||
const dots = document.querySelectorAll('[id^="station-dot-"]');
|
||||
dots.forEach(dot => {
|
||||
|
||||
@@ -397,7 +397,7 @@
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<div class="panel-subtitle">对 uploads/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
|
||||
<div class="panel-subtitle">对 /opt/CC-StationBroadcast/list.txt 进行增删改;每条记录格式:站号==音频文件1,音频文件2,... (支持多个站号用 / 分隔)</div>
|
||||
<div class="toolbar">
|
||||
<button class="btn" onclick="openAddModal()">添加</button>
|
||||
<div style="display:flex; gap:10px; flex-wrap:wrap;">
|
||||
@@ -545,7 +545,7 @@
|
||||
}
|
||||
|
||||
async function loadList() {
|
||||
const res = await fetch('/api/files/content?path=uploads/list.txt');
|
||||
const res = await fetch('/api/files/content?path=list.txt');
|
||||
const data = await res.json();
|
||||
listData = [];
|
||||
if (data.success && data.content) {
|
||||
@@ -704,7 +704,7 @@
|
||||
const res = await fetch('/api/files/content', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: 'uploads/list.txt', content: content + '\n' })
|
||||
body: JSON.stringify({ path: 'list.txt', content: content + '\n' })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!data.success) throw new Error(data.message || 'Save failed');
|
||||
|
||||
@@ -180,7 +180,7 @@
|
||||
<span class="badge" id="track_badge">未选择</span>
|
||||
</div>
|
||||
|
||||
<label for="track_select">选择音乐(来自 uploads/audio/general)</label>
|
||||
<label for="track_select">选择音乐(来自 /opt/CC-StationBroadcast/Audio/general)</label>
|
||||
<div class="row" style="margin-top:0">
|
||||
<select id="track_select" onchange="onTrackChange()" style="flex:1; min-width: 200px;"></select>
|
||||
<button class="btn" onclick="loadTracks()">刷新</button>
|
||||
@@ -316,7 +316,7 @@
|
||||
if (tracks.length === 0) {
|
||||
sel.innerHTML = '<option value="">(无可用音频)</option>';
|
||||
onTrackChange();
|
||||
setStatus("没有可用音频(请上传到 uploads/audio/general)");
|
||||
setStatus("没有可用音频(请上传到 /opt/CC-StationBroadcast/Audio/general)");
|
||||
return;
|
||||
}
|
||||
sel.appendChild(new Option("请选择…", ""));
|
||||
@@ -433,4 +433,3 @@
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@
|
||||
</div>
|
||||
|
||||
<div class="hint" id="hint_area">
|
||||
需要服务端已存在 lsts.wav(建议放在 uploads/audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
||||
需要服务端已存在 lsts.wav(建议放在 /opt/CC-StationBroadcast/Audio/general/lsts.wav)。点击“合成”会自动中文 TTS 并拼接生成临时广播,随后可发布到所选站台。
|
||||
</div>
|
||||
|
||||
<div id="preview_area" style="margin-top:12px; display:none;">
|
||||
|
||||
+168
-184
@@ -1,3 +1,4 @@
|
||||
|
||||
const express = require('express');
|
||||
const http = require('http');
|
||||
const WebSocket = require('ws');
|
||||
@@ -27,6 +28,7 @@ const STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8';
|
||||
const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
|
||||
const HLS_PREBUILD = process.env.HLS_PREBUILD !== '0';
|
||||
|
||||
const appRootDir = path.resolve(__dirname, '..');
|
||||
const tmpDir = path.join(__dirname, 'tmp');
|
||||
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
||||
const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
|
||||
@@ -34,13 +36,15 @@ const silence1sWavPath = path.join(tmpDir, 'silence-1s.wav');
|
||||
const localHlsDir = path.join(__dirname, 'public', 'hls');
|
||||
if (!fs.existsSync(localHlsDir)) fs.mkdirSync(localHlsDir, { recursive: true });
|
||||
|
||||
// Ensure upload directories exist
|
||||
const uploadDir = path.join(__dirname, 'public', 'uploads');
|
||||
const audioGeneralDir = path.join(uploadDir, 'audio', 'general');
|
||||
const audioStationDir = path.join(uploadDir, 'audio', 'station');
|
||||
const tempBroadcastDir = path.join(uploadDir, 'temp-broadcast');
|
||||
const audioRootDir = path.join(appRootDir, 'Audio');
|
||||
const audioGeneralDir = path.join(audioRootDir, 'general');
|
||||
const audioStationDir = path.join(audioRootDir, 'station');
|
||||
const listFilePath = path.join(appRootDir, 'list.txt');
|
||||
const tempBroadcastDir = path.join(appRootDir, 'temp-broadcast');
|
||||
|
||||
if (!fs.existsSync(uploadDir)) fs.mkdirSync(uploadDir, { recursive: true });
|
||||
if (!fs.existsSync(audioRootDir)) fs.mkdirSync(audioRootDir, { recursive: true });
|
||||
if (!fs.existsSync(audioGeneralDir)) fs.mkdirSync(audioGeneralDir, { recursive: true });
|
||||
if (!fs.existsSync(audioStationDir)) fs.mkdirSync(audioStationDir, { recursive: true });
|
||||
if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive: true });
|
||||
@@ -49,7 +53,7 @@ if (!fs.existsSync(tempBroadcastDir)) fs.mkdirSync(tempBroadcastDir, { recursive
|
||||
const storage = multer.diskStorage({
|
||||
destination: function (req, file, cb) {
|
||||
if (file.fieldname === 'listFile') {
|
||||
cb(null, uploadDir);
|
||||
cb(null, appRootDir);
|
||||
} else if (file.fieldname === 'generalAudioFiles') {
|
||||
cb(null, audioGeneralDir);
|
||||
} else if (file.fieldname === 'stationAudioFiles') {
|
||||
@@ -83,7 +87,6 @@ app.set('view engine', 'ejs');
|
||||
|
||||
// Data Store (In-memory)
|
||||
let stations = {};
|
||||
const stationRouteIndex = new Map();
|
||||
let activeTrains = [];
|
||||
let stationLogs = [];
|
||||
let audioVersion = String(Date.now());
|
||||
@@ -177,21 +180,23 @@ function sendAudioControl(stationName, msg, fallbackWs) {
|
||||
}
|
||||
}
|
||||
|
||||
async function sendDfpwmBufferToStation(stationName, fallbackWs, task) {
|
||||
async function sendAudioBufferToStation(stationName, fallbackWs, task) {
|
||||
const targetWs = getStationWs(stationName, fallbackWs);
|
||||
if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
|
||||
|
||||
const audioId = String(task.audioId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const buf = task.buffer;
|
||||
const durationSec = buf.length / 6000;
|
||||
const durationSec = Number(task.durationSec || 0);
|
||||
const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase();
|
||||
|
||||
const startMsg = {
|
||||
type: 'audio_start',
|
||||
stationName,
|
||||
audioId,
|
||||
byteLength: buf.length,
|
||||
sampleRate: 48000,
|
||||
channels: 1,
|
||||
sampleRate: Number(task.sampleRate || 48000),
|
||||
channels: Number(task.channels || 1),
|
||||
audioFormat,
|
||||
priority: Number(task.priority || 0),
|
||||
interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
|
||||
source: task.source || 'unknown'
|
||||
@@ -254,29 +259,73 @@ function listMusicTracks() {
|
||||
return Array.from(out).sort((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
function getDfpwmBufferByBaseName(baseName) {
|
||||
function getWavDurationSec(buffer) {
|
||||
if (!Buffer.isBuffer(buffer) || buffer.length < 44) return 0;
|
||||
if (buffer.toString('ascii', 0, 4) !== 'RIFF' || buffer.toString('ascii', 8, 12) !== 'WAVE') return 0;
|
||||
|
||||
let offset = 12;
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
let bitsPerSample = 0;
|
||||
let dataSize = 0;
|
||||
|
||||
while (offset + 8 <= buffer.length) {
|
||||
const chunkId = buffer.toString('ascii', offset, offset + 4);
|
||||
const chunkSize = buffer.readUInt32LE(offset + 4);
|
||||
const dataOffset = offset + 8;
|
||||
if (dataOffset + chunkSize > buffer.length) break;
|
||||
|
||||
if (chunkId === 'fmt ' && chunkSize >= 16) {
|
||||
channels = buffer.readUInt16LE(dataOffset + 2);
|
||||
sampleRate = buffer.readUInt32LE(dataOffset + 4);
|
||||
bitsPerSample = buffer.readUInt16LE(dataOffset + 14);
|
||||
} else if (chunkId === 'data') {
|
||||
dataSize = chunkSize;
|
||||
break;
|
||||
}
|
||||
|
||||
offset = dataOffset + chunkSize + (chunkSize % 2);
|
||||
}
|
||||
|
||||
if (!sampleRate || !channels || !bitsPerSample || !dataSize) return 0;
|
||||
return dataSize / (sampleRate * channels * (bitsPerSample / 8));
|
||||
}
|
||||
|
||||
function getPlaybackAudioByBaseName(baseName) {
|
||||
const base = normalizeBaseName(baseName);
|
||||
if (!base) throw new Error('Invalid track name');
|
||||
|
||||
const wavCandidates = [
|
||||
path.join(audioGeneralDir, `${base}.wav`),
|
||||
path.join(audioStationDir, `${base}.wav`)
|
||||
];
|
||||
for (const p of wavCandidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
const buffer = fs.readFileSync(p);
|
||||
return {
|
||||
buffer,
|
||||
audioFormat: 'wav',
|
||||
durationSec: getWavDurationSec(buffer) || 0
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const dfCandidates = [
|
||||
path.join(audioGeneralDir, `${base}.dfpwm`),
|
||||
path.join(audioStationDir, `${base}.dfpwm`)
|
||||
];
|
||||
for (const p of dfCandidates) {
|
||||
if (fs.existsSync(p)) return fs.readFileSync(p);
|
||||
if (fs.existsSync(p)) {
|
||||
const buffer = fs.readFileSync(p);
|
||||
return {
|
||||
buffer,
|
||||
audioFormat: 'dfpwm',
|
||||
durationSec: buffer.length / 6000
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wavCandidates = [
|
||||
path.join(audioGeneralDir, `${base}.wav`),
|
||||
path.join(audioStationDir, `${base}.wav`)
|
||||
];
|
||||
const wavPath = wavCandidates.find(p => fs.existsSync(p)) || null;
|
||||
if (!wavPath) throw new Error(`Audio not found: ${base}`);
|
||||
|
||||
const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm');
|
||||
if (!fs.existsSync(dfpwmPath)) convertToDfpwm(wavPath);
|
||||
if (!fs.existsSync(dfpwmPath)) throw new Error(`DFPWM build failed: ${base}`);
|
||||
return fs.readFileSync(dfpwmPath);
|
||||
throw new Error(`Audio not found: ${base}`);
|
||||
}
|
||||
|
||||
function stopStationMusic(stationName, fallbackWs) {
|
||||
@@ -357,7 +406,7 @@ async function processStationAudioQueue(stationName, fallbackWs) {
|
||||
|
||||
try {
|
||||
if (task.consoleStreamStart) broadcastToConsole(task.consoleStreamStart);
|
||||
const result = await sendDfpwmBufferToStation(cleanStation, fallbackWs, task);
|
||||
const result = await sendAudioBufferToStation(cleanStation, fallbackWs, task);
|
||||
if (task.consoleStreamEnd) {
|
||||
if (result.cancelled) {
|
||||
broadcastToConsole(task.consoleStreamEnd);
|
||||
@@ -425,15 +474,42 @@ function normalizeVersionString(input) {
|
||||
return input;
|
||||
}
|
||||
|
||||
function resolveManagedPath(inputPath) {
|
||||
const normalized = String(inputPath || '').replace(/\\/g, '/').replace(/^\/+/, '').trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
if (normalized === 'list.txt' || normalized === 'uploads/list.txt') {
|
||||
return { logicalPath: 'list.txt', fullPath: listFilePath };
|
||||
}
|
||||
|
||||
const prefixes = [
|
||||
{ prefix: 'Audio/general/', baseDir: audioGeneralDir, logicalPrefix: 'Audio/general/' },
|
||||
{ prefix: 'uploads/audio/general/', baseDir: audioGeneralDir, logicalPrefix: 'Audio/general/' },
|
||||
{ prefix: 'Audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' },
|
||||
{ prefix: 'uploads/audio/station/', baseDir: audioStationDir, logicalPrefix: 'Audio/station/' }
|
||||
];
|
||||
|
||||
for (const entry of prefixes) {
|
||||
if (!normalized.startsWith(entry.prefix)) continue;
|
||||
const relativeName = normalized.slice(entry.prefix.length).trim();
|
||||
if (!relativeName || relativeName.includes('/') || relativeName.includes('\\')) return null;
|
||||
return {
|
||||
logicalPath: `${entry.logicalPrefix}${relativeName}`,
|
||||
fullPath: path.join(entry.baseDir, relativeName)
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getManagedFiles() {
|
||||
const files = [];
|
||||
|
||||
const configPath = path.join(uploadDir, 'list.txt');
|
||||
if (fs.existsSync(configPath)) {
|
||||
const stats = fs.statSync(configPath);
|
||||
if (fs.existsSync(listFilePath)) {
|
||||
const stats = fs.statSync(listFilePath);
|
||||
files.push({
|
||||
name: 'list.txt',
|
||||
path: 'uploads/list.txt',
|
||||
path: 'list.txt',
|
||||
type: 'config',
|
||||
size: stats.size,
|
||||
mtime: stats.mtimeMs
|
||||
@@ -450,7 +526,7 @@ function getManagedFiles() {
|
||||
const stats = fs.statSync(filePath);
|
||||
files.push({
|
||||
name: file,
|
||||
path: `uploads/audio/general/${file}`,
|
||||
path: `Audio/general/${file}`,
|
||||
type: 'general',
|
||||
size: stats.size,
|
||||
mtime: stats.mtimeMs
|
||||
@@ -468,7 +544,7 @@ function getManagedFiles() {
|
||||
const stats = fs.statSync(filePath);
|
||||
files.push({
|
||||
name: file,
|
||||
path: `uploads/audio/station/${file}`,
|
||||
path: `Audio/station/${file}`,
|
||||
type: 'station',
|
||||
size: stats.size,
|
||||
mtime: stats.mtimeMs
|
||||
@@ -576,50 +652,15 @@ function normalizeStationPlatform(platform) {
|
||||
return value || '1';
|
||||
}
|
||||
|
||||
function getStationRouteKeys(name, code, platform) {
|
||||
const keys = [];
|
||||
const normalizedCode = normalizeStationCode(code || getStationCodeFromName(name));
|
||||
const normalizedPlatform = normalizeStationPlatform(platform || getPlatformNumberFromName(name));
|
||||
const baseName = getBaseName(name);
|
||||
|
||||
if (normalizedCode) {
|
||||
keys.push(`code:${normalizedCode}|platform:${normalizedPlatform}`);
|
||||
keys.push(`code:${normalizedCode}`);
|
||||
}
|
||||
if (baseName) {
|
||||
keys.push(`base:${baseName}|platform:${normalizedPlatform}`);
|
||||
keys.push(`base:${baseName}`);
|
||||
}
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
function unindexStation(station) {
|
||||
if (!station) return;
|
||||
const keys = Array.isArray(station.routeKeys) ? station.routeKeys : [];
|
||||
for (const key of keys) {
|
||||
const current = stationRouteIndex.get(key);
|
||||
if (current && current.name === station.name) {
|
||||
stationRouteIndex.delete(key);
|
||||
}
|
||||
}
|
||||
station.routeKeys = [];
|
||||
}
|
||||
|
||||
function indexStation(station) {
|
||||
function normalizeStationRecord(station) {
|
||||
if (!station || !station.name) return;
|
||||
unindexStation(station);
|
||||
station.platform = normalizeStationPlatform(station.platform || getPlatformNumberFromName(station.name));
|
||||
station.code = normalizeStationCode(station.code || getStationCodeFromName(station.name)) || station.code || '';
|
||||
station.routeKeys = getStationRouteKeys(station.name, station.code, station.platform);
|
||||
for (const key of station.routeKeys) {
|
||||
stationRouteIndex.set(key, station);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshStationIndex() {
|
||||
stationRouteIndex.clear();
|
||||
for (const station of Object.values(stations)) {
|
||||
indexStation(station);
|
||||
normalizeStationRecord(station);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,6 +808,25 @@ function buildConcatListFile(seq) {
|
||||
return listPath;
|
||||
}
|
||||
|
||||
function buildSequenceWavBuffer(seq) {
|
||||
const listPath = buildConcatListFile(seq);
|
||||
if (!listPath) return null;
|
||||
|
||||
const outWavPath = path.join(tmpDir, `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`);
|
||||
try {
|
||||
execSync(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listPath}" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
|
||||
const buffer = fs.readFileSync(outWavPath);
|
||||
return {
|
||||
buffer,
|
||||
audioFormat: 'wav',
|
||||
durationSec: getWavDurationSec(buffer) || 0
|
||||
};
|
||||
} finally {
|
||||
try { fs.unlinkSync(listPath); } catch {}
|
||||
try { fs.unlinkSync(outWavPath); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
function runFfmpegRtmpPush(streamName, seq) {
|
||||
const state = getOrCreateHlsStreamState(streamName);
|
||||
const listPath = buildConcatListFile(seq);
|
||||
@@ -1197,7 +1257,7 @@ function handleHeartbeat(ws, data) {
|
||||
lastSeen: Date.now(),
|
||||
ws: ws
|
||||
};
|
||||
indexStation(stations[cleanName]);
|
||||
normalizeStationRecord(stations[cleanName]);
|
||||
log('INFO', `Station auto-registered via heartbeat: "${cleanName}"`);
|
||||
broadcastUpdate();
|
||||
} else {
|
||||
@@ -1209,7 +1269,7 @@ function handleHeartbeat(ws, data) {
|
||||
log('INFO', `Station WebSocket updated via heartbeat: "${cleanName}"`);
|
||||
broadcastUpdate();
|
||||
}
|
||||
indexStation(stations[cleanName]);
|
||||
normalizeStationRecord(stations[cleanName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1225,7 +1285,7 @@ function handleStationRegister(ws, data) {
|
||||
lastSeen: Date.now(),
|
||||
ws: ws // Keep reference if we need to send back
|
||||
};
|
||||
indexStation(stations[cleanName]);
|
||||
normalizeStationRecord(stations[cleanName]);
|
||||
|
||||
log('SUCCESS', `Station registered: "${cleanName}" (${stationCode})`);
|
||||
|
||||
@@ -1255,24 +1315,15 @@ function handleTrainArrival(data) {
|
||||
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
||||
|
||||
// Create history entry
|
||||
const historyEntry = {
|
||||
station: stationName,
|
||||
action: 'Arrival',
|
||||
time: Date.now()
|
||||
};
|
||||
|
||||
let trainData = {
|
||||
trainId,
|
||||
currentStation: stationName,
|
||||
destination: destination || (existingIndex >= 0 ? activeTrains[existingIndex].destination : "Unknown"),
|
||||
status: "At Station",
|
||||
lastUpdate: Date.now(),
|
||||
schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null),
|
||||
tripLogs: existingIndex >= 0 ? (activeTrains[existingIndex].tripLogs || []) : []
|
||||
schedule: schedule || (existingIndex >= 0 ? activeTrains[existingIndex].schedule : null)
|
||||
};
|
||||
|
||||
trainData.tripLogs.push(historyEntry);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
activeTrains[existingIndex] = { ...activeTrains[existingIndex], ...trainData };
|
||||
} else {
|
||||
@@ -1347,12 +1398,6 @@ function isFuzzyMatch(str, pattern) {
|
||||
function findStationByFlexibleName(targetName) {
|
||||
if (!targetName) return null;
|
||||
|
||||
const directKeys = getStationRouteKeys(targetName);
|
||||
for (const key of directKeys) {
|
||||
const station = stationRouteIndex.get(key);
|
||||
if (station) return station;
|
||||
}
|
||||
|
||||
// 1. Try exact match
|
||||
if (stations[targetName]) return stations[targetName];
|
||||
|
||||
@@ -1385,20 +1430,13 @@ function handleTrainDeparture(data) {
|
||||
const existingIndex = activeTrains.findIndex(t => getBaseTrainId(t.trainId) === baseId);
|
||||
if (existingIndex >= 0) {
|
||||
const train = activeTrains[existingIndex];
|
||||
train.status = "In Transit";
|
||||
train.currentStation = "Transit (" + stationName + " -> " + (destination || "?") + ")";
|
||||
train.status = "Departed";
|
||||
train.currentStation = stationName;
|
||||
train.lastUpdate = Date.now();
|
||||
train.lastStation = stationName;
|
||||
train.departureTime = Date.now();
|
||||
train.destination = destination || train.destination;
|
||||
train.schedule = schedule || train.schedule;
|
||||
|
||||
if (!train.tripLogs) train.tripLogs = [];
|
||||
train.tripLogs.push({
|
||||
station: stationName,
|
||||
action: 'Departure',
|
||||
time: Date.now()
|
||||
});
|
||||
}
|
||||
broadcastUpdate();
|
||||
}
|
||||
@@ -1412,52 +1450,8 @@ function handlePlaySequence(ws, data) {
|
||||
|
||||
log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`);
|
||||
|
||||
// Process files sequentially
|
||||
let combinedBuffer = [];
|
||||
for (const file of seq) {
|
||||
if (file === '__DELAY_1__') {
|
||||
const silence = Buffer.alloc(6000, 0xAA);
|
||||
combinedBuffer.push(silence);
|
||||
continue;
|
||||
}
|
||||
|
||||
let foundPath = null;
|
||||
|
||||
const candidates = [
|
||||
path.join(audioStationDir, `${file}.dfpwm`),
|
||||
path.join(audioGeneralDir, `${file}.dfpwm`),
|
||||
path.join(audioStationDir, `${file}.wav`),
|
||||
path.join(audioGeneralDir, `${file}.wav`)
|
||||
];
|
||||
|
||||
for (const p of candidates) {
|
||||
if (fs.existsSync(p)) {
|
||||
foundPath = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (foundPath) {
|
||||
let dfpwmPath = foundPath;
|
||||
if (foundPath.endsWith('.wav')) {
|
||||
dfpwmPath = foundPath.replace(/\.wav$/, '.dfpwm');
|
||||
if (!fs.existsSync(dfpwmPath)) {
|
||||
convertToDfpwm(foundPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (fs.existsSync(dfpwmPath)) {
|
||||
combinedBuffer.push(fs.readFileSync(dfpwmPath));
|
||||
} else {
|
||||
log('WARN', `Could not get DFPWM for: ${file}`);
|
||||
}
|
||||
} else {
|
||||
log('WARN', `Audio missing on server: ${file}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (combinedBuffer.length > 0) {
|
||||
const finalBuffer = Buffer.concat(combinedBuffer);
|
||||
const builtAudio = buildSequenceWavBuffer(seq);
|
||||
if (builtAudio) {
|
||||
const priority = Number.isFinite(Number(data.priority))
|
||||
? Number(data.priority)
|
||||
: (String(data.category || '').toLowerCase() === 'station' ? 0 : 1);
|
||||
@@ -1467,7 +1461,9 @@ function handlePlaySequence(ws, data) {
|
||||
const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
|
||||
enqueueStationAudioTask(cleanStation, ws, {
|
||||
buffer: finalBuffer,
|
||||
buffer: builtAudio.buffer,
|
||||
audioFormat: builtAudio.audioFormat,
|
||||
durationSec: builtAudio.durationSec,
|
||||
priority,
|
||||
interruptMode,
|
||||
source,
|
||||
@@ -1476,6 +1472,8 @@ function handlePlaySequence(ws, data) {
|
||||
consoleStreamStart: { type: 'stream_start', station: cleanStation, seq: seq, streamName, hlsUrl },
|
||||
consoleStreamEnd: { type: 'stream_end', station: cleanStation }
|
||||
});
|
||||
} else {
|
||||
log('WARN', `Unable to build WAV sequence for ${cleanStation}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1746,24 +1744,20 @@ app.delete('/api/files', (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
||||
const fullPath = path.join(__dirname, 'public', safePath);
|
||||
|
||||
if (!fullPath.startsWith(uploadDir)) {
|
||||
return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||
}
|
||||
const resolved = resolveManagedPath(filePath);
|
||||
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||
const { fullPath, logicalPath } = resolved;
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
log('INFO', `Deleted file: ${safePath}`);
|
||||
log('INFO', `Deleted file: ${logicalPath}`);
|
||||
|
||||
// Also try to delete counterpart (.dfpwm if .wav deleted, or vice versa)
|
||||
if (safePath.endsWith('.wav')) {
|
||||
if (logicalPath.endsWith('.wav')) {
|
||||
const dfpwmPath = fullPath.replace(/\.wav$/, '.dfpwm');
|
||||
if (fs.existsSync(dfpwmPath)) fs.unlinkSync(dfpwmPath);
|
||||
} else if (safePath.endsWith('.dfpwm')) {
|
||||
} else if (logicalPath.endsWith('.dfpwm')) {
|
||||
const wavPath = fullPath.replace(/\.dfpwm$/, '.wav');
|
||||
if (fs.existsSync(wavPath)) fs.unlinkSync(wavPath);
|
||||
}
|
||||
@@ -1795,7 +1789,7 @@ app.post('/api/stations', express.json(), (req, res) => {
|
||||
platform: getPlatformNumberFromName(name),
|
||||
lastSeen: Date.now()
|
||||
};
|
||||
indexStation(stations[name]);
|
||||
normalizeStationRecord(stations[name]);
|
||||
saveData();
|
||||
broadcastUpdate();
|
||||
res.json({ success: true, station: { name, code, lastSeen: stations[name].lastSeen } });
|
||||
@@ -1813,7 +1807,6 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
|
||||
if (newName && newName !== name) {
|
||||
if (stations[newName]) return res.status(409).json({ success: false, message: 'New station name already exists' });
|
||||
stations[newName] = { ...st, name: newName };
|
||||
unindexStation(stations[name]);
|
||||
delete stations[name];
|
||||
targetName = newName;
|
||||
}
|
||||
@@ -1821,7 +1814,7 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
|
||||
if (nextCode !== undefined) stations[targetName].code = nextCode;
|
||||
stations[targetName].platform = getPlatformNumberFromName(stations[targetName].name);
|
||||
stations[targetName].lastSeen = stations[targetName].lastSeen || Date.now();
|
||||
indexStation(stations[targetName]);
|
||||
normalizeStationRecord(stations[targetName]);
|
||||
|
||||
saveData();
|
||||
broadcastUpdate();
|
||||
@@ -1831,7 +1824,6 @@ app.put('/api/stations/:name', express.json(), (req, res) => {
|
||||
app.delete('/api/stations/:name', (req, res) => {
|
||||
const name = req.params.name;
|
||||
if (stations[name]) {
|
||||
unindexStation(stations[name]);
|
||||
delete stations[name];
|
||||
log('INFO', `Deleted station: ${name}`);
|
||||
saveData();
|
||||
@@ -1873,12 +1865,9 @@ app.get('/api/files/content', (req, res) => {
|
||||
const filePath = req.query.path;
|
||||
if (!filePath) return res.status(400).json({ success: false, message: 'Path required' });
|
||||
|
||||
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
||||
const fullPath = path.join(__dirname, 'public', safePath);
|
||||
|
||||
if (!fullPath.startsWith(uploadDir)) {
|
||||
return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||
}
|
||||
const resolved = resolveManagedPath(filePath);
|
||||
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||
const { fullPath } = resolved;
|
||||
|
||||
if (fs.existsSync(fullPath)) {
|
||||
try {
|
||||
@@ -1896,16 +1885,13 @@ app.post('/api/files/content', express.json(), (req, res) => {
|
||||
const { path: filePath, content } = req.body;
|
||||
if (!filePath || content === undefined) return res.status(400).json({ success: false, message: 'Missing parameters' });
|
||||
|
||||
const safePath = path.normalize(filePath).replace(/^(\.\.[\/\\])+/, '');
|
||||
const fullPath = path.join(__dirname, 'public', safePath);
|
||||
|
||||
if (!fullPath.startsWith(uploadDir)) {
|
||||
return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||
}
|
||||
const resolved = resolveManagedPath(filePath);
|
||||
if (!resolved) return res.status(403).json({ success: false, message: 'Invalid path' });
|
||||
const { fullPath, logicalPath } = resolved;
|
||||
|
||||
try {
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
log('INFO', `Updated file: ${safePath}`);
|
||||
log('INFO', `Updated file: ${logicalPath}`);
|
||||
res.json({ success: true });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
@@ -2171,18 +2157,12 @@ function buildTempBroadcastFromWav(prefixWavPath, contentWavPath) {
|
||||
].join(';');
|
||||
|
||||
execSync(`ffmpeg -hide_banner -loglevel error -y -i "${prefixWavPath}" -i "${contentWavPath}" -filter_complex "${filterComplex}" -map "[a]" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`);
|
||||
|
||||
convertToDfpwm(outWavPath);
|
||||
|
||||
const dfpwmPath = outWavPath.replace(/\.wav$/i, '.dfpwm');
|
||||
if (!fs.existsSync(dfpwmPath)) throw new Error('DFPWM build failed');
|
||||
const dfpwmBuf = fs.readFileSync(dfpwmPath);
|
||||
const durationSec = dfpwmBuf.length / 6000;
|
||||
const wavBuf = fs.readFileSync(outWavPath);
|
||||
const durationSec = getWavDurationSec(wavBuf) || 0;
|
||||
|
||||
return {
|
||||
broadcastId,
|
||||
wavPath: outWavPath,
|
||||
dfpwmPath,
|
||||
durationSec
|
||||
};
|
||||
}
|
||||
@@ -2254,10 +2234,10 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
|
||||
if (urgency !== 'normal' && urgency !== 'emergency') return res.status(400).json({ success: false, message: 'Invalid urgency' });
|
||||
|
||||
const wavPath = path.join(tempBroadcastDir, `temp_${broadcastId}.wav`);
|
||||
const dfpwmPath = wavPath.replace(/\.wav$/i, '.dfpwm');
|
||||
if (!fs.existsSync(dfpwmPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (dfpwm)' });
|
||||
if (!fs.existsSync(wavPath)) return res.status(404).json({ success: false, message: 'Temp broadcast not found (wav)' });
|
||||
|
||||
const dfpwmBuf = fs.readFileSync(dfpwmPath);
|
||||
const wavBuf = fs.readFileSync(wavPath);
|
||||
const durationSec = getWavDurationSec(wavBuf) || 0;
|
||||
const priority = urgency === 'emergency' ? 2 : 0;
|
||||
const interruptMode = urgency === 'emergency' ? 'all' : 'none';
|
||||
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
|
||||
@@ -2277,7 +2257,9 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
|
||||
continue;
|
||||
}
|
||||
enqueueStationAudioTask(name, ws, {
|
||||
buffer: dfpwmBuf,
|
||||
buffer: wavBuf,
|
||||
audioFormat: 'wav',
|
||||
durationSec,
|
||||
priority,
|
||||
interruptMode,
|
||||
source,
|
||||
@@ -2305,9 +2287,9 @@ app.post('/api/music/start', express.json(), (req, res) => {
|
||||
if (!track) return res.status(400).json({ success: false, message: 'track required' });
|
||||
if (!Array.isArray(stationsReq) || stationsReq.length === 0) return res.status(400).json({ success: false, message: 'stations required' });
|
||||
|
||||
let dfpwmBuf;
|
||||
let playbackAudio;
|
||||
try {
|
||||
dfpwmBuf = getDfpwmBufferByBaseName(track);
|
||||
playbackAudio = getPlaybackAudioByBaseName(track);
|
||||
} catch (e) {
|
||||
return res.status(404).json({ success: false, message: e?.message || String(e) });
|
||||
}
|
||||
@@ -2338,7 +2320,9 @@ app.post('/api/music/start', express.json(), (req, res) => {
|
||||
}
|
||||
|
||||
enqueueStationAudioTask(name, currentWs, {
|
||||
buffer: dfpwmBuf,
|
||||
buffer: playbackAudio.buffer,
|
||||
audioFormat: playbackAudio.audioFormat,
|
||||
durationSec: playbackAudio.durationSec,
|
||||
priority: -1,
|
||||
interruptMode: 'none',
|
||||
source: 'music',
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
|
||||
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
|
||||
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
|
||||
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/commit/e4b4fa5cdb2bc42279fd87098fef0d27a90e23d5/station_broadcast.lua"
|
||||
|
||||
local function fetch(url)
|
||||
if not http then
|
||||
@@ -66,9 +64,7 @@ local function downloadTo(url, path)
|
||||
end
|
||||
|
||||
local targets = {
|
||||
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
|
||||
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
|
||||
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
|
||||
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }
|
||||
}
|
||||
|
||||
for _, t in ipairs(targets) do
|
||||
|
||||
@@ -10,6 +10,8 @@ local COLORS = {
|
||||
SUCCESS = colors.green
|
||||
}
|
||||
|
||||
local dfpwm = require "cc.audio.dfpwm"
|
||||
|
||||
-- Debug output function
|
||||
local function debugPrint(level, message)
|
||||
local timestamp = os.date("%H:%M:%S")
|
||||
@@ -21,6 +23,52 @@ local function debugPrint(level, message)
|
||||
term.setTextColor(oldColor)
|
||||
end
|
||||
|
||||
local function playDfpwmFile(path, volume)
|
||||
local speakers = { peripheral.find("speaker") }
|
||||
if #speakers == 0 then
|
||||
debugPrint("ERROR", "No speakers found")
|
||||
return false
|
||||
end
|
||||
|
||||
local file = fs.open(path, "rb")
|
||||
if not file then
|
||||
debugPrint("ERROR", "Cannot open audio file: " .. path)
|
||||
return false
|
||||
end
|
||||
|
||||
local decoder = dfpwm.make_decoder()
|
||||
local controller = speakers[1]
|
||||
while true do
|
||||
local chunk = file.read(16 * 1024)
|
||||
if not chunk then break end
|
||||
local samples = decoder(chunk)
|
||||
|
||||
if controller and type(controller.playAudioAll) == "function" then
|
||||
while not controller.playAudioAll(samples, volume) do
|
||||
os.pullEvent("speaker_audio_empty")
|
||||
end
|
||||
else
|
||||
local pending = {}
|
||||
for _, sp in ipairs(speakers) do
|
||||
pending[peripheral.getName(sp)] = sp
|
||||
end
|
||||
while next(pending) do
|
||||
for name, sp in pairs(pending) do
|
||||
if sp.playAudio(samples, volume) then
|
||||
pending[name] = nil
|
||||
end
|
||||
end
|
||||
if next(pending) then
|
||||
os.pullEvent("speaker_audio_empty")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
file.close()
|
||||
return true
|
||||
end
|
||||
|
||||
-- Add random audio playback function
|
||||
local function playRandomAudio()
|
||||
local audioFiles = {"aqtx", "jzxy"}
|
||||
@@ -29,7 +77,7 @@ local function playRandomAudio()
|
||||
|
||||
if fs.exists(path) then
|
||||
debugPrint("INFO", "Attempting to play: "..path)
|
||||
shell.run("austream "..path.." volume=3.0")
|
||||
playDfpwmFile(path, 3.0)
|
||||
debugPrint("INFO", "Finished playing: "..randomFile)
|
||||
else
|
||||
debugPrint("ERROR", "Audio file not found: "..path)
|
||||
|
||||
+157
-20
@@ -14,7 +14,7 @@ local CONFIG = {
|
||||
DEBUG = true
|
||||
}
|
||||
|
||||
local aukit = require "aukit"
|
||||
local dfpwm = require "cc.audio.dfpwm"
|
||||
|
||||
local COLORS = {
|
||||
INFO = colors.white,
|
||||
@@ -527,12 +527,14 @@ end
|
||||
|
||||
function Audio.enqueueDfpwm(dfpwmData)
|
||||
if type(dfpwmData) ~= "string" or #dfpwmData == 0 then return end
|
||||
Audio.enqueueItem({ dfpwmData = dfpwmData, priority = 1 })
|
||||
Audio.enqueueItem({ audioData = dfpwmData, audioFormat = "dfpwm", priority = 1 })
|
||||
end
|
||||
|
||||
function Audio.enqueueItem(item)
|
||||
if type(item) ~= "table" then return end
|
||||
if type(item.dfpwmData) ~= "string" and type(item.dfpwmData) ~= "table" then return end
|
||||
item.audioData = item.audioData or item.dfpwmData
|
||||
item.audioFormat = tostring(item.audioFormat or (item.dfpwmData and "dfpwm") or "dfpwm"):lower()
|
||||
if type(item.audioData) ~= "string" and type(item.audioData) ~= "table" then return end
|
||||
item.priority = tonumber(item.priority) or 0
|
||||
local idx = 1
|
||||
while idx <= #State.audioQueue do
|
||||
@@ -578,6 +580,19 @@ local function shouldStop(playSeq)
|
||||
return State.interruptSeq ~= playSeq
|
||||
end
|
||||
|
||||
local function waitForInterruptibleDelay(playSeq, seconds)
|
||||
local timer = os.startTimer(seconds)
|
||||
while true do
|
||||
local ev = { os.pullEvent() }
|
||||
if ev[1] == "audio_interrupt" and tonumber(ev[2]) and tonumber(ev[2]) >= playSeq then
|
||||
return false
|
||||
end
|
||||
if ev[1] == "timer" and ev[2] == timer then
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function playChunkOnSpeakers(samples, volume, playSeq)
|
||||
if not Peripherals.speakers or #Peripherals.speakers == 0 then return false end
|
||||
local controller = Peripherals.speakers[1]
|
||||
@@ -639,6 +654,82 @@ local function playChunkOnSpeakers(samples, volume, playSeq)
|
||||
return true
|
||||
end
|
||||
|
||||
local function areHqSpeakersBusy(speakers)
|
||||
for _, sp in ipairs(speakers) do
|
||||
local playing = false
|
||||
local queued = 0
|
||||
pcall(function()
|
||||
if type(sp.speakIsPlaying) == "function" then
|
||||
playing = sp.speakIsPlaying() and true or false
|
||||
end
|
||||
if type(sp.speakQueueSize) == "function" then
|
||||
queued = tonumber(sp.speakQueueSize()) or 0
|
||||
end
|
||||
end)
|
||||
if playing or queued > 0 then return true end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local function playHqAudioItem(item)
|
||||
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
||||
Utils.log("WARN", "No speakers found; cannot play audio")
|
||||
return
|
||||
end
|
||||
|
||||
local playSeq = State.interruptSeq
|
||||
local payload = item.audioData
|
||||
if type(payload) ~= "string" or #payload == 0 then return end
|
||||
|
||||
local format = tostring(item.audioFormat or "file"):lower()
|
||||
local controller = Peripherals.speakers[1]
|
||||
local called = false
|
||||
|
||||
if controller then
|
||||
local ok, result = pcall(function()
|
||||
if format == "wav" and type(controller.speakWavAll) == "function" then
|
||||
return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
|
||||
elseif type(controller.speakFileAll) == "function" then
|
||||
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
|
||||
end
|
||||
end)
|
||||
if ok and result ~= false then
|
||||
called = true
|
||||
end
|
||||
end
|
||||
|
||||
if not called then
|
||||
for _, sp in ipairs(Peripherals.speakers) do
|
||||
local ok, result = pcall(function()
|
||||
if format == "wav" and type(sp.speakWav) == "function" then
|
||||
return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
|
||||
elseif type(sp.speakFile) == "function" then
|
||||
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
|
||||
end
|
||||
end)
|
||||
if ok and result ~= false then
|
||||
called = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not called then
|
||||
Utils.log("ERROR", "HQ speaker API not available for format " .. format)
|
||||
return
|
||||
end
|
||||
|
||||
while areHqSpeakersBusy(Peripherals.speakers) do
|
||||
if shouldStop(playSeq) then
|
||||
Audio.stop("current")
|
||||
return
|
||||
end
|
||||
if not waitForInterruptibleDelay(playSeq, 0.1) then
|
||||
Audio.stop("current")
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Audio.playDfpwmItem(item)
|
||||
if not Peripherals.speakers or #Peripherals.speakers == 0 then
|
||||
Utils.log("WARN", "No speakers found; cannot play audio")
|
||||
@@ -646,24 +737,43 @@ function Audio.playDfpwmItem(item)
|
||||
end
|
||||
|
||||
local playSeq = State.interruptSeq
|
||||
local source = item.dfpwmData
|
||||
local source = item.audioData
|
||||
|
||||
-- Support both static string data and streaming functions
|
||||
if type(source) == "table" and source.read then
|
||||
source = source.read
|
||||
end
|
||||
|
||||
local ok, iter = pcall(aukit.stream.dfpwm, source, 48000, 1, false)
|
||||
if not ok then
|
||||
Utils.log("ERROR", "Failed to create audio stream: " .. tostring(iter))
|
||||
return
|
||||
if type(source) ~= "function" then
|
||||
local data = source
|
||||
source = function()
|
||||
local chunk = data
|
||||
data = nil
|
||||
return chunk
|
||||
end
|
||||
end
|
||||
|
||||
for chunk in iter do
|
||||
local decoder = dfpwm.make_decoder()
|
||||
while true do
|
||||
if shouldStop(playSeq) then break end
|
||||
if not playChunkOnSpeakers(chunk[1], CONFIG.AUDIO_VOLUME, playSeq) then break end
|
||||
local chunk = source()
|
||||
if chunk == nil then break end
|
||||
if type(chunk) == "string" and #chunk > 0 then
|
||||
local samples = decoder(chunk)
|
||||
if samples and not playChunkOnSpeakers(samples, CONFIG.AUDIO_VOLUME, playSeq) then
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Audio.playItem(item)
|
||||
local format = tostring(item.audioFormat or "dfpwm"):lower()
|
||||
if format == "wav" or format == "file" then
|
||||
return playHqAudioItem(item)
|
||||
end
|
||||
return Audio.playDfpwmItem(item)
|
||||
end
|
||||
|
||||
function Audio.playerLoop()
|
||||
while true do
|
||||
@@ -674,7 +784,7 @@ function Audio.playerLoop()
|
||||
State.currentPlaying = item
|
||||
State.currentPriority = tonumber(item.priority) or 0
|
||||
|
||||
local ok, err = pcall(Audio.playDfpwmItem, item)
|
||||
local ok, err = pcall(Audio.playItem, item)
|
||||
if not ok then
|
||||
Utils.log("ERROR", "Audio Playback Crash: " .. tostring(err))
|
||||
end
|
||||
@@ -927,6 +1037,23 @@ function Network.syncFiles()
|
||||
end
|
||||
|
||||
function Network.messageLoop()
|
||||
local function finishAudioRx()
|
||||
if not State.audioRx then return end
|
||||
local rx = State.audioRx
|
||||
if rx.stream then
|
||||
rx.stream.finish()
|
||||
else
|
||||
Audio.enqueueItem({
|
||||
audioData = table.concat(rx.chunks or {}),
|
||||
audioFormat = rx.audioFormat or "wav",
|
||||
priority = rx.priority,
|
||||
source = rx.source,
|
||||
audioId = rx.audioId
|
||||
})
|
||||
end
|
||||
State.audioRx = nil
|
||||
end
|
||||
|
||||
while true do
|
||||
local event, url, msg = os.pullEvent()
|
||||
if event == "websocket_message" and url == CONFIG.CLOUD_URL then
|
||||
@@ -937,6 +1064,7 @@ function Network.messageLoop()
|
||||
pcall(State.audioRx.stream.finish)
|
||||
end
|
||||
local priority = tonumber(data.priority) or 0
|
||||
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower()
|
||||
local interruptMode = tostring(data.interruptMode or "")
|
||||
if interruptMode == "" then
|
||||
if priority >= 2 then interruptMode = "all"
|
||||
@@ -950,27 +1078,31 @@ function Network.messageLoop()
|
||||
Audio.stop("current", priority)
|
||||
end
|
||||
|
||||
local stream = Audio.createStream()
|
||||
State.audioRx = {
|
||||
audioId = data.audioId,
|
||||
expectedBytes = tonumber(data.byteLength) or 0,
|
||||
receivedBytes = 0,
|
||||
stream = stream,
|
||||
priority = priority,
|
||||
source = data.source or data.category
|
||||
source = data.source or data.category,
|
||||
audioFormat = audioFormat
|
||||
}
|
||||
|
||||
-- Start playing immediately by enqueuing the stream
|
||||
if audioFormat == "dfpwm" then
|
||||
local stream = Audio.createStream()
|
||||
State.audioRx.stream = stream
|
||||
Audio.enqueueItem({
|
||||
dfpwmData = stream,
|
||||
audioData = stream,
|
||||
audioFormat = audioFormat,
|
||||
priority = priority,
|
||||
source = State.audioRx.source,
|
||||
audioId = data.audioId
|
||||
})
|
||||
else
|
||||
State.audioRx.chunks = {}
|
||||
end
|
||||
elseif data.type == "audio_end" then
|
||||
if State.audioRx and (not data.audioId or data.audioId == State.audioRx.audioId) then
|
||||
State.audioRx.stream.finish()
|
||||
State.audioRx = nil
|
||||
finishAudioRx()
|
||||
end
|
||||
elseif data.type == "audio_stop" then
|
||||
local scope = tostring(data.scope or "")
|
||||
@@ -1001,14 +1133,19 @@ function Network.messageLoop()
|
||||
-- Memory protection: limit to 10MB per audio item
|
||||
if State.audioRx.receivedBytes + #msg > 10 * 1024 * 1024 then
|
||||
Utils.log("ERROR", "Audio item too large, discarding")
|
||||
if State.audioRx.stream then
|
||||
State.audioRx.stream.finish()
|
||||
end
|
||||
State.audioRx = nil
|
||||
else
|
||||
if State.audioRx.stream then
|
||||
State.audioRx.stream.push(msg)
|
||||
else
|
||||
table.insert(State.audioRx.chunks, msg)
|
||||
end
|
||||
State.audioRx.receivedBytes = State.audioRx.receivedBytes + #msg
|
||||
if State.audioRx.expectedBytes > 0 and State.audioRx.receivedBytes >= State.audioRx.expectedBytes then
|
||||
State.audioRx.stream.finish()
|
||||
State.audioRx = nil
|
||||
finishAudioRx()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
local STATION_BROADCAST_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/station_broadcast.lua?sign=xXsMwezjnEs4c6QrjkJkbvz1rARokmm87zwDcDDS5dk=:0"
|
||||
local AUKIT_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/aukit.lua?sign=02_QDktYbSrHrtCDsFlKMkbEOWJGDautZaOtWkvg5q8=:0"
|
||||
local AUSTREAM_URL = "https://cloud.fse-media.group/d/API/StationBroadcast/austream.lua?sign=DrLbf9CIX1ZY8DaL8vliZiCMQzGos10nhG26Wf5lbgA=:0"
|
||||
local STATION_BROADCAST_URL = "https://gitea.fse-media.group/Henry_Du/CC-StationBroadcast/raw/commit/e4b4fa5cdb2bc42279fd87098fef0d27a90e23d5/station_broadcast.lua"
|
||||
|
||||
local function fetch(url)
|
||||
if not http then
|
||||
@@ -66,9 +64,7 @@ local function downloadTo(url, path)
|
||||
end
|
||||
|
||||
local targets = {
|
||||
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") },
|
||||
{ url = AUKIT_URL, path = shell.resolve("/aukit.lua") },
|
||||
{ url = AUSTREAM_URL, path = shell.resolve("/austream.lua") }
|
||||
{ url = STATION_BROADCAST_URL, path = shell.resolve("/startup") }
|
||||
}
|
||||
|
||||
for _, t in ipairs(targets) do
|
||||
|
||||
Reference in New Issue
Block a user