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(