diff --git a/CloudServer/server.js b/CloudServer/server.js index ccf795b..0023401 100644 --- a/CloudServer/server.js +++ b/CloudServer/server.js @@ -24,6 +24,7 @@ const HLS_OUTPUT_MODE = (process.env.HLS_OUTPUT_MODE || 'srs').toLowerCase(); const SRS_RTMP_BASE = process.env.SRS_RTMP_BASE || 'rtmp://127.0.0.1/live'; const STABROADCAST_PUBLIC_BASE_DEFAULT = 'https://stabroadcast.fse-media.group'; const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT; +const STABROADCAST_API_BASE = process.env.STABROADCAST_API_BASE || 'http://stabadmin.fse-media.group'; 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'; @@ -94,6 +95,26 @@ function sendListFile(res) { app.get('/list.txt', (req, res) => sendListFile(res)); app.get('/uploads/list.txt', (req, res) => sendListFile(res)); +function sendBinaryFile(res, filePath) { + if (!fs.existsSync(filePath)) return res.status(404).send('Not found'); + res.sendFile(filePath); +} + +app.get('/media/audio/general/:file', (req, res) => { + const name = path.basename(String(req.params.file || '')); + sendBinaryFile(res, path.join(audioGeneralDir, name)); +}); + +app.get('/media/audio/station/:file', (req, res) => { + const name = path.basename(String(req.params.file || '')); + sendBinaryFile(res, path.join(audioStationDir, name)); +}); + +app.get('/media/temp/:file', (req, res) => { + const name = path.basename(String(req.params.file || '')); + sendBinaryFile(res, path.join(tempBroadcastDir, name)); +}); + app.use(express.static(path.join(__dirname, 'public'))); app.set('view engine', 'ejs'); @@ -197,18 +218,20 @@ async function sendAudioBufferToStation(stationName, fallbackWs, task) { 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 buf = task.buffer || null; const durationSec = Number(task.durationSec || 0); const audioFormat = String(task.audioFormat || 'dfpwm').toLowerCase(); + const streamUrl = task.streamUrl ? String(task.streamUrl) : null; const startMsg = { type: 'audio_start', stationName, audioId, - byteLength: buf.length, + byteLength: buf ? buf.length : 0, sampleRate: Number(task.sampleRate || 48000), channels: Number(task.channels || 1), audioFormat, + streamUrl, priority: Number(task.priority || 0), interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)), source: task.source || 'unknown' @@ -216,6 +239,10 @@ async function sendAudioBufferToStation(stationName, fallbackWs, task) { targetWs.send(JSON.stringify(startMsg)); + if (streamUrl) { + return { audioId, cancelled: false, durationSec }; + } + const token = task.cancelToken; const SINGLE_SEND_LIMIT = 32 * 1024; const CHUNK_SIZE = 16 * 1024; @@ -255,6 +282,15 @@ function normalizeBaseName(name) { return base || null; } +function getApiBaseUrl() { + return String(STABROADCAST_API_BASE || '').replace(/\/+$/g, ''); +} + +function getManagedMediaUrl(kind, fileName) { + const base = getApiBaseUrl(); + return `${base}/media/${kind}/${encodeURIComponent(fileName)}`; +} + function listMusicTracks() { const out = new Set(); if (!fs.existsSync(audioGeneralDir)) return []; @@ -315,8 +351,8 @@ function getPlaybackAudioByBaseName(baseName) { if (fs.existsSync(p)) { const buffer = fs.readFileSync(p); return { - buffer, - audioFormat: 'wav', + audioFormat: 'stream', + streamUrl: getManagedMediaUrl(p.startsWith(audioStationDir) ? 'audio/station' : 'audio/general', path.basename(p)), durationSec: getWavDurationSec(buffer) || 0 }; } @@ -820,22 +856,26 @@ function buildConcatListFile(seq) { return listPath; } -function buildSequenceWavBuffer(seq) { +function buildSequenceWavFile(seq) { const listPath = buildConcatListFile(seq); if (!listPath) return null; - const outWavPath = path.join(tmpDir, `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`); + const fileName = `sequence-${Date.now()}-${Math.random().toString(16).slice(2)}.wav`; + const outWavPath = path.join(tempBroadcastDir, fileName); 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); + const durationSec = getWavDurationSec(buffer) || 0; + setTimeout(() => { + try { fs.unlinkSync(outWavPath); } catch {} + }, Math.max(10 * 60 * 1000, Math.ceil(durationSec * 2000))); return { - buffer, - audioFormat: 'wav', - durationSec: getWavDurationSec(buffer) || 0 + audioFormat: 'stream', + streamUrl: getManagedMediaUrl('temp', fileName), + durationSec }; } finally { try { fs.unlinkSync(listPath); } catch {} - try { fs.unlinkSync(outWavPath); } catch {} } } @@ -1462,7 +1502,7 @@ function handlePlaySequence(ws, data) { log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`); - const builtAudio = buildSequenceWavBuffer(seq); + const builtAudio = buildSequenceWavFile(seq); if (builtAudio) { const priority = Number.isFinite(Number(data.priority)) ? Number(data.priority) @@ -1473,8 +1513,8 @@ function handlePlaySequence(ws, data) { const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`); enqueueStationAudioTask(cleanStation, ws, { - buffer: builtAudio.buffer, audioFormat: builtAudio.audioFormat, + streamUrl: builtAudio.streamUrl, durationSec: builtAudio.durationSec, priority, interruptMode, @@ -2250,6 +2290,7 @@ app.post('/api/temp-broadcast/publish', (req, res) => { const wavBuf = fs.readFileSync(wavPath); const durationSec = getWavDurationSec(wavBuf) || 0; + const streamUrl = getManagedMediaUrl('temp', path.basename(wavPath)); const priority = urgency === 'emergency' ? 2 : 0; const interruptMode = urgency === 'emergency' ? 'all' : 'none'; const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal'; @@ -2269,8 +2310,8 @@ app.post('/api/temp-broadcast/publish', (req, res) => { continue; } enqueueStationAudioTask(name, ws, { - buffer: wavBuf, - audioFormat: 'wav', + audioFormat: 'stream', + streamUrl, durationSec, priority, interruptMode, @@ -2332,8 +2373,9 @@ app.post('/api/music/start', express.json(), (req, res) => { } enqueueStationAudioTask(name, currentWs, { - buffer: playbackAudio.buffer, audioFormat: playbackAudio.audioFormat, + buffer: playbackAudio.buffer, + streamUrl: playbackAudio.streamUrl, durationSec: playbackAudio.durationSec, priority: -1, interruptMode: 'none', diff --git a/station_broadcast.lua b/station_broadcast.lua index e0df46b..fa3f049 100644 --- a/station_broadcast.lua +++ b/station_broadcast.lua @@ -687,7 +687,9 @@ local function playHqAudioItem(item) if controller then local ok, result = pcall(function() - if format == "wav" and type(controller.speakWavAll) == "function" then + if format == "stream" and type(controller.speakStreamAll) == "function" then + return controller.speakStreamAll(payload, CONFIG.AUDIO_VOLUME) + elseif 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) @@ -701,7 +703,9 @@ local function playHqAudioItem(item) 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 + if format == "stream" and type(sp.speakStream) == "function" then + return sp.speakStream(payload, CONFIG.AUDIO_VOLUME) + elseif 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) @@ -769,7 +773,7 @@ end function Audio.playItem(item) local format = tostring(item.audioFormat or "dfpwm"):lower() - if format == "wav" or format == "file" then + if format == "wav" or format == "file" or format == "stream" then return playHqAudioItem(item) end return Audio.playDfpwmItem(item) @@ -1068,6 +1072,7 @@ function Network.messageLoop() end local priority = tonumber(data.priority) or 0 local audioFormat = tostring(data.audioFormat or "dfpwm"):lower() + local streamUrl = type(data.streamUrl) == "string" and data.streamUrl or nil local interruptMode = tostring(data.interruptMode or "") if interruptMode == "" then if priority >= 2 then interruptMode = "all" @@ -1090,7 +1095,16 @@ function Network.messageLoop() audioFormat = audioFormat } - if audioFormat == "dfpwm" then + if audioFormat == "stream" and streamUrl and streamUrl ~= "" then + Audio.enqueueItem({ + audioData = streamUrl, + audioFormat = "stream", + priority = priority, + source = State.audioRx.source, + audioId = data.audioId + }) + State.audioRx = nil + elseif audioFormat == "dfpwm" then local stream = Audio.createStream() State.audioRx.stream = stream Audio.enqueueItem({