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.
This commit is contained in:
194
server/web/src/audio/hoverSfx.ts
Normal file
194
server/web/src/audio/hoverSfx.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
/** 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();
|
||||
Reference in New Issue
Block a user