From b19e5da78ae2b788076ab4c29ccda30e04634518 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Wed, 5 Aug 2026 20:02:30 -0700 Subject: [PATCH] [eric] voice: capsule shrinks to Wispr scale and the bars get peak-meter attack --- frontend/src/shared/voice/VoiceOverlay.tsx | 225 ++++++------------ .../src/shared/voice/useVoiceDictation.ts | 20 +- 2 files changed, 90 insertions(+), 155 deletions(-) diff --git a/frontend/src/shared/voice/VoiceOverlay.tsx b/frontend/src/shared/voice/VoiceOverlay.tsx index b318ba61..9de0cc6a 100644 --- a/frontend/src/shared/voice/VoiceOverlay.tsx +++ b/frontend/src/shared/voice/VoiceOverlay.tsx @@ -1,143 +1,43 @@ import React, { useEffect, useRef, useState } from 'react'; import Box from '@mui/material/Box'; +import IconButton from '@mui/material/IconButton'; import CircularProgress from '@mui/material/CircularProgress'; import MicIcon from '@mui/icons-material/Mic'; +import CloseRoundedIcon from '@mui/icons-material/CloseRounded'; import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import ContentPasteRoundedIcon from '@mui/icons-material/ContentPasteRounded'; import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'; -import { useThemeAccent } from '@/shared/styles/ThemeContext'; import { useVoice } from './voiceContext'; -// WhisperFlow-style presence: while the mic is hot, an aurora breathes up from the bottom edge in -// the COMPLEMENT of the user's accent (opposite hue = always visible against their canvas, never -// camouflaged by it), as a three-hue gradient whose lobes morph with the live mic level. Imperative -// rAF writes only (opacity + transform), so 60Hz voice never re-renders React. +// WhisperFlow's recording grammar, and nothing else: one small capsule at bottom center holding +// cancel (X), a live waveform, and confirm (check). No aurora, no full-screen dressing; the capsule +// IS the "mic is hot" signal. Canvas bars are driven imperatively off the mic-level ref so 60Hz +// audio never re-renders React. +const BAR_COUNT = 18; -function hexToHue(hex: string): { h: number; s: number; l: number } { - const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); - if (!m) return { h: 250, s: 70, l: 60 }; - const n = parseInt(m[1], 16); - const r = ((n >> 16) & 255) / 255, g = ((n >> 8) & 255) / 255, b = (n & 255) / 255; - const max = Math.max(r, g, b), min = Math.min(r, g, b); - const l = (max + min) / 2; - if (max === min) return { h: 0, s: 0, l: l * 100 }; - const d = max - min; - const sat = l > 0.5 ? d / (2 - max - min) : d / (max + min); - let h = 0; - if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60; - else if (max === g) h = ((b - r) / d + 2) * 60; - else h = ((r - g) / d + 4) * 60; - return { h, s: sat * 100, l: l * 100 }; -} - -// Interleaved wisps, like colored gases: neighbors always carry a DIFFERENT hue and overlap ~70%, -// so mixture happens everywhere instead of three tinted patches sitting in a row. -const LOBES = [ - { left: '18%', width: '80vw', height: 200, drift: 0.9, phase: 0.0, hue: 0 }, - { left: '34%', width: '95vw', height: 165, drift: 0.65, phase: 2.1, hue: 2 }, - { left: '47%', width: '85vw', height: 210, drift: 1.1, phase: 4.2, hue: 1 }, - { left: '60%', width: '100vw', height: 170, drift: 0.8, phase: 1.3, hue: 0 }, - { left: '73%', width: '85vw', height: 205, drift: 1.2, phase: 3.4, hue: 2 }, - { left: '88%', width: '78vw', height: 175, drift: 0.7, phase: 5.1, hue: 1 }, -]; - -const VoiceAurora: React.FC<{ volumeRef: React.MutableRefObject }> = ({ volumeRef }) => { - const { accent } = useThemeAccent(); - const lobeRefs = useRef>([]); - const smooth = useRef([0, 0, 0]); - useEffect(() => { - const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - let raf = 0; - const tick = (): void => { - const v = Math.min(1, volumeRef.current); - const t = performance.now() / 1000; - LOBES.forEach((lobe, i) => { - const el = lobeRefs.current[i]; - if (!el) return; - // Lerp toward the live level so the shape glides instead of jittering; a slow sinusoid keeps - // it undulating at steady volume, and volume swells both the wave and the lobe height. - smooth.current[i] += (v - smooth.current[i]) * 0.16; - const sv = smooth.current[i]; - const wave = reduced ? 0 : Math.sin(t * lobe.drift + lobe.phase) * (0.08 + sv * 0.1); - el.style.opacity = String(0.3 + sv * 0.6); - el.style.transform = `translateX(-50%) scale(${1 + wave * 0.6}, ${0.45 + sv * 1.25 + wave})`; - }); - raf = requestAnimationFrame(tick); - }; - raf = requestAnimationFrame(tick); - return () => cancelAnimationFrame(raf); - }, [volumeRef]); - - // Opposite side of the wheel from the chosen accent, with two flanking hues for a real gradient. - const { h } = hexToHue(accent || '#6b62f0'); - // Wide, softly randomized spread around the complement: three tight neon bands read segmented - // (the red/orange/yellow stripe complaint); jittered hues + lower saturation blend like a real - // aurora while staying distinguishable. Jitter is per-mount so each dictation feels alive. - const jitter = React.useMemo(() => [Math.random() * 16 - 8, Math.random() * 12 - 6, Math.random() * 16 - 8], []); - const c0 = `hsl(${(h + 158 + jitter[0] + 360) % 360} 68% 63%)`; - const c1 = `hsl(${(h + 180 + jitter[1]) % 360} 72% 60%)`; - const c2 = `hsl(${(h + 202 + jitter[2]) % 360} 68% 65%)`; - return ( -
- {LOBES.map((lobe, i) => { - const col = [c0, c1, c2][lobe.hue]; - return ( -
{ lobeRefs.current[i] = el; }} - style={{ - position: 'absolute', bottom: -40, left: lobe.left, width: lobe.width, height: lobe.height, - transform: 'translateX(-50%) scale(1, 0.45)', transformOrigin: 'bottom center', - background: `radial-gradient(ellipse at 50% 100%, ${col} 0%, ${col}00 70%)`, - filter: 'blur(52px)', opacity: 0.17, willChange: 'transform, opacity', - }} - /> - ); - })} -
-
- ); -}; - -// 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 Waveform: React.FC<{ volumeRef: React.MutableRefObject }> = ({ volumeRef }) => { const canvasRef = useRef(null); - const history = useRef(new Array(BAR_COUNT).fill(0.06)); + const history = useRef(new Array(BAR_COUNT).fill(0.05)); 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(); - } + // Every frame, with a perceptual curve: speech snaps the bars up the way Wispr's do. + history.current.push(Math.min(1, Math.pow(volumeRef.current * 2.1, 0.75))); + 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; + const level = Math.max(0.08, history.current[i]); + const bh = Math.max(2, level * (h - 2)); + const x = i * bw + bw * 0.3; g.fillStyle = `rgba(255,255,255,${0.35 + level * 0.6})`; - const bwid = bw * 0.5; + const bwid = bw * 0.4; const y = (h - bh) / 2; g.beginPath(); g.roundRect(x, y, bwid, bh, bwid / 2); @@ -150,27 +50,57 @@ const VoiceTab: React.FC<{ volumeRef: React.MutableRefObject }> = ({ vol raf = requestAnimationFrame(draw); return () => cancelAnimationFrame(raf); }, [volumeRef]); - return ( - - - - - ); + 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. +const VoiceCapsule: React.FC<{ + transcribing: boolean; + volumeRef: React.MutableRefObject; + onCancel: () => void; + onConfirm: () => void; +}> = ({ transcribing, volumeRef, onCancel, onConfirm }) => ( + e.preventDefault()} + sx={{ + position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', zIndex: 2147483001, + display: 'flex', alignItems: 'center', gap: 0.75, pl: 0.5, pr: 0.5, py: 0.4, borderRadius: 999, + background: 'rgba(18,16,24,0.92)', + backdropFilter: 'blur(18px) saturate(150%)', WebkitBackdropFilter: 'blur(18px) saturate(150%)', + boxShadow: '0 10px 32px rgba(0,0,0,0.45), inset 0 0 0 1px rgba(255,255,255,0.07)', + '@keyframes vcap-in': { from: { opacity: 0, transform: 'translate(-50%, 10px) scale(0.96)' }, to: { opacity: 1, transform: 'translate(-50%, 0) scale(1)' } }, + animation: 'vcap-in 0.18s cubic-bezier(0.2, 0.8, 0.2, 1) both', + }} + > + + + + + {transcribing + ? + : } + + + + + +); + +// The floating status card above the capsule: live transcript while speaking, download progress, +// and terminal feedback (clipboard fallback, errors). Non-interactive, auto-dismisses. const FEEDBACK_MS = 4500; function feedbackIcon(icon: string): React.ReactElement { @@ -199,7 +129,7 @@ const LiveTranscript: React.FC<{ committed: string; tentative: string }> = ({ co }; const VoiceOverlay: React.FC = () => { - const { state, pct, feedback, partial, volumeRef } = useVoice(); + const { state, pct, feedback, partial, volumeRef, confirmRecording, cancelRecording } = useVoice(); const [showFeedback, setShowFeedback] = useState(false); useEffect(() => { @@ -212,25 +142,16 @@ const VoiceOverlay: React.FC = () => { const live = state !== 'idle'; const visible = live || (showFeedback && !!feedback); if (!visible) return null; - const aurora = state === 'recording' ? ( - <> - - - + + const capsule = (state === 'recording' || state === 'transcribing') ? ( + ) : null; const hasPartial = !!partial && !!(partial.committed || partial.tentative); let content: React.ReactElement | null; - if (state === 'recording') { - // The aurora says "listening"; the card appears only once there are live words to show. + if (state === 'recording' || state === 'transcribing') { + // The capsule says "listening"; the card appears only once there are live words to show. content = hasPartial ? : null; - } else if (state === 'transcribing') { - content = ( - <> - - {hasPartial ? : Transcribing} - - ); } else if (state === 'preparing') { content = (<>Downloading voice model {pct}%); } else if (feedback) { @@ -243,12 +164,12 @@ const VoiceOverlay: React.FC = () => { ); } else { - return null; + content = null; } return ( <> - {aurora} + {capsule} {content && ( volumeRef.current + ? volumeRef.current * 0.35 + level * 0.65 + : volumeRef.current * 0.78 + level * 0.22; if (endpointer && endpointer.push(data) !== 'listening') void stopRef.current?.(); }); source.connect(capture.node); @@ -245,6 +249,16 @@ export function useVoiceDictation() { stopRef.current = stop; + // The capsule's X: throw the take away. No transcription, no cue, straight back to idle. + const cancel = useCallback((): void => { + if (stateRef.current !== 'recording') return; + const rec = recRef.current; + if (rec?.streaming) window.openswarm?.voiceStreamCancel?.(); + teardown(); + setPartial(null); + setState('idle'); + }, [teardown]); + const toggle = useCallback((): void => { if (stateRef.current === 'recording') void stop(); else if (stateRef.current === 'idle') void start(); @@ -277,5 +291,5 @@ export function useVoiceDictation() { // A dangling recorder (unmount mid-capture) must release the mic. useEffect(() => () => { teardown(); }, [teardown]); - return { state, lastText, error, pct, feedback, partial, toggle, start, stop, volumeRef }; + return { state, lastText, error, pct, feedback, partial, toggle, start, stop, cancel, volumeRef }; }