diff --git a/README.md b/README.md index e56a048..dd8d61d 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,11 @@ The earlier ChatGPT discussion, **Discuss Application Vision** (conversation `6a ## Planning entry point -Phase 0 is complete, the production runtime is implemented through Phase 3c slice 3 (automation), and the audio subsystem contract is written through Phase 3c. Work is stopped at the user-requested slice-3 checkpoint. The next slice is 3c-4 (measured master protection), which requires user-observed hardware measurements and listening; it has not been started. The Phase 1 direct-file two-fixture restart and Phase 3 audible acceptance remain open — no sound has been heard from a production build. Workload measurements and release soak tests remain later explicit gates. +Phase 0 is complete, and the production runtime is implemented through Phase 3c slice 4 (master protection), with 102 automated tests passing. Slice 4's hardware measurement and listening acceptance remain pending; the [standalone audio acceptance page](prototypes/phase3/XZBT-audio-acceptance.html) and [run instructions](prototypes/phase3/README.md) are ready for user testing. The Phase 1 direct-file two-fixture restart and Phase 3 audible acceptance remain open — no sound has been heard from a production build. Combined workload measurements and release soak tests remain later explicit gates. The full PRD completion criteria remain the 0.1 release target. Early integrated demonstrations are milestones, not completed MVPs. Reference exhibits develop alongside the engine; Phase 9 completes and audits the suite. -All 10 Phase 0 direct-file feasibility checks in GC1 are verified, and the GC2–GC5 contract oracles pass. Phase 1 turns those contracts into a standalone runtime shell with import, caching, activation control, diagnostics, and production seeded RNG. Phase 2 adds parameters, state, signals, values, conditions, actions, bindings, transitions, overrides, and per-exhibit parameter persistence. Phases 3a and 3b add the audio graph contract and engine; Phase 3c slices 1–3 add the remaining audio contract, lifecycle/voice management, and automation. Master protection, visual, cadence, scenario, UI, library-hardening, benchmark, and soak gates remain explicit in the implementation plan. +All 10 Phase 0 direct-file feasibility checks in GC1 are verified, and the GC2–GC5 contract oracles pass. Phase 1 turns those contracts into a standalone runtime shell with import, caching, activation control, diagnostics, and production seeded RNG. Phase 2 adds parameters, state, signals, values, conditions, actions, bindings, transitions, overrides, and per-exhibit parameter persistence. Phases 3a and 3b add the audio graph contract and engine; Phase 3c adds the remaining audio contract, lifecycle/voice management, automation and master protection. Measured protection acceptance, visual, cadence, scenario, UI, library-hardening, benchmark, and soak gates remain explicit in the implementation plan. ## Repository configuration @@ -71,7 +71,7 @@ The active performance supports typed parameters and state, read-only runtime si The runtime implements recipe `release`, the seven-state lifecycle, determinable one-shot endings, voice ceilings, and graph-local `automation`. Tracks support `absolute`, `offset`, and `scale`, with `step`, `linear`, `exponential`, and `smooth` interpolation. Values are sampled once from the owning sound's seeded stream; duplicate targets and expanded limits are validated. Exposed component parameters participate without exposing component internals. Bus gains now use the shared binding → automation → override → modulation → clamp resolver. Bus automation/modulation registration is internal: the contract does not add document fields to buses or permit external node bindings/overrides. -Master protection is still the placeholder chain. No sound has yet been heard from a production build; measured protection, the audio acceptance challenge, real GC4 synchronization, peak/finite-sample capture, and listening observations remain open Phase 3 gates. Visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases. +Master protection now uses an engine-owned AudioWorklet limiter with finite-sample guards and output measurement support. `npm run build:audio-acceptance` builds the self-contained hardware capture page using the production engine and frozen stress/challenge fixtures. No sound has yet been heard from a production build; real-browser measured protection, the audio acceptance challenge, real GC4 synchronization and listening observations remain open Phase 3 gates. See the [slice-4 evidence](docs/evidence/phase3/2026-09-05-phase3c-protection.md). Visuals, cadence, events, scenarios, and the final schema-driven UI remain assigned to later phases. ## Local development server diff --git a/XZBT.html b/XZBT.html index e6d7df1..31c2348 100644 --- a/XZBT.html +++ b/XZBT.html @@ -554,6 +554,16 @@ class ConditionEvaluator { const AUDIO_STATIC_MAX_FREQUENCY = 24000; const AUDIO_NYQUIST_FACTOR = 0.45; +// Engine-owned candidate values; hardware/listening acceptance remains GC6. +const AUDIO_PROTECTION = Object.freeze({ + ceilingDb: -1, + toleranceDb: 0.1, + lookaheadMs: 5, + attackMs: 0.5, + releaseMs: 250, + channels: 2 +}); + const AUDIO_LIMITS = Object.freeze({ nodesPerSound: 128, routesPerSound: 256, @@ -784,6 +794,145 @@ function isSourceType(type) { return entry?.class === 'source' || entry?.class === 'control'; } +/* src/runtime/audio-protection.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. +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 }; + } +} + +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);`; +} + +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())}`); +} + +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 } + }); +} + /* src/runtime/audio-automation.js */ // Shared numeric stages and immutable, once-sampled automation (spec 8.1 / 16.1). @@ -2956,6 +3105,7 @@ class ActivationController { + function soundInstanceKey(soundId, ordinal) { return `${soundId}#${ordinal}`; } @@ -3508,7 +3658,10 @@ 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; } @@ -3617,6 +3770,13 @@ 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; } @@ -3627,11 +3787,11 @@ 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. 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; @@ -3651,6 +3811,13 @@ 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() { @@ -3658,35 +3825,123 @@ 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() { @@ -3694,14 +3949,14 @@ 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) { @@ -3713,6 +3968,7 @@ 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; } @@ -3830,12 +4086,16 @@ 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 */ } } diff --git a/docs/IMPLEMENTATION_STATUS.md b/docs/IMPLEMENTATION_STATUS.md index 516cd67..94c7ce6 100644 --- a/docs/IMPLEMENTATION_STATUS.md +++ b/docs/IMPLEMENTATION_STATUS.md @@ -1,10 +1,10 @@ # XZBT implementation status **Updated:** September 5, 2026 -**State:** Phase 3c slices 1–3 complete; stopped at the user-requested slice-3 checkpoint. Master protection (slice 4), the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending +**State:** Phase 3c slices 1–3 complete; slice 4 master protection implemented and automatically verified. Slice 4 hardware measurement/listening acceptance, the Phase 3 audible gates, and the Phase 1 direct-file import/restart observation remain pending **Planning baseline:** `05fe2b4e021ba86e4a290d05b63c7cae0e386128` -**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, Phase 2 common grammar, and audio engine through Phase 3c slice 3 pass automated checks. Phase 1's direct-file two-fixture restart observation, Phase 3's audible acceptance (no sound has been heard from a production build), and measured master protection remain open. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases. +**Exact demarcation:** GC1 direct-file feasibility (10/10 checks), GC2 shared format contracts, GC3 resolution semantics, GC4 clock/PRNG semantics, and GC5 ownership/failure semantics are complete at the Phase 0 contract-oracle level. The Phase 1 production runtime skeleton, Phase 2 common grammar, and audio engine through Phase 3c slice 4's implementation pass automated checks. Phase 1's direct-file two-fixture restart observation, Phase 3's audible acceptance (no sound has been heard from a production build), and real-browser measured master protection remain open. Visual, cadence/event, scenario, final generated-UI, performance, and soak work remains assigned to later phases. The user requested sequential implementation with a stop on problems. The [manual version 3 evidence](evidence/phase0/2026-09-04-user-run-v3.md) verifies embedded data-URL worklet loading in direct-file Chrome. The subsequent [user-performed restart test](evidence/phase0/2026-09-04-user-restart.md) restored Blue Study activity 0.37 and master volume 0.19 immediately on reopening. Native tone output and AudioContext suspend/resume are also observed. Ordinary file import, selection of both exhibits, regular Chrome mode, and [directory cancellation/denial fallback](evidence/phase0/2026-09-05-user-directory-fallback.md) have been confirmed. @@ -13,7 +13,7 @@ The user requested sequential implementation with a stop on problems. The [manua | 0 — Contracts and feasibility | Complete | GC1 passed; GC2–GC5 shared contracts and traces passed; GC6/GC7 later gates scheduled and mapped | | 1 — Runtime skeleton | Implemented; acceptance pending | [Automated evidence](evidence/phase1/2026-09-05-runtime-skeleton.md) passes production-module, lifecycle, PRNG-vector, cache/restore, fixture-validation, and deterministic-build tests. Direct-file two-fixture import/restart remains a user-observed gate. | | 2 — Common grammar | Complete | [Automated evidence](evidence/phase2/2026-09-05-common-grammar.md) covers production GC2 conformance, typed values, signals, actions, same-tick bindings, transitions, override precedence/release, and parameter restoration. | -| 3 — Audio engine | Slices 3c-1 (contract), 3c-2 (lifecycle/voices), and 3c-3 (automation) complete; stopped before slice 4 | [Audio authoring evidence](evidence/phase3/2026-09-05-audio-authoring-contract.md), [contract evidence](evidence/phase3/2026-09-05-phase3c-contract.md), [lifecycle/voice evidence](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md), and [automation evidence](evidence/phase3/2026-09-05-phase3c-automation.md). Slice 4 requires measured master protection and user listening; PRD 129 and real GC4 audio acceptance remain open. | +| 3 — Audio engine | Slices 3c-1–3 complete; 3c-4 implementation verified, user acceptance pending | [Contract](evidence/phase3/2026-09-05-phase3c-contract.md), [lifecycle/voices](evidence/phase3/2026-09-05-phase3c-lifecycle-voices.md), [automation](evidence/phase3/2026-09-05-phase3c-automation.md), and [protection evidence](evidence/phase3/2026-09-05-phase3c-protection.md). Slice 4 requires real-browser measurement and user listening; PRD 129 and real GC4 audio acceptance remain open. | | 4 — Visual engine | Not started | Earlier phases and visual contracts | | 5 — Events and cadence | Not started | Earlier phases and event/cadence contracts | | 6 — Scenario director | Not started | Earlier phases and scenario contracts | @@ -63,7 +63,7 @@ At the Phase 3a/3b checkpoint, automation, lifecycle/voices, and measured protec ## Phase 3c contract (slice 1) -Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation (done), and measured master protection (pending). Each slice ends green and committable. +Format Specification section 16 completes the audio subsystem contract. Phase 3c is delivered in four slices, recorded in the implementation plan: the contract (done), lifecycle and voices (done), automation (done), and measured master protection (implementation done; user acceptance pending). Each slice ends green and committable. Section 16 was contract only at slice 1. @@ -81,4 +81,12 @@ Slice 3c-3 implements section 16.1 graph-local tracks, all three modes and four `npm test` passes **88 tests**, including 21 new slice-3 tests. The self-contained build is deterministic; the artifact digest is `6aa1b659e1f06ca3175ac62a4544cc3342e8731f6d6f186c68fcd667cf683d97`. See the [slice-3 evidence](evidence/phase3/2026-09-05-phase3c-automation.md) for exact coverage, scheduling precision, and the test boundary. -**Stop:** Slice 3 is complete; wait for the user before starting slice 4. The master chain is unchanged. Measured protection, audible acceptance, and real GC4 synchronization are not claimed by the automated tests. +**Historical checkpoint:** Slice 3 ended with the master chain unchanged. The user subsequently authorized slice 4, recorded below. + +## Phase 3c master protection (slice 4) + +The placeholder compressor is replaced by an embedded AudioWorklet with stereo-linked lookahead limiting, a final sample clamp, and per-instance finite-sample guards. Every bus and master-volume gain precedes final protection. Nonfinite input mutes the complete mixed block and raises one warning per instance; measurement records repeat affected blocks. Initialization fails closed when protection is unavailable, and disposal owns all worklet nodes and ports. + +`npm test` passes **102 tests**, including 14 slice-4 tests. Production and acceptance builds are deterministic and self-contained. The [protection evidence](evidence/phase3/2026-09-05-phase3c-protection.md) records coverage and limits. A [standalone user-run page](../prototypes/phase3/XZBT-audio-acceptance.html) provides a frozen overlap workload, exact audio-frame capture, twelve challenge recipes and JSON export; see [run instructions](../prototypes/phase3/README.md). + +**Acceptance checkpoint:** Implementation is ready for user testing. The candidate −1 dBFS ceiling, 0.1 dB tolerance and audible release behavior remain provisional. No production audio has been heard or measured; traces 15–17, PRD 129, reference-exhibit listening and real GC4 synchronization remain open. Phase 3 is not accepted, and later phases have not started. diff --git a/docs/XZBT_0-1_Format_Specification.md b/docs/XZBT_0-1_Format_Specification.md index b484cd7..f00b9cd 100644 --- a/docs/XZBT_0-1_Format_Specification.md +++ b/docs/XZBT_0-1_Format_Specification.md @@ -1260,7 +1260,7 @@ This section closes the audio subsystem. It covers automation tracks and their p Sections 14 and 15 define what an exhibit may *declare*. This section defines what the runtime *does* with it over time. Where an earlier section deferred a rule to "Phase 3c", this section is the referent. -**Implementation status.** Phase 3c slices 1–3 are implemented: the runtime accepts graph-local `automation` and recipe `release`, implements lifecycle/voice management, and resolves automation before override and modulation. Slice 4 (measured master protection) and the user-observed audio acceptance gates remain open. See the implementation status and slice evidence; the current master chain is still a placeholder, not proof of the protection contract. +**Implementation status.** Phase 3c slices 1–3 and the slice-4 protection implementation pass automated checks. The runtime accepts graph-local `automation` and recipe `release`, implements lifecycle/voice management, resolves automation before override and modulation, and routes audio through an engine-owned finite-sample guard and final limiter. Slice 4's real-browser measurement and user-observed listening gates remain open. See the implementation status and slice evidence; the implemented limiter is not proof of the measured protection contract. ### 16.1 Automation tracks diff --git a/docs/evidence/phase3/2026-09-05-phase3c-protection.md b/docs/evidence/phase3/2026-09-05-phase3c-protection.md new file mode 100644 index 0000000..c593e76 --- /dev/null +++ b/docs/evidence/phase3/2026-09-05-phase3c-protection.md @@ -0,0 +1,27 @@ +# Phase 3c slice 4 — Master protection implementation + +Date: September 5, 2026. Contract: PRD 58, 118–120, 129 and Format Specification 16.7, 16.11. **Implementation and automated verification complete; hardware measurement and listening acceptance pending.** This supersedes the slice-3 stop for the newly authorized slice-4 work. + +## Runtime changes + +The placeholder compressor is replaced by an embedded AudioWorklet. The only audible route is voice → internal release gain → voice finite-sample guard → declared bus (when present) → master volume → final protection → destination. The engine creates these nodes; the document cannot target them, and `audio.master` remains rejected. + +The candidate settings are centralized in `AUDIO_PROTECTION`: −1 dBFS ceiling, 0.1 dB tolerance, 5 ms lookahead, 0.5 ms attack and 250 ms exponential gain-recovery time constant. A stereo-linked peak detector holds incoming peaks through the lookahead interval, smooths gain reduction/recovery, and a final hard sample clamp bounds attack overshoot and extreme overload. Stereo is explicitly two channels; quiet samples retain their values after the lookahead delay. Float32 rounding may put the mathematical ceiling a fraction above −1 dBFS, within the provisional tolerance. This algorithm and its numerical tests do not establish freedom from audible artifacts. + +A nonfinite sample anywhere in a voice guard mutes that entire stereo render quantum. A separate finite fault lane reaches the final master and mutes its entire mixed block, including healthy voices. The master also scans its summed input, and flushes delayed history when it mutes a block. Voice warnings use the production sound instance key; each guard and the final master warn once for their lifetime. The capture counts all affected blocks, including repeat faults after the first warning. + +Unlock resumes from the gesture, loads the embedded data-URL worklet, and exposes playable audio only after protected routing is ready. Concurrent unlocks share initialization. Unsupported/rejected worklet loading or resume fails silently with `WARN_AUDIO_UNAVAILABLE`; no unprotected fallback is connected. Disposal during loading cannot resurrect output. Processor failure disconnects output and rejects an active measurement. Guards, ports, master nodes and buses are disconnected/closed with their owners. + +## Verification + +`npm test`: **102 passed, 0 failed**, including 14 new slice-4 tests. The first sandboxed invocation could not spawn Node test workers (`EPERM`); the authorized escalated run passed. No browser or audio device was opened. + +The tests execute the exact serialized production worklet processor in a VM, exercising finite output and peak bounds at 8, 44.1, 48, 96 and 192 kHz under DC, alternating full-scale floating-point overload, sine overlap, transients and silence. They verify quiet stereo preservation and delay, monotonic release recovery, complete block muting for NaN and both infinities, once-per-instance warnings, fault-lane propagation, affected-block counts, capture-window boundaries, routing, async initialization failures/races, capture completion/disposal/failure, and missing AudioContext behavior (16.11 traces 12 and 14). + +Both new `.xzbt` fixtures validate and instantiate deterministically. The twelve challenge recipes cover the authored PRD 129 candidates. The overload fixture has finite transient ending bounds shorter than the 2-second burst cadence. Production and acceptance HTML builds are self-contained and deterministic; the acceptance build records the exact production and fixture hashes in its page and exported report. + +## User-run evidence still required + +[The standalone acceptance page](../../../prototypes/phase3/XZBT-audio-acceptance.html) and [run instructions](../../../prototypes/phase3/README.md) provide a frozen 16-continuous/64-one-shot workload, 30-second warmup, 120-second output capture, environment fields, actual burst/voice logs and JSON export. The meter measures the final worklet's Float32 samples before the destination. It cannot measure downstream browser/OS resampling or analog output. Counts apply to blocks intersecting the window; the sample peak covers exactly the requested frames. + +No production-build audio has been heard or measured in this work. The −1 dBFS / 0.1 dB values and audible release behavior remain provisional. Format Specification 16.11 traces 15–17, reference-exhibit listening, full PRD 129, real GC4 synchronization and the Phase 1 direct-file restart observation remain open. Slice 4 and Phase 3 are not accepted until the required user-performed evidence is reviewed. Later phases have not started. diff --git a/exhibits/audio-challenge.xzbt b/exhibits/audio-challenge.xzbt new file mode 100644 index 0000000..8cfd96a --- /dev/null +++ b/exhibits/audio-challenge.xzbt @@ -0,0 +1,667 @@ +{ + "xzbt": "0.1", + "meta": { + "id": "audio-challenge", + "name": "Audio Acceptance Challenge v1" + }, + "runtime": { + "seed": 42 + }, + "sounds": { + "chirp": { + "name": "01 · Electronic chirp", + "recipe": { + "mode": "oneshot", + "release": "200ms", + "nodes": { + "hit": { + "type": "impulse", + "duration": "160ms", + "color": "white" + }, + "ring": { + "type": "resonator", + "fundamental": 400, + "modes": [ + { + "ratio": 1, + "decay": "80ms" + } + ] + }, + "level": { + "type": "gain", + "gain": 0.4 + } + }, + "routes": [ + { + "from": "hit", + "to": "ring" + }, + { + "from": "ring", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ], + "automation": [ + { + "target": "ring.fundamental", + "interpolation": "exponential", + "points": [ + { + "at": "0ms", + "value": 400 + }, + { + "at": "160ms", + "value": 2400 + } + ] + } + ] + } + }, + "relay": { + "name": "02 · Relay / switch click", + "recipe": { + "mode": "oneshot", + "release": "200ms", + "nodes": { + "hit": { + "type": "impulse", + "duration": "3ms", + "color": "white" + }, + "shape": { + "type": "filter", + "mode": "highpass", + "frequency": 1300, + "q": 1 + }, + "level": { + "type": "gain", + "gain": 0.3 + } + }, + "routes": [ + { + "from": "hit", + "to": "shape" + }, + { + "from": "shape", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ] + } + }, + "siren": { + "name": "03 · Alarm / siren", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "tone": { + "type": "oscillator", + "frequency": 650, + "waveform": "triangle" + }, + "sweep": { + "type": "lfo", + "frequency": 0.7, + "polarity": "bipolar" + }, + "level": { + "type": "gain", + "gain": 0.12 + } + }, + "routes": [ + { + "from": "tone", + "to": "level" + }, + { + "from": "level", + "to": "output" + }, + { + "from": "sweep", + "to": "tone.frequency", + "depth": 250 + } + ] + } + }, + "airflow": { + "name": "04 · Airflow loop", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "air": { + "type": "noise", + "color": "pink" + }, + "shape": { + "type": "filter", + "mode": "lowpass", + "frequency": 1600, + "q": 0.7 + }, + "level": { + "type": "gain", + "gain": 0.25 + } + }, + "routes": [ + { + "from": "air", + "to": "shape" + }, + { + "from": "shape", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ] + } + }, + "machinery": { + "name": "05 · Machinery drone", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "low": { + "type": "oscillator", + "frequency": 65, + "waveform": "sawtooth" + }, + "high": { + "type": "oscillator", + "frequency": 130.7, + "waveform": "triangle" + }, + "shape": { + "type": "filter", + "mode": "lowpass", + "frequency": 400, + "q": 1 + }, + "level": { + "type": "gain", + "gain": 0.1 + } + }, + "routes": [ + { + "from": "high", + "to": "shape" + }, + { + "from": "low", + "to": "shape" + }, + { + "from": "shape", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ] + } + }, + "pressure": { + "name": "06 · Pressure release", + "recipe": { + "mode": "oneshot", + "release": "200ms", + "nodes": { + "hit": { + "type": "impulse", + "duration": "500ms", + "color": "white" + }, + "shape": { + "type": "filter", + "mode": "bandpass", + "frequency": 2000, + "q": 0.7 + }, + "level": { + "type": "gain", + "gain": 0.4 + } + }, + "routes": [ + { + "from": "hit", + "to": "shape" + }, + { + "from": "shape", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ], + "automation": [ + { + "target": "shape.frequency", + "interpolation": "linear", + "points": [ + { + "at": "0ms", + "value": 3500 + }, + { + "at": "500ms", + "value": 500 + } + ] + } + ] + } + }, + "groan": { + "name": "07 · Structural groan", + "recipe": { + "mode": "oneshot", + "release": "200ms", + "nodes": { + "hit": { + "type": "impulse", + "duration": "400ms", + "color": "brown" + }, + "body": { + "type": "resonator", + "fundamental": 95, + "modes": [ + { + "ratio": 1, + "decay": "2s" + }, + { + "ratio": 1.43, + "gain": 0.5, + "decay": "1500ms" + }, + { + "ratio": 2.71, + "gain": 0.2, + "decay": "1s" + } + ] + }, + "level": { + "type": "gain", + "gain": 0.4 + } + }, + "routes": [ + { + "from": "hit", + "to": "body" + }, + { + "from": "body", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ], + "automation": [ + { + "target": "body.fundamental", + "interpolation": "linear", + "points": [ + { + "at": "0ms", + "value": 95 + }, + { + "at": "2s", + "value": 55 + } + ] + } + ] + } + }, + "electrical": { + "name": "08 · Electrical buzz / arcing", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "buzz": { + "type": "oscillator", + "frequency": 120, + "waveform": "sawtooth" + }, + "arc": { + "type": "noise", + "color": "white" + }, + "shape": { + "type": "filter", + "mode": "highpass", + "frequency": 1500, + "q": 1 + }, + "steps": { + "type": "sample-hold", + "rate": 14, + "min": 0, + "max": 1, + "slew": "5ms" + }, + "level": { + "type": "gain", + "gain": 0.035 + } + }, + "routes": [ + { + "from": "arc", + "to": "shape" + }, + { + "from": "shape", + "to": "level" + }, + { + "from": "buzz", + "to": "level" + }, + { + "from": "level", + "to": "output" + }, + { + "from": "steps", + "to": "level.gain", + "depth": 0.06 + } + ] + } + }, + "impact": { + "name": "09 · Impact transient", + "recipe": { + "mode": "oneshot", + "release": "200ms", + "nodes": { + "hit": { + "type": "impulse", + "duration": "12ms", + "color": "white" + }, + "body": { + "type": "resonator", + "fundamental": 110, + "modes": [ + { + "ratio": 1, + "decay": "600ms" + }, + { + "ratio": 2.7, + "gain": 0.4, + "decay": "200ms" + } + ] + }, + "level": { + "type": "gain", + "gain": 0.5 + } + }, + "routes": [ + { + "from": "hit", + "to": "body" + }, + { + "from": "body", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ] + } + }, + "sequence": { + "name": "10 · Tonal sequence", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "tone": { + "type": "oscillator", + "frequency": 261.63, + "waveform": "sine" + }, + "level": { + "type": "gain", + "gain": 0.13 + } + }, + "routes": [ + { + "from": "tone", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ], + "automation": [ + { + "target": "tone.frequency", + "interpolation": "step", + "points": [ + { + "at": "0ms", + "value": 261.63 + }, + { + "at": "300ms", + "value": 329.63 + }, + { + "at": "600ms", + "value": 392 + }, + { + "at": "900ms", + "value": 523.25 + } + ] + }, + { + "target": "level.gain", + "interpolation": "linear", + "points": [ + { + "at": "0ms", + "value": 0 + }, + { + "at": "15ms", + "value": 0.13 + }, + { + "at": "1100ms", + "value": 0.13 + }, + { + "at": "1200ms", + "value": 0 + } + ] + } + ] + } + }, + "breathing": { + "name": "11 · Organic pulse / breathing", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "air": { + "type": "noise", + "color": "pink" + }, + "shape": { + "type": "filter", + "mode": "lowpass", + "frequency": 900, + "q": 0.7 + }, + "breath": { + "type": "lfo", + "frequency": 0.22, + "polarity": "unipolar" + }, + "level": { + "type": "gain", + "gain": 0.02 + } + }, + "routes": [ + { + "from": "air", + "to": "shape" + }, + { + "from": "shape", + "to": "level" + }, + { + "from": "level", + "to": "output" + }, + { + "from": "breath", + "to": "level.gain", + "depth": 0.3 + } + ] + } + }, + "atmosphere": { + "name": "12 · Evolving atmospheric texture", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "air": { + "type": "noise", + "color": "brown" + }, + "tone": { + "type": "oscillator", + "frequency": 180, + "waveform": "sine" + }, + "shape": { + "type": "filter", + "mode": "bandpass", + "frequency": 400, + "q": 0.6 + }, + "space": { + "type": "reverb", + "decay": "4s", + "mix": 0.6 + }, + "level": { + "type": "gain", + "gain": 0.15 + } + }, + "routes": [ + { + "from": "tone", + "to": "shape" + }, + { + "from": "air", + "to": "shape" + }, + { + "from": "shape", + "to": "space" + }, + { + "from": "space", + "to": "level" + }, + { + "from": "level", + "to": "output" + } + ], + "automation": [ + { + "target": "shape.frequency", + "interpolation": "smooth", + "points": [ + { + "at": "0ms", + "value": 250 + }, + { + "at": "10s", + "value": 2000 + }, + { + "at": "25s", + "value": 450 + } + ] + }, + { + "target": "tone.frequency", + "interpolation": "smooth", + "points": [ + { + "at": "0ms", + "value": 180 + }, + { + "at": "25s", + "value": 270 + } + ] + } + ] + } + } + } +} diff --git a/exhibits/audio-protection-stress.xzbt b/exhibits/audio-protection-stress.xzbt new file mode 100644 index 0000000..980a29e --- /dev/null +++ b/exhibits/audio-protection-stress.xzbt @@ -0,0 +1,87 @@ +{ + "xzbt": "0.1", + "meta": { + "id": "audio-protection-stress", + "name": "Master Protection Overlap v1" + }, + "runtime": { + "seed": 42 + }, + "audio": { + "buses": { + "load": { + "gain": 4 + } + } + }, + "sounds": { + "bed": { + "name": "Continuous overload", + "recipe": { + "mode": "continuous", + "release": "200ms", + "nodes": { + "tone": { + "type": "oscillator", + "frequency": 110, + "waveform": "square" + }, + "boost": { + "type": "gain", + "gain": 4 + } + }, + "routes": [ + { + "from": "tone", + "to": "boost" + }, + { + "from": "boost", + "to": "output" + } + ] + }, + "bus": "load" + }, + "hit": { + "name": "Overlapping transient", + "recipe": { + "mode": "oneshot", + "release": "200ms", + "nodes": { + "hit": { + "type": "impulse", + "duration": "500ms", + "color": "white" + }, + "boost": { + "type": "gain", + "gain": 4 + }, + "echo": { + "type": "delay", + "time": "50ms", + "feedback": 0.2, + "mix": 0.5 + } + }, + "routes": [ + { + "from": "hit", + "to": "boost" + }, + { + "from": "boost", + "to": "echo" + }, + { + "from": "echo", + "to": "output" + } + ] + }, + "bus": "load" + } + } +} diff --git a/package.json b/package.json index 3c7d023..624282e 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,12 @@ }, "scripts": { "build": "node tools/build-xzbt.mjs", + "build:audio-acceptance": "node tools/build-audio-acceptance.mjs", "test:phase1": "node test/phase1-runtime.test.mjs", "test:phase2": "node test/phase2-common-grammar.test.mjs", "test:phase3": "node --test test/phase3-*.test.mjs", "test:phase3c3": "node test/phase3-automation.test.mjs", + "test:phase3c4": "node test/phase3-protection.test.mjs", "test": "node --test test/*.test.mjs" } } diff --git a/prototypes/phase3/README.md b/prototypes/phase3/README.md new file mode 100644 index 0000000..ef9ade4 --- /dev/null +++ b/prototypes/phase3/README.md @@ -0,0 +1,17 @@ +# Phase 3c slice 4: user-run audio acceptance + +Open [XZBT-audio-acceptance.html](XZBT-audio-acceptance.html) directly from disk in Chrome. It is self-contained and embeds the same audio runtime and protection processor as the production `XZBT.html`. Rebuild both with `npm run build:audio-acceptance` after runtime or fixture changes. + +1. Record the reference computer, CPU, GPU/driver, RAM, OS and exact browser version, power mode, output device and OS volume. Use a 1920 × 1080 viewport. The report adds viewport, DPR, sample rate, latency, fixture hashes and production artifact SHA-256. +2. Lower the **device** volume before the overlap run. The engine master stays at 1 and the declared stress bus stays at 4 to exercise protection. Click **Run 150-second peak capture** and keep the page visible. The workload is `phase3c4-overlap-v1`, seed 42: 16 continuous square-wave voices, each boosted by 4, plus 64 noise transients with delay every 2 seconds. There are 30 seconds of warmup followed by 120 seconds of measured audio frames. Changing visibility or cancelling invalidates the run. Repeated bursts dispose completed voices through the production voice-limit policy; those warnings are expected. +3. Check that the output is audible, the peak is nonzero and at most **−0.9 dBFS** (candidate ceiling −1 dBFS plus 0.1 dB tolerance), and affected-block/nonfinite counts are zero. Review actual burst timestamps and voice counts, and note glitches or missed bursts. An interrupted run or missing workload is not acceptable evidence even if its numeric peak passes. +4. Listen to all twelve challenge buttons. Stop or release continuous voices between examples as needed. Record each sound's recognizability and any clicks, clipping or pumping. Layer sounds, release them, and exercise pause/resume. The numbered recipes map directly to PRD 129. These are authored candidates; validation is not listening acceptance. +5. Download the JSON report after entering observations. Supply the report for review and record it under `docs/evidence/phase3/` with the actual run date. Do not mark GC6 or Phase 3 accepted solely because the report's `candidateDigitalPeakPass` is true. + +The peak tap is the final protection worklet's Float32 output, after every engine gain, immediately before `AudioContext.destination`. It is a digital sample-peak measurement, not a capture after browser/OS resampling, an intersample true-peak measurement, or an analog-device measurement. Record output-device and listening observations separately. The ceiling/tolerance remain provisional until the evidence is reviewed. + +The capture records peak over exactly the requested frames. Affected-block, nonfinite-sample and clamp counters include every render block intersecting that window (including a partial first/last block). Nonfinite counts include voice-guard detections and detections in the summed master input. Warnings occur once per voice guard/master processor; repeated affected blocks are still counted. The fixed normal workload contains no intentional nonfinite injection; fault injection is covered by the automated worklet tests. + +The remaining Format Specification 16.11 traces 15–17 also require production reference Exhibits A/B, lifecycle eviction/unlock/pause observations, and full PRD 129 listening. Import `exhibits/audio-challenge.xzbt` into production `XZBT.html` to audition the same recipes there. The probe's manual pause/resume and overlapping voices do not implement or certify GC4 current-logical-time alignment. Those gates and Phase 1's direct-file two-fixture restart observation remain open. + +No agent-controlled browser or hardware audio run was used to prepare this page. The recorded user-performed browser test boundary remains in force. diff --git a/prototypes/phase3/XZBT-audio-acceptance.html b/prototypes/phase3/XZBT-audio-acceptance.html new file mode 100644 index 0000000..c84ff34 --- /dev/null +++ b/prototypes/phase3/XZBT-audio-acceptance.html @@ -0,0 +1,4867 @@ + +XZBT audio acceptance + +

