diff --git a/frontend/app.js b/frontend/app.js index 808bd92..b7a11f6 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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= — 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. diff --git a/frontend/asset-sync-core.js b/frontend/asset-sync-core.js index 0c6ede2..366d7eb 100644 --- a/frontend/asset-sync-core.js +++ b/frontend/asset-sync-core.js @@ -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); diff --git a/frontend/asset-sync-core.test.js b/frontend/asset-sync-core.test.js index 60b366d..28f1da8 100644 --- a/frontend/asset-sync-core.test.js +++ b/frontend/asset-sync-core.test.js @@ -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');}}); diff --git a/frontend/assets.json b/frontend/assets.json index 9a9f2de..ec2f500 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -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": [ diff --git a/frontend/index.html b/frontend/index.html index c37b12b..f0f9bec 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,7 @@ - + diff --git a/frontend/jamesdsp-app.test.js b/frontend/jamesdsp-app.test.js index ab42629..026b3ae 100644 --- a/frontend/jamesdsp-app.test.js +++ b/frontend/jamesdsp-app.test.js @@ -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/); }); diff --git a/frontend/jamesdsp-assets.test.js b/frontend/jamesdsp-assets.test.js index edd3e49..0689349 100644 --- a/frontend/jamesdsp-assets.test.js +++ b/frontend/jamesdsp-assets.test.js @@ -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')); diff --git a/frontend/jamesdsp-controller.test.js b/frontend/jamesdsp-controller.test.js index e0782c0..f241f96 100644 --- a/frontend/jamesdsp-controller.test.js +++ b/frontend/jamesdsp-controller.test.js @@ -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); }); diff --git a/frontend/jamesdsp-ir.test.js b/frontend/jamesdsp-ir.test.js new file mode 100644 index 0000000..37fb5fc --- /dev/null +++ b/frontend/jamesdsp-ir.test.js @@ -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/); +}); diff --git a/frontend/settings-persist.test.js b/frontend/settings-persist.test.js index 9ed8f6f..62b9134 100644 --- a/frontend/settings-persist.test.js +++ b/frontend/settings-persist.test.js @@ -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); }); diff --git a/frontend/styles.css b/frontend/styles.css index fa7092b..dbe047a 100755 --- a/frontend/styles.css +++ b/frontend/styles.css @@ -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 { diff --git a/frontend/vendor/jamesdsp/README.md b/frontend/vendor/jamesdsp/README.md index 75962bf..0e84ecb 100644 --- a/frontend/vendor/jamesdsp/README.md +++ b/frontend/vendor/jamesdsp/README.md @@ -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. diff --git a/frontend/vendor/jamesdsp/VERSION.txt b/frontend/vendor/jamesdsp/VERSION.txt index ada2ffe..77a737b 100644 --- a/frontend/vendor/jamesdsp/VERSION.txt +++ b/frontend/vendor/jamesdsp/VERSION.txt @@ -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. diff --git a/frontend/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs b/frontend/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs similarity index 78% rename from frontend/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs rename to frontend/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs index 7de737c..468d173 100644 --- a/frontend/vendor/jamesdsp/dsp/jamesdsp-77be0b2.mjs +++ b/frontend/vendor/jamesdsp/dsp/jamesdsp-a982b53.mjs @@ -1,2 +1,2 @@ -async function createJamesDSP(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof WorkerGlobalScope!="undefined";var ENVIRONMENT_IS_NODE=typeof process=="object"&&process.versions?.node&&process.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var readyPromiseResolve,readyPromiseReject;var wasmMemory;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["__wasm_call_ctors"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("jamesdsp.wasm")}return new URL("jamesdsp.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw'sync fetching of the wasm failed: you can preload it to Module["wasmBinary"] manually, or emcc.py will do that for you when generating HTML (but not JS)'}function instantiateSync(file,info){var module;var binary=getBinarySync(file);module=new WebAssembly.Module(binary);var instance=new WebAssembly.Instance(module,info);return[instance,module]}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];updateMemoryViews();assignWasmExports(wasmExports);return wasmExports}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(mod,inst)=>{resolve(receiveInstance(mod,inst))})})}wasmBinaryFile??=findWasmBinary();var result=instantiateSync(wasmBinaryFile,info);return receiveInstance(result[0])}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ___assert_fail=(condition,filename,line,func)=>abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"]);var ___syscall_chdir=path=>{};var SYSCALLS={varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;return 0}var ___syscall_getdents64=(fd,dirp,count)=>{};function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;return 0}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs}var _emscripten_get_now=()=>performance.now();var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var abortOnCannotGrowMemory=requestedSize=>{abort("OOM")};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;abortOnCannotGrowMemory(requestedSize)};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;var _fd_close=fd=>52;var _fd_read=(fd,iov,iovcnt,pnum)=>52;function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);return 70}var printCharBuffers=[null,[],[]];var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer));buffer.length=0}else{buffer.push(curr)}};var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}var _jdsp_create,_jdsp_destroy,_free,_jdsp_process,_jdsp_faults,_jdsp_set_param,_jdsp_set_text,_jdsp_load_ir,_malloc,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current;function assignWasmExports(wasmExports){Module["_jdsp_create"]=_jdsp_create=wasmExports["jdsp_create"];Module["_jdsp_destroy"]=_jdsp_destroy=wasmExports["jdsp_destroy"];Module["_free"]=_free=wasmExports["free"];Module["_jdsp_process"]=_jdsp_process=wasmExports["jdsp_process"];Module["_jdsp_faults"]=_jdsp_faults=wasmExports["jdsp_faults"];Module["_jdsp_set_param"]=_jdsp_set_param=wasmExports["jdsp_set_param"];Module["_jdsp_set_text"]=_jdsp_set_text=wasmExports["jdsp_set_text"];Module["_jdsp_load_ir"]=_jdsp_load_ir=wasmExports["jdsp_load_ir"];Module["_malloc"]=_malloc=wasmExports["malloc"];__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"];__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"];_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"]}var wasmImports={__assert_fail:___assert_fail,__syscall_chdir:___syscall_chdir,__syscall_fcntl64:___syscall_fcntl64,__syscall_getdents64:___syscall_getdents64,__syscall_ioctl:___syscall_ioctl,__syscall_openat:___syscall_openat,clock_time_get:_clock_time_get,emscripten_date_now:_emscripten_date_now,emscripten_get_now:_emscripten_get_now,emscripten_resize_heap:_emscripten_resize_heap,exit:_exit,fd_close:_fd_close,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=createWasm();run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} +async function createJamesDSP(moduleArg={}){var moduleRtn;var Module=moduleArg;var ENVIRONMENT_IS_WEB=typeof window=="object";var ENVIRONMENT_IS_WORKER=typeof WorkerGlobalScope!="undefined";var ENVIRONMENT_IS_NODE=typeof process=="object"&&process.versions?.node&&process.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=import.meta.url;var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var EXITSTATUS;var readyPromiseResolve,readyPromiseReject;var wasmMemory;var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);Module["HEAPF32"]=HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["__wasm_call_ctors"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);readyPromiseReject?.(e);throw e}var wasmBinaryFile;function findWasmBinary(){if(Module["locateFile"]){return locateFile("jamesdsp.wasm")}return new URL("jamesdsp.wasm",import.meta.url).href}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw'sync fetching of the wasm failed: you can preload it to Module["wasmBinary"] manually, or emcc.py will do that for you when generating HTML (but not JS)'}function instantiateSync(file,info){var module;var binary=getBinarySync(file);module=new WebAssembly.Module(binary);var instance=new WebAssembly.Instance(module,info);return[instance,module]}function getWasmImports(){return{env:wasmImports,wasi_snapshot_preview1:wasmImports}}function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;wasmMemory=wasmExports["memory"];updateMemoryViews();assignWasmExports(wasmExports);return wasmExports}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(mod,inst)=>{resolve(receiveInstance(mod,inst))})})}wasmBinaryFile??=findWasmBinary();var result=instantiateSync(wasmBinaryFile,info);return receiveInstance(result[0])}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var noExitRuntime=true;var UTF8Decoder=typeof TextDecoder!="undefined"?new TextDecoder:undefined;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ___assert_fail=(condition,filename,line,func)=>abort(`Assertion failed: ${UTF8ToString(condition)}, at: `+[filename?UTF8ToString(filename):"unknown filename",line,func?UTF8ToString(func):"unknown function"]);var ___syscall_chdir=path=>{};var SYSCALLS={varargs:undefined,getStr(ptr){var ret=UTF8ToString(ptr);return ret}};function ___syscall_fcntl64(fd,cmd,varargs){SYSCALLS.varargs=varargs;return 0}var ___syscall_getdents64=(fd,dirp,count)=>{};function ___syscall_ioctl(fd,op,varargs){SYSCALLS.varargs=varargs;return 0}function ___syscall_openat(dirfd,path,flags,varargs){SYSCALLS.varargs=varargs}var _emscripten_get_now=()=>performance.now();var _emscripten_date_now=()=>Date.now();var nowIsMonotonic=1;var checkWasiClock=clock_id=>clock_id>=0&&clock_id<=3;var INT53_MAX=9007199254740992;var INT53_MIN=-9007199254740992;var bigintToI53Checked=num=>numINT53_MAX?NaN:Number(num);function _clock_time_get(clk_id,ignored_precision,ptime){ignored_precision=bigintToI53Checked(ignored_precision);if(!checkWasiClock(clk_id)){return 28}var now;if(clk_id===0){now=_emscripten_date_now()}else if(nowIsMonotonic){now=_emscripten_get_now()}else{return 52}var nsec=Math.round(now*1e3*1e3);HEAP64[ptime>>3]=BigInt(nsec);return 0}var abortOnCannotGrowMemory=requestedSize=>{abort("OOM")};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;abortOnCannotGrowMemory(requestedSize)};var runtimeKeepaliveCounter=0;var keepRuntimeAlive=()=>noExitRuntime||runtimeKeepaliveCounter>0;var _proc_exit=code=>{EXITSTATUS=code;if(!keepRuntimeAlive()){Module["onExit"]?.(code);ABORT=true}quit_(code,new ExitStatus(code))};var exitJS=(status,implicit)=>{EXITSTATUS=status;_proc_exit(status)};var _exit=exitJS;var _fd_close=fd=>52;var _fd_read=(fd,iov,iovcnt,pnum)=>52;function _fd_seek(fd,offset,whence,newOffset){offset=bigintToI53Checked(offset);return 70}var printCharBuffers=[null,[],[]];var printChar=(stream,curr)=>{var buffer=printCharBuffers[stream];if(curr===0||curr===10){(stream===1?out:err)(UTF8ArrayToString(buffer));buffer.length=0}else{buffer.push(curr)}};var _fd_write=(fd,iov,iovcnt,pnum)=>{var num=0;for(var i=0;i>2];var len=HEAPU32[iov+4>>2];iov+=8;for(var j=0;j>2]=num;return 0};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}var _jdsp_create,_free,_jdsp_destroy,_jdsp_process,_jdsp_faults,_jdsp_get_info,_jdsp_set_param,_jdsp_set_text,_jdsp_load_ir_ex,_malloc,_jdsp_load_ir,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current;function assignWasmExports(wasmExports){Module["_jdsp_create"]=_jdsp_create=wasmExports["jdsp_create"];Module["_free"]=_free=wasmExports["free"];Module["_jdsp_destroy"]=_jdsp_destroy=wasmExports["jdsp_destroy"];Module["_jdsp_process"]=_jdsp_process=wasmExports["jdsp_process"];Module["_jdsp_faults"]=_jdsp_faults=wasmExports["jdsp_faults"];Module["_jdsp_get_info"]=_jdsp_get_info=wasmExports["jdsp_get_info"];Module["_jdsp_set_param"]=_jdsp_set_param=wasmExports["jdsp_set_param"];Module["_jdsp_set_text"]=_jdsp_set_text=wasmExports["jdsp_set_text"];Module["_jdsp_load_ir_ex"]=_jdsp_load_ir_ex=wasmExports["jdsp_load_ir_ex"];Module["_malloc"]=_malloc=wasmExports["malloc"];Module["_jdsp_load_ir"]=_jdsp_load_ir=wasmExports["jdsp_load_ir"];__emscripten_stack_restore=wasmExports["_emscripten_stack_restore"];__emscripten_stack_alloc=wasmExports["_emscripten_stack_alloc"];_emscripten_stack_get_current=wasmExports["emscripten_stack_get_current"]}var wasmImports={__assert_fail:___assert_fail,__syscall_chdir:___syscall_chdir,__syscall_fcntl64:___syscall_fcntl64,__syscall_getdents64:___syscall_getdents64,__syscall_ioctl:___syscall_ioctl,__syscall_openat:___syscall_openat,clock_time_get:_clock_time_get,emscripten_date_now:_emscripten_date_now,emscripten_get_now:_emscripten_get_now,emscripten_resize_heap:_emscripten_resize_heap,exit:_exit,fd_close:_fd_close,fd_read:_fd_read,fd_seek:_fd_seek,fd_write:_fd_write};function run(){preRun();function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();readyPromiseResolve?.(Module);Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;wasmExports=createWasm();run();if(runtimeInitialized){moduleRtn=Module}else{moduleRtn=new Promise((resolve,reject)=>{readyPromiseResolve=resolve;readyPromiseReject=reject})} ;return moduleRtn}export default createJamesDSP; diff --git a/frontend/vendor/jamesdsp/dsp/jamesdsp.wasm b/frontend/vendor/jamesdsp/dsp/jamesdsp.wasm index 884efd3..5999aaf 100755 Binary files a/frontend/vendor/jamesdsp/dsp/jamesdsp.wasm and b/frontend/vendor/jamesdsp/dsp/jamesdsp.wasm differ diff --git a/frontend/vendor/jamesdsp/ir-builtins.mjs b/frontend/vendor/jamesdsp/ir-builtins.mjs new file mode 100644 index 0000000..1ee90b7 --- /dev/null +++ b/frontend/vendor/jamesdsp/ir-builtins.mjs @@ -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;pathpaths[i]}; +} diff --git a/frontend/vendor/jamesdsp/ir-store.mjs b/frontend/vendor/jamesdsp/ir-store.mjs new file mode 100644 index 0000000..ab43958 --- /dev/null +++ b/frontend/vendor/jamesdsp/ir-store.mjs @@ -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;is.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;ichannels[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));} diff --git a/frontend/vendor/jamesdsp/ir-utils.mjs b/frontend/vendor/jamesdsp/ir-utils.mjs new file mode 100644 index 0000000..31bb834 --- /dev/null +++ b/frontend/vendor/jamesdsp/ir-utils.mjs @@ -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;chchannels[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=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;ichannels[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; +} diff --git a/frontend/vendor/jamesdsp/jamesdsp-feature.js b/frontend/vendor/jamesdsp/jamesdsp-feature.js index b9d0df7..15ff6e4 100644 --- a/frontend/vendor/jamesdsp/jamesdsp-feature.js +++ b/frontend/vendor/jamesdsp/jamesdsp-feature.js @@ -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 || {}; diff --git a/frontend/vendor/jamesdsp/jamesdsp-node.mjs b/frontend/vendor/jamesdsp/jamesdsp-node.mjs index addbc3b..8371149 100644 --- a/frontend/vendor/jamesdsp/jamesdsp-node.mjs +++ b/frontend/vendor/jamesdsp/jamesdsp-node.mjs @@ -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;chNumber(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(); } } diff --git a/frontend/vendor/jamesdsp/params.mjs b/frontend/vendor/jamesdsp/params.mjs index 664238e..e0e14c2 100644 --- a/frontend/vendor/jamesdsp/params.mjs +++ b/frontend/vendor/jamesdsp/params.mjs @@ -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}, }; diff --git a/frontend/vendor/jamesdsp/worklet.mjs b/frontend/vendor/jamesdsp/worklet.mjs index 18511e3..506261b 100644 --- a/frontend/vendor/jamesdsp/worklet.mjs +++ b/frontend/vendor/jamesdsp/worklet.mjs @@ -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}); diff --git a/frontend/views-core.js b/frontend/views-core.js index 753a67f..7d0936b 100644 --- a/frontend/views-core.js +++ b/frontend/views-core.js @@ -732,9 +732,11 @@ async function renderSettings() { const qOptions = qualities .map((q) => ``) .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 = '') => ``; 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) => ``; + const dspBuiltinIRSelect = ``; wrap.innerHTML = `
@@ -813,8 +816,10 @@ async function renderSettings() { +
-
Live processing${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.'}
+
Live processing${EQ.isIOS() ? 'On iPhone, enable Live sound processing above first. Audio may stop when the screen locks.' : 'Processing runs on this device.'}
+ ${dspLowEnd ? '

This device may have limited audio headroom. Long impulse responses are disabled and the short room response is capped at 0.5 seconds.

' : ''}
${dspBandRows}
@@ -827,7 +832,27 @@ async function renderSettings() {
- +
+

Convolver

+ + + + + + + + +

${jamesDsp.irRef?.name ? `Loaded: ${jamesDsp.irRef.name}` : 'No impulse response loaded.'}

+
+
+

3D spatial stage

+ + + ${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')} +