Add the guarded JamesDSP audio controller

This commit is contained in:
Jonathan Sykes
2026-10-09 20:00:49 +08:00
parent aca50c1690
commit 27b32ff0c9
9 changed files with 404 additions and 11 deletions

View File

@@ -663,6 +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: {} },
parallelSaves: 4, // offline saves running at once (Settings → Offline cache)
density: 'comfortable', // 'comfortable' | 'compact'
perfMode: false, // disable heavy visual effects for speed
@@ -672,7 +673,16 @@ const DEFAULT_SETTINGS = {
autoBackupIntervalDays: 7,
serviceVideo: false, // show the video panel inside service mode (lyric videos)
};
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: { ...DEFAULT_SETTINGS }, resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
function normalizeJamesDspSettings(settings) {
const saved = settings?.jamesDsp && typeof settings.jamesDsp === 'object' ? settings.jamesDsp : {};
settings.jamesDsp = {
enabled: saved.enabled === true,
preset: typeof saved.preset === 'string' ? saved.preset : 'flat',
params: saved.params && typeof saved.params === 'object' && !Array.isArray(saved.params) ? { ...saved.params } : {},
};
return settings;
}
let data = { playlists: [], history: [], queue: [], customVideos: [], pinnedPlaylists: [], settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS }), resumePositions: {}, rememberPos: {}, playCount: {}, abMarkers: {}, bookmarks: {}, lyricOffsets: {}, stats: null, lastAutoBackup: 0, profile: null };
let view = { type: 'search' }; // 'search'|'history'|'playlist'|'settings'|'queue'|'saved'|'downloads'|'channel'
let searchResults = [];
// Distinguishes "never searched" (hide the list pane, show the landing) from
@@ -695,6 +705,8 @@ const IS_IOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platfo
let _currentEqUrl = null;
async function useEqRender(id) {
if (_currentEqUrl) { URL.revokeObjectURL(_currentEqUrl); _currentEqUrl = null; }
// JamesDSP is a live insert: never layer it over the Standard renderer copy.
if (data.settings?.jamesDsp?.enabled) return null;
if (typeof EqRender === 'undefined' || !WEB) return null;
try { _currentEqUrl = await EqRender.urlFor(id); } catch { _currentEqUrl = null; }
return _currentEqUrl;
@@ -905,7 +917,7 @@ function applyProfileData(name, payload, updatedAt) {
if (payload.bookmarks && typeof payload.bookmarks === 'object') data.bookmarks = payload.bookmarks;
if (payload.lyricOffsets && typeof payload.lyricOffsets === 'object') data.lyricOffsets = payload.lyricOffsets;
if (payload.stats && typeof payload.stats === 'object') data.stats = payload.stats;
if (payload.settings && typeof payload.settings === 'object') data.settings = { ...DEFAULT_SETTINGS, ...payload.settings };
if (payload.settings && typeof payload.settings === 'object') data.settings = normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...payload.settings });
data.profile = { name, syncedAt: updatedAt || 0, protected: !!payload.__protected, kind: payload.__kind || null };
API.saveData(data).catch(() => {});
}
@@ -5334,7 +5346,7 @@ const Level = (() => {
// so it keeps going when locked. One render at a time, in the background.
const EqRender = (() => {
const DIR = 'eq-renders', KEY = 'ytpEqRenders', RATE = 32000, MAX_SECONDS = 12 * 60;
const active = () => EQ.isIOS() && WEB && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
const active = () => EQ.isIOS() && WEB && !data.settings?.jamesDsp?.enabled && !!window.OPFS && window.OPFS.isSupported() && typeof OfflineAudioContext !== 'undefined';
const readMap = () => { try { return JSON.parse(localStorage.getItem(KEY) || '{}'); } catch { return {}; } };
const writeMap = (m) => { try { localStorage.setItem(KEY, JSON.stringify(m)); } catch { /* blocked */ } };
const queue = new Set();
@@ -5504,10 +5516,12 @@ const PiP = (() => {
const loadSoundFeatures=()=>Promise.all([Lazy.load('feature:eq-core'),Lazy.load('feature:vocal-reducer')]);
const EQ_BANDS = EqParameters.bands;
const EQ_PRESETS = EqParameters.presets;
const JamesDSPFeature = Lazy.proxy('feature:jamesdsp', ['createController'], 'JamesDSPFeature');
const EQ = (() => {
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
const AC = window.AudioContext || window.webkitAudioContext;
const st = { ctx: null, filters: null };
const st = { ctx: null, filters: null, levelDb: 0 };
let dspController = null, dspControllerPromise = null;
// Web Audio is off on iOS (it silences playback when the screen locks)
// unless the user opted in through "Level volume" (Settings → Playback).
// Live Web Audio is off on iOS (it silences playback when the screen locks)
@@ -5522,6 +5536,43 @@ const EQ = (() => {
};
const isFlat = () => gains().every((v) => v === 0);
async function prepareDspGraph() {
if (!available()) throw new Error(isIOS ? 'Turn on Live sound processing in Settings → Playback first.' : 'Web Audio is unavailable.');
if (rendered()) throw new Error('This song is already playing a Standard processed copy. JamesDSP will start with the next original track.');
if (!st.ctx && !AudioGraph.snapshot().contextCreated) {
const context = AudioGraph.context({ sampleRate: 48000 });
if (context.state === 'suspended') context.resume().catch(() => {});
}
await loadSoundFeatures();
if (!st.ctx && !build()) throw new Error('The Standard fallback audio chain is not ready.');
if (!st.ctx || !st.filters?.[0]) throw new Error('The Standard fallback audio chain could not be built.');
attach(els.video);
attach(els.audio);
return { context: st.ctx, elements: [els.video, els.audio].filter(Boolean), standardInput: st.filters[0] };
}
function dspFailed(error) {
if (!data.settings.jamesDsp?.enabled) return;
data.settings.jamesDsp.enabled = false;
persist();
toast(`JamesDSP unavailable; Standard sound is active. ${error?.message || ''}`.trim());
apply();
Level.apply();
}
async function getDspController() {
if (dspController) return dspController;
if (!dspControllerPromise) dspControllerPromise = Lazy.load('feature:jamesdsp').then(() => {
dspController = JamesDSPFeature.createController({ graph: AudioGraph, prepareGraph: prepareDspGraph, onError: dspFailed });
return dspController;
}).catch(error => { dspControllerPromise = null; throw error; });
return dspControllerPromise;
}
function startDsp() {
getDspController().then(controller => controller.enable(data.settings.jamesDsp, st.levelDb)).catch(dspFailed);
}
function build() {
if (st.ctx) return true;
if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) {
@@ -5562,6 +5613,14 @@ const EQ = (() => {
// Apply the stored curve; wires the graph up the first time it's non-flat.
function apply() {
if (data.settings.jamesDsp?.enabled) {
if (!available()) { dspFailed(new Error(isIOS ? 'Live sound processing is off on this iPhone.' : 'Web Audio is unavailable.')); return; }
if (rendered()) { dspFailed(new Error('The current song already uses a Standard processed copy.')); return; }
startDsp();
renderButton();
return;
}
if (dspController) dspController.disable().catch(() => {});
if (!available()) return;
if (!st.ctx && isFlat() && EqParameters.amount(data.settings.vocalLevel ?? 100) === 100 && EqParameters.width(data.settings.stereoWidth) === 0) return;
if (!build()) return;
@@ -5641,6 +5700,7 @@ const EQ = (() => {
// one-way step the EQ takes, so it is only offered where the EQ is (never
// on iOS, where Web Audio would silence background playback).
function analyser() {
if (dspController?.active) return dspController.analyser();
if (!available() || !build()) return null;
attach(els.video);
attach(els.audio);
@@ -5656,8 +5716,13 @@ const EQ = (() => {
// Set the levelling gain (dB). 0 dB never wires the graph on its own.
function setLevel(db) {
if (!available()) return false;
if (rendered()) db = 0;
st.levelDb = Number(db) || 0;
if (data.settings.jamesDsp?.enabled) {
if (dspController?.desired) dspController.setLevelDb(st.levelDb).catch(dspFailed);
return true;
}
if (!available()) return false;
if (!st.ctx && Math.abs(db) < 0.3) return true;
if (!build()) return false;
attach(els.video);
@@ -8966,7 +9031,7 @@ async function boot() {
lyricOffsets: loaded.lyricOffsets || {},
stats: loaded.stats || null,
lastAutoBackup: loaded.lastAutoBackup || 0,
settings: { ...DEFAULT_SETTINGS, ...(loaded.settings || {}) },
settings: normalizeJamesDspSettings({ ...DEFAULT_SETTINGS, ...(loaded.settings || {}) }),
profile: loaded.profile || null,
};
}