@lokutor/sdk 1.1.43 → 1.2.1

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/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-header {
92
+ .cv-error {
93
93
  display: flex;
94
94
  flex-direction: column;
95
95
  align-items: center;
@@ -215,12 +215,47 @@ const PANEL_CSS = /*css*/ `
215
215
  }
216
216
  .cv-error.is-visible { display: flex; }
217
217
  .cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
218
+ .cv-voice-picker {
219
+ display: flex;
220
+ flex-wrap: wrap;
221
+ justify-content: center;
222
+ gap: 0.35rem;
223
+ z-index: 2;
224
+ }
225
+ .cv-voice-btn {
226
+ padding: 0.25rem 0.5rem;
227
+ border-radius: 100px;
228
+ border: 1px solid rgba(255,255,255,0.15);
229
+ background: rgba(255,255,255,0.05);
230
+ color: rgba(255,255,255,0.6);
231
+ font-size: 0.65rem;
232
+ font-weight: 600;
233
+ cursor: pointer;
234
+ transition: all 0.2s ease;
235
+ }
236
+ .cv-voice-btn:hover {
237
+ background: rgba(255,255,255,0.12);
238
+ color: #fff;
239
+ }
240
+ .cv-voice-btn.is-selected {
241
+ background: #fff;
242
+ color: #000;
243
+ border-color: #fff;
244
+ }
245
+ .cv-voice-label {
246
+ font-size: 0.55rem;
247
+ text-transform: uppercase;
248
+ letter-spacing: 0.08em;
249
+ color: rgba(255,255,255,0.35);
250
+ z-index: 2;
251
+ }
218
252
 
219
253
  /* === COMPACT MODE: < 300px width === */
220
254
  @container (max-width: 299px) {
221
255
  .cv-curtain-content { padding: 0.5rem; gap: 0.3rem; }
222
256
  .cv-curtain-title { font-size: 0.8rem; }
223
257
  .cv-curtain-desc { display: none; }
258
+ .cv-voice-picker { display: none; }
224
259
  .cv-curtain-btn { padding: 0.3rem 0.6rem; font-size: 0.6rem; gap: 0.3rem; }
225
260
  .cv-curtain-btn svg { display: none; }
226
261
  .cv-visualizer-wrap { width: 60px; height: 60px; }
@@ -297,13 +332,19 @@ function injectStyles() {
297
332
  }
298
333
 
299
334
  export interface ConversationalPanelConfig {
335
+ /** Container element to mount the panel */
300
336
  container: HTMLElement;
301
- title: string;
302
- description: string;
303
- prompt: string;
337
+ /** Agent name or title shown in the curtain */
338
+ title?: string;
339
+ /** Short description below the title */
340
+ description?: string;
341
+ /** System prompt for the voice agent */
342
+ prompt?: string;
343
+ /** Voice style identifier (default: M1) */
304
344
  voice?: string;
345
+ /** Language code (default: en) */
305
346
  language?: string;
306
- /** Accent color for the sphere glow and highlights, e.g. '#e74c3c' */
347
+ /** Accent color for the sphere glow and highlights, e.g. '#e74c3b' */
307
348
  accentColor?: string;
308
349
  /** Background color, e.g. '#0a0a0a' */
309
350
  backgroundColor?: string;
@@ -336,12 +377,13 @@ export class ConversationalPanel {
336
377
  private isRunning = false;
337
378
  private _locked = false;
338
379
  private _lastSpeechTime = 0;
339
-
340
- // Cached DOM refs
380
+ private selectedVoice = 'M1';
341
381
  private el!: HTMLElement;
342
382
  private curtain!: HTMLElement;
343
383
  private curtainTitle!: HTMLElement;
344
384
  private curtainDesc!: HTMLElement;
385
+ private curtainBg!: HTMLElement;
386
+ private curtainOverlay!: HTMLElement;
345
387
  private startBtn!: HTMLButtonElement;
346
388
  private errorEl!: HTMLElement;
347
389
  private errorText!: HTMLElement;
@@ -363,6 +405,7 @@ export class ConversationalPanel {
363
405
  constructor(cfg: ConversationalPanelConfig) {
364
406
  this.cfg = cfg;
365
407
  this.container = cfg.container;
408
+ this.selectedVoice = cfg.voice || 'M1';
366
409
  injectStyles();
367
410
  this.buildDOM();
368
411
  }
@@ -381,9 +424,9 @@ export class ConversationalPanel {
381
424
  <div class="cv-curtain-bg"></div>
382
425
  <div class="cv-curtain-overlay"></div>
383
426
  <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
- <button class="cv-curtain-btn">
427
+ <h3 class="cv-curtain-title">${this.esc(this.cfg.title || "Voice Chat")}</h3>
428
+ <p class="cv-curtain-desc">${this.esc(this.cfg.description || "")}</p>
429
+ <button class="cv-curtain-btn">
387
430
  <span>Start Conversation</span>
388
431
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
389
432
  <path d="M5 12h14M12 5l7 7-7 7"/>
@@ -401,7 +444,7 @@ export class ConversationalPanel {
401
444
  </div>
402
445
  </div>
403
446
  <div class="cv-header">
404
- <h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
447
+ <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")} <span class="cv-timer">00:00</span></h2>
405
448
  </div>
406
449
  <div class="cv-visualizer-wrap">
407
450
  <canvas class="cv-canvas"></canvas>
@@ -493,7 +536,7 @@ export class ConversationalPanel {
493
536
  apiKey: this.cfg.apiKey,
494
537
  tools: this.cfg.tools,
495
538
  prompt: this.cfg.prompt,
496
- voice: this.cfg.voice || 'M1',
539
+ voice: this.selectedVoice,
497
540
  language: this.cfg.language || 'en',
498
541
  onStatusChange: (status: string) => {
499
542
  this.el.classList.remove('cv-is-speaking', 'cv-is-thinking');
@@ -633,6 +676,7 @@ export class ConversationalPanel {
633
676
  if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || '00:00'}</span>`;
634
677
  }
635
678
 
679
+ /** Select a voice from the picker */
636
680
  /** Update description text */
637
681
  setDescription(desc: string) {
638
682
  this.cfg.description = desc;
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 { VoiceAgentClient, TTSClient, simpleConversation, simpleTTS } from './client';
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 import('speaker').catch(() => null);
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 import('node-record-lpcm16').catch(() => null);
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 import('speaker')).default;
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
  */