@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.
@@ -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
- /** Simulated TTS amplitude SharedValue (glides to 0 when not playing). */
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 ? 60 : 200;
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
- // dBFS: -160 (silence) to 0 (max). Normalize using -60 as practical floor.
71
- target = Math.min(1, Math.max(0, (recorderState.metering + 60) / 60));
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;
@@ -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
- const player = createAudioPlayer({ uri: audioUrl });
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
+ }
@@ -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 `alia-v1`, `useTTS` and `useVoiceRoom` sent `alia-v1-voice`
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