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 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");}
|
||||
});
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
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',
|
||||
'/theme-classic.css',
|
||||
'/recommendations.css',
|
||||
'/audio-graph.js',
|
||||
'/flag-ui.js',
|
||||
'/export.js',
|
||||
'/async-guard.js',
|
||||
|
||||
Reference in New Issue
Block a user