@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,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-time audio level monitoring using Web Audio API.
|
|
3
|
+
*
|
|
4
|
+
* Extracts RMS levels from LiveKit audio tracks (local mic + remote agents)
|
|
5
|
+
* and returns them at ~20fps for driving wave animations.
|
|
6
|
+
*
|
|
7
|
+
* Supports multiple remote audio tracks (primary + cohost) by taking
|
|
8
|
+
* the max RMS level across all subscribed remote tracks.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { useState, useEffect, useRef } from 'react';
|
|
12
|
+
import { Room, RoomEvent, Track } from 'livekit-client';
|
|
13
|
+
|
|
14
|
+
/** Compute RMS (root mean square) amplitude from an AnalyserNode. Returns 0-1. */
|
|
15
|
+
function getRMS(analyser: AnalyserNode, buffer: any): number {
|
|
16
|
+
analyser.getFloatTimeDomainData(buffer);
|
|
17
|
+
let sum = 0;
|
|
18
|
+
for (let i = 0; i < buffer.length; i++) sum += buffer[i] * buffer[i];
|
|
19
|
+
return Math.min(1, Math.sqrt(sum / buffer.length) * 8);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function useAudioLevelMonitor(room: Room | null, isConnected: boolean) {
|
|
23
|
+
const [captureLevel, setCaptureLevel] = useState(0);
|
|
24
|
+
const [playbackLevel, setPlaybackLevel] = useState(0);
|
|
25
|
+
const cleanupRef = useRef<(() => void) | null>(null);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
// Tear down previous monitors
|
|
29
|
+
cleanupRef.current?.();
|
|
30
|
+
cleanupRef.current = null;
|
|
31
|
+
|
|
32
|
+
if (!room || !isConnected) {
|
|
33
|
+
setCaptureLevel(0);
|
|
34
|
+
setPlaybackLevel(0);
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Guard: Web Audio API required
|
|
39
|
+
if (typeof AudioContext === 'undefined') return;
|
|
40
|
+
|
|
41
|
+
const audioCtx = new AudioContext();
|
|
42
|
+
// Resume AudioContext (may be suspended until user gesture on mobile/WebView)
|
|
43
|
+
if (audioCtx.state === 'suspended') {
|
|
44
|
+
audioCtx.resume().catch(() => {});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let localAnalyser: AnalyserNode | null = null;
|
|
48
|
+
let localBuffer: Float32Array | null = null;
|
|
49
|
+
const remoteAnalysers = new Map<string, { analyser: AnalyserNode; buffer: Float32Array }>();
|
|
50
|
+
let rafId = 0;
|
|
51
|
+
let disposed = false;
|
|
52
|
+
|
|
53
|
+
// ---------- Local mic ----------
|
|
54
|
+
const localTrack = room.localParticipant?.getTrackPublication(Track.Source.Microphone)?.track;
|
|
55
|
+
if (localTrack?.mediaStreamTrack) {
|
|
56
|
+
try {
|
|
57
|
+
const source = audioCtx.createMediaStreamSource(new MediaStream([localTrack.mediaStreamTrack]));
|
|
58
|
+
localAnalyser = audioCtx.createAnalyser();
|
|
59
|
+
localAnalyser.fftSize = 256;
|
|
60
|
+
source.connect(localAnalyser);
|
|
61
|
+
localBuffer = new Float32Array(localAnalyser.fftSize);
|
|
62
|
+
} catch {
|
|
63
|
+
// MediaStream creation can fail in some environments
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---------- Remote agent audio (supports multiple tracks) ----------
|
|
68
|
+
const setupRemoteAnalyser = (mediaStreamTrack: MediaStreamTrack, key: string) => {
|
|
69
|
+
if (remoteAnalysers.has(key) || disposed) return;
|
|
70
|
+
try {
|
|
71
|
+
const source = audioCtx.createMediaStreamSource(new MediaStream([mediaStreamTrack]));
|
|
72
|
+
const analyser = audioCtx.createAnalyser();
|
|
73
|
+
analyser.fftSize = 256;
|
|
74
|
+
source.connect(analyser);
|
|
75
|
+
const buffer = new Float32Array(analyser.fftSize);
|
|
76
|
+
remoteAnalysers.set(key, { analyser, buffer });
|
|
77
|
+
} catch {
|
|
78
|
+
// Ignore errors
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Check already-subscribed remote tracks
|
|
83
|
+
for (const [, p] of room.remoteParticipants) {
|
|
84
|
+
for (const [, pub] of p.audioTrackPublications) {
|
|
85
|
+
if (pub.track?.mediaStreamTrack) {
|
|
86
|
+
setupRemoteAnalyser(pub.track.mediaStreamTrack, `${p.identity}-${pub.trackSid}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Listen for new remote audio tracks
|
|
92
|
+
const onTrackSubscribed = (track: any, publication: any, participant: any) => {
|
|
93
|
+
if (track.kind === Track.Kind.Audio && track.mediaStreamTrack) {
|
|
94
|
+
setupRemoteAnalyser(track.mediaStreamTrack, `${participant.identity}-${publication.trackSid}`);
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
|
98
|
+
|
|
99
|
+
// Clean up stale analysers when tracks are unsubscribed
|
|
100
|
+
const onTrackUnsubscribed = (track: any, publication: any, participant: any) => {
|
|
101
|
+
const key = `${participant.identity}-${publication.trackSid}`;
|
|
102
|
+
remoteAnalysers.delete(key);
|
|
103
|
+
};
|
|
104
|
+
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
|
105
|
+
|
|
106
|
+
// ---------- Poll loop (~20fps) ----------
|
|
107
|
+
let lastUpdate = 0;
|
|
108
|
+
const poll = (time: number) => {
|
|
109
|
+
if (disposed) return;
|
|
110
|
+
if (time - lastUpdate > 50) {
|
|
111
|
+
lastUpdate = time;
|
|
112
|
+
if (localAnalyser && localBuffer) {
|
|
113
|
+
setCaptureLevel(getRMS(localAnalyser, localBuffer));
|
|
114
|
+
}
|
|
115
|
+
// Take max RMS across all remote tracks (primary + cohost)
|
|
116
|
+
let maxRemoteLevel = 0;
|
|
117
|
+
for (const [, { analyser, buffer }] of remoteAnalysers) {
|
|
118
|
+
const level = getRMS(analyser, buffer);
|
|
119
|
+
if (level > maxRemoteLevel) maxRemoteLevel = level;
|
|
120
|
+
}
|
|
121
|
+
setPlaybackLevel(maxRemoteLevel);
|
|
122
|
+
}
|
|
123
|
+
rafId = requestAnimationFrame(poll);
|
|
124
|
+
};
|
|
125
|
+
rafId = requestAnimationFrame(poll);
|
|
126
|
+
|
|
127
|
+
// ---------- Cleanup ----------
|
|
128
|
+
const dispose = () => {
|
|
129
|
+
disposed = true;
|
|
130
|
+
cancelAnimationFrame(rafId);
|
|
131
|
+
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
|
132
|
+
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
|
133
|
+
remoteAnalysers.clear();
|
|
134
|
+
audioCtx.close().catch(() => {});
|
|
135
|
+
};
|
|
136
|
+
cleanupRef.current = dispose;
|
|
137
|
+
|
|
138
|
+
return dispose;
|
|
139
|
+
}, [room, isConnected]);
|
|
140
|
+
|
|
141
|
+
return { captureLevel, playbackLevel };
|
|
142
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useSharedValue, withTiming, Easing } from 'react-native-reanimated';
|
|
3
|
+
import type { AgentState } from '../types';
|
|
4
|
+
|
|
5
|
+
interface UseAudioLevelsOptions {
|
|
6
|
+
captureLevel: number;
|
|
7
|
+
playbackLevel: number;
|
|
8
|
+
agentState: AgentState;
|
|
9
|
+
isConnected: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useAudioLevels({
|
|
13
|
+
captureLevel,
|
|
14
|
+
playbackLevel,
|
|
15
|
+
agentState,
|
|
16
|
+
isConnected,
|
|
17
|
+
}: UseAudioLevelsOptions) {
|
|
18
|
+
const waveAmplitude = useSharedValue(0);
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!isConnected) {
|
|
22
|
+
waveAmplitude.value = withTiming(0, { duration: 300 });
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const amplifiedPlayback = Math.min(1, playbackLevel * 3);
|
|
27
|
+
|
|
28
|
+
let target = 0;
|
|
29
|
+
if (agentState === 'thinking') {
|
|
30
|
+
target = Math.max(0.15, captureLevel);
|
|
31
|
+
} else if (agentState === 'speaking') {
|
|
32
|
+
// 0.4 baseline ensures visible waves while AI speaks
|
|
33
|
+
target = Math.max(0.4, amplifiedPlayback, captureLevel * 0.5);
|
|
34
|
+
} else if (agentState === 'listening') {
|
|
35
|
+
// Subtle pulse while listening; mic level takes over when available
|
|
36
|
+
target = Math.max(0.08, captureLevel, amplifiedPlayback * 0.5);
|
|
37
|
+
} else {
|
|
38
|
+
// Idle — calm, no minimum
|
|
39
|
+
target = Math.max(captureLevel, amplifiedPlayback * 0.5);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fast attack, slower decay for natural VU-meter feel
|
|
43
|
+
const duration = target > waveAmplitude.value ? 60 : 200;
|
|
44
|
+
waveAmplitude.value = withTiming(target, {
|
|
45
|
+
duration,
|
|
46
|
+
easing: Easing.bezier(0.33, 1, 0.68, 1),
|
|
47
|
+
});
|
|
48
|
+
}, [captureLevel, playbackLevel, agentState, isConnected]);
|
|
49
|
+
|
|
50
|
+
return { waveAmplitude };
|
|
51
|
+
}
|
|
@@ -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,180 @@
|
|
|
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
|
+
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
|
+
const base64 = await new Promise<string>((resolve, reject) => {
|
|
123
|
+
const reader = new FileReader();
|
|
124
|
+
reader.onload = () => {
|
|
125
|
+
const result = reader.result as string;
|
|
126
|
+
resolve(result.split(',')[1]);
|
|
127
|
+
};
|
|
128
|
+
reader.onerror = reject;
|
|
129
|
+
reader.readAsDataURL(blob);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// Send to transcription API
|
|
133
|
+
const token = getToken();
|
|
134
|
+
const transcribeResponse = await fetch(`${apiUrl}/v1/voice/transcribe`, {
|
|
135
|
+
method: 'POST',
|
|
136
|
+
headers: {
|
|
137
|
+
'Content-Type': 'application/json',
|
|
138
|
+
Authorization: `Bearer ${token}`,
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
audio: base64,
|
|
142
|
+
format: 'audio/m4a',
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
if (!transcribeResponse.ok) {
|
|
147
|
+
const errorData = await transcribeResponse.json().catch(() => ({}));
|
|
148
|
+
throw new Error(errorData.error || 'Transcription failed');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const result = await transcribeResponse.json() as { text: string };
|
|
152
|
+
setState('idle');
|
|
153
|
+
return result.text || null;
|
|
154
|
+
} catch (e: any) {
|
|
155
|
+
console.error('[STT] Transcription error:', e);
|
|
156
|
+
setError(e.message || 'Transcription failed');
|
|
157
|
+
setState('idle');
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
}, [audioRecorder, getToken, apiUrl]);
|
|
161
|
+
|
|
162
|
+
const cancel = useCallback(() => {
|
|
163
|
+
if (isRecordingRef.current) {
|
|
164
|
+
isRecordingRef.current = false;
|
|
165
|
+
Promise.resolve(audioRecorder.stop()).catch(() => {});
|
|
166
|
+
}
|
|
167
|
+
setState('idle');
|
|
168
|
+
setError(null);
|
|
169
|
+
}, [audioRecorder]);
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
state,
|
|
173
|
+
error,
|
|
174
|
+
startRecording,
|
|
175
|
+
stopAndTranscribe,
|
|
176
|
+
cancel,
|
|
177
|
+
isRecording: state === 'recording',
|
|
178
|
+
isTranscribing: state === 'transcribing',
|
|
179
|
+
};
|
|
180
|
+
}
|