Purrvana - Fall asleep to a cat purring.
---
format: typebulb/v1
name: Purrvana
---
**code.tsx**
```tsx
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 />);
```
**styles.css**
```css
:root {
--bg: #0a0a0a;
--text: #666;
--text-bright: #999;
--accent: #222;
--cat-body: #080808;
--cat-stroke: #151515;
--cat-detail: #444;
--btn-active: #888;
--slider-bg: #111;
--thumb: #888;
}
html[data-theme="light"] {
--bg: #ffffff;
--text: #888;
--text-bright: #444;
--accent: #ddd;
--cat-body: #eee;
--cat-stroke: #ccc;
--cat-detail: #999;
--btn-active: #222;
--slider-bg: #eee;
--thumb: #444;
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
background-color: var(--bg);
color: var(--text);
display: grid;
place-items: center;
height: 100vh;
overflow: hidden;
}
.title-text, .subtitle-text {
font-weight: 200;
letter-spacing: 0.4rem;
text-transform: uppercase;
color: var(--accent);
margin: 0;
}
.title-text { font-size: 1.9rem; }
.subtitle-text { font-size: 1.25rem; margin-bottom: 0; }
.container {
text-align: center;
width: 100%;
max-width: 400px;
}
.cat-container {
position: relative;
width: 150px;
height: 150px;
margin: 5rem auto;
display: flex;
justify-content: center;
align-items: center;
}
.cat-icon {
width: 150px;
height: 150px;
z-index: 2;
transition: transform 2.5s ease-in-out;
}
.cat-svg path, .cat-svg ellipse {
transition: fill 3s, stroke 3.5s, opacity 3s;
}
.is-pulsing .cat-icon {
transform: scale(1.05);
--cat-body: #111;
--cat-stroke: #333;
--cat-detail: #666;
}
.waves-wrapper {
position: absolute;
inset: -15px;
opacity: 0;
transition: opacity 3s;
pointer-events: none;
}
.is-enabled .waves-wrapper { opacity: 0.3; animation: rotate 20s linear infinite; }
.is-pulsing .waves-wrapper { opacity: 1; }
.wave {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid hsla(calc(var(--idx) * 45), 40%, 30%, 0.4);
opacity: 0;
box-shadow: 0 0 30px hsla(calc(var(--idx) * 45), 50%, 20%, 0.15);
}
.is-enabled .wave {
animation: ripple 10s infinite;
animation-delay: calc(var(--idx) * 1s);
}
@keyframes rotate { to { transform: rotate(360deg); } }
@keyframes ripple {
0% { transform: scale(0.8); opacity: 0; border-width: 3px; }
15% { opacity: 0.5; }
100% { transform: scale(6); opacity: 0; border-width: 1px; }
}
.play-btn {
background: transparent;
color: var(--text);
border: 1px solid var(--accent);
padding: 1rem 2rem;
border-radius: 50px;
font-size: 0.9rem;
text-transform: uppercase;
cursor: pointer;
width: 160px;
margin-bottom: 2.5rem;
transition: all 0.2s;
}
.play-btn.active { border-color: var(--cat-detail); color: var(--btn-active); }
.sliders {
display: flex;
flex-direction: column;
gap: 1.5rem;
align-items: center;
}
.slider-row { width: 100%; max-width: 240px; }
.label-container { display: flex; justify-content: space-between; margin-bottom: 0.5rem; }
.label, .value { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.05rem; }
.value { color: var(--text-bright); font-variant-numeric: tabular-nums; }
input[type="range"] {
width: 100%;
height: 2px;
background: var(--slider-bg);
appearance: none;
outline: none;
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 12px;
height: 12px;
background: var(--thumb);
border-radius: 50%;
}
```
**index.html**
```html
<div id="root"></div>
```
**config.json**
```json
{
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"description": "Purrvana - Fall asleep to a cat purring."
}
```