Audio acceptance · Phase 3c slice 4

+

This page embeds the production audio engine and frozen exhibit fixtures. Open it directly from disk in Chrome. Results measure samples at the final worklet output; device playback and listening must be recorded separately.

+

Ready. Audio starts only when you press a button.

+

1. Record the environment

+

Use a 1920 × 1080 viewport for GC6. Start with a low device volume: the overlap test deliberately drives the engine at full master and bus gain.

+ + +
+

2. Capture overlapping recipes

+

16 continuous voices and bursts of 64 one-shots. Warm up for 30 seconds, then capture 120 seconds on the audio clock. Keep this page visible. The report includes actual burst times and voice counts; any interrupted run is invalid.

+ +
No measurement yet.
+
+

3. Listen to the audio challenge

+

Each button plays a declarative recipe from audio-challenge.xzbt. Layer continuous sounds, then release them; listen for clicks, clipping and pumping. The gain recovery time constant is 250 ms, with 5 ms lookahead. These are candidate settings awaiting listening acceptance.

+
+ + +

The reference exhibits and current-logical-time GC4 unlock checks require their own production runs. This capture page does not certify them.

+
+

4. Save the evidence

+ diff --git a/prototypes/phase3/acceptance.template.html b/prototypes/phase3/acceptance.template.html new file mode 100644 index 0000000..d781048 --- /dev/null +++ b/prototypes/phase3/acceptance.template.html @@ -0,0 +1,32 @@ + +XZBT audio acceptance + +

