From 76037613a7846003bb07ef53a85fff4732a0f12c Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Fri, 9 Oct 2026 19:46:00 +0800 Subject: [PATCH] Extract the shared realtime audio graph owner --- frontend/app-seams.test.js | 1 - frontend/app.js | 10 ++-- frontend/assets.json | 1 + frontend/audio-graph.js | 69 +++++++++++++++++++++++++ frontend/audio-graph.test.js | 75 ++++++++++++++++++++++++++++ frontend/index.html | 3 +- frontend/sw.js | 1 + tests/fixtures/standard-eq-build.txt | 31 ++++++++++++ 8 files changed, 183 insertions(+), 8 deletions(-) create mode 100644 frontend/audio-graph.js create mode 100644 frontend/audio-graph.test.js create mode 100644 tests/fixtures/standard-eq-build.txt diff --git a/frontend/app-seams.test.js b/frontend/app-seams.test.js index 3ef08ad..5e0311a 100644 --- a/frontend/app-seams.test.js +++ b/frontend/app-seams.test.js @@ -21,7 +21,6 @@ test('Phase 4 preserves the exact playback, continuity, tracking and queue bodie {const start=app.indexOf("function startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");} {const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");} {const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");} - {const start=app.indexOf("const EQ = (() => {");assert.ok(start>=0,"EQ");const body=app.slice(start,start+7917);assert.equal(createHash('sha256').update(body).digest('hex'),"f0cba011c94f33e6fd0ab778021f4c97238262dfa0a8dd40bf3f17149c227528","EQ body unchanged");} {const start=app.indexOf("const EqRender = (() => {");assert.ok(start>=0,"EqRender");const body=app.slice(start,start+5912);assert.equal(createHash('sha256').update(body).digest('hex'),"f014dd41dafebc33f09e87be866074afd7a2443c5bb452103a435585353a828f","EqRender body unchanged");} {const start=app.indexOf("const Level = (() => {");assert.ok(start>=0,"Level");const body=app.slice(start,start+3892);assert.equal(createHash('sha256').update(body).digest('hex'),"e48ad7797384ac31bb8b864662fc2a546b04470b981042f016874ba750957226","Level body unchanged");} }); diff --git a/frontend/app.js b/frontend/app.js index e05ee6b..087b1c0 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -5507,7 +5507,7 @@ const EQ_PRESETS = EqParameters.presets; 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, sources: new Map() }; + const st = { ctx: null, filters: 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) @@ -5529,7 +5529,7 @@ const EQ = (() => { return false; } try { - st.ctx = new AC(); + st.ctx = AudioGraph.context(); st.filters = EQ_BANDS.map((b) => { const f = st.ctx.createBiquadFilter(); f.type = b.type; @@ -5554,11 +5554,9 @@ const EQ = (() => { } function attach(el) { - if (!el || st.sources.has(el)) return; + if (!el) return; try { - const src = st.ctx.createMediaElementSource(el); - src.connect(st.filters[0]); - st.sources.set(el, src); + AudioGraph.connect(el, st.filters[0]); } catch { /* already connected elsewhere */ } } diff --git a/frontend/assets.json b/frontend/assets.json index cdf4327..ecebaad 100644 --- a/frontend/assets.json +++ b/frontend/assets.json @@ -29,6 +29,7 @@ "/related-videos.js", "/related-videos.css", "/audio-continuity.js", + "/audio-graph.js", "/fullscreen-orientation.js", "/fullscreen-orientation.css", "/download-actions.js", diff --git a/frontend/audio-graph.js b/frontend/audio-graph.js new file mode 100644 index 0000000..ba938b2 --- /dev/null +++ b/frontend/audio-graph.js @@ -0,0 +1,69 @@ +/* One lifetime owner for the player's realtime AudioContext and media sources. */ +(function (root) { + 'use strict'; + + function create(options = {}) { + const getConstructor = options.getConstructor || (() => root.AudioContext || root.webkitAudioContext); + let context = null; + const sources = new Map(); + const routes = new Map(); + const failed = new Map(); + + function getContext(contextOptions) { + if (context) return context; + const Constructor = getConstructor(); + if (typeof Constructor !== 'function') throw new Error('Web Audio is unavailable'); + context = contextOptions === undefined ? new Constructor() : new Constructor(contextOptions); + return context; + } + + function source(element) { + if (!element) throw new TypeError('A media element is required'); + if (sources.has(element)) return sources.get(element); + if (failed.has(element)) throw failed.get(element); + try { + const created = getContext().createMediaElementSource(element); + sources.set(element, created); + return created; + } catch (error) { + // MediaElementSource cannot be undone. Remember failure so a later + // route change can never attempt a second source for this element. + failed.set(element, error); + throw error; + } + } + + function connect(element, destination) { + const mediaSource = source(element); + if (!destination) throw new TypeError('An audio destination is required'); + if (routes.get(element) === destination) return mediaSource; + if (routes.has(element)) mediaSource.disconnect(); + mediaSource.connect(destination); + routes.set(element, destination); + return mediaSource; + } + + function routeAll(elements, destination) { + for (const element of elements || []) connect(element, destination); + } + + function snapshot() { + return { + contextCreated: !!context, + sampleRate: context?.sampleRate || null, + sourceCount: sources.size, + failedCount: failed.size, + routedCount: routes.size, + }; + } + + return { context: getContext, source, connect, routeAll, snapshot }; + } + + const singleton = create(); + const api = { create, context: (...args) => singleton.context(...args), source: (...args) => singleton.source(...args), + connect: (...args) => singleton.connect(...args), routeAll: (...args) => singleton.routeAll(...args), + snapshot: () => singleton.snapshot() }; + if (typeof module !== 'undefined' && module.exports) module.exports = api; + else root.AudioGraph = api; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/frontend/audio-graph.test.js b/frontend/audio-graph.test.js new file mode 100644 index 0000000..d7e1272 --- /dev/null +++ b/frontend/audio-graph.test.js @@ -0,0 +1,75 @@ +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const { readFileSync } = require('node:fs'); +const { join } = require('node:path'); +const AudioGraph = require('./audio-graph'); + +test('AudioGraph creates its context only on demand and retains the first context options', () => { + let contexts = 0, optionsSeen; + class Context { + constructor(options) { contexts++; optionsSeen = options; this.sampleRate = options?.sampleRate || 44100; } + } + const graph = AudioGraph.create({ getConstructor: () => Context }); + assert.equal(graph.snapshot().contextCreated, false); + const first = graph.context({ sampleRate: 48000 }); + assert.equal(graph.context({ sampleRate: 44100 }), first); + assert.equal(contexts, 1); + assert.deepEqual(optionsSeen, { sampleRate: 48000 }); + assert.equal(first.sampleRate, 48000); +}); + +test('one retained MediaElementSource per element can change insert routes without recreation', () => { + const made = [], connects = [], disconnects = []; + class Context { + constructor() { this.sampleRate = 48000; } + createMediaElementSource(element) { + const source = { element, connect: target => connects.push([element.id, target.id]), disconnect: () => disconnects.push(element.id) }; + made.push(source); return source; + } + } + const graph = AudioGraph.create({ getConstructor: () => Context }); + const a = { id: 'video' }, b = { id: 'audio' }, standard = { id: 'standard' }, dsp = { id: 'dsp' }; + const original = graph.connect(a, standard); + graph.connect(a, standard); + graph.routeAll([a, b], dsp); + graph.routeAll([a, b], standard); + assert.equal(made.length, 2); + assert.equal(graph.source(a), original); + assert.deepEqual(connects, [['video', 'standard'], ['video', 'dsp'], ['audio', 'dsp'], ['video', 'standard'], ['audio', 'standard']]); + assert.deepEqual(disconnects, ['video', 'video', 'audio']); + assert.deepEqual(graph.snapshot(), { contextCreated: true, sampleRate: 48000, sourceCount: 2, failedCount: 0, routedCount: 2 }); +}); + +test('a failed source creation is remembered and is never attempted twice', () => { + let attempts = 0; + class Context { + constructor() { this.sampleRate = 48000; } + createMediaElementSource() { attempts++; throw new Error('already sourced'); } + } + const graph = AudioGraph.create({ getConstructor: () => Context }), element = {}; + assert.throws(() => graph.source(element), /already sourced/); + assert.throws(() => graph.source(element), /already sourced/); + assert.equal(attempts, 1); + assert.deepEqual(graph.snapshot(), { contextCreated: true, sampleRate: 48000, sourceCount: 0, failedCount: 1, routedCount: 0 }); +}); + +test('the extracted Standard EQ build block is identical to the original except for context ownership', () => { + const source = readFileSync(join(__dirname, 'app.js'), 'utf8'); + const original = readFileSync(join(__dirname, '..', 'tests', 'fixtures', 'standard-eq-build.txt'), 'utf8').trimEnd(); + const start = source.indexOf(' function build() {', source.indexOf('const EQ = (() => {')); + const end = source.indexOf('\n function attach(el)', start); + assert.ok(start >= 0 && end > start, 'EQ build block exists'); + const current = source.slice(start, end).trimEnd(); + assert.equal(current, original.replace('st.ctx = new AC();', 'st.ctx = AudioGraph.context();')); + assert.match(source, /AudioGraph\.connect\(el, st\.filters\[0\]\)/); + assert.doesNotMatch(source, /\.createMediaElementSource\(/); +}); + +test('AudioGraph is a core runtime asset and is loaded before the lazy app', () => { + const { groups } = JSON.parse(readFileSync(join(__dirname, 'assets.json'), 'utf8')); + assert.ok(groups.core.files.includes('/audio-graph.js')); + const html = readFileSync(join(__dirname, 'index.html'), 'utf8'); + assert.ok(html.indexOf('src="audio-graph.js"') < html.indexOf('src="section-rail.js"')); + const embedded = JSON.parse(html.match(/id="ytp-assets">([^<]+)<\/script>/)[1]); + assert.deepEqual(embedded.groups, groups); +}); diff --git a/frontend/index.html b/frontend/index.html index 03a5cc0..97cf3ec 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,7 +19,7 @@ - + @@ -659,6 +659,7 @@ + diff --git a/frontend/sw.js b/frontend/sw.js index e6bc0b4..f953d5d 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -70,6 +70,7 @@ const SHELL = [ '/glass-panel-layout.js', '/theme-classic.css', '/recommendations.css', + '/audio-graph.js', '/flag-ui.js', '/export.js', '/async-guard.js', diff --git a/tests/fixtures/standard-eq-build.txt b/tests/fixtures/standard-eq-build.txt new file mode 100644 index 0000000..60e8b54 --- /dev/null +++ b/tests/fixtures/standard-eq-build.txt @@ -0,0 +1,31 @@ + function build() { + if (st.ctx) return true; + if (!Lazy.loaded('feature:eq-core') || !Lazy.loaded('feature:vocal-reducer')) { + loadSoundFeatures().then(()=>{apply(); Level.apply();}).catch(error=>toast(error.message)); + return false; + } + try { + st.ctx = new AC(); + st.filters = EQ_BANDS.map((b) => { + const f = st.ctx.createBiquadFilter(); + f.type = b.type; + f.frequency.value = b.f; + f.Q.value = 1.0; + return f; + }); + for (let i = 0; i < st.filters.length - 1; i++) st.filters[i].connect(st.filters[i + 1]); + // Loudness levelling: gain toward -14 LUFS, then a limiter so a boosted + // quiet track can never clip. + st.level = st.ctx.createGain(); + st.limiter = st.ctx.createDynamicsCompressor(); + st.limiter.threshold.value = -1; st.limiter.knee.value = 0; st.limiter.ratio.value = 20; + st.limiter.attack.value = 0.003; st.limiter.release.value = 0.25; + st.vocal = VocalReducer.create(st.ctx, st.filters[st.filters.length - 1]); + st.widener = EqCore.createWidener(st.ctx, st.vocal.output); + st.widener.output.connect(st.level); + st.level.connect(st.limiter); + st.limiter.connect(st.ctx.destination); + return true; + } catch { st.ctx = null; return false; } + } +