[eric] voice: start/stop cues are Material's forward/backward-selection-minimal (CC BY 4.0), Eric's pick

This commit is contained in:
ciregenz
2026-08-05 19:54:33 -07:00
parent 1ad97f4dd2
commit f7afffba1f
2 changed files with 25 additions and 61 deletions
File diff suppressed because one or more lines are too long
+20 -61
View File
@@ -1,68 +1,27 @@
// 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.
import { VOICE_CUE_START, VOICE_CUE_STOP } from './voiceCueSounds';
let ctx: AudioContext | null = null;
// Eric picked these from Google's Material product sound set after a bake-off against Wispr Flow's
// real cues; the synth versions never survived an ear test. Files are embedded data URIs (see
// voiceCueSounds.ts), pre-instantiated so playback is instant on the press.
function ensureCtx(): AudioContext | null {
try {
if (!ctx || ctx.state === 'closed') ctx = new AudioContext();
if (ctx.state === 'suspended') void ctx.resume();
return ctx;
} catch {
return null;
const CUE_VOLUME = 0.35;
const p_players: Record<'start' | 'stop', HTMLAudioElement | null> = { start: null, stop: null };
function player(kind: 'start' | 'stop'): HTMLAudioElement {
let a = p_players[kind];
if (!a) {
a = new Audio(kind === 'start' ? VOICE_CUE_START : VOICE_CUE_STOP);
a.volume = CUE_VOLUME;
p_players[kind] = a;
}
}
// 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 = 3200;
osc.type = 'sine';
osc.frequency.setValueAtTime(from, at);
osc.frequency.exponentialRampToValueAtTime(to, at + dur * 0.7);
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(peak, at + 0.006);
gain.gain.exponentialRampToValueAtTime(0.0004, at + dur);
osc.connect(lp);
lp.connect(gain);
gain.connect(ac.destination);
osc.start(at);
osc.stop(at + dur + 0.02);
return a;
}
export function playVoiceCue(kind: 'start' | 'stop'): void {
const ac = ensureCtx();
if (!ac) return;
const t = ac.currentTime + 0.01;
if (kind === 'start') {
tick(ac, t, 2200, 0.05);
pop(ac, t, 760, 520, 0.11, 0.06);
} else {
tick(ac, t, 1600, 0.04);
pop(ac, t, 520, 360, 0.13, 0.055);
}
try {
const a = player(kind);
a.currentTime = 0;
void a.play();
} catch { /* a missing audio device must never break dictation */ }
}