From 7112a8786e95ad301e9488c65dfaff79499e8eca Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 14:24:58 -0700 Subject: [PATCH] [eric] voice: WhisperFlow top waveform tab, synthesized start/stop chimes, polish capped at 900ms; settings: privacy + advanced unified onto the chip grammar --- .../sections/general/DataPrivacySection.tsx | 16 ++--- .../sections/general/GeneralAdvanced.tsx | 31 +++------ frontend/src/shared/voice/VoiceOverlay.tsx | 68 ++++++++++++++++++- .../src/shared/voice/useVoiceDictation.ts | 10 ++- frontend/src/shared/voice/voiceCues.ts | 46 +++++++++++++ 5 files changed, 140 insertions(+), 31 deletions(-) create mode 100644 frontend/src/shared/voice/voiceCues.ts diff --git a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx index ea416943..3a1bbfb3 100644 --- a/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx +++ b/frontend/src/app/pages/Settings/sections/general/DataPrivacySection.tsx @@ -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 ( - - + + Reset all settings Puts your preferences back to defaults. Your apps, chats, skills, and sign-in stay. - - + + Clear browsing data Signs you out of sites opened in browser cards and clears their cookies, cache, and local storage. Your chats, apps, and settings stay. - - + + Erase all content and settings Removes every chat, app, skill, and setting and restarts OpenSwarm fresh. This can't be undone. diff --git a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx index 268aafe7..5140f63a 100644 --- a/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx +++ b/frontend/src/app/pages/Settings/sections/general/GeneralAdvanced.tsx @@ -64,34 +64,23 @@ const GeneralAdvanced: React.FC<{ - - Version - - {appVersion ?? '-'} - - + Version + {appVersion ?? '-'} + {buildLabel && ( + + Build + {buildLabel} + + )} - {buildLabel && ( - - - - Build - - {buildLabel} - - - - - )} - - - + + Onboarding tour Re-run the Show me walkthrough at any time. diff --git a/frontend/src/shared/voice/VoiceOverlay.tsx b/frontend/src/shared/voice/VoiceOverlay.tsx index 7a6041d4..40d9d721 100644 --- a/frontend/src/shared/voice/VoiceOverlay.tsx +++ b/frontend/src/shared/voice/VoiceOverlay.tsx @@ -98,6 +98,67 @@ const VoiceAurora: React.FC<{ volumeRef: React.MutableRefObject }> = ({ ); }; +// 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 }> = ({ volumeRef }) => { + const canvasRef = useRef(null); + const history = useRef(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 ( + + + + + ); +}; + // 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' ? : null; + const aurora = state === 'recording' ? ( + <> + + + + ) : null; let content: React.ReactElement | null; if (state === 'recording') { diff --git a/frontend/src/shared/voice/useVoiceDictation.ts b/frontend/src/shared/voice/useVoiceDictation.ts index 6888a79a..160935df 100644 --- a/frontend/src/shared/voice/useVoiceDictation.ts +++ b/frontend/src/shared/voice/useVoiceDictation.ts @@ -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 => { 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((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. diff --git a/frontend/src/shared/voice/voiceCues.ts b/frontend/src/shared/voice/voiceCues.ts new file mode 100644 index 00000000..cbe11bac --- /dev/null +++ b/frontend/src/shared/voice/voiceCues.ts @@ -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); + } +}