From c1f8077438c9a021a58bcf34a618fef9e03f56f4 Mon Sep 17 00:00:00 2001 From: Trevor Mears Date: Sat, 17 Jan 2026 13:31:13 -0800 Subject: [PATCH] feat: add smart audio copy and audio mix presets for configurable 5.1 downmixing --- public/index.html | 14 +++++++++ public/js/components/VideoPlayer.js | 7 ++++- public/js/pages/Settings.js | 10 +++++++ public/js/pages/WatchPage.js | 7 ++++- server/db.js | 1 + server/routes/probe.js | 1 + server/routes/transcode.js | 7 +++-- server/services/transcodeSession.js | 46 +++++++++++++++++++++++------ 8 files changed, 80 insertions(+), 13 deletions(-) diff --git a/public/index.html b/public/index.html index b8abd83..4d9ce0f 100644 --- a/public/index.html +++ b/public/index.html @@ -548,6 +548,20 @@ +
+
+ Audio Mix Preset + How multi-channel audio (5.1/7.1) is mixed down to stereo for browser + playback. +
+ +
diff --git a/public/js/components/VideoPlayer.js b/public/js/components/VideoPlayer.js index 9a52a56..b9aac7c 100644 --- a/public/js/components/VideoPlayer.js +++ b/public/js/components/VideoPlayer.js @@ -806,7 +806,12 @@ class VideoPlayer { const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)'; this.updateTranscodeStatus('transcoding', statusText); - const playlistUrl = await this.startTranscodeSession(streamUrl, { videoMode, videoCodec: info.video }); + const playlistUrl = await this.startTranscodeSession(streamUrl, { + videoMode, + videoCodec: info.video, + audioCodec: info.audio, + audioChannels: info.audioChannels + }); this.currentUrl = playlistUrl; // Update currentUrl for HLS reload this.playHls(playlistUrl); diff --git a/public/js/pages/Settings.js b/public/js/pages/Settings.js index f4b4472..a8ff0b2 100644 --- a/public/js/pages/Settings.js +++ b/public/js/pages/Settings.js @@ -149,6 +149,16 @@ class SettingsPage { this.app.player.saveSettings(); }); + // Audio Mix Preset + const audioMixSelect = document.getElementById('setting-audio-mix'); + if (audioMixSelect) { + audioMixSelect.value = this.app.player?.settings?.audioMixPreset || 'auto'; + audioMixSelect.addEventListener('change', () => { + this.app.player.settings.audioMixPreset = audioMixSelect.value; + this.app.player.saveSettings(); + }); + } + // Stream processing toggles forceProxyToggle?.addEventListener('change', () => { this.app.player.settings.forceProxy = forceProxyToggle.checked; diff --git a/public/js/pages/WatchPage.js b/public/js/pages/WatchPage.js index 2f5087c..630c826 100644 --- a/public/js/pages/WatchPage.js +++ b/public/js/pages/WatchPage.js @@ -330,7 +330,12 @@ class WatchPage { const statusText = videoMode === 'copy' ? 'Transcoding (Audio)' : 'Transcoding (Video)'; this.updateTranscodeStatus('transcoding', statusText); - const playlistUrl = await this.startTranscodeSession(url, { videoMode, videoCodec: info.video }); + const playlistUrl = await this.startTranscodeSession(url, { + videoMode, + videoCodec: info.video, + audioCodec: info.audio, + audioChannels: info.audioChannels + }); this.playHls(playlistUrl); this.setVolumeFromStorage(); return; diff --git a/server/db.js b/server/db.js index 5ad5392..d012ca8 100644 --- a/server/db.js +++ b/server/db.js @@ -76,6 +76,7 @@ function getDefaultSettings() { hwEncoder: 'auto', // auto | nvenc | amf | qsv | vaapi | software maxResolution: '1080p', // 4k | 1080p | 720p | 480p quality: 'medium', // high | medium | low + audioMixPreset: 'auto', // auto | itu | night | cinematic | passthrough // Probe cache settings probeCacheTTL: 300, // 5 minutes for URL probe cache seriesProbeCacheDays: 7 // 7 days for series episode probe cache diff --git a/server/routes/probe.js b/server/routes/probe.js index 367d33d..ac4b874 100644 --- a/server/routes/probe.js +++ b/server/routes/probe.js @@ -128,6 +128,7 @@ function analyzeProbeResult(probeResult, url) { return { video: videoCodec, audio: audioCodec, + audioChannels: audioStream?.channels || 0, // For Smart Audio Copy container: container, compatible: compatible, needsRemux: needsRemux, diff --git a/server/routes/transcode.js b/server/routes/transcode.js index ce79a12..08c736e 100644 --- a/server/routes/transcode.js +++ b/server/routes/transcode.js @@ -29,7 +29,7 @@ transcodeSession.startCleanupInterval(); * Body: { url: string, seekOffset?: number } */ router.post('/session', async (req, res) => { - const { url, seekOffset, videoMode, videoCodec } = req.body; + const { url, seekOffset, videoMode, videoCodec, audioCodec, audioChannels } = req.body; if (!url) { return res.status(400).json({ error: 'URL is required' }); @@ -47,8 +47,11 @@ router.post('/session', async (req, res) => { hwEncoder: settings.hwEncoder || 'software', maxResolution: settings.maxResolution || '1080p', quality: settings.quality || 'medium', + audioMixPreset: settings.audioMixPreset || 'auto', // Audio downmix preset videoMode: videoMode, // 'copy' or 'encode' - videoCodec: videoCodec // 'h264', 'hevc', etc. + videoCodec: videoCodec, // 'h264', 'hevc', etc. + audioCodec: audioCodec, // 'aac', 'ac3', etc. + audioChannels: audioChannels // number of channels (2=stereo) }); await session.start(); diff --git a/server/services/transcodeSession.js b/server/services/transcodeSession.js index 09ece40..c43ef35 100644 --- a/server/services/transcodeSession.js +++ b/server/services/transcodeSession.js @@ -222,15 +222,43 @@ class TranscodeSession extends EventEmitter { this.addVideoEncoderArgs(args, encoder); } - // Audio: Transcode to AAC - args.push( - '-c:a', 'aac', - '-ar', '48000', - '-b:a', '192k', - // Smart Stereo Downmix (ITU-R BS.775 Standard + LFE): - // Balanced mix: FL/FR=100%, FC=70% (Unity), Surrounds=70%, LFE=50% - '-af', 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE,aresample=async=1' - ); + // Audio: Apply mix preset + const audioCodec = this.options.audioCodec?.toLowerCase() || 'unknown'; + const audioChannels = this.options.audioChannels || 0; + const audioMixPreset = this.options.audioMixPreset || 'auto'; + const isStereoAac = audioCodec.includes('aac') && audioChannels === 2; + + // Define pan filter presets for 5.1 -> Stereo downmix + const AUDIO_MIX_FILTERS = { + // ITU-R BS.775 Standard: Mathematically balanced, transparent + itu: 'pan=stereo|FL=FL+0.707*FC+0.707*BL+0.5*LFE|FR=FR+0.707*FC+0.707*BR+0.5*LFE', + // Night Mode: Heavy dialogue boost, reduced bass/surrounds for quiet viewing + night: 'pan=stereo|FL=0.5*FL+1.2*FC+0.3*BL+0.1*LFE|FR=0.5*FR+1.2*FC+0.3*BR+0.1*LFE', + // Cinematic: Wide soundstage, immersive (original "dialogue boost" mix) + cinematic: 'pan=stereo|FL=FC+0.80*FL+0.60*BL+0.5*LFE|FR=FC+0.80*FR+0.60*BR+0.5*LFE' + }; + + if (audioMixPreset === 'passthrough') { + // Passthrough: Always copy audio, no processing + console.log(`[TranscodeSession ${this.id}] Audio: Passthrough (copy)`); + args.push('-c:a', 'copy'); + } else if (audioMixPreset === 'auto' && isStereoAac) { + // Auto + Stereo AAC source: Smart copy + console.log(`[TranscodeSession ${this.id}] Audio: Auto (Smart Copy) - Source is Stereo AAC`); + args.push('-c:a', 'copy'); + } else { + // Transcode to AAC with selected mix preset (default to ITU for 'auto') + const mixPreset = (audioMixPreset === 'auto') ? 'itu' : audioMixPreset; + const panFilter = AUDIO_MIX_FILTERS[mixPreset] || AUDIO_MIX_FILTERS.itu; + + console.log(`[TranscodeSession ${this.id}] Audio: ${mixPreset.toUpperCase()} mix (${audioCodec} ${audioChannels}ch -> Stereo AAC)`); + args.push( + '-c:a', 'aac', + '-ar', '48000', + '-b:a', '192k', + '-af', `${panFilter},aresample=async=1` + ); + } // HLS output options args.push(