🕯️ hokkori

How a pomodoro app fits a rain, fire and lo-fi mixer into under 100 kB

Hokkori is a pomodoro timer in a pixel-art room with a three-channel ambient mixer: rain, fireplace, lo-fi. The page that renders the whole app is 94,286 bytes gzipped. The lo-fi pad, the chimes and the offline fallbacks for rain and fire are synthesized with the Web Audio API; the default rain and fire are short recordings that load only when you turn the channel up.

Signal chain of Hokkori's fallback rain synth: a looping 4-second white-noise buffer passes through a 400 Hz highpass, a 3.4 kHz lowpass and a bed gain whose level and cutoff drift on a slow random walk every 2.5 to 6 seconds. A droplet scheduler fires every 70 to 380 ms: 20 ms noise plips through a 1.4 to 3.8 kHz bandpass at Q 7, and 12 percent of the time a sine drip starting at 900 to 1500 Hz gliding down to 55 percent, each placed with a stereo panner. Everything sums into the channel gain. random walk every 2.5–6 s nudges bed gain and cutoff (2.1–3.7 kHz) white noise 4 s loop highpass 400 Hz lowpass 3.4 kHz bed gain channel gain droplet scheduler, every 70–380 ms (faster when the walk is heavier) noise plip 20 ms bandpass 1.4–3.8 kHz Q 7 StereoPanner random position 12 %: sine drip 900–1500 Hz, gliding down to 55 %
The fallback rain synth as a signal chain: a filtered noise bed whose gain and cutoff drift on a random walk, plus a droplet branch of short bandpassed plips and occasional sine drips, each stereo-panned. It is only built when the recording cannot be fetched.

How big is Hokkori, exactly?

Measured from a fresh npm run build on 16 September 2026, version 0.1.0, with gzip -c file | wc -c on each output file (default gzip level).

File (dist/)What it isRaw bytesGzipped bytes
assets/index-*.jsMain JS: React 18, Zustand, timer, scene, audio engine355,92883,763
assets/index-*.cssAll styles20,8074,814
index.htmlApp shell plus static intro text10,9073,313
assets/workbox-window-*.jsService-worker registration helper5,7482,396
Page payloadEverything needed to render and run the room393,39094,286
workbox-*.jsService-worker runtime (fetched by the SW, not the page)22,6867,785
sw.jsService worker: precache manifest and audio cache rules2,5321,325
manifest.webmanifestPWA install manifest548337
Total, including offline support419,156103,733

Not counted: the two Google Fonts (Nunito, Silkscreen), the og.png social image and the PWA icons. sw.js embeds content hashes, so it wobbles by a byte or two per build.

Honest phrasing: the app itself is 94 kB gzipped; with the service worker that makes it work offline, 104 kB.

The sound files in public/audio/, measured with ls -l:

FileUsed forBytes on disk
rain-gentle.m4aRain channel, 40 s excerpt, mono AAC502,684
fire-crackling.m4aFireplace channel, 40 s excerpt, mono AAC502,812
lofi-coffee-shop.mp3Optional lo-fi track3,644,160
lofi-sunny-cafe.mp3Optional lo-fi track3,971,328
lofi-study-session.mp3Optional lo-fi track4,201,728

None of these are in the initial payload or the precache. Audio matches a separate cache-first rule and is stored the first time you listen. If you never touch the fireplace slider, the fire file is never downloaded.

What is synthesized and what is recorded

Read from the shipped code: one AudioContext, one master GainNode, one GainNode per channel; each channel's graph is built lazily the first time its slider goes above zero.

Rain: recording by default, noise synth as fallback

The default fetches rain-gentle.m4a, decodes it with decodeAudioData, and loops it in an AudioBufferSourceNode with loopStart and loopEnd trimmed 50 ms from each end. If the fetch rejects (offline on a first run), the synth is built instead: a 4-second white-noise buffer looped through a 400 Hz highpass and a 3400 Hz lowpass BiquadFilterNode, with a bounded random walk every 2.5 to 6 s nudging the bed gain and cutoff (about 2.1 to 3.7 kHz) so the rain swells and eases. A droplet scheduler fires every 70 to 380 ms, faster when the walk is "heavier": mostly 20 ms bandpassed noise plips (1400 to 3800 Hz, Q 7), and 12 percent of the time a gutter drip, a sine gliding down to 55 percent of a 900 to 1500 Hz start, each placed with a StereoPannerNode.

