feat: Telegram fleet alerts, forge sigil scramble, UI polish, agent ops
- Calibrate: per-event Telegram/SMTP toggles, test notification, chat ID help - Notify on agent connect/reconnect, offline/hashrate/rejection, forge complete - Sigil scramble post-forge uniquification and Dispense Reveal ceremony - Full system check, desktop push, BITS/host-binary persistence, Path Tracer - Dashboard/Crucible visual polish, haptics, sacred geometry, mobile nav - README documents alerts, sigil scramble, and pack-usb workflow - USB bundle repacked via pack-usb.bat (AetherForge.exe + synced agent source)
This commit is contained in:
79
server/web/src/audio/hapticEngine.test.ts
Normal file
79
server/web/src/audio/hapticEngine.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* @vitest-environment happy-dom
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
HapticEngine,
|
||||
loadSoundEnabled,
|
||||
loadSoundVolume,
|
||||
SFX_STORAGE_KEY,
|
||||
SFX_VOLUME_KEY,
|
||||
} from './hapticEngine';
|
||||
|
||||
describe('hapticEngine prefs', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('defaults sound on and volume ~0.4', () => {
|
||||
expect(loadSoundEnabled()).toBe(true);
|
||||
expect(loadSoundVolume()).toBeCloseTo(0.4);
|
||||
});
|
||||
|
||||
it('persists enabled flag', () => {
|
||||
const e = new HapticEngine();
|
||||
e.setEnabled(false);
|
||||
expect(localStorage.getItem(SFX_STORAGE_KEY)).toBe('0');
|
||||
expect(loadSoundEnabled()).toBe(false);
|
||||
});
|
||||
|
||||
it('clamps volume', () => {
|
||||
const e = new HapticEngine();
|
||||
e.setVolume(2);
|
||||
expect(e.getVolume()).toBe(1);
|
||||
e.setVolume(-1);
|
||||
expect(e.getVolume()).toBe(0);
|
||||
expect(localStorage.getItem(SFX_VOLUME_KEY)).toBe('0');
|
||||
});
|
||||
});
|
||||
|
||||
describe('HapticEngine.play', () => {
|
||||
const vibrate = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vibrate.mockClear();
|
||||
Object.defineProperty(navigator, 'vibrate', {
|
||||
value: vibrate,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('does not vibrate when disabled', () => {
|
||||
const e = new HapticEngine();
|
||||
e.setEnabled(false);
|
||||
e.play('click');
|
||||
expect(vibrate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('vibrates when enabled', () => {
|
||||
const e = new HapticEngine();
|
||||
e.setEnabled(true);
|
||||
e.play('success');
|
||||
expect(vibrate).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('play does not throw without AudioContext', () => {
|
||||
const prev = globalThis.AudioContext;
|
||||
// @ts-expect-error test shim
|
||||
delete globalThis.AudioContext;
|
||||
const e = new HapticEngine();
|
||||
e.setEnabled(true);
|
||||
expect(() => e.play('click')).not.toThrow();
|
||||
globalThis.AudioContext = prev;
|
||||
});
|
||||
});
|
||||
228
server/web/src/audio/hapticEngine.ts
Normal file
228
server/web/src/audio/hapticEngine.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/** UI + fleet event cues — synthesized via Web Audio (no asset files). */
|
||||
|
||||
export type SoundCue =
|
||||
| 'click'
|
||||
| 'nav'
|
||||
| 'success'
|
||||
| 'error'
|
||||
| 'alert'
|
||||
| 'alertCritical'
|
||||
| 'share'
|
||||
| 'connect'
|
||||
| 'disconnect'
|
||||
| 'online'
|
||||
| 'offline';
|
||||
|
||||
export const SFX_STORAGE_KEY = 'aetherforge-sfx';
|
||||
export const SFX_VOLUME_KEY = 'aetherforge-sfx-volume';
|
||||
|
||||
export function loadSoundEnabled(): boolean {
|
||||
try {
|
||||
const v = localStorage.getItem(SFX_STORAGE_KEY);
|
||||
return v === null ? true : v === '1';
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function loadSoundVolume(): number {
|
||||
try {
|
||||
const v = localStorage.getItem(SFX_VOLUME_KEY);
|
||||
if (v === null) return 0.4;
|
||||
const n = parseFloat(v);
|
||||
return Number.isFinite(n) ? Math.min(1, Math.max(0, n)) : 0.4;
|
||||
} catch {
|
||||
return 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
function persistEnabled(enabled: boolean) {
|
||||
try {
|
||||
localStorage.setItem(SFX_STORAGE_KEY, enabled ? '1' : '0');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function persistVolume(volume: number) {
|
||||
try {
|
||||
localStorage.setItem(SFX_VOLUME_KEY, String(volume));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const VIBRATE: Partial<Record<SoundCue, number | number[]>> = {
|
||||
click: 8,
|
||||
nav: 12,
|
||||
success: [12, 40, 18],
|
||||
error: [30, 50, 80],
|
||||
alert: [20, 30, 20],
|
||||
alertCritical: [40, 60, 40, 80],
|
||||
share: 14,
|
||||
connect: [10, 25],
|
||||
disconnect: [35, 20],
|
||||
online: [15, 35],
|
||||
offline: [25, 15],
|
||||
};
|
||||
|
||||
type ToneSpec = {
|
||||
freq: number;
|
||||
duration: number;
|
||||
type?: OscillatorType;
|
||||
gain?: number;
|
||||
delay?: number;
|
||||
};
|
||||
|
||||
function vibrateFor(cue: SoundCue) {
|
||||
if (typeof navigator === 'undefined' || !navigator.vibrate) return;
|
||||
const pattern = VIBRATE[cue];
|
||||
if (pattern !== undefined) navigator.vibrate(pattern);
|
||||
}
|
||||
|
||||
export class HapticEngine {
|
||||
private ctx: AudioContext | null = null;
|
||||
private enabled = loadSoundEnabled();
|
||||
private volume = loadSoundVolume();
|
||||
private unlocked = false;
|
||||
|
||||
isEnabled() {
|
||||
return this.enabled;
|
||||
}
|
||||
|
||||
getVolume() {
|
||||
return this.volume;
|
||||
}
|
||||
|
||||
setEnabled(enabled: boolean) {
|
||||
this.enabled = enabled;
|
||||
persistEnabled(enabled);
|
||||
}
|
||||
|
||||
setVolume(volume: number) {
|
||||
this.volume = Math.min(1, Math.max(0, volume));
|
||||
persistVolume(this.volume);
|
||||
}
|
||||
|
||||
/** Browsers require a user gesture before audio plays. */
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
play(cue: SoundCue) {
|
||||
if (!this.enabled) return;
|
||||
this.unlock();
|
||||
vibrateFor(cue);
|
||||
const specs = cueSpecs(cue);
|
||||
if (!specs.length) return;
|
||||
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 master = ctx.createGain();
|
||||
master.gain.value = this.volume;
|
||||
master.connect(ctx.destination);
|
||||
const now = ctx.currentTime;
|
||||
for (const spec of specs) {
|
||||
this.scheduleTone(ctx, master, spec, now);
|
||||
}
|
||||
} catch {
|
||||
/* Audio blocked or unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleTone(ctx: AudioContext, dest: GainNode, spec: ToneSpec, base: number) {
|
||||
const osc = ctx.createOscillator();
|
||||
const g = ctx.createGain();
|
||||
const t0 = base + (spec.delay ?? 0);
|
||||
const dur = spec.duration;
|
||||
const peak = (spec.gain ?? 0.12) * this.volume;
|
||||
osc.type = spec.type ?? 'sine';
|
||||
osc.frequency.setValueAtTime(spec.freq, t0);
|
||||
g.gain.setValueAtTime(0.0001, t0);
|
||||
g.gain.exponentialRampToValueAtTime(Math.max(peak, 0.0001), t0 + 0.008);
|
||||
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(g);
|
||||
g.connect(dest);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.02);
|
||||
}
|
||||
}
|
||||
|
||||
function cueSpecs(cue: SoundCue): ToneSpec[] {
|
||||
switch (cue) {
|
||||
case 'click':
|
||||
return [{ freq: 920, duration: 0.04, type: 'square', gain: 0.06 }];
|
||||
case 'nav':
|
||||
return [
|
||||
{ freq: 440, duration: 0.05, gain: 0.07 },
|
||||
{ freq: 660, duration: 0.06, delay: 0.04, gain: 0.06 },
|
||||
];
|
||||
case 'success':
|
||||
return [
|
||||
{ freq: 523, duration: 0.08, gain: 0.1 },
|
||||
{ freq: 784, duration: 0.1, delay: 0.07, gain: 0.09 },
|
||||
];
|
||||
case 'error':
|
||||
return [
|
||||
{ freq: 180, duration: 0.12, type: 'sawtooth', gain: 0.11 },
|
||||
{ freq: 140, duration: 0.14, delay: 0.1, type: 'sawtooth', gain: 0.09 },
|
||||
];
|
||||
case 'alert':
|
||||
return [
|
||||
{ freq: 740, duration: 0.07, type: 'triangle', gain: 0.09 },
|
||||
{ freq: 620, duration: 0.08, delay: 0.09, type: 'triangle', gain: 0.08 },
|
||||
];
|
||||
case 'alertCritical':
|
||||
return [
|
||||
{ freq: 880, duration: 0.06, type: 'square', gain: 0.1 },
|
||||
{ freq: 660, duration: 0.06, delay: 0.07, type: 'square', gain: 0.1 },
|
||||
{ freq: 440, duration: 0.1, delay: 0.14, type: 'square', gain: 0.11 },
|
||||
];
|
||||
case 'share':
|
||||
return [
|
||||
{ freq: 1200, duration: 0.05, gain: 0.08 },
|
||||
{ freq: 1600, duration: 0.06, delay: 0.05, gain: 0.07 },
|
||||
];
|
||||
case 'connect':
|
||||
return [
|
||||
{ freq: 330, duration: 0.07, gain: 0.08 },
|
||||
{ freq: 495, duration: 0.09, delay: 0.06, gain: 0.08 },
|
||||
];
|
||||
case 'disconnect':
|
||||
return [
|
||||
{ freq: 400, duration: 0.1, gain: 0.08 },
|
||||
{ freq: 260, duration: 0.12, delay: 0.08, gain: 0.07 },
|
||||
];
|
||||
case 'online':
|
||||
return [
|
||||
{ freq: 587, duration: 0.07, gain: 0.08 },
|
||||
{ freq: 880, duration: 0.09, delay: 0.06, gain: 0.07 },
|
||||
];
|
||||
case 'offline':
|
||||
return [
|
||||
{ freq: 440, duration: 0.09, gain: 0.07 },
|
||||
{ freq: 330, duration: 0.1, delay: 0.07, gain: 0.06 },
|
||||
];
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const hapticEngine = new HapticEngine();
|
||||
Reference in New Issue
Block a user