@alia.onl/sdk 1.0.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +15 -3
- package/src/components/AliaChatInput.tsx +1 -1
- package/src/components/AliaChatMessageList.tsx +85 -32
- package/src/components/AliaChatSheet.tsx +14 -18
- package/src/components/AliaChatSuggestions.tsx +1 -1
- package/src/components/AliaFace.tsx +402 -0
- package/src/components/Markdown.tsx +288 -0
- package/src/components/Reasoning.tsx +154 -0
- package/src/components/ThinkingIndicator.tsx +134 -0
- package/src/components/voice/AudioWaveVisualizer.tsx +377 -0
- package/src/components/voice/VoiceControls.tsx +180 -0
- package/src/components/voice/VoiceOverlay.tsx +43 -0
- package/src/hooks/useAliaChat.ts +265 -87
- package/src/hooks/useAudioLevelMonitor.ts +142 -0
- package/src/hooks/useAudioLevels.ts +51 -0
- package/src/hooks/useSoundEffects.ts +128 -0
- package/src/hooks/useSpeechToText.ts +180 -0
- package/src/hooks/useTTS.ts +233 -0
- package/src/hooks/useVoiceRoom.ts +420 -0
- package/src/index.ts +40 -6
- package/src/optional-deps.d.ts +69 -0
- package/src/theme.ts +25 -2
- package/src/types.ts +59 -1
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import React, { createContext, useContext, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { View, Text, Pressable, StyleSheet } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
useAnimatedStyle,
|
|
5
|
+
useSharedValue,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
withSequence,
|
|
9
|
+
cancelAnimation,
|
|
10
|
+
} from 'react-native-reanimated';
|
|
11
|
+
import { useAliaColors } from '../theme';
|
|
12
|
+
|
|
13
|
+
// Context
|
|
14
|
+
type ReasoningContextType = {
|
|
15
|
+
isStreaming: boolean;
|
|
16
|
+
isOpen: boolean;
|
|
17
|
+
setIsOpen: (open: boolean) => void;
|
|
18
|
+
duration: number | undefined;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const ReasoningContext = createContext<ReasoningContextType | null>(null);
|
|
22
|
+
|
|
23
|
+
function useReasoning() {
|
|
24
|
+
const context = useContext(ReasoningContext);
|
|
25
|
+
if (!context) throw new Error('useReasoning must be used within a Reasoning component');
|
|
26
|
+
return context;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Props
|
|
30
|
+
interface ReasoningProps {
|
|
31
|
+
isStreaming?: boolean;
|
|
32
|
+
defaultOpen?: boolean;
|
|
33
|
+
duration?: number;
|
|
34
|
+
children: React.ReactNode;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface ReasoningTriggerProps {
|
|
38
|
+
onPress?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface ReasoningContentProps {
|
|
42
|
+
children: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reasoning wrapper — manages open/close state with auto-open during streaming
|
|
46
|
+
export function Reasoning({ isStreaming = false, defaultOpen = true, duration: externalDuration, children }: ReasoningProps) {
|
|
47
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
48
|
+
const [duration, setDuration] = useState<number | undefined>(externalDuration);
|
|
49
|
+
const startTimeRef = useRef<number | null>(null);
|
|
50
|
+
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
51
|
+
|
|
52
|
+
useEffect(() => {
|
|
53
|
+
if (isStreaming) {
|
|
54
|
+
setIsOpen(true);
|
|
55
|
+
startTimeRef.current = Date.now();
|
|
56
|
+
intervalRef.current = setInterval(() => {
|
|
57
|
+
if (startTimeRef.current) {
|
|
58
|
+
setDuration(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
|
59
|
+
}
|
|
60
|
+
}, 1000);
|
|
61
|
+
} else {
|
|
62
|
+
if (intervalRef.current) { clearInterval(intervalRef.current); intervalRef.current = null; }
|
|
63
|
+
if (startTimeRef.current !== null) {
|
|
64
|
+
const t = setTimeout(() => setIsOpen(false), 500);
|
|
65
|
+
return () => clearTimeout(t);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
|
|
69
|
+
}, [isStreaming]);
|
|
70
|
+
|
|
71
|
+
useEffect(() => { if (externalDuration !== undefined) setDuration(externalDuration); }, [externalDuration]);
|
|
72
|
+
|
|
73
|
+
return (
|
|
74
|
+
<ReasoningContext.Provider value={{ isStreaming, isOpen, setIsOpen, duration }}>
|
|
75
|
+
<View>{children}</View>
|
|
76
|
+
</ReasoningContext.Provider>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Trigger — shows "Thinking for Xs..." label with pulsing icon
|
|
81
|
+
export function ReasoningTrigger({ onPress }: ReasoningTriggerProps) {
|
|
82
|
+
const { isStreaming, isOpen, setIsOpen, duration } = useReasoning();
|
|
83
|
+
const colors = useAliaColors();
|
|
84
|
+
const pulseOpacity = useSharedValue(1);
|
|
85
|
+
|
|
86
|
+
useEffect(() => {
|
|
87
|
+
if (isStreaming) {
|
|
88
|
+
pulseOpacity.value = withRepeat(withSequence(withTiming(0.4, { duration: 800 }), withTiming(1, { duration: 800 })), -1, false);
|
|
89
|
+
} else {
|
|
90
|
+
cancelAnimation(pulseOpacity);
|
|
91
|
+
pulseOpacity.value = 1;
|
|
92
|
+
}
|
|
93
|
+
}, [isStreaming]);
|
|
94
|
+
|
|
95
|
+
const pulseStyle = useAnimatedStyle(() => ({ opacity: pulseOpacity.value }));
|
|
96
|
+
|
|
97
|
+
const message = isStreaming
|
|
98
|
+
? (duration ? `Thinking for ${duration}s...` : 'Thinking...')
|
|
99
|
+
: (duration ? `Thought for ${duration} seconds` : 'Reasoning');
|
|
100
|
+
|
|
101
|
+
const handlePress = onPress || (() => setIsOpen(!isOpen));
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<Pressable onPress={handlePress} style={styles.trigger}>
|
|
105
|
+
<Animated.View style={isStreaming ? pulseStyle : undefined}>
|
|
106
|
+
<Text style={{ color: '#a855f7', fontSize: 14 }}>{'\u2726'}</Text>
|
|
107
|
+
</Animated.View>
|
|
108
|
+
<Text style={[styles.triggerText, { color: '#a855f7' }]}>{message}</Text>
|
|
109
|
+
<Text style={{ color: '#a855f7', fontSize: 14 }}>{onPress ? '\u203A' : (isOpen ? '\u25BE' : '\u25B8')}</Text>
|
|
110
|
+
</Pressable>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Content — renders thinking text (collapsible)
|
|
115
|
+
export function ReasoningContent({ children }: ReasoningContentProps) {
|
|
116
|
+
const { isOpen, isStreaming } = useReasoning();
|
|
117
|
+
const colors = useAliaColors();
|
|
118
|
+
|
|
119
|
+
if (!isOpen) return null;
|
|
120
|
+
|
|
121
|
+
// Lazy import AliaMarkdown to avoid circular dependency
|
|
122
|
+
const { AliaMarkdown } = require('./Markdown');
|
|
123
|
+
|
|
124
|
+
return (
|
|
125
|
+
<View style={[styles.contentWrapper, { backgroundColor: '#a855f710', borderColor: '#a855f730' }]}>
|
|
126
|
+
<View style={{ opacity: isStreaming ? 0.8 : 1 }}>
|
|
127
|
+
<AliaMarkdown content={children} />
|
|
128
|
+
</View>
|
|
129
|
+
</View>
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const styles = StyleSheet.create({
|
|
134
|
+
trigger: {
|
|
135
|
+
flexDirection: 'row',
|
|
136
|
+
alignItems: 'center',
|
|
137
|
+
gap: 8,
|
|
138
|
+
paddingVertical: 8,
|
|
139
|
+
paddingHorizontal: 12,
|
|
140
|
+
borderRadius: 8,
|
|
141
|
+
},
|
|
142
|
+
triggerText: {
|
|
143
|
+
fontSize: 14,
|
|
144
|
+
fontWeight: '500',
|
|
145
|
+
flex: 1,
|
|
146
|
+
},
|
|
147
|
+
contentWrapper: {
|
|
148
|
+
borderRadius: 8,
|
|
149
|
+
padding: 12,
|
|
150
|
+
borderWidth: 1,
|
|
151
|
+
marginHorizontal: 12,
|
|
152
|
+
marginBottom: 12,
|
|
153
|
+
},
|
|
154
|
+
});
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import React, { useState, useEffect } from "react";
|
|
2
|
+
import { View, Text, StyleSheet } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
useSharedValue,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
withRepeat,
|
|
7
|
+
withTiming,
|
|
8
|
+
withSequence,
|
|
9
|
+
Easing,
|
|
10
|
+
} from "react-native-reanimated";
|
|
11
|
+
import { useAliaColors } from "../theme";
|
|
12
|
+
|
|
13
|
+
const thinkingPhrases = [
|
|
14
|
+
"Thinking...",
|
|
15
|
+
"Crafting...",
|
|
16
|
+
"Pondering...",
|
|
17
|
+
"Computing...",
|
|
18
|
+
"Processing...",
|
|
19
|
+
"Analyzing...",
|
|
20
|
+
"Reasoning...",
|
|
21
|
+
"Cooking...",
|
|
22
|
+
"Brewing...",
|
|
23
|
+
"Conjuring...",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const workingPhrases = [
|
|
27
|
+
"Working...",
|
|
28
|
+
"Executing...",
|
|
29
|
+
"Running...",
|
|
30
|
+
"Building...",
|
|
31
|
+
"Creating...",
|
|
32
|
+
"Doing the thing...",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export function ThinkingIndicator({ isWorking = false, statusText }: { isWorking?: boolean; statusText?: string }) {
|
|
36
|
+
const colors = useAliaColors();
|
|
37
|
+
const phrases = isWorking ? workingPhrases : thinkingPhrases;
|
|
38
|
+
const [phraseIndex, setPhraseIndex] = useState(() =>
|
|
39
|
+
Math.floor(Math.random() * phrases.length)
|
|
40
|
+
);
|
|
41
|
+
const [displayText, setDisplayText] = useState("");
|
|
42
|
+
const [isTyping, setIsTyping] = useState(true);
|
|
43
|
+
|
|
44
|
+
// Reset phraseIndex when isWorking changes (arrays have different lengths)
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
setPhraseIndex(Math.floor(Math.random() * phrases.length));
|
|
47
|
+
}, [isWorking]);
|
|
48
|
+
|
|
49
|
+
// Spinning asterisk animation
|
|
50
|
+
const rotation = useSharedValue(0);
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
rotation.value = withRepeat(
|
|
53
|
+
withTiming(360, { duration: 2000, easing: Easing.linear }),
|
|
54
|
+
-1
|
|
55
|
+
);
|
|
56
|
+
}, [rotation]);
|
|
57
|
+
|
|
58
|
+
const spinStyle = useAnimatedStyle(() => ({
|
|
59
|
+
transform: [{ rotate: `${rotation.value}deg` }],
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// Pulsing cursor animation
|
|
63
|
+
const cursorOpacity = useSharedValue(1);
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
cursorOpacity.value = withRepeat(
|
|
66
|
+
withSequence(
|
|
67
|
+
withTiming(0.2, { duration: 400 }),
|
|
68
|
+
withTiming(1, { duration: 400 })
|
|
69
|
+
),
|
|
70
|
+
-1
|
|
71
|
+
);
|
|
72
|
+
}, [cursorOpacity]);
|
|
73
|
+
|
|
74
|
+
const cursorStyle = useAnimatedStyle(() => ({
|
|
75
|
+
opacity: cursorOpacity.value,
|
|
76
|
+
}));
|
|
77
|
+
|
|
78
|
+
// Typewriter effect — skipped when statusText is provided (real-time status shown directly)
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (statusText) return;
|
|
81
|
+
const phrase = phrases[phraseIndex % phrases.length];
|
|
82
|
+
let charIndex = 0;
|
|
83
|
+
setIsTyping(true);
|
|
84
|
+
setDisplayText("");
|
|
85
|
+
|
|
86
|
+
const typeInterval = setInterval(() => {
|
|
87
|
+
if (charIndex < phrase.length) {
|
|
88
|
+
setDisplayText(phrase.slice(0, charIndex + 1));
|
|
89
|
+
charIndex++;
|
|
90
|
+
} else {
|
|
91
|
+
clearInterval(typeInterval);
|
|
92
|
+
setIsTyping(false);
|
|
93
|
+
|
|
94
|
+
// Wait then switch to next phrase
|
|
95
|
+
setTimeout(() => {
|
|
96
|
+
setPhraseIndex((prev) => (prev + 1) % phrases.length);
|
|
97
|
+
}, 1500);
|
|
98
|
+
}
|
|
99
|
+
}, 40);
|
|
100
|
+
|
|
101
|
+
return () => clearInterval(typeInterval);
|
|
102
|
+
}, [phraseIndex, phrases, statusText]);
|
|
103
|
+
|
|
104
|
+
const shownText = statusText || displayText;
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<View style={styles.container}>
|
|
108
|
+
<Animated.View style={spinStyle}>
|
|
109
|
+
<Text style={{ fontSize: 16, color: colors.mutedForeground }}>✱</Text>
|
|
110
|
+
</Animated.View>
|
|
111
|
+
<View style={styles.textRow}>
|
|
112
|
+
<Text style={{ fontSize: 16, color: colors.mutedForeground }}>{shownText}</Text>
|
|
113
|
+
{(statusText || isTyping) && (
|
|
114
|
+
<Animated.View style={cursorStyle}>
|
|
115
|
+
<Text style={{ fontSize: 16, color: colors.mutedForeground }}>|</Text>
|
|
116
|
+
</Animated.View>
|
|
117
|
+
)}
|
|
118
|
+
</View>
|
|
119
|
+
</View>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const styles = StyleSheet.create({
|
|
124
|
+
container: {
|
|
125
|
+
flexDirection: 'row',
|
|
126
|
+
alignItems: 'center',
|
|
127
|
+
gap: 8,
|
|
128
|
+
paddingVertical: 8,
|
|
129
|
+
},
|
|
130
|
+
textRow: {
|
|
131
|
+
flexDirection: 'row',
|
|
132
|
+
alignItems: 'center',
|
|
133
|
+
},
|
|
134
|
+
});
|
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { View, Platform, useWindowDimensions } from 'react-native';
|
|
3
|
+
import Svg, { Path } from 'react-native-svg';
|
|
4
|
+
import Animated, {
|
|
5
|
+
useSharedValue,
|
|
6
|
+
useAnimatedProps,
|
|
7
|
+
useAnimatedStyle,
|
|
8
|
+
withTiming,
|
|
9
|
+
withRepeat,
|
|
10
|
+
withSequence,
|
|
11
|
+
Easing,
|
|
12
|
+
type SharedValue,
|
|
13
|
+
} from 'react-native-reanimated';
|
|
14
|
+
import type { AgentState } from '../../types';
|
|
15
|
+
|
|
16
|
+
const AnimatedPath = Animated.createAnimatedComponent(Path);
|
|
17
|
+
|
|
18
|
+
const GLOW_HEIGHT = 350;
|
|
19
|
+
const NUM_POINTS = 8;
|
|
20
|
+
|
|
21
|
+
// Ocean-style wave layers: fill from bottom to a surface level, crests undulate
|
|
22
|
+
interface WaveLayerConfig {
|
|
23
|
+
color: string;
|
|
24
|
+
surfaceY: number; // surface level as % from top (50 = middle)
|
|
25
|
+
waveHeight: number; // base crest height in px
|
|
26
|
+
maxWaveBoost: number; // extra crest height from audio
|
|
27
|
+
phaseOffset: number;
|
|
28
|
+
speed: number; // ms for one full horizontal cycle
|
|
29
|
+
blur: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const WAVE_LAYERS: WaveLayerConfig[] = [
|
|
33
|
+
// Deepest layer - dark blue, highest surface (closest to top)
|
|
34
|
+
{
|
|
35
|
+
color: 'rgba(12, 74, 110, 0.8)',
|
|
36
|
+
surfaceY: 35,
|
|
37
|
+
waveHeight: 14,
|
|
38
|
+
maxWaveBoost: 120,
|
|
39
|
+
phaseOffset: 0,
|
|
40
|
+
speed: 7000,
|
|
41
|
+
blur: 30,
|
|
42
|
+
},
|
|
43
|
+
// Mid layer - ocean blue
|
|
44
|
+
{
|
|
45
|
+
color: 'rgba(14, 165, 233, 0.45)',
|
|
46
|
+
surfaceY: 42,
|
|
47
|
+
waveHeight: 16,
|
|
48
|
+
maxWaveBoost: 140,
|
|
49
|
+
phaseOffset: Math.PI * 0.8,
|
|
50
|
+
speed: 5500,
|
|
51
|
+
blur: 22,
|
|
52
|
+
},
|
|
53
|
+
// Front layer - bright cyan, lowest surface
|
|
54
|
+
{
|
|
55
|
+
color: 'rgba(56, 189, 248, 0.35)',
|
|
56
|
+
surfaceY: 50,
|
|
57
|
+
waveHeight: 20,
|
|
58
|
+
maxWaveBoost: 150,
|
|
59
|
+
phaseOffset: Math.PI * 1.5,
|
|
60
|
+
speed: 4500,
|
|
61
|
+
blur: 16,
|
|
62
|
+
},
|
|
63
|
+
// Accent layer - indigo/purple
|
|
64
|
+
{
|
|
65
|
+
color: 'rgba(99, 102, 241, 0.25)',
|
|
66
|
+
surfaceY: 38,
|
|
67
|
+
waveHeight: 12,
|
|
68
|
+
maxWaveBoost: 100,
|
|
69
|
+
phaseOffset: Math.PI * 0.3,
|
|
70
|
+
speed: 8000,
|
|
71
|
+
blur: 28,
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
// Background glow blobs for ambient color
|
|
76
|
+
interface BlobConfig {
|
|
77
|
+
color: string;
|
|
78
|
+
baseWidth: number;
|
|
79
|
+
baseHeight: number;
|
|
80
|
+
x: number;
|
|
81
|
+
y: number;
|
|
82
|
+
blur: number;
|
|
83
|
+
amplitudeScale: number;
|
|
84
|
+
phaseSpeed: number;
|
|
85
|
+
phaseDirection: 1 | -1;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const BLOBS: BlobConfig[] = [
|
|
89
|
+
{
|
|
90
|
+
color: '#0c4a6e',
|
|
91
|
+
baseWidth: 320,
|
|
92
|
+
baseHeight: 200,
|
|
93
|
+
x: 50,
|
|
94
|
+
y: 55,
|
|
95
|
+
blur: 80,
|
|
96
|
+
amplitudeScale: 1.0,
|
|
97
|
+
phaseSpeed: 4000,
|
|
98
|
+
phaseDirection: 1,
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
color: '#0ea5e9',
|
|
102
|
+
baseWidth: 220,
|
|
103
|
+
baseHeight: 140,
|
|
104
|
+
x: 38,
|
|
105
|
+
y: 65,
|
|
106
|
+
blur: 60,
|
|
107
|
+
amplitudeScale: 1.2,
|
|
108
|
+
phaseSpeed: 3500,
|
|
109
|
+
phaseDirection: -1,
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
color: '#6366f1',
|
|
113
|
+
baseWidth: 180,
|
|
114
|
+
baseHeight: 160,
|
|
115
|
+
x: 70,
|
|
116
|
+
y: 55,
|
|
117
|
+
blur: 70,
|
|
118
|
+
amplitudeScale: 0.7,
|
|
119
|
+
phaseSpeed: 4500,
|
|
120
|
+
phaseDirection: -1,
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
color: '#a855f7',
|
|
124
|
+
baseWidth: 130,
|
|
125
|
+
baseHeight: 110,
|
|
126
|
+
x: 78,
|
|
127
|
+
y: 65,
|
|
128
|
+
blur: 60,
|
|
129
|
+
amplitudeScale: 0.5,
|
|
130
|
+
phaseSpeed: 5500,
|
|
131
|
+
phaseDirection: 1,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
// Colors per agent state for waves — speaking blends blue + purple
|
|
136
|
+
const WAVE_COLORS: Record<AgentState, WaveLayerConfig['color'][]> = {
|
|
137
|
+
idle: [
|
|
138
|
+
'rgba(12, 74, 110, 0.8)',
|
|
139
|
+
'rgba(14, 165, 233, 0.45)',
|
|
140
|
+
'rgba(56, 189, 248, 0.35)',
|
|
141
|
+
'rgba(99, 102, 241, 0.25)',
|
|
142
|
+
],
|
|
143
|
+
listening: [
|
|
144
|
+
'rgba(12, 74, 110, 0.8)',
|
|
145
|
+
'rgba(14, 165, 233, 0.45)',
|
|
146
|
+
'rgba(56, 189, 248, 0.35)',
|
|
147
|
+
'rgba(99, 102, 241, 0.25)',
|
|
148
|
+
],
|
|
149
|
+
thinking: [
|
|
150
|
+
'rgba(120, 80, 20, 0.75)',
|
|
151
|
+
'rgba(234, 179, 8, 0.45)',
|
|
152
|
+
'rgba(251, 191, 36, 0.35)',
|
|
153
|
+
'rgba(253, 224, 71, 0.25)',
|
|
154
|
+
],
|
|
155
|
+
speaking: [
|
|
156
|
+
'rgba(30, 40, 100, 0.8)',
|
|
157
|
+
'rgba(99, 102, 241, 0.5)',
|
|
158
|
+
'rgba(147, 51, 234, 0.4)',
|
|
159
|
+
'rgba(56, 189, 248, 0.3)',
|
|
160
|
+
],
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const BLOB_COLORS: Record<AgentState, string[]> = {
|
|
164
|
+
idle: ['#0c4a6e', '#0ea5e9', '#6366f1', '#a855f7'],
|
|
165
|
+
listening: ['#0c4a6e', '#0ea5e9', '#6366f1', '#a855f7'],
|
|
166
|
+
thinking: ['#92400e', '#eab308', '#f59e0b', '#fbbf24'],
|
|
167
|
+
speaking: ['#1e2870', '#6366f1', '#9333ea', '#0ea5e9'],
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
interface AudioWaveVisualizerProps {
|
|
171
|
+
waveAmplitude: SharedValue<number>;
|
|
172
|
+
agentState: AgentState;
|
|
173
|
+
isConnected: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function AudioWaveVisualizer({
|
|
177
|
+
waveAmplitude,
|
|
178
|
+
agentState = 'idle',
|
|
179
|
+
isConnected,
|
|
180
|
+
}: AudioWaveVisualizerProps) {
|
|
181
|
+
const { width: screenWidth } = useWindowDimensions();
|
|
182
|
+
const state = WAVE_COLORS[agentState] ? agentState : 'idle';
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<View
|
|
186
|
+
style={{
|
|
187
|
+
width: screenWidth,
|
|
188
|
+
height: GLOW_HEIGHT,
|
|
189
|
+
position: 'relative',
|
|
190
|
+
}}
|
|
191
|
+
>
|
|
192
|
+
{/* Background glow blobs */}
|
|
193
|
+
{BLOBS.map((blob, i) => (
|
|
194
|
+
<GlowBlob
|
|
195
|
+
key={`blob-${i}`}
|
|
196
|
+
blob={blob}
|
|
197
|
+
color={BLOB_COLORS[state][i]}
|
|
198
|
+
waveAmplitude={waveAmplitude}
|
|
199
|
+
isConnected={isConnected}
|
|
200
|
+
screenWidth={screenWidth}
|
|
201
|
+
/>
|
|
202
|
+
))}
|
|
203
|
+
|
|
204
|
+
{/* Ocean wave layers — filled from surface down to bottom, with blur */}
|
|
205
|
+
{WAVE_LAYERS.map((layer, i) => (
|
|
206
|
+
<OceanWave
|
|
207
|
+
key={`wave-${i}`}
|
|
208
|
+
layer={layer}
|
|
209
|
+
color={WAVE_COLORS[state][i]}
|
|
210
|
+
waveAmplitude={waveAmplitude}
|
|
211
|
+
isConnected={isConnected}
|
|
212
|
+
screenWidth={screenWidth}
|
|
213
|
+
/>
|
|
214
|
+
))}
|
|
215
|
+
</View>
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Ocean wave: surface undulates horizontally, filled from surface to bottom
|
|
220
|
+
function OceanWave({
|
|
221
|
+
layer,
|
|
222
|
+
color,
|
|
223
|
+
waveAmplitude,
|
|
224
|
+
isConnected,
|
|
225
|
+
screenWidth,
|
|
226
|
+
}: {
|
|
227
|
+
layer: WaveLayerConfig;
|
|
228
|
+
color: string;
|
|
229
|
+
waveAmplitude: SharedValue<number>;
|
|
230
|
+
isConnected: boolean;
|
|
231
|
+
screenWidth: number;
|
|
232
|
+
}) {
|
|
233
|
+
const phase = useSharedValue(0);
|
|
234
|
+
|
|
235
|
+
useEffect(() => {
|
|
236
|
+
phase.value = 0;
|
|
237
|
+
phase.value = withRepeat(
|
|
238
|
+
withTiming(Math.PI * 2, { duration: layer.speed, easing: Easing.linear }),
|
|
239
|
+
-1,
|
|
240
|
+
false,
|
|
241
|
+
);
|
|
242
|
+
}, []);
|
|
243
|
+
|
|
244
|
+
const animatedProps = useAnimatedProps(() => {
|
|
245
|
+
const amp = waveAmplitude.value;
|
|
246
|
+
// Surface Y position (fixed, doesn't move with audio)
|
|
247
|
+
const surfaceY = (layer.surfaceY / 100) * GLOW_HEIGHT;
|
|
248
|
+
// Wave crest height: base + audio boost
|
|
249
|
+
const crestHeight = layer.waveHeight + amp * layer.maxWaveBoost;
|
|
250
|
+
const currentPhase = phase.value + layer.phaseOffset;
|
|
251
|
+
const segW = screenWidth / NUM_POINTS;
|
|
252
|
+
|
|
253
|
+
// Start at bottom-left corner
|
|
254
|
+
let d = `M 0 ${GLOW_HEIGHT}`;
|
|
255
|
+
|
|
256
|
+
// Up to first surface point
|
|
257
|
+
const firstY = surfaceY + Math.sin(currentPhase) * crestHeight;
|
|
258
|
+
d += ` L 0 ${firstY}`;
|
|
259
|
+
|
|
260
|
+
// Draw undulating surface across the width
|
|
261
|
+
for (let i = 0; i < NUM_POINTS; i++) {
|
|
262
|
+
const x1 = (i + 1) * segW;
|
|
263
|
+
const y0 =
|
|
264
|
+
surfaceY +
|
|
265
|
+
Math.sin(currentPhase + (i / NUM_POINTS) * Math.PI * 2) * crestHeight;
|
|
266
|
+
const y1 =
|
|
267
|
+
surfaceY +
|
|
268
|
+
Math.sin(currentPhase + ((i + 1) / NUM_POINTS) * Math.PI * 2) * crestHeight;
|
|
269
|
+
|
|
270
|
+
const cp1x = i * segW + segW * 0.5;
|
|
271
|
+
const cp2x = x1 - segW * 0.5;
|
|
272
|
+
|
|
273
|
+
d += ` C ${cp1x} ${y0}, ${cp2x} ${y1}, ${x1} ${y1}`;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Close: down to bottom-right, across to bottom-left
|
|
277
|
+
d += ` L ${screenWidth} ${GLOW_HEIGHT} Z`;
|
|
278
|
+
|
|
279
|
+
return { d };
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
const webBlurStyle =
|
|
283
|
+
Platform.OS === 'web'
|
|
284
|
+
? ({
|
|
285
|
+
filter: `blur(${layer.blur}px)`,
|
|
286
|
+
WebkitFilter: `blur(${layer.blur}px)`,
|
|
287
|
+
} as any)
|
|
288
|
+
: {};
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<View
|
|
292
|
+
style={{
|
|
293
|
+
position: 'absolute',
|
|
294
|
+
top: 0,
|
|
295
|
+
left: 0,
|
|
296
|
+
right: 0,
|
|
297
|
+
bottom: 0,
|
|
298
|
+
opacity: isConnected ? 1 : 0,
|
|
299
|
+
...webBlurStyle,
|
|
300
|
+
pointerEvents: 'none',
|
|
301
|
+
}}
|
|
302
|
+
>
|
|
303
|
+
<Svg width={screenWidth} height={GLOW_HEIGHT}>
|
|
304
|
+
<AnimatedPath animatedProps={animatedProps} fill={color} />
|
|
305
|
+
</Svg>
|
|
306
|
+
</View>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Ambient glow blob
|
|
311
|
+
function GlowBlob({
|
|
312
|
+
blob,
|
|
313
|
+
color,
|
|
314
|
+
waveAmplitude,
|
|
315
|
+
isConnected,
|
|
316
|
+
screenWidth,
|
|
317
|
+
}: {
|
|
318
|
+
blob: BlobConfig;
|
|
319
|
+
color: string;
|
|
320
|
+
waveAmplitude: SharedValue<number>;
|
|
321
|
+
isConnected: boolean;
|
|
322
|
+
screenWidth: number;
|
|
323
|
+
}) {
|
|
324
|
+
const drift = useSharedValue(0);
|
|
325
|
+
|
|
326
|
+
useEffect(() => {
|
|
327
|
+
drift.value = 0;
|
|
328
|
+
drift.value = withRepeat(
|
|
329
|
+
withSequence(
|
|
330
|
+
withTiming(1, {
|
|
331
|
+
duration: blob.phaseSpeed,
|
|
332
|
+
easing: Easing.bezier(0.37, 0, 0.63, 1),
|
|
333
|
+
}),
|
|
334
|
+
withTiming(0, {
|
|
335
|
+
duration: blob.phaseSpeed,
|
|
336
|
+
easing: Easing.bezier(0.37, 0, 0.63, 1),
|
|
337
|
+
}),
|
|
338
|
+
),
|
|
339
|
+
-1,
|
|
340
|
+
false,
|
|
341
|
+
);
|
|
342
|
+
}, []);
|
|
343
|
+
|
|
344
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
345
|
+
const amp = waveAmplitude.value;
|
|
346
|
+
const scale = 1 + amp * 0.3 * blob.amplitudeScale;
|
|
347
|
+
const driftOffset = (drift.value - 0.5) * 20 * blob.phaseDirection;
|
|
348
|
+
const opacity = isConnected ? 0.45 + amp * 0.35 * blob.amplitudeScale : 0;
|
|
349
|
+
|
|
350
|
+
const w = blob.baseWidth * scale;
|
|
351
|
+
const h = blob.baseHeight * scale;
|
|
352
|
+
const left = (blob.x / 100) * screenWidth - w / 2 + driftOffset;
|
|
353
|
+
const top = (blob.y / 100) * GLOW_HEIGHT - h / 2;
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
position: 'absolute' as const,
|
|
357
|
+
width: w,
|
|
358
|
+
height: h,
|
|
359
|
+
left,
|
|
360
|
+
top,
|
|
361
|
+
borderRadius: w / 2,
|
|
362
|
+
backgroundColor: color,
|
|
363
|
+
opacity,
|
|
364
|
+
transform: [{ scale }],
|
|
365
|
+
};
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const webBlurStyle =
|
|
369
|
+
Platform.OS === 'web'
|
|
370
|
+
? ({
|
|
371
|
+
filter: `blur(${blob.blur}px)`,
|
|
372
|
+
WebkitFilter: `blur(${blob.blur}px)`,
|
|
373
|
+
} as any)
|
|
374
|
+
: {};
|
|
375
|
+
|
|
376
|
+
return <Animated.View style={[animatedStyle, webBlurStyle]} />;
|
|
377
|
+
}
|