SOURCE / PINNED RELEASE
Made of little things.
Shiftfield
- Release
- 5a170c505f6b…
- Author-recorded commit
- a2e23e0171aa…
- License
- LICENSE
- Author’s source reference
- nostr://npub16nzc4ql748r2kv9202rgzrt0fmtk4n3tzsjl86yf6ty9agqe9u0qjcc7hc/wss%3A%2F%2Fgit.napplet.soy%2F/n-e4fb6341ccd
Archive hash verified: 66de7623efd91da1…. The source-to-build association is the author’s claim; it has not been independently rebuilt.
/* Minesweeper DX — Phase 4 "Juice": procedural sound effects.
* Web Audio synthesis only: every sound is an oscillator/noise graph built at
* play time. NO assets, NO network (docs/napplet-space.md: "Small embedded
* data/blob audio and Web Audio are separate browser features, subject to
* user activation and codec support" — we create the context on the first
* real gesture and stay fully offline).
* The mute preference is a UI setting owned by the caller: it lives in the
* NAP-STORAGE settings key (`minesweeper-dx.settings.v1`, never localStorage
* — the §4.2A save schema is frozen) and round-trips through onChanged.
* Everything degrades silently when Web Audio is unavailable or the context
* refuses to start — audio is decoration, never a dependency.
*/
export type SfxId =
| 'reveal'
| 'flag'
| 'unflag'
| 'shift'
| 'shiftWarn'
| 'awaken'
| 'boom'
| 'shielded'
| 'ability'
| 'deny'
| 'clear'
| 'ui';
export class Sfx {
muted = false;
/** Called after the user flips the toggle so the host can persist it. */
onChanged: ((muted: boolean) => void) | null = null;
private ctx: AudioContext | null = null;
private master: GainNode | null = null;
private broken = false;
constructor(private readonly button: HTMLButtonElement | null) {
if (this.button) {
this.button.addEventListener('click', () => this.toggle());
this.render();
}
}
setMuted(v: boolean): void {
this.muted = v;
this.render();
}
private toggle(): void {
this.muted = !this.muted;
this.render();
if (!this.muted) this.play('ui');
this.onChanged?.(this.muted);
}
private render(): void {
if (!this.button) return;
this.button.classList.toggle('muted', this.muted);
this.button.setAttribute('aria-pressed', String(this.muted));
this.button.title = this.muted
? 'Sound off — click to unmute'
: 'Sound on — click to mute';
}
/** Lazily build the audio graph on first use (a user gesture has usually
* happened by then). Returns null when audio is muted, broken, or the
* browser has no usable AudioContext. */
private ensure(): AudioContext | null {
if (this.muted || this.broken) return null;
if (this.ctx) {
if (this.ctx.state === 'suspended') void this.ctx.resume().catch(() => {});
return this.ctx;
}
try {
const Ctor =
window.AudioContext ??
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctor) throw new Error('no AudioContext');
this.ctx = new Ctor();
this.master = this.ctx.createGain();
this.master.gain.value = 0.5;
this.master.connect(this.ctx.destination);
return this.ctx;
} catch {
this.broken = true;
return null;
}
}
// ------------------------------ synth parts ------------------------------
/** One pitched blip: exp-decaying sine/triangle/square from f0 to f1. */
private tone(
ctx: AudioContext,
opts: {
type?: OscillatorType;
f0: number;
f1?: number;
t?: number;
dur: number;
gain?: number;
},
): void {
const { type = 'sine', f0, f1 = f0, dur } = opts;
const at = (opts.t ?? 0);
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = type;
const now = ctx.currentTime;
osc.frequency.setValueAtTime(Math.max(20, f0), now + at);
osc.frequency.exponentialRampToValueAtTime(Math.max(20, f1), now + at + dur);
g.gain.setValueAtTime(0.0001, now + at);
g.gain.exponentialRampToValueAtTime(opts.gain ?? 0.25, now + at + 0.008);
g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
osc.connect(g).connect(this.master!);
osc.start(now + at);
osc.stop(now + at + dur + 0.05);
}
/** Filtered noise burst (booms, swooshes) through an exp-decaying gain. */
private noise(
ctx: AudioContext,
opts: {
t?: number;
dur: number;
gain?: number;
filter?: BiquadFilterType;
f0: number;
f1?: number;
q?: number;
},
): void {
const { dur, filter = 'lowpass', f0, f1 = f0, q = 1 } = opts;
const at = opts.t ?? 0;
const now = ctx.currentTime;
const frames = Math.max(1, Math.floor(ctx.sampleRate * dur));
const buffer = ctx.createBuffer(1, frames, ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < frames; i++) data[i] = Math.random() * 2 - 1;
const src = ctx.createBufferSource();
src.buffer = buffer;
const bq = ctx.createBiquadFilter();
bq.type = filter;
bq.Q.value = q;
bq.frequency.setValueAtTime(Math.max(20, f0), now + at);
bq.frequency.exponentialRampToValueAtTime(Math.max(20, f1), now + at + dur);
const g = ctx.createGain();
g.gain.setValueAtTime(opts.gain ?? 0.4, now + at);
g.gain.exponentialRampToValueAtTime(0.0001, now + at + dur);
src.connect(bq).connect(g).connect(this.master!);
src.start(now + at);
src.stop(now + at + dur + 0.05);
}
// --------------------------------- voices --------------------------------
play(id: SfxId): void {
const ctx = this.ensure();
if (!ctx || !this.master) return;
try {
switch (id) {
case 'reveal': {
// soft scanner tick, slightly randomized so a chain never feels machine-gunned
const f = 760 + Math.random() * 240;
this.tone(ctx, { type: 'triangle', f0: f, f1: f * 0.6, dur: 0.09, gain: 0.10 });
break;
}
case 'flag':
this.tone(ctx, { type: 'square', f0: 420, f1: 660, dur: 0.07, gain: 0.10 });
this.tone(ctx, { type: 'square', f0: 880, dur: 0.05, t: 0.06, gain: 0.05 });
break;
case 'unflag':
this.tone(ctx, { type: 'square', f0: 520, f1: 220, dur: 0.09, gain: 0.09 });
break;
case 'shift':
// distant metallic whoosh as the whole field slides one cell
this.noise(ctx, { dur: 0.28, gain: 0.12, f0: 900, f1: 180, filter: 'bandpass', q: 1.4 });
this.tone(ctx, { type: 'sine', f0: 130, f1: 90, dur: 0.22, gain: 0.12 });
break;
case 'shiftWarn':
// Klaxon tick for the final turns of a shift cycle — two rising
// square blips, unmistakable next to the reveal tick chain.
this.tone(ctx, { type: 'square', f0: 520, f1: 780, dur: 0.10, gain: 0.12 });
this.tone(ctx, { type: 'square', f0: 920, dur: 0.07, t: 0.13, gain: 0.09 });
break;
case 'awaken': {
// The Shifter's awakening at depth 6 (user ruling 2026-10-28):
// a threat, not a klaxon — sub-bass drop, a filtered rumble, and
// a slightly-beating dissonant drone pair swelling underneath.
this.tone(ctx, { type: 'sine', f0: 74, f1: 30, dur: 1.5, gain: 0.38 });
this.noise(ctx, { dur: 1.4, gain: 0.22, f0: 220, f1: 32 });
this.tone(ctx, { type: 'sawtooth', f0: 55.0, f1: 52.0, dur: 1.7, t: 0.12, gain: 0.05 });
this.tone(ctx, { type: 'sawtooth', f0: 58.3, f1: 55.3, dur: 1.7, t: 0.12, gain: 0.05 });
this.tone(ctx, { type: 'triangle', f0: 320, f1: 226, dur: 0.6, t: 0.55, gain: 0.045 });
break;
}
case 'boom':
this.noise(ctx, { dur: 0.7, gain: 0.6, f0: 1600, f1: 40 });
this.tone(ctx, { type: 'sawtooth', f0: 150, f1: 28, dur: 0.6, gain: 0.4 });
this.tone(ctx, { type: 'sine', f0: 60, f1: 24, dur: 0.9, t: 0.05, gain: 0.5 });
break;
case 'shielded':
// absorbed blast: muffled thud + bright shield chime
this.noise(ctx, { dur: 0.22, gain: 0.18, f0: 500, f1: 120 });
this.tone(ctx, { type: 'sine', f0: 660, dur: 0.18, gain: 0.16 });
this.tone(ctx, { type: 'sine', f0: 990, dur: 0.26, t: 0.07, gain: 0.14 });
break;
case 'ability':
this.tone(ctx, { type: 'square', f0: 440, f1: 880, dur: 0.09, gain: 0.08 });
this.tone(ctx, { type: 'square', f0: 660, f1: 1320, dur: 0.09, t: 0.07, gain: 0.06 });
break;
case 'deny':
this.tone(ctx, { type: 'sawtooth', f0: 180, f1: 120, dur: 0.12, gain: 0.10 });
break;
case 'clear': {
// major arpeggio — the sector is yours
const notes = [523.25, 659.25, 783.99, 1046.5];
notes.forEach((f, i) =>
this.tone(ctx, { type: 'triangle', f0: f, dur: 0.3, t: i * 0.09, gain: 0.14 }),
);
break;
}
case 'ui':
this.tone(ctx, { type: 'triangle', f0: 880, dur: 0.05, gain: 0.08 });
break;
}
} catch {
// Audio graph hiccups must never break the game.
}
}
}
