From f89c80faf70b2b4c1d525f4cfbad69005a5612b3 Mon Sep 17 00:00:00 2001 From: ciregenz Date: Tue, 28 Jul 2026 14:17:10 -0700 Subject: [PATCH] [eric] voice: aurora speaks in the accent's complement (three-hue gradient, volume-morphing lobes with smoothing) --- frontend/src/shared/voice/VoiceOverlay.tsx | 97 +++++++++++++++++----- 1 file changed, 76 insertions(+), 21 deletions(-) diff --git a/frontend/src/shared/voice/VoiceOverlay.tsx b/frontend/src/shared/voice/VoiceOverlay.tsx index 2fef3f29..7a6041d4 100644 --- a/frontend/src/shared/voice/VoiceOverlay.tsx +++ b/frontend/src/shared/voice/VoiceOverlay.tsx @@ -8,38 +8,93 @@ 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 accent-tinted aurora breathes up from the -// bottom edge, its height and glow riding the live mic level. Imperative rAF writes only (opacity + -// transform on a fixed layer), so 60Hz voice never re-renders React. +// 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. + +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 }; +} + +const LOBES = [ + { left: '14%', width: '58vw', drift: 0.9, phase: 0.0 }, + { left: '50%', width: '74vw', drift: 0.7, phase: 2.1 }, + { left: '86%', width: '52vw', drift: 1.15, phase: 4.2 }, +]; + const VoiceAurora: React.FC<{ volumeRef: React.MutableRefObject }> = ({ volumeRef }) => { const { accent } = useThemeAccent(); - const ref = useRef(null); + 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 el = ref.current; - if (el) { - const v = volumeRef.current; - el.style.opacity = String(0.35 + v * 0.65); - el.style.transform = `scaleY(${0.55 + v * 1.1})`; - } + 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]); - const a = accent || '#6b62f0'; + + // Opposite side of the wheel from the chosen accent, with two flanking hues for a real gradient. + const { h } = hexToHue(accent || '#6b62f0'); + const c0 = `hsl(${(h + 156) % 360} 92% 62%)`; + const c1 = `hsl(${(h + 180) % 360} 94% 58%)`; + const c2 = `hsl(${(h + 204) % 360} 92% 64%)`; return ( -
+
+ {LOBES.map((lobe, i) => { + const col = [c0, c1, c2][i]; + return ( +
{ lobeRefs.current[i] = el; }} + style={{ + position: 'absolute', bottom: -40, left: lobe.left, width: lobe.width, height: 190, + transform: 'translateX(-50%) scale(1, 0.45)', transformOrigin: 'bottom center', + background: `radial-gradient(ellipse at 50% 100%, ${col} 0%, ${col}00 70%)`, + filter: 'blur(26px)', opacity: 0.3, willChange: 'transform, opacity', + }} + /> + ); + })} +
+
); };