From 1dfcf710ffe0c1e724f8b51270d205699a5f40c3 Mon Sep 17 00:00:00 2001 From: zefie Date: Thu, 30 Apr 2026 00:59:29 -0400 Subject: [PATCH] experimental AudioProxy --- zefie_wtvp_minisrv/app.js | 50 +++++- .../includes/classes/WTVAudioProxy.js | 167 ++++++++++++++++++ .../includes/classes/WTVHTTP.js | 2 +- zefie_wtvp_minisrv/includes/config.json | 23 ++- 4 files changed, 239 insertions(+), 3 deletions(-) create mode 100644 zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js diff --git a/zefie_wtvp_minisrv/app.js b/zefie_wtvp_minisrv/app.js index 77d6061f..022aaecb 100644 --- a/zefie_wtvp_minisrv/app.js +++ b/zefie_wtvp_minisrv/app.js @@ -11,7 +11,7 @@ const process = wtvshared.process; const util = wtvshared.util; const nunjucks = require('nunjucks'); const {serialize, unserialize} = require('php-serialize'); -const {spawn} = require('child_process'); +const {spawn, spawnSync} = require('child_process'); const http = require('follow-redirects').http; const httpx = require(classPath + "/HTTPX.js"); const { URL } = require('url'); @@ -27,6 +27,7 @@ const WTVClientSessionData = require(classPath + "/WTVClientSessionData.js"); const WTVMime = require(classPath + "/WTVMime.js"); const WTVFlashrom = require(classPath + "/WTVFlashrom.js"); const WTVImage = require(classPath + "/WTVImage.js"); +const WTVAudioProxy = require(classPath + "/WTVAudioProxy.js"); const vm = require('vm'); const debug = require('debug')('app'); const express = require('express'); @@ -37,6 +38,32 @@ const protocolServers = []; const minisrv_config = wtvshared.getMiniSrvConfig(); // snatches minisrv_config const wtvmime = new WTVMime(minisrv_config); +const wtvAudioProxy = new WTVAudioProxy(minisrv_config); + +function validateAudioProxy() { + if (!wtvAudioProxy || !wtvAudioProxy.isEnabled()) return; + + try { + const check = spawnSync(wtvAudioProxy.config.ffmpegPath, ['-hide_banner', '-version'], { + stdio: ['ignore', 'ignore', 'ignore'], + timeout: 5000 + }); + + if (check.error || check.status !== 0) { + console.warn(`AudioProxy disabled: ffmpeg not found or failed to execute at '${wtvAudioProxy.config.ffmpegPath}'.`); + if (check.error) console.warn(`AudioProxy ffmpeg error: ${check.error.message}`); + wtvAudioProxy.config.enabled = false; + return; + } + + console.log(`AudioProxy enabled: transcoding audio to ${wtvAudioProxy.config.bitrate} ${wtvAudioProxy.config.sampleRate}Hz mono MP3.`); + } catch (error) { + console.warn(`AudioProxy disabled: ffmpeg validation failed: ${error.message}`); + wtvAudioProxy.config.enabled = false; + } +} + +validateAudioProxy(); process .on('SIGTERM', shutdown('SIGTERM')) @@ -1419,6 +1446,27 @@ async function sendToClient(socket, headers_obj, data = null, throttle = 0) { } } + if (wtvAudioProxy && wtvAudioProxy.isEnabled()) { + const contype_key = wtvshared.getCaseInsensitiveKey('content-type', headers_obj); + if (contype_key && wtvAudioProxy.shouldProxy(headers_obj[contype_key])) { + try { + const transformResult = await wtvAudioProxy.transformIfNeeded(headers_obj, data); + headers_obj = transformResult.headers; + data = transformResult.data; + content_length = Buffer.isBuffer(data) ? data.length : Buffer.byteLength(data || ''); + } catch (e) { + console.error('Audio proxy error:', e.message); + headers_obj = { + "Status": "413 Audio Too Long", + "Content-type": "text/plain" + }; + data = (e.code === 'AUDIO_TOO_LONG') ? + `Audio exceeds maximum allowed duration of ${wtvAudioProxy.config.maxDurationSeconds} seconds.` : + `Audio proxy failure: ${e.message}`; + } + } + } + // if client can do compression, see if its worth enabling // small files actually get larger, so don't compress them diff --git a/zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js b/zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js new file mode 100644 index 00000000..e5c77590 --- /dev/null +++ b/zefie_wtvp_minisrv/includes/classes/WTVAudioProxy.js @@ -0,0 +1,167 @@ +'use strict'; +const { spawn } = require('child_process'); + +class WTVAudioProxy { + constructor(minisrv_config) { + this.minisrv_config = minisrv_config || {}; + const audioProxyConfig = (minisrv_config && minisrv_config.config && minisrv_config.config.audio_proxy) ? minisrv_config.config.audio_proxy : {}; + this.config = Object.assign({ + enabled: false, + types: [ + 'audio/mpeg', + 'audio/mp3', + 'audio/wav', + 'audio/ogg', + 'audio/x-wav', + 'audio/flac', + 'audio/x-flac', + 'audio/aac', + 'audio/mp4', + 'audio/x-m4a', + 'audio/x-ms-wma' + ], + bitrate: '20k', + sampleRate: 16000, + channels: 1, + maxDurationSeconds: 480, + ffmpegPath: 'ffmpeg' + }, audioProxyConfig); + + if (!Array.isArray(this.config.types)) { + this.config.types = []; + } + this.config.types = this.config.types.map((t) => String(t).toLowerCase().trim()).filter(Boolean); + this.config.bitrate = String(this.config.bitrate || '20k'); + this.config.sampleRate = parseInt(this.config.sampleRate, 10) || 16000; + this.config.channels = parseInt(this.config.channels, 10) || 1; + this.config.maxDurationSeconds = parseInt(this.config.maxDurationSeconds, 10) || 480; + this.config.ffmpegPath = String(this.config.ffmpegPath || 'ffmpeg'); + } + + isEnabled() { + return this.config.enabled === true; + } + + normalizeContentType(contentType) { + if (!contentType) return ''; + return contentType.split(';')[0].trim().toLowerCase(); + } + + shouldProxy(contentType) { + if (!this.isEnabled()) return false; + const normalized = this.normalizeContentType(contentType); + return normalized && this.config.types.includes(normalized); + } + + async inspectDuration(sourceData) { + return new Promise((resolve, reject) => { + const args = [ + '-hide_banner', + '-nostdin', + '-i', + 'pipe:0', + '-f', + 'null', + '-' + ]; + const ffprobe = spawn(this.config.ffmpegPath, args, { stdio: ['pipe', 'ignore', 'pipe'] }); + let stderr = ''; + + ffprobe.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + + ffprobe.on('error', (err) => reject(err)); + ffprobe.on('close', (code) => { + const matches = stderr.match(/Duration:\s*([0-9]{2}):([0-9]{2}):([0-9]{2}\.[0-9]+)/); + if (matches) { + const hours = parseInt(matches[1], 10); + const minutes = parseInt(matches[2], 10); + const seconds = parseFloat(matches[3]); + return resolve(hours * 3600 + minutes * 60 + seconds); + } + if (code === 0) { + return resolve(0); + } + return reject(new Error(`ffmpeg failed to inspect media: ${stderr.trim()}`)); + }); + + ffprobe.stdin.end(sourceData); + }); + } + + async transcode(sourceData) { + return new Promise((resolve, reject) => { + const args = [ + '-hide_banner', + '-nostdin', + '-y', + '-i', + 'pipe:0', + '-vn', + '-acodec', + 'libmp3lame', + '-b:a', + this.config.bitrate, + '-ar', + String(this.config.sampleRate), + '-ac', + String(this.config.channels), + '-f', + 'mp3', + 'pipe:1' + ]; + const ffmpeg = spawn(this.config.ffmpegPath, args, { stdio: ['pipe', 'pipe', 'pipe'] }); + const outputChunks = []; + let stderr = ''; + + ffmpeg.stdout.on('data', (chunk) => outputChunks.push(chunk)); + ffmpeg.stderr.on('data', (chunk) => { stderr += chunk.toString(); }); + ffmpeg.on('error', (err) => reject(err)); + ffmpeg.on('close', (code) => { + if (code !== 0) { + return reject(new Error(`ffmpeg transcode failed (${code}): ${stderr.trim()}`)); + } + resolve(Buffer.concat(outputChunks)); + }); + + ffmpeg.stdin.end(sourceData); + }); + } + + async transformIfNeeded(headers, data) { + if (!headers || !this.shouldProxy(headers['Content-type'])) { + return { headers, data }; + } + + if (headers['Content-Encoding'] && headers['Content-Encoding'].toLowerCase() !== 'identity') { + return { headers, data }; + } + + const sourceBuffer = Buffer.isBuffer(data) ? data : Buffer.from(data || ''); + if (sourceBuffer.length === 0) { + return { headers, data: sourceBuffer }; + } + + const duration = await this.inspectDuration(sourceBuffer); + if (duration > this.config.maxDurationSeconds) { + const error = new Error(`Audio duration ${duration.toFixed(1)}s exceeds maximum of ${this.config.maxDurationSeconds}s`); + error.code = 'AUDIO_TOO_LONG'; + throw error; + } + + const converted = await this.transcode(sourceBuffer); + const originalType = this.normalizeContentType(headers['Content-type']); + if (converted.length >= sourceBuffer.length && (originalType === 'audio/wav' || originalType === 'audio/mpeg')) { + return { headers, data: sourceBuffer }; + } + + const newHeaders = Object.assign({}, headers); + newHeaders['Content-type'] = 'audio/mpeg'; + delete newHeaders['Content-Encoding']; + delete newHeaders['Content-Length']; + return { headers: newHeaders, data: converted }; + } +} + +module.exports = WTVAudioProxy; diff --git a/zefie_wtvp_minisrv/includes/classes/WTVHTTP.js b/zefie_wtvp_minisrv/includes/classes/WTVHTTP.js index ebf014f4..7d9c5c7c 100644 --- a/zefie_wtvp_minisrv/includes/classes/WTVHTTP.js +++ b/zefie_wtvp_minisrv/includes/classes/WTVHTTP.js @@ -1,5 +1,5 @@ class WTVHTTP { - constructor(...[minisrv_config, service_name, wtvshared, sendToClient, http]) { + constructor(...[minisrv_config, service_name, wtvshared, sendToClient, net, http]) { this.minisrv_config = minisrv_config; this.service_name = service_name; this.wtvshared = wtvshared; diff --git a/zefie_wtvp_minisrv/includes/config.json b/zefie_wtvp_minisrv/includes/config.json index b5df2343..9d10dcae 100644 --- a/zefie_wtvp_minisrv/includes/config.json +++ b/zefie_wtvp_minisrv/includes/config.json @@ -127,7 +127,7 @@ "image/webp" ], "image_options": { - "compressionLevel": 0, + "compressionLevel": 0, // for png, anything higher than 0 is just wasting CPU time for minisrv's use case "adaptiveFiltering": true, "dither": 1, "colors": 256, @@ -138,6 +138,27 @@ "max_file_size": 524288, "jpeg_interval": 5, // lower quality by this amount to try to lower filesize "max_quality_tries": 5 // try to decode up to this many times, reducing quality each time, until the file is under the max_file_size. After this many tries, it will error out rather than sending an oversized file to the client. + }, + "audio_proxy": { + "enabled": false, + "types": [ + "audio/mpeg", + "audio/mp3", + "audio/wav", + "audio/ogg", + "audio/x-wav", + "audio/flac", + "audio/x-flac", + "audio/aac", + "audio/mp4", + "audio/x-m4a", + "audio/x-ms-wma" + ], + "bitrate": "32k", + "sampleRate": 22050, + "channels": 1, + "maxDurationSeconds": 480, + "ffmpegPath": "ffmpeg" } }, "services": {