Add the guarded JamesDSP audio controller
This commit is contained in:
@@ -21,7 +21,7 @@ test('Phase 4 preserves the exact playback, continuity, tracking and queue bodie
|
|||||||
{const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");}
|
{const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");}
|
||||||
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
|
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
|
||||||
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
|
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
|
||||||
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");}
|
{const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5960);assert.match(body,/EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);const standardBody=body.replace(' && !data.settings?.jamesDsp?.enabled','').slice(0,5912);assert.equal(createHash('sha256').update(standardBody).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","Standard EqRender body unchanged");}
|
||||||
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
|
{const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -663,6 +663,7 @@ const DEFAULT_SETTINGS = {
|
|||||||
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
||||||
levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies)
|
levelVolume: null, // loudness levelling; null = on (on iPhone: applied to saved songs' rendered copies)
|
||||||
liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks)
|
liveAudioIOS: false, // iPhone: live Web Audio EQ/levelling for streamed songs (stops when the screen locks)
|
||||||
|
jamesDsp: { enabled: false, preset: 'flat', params: {} },
|
||||||
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
|
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
|
||||||
density: 'comfortable', // 'comfortable' | 'compact'
|
density: 'comfortable', // 'comfortable' | 'compact'
|
||||||
perfMode: false, // disable heavy visual effects for speed
|
perfMode: false, // disable heavy visual effects for speed
|
||||||
@@ -672,7 +673,16 @@ const DEFAULT_SETTINGS = {
|
|||||||
autoBackupIntervalDays: 7,
|
autoBackupIntervalDays: 7,
|
||||||
serviceVideo: false, // show the video panel inside service mode (lyric videos)
|
serviceVideo: false, // show the video panel inside service mode (lyric videos)
|
||||||
};
|
};
|
||||||
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
|
function normalizeJamesDspSettings(settings) {
|
||||||
|
const saved = settings?.jamesDsp && typeof settings.jamesDsp === 'object' ? settings.jamesDsp : {};
|
||||||
|
settings.jamesDsp = {
|
||||||
|
enabled: saved.enabled === true,
|
||||||
|
preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
|
||||||
|
params: saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {},
|
||||||
|
};
|
||||||
|
return settings;
|
||||||
|
}
|
||||||
|
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS }), resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
|
||||||
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
|
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
|
||||||
let searchResults = [];
|
let searchResults = [];
|
||||||
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
// Distinguishes "never searched" (hide the list pane, show the landing) from
|
||||||
@@ -695,6 +705,8 @@ const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platfo
|
|||||||
let _currentEqUrl = null;
|
let _currentEqUrl = null;
|
||||||
async function useEqRender(id) {
|
async function useEqRender(id) {
|
||||||
if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; }
|
if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; }
|
||||||
|
// JamesDSP is a live insert: never layer it over the Standard renderer copy.
|
||||||
|
if (data.settings?.jamesDsp?.enabled) return null;
|
||||||
if (typeof EqRender === 'undefined' || !WEB) return null;
|
if (typeof EqRender === 'undefined' || !WEB) return null;
|
||||||
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
|
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
|
||||||
return _currentEqUrl;
|
return _currentEqUrl;
|
||||||
@@ -905,7 +917,7 @@ function applyProfileData(name, payload, updatedAt) {
|
|||||||
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
|
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
|
||||||
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
|
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
|
||||||
if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats;
|
if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats;
|
||||||
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
|
if (payload.settings && typeof payload.settings === 'object') data.settings = normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...payload.settings });
|
||||||
data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null };
|
data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null };
|
||||||
API.saveData(data).catch(() => {});
|
API.saveData(data).catch(() => {});
|
||||||
}
|
}
|
||||||
@@ -5334,7 +5346,7 @@ const Level = (() => {
|
|||||||
// so it keeps going when locked. One render at a time, in the background.
|
// so it keeps going when locked. One render at a time, in the background.
|
||||||
const EqRender = (() => {
|
const EqRender = (() => {
|
||||||
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
|
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
|
||||||
const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
|
const active = () => EQ.isIOS() && WEB && !data.settings?.jamesDsp?.enabled && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
|
||||||
const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
|
const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
|
||||||
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
|
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
|
||||||
const queue = new Set();
|
const queue = new Set();
|
||||||
@@ -5504,10 +5516,12 @@ const PiP = (() => {
|
|||||||
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
|
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
|
||||||
const EQ_BANDS = EqParameters.bands;
|
const EQ_BANDS = EqParameters.bands;
|
||||||
const EQ_PRESETS = EqParameters.presets;
|
const EQ_PRESETS = EqParameters.presets;
|
||||||
|
const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature');
|
||||||
const EQ = (() => {
|
const EQ = (() => {
|
||||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
const AC = window.AudioContext || window.webkitAudioContext;
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
const st = { ctx: null, filters: null };
|
const st = { ctx: null, filters: null, levelDb: 0 };
|
||||||
|
let dspController = null, dspControllerPromise = null;
|
||||||
// Web Audio is off on iOS (it silences playback when the screen locks)
|
// Web Audio is off on iOS (it silences playback when the screen locks)
|
||||||
// unless the user opted in through "Level volume" (Settings → Playback).
|
// unless the user opted in through "Level volume" (Settings → Playback).
|
||||||
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
||||||
@@ -5522,6 +5536,43 @@ const EQ = (() => {
|
|||||||
};
|
};
|
||||||
const isFlat = () => gains().every((v) => v === 0);
|
const isFlat = () => gains().every((v) => v === 0);
|
||||||
|
|
||||||
|
async function prepareDspGraph() {
|
||||||
|
if (!available()) throw new Error(isIOS ? 'Turn on Live sound processing in Settings → Playback first.' : 'Web Audio is unavailable.');
|
||||||
|
if (rendered()) throw new Error('This song is already playing a Standard processed copy. JamesDSP will start with the next original track.');
|
||||||
|
if (!st.ctx && !AudioGraph.snapshot().contextCreated) {
|
||||||
|
const context = AudioGraph.context({ sampleRate: 48000 });
|
||||||
|
if (context.state === 'suspended') context.resume().catch(() => {});
|
||||||
|
}
|
||||||
|
await loadSoundFeatures();
|
||||||
|
if (!st.ctx && !build()) throw new Error('The Standard fallback audio chain is not ready.');
|
||||||
|
if (!st.ctx || !st.filters?.[0]) throw new Error('The Standard fallback audio chain could not be built.');
|
||||||
|
attach(els.video);
|
||||||
|
attach(els.audio);
|
||||||
|
return { context: st.ctx, elements: [els.video, els.audio].filter(Boolean), standardInput: st.filters[0] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function dspFailed(error) {
|
||||||
|
if (!data.settings.jamesDsp?.enabled) return;
|
||||||
|
data.settings.jamesDsp.enabled = false;
|
||||||
|
persist();
|
||||||
|
toast(`JamesDSP unavailable; Standard sound is active. ${error?.message || ''}`.trim());
|
||||||
|
apply();
|
||||||
|
Level.apply();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getDspController() {
|
||||||
|
if (dspController) return dspController;
|
||||||
|
if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(() => {
|
||||||
|
dspController = JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed });
|
||||||
|
return dspController;
|
||||||
|
}).catch(error => { dspControllerPromise = null; throw error; });
|
||||||
|
return dspControllerPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startDsp() {
|
||||||
|
getDspController().then(controller => controller.enable(data.settings.jamesDsp, st.levelDb)).catch(dspFailed);
|
||||||
|
}
|
||||||
|
|
||||||
function build() {
|
function build() {
|
||||||
if (st.ctx) return true;
|
if (st.ctx) return true;
|
||||||
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
|
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
|
||||||
@@ -5562,6 +5613,14 @@ const EQ = (() => {
|
|||||||
|
|
||||||
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
// Apply the stored curve; wires the graph up the first time it's non-flat.
|
||||||
function apply() {
|
function apply() {
|
||||||
|
if (data.settings.jamesDsp?.enabled) {
|
||||||
|
if (!available()) { dspFailed(new Error(isIOS ? 'Live sound processing is off on this iPhone.' : 'Web Audio is unavailable.')); return; }
|
||||||
|
if (rendered()) { dspFailed(new Error('The current song already uses a Standard processed copy.')); return; }
|
||||||
|
startDsp();
|
||||||
|
renderButton();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (dspController) dspController.disable().catch(() => {});
|
||||||
if (!available()) return;
|
if (!available()) return;
|
||||||
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
|
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
|
||||||
if (!build()) return;
|
if (!build()) return;
|
||||||
@@ -5641,6 +5700,7 @@ const EQ = (() => {
|
|||||||
// one-way step the EQ takes, so it is only offered where the EQ is (never
|
// one-way step the EQ takes, so it is only offered where the EQ is (never
|
||||||
// on iOS, where Web Audio would silence background playback).
|
// on iOS, where Web Audio would silence background playback).
|
||||||
function analyser() {
|
function analyser() {
|
||||||
|
if (dspController?.active) return dspController.analyser();
|
||||||
if (!available() || !build()) return null;
|
if (!available() || !build()) return null;
|
||||||
attach(els.video);
|
attach(els.video);
|
||||||
attach(els.audio);
|
attach(els.audio);
|
||||||
@@ -5656,8 +5716,13 @@ const EQ = (() => {
|
|||||||
|
|
||||||
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
|
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
|
||||||
function setLevel(db) {
|
function setLevel(db) {
|
||||||
if (!available()) return false;
|
|
||||||
if (rendered()) db = 0;
|
if (rendered()) db = 0;
|
||||||
|
st.levelDb = Number(db) || 0;
|
||||||
|
if (data.settings.jamesDsp?.enabled) {
|
||||||
|
if (dspController?.desired) dspController.setLevelDb(st.levelDb).catch(dspFailed);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (!available()) return false;
|
||||||
if (!st.ctx && Math.abs(db) < 0.3) return true;
|
if (!st.ctx && Math.abs(db) < 0.3) return true;
|
||||||
if (!build()) return false;
|
if (!build()) return false;
|
||||||
attach(els.video);
|
attach(els.video);
|
||||||
@@ -8966,7 +9031,7 @@ async function boot() {
|
|||||||
lyricOffsets: loaded.lyricOffsets || {},
|
lyricOffsets: loaded.lyricOffsets || {},
|
||||||
stats: loaded.stats || null,
|
stats: loaded.stats || null,
|
||||||
lastAutoBackup: loaded.lastAutoBackup || 0,
|
lastAutoBackup: loaded.lastAutoBackup || 0,
|
||||||
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
|
settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }),
|
||||||
profile: loaded.profile || null,
|
profile: loaded.profile || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -366,11 +366,16 @@
|
|||||||
"eager": false,
|
"eager": false,
|
||||||
"background": true,
|
"background": true,
|
||||||
"files": [
|
"files": [
|
||||||
|
"/vendor/jamesdsp/jamesdsp-feature.js",
|
||||||
"/vendor/jamesdsp/jamesdsp-node.mjs",
|
"/vendor/jamesdsp/jamesdsp-node.mjs",
|
||||||
"/vendor/jamesdsp/params.mjs",
|
"/vendor/jamesdsp/params.mjs",
|
||||||
"/vendor/jamesdsp/worklet.mjs",
|
"/vendor/jamesdsp/worklet.mjs",
|
||||||
"/vendor/jamesdsp/dsp/jamesdsp.mjs",
|
"/vendor/jamesdsp/dsp/jamesdsp.mjs",
|
||||||
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
||||||
|
],
|
||||||
|
"dependencies": [
|
||||||
|
"feature:eq-core",
|
||||||
|
"feature:vocal-reducer"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
30
frontend/jamesdsp-app.test.js
Normal file
30
frontend/jamesdsp-app.test.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { readFileSync } = require('node:fs');
|
||||||
|
const { join } = require('node:path');
|
||||||
|
|
||||||
|
const app = readFileSync(join(__dirname, 'app.js'), 'utf8');
|
||||||
|
|
||||||
|
test('the persisted engine is Standard by default and upgrades missing JamesDSP settings safely', () => {
|
||||||
|
assert.match(app, /jamesDsp: \{ enabled: false, preset: 'flat', params: \{\} \}/);
|
||||||
|
assert.match(app, /enabled: saved\.enabled === true/);
|
||||||
|
assert.match(app, /preset: typeof saved\.preset === 'string' \? saved\.preset : 'flat'/);
|
||||||
|
assert.match(app, /settings: normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.\(loaded\.settings \|\| \{\}\) \}\)/);
|
||||||
|
assert.match(app, /data\.settings = normalizeJamesDspSettings\(\{ \.\.\.DEFAULT_SETTINGS, \.\.\.payload\.settings \}\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('live JamesDSP shares the existing iOS gate and never stacks over EqRender copies', () => {
|
||||||
|
assert.match(app, /const available = \(\) => !!AC && \(!isIOS \|\| data\.settings\.liveAudioIOS === true\)/);
|
||||||
|
assert.match(app, /if \(data\.settings\?\.jamesDsp\?\.enabled\) return null/);
|
||||||
|
assert.match(app, /const active = \(\) => EQ\.isIOS\(\) && WEB && !data\.settings\?\.jamesDsp\?\.enabled/);
|
||||||
|
assert.match(app, /if \(rendered\(\)\) throw new Error\('This song is already playing a Standard processed copy\./);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('EQ creates no second context or element source and passes Standard fallback routing to the controller', () => {
|
||||||
|
assert.equal((app.match(/\.createMediaElementSource\(/g) || []).length, 0);
|
||||||
|
assert.match(app, /st\.ctx = AudioGraph\.context\(\)/);
|
||||||
|
assert.match(app, /AudioGraph\.connect\(el, st\.filters\[0\]\)/);
|
||||||
|
assert.match(app, /standardInput: st\.filters\[0\]/);
|
||||||
|
assert.match(app, /dspController\.setLevelDb\(st\.levelDb\)/);
|
||||||
|
assert.match(app, /if \(data\.settings\.jamesDsp\?\.enabled\) \{[\s\S]{0,450}startDsp\(\)/);
|
||||||
|
});
|
||||||
@@ -12,7 +12,9 @@ test('JamesDSP runtime files live only in a lazy background group', () => {
|
|||||||
assert.equal(group.contract, 1);
|
assert.equal(group.contract, 1);
|
||||||
assert.equal(group.eager, false);
|
assert.equal(group.eager, false);
|
||||||
assert.equal(group.background, true);
|
assert.equal(group.background, true);
|
||||||
|
assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']);
|
||||||
assert.deepEqual(group.files, [
|
assert.deepEqual(group.files, [
|
||||||
|
'/vendor/jamesdsp/jamesdsp-feature.js',
|
||||||
'/vendor/jamesdsp/jamesdsp-node.mjs',
|
'/vendor/jamesdsp/jamesdsp-node.mjs',
|
||||||
'/vendor/jamesdsp/params.mjs',
|
'/vendor/jamesdsp/params.mjs',
|
||||||
'/vendor/jamesdsp/worklet.mjs',
|
'/vendor/jamesdsp/worklet.mjs',
|
||||||
@@ -33,6 +35,9 @@ test('JamesDSP resolves every module dependency through the page build manifest'
|
|||||||
assert.match(node, /workletURL\.hash=new URLSearchParams/);
|
assert.match(node, /workletURL\.hash=new URLSearchParams/);
|
||||||
assert.match(worklet, /new URLSearchParams\(new URL\(import\.meta\.url\)\.hash\.slice\(1\)\)/);
|
assert.match(worklet, /new URLSearchParams\(new URL\(import\.meta\.url\)\.hash\.slice\(1\)\)/);
|
||||||
assert.match(worklet, /await import\(engineURL\)/);
|
assert.match(worklet, /await import\(engineURL\)/);
|
||||||
|
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
|
||||||
|
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/jamesdsp-node\.mjs'\)/);
|
||||||
|
assert.match(feature, /Lazy\.url\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'\)/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('v1 wrapper rejects text effects and omits them from portable presets', () => {
|
test('v1 wrapper rejects text effects and omits them from portable presets', () => {
|
||||||
|
|||||||
98
frontend/jamesdsp-controller.test.js
Normal file
98
frontend/jamesdsp-controller.test.js
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
const { test } = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createController } = require('./vendor/jamesdsp/jamesdsp-feature.js');
|
||||||
|
|
||||||
|
function fixture({ loadNode } = {}) {
|
||||||
|
const routed = [], errors = [], created = [], configured = [], bypass = [];
|
||||||
|
const graph = { routeAll: (elements, destination) => routed.push([elements.map(x => x.id), destination]) };
|
||||||
|
const gainParam = { value: 1, cancelScheduledValues() {}, setValueAtTime(v) { this.value = v; }, linearRampToValueAtTime(v) { this.value = v; } };
|
||||||
|
const context = { sampleRate: 48000, state: 'running', currentTime: 0, destination: 'destination', createGain: () => ({ gain: gainParam, connect() {}, disconnect() {} }), resume: async () => {} };
|
||||||
|
const standardInput = 'standard-input', elements = [{ id: 'video' }, { id: 'audio' }];
|
||||||
|
const prepareGraph = async () => ({ context, standardInput, elements });
|
||||||
|
const defaultLoad = async () => {
|
||||||
|
const listeners = new Map();
|
||||||
|
const instance = {
|
||||||
|
node: { connect() {}, disconnect() {} }, destroyed: false,
|
||||||
|
addEventListener(type, fn) { listeners.set(type, fn); },
|
||||||
|
removeEventListener(type, fn) { if (listeners.get(type) === fn) listeners.delete(type); },
|
||||||
|
async setBypass(v) { bypass.push(v); },
|
||||||
|
async applyPreset(params) { configured.push({ ...params }); },
|
||||||
|
async setParam(name, value) { configured.push({ [name]: value }); },
|
||||||
|
destroy() { this.destroyed = true; },
|
||||||
|
fail(error) { listeners.get('error')?.({ detail: error }); },
|
||||||
|
};
|
||||||
|
created.push(instance); return instance;
|
||||||
|
};
|
||||||
|
const controller = createController({ graph, prepareGraph, loadNode: loadNode || defaultLoad, onError: error => errors.push(error) });
|
||||||
|
return { controller, routed, errors, created, configured, bypass, context, elements, standardInput };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('enable routes one initialized JamesDSP node and disable restores Standard before destroying it', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
assert.equal(await f.controller.enable({ preset: 'bass', params: { gain: -2 } }, 3), true);
|
||||||
|
assert.equal(f.controller.active, true);
|
||||||
|
assert.deepEqual(f.routed, [[['video', 'audio'], f.created[0].node]]);
|
||||||
|
assert.equal(f.created.length, 1);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 1);
|
||||||
|
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||||
|
const standard = f.controller.disable();
|
||||||
|
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
|
||||||
|
await standard;
|
||||||
|
assert.equal(f.created[0].destroyed, true);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 0);
|
||||||
|
assert.equal(f.controller.state().nodesDestroyed, 1);
|
||||||
|
assert.equal(f.errors.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a rejected or unsupported engine leaves the Standard route active', async () => {
|
||||||
|
const f = fixture({ loadNode: async () => { throw new Error('corrupt wasm'); } });
|
||||||
|
await assert.rejects(f.controller.enable({ preset: 'flat' }), /corrupt wasm/);
|
||||||
|
assert.deepEqual(f.routed, [[['video', 'audio'], f.standardInput]]);
|
||||||
|
assert.equal(f.controller.active, false);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 0);
|
||||||
|
assert.equal(f.errors.length, 1);
|
||||||
|
|
||||||
|
const wrongRate = fixture();
|
||||||
|
wrongRate.context.sampleRate = 32000;
|
||||||
|
await assert.rejects(wrongRate.controller.enable({ preset: 'flat' }), /44\.1 or 48 kHz/);
|
||||||
|
assert.equal(wrongRate.created.length, 0);
|
||||||
|
assert.equal(wrongRate.controller.state().liveHeaps, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await f.controller.enable({ preset: 'flat' });
|
||||||
|
const instance = f.created[0];
|
||||||
|
instance.fail(new Error('processor fault'));
|
||||||
|
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput]);
|
||||||
|
assert.equal(instance.destroyed, true);
|
||||||
|
assert.equal(f.controller.active, false);
|
||||||
|
assert.equal(f.controller.state().liveHeaps, 0);
|
||||||
|
assert.match(f.errors[0].message, /processor fault/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disable during async node loading prevents a late DSP route', async () => {
|
||||||
|
let finish;
|
||||||
|
const f = fixture({ loadNode: () => new Promise(resolve => { finish = resolve; }) });
|
||||||
|
const enabling = f.controller.enable({ preset: 'flat' });
|
||||||
|
await new Promise(resolve => setImmediate(resolve));
|
||||||
|
const disabling = f.controller.disable();
|
||||||
|
const neverRouted = {
|
||||||
|
node: { connect() {}, disconnect() {} },
|
||||||
|
addEventListener() {}, removeEventListener() {}, setBypass: async () => {}, applyPreset: async () => {}, setParam: async () => {}, destroy() { this.destroyed = true; },
|
||||||
|
};
|
||||||
|
finish(neverRouted);
|
||||||
|
await enabling; await disabling;
|
||||||
|
assert.equal(f.routed.some(([, destination]) => destination === neverRouted.node), false);
|
||||||
|
assert.equal(neverRouted.destroyed, true);
|
||||||
|
assert.equal(f.controller.state().maxLiveHeaps, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('preset gain and Level gain combine within engine bounds without pausing playback', async () => {
|
||||||
|
const f = fixture();
|
||||||
|
await f.controller.enable({ preset: 'loudness', params: { gain: 8 } }, 9);
|
||||||
|
assert.ok(f.configured.some(entry => entry.gain === 12));
|
||||||
|
assert.equal(f.created.length, 1);
|
||||||
|
assert.equal(f.controller.state().maxLiveHeaps, 1);
|
||||||
|
assert.equal(f.controller.active, true);
|
||||||
|
});
|
||||||
@@ -6,9 +6,10 @@ const source = fs.readFileSync(__dirname + '/app.js', 'utf8');
|
|||||||
// Exercise the production persistence/profile functions without starting a DOM player.
|
// Exercise the production persistence/profile functions without starting a DOM player.
|
||||||
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
|
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
|
||||||
function fixture(updatedAt) {
|
function fixture(updatedAt) {
|
||||||
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] } };
|
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } } };
|
||||||
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: { saveBeforePlay: false, autoPreload: true, layout: 'classic' }, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
|
const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, preset: 'flat', params: {} } };
|
||||||
vm.runInContext([declaration('loadDataFromStorage'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
|
const context = vm.createContext({ WEB: true, data: { settings, profile: { name: 'test', syncedAt: 200 } }, DEFAULT_SETTINGS: defaults, API: { saveData: async () => {} }, profileHeaders: () => ({}), scheduleProfilePush: () => context.pushes++, pushes: 0, fetch: async () => ({ status: 200, json: async () => ({ ok: true, name: 'test', updatedAt, data: { settings: { saveBeforePlay: false } } }) }), localStorage: { getItem: () => JSON.stringify({ settings }) } });
|
||||||
|
vm.runInContext([declaration('loadDataFromStorage'), declaration('normalizeJamesDspSettings'), declaration('applyProfileData'), declaration('pullProfileIfNewer')].join('\n'), context);
|
||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
test('stored settings load verbatim instead of reverting to defaults', () => {
|
test('stored settings load verbatim instead of reverting to defaults', () => {
|
||||||
@@ -17,6 +18,7 @@ test('stored settings load verbatim instead of reverting to defaults', () => {
|
|||||||
assert.equal(loaded.settings.saveBeforePlay, true);
|
assert.equal(loaded.settings.saveBeforePlay, true);
|
||||||
assert.equal(loaded.settings.layout, 'f7-swipe');
|
assert.equal(loaded.settings.layout, 'f7-swipe');
|
||||||
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]);
|
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.eq.g)), [1,2,3,4,5]);
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } });
|
||||||
});
|
});
|
||||||
for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => {
|
for (const updatedAt of [100,200]) test(`profile timestamp ${updatedAt} cannot overwrite local settings synced at 200`, async () => {
|
||||||
const c = fixture(updatedAt), previous = c.data.settings;
|
const c = fixture(updatedAt), previous = c.data.settings;
|
||||||
@@ -30,5 +32,6 @@ test('newer profile intentionally replaces settings and restores missing default
|
|||||||
assert.equal(c.data.settings.saveBeforePlay, false);
|
assert.equal(c.data.settings.saveBeforePlay, false);
|
||||||
assert.equal(c.data.settings.autoPreload, true);
|
assert.equal(c.data.settings.autoPreload, true);
|
||||||
assert.equal(c.data.settings.layout, 'classic');
|
assert.equal(c.data.settings.layout, 'classic');
|
||||||
|
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, preset: 'flat', params: {} });
|
||||||
assert.equal(c.data.profile.syncedAt, 300);
|
assert.equal(c.data.profile.syncedAt, 300);
|
||||||
});
|
});
|
||||||
|
|||||||
187
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
Normal file
187
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
/* Lazy classic-script bridge for the optional JamesDSP ES module and worklet. */
|
||||||
|
(function (root) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const PRESETS = Object.freeze({
|
||||||
|
flat: { name: 'Flat', params: {} },
|
||||||
|
bass: { name: 'Bass', params: { 'eq.enabled': 1, 'eq.band0': 4, 'eq.band1': 3, 'bass.enabled': 1, 'bass.boost': 5, gain: -3 } },
|
||||||
|
vocal: { name: 'Vocal', params: { 'eq.enabled': 1, 'eq.band5': 2, 'eq.band7': 3, 'eq.band9': 2, 'compressor.enabled': 1 } },
|
||||||
|
loudness: { name: 'Loudness', params: { 'limiter.threshold': -3, gain: 2 } },
|
||||||
|
headphone: { name: 'Headphone', params: { 'crossfeed.enabled': 1, 'crossfeed.mode': 1 } },
|
||||||
|
bright: { name: 'Bright', params: { 'eq.enabled': 1, 'eq.band9': 2, 'eq.band10': 3, 'eq.band11': 3, 'eq.band12': 2 } },
|
||||||
|
warm: { name: 'Warm', params: { 'eq.enabled': 1, 'eq.band0': 3, 'eq.band1': 2, 'eq.band7': -1, gain: -2 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const clampGain = value => Math.max(-24, Math.min(12, Number(value) || 0));
|
||||||
|
|
||||||
|
function createController(options) {
|
||||||
|
const graph = options.graph;
|
||||||
|
let desired = false, active = false, nodeEntry = null, gainNode = null, context = null;
|
||||||
|
let elements = [], standardInput = null, levelDb = 0, settings = null;
|
||||||
|
let tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0;
|
||||||
|
let faultListener = null, notifying = false, dspAnalyser = null;
|
||||||
|
|
||||||
|
const serial = fn => {
|
||||||
|
const result = tail.then(fn, fn);
|
||||||
|
tail = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
function snapshot() { return { active, desired, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; }
|
||||||
|
|
||||||
|
function routeStandard() {
|
||||||
|
if (!elements.length) return;
|
||||||
|
try {
|
||||||
|
graph.routeAll(elements, standardInput || context?.destination);
|
||||||
|
} catch (error) {
|
||||||
|
try { if (context?.destination) graph.routeAll(elements, context.destination); } catch { /* direct graph route is the last audible fallback */ }
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function releaseNode() {
|
||||||
|
const entry = nodeEntry;
|
||||||
|
nodeEntry = null;
|
||||||
|
if (faultListener && entry) entry.node.removeEventListener('error', faultListener);
|
||||||
|
faultListener = null;
|
||||||
|
try { entry?.node.node.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
try { gainNode?.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
gainNode = null;
|
||||||
|
try { dspAnalyser?.disconnect(); } catch { /* already disconnected */ }
|
||||||
|
dspAnalyser = null;
|
||||||
|
if (entry) {
|
||||||
|
try { entry.node.destroy(); } catch { /* continue freeing references */ }
|
||||||
|
if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fail(error) {
|
||||||
|
desired = false;
|
||||||
|
active = false;
|
||||||
|
try { routeStandard(); } catch { /* graph route has already tried a direct audible fallback */ }
|
||||||
|
releaseNode();
|
||||||
|
if (!notifying) {
|
||||||
|
notifying = true;
|
||||||
|
try { options.onError?.(error instanceof Error ? error : new Error(String(error || 'JamesDSP failed'))); }
|
||||||
|
finally { notifying = false; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rampGain(value) {
|
||||||
|
if (!gainNode || !context) return;
|
||||||
|
const param = gainNode.gain;
|
||||||
|
const now = context.currentTime || 0;
|
||||||
|
if (context.state !== 'running' || typeof param.linearRampToValueAtTime !== 'function') {
|
||||||
|
param.value = value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
param.cancelScheduledValues?.(now);
|
||||||
|
param.setValueAtTime?.(param.value, now);
|
||||||
|
param.linearRampToValueAtTime(value, now + 0.018);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configure(instance, nextSettings, nextLevelDb) {
|
||||||
|
const preset = PRESETS[nextSettings?.preset] || PRESETS.flat;
|
||||||
|
const params = { ...preset.params, ...(nextSettings?.params || {}) };
|
||||||
|
params.gain = clampGain((Number(params.gain) || 0) + (Number(nextLevelDb) || 0));
|
||||||
|
params.bypass = 1;
|
||||||
|
if (active && context?.state === 'running') {
|
||||||
|
rampGain(0);
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 20));
|
||||||
|
}
|
||||||
|
await instance.setBypass(true);
|
||||||
|
await instance.applyPreset(params);
|
||||||
|
await instance.setParam('gain', params.gain);
|
||||||
|
if (desired) await instance.setBypass(false);
|
||||||
|
if (desired) rampGain(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNode(audioContext) {
|
||||||
|
const moduleURL = root.Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs');
|
||||||
|
const { JamesDSPNode } = await import(moduleURL);
|
||||||
|
return JamesDSPNode.create(audioContext, {
|
||||||
|
wasmURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'),
|
||||||
|
engineURL: root.Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.mjs'),
|
||||||
|
paramsURL: root.Lazy.url('/vendor/jamesdsp/params.mjs'),
|
||||||
|
workletURL: root.Lazy.url('/vendor/jamesdsp/worklet.mjs'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enable(nextSettings, nextLevelDb = levelDb) {
|
||||||
|
desired = true;
|
||||||
|
settings = nextSettings || {};
|
||||||
|
levelDb = Number(nextLevelDb) || 0;
|
||||||
|
return serial(async () => {
|
||||||
|
if (!desired) return false;
|
||||||
|
const prepared = await options.prepareGraph();
|
||||||
|
context = prepared.context;
|
||||||
|
elements = prepared.elements;
|
||||||
|
standardInput = prepared.standardInput;
|
||||||
|
if (![44100, 48000].includes(context?.sampleRate)) throw new RangeError(`JamesDSP supports 44.1 or 48 kHz; this device uses ${context?.sampleRate || 'unknown'} Hz`);
|
||||||
|
if (!desired) return false;
|
||||||
|
let instance = nodeEntry?.node;
|
||||||
|
if (!instance) {
|
||||||
|
instance = await (options.loadNode || loadNode)(context);
|
||||||
|
if (!desired) { try { instance.destroy(); } catch {} return false; }
|
||||||
|
nodeEntry = { node: instance, live: true };
|
||||||
|
liveHeaps++; nodesCreated++; maxLiveHeaps = Math.max(maxLiveHeaps, liveHeaps);
|
||||||
|
faultListener = event => fail(event.detail || new Error('JamesDSP processing failed'));
|
||||||
|
instance.addEventListener('error', faultListener);
|
||||||
|
gainNode = context.createGain();
|
||||||
|
gainNode.gain.value = 1;
|
||||||
|
instance.node.connect(gainNode);
|
||||||
|
gainNode.connect(context.destination);
|
||||||
|
}
|
||||||
|
if (!desired) { releaseNode(); return false; }
|
||||||
|
await configure(instance, settings, levelDb);
|
||||||
|
if (!desired) { releaseNode(); return false; }
|
||||||
|
if (context.state === 'suspended') await context.resume().catch(() => {});
|
||||||
|
graph.routeAll(elements, instance.node);
|
||||||
|
active = true;
|
||||||
|
return true;
|
||||||
|
}).catch(error => { fail(error); throw error; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function disable() {
|
||||||
|
desired = false;
|
||||||
|
if (active) {
|
||||||
|
active = false;
|
||||||
|
try { routeStandard(); } catch { /* routeStandard tried its direct fallback */ }
|
||||||
|
}
|
||||||
|
return serial(async () => { releaseNode(); return true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLevelDb(value) {
|
||||||
|
levelDb = Number(value) || 0;
|
||||||
|
if (!desired) return Promise.resolve(false);
|
||||||
|
return enable(settings, levelDb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBypass(value) {
|
||||||
|
if (!nodeEntry) return Promise.resolve(false);
|
||||||
|
return nodeEntry.node.setBypass(Boolean(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function analyser() {
|
||||||
|
if (!active || !gainNode || !context) return null;
|
||||||
|
if (!dspAnalyser) {
|
||||||
|
dspAnalyser = context.createAnalyser();
|
||||||
|
dspAnalyser.fftSize = 128;
|
||||||
|
dspAnalyser.smoothingTimeConstant = 0.78;
|
||||||
|
gainNode.disconnect();
|
||||||
|
gainNode.connect(dspAnalyser);
|
||||||
|
dspAnalyser.connect(context.destination);
|
||||||
|
}
|
||||||
|
return dspAnalyser;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { enable, disable, setLevelDb, setBypass, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot };
|
||||||
|
}
|
||||||
|
|
||||||
|
const api = { PRESETS, createController };
|
||||||
|
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||||
|
else {
|
||||||
|
root.YT = root.YT || {};
|
||||||
|
root.YT.JamesDSPFeature = api;
|
||||||
|
}
|
||||||
|
})(typeof window !== 'undefined' ? window : globalThis);
|
||||||
Reference in New Issue
Block a user