@alia.onl/sdk 1.0.0 → 3.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 +37 -3
- package/src/components/AliaChatContent.tsx +313 -0
- package/src/components/AliaChatInput.tsx +174 -27
- package/src/components/AliaChatMessageList.tsx +220 -34
- package/src/components/AliaChatScreen.tsx +132 -0
- package/src/components/AliaChatSheet.tsx +36 -49
- 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/ui/button.tsx +104 -0
- package/src/components/ui/chat-text-input.tsx +164 -0
- package/src/components/ui/dropdown-menu.tsx +2 -0
- package/src/components/ui/dropdown-menu.web.tsx +355 -0
- package/src/components/ui/prompt-input/actions.tsx +28 -0
- package/src/components/ui/prompt-input/add-menu.tsx +83 -0
- package/src/components/ui/prompt-input/attachments.tsx +183 -0
- package/src/components/ui/prompt-input/autocomplete.tsx +168 -0
- package/src/components/ui/prompt-input/context.tsx +71 -0
- package/src/components/ui/prompt-input/mic-button.tsx +53 -0
- package/src/components/ui/prompt-input/prompt-input.tsx +276 -0
- package/src/components/ui/prompt-input/submit-button.tsx +49 -0
- package/src/components/ui/prompt-input/textarea.tsx +64 -0
- package/src/components/ui/text.tsx +29 -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 +278 -91
- package/src/hooks/useAudioLevelMonitor.ts +142 -0
- package/src/hooks/useAudioLevels.ts +51 -0
- package/src/hooks/useDocumentPicker.ts +37 -0
- package/src/hooks/useImagePicker.ts +38 -0
- package/src/hooks/useSoundEffects.ts +128 -0
- package/src/hooks/useSpeechToText.ts +182 -0
- package/src/hooks/useTTS.ts +233 -0
- package/src/hooks/useVoiceRoom.ts +420 -0
- package/src/index.ts +71 -6
- package/src/lib/keyboard.native.tsx +6 -0
- package/src/lib/keyboard.tsx +25 -0
- package/src/lib/utils.ts +14 -0
- package/src/optional-deps.d.ts +86 -0
- package/src/theme.ts +25 -2
- package/src/types.ts +63 -1
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import * as DocumentPicker from 'expo-document-picker';
|
|
2
|
+
|
|
3
|
+
export type DocumentPickerResult = {
|
|
4
|
+
uri: string;
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type UseDocumentPickerResult = {
|
|
11
|
+
pickDocument: () => Promise<DocumentPickerResult[] | undefined>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useDocumentPicker(): UseDocumentPickerResult {
|
|
15
|
+
const pickDocument = async (): Promise<DocumentPickerResult[] | undefined> => {
|
|
16
|
+
try {
|
|
17
|
+
const result = await DocumentPicker.getDocumentAsync({
|
|
18
|
+
type: '*/*',
|
|
19
|
+
multiple: true,
|
|
20
|
+
copyToCacheDirectory: true,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (result.canceled === false && result.assets && result.assets.length > 0) {
|
|
24
|
+
return result.assets.map((asset: any) => ({
|
|
25
|
+
uri: asset.uri,
|
|
26
|
+
name: asset.name,
|
|
27
|
+
size: asset.size || 0,
|
|
28
|
+
mimeType: asset.mimeType || 'application/octet-stream',
|
|
29
|
+
}));
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.warn('Failed to pick document:', error);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
return { pickDocument };
|
|
37
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as ImagePicker from 'expo-image-picker';
|
|
2
|
+
|
|
3
|
+
export type ImagePickerAsset = {
|
|
4
|
+
uri: string;
|
|
5
|
+
name: string;
|
|
6
|
+
size: number;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
type ImagePickerResult = {
|
|
11
|
+
pickImage: () => Promise<ImagePickerAsset[] | undefined>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export function useImagePicker(): ImagePickerResult {
|
|
15
|
+
const pickImage = async (): Promise<ImagePickerAsset[] | undefined> => {
|
|
16
|
+
try {
|
|
17
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
18
|
+
allowsMultipleSelection: true,
|
|
19
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
20
|
+
aspect: [4, 3],
|
|
21
|
+
quality: 1,
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
if (!result.canceled && result.assets.length > 0) {
|
|
25
|
+
return result.assets.map((asset: any) => ({
|
|
26
|
+
uri: asset.uri,
|
|
27
|
+
name: asset.fileName || `image-${Date.now()}.jpg`,
|
|
28
|
+
size: asset.fileSize || 0,
|
|
29
|
+
mimeType: asset.mimeType || 'image/jpeg',
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
} catch (error) {
|
|
33
|
+
console.warn('Failed to pick image:', error);
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
return { pickImage };
|
|
38
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sound effects hook for voice mode.
|
|
3
|
+
* Plays short audio cues when the AI is thinking, using tools,
|
|
4
|
+
* or when voice connection state changes.
|
|
5
|
+
*
|
|
6
|
+
* Sounds are provided via options instead of hardcoded require() calls,
|
|
7
|
+
* making this hook portable across different bundlers and apps.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { useEffect, useRef, useCallback } from 'react';
|
|
11
|
+
import type { AgentState } from '../types';
|
|
12
|
+
|
|
13
|
+
export type SoundName = 'thinking' | 'toolCall' | 'voiceConnect' | 'voiceDisconnect';
|
|
14
|
+
|
|
15
|
+
export interface SoundSources {
|
|
16
|
+
thinking?: any;
|
|
17
|
+
toolCall?: any;
|
|
18
|
+
voiceConnect?: any;
|
|
19
|
+
voiceDisconnect?: any;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const SOUND_NAMES: SoundName[] = ['thinking', 'toolCall', 'voiceConnect', 'voiceDisconnect'];
|
|
23
|
+
|
|
24
|
+
export function useSoundEffects(enabled: boolean = false, sounds: SoundSources = {}) {
|
|
25
|
+
const playersRef = useRef<Map<SoundName, any>>(new Map());
|
|
26
|
+
const loadedRef = useRef(false);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
if (!enabled || loadedRef.current) return;
|
|
30
|
+
|
|
31
|
+
// Only load sounds that were actually provided
|
|
32
|
+
const hasAnySounds = SOUND_NAMES.some((name) => sounds[name] != null);
|
|
33
|
+
if (!hasAnySounds) return;
|
|
34
|
+
|
|
35
|
+
let mounted = true;
|
|
36
|
+
|
|
37
|
+
(async () => {
|
|
38
|
+
try {
|
|
39
|
+
const { createAudioPlayer } = await import('expo-audio');
|
|
40
|
+
if (!mounted) return;
|
|
41
|
+
|
|
42
|
+
for (const name of SOUND_NAMES) {
|
|
43
|
+
const source = sounds[name];
|
|
44
|
+
if (source == null) continue;
|
|
45
|
+
try {
|
|
46
|
+
const player = createAudioPlayer(source);
|
|
47
|
+
playersRef.current.set(name, player);
|
|
48
|
+
} catch {
|
|
49
|
+
// Individual sound failed to load — skip it
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
loadedRef.current = true;
|
|
53
|
+
} catch {
|
|
54
|
+
// expo-audio not available — degrade silently
|
|
55
|
+
}
|
|
56
|
+
})();
|
|
57
|
+
|
|
58
|
+
return () => {
|
|
59
|
+
mounted = false;
|
|
60
|
+
for (const player of playersRef.current.values()) {
|
|
61
|
+
try { player.release(); } catch {}
|
|
62
|
+
}
|
|
63
|
+
playersRef.current.clear();
|
|
64
|
+
loadedRef.current = false;
|
|
65
|
+
};
|
|
66
|
+
}, [enabled, sounds]);
|
|
67
|
+
|
|
68
|
+
const play = useCallback((sound: SoundName) => {
|
|
69
|
+
if (!enabled) return;
|
|
70
|
+
const player = playersRef.current.get(sound);
|
|
71
|
+
if (player) {
|
|
72
|
+
try {
|
|
73
|
+
player.seekTo(0);
|
|
74
|
+
player.play();
|
|
75
|
+
} catch {
|
|
76
|
+
// Playback failed — ignore
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}, [enabled]);
|
|
80
|
+
|
|
81
|
+
return { play };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Hook that automatically triggers sound effects based on voice state changes.
|
|
86
|
+
* Wire this into the conversation page alongside useVoiceMode.
|
|
87
|
+
*/
|
|
88
|
+
export function useVoiceSoundEffects({
|
|
89
|
+
isVoiceActive,
|
|
90
|
+
agentState,
|
|
91
|
+
isConnected,
|
|
92
|
+
sounds = {},
|
|
93
|
+
}: {
|
|
94
|
+
isVoiceActive: boolean;
|
|
95
|
+
agentState: AgentState;
|
|
96
|
+
isConnected: boolean;
|
|
97
|
+
sounds?: SoundSources;
|
|
98
|
+
}) {
|
|
99
|
+
const { play } = useSoundEffects(isVoiceActive, sounds);
|
|
100
|
+
const prevAgentStateRef = useRef<AgentState>('idle');
|
|
101
|
+
const prevConnectedRef = useRef(false);
|
|
102
|
+
|
|
103
|
+
// Play sound on agent state transitions
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (!isVoiceActive) return;
|
|
106
|
+
|
|
107
|
+
const prev = prevAgentStateRef.current;
|
|
108
|
+
prevAgentStateRef.current = agentState;
|
|
109
|
+
|
|
110
|
+
if (agentState === 'thinking' && prev !== 'thinking') {
|
|
111
|
+
play('thinking');
|
|
112
|
+
}
|
|
113
|
+
}, [agentState, isVoiceActive, play]);
|
|
114
|
+
|
|
115
|
+
// Play sound on connection state changes
|
|
116
|
+
useEffect(() => {
|
|
117
|
+
if (!isVoiceActive) return;
|
|
118
|
+
|
|
119
|
+
const wasConnected = prevConnectedRef.current;
|
|
120
|
+
prevConnectedRef.current = isConnected;
|
|
121
|
+
|
|
122
|
+
if (isConnected && !wasConnected) {
|
|
123
|
+
play('voiceConnect');
|
|
124
|
+
} else if (!isConnected && wasConnected) {
|
|
125
|
+
play('voiceDisconnect');
|
|
126
|
+
}
|
|
127
|
+
}, [isConnected, isVoiceActive, play]);
|
|
128
|
+
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
useAudioRecorder,
|
|
4
|
+
useAudioRecorderState,
|
|
5
|
+
RecordingPresets,
|
|
6
|
+
requestRecordingPermissionsAsync,
|
|
7
|
+
setAudioModeAsync,
|
|
8
|
+
} from 'expo-audio';
|
|
9
|
+
import { useOxy } from '@oxyhq/services';
|
|
10
|
+
import { create } from 'zustand';
|
|
11
|
+
|
|
12
|
+
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
13
|
+
|
|
14
|
+
// ============== OPTIONS ==============
|
|
15
|
+
|
|
16
|
+
export interface UseSTTOptions {
|
|
17
|
+
apiUrl?: string;
|
|
18
|
+
accessToken?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// ============== INLINE STT STORE ==============
|
|
22
|
+
|
|
23
|
+
interface STTStoreState {
|
|
24
|
+
isRecording: boolean;
|
|
25
|
+
metering: number;
|
|
26
|
+
setRecording: (v: boolean) => void;
|
|
27
|
+
setMetering: (v: number) => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const useSTTStore = create<STTStoreState>((set) => ({
|
|
31
|
+
isRecording: false,
|
|
32
|
+
metering: 0,
|
|
33
|
+
setRecording: (isRecording) => set({ isRecording }),
|
|
34
|
+
setMetering: (metering) => set({ metering }),
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
// ============== HOOK ==============
|
|
38
|
+
|
|
39
|
+
type STTState = 'idle' | 'recording' | 'transcribing';
|
|
40
|
+
|
|
41
|
+
const METERING_PRESET = { ...RecordingPresets.HIGH_QUALITY, isMeteringEnabled: true };
|
|
42
|
+
|
|
43
|
+
export function useSpeechToText(options: UseSTTOptions = {}) {
|
|
44
|
+
const apiUrl = options.apiUrl || API_URL;
|
|
45
|
+
|
|
46
|
+
const [state, setState] = useState<STTState>('idle');
|
|
47
|
+
const [error, setError] = useState<string | null>(null);
|
|
48
|
+
const isRecordingRef = useRef(false);
|
|
49
|
+
const { oxyServices } = useOxy();
|
|
50
|
+
|
|
51
|
+
const audioRecorder = useAudioRecorder(METERING_PRESET);
|
|
52
|
+
const recorderState = useAudioRecorderState(audioRecorder, 100);
|
|
53
|
+
|
|
54
|
+
const sttStore = useSTTStore;
|
|
55
|
+
|
|
56
|
+
// ============== AUTH ==============
|
|
57
|
+
|
|
58
|
+
const getToken = useCallback((): string | null => {
|
|
59
|
+
if (options.accessToken) return options.accessToken;
|
|
60
|
+
return oxyServices.httpService.getAccessToken();
|
|
61
|
+
}, [options.accessToken, oxyServices]);
|
|
62
|
+
|
|
63
|
+
// Push metering to store while recording
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (state === 'recording' && recorderState.metering != null) {
|
|
66
|
+
// dBFS: -160 (silence) to 0 (max). Normalize using -60 as practical floor.
|
|
67
|
+
const normalized = Math.min(1, Math.max(0, (recorderState.metering + 60) / 60));
|
|
68
|
+
sttStore.getState().setMetering(normalized);
|
|
69
|
+
}
|
|
70
|
+
}, [state, recorderState.metering]);
|
|
71
|
+
|
|
72
|
+
// Sync recording state to store
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
sttStore.getState().setRecording(state === 'recording');
|
|
75
|
+
}, [state]);
|
|
76
|
+
|
|
77
|
+
const startRecording = useCallback(async () => {
|
|
78
|
+
try {
|
|
79
|
+
setError(null);
|
|
80
|
+
|
|
81
|
+
const permStatus = await requestRecordingPermissionsAsync();
|
|
82
|
+
if (!permStatus.granted) {
|
|
83
|
+
setError('Microphone permission required');
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
setState('recording');
|
|
88
|
+
|
|
89
|
+
await setAudioModeAsync({
|
|
90
|
+
allowsRecording: true,
|
|
91
|
+
playsInSilentMode: true,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await audioRecorder.prepareToRecordAsync();
|
|
95
|
+
audioRecorder.record();
|
|
96
|
+
isRecordingRef.current = true;
|
|
97
|
+
} catch (e: any) {
|
|
98
|
+
console.error('[STT] Recording error:', e);
|
|
99
|
+
setError('Failed to start recording');
|
|
100
|
+
setState('idle');
|
|
101
|
+
isRecordingRef.current = false;
|
|
102
|
+
}
|
|
103
|
+
}, [audioRecorder]);
|
|
104
|
+
|
|
105
|
+
const stopAndTranscribe = useCallback(async (): Promise<string | null> => {
|
|
106
|
+
if (!isRecordingRef.current) return null;
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
setState('transcribing');
|
|
110
|
+
isRecordingRef.current = false;
|
|
111
|
+
await audioRecorder.stop();
|
|
112
|
+
const uri = audioRecorder.uri;
|
|
113
|
+
|
|
114
|
+
if (!uri) {
|
|
115
|
+
setState('idle');
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Read audio file as base64
|
|
120
|
+
const response = await fetch(uri);
|
|
121
|
+
const blob = await response.blob();
|
|
122
|
+
// Detect actual MIME type (web records webm, native records m4a)
|
|
123
|
+
const detectedFormat = blob.type?.split(';')[0] || 'audio/m4a';
|
|
124
|
+
const base64 = await new Promise<string>((resolve, reject) => {
|
|
125
|
+
const reader = new FileReader();
|
|
126
|
+
reader.onload = () => {
|
|
127
|
+
const result = reader.result as string;
|
|
128
|
+
resolve(result.split(',')[1]);
|
|
129
|
+
};
|
|
130
|
+
reader.onerror = reject;
|
|
131
|
+
reader.readAsDataURL(blob);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// Send to transcription API
|
|
135
|
+
const token = getToken();
|
|
136
|
+
const transcribeResponse = await fetch(`${apiUrl}/v1/voice/transcribe`, {
|
|
137
|
+
method: 'POST',
|
|
138
|
+
headers: {
|
|
139
|
+
'Content-Type': 'application/json',
|
|
140
|
+
Authorization: `Bearer ${token}`,
|
|
141
|
+
},
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
audio: base64,
|
|
144
|
+
format: detectedFormat,
|
|
145
|
+
}),
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
if (!transcribeResponse.ok) {
|
|
149
|
+
const errorData = await transcribeResponse.json().catch(() => ({}));
|
|
150
|
+
throw new Error(errorData.error || 'Transcription failed');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const result = await transcribeResponse.json() as { text: string };
|
|
154
|
+
setState('idle');
|
|
155
|
+
return result.text || null;
|
|
156
|
+
} catch (e: any) {
|
|
157
|
+
console.error('[STT] Transcription error:', e);
|
|
158
|
+
setError(e.message || 'Transcription failed');
|
|
159
|
+
setState('idle');
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
}, [audioRecorder, getToken, apiUrl]);
|
|
163
|
+
|
|
164
|
+
const cancel = useCallback(() => {
|
|
165
|
+
if (isRecordingRef.current) {
|
|
166
|
+
isRecordingRef.current = false;
|
|
167
|
+
Promise.resolve(audioRecorder.stop()).catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
setState('idle');
|
|
170
|
+
setError(null);
|
|
171
|
+
}, [audioRecorder]);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
state,
|
|
175
|
+
error,
|
|
176
|
+
startRecording,
|
|
177
|
+
stopAndTranscribe,
|
|
178
|
+
cancel,
|
|
179
|
+
isRecording: state === 'recording',
|
|
180
|
+
isTranscribing: state === 'transcribing',
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
+
import {
|
|
3
|
+
useSharedValue,
|
|
4
|
+
withRepeat,
|
|
5
|
+
withSequence,
|
|
6
|
+
withTiming,
|
|
7
|
+
cancelAnimation,
|
|
8
|
+
Easing,
|
|
9
|
+
} from 'react-native-reanimated';
|
|
10
|
+
import { useOxy } from '@oxyhq/services';
|
|
11
|
+
import { create } from 'zustand';
|
|
12
|
+
|
|
13
|
+
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
14
|
+
|
|
15
|
+
// ============== OPTIONS ==============
|
|
16
|
+
|
|
17
|
+
export interface UseTTSOptions {
|
|
18
|
+
apiUrl?: string;
|
|
19
|
+
accessToken?: string;
|
|
20
|
+
voice?: 'male' | 'female';
|
|
21
|
+
tone?: 'brief' | 'chill' | 'default';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ============== INLINE TTS STORE ==============
|
|
25
|
+
|
|
26
|
+
type PlaybackState = 'idle' | 'loading' | 'playing' | 'paused' | 'error';
|
|
27
|
+
|
|
28
|
+
interface TTSStore {
|
|
29
|
+
activeMessageId: string | null;
|
|
30
|
+
playbackState: PlaybackState;
|
|
31
|
+
error: string | null;
|
|
32
|
+
setActiveMessage: (id: string | null) => void;
|
|
33
|
+
setPlaybackState: (state: PlaybackState) => void;
|
|
34
|
+
setError: (error: string | null) => void;
|
|
35
|
+
reset: () => void;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const useTTSStore = create<TTSStore>((set) => ({
|
|
39
|
+
activeMessageId: null,
|
|
40
|
+
playbackState: 'idle',
|
|
41
|
+
error: null,
|
|
42
|
+
setActiveMessage: (id) => set({ activeMessageId: id }),
|
|
43
|
+
setPlaybackState: (playbackState) => set({ playbackState }),
|
|
44
|
+
setError: (error) => set({ error, playbackState: error ? 'error' : 'idle' }),
|
|
45
|
+
reset: () => set({ activeMessageId: null, playbackState: 'idle', error: null }),
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// ============== HOOK ==============
|
|
49
|
+
|
|
50
|
+
export function useTTS(options: UseTTSOptions = {}) {
|
|
51
|
+
const apiUrl = options.apiUrl || API_URL;
|
|
52
|
+
const voicePref = options.voice ?? 'female';
|
|
53
|
+
const tonePref = options.tone ?? 'default';
|
|
54
|
+
|
|
55
|
+
const { oxyServices } = useOxy();
|
|
56
|
+
const {
|
|
57
|
+
activeMessageId,
|
|
58
|
+
playbackState,
|
|
59
|
+
error,
|
|
60
|
+
setActiveMessage,
|
|
61
|
+
setPlaybackState,
|
|
62
|
+
setError,
|
|
63
|
+
reset,
|
|
64
|
+
} = useTTSStore();
|
|
65
|
+
|
|
66
|
+
const playerRef = useRef<any>(null);
|
|
67
|
+
|
|
68
|
+
// Simulated wave amplitude for visualization
|
|
69
|
+
const ttsWaveAmplitude = useSharedValue(0);
|
|
70
|
+
|
|
71
|
+
// ============== AUTH ==============
|
|
72
|
+
|
|
73
|
+
const getToken = useCallback((): string | null => {
|
|
74
|
+
if (options.accessToken) return options.accessToken;
|
|
75
|
+
return oxyServices.httpService.getAccessToken();
|
|
76
|
+
}, [options.accessToken, oxyServices]);
|
|
77
|
+
|
|
78
|
+
// Animate wave when playing
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
if (playbackState === 'playing') {
|
|
81
|
+
ttsWaveAmplitude.value = withRepeat(
|
|
82
|
+
withSequence(
|
|
83
|
+
withTiming(0.5, { duration: 800, easing: Easing.inOut(Easing.sin) }),
|
|
84
|
+
withTiming(0.2, { duration: 600, easing: Easing.inOut(Easing.sin) }),
|
|
85
|
+
withTiming(0.6, { duration: 700, easing: Easing.inOut(Easing.sin) }),
|
|
86
|
+
withTiming(0.15, { duration: 500, easing: Easing.inOut(Easing.sin) }),
|
|
87
|
+
),
|
|
88
|
+
-1,
|
|
89
|
+
);
|
|
90
|
+
} else {
|
|
91
|
+
cancelAnimation(ttsWaveAmplitude);
|
|
92
|
+
ttsWaveAmplitude.value = withTiming(0, { duration: 300 });
|
|
93
|
+
}
|
|
94
|
+
}, [playbackState]);
|
|
95
|
+
|
|
96
|
+
const getTTSVoice = useCallback(() => {
|
|
97
|
+
return voicePref === 'male' ? 'echo' : 'nova';
|
|
98
|
+
}, [voicePref]);
|
|
99
|
+
|
|
100
|
+
const getTTSSpeed = useCallback(() => {
|
|
101
|
+
if (tonePref === 'brief') return 1.15;
|
|
102
|
+
if (tonePref === 'chill') return 0.9;
|
|
103
|
+
return 1.0;
|
|
104
|
+
}, [tonePref]);
|
|
105
|
+
|
|
106
|
+
const releasePlayer = useCallback(() => {
|
|
107
|
+
try {
|
|
108
|
+
playerRef.current?.remove();
|
|
109
|
+
} catch {}
|
|
110
|
+
playerRef.current = null;
|
|
111
|
+
}, []);
|
|
112
|
+
|
|
113
|
+
const stop = useCallback(() => {
|
|
114
|
+
releasePlayer();
|
|
115
|
+
reset();
|
|
116
|
+
}, [reset, releasePlayer]);
|
|
117
|
+
|
|
118
|
+
const playFromUrl = useCallback((audioUrl: string, _messageId: string) => {
|
|
119
|
+
releasePlayer();
|
|
120
|
+
|
|
121
|
+
(async () => {
|
|
122
|
+
try {
|
|
123
|
+
const { createAudioPlayer } = await import('expo-audio');
|
|
124
|
+
const player = createAudioPlayer({ uri: audioUrl });
|
|
125
|
+
playerRef.current = player;
|
|
126
|
+
|
|
127
|
+
player.addListener('playbackStatusUpdate', (status: any) => {
|
|
128
|
+
if (status.didJustFinish) {
|
|
129
|
+
releasePlayer();
|
|
130
|
+
reset();
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
player.play();
|
|
135
|
+
setPlaybackState('playing');
|
|
136
|
+
} catch {
|
|
137
|
+
setError('Audio playback not available');
|
|
138
|
+
}
|
|
139
|
+
})();
|
|
140
|
+
}, [releasePlayer, reset, setPlaybackState, setError]);
|
|
141
|
+
|
|
142
|
+
const readAloud = useCallback(async (
|
|
143
|
+
messageId: string,
|
|
144
|
+
text: string,
|
|
145
|
+
conversationId?: string,
|
|
146
|
+
audioUrl?: string,
|
|
147
|
+
) => {
|
|
148
|
+
// If same message is playing, toggle pause/play
|
|
149
|
+
if (activeMessageId === messageId) {
|
|
150
|
+
if (playbackState === 'playing') {
|
|
151
|
+
playerRef.current?.pause();
|
|
152
|
+
setPlaybackState('paused');
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (playbackState === 'paused') {
|
|
156
|
+
playerRef.current?.play();
|
|
157
|
+
setPlaybackState('playing');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Stop any current playback
|
|
163
|
+
if (activeMessageId) {
|
|
164
|
+
stop();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
setActiveMessage(messageId);
|
|
169
|
+
setPlaybackState('loading');
|
|
170
|
+
|
|
171
|
+
// If cached audioUrl exists, play directly
|
|
172
|
+
if (audioUrl) {
|
|
173
|
+
playFromUrl(audioUrl, messageId);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Call backend TTS API
|
|
178
|
+
const token = getToken();
|
|
179
|
+
if (!token) {
|
|
180
|
+
throw new Error('Not authenticated');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const response = await fetch(`${apiUrl}/v1/audio/speech`, {
|
|
184
|
+
method: 'POST',
|
|
185
|
+
headers: {
|
|
186
|
+
'Content-Type': 'application/json',
|
|
187
|
+
Authorization: `Bearer ${token}`,
|
|
188
|
+
},
|
|
189
|
+
body: JSON.stringify({
|
|
190
|
+
model: 'alia-v1-voice',
|
|
191
|
+
input: text,
|
|
192
|
+
voice: getTTSVoice(),
|
|
193
|
+
speed: getTTSSpeed(),
|
|
194
|
+
conversationId,
|
|
195
|
+
messageId,
|
|
196
|
+
}),
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
if (!response.ok) {
|
|
200
|
+
if (response.status === 504) {
|
|
201
|
+
throw new Error('Request timed out — please try again');
|
|
202
|
+
}
|
|
203
|
+
const errData = await response.json().catch(() => ({}));
|
|
204
|
+
throw new Error(errData.error?.message || errData.error || 'TTS failed');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const data = await response.json();
|
|
208
|
+
playFromUrl(data.audioUrl, messageId);
|
|
209
|
+
} catch (e: any) {
|
|
210
|
+
console.error('[TTS] Error:', e);
|
|
211
|
+
setError(e.message || 'Failed to read aloud');
|
|
212
|
+
}
|
|
213
|
+
}, [activeMessageId, playbackState, getToken, apiUrl, getTTSVoice, getTTSSpeed, stop, playFromUrl, setActiveMessage, setPlaybackState, setError]);
|
|
214
|
+
|
|
215
|
+
// Cleanup on unmount
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
return () => {
|
|
218
|
+
releasePlayer();
|
|
219
|
+
};
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
return {
|
|
223
|
+
readAloud,
|
|
224
|
+
stop,
|
|
225
|
+
activeMessageId,
|
|
226
|
+
playbackState,
|
|
227
|
+
error,
|
|
228
|
+
ttsWaveAmplitude,
|
|
229
|
+
isPlaying: playbackState === 'playing',
|
|
230
|
+
isPaused: playbackState === 'paused',
|
|
231
|
+
isLoading: playbackState === 'loading',
|
|
232
|
+
};
|
|
233
|
+
}
|