Audio acceptance · Phase 3c slice 4

+

This page embeds the production audio engine and frozen exhibit fixtures. Open it directly from disk in Chrome. Results measure samples at the final worklet output; device playback and listening must be recorded separately.

+

Ready. Audio starts only when you press a button.

+

1. Record the environment

+

Use a 1920 × 1080 viewport for GC6. Start with a low device volume: the overlap test deliberately drives the engine at full master and bus gain.

+ + +
+

2. Capture overlapping recipes

+

16 continuous voices and bursts of 64 one-shots. Warm up for 30 seconds, then capture 120 seconds on the audio clock. Keep this page visible. The report includes actual burst times and voice counts; any interrupted run is invalid.

+ +
No measurement yet.
+
+

3. Listen to the audio challenge

+

Each button plays a declarative recipe from audio-challenge.xzbt. Layer continuous sounds, then release them; listen for clicks, clipping and pumping. The gain recovery time constant is 250 ms, with 5 ms lookahead. These are candidate settings awaiting listening acceptance.

+
+ + +

The reference exhibits and current-logical-time GC4 unlock checks require their own production runs. This capture page does not certify them.

+
+

4. Save the evidence

+ diff --git a/src/runtime/audio-contract.js b/src/runtime/audio-contract.js index f99a336..e34459a 100644 --- a/src/runtime/audio-contract.js +++ b/src/runtime/audio-contract.js @@ -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, diff --git a/src/runtime/audio-engine.js b/src/runtime/audio-engine.js index d84d725..4d8b113 100644 --- a/src/runtime/audio-engine.js +++ b/src/runtime/audio-engine.js @@ -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 */ } } diff --git a/src/runtime/audio-protection.js b/src/runtime/audio-protection.js new file mode 100644 index 0000000..9a43b36 --- /dev/null +++ b/src/runtime/audio-protection.js @@ -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 } + }); +} diff --git a/test/helpers/protection-fake.mjs b/test/helpers/protection-fake.mjs new file mode 100644 index 0000000..3478127 --- /dev/null +++ b/test/helpers/protection-fake.mjs @@ -0,0 +1,10 @@ +// Recording tests inject this explicitly. Production never substitutes a gain for +// protection when AudioWorklet is missing; DSP tests execute the real processor. +export const fakeProtectionFactory = { + async load() {}, + create(context) { + const node = context.createGain(); + node.port = { onmessage: null, postMessage() {}, close() {} }; + return node; + } +}; diff --git a/test/phase3-audio.test.mjs b/test/phase3-audio.test.mjs index 1e67ceb..0c34125 100644 --- a/test/phase3-audio.test.mjs +++ b/test/phase3-audio.test.mjs @@ -1,3 +1,4 @@ +import { fakeProtectionFactory } from './helpers/protection-fake.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; import { @@ -485,7 +486,7 @@ test('every node type realizes against an AudioContext stand-in and disposes cle voice.dispose(); assert.equal(context.log.stopped, context.log.started); - const subsystem = new AudioSubsystem({ document, rng: new SeededRNG(42), contextFactory: () => mockContext() }); + const subsystem = new AudioSubsystem({ document, rng: new SeededRNG(42), protectionFactory: fakeProtectionFactory, contextFactory: () => mockContext() }); await subsystem.unlock(); const handle = subsystem.play('probe'); assert.equal(handle.soundId, 'probe'); @@ -867,7 +868,7 @@ test('trace 10: eviction order is followed at ceiling, WARN_VOICE_LIMIT raised, const subsystem = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), - contextFactory: () => mockContext(), + protectionFactory: fakeProtectionFactory, contextFactory: () => mockContext(), diagnostics, voiceLimits: limits }); @@ -954,7 +955,7 @@ test('trace 11: disposal releases all nodes/connections/buffers, and a FINISHED const subsystem = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), - contextFactory: () => context, + protectionFactory: fakeProtectionFactory, contextFactory: () => context, voiceLimits: { oneshot: 2, continuous: 2 } }); await subsystem.unlock(); diff --git a/test/phase3-automation.test.mjs b/test/phase3-automation.test.mjs index 281e49c..3a158e0 100644 --- a/test/phase3-automation.test.mjs +++ b/test/phase3-automation.test.mjs @@ -1,3 +1,4 @@ +import { fakeProtectionFactory } from './helpers/protection-fake.mjs'; import assert from 'node:assert/strict'; import test from 'node:test'; import { readFileSync } from 'node:fs'; @@ -174,7 +175,7 @@ test('trace 8: bus binding -> automation -> override -> modulation -> clamp, inc doc.parameters = { amount: { type: 'number', default: 1, min: 0, max: 10 } }; doc.bindings = [{ source: 'parameters.amount', target: 'audio.buses.main.gain', scale: 2 }]; const engine = new ResolutionEngine(doc, new SeededRNG(42)); - const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), resolutionEngine: engine, contextFactory: recordingContext }); + const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), resolutionEngine: engine, protectionFactory: fakeProtectionFactory, contextFactory: recordingContext }); await audio.unlock(); const path = 'audio.buses.main.gain'; const remove = engine.addAutomation(path, track(path, [0.5, 1.5], { mode: 'scale' })); @@ -363,7 +364,7 @@ test('automation extends determinable delay tails without mutating sampled base test('trace 11: disposal and scheduler failure cancel automation and release all voice resources', async () => { const doc = fixture([track(undefined, [100, 400], { interpolation: 'smooth' })]); const context = recordingContext(); - const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), contextFactory: () => context }); + const audio = new AudioSubsystem({ document: doc, rng: new SeededRNG(42), protectionFactory: fakeProtectionFactory, contextFactory: () => context }); await audio.unlock(); const voice = audio.play('probe'); voice.dispose(); diff --git a/test/phase3-protection.test.mjs b/test/phase3-protection.test.mjs new file mode 100644 index 0000000..3581cdb --- /dev/null +++ b/test/phase3-protection.test.mjs @@ -0,0 +1,260 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import vm from 'node:vm'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { AUDIO_PROTECTION } from '../src/runtime/audio-contract.js'; +import { MasterProtectionDSP, protectionWorkletSource, loadProtectionWorklet, createProtectionNode } from '../src/runtime/audio-protection.js'; +import { AudioSubsystem, instantiateSoundGraph } from '../src/runtime/audio-engine.js'; +import { validateExhibit } from '../src/runtime/validator.js'; +import { buildAudioAcceptance } from '../tools/build-audio-acceptance.mjs'; +import { Diagnostics } from '../src/runtime/diagnostics.js'; +import { SeededRNG } from '../src/runtime/rng.js'; + +const block = (value = 0, length = 128) => [new Float32Array(length).fill(value), new Float32Array(length).fill(value)]; +const limit = 10 ** ((AUDIO_PROTECTION.ceilingDb + AUDIO_PROTECTION.toleranceDb) / 20); +function processor(rate = 48000, guard = false) { + let Processor; + vm.runInNewContext(protectionWorkletSource(), { + sampleRate: rate, + AudioWorkletProcessor: class { constructor() { this.messages = []; this.port = { postMessage: data => this.messages.push(data) }; } }, + registerProcessor(name, implementation) { assert.equal(name, 'xzbt-protection'); Processor = implementation; } + }); + return new Processor({ processorOptions: { guard } }); +} + +test('the shipped worklet bounds overload, DC, alternating peaks, transients and silence at multiple sample rates', () => { + for (const rate of [8000, 44100, 48000, 96000, 192000]) { + const p = processor(rate), output = block(); + let observed = 0; + for (let n = 0; n < 300; n++) { + const input = block(); + for (let i = 0; i < 128; i++) { + const t = (n * 128 + i) / rate; + input[0][i] = n < 50 ? 0 : n < 100 ? 1024 : n < 150 ? 4096 * Math.sin(2 * Math.PI * 440 * t) : n < 200 ? (i % 2 ? -3.4e38 : 3.4e38) : n < 250 ? (i === 63 ? 10000 : 0.1) : 0; + input[1][i] = input[0][i] * -0.5; + } + assert.equal(p.process([input, []], [output]), true); + for (const channel of output) for (const sample of channel) { + assert.ok(Number.isFinite(sample)); + assert.ok(Math.abs(sample) <= limit); + observed = Math.max(observed, Math.abs(sample)); + } + } + assert.ok(observed > 0.8, `rate ${rate} must produce audio, not silently pass`); + } +}); + +test('quiet stereo audio retains its samples and polarity after exactly the lookahead delay', () => { + const dsp = new MasterProtectionDSP(48000, AUDIO_PROTECTION); + const input = block(0, 1024), output = block(0, 1024); + for (let i = 0; i < 1024; i++) { input[0][i] = Math.sin(i) * 0.2; input[1][i] = input[0][i] * -0.5; } + dsp.process(input, output); + for (let c = 0; c < 2; c++) for (let i = 0; i < 1024; i++) assert.equal(output[c][i], i < dsp.delayFrames ? 0 : input[c][i - dsp.delayFrames]); +}); + +test('gain recovers monotonically with the configured release time constant, shared across stereo', () => { + const dsp = new MasterProtectionDSP(48000, AUDIO_PROTECTION), output = block(); + for (let i = 0; i < 200; i++) dsp.process(block(100), output); + let previous = dsp.gain; + for (let i = 0; i < 400; i++) { + dsp.process(block(0.1), output); + assert.ok(dsp.gain >= previous - 1e-12); + assert.ok(dsp.gain <= 1); + previous = dsp.gain; + } + assert.ok(dsp.gain > 0.98); + assert.deepEqual(output[0], output[1]); +}); + +test('trace 12: nonfinite at the master mutes the entire stereo block, flushes history and warns once', () => { + const p = processor(), output = block(); + p.process([block(0.3)], [output]); + for (const invalid of [NaN, Infinity, -Infinity]) { + const input = block(0.3); input[1][127] = invalid; + p.process([input], [output]); + assert.ok(output.every(channel => channel.every(value => value === 0))); + } + assert.equal(p.messages.filter(message => message.type === 'nonfinite').length, 1); + assert.equal(p.dsp.affectedBlocks, 3); + p.process([block()], [output]); + assert.ok(output.every(channel => channel.every(value => value === 0))); +}); + +test('voice guards attribute once per instance and a fault mutes the mixed output even with healthy voices', () => { + const master = processor(), guards = [processor(48000, true), processor(48000, true)]; + master.port.onmessage({ data: { type: 'capture', id: 1, warmupFrames: 0, frames: 384 } }); + for (let b = 0; b < 3; b++) { + const fault = block(0, 128), output = block(); + for (const guard of guards) { + const input = block(0.1), guarded = block(), lane = [new Float32Array(128)]; + input[0][127] = NaN; + guard.process([input], [guarded, lane]); + assert.ok(guarded.every(channel => channel.every(value => value === 0))); + for (let i = 0; i < 128; i++) fault[0][i] += lane[0][i]; + } + master.process([block(10), fault], [output]); + assert.ok(output.every(channel => channel.every(value => value === 0))); + } + for (const guard of guards) assert.equal(guard.messages.length, 1); + const capture = master.messages.find(message => message.type === 'capture'); + assert.equal(capture.affectedBlocks, 3); + assert.equal(capture.nonfiniteSamples, 6); + assert.equal(master.messages.filter(message => message.type === 'nonfinite').length, 0); +}); + +test('capture excludes warmup and measures exact output frames including partial boundary blocks', () => { + const p = processor(), output = block(); + p.port.onmessage({ data: { type: 'capture', id: 12, warmupFrames: 300, frames: 257 } }); + for (let n = 0; n < 4; n++) p.process([block(0.2)], [output]); + assert.equal(p.messages.length, 0); + p.process([block(0.2)], [output]); + const result = p.messages[0]; + assert.equal(result.id, 12); + assert.equal(result.frames, 257); + assert.equal(result.peak, Math.fround(0.2)); + assert.equal(result.affectedBlocks, 0); +}); + +function recordingSetup(load = async () => {}) { + const nodes = []; + const node = kind => { + const result = { kind, connections: [], gain: { value: 1 }, port: { messages: [], postMessage(data) { this.messages.push(data); }, close() { this.closed = true; } }, + connect(target, output = 0, input = 0) { this.connections.push({ target, output, input }); return target; }, + disconnect() { this.connections = []; this.disconnected = true; } }; + nodes.push(result); return result; + }; + const context = { state: 'suspended', sampleRate: 48000, currentTime: 0, destination: node('destination'), + createGain: () => node('gain'), async resume() { this.state = 'running'; }, async close() { this.state = 'closed'; } }; + const diagnostics = new Diagnostics(); + const audio = new AudioSubsystem({ document: { audio: { buses: { bed: { gain: 1 } } } }, rng: new SeededRNG(42), diagnostics, + contextFactory: () => context, protectionFactory: { load, create: (ctx, guard) => node(guard ? 'guard' : 'master-protection') } }); + return { audio, context, diagnostics, nodes }; +} + +test('all buses and direct sounds route through volume then final protection; guard fault lane is separate', async () => { + const { audio, context, nodes } = recordingSetup(); + assert.equal(await audio.unlock(), true); + assert.equal(audio.busFor('direct'), audio.master); + assert.equal(audio.buses.get('bed').connections[0].target, audio.master); + assert.equal(audio.master.connections[0].target, audio.protection); + const outputEdges = nodes.flatMap(node => node.connections.map(edge => ({ node, ...edge }))).filter(edge => edge.target === context.destination); + assert.equal(outputEdges.length, 1); + assert.equal(outputEdges[0].node, audio.protection); + const guard = audio.createVoiceGuard({ soundId: 'probe', creationOrder: 7 }); + assert.deepEqual(guard.connections[0], { target: audio.protection, output: 1, input: 1 }); + guard.port.onmessage({ data: { type: 'nonfinite' } }); + assert.equal(audio.diagnostics.list()[0].objectId, 'probe#7'); + guard.disconnect(); guard.port.close(); + audio.setMasterVolume(NaN); + assert.equal(audio.master.gain.value, 0.8); + await audio.dispose(); + assert.ok(nodes.filter(node => node.kind !== 'destination').every(node => node.disconnected)); +}); + +test('missing worklets and rejected resume/load fail closed, warn once, and create no voice', async () => { + for (const failure of ['load', 'resume']) { + const { audio, context, diagnostics, nodes } = recordingSetup(async () => { if (failure === 'load') throw new Error('load rejected'); }); + if (failure === 'resume') context.resume = async () => { throw new Error('resume rejected'); }; + assert.equal(await audio.unlock(), false); + assert.equal(await audio.unlock(), false); + assert.equal(audio.play('probe'), null); + assert.equal(audio.unlocked, false); + assert.equal(diagnostics.list().filter(entry => entry.code === 'WARN_AUDIO_UNAVAILABLE').length, 1); + assert.ok(nodes.every(node => node.connections.length === 0)); + await audio.dispose(); + } + await assert.rejects(loadProtectionWorklet({}), /unavailable/); +}); + +test('concurrent unlocks share initialization; disposal during module loading cannot resurrect output', async () => { + let finish, loads = 0; + const pending = new Promise(resolve => { finish = resolve; }); + const { audio, nodes } = recordingSetup(() => { loads++; return pending; }); + const first = audio.unlock(), second = audio.unlock(); + assert.equal(audio.unlocked, false); + assert.equal(loads, 1); + await audio.dispose(); finish(); + assert.deepEqual(await Promise.all([first, second]), [false, false]); + assert.equal(nodes.filter(node => node.kind === 'master-protection').length, 0); +}); + +test('capture resolves only its own result, prevents overlap, and rejects on disposal or processor failure', async () => { + for (const ending of ['complete', 'dispose', 'error']) { + const { audio } = recordingSetup(); + await audio.unlock(); + const pending = audio.captureOutput({ seconds: 1, warmupSeconds: 0 }); + await assert.rejects(audio.captureOutput(), /already running/); + const request = audio.protection.port.messages[0]; + assert.equal(request.frames, 48000); + if (ending === 'complete') { + audio.protection.port.onmessage({ data: { type: 'capture', id: request.id + 1 } }); + assert.ok(audio.capturePending); + audio.protection.port.onmessage({ data: { type: 'capture', id: request.id, peak: 0.8 } }); + assert.equal((await pending).peak, 0.8); + } else { + const rejected = assert.rejects(pending, /disposed|failed/); + if (ending === 'dispose') await audio.dispose(); + else audio.protection.onprocessorerror(); + await rejected; + assert.equal(audio.unlocked, false); + } + await audio.dispose(); + } +}); + +test('production loader embeds the exact processor and constructs explicit stereo worklets', async () => { + const previous = globalThis.AudioWorkletNode; + const calls = []; + try { + globalThis.AudioWorkletNode = class { constructor(...args) { calls.push(args); } }; + const context = { audioWorklet: { async addModule(url) { + assert.equal(decodeURIComponent(url.split(',').slice(1).join(',')), protectionWorkletSource()); + } } }; + await loadProtectionWorklet(context); + createProtectionNode(context); createProtectionNode(context, true); + assert.deepEqual(calls[0][2].outputChannelCount, [2]); + assert.equal(calls[0][2].numberOfInputs, 2); + assert.deepEqual(calls[1][2].outputChannelCount, [2, 1]); + } finally { globalThis.AudioWorkletNode = previous; } +}); + +test('trace 14: absent AudioContext stays silent, creates no instance, and warns once', async () => { + const diagnostics = new Diagnostics(); + const audio = new AudioSubsystem({ document: {}, rng: new SeededRNG(42), diagnostics }); + assert.equal(await audio.unlock(), false); + assert.equal(await audio.unlock(), false); + assert.equal(audio.play('probe'), null); + assert.equal(audio.voices.size, 0); + assert.equal(diagnostics.list().length, 1); + assert.equal(diagnostics.list()[0].code, 'WARN_AUDIO_UNAVAILABLE'); + await audio.dispose(); +}); + +test('the twelve challenge recipes and frozen overload workload validate and instantiate deterministically', () => { + for (const file of ['audio-challenge', 'audio-protection-stress']) { + const document = JSON.parse(readFileSync(`exhibits/${file}.xzbt`, 'utf8')); + assert.deepEqual(validateExhibit(document).errors, []); + if (file === 'audio-challenge') assert.equal(Object.keys(document.sounds).length, 12); + for (const [id, sound] of Object.entries(document.sounds)) { + const plan = instantiateSoundGraph(document, id, { rng: new SeededRNG(42) }); + assert.deepEqual(plan.errors, [], id); + assert.deepEqual(plan, instantiateSoundGraph(document, id, { rng: new SeededRNG(42) })); + if (sound.recipe.mode === 'oneshot') assert.ok(Number.isFinite(plan.endingBoundMs)); + if (file === 'audio-protection-stress' && id === 'hit') assert.ok(plan.endingBoundMs < 2000); + } + } +}); + +test('the direct-file acceptance build is deterministic, self-contained, and embeds the production processor', () => { + const directory = mkdtempSync(join(tmpdir(), 'xzbt-protection-')); + const first = buildAudioAcceptance(join(directory, 'first.html')); + const second = buildAudioAcceptance(join(directory, 'second.html')); + assert.equal(first.sha256, second.sha256); + const html = readFileSync(first.outputPath, 'utf8'); + assert.doesNotMatch(html, /<(script|link)[^>]+(?:src|href)=/i); + assert.ok(html.includes(MasterProtectionDSP.toString())); + const source = html.match(/