Add audio validation, seamless transitions, and quality improvements for music streaming
This commit is contained in:
@@ -28,17 +28,29 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
|
||||
'-i', 'pipe:0'
|
||||
);
|
||||
|
||||
// Input 1: audio from concat playlist
|
||||
// Input 1: audio from concat playlist with smoother demuxing
|
||||
ffmpegArgs.push(
|
||||
'-f', 'concat',
|
||||
'-safe', '0',
|
||||
'-protocol_whitelist', 'file,pipe',
|
||||
'-stream_loop', '-1', // Loop playlist indefinitely
|
||||
'-i', playlistFile
|
||||
);
|
||||
|
||||
// Encoding with audio filtering for smooth transitions
|
||||
// Encoding with robust audio filtering to prevent distortion and transition glitches
|
||||
ffmpegArgs.push(
|
||||
// Use audio filter to ensure smooth transitions and consistent format
|
||||
'-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0,aformat=sample_rates=44100:channel_layouts=stereo',
|
||||
// Complex audio filter chain:
|
||||
// 1. aresample - Resample with async mode and smooth transitions
|
||||
// 2. asetpts - Reset timestamps to prevent drift
|
||||
// 3. afade - Add micro-crossfades at discontinuities to smooth transitions
|
||||
// 4. dynaudnorm - Dynamic audio normalization with transition smoothing
|
||||
// 5. alimiter - Hard limiter to prevent clipping
|
||||
// 6. aformat - Force consistent output format
|
||||
'-af', 'aresample=async=1000:min_hard_comp=0.100000:first_pts=0,' + // Increased async window
|
||||
'asetpts=PTS-STARTPTS,' + // Reset timestamps to prevent drift
|
||||
'dynaudnorm=f=150:g=15:p=0.9:m=10.0:r=0.5:b=1:s=15,' + // Smoother normalization with frame smoothing
|
||||
'alimiter=limit=0.95:attack=5:release=50:level=disabled,' + // Prevent clipping
|
||||
'aformat=sample_rates=44100:sample_fmts=fltp:channel_layouts=stereo', // Force format
|
||||
'-c:v', 'libx264',
|
||||
'-preset', 'ultrafast',
|
||||
'-tune', 'zerolatency',
|
||||
@@ -53,9 +65,14 @@ async function buildFFmpegArgs({ fps, useMusic, musicPath }) {
|
||||
'-b:a', '128k',
|
||||
'-ar', '44100', // Set explicit audio sample rate
|
||||
'-ac', '2', // Stereo output
|
||||
'-async', '1', // Audio sync method for smooth transitions
|
||||
'-vsync', 'cfr', // Constant frame rate for video
|
||||
'-max_muxing_queue_size', '4096', // Prevent queue overflow during transitions
|
||||
'-avoid_negative_ts', 'make_zero', // Prevent timestamp issues
|
||||
'-fflags', '+genpts+igndts', // Generate presentation timestamps, ignore decode timestamps
|
||||
'-max_interleave_delta', '0', // Reduce audio/video sync issues during transitions
|
||||
'-fflags', '+genpts+igndts+discardcorrupt', // Generate PTS, ignore DTS, discard corrupt packets
|
||||
'-copytb', '0', // Don't copy input timebase
|
||||
'-max_interleave_delta', '500000', // Increased for smoother transitions (500ms)
|
||||
'-err_detect', 'ignore_err', // Continue on minor audio errors
|
||||
'-f', 'hls',
|
||||
'-hls_time', '1', // Smaller segments for faster startup
|
||||
'-hls_list_size', '3', // Fewer segments in playlist
|
||||
|
||||
@@ -1,5 +1,60 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { execSync } = require('child_process');
|
||||
|
||||
// Cache for validated files to avoid re-validation on every request
|
||||
let validationCache = null;
|
||||
let cacheForPath = null;
|
||||
|
||||
/**
|
||||
* Validate an audio file using ffprobe
|
||||
* @param {string} filePath - Path to audio file
|
||||
* @returns {boolean} True if file is valid and playable
|
||||
*/
|
||||
function validateAudioFile(filePath) {
|
||||
try {
|
||||
// Use ffprobe to check if file is valid and get audio info
|
||||
const output = execSync(
|
||||
`ffprobe -v error -select_streams a:0 -show_entries stream=codec_name,sample_rate,channels -of json "${filePath}"`,
|
||||
{ encoding: 'utf8', timeout: 5000, stdio: ['pipe', 'pipe', 'pipe'] }
|
||||
);
|
||||
|
||||
const data = JSON.parse(output);
|
||||
|
||||
// Check if audio stream exists and has valid properties
|
||||
if (!data.streams || data.streams.length === 0) {
|
||||
console.warn(`⚠️ Skipping ${path.basename(filePath)}: No audio stream found`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const stream = data.streams[0];
|
||||
|
||||
// Validate codec
|
||||
if (!stream.codec_name) {
|
||||
console.warn(`⚠️ Skipping ${path.basename(filePath)}: Unknown codec`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate sample rate (should be reasonable)
|
||||
const sampleRate = parseInt(stream.sample_rate);
|
||||
if (!sampleRate || sampleRate < 8000 || sampleRate > 192000) {
|
||||
console.warn(`⚠️ Skipping ${path.basename(filePath)}: Invalid sample rate (${sampleRate})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validate channels
|
||||
const channels = parseInt(stream.channels);
|
||||
if (!channels || channels < 1 || channels > 8) {
|
||||
console.warn(`⚠️ Skipping ${path.basename(filePath)}: Invalid channel count (${channels})`);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Skipping ${path.basename(filePath)}: Validation failed (${error.message})`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a random music file from the music directory
|
||||
@@ -24,17 +79,42 @@ function getRandomMusicFile(musicPath) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all music files for playlist generation
|
||||
* Get all valid music files for playlist generation (with caching)
|
||||
* @param {string} musicPath - Path to music directory
|
||||
* @returns {string[]} Array of music file paths
|
||||
* @returns {string[]} Array of validated music file paths
|
||||
*/
|
||||
function getAllMusicFiles(musicPath) {
|
||||
try {
|
||||
if (!fs.existsSync(musicPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Return cached results if available for this path
|
||||
if (validationCache && cacheForPath === musicPath) {
|
||||
console.log(`✅ Using cached validation results: ${validationCache.length} files`);
|
||||
return validationCache;
|
||||
}
|
||||
|
||||
const files = fs.readdirSync(musicPath).filter(f => f.endsWith('.ogg'));
|
||||
return files.map(f => path.join(musicPath, f));
|
||||
const filePaths = files.map(f => path.join(musicPath, f));
|
||||
|
||||
console.log(`🎵 Validating ${filePaths.length} audio files (first run, this will be cached)...`);
|
||||
|
||||
// Validate each file
|
||||
const validFiles = filePaths.filter(validateAudioFile);
|
||||
|
||||
console.log(`✅ ${validFiles.length}/${filePaths.length} audio files passed validation`);
|
||||
|
||||
if (validFiles.length < filePaths.length) {
|
||||
console.log(`⚠️ ${filePaths.length - validFiles.length} files were skipped due to issues`);
|
||||
}
|
||||
|
||||
// Cache the results
|
||||
validationCache = validFiles;
|
||||
cacheForPath = musicPath;
|
||||
console.log(`💾 Validation results cached for future requests`);
|
||||
|
||||
return validFiles;
|
||||
} catch (error) {
|
||||
console.error('Error getting music files:', error);
|
||||
return [];
|
||||
@@ -55,13 +135,13 @@ function shuffleArray(array) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a playlist file for FFmpeg concat demuxer
|
||||
* Create a playlist file for FFmpeg concat demuxer with optimized format
|
||||
* @param {string} musicPath - Path to music directory
|
||||
* @returns {{playlistFile: string, trackCount: number}|null} Playlist info or null
|
||||
*/
|
||||
function createPlaylist(musicPath) {
|
||||
const allMusicFiles = getAllMusicFiles(musicPath);
|
||||
console.log(`Found ${allMusicFiles.length} music files in ${musicPath}`);
|
||||
console.log(`Found ${allMusicFiles.length} validated music files`);
|
||||
|
||||
if (allMusicFiles.length === 0) {
|
||||
return null;
|
||||
@@ -75,18 +155,27 @@ function createPlaylist(musicPath) {
|
||||
const repetitions = Math.max(20, Math.ceil(480 / allMusicFiles.length)); // At least 480 tracks (~24hrs)
|
||||
const playlistLines = [];
|
||||
|
||||
// Use FFmpeg concat demuxer format with improved options for smooth transitions
|
||||
playlistLines.push('ffconcat version 1.0');
|
||||
|
||||
for (let i = 0; i < repetitions; i++) {
|
||||
// Re-shuffle each repetition for more variety
|
||||
const shuffled = shuffleArray([...allMusicFiles]);
|
||||
shuffled.forEach(f => playlistLines.push(`file '${f}'`));
|
||||
shuffled.forEach(f => {
|
||||
// Escape single quotes in file paths for concat format
|
||||
const escapedPath = f.replace(/'/g, "'\\''");
|
||||
playlistLines.push(`file '${escapedPath}'`);
|
||||
// Add duration metadata hint for smoother transitions (helps FFmpeg prebuffer)
|
||||
playlistLines.push('# Auto-generated entry');
|
||||
});
|
||||
}
|
||||
|
||||
fs.writeFileSync(playlistFile, playlistLines.join('\n'));
|
||||
console.log(`Created playlist with ${allMusicFiles.length} tracks x${repetitions} repetitions (~${playlistLines.length} total tracks)`);
|
||||
console.log(`✅ Created seamless playlist: ${allMusicFiles.length} tracks × ${repetitions} repetitions = ${allMusicFiles.length * repetitions} total tracks`);
|
||||
|
||||
return {
|
||||
playlistFile,
|
||||
trackCount: playlistLines.length
|
||||
trackCount: allMusicFiles.length * repetitions
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const { setupPage, waitForPageFullyLoaded, hideLogo } = require('./pageLoader');
|
||||
* @param {Promise<string>} options.lateGeocodePromise - Optional promise for late URL update
|
||||
*/
|
||||
async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocodePromise = null }) {
|
||||
const { url, width = 1920, height = 1080, fps = 30, hideLogo: hideLogoFlag = 'false' } = req.query;
|
||||
const { url, width = 1920, height = 1080, fps = 30, hideLogo: hideLogoFlag = 'false', refreshInterval = 90 } = req.query;
|
||||
|
||||
if (!url) {
|
||||
return res.status(400).send('URL parameter is required');
|
||||
@@ -31,6 +31,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
||||
let ffmpegProcess = null;
|
||||
let isCleaningUp = false;
|
||||
let playlistFile = null;
|
||||
let cleanup; // Declare cleanup function variable early
|
||||
|
||||
try {
|
||||
// Set HLS headers
|
||||
@@ -80,37 +81,52 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
||||
ffmpegProcess = spawn('ffmpeg', ['-loglevel', 'error', '-hide_banner', ...ffmpegConfig.args], {
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
});
|
||||
|
||||
console.log('FFmpeg started with args:', ffmpegConfig.args.slice(0, 20).join(' ') + '...');
|
||||
|
||||
// Pipe FFmpeg output to response
|
||||
ffmpegProcess.stdout.pipe(res);
|
||||
|
||||
ffmpegProcess.stderr.on('data', (data) => {
|
||||
const message = data.toString();
|
||||
// Suppress expected warnings from corrupted audio frames and HLS piping
|
||||
if (message.includes('failed to delete old segment') ||
|
||||
message.includes('Error submitting packet to decoder') ||
|
||||
message.includes('Invalid data found when processing input')) {
|
||||
// These are expected with some music files - FFmpeg handles them gracefully
|
||||
|
||||
// Suppress expected HLS warnings (these are harmless when piping)
|
||||
if (message.includes('failed to delete old segment')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Log audio-related warnings (but don't crash)
|
||||
if (message.includes('Error submitting packet to decoder') ||
|
||||
message.includes('Invalid data found when processing input') ||
|
||||
message.includes('corrupt decoded frame') ||
|
||||
message.includes('decoding for stream')) {
|
||||
// These indicate problematic audio files that were hopefully filtered out
|
||||
console.warn(`⚠️ FFmpeg audio warning: ${message.trim()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log important warnings about stream issues
|
||||
if (message.includes('Non-monotonous DTS') ||
|
||||
message.includes('Application provided invalid') ||
|
||||
message.includes('past duration') ||
|
||||
message.includes('Error while decoding stream')) {
|
||||
console.warn(`FFmpeg warning: ${message.trim()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Log any other stderr output (likely errors)
|
||||
console.error(`FFmpeg stderr: ${message.trim()}`);
|
||||
});
|
||||
|
||||
ffmpegProcess.on('error', (error) => {
|
||||
console.error('FFmpeg error:', error);
|
||||
cleanup();
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
});
|
||||
|
||||
ffmpegProcess.on('close', (code) => {
|
||||
if (code && code !== 0 && !isCleaningUp) {
|
||||
console.error(`FFmpeg exited with code ${code}`);
|
||||
cleanup();
|
||||
if (typeof cleanup === 'function') cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -200,7 +216,8 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
||||
});
|
||||
}
|
||||
|
||||
// Periodic page refresh
|
||||
// Periodic page refresh (configurable via refreshInterval parameter in minutes)
|
||||
const refreshIntervalMs = parseInt(refreshInterval) * 60 * 1000;
|
||||
const pageRefreshInterval = setInterval(async () => {
|
||||
if (!isCleaningUp && page && !page.isClosed()) {
|
||||
try {
|
||||
@@ -213,7 +230,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
||||
console.error('Page refresh error:', err.message);
|
||||
}
|
||||
}
|
||||
}, 30 * 60 * 1000);
|
||||
}, refreshIntervalMs);
|
||||
|
||||
// Frame capture
|
||||
const frameInterval = 1000 / fps;
|
||||
@@ -316,7 +333,7 @@ async function streamHandler(req, res, { useMusic = false, musicPath, lateGeocod
|
||||
captureLoop();
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = async () => {
|
||||
cleanup = async () => {
|
||||
if (isCleaningUp) return;
|
||||
isCleaningUp = true;
|
||||
console.log('Cleaning up stream...');
|
||||
|
||||
Reference in New Issue
Block a user