Hush - Sound the alarm when noise gets out of hand
import React, { useState, useEffect, useRef, useCallback } from "react";
import { createRoot } from "react-dom/client";
class NoiseMonitor {
private ctx: AudioContext | null = null;
private analyser: AnalyserNode | null = null;
private stream: MediaStream | null = null;
private buffer: Float32Array | null = null;
private rafId: number | null = null;
private muted = false;
private smoothed = 0;
private mediaRec: MediaRecorder | null = null;
constructor(private onLevel: (level: number) => void) {}
async start(): Promise<void> {
if (!this.stream) {
this.stream = await navigator.mediaDevices.getUserMedia({ audio: true });
}
if (!this.ctx) {
this.ctx = new AudioContext();
const source = this.ctx.createMediaStreamSource(this.stream);
this.analyser = this.ctx.createAnalyser();
this.analyser.fftSize = 2048;
this.buffer = new Float32Array(this.analyser.fftSize);
source.connect(this.analyser);
}
if (this.ctx.state === "suspended") await this.ctx.resume();
this.tick();
}
private tick = () => {
if (!this.analyser || !this.buffer) return;
this.analyser.getFloatTimeDomainData(this.buffer);
let sum = 0;
for (let i = 0; i < this.buffer.length; i++) sum += this.buffer[i] * this.buffer[i];
const rms = Math.sqrt(sum / this.buffer.length);
const raw = Math.min(100, rms * 400);
// EMA smoothing across frames (~100ms time constant)
this.smoothed = this.smoothed * 0.7 + raw * 0.3;
this.onLevel(this.muted ? 0 : Math.round(this.smoothed));
this.rafId = requestAnimationFrame(this.tick);
};
setMuted(m: boolean) {
this.muted = m;
if (m) this.smoothed = 0;
}
startRecording(): void {
if (!this.stream) return;
const mime = MediaRecorder.isTypeSupported("audio/webm;codecs=opus")
? "audio/webm;codecs=opus"
: MediaRecorder.isTypeSupported("audio/webm")
? "audio/webm"
: "";
this.mediaRec = mime
? new MediaRecorder(this.stream, { mimeType: mime })
: new MediaRecorder(this.stream);
const chunks: Blob[] = [];
this.mediaRec.ondataavailable = e => { if (e.data.size > 0) chunks.push(e.data); };
(this.mediaRec as any)._chunks = chunks;
this.mediaRec.start();
}
stopRecording(): Promise<string> {
return new Promise(resolve => {
const rec = this.mediaRec;
if (!rec) return resolve("");
rec.onstop = () => {
const chunks = (rec as any)._chunks as Blob[];
const blob = new Blob(chunks, { type: rec.mimeType || "audio/webm" });
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => resolve("");
reader.readAsDataURL(blob);
this.mediaRec = null;
};
try { rec.stop(); } catch { resolve(""); }
});
}
stop() {
if (this.rafId) cancelAnimationFrame(this.rafId);
this.rafId = null;
this.stream?.getTracks().forEach(t => t.stop());
this.stream = null;
this.ctx?.close().catch(() => {});
this.ctx = null;
this.analyser = null;
this.buffer = null;
this.smoothed = 0;
}
}
const DEFAULT_TTS = "shut the fuck up";
class ShushPlayer {
private ctx: AudioContext | null = null;
private buffer: AudioBuffer | null = null;
private getCtx(): AudioContext {
if (!this.ctx) this.ctx = new AudioContext();
return this.ctx;
}
async loadFromDataUrl(url: string) {
const res = await fetch(url);
const arrayBuf = await res.arrayBuffer();
this.buffer = await this.getCtx().decodeAudioData(arrayBuf);
}
clearCustom() { this.buffer = null; }
hasCustom() { return this.buffer !== null; }
play(): Promise<void> {
return this.buffer ? this.playBuffer(this.buffer) : this.playTts(DEFAULT_TTS);
}
private async playBuffer(buffer: AudioBuffer): Promise<void> {
const ctx = this.getCtx();
if (ctx.state === "suspended") await ctx.resume();
return new Promise(resolve => {
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(ctx.destination);
src.onended = () => resolve();
src.start();
});
}
private playTts(text: string): Promise<void> {
return new Promise(resolve => {
const u = new SpeechSynthesisUtterance(text);
u.onend = () => resolve();
u.onerror = () => resolve();
window.speechSynthesis.speak(u);
});
}
}
/** Sets the dynamic background color on the root element. Both html and body
* reference --bg-dynamic via CSS, so a single set covers the entire viewport
* (including the iOS Safari area outside `100vh`). null clears the override. */
const setRootBg = (color: string | null) => {
const root = document.documentElement;
if (color === null) root.style.removeProperty("--bg-dynamic");
else root.style.setProperty("--bg-dynamic", color);
};
const ControlSlider = ({ label, value, display, min, max, step, onChange }: any) => (
<div className="slider-row">
<div className="label-container"><span className="label">{label}</span><span className="value">{display}</span></div>
<input type="range" min={min} max={max} step={step} value={value} onChange={e => onChange(e.target.value)}/>
</div>
);
function App() {
const monitorRef = useRef<NoiseMonitor | null>(null);
const playerRef = useRef<ShushPlayer | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const historyRef = useRef<number[]>([]);
const breachStartRef = useRef<number | null>(null);
const lastAboveRef = useRef<number>(0);
const armedRef = useRef(true);
const calibSamplesRef = useRef<number[] | null>(null);
const lastHistPushRef = useRef<number>(0);
const wakeLockRef = useRef<any>(null);
const [permission, setPermission] = useState<"idle" | "granted" | "denied">("idle");
const [listening, setListening] = useState(false);
const [threshold, setThreshold] = useState(() => {
const v = parseInt(localStorage.getItem("hush-threshold") || "");
return Number.isFinite(v) ? v : 25;
});
const [continuousSecs, setContinuousSecs] = useState(() => {
const v = parseInt(localStorage.getItem("hush-patience") || "");
return Number.isFinite(v) ? v : 3;
});
const [warnings, setWarnings] = useState(0);
const [currentLevel, setCurrentLevel] = useState(0);
const [calibrating, setCalibrating] = useState(false);
const [recState, setRecState] = useState<"idle" | "recording" | "has-custom">("idle");
const [breachActive, setBreachActive] = useState(false);
const [breachElapsed, setBreachElapsed] = useState(0);
const [bgLevel, setBgLevel] = useState(0);
const [flashing, setFlashing] = useState(false);
const thresholdRef = useRef(threshold);
const continuousSecsRef = useRef(continuousSecs);
useEffect(() => {
thresholdRef.current = threshold;
localStorage.setItem("hush-threshold", String(threshold));
}, [threshold]);
useEffect(() => {
continuousSecsRef.current = continuousSecs;
localStorage.setItem("hush-patience", String(continuousSecs));
}, [continuousSecs]);
// Init player + load saved recording
useEffect(() => {
playerRef.current = new ShushPlayer();
const saved = localStorage.getItem("hush-shush");
if (saved) {
playerRef.current.loadFromDataUrl(saved)
.then(() => setRecState("has-custom"))
.catch(() => localStorage.removeItem("hush-shush"));
}
}, []);
const clearBreach = useCallback(() => {
breachStartRef.current = null;
setBreachActive(false);
}, []);
const syncRecStateFromPlayer = useCallback(() => {
setRecState(playerRef.current?.hasCustom() ? "has-custom" : "idle");
}, []);
const playShush = useCallback(async () => {
const player = playerRef.current;
if (!player) return;
monitorRef.current?.setMuted(true);
await player.play();
setTimeout(() => monitorRef.current?.setMuted(false), 600);
}, []);
const triggerFlash = useCallback(() => {
setFlashing(true);
document.documentElement.classList.add("flashing");
let count = 0;
const id = window.setInterval(() => {
setRootBg(count % 2 === 0 ? "#fff" : "#000");
count++;
if (count >= 8) {
window.clearInterval(id);
document.documentElement.classList.remove("flashing");
setFlashing(false);
}
}, 125);
}, []);
const fireWarning = useCallback(() => {
setWarnings(w => w + 1);
triggerFlash();
playShush();
}, [triggerFlash, playShush]);
const handleLevel = useCallback((level: number) => {
const now = performance.now();
if (calibSamplesRef.current) calibSamplesRef.current.push(level);
const t = thresholdRef.current;
const GRACE_MS = 400;
if (level > t) {
lastAboveRef.current = now;
if (armedRef.current) {
if (breachStartRef.current === null) {
breachStartRef.current = now;
setBreachActive(true);
} else if (now - breachStartRef.current >= continuousSecsRef.current * 1000) {
fireWarning();
armedRef.current = false;
clearBreach();
}
}
} else {
const sinceAbove = now - lastAboveRef.current;
if (sinceAbove > GRACE_MS) {
if (breachStartRef.current !== null) clearBreach();
armedRef.current = true;
}
}
// Throttle React state updates / history pushes to ~10Hz
if (now - lastHistPushRef.current > 100) {
lastHistPushRef.current = now;
setCurrentLevel(level);
const hist = historyRef.current;
hist.push(level);
if (hist.length > 300) hist.shift();
setBreachElapsed(
breachStartRef.current !== null
? (now - breachStartRef.current) / 1000
: 0
);
// Moving average over the patience window drives the bg-color gradient.
const windowSize = continuousSecsRef.current * 10;
const slice = hist.slice(-windowSize);
const avg = slice.length ? slice.reduce((a, b) => a + b, 0) / slice.length : 0;
setBgLevel(avg);
}
}, [fireWarning, clearBreach]);
// Drive root background color from the moving-average noise level.
// Skipped while flashing (the flash interval owns the bg directly).
// Cleared when not listening so the CSS theme rule takes over again.
useEffect(() => {
if (!listening) {
setRootBg(null);
return;
}
if (flashing) return;
const max = 220;
const r = Math.round((max * Math.min(100, bgLevel)) / 100);
setRootBg(`rgb(${r}, 0, 0)`);
}, [bgLevel, flashing, listening]);
// Cleanup on unmount — don't leave a red bg behind.
useEffect(() => {
return () => {
setRootBg(null);
document.documentElement.classList.remove("flashing");
};
}, []);
// Graph drawing loop
useEffect(() => {
let raf = 0;
const draw = () => {
const canvas = canvasRef.current;
if (canvas) {
const dpr = window.devicePixelRatio || 1;
const cw = canvas.clientWidth;
const ch = canvas.clientHeight;
if (canvas.width !== cw * dpr) canvas.width = cw * dpr;
if (canvas.height !== ch * dpr) canvas.height = ch * dpr;
const w = canvas.width;
const h = canvas.height;
const ctx = canvas.getContext("2d")!;
ctx.clearRect(0, 0, w, h);
const hist = historyRef.current;
const t = thresholdRef.current;
// Threshold line
const ty = h - (t / 100) * h;
ctx.strokeStyle = "#664a2c";
ctx.lineWidth = 1 * dpr;
ctx.setLineDash([6 * dpr, 4 * dpr]);
ctx.beginPath();
ctx.moveTo(0, ty);
ctx.lineTo(w, ty);
ctx.stroke();
ctx.setLineDash([]);
// Level line + fill
if (hist.length > 1) {
const denom = 299;
ctx.lineWidth = 1.5 * dpr;
ctx.strokeStyle = "#9a9a9a";
ctx.beginPath();
for (let i = 0; i < hist.length; i++) {
const x = (i / denom) * w;
const y = h - (hist[i] / 100) * h;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
}
ctx.stroke();
ctx.lineTo(((hist.length - 1) / denom) * w, h);
ctx.lineTo(0, h);
ctx.closePath();
ctx.fillStyle = "rgba(150, 150, 150, 0.06)";
ctx.fill();
}
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, []);
const startListening = async (): Promise<boolean> => {
try {
const m = monitorRef.current ?? new NoiseMonitor(handleLevel);
await m.start();
monitorRef.current = m;
setPermission("granted");
setListening(true);
armedRef.current = true;
breachStartRef.current = null;
lastAboveRef.current = 0;
if ("wakeLock" in navigator) {
try {
wakeLockRef.current = await (navigator as any).wakeLock.request("screen");
} catch {}
}
// Warm up speech synthesis inside the user gesture so it can speak later
// from the breach handler (iOS Safari requires the first speak() to be
// inside a user activation event).
try {
const warmup = new SpeechSynthesisUtterance(" ");
warmup.volume = 0;
window.speechSynthesis.speak(warmup);
} catch {}
return true;
} catch {
setPermission("denied");
return false;
}
};
const stopListening = () => {
monitorRef.current?.stop();
monitorRef.current = null;
setListening(false);
setCurrentLevel(0);
setBreachElapsed(0);
setBgLevel(0);
historyRef.current = [];
clearBreach();
armedRef.current = true;
if (recState === "recording") syncRecStateFromPlayer();
wakeLockRef.current?.release().catch(() => {});
wakeLockRef.current = null;
};
const toggleListening = () => listening ? stopListening() : startListening();
const calibrate = async () => {
if (!listening) {
const ok = await startListening();
if (!ok) return;
}
if (!monitorRef.current) return;
setCalibrating(true);
calibSamplesRef.current = [];
setTimeout(() => {
const samples = calibSamplesRef.current ?? [];
calibSamplesRef.current = null;
if (samples.length > 0) {
const peak = Math.max(...samples);
const avg = samples.reduce((a, b) => a + b, 0) / samples.length;
const newThreshold = Math.max(Math.round(Math.max(avg * 2.5, peak * 1.4, 5)), 5);
setThreshold(Math.min(100, newThreshold));
}
setCalibrating(false);
}, 2500);
};
const adjustThreshold = (delta: number) => {
setThreshold(t => Math.max(1, Math.min(100, t + delta)));
};
const resetWarnings = () => setWarnings(0);
const toggleRecording = async () => {
if (recState === "recording") {
const m = monitorRef.current;
if (!m) { syncRecStateFromPlayer(); return; }
const dataUrl = await m.stopRecording();
m.setMuted(false);
if (!dataUrl || !playerRef.current) { syncRecStateFromPlayer(); return; }
try {
await playerRef.current.loadFromDataUrl(dataUrl);
try { localStorage.setItem("hush-shush", dataUrl); } catch {}
setRecState("has-custom");
} catch {
syncRecStateFromPlayer();
}
} else {
const ok = listening || await startListening();
if (!ok || !monitorRef.current) return;
monitorRef.current.setMuted(true);
monitorRef.current.startRecording();
setRecState("recording");
}
};
const clearRecording = () => {
localStorage.removeItem("hush-shush");
playerRef.current?.clearCustom();
setRecState("idle");
};
return (
<div className="container">
<header>
<h1 className="title-text">hush</h1>
<div className="subtitle-text">Shut the fuck up</div>
</header>
<div className={`graph-wrap ${breachActive ? "is-breaching" : ""} ${listening ? "is-listening" : ""}`}>
<canvas ref={canvasRef} className="graph"/>
{breachElapsed > 0 && (
<div className="breach-elapsed">
{breachElapsed.toFixed(1)}s / {continuousSecs}s
</div>
)}
<div className="readout">
<div className="readout-cell">
<div className="readout-value">{currentLevel}</div>
<div className="readout-label">Level</div>
</div>
<div className="readout-divider"/>
<div className="readout-cell">
<div className="readout-value">{threshold}</div>
<div className="readout-label">Limit</div>
</div>
<div className="readout-divider"/>
<div className="readout-cell warnings-cell">
<div className="readout-value">{warnings}</div>
<div className="readout-label">Warnings</div>
</div>
</div>
</div>
<div className="controls">
<div className="threshold-row">
<button className="step-btn" onClick={() => adjustThreshold(-1)} aria-label="Lower limit">−</button>
<button
className={`calibrate-btn ${calibrating ? "calibrating" : ""}`}
onClick={calibrate}
disabled={calibrating}
>
{calibrating ? "Listening…" : "Calibrate"}
</button>
<button className="step-btn" onClick={() => adjustThreshold(1)} aria-label="Raise limit">+</button>
</div>
<ControlSlider
label="Patience"
value={continuousSecs}
display={`${continuousSecs}s`}
min="1" max="10" step="1"
onChange={(v: string) => setContinuousSecs(parseInt(v))}
/>
<div className="record-row">
<button className={`record-btn ${recState}`} onClick={toggleRecording}>
{recState === "recording" ? "Stop Recording" : recState === "has-custom" ? "Re-record" : "Record Custom"}
</button>
<button className="ghost-btn" onClick={playShush}>Preview</button>
{recState === "has-custom" && (
<button className="ghost-btn" onClick={clearRecording}>Use default</button>
)}
</div>
<button className="ghost-btn reset-btn" onClick={resetWarnings}>Reset warnings</button>
<div className="btn-wrapper">
<button className={`play-btn ${listening ? "active" : ""}`} onClick={toggleListening}>
{listening ? "Stop" : "Start"}
</button>
</div>
{permission === "denied" && (
<div className="warning-text">No mic. Grant permission and reload.</div>
)}
{listening && (
<div className="hint-text">Keep this tab visible — backgrounded tabs get suspended.</div>
)}
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);