Extend JamesDSP with convolution and spatial audio
This commit is contained in:
@@ -663,7 +663,7 @@ const DEFAULT_SETTINGS = {
|
||||
fontScale: 'normal', // 'small' | 'normal' | 'large' | 'xl'
|
||||
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)
|
||||
jamesDsp: { enabled: false, preset: 'flat', params: {} },
|
||||
jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null },
|
||||
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
|
||||
density: 'comfortable', // 'comfortable' | 'compact'
|
||||
perfMode: false, // disable heavy visual effects for speed
|
||||
@@ -678,11 +678,15 @@ function normalizeJamesDspSettings(settings) {
|
||||
const params = saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {};
|
||||
// Impulse-response PCM is intentionally session-only; do not re-enable the
|
||||
// convolver on a later boot without its uploaded audio buffer.
|
||||
delete params['convolver.enabled'];
|
||||
const irRef = saved.irRef && typeof saved.irRef.name === 'string' && typeof saved.irRef.hash === 'string'
|
||||
? { name: saved.irRef.name, hash: saved.irRef.hash } : null;
|
||||
if (!irRef) delete params['convolver.enabled'];
|
||||
settings.jamesDsp = {
|
||||
enabled: saved.enabled === true,
|
||||
downloadForOffline: saved.downloadForOffline === true,
|
||||
preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
|
||||
params,
|
||||
irRef,
|
||||
};
|
||||
return settings;
|
||||
}
|
||||
@@ -5535,6 +5539,7 @@ const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load(
|
||||
const EQ_BANDS = EqParameters.bands;
|
||||
const EQ_PRESETS = EqParameters.presets;
|
||||
const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature');
|
||||
const JamesDSPTools = Lazy.proxy('feature:jamesdsp', ['generateIR', 'decodeIR', 'prepareIR', 'saveIR', 'getIR', 'listIRs', 'deleteIR'], 'JamesDSPFeature');
|
||||
const EQ = (() => {
|
||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||
const AC = window.AudioContext || window.webkitAudioContext;
|
||||
@@ -5726,7 +5731,7 @@ const EQ = (() => {
|
||||
// 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).
|
||||
function analyser() {
|
||||
if (dspController?.active) return dspController.analyser();
|
||||
if (dspController?.active && !dspController.state().bypassed) return dspController.analyser();
|
||||
if (!available() || !build()) return null;
|
||||
attach(els.video);
|
||||
attach(els.audio);
|
||||
@@ -5765,16 +5770,50 @@ const EQ = (() => {
|
||||
catch (error) { dspFailed(error); return false; }
|
||||
}
|
||||
|
||||
function dspLowEndDevice() {
|
||||
const cores=Number(navigator.hardwareConcurrency), memory=Number(navigator.deviceMemory);
|
||||
return (cores>0&&cores<=4)||(memory>0&&memory<=4);
|
||||
}
|
||||
|
||||
async function loadDspIRBuffer(buffer, reference=null) {
|
||||
if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.');
|
||||
await dspController.loadIR(buffer,reference);
|
||||
data.settings.jamesDsp.irRef=reference;
|
||||
data.settings.jamesDsp.params['convolver.enabled']=1;
|
||||
persist();
|
||||
return reference;
|
||||
}
|
||||
|
||||
async function loadDspIR(file) {
|
||||
if (!data.settings.jamesDsp?.enabled || !dspController?.active) throw new Error('Turn on JamesDSP before importing an impulse response.');
|
||||
const context = st.ctx;
|
||||
if (!context?.decodeAudioData) throw new Error('Audio decoding is unavailable on this device.');
|
||||
const audioBuffer = await context.decodeAudioData(await file.arrayBuffer());
|
||||
return dspController.loadIR(audioBuffer);
|
||||
const context=st.ctx;if(!context?.decodeAudioData)throw new Error('Audio decoding is unavailable on this device.');
|
||||
const buffer=await context.decodeAudioData(await file.arrayBuffer());
|
||||
const prepared=await JamesDSPTools.prepareIR(context,buffer,dspLowEndDevice()?24000:65536);
|
||||
const name=String(file.name||'Uploaded IR').replace(/\.[^.]+$/,'').slice(0,100)||'Uploaded IR';
|
||||
let reference=null;try{reference=await JamesDSPTools.saveIR(name,prepared,{kind:'uploaded'});}catch(error){toast(`Impulse response stays on this device for this session: ${error.message}`);}
|
||||
return loadDspIRBuffer(prepared,reference);
|
||||
}
|
||||
|
||||
async function loadDspBuiltinIR(name) {
|
||||
if(!data.settings.jamesDsp?.enabled||!dspController?.active)throw new Error('Turn on JamesDSP before loading an impulse response.');
|
||||
const context=st.ctx;if(!context)throw new Error('Audio context is unavailable.');
|
||||
const lowEnd=dspLowEndDevice();if(lowEnd&&name!=='Small room')throw new Error('Long impulse responses are disabled on this lower-power device.');
|
||||
const buffer=await JamesDSPTools.generateIR(name,context.sampleRate,2,lowEnd?24000:65536);
|
||||
let reference=null;try{reference=await JamesDSPTools.saveIR(name,buffer,{kind:'generated'});}catch(error){toast(`Generated response stays in this session: ${error.message}`);}
|
||||
return loadDspIRBuffer(buffer,reference);
|
||||
}
|
||||
|
||||
async function loadDspSavedIR(name) {
|
||||
const stored=await JamesDSPTools.getIR(name);if(!stored)throw new Error(`Stored impulse response not found: ${name}`);
|
||||
return loadDspIRBuffer(stored.buffer,{name:stored.name,hash:stored.hash});
|
||||
}
|
||||
|
||||
async function listDspIRs() {
|
||||
return JamesDSPTools.listIRs();
|
||||
}
|
||||
|
||||
return { open, apply, renderButton, available, analyser, setLevel, gains, bands: EQ_BANDS, isIOS: () => isIOS, state: () => ({ wired: !!st.ctx, ctx: st.ctx && st.ctx.state, gains: st.filters ? st.filters.map((f) => f.gain.value) : null,
|
||||
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR };
|
||||
levelDb: st.level ? Math.round(200 * Math.log10(Math.max(1e-6, st.level.gain.value))) / 10 : null, jamesDsp: dspController?.state() || null }), toggleDspBypass, loadDspIR, loadDspIRBuffer, loadDspBuiltinIR, loadDspSavedIR, listDspIRs };
|
||||
})();
|
||||
|
||||
// ============================================================================
|
||||
@@ -8857,6 +8896,8 @@ async function registerServiceWorker() {
|
||||
const reg = await navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' });
|
||||
_swReg = reg;
|
||||
Lazy.reportLayout(reg);
|
||||
reportDspOfflineOptIn(reg);
|
||||
navigator.serviceWorker.ready.then(readyReg => reportDspOfflineOptIn(readyReg)).catch(() => {});
|
||||
|
||||
// A waiting worker only means "update pending" when this page is already
|
||||
// controlled by a previous SW. On a FIRST install (fresh visit, or right
|
||||
@@ -8892,6 +8933,14 @@ async function registerServiceWorker() {
|
||||
}
|
||||
}
|
||||
|
||||
function reportDspOfflineOptIn(reg = _swReg) {
|
||||
if (!WEB || !navigator.serviceWorker) return;
|
||||
const dsp = data.settings?.jamesDsp;
|
||||
const values = dsp?.enabled || dsp?.downloadForOffline ? ['jamesDsp'] : [];
|
||||
const workers = new Set([navigator.serviceWorker.controller, reg?.installing, reg?.active, reg?.waiting]);
|
||||
for (const worker of workers) worker?.postMessage({ type: 'OFFLINE_OPT_INS', values });
|
||||
}
|
||||
|
||||
// /?q=<query> — a shareable search link (same family as Share's /?v=&t=).
|
||||
// Runs after wireUI() so the search form's submit handler exists; the param
|
||||
// is stripped immediately so a reload doesn't re-run the search.
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
'use strict';
|
||||
const CACHE = 'ytplayer-assets';
|
||||
const STATE = '/__ytp_asset_state';
|
||||
const OPT_INS = '/__ytp_offline_opt_ins';
|
||||
const url = (path, file) => path + '?v=' + file.h;
|
||||
|
||||
function paths(manifest) {
|
||||
return Object.keys(manifest.files).sort();
|
||||
}
|
||||
|
||||
function offlinePaths(manifest, optIns = []) {
|
||||
const allowed = new Set(optIns || []), optional = new Map();
|
||||
for (const group of Object.values(manifest.groups || {})) {
|
||||
if (!group.offlineOptIn || allowed.has(group.offlineOptIn)) continue;
|
||||
for (const path of group.files || []) optional.set(path, true);
|
||||
}
|
||||
return paths(manifest).filter(path => !optional.has(path));
|
||||
}
|
||||
|
||||
function blocking(manifest, previous, activeLayout) {
|
||||
const names = new Set(Object.entries(manifest.groups)
|
||||
.filter(([name, group]) => name === 'core' || !group.background ||
|
||||
@@ -61,13 +71,17 @@
|
||||
return valid;
|
||||
}
|
||||
|
||||
async function status(manifest, cache, { previous, activeLayout, offline = true } = {}) {
|
||||
async function readOptIns(cache) {
|
||||
try { return (await (await cache.match(OPT_INS))?.json()) || []; } catch { return []; }
|
||||
}
|
||||
|
||||
async function status(manifest, cache, { previous, activeLayout, offline = true, optIns } = {}) {
|
||||
const found = await Promise.all(blocking(manifest, previous, activeLayout).map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return verifiedProbe(response, manifest.files[path].h);
|
||||
}));
|
||||
const missing = found.filter(value => !value).length;
|
||||
return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache) : {}) };
|
||||
return { ready: missing === 0, missing, version: manifest.buildTag, ...(offline ? await completeness(manifest, cache, { optIns }) : {}) };
|
||||
}
|
||||
|
||||
// All concurrent messages/layout jobs in this worker share six download slots.
|
||||
@@ -89,10 +103,10 @@
|
||||
finally { pendingDownloads.delete(key); }
|
||||
}
|
||||
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false }) {
|
||||
async function syncAssets(manifest, { cache, fetchFn, concurrency = 6, attempts = 3, activeLayout, groups, all = false, optIns } = {}) {
|
||||
const previous = (await state(cache))?.current;
|
||||
const missing = [];
|
||||
const selected = all ? paths(manifest) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
|
||||
const selected = all ? offlinePaths(manifest, optIns || await readOptIns(cache)) : groups ? [...new Set(groups.flatMap(name => manifest.groups[name]?.files || []))].sort()
|
||||
: blocking(manifest, previous, activeLayout);
|
||||
for (const path of selected) {
|
||||
const key = url(path, manifest.files[path]);
|
||||
@@ -135,8 +149,8 @@
|
||||
|
||||
// Verified cache entries are the durable progress journal. Counters are rebuilt
|
||||
// after worker termination or browser eviction; never trust a stored ready bit.
|
||||
async function completeness(manifest, cache) {
|
||||
const files = paths(manifest);
|
||||
async function completeness(manifest, cache, { optIns } = {}) {
|
||||
const files = offlinePaths(manifest, optIns || await readOptIns(cache));
|
||||
const missingFiles = (await Promise.all(files.map(async path => {
|
||||
const response = await cache.match(url(path, manifest.files[path]));
|
||||
return verifiedProbe(response, manifest.files[path].h) ? null : path;
|
||||
@@ -146,12 +160,13 @@
|
||||
}
|
||||
|
||||
const completionKey = manifest => '/__ytp_completion/' + manifest.buildTag;
|
||||
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now,
|
||||
async function complete(manifest, { cache, fetchFn, cycles = 4, now = Date.now, optIns,
|
||||
sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), baseDelay = 1000,
|
||||
maxDelay = 30000, notify = () => {}, force = false } = {}) {
|
||||
const key = completionKey(manifest), saved = await cache.match(key);
|
||||
let job = saved ? await saved.json() : { failures: 0, nextRetryAt: 0 };
|
||||
let snapshot = await completeness(manifest, cache);
|
||||
const selectedOptIns = optIns || await readOptIns(cache);
|
||||
let snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
|
||||
const publish = async running => {
|
||||
job = { ...job, ...snapshot, running, version: manifest.buildTag };
|
||||
await cache.put(key, Response.json(job));
|
||||
@@ -162,9 +177,9 @@
|
||||
for (let cycle = 0; cycle < cycles; cycle++) {
|
||||
await publish(true);
|
||||
let error;
|
||||
try { await syncAssets(manifest, { cache, fetchFn, all: true }); }
|
||||
try { await syncAssets(manifest, { cache, fetchFn, all: true, optIns: selectedOptIns }); }
|
||||
catch (failure) { error = failure; }
|
||||
snapshot = await completeness(manifest, cache);
|
||||
snapshot = await completeness(manifest, cache, { optIns: selectedOptIns });
|
||||
if (snapshot.offlineReady) { job.failures = 0; job.nextRetryAt = 0; job.error = null; await publish(false); return job; }
|
||||
job.failures++;
|
||||
const delay = Math.min(maxDelay, baseDelay * 2 ** Math.min(job.failures - 1, 10));
|
||||
@@ -198,7 +213,7 @@
|
||||
return next;
|
||||
}
|
||||
|
||||
const api = { CACHE, STATE, url, paths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, state, commit };
|
||||
const api = { CACHE, STATE, OPT_INS, url, paths, offlinePaths, blocking, plan, retained, fallback, status, completeness, completionKey, complete, syncAssets, readOptIns, state, commit };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else root.AssetSyncCore = api;
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
|
||||
@@ -56,6 +56,20 @@ test('completion includes ungrouped runtime files and exposes verified missing p
|
||||
assert.equal((await core.status(m,c)).offlineReady,true);
|
||||
});
|
||||
|
||||
test('offline completion skips opt-in DSP assets until the user enables offline audio effects', async () => {
|
||||
const c=cache(),m=manifest();m.files['/vendor/jamesdsp/dsp/jamesdsp.wasm']={h:'wasm'};
|
||||
m.groups.jamesdsp={contract:1,eager:false,background:true,offlineOptIn:'jamesDsp',files:['/vendor/jamesdsp/dsp/jamesdsp.wasm']};
|
||||
const calls=[];
|
||||
const defaultJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}});
|
||||
assert.deepEqual(calls.sort(),['/a.js?v=a','/index.html?v=b']);
|
||||
assert.equal(defaultJob.total,2);assert.equal(defaultJob.offlineReady,true);
|
||||
calls.length=0;
|
||||
await c.put(core.OPT_INS,Response.json(['jamesDsp']));
|
||||
const optedJob=await core.complete(m,{cache:c,fetchFn:async u=>{calls.push(u);return response(u.split('=')[1])}});
|
||||
assert.deepEqual(calls,['/vendor/jamesdsp/dsp/jamesdsp.wasm?v=wasm']);
|
||||
assert.equal(optedJob.total,3);assert.equal(optedJob.offlineReady,true);
|
||||
});
|
||||
|
||||
test('completion persists backoff and resumes only missing files after a worker restart',async()=>{
|
||||
const c=cache(),m=manifest();let clock=1000;const calls=[];
|
||||
await core.complete(m,{cache:c,cycles:1,now:()=>clock,baseDelay:10,fetchFn:async u=>{if(u.includes('index'))throw Error('offline');return response('a');}});
|
||||
|
||||
@@ -365,12 +365,16 @@
|
||||
"contract": 1,
|
||||
"eager": false,
|
||||
"background": true,
|
||||
"offlineOptIn": "jamesDsp",
|
||||
"files": [
|
||||
"/vendor/jamesdsp/jamesdsp-feature.js",
|
||||
"/vendor/jamesdsp/jamesdsp-node.mjs",
|
||||
"/vendor/jamesdsp/params.mjs",
|
||||
"/vendor/jamesdsp/ir-utils.mjs",
|
||||
"/vendor/jamesdsp/ir-builtins.mjs",
|
||||
"/vendor/jamesdsp/ir-store.mjs",
|
||||
"/vendor/jamesdsp/worklet.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs",
|
||||
"/vendor/jamesdsp/dsp/jamesdsp.wasm"
|
||||
],
|
||||
"dependencies": [
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -16,7 +16,8 @@ function parsePreset(text) {
|
||||
}
|
||||
|
||||
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, /jamesDsp: \{ enabled: false, downloadForOffline: false, preset: 'flat', params: \{\}, irRef: null \}/);
|
||||
assert.match(app, /downloadForOffline: saved\.downloadForOffline === true/);
|
||||
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 \|\| \{\}\) \}\)/);
|
||||
@@ -47,6 +48,12 @@ test('Playback settings expose persisted presets, bands, safe import/export, IR,
|
||||
assert.match(views, /\[25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000\]/);
|
||||
assert.match(views, /imported\?\.texts/);
|
||||
assert.doesNotMatch(views, /liveprog|ddc|arbitrary/);
|
||||
assert.match(views, /navigator\.hardwareConcurrency/);
|
||||
assert.match(views, /Long impulse responses are disabled/);
|
||||
assert.doesNotMatch(views, /44\.1 or 48 kHz/);
|
||||
assert.match(views, /setDspSpatialMode/);
|
||||
assert.match(views, /data-dsp-param="convolver\.predelayMs"/);
|
||||
assert.match(views, /data-dsp-param="convolver\.mix"/);
|
||||
assert.match(app, /Bypass JamesDSP/);
|
||||
assert.match(app, /toggleDspBypass/);
|
||||
});
|
||||
|
||||
@@ -12,13 +12,17 @@ test('JamesDSP runtime files live only in a lazy background group', () => {
|
||||
assert.equal(group.contract, 1);
|
||||
assert.equal(group.eager, false);
|
||||
assert.equal(group.background, true);
|
||||
assert.equal(group.offlineOptIn, 'jamesDsp');
|
||||
assert.deepEqual(group.dependencies, ['feature:eq-core', 'feature:vocal-reducer']);
|
||||
assert.deepEqual(group.files, [
|
||||
'/vendor/jamesdsp/jamesdsp-feature.js',
|
||||
'/vendor/jamesdsp/jamesdsp-node.mjs',
|
||||
'/vendor/jamesdsp/params.mjs',
|
||||
'/vendor/jamesdsp/ir-utils.mjs',
|
||||
'/vendor/jamesdsp/ir-builtins.mjs',
|
||||
'/vendor/jamesdsp/ir-store.mjs',
|
||||
'/vendor/jamesdsp/worklet.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs',
|
||||
'/vendor/jamesdsp/dsp/jamesdsp.wasm',
|
||||
]);
|
||||
for (const path of group.files) assert.equal(assets.groups.core.files.includes(path), false, `${path} is not core`);
|
||||
@@ -33,24 +37,31 @@ test('JamesDSP resolves every module dependency through the page build manifest'
|
||||
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/dsp\/jamesdsp\.wasm'/);
|
||||
assert.match(node, /assetURL\('\/vendor\/jamesdsp\/params\.mjs'/);
|
||||
assert.match(node, /processorOptions:\{wasmBytes\}/);
|
||||
assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-77be0b2\.mjs'/);
|
||||
assert.match(worklet, /import createJamesDSP from '\.\/dsp\/jamesdsp-a982b53\.mjs'/);
|
||||
assert.doesNotMatch(worklet, /import\(/);
|
||||
assert.match(node, /context\.sampleRate<8000\|\|context\.sampleRate>96000/);
|
||||
assert.match(node, /!\[1,2,4\]\.includes\(buffer\.numberOfChannels\) \|\| buffer\.length>65536/);
|
||||
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('player wrapper rejects text effects while portable presets retain IR references', () => {
|
||||
const node = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-node.mjs'), 'utf8');
|
||||
const worklet = readFileSync(join(root, 'vendor/jamesdsp/worklet.mjs'), 'utf8');
|
||||
assert.match(node, /Text effects are disabled in this player version/);
|
||||
assert.match(node, /toJSON\(\) \{return \{version:1,params:/);
|
||||
assert.match(node, /toJSON\(\) \{return \{version:2,params:/);
|
||||
assert.match(node, /irCapFrames:view\.getUint32\(32,true\)/);
|
||||
assert.match(node, /getIR\(preset\.irRef\.name\)/);
|
||||
const feature = readFileSync(join(root, 'vendor/jamesdsp/jamesdsp-feature.js'), 'utf8');
|
||||
assert.match(feature, /async function prepareIR\(context, buffer, maxFrames = 65536\)/);
|
||||
assert.match(feature, /prepared\.length>Math\.min\(65536,maxFrames\)/);
|
||||
assert.doesNotMatch(worklet, /data\.type==='text'/);
|
||||
});
|
||||
|
||||
test('vendor provenance pins the source and retains both supplied license texts', () => {
|
||||
const version = readFileSync(join(root, 'vendor/jamesdsp/VERSION.txt'), 'utf8');
|
||||
assert.match(version, /77be0b2e825a20ba98feff33d16dd0a43232ef3b/);
|
||||
assert.match(version, /a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817/);
|
||||
assert.match(version, /GPL-2\.0 text/);
|
||||
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/LICENSE.txt'), 'utf8').includes('Version 3'));
|
||||
assert.ok(readFileSync(join(root, 'vendor/jamesdsp/UPSTREAM-MAIN-LICENSE.txt'), 'utf8').includes('Version 2'));
|
||||
|
||||
@@ -52,11 +52,11 @@ test('a rejected or unsupported engine leaves the Standard route active', async
|
||||
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);
|
||||
const supportedRate = fixture();
|
||||
supportedRate.context.sampleRate = 32000;
|
||||
await supportedRate.controller.enable({ preset: 'flat' });
|
||||
assert.equal(supportedRate.controller.state().active, true);
|
||||
assert.equal(supportedRate.controller.state().sampleRate, 32000);
|
||||
});
|
||||
|
||||
test('a runtime worklet failure switches sources to Standard before destroying the heap', async () => {
|
||||
@@ -101,9 +101,14 @@ test('A/B bypass is retained when settings are reapplied', async () => {
|
||||
const f = fixture();
|
||||
await f.controller.enable({ preset: 'warm' }, 0);
|
||||
assert.equal(await f.controller.toggleBypass(), true);
|
||||
assert.equal(f.controller.state().route, 'standard');
|
||||
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'bypass follows the exact Standard route');
|
||||
await f.controller.enable({ preset: 'bass' }, 0);
|
||||
assert.equal(f.controller.state().bypassed, true);
|
||||
assert.equal(f.controller.state().route, 'standard');
|
||||
assert.deepEqual(f.routed.at(-1), [['video', 'audio'], f.standardInput], 'settings changes cannot replace the Standard route while bypassed');
|
||||
assert.ok(f.bypass.at(-1));
|
||||
assert.equal(await f.controller.toggleBypass(), false);
|
||||
assert.equal(f.controller.state().route, 'jamesdsp');
|
||||
assert.equal(f.bypass.at(-1), false);
|
||||
});
|
||||
|
||||
23
frontend/jamesdsp-ir.test.js
Normal file
23
frontend/jamesdsp-ir.test.js
Normal file
@@ -0,0 +1,23 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
test('generated room responses support four true-stereo paths and respect the low-memory cap', async () => {
|
||||
const { generateIR, BUILTIN_IRS } = await import('./vendor/jamesdsp/ir-builtins.mjs');
|
||||
const { resampleIR } = await import('./vendor/jamesdsp/ir-utils.mjs');
|
||||
const ir = generateIR('Hall', 48000, 4, { maxFrames: 24000 });
|
||||
assert.equal(ir.numberOfChannels, 4);
|
||||
assert.equal(ir.length, 24000);
|
||||
assert.equal(ir.truncated, true);
|
||||
assert.ok(ir.getChannelData(0).some((sample, i) => sample !== ir.getChannelData(1)[i]), 'each matrix path has distinct taps');
|
||||
const converted = resampleIR(ir, 32000);
|
||||
assert.equal(converted.sampleRate, 32000);
|
||||
assert.equal(converted.numberOfChannels, 4);
|
||||
assert.equal(converted.length, 16000);
|
||||
assert.deepEqual(Object.keys(BUILTIN_IRS), ['Small room', 'Hall', 'Plate', 'Cathedral']);
|
||||
});
|
||||
|
||||
test('the IndexedDB store rejects impulse responses beyond its measured internal-frame cap', async () => {
|
||||
const { saveIR } = await import('./vendor/jamesdsp/ir-store.mjs');
|
||||
const oversized = { sampleRate: 48000, numberOfChannels: 4, length: 65537, getChannelData: () => new Float32Array(65537) };
|
||||
await assert.rejects(saveIR('oversized', oversized), /65,536-frame internal limit/);
|
||||
});
|
||||
@@ -6,8 +6,8 @@ const source = fs.readFileSync(__dirname + '/app.js', 'utf8');
|
||||
// Exercise the production persistence/profile functions without starting a DOM player.
|
||||
const declaration = name => source.match(new RegExp('(?:async )?function ' + name + '\\([^]*?\\n}'))[0];
|
||||
function fixture(updatedAt) {
|
||||
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 defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, preset: 'flat', params: {} } };
|
||||
const settings = { saveBeforePlay: true, autoPreload: true, layout: 'f7-swipe', eq: { preset: 'custom', g: [1,2,3,4,5] }, jamesDsp: { enabled: true, downloadForOffline: true, preset: 'warm', params: { 'eq.band0': 3 } } };
|
||||
const defaults = { saveBeforePlay: false, autoPreload: true, layout: 'classic', jamesDsp: { enabled: false, downloadForOffline: false, preset: 'flat', params: {} } };
|
||||
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;
|
||||
@@ -18,7 +18,7 @@ test('stored settings load verbatim instead of reverting to defaults', () => {
|
||||
assert.equal(loaded.settings.saveBeforePlay, true);
|
||||
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.jamesDsp)), { enabled: true, preset: 'warm', params: { 'eq.band0': 3 } });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(loaded.settings.jamesDsp)), { enabled: true, downloadForOffline: 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 () => {
|
||||
const c = fixture(updatedAt), previous = c.data.settings;
|
||||
@@ -32,6 +32,6 @@ test('newer profile intentionally replaces settings and restores missing default
|
||||
assert.equal(c.data.settings.saveBeforePlay, false);
|
||||
assert.equal(c.data.settings.autoPreload, true);
|
||||
assert.equal(c.data.settings.layout, 'classic');
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, preset: 'flat', params: {} });
|
||||
assert.deepEqual(JSON.parse(JSON.stringify(c.data.settings.jamesDsp)), { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
|
||||
assert.equal(c.data.profile.syncedAt, 300);
|
||||
});
|
||||
|
||||
@@ -1165,6 +1165,9 @@ input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.25); }
|
||||
.dsp-param-row input[type="range"] { width: min(46vw, 190px); }
|
||||
.dsp-actions { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px 0; }
|
||||
.dsp-actions .btn { color: var(--text); }
|
||||
.dsp-subsection { margin: 14px 0; padding: 12px; border: 1px solid var(--line); border-radius: 10px; background: var(--bg-1); }
|
||||
.dsp-subsection h4 { margin: 0 0 8px; color: var(--text); }
|
||||
.dsp-risk { padding: 9px 11px; border-radius: 8px; color: var(--text); background: color-mix(in srgb, var(--accent) 13%, var(--bg-2)); font-size: .9em; }
|
||||
.set-row input[type="checkbox"] { width: 17px; height: 17px; accent-color: var(--accent); cursor: pointer; }
|
||||
.about-version { color: var(--text-dim); font-size: 12px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; user-select: text; }
|
||||
.set-select {
|
||||
|
||||
28
frontend/vendor/jamesdsp/README.md
vendored
28
frontend/vendor/jamesdsp/README.md
vendored
@@ -1,19 +1,17 @@
|
||||
# JamesDSP vendor files
|
||||
|
||||
The WebAssembly engine, parameter table and browser adapter originate from
|
||||
`jamesdsp-web` revision `77be0b2e825a20ba98feff33d16dd0a43232ef3b`. Source is
|
||||
available in that repository and the underlying JamesDSPManager source is
|
||||
referenced by its `upstream/` tree. The runtime files remain in the lazy
|
||||
`feature:jamesdsp` asset group; license and provenance documents are not runtime
|
||||
assets.
|
||||
The browser port's worklet, 8–96 kHz streaming sample-rate adapter, matrix
|
||||
convolver, HRTF/virtual-speaker spatial stage, generated IRs and IndexedDB IR
|
||||
store are pinned to the source revision in VERSION.txt. Runtime files stay in
|
||||
the lazy `feature:jamesdsp` group. A user must enable the engine or choose
|
||||
“Download audio effects for offline” before the offline-completion job caches
|
||||
them, including the WASM binary.
|
||||
|
||||
`jamesdsp-node.js` and `worklet.js` carry the integration changes described in
|
||||
`VERSION.txt`. The generated C engine and `params.js` are unchanged from the
|
||||
pinned revision. The Node wrapper intentionally rejects text-effect parameters
|
||||
and does not serialize text, preventing Liveprog/DDC/arbitrary text payloads
|
||||
from entering the v1 surface.
|
||||
The adapted wrapper resolves dependencies through `Lazy.url()` and routes the
|
||||
worklet through the existing AudioGraph. It rejects Liveprog, DDC and arbitrary
|
||||
text effects. Impulse responses are limited to 65,536 internal frames; lower-
|
||||
power devices use a 24,000-frame cap and warn before larger responses are used.
|
||||
|
||||
The upstream `upstream/Main/LICENSE` text says GPL-2.0 while the port repository
|
||||
ships a GPL-3.0-or-later license. Both texts are retained as supplied. Confirm
|
||||
the upstream provenance and redistribution position before a public release;
|
||||
this note is not a legal conclusion.
|
||||
The port is GPL-3.0-or-later (`LICENSE.txt`). The upstream license snapshot says
|
||||
GPL-2.0; both supplied notices remain in this directory pending upstream
|
||||
clarification. See VERSION.txt for the adaptation record.
|
||||
|
||||
27
frontend/vendor/jamesdsp/VERSION.txt
vendored
27
frontend/vendor/jamesdsp/VERSION.txt
vendored
@@ -1,15 +1,18 @@
|
||||
JamesDSP browser port source revision: 77be0b2e825a20ba98feff33d16dd0a43232ef3b
|
||||
JamesDSP browser port source revision: a982b53c4ab347d1d9c50f4ce7ee3c2735eaa817
|
||||
Repository: /home/josh/development/personal/jamesdsp-web (main)
|
||||
Vendored from web/jamesdsp-node.js, web/params.js, web/worklet.js,
|
||||
web/dsp/jamesdsp.js and web/dsp/jamesdsp.wasm.
|
||||
Vendored source files: web/jamesdsp-node.js, params.js, ir-utils.js,
|
||||
ir-builtins.js, ir-store.js, worklet.js, dsp/jamesdsp.js and dsp/jamesdsp.wasm.
|
||||
|
||||
Integration adaptations: manifest-hashed module URLs are selected through Lazy;
|
||||
the worklet carries its engine URL in a fragment so its HTTP/CacheStorage key
|
||||
remains the exact manifest URL; the wrapper and worklet do not expose text
|
||||
effects (Liveprog, DDC, arbitrary EQ) in this v1 integration. Upstream source is
|
||||
retained unchanged for the generated engine and parameter module.
|
||||
Integration adaptations: wrapper dependencies and WASM URLs use the page's
|
||||
manifest-hashed Lazy.url map; the worklet statically imports the pinned
|
||||
jamesdsp-a982b53.mjs module because AudioWorkletGlobalScope rejects dynamic
|
||||
import. The wrapper keeps the shared AudioGraph route and does not create a
|
||||
MediaElementSource. Liveprog, DDC and arbitrary text effects are rejected and
|
||||
not serialized in ytplayer. All runtime code remains in lazy feature:jamesdsp;
|
||||
its offline completion membership requires the explicit jamesDsp opt-in.
|
||||
|
||||
License note: LICENSE.txt is the port's GPL-3.0-or-later license. The upstream
|
||||
snapshot at upstream/Main/LICENSE contains GPL-2.0 text; the discrepancy is
|
||||
preserved and recorded for release review. See README.md and JamesDSP's source
|
||||
repository for corresponding source information.
|
||||
The generated engine, parameter tables, IR utilities/store and built-in IR set
|
||||
are copied from the pinned revision. The uploaded/generated IR cap is 65,536
|
||||
frames at the internal 44.1/48 kHz rate (low-power devices use 24,000 frames).
|
||||
License provenance: LICENSE.txt is the port's GPL-3.0-or-later license. The
|
||||
upstream snapshot at upstream/Main/LICENSE contains GPL-2.0 text; see README.md.
|
||||
|
||||
File diff suppressed because one or more lines are too long
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
BIN
frontend/vendor/jamesdsp/dsp/jamesdsp.wasm
vendored
Binary file not shown.
22
frontend/vendor/jamesdsp/ir-builtins.mjs
vendored
Normal file
22
frontend/vendor/jamesdsp/ir-builtins.mjs
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
const definitions={
|
||||
'Small room':{seconds:.5,rt60:.25,seed:0x51a11},
|
||||
Hall:{seconds:2,rt60:1.4,seed:0x4a11},
|
||||
Plate:{seconds:1.5,rt60:1,seed:0x91a7e},
|
||||
Cathedral:{seconds:5,rt60:4,seed:0xca7ed},
|
||||
};
|
||||
export const BUILTIN_IRS=Object.freeze(Object.fromEntries(Object.entries(definitions).map(([name,d])=>[name,Object.freeze({...d,version:1})])));
|
||||
export function generateIR(name,sampleRate,channels=2,{maxFrames=65536}={}) {
|
||||
const spec=BUILTIN_IRS[name];if(!spec||!Number.isInteger(sampleRate)||sampleRate<8000||sampleRate>96000||![1,2,4].includes(channels)) throw new RangeError('Unsupported generated IR');
|
||||
const frames=Math.max(1,Math.min(Math.round(spec.seconds*sampleRate),maxFrames));let seed=spec.seed>>>0;
|
||||
const paths=Array.from({length:channels},()=>new Float32Array(frames));
|
||||
const next=()=>{seed=(Math.imul(seed,1664525)+1013904223)>>>0;return seed/0x100000000*2-1;};
|
||||
for(let path=0;path<channels;path++) {
|
||||
const x=paths[path];x[0]=1;
|
||||
for(let tap=1;tap<Math.min(24,frames);tap++) {const at=Math.min(frames-1,Math.round(sampleRate*(.004+tap*.009+next()*.001)));x[at]+=(next()*.22)/(1+tap*.25);}
|
||||
for(let i=1;i<frames;i++) {const t=i/sampleRate, fade=Math.min(1,t/.012,(frames-i)/Math.max(1,sampleRate*.02));x[i]+=next()*.045*Math.exp(-6.91*t/spec.rt60)*Math.max(0,fade);}
|
||||
}
|
||||
let peak=0;for(const path of paths) for(const value of path) peak=Math.max(peak,Math.abs(value));
|
||||
for(const path of paths) for(let i=0;i<path.length;i++) path[i]*=.5/peak;
|
||||
return {name,sampleRate,numberOfChannels:channels,length:frames,seed:spec.seed,version:1,truncated:frames<spec.seconds*sampleRate,getChannelData:i=>paths[i]};
|
||||
}
|
||||
24
frontend/vendor/jamesdsp/ir-store.mjs
vendored
Normal file
24
frontend/vendor/jamesdsp/ir-store.mjs
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
const DB='jamesdsp-ir', STORE='irs', VERSION=1;
|
||||
function openDB() {
|
||||
if(!globalThis.indexedDB) return Promise.reject(new Error('IndexedDB is unavailable'));
|
||||
return new Promise((resolve,reject)=>{const request=indexedDB.open(DB,VERSION);request.onupgradeneeded=()=>request.result.createObjectStore(STORE,{keyPath:'name'});request.onsuccess=()=>resolve(request.result);request.onerror=()=>reject(request.error);});
|
||||
}
|
||||
async function transaction(mode,action) {
|
||||
const db=await openDB();return new Promise((resolve,reject)=>{const tx=db.transaction(STORE,mode),store=tx.objectStore(STORE);let result;try {result=action(store);} catch(error){db.close();reject(error);return;}tx.oncomplete=()=>{db.close();resolve(result);};tx.onerror=()=>{db.close();reject(tx.error);};tx.onabort=()=>{db.close();reject(tx.error||new Error('IR storage transaction aborted'));};});
|
||||
}
|
||||
async function digest(data) {const hash=await crypto.subtle.digest('SHA-256',data);return [...new Uint8Array(hash)].map(x=>x.toString(16).padStart(2,'0')).join('');}
|
||||
export async function saveIR(name,buffer,metadata={}) {
|
||||
if(typeof name!=='string'||!name.trim()||name.length>100||![1,2,4].includes(buffer?.numberOfChannels)||!Number.isFinite(buffer.sampleRate)||buffer.sampleRate<8000||buffer.sampleRate>96000||!buffer.length) throw new TypeError('Invalid named impulse response');
|
||||
const internalRate=buffer.sampleRate===44100?44100:48000;
|
||||
if(Math.round(buffer.length*internalRate/buffer.sampleRate)>65536) throw new RangeError('Stored impulse response exceeds the 65,536-frame internal limit');
|
||||
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
|
||||
const pcm=new Float32Array(channels.length*buffer.length);for(let i=0;i<buffer.length;i++)for(let c=0;c<channels.length;c++)pcm[i*channels.length+c]=channels[c][i];
|
||||
const record={name:name.trim(),sampleRate:buffer.sampleRate,channels:channels.length,frames:buffer.length,pcm,metadata:{...metadata},hash:await digest(pcm.buffer),version:1};
|
||||
await transaction('readwrite',s=>s.put(record));return {name:record.name,hash:record.hash};
|
||||
}
|
||||
export async function getIR(name) {
|
||||
const db=await openDB();return new Promise((resolve,reject)=>{const r=db.transaction(STORE,'readonly').objectStore(STORE).get(name);r.onsuccess=async()=>{try {const x=r.result;if(!x){resolve(null);return;}if(await digest(x.pcm.buffer)!==x.hash)throw new Error('Stored IR hash mismatch');const channels=Array.from({length:x.channels},()=>new Float32Array(x.frames));for(let i=0;i<x.frames;i++)for(let c=0;c<x.channels;c++)channels[c][i]=x.pcm[i*x.channels+c];resolve({...x,buffer:{sampleRate:x.sampleRate,numberOfChannels:x.channels,length:x.frames,getChannelData:i=>channels[i]}});}catch(e){reject(e);}finally{db.close();}};r.onerror=()=>{db.close();reject(r.error);};});
|
||||
}
|
||||
export async function listIRs() {const db=await openDB();return new Promise((resolve,reject)=>{const r=db.transaction(STORE,'readonly').objectStore(STORE).getAll();r.onsuccess=()=>{db.close();resolve(r.result.map(({name,hash,sampleRate,channels,frames,metadata})=>({name,hash,sampleRate,channels,frames,metadata})));};r.onerror=()=>{db.close();reject(r.error);};});}
|
||||
export async function deleteIR(name) {await transaction('readwrite',s=>s.delete(name));}
|
||||
43
frontend/vendor/jamesdsp/ir-utils.mjs
vendored
Normal file
43
frontend/vendor/jamesdsp/ir-utils.mjs
vendored
Normal file
@@ -0,0 +1,43 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
const copyBuffer=(buffer,rate=buffer.sampleRate)=>{
|
||||
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
|
||||
return {sampleRate:rate,numberOfChannels:channels.length,length:channels[0]?.length||0,getChannelData:i=>channels[i]};
|
||||
};
|
||||
export async function decodeIR(context,bytes) {
|
||||
const decoded=await context.decodeAudioData(bytes.slice(0));
|
||||
return resampleIR(decoded,context.sampleRate);
|
||||
}
|
||||
export function resampleIR(buffer,targetRate) {
|
||||
if(!Number.isFinite(targetRate)||targetRate<8000||targetRate>96000||!buffer?.numberOfChannels||buffer.numberOfChannels>4||!buffer.length) throw new RangeError('Invalid IR or target sample rate');
|
||||
if(buffer.sampleRate===targetRate) return copyBuffer(buffer,targetRate);
|
||||
const length=Math.max(1,Math.round(buffer.length*targetRate/buffer.sampleRate));
|
||||
const channels=[];
|
||||
for(let ch=0;ch<buffer.numberOfChannels;ch++) {
|
||||
const input=buffer.getChannelData(ch), output=new Float32Array(length), ratio=buffer.sampleRate/targetRate;
|
||||
for(let i=0;i<length;i++) {const x=i*ratio, a=Math.min(Math.floor(x),input.length-1), b=Math.min(a+1,input.length-1), f=x-a;output[i]=input[a]*(1-f)+input[b]*f;}
|
||||
channels.push(output);
|
||||
}
|
||||
return {sampleRate:targetRate,numberOfChannels:channels.length,length,getChannelData:i=>channels[i]};
|
||||
}
|
||||
export function trimIR(buffer,{thresholdDb=-80}={}) {
|
||||
if(!Number.isFinite(thresholdDb)||thresholdDb>0||thresholdDb<-160) throw new RangeError('Invalid trim threshold');
|
||||
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>buffer.getChannelData(i));
|
||||
const threshold=10**(thresholdDb/20);let first=buffer.length,last=-1;
|
||||
for(let i=0;i<buffer.length;i++) {let peak=0;for(const channel of channels) peak=Math.max(peak,Math.abs(channel[i]));if(peak>=threshold){first=Math.min(first,i);last=i;}}
|
||||
if(last<0) first=last=0;
|
||||
const result=channels.map(channel=>channel.slice(first,last+1));
|
||||
return {buffer:{sampleRate:buffer.sampleRate,numberOfChannels:result.length,length:result[0]?.length||0,getChannelData:i=>result[i]},startFrame:first,endFrame:last+1};
|
||||
}
|
||||
export function normalizeIR(buffer,{peak=0.5}={}) {
|
||||
if(!Number.isFinite(peak)||peak<=0||peak>1) throw new RangeError('Peak must be in (0, 1]');
|
||||
const channels=Array.from({length:buffer.numberOfChannels},(_,i)=>new Float32Array(buffer.getChannelData(i)));
|
||||
let max=0;for(const channel of channels) for(const value of channel) max=Math.max(max,Math.abs(value));
|
||||
const gain=max===0?1:peak/max;for(const channel of channels) for(let i=0;i<channel.length;i++) channel[i]*=gain;
|
||||
return {sampleRate:buffer.sampleRate,numberOfChannels:channels.length,length:channels[0]?.length||0,getChannelData:i=>channels[i]};
|
||||
}
|
||||
export function prepareIR(context,buffer,{trim=false,normalize=true,peak=0.5,thresholdDb=-80}={}) {
|
||||
let result=resampleIR(buffer,context.sampleRate);
|
||||
if(trim) result=trimIR(result,{thresholdDb}).buffer;
|
||||
if(normalize) result=normalizeIR(result,{peak});
|
||||
return result;
|
||||
}
|
||||
70
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
70
frontend/vendor/jamesdsp/jamesdsp-feature.js
vendored
@@ -2,6 +2,13 @@
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
let irToolsPromise;
|
||||
const irTools = () => irToolsPromise ||= Promise.all([
|
||||
import(root.Lazy.url('/vendor/jamesdsp/ir-utils.mjs')),
|
||||
import(root.Lazy.url('/vendor/jamesdsp/ir-builtins.mjs')),
|
||||
import(root.Lazy.url('/vendor/jamesdsp/ir-store.mjs')),
|
||||
]).then(([utils,builtins,store])=>({utils,builtins,store}));
|
||||
|
||||
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 } },
|
||||
@@ -18,7 +25,7 @@
|
||||
const graph = options.graph;
|
||||
let desired = false, active = false, bypassed = 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 tail = Promise.resolve(), maxLiveHeaps = 0, liveHeaps = 0, nodesCreated = 0, nodesDestroyed = 0, loadedIRRef = null;
|
||||
let faultListener = null, notifying = false, dspAnalyser = null;
|
||||
|
||||
const serial = fn => {
|
||||
@@ -27,7 +34,7 @@
|
||||
return result;
|
||||
};
|
||||
|
||||
function snapshot() { return { active, desired, bypassed, liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null }; }
|
||||
function snapshot() { return { active, desired, bypassed, route: active && !bypassed ? 'jamesdsp' : 'standard', liveHeaps, maxLiveHeaps, nodesCreated, nodesDestroyed, sampleRate: context?.sampleRate || null, irRef: loadedIRRef }; }
|
||||
|
||||
function routeStandard() {
|
||||
if (!elements.length) return;
|
||||
@@ -51,6 +58,7 @@
|
||||
dspAnalyser = null;
|
||||
if (entry) {
|
||||
try { entry.node.destroy(); } catch { /* continue freeing references */ }
|
||||
loadedIRRef = null;
|
||||
if (entry.live) { entry.live = false; liveHeaps = Math.max(0, liveHeaps - 1); nodesDestroyed++; }
|
||||
}
|
||||
}
|
||||
@@ -84,6 +92,7 @@
|
||||
async function configure(instance, nextSettings, nextLevelDb) {
|
||||
const preset = PRESETS[nextSettings?.preset] || PRESETS.flat;
|
||||
const params = { ...preset.params, ...(nextSettings?.params || {}) };
|
||||
if (Number(params['spatial.enabled']) && Number(params['crossfeed.enabled'])) params['crossfeed.enabled'] = 0;
|
||||
params.gain = clampGain((Number(params.gain) || 0) + (Number(nextLevelDb) || 0));
|
||||
params.bypass = 1;
|
||||
if (active && context?.state === 'running') {
|
||||
@@ -91,7 +100,10 @@
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
}
|
||||
await instance.setBypass(true);
|
||||
await instance.applyPreset(params);
|
||||
const ref = nextSettings?.irRef;
|
||||
const needsIR = ref && (loadedIRRef?.name !== ref.name || loadedIRRef?.hash !== ref.hash);
|
||||
await instance.applyPreset(needsIR ? { params, irRef: ref } : params);
|
||||
if (needsIR) loadedIRRef = { name: ref.name, hash: ref.hash };
|
||||
await instance.setParam('gain', params.gain);
|
||||
if (desired) await instance.setBypass(bypassed);
|
||||
if (desired) rampGain(1);
|
||||
@@ -117,7 +129,7 @@
|
||||
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 (!Number.isFinite(context?.sampleRate) || context.sampleRate < 8000 || context.sampleRate > 96000) throw new RangeError(`JamesDSP supports 8–96 kHz; this device uses ${context?.sampleRate || 'unknown'} Hz`);
|
||||
if (!desired) return false;
|
||||
let instance = nodeEntry?.node;
|
||||
if (!instance) {
|
||||
@@ -136,7 +148,7 @@
|
||||
await configure(instance, settings, levelDb);
|
||||
if (!desired) { releaseNode(); return false; }
|
||||
if (context.state === 'suspended') await context.resume().catch(() => {});
|
||||
graph.routeAll(elements, instance.node);
|
||||
graph.routeAll(elements, bypassed ? standardInput : instance.node);
|
||||
active = true;
|
||||
return true;
|
||||
}).catch(error => { fail(error); throw error; });
|
||||
@@ -158,22 +170,35 @@
|
||||
return enable(settings, levelDb);
|
||||
}
|
||||
|
||||
function setBypass(value) {
|
||||
function changeBypass(value) {
|
||||
if (!nodeEntry) return Promise.resolve(false);
|
||||
return serial(async () => { bypassed = Boolean(value); await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; });
|
||||
return serial(async () => {
|
||||
const next = Boolean(value);
|
||||
if (next === bypassed) return bypassed;
|
||||
// A/B bypass is an exact route around the worklet. This preserves the
|
||||
// Standard graph sample-for-sample instead of relying on DSP bypass math.
|
||||
if (!next) await nodeEntry.node.setBypass(false);
|
||||
if (active) {
|
||||
rampGain(0);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
graph.routeAll(elements, next ? standardInput : nodeEntry.node);
|
||||
rampGain(next ? 0 : 1);
|
||||
}
|
||||
if (next) await nodeEntry.node.setBypass(true);
|
||||
bypassed = next;
|
||||
return bypassed;
|
||||
}).catch(error => { fail(error); throw error; });
|
||||
}
|
||||
|
||||
function toggleBypass() {
|
||||
if (!nodeEntry) return Promise.resolve(false);
|
||||
return serial(async () => { bypassed = !bypassed; await nodeEntry.node.setBypass(bypassed); return bypassed; }).catch(error => { fail(error); throw error; });
|
||||
}
|
||||
function setBypass(value) { return changeBypass(value); }
|
||||
function toggleBypass() { return changeBypass(!bypassed); }
|
||||
|
||||
function loadIR(buffer) {
|
||||
function loadIR(buffer, reference = null) {
|
||||
if (!active || !nodeEntry) return Promise.reject(new Error('Enable JamesDSP before loading an impulse response'));
|
||||
return serial(async () => {
|
||||
if (!active || !nodeEntry) throw new Error('JamesDSP is no longer active');
|
||||
rampGain(0);
|
||||
try { await nodeEntry.node.loadIR(buffer); await nodeEntry.node.setParam('convolver.enabled', 1); }
|
||||
try { await nodeEntry.node.loadIR(buffer, reference); loadedIRRef = reference ? { name: reference.name, hash: reference.hash } : null; await nodeEntry.node.setParam('convolver.enabled', 1); }
|
||||
finally { if (active && desired) rampGain(1); }
|
||||
return true;
|
||||
}).catch(error => { fail(error); throw error; });
|
||||
@@ -195,7 +220,24 @@
|
||||
return { enable, disable, setLevelDb, setBypass, toggleBypass, loadIR, analyser, get active() { return active; }, get desired() { return desired; }, state: snapshot };
|
||||
}
|
||||
|
||||
const api = { PRESETS, createController };
|
||||
async function generateIR(name, sampleRate, channels = 2, maxFrames = 65536) {
|
||||
const {builtins}=await irTools();
|
||||
return builtins.generateIR(name,sampleRate,channels,{maxFrames});
|
||||
}
|
||||
async function decodeIR(context, bytes) { const {utils}=await irTools();return utils.decodeIR(context,bytes); }
|
||||
async function prepareIR(context, buffer, maxFrames = 65536) {
|
||||
const {utils}=await irTools();
|
||||
const rate=context.sampleRate===44100?44100:48000;
|
||||
const prepared=utils.resampleIR(utils.prepareIR(context,buffer,{normalize:false}),rate);
|
||||
if (![1,2,4].includes(prepared.numberOfChannels) || prepared.length<1 || prepared.length>Math.min(65536,maxFrames)) throw new RangeError(`Impulse response exceeds the ${Math.min(65536,maxFrames)}-frame internal limit`);
|
||||
return prepared;
|
||||
}
|
||||
async function saveIR(name, buffer, metadata) { const {store}=await irTools();return store.saveIR(name,buffer,metadata); }
|
||||
async function getIR(name) { const {store}=await irTools();return store.getIR(name); }
|
||||
async function listIRs() { const {store}=await irTools();return store.listIRs(); }
|
||||
async function deleteIR(name) { const {store}=await irTools();return store.deleteIR(name); }
|
||||
|
||||
const api = { PRESETS, createController, generateIR, decodeIR, prepareIR, saveIR, getIR, listIRs, deleteIR };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
else {
|
||||
root.YT = root.YT || {};
|
||||
|
||||
70
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
70
frontend/vendor/jamesdsp/jamesdsp-node.mjs
vendored
@@ -1,27 +1,29 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
function assetURL(path, fallback) { return globalThis.Lazy?.url?.(path) || new URL(fallback, import.meta.url); }
|
||||
const [parameterAPI, irUtils, irStore] = await Promise.all([
|
||||
import(assetURL('/vendor/jamesdsp/params.mjs', './params.mjs')),
|
||||
import(assetURL('/vendor/jamesdsp/ir-utils.mjs', './ir-utils.mjs')),
|
||||
import(assetURL('/vendor/jamesdsp/ir-store.mjs', './ir-store.mjs')),
|
||||
]);
|
||||
const {DEFAULT_PARAMS, validateParam} = parameterAPI;
|
||||
const {prepareIR,resampleIR} = irUtils;
|
||||
const {getIR} = irStore;
|
||||
const moduleLoads = new WeakMap();
|
||||
let paramsLoad;
|
||||
function assetURL(path, relative) { return globalThis.Lazy?.url?.(path) || new URL(relative, import.meta.url); }
|
||||
function loadParams(paramsURL) { if (!paramsLoad) paramsLoad = import(paramsURL); return paramsLoad; }
|
||||
export class JamesDSPNode extends EventTarget {
|
||||
static async create(context, options={}) {
|
||||
if (![44100,48000].includes(context.sampleRate)) throw new RangeError('Use a 44.1 or 48 kHz AudioContext');
|
||||
const wasmURL=options.wasmURL || assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm');
|
||||
const paramsURL=options.paramsURL || assetURL('/vendor/jamesdsp/params.mjs','./params.mjs');
|
||||
const baseWorkletURL=options.workletURL || assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs');
|
||||
const paramApi=await loadParams(paramsURL);
|
||||
static async create(context, {wasmURL=assetURL('/vendor/jamesdsp/dsp/jamesdsp.wasm','./dsp/jamesdsp.wasm'), workletURL=assetURL('/vendor/jamesdsp/worklet.mjs','./worklet.mjs')}={}) {
|
||||
if (!Number.isFinite(context.sampleRate)||context.sampleRate<8000||context.sampleRate>96000) throw new RangeError('AudioContext rate must be 8–96 kHz');
|
||||
if (!moduleLoads.has(context)) moduleLoads.set(context,new Map());
|
||||
const cache=moduleLoads.get(context), key=String(baseWorkletURL);
|
||||
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(baseWorkletURL).catch(e=>{cache.delete(key);throw e;}));
|
||||
const cache=moduleLoads.get(context), key=String(workletURL);
|
||||
if (!cache.has(key)) cache.set(key,context.audioWorklet.addModule(workletURL).catch(e=>{cache.delete(key);throw e;}));
|
||||
const response=await fetch(wasmURL);
|
||||
if (!response.ok) throw new Error(`WASM fetch failed: ${response.status}`);
|
||||
const wasmBytes=await response.arrayBuffer();
|
||||
await cache.get(key);
|
||||
const wrapper=new JamesDSPNode(context,wasmBytes,paramApi);
|
||||
const wrapper=new JamesDSPNode(context,wasmBytes);
|
||||
try { await wrapper.ready; return wrapper; } catch(e) { wrapper.destroy(); throw e; }
|
||||
}
|
||||
constructor(context,wasmBytes,paramApi) {
|
||||
super(); this.context=context; this.defaultParams=Object.fromEntries(Object.entries(paramApi.DEFAULT_PARAMS).filter(([name])=>!/^(?:liveprog|ddc|arbitrary)\./.test(name))); this.validateParam=paramApi.validateParam; this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false;
|
||||
constructor(context,wasmBytes) {
|
||||
super(); this.context=context; this.defaultParams=Object.fromEntries(Object.entries(DEFAULT_PARAMS).filter(([name])=>!/^(?:liveprog|ddc|arbitrary)\./.test(name))); this.params={...this.defaultParams}; this.pending=new Map(); this.serial=0; this.destroyed=false; this.source=null; this.irRef=null;
|
||||
this.node=new AudioWorkletNode(context,'jamesdsp',{numberOfInputs:1,numberOfOutputs:1,outputChannelCount:[2],channelCount:2,channelCountMode:'explicit',processorOptions:{wasmBytes}});
|
||||
this.ready=new Promise((resolve,reject)=> {
|
||||
const timer=setTimeout(()=>reject(new Error('DSP initialization timed out')),15000);
|
||||
@@ -35,44 +37,48 @@ export class JamesDSPNode extends EventTarget {
|
||||
});
|
||||
}
|
||||
fail(error) { for(const p of this.pending.values()) {clearTimeout(p.timer);p.reject(error);} this.pending.clear();this.dispatchEvent(new CustomEvent('error',{detail:error})); }
|
||||
request(type,data={}) {
|
||||
request(type,data={},transfer=[]) {
|
||||
if(this.destroyed) return Promise.reject(new Error('DSP has been destroyed'));
|
||||
const id=++this.serial;
|
||||
return new Promise((resolve,reject)=> {
|
||||
const timer=setTimeout(()=>{this.pending.delete(id);reject(new Error(`DSP ${type} timed out`));},15000);
|
||||
this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data});
|
||||
this.pending.set(id,{resolve,reject,timer});this.node.port.postMessage({type,id,...data},transfer);
|
||||
});
|
||||
}
|
||||
// Routing is owned by AudioGraph. Expose the worklet node without creating a source.
|
||||
async setParam(name,value) {
|
||||
if (/^(?:liveprog|ddc|arbitrary)\./.test(name)) throw new Error('Text effects are disabled in this player version');
|
||||
this.validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;
|
||||
// One source → this DSP → destination. For arbitrary graphs use .node directly.
|
||||
connect(source,destination=this.context.destination) {
|
||||
if(this.destroyed) throw new Error('DSP has been destroyed');
|
||||
if(this.source) this.source.disconnect(this.node);
|
||||
this.node.disconnect();this.source=source;source.connect(this.node);this.node.connect(destination);return destination;
|
||||
}
|
||||
async setParam(name,value) {if(/^(?:liveprog|ddc|arbitrary)\./.test(name))throw new Error('Text effects are disabled in this player version');validateParam(name,value);await this.request('param',{name,value});this.params[name]=value;}
|
||||
setBypass(enabled) {return this.setParam('bypass',Number(Boolean(enabled)));}
|
||||
async setText() {
|
||||
async setText(effect,text) {
|
||||
throw new Error('Text effects are disabled in this player version');
|
||||
}
|
||||
async loadIR(buffer) {
|
||||
if(buffer.sampleRate!==this.context.sampleRate || ![1,2].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must be mono/stereo, at context rate, at most 65536 frames');
|
||||
async loadIR(buffer,reference=null) {
|
||||
buffer=prepareIR(this.context,buffer,{normalize:false});
|
||||
const internalRate=this.context.sampleRate===44100?44100:48000;
|
||||
buffer=resampleIR(buffer,internalRate);
|
||||
if(![1,2,4].includes(buffer.numberOfChannels) || buffer.length>65536 || buffer.length<1) throw new RangeError('IR must have 1, 2, or 4 channels and at most 65536 frames');
|
||||
const pcm=new Float32Array(buffer.length*buffer.numberOfChannels);
|
||||
for(let ch=0;ch<buffer.numberOfChannels;ch++) {const data=buffer.getChannelData(ch);for(let i=0;i<buffer.length;i++) pcm[i*buffer.numberOfChannels+ch]=data[i];}
|
||||
await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels});this.params['convolver.enabled']=0;
|
||||
await this.request('ir',{pcm,frames:buffer.length,channels:buffer.numberOfChannels,sourceRate:internalRate},[pcm.buffer]);this.params['convolver.enabled']=0;this.irRef=reference?{name:reference.name,hash:reference.hash}:null;
|
||||
}
|
||||
async getInfo() {const {bytes,heapBytes}=await this.request('info');const view=new DataView(bytes);return {abiVersion:view.getUint32(0,true),structBytes:view.getUint32(4,true),contextRate:view.getFloat32(8,true),internalRate:view.getFloat32(12,true),srcBackend:view.getUint32(16,true),latencyFrames:view.getUint32(20,true),irChannels:view.getUint32(24,true),irFrames:view.getUint32(28,true),irCapFrames:view.getUint32(32,true),headFrames:view.getUint32(36,true),tailFrames:view.getUint32(40,true),liveBytes:view.getUint32(44,true),peakBytes:view.getUint32(48,true),faults:view.getUint32(52,true),underruns:view.getUint32(56,true),overflows:view.getUint32(60,true),heapBytes};}
|
||||
async applyPreset(preset) {
|
||||
if (preset?.texts && Object.keys(preset.texts).length) throw new Error('Text effects are disabled in this player version');
|
||||
// All keys validated before mutation; applying a preset resets unspecified params.
|
||||
if(preset?.texts && Object.keys(preset.texts).length) throw new Error('Text effects are disabled in this player version');
|
||||
const params={...this.defaultParams,...(preset.params||preset)};
|
||||
for(const [name,value] of Object.entries(params)) {
|
||||
if (/^(?:liveprog|ddc|arbitrary)\./.test(name)) throw new Error('Text effects are disabled in this player version');
|
||||
this.validateParam(name,value);
|
||||
}
|
||||
// Configure before enabling; convolution still requires a separately loaded IR.
|
||||
for(const [name,value] of Object.entries(params)) {if(/^(?:liveprog|ddc|arbitrary)\./.test(name))throw new Error('Text effects are disabled in this player version');validateParam(name,value);}
|
||||
if(preset.irRef) {const saved=await getIR(preset.irRef.name);if(!saved||saved.hash!==preset.irRef.hash)throw new Error(`Preset IR is missing or changed: ${preset.irRef.name}`);await this.loadIR(saved.buffer,preset.irRef);}
|
||||
// Configure before enabling; the referenced IR is loaded first.
|
||||
for(const [name,value] of Object.entries(params).sort(([a],[b])=>Number(a.endsWith('.enabled'))-Number(b.endsWith('.enabled')))) await this.setParam(name,value);
|
||||
}
|
||||
toJSON() {return {version:1,params:{...this.params}};}
|
||||
toJSON() {return {version:2,params:{...this.params},...(this.irRef?{irRef:{...this.irRef}}:{})};}
|
||||
destroy() {
|
||||
if(this.destroyed) return;
|
||||
this.node.disconnect();
|
||||
if(this.source) {try {this.source.disconnect(this.node);} catch {}} this.node.disconnect();
|
||||
this.node.port.postMessage({type:'destroy'});this.destroyed=true;this.fail(new Error('DSP destroyed'));this.node.port.close();
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/vendor/jamesdsp/params.mjs
vendored
6
frontend/vendor/jamesdsp/params.mjs
vendored
@@ -1,14 +1,17 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
export const EQ_FREQUENCIES = [25,40,63,100,160,250,400,630,1000,1600,2500,4000,6300,10000,16000];
|
||||
export const COMP_FREQUENCIES = [95,200,400,800,1600,3400,7500];
|
||||
export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','tube','convolver','liveprog','ddc','arbitrary'];
|
||||
export const EFFECTS = ['eq','compressor','bass','stereo','reverb','crossfeed','spatial','tube','convolver','liveprog','ddc','arbitrary'];
|
||||
export const PARAMS = {
|
||||
bypass:[0,1,0], gain:[-24,12,0],
|
||||
'limiter.threshold':[-24,0,-0.1], 'limiter.release':[10,1000,100],
|
||||
'eq.mode':[0,5,1], 'eq.interpolation':[0,1,0],
|
||||
'compressor.time':[0.01,2,0.1], 'compressor.mode':[0,3,3], 'compressor.granularity':[0,4,0],
|
||||
'bass.boost':[0,12,6], 'stereo.mix':[0,1,0.5], 'reverb.preset':[0,18,0],
|
||||
'convolver.mix':[0,1,1], 'convolver.predelayMs':[0,250,0],
|
||||
'crossfeed.mode':[0,5,0], 'tube.drive':[-12,12,0],
|
||||
'spatial.mode':[2,6,2], 'spatial.azimuth':[-90,90,0], 'spatial.spread':[0,120,60],
|
||||
'spatial.distance':[0.5,5,1], 'spatial.room':[0,1,0], 'spatial.centerDb':[0,6,0], 'spatial.mix':[0,1,1],
|
||||
};
|
||||
for (const effect of EFFECTS) PARAMS[`${effect}.enabled`] = [0,1,0];
|
||||
EQ_FREQUENCIES.forEach((_, i) => PARAMS[`eq.band${i}`] = [-12,12,0]);
|
||||
@@ -24,4 +27,5 @@ export const PRESETS = {
|
||||
'Warm lows': {'eq.band0':4,'eq.band1':4,'eq.band2':3,'eq.band3':2,'eq.enabled':1,'gain':-3},
|
||||
'Headphones': {'crossfeed.enabled':1,'crossfeed.mode':1,'eq.band8':-2,'eq.enabled':1},
|
||||
'Small room': {'reverb.preset':7,'reverb.enabled':1,'gain':-3},
|
||||
'Virtual speakers': {'spatial.enabled':1,'spatial.mode':6,'spatial.spread':90},
|
||||
};
|
||||
|
||||
9
frontend/vendor/jamesdsp/worklet.mjs
vendored
9
frontend/vendor/jamesdsp/worklet.mjs
vendored
@@ -1,7 +1,5 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// AudioWorkletGlobalScope does not support dynamic import; this immutable
|
||||
// vendor path is versioned with the upstream pin and remapped by the SW cache.
|
||||
import createJamesDSP from './dsp/jamesdsp-77be0b2.mjs';
|
||||
import createJamesDSP from './dsp/jamesdsp-a982b53.mjs';
|
||||
class JamesDSPProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super(); this.closed=false;this.failed=false;
|
||||
@@ -31,7 +29,10 @@ class JamesDSPProcessor extends AudioWorkletProcessor {
|
||||
if(data.type==='param') result=this.string(data.name,p=>this.wasm._jdsp_set_param(this.handle,p,data.value));
|
||||
else if(data.type==='ir') {
|
||||
const p=this.wasm._malloc(data.pcm.byteLength);if(!p) throw new Error('IR allocation failed');
|
||||
try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir(this.handle,p,data.frames,data.channels);} finally {this.wasm._free(p);}
|
||||
try {this.wasm.HEAPF32.set(data.pcm,p/4);result=this.wasm._jdsp_load_ir_ex(this.handle,p,data.frames,data.channels,data.sourceRate);} finally {this.wasm._free(p);}
|
||||
} else if(data.type==='info') {
|
||||
const p=this.wasm._malloc(64);if(!p)throw new Error('DSP info allocation failed');
|
||||
try {result=this.wasm._jdsp_get_info(this.handle,p);if(result===0){const bytes=this.wasm.HEAPU8.slice(p,p+64);this.port.postMessage({type:'reply',id:data.id,result:{bytes:bytes.buffer,heapBytes:this.wasm.HEAPU8.buffer.byteLength}},[bytes.buffer]);return;}} finally {this.wasm._free(p);}
|
||||
} else throw new Error('Unknown DSP message');
|
||||
if(result!==0) throw new Error(`DSP rejected ${data.name||data.effect||data.type}`);
|
||||
this.port.postMessage({type:'reply',id:data.id,result});
|
||||
|
||||
@@ -732,9 +732,11 @@ async function renderSettings() {
|
||||
const qOptions = qualities
|
||||
.map((q) => `<option value="${q}"${data.settings.quality === q ? ' selected' : ''}>${q === 'auto' ? 'Auto' : q}</option>`)
|
||||
.join('');
|
||||
const jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
|
||||
const jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
|
||||
const dspCores=Number(navigator.hardwareConcurrency), dspMemory=Number(navigator.deviceMemory);
|
||||
const dspLowEnd=(dspCores>0&&dspCores<=4)||(dspMemory>0&&dspMemory<=4);
|
||||
const dspBands = [25, 40, 63, 100, 160, 250, 400, 630, 1000, 1600, 2500, 4000, 6300, 10000, 16000];
|
||||
const dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1 };
|
||||
const dspParamDefaults = { 'bass.boost': 6, 'stereo.mix': 0.5, 'limiter.threshold': -0.1, 'convolver.mix': 1, 'convolver.predelayMs': 0, 'spatial.mode': 6, 'spatial.spread': 60, 'spatial.distance': 1, 'spatial.mix': 1 };
|
||||
const dspParam = key => Number.isFinite(Number(jamesDsp.params?.[key])) ? Number(jamesDsp.params[key]) : (dspParamDefaults[key] ?? 0);
|
||||
const dspRange = (key, label, min, max, step = 1, unit = '') => `<label class="set-row dsp-param-row"><span>${label}<small>${dspParam(key)}${unit}</small></span><input type="range" data-dsp-param="${key}" min="${min}" max="${max}" step="${step}" value="${dspParam(key)}" /></label>`;
|
||||
const dspBandRows = dspBands.map((hz, i) => dspRange(`eq.band${i}`, `${hz >= 1000 ? (hz / 1000) + ' kHz' : hz + ' Hz'}`, -12, 12, 0.5, ' dB')).join('');
|
||||
@@ -742,6 +744,7 @@ async function renderSettings() {
|
||||
const sel = (id, val, opts) => `<select id="${id}" class="set-select">` +
|
||||
opts.map(([v, label]) => `<option value="${v}"${val === v ? ' selected' : ''}>${label}</option>`).join('') +
|
||||
`</select>`;
|
||||
const dspBuiltinIRSelect = `<select id="setDspBuiltinIR" class="set-select">${['Small room','Hall','Plate','Cathedral'].map(name=>`<option value="${name}"${dspLowEnd&&name!=='Small room'?' disabled':''}>${name}</option>`).join('')}</select>`;
|
||||
|
||||
wrap.innerHTML = `
|
||||
<div class="set-group">
|
||||
@@ -813,8 +816,10 @@ async function renderSettings() {
|
||||
<label class="set-row"><span>Engine<small>Standard is the default. JamesDSP replaces the Standard EQ and vocal/stereo stages.</small></span>
|
||||
${sel('setSoundEngine', jamesDsp.enabled ? 'jamesdsp' : 'standard', [['standard', 'Standard'], ['jamesdsp', 'JamesDSP']])}
|
||||
</label>
|
||||
<label class="set-row"><span>Download audio effects for offline<small>Includes the optional JamesDSP engine and its large WASM file in the offline cache.</small></span><input id="setDspOffline" type="checkbox" ${jamesDsp.downloadForOffline ? 'checked' : ''} /></label>
|
||||
<div id="jamesDspControls" ${jamesDsp.enabled ? '' : 'hidden'}>
|
||||
<div class="set-row"><span>Live processing<small>${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device. Large reverbs and convolution can use more CPU.'}</small></span></div>
|
||||
<div class="set-row"><span>Live processing<small>${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device.'}</small></span></div>
|
||||
${dspLowEnd ? '<p class="dsp-risk" role="note">This device may have limited audio headroom. Long impulse responses are disabled and the short room response is capped at 0.5 seconds.</p>' : ''}
|
||||
<label class="set-row"><span>Preset</span>${sel('setDspPreset', jamesDsp.preset || 'flat', [['flat', 'Flat'], ['bass', 'Bass boost'], ['vocal', 'Vocal'], ['loudness', 'Loudness'], ['headphone', 'Headphone crossfeed'], ['bright', 'Bright'], ['warm', 'Warm']])}</label>
|
||||
<div class="dsp-param-list" aria-label="15-band equalizer">${dspBandRows}</div>
|
||||
<label class="set-row"><span>Bass boost<small>${dspParam('bass.boost')} dB</small></span><input type="range" data-dsp-param="bass.boost" min="0" max="12" step="0.5" value="${dspParam('bass.boost')}" /></label>
|
||||
@@ -827,7 +832,27 @@ async function renderSettings() {
|
||||
<label class="set-row"><span>Compressor</span><input id="setDspCompressor" type="checkbox" ${dspParam('compressor.enabled') ? 'checked' : ''} /></label>
|
||||
<div class="dsp-actions"><button id="dspPresetImportBtn" class="btn" type="button">Import preset JSON</button><button id="dspPresetExportBtn" class="btn" type="button">Export preset JSON</button><button id="dspResetBtn" class="btn" type="button">Reset DSP</button></div>
|
||||
<input id="dspPresetFile" type="file" accept="application/json,.json" hidden />
|
||||
<label class="set-row"><span>Impulse response<small>WAV or other browser-decodable audio; upload enables convolution for this session only.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label>
|
||||
<section class="dsp-subsection" aria-labelledby="dspConvolverTitle">
|
||||
<h4 id="dspConvolverTitle">Convolver</h4>
|
||||
<label class="set-row"><span>Enable convolution</span><input id="setDspConvolver" type="checkbox" ${dspParam('convolver.enabled') ? 'checked' : ''} /></label>
|
||||
<label class="set-row"><span>Dry / wet<small>${Math.round(dspParam('convolver.mix')*100)}%</small></span><input type="range" data-dsp-param="convolver.mix" min="0" max="1" step="0.01" value="${dspParam('convolver.mix')}" /></label>
|
||||
<label class="set-row"><span>Pre-delay<small>${dspParam('convolver.predelayMs')} ms</small></span><input type="range" data-dsp-param="convolver.predelayMs" min="0" max="250" step="1" value="${dspParam('convolver.predelayMs')}" /></label>
|
||||
<label class="set-row"><span>Built-in response</span>${dspBuiltinIRSelect}</label>
|
||||
<button id="setDspLoadBuiltin" class="btn" type="button">Generate and load</button>
|
||||
<label class="set-row"><span>Upload impulse response<small>WAV or other browser-decodable audio; up to 65,536 frames at the internal sample rate.</small></span><input id="dspIrFile" type="file" accept="audio/*,.wav" /></label>
|
||||
<label class="set-row"><span>Stored responses</span><select id="setDspSavedIR" class="set-select"><option value="">Choose a saved response</option></select></label>
|
||||
<button id="setDspLoadSavedIR" class="btn" type="button">Load saved response</button>
|
||||
<p id="dspIrStatus" class="sp-note">${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}</p>
|
||||
</section>
|
||||
<section class="dsp-subsection" aria-labelledby="dspSpatialTitle">
|
||||
<h4 id="dspSpatialTitle">3D spatial stage</h4>
|
||||
<label class="set-row"><span>Enable 3D spatial stage</span><input id="setDspSpatial" type="checkbox" ${dspParam('spatial.enabled') ? 'checked' : ''} /></label>
|
||||
<label class="set-row"><span>HRTF / renderer mode</span>${sel('setDspSpatialMode',String(dspParam('spatial.mode')),[['2','HRTF crossfeed'],['3','HRTF surround 1'],['4','HRTF surround 2'],['5','HRTF surround 3'],['6','Virtual speakers']])}</label>
|
||||
${dspRange('spatial.azimuth','Azimuth',-90,90,1,'°')}
|
||||
${dspRange('spatial.spread','Speaker spread',0,120,1,'°')}
|
||||
${dspRange('spatial.distance','Distance',0.5,5,0.1,' m')}
|
||||
${dspRange('spatial.centerDb','Center boost',0,6,0.1,' dB')}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<label class="set-row">
|
||||
@@ -1106,13 +1131,23 @@ async function renderSettings() {
|
||||
toast('On iPhone, turn on Live sound processing first.');
|
||||
return;
|
||||
}
|
||||
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, preset: 'flat', params: {} };
|
||||
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null };
|
||||
data.settings.jamesDsp.enabled = enable;
|
||||
if (enable) data.settings.jamesDsp.downloadForOffline = true;
|
||||
if (enable && $('setDspOffline')) $('setDspOffline').checked = true;
|
||||
if (dspControls) dspControls.hidden = !enable;
|
||||
persist();
|
||||
reportDspOfflineOptIn();
|
||||
EQ.apply();
|
||||
});
|
||||
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} });
|
||||
if ($('setDspOffline')) $('setDspOffline').addEventListener('change', e => {
|
||||
data.settings.jamesDsp = data.settings.jamesDsp || { enabled: false, downloadForOffline: false, preset: 'flat', params: {} };
|
||||
data.settings.jamesDsp.downloadForOffline = e.target.checked;
|
||||
persist();
|
||||
reportDspOfflineOptIn();
|
||||
toast(e.target.checked ? 'Preparing audio effects for offline use' : 'Audio effects removed from the offline completion list');
|
||||
});
|
||||
const dspConfig = data.settings.jamesDsp || (data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null });
|
||||
let dspApplyTimer = null;
|
||||
const applyDspSettings = () => {
|
||||
clearTimeout(dspApplyTimer);
|
||||
@@ -1133,8 +1168,41 @@ async function renderSettings() {
|
||||
applyDspSettings();
|
||||
}));
|
||||
if ($('setDspCrossfeed')) $('setDspCrossfeed').addEventListener('change', e => { dspConfig.params['crossfeed.mode'] = Number(e.target.value); dspConfig.params['crossfeed.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
|
||||
if ($('setDspConvolver')) $('setDspConvolver').addEventListener('change', e => {
|
||||
const hasIR=dspConfig.irRef||EQ.state().jamesDsp?.irRef;
|
||||
if(e.target.checked&&!hasIR){e.target.checked=false;toast('Load an impulse response first.');return;}
|
||||
dspConfig.params['convolver.enabled']=e.target.checked?1:0;applyDspSettings();
|
||||
});
|
||||
if ($('setDspSpatial')) $('setDspSpatial').addEventListener('change', e => {
|
||||
dspConfig.params['spatial.enabled']=e.target.checked?1:0;
|
||||
if(e.target.checked){dspConfig.params['crossfeed.enabled']=0;if($('setDspCrossfeed'))$('setDspCrossfeed').value='0';}
|
||||
applyDspSettings();
|
||||
});
|
||||
if ($('setDspSpatialMode')) $('setDspSpatialMode').addEventListener('change', e => {dspConfig.params['spatial.mode']=Number(e.target.value);applyDspSettings();});
|
||||
if ($('setDspReverb')) $('setDspReverb').addEventListener('change', e => { dspConfig.params['reverb.preset'] = Number(e.target.value); dspConfig.params['reverb.enabled'] = Number(e.target.value) ? 1 : 0; applyDspSettings(); });
|
||||
if ($('setDspCompressor')) $('setDspCompressor').addEventListener('change', e => { dspConfig.params['compressor.enabled'] = e.target.checked ? 1 : 0; applyDspSettings(); });
|
||||
const refreshDspIrList=async()=>{
|
||||
const select=$('setDspSavedIR');if(!select)return;
|
||||
const selected=select.value;select.replaceChildren(new Option('Choose a saved response',''));
|
||||
try{for(const item of await EQ.listDspIRs())select.add(new Option(`${item.name} · ${item.channels} ch · ${item.frames} frames`,item.name));if([...select.options].some(o=>o.value===selected))select.value=selected;}
|
||||
catch(error){if($('dspIrStatus'))$('dspIrStatus').textContent=`Saved response list unavailable: ${error.message}`;}
|
||||
};
|
||||
if ($('setDspLoadBuiltin')) $('setDspLoadBuiltin').addEventListener('click',async()=>{
|
||||
try{const name=$('setDspBuiltinIR').value;const ref=await EQ.loadDspBuiltinIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||
catch(error){toast(`Impulse response failed: ${error.message}`);}
|
||||
});
|
||||
if ($('setDspLoadSavedIR')) $('setDspLoadSavedIR').addEventListener('click',async()=>{
|
||||
const name=$('setDspSavedIR').value;if(!name){await refreshDspIrList();return;}
|
||||
try{const ref=await EQ.loadDspSavedIR(name);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded saved response: ${name}`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||
catch(error){toast(`Saved response failed: ${error.message}`);}
|
||||
});
|
||||
if ($('dspIrFile')) $('dspIrFile').addEventListener('change',async e=>{
|
||||
const file=e.target.files?.[0];if(!file)return;
|
||||
try{const ref=await EQ.loadDspIR(file);if($('dspIrStatus'))$('dspIrStatus').textContent=`Loaded ${ref?.name||file.name}${ref?' and saved it for this device':' for this session only'}.`;$('setDspConvolver').checked=true;await refreshDspIrList();}
|
||||
catch(error){toast(`Impulse response failed: ${error.message}`);}
|
||||
finally{e.target.value='';}
|
||||
});
|
||||
if(jamesDsp.enabled||jamesDsp.downloadForOffline)refreshDspIrList();
|
||||
if ($('dspResetBtn')) $('dspResetBtn').addEventListener('click', () => {
|
||||
dspConfig.preset = 'flat'; dspConfig.params = {};
|
||||
if ($('setDspPreset')) $('setDspPreset').value = 'flat';
|
||||
|
||||
@@ -75,9 +75,9 @@ Each scenario records the following metrics across **5 independent runs** (repor
|
||||
## 4. How to Run
|
||||
|
||||
### Optional JamesDSP integration check
|
||||
`node perf/dsp.mjs --browser all` builds short local H.264/AAC test tones, exercises Standard and the lazy JamesDSP presets through the real app audio graph, compares the A/B-bypassed RMS to Standard, verifies one worklet heap and no extra media sources, tests the offline cached reload, and injects a failed WASM response to check Standard fallback. Its detailed measurements stay in ignored `perf/.tmp/dsp-results.json`.
|
||||
`node perf/dsp.mjs --browser all` builds short local H.264/AAC test tones, exercises Standard and lazy JamesDSP presets through the real app graph, verifies A/B bypass routes directly to the unchanged Standard chain, measures generated-IR effects and the four-channel/spatial stages in an offline render, checks an arbitrary 32 kHz context, and tests stored-IR offline reload, one worklet heap, no new media sources, and corrupt-WASM fallback. The default install must omit the optional WASM; the harness then opts in through Settings and confirms completion. Detailed measurements stay in ignored `perf/.tmp/dsp-results.json`.
|
||||
|
||||
`node perf/settings-persist.mjs --browser all` also round-trips a disabled, custom JamesDSP preset through local storage and confirms the Standard selector remains selected.
|
||||
`node perf/settings-persist.mjs --browser all` also round-trips a disabled, custom JamesDSP preset and the offline-audio opt-in through local storage and confirms the Standard selector remains selected.
|
||||
|
||||
### Prerequisites
|
||||
Symlink dependencies and ensure server dependencies are installed:
|
||||
|
||||
59
perf/dsp.mjs
59
perf/dsp.mjs
@@ -57,16 +57,17 @@ async function run(engine) {
|
||||
(result.fixtureResponses ||= []).push({ url: response.url(), status: response.status() });
|
||||
} else if (!path.startsWith('/api/') && response.status() >= 400) result.errors.push(`asset response ${response.status()} ${response.url()}`);
|
||||
});
|
||||
page.on('response', response => { if (/jamesdsp\/(?:worklet|dsp\/jamesdsp)\.mjs/.test(response.url())) result.requests ||= [], result.requests.push({ url: response.url(), status: response.status(), type: response.headers()['content-type'] }); });
|
||||
page.on('response', response => { if (/jamesdsp\/(?:worklet\.mjs|dsp\/jamesdsp[^/?]*\.mjs)/.test(response.url())) result.requests ||= [], result.requests.push({ url: response.url(), status: response.status(), type: response.headers()['content-type'] }); });
|
||||
page.on('requestfailed', request => { if (/jamesdsp\//.test(request.url())) result.requestsFailed ||= [], result.requestsFailed.push({ url: request.url(), error: request.failure()?.errorText }); });
|
||||
const boot = async () => { await page.goto(origin); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle')); };
|
||||
await boot();
|
||||
const manifest = await (await fetch(origin + '/api/manifest')).json();
|
||||
const checkComplete = () => page.evaluate(async files => { const cache = await caches.open('ytplayer-assets'); const missing = []; for (const [path, file] of Object.entries(files)) { const r = await cache.match(path + '?v=' + file.h); if (!r || r.headers.get('X-Asset-Hash') !== file.h) missing.push(path); } return missing; }, manifest.files);
|
||||
const checkComplete = () => page.evaluate(async ({ files, groups }) => { const cache = await caches.open('ytplayer-assets'), opted = !!(data.settings.jamesDsp?.enabled || data.settings.jamesDsp?.downloadForOffline); const optional = new Set(Object.values(groups).filter(group => group.offlineOptIn && !(opted && group.offlineOptIn === 'jamesDsp')).flatMap(group => group.files)); const missing = []; for (const [path, file] of Object.entries(files)) { if (optional.has(path)) continue; const r = await cache.match(path + '?v=' + file.h); if (!r || r.headers.get('X-Asset-Hash') !== file.h) missing.push(path); } return missing; }, manifest);
|
||||
assert.equal((await page.evaluate(async () => { const shell = await caches.open('ytplayer-shell'); return (await shell.keys()).some(r => r.url.includes('jamesdsp')); })), false, 'DSP is never in the shell cache');
|
||||
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'offline completion job fills manifest before offline test');
|
||||
result.checks.push({ name: 'all manifest files cached before offline', files: Object.keys(manifest.files).length });
|
||||
await page.evaluate(async () => { await Lazy.load('feature:eq-core'); await Lazy.load('feature:vocal-reducer'); data.settings.jamesDsp = { enabled: false, preset: 'flat', params: {} }; EQ.setLevel(1); EQ.setLevel(0); EQ.apply(); });
|
||||
const optionalCount = Object.values(manifest.groups || {}).filter(group => group.offlineOptIn).reduce((n, group) => n + group.files.filter(path => manifest.files[path]).length, 0);
|
||||
result.checks.push({ name: 'required files cached; optional DSP deferred', files: Object.keys(manifest.files).length - optionalCount, optionalDeferred: optionalCount, manifestFiles: Object.keys(manifest.files).length });
|
||||
await page.evaluate(async () => { await Lazy.load('feature:eq-core'); await Lazy.load('feature:vocal-reducer'); data.settings.jamesDsp = { enabled: false, downloadForOffline: false, preset: 'flat', params: {}, irRef: null }; EQ.setLevel(1); EQ.setLevel(0); EQ.apply(); });
|
||||
await page.evaluate(clip => Player.loadVideo(clip), clips[0]);
|
||||
await page.locator('#playBtn').click({ force: true }).catch(() => {});
|
||||
if (!await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000)) { await page.evaluate(() => Player.master?.play().catch(() => {})); assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.4), 12000), true, 'local test clip starts'); }
|
||||
@@ -84,28 +85,66 @@ async function run(engine) {
|
||||
const active = await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 12000);
|
||||
if (!active) result.diagnostics = await page.evaluate(() => ({ enabled: data.settings.jamesDsp?.enabled, state: EQ.state(), lazyLoaded: Lazy.loaded('feature:jamesdsp'), toast: document.querySelector('#toastContainer')?.textContent || '', toastHistory: window.__dspToasts }));
|
||||
assert.equal(active, true, `JamesDSP worklet activates ${JSON.stringify(result.diagnostics || {})}`);
|
||||
assert.equal(await poll(async () => !(await checkComplete()).length, 120000), true, 'engine opt-in completes optional WASM and module cache');
|
||||
await sleep(200); const flat = await rms(); result.rms.push({ effect: 'flat', rms: flat });
|
||||
for (const preset of ['bass', 'vocal', 'loudness', 'headphone', 'bright', 'warm']) {
|
||||
await page.selectOption('#setDspPreset', preset); await sleep(900);
|
||||
const value = await rms(); assert.ok(Number.isFinite(value), `${preset} RMS is finite`); result.rms.push({ effect: preset, rms: value });
|
||||
}
|
||||
const preConvolverRms = await rms();
|
||||
result.irLoad = await page.evaluate(async () => { const reference = await EQ.loadDspBuiltinIR('Small room'); await new Promise(resolve => setTimeout(resolve, 300)); return { reference, settings: data.settings.jamesDsp, state: EQ.state().jamesDsp, toast: document.querySelector('#toastContainer')?.textContent || '' }; });
|
||||
const convolverRms = await rms();
|
||||
assert.equal(result.irLoad.state.irRef?.name, 'Small room', 'generated IR is loaded into the active worklet');
|
||||
result.checks.push({ name: 'live generated IR loaded', beforeRms: preConvolverRms, afterRms: convolverRms, ir: result.irLoad.state.irRef });
|
||||
await page.evaluate(clip => { Player.pause(); Player.loadVideo(clip); }, clips[1]); await sleep(500); await page.evaluate(() => Player.master?.play().catch(() => {}));
|
||||
const trackSwapActive = await poll(() => page.evaluate(() => Player.master?.currentSrc.includes('dspfixture02') && Player.master?.readyState >= 2 && EQ.state().jamesDsp?.active), 12000);
|
||||
if (!trackSwapActive) result.trackSwapDiagnostics = await page.evaluate(() => ({ time: Player.master?.currentTime, paused: Player.master?.paused, readyState: Player.master?.readyState, state: EQ.state().jamesDsp, graph: AudioGraph.snapshot(), source: Player.master?.currentSrc }));
|
||||
assert.equal(trackSwapActive, true, 'DSP remains active on the replacement media source');
|
||||
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) });
|
||||
await page.evaluate(() => { data.settings.jamesDsp.params['bass.enabled'] = 1; data.settings.jamesDsp.params['eq.enabled'] = 1; EQ.apply(); }); await sleep(800);
|
||||
const activeRms = await rms();
|
||||
await page.evaluate(async () => { await EQ.toggleDspBypass(); }); await sleep(200); const bypassRms = await rms();
|
||||
assert.ok(Math.abs(bypassRms - standardRms) <= Math.max(standardRms * 0.15, 0.002), `bypass RMS returns near Standard (${bypassRms} vs ${standardRms})`);
|
||||
result.checks.push({ name: 'A/B bypass RMS', standardRms, activeRms, bypassRms });
|
||||
const bypassState = await page.evaluate(() => EQ.state().jamesDsp);
|
||||
assert.equal(bypassState.route, 'standard', 'A/B bypass routes directly to the unchanged Standard graph');
|
||||
result.checks.push({ name: 'A/B exact Standard route', standardRms, activeRms, bypassRms, ...bypassState });
|
||||
await page.evaluate(async () => { const state = EQ.state().jamesDsp, graph = AudioGraph.snapshot(); return { state, graph }; }).then(({ state, graph }) => {
|
||||
assert.equal(state.liveHeaps, 1); assert.equal(state.maxLiveHeaps, 1); assert.ok(graph.sourceCount <= 2, 'only existing player media sources'); result.checks.push({ name: 'one worklet heap / single graph', ...state, graph });
|
||||
});
|
||||
await page.evaluate(clip => { Player.pause(); Player.loadVideo(clip); }, clips[1]); await sleep(500); await page.evaluate(() => Player.master?.play().catch(() => {}));
|
||||
assert.equal(await poll(() => page.evaluate(() => Player.master?.currentTime > 0.2 && EQ.state().jamesDsp?.active), 12000), true, 'DSP survives media element/track swap');
|
||||
result.checks.push({ name: 'track switch retains DSP route', graph: await page.evaluate(() => AudioGraph.snapshot()) });
|
||||
const dspRender = await page.evaluate(async () => {
|
||||
const { JamesDSPNode } = await import(Lazy.url('/vendor/jamesdsp/jamesdsp-node.mjs'));
|
||||
async function render(effect, rate = 32000) {
|
||||
const frames = 32768, context = new OfflineAudioContext(2, frames, rate);
|
||||
const engine = await JamesDSPNode.create(context, { wasmURL: Lazy.url('/vendor/jamesdsp/dsp/jamesdsp.wasm'), workletURL: Lazy.url('/vendor/jamesdsp/worklet.mjs') });
|
||||
const buffer = context.createBuffer(2, 2048, rate);
|
||||
for (let i = 0; i < buffer.length; i++) { const sample = Math.sin(2 * Math.PI * 440 * i / rate) * 0.25; buffer.getChannelData(0)[i] = sample; buffer.getChannelData(1)[i] = sample; }
|
||||
if (effect === 'convolver') {
|
||||
const ir = context.createBuffer(4, 4096, rate); ir.getChannelData(0)[0] = 0.5; ir.getChannelData(1)[97] = 0.25; ir.getChannelData(2)[211] = -0.2; ir.getChannelData(3)[0] = 0.5;
|
||||
await engine.loadIR(ir); await engine.setParam('convolver.mix', 1); await engine.setParam('convolver.enabled', 1);
|
||||
} else if (effect === 'spatial') {
|
||||
await engine.setParam('spatial.enabled', 1); await engine.setParam('spatial.mode', 6); await engine.setParam('spatial.spread', 100); await engine.setParam('spatial.azimuth', 45);
|
||||
}
|
||||
await engine.setBypass(false);
|
||||
const source = context.createBufferSource(); source.buffer = buffer; source.connect(engine.node); engine.node.connect(context.destination); source.start();
|
||||
const rendered = await context.startRendering();
|
||||
const left = rendered.getChannelData(0), right = rendered.getChannelData(1);
|
||||
let l = 0, r = 0, diff = 0; for (let i = 0; i < frames; i++) { l += left[i] ** 2; r += right[i] ** 2; diff += (left[i] - right[i]) ** 2; }
|
||||
const metrics = { leftRms: Math.sqrt(l / frames), rightRms: Math.sqrt(r / frames), interauralRms: Math.sqrt(diff / frames), rate: rendered.sampleRate };
|
||||
engine.destroy(); return metrics;
|
||||
}
|
||||
const dry = await render('dry'), convolver = await render('convolver'), spatial = await render('spatial');
|
||||
return { dry, convolver, spatial };
|
||||
});
|
||||
assert.equal(dspRender.dry.rate, 32000, 'ASRC accepts an arbitrary AudioContext rate');
|
||||
assert.ok(dspRender.convolver.interauralRms > dspRender.dry.interauralRms + 0.00001, `four-channel IR changes rendered stereo output (${JSON.stringify(dspRender)})`);
|
||||
assert.ok(dspRender.spatial.interauralRms > dspRender.dry.interauralRms + 0.00001, `3D stage changes interaural difference (${JSON.stringify(dspRender)})`);
|
||||
result.checks.push({ name: 'offline four-channel convolver, spatial difference and 32 kHz ASRC', ...dspRender });
|
||||
|
||||
await page.evaluate(() => persist()); await sleep(500);
|
||||
offlinePhase = true; if (engine === 'chromium') await context.setOffline(true); else await proxy.setOffline(true);
|
||||
await page.reload(); await page.waitForFunction(() => typeof data !== 'undefined' && document.querySelector('#sidebarToggle'));
|
||||
assert.equal(await poll(() => page.evaluate(() => !!EQ.state().jamesDsp?.active), 30000), true, 'offline reload restores enabled DSP from cached modules');
|
||||
result.checks.push({ name: 'offline reload with engine enabled', missing: await checkComplete() });
|
||||
assert.equal(await page.evaluate(() => EQ.state().jamesDsp?.irRef?.name), 'Small room', 'stored generated IR is restored offline');
|
||||
result.checks.push({ name: 'offline reload with engine and stored IR enabled', missing: await checkComplete(), ir: await page.evaluate(() => EQ.state().jamesDsp?.irRef) });
|
||||
|
||||
// A separate fresh profile corrupts the optional wasm response and verifies that Standard stays audible.
|
||||
offlinePhase = false; failedApiTransports = 0; if (engine === 'chromium') await context.setOffline(false); else await proxy.setOffline(false);
|
||||
|
||||
@@ -42,7 +42,8 @@ async function run(name){
|
||||
console.log(name,'initial boot');await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#carModeBtn'));
|
||||
await wait(page,async()=>!!(await navigator.serviceWorker.getRegistration())?.active);
|
||||
await page.evaluate(()=>Lazy.warm());
|
||||
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json();return !(await Promise.all(Object.values(s.current.groups).flatMap(g=>g.files).map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
||||
await wait(page,async()=>{const c=await caches.open('ytplayer-assets'),r=await c.match('/__ytp_asset_state');if(!r)return false;const s=await r.json(),files=Object.entries(s.current.groups).filter(([,g])=>!g.offlineOptIn).flatMap(([,g])=>g.files);return !(await Promise.all(files.map(async p=>!!await c.match(p+'?v='+s.current.files[p].h)))).includes(false);});
|
||||
assert.equal(await page.evaluate(async()=>{const m=Lazy.manifest,path='/vendor/jamesdsp/dsp/jamesdsp.wasm',cache=await caches.open('ytplayer-assets');return !await cache.match(path+'?v='+m.files[path].h);}),true,'optional DSP WASM stays uncached on a default install');
|
||||
console.log(name,'warm complete');assert.equal(await page.evaluate(()=>Lazy.loaded('feature:car-mode')),false);
|
||||
assert.equal(await page.evaluate(()=>[...document.querySelectorAll('.stg-overlay,.deck-chips,.vol-pct,.mode-name,.stg-bot,.ch-badge,.upnext-clear,.list-sub,.p2p-strip,.sleep-status .sl-ic,.sleep-status .sl-sub,.mc-tune')].every(n=>getComputedStyle(n).display==='none')),true,'classic retains shared visibility defaults without loading Glass CSS');
|
||||
await checkCssOrder(page);
|
||||
|
||||
@@ -37,13 +37,22 @@ async function run(engine){
|
||||
};
|
||||
let page=await context.newPage();attach(page);
|
||||
const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('[aria-label="Car mode"]'));};
|
||||
const inventory=()=>page.evaluate(async({manifest,legacy})=>{
|
||||
const inventory=(includeOptional=false)=>page.evaluate(async({manifest,legacy,includeOptional})=>{
|
||||
const names=await caches.keys(),cache=await caches.open(legacy?names.find(n=>n.startsWith('ytplayer-')&&!['ytplayer-assets','ytplayer-fonts','ytplayer-thumbs'].includes(n)):'ytplayer-assets');
|
||||
const missing=[];for(const [path,file]of Object.entries(manifest.files)){const r=await cache.match(legacy?path:path+'?v='+file.h);if(!r||(!legacy&&r.headers.get('X-Asset-Hash')!==file.h))missing.push(path);}
|
||||
return{total:Object.keys(manifest.files).length,missing};
|
||||
},{manifest,legacy});
|
||||
const optional=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn).flatMap(group=>group.files||[]));
|
||||
const expected=Object.entries(manifest.files).filter(([path])=>legacy||includeOptional||!optional.has(path));
|
||||
const missing=[];for(const [path,file]of expected){const r=await cache.match(legacy?path:path+'?v='+file.h);if(!r||(!legacy&&r.headers.get('X-Asset-Hash')!==file.h))missing.push(path);}
|
||||
return{total:expected.length,manifestTotal:Object.keys(manifest.files).length,optionalExcluded:Object.keys(manifest.files).length-expected.length,missing};
|
||||
},{manifest,legacy,includeOptional});
|
||||
const started=Date.now();console.log(engine,'install boot');await boot();console.log(engine,'waiting for worker activation');if(!await poll(()=>page.evaluate(async()=>!!(await navigator.serviceWorker.getRegistration())?.active),30000))throw Error('Worker did not activate: '+JSON.stringify(await page.evaluate(async()=>{const r=await navigator.serviceWorker.getRegistration();return {active:r?.active?.state,waiting:r?.waiting?.state,installing:r?.installing?.state,caches:await caches.keys(),keys:(await(await caches.open('ytplayer-assets')).keys()).map(r=>new URL(r.url).pathname)};})));await page.evaluate(()=>navigator.serviceWorker.ready);console.log(engine,'worker active');await page.evaluate(()=>window.Lazy?.warm());
|
||||
await poll(async()=>!(await inventory()).missing.length,Number(option('--warm-timeout','15000')));
|
||||
if(!legacy){const wasmPath='/vendor/jamesdsp/dsp/jamesdsp.wasm',wasm=manifest.files[wasmPath];if(wasm){const cached=await page.evaluate(async key=>!!await(await caches.open('ytplayer-assets')).match(key),wasmPath+'?v='+wasm.h);if(cached)result.failures.push({kind:'opt-in-wasm-cached-by-default',path:wasmPath});}}
|
||||
if(!legacy){
|
||||
await page.evaluate(()=>{data.settings.jamesDsp={...data.settings.jamesDsp,downloadForOffline:true};persist();reportDspOfflineOptIn();});
|
||||
const dspReady=await poll(async()=>{const inventoryNow=await inventory(true);return !inventoryNow.missing.length;},120000);
|
||||
result.conditions.push({name:'explicit audio-effects offline opt-in completes optional assets',complete:dspReady});
|
||||
if(!dspReady)result.failures.push({kind:'optional-dsp-completion',paths:(await inventory(true)).missing});
|
||||
}
|
||||
console.log(engine,'inventory complete');result.installMs=Date.now()-started;result.inventory=await inventory();result.installWire=proxy.getStats();result.saveData=await page.evaluate(()=>navigator.connection?.saveData);
|
||||
if(result.inventory.missing.length)result.failures.push({kind:'incomplete',paths:result.inventory.missing});
|
||||
// Offline navigation always starts from a controlled document.
|
||||
@@ -108,13 +117,17 @@ async function run(engine){
|
||||
await page.evaluate(async manifest=>{const c=await caches.open('ytplayer-assets');for(const path of Object.keys(manifest.files).filter(p=>manifest.files[p].g!=='core'))await c.delete(path+'?v='+manifest.files[path].h);navigator.serviceWorker.controller.postMessage({type:'COMPLETE_ASSETS'});},manifest);
|
||||
await poll(()=>page.evaluate(async()=>{const c=await caches.open('ytplayer-assets'),s=await(await c.match('/__ytp_asset_state')).json(),r=await c.match('/__ytp_completion/'+s.current.buildTag);return r&&(await r.json()).running;}),5000);
|
||||
await page.close();await other.close();await proxy.setOffline(false);
|
||||
const withoutPage=await poll(async()=>{const {cacheDataEntries:entries}=await cdp.send('CacheStorage.requestEntries',{cacheId,skipCount:0,pageSize:1000});return Object.entries(manifest.files).every(([path,file])=>entries.some(e=>e.requestURL===origin+path+'?v='+file.h&&e.responseHeaders.some(h=>h.name.toLowerCase()==='x-asset-hash'&&h.value===file.h)));});
|
||||
const optionalPaths=new Set(Object.values(manifest.groups||{}).filter(group=>group.offlineOptIn==='jamesDsp').flatMap(group=>group.files||[]));
|
||||
const includeOptional=await page.evaluate(async()=>{const cache=await caches.open('ytplayer-assets'),response=await cache.match('/__ytp_offline_opt_ins');return (await response?.json()||[]).includes('jamesDsp');});
|
||||
const requiredFiles=Object.entries(manifest.files).filter(([path])=>includeOptional||!optionalPaths.has(path));
|
||||
const withoutPage=await poll(async()=>{const {cacheDataEntries:entries}=await cdp.send('CacheStorage.requestEntries',{cacheId,skipCount:0,pageSize:1000});return requiredFiles.every(([path,file])=>entries.some(e=>e.requestURL===origin+path+'?v='+file.h&&e.responseHeaders.some(h=>h.name.toLowerCase()==='x-asset-hash'&&h.value===file.h)));});
|
||||
result.conditions.push({name:'worker completes with every application page closed; passive same-origin inspector only',complete:withoutPage});
|
||||
if(!withoutPage)result.failures.push({kind:'completion-without-page'});
|
||||
await cdp.detach();await inspector.close();
|
||||
}else await other.close();
|
||||
}
|
||||
console.log(engine,JSON.stringify({missing:result.inventory.missing,failures:result.failures,conditions:result.conditions}));
|
||||
result.inventory=await inventory(!legacy);
|
||||
console.log(engine,JSON.stringify({missing:result.inventory.missing,inventory:result.inventory.total,manifestFiles:result.inventory.manifestTotal,failures:result.failures,conditions:result.conditions}));
|
||||
}catch(e){result.failures.push({kind:'harness',error:e.stack});console.log(engine,e.message);}finally{await context?.close();await browser?.close();await proxy?.close();child.kill('SIGTERM');if(child.exitCode===null)await new Promise(r=>child.once('exit',r));}
|
||||
}
|
||||
try{for(const engine of option('--browser','all')==='all'?['chromium','webkit']:[option('--browser')])await run(engine);}finally{writeFileSync(resolve(option('--out',join(repo,'perf/results/offline-complete.json'))),JSON.stringify({results},null,2)+'\n');rmSync(scratch,{recursive:true,force:true});}
|
||||
|
||||
@@ -49,7 +49,7 @@ async function run(name){
|
||||
await context.route('**/*',route=>route.request().url().startsWith(origin)?route.continue():route.abort());
|
||||
const page=await context.newPage();page.on('pageerror',e=>result.errors.push(e.message));
|
||||
const boot=async()=>{await page.goto(origin);await page.waitForFunction(()=>typeof data!=='undefined'&&document.querySelector('#sidebarToggle'));await page.waitForTimeout(800);};
|
||||
const settings=()=>page.evaluate(()=>JSON.parse(localStorage.getItem('_ytpdata')).settings);
|
||||
const settings=()=>page.evaluate(()=>JSON.parse(JSON.stringify(data.settings)));
|
||||
const openSettings=async()=>{await page.locator('[data-view="settings"]').first().evaluate(e=>e.click());await page.waitForSelector('#setSaveBeforePlay',{state:'attached'});};
|
||||
const readUI=()=>page.evaluate(ids=>Object.fromEntries(ids.map(id=>{const e=document.getElementById(id);return[id,e.type==='checkbox'?e.checked:e.value];})),Object.values(ui).map(x=>x[0]));
|
||||
await boot();await page.waitForFunction(()=>!!navigator.serviceWorker.controller);
|
||||
@@ -67,13 +67,15 @@ async function run(name){
|
||||
await page.waitForFunction(()=>!document.getElementById('modal').classList.contains('hidden')&&/Update available/.test(document.getElementById('modalTitle').textContent));
|
||||
await page.locator('#modal').getByRole('button',{name:'Refresh UI',exact:true}).evaluate(e=>e.click());await page.waitForFunction(tag=>document.querySelector('meta[name="ytp-build"]')?.content===tag,result.newTag,{timeout:90000});await sleep(1000);await openSettings();
|
||||
console.log(name,'real Refresh UI completed');result.upgraded=await settings();result.newRaw=await page.evaluate(()=>localStorage.getItem('_ytpdata'));result.newUI=await readUI();assert.deepEqual(result.newUI,result.oldUI,'checkbox/select values preserved');
|
||||
const {migrate}=require(join(scratch,'new/frontend/eq-parameters.js'));const expected=structuredClone(result.old);if(expected.eq?.g?.length===5)expected.eq.g=migrate(expected.eq.g);
|
||||
const {migrate}=require(join(scratch,'new/frontend/eq-parameters.js'));const expected=structuredClone(result.old);if(expected.eq?.g?.length===5)expected.eq.g=migrate(expected.eq.g);expected.jamesDsp={enabled:false,downloadForOffline:false,preset:'flat',params:{},irRef:null,...(expected.jamesDsp||{}),params:expected.jamesDsp?.params||{}};
|
||||
for(const key of new Set([...Object.keys(result.old),...Object.keys(result.upgraded)]))result.diff.push({key,before:result.old[key],after:result.upgraded[key],equal:JSON.stringify(result.old[key])===JSON.stringify(result.upgraded[key]),expectedMigration:key==='eq'&&result.old.eq?.g?.length===5});
|
||||
assert.deepEqual(result.upgraded,expected,'all persisted settings preserved except deliberate 5→10 EQ migration');await behaviour('upgraded');
|
||||
await openSettings();await page.locator('[data-section="playback"]').evaluate(e=>e.click());await page.locator('#setDspPreset').evaluate(e=>{e.value='warm';e.dispatchEvent(new Event('change',{bubbles:true}));});
|
||||
for(const [key,value] of [['eq.band0','3'],['tube.drive','1.5']])await page.locator(`[data-dsp-param="${key}"]`).evaluate((e,v)=>{e.value=v;e.dispatchEvent(new Event('input',{bubbles:true}));},value);
|
||||
await sleep(600);await page.reload();await page.waitForFunction(()=>typeof data!=='undefined');await openSettings();await page.locator('[data-section="playback"]').evaluate(e=>e.click());
|
||||
const james=await page.evaluate(()=>data.settings.jamesDsp);assert.deepEqual(james,{enabled:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5}});assert.equal(await page.locator('#setSoundEngine').inputValue(),'standard');assert.equal(await page.locator('#setDspPreset').inputValue(),'warm');result.jamesDspSettingsPersisted=true;
|
||||
const james=await page.evaluate(()=>data.settings.jamesDsp);assert.deepEqual(james,{enabled:false,downloadForOffline:false,preset:'warm',params:{'eq.band0':3,'tube.drive':1.5},irRef:null});assert.equal(await page.locator('#setSoundEngine').inputValue(),'standard');assert.equal(await page.locator('#setDspPreset').inputValue(),'warm');
|
||||
await page.locator('#setDspOffline').check();const wasmReady=await poll(()=>page.evaluate(async()=>{const m=JSON.parse(document.getElementById('ytp-assets').textContent),c=await caches.open('ytplayer-assets');return !!await c.match('/vendor/jamesdsp/dsp/jamesdsp.wasm?v='+m.files['/vendor/jamesdsp/dsp/jamesdsp.wasm'].h);}));assert.equal(wasmReady,true,'offline opt-in eventually caches the optional WASM');
|
||||
await page.reload();await page.waitForFunction(()=>typeof data!=='undefined');await openSettings();await page.locator('[data-section="playback"]').evaluate(e=>e.click());assert.equal(await page.locator('#setDspOffline').isChecked(),true);result.jamesDspSettingsPersisted=true;
|
||||
await page.evaluate(()=>{data.settings.jamesDsp.enabled=false;persist();});
|
||||
const local=await settings(),remote={...local,saveBeforePlay:false,autoPreload:false,layout:'classic',quality:'auto'};
|
||||
async function profileCheck(updatedAt,expectedSave,stage){profileReply={ok:true,name:'probe',updatedAt,data:{settings:remote,playlists:[]}};await page.evaluate(s=>{data.settings=s;data.profile={name:'probe',syncedAt:200};localStorage.setItem('_ytpdata',JSON.stringify(data));},local);await boot();await page.waitForFunction(()=>_syncBusy===0);await openSettings();const s=await settings();assert.equal(s.saveBeforePlay,expectedSave);assert.equal(await page.locator('#setSaveBeforePlay').isChecked(),expectedSave);if(!expectedSave)assert.equal(s.layout,'classic');else assert.deepEqual(s,local);result.profileChecks.push({stage,serverUpdatedAt:updatedAt,localSyncedAt:200,saveBeforePlay:s.saveBeforePlay});}
|
||||
|
||||
Reference in New Issue
Block a user