---
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.3",
    "react-dom": "^19.2.3"
  },
  "description": "Purrvana - Fall asleep to a cat purring"
}
```