Files
AetherForge/server/web/src/audio/hoverSfx.ts
AetherForge 1551bd5dad feat: Emberwake, Crucible phases, Linux agent, musical dashboard, e2e
Emberwake spread/waterhole UI, campaign DB, spread handler, spread-kit web publisher, and SPREAD_TECHNIQUES doc. Crucible Phase A-C: expanded ops, port-forward matrix, remote dir browser, crucible help/tests.

Linux agent hardening: credential vault, persistence audit, firewall/defender deploy, SMB spread status, CPU stats, screenshots/crypt/file-ops split. Docker compose and agent/server images with e2e validation script and docs.

Musical dashboard: ambient music player, hover SFX, SoundContext/AmbientMusicContext, steampunk polish. Public builds API, dropper handler updates, SessionGate and fleet UX. README and PROBLEMS.md refresh.
2026-06-04 21:53:31 -07:00

195 lines
5.3 KiB
TypeScript

/** Short dubstep/techno hover blips — Web Audio, no asset files. */
export const HOVER_SFX_STORAGE_KEY = 'aetherforge-hover-sfx';
export const HOVER_SFX_VOLUME_KEY = 'aetherforge-hover-volume';
export function loadHoverEnabled(): boolean {
try {
const v = localStorage.getItem(HOVER_SFX_STORAGE_KEY);
return v === null ? true : v === '1';
} catch {
return true;
}
}
export function loadHoverVolume(): number {
try {
const v = localStorage.getItem(HOVER_SFX_VOLUME_KEY);
if (v === null) return 0.25;
const n = parseFloat(v);
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.25;
} catch {
return 0.25;
}
}
function persistHoverEnabled(enabled: boolean) {
try {
localStorage.setItem(HOVER_SFX_STORAGE_KEY, enabled ? '1' : '0');
} catch {
/* ignore */
}
}
function persistHoverVolume(volume: number) {
try {
localStorage.setItem(HOVER_SFX_VOLUME_KEY, String(volume));
} catch {
/* ignore */
}
}
type HoverVariant = 'wub' | 'blip' | 'stab';
export class HoverSfxEngine {
private ctx: AudioContext | null = null;
private enabled = loadHoverEnabled();
private volume = loadHoverVolume();
private unlocked = false;
private lastPlayAt = 0;
private debounceMs = 140;
isEnabled() {
return this.enabled;
}
getVolume() {
return this.volume;
}
setEnabled(enabled: boolean) {
this.enabled = enabled;
persistHoverEnabled(enabled);
}
setVolume(volume: number) {
this.volume = Math.min(1, Math.max(0, volume));
persistHoverVolume(this.volume);
}
setDebounceMs(ms: number) {
this.debounceMs = Math.max(80, ms);
}
unlock() {
if (this.unlocked) return;
try {
const Ctx =
typeof window !== 'undefined'
? window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext
: undefined;
if (!Ctx) return;
if (!this.ctx) this.ctx = new Ctx();
if (this.ctx.state === 'suspended') void this.ctx.resume();
this.unlocked = true;
} catch {
/* ignore */
}
}
/** Respects main SFX mute via `sfxEnabled` from caller. */
play(sfxEnabled = true) {
if (!sfxEnabled || !this.enabled) return;
const now = Date.now();
if (now - this.lastPlayAt < this.debounceMs) return;
this.lastPlayAt = now;
this.unlock();
try {
const Ctx = window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
if (!Ctx) return;
if (!this.ctx) this.ctx = new Ctx();
const ctx = this.ctx;
if (ctx.state === 'suspended') void ctx.resume();
const variant = pickVariant();
this.scheduleVariant(ctx, variant);
} catch {
/* Audio blocked */
}
}
preview(sfxEnabled = true) {
this.lastPlayAt = 0;
this.play(sfxEnabled);
}
private scheduleVariant(ctx: AudioContext, variant: HoverVariant) {
const master = ctx.createGain();
master.gain.value = this.volume;
master.connect(ctx.destination);
const t0 = ctx.currentTime;
switch (variant) {
case 'wub':
this.scheduleWub(ctx, master, t0);
break;
case 'blip':
this.scheduleBlip(ctx, master, t0);
break;
case 'stab':
this.scheduleStab(ctx, master, t0);
break;
}
}
private scheduleWub(ctx: AudioContext, dest: GainNode, t0: number) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
const filter = ctx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.setValueAtTime(420, t0);
filter.frequency.exponentialRampToValueAtTime(90, t0 + 0.1);
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(95, t0);
osc.frequency.exponentialRampToValueAtTime(42, t0 + 0.09);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.09, t0 + 0.012);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.11);
osc.connect(filter);
filter.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + 0.13);
}
private scheduleBlip(ctx: AudioContext, dest: GainNode, t0: number) {
const freq = 280 + Math.random() * 180;
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(freq, t0);
osc.frequency.exponentialRampToValueAtTime(freq * 1.4, t0 + 0.04);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.05, t0 + 0.006);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.055);
osc.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + 0.07);
}
private scheduleStab(ctx: AudioContext, dest: GainNode, t0: number) {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(62, t0);
osc.frequency.setValueAtTime(48, t0 + 0.03);
g.gain.setValueAtTime(0.0001, t0);
g.gain.exponentialRampToValueAtTime(0.07, t0 + 0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.08);
osc.connect(g);
g.connect(dest);
osc.start(t0);
osc.stop(t0 + 0.1);
}
}
function pickVariant(): HoverVariant {
const r = Math.random();
if (r < 0.45) return 'wub';
if (r < 0.8) return 'blip';
return 'stab';
}
export const hoverSfxEngine = new HoverSfxEngine();