feat: 重构音频系统,移除外部依赖并新增WAV格式支持
- 移除install和update脚本中的aukit/austream下载配置与目标,不再部署辅助脚本 - 替换station_broadcast.lua的aukit库为CC内置dfpwm模块,重写音频播放逻辑 - 添加原生扬声器API支持,支持WAV格式音频播放 - 重写云端服务器音频处理逻辑,支持直接传输WAV文件无需预转换 - 新增WAV文件时长解析、音频序列合成等辅助功能
This commit is contained in:
+103
-80
@@ -177,21 +177,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 +256,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 +403,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);
|
||||
@@ -767,6 +813,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);
|
||||
@@ -1412,52 +1477,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 +1488,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 +1499,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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2171,18 +2196,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 +2273,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 +2296,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 +2326,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 +2359,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',
|
||||
|
||||
Reference in New Issue
Block a user