@alia.onl/sdk 7.2.9 → 10.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/CHANGELOG.md +142 -6
- package/README.md +242 -0
- package/package.json +8 -12
- package/src/components/AliaChatContent.tsx +0 -1
- package/src/components/AliaChatMessageList.tsx +0 -4
- package/src/components/IdentityMark.tsx +3 -0
- package/src/components/Markdown.tsx +89 -8
- package/src/components/ResearchProgressCard.tsx +3 -0
- package/src/components/ui/prompt-input/mic-button.tsx +2 -3
- package/src/components/ui/prompt-input/prompt-input.tsx +1 -4
- package/src/components/voice/VoiceControls.tsx +48 -63
- package/src/components/voice/VoiceSession.tsx +6 -19
- package/src/hooks/useAliaChat.ts +24 -10
- package/src/hooks/useAudioLevelMonitor.ts +26 -122
- package/src/hooks/useSpeechToText.ts +196 -147
- package/src/hooks/useTTS.ts +33 -38
- package/src/hooks/useVoiceRoom.ts +581 -416
- package/src/index.ts +6 -19
- package/src/lib/audio-level.ts +3 -2
- package/src/lib/catalogue.ts +116 -103
- package/src/lib/chat-stream.ts +0 -5
- package/src/lib/chat-transport.ts +14 -2
- package/src/lib/speech-language.ts +20 -0
- package/src/lib/speech-messages.ts +76 -0
- package/src/lib/speech-recognition-types.ts +108 -0
- package/src/lib/speech-recognition.native.ts +226 -0
- package/src/lib/speech-recognition.ts +288 -0
- package/src/lib/speech-synthesis.ts +122 -0
- package/src/lib/voice-levels.ts +60 -0
- package/src/lib/voice-text.ts +192 -0
- package/src/lib/voice-turn.ts +86 -0
- package/src/types.ts +3 -3
- package/src/voice.ts +11 -3
- package/src/lib/config.ts +0 -36
|
@@ -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,86 @@
|
|
|
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
|
+
|
|
20
|
+
export interface VoiceTurnMessage {
|
|
21
|
+
readonly role: 'user' | 'assistant';
|
|
22
|
+
readonly content: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface VoiceTurn {
|
|
26
|
+
/** What the person said, as recognized. */
|
|
27
|
+
readonly text: string;
|
|
28
|
+
/** Earlier turns of this call, oldest first, not including `text`. */
|
|
29
|
+
readonly history: readonly VoiceTurnMessage[];
|
|
30
|
+
/** Aborted when the person interrupts or the call ends. */
|
|
31
|
+
readonly signal: AbortSignal;
|
|
32
|
+
/** Report the answer as it streams — the WHOLE answer so far, each time. */
|
|
33
|
+
readonly onText: (answerSoFar: string) => void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Send one spoken turn and stream its answer through `onText`.
|
|
38
|
+
*
|
|
39
|
+
* Resolves when the answer is complete; rejects when the turn could not be
|
|
40
|
+
* sent at all. An answer that is empty is not a rejection — the voice loop
|
|
41
|
+
* reports it and keeps listening.
|
|
42
|
+
*/
|
|
43
|
+
export type VoiceTurnSender = (turn: VoiceTurn) => Promise<void>;
|
|
44
|
+
|
|
45
|
+
interface LinkedClientFactory {
|
|
46
|
+
createLinkedClient(config: { baseURL: string }): { client: AuthenticatedResponseClient; dispose(): void };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AliaVoiceTurnSenderOptions {
|
|
50
|
+
readonly oxyServices: LinkedClientFactory;
|
|
51
|
+
readonly apiUrl: string;
|
|
52
|
+
/** `publisher/model`, checked against `GET /catalogue` like `useAliaChat`'s; omitted, the server's default. */
|
|
53
|
+
readonly model?: string;
|
|
54
|
+
readonly agentId?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The default sender: `POST /v1/chat/completions` as `useAliaChat` sends it, marked as voice. */
|
|
58
|
+
export function createAliaVoiceTurnSender(options: AliaVoiceTurnSenderOptions): VoiceTurnSender {
|
|
59
|
+
const { oxyServices, apiUrl, model, agentId } = options;
|
|
60
|
+
return async ({ text, history, signal, onText }) => {
|
|
61
|
+
const linked = oxyServices.createLinkedClient({ baseURL: apiUrl });
|
|
62
|
+
try {
|
|
63
|
+
const effectiveModel = await resolveModelId(apiUrl, model);
|
|
64
|
+
signal.throwIfAborted();
|
|
65
|
+
let answer = '';
|
|
66
|
+
await streamAliaChat(
|
|
67
|
+
linked.client,
|
|
68
|
+
{
|
|
69
|
+
url: '/v1/chat/completions',
|
|
70
|
+
model: effectiveModel,
|
|
71
|
+
messages: [...history, { role: 'user', content: text }],
|
|
72
|
+
responseMode: 'voice',
|
|
73
|
+
...(agentId === undefined ? {} : { agentId }),
|
|
74
|
+
},
|
|
75
|
+
signal,
|
|
76
|
+
(event) => {
|
|
77
|
+
if (event.kind !== 'content' && event.kind !== 'agent_answer') return;
|
|
78
|
+
answer += event.content;
|
|
79
|
+
onText(answer);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
} finally {
|
|
83
|
+
linked.dispose();
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
}
|
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
|
|
60
|
-
speaker?: 'primary'
|
|
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'
|
|
93
|
+
speaker?: 'primary';
|
|
94
94
|
content: string;
|
|
95
95
|
timestamp: number;
|
|
96
96
|
isStreaming: boolean;
|
package/src/voice.ts
CHANGED
|
@@ -1,16 +1,24 @@
|
|
|
1
1
|
// @alia.onl/sdk/voice — Alia voice surface
|
|
2
|
-
//
|
|
3
|
-
// Split out of the root entry so text-chat
|
|
4
|
-
//
|
|
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';
|
|
8
8
|
export { AudioWaveVisualizer } from './components/voice/AudioWaveVisualizer';
|
|
9
9
|
export { VoiceOverlay } from './components/voice/VoiceOverlay';
|
|
10
10
|
export { VoiceControls } from './components/voice/VoiceControls';
|
|
11
|
+
export { VOICE_CONTROLS_LABELS } from './components/voice/VoiceControls';
|
|
12
|
+
export type { VoiceControlsLabels, VoiceControlsProps } from './components/voice/VoiceControls';
|
|
11
13
|
|
|
12
14
|
// ── Voice hooks ──
|
|
13
15
|
export { useVoiceRoom } from './hooks/useVoiceRoom';
|
|
16
|
+
export type { UseVoiceRoomOptions } from './hooks/useVoiceRoom';
|
|
17
|
+
export { createAliaVoiceTurnSender } from './lib/voice-turn';
|
|
18
|
+
export type { VoiceTurn, VoiceTurnMessage, VoiceTurnSender, AliaVoiceTurnSenderOptions } from './lib/voice-turn';
|
|
19
|
+
export type { VoiceLevelSource } from './lib/voice-levels';
|
|
20
|
+
export { VOICE_ERROR_MESSAGES } from './lib/speech-messages';
|
|
21
|
+
export type { VoiceErrorCode } from './lib/speech-messages';
|
|
14
22
|
export { useAudioLevelMonitor } from './hooks/useAudioLevelMonitor';
|
|
15
23
|
export { useAudioLevels } from './hooks/useAudioLevels';
|
|
16
24
|
export { useAmbientWave } from './hooks/useAmbientWave';
|
package/src/lib/config.ts
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Build-time preferences for the SDK.
|
|
3
|
-
*
|
|
4
|
-
* These mirror `packages/app/lib/config.ts`, and carry the same warning it does:
|
|
5
|
-
* **a value here is never trusted.** The catalogue carries no default of its own
|
|
6
|
-
* — it orders entries by price and says explicitly that position is not a
|
|
7
|
-
* recommendation — so a build-time value is the only mechanism available for
|
|
8
|
-
* "what should this ask for first". `resolveSelection` then checks it against
|
|
9
|
-
* what the server actually offers and falls through when the catalogue does not
|
|
10
|
-
* list it.
|
|
11
|
-
*
|
|
12
|
-
* Overridable per build so a consumer is not stranded on a retired identifier
|
|
13
|
-
* until the SDK ships a new version, which is the failure the hardcoded
|
|
14
|
-
* `'route:auto'` inside `useAliaChat` used to guarantee.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
/** What a chat request asks for when the caller names no model. Checked against the catalogue. */
|
|
18
|
-
export const PREFERRED_CHAT_MODEL_ID = 'mode:auto';
|
|
19
|
-
|
|
20
|
-
/**
|
|
21
|
-
* What speech synthesis and the voice session ask for.
|
|
22
|
-
*
|
|
23
|
-
* **Not resolved against the chat catalogue, and that is deliberate.**
|
|
24
|
-
* `GET /catalogue` describes what a chat PICKER may offer, and
|
|
25
|
-
* `resolveSelection` filters to `chat_visible` entries; a voice identifier is
|
|
26
|
-
* not one of those. Passing this through the chat resolver would replace it with
|
|
27
|
-
* a chat model the moment the catalogue did not list it, and `/v1/audio/speech`
|
|
28
|
-
* would then be asked to synthesize with something that cannot speak — a
|
|
29
|
-
* plausible-looking substitution that fails at the far end, which is worse than
|
|
30
|
-
* the 400 it replaces.
|
|
31
|
-
*
|
|
32
|
-
* So this stays a preference the consumer can override and the server validates.
|
|
33
|
-
* Resolving it properly needs a catalogue that describes voice capability as a
|
|
34
|
-
* first-class filter; that is workstream 5's business, not a thing to fake here.
|
|
35
|
-
*/
|
|
36
|
-
export const PREFERRED_VOICE_MODEL_ID = 'route:voice';
|