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:
@@ -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?.()
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|||||||
Reference in New Issue
Block a user