feat(audio): implement phase 3c master protection
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
// Bundled by build-audio-acceptance.mjs with the production runtime and fixtures.
|
||||
const acceptanceElement = id => document.getElementById(id);
|
||||
let acceptanceAudio = null, acceptanceTimer = null, acceptanceRunning = false;
|
||||
let acceptanceMeasurement = null, acceptanceDiagnostics = null;
|
||||
const acceptanceListeningLog = [];
|
||||
const acceptanceStatus = message => { acceptanceElement('state').textContent = message; };
|
||||
function acceptanceLock(running) {
|
||||
acceptanceRunning = running;
|
||||
acceptanceElement('capture').disabled = running;
|
||||
for (const button of acceptanceElement('sounds').querySelectorAll('button')) button.disabled = running;
|
||||
for (const id of ['release', 'pause', 'resume']) acceptanceElement(id).disabled = running;
|
||||
}
|
||||
async function acceptanceStop() {
|
||||
clearInterval(acceptanceTimer); acceptanceTimer = null;
|
||||
const previous = acceptanceAudio; acceptanceAudio = null;
|
||||
if (previous?.context) previous.context.onstatechange = null;
|
||||
await previous?.dispose();
|
||||
}
|
||||
function acceptanceMakeAudio(fixture) {
|
||||
const result = validateExhibit(fixture);
|
||||
if (!result.valid) throw new Error(JSON.stringify(result.errors));
|
||||
acceptanceDiagnostics = new Diagnostics();
|
||||
const audio = new AudioSubsystem({ document: fixture, rng: new SeededRNG(42), diagnostics: acceptanceDiagnostics });
|
||||
audio.setMasterVolume(1);
|
||||
acceptanceAudio = audio;
|
||||
return audio;
|
||||
}
|
||||
function acceptanceEnvironment() {
|
||||
return {
|
||||
notes: acceptanceElement('environment').value,
|
||||
userAgent: navigator.userAgent,
|
||||
viewport: { width: innerWidth, height: innerHeight },
|
||||
devicePixelRatio, hardwareConcurrency: navigator.hardwareConcurrency,
|
||||
deviceMemoryGiB: navigator.deviceMemory ?? null,
|
||||
protocol: location.protocol
|
||||
};
|
||||
}
|
||||
acceptanceElement('build').textContent = JSON.stringify(ACCEPTANCE_BUILD, null, 2);
|
||||
acceptanceElement('capture').onclick = async () => {
|
||||
acceptanceLock(true);
|
||||
acceptanceMeasurement = null;
|
||||
acceptanceElement('measurement').textContent = 'Initializing protected audio…';
|
||||
await acceptanceStop();
|
||||
let audio;
|
||||
try {
|
||||
audio = acceptanceMakeAudio(STRESS);
|
||||
if (!await audio.unlock()) throw new Error('Protected audio could not start. See diagnostics in the report.');
|
||||
const context = audio.context, bursts = [], start = context.currentTime;
|
||||
context.onstatechange = () => {
|
||||
if (context.state !== 'running') void acceptanceStop();
|
||||
};
|
||||
const environment = acceptanceEnvironment();
|
||||
for (let i = 0; i < 16; i++) if (!audio.play('bed')) throw new Error('Continuous stress voice refused.');
|
||||
const capture = audio.captureOutput();
|
||||
// Attach a rejection handler immediately, including synchronous burst failures.
|
||||
capture.catch(() => {});
|
||||
const burst = () => {
|
||||
for (let i = 0; i < 64; i++) if (!audio.play('hit')) throw new Error('Transient stress voice refused.');
|
||||
bursts.push({ seconds: context.currentTime - start, oneshots: audio.oneshotVoices.size, continuous: audio.continuousVoices.size,
|
||||
expandedGraphNodes: [...audio.oneshotVoices, ...audio.continuousVoices].reduce((count, voice) => count + (voice.plan?.nodes.length ?? 0), 0) });
|
||||
const elapsed = context.currentTime - start;
|
||||
acceptanceStatus(elapsed < 30 ? `Warming up · ${elapsed.toFixed(0)} / 30 seconds` : `Capturing · ${(elapsed - 30).toFixed(0)} / 120 seconds`);
|
||||
};
|
||||
burst();
|
||||
acceptanceTimer = setInterval(() => {
|
||||
try { burst(); } catch (error) { acceptanceStatus(error.message); void acceptanceStop(); }
|
||||
}, 2000);
|
||||
const result = await capture;
|
||||
const peakDb = result.peak > 0 ? 20 * Math.log10(result.peak) : null;
|
||||
acceptanceMeasurement = {
|
||||
capturedAt: new Date().toISOString(), environment, ...result, peakDb,
|
||||
ceilingDb: AUDIO_PROTECTION.ceilingDb, toleranceDb: AUDIO_PROTECTION.toleranceDb,
|
||||
lookaheadFrames: Math.ceil(context.sampleRate * AUDIO_PROTECTION.lookaheadMs / 1000),
|
||||
baseLatency: context.baseLatency ?? null, outputLatency: context.outputLatency ?? null,
|
||||
bursts, maxBurstGapSeconds: Math.max(0, ...bursts.slice(1).map((item, i) => item.seconds - bursts[i].seconds)),
|
||||
candidateDigitalPeakPass: result.peak > 0.01 && peakDb <= AUDIO_PROTECTION.ceilingDb + AUDIO_PROTECTION.toleranceDb && result.affectedBlocks === 0,
|
||||
diagnostics: acceptanceDiagnostics.list(),
|
||||
acceptance: 'Pending user review of workload timing, environment, listening, and production reference/challenge runs.'
|
||||
};
|
||||
acceptanceElement('measurement').textContent = JSON.stringify(acceptanceMeasurement, null, 2);
|
||||
acceptanceStatus('Digital capture finished. Record listening observations and download the report.');
|
||||
} catch (error) {
|
||||
acceptanceMeasurement = { interrupted: true, reason: error.message, diagnostics: acceptanceDiagnostics?.list() ?? [] };
|
||||
acceptanceElement('measurement').textContent = JSON.stringify(acceptanceMeasurement, null, 2);
|
||||
acceptanceStatus(`Capture incomplete: ${error.message}`);
|
||||
} finally {
|
||||
await acceptanceStop();
|
||||
acceptanceLock(false);
|
||||
}
|
||||
};
|
||||
acceptanceElement('cancel').onclick = async () => {
|
||||
await acceptanceStop();
|
||||
acceptanceListeningLog.push({ action: 'stop', at: new Date().toISOString() });
|
||||
acceptanceStatus('Audio stopped.');
|
||||
};
|
||||
for (const [id, sound] of Object.entries(CHALLENGE.sounds)) {
|
||||
const button = document.createElement('button'); button.textContent = sound.name;
|
||||
button.onclick = async () => {
|
||||
try {
|
||||
if (!acceptanceAudio) acceptanceMakeAudio(CHALLENGE);
|
||||
const audio = acceptanceAudio;
|
||||
if (!await audio.unlock()) throw new Error('Protected audio could not start.');
|
||||
if (!audio.play(id)) throw new Error('Voice could not start; release or stop existing voices.');
|
||||
acceptanceListeningLog.push({ action: 'play', sound: id, at: new Date().toISOString() });
|
||||
acceptanceStatus(`Playing ${sound.name}.`);
|
||||
} catch (error) { acceptanceStatus(error.message); }
|
||||
};
|
||||
acceptanceElement('sounds').append(button);
|
||||
}
|
||||
acceptanceElement('release').onclick = () => {
|
||||
acceptanceAudio?.stopAll();
|
||||
acceptanceListeningLog.push({ action: 'release', at: new Date().toISOString() });
|
||||
acceptanceStatus('All voices releasing.');
|
||||
};
|
||||
for (const [id, method] of [['pause', 'suspend'], ['resume', 'resume']]) {
|
||||
acceptanceElement(id).onclick = async () => {
|
||||
try {
|
||||
await acceptanceAudio?.context?.[method]();
|
||||
acceptanceListeningLog.push({ action: id, at: new Date().toISOString() });
|
||||
acceptanceStatus(id === 'pause' ? 'Audio paused.' : 'Audio resumed.');
|
||||
} catch (error) { acceptanceStatus(error.message); }
|
||||
};
|
||||
}
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden && acceptanceRunning) void acceptanceStop();
|
||||
});
|
||||
acceptanceElement('save').onclick = () => {
|
||||
const report = { build: ACCEPTANCE_BUILD, exportedAt: new Date().toISOString(), environment: acceptanceEnvironment(),
|
||||
measurement: acceptanceMeasurement, listeningLog: acceptanceListeningLog,
|
||||
listeningObservations: acceptanceElement('observations').value, diagnostics: acceptanceDiagnostics?.list() ?? [],
|
||||
phase3Acceptance: 'Pending user review; this report does not automatically close GC6 or PRD 129.' };
|
||||
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }));
|
||||
const anchor = document.createElement('a'); anchor.href = url; anchor.download = 'xzbt-phase3c4-evidence.json'; anchor.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { buildStandalone, bundleRuntime } from './build-xzbt.mjs';
|
||||
|
||||
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
export function buildAudioAcceptance(outputPath = resolve(root, 'prototypes/phase3/XZBT-audio-acceptance.html')) {
|
||||
const runtime = buildStandalone();
|
||||
const challengeSource = readFileSync(resolve(root, 'exhibits/audio-challenge.xzbt'), 'utf8');
|
||||
const stressSource = readFileSync(resolve(root, 'exhibits/audio-protection-stress.xzbt'), 'utf8');
|
||||
const digest = source => createHash('sha256').update(source).digest('hex');
|
||||
const metadata = { runtimeSha256: runtime.sha256, challengeSha256: digest(challengeSource), stressSha256: digest(stressSource), workload: 'phase3c4-overlap-v1', seed: 42, warmupSeconds: 30, measurementSeconds: 120 };
|
||||
const source = `${bundleRuntime(false)}\nconst ACCEPTANCE_BUILD = ${JSON.stringify(metadata)};\nconst CHALLENGE = ${challengeSource};\nconst STRESS = ${stressSource};\n${readFileSync(resolve(root, 'tools/audio-acceptance.js'), 'utf8')}`;
|
||||
const template = readFileSync(resolve(root, 'prototypes/phase3/acceptance.template.html'), 'utf8');
|
||||
const artifact = template.replace('/*__ACCEPTANCE_SCRIPT__*/', () => `'use strict';\n(() => {\n${source}\n})();`).replace(/\r\n/g, '\n');
|
||||
mkdirSync(dirname(outputPath), { recursive: true });
|
||||
writeFileSync(outputPath, artifact);
|
||||
return { outputPath, sha256: digest(artifact), bytes: Buffer.byteLength(artifact) };
|
||||
}
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) console.log(JSON.stringify(buildAudioAcceptance(), null, 2));
|
||||
@@ -12,6 +12,7 @@ const sourceFiles = Object.freeze([
|
||||
'src/runtime/types.js',
|
||||
'src/runtime/values.js',
|
||||
'src/runtime/audio-contract.js',
|
||||
'src/runtime/audio-protection.js',
|
||||
'src/runtime/audio-automation.js',
|
||||
'src/runtime/audio-graph.js',
|
||||
'src/runtime/audio-controls.js',
|
||||
@@ -36,10 +37,15 @@ function bundleModule(source, path) {
|
||||
return `\n/* ${path} */\n${withoutExports.trim()}\n`;
|
||||
}
|
||||
|
||||
export function bundleRuntime(includeApp = true) {
|
||||
return sourceFiles.filter(path => includeApp || path !== 'src/runtime/app.js')
|
||||
.map(path => bundleModule(normalized(path), path)).join('');
|
||||
}
|
||||
|
||||
export function buildStandalone(outputPath = resolve(repositoryRoot, 'XZBT.html')) {
|
||||
const template = normalized('src/XZBT.template.html');
|
||||
const styles = normalized('src/styles.css').trim();
|
||||
const script = `'use strict';\n(() => {\n${sourceFiles.map((path) => bundleModule(normalized(path), path)).join('')}\n})();`;
|
||||
const script = `'use strict';\n(() => {\n${bundleRuntime()}\n})();`;
|
||||
if (!template.includes('/*__XZBT_STYLES__*/') || !template.includes('/*__XZBT_SCRIPT__*/')) {
|
||||
throw new Error('Standalone template is missing a build placeholder.');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user