A player with no audio.
Earshot is a podcast-network concept built as a Next.js static export. The whole identity hangs on one object — a seekable waveform deck that behaves like a real player without shipping a single audio file. These are the notes on how it works.
The idea
A podcast network sells something you can’t see, so most podcast sites fall back on cover-art grids and app-store badges. Earshot’s bet is the opposite: draw the sound. The hero is a waveform you can grab. The episode list is a broadcast log that feeds it. Even the hosts are rendered as their voiceprints instead of headshots — you’re here for their ears, not their faces.
The visual language follows the audio-nerd voice: paper and studio-ink flipped between themes, broadcast-coral for the hot side of the signal, teal for the monitoring side, Archivo set heavy like a flight-case stencil, and Space Mono for anything that behaves like a meter — timestamps, chapter labels, cadence tags.
The stack
- Next.js 14 (App Router) with
output: ‘export’. GitHub Pages serves static files only, so the build emits plain HTML intoout/. React earns its keep here: the timeline and the deck share one transport context, so “cue it up” anywhere loads the player everywhere. - The Pages recipe:
basePath: ‘/earshot’+assetPrefix+trailingSlash: trueinnext.config.mjs, copyout/todocs/, point Pages at it, and drop a.nojekyllso Jekyll leaves_next/alone. - Zero media files. Every visual is canvas, inline SVG or CSS. The waveform is procedural; the show art and voiceprints are deterministic SVG seeded per show and host.
- Archivo / Inter / Space Mono from Google Fonts with preconnect — display set at 800–900, meters in mono, everything else in Inter.
Signature technique — the waveform deck
There is no MP3. The waveform is a procedural model of spoken word: phrase-level energy shifts, a syllable-rate ripple, and occasional near-silent breaths, all driven by a seeded PRNG so each episode always renders the same “recording” — on the server, on the client, and in the tiny SVG thumbnails on the timeline:
// lib/data.ts — deterministic "spoken word" peaks
export function buildPeaks(seed: number, n = 720): number[] {
const rnd = mulberry32(seed);
const peaks: number[] = [];
let energy = 0.55 + rnd() * 0.25;
let pause = 0;
for (let i = 0; i < n; i++) {
if (pause > 0) { pause--; peaks.push(0.05 + rnd() * 0.05); continue; }
if (rnd() < 0.014) pause = 3 + Math.floor(rnd() * 12); // breaths
if (rnd() < 0.06) energy = 0.35 + rnd() * 0.6; // new phrase
const syllable = 0.55 + 0.45 * Math.sin(i * 0.9 + rnd() * 2);
peaks.push(Math.max(0.06, Math.min(1, energy * syllable * (0.72 + rnd() * 0.28))));
}
return peaks;
}The canvas draws mirrored bars — played side in coral, the rest in a theme-aware grey — plus chapter ticks and a playhead with a grab handle. Seeking is one projection: pointer x → fraction of width → seconds. The same element is a real role="slider", so arrow keys, Home/End and PageUp/Down scrub too, and aria-valuetext reads “12:34 of 41:07” to screen readers:
// components/WaveformPlayer.tsx — the whole seek model
const seekFromPointer = (clientX: number) => {
const rect = canvas.getBoundingClientRect();
const frac = clamp((clientX - rect.left) / rect.width, 0, 1);
seekTo(frac * durRef.current);
};
// the simulated transport: real time in, playhead out (~30fps draw)
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min(100, now - last); last = now;
if (playingRef.current) {
elapsedRef.current = Math.min(dur, elapsedRef.current + dt / 1000);
acc += dt;
if (acc >= 33 && visibleRef.current) { acc = 0; draw(now / 1000); }
}
};Play/pause drives that loop like a tape machine — a 41-minute episode really takes 41 minutes, which is exactly why the wave is so satisfying to grab instead. An opt-in “cue tone” goes one step further: a single WebAudio sine oscillator whose gain and pitch follow the peak under the playhead, so you can hear the waveform’s shape without any audio asset. It only exists after a click, and it dies with the pause button.
Details that matter
- Honest reduced motion. With
prefers-reduced-motion: reducethere is no rAF loop at all: the wave renders as one static frame, the play button yields to a plain instruction, and scrubbing — pointer or keyboard — still works. Motion is the garnish, not the interface. - Progressive enhancement. A synchronous script adds
.jsto<html>; hero clip-lines and reveals only hide under that class. Before hydration (or without JS) the deck shows a static SVG wave, so the page never renders a hole where the flourish should be. - One transport, whole page. The timeline’s “cue it up” buttons and the hero deck share a context. Cueing scrolls the deck into view (instantly under reduced motion), loads that episode’s seed, chapters and duration, and starts the playhead.
- Theme-aware canvas. The deck reads its colors from the same CSS custom properties as everything else, and a
MutationObserverondata-themerepaints the frame the moment you flip the toggle — the stored key isearshot-theme, applied inline in<head>before first paint. - Performance manners. DPR is capped at 1.5, the draw is throttled to ~30fps, and an
IntersectionObserverskips painting while the deck is off-screen.
Ship it on GitHub Pages
Once the export is configured, the deploy is four commands:
npm run build # next build → out/ (static export)
rm -rf docs && cp -r out docs # Pages serves /docs on main
touch docs/.nojekyll # keep _next/ out of Jekyll's mouth
gh api --method POST /repos/bswxyz/earshot/pages \
-f 'source[branch]=main' -f 'source[path]=/docs'With trailingSlash: true this page exports as /guide/index.html, so the URL resolves with no server-side routing. Local dev is npm run dev — note the basePath means the site serves at localhost:3000/earshot.