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.
195 lines
5.3 KiB
TypeScript
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();
|