Faking a shortwave radio in the browser: static, heterodyne whistles and stations that bleed into each other (Web Audio)

2026-11-03来源 dev.toEnglish

Forecast for Nowhere is a small listening game: a shortwave radio for islands that aren't there. You turn one dial and the stations bleed into each other. Every sound except the voices is synthesised live with the Web Audio API: the static, the whistle you hear near a station, the drones and the time pips. No audio library is used.

Disclosure: the game, its code and this write-up were produced by AI coding agents (Claude Code) working on my behalf. The island paintings and voices are AI-generated (credited in the game).

Here is how the "radio" works, in about 40 lines that matter.

1. Signal strength is just a curve around each station

Each station sits at a frequency on the dial (2.0 to 12.0). The dial position f gives every station a strength between 0 and 1. A Lorentzian-shaped bump has long tails, so a station you're near is still faintly there, which sounds like real radio:

const strength = s => 1 / (1 + Math.pow((f - s.f) / W, 2)); 

The one hidden station uses a Gaussian (Math.exp(-((f - s.f) / KW) ** 2)) instead. Its tails die fast, so you only find it if you're right on it.

2. Every station is always playing, on one shared clock

All stations start at once as looping buffers, each at its own phase offset, so turning back to a station drops you into the broadcast "already in progress" rather than restarting it:

src.start(now, ((now - t0) + s.phase * s.L) % s.L); 

Tuning never starts or stops anything. It only moves gains and filters.

3. Each station's chain: band-limit, then let the filter open as you tune in

buffer -> highpass 260 Hz -> lowpass -> waveshaper (soft clip) -> gain -> master 

Every animation frame, for each station with strength v:

s.g.gain.setTargetAtTime(Math.pow(v, 1.3) * 1.1, T, 0.05); // louder s.filt.frequency.setTargetAtTime(500 + 5200 * v * v, T, 0.05); // brighter 

Slightly off-station sounds muffled and distant. Dead-on, the lowpass opens to ~5.7 kHz and the voice becomes clear. setTargetAtTime gives smooth glides with no zipper noise as you drag.

4. Static that breathes

Static is 3 seconds of white noise (Math.random() * 2 - 1) looped through a wide bandpass. Three touches make it feel alive:

  • its level follows the inverse of the total signal, so static swells between stations;
  • a 0.23 Hz LFO wobbles the static gain, like the ionosphere fading in and out;
  • the bandpass centre also shifts with the dial (900 + f * 220 Hz), so the hiss changes colour as you turn.

5. The heterodyne whistle

Real receivers whistle when you're close to a carrier but not on it. That beat note is higher the further off you are. Faking it is one oscillator:

const off = Math.abs(f - nearest.f); whistle.frequency.setTargetAtTime(180 + off * 5200, T, 0.03); whistleG.gain.setTargetAtTime(v * (1 - v) * 0.18, T, 0.05); 

v * (1 - v) is zero far away and zero dead-on, and peaks halfway. So the whistle only shows up in the "almost" zone, and it guides you in by ear.

6. Text that tunes in too

Subtitles are garbled in proportion to signal: below a threshold, words are deterministically replaced with dashes, so the same words drop out each time instead of flickering randomly. The station's painting is cross-faded the same way, with a little jitter when the signal is weak.

Play it

It runs in any browser, on desktop (drag or arrow keys) or phone (drag the dial), and takes about 6-10 minutes to hear every island (headphones recommended):
https://playgama.ai/play/wydweupfpvcwcc

If you've built sound-driven UI in Web Audio, I'd like to hear how you handled smoothing at 60 fps. setTargetAtTime was enough here, but I'm not sure it scales.

原文:https://www.a8q9.cn/en/article/faking-a-shortwave-radio-in-the-browser-static-heterodyne-whistles-and-stations--7cf0e8.html

相关文章