Purrvana - Fall asleep to a cat purring
import React, { useState, useEffect, useRef } from "react";
import { createRoot } from "react-dom/client";
class PurrSynth {
private ctx = new (window.AudioContext || (window as any).webkitAudioContext)();
private masterVca = this.ctx.createGain();
private envelope = this.ctx.createGain();
private isRunning = false;
private isExhale = true;
private timer: number | null = null;
// Components of the purr
private glottisOsc = this.ctx.createOscillator();
private bodyFilter = this.ctx.createBiquadFilter();
private throatFilter = this.ctx.createBiquadFilter();
constructor(initialVolume: number, private onActiveChange?: (active: boolean) => void) {
const limiter = this.ctx.createDynamicsCompressor();
limiter.threshold.value = -10;
limiter.ratio.value = 20;
limiter.connect(this.ctx.destination);
this.masterVca.connect(limiter);
this.masterVca.gain.value = initialVolume;
// 1. SOURCE: Brown Noise (Deepest Rumble)
const bufferSize = this.ctx.sampleRate * 2;
const brownBuffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const output = brownBuffer.getChannelData(0);
let lastOut = 0;
for (let i = 0; i < bufferSize; i++) {
const white = Math.random() * 2 - 1;
output[i] = (lastOut + (0.02 * white)) / 1.02;
lastOut = output[i];
output[i] *= 4.0;
}
const noise = this.ctx.createBufferSource();
noise.buffer = brownBuffer;
noise.loop = true;
// 2. SATURATION (The Secret to Hearing Bass on Small Speakers)
// We use a waveshaper to create upper harmonics of the low vibration.
const waveshaper = this.ctx.createWaveShaper();
const curve = new Float32Array(44100);
for (let i = 0; i < 44100; i++) {
const x = (i * 2) / 44100 - 1;
curve[i] = (Math.PI / 2 * Math.atan(x * 2)) / (Math.PI / 2); // Soft clipping
}
waveshaper.curve = curve;
// 3. THE GLOTTAL RATTLE (The Laryngeal vibration)
// Pulsing with a Sine instead of Sawtooth to eliminate "clicks"
this.glottisOsc.type = "sine";
this.glottisOsc.frequency.value = 24;
const glottisGain = this.ctx.createGain();
const glottisBias = this.ctx.createConstantSource();
// We map the sine wave (-1 to 1) to a positive range (0.2 to 1.0)
// This creates a smooth "throbbing" modulation without any sharp edges
const glottisScaler = this.ctx.createGain();
glottisScaler.gain.value = 0.4; // Modulation depth
glottisBias.offset.value = 0.6; // Base volume floor
this.glottisOsc.connect(glottisScaler);
glottisScaler.connect(glottisGain.gain);
this.glottisOsc.connect(glottisGain.gain);
glottisBias.connect(glottisGain.gain);
// 4. FILTER PATHS
// Body rumble (Low Bass)
this.bodyFilter.type = "lowpass";
this.bodyFilter.frequency.value = 60;
this.bodyFilter.Q.value = 2.0;
// Throat rattle (Audible texture)
this.throatFilter.type = "bandpass";
this.throatFilter.frequency.value = 220;
this.throatFilter.Q.value = 1.5;
const mixer = this.ctx.createGain();
// Low path through saturator for harmonics
noise.connect(this.bodyFilter).connect(waveshaper).connect(mixer);
// High path for the "air" and rattle resonance
const throatGain = this.ctx.createGain();
throatGain.gain.value = 0.4;
noise.connect(this.throatFilter).connect(throatGain).connect(mixer);
// Connect everything to the pulsed gate (Glottis)
mixer.connect(glottisGain);
glottisGain.connect(this.envelope).connect(this.masterVca);
noise.start();
glottisBias.start();
this.glottisOsc.start();
}
private scheduleNext() {
if (!this.isRunning) return;
const now = this.ctx.currentTime;
// BREATH CHARACTERISTICS
// Exhale: Lower freq, longer, more "relaxed" rumble
// Inhale: Higher freq, shorter, more "mechanical" rattle
const duration = this.isExhale ? 2.5 + Math.random() * 0.5 : 1.6 + Math.random() * 0.3;
const pulseFreq = this.isExhale ? 22 + Math.random() : 28 + Math.random();
const resonance = this.isExhale ? 180 : 260;
const bodyFreq = this.isExhale ? 55 : 85;
const volume = this.isExhale ? 1.0 : 0.65;
const gap = this.isExhale ? 0.1 : 0.4;
// Transition parameters smoothly over 0.3s to prevent step-clicks
this.glottisOsc.frequency.setTargetAtTime(pulseFreq, now, 0.3);
this.throatFilter.frequency.setTargetAtTime(resonance, now, 0.4);
this.bodyFilter.frequency.setTargetAtTime(bodyFreq, now, 0.4);
this.envelope.gain.cancelScheduledValues(now);
this.envelope.gain.setValueAtTime(this.envelope.gain.value, now);
// Soften the attack of the breath to 0.5s
this.envelope.gain.linearRampToValueAtTime(volume, now + 0.5);
this.envelope.gain.exponentialRampToValueAtTime(0.01, now + duration);
this.onActiveChange?.(true);
window.setTimeout(() => this.onActiveChange?.(false), (duration - 0.2) * 1000);
this.isExhale = !this.isExhale;
this.timer = window.setTimeout(() => this.scheduleNext(), (duration + gap) * 1000);
}
start() {
if (this.ctx.state === "suspended") this.ctx.resume();
this.isRunning = true;
this.scheduleNext();
}
stop() {
this.isRunning = false;
const now = this.ctx.currentTime;
if (this.timer) clearTimeout(this.timer);
this.envelope.gain.cancelScheduledValues(now);
this.masterVca.gain.cancelScheduledValues(now);
this.envelope.gain.setValueAtTime(this.envelope.gain.value, now);
this.envelope.gain.setTargetAtTime(0, now, 0.2);
this.onActiveChange?.(false);
}
setVolume(v: number, fade: number = 0) {
const now = this.ctx.currentTime;
const targetV = v * 2.5; // High headroom for the saturator
this.masterVca.gain.cancelScheduledValues(now);
this.masterVca.gain.setValueAtTime(this.masterVca.gain.value, now);
this.masterVca.gain.linearRampToValueAtTime(targetV, now + 0.1);
if (fade > 0 && targetV > 0) {
this.masterVca.gain.exponentialRampToValueAtTime(0.0001, now + (fade * 60));
}
}
}
const CatSVG = () => (
<svg viewBox="0 0 200 200" className="cat-svg">
<path d="M40 80 L45 15 L95 65 Z" fill="var(--cat-body)" stroke="var(--cat-stroke)" strokeWidth="2"/>
<path d="M160 80 L155 15 L105 65 Z" fill="var(--cat-body)" stroke="var(--cat-stroke)" strokeWidth="2"/>
<ellipse cx="100" cy="110" rx="70" ry="60" fill="var(--cat-body)" stroke="var(--cat-stroke)" strokeWidth="1" />
<path d="M60 105 Q75 95 90 105M110 105 Q125 95 140 105" fill="none" stroke="var(--cat-detail)" strokeWidth="3" strokeLinecap="round" />
<path d="M95 125 L105 125 L100 132 Z" fill="#b08d8d" opacity="0.8" />
<path d="M85 135 Q100 138 100 132 Q100 138 115 135" fill="none" stroke="var(--cat-detail)" strokeWidth="2" strokeLinecap="round" />
<g stroke="var(--cat-detail)" strokeWidth="1.5" strokeLinecap="round" fill="none" opacity="0.4">
<path d="M65 130 Q35 120 5 140M65 140 Q32 138 0 160M65 150 Q37 158 10 185" />
<path d="M135 130 Q165 120 195 140M135 140 Q168 138 200 160M135 150 Q163 158 190 185" />
</g>
</svg>
);
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 synth = useRef<PurrSynth | null>(null);
const [enabled, setEnabled] = useState(false);
const [pulsing, setPulsing] = useState(false);
const [vol, setVol] = useState(0.8);
const [fade, setFade] = useState(30);
const toggle = () => {
if (!synth.current) synth.current = new PurrSynth(vol, setPulsing);
if (enabled) {
synth.current.stop();
setPulsing(false);
} else {
synth.current.setVolume(vol, fade);
synth.current.start();
}
setEnabled(!enabled);
};
useEffect(() => { synth.current?.setVolume(vol, fade); }, [vol, fade]);
return (
<div className="container">
<header>
<h1 className="title-text">purrvana</h1>
<div className="subtitle-text">Fall asleep to purring</div>
</header>
<div className={`cat-container ${enabled ? "is-enabled" : ""} ${pulsing ? "is-pulsing" : ""}`}>
<div className="cat-icon"><CatSVG /></div>
<div className="waves-wrapper">
{[...Array(8)].map((_, i) => <div key={i} className="wave" style={{ '--idx': i } as any} />)}
</div>
</div>
<div className="controls">
<div className="btn-wrapper">
<button className={`play-btn ${enabled ? 'active' : ''}`} onClick={toggle}>
{enabled ? "Stop" : "Start"}
</button>
</div>
<div className="sliders">
<ControlSlider label="Volume" value={vol} display={`${Math.round(vol * 100)}%`} min="0" max="1" step="0.01" onChange={v => setVol(parseFloat(v))} />
<ControlSlider label="Fade out" value={fade} display={`${fade}m`} min="1" max="60" step="1" onChange={v => setFade(parseInt(v))} />
</div>
</div>
</div>
);
}
createRoot(document.getElementById("root")!).render(<App />);