feat: add stream audio broadcast support

- 添加stream音频格式的客户端播放逻辑与网络消息解析
- 新增云服务端媒体静态服务端点,重构音频处理逻辑以返回流URL替代原始音频缓冲区
- 更新所有相关业务接口适配新的流音频播放流程
- 新增API基础地址环境变量配置
This commit is contained in:
2026-07-31 20:15:21 +08:00
parent cd22b1ca3e
commit 952efbc2c7
2 changed files with 75 additions and 19 deletions
+57 -15
View File
@@ -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 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_DEFAULT = 'https://stabroadcast.fse-media.group';
const STABROADCAST_PUBLIC_BASE = process.env.STABROADCAST_PUBLIC_BASE || STABROADCAST_PUBLIC_BASE_DEFAULT; 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 STABROADCAST_HLS_SUFFIX = process.env.STABROADCAST_HLS_SUFFIX || '.m3u8';
const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0'); const HLS_TAIL_SILENCE_SEC = Number(process.env.HLS_TAIL_SILENCE_SEC || '0');
const HLS_PREBUILD = process.env.HLS_PREBUILD !== '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('/list.txt', (req, res) => sendListFile(res));
app.get('/uploads/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.use(express.static(path.join(__dirname, 'public')));
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
@@ -197,18 +218,20 @@ async function sendAudioBufferToStation(stationName, fallbackWs, task) {
if (!targetWs) throw new Error(`Station "${stationName}" not connected`); if (!targetWs) throw new Error(`Station "${stationName}" not connected`);
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 buf = task.buffer; const buf = task.buffer || null;
const durationSec = Number(task.durationSec || 0); 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 startMsg = { const startMsg = {
type: 'audio_start', type: 'audio_start',
stationName, stationName,
audioId, audioId,
byteLength: buf.length, byteLength: buf ? buf.length : 0,
sampleRate: Number(task.sampleRate || 48000), sampleRate: Number(task.sampleRate || 48000),
channels: Number(task.channels || 1), channels: Number(task.channels || 1),
audioFormat, audioFormat,
streamUrl,
priority: Number(task.priority || 0), priority: Number(task.priority || 0),
interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)), interruptMode: task.interruptMode || getInterruptModeForPriority(Number(task.priority || 0)),
source: task.source || 'unknown' source: task.source || 'unknown'
@@ -216,6 +239,10 @@ async function sendAudioBufferToStation(stationName, fallbackWs, task) {
targetWs.send(JSON.stringify(startMsg)); targetWs.send(JSON.stringify(startMsg));
if (streamUrl) {
return { audioId, cancelled: false, durationSec };
}
const token = task.cancelToken; const token = task.cancelToken;
const SINGLE_SEND_LIMIT = 32 * 1024; const SINGLE_SEND_LIMIT = 32 * 1024;
const CHUNK_SIZE = 16 * 1024; const CHUNK_SIZE = 16 * 1024;
@@ -255,6 +282,15 @@ function normalizeBaseName(name) {
return base || null; 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() { function listMusicTracks() {
const out = new Set(); const out = new Set();
if (!fs.existsSync(audioGeneralDir)) return []; if (!fs.existsSync(audioGeneralDir)) return [];
@@ -315,8 +351,8 @@ function getPlaybackAudioByBaseName(baseName) {
if (fs.existsSync(p)) { if (fs.existsSync(p)) {
const buffer = fs.readFileSync(p); const buffer = fs.readFileSync(p);
return { return {
buffer, audioFormat: 'stream',
audioFormat: 'wav', streamUrl: getManagedMediaUrl(p.startsWith(audioStationDir) ? 'audio/station' : 'audio/general', path.basename(p)),
durationSec: getWavDurationSec(buffer) || 0 durationSec: getWavDurationSec(buffer) || 0
}; };
} }
@@ -820,22 +856,26 @@ function buildConcatListFile(seq) {
return listPath; return listPath;
} }
function buildSequenceWavBuffer(seq) { function buildSequenceWavFile(seq) {
const listPath = buildConcatListFile(seq); const listPath = buildConcatListFile(seq);
if (!listPath) return null; 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 { try {
execSync(`ffmpeg -hide_banner -loglevel error -y -f concat -safe 0 -i "${listPath}" -ac 1 -ar 48000 -c:a pcm_s16le "${outWavPath}"`); 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 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 { return {
buffer, audioFormat: 'stream',
audioFormat: 'wav', streamUrl: getManagedMediaUrl('temp', fileName),
durationSec: getWavDurationSec(buffer) || 0 durationSec
}; };
} finally { } finally {
try { fs.unlinkSync(listPath); } catch {} 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(', ')}`); log('INFO', `Playing sequence for ${correctStation}: ${seq.join(', ')}`);
const builtAudio = buildSequenceWavBuffer(seq); const builtAudio = buildSequenceWavFile(seq);
if (builtAudio) { if (builtAudio) {
const priority = Number.isFinite(Number(data.priority)) const priority = Number.isFinite(Number(data.priority))
? 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)}`); const audioId = String(requestId || `${Date.now()}-${Math.random().toString(16).slice(2)}`);
enqueueStationAudioTask(cleanStation, ws, { enqueueStationAudioTask(cleanStation, ws, {
buffer: builtAudio.buffer,
audioFormat: builtAudio.audioFormat, audioFormat: builtAudio.audioFormat,
streamUrl: builtAudio.streamUrl,
durationSec: builtAudio.durationSec, durationSec: builtAudio.durationSec,
priority, priority,
interruptMode, interruptMode,
@@ -2250,6 +2290,7 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
const wavBuf = fs.readFileSync(wavPath); const wavBuf = fs.readFileSync(wavPath);
const durationSec = getWavDurationSec(wavBuf) || 0; const durationSec = getWavDurationSec(wavBuf) || 0;
const streamUrl = getManagedMediaUrl('temp', path.basename(wavPath));
const priority = urgency === 'emergency' ? 2 : 0; const priority = urgency === 'emergency' ? 2 : 0;
const interruptMode = urgency === 'emergency' ? 'all' : 'none'; const interruptMode = urgency === 'emergency' ? 'all' : 'none';
const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal'; const source = urgency === 'emergency' ? 'temp_emergency' : 'temp_normal';
@@ -2269,8 +2310,8 @@ app.post('/api/temp-broadcast/publish', (req, res) => {
continue; continue;
} }
enqueueStationAudioTask(name, ws, { enqueueStationAudioTask(name, ws, {
buffer: wavBuf, audioFormat: 'stream',
audioFormat: 'wav', streamUrl,
durationSec, durationSec,
priority, priority,
interruptMode, interruptMode,
@@ -2332,8 +2373,9 @@ app.post('/api/music/start', express.json(), (req, res) => {
} }
enqueueStationAudioTask(name, currentWs, { enqueueStationAudioTask(name, currentWs, {
buffer: playbackAudio.buffer,
audioFormat: playbackAudio.audioFormat, audioFormat: playbackAudio.audioFormat,
buffer: playbackAudio.buffer,
streamUrl: playbackAudio.streamUrl,
durationSec: playbackAudio.durationSec, durationSec: playbackAudio.durationSec,
priority: -1, priority: -1,
interruptMode: 'none', interruptMode: 'none',
+18 -4
View File
@@ -687,7 +687,9 @@ local function playHqAudioItem(item)
if controller then if controller then
local ok, result = pcall(function() 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) return controller.speakWavAll(payload, CONFIG.AUDIO_VOLUME)
elseif type(controller.speakFileAll) == "function" then elseif type(controller.speakFileAll) == "function" then
return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME) return controller.speakFileAll(payload, CONFIG.AUDIO_VOLUME)
@@ -701,7 +703,9 @@ local function playHqAudioItem(item)
if not called then if not called then
for _, sp in ipairs(Peripherals.speakers) do for _, sp in ipairs(Peripherals.speakers) do
local ok, result = pcall(function() 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) return sp.speakWav(payload, CONFIG.AUDIO_VOLUME)
elseif type(sp.speakFile) == "function" then elseif type(sp.speakFile) == "function" then
return sp.speakFile(payload, CONFIG.AUDIO_VOLUME) return sp.speakFile(payload, CONFIG.AUDIO_VOLUME)
@@ -769,7 +773,7 @@ end
function Audio.playItem(item) function Audio.playItem(item)
local format = tostring(item.audioFormat or "dfpwm"):lower() 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) return playHqAudioItem(item)
end end
return Audio.playDfpwmItem(item) return Audio.playDfpwmItem(item)
@@ -1068,6 +1072,7 @@ function Network.messageLoop()
end end
local priority = tonumber(data.priority) or 0 local priority = tonumber(data.priority) or 0
local audioFormat = tostring(data.audioFormat or "dfpwm"):lower() 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 "") local interruptMode = tostring(data.interruptMode or "")
if interruptMode == "" then if interruptMode == "" then
if priority >= 2 then interruptMode = "all" if priority >= 2 then interruptMode = "all"
@@ -1090,7 +1095,16 @@ function Network.messageLoop()
audioFormat = audioFormat 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() local stream = Audio.createStream()
State.audioRx.stream = stream State.audioRx.stream = stream
Audio.enqueueItem({ Audio.enqueueItem({