Add typewriter clatter during text reveal

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 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 20:24:06 +02:00
co-authored by Claude Opus 4.8
parent 6d4026476e
commit 80e5f548ac
2 changed files with 34 additions and 0 deletions
+27
View File
@@ -10,6 +10,17 @@ function audioContext() {
return ctx 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 const music = typeof Audio !== 'undefined' ? new Audio() : null
if (music) music.loop = true if (music) music.loop = true
let currentUrl: string | null = null let currentUrl: string | null = null
@@ -70,6 +81,22 @@ export const audio = {
osc.connect(gain).connect(context.destination) osc.connect(gain).connect(context.destination)
osc.start(now); osc.stop(now + 0.18) 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 the context and retry pending music on a user gesture.
resume() { resume() {
void audioContext()?.resume?.() void audioContext()?.resume?.()
+7
View File
@@ -84,6 +84,13 @@ export function DialoguePlayer({ node, onExit, inline, startId }: { node: { utte
return () => window.clearInterval(id) return () => window.clearInterval(id)
}, [currentId, fullText, reduced]) // eslint-disable-line react-hooks/exhaustive-deps }, [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) => { const pick = (choice: RuntimeUtterance) => {
if (!inline) audio.sfx('choice') if (!inline) audio.sfx('choice')
if (choice.childIds.length > 0) setCurrentId(choice.childIds[0]) if (choice.childIds.length > 0) setCurrentId(choice.childIds[0])