@alia.onl/sdk 7.0.0 → 7.2.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 +37 -0
- package/package.json +11 -6
- package/src/components/AliaChatContent.tsx +8 -0
- package/src/components/AliaChatScreen.tsx +2 -2
- package/src/components/AliaChatSheet.tsx +16 -4
- package/src/components/AliaWelcomeMessage.tsx +1 -1
- package/src/components/IdentityMark.tsx +38 -12
- package/src/hooks/useAliaChat.ts +306 -389
- package/src/hooks/useAmbientWave.ts +14 -2
- package/src/hooks/useSpeechToText.ts +4 -2
- package/src/hooks/useTTS.ts +75 -31
- package/src/lib/audio-level.ts +129 -0
- package/src/lib/catalogue.ts +1 -1
- package/src/lib/chat-stream.ts +560 -0
- package/src/lib/chat-transport.ts +55 -0
- package/src/lib/config.ts +1 -1
- package/src/lib/tool-registry.ts +19 -3
- package/src/lib/utils.ts +14 -4
- package/tsconfig.json +1 -1
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type SharedValue,
|
|
12
12
|
} from 'react-native-reanimated';
|
|
13
13
|
import { useSTTStore } from './useSpeechToText';
|
|
14
|
+
import { LEVEL_ATTACK_MS, LEVEL_RELEASE_MS } from '../lib/audio-level';
|
|
14
15
|
import type { AgentState } from '../types';
|
|
15
16
|
|
|
16
17
|
export type AmbientWaveMode = 'voice' | 'tts' | 'stt' | 'thinking' | 'idle';
|
|
@@ -27,7 +28,7 @@ export interface UseAmbientWaveOptions {
|
|
|
27
28
|
voice?: AmbientWaveVoice;
|
|
28
29
|
/** Whether TTS ("read aloud") is currently playing. */
|
|
29
30
|
isTTSPlaying: boolean;
|
|
30
|
-
/**
|
|
31
|
+
/** Live "read aloud" playback level, 0..1 (glides to 0 when not playing). */
|
|
31
32
|
ttsWaveAmplitude: SharedValue<number>;
|
|
32
33
|
/** Text-chat generation in flight (thinking or streaming) — gentle wave motion. */
|
|
33
34
|
isGenerating?: boolean;
|
|
@@ -78,6 +79,17 @@ export function useAmbientWave({ voice, isTTSPlaying, ttsWaveAmplitude, isGenera
|
|
|
78
79
|
);
|
|
79
80
|
|
|
80
81
|
// ── STT metering smoothing (floor 0.08, fast attack / slow decay, →0 on stop) ──
|
|
82
|
+
//
|
|
83
|
+
// The recorder publishes on a 100ms poll, so what makes this read as speech
|
|
84
|
+
// rather than as ten steps a second is `withTiming` interpolating between
|
|
85
|
+
// them. Read-aloud reaches the same place by a different mechanism — its
|
|
86
|
+
// player pushes buffers far too fast to route through React — off the same
|
|
87
|
+
// two constants, but NOT with the same curve: this is a fixed-duration ease
|
|
88
|
+
// that is at the target after `LEVEL_ATTACK_MS`, and `audio-level`'s is an
|
|
89
|
+
// exponential whose time constant that is, so 60ms puts it at 63% and full
|
|
90
|
+
// travel takes about three times as long. Tuning the constant moves both, by
|
|
91
|
+
// different amounts. They are shared because one number should tune one
|
|
92
|
+
// feel, not because the two are interchangeable.
|
|
81
93
|
const sttIsRecording = useSTTStore((s) => s.isRecording);
|
|
82
94
|
const sttMetering = useSTTStore((s) => s.metering);
|
|
83
95
|
const stt = useSharedValue(0);
|
|
@@ -88,7 +100,7 @@ export function useAmbientWave({ voice, isTTSPlaying, ttsWaveAmplitude, isGenera
|
|
|
88
100
|
stt.value = withTiming(0, { duration: 300 });
|
|
89
101
|
return;
|
|
90
102
|
}
|
|
91
|
-
const duration = target > stt.value ?
|
|
103
|
+
const duration = target > stt.value ? LEVEL_ATTACK_MS : LEVEL_RELEASE_MS;
|
|
92
104
|
stt.value = withTiming(target, { duration, easing: Easing.bezier(0.33, 1, 0.68, 1) });
|
|
93
105
|
},
|
|
94
106
|
[sttIsRecording, sttMetering],
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
} from 'expo-audio';
|
|
10
10
|
import { useOxy } from '@oxyhq/services';
|
|
11
11
|
import { create } from 'zustand';
|
|
12
|
+
import { levelFromDbfs } from '../lib/audio-level';
|
|
12
13
|
|
|
13
14
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
14
15
|
|
|
@@ -67,8 +68,9 @@ export function useSpeechToText(options: UseSTTOptions = {}) {
|
|
|
67
68
|
if (state === 'recording') {
|
|
68
69
|
let target: number;
|
|
69
70
|
if (recorderState.metering != null) {
|
|
70
|
-
//
|
|
71
|
-
|
|
71
|
+
// The same curve read-aloud playback uses, so one background answers
|
|
72
|
+
// both the same way — see `lib/audio-level.ts`.
|
|
73
|
+
target = levelFromDbfs(recorderState.metering);
|
|
72
74
|
} else {
|
|
73
75
|
// Metering unavailable — simulate gentle activity
|
|
74
76
|
target = 0.12 + Math.random() * 0.18;
|
package/src/hooks/useTTS.ts
CHANGED
|
@@ -1,16 +1,10 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from 'react';
|
|
2
|
-
import type { AudioPlayer, AudioStatus } from 'expo-audio';
|
|
3
|
-
import {
|
|
4
|
-
useSharedValue,
|
|
5
|
-
withRepeat,
|
|
6
|
-
withSequence,
|
|
7
|
-
withTiming,
|
|
8
|
-
cancelAnimation,
|
|
9
|
-
Easing,
|
|
10
|
-
} from 'react-native-reanimated';
|
|
2
|
+
import type { AudioPlayer, AudioSample, AudioStatus } from 'expo-audio';
|
|
3
|
+
import { makeMutable, withTiming } from 'react-native-reanimated';
|
|
11
4
|
import { useOxy } from '@oxyhq/services';
|
|
12
5
|
import { errorMessage } from '../lib/utils';
|
|
13
6
|
import { create } from 'zustand';
|
|
7
|
+
import { createAudioLevelMeter } from '../lib/audio-level';
|
|
14
8
|
import { PREFERRED_VOICE_MODEL_ID } from '../lib/config';
|
|
15
9
|
|
|
16
10
|
const API_URL = process.env.EXPO_PUBLIC_ALIA_API_URL ?? 'https://api.alia.onl';
|
|
@@ -57,6 +51,39 @@ const useTTSStore = create<TTSStore>((set) => ({
|
|
|
57
51
|
reset: () => set({ activeMessageId: null, playbackState: 'idle', error: null }),
|
|
58
52
|
}));
|
|
59
53
|
|
|
54
|
+
// ============== PLAYBACK LEVEL ==============
|
|
55
|
+
|
|
56
|
+
/** How long the level takes to reach silence once a clip stops sounding. */
|
|
57
|
+
const SILENCE_FADE_MS = 300;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The live playback level, 0..1, shared by every consumer.
|
|
61
|
+
*
|
|
62
|
+
* At module scope rather than inside the hook because the two ends of it live
|
|
63
|
+
* in different components: the message list calls `readAloud` and therefore
|
|
64
|
+
* owns the player, while the ambient field behind the conversation is rendered
|
|
65
|
+
* somewhere else entirely. Each `useTTS()` call is its own React instance, so a
|
|
66
|
+
* per-instance `useSharedValue` leaves the field reading a value that nothing
|
|
67
|
+
* writes — which is why the animation it replaces had to be driven off the
|
|
68
|
+
* shared playback STATE instead of off the audio. One player, one store, one
|
|
69
|
+
* level.
|
|
70
|
+
*/
|
|
71
|
+
const ttsWaveAmplitude = makeMutable(0);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Silence is a projection of playback state, not an animation of its own.
|
|
75
|
+
*
|
|
76
|
+
* Every platform stops delivering buffers the instant a clip pauses, ends or
|
|
77
|
+
* fails, so nothing would write the level down again and the field would freeze
|
|
78
|
+
* swollen wherever the last syllable left it. Subscribed once, beside the store
|
|
79
|
+
* that owns the transitions, so a transition added later cannot forget it.
|
|
80
|
+
*/
|
|
81
|
+
useTTSStore.subscribe((state, previous) => {
|
|
82
|
+
if (state.playbackState === 'playing') return;
|
|
83
|
+
if (previous.playbackState !== 'playing') return;
|
|
84
|
+
ttsWaveAmplitude.value = withTiming(0, { duration: SILENCE_FADE_MS });
|
|
85
|
+
});
|
|
86
|
+
|
|
60
87
|
// ============== HOOK ==============
|
|
61
88
|
|
|
62
89
|
export function useTTS(options: UseTTSOptions = {}) {
|
|
@@ -79,9 +106,6 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
79
106
|
|
|
80
107
|
const playerRef = useRef<AudioPlayer | null>(null);
|
|
81
108
|
|
|
82
|
-
// Simulated wave amplitude for visualization
|
|
83
|
-
const ttsWaveAmplitude = useSharedValue(0);
|
|
84
|
-
|
|
85
109
|
// ============== AUTH ==============
|
|
86
110
|
|
|
87
111
|
const getToken = useCallback((): string | null => {
|
|
@@ -89,24 +113,6 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
89
113
|
return oxyServices.httpService.getAccessToken();
|
|
90
114
|
}, [options.accessToken, oxyServices]);
|
|
91
115
|
|
|
92
|
-
// Animate wave when playing
|
|
93
|
-
useEffect(() => {
|
|
94
|
-
if (playbackState === 'playing') {
|
|
95
|
-
ttsWaveAmplitude.value = withRepeat(
|
|
96
|
-
withSequence(
|
|
97
|
-
withTiming(0.5, { duration: 800, easing: Easing.inOut(Easing.sin) }),
|
|
98
|
-
withTiming(0.2, { duration: 600, easing: Easing.inOut(Easing.sin) }),
|
|
99
|
-
withTiming(0.6, { duration: 700, easing: Easing.inOut(Easing.sin) }),
|
|
100
|
-
withTiming(0.15, { duration: 500, easing: Easing.inOut(Easing.sin) }),
|
|
101
|
-
),
|
|
102
|
-
-1,
|
|
103
|
-
);
|
|
104
|
-
} else {
|
|
105
|
-
cancelAnimation(ttsWaveAmplitude);
|
|
106
|
-
ttsWaveAmplitude.value = withTiming(0, { duration: 300 });
|
|
107
|
-
}
|
|
108
|
-
}, [playbackState]);
|
|
109
|
-
|
|
110
116
|
const getTTSVoice = useCallback(() => {
|
|
111
117
|
return voicePref === 'male' ? 'echo' : 'nova';
|
|
112
118
|
}, [voicePref]);
|
|
@@ -151,9 +157,47 @@ export function useTTS(options: UseTTSOptions = {}) {
|
|
|
151
157
|
(async () => {
|
|
152
158
|
try {
|
|
153
159
|
const { createAudioPlayer } = await import('expo-audio');
|
|
154
|
-
|
|
160
|
+
/**
|
|
161
|
+
* `crossOrigin` is web-only and ignored elsewhere, and it is what makes
|
|
162
|
+
* the waveform readable there: a browser will not let an `AnalyserNode`
|
|
163
|
+
* see a cross-origin media element loaded without CORS, so without this
|
|
164
|
+
* `setAudioSamplingEnabled` below silently declines and the field never
|
|
165
|
+
* moves. Clips are served by `GET /media` on the API host, which is a
|
|
166
|
+
* different origin from the app on every deployment.
|
|
167
|
+
*
|
|
168
|
+
* It costs the clip its CORS headers if the app's origin is not on the
|
|
169
|
+
* API's allowlist — but such an origin cannot load a conversation to
|
|
170
|
+
* read aloud in the first place, since `/media` and `/conversations`
|
|
171
|
+
* share that allowlist. Measured against production 2026-08-26:
|
|
172
|
+
* `Origin: https://alia.onl` is answered
|
|
173
|
+
* `access-control-allow-origin: https://alia.onl`, with `Vary: Origin`.
|
|
174
|
+
*/
|
|
175
|
+
const player = createAudioPlayer({ uri: audioUrl }, { crossOrigin: 'anonymous' });
|
|
155
176
|
playerRef.current = player;
|
|
156
177
|
|
|
178
|
+
/**
|
|
179
|
+
* The real waveform, which is what the ambient field behind the
|
|
180
|
+
* conversation moves to. Enabled BEFORE `play()`: on web this is what
|
|
181
|
+
* builds the analyser, and the sampling loop only starts if the player
|
|
182
|
+
* is already playing or starts afterwards.
|
|
183
|
+
*
|
|
184
|
+
* It can decline, and declining is not an error. Android routes
|
|
185
|
+
* playback sampling through `android.media.audiofx.Visualizer`, which
|
|
186
|
+
* needs `RECORD_AUDIO` already granted — and asking a person for the
|
|
187
|
+
* microphone so that a background can move would be a worse trade than
|
|
188
|
+
* the background not moving. Where it declines no buffer ever arrives,
|
|
189
|
+
* the level stays at zero and the field simply rests. That is the
|
|
190
|
+
* honest reading of "we cannot hear this", and it is deliberately not
|
|
191
|
+
* papered over with motion that does not come from the audio.
|
|
192
|
+
*/
|
|
193
|
+
player.setAudioSamplingEnabled(true);
|
|
194
|
+
const meter = createAudioLevelMeter();
|
|
195
|
+
player.addListener('audioSampleUpdate', (sample: AudioSample) => {
|
|
196
|
+
const frames = sample.channels[0]?.frames;
|
|
197
|
+
if (frames === undefined) return;
|
|
198
|
+
ttsWaveAmplitude.value = meter.push(frames, Date.now());
|
|
199
|
+
});
|
|
200
|
+
|
|
157
201
|
player.addListener('playbackStatusUpdate', (status: AudioStatus) => {
|
|
158
202
|
if (status.error) {
|
|
159
203
|
releasePlayer();
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How audio becomes the ambient field's 0..1 level — one curve, one pair of
|
|
3
|
+
* time constants, for every source that drives it.
|
|
4
|
+
*
|
|
5
|
+
* Dictation got here first and is the reference: the recorder reports dBFS,
|
|
6
|
+
* `useSpeechToText` normalises it against a -60 floor, and `useAmbientWave`
|
|
7
|
+
* smooths it with a fast attack and a slow decay before it reaches
|
|
8
|
+
* `AmbientField`. That is the behaviour people like, so it is the behaviour
|
|
9
|
+
* everything else adopts rather than re-invents — including "read aloud", which
|
|
10
|
+
* until now drove the same field from a canned `withRepeat` loop that had never
|
|
11
|
+
* heard the clip.
|
|
12
|
+
*
|
|
13
|
+
* The two sources differ only in what they can measure and how fast:
|
|
14
|
+
*
|
|
15
|
+
* - A **recorder** publishes dBFS on a 100ms poll, through React state, and
|
|
16
|
+
* `levelFromDbfs` is the whole conversion.
|
|
17
|
+
* - A **player** publishes raw PCM as fast as the platform samples, so it needs
|
|
18
|
+
* `levelFromFrames` to reach the same dBFS first, and it cannot go through
|
|
19
|
+
* React at all — a render per audio buffer is not affordable. That is why the
|
|
20
|
+
* smoothing here is a time integration it can run per buffer rather than the
|
|
21
|
+
* `withTiming` the 10Hz path uses. The constants are the same; the CURVES are
|
|
22
|
+
* not. `withTiming` is at its target after `LEVEL_ATTACK_MS`, and this is an
|
|
23
|
+
* exponential whose time constant that is — 63% of the way at 60ms, ~95% at
|
|
24
|
+
* 180. Sharing the number keeps one knob for one feel; it does not make the
|
|
25
|
+
* two settle alike, and anyone tuning it should expect the two paths to move
|
|
26
|
+
* by different amounts.
|
|
27
|
+
*
|
|
28
|
+
* This module deliberately imports nothing. It is the part of the metering that
|
|
29
|
+
* can be driven with a buffer and asserted on, and `react-native-reanimated`
|
|
30
|
+
* does not load outside a bundler — a level that could only be observed by
|
|
31
|
+
* playing a real clip could not have a positive control.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The quietest the field can hear, in dBFS.
|
|
36
|
+
*
|
|
37
|
+
* Silence is -160 on a real meter and negative infinity on a computed one, so a
|
|
38
|
+
* practical floor is what makes the scale usable. -60 is dictation's, and the
|
|
39
|
+
* compression it produces is most of why that field reads well: it answers
|
|
40
|
+
* quiet speech nearly as much as loud, which is what a person watching a
|
|
41
|
+
* background wants, and unlike a linear reading it does not sit near zero for
|
|
42
|
+
* everything below a shout.
|
|
43
|
+
*/
|
|
44
|
+
export const LEVEL_FLOOR_DBFS = -60;
|
|
45
|
+
|
|
46
|
+
/** Rise fast enough to catch the front of a syllable… */
|
|
47
|
+
export const LEVEL_ATTACK_MS = 60;
|
|
48
|
+
/** …and fall slowly enough that the field does not strobe between them. */
|
|
49
|
+
export const LEVEL_RELEASE_MS = 200;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* dBFS, as a recorder reports it, to the field's 0..1.
|
|
53
|
+
*
|
|
54
|
+
* `Number.NEGATIVE_INFINITY` is a legitimate input — it is what digital silence
|
|
55
|
+
* computes to — and clamps to 0 like anything below the floor.
|
|
56
|
+
*/
|
|
57
|
+
export function levelFromDbfs(dbfs: number): number {
|
|
58
|
+
const level = (dbfs - LEVEL_FLOOR_DBFS) / -LEVEL_FLOOR_DBFS;
|
|
59
|
+
return Math.min(1, Math.max(0, level));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One buffer of PCM frames, as a player reports them, to the field's 0..1.
|
|
64
|
+
*
|
|
65
|
+
* The frames are -1..1 on every platform `expo-audio` supports, so their RMS is
|
|
66
|
+
* an amplitude ratio and `20·log₁₀` is the dBFS a recorder would have reported
|
|
67
|
+
* for the same sound. Both sources end up on one scale.
|
|
68
|
+
*/
|
|
69
|
+
export function levelFromFrames(frames: readonly number[]): number {
|
|
70
|
+
if (frames.length === 0) return 0;
|
|
71
|
+
|
|
72
|
+
let sumSquares = 0;
|
|
73
|
+
for (let index = 0; index < frames.length; index += 1) {
|
|
74
|
+
sumSquares += frames[index] * frames[index];
|
|
75
|
+
}
|
|
76
|
+
const rms = Math.sqrt(sumSquares / frames.length);
|
|
77
|
+
|
|
78
|
+
// log₁₀(0) is -Infinity, which `levelFromDbfs` clamps — but only because it
|
|
79
|
+
// is asked to. Digital silence is exactly this case, so it is the one that
|
|
80
|
+
// matters most.
|
|
81
|
+
return levelFromDbfs(20 * Math.log10(rms));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export interface AudioLevelMeter {
|
|
85
|
+
/**
|
|
86
|
+
* Fold one buffer into the running level and return it.
|
|
87
|
+
*
|
|
88
|
+
* @param frames PCM frames, each -1..1, from `AudioSample.channels[n].frames`.
|
|
89
|
+
* @param nowMs A wall clock in milliseconds. The buffer rate is not fixed —
|
|
90
|
+
* web samples once per animation frame, the native taps run at the audio
|
|
91
|
+
* hardware's rate — so the smoothing is integrated against real elapsed
|
|
92
|
+
* time. The same sentence has to read the same at 30Hz and at 90Hz.
|
|
93
|
+
*/
|
|
94
|
+
push(frames: readonly number[], nowMs: number): number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A meter per clip: the level starts from silence and carries no history from
|
|
99
|
+
* whatever was playing before.
|
|
100
|
+
*/
|
|
101
|
+
export function createAudioLevelMeter(): AudioLevelMeter {
|
|
102
|
+
let level = 0;
|
|
103
|
+
let lastMs: number | null = null;
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
push(frames, nowMs) {
|
|
107
|
+
// A platform that cannot sample sends nothing rather than zeroes, but an
|
|
108
|
+
// empty buffer is the same statement: no new evidence, so no new level.
|
|
109
|
+
if (frames.length === 0) return level;
|
|
110
|
+
|
|
111
|
+
const target = levelFromFrames(frames);
|
|
112
|
+
|
|
113
|
+
if (lastMs === null) {
|
|
114
|
+
// Nothing to smooth against on the first buffer of a clip. Smoothing it
|
|
115
|
+
// from an invented zero would put a fade-in at the start of every clip
|
|
116
|
+
// that the audio does not have.
|
|
117
|
+
lastMs = nowMs;
|
|
118
|
+
level = target;
|
|
119
|
+
return level;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const elapsed = Math.max(0, nowMs - lastMs);
|
|
123
|
+
lastMs = nowMs;
|
|
124
|
+
const tau = target > level ? LEVEL_ATTACK_MS : LEVEL_RELEASE_MS;
|
|
125
|
+
level += (target - level) * (1 - Math.exp(-elapsed / tau));
|
|
126
|
+
return level;
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
package/src/lib/catalogue.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* workstream 5).
|
|
4
4
|
*
|
|
5
5
|
* Every surface used to hardcode an `alia-*` identifier as its default —
|
|
6
|
-
* `useAliaChat` sent `
|
|
6
|
+
* `useAliaChat` sent `kaana-v1`, `useTTS` and `useVoiceRoom` sent `kaana-v1-voice`
|
|
7
7
|
* — which meant a retired identifier became a 400 in a consumer's app with
|
|
8
8
|
* nothing the consumer could do about it, since the string was baked into the
|
|
9
9
|
* package they installed. This module is how the SDK asks the server what it
|