@alia.onl/sdk 7.2.8 → 8.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.
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Speech out: ask `POST /v1/audio/speech` for a clip, and play one.
3
+ *
4
+ * Shared by read-aloud (`useTTS`) and voice mode (`useVoiceRoom`) so both speak
5
+ * through the one route Alia has for it — Alia → Oxy → Kaana, billed by Oxy per
6
+ * call — with the same request body and the same error copy. Voice names are
7
+ * the PRODUCT's (`male` / `female`); the route maps them, and no upstream voice
8
+ * name ever appears on this side.
9
+ */
10
+
11
+ import { createAudioLevelMeter } from './audio-level';
12
+
13
+ export type ProductVoice = 'male' | 'female';
14
+
15
+ export interface SpeechClipRequest {
16
+ readonly apiUrl: string;
17
+ readonly token: string;
18
+ /** The speech routing profile; the route accepts only its own. */
19
+ readonly model: string;
20
+ readonly input: string;
21
+ readonly voice: ProductVoice;
22
+ readonly speed?: number;
23
+ /** Both or neither: the route stores the clip on that message when given. */
24
+ readonly conversationId?: string;
25
+ readonly messageId?: string;
26
+ readonly signal: AbortSignal;
27
+ }
28
+
29
+ /** Synthesize `input` and return the private link the route answers with. */
30
+ export async function requestSpeechClip(request: SpeechClipRequest): Promise<string> {
31
+ const response = await fetch(`${request.apiUrl}/v1/audio/speech`, {
32
+ method: 'POST',
33
+ signal: request.signal,
34
+ headers: {
35
+ 'Content-Type': 'application/json',
36
+ Authorization: `Bearer ${request.token}`,
37
+ },
38
+ body: JSON.stringify({
39
+ model: request.model,
40
+ input: request.input,
41
+ voice: request.voice,
42
+ speed: request.speed,
43
+ conversationId: request.conversationId,
44
+ messageId: request.messageId,
45
+ }),
46
+ });
47
+
48
+ if (!response.ok) {
49
+ if (response.status === 504) {
50
+ throw new Error('Request timed out — please try again');
51
+ }
52
+ const errData = await response.json().catch(() => ({}));
53
+ throw new Error(errData.error?.message || errData.error || 'TTS failed');
54
+ }
55
+
56
+ const data = (await response.json()) as { audioUrl?: unknown };
57
+ request.signal.throwIfAborted();
58
+ if (typeof data.audioUrl !== 'string' || data.audioUrl === '') throw new Error('TTS failed');
59
+ return data.audioUrl;
60
+ }
61
+
62
+ export interface PlayClipOptions {
63
+ readonly signal: AbortSignal;
64
+ /** The clip's live level, 0..1, where the platform lets playback be sampled. */
65
+ readonly onLevel?: (level: number) => void;
66
+ }
67
+
68
+ /**
69
+ * Play one clip to its end.
70
+ *
71
+ * Resolves when it finishes or when `signal` aborts (the player is released
72
+ * either way — an interrupted answer stops at once), and rejects if the player
73
+ * reports an error. `crossOrigin` and sampling are set as `useTTS` sets them;
74
+ * its comments explain why.
75
+ */
76
+ export async function playSpeechClip(uri: string, options: PlayClipOptions): Promise<void> {
77
+ const { signal, onLevel } = options;
78
+ if (signal.aborted) return;
79
+ const { createAudioPlayer } = await import('expo-audio');
80
+ if (signal.aborted) return;
81
+
82
+ const player = createAudioPlayer({ uri }, { crossOrigin: 'anonymous' });
83
+ const release = (): void => {
84
+ try {
85
+ player.remove();
86
+ } catch {
87
+ // Already removed.
88
+ }
89
+ };
90
+
91
+ try {
92
+ await new Promise<void>((resolve, reject) => {
93
+ const onAbort = (): void => resolve();
94
+ signal.addEventListener('abort', onAbort, { once: true });
95
+ const settle = (settleWith: () => void): void => {
96
+ signal.removeEventListener('abort', onAbort);
97
+ settleWith();
98
+ };
99
+
100
+ if (onLevel !== undefined) {
101
+ player.setAudioSamplingEnabled(true);
102
+ const meter = createAudioLevelMeter();
103
+ player.addListener('audioSampleUpdate', (sample) => {
104
+ const frames = sample.channels[0]?.frames;
105
+ if (frames === undefined) return;
106
+ onLevel(meter.push(frames, Date.now()));
107
+ });
108
+ }
109
+ player.addListener('playbackStatusUpdate', (status) => {
110
+ if (status.error) {
111
+ settle(() => reject(new Error(String(status.error))));
112
+ return;
113
+ }
114
+ if (status.didJustFinish) settle(resolve);
115
+ });
116
+ player.play();
117
+ });
118
+ } finally {
119
+ release();
120
+ onLevel?.(0);
121
+ }
122
+ }
@@ -0,0 +1,60 @@
1
+ import { clampLevel } from './speech-recognition-types';
2
+
3
+ /**
4
+ * The two live levels of a voice call — what the microphone hears and what the
5
+ * call is saying — for whatever draws them.
6
+ *
7
+ * It replaced the LiveKit `Room` that `useAudioLevelMonitor` used to take and
8
+ * put analysers on. The voice loop writes here from the recognizer's level
9
+ * events and the clip player's samples; the monitor subscribes and hands React
10
+ * a throttled copy. Writes are cheap and never render anything by themselves.
11
+ */
12
+ export interface VoiceLevelSource {
13
+ readonly capture: number;
14
+ readonly playback: number;
15
+ subscribe(listener: () => void): () => void;
16
+ }
17
+
18
+ export class VoiceLevelChannel implements VoiceLevelSource {
19
+ private captureLevel = 0;
20
+ private playbackLevel = 0;
21
+ private readonly listeners = new Set<() => void>();
22
+
23
+ get capture(): number {
24
+ return this.captureLevel;
25
+ }
26
+
27
+ get playback(): number {
28
+ return this.playbackLevel;
29
+ }
30
+
31
+ setCapture(level: number): void {
32
+ const next = clampLevel(level);
33
+ if (next === this.captureLevel) return;
34
+ this.captureLevel = next;
35
+ this.emit();
36
+ }
37
+
38
+ setPlayback(level: number): void {
39
+ const next = clampLevel(level);
40
+ if (next === this.playbackLevel) return;
41
+ this.playbackLevel = next;
42
+ this.emit();
43
+ }
44
+
45
+ reset(): void {
46
+ this.setCapture(0);
47
+ this.setPlayback(0);
48
+ }
49
+
50
+ subscribe(listener: () => void): () => void {
51
+ this.listeners.add(listener);
52
+ return () => {
53
+ this.listeners.delete(listener);
54
+ };
55
+ }
56
+
57
+ private emit(): void {
58
+ for (const listener of this.listeners) listener();
59
+ }
60
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Text in and out of a voice call, as pure functions: what of an answer is
3
+ * ready to be spoken, how it is made speakable, and whether what the
4
+ * microphone just heard was the person or the call's own voice.
5
+ *
6
+ * Imports nothing, so every rule here is asserted directly
7
+ * (`tests/lib/voice-text.test.ts`) rather than through a hook.
8
+ */
9
+
10
+ // ============== TITLE TAGS ==============
11
+
12
+ const TAG = String.raw`ALIA_TITLE|TITLE|TÍTULO|TITRE|TITOLO|TITEL|ЗАГОЛОВОК`;
13
+
14
+ const TITLE_STRIP_RE = new RegExp(
15
+ String.raw`\[(${TAG})\].*?\[\/\1\]|<(${TAG})>.*?<\/\2>`, 'gi',
16
+ );
17
+
18
+ const TITLE_PARTIAL_RE = new RegExp(
19
+ String.raw`\[(${TAG})\].*?(\[\/\1\])?$|<(${TAG})>.*?(<\/\3>)?$`, 'si',
20
+ );
21
+
22
+ /**
23
+ * A settled answer without the conversation-title tag a model may append.
24
+ *
25
+ * `trim: false` keeps surrounding whitespace, which the speech chunker needs:
26
+ * a trailing space is how it knows a period ended a sentence.
27
+ */
28
+ export function stripTitleTags(content: string, { trim = true }: { trim?: boolean } = {}): string {
29
+ const stripped = content.replace(TITLE_STRIP_RE, '');
30
+ return trim ? stripped.trim() : stripped;
31
+ }
32
+
33
+ /** The same, for an answer still streaming: an opened tag hides the rest. */
34
+ export function stripTitleTagsPartial(content: string, { trim = true }: { trim?: boolean } = {}): string {
35
+ const stripped = content.replace(TITLE_STRIP_RE, '').replace(TITLE_PARTIAL_RE, '');
36
+ return trim ? stripped.trim() : stripped;
37
+ }
38
+
39
+ // ============== SPEAKABLE TEXT ==============
40
+
41
+ /**
42
+ * Markdown and links read aloud are noise ("asterisk asterisk", a URL spelled
43
+ * out). The voice prompt asks for none, but a model does not always listen, and
44
+ * an agent's own prompt may not ask at all — so the formatting is taken off
45
+ * here, at the last moment, and the transcript on screen keeps it.
46
+ */
47
+ export function toSpeakableText(text: string): string {
48
+ return text
49
+ .replace(/```[\s\S]*?(```|$)/g, ' ')
50
+ .replace(/`([^`]*)`/g, '$1')
51
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
52
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
53
+ .replace(/https?:\/\/\S+/g, ' ')
54
+ .replace(/^\s{0,3}#{1,6}\s+/gm, '')
55
+ .replace(/^\s*[-*+•]\s+/gm, '')
56
+ .replace(/^\s*>\s?/gm, '')
57
+ .replace(/\|/g, ' ')
58
+ .replace(/(\*\*|__|\*|_|~~)(?=\S)([\s\S]*?\S)\1/g, '$2')
59
+ .replace(/\s+/g, ' ')
60
+ .trim();
61
+ }
62
+
63
+ /**
64
+ * A sentence ends at terminal punctuation followed by whitespace, or at a line
65
+ * break. Requiring the whitespace is what keeps `3.5` and a half-streamed
66
+ * `e.g` whole: until the next character arrives, the period might not be one.
67
+ */
68
+ const SENTENCE_END = /[.!?…。!?]+["'”’)\]]*\s+|\n+/g;
69
+
70
+ /** Past this, a run with no sentence end is cut at a pause anyway. */
71
+ const MAX_RUN_CHARS = 280;
72
+ /** After the first, chunks gather sentences until this long: fewer, fuller requests. */
73
+ const MIN_CHUNK_CHARS = 80;
74
+
75
+ export interface SpeechChunks {
76
+ /** Ready to synthesize, in order, already speakable. */
77
+ readonly chunks: string[];
78
+ /** Not yet ready: the caller keeps it and passes it again with more text. */
79
+ readonly rest: string;
80
+ }
81
+
82
+ /**
83
+ * Split what has streamed so far into what can be spoken now and what must
84
+ * wait.
85
+ *
86
+ * The FIRST chunk of an answer is a single sentence, however short: time to
87
+ * first sound is the latency a person feels. Later chunks gather sentences up
88
+ * to `MIN_CHUNK_CHARS`, because each chunk is a synthesis request and playback
89
+ * of the one before covers the wait for the next. `final` flushes everything.
90
+ */
91
+ export function takeSpeechChunks(
92
+ pending: string,
93
+ { final, isFirst }: { final: boolean; isFirst: boolean },
94
+ ): SpeechChunks {
95
+ const sentences: string[] = [];
96
+ let cursor = 0;
97
+ for (const match of pending.matchAll(SENTENCE_END)) {
98
+ const end = (match.index ?? 0) + match[0].length;
99
+ sentences.push(pending.slice(cursor, end));
100
+ cursor = end;
101
+ }
102
+ let rest = pending.slice(cursor);
103
+
104
+ if (final) {
105
+ if (rest.trim() !== '') sentences.push(rest);
106
+ rest = '';
107
+ } else if (rest.length > MAX_RUN_CHARS) {
108
+ const window = rest.slice(0, MAX_RUN_CHARS);
109
+ const pause = Math.max(window.lastIndexOf(', '), window.lastIndexOf('; '), window.lastIndexOf(': '));
110
+ const cut = pause > 0 ? pause + 2 : Math.max(window.lastIndexOf(' '), 1);
111
+ sentences.push(rest.slice(0, cut));
112
+ rest = rest.slice(cut);
113
+ }
114
+
115
+ const chunks: string[] = [];
116
+ let gathering = '';
117
+ for (const sentence of sentences) {
118
+ gathering += sentence;
119
+ const threshold = chunks.length === 0 && isFirst ? 1 : MIN_CHUNK_CHARS;
120
+ if (gathering.trim().length >= threshold) {
121
+ chunks.push(gathering);
122
+ gathering = '';
123
+ }
124
+ }
125
+ if (gathering !== '') {
126
+ if (final) chunks.push(gathering);
127
+ else rest = gathering + rest;
128
+ }
129
+
130
+ return {
131
+ chunks: chunks.map(toSpeakableText).filter((chunk) => chunk !== ''),
132
+ rest,
133
+ };
134
+ }
135
+
136
+ // ============== BARGE-IN ==============
137
+
138
+ function words(text: string): string[] {
139
+ return text
140
+ .toLowerCase()
141
+ .replace(/[^\p{L}\p{N}\s']/gu, ' ')
142
+ .split(/\s+/)
143
+ .filter((word) => word !== '');
144
+ }
145
+
146
+ export function wordCount(text: string): number {
147
+ return words(text).length;
148
+ }
149
+
150
+ /** How many of the most recent words decide whether the person is talking. */
151
+ const INTERRUPTION_WINDOW = 6;
152
+
153
+ /**
154
+ * Whether what the microphone heard is the person talking over the answer,
155
+ * rather than the answer coming back through it.
156
+ *
157
+ * Voice mode keeps listening while it speaks so a person can interrupt, and on
158
+ * a laptop speaker the recognizer can hear the answer. Where the platform
159
+ * cancels echo that never reaches here; where it does not, the words give it
160
+ * away. `spoken` is everything the call has said this turn, and the transcript
161
+ * of one session keeps the echo it already heard, so only the latest words
162
+ * are judged, from the first one the call did not say: at least `minWords` of
163
+ * them — and at least half — must be new. A single misheard word of echo is
164
+ * not the person; two new words in a row are.
165
+ */
166
+ export function isInterruption(heard: string, spoken: string, minWords: number): boolean {
167
+ const said = new Set(words(spoken));
168
+ const tail = words(heard).slice(-INTERRUPTION_WINDOW);
169
+ // Judge from the first word the call did not say: echo before it is echo.
170
+ const first = tail.findIndex((word) => !said.has(word));
171
+ if (first < 0) return false;
172
+ const candidate = tail.slice(first);
173
+ const novel = candidate.filter((word) => !said.has(word)).length;
174
+ return novel >= minWords && novel * 2 >= candidate.length;
175
+ }
176
+
177
+ /**
178
+ * The person's words without the echo the same session heard before them:
179
+ * leading words the call itself had just said are dropped.
180
+ */
181
+ export function dropEchoPrefix(heard: string, spoken: string): string {
182
+ const said = new Set(words(spoken));
183
+ if (said.size === 0) return heard.trim();
184
+ const tokens = heard.trim().split(/\s+/);
185
+ let start = 0;
186
+ while (start < tokens.length) {
187
+ const normalized = words(tokens[start] ?? '');
188
+ if (normalized.length === 0 || normalized.every((word) => said.has(word))) start += 1;
189
+ else break;
190
+ }
191
+ return tokens.slice(start).join(' ');
192
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * How a voice call gets its answers: through the ordinary chat path.
3
+ *
4
+ * A spoken turn is a chat turn. There is no voice session endpoint and no
5
+ * realtime model behind this — the person's words, recognized on the device,
6
+ * go to Alia's chat handler exactly as typed words do (conversation, memory,
7
+ * agents, tools and all), and the answer comes back as the same SSE stream.
8
+ * The only thing that marks it as spoken is `responseMode: 'voice'`, which asks
9
+ * for an answer meant to be heard.
10
+ *
11
+ * A consumer that already owns a chat — the Alia app, whose conversation screen
12
+ * persists every turn and shows it in the thread — passes its own sender, so
13
+ * the call's turns ARE that conversation's turns. `createAliaVoiceTurnSender`
14
+ * is the default for everyone else: the same request `useAliaChat` makes.
15
+ */
16
+
17
+ import { streamAliaChat, type AuthenticatedResponseClient } from './chat-transport';
18
+ import { resolveModelId } from './catalogue';
19
+ import { PREFERRED_CHAT_MODEL_ID } from './config';
20
+
21
+ export interface VoiceTurnMessage {
22
+ readonly role: 'user' | 'assistant';
23
+ readonly content: string;
24
+ }
25
+
26
+ export interface VoiceTurn {
27
+ /** What the person said, as recognized. */
28
+ readonly text: string;
29
+ /** Earlier turns of this call, oldest first, not including `text`. */
30
+ readonly history: readonly VoiceTurnMessage[];
31
+ /** Aborted when the person interrupts or the call ends. */
32
+ readonly signal: AbortSignal;
33
+ /** Report the answer as it streams — the WHOLE answer so far, each time. */
34
+ readonly onText: (answerSoFar: string) => void;
35
+ }
36
+
37
+ /**
38
+ * Send one spoken turn and stream its answer through `onText`.
39
+ *
40
+ * Resolves when the answer is complete; rejects when the turn could not be
41
+ * sent at all. An answer that is empty is not a rejection — the voice loop
42
+ * reports it and keeps listening.
43
+ */
44
+ export type VoiceTurnSender = (turn: VoiceTurn) => Promise<void>;
45
+
46
+ interface LinkedClientFactory {
47
+ createLinkedClient(config: { baseURL: string }): { client: AuthenticatedResponseClient; dispose(): void };
48
+ }
49
+
50
+ export interface AliaVoiceTurnSenderOptions {
51
+ readonly oxyServices: LinkedClientFactory;
52
+ readonly apiUrl: string;
53
+ /** Chat routing profile, checked against `GET /catalogue` like `useAliaChat`'s. */
54
+ readonly model?: string;
55
+ readonly agentId?: string;
56
+ }
57
+
58
+ /** The default sender: `POST /v1/chat/completions` as `useAliaChat` sends it, marked as voice. */
59
+ export function createAliaVoiceTurnSender(options: AliaVoiceTurnSenderOptions): VoiceTurnSender {
60
+ const { oxyServices, apiUrl, model = PREFERRED_CHAT_MODEL_ID, agentId } = options;
61
+ return async ({ text, history, signal, onText }) => {
62
+ const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
63
+ try {
64
+ const effectiveModel = await resolveModelId(apiUrl, model, undefined, PREFERRED_CHAT_MODEL_ID);
65
+ signal.throwIfAborted();
66
+ let answer = '';
67
+ await streamAliaChat(
68
+ linked.client,
69
+ {
70
+ url: '/v1/chat/completions',
71
+ model: effectiveModel,
72
+ messages: [...history, { role: 'user', content: text }],
73
+ responseMode: 'voice',
74
+ ...(agentId === undefined ? {} : { agentId }),
75
+ },
76
+ signal,
77
+ (event) => {
78
+ if (event.kind !== 'content' && event.kind !== 'agent_answer') return;
79
+ answer += event.content;
80
+ onText(answer);
81
+ },
82
+ );
83
+ } finally {
84
+ linked.dispose();
85
+ }
86
+ };
87
+ }
package/src/types.ts CHANGED
@@ -56,8 +56,8 @@ export interface ChatMessage {
56
56
  pendingPlan?: PendingPlan;
57
57
  /** Message origin: text chat or voice */
58
58
  source?: 'text' | 'voice';
59
- /** Speaker identity in cohost voice mode */
60
- speaker?: 'primary' | 'cohost';
59
+ /** Speaker identity in voice mode */
60
+ speaker?: 'primary';
61
61
  /** Whether a voice transcript is still streaming */
62
62
  isStreaming?: boolean;
63
63
  /** Cached TTS audio URL for read-aloud */
@@ -90,7 +90,7 @@ export interface VoiceToolInvocation {
90
90
  export interface VoiceMessage {
91
91
  id: string;
92
92
  role: 'user' | 'assistant';
93
- speaker?: 'primary' | 'cohost';
93
+ speaker?: 'primary';
94
94
  content: string;
95
95
  timestamp: number;
96
96
  isStreaming: boolean;
package/src/voice.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // @alia.onl/sdk/voice — Alia voice surface
2
- // LiveKit-backed voice room, audio visualisation, ambient wave, and sound effects.
3
- // Split out of the root entry so text-chat consumers never pull livekit-client
4
- // into their module graph.
2
+ // The on-device voice loop (listen → chat → speak), audio visualisation,
3
+ // ambient wave, and sound effects. Split out of the root entry so text-chat
4
+ // consumers never compile the call.
5
5
 
6
6
  // ── Voice components ──
7
7
  export { VoiceSession } from './components/voice/VoiceSession';
@@ -11,6 +11,10 @@ export { VoiceControls } from './components/voice/VoiceControls';
11
11
 
12
12
  // ── Voice hooks ──
13
13
  export { useVoiceRoom } from './hooks/useVoiceRoom';
14
+ export type { UseVoiceRoomOptions } from './hooks/useVoiceRoom';
15
+ export { createAliaVoiceTurnSender } from './lib/voice-turn';
16
+ export type { VoiceTurn, VoiceTurnMessage, VoiceTurnSender, AliaVoiceTurnSenderOptions } from './lib/voice-turn';
17
+ export type { VoiceLevelSource } from './lib/voice-levels';
14
18
  export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
15
19
  export { useAudioLevels } from './hooks/useAudioLevels';
16
20
  export { useAmbientWave } from './hooks/useAmbientWave';