Purrvana

Purrvana - Fall asleep to a cat purring

code.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 />);

Markdown source · More bulbs by samples · Typebulb home