diff --git a/frontend/src/shared/voice/voiceCues.ts b/frontend/src/shared/voice/voiceCues.ts index cbe11bac..7513f965 100644 --- a/frontend/src/shared/voice/voiceCues.ts +++ b/frontend/src/shared/voice/voiceCues.ts @@ -1,6 +1,6 @@ -// WhisperFlow-grade start/stop chimes, synthesized in WebAudio so they ship weightless and always -// match: a soft two-note rise on start ("I'm listening"), the mirrored fall on stop. Sine + gentle -// lowpass + fast attack / exponential release = crisp but soothing, never a system beep. +// WhisperFlow-grade start/stop cues, synthesized in WebAudio so they ship weightless and always +// match. Not a chime: a soft percussive "pop" (tiny click transient + falling tone), like a water +// drop. Start pops slightly higher than stop, so the pair reads as press/release, not as an alarm. let ctx: AudioContext | null = null; @@ -14,16 +14,38 @@ function ensureCtx(): AudioContext | null { } } -function blip(ac: AudioContext, freq: number, at: number, dur: number, peak: number): void { +// The transient: 6ms of bandpassed noise gives the pop its tactile "tick" edge. +function tick(ac: AudioContext, at: number, freq: number, peak: number): void { + const len = Math.floor(ac.sampleRate * 0.006); + const buf = ac.createBuffer(1, len, ac.sampleRate); + const data = buf.getChannelData(0); + for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len); + const src = ac.createBufferSource(); + src.buffer = buf; + const bp = ac.createBiquadFilter(); + bp.type = 'bandpass'; + bp.frequency.value = freq; + bp.Q.value = 1.2; + const gain = ac.createGain(); + gain.gain.value = peak; + src.connect(bp); + bp.connect(gain); + gain.connect(ac.destination); + src.start(at); +} + +// The body: a sine gliding down fast with an exponential decay reads as a "plop", not a beep. +function pop(ac: AudioContext, at: number, from: number, to: number, dur: number, peak: number): void { const osc = ac.createOscillator(); const gain = ac.createGain(); const lp = ac.createBiquadFilter(); lp.type = 'lowpass'; - lp.frequency.value = 2400; + lp.frequency.value = 3200; osc.type = 'sine'; - osc.frequency.setValueAtTime(freq, at); + osc.frequency.setValueAtTime(from, at); + osc.frequency.exponentialRampToValueAtTime(to, at + dur * 0.7); gain.gain.setValueAtTime(0, at); - gain.gain.linearRampToValueAtTime(peak, at + 0.012); + gain.gain.linearRampToValueAtTime(peak, at + 0.006); gain.gain.exponentialRampToValueAtTime(0.0004, at + dur); osc.connect(lp); lp.connect(gain); @@ -37,10 +59,10 @@ export function playVoiceCue(kind: 'start' | 'stop'): void { if (!ac) return; const t = ac.currentTime + 0.01; if (kind === 'start') { - blip(ac, 587, t, 0.16, 0.055); - blip(ac, 880, t + 0.085, 0.2, 0.05); + tick(ac, t, 2200, 0.05); + pop(ac, t, 760, 520, 0.11, 0.06); } else { - blip(ac, 880, t, 0.14, 0.045); - blip(ac, 587, t + 0.075, 0.22, 0.05); + tick(ac, t, 1600, 0.04); + pop(ac, t, 520, 360, 0.13, 0.055); } }