@tduncan / Cortana / commits / ef38958

feat: implement OrbWidget with a simplified and optimized GLSL fragment shader for voice visualization

abdulmominsakib committed Aug 5, 2026 at 12:53 UTC ef38958a4b90ec6d1bcecc9e359e603003c84df2
4 files changed +302 -315
assets/shaders/orb.frag
+76 -259
@@ -1,282 +1,99 @@
1 #version 460 core
2 -
2 precision highp float;
3
4 #include <flutter/runtime_effect.glsl>
5
7 -out vec4 fragColor;
8 -
9 -uniform vec2 uResolution;
6 +// Uniform layout (must match the order .setFloat() is called in Dart):
7 +// 0-1: uSize (vec2)
8 +// 2: uTime
9 +// 3-5: uBaseColor (vec3, outer/base color of the orb)
10 +// 6-8: uGlowColor (vec3, inner glow color)
11 +uniform vec2 uSize;
12 uniform float uTime;
11 -uniform float uMicLevel;
12 -uniform float uPhase;
13 -uniform vec4 uAccentColor;
14 -uniform vec4 uSecondaryColor;
15 -uniform float uIsDark;
16 -uniform float uEnergy;
17 -uniform float uProcessingWeight;
18 -uniform float uSpeakingWeight;
19 -uniform float uErrorWeight;
20 -uniform float uResponseProgress;
21 -
22 -const float PI = 3.14159265359;
23 -
24 -vec3 hash3(vec3 p) {
25 - p = vec3(
26 - dot(p, vec3(127.1, 311.7, 74.7)),
27 - dot(p, vec3(269.5, 183.3, 246.1)),
28 - dot(p, vec3(113.5, 271.9, 124.6))
29 - );
30 - return -1.0 + 2.0 * fract(sin(p) * 43758.5453123);
31 -}
13 +uniform vec3 uBaseColor;
14 +uniform vec3 uGlowColor;
15
33 -float noise3d(vec3 p) {
34 - vec3 cell = floor(p);
35 - vec3 local = fract(p);
36 - vec3 curve = local * local * (3.0 - 2.0 * local);
37 -
38 - return mix(
39 - mix(
40 - mix(
41 - dot(hash3(cell), local),
42 - dot(hash3(cell + vec3(1.0, 0.0, 0.0)), local - vec3(1.0, 0.0, 0.0)),
43 - curve.x
44 - ),
45 - mix(
46 - dot(hash3(cell + vec3(0.0, 1.0, 0.0)), local - vec3(0.0, 1.0, 0.0)),
47 - dot(hash3(cell + vec3(1.0, 1.0, 0.0)), local - vec3(1.0, 1.0, 0.0)),
48 - curve.x
49 - ),
50 - curve.y
51 - ),
52 - mix(
53 - mix(
54 - dot(hash3(cell + vec3(0.0, 0.0, 1.0)), local - vec3(0.0, 0.0, 1.0)),
55 - dot(hash3(cell + vec3(1.0, 0.0, 1.0)), local - vec3(1.0, 0.0, 1.0)),
56 - curve.x
57 - ),
58 - mix(
59 - dot(hash3(cell + vec3(0.0, 1.0, 1.0)), local - vec3(0.0, 1.0, 1.0)),
60 - dot(hash3(cell + vec3(1.0, 1.0, 1.0)), local - vec3(1.0, 1.0, 1.0)),
61 - curve.x
62 - ),
63 - curve.y
64 - ),
65 - curve.z
66 - );
67 -}
68 -
69 -float fbm3d(vec3 p) {
70 - float value = 0.0;
71 - float amplitude = 0.5;
72 - vec3 samplePoint = p;
16 +out vec4 fragColor;
17
74 - // Four octaves keep the orb fluid while remaining safe for mobile GPUs.
75 - for (int octave = 0; octave < 4; octave++) {
76 - value += amplitude * noise3d(samplePoint);
77 - samplePoint = samplePoint * 2.03 + vec3(0.13, -0.17, 0.11);
78 - amplitude *= 0.5;
79 - }
80 - return value;
18 +// ---- Simplex noise (Ashima Arts, MIT licensed pattern) ----
19 +vec3 mod289(vec3 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
20 +vec2 mod289(vec2 x) { return x - floor(x * (1.0 / 289.0)) * 289.0; }
21 +vec3 permute(vec3 x) { return mod289(((x * 34.0) + 1.0) * x); }
22 +
23 +float snoise(vec2 v) {
24 + const vec4 C = vec4(0.211324865405187, 0.366025403784439,
25 + -0.577350269189626, 0.024390243902439);
26 + vec2 i = floor(v + dot(v, C.yy));
27 + vec2 x0 = v - i + dot(i, C.xx);
28 + vec2 i1 = (x0.x > x0.y) ? vec2(1.0, 0.0) : vec2(0.0, 1.0);
29 + vec4 x12 = x0.xyxy + C.xxzz;
30 + x12.xy -= i1;
31 + i = mod289(i);
32 + vec3 p = permute(permute(i.y + vec3(0.0, i1.y, 1.0))
33 + + i.x + vec3(0.0, i1.x, 1.0));
34 + vec3 m = max(0.5 - vec3(dot(x0, x0), dot(x12.xy, x12.xy), dot(x12.zw, x12.zw)), 0.0);
35 + m = m * m;
36 + m = m * m;
37 + vec3 x = 2.0 * fract(p * C.www) - 1.0;
38 + vec3 h = abs(x) - 0.5;
39 + vec3 ox = floor(x + 0.5);
40 + vec3 a0 = x - ox;
41 + m *= 1.79284291400159 - 0.85373472095314 * (a0 * a0 + h * h);
42 + vec3 g;
43 + g.x = a0.x * x0.x + h.x * x0.y;
44 + g.yz = a0.yz * x12.xz + h.yz * x12.yw;
45 + return 130.0 * dot(m, g);
46 }
47
83 -float thinBand(float wave, float width, float softness) {
84 - return 1.0 - smoothstep(width, width + softness, abs(sin(wave)));
48 +float fbm(vec2 p) {
49 + float v = 0.0;
50 + float amp = 0.5;
51 + for (int i = 0; i < 5; i++) {
52 + v += amp * snoise(p);
53 + p *= 2.0;
54 + amp *= 0.5;
55 + }
56 + return v;
57 }
58
59 void main() {
88 - vec2 fragCoord = FlutterFragCoord().xy;
89 - float shortSide = min(uResolution.x, uResolution.y);
90 - vec2 point = (fragCoord - uResolution * 0.5) / shortSide;
91 - float pixel = 1.25 / shortSide;
92 -
93 - float dark = step(0.5, uIsDark);
94 - float responseCadence =
95 - 0.5 +
96 - 0.30 * sin(uTime * 7.4 + uResponseProgress * PI * 18.0) +
97 - 0.20 * sin(uTime * 12.7 - uResponseProgress * PI * 11.0);
98 - responseCadence = smoothstep(0.18, 0.86, responseCadence);
99 -
100 - float activity = clamp(
101 - max(uEnergy, uMicLevel * (1.0 - uSpeakingWeight)) +
102 - uSpeakingWeight * responseCadence * 0.22,
103 - 0.0,
104 - 1.0
105 - );
106 -
107 - // Keep the silhouette mathematically circular. Voice activity changes the
108 - // orb's overall radius, while motion stays inside the surface so speech
109 - // never makes the edge look flattened or lopsided.
110 - vec2 shapePoint = point;
111 - float slowTime = uTime * (
112 - 0.115 +
113 - 0.065 * uProcessingWeight +
114 - 0.005 * uPhase
115 - );
116 - float breathing = sin(uTime * 1.35 + uResponseProgress * PI * 2.0);
117 - float radius =
118 - 0.378 +
119 - activity * 0.010 +
120 - breathing * (0.0015 + activity * 0.0015) -
121 - uProcessingWeight * 0.004 -
122 - uErrorWeight * 0.028;
123 - float signedDistance = length(shapePoint) - radius;
124 -
125 - float bodyMask = smoothstep(pixel * 3.6, -pixel * 3.6, signedDistance);
126 - float haloMask = exp(-max(signedDistance, 0.0) * mix(25.0, 20.0, dark));
127 - haloMask *= 1.0 - smoothstep(
128 - radius + 0.018,
129 - radius + 0.145,
130 - length(shapePoint)
131 - );
132 -
133 - vec2 orbPoint = shapePoint / max(radius, 0.001);
134 - float orbDistance = length(orbPoint);
135 - float edge = smoothstep(0.42, 0.96, orbDistance);
136 - float edgeGlow = pow(clamp(orbDistance, 0.0, 1.0), 5.0);
137 -
138 - // Reconstruct the front half of a sphere from the circular silhouette.
139 - // Object-space lighting supplies depth without changing the round edge.
140 - float surfaceZ = sqrt(max(0.0, 1.0 - min(dot(orbPoint, orbPoint), 1.0)));
141 - vec3 surfaceNormal = normalize(vec3(orbPoint, surfaceZ + 0.001));
142 - vec3 lightDirection = normalize(vec3(-0.52, -0.46, 0.78));
143 - vec3 viewDirection = vec3(0.0, 0.0, 1.0);
144 - float diffuseLight = smoothstep(
145 - -0.42,
146 - 0.88,
147 - dot(surfaceNormal, lightDirection)
148 - );
149 - float centerVolume = pow(surfaceZ, 0.62);
150 - vec3 halfVector = normalize(lightDirection + viewDirection);
151 - float softSheen = pow(
152 - max(dot(surfaceNormal, halfVector), 0.0),
153 - mix(5.0, 7.0, dark)
154 - );
155 -
156 - // Layered curved coordinates create the wrapped, hair-like fibers in the
157 - // reference. They move slowly and remain visible throughout every phase.
158 - float flowNoise = fbm3d(vec3(
159 - orbPoint * 1.45,
160 - slowTime * 1.35
161 - ));
162 - float primaryCurve =
163 - orbPoint.y +
164 - 0.31 * orbPoint.x * orbPoint.x -
165 - 0.13 * orbPoint.x +
166 - 0.055 * sin(orbPoint.x * 3.7 - uTime * 0.19) +
167 - flowNoise * (0.045 + activity * 0.018);
168 - float secondaryCurve =
169 - orbPoint.y * 0.88 +
170 - orbPoint.x * 0.27 +
171 - 0.19 * orbPoint.x * orbPoint.x +
172 - 0.045 * sin(orbPoint.x * 4.4 + uTime * 0.14) -
173 - flowNoise * 0.035;
174 -
175 - float primaryFlow = primaryCurve * PI * 4.65 + uTime * 0.14;
176 - float secondaryFlow = secondaryCurve * PI * 3.55 - uTime * 0.11;
177 - float silkBands = thinBand(primaryFlow, 0.060, 0.185);
178 - float broadSilk = thinBand(primaryFlow + 0.18, 0.19, 0.31);
179 - float fineBands = thinBand(secondaryFlow + 1.15, 0.042, 0.135);
180 -
181 - float topCoverage = 1.0 - smoothstep(-0.56, 0.76, orbPoint.y);
182 - float leftCoverage = 1.0 - smoothstep(-0.82, 0.18, orbPoint.x);
183 - float ribbonCoverage = clamp(
184 - edge * (0.34 + topCoverage * 0.72 + leftCoverage * 0.28),
185 - 0.0,
186 - 1.0
187 - );
188 - float ribbonLight = (
189 - silkBands * (0.82 + activity * 0.28) +
190 - broadSilk * 0.20 +
191 - fineBands * 0.30
192 - ) * ribbonCoverage;
193 - // Fibres facing the light are clearer and those rolling around the shaded
194 - // side recede, which makes them feel embedded in a curved surface.
195 - ribbonLight *= 0.58 + diffuseLight * 0.31 + centerVolume * 0.11;
196 -
197 - vec3 accent = clamp(uAccentColor.rgb, 0.0, 1.0);
198 - vec3 secondary = clamp(uSecondaryColor.rgb, 0.0, 1.0);
199 -
200 - vec3 lightPearl = vec3(0.965, 0.960, 1.000);
201 - vec3 lightLilac = mix(vec3(0.735, 0.690, 0.985), accent, 0.055);
202 - vec3 lightPink = mix(vec3(0.985, 0.555, 0.950), secondary, 0.035);
203 - vec3 darkPearl = mix(vec3(0.690, 0.625, 1.000), accent, 0.28);
204 - vec3 darkLilac = mix(vec3(0.155, 0.105, 0.355), accent, 0.32);
205 - vec3 darkPink = mix(vec3(0.895, 0.290, 0.805), secondary, 0.25);
60 + vec2 fragCoord = FlutterFragCoord().xy;
61 + vec2 center = uSize * 0.5;
62 + float radiusPx = min(uSize.x, uSize.y) * 0.36;
63 + vec2 uv = (fragCoord - center) / radiusPx;
64 + float dist = length(uv);
65
207 - vec3 pearl = mix(lightPearl, darkPearl, dark);
208 - vec3 lilac = mix(lightLilac, darkLilac, dark);
209 - vec3 pink = mix(lightPink, darkPink, dark);
66 + // soft circular falloff -> matches the blurred edge in the reference image
67 + float sphereMask = 1.0 - smoothstep(0.75, 1.05, dist);
68
211 - float innerCloud = fbm3d(vec3(
212 - orbPoint * 1.35 + vec2(-slowTime * 0.25, slowTime * 0.16),
213 - slowTime * 0.48
214 - ));
215 - float pinkBloom = exp(
216 - -dot(
217 - orbPoint - vec2(-0.18, -0.08),
218 - orbPoint - vec2(-0.18, -0.08)
219 - ) * 3.9
220 - );
221 - float pearlBloom = exp(
222 - -dot(
223 - orbPoint - vec2(0.24, 0.20),
224 - orbPoint - vec2(0.24, 0.20)
225 - ) * 3.2
226 - );
69 + // gentle breathing pulse for the inner glow size
70 + float breathe = 0.5 + 0.5 * sin(uTime * 0.6);
71
228 - vec3 color = lilac;
229 - color = mix(color, pink, pinkBloom * mix(0.52, 0.50, dark));
230 - color = mix(color, pearl, pearlBloom * mix(0.17, 0.12, dark));
231 - color += pearl * innerCloud * mix(0.055, 0.085, dark);
72 + // slowly rotate coordinates to drive the swirling highlight bands
73 + float angle = uTime * 0.15;
74 + float ca = cos(angle);
75 + float sa = sin(angle);
76 + vec2 ruv = vec2(uv.x * ca - uv.y * sa, uv.x * sa + uv.y * ca);
77
233 - vec3 silkColor = mix(vec3(1.0, 0.985, 1.0), vec3(0.88, 0.83, 1.0), dark);
234 - float formLight = mix(
235 - mix(0.79, 1.075, diffuseLight),
236 - mix(0.54, 1.13, diffuseLight),
237 - dark
238 - );
239 - formLight *= mix(0.91, 1.035, centerVolume);
240 - color *= formLight;
241 - color = mix(
242 - color,
243 - pearl,
244 - softSheen * mix(0.15, 0.18, dark)
245 - );
246 - color = mix(
247 - color,
248 - silkColor,
249 - clamp(ribbonLight * mix(0.68, 0.58, dark), 0.0, 0.82)
250 - );
251 - vec3 rimColor = mix(lilac, silkColor, 0.34 + diffuseLight * 0.48);
252 - color = mix(
253 - color,
254 - rimColor,
255 - edgeGlow * mix(0.28, 0.31, dark)
256 - );
78 + // thin swirl streaks (stretched fbm), drifting over time
79 + float streaks = fbm(vec2(ruv.x * 2.0, ruv.y * 6.0) + vec2(uTime * 0.05, 0.0));
80 + streaks = smoothstep(0.1, 0.9, streaks);
81
258 - float bodyAlpha = bodyMask * mix(0.69, 0.88, dark);
259 - bodyAlpha *= mix(0.93 + pinkBloom * 0.045, 0.96, dark);
82 + // inner core glow, offset slightly like the reference, pulsing + drifting
83 + vec2 glowCenter = vec2(-0.15, 0.25) + 0.05 * vec2(cos(uTime * 0.3), sin(uTime * 0.4));
84 + float glowDist = length(uv - glowCenter);
85 + float innerGlow = exp(-glowDist * glowDist * (2.5 - 0.4 * breathe));
86
261 - float haloStrength =
262 - haloMask *
263 - (0.11 + activity * 0.06) *
264 - mix(0.72, 1.0, dark);
265 - vec3 haloColor = mix(
266 - mix(pearl, pink, 0.18),
267 - mix(darkPearl, secondary, 0.20),
268 - dark
269 - );
87 + // base radial gradient: outer base color -> inner glow color
88 + vec3 color = mix(uBaseColor, uGlowColor, innerGlow);
89
271 - if (uErrorWeight > 0.001) {
272 - vec3 errorColor = mix(vec3(1.0, 0.54, 0.58), vec3(1.0, 0.20, 0.28), dark);
273 - color = mix(color, errorColor, uErrorWeight * (0.30 + edgeGlow * 0.20));
274 - }
90 + // subtle rotating light streaks near the rim
91 + float rim = smoothstep(0.55, 1.0, dist) * (1.0 - smoothstep(0.95, 1.05, dist));
92 + color += streaks * rim * 0.35;
93
276 - vec3 premultiplied =
277 - clamp(color, 0.0, 1.15) * bodyAlpha +
278 - haloColor * haloStrength;
279 - float alpha = clamp(bodyAlpha + haloStrength, 0.0, 1.0);
94 + // soft white wash toward the edges, matching the blurred-glass look
95 + color = mix(color, vec3(1.0), smoothstep(0.5, 1.05, dist) * 0.6);
96
281 - fragColor = vec4(premultiplied, alpha);
97 + float alpha = sphereMask;
98 + fragColor = vec4(color * alpha, alpha);
99 }
lib/features/voice_mode/views/components/orb_widget.dart new
+193
@@ -0,0 +1,193 @@
1 +import 'dart:math' as math;
2 +import 'dart:ui' as ui;
3 +
4 +import 'package:flutter/material.dart';
5 +
6 +/// Animated glowing orb, rendered entirely with a GLSL fragment shader
7 +/// (`assets/shaders/orb.frag`).
8 +///
9 +/// Features:
10 +/// - Real-time audio reactivity: size pulses dynamically when speaking or listening.
11 +/// - Continuous elapsed time clock (prevents time-reset jumps / flickering).
12 +/// - Cached native [ui.FragmentShader] instance (eliminates GPU allocation churn).
13 +class OrbWidget extends StatefulWidget {
14 + const OrbWidget({
15 + super.key,
16 + this.diameter = 230,
17 + this.baseColor,
18 + this.glowColor,
19 + this.micLevel = 0.0,
20 + this.isSpeaking = false,
21 + this.isListening = false,
22 + this.scale,
23 + });
24 +
25 + final double diameter;
26 + final Color? baseColor;
27 + final Color? glowColor;
28 +
29 + /// Microphone volume input level (0.0 to 1.0) when listening.
30 + final double micLevel;
31 +
32 + /// Whether the AI/TTS system is currently speaking.
33 + final bool isSpeaking;
34 +
35 + /// Whether the system is currently listening for user input.
36 + final bool isListening;
37 +
38 + /// Explicit override scale factor. If null, automatically computed from phase.
39 + final double? scale;
40 +
41 + @override
42 + State<OrbWidget> createState() => _OrbWidgetState();
43 +}
44 +
45 +class _OrbWidgetState extends State<OrbWidget>
46 + with SingleTickerProviderStateMixin {
47 + late final AnimationController _ticker;
48 + final Stopwatch _stopwatch = Stopwatch();
49 + ui.FragmentShader? _shader;
50 +
51 + @override
52 + void initState() {
53 + super.initState();
54 + _stopwatch.start();
55 + _ticker = AnimationController(
56 + vsync: this,
57 + duration: const Duration(seconds: 1),
58 + )..repeat();
59 +
60 + _loadShader();
61 + }
62 +
63 + Future<void> _loadShader() async {
64 + try {
65 + final program = await ui.FragmentProgram.fromAsset(
66 + 'assets/shaders/orb.frag',
67 + );
68 + if (mounted) {
69 + setState(() {
70 + _shader = program.fragmentShader();
71 + });
72 + }
73 + } catch (_) {
74 + // Graceful fallback if GLSL shader is unsupported on current backend
75 + }
76 + }
77 +
78 + @override
79 + void dispose() {
80 + _stopwatch.stop();
81 + _ticker.dispose();
82 + super.dispose();
83 + }
84 +
85 + @override
86 + Widget build(BuildContext context) {
87 + final theme = Theme.of(context);
88 + final baseColor = widget.baseColor ?? theme.colorScheme.primaryContainer;
89 + final glowColor = widget.glowColor ?? theme.colorScheme.secondary;
90 +
91 + final shader = _shader;
92 + if (shader == null) {
93 + return SizedBox(width: widget.diameter, height: widget.diameter);
94 + }
95 +
96 + return RepaintBoundary(
97 + child: AnimatedBuilder(
98 + animation: _ticker,
99 + builder: (context, _) {
100 + final time = _stopwatch.elapsedMicroseconds / 1000000.0;
101 + return CustomPaint(
102 + size: Size.square(widget.diameter),
103 + painter: _OrbPainter(
104 + shader: shader,
105 + time: time,
106 + baseColor: baseColor,
107 + glowColor: glowColor,
108 + micLevel: widget.micLevel,
109 + isSpeaking: widget.isSpeaking,
110 + isListening: widget.isListening,
111 + customScale: widget.scale,
112 + ),
113 + );
114 + },
115 + ),
116 + );
117 + }
118 +}
119 +
120 +class _OrbPainter extends CustomPainter {
121 + const _OrbPainter({
122 + required this.shader,
123 + required this.time,
124 + required this.baseColor,
125 + required this.glowColor,
126 + required this.micLevel,
127 + required this.isSpeaking,
128 + required this.isListening,
129 + this.customScale,
130 + });
131 +
132 + final ui.FragmentShader shader;
133 + final double time;
134 + final Color baseColor;
135 + final Color glowColor;
136 + final double micLevel;
137 + final bool isSpeaking;
138 + final bool isListening;
139 + final double? customScale;
140 +
141 + @override
142 + void paint(Canvas canvas, Size size) {
143 + final center = Offset(size.width / 2, size.height / 2);
144 +
145 + // Compute dynamic scaling when speaking or listening
146 + double pulseScale = customScale ?? 1.0;
147 + if (customScale == null) {
148 + if (isListening) {
149 + // Enlarge dynamically with mic volume input
150 + final clampedMic = micLevel.clamp(0.0, 1.0);
151 + pulseScale = 1.0 + (math.pow(clampedMic, 0.75) * 0.20);
152 + } else if (isSpeaking) {
153 + // Organic breathing pulsation synced with TTS speech cadence
154 + final cadence = 0.5 + 0.5 * (math.sin(time * 7.2) * math.sin(time * 3.4));
155 + pulseScale = 1.0 + (cadence * 0.16);
156 + }
157 + }
158 +
159 + canvas.save();
160 + canvas.translate(center.dx, center.dy);
161 + canvas.scale(pulseScale);
162 + canvas.translate(-center.dx, -center.dy);
163 +
164 + shader
165 + ..setFloat(0, size.width)
166 + ..setFloat(1, size.height)
167 + ..setFloat(2, time)
168 + ..setFloat(3, baseColor.r)
169 + ..setFloat(4, baseColor.g)
170 + ..setFloat(5, baseColor.b)
171 + ..setFloat(6, glowColor.r)
172 + ..setFloat(7, glowColor.g)
173 + ..setFloat(8, glowColor.b);
174 +
175 + canvas.drawRect(
176 + Offset.zero & size,
177 + Paint()..shader = shader,
178 + );
179 +
180 + canvas.restore();
181 + }
182 +
183 + @override
184 + bool shouldRepaint(covariant _OrbPainter oldDelegate) =>
185 + oldDelegate.time != time ||
186 + oldDelegate.baseColor != baseColor ||
187 + oldDelegate.glowColor != glowColor ||
188 + oldDelegate.micLevel != micLevel ||
189 + oldDelegate.isSpeaking != isSpeaking ||
190 + oldDelegate.isListening != isListening ||
191 + oldDelegate.customScale != customScale ||
192 + oldDelegate.shader != shader;
193 +}
lib/features/voice_mode/views/components/voice_visualizer.dart
+30 -53
@@ -51,7 +51,7 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
51 double _smoothedMicLevel = 0.0;
52 final Stopwatch _clock = Stopwatch();
53 int _lastFrameMicros = 0;
54 - FragmentProgram? _shaderProgram;
54 + FragmentShader? _shader;
55
56 @override
57 void initState() {
@@ -81,7 +81,7 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
81 );
82 if (mounted) {
83 setState(() {
84 - _shaderProgram = program;
84 + _shader = program.fragmentShader();
85 });
86 }
87 } catch (_) {
@@ -149,7 +149,7 @@ class _VoiceVisualizerState extends State<VoiceVisualizer>
149 responseProgress: widget.responseProgress.clamp(0.0, 1.0),
150 time: nowMicros / Duration.microsecondsPerSecond,
151 isDark: isDark,
152 - shaderProgram: _shaderProgram,
152 + shader: _shader,
153 ),
154 );
155 },
@@ -166,7 +166,7 @@ class _OrbWavePainter extends CustomPainter {
166 final double responseProgress;
167 final double time;
168 final bool isDark;
169 - final FragmentProgram? shaderProgram;
169 + final FragmentShader? shader;
170
171 _OrbWavePainter({
172 required this.oldPhase,
@@ -176,7 +176,7 @@ class _OrbWavePainter extends CustomPainter {
176 required this.responseProgress,
177 required this.time,
178 required this.isDark,
179 - this.shaderProgram,
179 + this.shader,
180 });
181
182 @override
@@ -244,35 +244,33 @@ class _OrbWavePainter extends CustomPainter {
244 final baseRadius = (size.width * 0.27) * scaleFactor;
245
246 // 3. Render GPU Shader if Available
247 - if (shaderProgram != null) {
248 - final shader = shaderProgram!.fragmentShader();
249 - shader.setFloat(0, size.width);
250 - shader.setFloat(1, size.height);
251 - shader.setFloat(2, time);
252 - shader.setFloat(3, micLevel);
253 - shader.setFloat(4, _phaseToDouble(targetPhase));
254 -
255 - shader.setFloat(5, accent.r);
256 - shader.setFloat(6, accent.g);
257 - shader.setFloat(7, accent.b);
258 - shader.setFloat(8, accent.a);
259 -
260 - shader.setFloat(9, secondary.r);
261 - shader.setFloat(10, secondary.g);
262 - shader.setFloat(11, secondary.b);
263 - shader.setFloat(12, secondary.a);
264 - shader.setFloat(13, isDark ? 1.0 : 0.0);
265 - shader.setFloat(14, amp);
266 - shader.setFloat(15, _phaseWeight(VoiceModePhase.processing));
267 - shader.setFloat(16, _phaseWeight(VoiceModePhase.speaking));
268 - shader.setFloat(17, _phaseWeight(VoiceModePhase.error));
269 - shader.setFloat(18, responseProgress);
270 -
271 - final shaderPaint = Paint()..shader = shader;
247 + if (shader != null) {
248 + // Dynamic expansion driven by speaking cadence / microphone listening level
249 + final dynamicScale = scaleFactor * (1.0 + amp * 0.20);
250 +
251 + canvas.save();
252 + canvas.translate(center.dx, center.dy);
253 + canvas.scale(dynamicScale);
254 + canvas.translate(-rawCenter.dx, -rawCenter.dy);
255 +
256 + shader!
257 + ..setFloat(0, size.width)
258 + ..setFloat(1, size.height)
259 + ..setFloat(2, time)
260 + ..setFloat(3, accent.r)
261 + ..setFloat(4, accent.g)
262 + ..setFloat(5, accent.b)
263 + ..setFloat(6, secondary.r)
264 + ..setFloat(7, secondary.g)
265 + ..setFloat(8, secondary.b);
266 +
267 + final shaderPaint = Paint()..shader = shader!;
268 canvas.drawRect(
273 - Rect.fromLTWH(0, 0, size.width, size.height),
269 + Offset.zero & size,
270 shaderPaint,
271 );
272 +
273 + canvas.restore();
274 return;
275 }
276
@@ -304,27 +302,6 @@ class _OrbWavePainter extends CustomPainter {
302 _paint3DOrbCore(canvas, center, baseRadius, time, amp, accent, secondary);
303 }
304
307 - double _phaseToDouble(VoiceModePhase phase) {
308 - switch (phase) {
309 - case VoiceModePhase.idle:
310 - return 0.0;
311 - case VoiceModePhase.listening:
312 - return 1.0;
313 - case VoiceModePhase.processing:
314 - return 2.0;
315 - case VoiceModePhase.speaking:
316 - return 3.0;
317 - case VoiceModePhase.error:
318 - return 4.0;
319 - }
320 - }
321 -
322 - double _phaseWeight(VoiceModePhase phase) {
323 - final oldWeight = oldPhase == phase ? 1.0 : 0.0;
324 - final targetWeight = targetPhase == phase ? 1.0 : 0.0;
325 - return lerpDouble(oldWeight, targetWeight, transitionProgress)!;
326 - }
327 -
305 double _computeAmplitudeFor(
306 VoiceModePhase phase,
307 double time,
@@ -697,7 +674,7 @@ class _OrbWavePainter extends CustomPainter {
674 responseProgress != oldDelegate.responseProgress ||
675 time != oldDelegate.time ||
676 isDark != oldDelegate.isDark ||
700 - shaderProgram != oldDelegate.shaderProgram;
677 + shader != oldDelegate.shader;
678 }
679 }
680
lib/features/voice_mode/views/voice_mode_overlay.dart
+3 -3
@@ -178,9 +178,9 @@ class _VoiceModeOverlayState extends ConsumerState<VoiceModeOverlay> {
178 0.0,
179 1.0,
180 );
181 - final orbSize = (MediaQuery.sizeOf(context).width * 0.88).clamp(
182 - 300.0,
183 - 360.0,
181 + final orbSize = (MediaQuery.sizeOf(context).width * 0.72).clamp(
182 + 225.0,
183 + 255.0,
184 );
185
186 return PopScope(