@lokutor/sdk 1.1.43 → 1.2.2
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/dist/index.d.mts +150 -7
- package/dist/index.d.ts +150 -7
- package/dist/index.js +340 -36
- package/dist/index.mjs +335 -35
- package/package.json +1 -1
- package/src/browser-audio.ts +32 -5
- package/src/client.ts +219 -0
- package/src/conversational-panel.ts +59 -34
- package/src/index.ts +15 -1
- package/src/node-audio.ts +15 -3
- package/src/types.ts +59 -0
package/src/client.ts
CHANGED
|
@@ -16,6 +16,9 @@ import {
|
|
|
16
16
|
ServerConfig,
|
|
17
17
|
ServerStatus,
|
|
18
18
|
HealthStatus,
|
|
19
|
+
TranscribeOptions,
|
|
20
|
+
TranscribeResult,
|
|
21
|
+
SpeechToTextOptions,
|
|
19
22
|
} from './types';
|
|
20
23
|
import { BrowserAudioManager } from './browser-audio';
|
|
21
24
|
|
|
@@ -97,6 +100,16 @@ function base64ToUint8Array(base64: string): Uint8Array {
|
|
|
97
100
|
return bytes;
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
// Browser-compatible Uint8Array to base64
|
|
104
|
+
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
|
105
|
+
let binaryString = '';
|
|
106
|
+
const chunkSize = 0x8000; // avoid call-stack limits on String.fromCharCode(...large array)
|
|
107
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
108
|
+
binaryString += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
109
|
+
}
|
|
110
|
+
return btoa(binaryString);
|
|
111
|
+
}
|
|
112
|
+
|
|
100
113
|
function normalizeVisemes(payload: any): Viseme[] {
|
|
101
114
|
if (!Array.isArray(payload)) return [];
|
|
102
115
|
const normalized: Viseme[] = [];
|
|
@@ -144,6 +157,7 @@ function extractVisemePayload(msg: any): Viseme[] {
|
|
|
144
157
|
export class VoiceAgentClient {
|
|
145
158
|
private ws: WebSocket | null = null;
|
|
146
159
|
private apiKey: string;
|
|
160
|
+
private agentId: string = "";
|
|
147
161
|
public prompt: string;
|
|
148
162
|
public voice: VoiceStyle;
|
|
149
163
|
public language: Language;
|
|
@@ -192,6 +206,7 @@ export class VoiceAgentClient {
|
|
|
192
206
|
serverUrl?: string,
|
|
193
207
|
}) {
|
|
194
208
|
this.apiKey = config.apiKey;
|
|
209
|
+
this.agentId = config.agentId || "";
|
|
195
210
|
this.prompt = config.prompt;
|
|
196
211
|
this.voice = config.voice || VoiceStyle.F1;
|
|
197
212
|
this.language = config.language || Language.ENGLISH;
|
|
@@ -242,6 +257,10 @@ export class VoiceAgentClient {
|
|
|
242
257
|
const separator = url.includes('?') ? '&' : '?';
|
|
243
258
|
url += `${separator}api_key=${this.apiKey}`;
|
|
244
259
|
}
|
|
260
|
+
if (this.agentId) {
|
|
261
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
262
|
+
url += `${separator}agent_id=${this.agentId}`;
|
|
263
|
+
}
|
|
245
264
|
|
|
246
265
|
const redactedUrl = url.replace(/api_key=[^&]+/, 'api_key=***');
|
|
247
266
|
sdkTrace('ws.connect', {
|
|
@@ -955,6 +974,198 @@ export class TTSClient {
|
|
|
955
974
|
}
|
|
956
975
|
}
|
|
957
976
|
|
|
977
|
+
/**
|
|
978
|
+
* Standalone speech-to-text — batch transcription of a single, complete
|
|
979
|
+
* audio clip (POST /stt/transcribe). No LLM, no TTS, no voice-agent session.
|
|
980
|
+
* For continuous/live transcription (e.g. dictation, live captions), use
|
|
981
|
+
* SpeechToTextClient instead.
|
|
982
|
+
*/
|
|
983
|
+
export class STTClient {
|
|
984
|
+
private apiKey: string;
|
|
985
|
+
private baseUrl: string;
|
|
986
|
+
|
|
987
|
+
constructor(config: { apiKey: string; serverUrl?: string }) {
|
|
988
|
+
this.apiKey = config.apiKey;
|
|
989
|
+
this.baseUrl = wsToHttp(config.serverUrl || DEFAULT_URLS.STT).replace(/\/ws\/stt\/?$/, '');
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
/**
|
|
993
|
+
* Transcribe a complete audio clip. Pass a Blob/File (e.g. from a file
|
|
994
|
+
* input or MediaRecorder) for WAV/compressed audio, or a raw PCM16
|
|
995
|
+
* buffer with `format: "pcm16"` and `sampleRate` set.
|
|
996
|
+
*/
|
|
997
|
+
public async transcribe(options: TranscribeOptions): Promise<TranscribeResult> {
|
|
998
|
+
const url = `${this.baseUrl}/stt/transcribe`;
|
|
999
|
+
let res: Response;
|
|
1000
|
+
|
|
1001
|
+
if (typeof Blob !== 'undefined' && options.audio instanceof Blob) {
|
|
1002
|
+
const form = new FormData();
|
|
1003
|
+
form.append('audio', options.audio, 'audio.wav');
|
|
1004
|
+
if (options.language) form.append('lang', options.language);
|
|
1005
|
+
if (options.sampleRate) form.append('sample_rate', String(options.sampleRate));
|
|
1006
|
+
res = await fetch(url, {
|
|
1007
|
+
method: 'POST',
|
|
1008
|
+
headers: { 'X-API-Key': this.apiKey },
|
|
1009
|
+
body: form,
|
|
1010
|
+
});
|
|
1011
|
+
} else {
|
|
1012
|
+
const bytes = options.audio instanceof Uint8Array
|
|
1013
|
+
? options.audio
|
|
1014
|
+
: new Uint8Array(options.audio as ArrayBuffer);
|
|
1015
|
+
res = await fetch(url, {
|
|
1016
|
+
method: 'POST',
|
|
1017
|
+
headers: { 'X-API-Key': this.apiKey, 'Content-Type': 'application/json' },
|
|
1018
|
+
body: JSON.stringify({
|
|
1019
|
+
audio: uint8ArrayToBase64(bytes),
|
|
1020
|
+
format: options.format || 'pcm16',
|
|
1021
|
+
sample_rate: options.sampleRate,
|
|
1022
|
+
lang: options.language,
|
|
1023
|
+
}),
|
|
1024
|
+
});
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (!res.ok) {
|
|
1028
|
+
const detail = await res.text().catch(() => '');
|
|
1029
|
+
throw new LokutorError('internal.error', `HTTP ${res.status} from ${url}`, {
|
|
1030
|
+
detail,
|
|
1031
|
+
retryable: res.status >= 500,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
const data = await res.json();
|
|
1036
|
+
return {
|
|
1037
|
+
text: data.text ?? '',
|
|
1038
|
+
latencyMs: data.latency_ms ?? 0,
|
|
1039
|
+
engine: data.engine ?? '',
|
|
1040
|
+
sampleRate: data.sample_rate ?? 0,
|
|
1041
|
+
language: data.language ?? '',
|
|
1042
|
+
durationSeconds: data.duration_seconds ?? 0,
|
|
1043
|
+
segments: data.segments,
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1048
|
+
/**
|
|
1049
|
+
* Continuous speech-to-text (WS /ws/stt) — streams microphone audio to the
|
|
1050
|
+
* server and receives partial transcripts while the user is speaking and a
|
|
1051
|
+
* final transcript when the server's VAD detects the utterance ended.
|
|
1052
|
+
* Standalone: no LLM turn, no TTS, no voice-agent session — just
|
|
1053
|
+
* transcription. For a single pre-recorded clip, use STTClient instead.
|
|
1054
|
+
*/
|
|
1055
|
+
export class SpeechToTextClient {
|
|
1056
|
+
private apiKey: string;
|
|
1057
|
+
private serverUrl: string;
|
|
1058
|
+
private language?: Language;
|
|
1059
|
+
private vad: 'silero' | 'rms';
|
|
1060
|
+
private onPartialTranscript?: (text: string) => void;
|
|
1061
|
+
private onFinalTranscript?: (text: string) => void;
|
|
1062
|
+
private onError?: (error: LokutorError) => void;
|
|
1063
|
+
private onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected') => void;
|
|
1064
|
+
|
|
1065
|
+
private ws: WebSocket | null = null;
|
|
1066
|
+
private audioManager: AudioManager | null = null;
|
|
1067
|
+
private isConnected = false;
|
|
1068
|
+
|
|
1069
|
+
constructor(config: SpeechToTextOptions) {
|
|
1070
|
+
this.apiKey = config.apiKey;
|
|
1071
|
+
this.serverUrl = config.serverUrl || DEFAULT_URLS.STT;
|
|
1072
|
+
this.language = config.language;
|
|
1073
|
+
this.vad = config.vad || 'silero';
|
|
1074
|
+
this.onPartialTranscript = config.onPartialTranscript;
|
|
1075
|
+
this.onFinalTranscript = config.onFinalTranscript;
|
|
1076
|
+
this.onError = config.onError;
|
|
1077
|
+
this.onStatusChange = config.onStatusChange;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
/**
|
|
1081
|
+
* Connect and start streaming microphone audio.
|
|
1082
|
+
* @param customAudioManager Optional replacement for the default audio hardware handler (e.g. NodeAudioManager for CLI use)
|
|
1083
|
+
*/
|
|
1084
|
+
public async connect(customAudioManager?: AudioManager): Promise<boolean> {
|
|
1085
|
+
this.audioManager = customAudioManager
|
|
1086
|
+
|| (typeof window !== 'undefined' ? new BrowserAudioManager() : null);
|
|
1087
|
+
if (!this.audioManager) {
|
|
1088
|
+
throw new LokutorError('internal.error', 'No audio manager available — pass one explicitly outside the browser (e.g. NodeAudioManager).');
|
|
1089
|
+
}
|
|
1090
|
+
await this.audioManager.init();
|
|
1091
|
+
|
|
1092
|
+
this.onStatusChange?.('connecting');
|
|
1093
|
+
|
|
1094
|
+
return new Promise((resolve, reject) => {
|
|
1095
|
+
let settled = false;
|
|
1096
|
+
const settle = (fn: () => void) => { if (!settled) { settled = true; fn(); } };
|
|
1097
|
+
|
|
1098
|
+
try {
|
|
1099
|
+
let url = this.serverUrl;
|
|
1100
|
+
const separator = url.includes('?') ? '&' : '?';
|
|
1101
|
+
url += `${separator}api_key=${this.apiKey}`;
|
|
1102
|
+
|
|
1103
|
+
this.ws = new WebSocket(url);
|
|
1104
|
+
|
|
1105
|
+
this.ws.onopen = async () => {
|
|
1106
|
+
this.isConnected = true;
|
|
1107
|
+
this.onStatusChange?.('connected');
|
|
1108
|
+
this.ws!.send(JSON.stringify({ lang: this.language || Language.ENGLISH, vad: this.vad }));
|
|
1109
|
+
|
|
1110
|
+
await this.audioManager!.startMicrophone((data) => {
|
|
1111
|
+
if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
|
|
1112
|
+
this.ws.send(data);
|
|
1113
|
+
}
|
|
1114
|
+
});
|
|
1115
|
+
|
|
1116
|
+
settle(() => resolve(true));
|
|
1117
|
+
};
|
|
1118
|
+
|
|
1119
|
+
this.ws.onmessage = (event) => {
|
|
1120
|
+
if (typeof event.data !== 'string') return; // this endpoint only ever sends JSON text frames
|
|
1121
|
+
try {
|
|
1122
|
+
const msg = JSON.parse(event.data);
|
|
1123
|
+
if (msg.type === 'transcript') {
|
|
1124
|
+
if (msg.isFinal) {
|
|
1125
|
+
this.onFinalTranscript?.(msg.data ?? '');
|
|
1126
|
+
} else {
|
|
1127
|
+
this.onPartialTranscript?.(msg.data ?? '');
|
|
1128
|
+
}
|
|
1129
|
+
} else if (msg.type === 'error') {
|
|
1130
|
+
this.onError?.(new LokutorError('internal.error', msg.data ?? 'STT error'));
|
|
1131
|
+
}
|
|
1132
|
+
} catch {
|
|
1133
|
+
// ignore malformed frames
|
|
1134
|
+
}
|
|
1135
|
+
};
|
|
1136
|
+
|
|
1137
|
+
this.ws.onerror = (err) => {
|
|
1138
|
+
const lokutorErr = new LokutorError('internal.error', 'WebSocket error', { original: err });
|
|
1139
|
+
this.onError?.(lokutorErr);
|
|
1140
|
+
settle(() => reject(lokutorErr));
|
|
1141
|
+
};
|
|
1142
|
+
|
|
1143
|
+
this.ws.onclose = () => {
|
|
1144
|
+
this.isConnected = false;
|
|
1145
|
+
this.onStatusChange?.('disconnected');
|
|
1146
|
+
};
|
|
1147
|
+
} catch (err) {
|
|
1148
|
+
settle(() => reject(err));
|
|
1149
|
+
}
|
|
1150
|
+
});
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/** Force-finalize whatever utterance is currently in progress. */
|
|
1154
|
+
public endUtterance(): void {
|
|
1155
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
1156
|
+
this.ws.send(JSON.stringify({ type: 'end' }));
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
public disconnect(): void {
|
|
1161
|
+
this.isConnected = false;
|
|
1162
|
+
this.audioManager?.stopMicrophone();
|
|
1163
|
+
this.audioManager?.cleanup();
|
|
1164
|
+
this.ws?.close();
|
|
1165
|
+
this.ws = null;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
958
1169
|
/**
|
|
959
1170
|
* Quick function to start a conversation (requires manual audio piping in JS)
|
|
960
1171
|
*/
|
|
@@ -971,3 +1182,11 @@ export async function simpleTTS(options: SynthesizeOptions & { apiKey: string, o
|
|
|
971
1182
|
const client = new TTSClient({ apiKey: options.apiKey });
|
|
972
1183
|
return client.synthesize(options);
|
|
973
1184
|
}
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* Quick function for standalone, one-shot transcription of a complete audio clip.
|
|
1188
|
+
*/
|
|
1189
|
+
export async function simpleTranscribe(options: TranscribeOptions & { apiKey: string; serverUrl?: string }): Promise<TranscribeResult> {
|
|
1190
|
+
const client = new STTClient({ apiKey: options.apiKey, serverUrl: options.serverUrl });
|
|
1191
|
+
return client.transcribe(options);
|
|
1192
|
+
}
|
|
@@ -89,7 +89,7 @@ const PANEL_CSS = /*css*/ `
|
|
|
89
89
|
}
|
|
90
90
|
.cv-curtain-btn svg { transition: transform 0.3s ease; }
|
|
91
91
|
.cv-curtain-btn:hover svg { transform: translateX(4px); }
|
|
92
|
-
.cv-
|
|
92
|
+
.cv-error {
|
|
93
93
|
display: flex;
|
|
94
94
|
flex-direction: column;
|
|
95
95
|
align-items: center;
|
|
@@ -107,12 +107,6 @@ const PANEL_CSS = /*css*/ `
|
|
|
107
107
|
gap: 1rem;
|
|
108
108
|
letter-spacing: -0.02em;
|
|
109
109
|
}
|
|
110
|
-
.cv-title .cv-timer {
|
|
111
|
-
font-variant-numeric: tabular-nums;
|
|
112
|
-
color: var(--cv-accent);
|
|
113
|
-
font-weight: 400;
|
|
114
|
-
opacity: 0.8;
|
|
115
|
-
}
|
|
116
110
|
.cv-visualizer-wrap {
|
|
117
111
|
position: absolute;
|
|
118
112
|
top: 50%;
|
|
@@ -215,18 +209,52 @@ const PANEL_CSS = /*css*/ `
|
|
|
215
209
|
}
|
|
216
210
|
.cv-error.is-visible { display: flex; }
|
|
217
211
|
.cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
|
|
212
|
+
.cv-voice-picker {
|
|
213
|
+
display: flex;
|
|
214
|
+
flex-wrap: wrap;
|
|
215
|
+
justify-content: center;
|
|
216
|
+
gap: 0.35rem;
|
|
217
|
+
z-index: 2;
|
|
218
|
+
}
|
|
219
|
+
.cv-voice-btn {
|
|
220
|
+
padding: 0.25rem 0.5rem;
|
|
221
|
+
border-radius: 100px;
|
|
222
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
223
|
+
background: rgba(255,255,255,0.05);
|
|
224
|
+
color: rgba(255,255,255,0.6);
|
|
225
|
+
font-size: 0.65rem;
|
|
226
|
+
font-weight: 600;
|
|
227
|
+
cursor: pointer;
|
|
228
|
+
transition: all 0.2s ease;
|
|
229
|
+
}
|
|
230
|
+
.cv-voice-btn:hover {
|
|
231
|
+
background: rgba(255,255,255,0.12);
|
|
232
|
+
color: #fff;
|
|
233
|
+
}
|
|
234
|
+
.cv-voice-btn.is-selected {
|
|
235
|
+
background: #fff;
|
|
236
|
+
color: #000;
|
|
237
|
+
border-color: #fff;
|
|
238
|
+
}
|
|
239
|
+
.cv-voice-label {
|
|
240
|
+
font-size: 0.55rem;
|
|
241
|
+
text-transform: uppercase;
|
|
242
|
+
letter-spacing: 0.08em;
|
|
243
|
+
color: rgba(255,255,255,0.35);
|
|
244
|
+
z-index: 2;
|
|
245
|
+
}
|
|
218
246
|
|
|
219
247
|
/* === COMPACT MODE: < 300px width === */
|
|
220
248
|
@container (max-width: 299px) {
|
|
221
249
|
.cv-curtain-content { padding: 0.5rem; gap: 0.3rem; }
|
|
222
250
|
.cv-curtain-title { font-size: 0.8rem; }
|
|
223
251
|
.cv-curtain-desc { display: none; }
|
|
252
|
+
.cv-voice-picker { display: none; }
|
|
224
253
|
.cv-curtain-btn { padding: 0.3rem 0.6rem; font-size: 0.6rem; gap: 0.3rem; }
|
|
225
254
|
.cv-curtain-btn svg { display: none; }
|
|
226
255
|
.cv-visualizer-wrap { width: 60px; height: 60px; }
|
|
227
256
|
.cv-header { padding-top: 0.75rem; }
|
|
228
257
|
.cv-title { font-size: 0.7rem; gap: 0.3rem; }
|
|
229
|
-
.cv-title .cv-timer { display: none; }
|
|
230
258
|
.cv-controls { gap: 0.6rem; padding-bottom: 0.6rem; }
|
|
231
259
|
.cv-pill { padding: 0.15rem 0.4rem; gap: 0.3rem; }
|
|
232
260
|
.cv-btn { padding: 0.2rem 0.4rem; font-size: 0.6rem; gap: 0; }
|
|
@@ -244,7 +272,6 @@ const PANEL_CSS = /*css*/ `
|
|
|
244
272
|
.cv-visualizer-wrap { width: clamp(80px, 35cqw, 140px); height: clamp(80px, 35cqw, 140px); }
|
|
245
273
|
.cv-header { padding-top: 0.8rem; }
|
|
246
274
|
.cv-title { font-size: clamp(0.7rem, 2.5cqw, 1rem); gap: 0.4rem; }
|
|
247
|
-
.cv-title .cv-timer { font-size: 0.65em; }
|
|
248
275
|
.cv-controls { gap: clamp(0.6rem, 1.5cqw, 1rem); padding-bottom: clamp(0.6rem, 1.5cqw, 1rem); }
|
|
249
276
|
.cv-pill { padding: clamp(0.15rem, 0.5cqw, 0.25rem) clamp(0.35rem, 1cqw, 0.6rem); font-size: 0.65rem; gap: 0.3rem; }
|
|
250
277
|
.cv-btn { padding: clamp(0.2rem, 0.5cqw, 0.3rem) clamp(0.35rem, 1cqw, 0.6rem); font-size: clamp(0.6rem, 1.2cqw, 0.7rem); }
|
|
@@ -262,7 +289,6 @@ const PANEL_CSS = /*css*/ `
|
|
|
262
289
|
.cv-visualizer-wrap { width: clamp(100px, 40cqw, 200px); height: clamp(100px, 40cqw, 200px); }
|
|
263
290
|
.cv-header { padding-top: clamp(1rem, 1.5cqw, 1.5rem); }
|
|
264
291
|
.cv-title { font-size: clamp(0.9rem, 3cqw, 1.3rem); gap: clamp(0.4rem, 1cqw, 0.75rem); }
|
|
265
|
-
.cv-title .cv-timer { font-size: 0.85em; }
|
|
266
292
|
.cv-controls { gap: clamp(0.8rem, 1.5cqw, 1.2rem); padding-bottom: clamp(0.8rem, 1.5cqw, 1.2rem); }
|
|
267
293
|
.cv-pill { padding: clamp(0.2rem, 0.75cqw, 0.3rem) clamp(0.5rem, 1.2cqw, 0.75rem); font-size: 0.7rem; }
|
|
268
294
|
.cv-btn { padding: clamp(0.25rem, 0.75cqw, 0.35rem) clamp(0.5rem, 1.2cqw, 0.75rem); font-size: clamp(0.65rem, 1.2cqw, 0.8rem); }
|
|
@@ -278,7 +304,6 @@ const PANEL_CSS = /*css*/ `
|
|
|
278
304
|
.cv-visualizer-wrap { width: clamp(140px, 45cqw, 300px); height: clamp(140px, 45cqw, 300px); }
|
|
279
305
|
.cv-header { padding-top: clamp(1.5rem, 2cqw, 2rem); }
|
|
280
306
|
.cv-title { font-size: clamp(1rem, 3cqw, 1.8rem); gap: clamp(0.75rem, 1.5cqw, 1.2rem); }
|
|
281
|
-
.cv-title .cv-timer { font-size: 0.9em; }
|
|
282
307
|
.cv-controls { gap: clamp(1.2rem, 2cqw, 1.8rem); padding-bottom: clamp(1.2rem, 2cqw, 1.8rem); }
|
|
283
308
|
.cv-pill { padding: clamp(0.3rem, 0.8cqw, 0.4rem) clamp(0.75rem, 1.2cqw, 1rem); font-size: 0.85rem; gap: 0.75rem; }
|
|
284
309
|
.cv-btn { padding: clamp(0.35rem, 0.8cqw, 0.4rem) clamp(0.75rem, 1.2cqw, 1rem); font-size: clamp(0.75rem, 1.2cqw, 0.9rem); }
|
|
@@ -297,13 +322,19 @@ function injectStyles() {
|
|
|
297
322
|
}
|
|
298
323
|
|
|
299
324
|
export interface ConversationalPanelConfig {
|
|
325
|
+
/** Container element to mount the panel */
|
|
300
326
|
container: HTMLElement;
|
|
301
|
-
title
|
|
302
|
-
|
|
303
|
-
|
|
327
|
+
/** Agent name or title shown in the curtain */
|
|
328
|
+
title?: string;
|
|
329
|
+
/** Short description below the title */
|
|
330
|
+
description?: string;
|
|
331
|
+
/** System prompt for the voice agent */
|
|
332
|
+
prompt?: string;
|
|
333
|
+
/** Voice style identifier (default: M1) */
|
|
304
334
|
voice?: string;
|
|
335
|
+
/** Language code (default: en) */
|
|
305
336
|
language?: string;
|
|
306
|
-
/** Accent color for the sphere glow and highlights, e.g. '#
|
|
337
|
+
/** Accent color for the sphere glow and highlights, e.g. '#e74c3b' */
|
|
307
338
|
accentColor?: string;
|
|
308
339
|
/** Background color, e.g. '#0a0a0a' */
|
|
309
340
|
backgroundColor?: string;
|
|
@@ -336,16 +367,16 @@ export class ConversationalPanel {
|
|
|
336
367
|
private isRunning = false;
|
|
337
368
|
private _locked = false;
|
|
338
369
|
private _lastSpeechTime = 0;
|
|
339
|
-
|
|
340
|
-
// Cached DOM refs
|
|
370
|
+
private selectedVoice = 'M1';
|
|
341
371
|
private el!: HTMLElement;
|
|
342
372
|
private curtain!: HTMLElement;
|
|
343
373
|
private curtainTitle!: HTMLElement;
|
|
344
374
|
private curtainDesc!: HTMLElement;
|
|
375
|
+
private curtainBg!: HTMLElement;
|
|
376
|
+
private curtainOverlay!: HTMLElement;
|
|
345
377
|
private startBtn!: HTMLButtonElement;
|
|
346
378
|
private errorEl!: HTMLElement;
|
|
347
379
|
private errorText!: HTMLElement;
|
|
348
|
-
private timerEl!: HTMLElement;
|
|
349
380
|
private canvas!: HTMLCanvasElement;
|
|
350
381
|
private muteBtn!: HTMLElement;
|
|
351
382
|
private muteSvg!: SVGElement;
|
|
@@ -363,6 +394,7 @@ export class ConversationalPanel {
|
|
|
363
394
|
constructor(cfg: ConversationalPanelConfig) {
|
|
364
395
|
this.cfg = cfg;
|
|
365
396
|
this.container = cfg.container;
|
|
397
|
+
this.selectedVoice = cfg.voice || 'M1';
|
|
366
398
|
injectStyles();
|
|
367
399
|
this.buildDOM();
|
|
368
400
|
}
|
|
@@ -381,9 +413,9 @@ export class ConversationalPanel {
|
|
|
381
413
|
<div class="cv-curtain-bg"></div>
|
|
382
414
|
<div class="cv-curtain-overlay"></div>
|
|
383
415
|
<div class="cv-curtain-content">
|
|
384
|
-
<h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
|
|
385
|
-
<p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
|
|
386
|
-
|
|
416
|
+
<h3 class="cv-curtain-title">${this.esc(this.cfg.title || "Voice Chat")}</h3>
|
|
417
|
+
<p class="cv-curtain-desc">${this.esc(this.cfg.description || "")}</p>
|
|
418
|
+
<button class="cv-curtain-btn">
|
|
387
419
|
<span>Start Conversation</span>
|
|
388
420
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
|
|
389
421
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
|
@@ -401,7 +433,7 @@ export class ConversationalPanel {
|
|
|
401
433
|
</div>
|
|
402
434
|
</div>
|
|
403
435
|
<div class="cv-header">
|
|
404
|
-
<h2 class="cv-title">${this.esc(this.cfg.title
|
|
436
|
+
<h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
|
|
405
437
|
</div>
|
|
406
438
|
<div class="cv-visualizer-wrap">
|
|
407
439
|
<canvas class="cv-canvas"></canvas>
|
|
@@ -435,7 +467,6 @@ export class ConversationalPanel {
|
|
|
435
467
|
this.startBtn = this.el.querySelector('.cv-curtain-btn')!;
|
|
436
468
|
this.errorEl = this.el.querySelector('.cv-error')!;
|
|
437
469
|
this.errorText = this.el.querySelector('.cv-error-text')!;
|
|
438
|
-
this.timerEl = this.el.querySelector('.cv-timer')!;
|
|
439
470
|
this.canvas = this.el.querySelector('.cv-canvas')!;
|
|
440
471
|
this.muteBtn = this.el.querySelector('.cv-btn--mute')!;
|
|
441
472
|
this.muteSvg = this.muteBtn.querySelector('svg')!;
|
|
@@ -493,7 +524,7 @@ export class ConversationalPanel {
|
|
|
493
524
|
apiKey: this.cfg.apiKey,
|
|
494
525
|
tools: this.cfg.tools,
|
|
495
526
|
prompt: this.cfg.prompt,
|
|
496
|
-
voice: this.
|
|
527
|
+
voice: this.selectedVoice,
|
|
497
528
|
language: this.cfg.language || 'en',
|
|
498
529
|
onStatusChange: (status: string) => {
|
|
499
530
|
this.el.classList.remove('cv-is-speaking', 'cv-is-thinking');
|
|
@@ -630,9 +661,10 @@ export class ConversationalPanel {
|
|
|
630
661
|
this.cfg.title = title;
|
|
631
662
|
this.curtainTitle.textContent = title;
|
|
632
663
|
const h2 = this.el.querySelector('.cv-title');
|
|
633
|
-
if (h2) h2.
|
|
664
|
+
if (h2) h2.textContent = title;
|
|
634
665
|
}
|
|
635
666
|
|
|
667
|
+
/** Select a voice from the picker */
|
|
636
668
|
/** Update description text */
|
|
637
669
|
setDescription(desc: string) {
|
|
638
670
|
this.cfg.description = desc;
|
|
@@ -666,22 +698,15 @@ export class ConversationalPanel {
|
|
|
666
698
|
|
|
667
699
|
// ─── internal helpers ────────────────────────────────────
|
|
668
700
|
|
|
669
|
-
private fmt(s: number): string {
|
|
670
|
-
const m = Math.floor(s / 60).toString().padStart(2, '0');
|
|
671
|
-
const sec = (s % 60).toString().padStart(2, '0');
|
|
672
|
-
return `${m}:${sec}`;
|
|
673
|
-
}
|
|
674
|
-
|
|
675
701
|
private startTimer() {
|
|
702
|
+
// No visible countdown — this interval only enforces maxDuration and
|
|
703
|
+
// the silence timeout, both real safety/cost-control cutoffs, not UI.
|
|
676
704
|
let elapsed = 0;
|
|
677
705
|
const maxDur = this.cfg.maxDuration || 300;
|
|
678
706
|
const silentMax = this.cfg.silenceTimeout || 60;
|
|
679
707
|
this._lastSpeechTime = Date.now();
|
|
680
|
-
this.timerEl.textContent = this.fmt(maxDur);
|
|
681
708
|
this.timerTicker = window.setInterval(() => {
|
|
682
709
|
elapsed++;
|
|
683
|
-
const remaining = Math.max(0, maxDur - elapsed);
|
|
684
|
-
this.timerEl.textContent = this.fmt(remaining);
|
|
685
710
|
|
|
686
711
|
if (elapsed >= maxDur) {
|
|
687
712
|
this._locked = true;
|
package/src/index.ts
CHANGED
|
@@ -2,8 +2,18 @@ export * from './types';
|
|
|
2
2
|
export * from './client';
|
|
3
3
|
export * from './audio-utils';
|
|
4
4
|
export * from './browser-audio';
|
|
5
|
-
export
|
|
5
|
+
export * from './node-audio';
|
|
6
|
+
export {
|
|
7
|
+
VoiceAgentClient,
|
|
8
|
+
TTSClient,
|
|
9
|
+
STTClient,
|
|
10
|
+
SpeechToTextClient,
|
|
11
|
+
simpleConversation,
|
|
12
|
+
simpleTTS,
|
|
13
|
+
simpleTranscribe,
|
|
14
|
+
} from './client';
|
|
6
15
|
export { BrowserAudioManager } from './browser-audio';
|
|
16
|
+
export { NodeAudioManager } from './node-audio';
|
|
7
17
|
export { ConversationalPanel } from './conversational-panel';
|
|
8
18
|
export type { ConversationalPanelConfig } from './conversational-panel';
|
|
9
19
|
export {
|
|
@@ -24,4 +34,8 @@ export type {
|
|
|
24
34
|
ServerConfig,
|
|
25
35
|
ServerStatus,
|
|
26
36
|
HealthStatus,
|
|
37
|
+
TranscribeOptions,
|
|
38
|
+
TranscribeResult,
|
|
39
|
+
TranscribeSegment,
|
|
40
|
+
SpeechToTextOptions,
|
|
27
41
|
} from './types';
|
package/src/node-audio.ts
CHANGED
|
@@ -3,6 +3,18 @@
|
|
|
3
3
|
import { AudioManager } from './client';
|
|
4
4
|
import { AUDIO_CONFIG } from './types';
|
|
5
5
|
|
|
6
|
+
// 'speaker' and 'node-record-lpcm16' are optional peer dependencies — Node.js
|
|
7
|
+
// users who want managed microphone/speaker I/O install them themselves (see
|
|
8
|
+
// the warnings below); they are not bundled or required by this package, and
|
|
9
|
+
// aren't installed while building the SDK itself. Importing by a non-literal
|
|
10
|
+
// string (rather than `import('speaker')` directly) keeps TypeScript from
|
|
11
|
+
// trying to resolve either module's types at the SDK's own build time —
|
|
12
|
+
// this always resolves to `any`, and at runtime falls through to the
|
|
13
|
+
// .catch(() => null) handling below if the consumer hasn't installed it.
|
|
14
|
+
function optionalImport(moduleName: string): Promise<any> {
|
|
15
|
+
return import(moduleName);
|
|
16
|
+
}
|
|
17
|
+
|
|
6
18
|
/**
|
|
7
19
|
* Node.js-specific AudioManager implementation.
|
|
8
20
|
* Note: These require 'speaker' and 'node-record-lpcm16' to be installed by the user.
|
|
@@ -20,7 +32,7 @@ export class NodeAudioManager implements AudioManager {
|
|
|
20
32
|
try {
|
|
21
33
|
// Dynamic imports to avoid crashing if dependencies are missing at build time
|
|
22
34
|
// The user must install these manually for managed Node.js audio
|
|
23
|
-
const Speaker = await
|
|
35
|
+
const Speaker = await optionalImport('speaker').catch(() => null);
|
|
24
36
|
if (!Speaker) {
|
|
25
37
|
console.warn('⚠️ Package "speaker" is missing. Hardware output will be disabled.');
|
|
26
38
|
console.warn('👉 Run: npm install speaker');
|
|
@@ -34,7 +46,7 @@ export class NodeAudioManager implements AudioManager {
|
|
|
34
46
|
if (this.isListening) return;
|
|
35
47
|
|
|
36
48
|
try {
|
|
37
|
-
const recorder = await
|
|
49
|
+
const recorder = await optionalImport('node-record-lpcm16').catch(() => null);
|
|
38
50
|
if (!recorder) {
|
|
39
51
|
throw new Error('Package "node-record-lpcm16" is missing. Microphone input failed.\n👉 Run: npm install node-record-lpcm16');
|
|
40
52
|
}
|
|
@@ -72,7 +84,7 @@ export class NodeAudioManager implements AudioManager {
|
|
|
72
84
|
async playAudio(pcm16Data: Uint8Array): Promise<void> {
|
|
73
85
|
try {
|
|
74
86
|
if (!this.speaker) {
|
|
75
|
-
const Speaker = (await
|
|
87
|
+
const Speaker = (await optionalImport('speaker')).default;
|
|
76
88
|
this.speaker = new Speaker({
|
|
77
89
|
channels: AUDIO_CONFIG.CHANNELS,
|
|
78
90
|
bitDepth: 16,
|
package/src/types.ts
CHANGED
|
@@ -76,6 +76,7 @@ export const AUDIO_CONFIG = {
|
|
|
76
76
|
export const DEFAULT_URLS = {
|
|
77
77
|
VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
|
|
78
78
|
TTS: "wss://api.lokutor.com/ws/tts",
|
|
79
|
+
STT: "wss://api.lokutor.com/ws/stt",
|
|
79
80
|
};
|
|
80
81
|
|
|
81
82
|
/**
|
|
@@ -83,6 +84,7 @@ export const DEFAULT_URLS = {
|
|
|
83
84
|
*/
|
|
84
85
|
export interface LokutorConfig {
|
|
85
86
|
apiKey: string;
|
|
87
|
+
agentId?: string;
|
|
86
88
|
onTranscription?: (text: string) => void;
|
|
87
89
|
onResponse?: (text: string) => void;
|
|
88
90
|
onAudio?: (data: Uint8Array) => void;
|
|
@@ -102,6 +104,63 @@ export interface SynthesizeOptions {
|
|
|
102
104
|
visemes?: boolean;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Options for one-shot batch transcription via STTClient.transcribe()
|
|
109
|
+
* (POST /stt/transcribe) — pass a complete recording and get a transcript
|
|
110
|
+
* back. For continuous/live transcription, use SpeechToTextClient instead.
|
|
111
|
+
*/
|
|
112
|
+
export interface TranscribeOptions {
|
|
113
|
+
/** Complete audio to transcribe. A Blob/File (e.g. from a file input or
|
|
114
|
+
* MediaRecorder) is sent as multipart/form-data; a raw PCM16 buffer is
|
|
115
|
+
* sent as base64 JSON with `format: "pcm16"`. */
|
|
116
|
+
audio: Blob | ArrayBuffer | Uint8Array;
|
|
117
|
+
/** Required when `audio` is raw PCM16 (ignored for WAV/Blob input, whose
|
|
118
|
+
* rate is read from the file header). */
|
|
119
|
+
sampleRate?: number;
|
|
120
|
+
/** Set when passing a raw PCM16 buffer instead of a WAV Blob. */
|
|
121
|
+
format?: 'wav' | 'pcm16';
|
|
122
|
+
language?: Language;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** One transcribed segment with timing, when the engine provides them. */
|
|
126
|
+
export interface TranscribeSegment {
|
|
127
|
+
text: string;
|
|
128
|
+
start: number;
|
|
129
|
+
end: number;
|
|
130
|
+
duration: number;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Result of STTClient.transcribe() — mirrors POST /stt/transcribe's response body. */
|
|
134
|
+
export interface TranscribeResult {
|
|
135
|
+
text: string;
|
|
136
|
+
latencyMs: number;
|
|
137
|
+
engine: string;
|
|
138
|
+
sampleRate: number;
|
|
139
|
+
language: string;
|
|
140
|
+
durationSeconds: number;
|
|
141
|
+
segments?: TranscribeSegment[];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Continuous speech-to-text options (WS /ws/stt) — standalone transcription
|
|
146
|
+
* with server-side VAD, independent of the full voice-agent pipeline (no
|
|
147
|
+
* LLM turn, no TTS). Use this for live captioning/dictation; use
|
|
148
|
+
* STTClient.transcribe() for a single pre-recorded clip.
|
|
149
|
+
*/
|
|
150
|
+
export interface SpeechToTextOptions {
|
|
151
|
+
apiKey: string;
|
|
152
|
+
serverUrl?: string;
|
|
153
|
+
language?: Language;
|
|
154
|
+
/** VAD engine: "silero" (default, neural) or "rms" (energy-threshold fallback). */
|
|
155
|
+
vad?: 'silero' | 'rms';
|
|
156
|
+
/** Fires repeatedly while the user is mid-utterance, with the best partial guess so far. */
|
|
157
|
+
onPartialTranscript?: (text: string) => void;
|
|
158
|
+
/** Fires once VAD detects the utterance ended. */
|
|
159
|
+
onFinalTranscript?: (text: string) => void;
|
|
160
|
+
onError?: (error: LokutorError) => void;
|
|
161
|
+
onStatusChange?: (status: 'connecting' | 'connected' | 'disconnected') => void;
|
|
162
|
+
}
|
|
163
|
+
|
|
105
164
|
/**
|
|
106
165
|
* Browser audio configuration options
|
|
107
166
|
*/
|