mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-08-24 05:22:22 +02:00
[eric] voice: start/stop cues are Material's forward/backward-selection-minimal (CC BY 4.0), Eric's pick
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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 */ }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user