[eric] voice: WhisperFlow top waveform tab, synthesized start/stop chimes, polish capped at 900ms; settings: privacy + advanced unified onto the chip grammar

This commit is contained in:
ciregenz
2026-07-28 14:24:58 -07:00
parent f89c80faf7
commit 7112a8786e
5 changed files with 140 additions and 31 deletions
@@ -13,7 +13,7 @@ const ERASE_WORD = 'ERASE';
// The iOS Reset menu, two actions only: "Reset All Settings" (preferences back to defaults, your stuff + sign-in stay) and "Erase All Content and Settings" (factory wipe + relaunch). Flat rows, not a boxed "danger zone": red lives only on the destructive label, and the real friction is the typed-confirm in the dialog.
const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) => {
const c = useClaudeTokens();
const { sectionSx, labelSx, descSx } = styles;
const { labelSx, descSx, inlineRowSx, inlineRowLastSx } = styles;
const [resetOpen, setResetOpen] = useState(false);
const [eraseOpen, setEraseOpen] = useState(false);
@@ -108,29 +108,29 @@ const DataPrivacySection: React.FC<{ styles: SettingsStyles }> = ({ styles }) =>
borderColor: c.status.error,
'&:hover': { color: c.status.error, borderColor: c.status.error, bgcolor: c.status.errorBg },
};
const rowSx = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 3, py: 2 };
return (
<Box>
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Reset all settings</Typography>
<Typography sx={descSx}>Puts your preferences back to defaults. Your apps, chats, skills, and sign-in stay.</Typography>
</Box>
<Button variant="outlined" size="small" onClick={() => { setErr(null); setResetOpen(true); }} sx={rowBtnSx}>Reset</Button>
</Box>
<Box sx={{ ...rowSx, borderBottom: `1px solid ${c.border.subtle}` }}>
<Box>
<Box sx={inlineRowSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={labelSx}>Clear browsing data</Typography>
<Typography sx={descSx}>Signs you out of sites opened in browser cards and clears their cookies, cache, and local storage. Your chats, apps, and settings stay.</Typography>
</Box>
<Button variant="outlined" size="small" onClick={() => { setErr(null); setClearedOk(false); setClearOpen(true); }} sx={rowBtnSx}>Clear</Button>
</Box>
<Box sx={rowSx}>
<Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={{ ...labelSx, color: c.status.error }}>Erase all content and settings</Typography>
<Typography sx={descSx}>Removes every chat, app, skill, and setting and restarts OpenSwarm fresh. This can't be undone.</Typography>
</Box>
@@ -64,34 +64,23 @@ const GeneralAdvanced: React.FC<{
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={labelSx}>Version</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
{appVersion ?? '-'}
</Typography>
</Box>
<Typography sx={labelSx}>Version</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>{appVersion ?? '-'}</Typography>
</Box>
{buildLabel && (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mt: 1 }}>
<Typography sx={labelSx}>Build</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>{buildLabel}</Typography>
</Box>
)}
</Box>
{buildLabel && (
<Box sx={rowSx}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={labelSx}>Build</Typography>
<Typography sx={{ ...descSx, fontFamily: c.font.mono }}>
{buildLabel}
</Typography>
</Box>
</Box>
</Box>
)}
<SoftwareUpdateRow styles={styles} />
<TrustedFilePatterns />
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Box sx={inlineRowLastSx}>
<Box sx={{ mr: 3 }}>
<Typography sx={{ ...labelSx, mb: 0.25 }}>Onboarding tour</Typography>
<Typography sx={{ ...descSx, mb: 0 }}>
Re-run the Show me walkthrough at any time.
+67 -1
View File
@@ -98,6 +98,67 @@ const VoiceAurora: React.FC<{ volumeRef: React.MutableRefObject<number> }> = ({
);
};
// WhisperFlow's signature: a small capsule drops from the TOP edge while the mic is hot, carrying a
// live waveform. Canvas bars driven straight off the mic level ring buffer, imperative rAF only.
const BAR_COUNT = 26;
const VoiceTab: React.FC<{ volumeRef: React.MutableRefObject<number> }> = ({ volumeRef }) => {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const history = useRef<number[]>(new Array(BAR_COUNT).fill(0.06));
useEffect(() => {
let raf = 0;
let frame = 0;
const draw = (): void => {
const canvas = canvasRef.current;
if (canvas) {
const g = canvas.getContext('2d');
if (g) {
// Shift one bar every other frame so the wave scrolls readably at 60Hz input.
frame += 1;
if (frame % 2 === 0) {
history.current.push(Math.min(1, 0.08 + volumeRef.current * 1.6));
history.current.shift();
}
const w = canvas.width;
const h = canvas.height;
g.clearRect(0, 0, w, h);
const bw = w / BAR_COUNT;
for (let i = 0; i < BAR_COUNT; i++) {
const level = history.current[i];
const bh = Math.max(3, level * (h - 4));
const x = i * bw + bw * 0.25;
g.fillStyle = `rgba(255,255,255,${0.35 + level * 0.6})`;
const bwid = bw * 0.5;
const y = (h - bh) / 2;
g.beginPath();
g.roundRect(x, y, bwid, bh, bwid / 2);
g.fill();
}
}
}
raf = requestAnimationFrame(draw);
};
raf = requestAnimationFrame(draw);
return () => cancelAnimationFrame(raf);
}, [volumeRef]);
return (
<Box
sx={{
position: 'fixed', top: 10, left: '50%', zIndex: 2147483001, pointerEvents: 'none',
display: 'flex', alignItems: 'center', gap: 1, px: 1.5, py: 0.75, borderRadius: 999,
background: 'rgba(18,12,26,0.88)',
backdropFilter: 'blur(18px) saturate(150%)', WebkitBackdropFilter: 'blur(18px) saturate(150%)',
boxShadow: '0 8px 28px rgba(0,0,0,0.4)',
'@keyframes vtab-in': { from: { opacity: 0, transform: 'translate(-50%, -14px)' }, to: { opacity: 1, transform: 'translate(-50%, 0)' } },
animation: 'vtab-in 0.22s cubic-bezier(0.2, 0.8, 0.2, 1) both',
}}
>
<MicIcon sx={{ fontSize: 14, color: 'rgba(255,255,255,0.75)' }} />
<canvas ref={canvasRef} width={132} height={22} style={{ display: 'block', width: 132, height: 22 }} />
</Box>
);
};
// The whole point: dictation must never look like "nothing happened." This floats a small status
// card above the composer for every phase (listening, transcribing, downloading the model) and shows
// the transcript + whether it was pasted or just copied. Non-interactive, auto-dismisses.
@@ -124,7 +185,12 @@ const VoiceOverlay: React.FC = () => {
const live = state !== 'idle';
const visible = live || (showFeedback && !!feedback);
if (!visible) return null;
const aurora = state === 'recording' ? <VoiceAurora volumeRef={volumeRef} /> : null;
const aurora = state === 'recording' ? (
<>
<VoiceAurora volumeRef={volumeRef} />
<VoiceTab volumeRef={volumeRef} />
</>
) : null;
let content: React.ReactElement | null;
if (state === 'recording') {
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { API_BASE } from '@/shared/config';
import { encodeWav, VOICE_SAMPLE_RATE } from './encodeWav';
import { playVoiceCue } from './voiceCues';
import { injectAtFocus } from './injectAtFocus';
export type VoiceState = 'idle' | 'recording' | 'transcribing' | 'preparing';
@@ -117,6 +118,7 @@ export function useVoiceDictation() {
node.connect(ctx.destination);
recRef.current = { ctx, stream, node, source, chunks };
setState('recording');
playVoiceCue('start');
// Warm the model the moment recording begins so transcription is instant on stop.
void window.openswarm?.voiceWarmup?.();
} catch (err) {
@@ -131,6 +133,7 @@ export function useVoiceDictation() {
const stop = useCallback(async (): Promise<void> => {
if (stateRef.current !== 'recording') return;
const samples = teardown();
playVoiceCue('stop');
setState('transcribing');
try {
if (!samples || samples.length < VOICE_SAMPLE_RATE * 0.2) { setState('idle'); return; } // < 0.2s = a misfire
@@ -139,7 +142,12 @@ export function useVoiceDictation() {
if (res?.ok && res.text) {
// WhisperFlow-style cleanup: punctuation + filler words via the cheap aux tier, fail-open to
// the raw transcript on any error/timeout so dictation never breaks with the aux down.
const text = await polishText(res.text);
// Latency: short phrases don't need the aux cleanup (whisper's raw output is fine), and a
// slow aux must never hold the cursor hostage; 900ms is the most polish is allowed to cost.
const raw = res.text.trim();
const text = raw.split(/\s+/).length <= 6
? raw
: await Promise.race([polishText(raw), new Promise<string>((r) => window.setTimeout(() => r(raw), 900))]);
setLastText(text);
// Land the text where the user's cursor is: focused field, then focused browser page, then
// the OS paste fallback (other apps). The floating bubble is just confirmation, not the output.
+46
View File
@@ -0,0 +1,46 @@
// 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.
let ctx: AudioContext | null = null;
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;
}
}
function blip(ac: AudioContext, freq: number, at: 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;
osc.type = 'sine';
osc.frequency.setValueAtTime(freq, at);
gain.gain.setValueAtTime(0, at);
gain.gain.linearRampToValueAtTime(peak, at + 0.012);
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);
}
export function playVoiceCue(kind: 'start' | 'stop'): void {
const ac = ensureCtx();
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);
} else {
blip(ac, 880, t, 0.14, 0.045);
blip(ac, 587, t + 0.075, 0.22, 0.05);
}
}