@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/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;
@@ -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: string;
302
- description: string;
303
- prompt: string;
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. '#e74c3c' */
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
- <button class="cv-curtain-btn">
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)} <span class="cv-timer">00:00</span></h2>
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.cfg.voice || 'M1',
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.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || '00:00'}</span>`;
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 { 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
  */