From 8a3dc323bc1a27f8c0c258c3611397d5564ab7e1 Mon Sep 17 00:00:00 2001 From: type-two Date: Sat, 18 Jul 2026 11:11:39 +1000 Subject: [PATCH] =?UTF-8?q?Lane=20C=20S13:=20sky.setMute(on)=20=E2=80=94?= =?UTF-8?q?=20the=20tap=20A's=20M=20key=20was=20waiting=20on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One flag plus a gain write, but the flag is the point: a mute pressed on the splash screen, before the first gesture builds the audio graph, is remembered and honoured by unlock(). main.js already feature-detects sky.setMute, so the M key lights itself up on this landing. audio.masterGain getter added so the assert reads the real bus, not the flag back. Selftest 336/0/0 (was 335, +1 test). Mutation-checked both ways: a no-op setMute and an unlock that forgets a pre-unlock mute both go red. Co-Authored-By: Claude Fable 5 --- web/world/js/skyfx.js | 26 +++++++++++++++++++++++++- web/world/js/tests/c.test.js | 21 +++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/web/world/js/skyfx.js b/web/world/js/skyfx.js index cbbae45..3651155 100644 --- a/web/world/js/skyfx.js +++ b/web/world/js/skyfx.js @@ -342,8 +342,11 @@ function cloudTexture(size = 256, seed = 7) { // ---------------------------------------------------------------- audio // Synthesized layers. WebAudio won't start until a gesture (browser rule), so // everything is built lazily on unlock() and silently absent before it. +const MASTER_GAIN = 0.55; + function createAudio(seed = 1) { let ctx = null, master = null; + let muted = false; let windGain, windFilter, windHowl, howlGain; let rainGain, rainFilter; let gustGain, gustFilter; @@ -380,6 +383,15 @@ function createAudio(seed = 1) { /** 'running' | 'suspended' | 'closed' | 'none'. `ready` only means the graph * got built — a suspended context is still silent, so the HUD reports this. */ get state() { return ctx ? ctx.state : 'none'; }, + /** A's M key (gate 3.3). A flag, not just a gain write, so muting works + * before unlock() has built the graph and survives it. */ + setMute(on) { + muted = !!on; + if (master) master.gain.value = muted ? 0 : MASTER_GAIN; + }, + get muted() { return muted; }, + /** Real bus gain, for asserts — null until unlock() builds the graph. */ + get masterGain() { return master ? master.gain.value : null; }, /** Current layer gains — for the HUD and for asserting the bed tracks wind. */ levels() { if (!started) return null; @@ -399,7 +411,9 @@ function createAudio(seed = 1) { ctx = new AC(); if (ctx.state === 'suspended') ctx.resume(); master = ctx.createGain(); - master.gain.value = 0.55; + // honour a mute that landed BEFORE the first gesture built the graph — + // M on the splash screen must not be forgotten by the unlock + master.gain.value = muted ? 0 : MASTER_GAIN; master.connect(ctx.destination); noiseBuf = noiseBuffer(ctx); @@ -792,6 +806,16 @@ export function createSkyFx(o = {}) { /** Wire to the first click/keydown — browsers won't start audio otherwise. */ unlockAudio() { audio.unlock(); }, + /** + * A's M key (gate 3.3): mute the whole audio bus. main.js feature-detects + * this (`typeof sky.setMute === 'function'`) and only advertises M once it + * exists — so this method appearing is what lights the key up. Safe at any + * time: before unlock it sets a flag the unlock honours, after unlock it + * writes the master gain directly. + */ + setMute(on) { audio.setMute(on); }, + get muted() { return audio.muted; }, + /** * @param {number} dt * @param {number} t storm time diff --git a/web/world/js/tests/c.test.js b/web/world/js/tests/c.test.js index ee6a125..9afb5b7 100644 --- a/web/world/js/tests/c.test.js +++ b/web/world/js/tests/c.test.js @@ -172,6 +172,27 @@ export default async function run(t) { `skyfx left ${scene.children.length - before.children} object(s) in the scene`); }); + // SPRINT13 gate 3.3 — A's M key. main.js feature-detects sky.setMute and only + // advertises M once it exists, so this contract is what lights the key up. + // The pre-unlock case is the one that bites: M pressed on the splash screen, + // BEFORE the first gesture builds the audio graph, must survive the unlock. + t.test('M-mute: setMute silences the bus, and a pre-unlock mute survives unlock', () => { + const wind = createWind(storms.storm_02_wildnight); + const sky = createSkyFx({ wind }); // headless — the bus needs no scene + assert(typeof sky.setMute === 'function', "no setMute — A's M key has nothing to call"); + assert(sky.muted === false, 'a fresh sky must not start muted'); + sky.setMute(true); // before unlock — the splash case + assert(sky.muted === true, 'setMute(true) did not take'); + sky.unlockAudio(); + if (sky.audio.ready) { // no AudioContext = nothing to silence + assert(sky.audio.masterGain === 0, + `unlock forgot a pre-unlock mute: master at ${sky.audio.masterGain}`); + sky.setMute(false); + assert(sky.audio.masterGain > 0, 'unmute left the master gain at 0'); + } + sky.dispose(); + }); + // --- SPRINT2 §Lane C.3: rain has to stop at the cloth --- // Driven with a synthetic 4×4 m panel rather than a whole cloth sim: the thing // under test is the projection, and a flat panel makes the right answer