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.
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 is | Raw bytes | Gzipped bytes |
|---|---|---|---|
| assets/index-*.js | Main JS: React 18, Zustand, timer, scene, audio engine | 355,928 | 83,763 |
| assets/index-*.css | All styles | 20,807 | 4,814 |
| index.html | App shell plus static intro text | 10,907 | 3,313 |
| assets/workbox-window-*.js | Service-worker registration helper | 5,748 | 2,396 |
| Page payload | Everything needed to render and run the room | 393,390 | 94,286 |
| workbox-*.js | Service-worker runtime (fetched by the SW, not the page) | 22,686 | 7,785 |
| sw.js | Service worker: precache manifest and audio cache rules | 2,532 | 1,325 |
| manifest.webmanifest | PWA install manifest | 548 | 337 |
| Total, including offline support | 419,156 | 103,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:
| File | Used for | Bytes on disk |
|---|---|---|
| rain-gentle.m4a | Rain channel, 40 s excerpt, mono AAC | 502,684 |
| fire-crackling.m4a | Fireplace channel, 40 s excerpt, mono AAC | 502,812 |
| lofi-coffee-shop.mp3 | Optional lo-fi track | 3,644,160 |
| lofi-sunny-cafe.mp3 | Optional lo-fi track | 3,971,328 |
| lofi-study-session.mp3 | Optional lo-fi track | 4,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.