feat(audio): implement phase 3c master protection
This commit is contained in:
@@ -4,6 +4,16 @@
|
||||
export const AUDIO_STATIC_MAX_FREQUENCY = 24000;
|
||||
export const AUDIO_NYQUIST_FACTOR = 0.45;
|
||||
|
||||
// Engine-owned candidate values; hardware/listening acceptance remains GC6.
|
||||
export const AUDIO_PROTECTION = Object.freeze({
|
||||
ceilingDb: -1,
|
||||
toleranceDb: 0.1,
|
||||
lookaheadMs: 5,
|
||||
attackMs: 0.5,
|
||||
releaseMs: 250,
|
||||
channels: 2
|
||||
});
|
||||
|
||||
export const AUDIO_LIMITS = Object.freeze({
|
||||
nodesPerSound: 128,
|
||||
routesPerSound: 256,
|
||||
|
||||
+139
-28
@@ -19,6 +19,7 @@ import {
|
||||
} from './audio-graph.js';
|
||||
import { applyAutomationMode, sampleAutomationTrack } from './audio-automation.js';
|
||||
import { createAudioControls } from './audio-controls.js';
|
||||
import { createProtectionNode, loadProtectionWorklet } from './audio-protection.js';
|
||||
import { ValueResolver } from './values.js';
|
||||
import { ResolutionEngine } from './resolution.js';
|
||||
import { SeededRNG } from './rng.js';
|
||||
@@ -576,7 +577,10 @@ export class SoundInstance {
|
||||
}
|
||||
|
||||
realize(context, destination) {
|
||||
this.realized = realizeSoundGraph(context, this.plan, destination);
|
||||
const guard = this.subsystem?.createVoiceGuard(this);
|
||||
this.guard = guard;
|
||||
if (guard) guard.connect(destination);
|
||||
this.realized = realizeSoundGraph(context, this.plan, guard ?? destination);
|
||||
return this.realized;
|
||||
}
|
||||
|
||||
@@ -685,6 +689,13 @@ export class SoundInstance {
|
||||
this.realized = null;
|
||||
}
|
||||
this.plan = null;
|
||||
if (this.guard) {
|
||||
this.guard.port.onmessage = null;
|
||||
this.guard.onprocessorerror = null;
|
||||
this.guard.port.close();
|
||||
this.guard.disconnect();
|
||||
this.guard = null;
|
||||
}
|
||||
this.bus = null;
|
||||
this.context = null;
|
||||
}
|
||||
@@ -695,11 +706,11 @@ export class SoundInstance {
|
||||
* ------------------------------------------------------------------ */
|
||||
|
||||
// Owns the AudioContext, the declared buses, and the engine master chain, and turns a
|
||||
// sound definition into a realized voice. The lifecycle state machine (PRD 57), voice
|
||||
// ceilings (16.6), and the measured master-protection contract (PRD 58) are Phase 3c;
|
||||
// the master chain built here is engine-owned and unbypassable.
|
||||
// sound definition into a realized voice. All buses and volume controls are upstream
|
||||
// of the final protection worklet; hardware/listening acceptance is tracked in GC6.
|
||||
export class AudioSubsystem {
|
||||
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null, resolutionEngine = null } = {}) {
|
||||
constructor({ document, rng, diagnostics = null, contextFactory = null, voiceLimits = null, resolutionEngine = null,
|
||||
protectionFactory = { load: loadProtectionWorklet, create: createProtectionNode } } = {}) {
|
||||
this.document = document;
|
||||
this.rng = rng;
|
||||
this.diagnostics = diagnostics;
|
||||
@@ -719,6 +730,13 @@ export class AudioSubsystem {
|
||||
this.ordinals = new Map();
|
||||
this.nextCreationOrder = 0;
|
||||
this.masterVolume = 0.8;
|
||||
this.protectionFactory = protectionFactory;
|
||||
this.ready = false;
|
||||
this.disposed = false;
|
||||
this.unlockPending = null;
|
||||
this.capturePending = null;
|
||||
this.captureSequence = 0;
|
||||
this.unavailableWarned = false;
|
||||
}
|
||||
|
||||
get available() {
|
||||
@@ -726,35 +744,123 @@ export class AudioSubsystem {
|
||||
}
|
||||
|
||||
get unlocked() {
|
||||
return this.context !== null && this.context.state === 'running';
|
||||
return this.ready && this.context !== null && this.context.state === 'running';
|
||||
}
|
||||
|
||||
// Must be called from a user gesture; browsers refuse to start audio otherwise.
|
||||
async unlock() {
|
||||
if (!this.available) {
|
||||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'This browser exposes no AudioContext; audio is disabled.', { section: 'audio' });
|
||||
return false;
|
||||
}
|
||||
if (!this.context) {
|
||||
this.context = this.contextFactory();
|
||||
this.buildMaster();
|
||||
this.buildBuses();
|
||||
}
|
||||
if (this.context.state === 'suspended') await this.context.resume();
|
||||
return this.unlocked;
|
||||
if (this.disposed) return false;
|
||||
if (this.unlockPending) return this.unlockPending;
|
||||
this.unlockPending = this.initializeAudio();
|
||||
try { return await this.unlockPending; }
|
||||
finally { this.unlockPending = null; }
|
||||
}
|
||||
|
||||
buildMaster() {
|
||||
async initializeAudio() {
|
||||
try {
|
||||
if (this.protectionFailed) throw new Error('Master protection failed; reactivate the exhibit to restart audio.');
|
||||
if (!this.available) throw new Error('This browser exposes no AudioContext.');
|
||||
if (!this.context) this.context = this.contextFactory();
|
||||
const context = this.context;
|
||||
// Resume synchronously with the gesture before awaiting module loading.
|
||||
const resume = context.state === 'suspended' ? context.resume() : Promise.resolve();
|
||||
await Promise.all([resume, this.ready ? Promise.resolve() : this.buildMaster()]);
|
||||
if (this.disposed || this.context !== context) return false;
|
||||
if (context.state !== 'running') throw new Error('The audio context did not enter the running state.');
|
||||
if (!this.ready) this.buildBuses();
|
||||
this.ready = true;
|
||||
return this.unlocked;
|
||||
} catch (error) {
|
||||
this.ready = false;
|
||||
for (const instance of [...this.oneshotVoices, ...this.continuousVoices]) instance.dispose();
|
||||
this.disconnectMaster();
|
||||
const context = this.context;
|
||||
this.context = null;
|
||||
try { await context?.close(); } catch { /* already closed */ }
|
||||
if (!this.unavailableWarned && !this.disposed) {
|
||||
this.unavailableWarned = true;
|
||||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', `Audio disabled: ${error.message}`, { section: 'audio' });
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async buildMaster() {
|
||||
const context = this.context;
|
||||
this.protection = context.createDynamicsCompressor();
|
||||
this.protection.threshold.value = -3;
|
||||
this.protection.knee.value = 0;
|
||||
this.protection.ratio.value = 20;
|
||||
this.protection.attack.value = 0.003;
|
||||
this.protection.release.value = 0.25;
|
||||
await this.protectionFactory.load(context);
|
||||
if (this.disposed || this.context !== context) return;
|
||||
this.protection = this.protectionFactory.create(context, false);
|
||||
this.protection.port.onmessage = ({ data }) => {
|
||||
if (data.type === 'nonfinite') this.warnNonfinite('master');
|
||||
if (data.type === 'capture' && data.id === this.capturePending?.id) {
|
||||
const pending = this.capturePending;
|
||||
this.capturePending = null;
|
||||
pending.resolve(data);
|
||||
}
|
||||
};
|
||||
this.protection.onprocessorerror = () => {
|
||||
this.ready = false;
|
||||
this.protectionFailed = true;
|
||||
this.capturePending?.reject(new Error('Master protection processor failed.'));
|
||||
this.capturePending = null;
|
||||
this.protection?.disconnect();
|
||||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', 'Master protection failed; output is muted.', { section: 'audio' });
|
||||
};
|
||||
this.master = context.createGain();
|
||||
this.master.gain.value = this.masterVolume;
|
||||
this.protection.connect(this.master).connect(context.destination);
|
||||
this.master.connect(this.protection).connect(context.destination);
|
||||
}
|
||||
|
||||
warnNonfinite(instanceId) {
|
||||
this.diagnostics?.warn('WARN_AUDIO_NONFINITE', 'A non-finite audio sample was detected; the containing output block was muted.', { section: 'audio', objectId: instanceId });
|
||||
}
|
||||
|
||||
createVoiceGuard(instance) {
|
||||
const guard = this.protectionFactory.create(this.context, true);
|
||||
const instanceId = instance.plan?.instanceKey ?? soundInstanceKey(instance.soundId, instance.creationOrder);
|
||||
guard.port.onmessage = ({ data }) => {
|
||||
if (data.type === 'nonfinite') this.warnNonfinite(instanceId);
|
||||
};
|
||||
guard.onprocessorerror = () => {
|
||||
instance.dispose();
|
||||
this.diagnostics?.warn('WARN_AUDIO_UNAVAILABLE', `Audio guard failed for '${instance.soundId}'; voice disposed.`, { section: 'audio', objectId: instance.soundId });
|
||||
};
|
||||
try { guard.connect(this.protection, 1, 1); }
|
||||
catch (error) {
|
||||
guard.port.onmessage = null;
|
||||
guard.onprocessorerror = null;
|
||||
guard.port.close();
|
||||
guard.disconnect();
|
||||
throw error;
|
||||
}
|
||||
return guard;
|
||||
}
|
||||
|
||||
captureOutput({ seconds = 120, warmupSeconds = 30 } = {}) {
|
||||
if (!this.unlocked) return Promise.reject(new Error('Unlock protected audio before capturing.'));
|
||||
if (this.capturePending) return Promise.reject(new Error('An output capture is already running.'));
|
||||
if (!Number.isFinite(seconds) || seconds <= 0 || !Number.isFinite(warmupSeconds) || warmupSeconds < 0) return Promise.reject(new Error('Invalid capture duration.'));
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++this.captureSequence;
|
||||
this.capturePending = { id, resolve, reject };
|
||||
this.protection.port.postMessage({ type: 'capture', id,
|
||||
frames: Math.max(1, Math.round(seconds * this.context.sampleRate)),
|
||||
warmupFrames: Math.round(warmupSeconds * this.context.sampleRate) });
|
||||
});
|
||||
}
|
||||
|
||||
disconnectMaster() {
|
||||
for (const bus of this.buses.values()) bus.disconnect();
|
||||
this.buses.clear();
|
||||
this.master?.disconnect();
|
||||
if (this.protection) {
|
||||
this.protection.port.onmessage = null;
|
||||
this.protection.port.close();
|
||||
this.protection.onprocessorerror = null;
|
||||
this.protection.disconnect();
|
||||
}
|
||||
this.master = null;
|
||||
this.protection = null;
|
||||
}
|
||||
|
||||
buildBuses() {
|
||||
@@ -762,14 +868,14 @@ export class AudioSubsystem {
|
||||
for (const id of Object.keys(declared)) {
|
||||
const gain = this.context.createGain();
|
||||
gain.gain.value = this.resolution.get(`audio.buses.${id}.gain`);
|
||||
gain.connect(this.protection);
|
||||
gain.connect(this.master);
|
||||
this.buses.set(id, gain);
|
||||
}
|
||||
}
|
||||
|
||||
busFor(soundId) {
|
||||
const name = this.document?.sounds?.[soundId]?.bus;
|
||||
return (name && this.buses.get(name)) || this.protection;
|
||||
return (name && this.buses.get(name)) || this.master;
|
||||
}
|
||||
|
||||
setBusGain(id, value) {
|
||||
@@ -781,6 +887,7 @@ export class AudioSubsystem {
|
||||
}
|
||||
|
||||
setMasterVolume(value) {
|
||||
if (!Number.isFinite(value)) return;
|
||||
this.masterVolume = Math.min(1, Math.max(0, value));
|
||||
if (this.master) this.master.gain.value = this.masterVolume;
|
||||
}
|
||||
@@ -898,12 +1005,16 @@ export class AudioSubsystem {
|
||||
}
|
||||
|
||||
async dispose() {
|
||||
this.disposed = true;
|
||||
this.ready = false;
|
||||
this.capturePending?.reject(new Error('Audio disposed during output capture.'));
|
||||
this.capturePending = null;
|
||||
this.unsubscribeResolution?.();
|
||||
this.unsubscribeResolution = null;
|
||||
this.stopAll();
|
||||
for (const instance of [...this.oneshotVoices]) instance.dispose();
|
||||
for (const instance of [...this.continuousVoices]) instance.dispose();
|
||||
this.buses.clear();
|
||||
this.disconnectMaster();
|
||||
if (this.context) {
|
||||
try { await this.context.close(); } catch { /* already closed */ }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { AUDIO_PROTECTION } from './audio-contract.js';
|
||||
|
||||
// This exact class is serialized into the embedded worklet and exercised in tests.
|
||||
// No browser globals or imports are used by the DSP core.
|
||||
export class MasterProtectionDSP {
|
||||
constructor(rate, settings) {
|
||||
this.ceiling = 10 ** (settings.ceilingDb / 20);
|
||||
this.delayFrames = Math.max(1, Math.ceil(rate * settings.lookaheadMs / 1000));
|
||||
this.delay = Array.from({ length: settings.channels }, () => new Float32Array(this.delayFrames));
|
||||
this.attack = Math.exp(-1 / (rate * settings.attackMs / 1000));
|
||||
this.release = Math.exp(-1 / (rate * settings.releaseMs / 1000));
|
||||
this.position = 0;
|
||||
this.gain = 1;
|
||||
this.heldPeak = 0;
|
||||
this.hold = 0;
|
||||
this.affectedBlocks = 0;
|
||||
}
|
||||
|
||||
process(input, output, fault = false) {
|
||||
let badSamples = 0;
|
||||
for (const channel of input) for (const sample of channel) if (!Number.isFinite(sample)) badSamples++;
|
||||
const muted = fault || badSamples > 0;
|
||||
if (muted) {
|
||||
// Flush pending audio as well: no corrupted history can reappear later.
|
||||
for (const channel of this.delay) channel.fill(0);
|
||||
for (const channel of output) channel.fill(0);
|
||||
this.heldPeak = 0;
|
||||
this.hold = 0;
|
||||
this.affectedBlocks++;
|
||||
return { muted, badSamples, peak: 0, clampedSamples: 0 };
|
||||
}
|
||||
let peak = 0, clampedSamples = 0;
|
||||
for (let i = 0; i < output[0].length; i++) {
|
||||
let incomingPeak = 0;
|
||||
for (const channel of input) incomingPeak = Math.max(incomingPeak, Math.abs(channel[i] ?? 0));
|
||||
if (incomingPeak >= this.heldPeak) {
|
||||
this.heldPeak = incomingPeak;
|
||||
this.hold = this.delayFrames;
|
||||
} else if (this.hold > 0) this.hold--;
|
||||
else this.heldPeak = incomingPeak;
|
||||
const target = this.heldPeak > this.ceiling ? this.ceiling / this.heldPeak : 1;
|
||||
const coefficient = target < this.gain ? this.attack : this.release;
|
||||
this.gain = target + coefficient * (this.gain - target);
|
||||
for (let c = 0; c < output.length; c++) {
|
||||
const delayed = this.delay[c][this.position];
|
||||
this.delay[c][this.position] = input[c]?.[i] ?? 0;
|
||||
const value = delayed * this.gain;
|
||||
if (Math.abs(value) > this.ceiling) clampedSamples++;
|
||||
// Final sample clamp is required even during attack and extreme overload.
|
||||
output[c][i] = Math.max(-this.ceiling, Math.min(this.ceiling, value));
|
||||
peak = Math.max(peak, Math.abs(output[c][i]));
|
||||
}
|
||||
this.position = (this.position + 1) % this.delayFrames;
|
||||
}
|
||||
return { muted, badSamples, peak, clampedSamples };
|
||||
}
|
||||
}
|
||||
|
||||
export function protectionWorkletSource() {
|
||||
return `const SETTINGS = ${JSON.stringify(AUDIO_PROTECTION)};
|
||||
${MasterProtectionDSP.toString()}
|
||||
class XZBTProtectionProcessor extends AudioWorkletProcessor {
|
||||
constructor(options) {
|
||||
super();
|
||||
this.guard = options.processorOptions?.guard === true;
|
||||
this.dsp = this.guard ? null : new MasterProtectionDSP(sampleRate, SETTINGS);
|
||||
this.warned = false;
|
||||
this.capture = null;
|
||||
this.port.onmessage = ({ data }) => {
|
||||
if (data.type === 'capture' && !this.guard) {
|
||||
this.capture = { id: data.id, skip: data.warmupFrames, remaining: data.frames,
|
||||
frames: 0, peak: 0, affectedBlocks: 0, nonfiniteSamples: 0, clampedSamples: 0 };
|
||||
}
|
||||
};
|
||||
}
|
||||
process(inputs, outputs) {
|
||||
const input = inputs[0] ?? [], output = outputs[0];
|
||||
let result;
|
||||
if (this.guard) {
|
||||
let badSamples = 0;
|
||||
for (const channel of input) for (const sample of channel) if (!Number.isFinite(sample)) badSamples++;
|
||||
for (let c = 0; c < output.length; c++) {
|
||||
output[c].fill(0);
|
||||
if (!badSamples && input[c]) output[c].set(input[c]);
|
||||
}
|
||||
// Separate finite fault lane lets the final master mute the same whole
|
||||
// render quantum, while attribution remains attached to this voice.
|
||||
outputs[1][0].fill(badSamples);
|
||||
result = { badSamples, muted: badSamples > 0 };
|
||||
} else {
|
||||
const fault = (inputs[1] ?? []).some(channel => channel.some(value => value !== 0));
|
||||
result = this.dsp.process(input, output, fault);
|
||||
const capture = this.capture;
|
||||
if (capture) {
|
||||
const start = Math.min(capture.skip, output[0].length);
|
||||
capture.skip -= start;
|
||||
const count = Math.min(capture.remaining, output[0].length - start);
|
||||
if (count > 0) {
|
||||
for (const channel of output) for (let i = start; i < start + count; i++) capture.peak = Math.max(capture.peak, Math.abs(channel[i]));
|
||||
capture.frames += count;
|
||||
capture.remaining -= count;
|
||||
capture.affectedBlocks += result.muted ? 1 : 0;
|
||||
capture.nonfiniteSamples += result.badSamples + (inputs[1]?.[0]?.[0] ?? 0);
|
||||
capture.clampedSamples += result.clampedSamples;
|
||||
}
|
||||
if (capture.remaining === 0) {
|
||||
this.port.postMessage({ type: 'capture', ...capture, sampleRate });
|
||||
this.capture = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (result.badSamples > 0 && !this.warned) {
|
||||
this.warned = true;
|
||||
this.port.postMessage({ type: 'nonfinite' });
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
registerProcessor('xzbt-protection', XZBTProtectionProcessor);`;
|
||||
}
|
||||
|
||||
export async function loadProtectionWorklet(context) {
|
||||
if (!context.audioWorklet || typeof globalThis.AudioWorkletNode !== 'function') {
|
||||
throw new Error('AudioWorklet master protection is unavailable.');
|
||||
}
|
||||
// The Phase 0 direct-file probe verified this embedded data-URL loading path.
|
||||
await context.audioWorklet.addModule(`data:text/javascript;charset=utf-8,${encodeURIComponent(protectionWorkletSource())}`);
|
||||
}
|
||||
|
||||
export function createProtectionNode(context, guard = false) {
|
||||
return new AudioWorkletNode(context, 'xzbt-protection', {
|
||||
numberOfInputs: guard ? 1 : 2,
|
||||
numberOfOutputs: guard ? 2 : 1,
|
||||
outputChannelCount: guard ? [AUDIO_PROTECTION.channels, 1] : [AUDIO_PROTECTION.channels],
|
||||
channelCount: AUDIO_PROTECTION.channels,
|
||||
channelCountMode: 'explicit',
|
||||
processorOptions: { guard }
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user