Fire: recording by default, brown-noise synth as fallback

Same shape: fire-crackling.m4a in a trimmed loop, lifted 3.2x to balance against the rain bed. The fallback synth is a brown-noise bed through a 260 Hz lowpass, amplitude-modulated by a second brown-noise source lowpassed to 12 Hz and fed into a gain's gain AudioParam as an LFO (the flame flutter). Crackles are 2 to 60 ms noise bursts through a bandpass at 3 to 11 kHz (ticks), 1.2 to 4 kHz (mids) or 250 to 700 Hz (pops), with a heavy-tailed loudness (Math.random() ** 2.5), every 15 to 90 ms with calmer spells. Those numbers came from analysing a 79-second reference recording. It was one of many attempts at convincing crackle, and in listening tests it still lost to the recording. Synth rain passed; synth fire did not. Two 503 kB files, loaded on demand, were the pragmatic answer.

Lo-fi: a synthesized pad, or an mp3

The built-in "synth pad" needs no download: a vinyl-hiss bed (white noise through a 2 kHz highpass at gain 0.015) under a four-chord loop, Am7, Fmaj7, G7, Em7, each chord four triangle-wave OscillatorNodes detuned up to ±6 cents, 1.5 s attack, 3 s hold, 1.5 s release, through a 1200 Hz lowpass, every 5.5 s. The three Pixabay mp3s play through an HTMLAudioElement instead, because on iOS that path uses the "playback" audio session that the silent switch does not mute. (Whether lo-fi helps at all is another question: does lofi music actually help you focus?)

Chimes: always synthesized

The session-end chimes (bell, kalimba, bowl) are one to three struck sine or triangle oscillators with a linear attack and exponential decay; the kalimba adds a 4x partial, the bowl a 2.004x partial so it beats slowly.

The noise generator, verbatim

Every synthesized channel starts from this one function: white noise, or brown noise via a leaky integrator.

function noiseBuffer(seconds: number, brown = false): AudioBuffer {
  const c = ctx!;
  const buf = c.createBuffer(1, c.sampleRate * seconds, c.sampleRate);
  const data = buf.getChannelData(0);
  let last = 0;
  for (let i = 0; i < data.length; i++) {
    const white = Math.random() * 2 - 1;
    if (brown) {
      last = (last + 0.02 * white) / 1.02;
      data[i] = last * 3.5;
    } else {
      data[i] = white;
    }
  }
  return buf;
}

Why not video backgrounds?

Many cozy focus apps play a looping 1080p video of a rainy café. YouTube's upload guidance recommends 8 Mbps for 1080p SDR video at standard frame rates (YouTube Help, "Recommended upload encoding settings"), which is 1 MB per second, about 60 MB per minute of loop. That is an estimate for a well-encoded file, not a measurement of any particular app; streaming versions can be several times smaller and still be tens of MB. Hokkori's entire app, offline support included, is 0.1 MB. Even the largest optional lo-fi track (4.2 MB) is smaller than a minute of such video.

FAQ

Can you synthesize rain with the Web Audio API?

Yes. Hokkori's fallback rain is a looping white-noise buffer through highpass and lowpass filters, with gain and cutoff drifting on a slow random walk, plus scheduled droplet plips and sine drips. It costs zero bytes and was the default before the recording arrived; the recording simply sounds better.

Why does Hokkori use recordings for rain and fire?

Because the synthesized versions lost listening tests against real recordings, fire decisively. The 40-second mono AAC excerpts are about 503 kB each, load only when a channel is first turned up, and are cached afterwards. If the fetch fails, the synth is built instead.

How big is Hokkori?

94,286 bytes gzipped for the page that renders the app, 103,733 bytes including the service worker and manifest, measured 16 September 2026 on version 0.1.0. Audio loads on demand and is not counted. More about the project on the about page.

Hear it yourself. Open Hokkori, switch the sound panel on, and drag the rain slider. Start a session →