@alia.onl/sdk 13.2.0 → 15.3.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/CHANGELOG.md +139 -0
- package/README.md +53 -21
- package/package.json +9 -5
- package/src/components/AliaAvatarIcon.tsx +30 -0
- package/src/components/AliaChatContent.tsx +4 -7
- package/src/components/AliaChatMessageList.tsx +2 -10
- package/src/components/AliaChatScreen.tsx +1 -1
- package/src/components/AliaChatSheet.tsx +15 -4
- package/src/components/AliaComposer.tsx +62 -16
- package/src/components/voice/VoiceControls.tsx +28 -4
- package/src/components/voice/VoiceSession.tsx +23 -7
- package/src/hooks/useAliaActivity.ts +31 -0
- package/src/hooks/useAliaChat.ts +14 -3
- package/src/hooks/useAmbientWave.ts +3 -3
- package/src/hooks/useAudioLevelMonitor.ts +2 -2
- package/src/hooks/useDictation.ts +295 -0
- package/src/hooks/useTTS.ts +24 -63
- package/src/hooks/useVoiceRoom.ts +148 -736
- package/src/index.ts +9 -3
- package/src/lib/activity-socket.ts +102 -0
- package/src/lib/activity-store.ts +53 -0
- package/src/lib/api-url.ts +2 -0
- package/src/lib/audio-level.ts +2 -2
- package/src/lib/dictation-recorder-types.ts +37 -0
- package/src/lib/dictation-recorder.native.ts +90 -0
- package/src/lib/dictation-recorder.ts +131 -0
- package/src/lib/dictation.ts +208 -0
- package/src/lib/labels.tsx +3 -0
- package/src/lib/pcm.ts +57 -0
- package/src/lib/speech-language.ts +1 -1
- package/src/lib/speech-messages.ts +43 -97
- package/src/lib/speech-synthesis.ts +5 -1
- package/src/lib/turn-state.ts +15 -0
- package/src/lib/voice-audio-engine-types.ts +59 -0
- package/src/lib/voice-audio-engine.native.ts +232 -0
- package/src/lib/voice-audio-engine.ts +350 -0
- package/src/lib/voice-call.ts +539 -0
- package/src/lib/voice-levels.ts +7 -3
- package/src/lib/voice-protocol.ts +309 -0
- package/src/types.ts +49 -4
- package/src/voice.ts +31 -12
- package/tsconfig.json +2 -0
- package/src/hooks/useSpeechToText.ts +0 -268
- package/src/lib/speech-recognition-types.ts +0 -108
- package/src/lib/speech-recognition.native.ts +0 -237
- package/src/lib/speech-recognition.ts +0 -308
- package/src/lib/voice-text.ts +0 -198
- package/src/lib/voice-turn.ts +0 -89
|
@@ -8,15 +8,17 @@ import { Loading } from '@oxy.so/bloom/loading';
|
|
|
8
8
|
import { Text } from '@oxy.so/bloom/typography';
|
|
9
9
|
import { VoiceControls as BloomVoiceControls, type VoiceState } from '@oxy.so/bloom/voice';
|
|
10
10
|
import { View } from 'react-native';
|
|
11
|
-
import type { RoomState, AgentState } from '../../types';
|
|
11
|
+
import type { RoomState, AgentState, VoiceCallState } from '../../types';
|
|
12
12
|
|
|
13
13
|
/** Every word the bar shows or announces. An app in another language passes its own. */
|
|
14
14
|
export interface VoiceControlsLabels {
|
|
15
15
|
connecting: string;
|
|
16
|
+
reconnecting: string;
|
|
16
17
|
connected: string;
|
|
17
18
|
listening: string;
|
|
18
19
|
muted: string;
|
|
19
20
|
thinking: string;
|
|
21
|
+
usingTool: string;
|
|
20
22
|
speaking: string;
|
|
21
23
|
mute: string;
|
|
22
24
|
unmute: string;
|
|
@@ -25,10 +27,12 @@ export interface VoiceControlsLabels {
|
|
|
25
27
|
|
|
26
28
|
export const VOICE_CONTROLS_LABELS: VoiceControlsLabels = {
|
|
27
29
|
connecting: 'Connecting...',
|
|
30
|
+
reconnecting: 'Reconnecting...',
|
|
28
31
|
connected: 'Connected',
|
|
29
32
|
listening: 'Listening...',
|
|
30
33
|
muted: 'Muted',
|
|
31
34
|
thinking: 'Thinking...',
|
|
35
|
+
usingTool: 'Working on it...',
|
|
32
36
|
speaking: 'Speaking...',
|
|
33
37
|
mute: 'Mute',
|
|
34
38
|
unmute: 'Unmute',
|
|
@@ -41,6 +45,16 @@ export interface VoiceControlsProps {
|
|
|
41
45
|
isMuted: boolean;
|
|
42
46
|
onToggleMute: () => void;
|
|
43
47
|
onEnd: () => void;
|
|
48
|
+
/**
|
|
49
|
+
* Something the person should know while the call listens — e.g. that the
|
|
50
|
+
* call is about to reach its time limit. Shown in place of "Listening…".
|
|
51
|
+
*/
|
|
52
|
+
notice?: string | null;
|
|
53
|
+
/**
|
|
54
|
+
* The call's full state, when the host has it: a tool running reads as such
|
|
55
|
+
* rather than as "Thinking…".
|
|
56
|
+
*/
|
|
57
|
+
callState?: VoiceCallState;
|
|
44
58
|
/** Overrides for any of {@link VOICE_CONTROLS_LABELS}. */
|
|
45
59
|
labels?: Partial<VoiceControlsLabels>;
|
|
46
60
|
}
|
|
@@ -49,11 +63,15 @@ function getStatusText(
|
|
|
49
63
|
roomState: RoomState,
|
|
50
64
|
agentState: AgentState,
|
|
51
65
|
isMuted: boolean,
|
|
66
|
+
notice: string | null,
|
|
52
67
|
labels: VoiceControlsLabels,
|
|
68
|
+
callState: VoiceCallState | undefined,
|
|
53
69
|
): string {
|
|
54
70
|
if (roomState === 'connecting') return labels.connecting;
|
|
71
|
+
if (roomState === 'reconnecting') return labels.reconnecting;
|
|
55
72
|
if (roomState === 'connected') {
|
|
56
|
-
if (agentState === 'listening') return isMuted ? labels.muted : labels.listening;
|
|
73
|
+
if (agentState === 'listening') return isMuted ? labels.muted : (notice ?? labels.listening);
|
|
74
|
+
if (callState?.kind === 'using-tool') return labels.usingTool;
|
|
57
75
|
if (agentState === 'thinking') return labels.thinking;
|
|
58
76
|
if (agentState === 'speaking') return labels.speaking;
|
|
59
77
|
return labels.connected;
|
|
@@ -74,10 +92,12 @@ export function VoiceControls({
|
|
|
74
92
|
isMuted,
|
|
75
93
|
onToggleMute,
|
|
76
94
|
onEnd,
|
|
95
|
+
notice = null,
|
|
96
|
+
callState,
|
|
77
97
|
labels: labelOverrides,
|
|
78
98
|
}: VoiceControlsProps) {
|
|
79
99
|
const labels = { ...VOICE_CONTROLS_LABELS, ...labelOverrides };
|
|
80
|
-
const statusText = getStatusText(roomState, agentState, isMuted, labels);
|
|
100
|
+
const statusText = getStatusText(roomState, agentState, isMuted, notice, labels, callState);
|
|
81
101
|
|
|
82
102
|
return (
|
|
83
103
|
<View className="items-center gap-4 pb-6 pt-4">
|
|
@@ -102,7 +122,11 @@ export function VoiceControls({
|
|
|
102
122
|
/>
|
|
103
123
|
) : null}
|
|
104
124
|
|
|
105
|
-
{roomState === 'connecting'
|
|
125
|
+
{roomState === 'connecting' || roomState === 'reconnecting' ? (
|
|
126
|
+
<Loading
|
|
127
|
+
accessibilityLabel={roomState === 'connecting' ? labels.connecting : labels.reconnecting}
|
|
128
|
+
/>
|
|
129
|
+
) : null}
|
|
106
130
|
</View>
|
|
107
131
|
);
|
|
108
132
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* A live voice call, mounted only while voice mode is on.
|
|
3
3
|
*
|
|
4
4
|
* The chat shell receives it as the `voiceSession` prop rather than importing
|
|
5
|
-
* it, so a text-only consumer never compiles the
|
|
5
|
+
* it, so a text-only consumer never compiles the call, its audio engine or
|
|
6
6
|
* the call UI — which is the reason the `@alia.onl/sdk/voice` entry exists.
|
|
7
7
|
*
|
|
8
8
|
* Mounting starts the call; unmounting ends it (`useVoiceRoom` stops listening
|
|
@@ -17,9 +17,18 @@ import { useAudioLevels } from '../../hooks/useAudioLevels';
|
|
|
17
17
|
import { VoiceControls } from './VoiceControls';
|
|
18
18
|
import type { VoiceSessionProps } from '../../types';
|
|
19
19
|
|
|
20
|
-
export function VoiceSession({
|
|
20
|
+
export function VoiceSession({
|
|
21
|
+
apiUrl,
|
|
22
|
+
conversationId,
|
|
23
|
+
agentId,
|
|
24
|
+
locale,
|
|
25
|
+
voice,
|
|
26
|
+
onStateChange,
|
|
27
|
+
onEnd,
|
|
28
|
+
}: VoiceSessionProps) {
|
|
21
29
|
const {
|
|
22
30
|
room,
|
|
31
|
+
callState,
|
|
23
32
|
roomState,
|
|
24
33
|
agentState,
|
|
25
34
|
isMuted,
|
|
@@ -28,7 +37,13 @@ export function VoiceSession({ apiUrl, onStateChange, onEnd }: VoiceSessionProps
|
|
|
28
37
|
isConnected,
|
|
29
38
|
connect,
|
|
30
39
|
toggleMute,
|
|
31
|
-
} = useVoiceRoom({
|
|
40
|
+
} = useVoiceRoom({
|
|
41
|
+
apiUrl,
|
|
42
|
+
...(conversationId === undefined ? {} : { conversationId }),
|
|
43
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
44
|
+
...(locale === undefined ? {} : { lang: locale }),
|
|
45
|
+
...(voice === undefined ? {} : { voicePreference: voice }),
|
|
46
|
+
});
|
|
32
47
|
|
|
33
48
|
const { captureLevel, playbackLevel } = useAudioLevelMonitor(room, isConnected);
|
|
34
49
|
const { waveAmplitude } = useAudioLevels({
|
|
@@ -44,21 +59,21 @@ export function VoiceSession({ apiUrl, onStateChange, onEnd }: VoiceSessionProps
|
|
|
44
59
|
useEffect(() => {
|
|
45
60
|
if (dialedRef.current) return;
|
|
46
61
|
dialedRef.current = true;
|
|
47
|
-
connect();
|
|
62
|
+
void connect();
|
|
48
63
|
}, [connect]);
|
|
49
64
|
|
|
50
65
|
useEffect(() => {
|
|
51
66
|
onStateChange({ isConnected, agentState, waveAmplitude, messages });
|
|
52
67
|
}, [isConnected, agentState, waveAmplitude, messages, onStateChange]);
|
|
53
68
|
|
|
54
|
-
// End the call on failure, or once a
|
|
69
|
+
// End the call on failure, or once a call we actually reached ends. The
|
|
55
70
|
// "reached" flag is what keeps the pre-connect `disconnected` state — which
|
|
56
71
|
// every session starts in — from ending the call before it begins.
|
|
57
72
|
const reachedRoomRef = useRef(false);
|
|
58
73
|
useEffect(() => {
|
|
59
|
-
if (roomState
|
|
74
|
+
if (roomState !== 'disconnected') reachedRoomRef.current = true;
|
|
60
75
|
if (error || roomState === 'error') {
|
|
61
|
-
onEnd(error ?? VOICE_ERROR_MESSAGES['
|
|
76
|
+
onEnd(error ?? VOICE_ERROR_MESSAGES['realtime-failed']);
|
|
62
77
|
return;
|
|
63
78
|
}
|
|
64
79
|
if (roomState === 'disconnected' && reachedRoomRef.current) onEnd();
|
|
@@ -71,6 +86,7 @@ export function VoiceSession({ apiUrl, onStateChange, onEnd }: VoiceSessionProps
|
|
|
71
86
|
isMuted={isMuted}
|
|
72
87
|
onToggleMute={toggleMute}
|
|
73
88
|
onEnd={onEnd}
|
|
89
|
+
callState={callState}
|
|
74
90
|
/>
|
|
75
91
|
);
|
|
76
92
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
3
|
+
import type { AliaAvatarState } from '../components/AliaAvatar';
|
|
4
|
+
import { selectAliaActivity, useAliaActivityStore } from '../lib/activity-store';
|
|
5
|
+
import { acquireActivitySocket } from '../lib/activity-socket';
|
|
6
|
+
import { ALIA_API_URL } from '../lib/api-url';
|
|
7
|
+
|
|
8
|
+
export interface UseAliaActivityOptions {
|
|
9
|
+
/** The API the activity socket connects to. Defaults like `useAliaChat`'s `apiUrl`. */
|
|
10
|
+
apiUrl?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What Alia is doing right now, for any surface that wants to show it — an "Ask Alia" button's
|
|
15
|
+
* avatar, a status dot. It is the busiest of:
|
|
16
|
+
*
|
|
17
|
+
* - a turn running in this app (`useAliaChat`, so the sheet drives every button), and
|
|
18
|
+
* - her runs for the signed-in person elsewhere, reported by the server over one shared socket
|
|
19
|
+
* (background work, another device), connected only while someone is signed in AND using this.
|
|
20
|
+
*/
|
|
21
|
+
export function useAliaActivity(options: UseAliaActivityOptions = {}): AliaAvatarState {
|
|
22
|
+
const { apiUrl = ALIA_API_URL } = options;
|
|
23
|
+
const { oxyServices, isAuthenticated } = useOxy();
|
|
24
|
+
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
if (!isAuthenticated) return;
|
|
27
|
+
return acquireActivitySocket(apiUrl, () => oxyServices.session.accessToken);
|
|
28
|
+
}, [apiUrl, isAuthenticated, oxyServices]);
|
|
29
|
+
|
|
30
|
+
return useAliaActivityStore(selectAliaActivity);
|
|
31
|
+
}
|
package/src/hooks/useAliaChat.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react';
|
|
2
2
|
import { useOxy } from '@oxy.so/services/ui/client';
|
|
3
3
|
import type { ChatMessage, ToolInvocation } from '../types';
|
|
4
4
|
import { getTextFromContent, type MessageContent } from '../lib/content-utils';
|
|
@@ -7,7 +7,9 @@ import { agentMessageId, delegatedAgent } from '../lib/delegation';
|
|
|
7
7
|
import { streamAliaChat } from '../lib/chat-transport';
|
|
8
8
|
import type { AliaChatStreamEvent } from '../lib/chat-stream';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
import { ALIA_API_URL } from '../lib/api-url';
|
|
11
|
+
import { useAliaActivityStore } from '../lib/activity-store';
|
|
12
|
+
import { turnActivityState } from '../lib/turn-state';
|
|
11
13
|
|
|
12
14
|
export interface UseAliaChatOptions {
|
|
13
15
|
/**
|
|
@@ -129,7 +131,7 @@ function waitWithAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T>
|
|
|
129
131
|
* handler during its recorded compatibility window.
|
|
130
132
|
*/
|
|
131
133
|
export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn {
|
|
132
|
-
const { apiUrl =
|
|
134
|
+
const { apiUrl = ALIA_API_URL, model, clientContext, accessToken: accessTokenProp } = options;
|
|
133
135
|
const directoryRef = useRef(new Map<string, { id: string; character?: unknown }>());
|
|
134
136
|
directoryRef.current = new Map((options.agents ?? []).map((agent) => [agent.id, agent]));
|
|
135
137
|
const requestUrl = '/v1/chat/completions';
|
|
@@ -147,6 +149,15 @@ export function useAliaChat(options: UseAliaChatOptions = {}): UseAliaChatReturn
|
|
|
147
149
|
messagesRef.current = messages;
|
|
148
150
|
}, [messages]);
|
|
149
151
|
|
|
152
|
+
// The turn's lifecycle, published for every "Ask Alia" button (`useAliaActivity`).
|
|
153
|
+
const activitySource = useId();
|
|
154
|
+
const activity = turnActivityState(messages, isStreaming);
|
|
155
|
+
useEffect(() => {
|
|
156
|
+
const { setLocal } = useAliaActivityStore.getState();
|
|
157
|
+
setLocal(activitySource, activity);
|
|
158
|
+
return () => setLocal(activitySource, 'idle');
|
|
159
|
+
}, [activitySource, activity]);
|
|
160
|
+
|
|
150
161
|
const updateAssistant = useCallback(
|
|
151
162
|
(assistantId: string, updater: (message: ChatMessage) => Partial<ChatMessage>) => {
|
|
152
163
|
if (!mountedRef.current) return;
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
Easing,
|
|
11
11
|
type SharedValue,
|
|
12
12
|
} from 'react-native-reanimated';
|
|
13
|
-
import {
|
|
13
|
+
import { useDictationStore } from './useDictation';
|
|
14
14
|
import { LEVEL_ATTACK_MS, LEVEL_RELEASE_MS } from '../lib/audio-level';
|
|
15
15
|
import type { AgentState } from '../types';
|
|
16
16
|
|
|
@@ -95,8 +95,8 @@ export function useAmbientWave({
|
|
|
95
95
|
// travel takes about three times as long. Tuning the constant moves both, by
|
|
96
96
|
// different amounts. They are shared because one number should tune one
|
|
97
97
|
// feel, not because the two are interchangeable.
|
|
98
|
-
const sttIsRecording =
|
|
99
|
-
const sttMetering =
|
|
98
|
+
const sttIsRecording = useDictationStore((s) => s.isRecording);
|
|
99
|
+
const sttMetering = useDictationStore((s) => s.metering);
|
|
100
100
|
const stt = useSharedValue(0);
|
|
101
101
|
useAnimatedReaction(
|
|
102
102
|
() => (sttIsRecording ? Math.max(0.08, sttMetering) : -1),
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* A voice call's live levels, as React state at ~20fps.
|
|
3
3
|
*
|
|
4
|
-
* `useVoiceRoom` writes the microphone level (from the
|
|
5
|
-
* playback level (from the
|
|
4
|
+
* `useVoiceRoom` writes the microphone level (from the captured buffers) and the
|
|
5
|
+
* playback level (from the answer being played) into a `VoiceLevelSource`; this
|
|
6
6
|
* reads them on a clock so a wave can follow them without rendering once per
|
|
7
7
|
* audio buffer. It used to put Web Audio analysers on a LiveKit room's tracks;
|
|
8
8
|
* the signature kept its shape — `room` is now the level source `useVoiceRoom`
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's microphone: record a clip, then transcribe it with a Kaana
|
|
3
|
+
* transcription model through Alia (`POST /v1/audio/transcriptions`, ADR 0017
|
|
4
|
+
* §7). Free on every plan, five minutes per clip at most.
|
|
5
|
+
*
|
|
6
|
+
* idle → requesting-permission → recording → transcribing → idle (text)
|
|
7
|
+
* ↘ error { code }
|
|
8
|
+
*
|
|
9
|
+
* - `recording` is entered only once the recorder reports it started; the
|
|
10
|
+
* level meter is the real recorder's.
|
|
11
|
+
* - The text arrives after the person stops — `onText` — and never partially:
|
|
12
|
+
* a failed transcription inserts nothing and keeps the clip for ONE `retry()`.
|
|
13
|
+
* - At five minutes the recording stops by itself and is transcribed; the
|
|
14
|
+
* remaining time is exposed for the last 30 seconds.
|
|
15
|
+
*
|
|
16
|
+
* Nothing is recognized on the device.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
20
|
+
import { create } from 'zustand';
|
|
21
|
+
import { useOxy } from '@oxy.so/services/ui/client';
|
|
22
|
+
import {
|
|
23
|
+
dictationFailureCode,
|
|
24
|
+
MAX_DICTATION_MS,
|
|
25
|
+
requestTranscription,
|
|
26
|
+
type DictationClip,
|
|
27
|
+
} from '../lib/dictation';
|
|
28
|
+
import { DictationRecorderError, useDictationRecorder } from '../lib/dictation-recorder';
|
|
29
|
+
import { defaultSpeechLanguage } from '../lib/speech-language';
|
|
30
|
+
import { VOICE_ERROR_MESSAGES, type VoiceErrorCode } from '../lib/speech-messages';
|
|
31
|
+
|
|
32
|
+
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
33
|
+
|
|
34
|
+
/** The remaining time is shown from here on. */
|
|
35
|
+
const COUNTDOWN_FROM_MS = 30_000;
|
|
36
|
+
const CLOCK_MS = 250;
|
|
37
|
+
|
|
38
|
+
export type DictationState =
|
|
39
|
+
| 'idle'
|
|
40
|
+
| 'requesting-permission'
|
|
41
|
+
| 'recording'
|
|
42
|
+
| 'transcribing'
|
|
43
|
+
| 'error';
|
|
44
|
+
|
|
45
|
+
export interface UseDictationOptions {
|
|
46
|
+
apiUrl?: string;
|
|
47
|
+
/** Bearer; defaults to the surrounding Oxy session's. */
|
|
48
|
+
accessToken?: string;
|
|
49
|
+
/** Language spoken (BCP-47); defaults to the platform's. The Alia app passes its own. */
|
|
50
|
+
lang?: string;
|
|
51
|
+
/** The transcribed text, once — after a stop, the five-minute stop, or a retry. */
|
|
52
|
+
onText?: (text: string) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface DictationStoreState {
|
|
56
|
+
isRecording: boolean;
|
|
57
|
+
metering: number;
|
|
58
|
+
setRecording: (isRecording: boolean) => void;
|
|
59
|
+
setMetering: (metering: number) => void;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether the composer is recording, and its level: what the ambient field and
|
|
64
|
+
* the app's turn actions read without holding the hook.
|
|
65
|
+
*/
|
|
66
|
+
export const useDictationStore = create<DictationStoreState>((set) => ({
|
|
67
|
+
isRecording: false,
|
|
68
|
+
metering: 0,
|
|
69
|
+
setRecording: (isRecording) => set({ isRecording }),
|
|
70
|
+
setMetering: (metering) => set({ metering }),
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
export function useDictation(options: UseDictationOptions = {}) {
|
|
74
|
+
const { oxyServices } = useOxy();
|
|
75
|
+
const recorder = useDictationRecorder();
|
|
76
|
+
const [state, setStateValue] = useState<DictationState>('idle');
|
|
77
|
+
const [errorCode, setErrorCode] = useState<VoiceErrorCode | null>(null);
|
|
78
|
+
const [elapsedMs, setElapsedMs] = useState(0);
|
|
79
|
+
const [canRetry, setCanRetry] = useState(false);
|
|
80
|
+
|
|
81
|
+
const stateRef = useRef<DictationState>('idle');
|
|
82
|
+
const mountedRef = useRef(true);
|
|
83
|
+
const startedAtRef = useRef(0);
|
|
84
|
+
const clockRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
85
|
+
const capRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
86
|
+
const failedClipRef = useRef<DictationClip | null>(null);
|
|
87
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
88
|
+
const lastMeteringRef = useRef(0);
|
|
89
|
+
const optionsRef = useRef(options);
|
|
90
|
+
optionsRef.current = options;
|
|
91
|
+
|
|
92
|
+
const setState = useCallback((next: DictationState) => {
|
|
93
|
+
stateRef.current = next;
|
|
94
|
+
if (mountedRef.current) setStateValue(next);
|
|
95
|
+
}, []);
|
|
96
|
+
|
|
97
|
+
const report = useCallback(
|
|
98
|
+
(code: VoiceErrorCode | null) => {
|
|
99
|
+
if (mountedRef.current) setErrorCode(code);
|
|
100
|
+
if (code !== null) setState('error');
|
|
101
|
+
},
|
|
102
|
+
[setState],
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
const store = useDictationStore.getState();
|
|
107
|
+
store.setRecording(state === 'recording');
|
|
108
|
+
if (state !== 'recording') {
|
|
109
|
+
lastMeteringRef.current = 0;
|
|
110
|
+
store.setMetering(0);
|
|
111
|
+
}
|
|
112
|
+
}, [state]);
|
|
113
|
+
|
|
114
|
+
const clearClock = useCallback((): void => {
|
|
115
|
+
if (clockRef.current !== null) clearInterval(clockRef.current);
|
|
116
|
+
if (capRef.current !== null) clearTimeout(capRef.current);
|
|
117
|
+
clockRef.current = null;
|
|
118
|
+
capRef.current = null;
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const transcribe = useCallback(
|
|
122
|
+
async (clip: DictationClip): Promise<string | null> => {
|
|
123
|
+
const token = optionsRef.current.accessToken || oxyServices.session.accessToken || '';
|
|
124
|
+
if (token === '') {
|
|
125
|
+
failedClipRef.current = null;
|
|
126
|
+
if (mountedRef.current) setCanRetry(false);
|
|
127
|
+
report('not-authenticated');
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
setState('transcribing');
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
abortRef.current = controller;
|
|
133
|
+
try {
|
|
134
|
+
const result = await requestTranscription({
|
|
135
|
+
apiUrl: optionsRef.current.apiUrl || API_URL,
|
|
136
|
+
token,
|
|
137
|
+
clip,
|
|
138
|
+
language: optionsRef.current.lang ?? defaultSpeechLanguage(),
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
});
|
|
141
|
+
if (controller.signal.aborted) return null;
|
|
142
|
+
failedClipRef.current = null;
|
|
143
|
+
if (mountedRef.current) setCanRetry(false);
|
|
144
|
+
setState('idle');
|
|
145
|
+
const text = result.text.trim();
|
|
146
|
+
if (text === '') return null;
|
|
147
|
+
optionsRef.current.onText?.(text);
|
|
148
|
+
return text;
|
|
149
|
+
} catch (caught: unknown) {
|
|
150
|
+
if (controller.signal.aborted) return null;
|
|
151
|
+
const code = dictationFailureCode(caught);
|
|
152
|
+
// Kept for one retry, unless retrying cannot change the answer.
|
|
153
|
+
const keep = code !== 'too-long' && code !== 'not-authenticated';
|
|
154
|
+
failedClipRef.current = keep ? clip : null;
|
|
155
|
+
if (mountedRef.current) setCanRetry(keep);
|
|
156
|
+
report(code);
|
|
157
|
+
return null;
|
|
158
|
+
} finally {
|
|
159
|
+
if (abortRef.current === controller) abortRef.current = null;
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
[oxyServices, report, setState],
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const stop = useCallback(async (): Promise<string | null> => {
|
|
166
|
+
if (stateRef.current !== 'recording') return null;
|
|
167
|
+
clearClock();
|
|
168
|
+
setState('transcribing');
|
|
169
|
+
let clip: DictationClip | null;
|
|
170
|
+
try {
|
|
171
|
+
clip = await recorder.stop();
|
|
172
|
+
} catch {
|
|
173
|
+
report('mic-unavailable');
|
|
174
|
+
return null;
|
|
175
|
+
}
|
|
176
|
+
if (clip === null) {
|
|
177
|
+
setState('idle');
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
if (clip.durationMs > MAX_DICTATION_MS + 1_000) {
|
|
181
|
+
report('too-long');
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
return transcribe(clip);
|
|
185
|
+
}, [clearClock, recorder, report, setState, transcribe]);
|
|
186
|
+
|
|
187
|
+
const stopRef = useRef(stop);
|
|
188
|
+
stopRef.current = stop;
|
|
189
|
+
|
|
190
|
+
const start = useCallback(async (): Promise<void> => {
|
|
191
|
+
if (stateRef.current !== 'idle' && stateRef.current !== 'error') return;
|
|
192
|
+
if (mountedRef.current) {
|
|
193
|
+
setErrorCode(null);
|
|
194
|
+
setCanRetry(false);
|
|
195
|
+
setElapsedMs(0);
|
|
196
|
+
}
|
|
197
|
+
failedClipRef.current = null;
|
|
198
|
+
setState('requesting-permission');
|
|
199
|
+
try {
|
|
200
|
+
await recorder.start((level) => {
|
|
201
|
+
if (stateRef.current !== 'recording') return;
|
|
202
|
+
// Quiet should not thrash the store's subscribers.
|
|
203
|
+
if (Math.abs(level - lastMeteringRef.current) < 0.02) return;
|
|
204
|
+
lastMeteringRef.current = level;
|
|
205
|
+
useDictationStore.getState().setMetering(level);
|
|
206
|
+
});
|
|
207
|
+
} catch (caught: unknown) {
|
|
208
|
+
if ((stateRef.current as DictationState) !== 'requesting-permission') return;
|
|
209
|
+
report(caught instanceof DictationRecorderError ? caught.code : 'mic-unavailable');
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if ((stateRef.current as DictationState) !== 'requesting-permission') {
|
|
213
|
+
// Cancelled while the permission was asked.
|
|
214
|
+
recorder.cancel();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
startedAtRef.current = Date.now();
|
|
218
|
+
setState('recording');
|
|
219
|
+
clockRef.current = setInterval(() => {
|
|
220
|
+
if (mountedRef.current) setElapsedMs(Date.now() - startedAtRef.current);
|
|
221
|
+
}, CLOCK_MS);
|
|
222
|
+
capRef.current = setTimeout(() => {
|
|
223
|
+
void stopRef.current();
|
|
224
|
+
}, MAX_DICTATION_MS);
|
|
225
|
+
}, [recorder, report, setState]);
|
|
226
|
+
|
|
227
|
+
const retry = useCallback(async (): Promise<string | null> => {
|
|
228
|
+
const clip = failedClipRef.current;
|
|
229
|
+
if (clip === null || stateRef.current === 'recording' || stateRef.current === 'transcribing')
|
|
230
|
+
return null;
|
|
231
|
+
// One retry: whatever it answers, the clip is not kept for another.
|
|
232
|
+
failedClipRef.current = null;
|
|
233
|
+
if (mountedRef.current) {
|
|
234
|
+
setCanRetry(false);
|
|
235
|
+
setErrorCode(null);
|
|
236
|
+
}
|
|
237
|
+
const text = await transcribe(clip);
|
|
238
|
+
failedClipRef.current = null;
|
|
239
|
+
if (mountedRef.current) setCanRetry(false);
|
|
240
|
+
return text;
|
|
241
|
+
}, [transcribe]);
|
|
242
|
+
|
|
243
|
+
const cancel = useCallback(() => {
|
|
244
|
+
clearClock();
|
|
245
|
+
abortRef.current?.abort();
|
|
246
|
+
abortRef.current = null;
|
|
247
|
+
if (stateRef.current === 'recording' || stateRef.current === 'requesting-permission')
|
|
248
|
+
recorder.cancel();
|
|
249
|
+
failedClipRef.current = null;
|
|
250
|
+
if (mountedRef.current) {
|
|
251
|
+
setErrorCode(null);
|
|
252
|
+
setCanRetry(false);
|
|
253
|
+
}
|
|
254
|
+
setState('idle');
|
|
255
|
+
}, [clearClock, recorder, setState]);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
mountedRef.current = true;
|
|
259
|
+
return () => {
|
|
260
|
+
mountedRef.current = false;
|
|
261
|
+
clearClock();
|
|
262
|
+
abortRef.current?.abort();
|
|
263
|
+
const wasRecording =
|
|
264
|
+
stateRef.current === 'recording' || stateRef.current === 'requesting-permission';
|
|
265
|
+
stateRef.current = 'idle';
|
|
266
|
+
if (wasRecording) recorder.cancel();
|
|
267
|
+
// The store outlives this hook: a composer unmounted mid-dictation (the
|
|
268
|
+
// call bar replacing it) must not leave the field reading "recording".
|
|
269
|
+
useDictationStore.getState().setRecording(false);
|
|
270
|
+
useDictationStore.getState().setMetering(0);
|
|
271
|
+
};
|
|
272
|
+
}, [clearClock, recorder]);
|
|
273
|
+
|
|
274
|
+
const remaining = MAX_DICTATION_MS - elapsedMs;
|
|
275
|
+
return {
|
|
276
|
+
state,
|
|
277
|
+
/** What went wrong, in English. */
|
|
278
|
+
error: errorCode === null ? null : VOICE_ERROR_MESSAGES[errorCode],
|
|
279
|
+
/** The same, as a code an app can translate. */
|
|
280
|
+
errorCode,
|
|
281
|
+
/** Since the recording started. */
|
|
282
|
+
elapsedMs,
|
|
283
|
+
/** Left before the five-minute stop, in the last 30 seconds; else `null`. */
|
|
284
|
+
remainingMs:
|
|
285
|
+
state === 'recording' && remaining <= COUNTDOWN_FROM_MS ? Math.max(0, remaining) : null,
|
|
286
|
+
/** A failed transcription kept its clip for one more try. */
|
|
287
|
+
canRetry,
|
|
288
|
+
start,
|
|
289
|
+
stop,
|
|
290
|
+
retry,
|
|
291
|
+
cancel,
|
|
292
|
+
isRecording: state === 'recording',
|
|
293
|
+
isTranscribing: state === 'transcribing',
|
|
294
|
+
};
|
|
295
|
+
}
|