/* SPDX-License-Identifier: Apache-2.0
* Copyright 2026 alvations (Melon Lab)
*/
/* Procedural ambience, generated live with the Web Audio API.
*
* No audio files: each arc's soundscape is synthesised from oscillators and
* filtered noise. It is deliberately not music and not eerie stings, just the
* room tone of the place:
* hallway - fluorescent buzz + ventilation hum, the odd flicker
* coach - rolling rumble + steady rail-joint clacks, an opposing train now and then
* stairway - near silence: low room tone, a distant extractor fan, slow wind, echo
*
* The three arcs are level-matched to the landing theme (see the per-arc BUS map
* in play()). The movement SFX play at their natural level.
*
* Browsers block audio until a user gesture, so play() is only ever called from
* a click (entering a loop). A always-visible mute toggle persists the choice.
*/
class Ambience {
constructor() {
this.ctx = null;
this.master = null;
this.bus = null;
this.sources = [];
this.timers = [];
let muted = false;
try {
muted = localStorage.getItem("h8_muted") === "1";
} catch (e) {} // localStorage can throw on opaque origins / private mode
this.muted = muted;
// Master output level. 1.08 = 8% above the old unity level, a small lift to
// every sound (SFX and the quiet ambience alike) without letting the room
// tone get loud.
this.level = 1.08;
}
_ensure() {
if (this.ctx) return;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return;
this.ctx = new AC();
this.master = this.ctx.createGain();
this.master.gain.value = this.muted ? 0 : this.level;
this.master.connect(this.ctx.destination);
}
_noiseBuffer(seconds) {
const len = Math.floor(this.ctx.sampleRate * seconds);
const buf = this.ctx.createBuffer(1, len, this.ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
return buf;
}
_noise(seconds) {
const s = this.ctx.createBufferSource();
s.buffer = this._noiseBuffer(seconds || 3);
s.loop = true;
this.sources.push(s);
return s;
}
_osc(type, freq) {
const o = this.ctx.createOscillator();
o.type = type;
o.frequency.value = freq;
this.sources.push(o);
return o;
}
_gain(v) {
const g = this.ctx.createGain();
g.gain.value = v;
return g;
}
_filter(type, freq, q) {
const f = this.ctx.createBiquadFilter();
f.type = type;
f.frequency.value = freq;
if (q != null) f.Q.value = q;
return f;
}
// a decaying-noise impulse response for a cheap sense of space
_reverb(seconds, decay) {
const rate = this.ctx.sampleRate;
const len = Math.floor(rate * seconds);
const buf = this.ctx.createBuffer(2, len, rate);
for (let ch = 0; ch < 2; ch++) {
const d = buf.getChannelData(ch);
for (let i = 0; i < len; i++) {
d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
}
const c = this.ctx.createConvolver();
c.buffer = buf;
return c;
}
_mtof(m) {
return 440 * Math.pow(2, (m - 69) / 12);
}
// A short noise source that is NOT tracked (one-shot SFX manage their own
// lifetime), so repeated sound effects don't grow the sources list.
_noiseOneShot(seconds) {
const s = this.ctx.createBufferSource();
s.buffer = this._noiseBuffer(seconds || 0.3);
return s;
}
// one soft, ringing arpeggio note (bell/harp-like) at a scheduled time
_pluck(out, verb, freq, t, dur) {
const o = this.ctx.createOscillator();
o.type = "triangle";
o.frequency.value = freq;
const lp = this._filter("lowpass", 2400, 0.5);
const g = this._gain(0.0001);
o.connect(lp).connect(g);
g.connect(out);
g.connect(verb);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(0.9, t + 0.02);
g.gain.exponentialRampToValueAtTime(0.0008, t + dur);
o.start(t);
o.stop(t + dur + 0.05);
}
_every(minMs, maxMs, fn) {
const tick = () => {
fn();
const t = setTimeout(tick, minMs + Math.random() * (maxMs - minMs));
this.timers.push(t);
};
const t = setTimeout(tick, minMs + Math.random() * (maxMs - minMs));
this.timers.push(t);
}
// --- per-arc soundscapes -------------------------------------------------
_hallway(bus) {
const now = this.ctx.currentTime;
// mains buzz: a low hum plus a thin harmonic
const hum = this._osc("sine", 120);
const humG = this._gain(0.028);
hum.connect(humG).connect(bus);
const harm = this._osc("triangle", 240);
const harmG = this._gain(0.011);
harm.connect(harmG).connect(bus);
// faint high whine
const whine = this._osc("sine", 8200);
const whineG = this._gain(0.0021);
whine.connect(whineG).connect(bus);
// ventilation: brown-ish noise, low-passed
const vent = this._noise(4);
const ventG = this._gain(0.046);
vent.connect(this._filter("lowpass", 320, 0.7)).connect(ventG).connect(bus);
[hum, harm, whine, vent].forEach((s) => s.start(now));
// a flicker, soft: a small dip in the hum and a faint crackle, about once
// every 30 seconds.
this._every(22000, 40000, () => {
const t = this.ctx.currentTime;
humG.gain.setValueAtTime(humG.gain.value, t);
humG.gain.linearRampToValueAtTime(0.012, t + 0.04);
humG.gain.linearRampToValueAtTime(0.021, t + 0.25);
const cr = this._noise(0.2);
const crG = this._gain(0);
cr.connect(this._filter("highpass", 2000)).connect(crG).connect(bus);
cr.start(t);
crG.gain.setValueAtTime(0.014, t);
crG.gain.exponentialRampToValueAtTime(0.0004, t + 0.12);
cr.stop(t + 0.2);
});
}
_coach(bus) {
const now = this.ctx.currentTime;
// an almost-empty subway car: a steady, smooth roll, no constant clatter.
// deep rumble of the car on the rails. The cutoff is opened up from a pure
// sub-bass floor (which barely reproduces on laptop speakers) to carry some
// low-mid body, so the car is heard, not just felt.
const rumble = this._noise(4);
const rumbleG = this._gain(0.13);
rumble.connect(this._filter("lowpass", 140, 0.8)).connect(rumbleG).connect(bus);
// airy hiss of rolling, steady: the mid layer that gives the car presence
// and sets it apart from the stairwell's still air.
const roll = this._noise(4);
const rollG = this._gain(0.05);
roll.connect(this._filter("bandpass", 700, 0.6)).connect(rollG).connect(bus);
// a very slow breathing sway in the rumble, so it feels alive but not chuggy
const sway = this._osc("sine", 0.13);
const swayG = this._gain(0.021);
sway.connect(swayG).connect(rumbleG.gain);
[rumble, roll, sway].forEach((s) => s.start(now));
// steady rail-joint clacks: soft, evenly spaced knocks of the wheels over
// the joints. This is what tells the car apart from the stairwell's still
// room tone. Kept quiet and a touch irregular so it never turns chuggy.
const railTick = () => {
const t = this.ctx.currentTime;
const c = this._noiseOneShot(0.12);
const bp = this._filter("bandpass", 180, 1.5);
const g = this._gain(0.0001);
c.connect(bp).connect(g).connect(bus);
c.start(t);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.034, t + 0.008);
g.gain.exponentialRampToValueAtTime(0.0004, t + 0.08);
c.stop(t + 0.14);
const nx = setTimeout(railTick, 1250 + Math.random() * 550);
this.timers.push(nx);
};
const rt = setTimeout(railTick, 900);
this.timers.push(rt);
// a lurch/jerk of the car: a soft low thud with a short rattle. Now that the
// coach bed is quieter and calmer, its events are spaced out too (about once
// every 42s), so the car still moves under you but interrupts the quiet less.
this._every(30000, 54000, () => {
const t = this.ctx.currentTime;
const thud = this._osc("sine", 80);
const tg = this._gain(0.0001);
thud.connect(tg).connect(bus);
thud.start(t);
thud.frequency.setValueAtTime(85, t);
thud.frequency.exponentialRampToValueAtTime(45, t + 0.4);
tg.gain.setValueAtTime(0.0001, t);
tg.gain.linearRampToValueAtTime(0.085, t + 0.03);
tg.gain.exponentialRampToValueAtTime(0.0005, t + 0.5);
thud.stop(t + 0.6);
const r = this._noise(0.5);
const rg = this._gain(0.0001);
r.connect(this._filter("bandpass", 320, 1.2)).connect(rg).connect(bus);
r.start(t);
rg.gain.setValueAtTime(0.0001, t);
rg.gain.linearRampToValueAtTime(0.028, t + 0.05);
rg.gain.exponentialRampToValueAtTime(0.0004, t + 0.4);
r.stop(t + 0.6);
});
// a train passing the other way: a swell that rises and recedes as it goes
// by. Spaced with the quieter coach bed to about once every 42s (still well
// inside a normal climb, so it is met, just not as often).
this._every(30000, 54000, () => {
const t = this.ctx.currentTime;
const n = this._noise(4);
const bp = this._filter("bandpass", 300, 0.9);
const g = this._gain(0.0001);
n.connect(bp).connect(g).connect(bus);
n.start(t);
bp.frequency.setValueAtTime(260, t);
bp.frequency.linearRampToValueAtTime(950, t + 1.6);
bp.frequency.linearRampToValueAtTime(300, t + 3.4);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.11, t + 1.6);
g.gain.linearRampToValueAtTime(0.0001, t + 3.5);
n.stop(t + 3.7);
});
}
_stairway(bus) {
const now = this.ctx.currentTime;
const verb = this._reverb(2.6, 2.2);
const verbG = this._gain(0.6);
verb.connect(verbG).connect(bus);
// Low room tone, continuous, with a little sent to the reverb for space.
// Quiet, but present: a stairwell is near-silent, not silent.
const room = this._noise(4);
const roomG = this._gain(0.082);
room.connect(this._filter("lowpass", 240, 0.6)).connect(roomG);
roomG.connect(bus);
roomG.connect(verb);
room.start(now);
// A faint concrete-shaft hum: the building itself, a low steady presence so
// the arc is not mistaken for muted. Kept soft.
const hum = this._osc("sine", 66);
const humG = this._gain(0.044);
hum.connect(humG).connect(bus);
const air = this._noise(4);
const airG = this._gain(0.019);
air.connect(this._filter("bandpass", 420, 0.5)).connect(airG).connect(bus);
// A soft ventilation drone: a distant extractor fan somewhere in the shaft.
// Steady low-passed noise plus a slow tremolo, sitting lower and airier than
// the coach roll, so the stairwell reads as a still building, not a moving
// car. Kept quiet so the arc stays near-silent.
const fan = this._noise(4);
const fanG = this._gain(0.017);
fan.connect(this._filter("lowpass", 500, 0.7)).connect(fanG).connect(bus);
const fanLfo = this._osc("sine", 0.18);
const fanLfoG = this._gain(0.006);
fanLfo.connect(fanLfoG).connect(fanG.gain);
[hum, air, fan, fanLfo].forEach((s) => s.start(now));
// wind: not constant. A gust that swells and fades into the echo, about
// once every 30 seconds, so a normal climb meets it.
this._every(22000, 40000, () => {
const t = this.ctx.currentTime;
const w = this._noise(6);
const bp = this._filter("bandpass", 500, 0.7);
const g = this._gain(0.0001);
w.connect(bp).connect(g);
g.connect(bus);
g.connect(verb);
w.start(t);
bp.frequency.setValueAtTime(300, t);
bp.frequency.linearRampToValueAtTime(720, t + 2.5);
bp.frequency.linearRampToValueAtTime(350, t + 5);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.07, t + 2);
g.gain.linearRampToValueAtTime(0.0001, t + 5);
w.stop(t + 5.3);
});
}
// Landing theme: a soft, slow arpeggio. Wistful fantasy, not epic, not eerie,
// and quiet enough to stay background. vi-IV-I-V, gently voiced.
_landing(bus) {
const verb = this._reverb(3.2, 2.6);
const verbG = this._gain(0.5);
verb.connect(verbG).connect(bus);
const voice = this._gain(0.042); // muted (~70% of the prior level)
voice.connect(bus);
// a faint warm pad underneath
const padG = this._gain(0.0126);
padG.connect(bus);
const pad = this._noise(4);
pad.connect(this._filter("lowpass", 240, 0.5)).connect(padG);
pad.start(this.ctx.currentTime);
const chords = [
[57, 60, 64], // Am
[53, 57, 60, 64], // Fmaj7
[48, 52, 55, 60], // C
[55, 59, 62], // G
];
const step = 0.22; // time between notes (slow, calm)
const ring = 0.95; // each note rings and overlaps the next
const perChord = 12;
let idx = 0;
let nextTime = this.ctx.currentTime + 0.2;
const tick = () => {
const ahead = this.ctx.currentTime + 0.5;
while (nextTime < ahead) {
const chord = chords[Math.floor(idx / perChord) % chords.length];
const tones = chord.concat(chord.map((m) => m + 12));
const seq = tones.concat(tones.slice(1, -1).reverse());
const m = seq[idx % seq.length];
this._pluck(voice, verb, this._mtof(m), nextTime, ring);
nextTime += step;
idx++;
}
const t = setTimeout(tick, 120);
this.timers.push(t);
};
tick();
}
// --- action sound effects -----------------------------------------------
// A footstep: a soft low heel-thud plus a short scuff. Routed through the
// master gain so the mute toggle covers it.
_footstep(t, gain, pitch, bright) {
const out = this.master;
const o = this.ctx.createOscillator();
o.type = "triangle";
const base = 110 * pitch;
o.frequency.setValueAtTime(base * 1.7, t);
o.frequency.exponentialRampToValueAtTime(base * 0.7, t + 0.07);
const g = this._gain(0.0001);
o.connect(g).connect(out);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(gain, t + 0.006);
g.gain.exponentialRampToValueAtTime(0.0006, t + 0.13);
o.start(t);
o.stop(t + 0.17);
const n = this._noiseOneShot(0.2);
const bp = this._filter("bandpass", 1600 * bright, 0.8);
const ng = this._gain(0.0001);
n.connect(bp).connect(ng).connect(out);
ng.gain.setValueAtTime(0.0001, t);
ng.gain.linearRampToValueAtTime(gain * 0.5, t + 0.004);
ng.gain.exponentialRampToValueAtTime(0.0004, t + 0.05);
n.start(t);
n.stop(t + 0.12);
}
// A brief directional swish, a nod to an old-school scene-change: sweeps up
// when moving on, down when turning back.
_swish(t, back) {
const n = this._noiseOneShot(0.5);
const bp = this._filter("bandpass", back ? 1600 : 700, 0.7);
const g = this._gain(0.0001);
n.connect(bp).connect(g).connect(this.master);
bp.frequency.setValueAtTime(back ? 1600 : 700, t);
bp.frequency.exponentialRampToValueAtTime(back ? 600 : 1800, t + 0.28);
g.gain.setValueAtTime(0.0001, t);
g.gain.linearRampToValueAtTime(0.06, t + 0.06);
g.gain.exponentialRampToValueAtTime(0.0004, t + 0.3);
n.start(t);
n.stop(t + 0.4);
}
// Play the movement sound for a committed choice. `dwellMs` is how long the
// player lingered before pressing: a quick, confident press gets real
// footsteps; a long deliberation softens to a tiptoe, then to near silence.
step(dir, dwellMs, confidence) {
this._ensure();
if (!this.ctx) return;
if (this.ctx.state === "suspended") this.ctx.resume();
const t = this.ctx.currentTime + 0.01;
const back = dir === "back";
const pitch = back ? 0.82 : 1.0; // turning back sits a touch lower
// Confidence 0..1: fast presses read as confident; an explicit certainty
// rating (only 3/4 ever reach a commit) raises it, so "I took my time but
// I'm sure" still lands firm footsteps rather than a tiptoe.
let conf = Math.max(0, Math.min(1, 1 - (dwellMs - 500) / 5000));
if (confidence >= 4) conf = Math.max(conf, 0.95);
else if (confidence >= 3) conf = Math.max(conf, 0.7);
const randInt = (lo, hi) => lo + Math.floor(Math.random() * (hi - lo + 1));
const firm = 0.85 + conf * 0.15; // more certain, a touch firmer
if (conf >= 0.55) {
// confident: firm steps (3-5) + a scene-change swish
const n = randInt(3, 5);
for (let i = 0; i < n; i++) {
this._footstep(t + i * 0.185, (0.4 - i * 0.035) * firm, pitch * (1 + i * 0.02), 1.0 + i * 0.03);
}
this._swish(t, back);
} else if (conf >= 0.25) {
// hesitant: light, slower tiptoe (2-4)
const n = randInt(2, 4);
for (let i = 0; i < n; i++) {
this._footstep(t + i * 0.27, (0.16 - i * 0.02) * firm, pitch * 1.15 * (1 + i * 0.02), 1.4);
}
} else {
// long and unsure: a single, near-silent shuffle
this._footstep(t, 0.06, pitch * 1.2, 1.6);
}
}
// A quick flurry of light, fast steps: the player hurrying away. Used
// sparingly (see game.js) when someone bolts back from a changed loop.
run(dir) {
this._ensure();
if (!this.ctx) return;
if (this.ctx.state === "suspended") this.ctx.resume();
const t = this.ctx.currentTime + 0.02;
const pitch = (dir === "back" ? 0.9 : 1.05) * 1.12; // lighter, quicker
const n = 5 + Math.floor(Math.random() * 3); // 5-7 hurried steps
for (let i = 0; i < n; i++) {
this._footstep(t + i * 0.11, 0.2 - i * 0.012, pitch * (1 + i * 0.01), 1.25);
}
}
// --- controls ------------------------------------------------------------
play(skin) {
this._ensure();
if (!this.ctx) return;
if (this.ctx.state === "suspended") this.ctx.resume();
this.stop();
const bus = this.ctx.createGain();
bus.gain.value = 0.0001;
bus.connect(this.master);
this.bus = bus;
// Per-arc bus level. The room tone runs on this bus; the movement SFX bypass
// it (they connect straight to master), so lifting only the bus makes the BGM
// sit up under the footsteps rather than dropping out beneath them. Each arc
// is calibrated so its measured loudness (RMS) matches the landing theme, so
// the whole game sits at one level: the low-frequency coach needs the most
// lift (sub-bass reads quiet), the stairwell the least. See
// scripts/measure_audio.cjs for how these were derived.
const BUS = { landing: 1, hallway: 2.2, coach: 0.8, stairway: 1.48 };
const busLevel = BUS[skin] || 2.2;
if (skin === "coach") this._coach(bus);
else if (skin === "stairway") this._stairway(bus);
else if (skin === "landing") this._landing(bus);
else this._hallway(bus);
// fade in
const t = this.ctx.currentTime;
bus.gain.setValueAtTime(0.0001, t);
bus.gain.exponentialRampToValueAtTime(busLevel, t + 1.5);
}
stop() {
this.timers.forEach((t) => clearTimeout(t));
this.timers = [];
const bus = this.bus;
const srcs = this.sources;
this.bus = null;
this.sources = [];
if (!bus) return;
const t = this.ctx.currentTime;
try {
bus.gain.cancelScheduledValues(t);
bus.gain.setValueAtTime(bus.gain.value, t);
bus.gain.exponentialRampToValueAtTime(0.0001, t + 0.3);
} catch (e) {}
setTimeout(() => {
srcs.forEach((s) => {
try { s.stop(); } catch (e) {}
try { s.disconnect(); } catch (e) {}
});
try { bus.disconnect(); } catch (e) {}
}, 350);
}
setMuted(m) {
this.muted = m;
try {
localStorage.setItem("h8_muted", m ? "1" : "0");
} catch (e) {}
if (this.master && this.ctx) {
const t = this.ctx.currentTime;
this.master.gain.cancelScheduledValues(t);
this.master.gain.linearRampToValueAtTime(m ? 0 : this.level, t + 0.2);
}
}
toggle() {
this.setMuted(!this.muted);
return this.muted;
}
// Resume a suspended context from inside a user gesture. iOS/Safari, and
// mobile Chrome inside a cross-origin iframe (as the game runs on Spaces),
// only start audio if resume() is called synchronously within the gesture
// handler; a resume() that runs after an await (e.g. once an arc's fetch has
// resolved) is ignored. So this must be called first thing on the gesture,
// before any async work. Returns true once the context is actually running.
unlock() {
this._ensure();
if (!this.ctx) return false;
if (this.ctx.state === "suspended") {
try { this.ctx.resume(); } catch (e) {}
}
return this.ctx.state === "running";
}
// Localized aria-labels for the mute toggle, handed in by the UI layer once the
// catalog loads. Falls back to English until then.
setLabels(on, off) {
this.labelOn = on;
this.labelOff = off;
if (this._repaint) this._repaint();
}
}
window.ambience = new Ambience();
// Wire the always-visible mute toggle. Drawn as a minimal line icon (not an
// emoji) so it matches the game's quiet, monochrome look and inherits the
// chrome-tier colour: a speaker cone with two sound waves, or with a small X
// when muted.
(function () {
const btn = document.getElementById("sound-toggle");
if (!btn) return;
const CONE = "M4 9 H7 L11 5.5 V16.5 L7 13 H4 Z";
const svg = (inner) =>
'";
const ON = svg(
`` +
'' +
''
);
const OFF = svg(
`` +
'' +
''
);
const paint = () => {
const a = window.ambience;
btn.innerHTML = a.muted ? OFF : ON;
btn.setAttribute(
"aria-label",
a.muted ? (a.labelOff || "Sound off") : (a.labelOn || "Sound on")
);
};
window.ambience._repaint = paint; // so setLabels() can refresh the label
paint();
btn.addEventListener("click", () => {
window.ambience.toggle();
paint();
});
})();