From 80e5f548acccc02b2d0274d473380a704541bd88 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Tue, 18 Aug 2026 20:24:06 +0200 Subject: [PATCH] Add typewriter clatter during text reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dry synthesized key-clack (filtered noise burst with pitch jitter) plays as each line types out — every other non-space character — for a detective-terminal feel. Skipped in the editor preview and when reduced-motion is set. Co-Authored-By: Claude Opus 4.8 --- src/audio.ts | 27 +++++++++++++++++++++++++++ src/narrative.tsx | 7 +++++++ 2 files changed, 34 insertions(+) diff --git a/src/audio.ts b/src/audio.ts index 1ac5a36..03a86d9 100644 --- a/src/audio.ts +++ b/src/audio.ts @@ -10,6 +10,17 @@ function audioContext() { return ctx } +let noiseBuffer: AudioBuffer | null = null +function noise(context: AudioContext) { + if (!noiseBuffer) { + const length = Math.floor(context.sampleRate * 0.05) + noiseBuffer = context.createBuffer(1, length, context.sampleRate) + const data = noiseBuffer.getChannelData(0) + for (let i = 0; i < length; i++) data[i] = Math.random() * 2 - 1 + } + return noiseBuffer +} + const music = typeof Audio !== 'undefined' ? new Audio() : null if (music) music.loop = true let currentUrl: string | null = null @@ -70,6 +81,22 @@ export const audio = { osc.connect(gain).connect(context.destination) osc.start(now); osc.stop(now + 0.18) }, + // A dry typewriter key-clack, for the text-reveal clatter. Short filtered noise + // burst with slight pitch jitter so successive keys differ. + type() { + if (muted) return + const context = audioContext() + if (!context) return + if (context.state === 'suspended') void context.resume() + const src = context.createBufferSource(); src.buffer = noise(context) + const filter = context.createBiquadFilter(); filter.type = 'bandpass'; filter.frequency.value = 1500 + Math.random() * 900; filter.Q.value = 0.9 + const gain = context.createGain() + const now = context.currentTime + gain.gain.setValueAtTime(0.06, now) + gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.028) + src.connect(filter).connect(gain).connect(context.destination) + src.start(now); src.stop(now + 0.04) + }, // Resume the context and retry pending music on a user gesture. resume() { void audioContext()?.resume?.() diff --git a/src/narrative.tsx b/src/narrative.tsx index dcc0f8a..9b4a33f 100644 --- a/src/narrative.tsx +++ b/src/narrative.tsx @@ -84,6 +84,13 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte return () => window.clearInterval(id) }, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps + // Typewriter clatter as characters are revealed (every other non-space char). + useEffect(() => { + if (inline || charCount === 0 || charCount > fullText.length) return + const ch = fullText[charCount - 1] + if (ch && ch !== ' ' && charCount % 2 === 0) audio.type() + }, [charCount]) // eslint-disable-line react-hooks/exhaustive-deps + const pick = (choice: RuntimeUtterance) => { if (!inline) audio.sfx('choice') if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])