Extract the shared realtime audio graph owner
This commit is contained in:
@@ -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 startSleepTimer(minutes, { endOfSong = false } = {}) {");assert.ok(start>=0,"startSleepTimer");const body=app.slice(start,start+487);assert.equal(createHash('sha256').update(body).digest('hex'),"f0a93401ab90196096074cb607b28d52c5903e11dbdfb8ddb95114e143af2409","startSleepTimer body unchanged");}
|
||||||
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
|
{const start=app.indexOf("function cancelSleepTimer() {");assert.ok(start>=0,"cancelSleepTimer");const body=app.slice(start,start+253);assert.equal(createHash('sha256').update(body).digest('hex'),"cf396ab758a70eede6af7444539a82ca15af801a9a57024dd5542067e9fa771a","cancelSleepTimer body unchanged");}
|
||||||
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
|
{const start=app.indexOf("function finishSleep() {");assert.ok(start>=0,"finishSleep");const body=app.slice(start,start+662);assert.equal(createHash('sha256').update(body).digest('hex'),"7ee93bf760bad5f39f4644339963918c5b369b2acc554b8e561c1a1f070ac6fb","finishSleep body unchanged");}
|
||||||
{const start=app.indexOf("const 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 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");}
|
{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");}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5507,7 +5507,7 @@ const EQ_PRESETS = EqParameters.presets;
|
|||||||
const EQ = (() => {
|
const EQ = (() => {
|
||||||
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
|
||||||
const AC = window.AudioContext || window.webkitAudioContext;
|
const AC = window.AudioContext || window.webkitAudioContext;
|
||||||
const st = { ctx: null, filters: null, sources: new Map() };
|
const st = { ctx: null, filters: null };
|
||||||
// Web Audio is off on iOS (it silences playback when the screen locks)
|
// Web Audio is off on iOS (it silences playback when the screen locks)
|
||||||
// unless the user opted in through "Level volume" (Settings → Playback).
|
// unless the user opted in through "Level volume" (Settings → Playback).
|
||||||
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
// Live Web Audio is off on iOS (it silences playback when the screen locks)
|
||||||
@@ -5529,7 +5529,7 @@ const EQ = (() => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
st.ctx = new AC();
|
st.ctx = AudioGraph.context();
|
||||||
st.filters = EQ_BANDS.map((b) => {
|
st.filters = EQ_BANDS.map((b) => {
|
||||||
const f = st.ctx.createBiquadFilter();
|
const f = st.ctx.createBiquadFilter();
|
||||||
f.type = b.type;
|
f.type = b.type;
|
||||||
@@ -5554,11 +5554,9 @@ const EQ = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function attach(el) {
|
function attach(el) {
|
||||||
if (!el || st.sources.has(el)) return;
|
if (!el) return;
|
||||||
try {
|
try {
|
||||||
const src = st.ctx.createMediaElementSource(el);
|
AudioGraph.connect(el, st.filters[0]);
|
||||||
src.connect(st.filters[0]);
|
|
||||||
st.sources.set(el, src);
|
|
||||||
} catch { /* already connected elsewhere */ }
|
} catch { /* already connected elsewhere */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
"/related-videos.js",
|
"/related-videos.js",
|
||||||
"/related-videos.css",
|
"/related-videos.css",
|
||||||
"/audio-continuity.js",
|
"/audio-continuity.js",
|
||||||
|
"/audio-graph.js",
|
||||||
"/fullscreen-orientation.js",
|
"/fullscreen-orientation.js",
|
||||||
"/fullscreen-orientation.css",
|
"/fullscreen-orientation.css",
|
||||||
"/download-actions.js",
|
"/download-actions.js",
|
||||||
|
|||||||
69
frontend/audio-graph.js
Normal file
69
frontend/audio-graph.js
Normal file
@@ -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);
|
||||||
75
frontend/audio-graph.test.js
Normal file
75
frontend/audio-graph.test.js
Normal file
@@ -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);
|
||||||
|
});
|
||||||
File diff suppressed because one or more lines are too long
@@ -70,6 +70,7 @@ const SHELL = [
|
|||||||
'/glass-panel-layout.js',
|
'/glass-panel-layout.js',
|
||||||
'/theme-classic.css',
|
'/theme-classic.css',
|
||||||
'/recommendations.css',
|
'/recommendations.css',
|
||||||
|
'/audio-graph.js',
|
||||||
'/flag-ui.js',
|
'/flag-ui.js',
|
||||||
'/export.js',
|
'/export.js',
|
||||||
'/async-guard.js',
|
'/async-guard.js',
|
||||||
|
|||||||
31
tests/fixtures/standard-eq-build.txt
vendored
Normal file
31
tests/fixtures/standard-eq-build.txt
vendored
Normal file
@@ -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; }
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user