@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/dist/index.js CHANGED
@@ -26,6 +26,9 @@ __export(index_exports, {
26
26
  DEFAULT_URLS: () => DEFAULT_URLS,
27
27
  Language: () => Language,
28
28
  LokutorError: () => LokutorError,
29
+ NodeAudioManager: () => NodeAudioManager,
30
+ STTClient: () => STTClient,
31
+ SpeechToTextClient: () => SpeechToTextClient,
29
32
  StreamResampler: () => StreamResampler,
30
33
  TTSClient: () => TTSClient,
31
34
  VoiceAgentClient: () => VoiceAgentClient,
@@ -41,7 +44,8 @@ __export(index_exports, {
41
44
  resample: () => resample,
42
45
  resampleWithAntiAliasing: () => resampleWithAntiAliasing,
43
46
  simpleConversation: () => simpleConversation,
44
- simpleTTS: () => simpleTTS
47
+ simpleTTS: () => simpleTTS,
48
+ simpleTranscribe: () => simpleTranscribe
45
49
  });
46
50
  module.exports = __toCommonJS(index_exports);
47
51
 
@@ -107,7 +111,8 @@ var AUDIO_CONFIG = {
107
111
  };
108
112
  var DEFAULT_URLS = {
109
113
  VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
110
- TTS: "wss://api.lokutor.com/ws/tts"
114
+ TTS: "wss://api.lokutor.com/ws/tts",
115
+ STT: "wss://api.lokutor.com/ws/stt"
111
116
  };
112
117
  var LokutorError = class extends Error {
113
118
  code;
@@ -287,6 +292,10 @@ var BrowserAudioManager = class {
287
292
  mediaStreamAudioSourceNode = null;
288
293
  scriptProcessor = null;
289
294
  analyserNode = null;
295
+ // Reused across getAmplitude() calls instead of allocating a new
296
+ // Uint8Array on every call — this runs once per animation frame (~60/sec)
297
+ // from the visualizer, on the same main thread as audio capture.
298
+ amplitudeBuffer = null;
290
299
  mediaStream = null;
291
300
  resampler = null;
292
301
  // Playback scheduling
@@ -353,7 +362,7 @@ var BrowserAudioManager = class {
353
362
  }
354
363
  });
355
364
  this.mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
356
- const bufferSize = 4096;
365
+ const bufferSize = 1024;
357
366
  this.scriptProcessor = this.audioContext.createScriptProcessor(
358
367
  bufferSize,
359
368
  1,
@@ -387,7 +396,6 @@ var BrowserAudioManager = class {
387
396
  */
388
397
  _processAudioInput(event) {
389
398
  if (!this.onAudioInput || !this.audioContext || !this.isListening) return;
390
- if (this.isMuted) return;
391
399
  const inputBuffer = event.inputBuffer;
392
400
  const inputData = inputBuffer.getChannelData(0);
393
401
  const outputBuffer = event.outputBuffer;
@@ -399,6 +407,9 @@ var BrowserAudioManager = class {
399
407
  processedData = this.resampler.process(processedData);
400
408
  }
401
409
  if (processedData.length === 0) return;
410
+ if (this.isMuted) {
411
+ processedData = new Float32Array(processedData.length);
412
+ }
402
413
  const int16Data = float32ToPcm16(processedData);
403
414
  const uint8Data = new Uint8Array(
404
415
  int16Data.buffer,
@@ -522,9 +533,11 @@ var BrowserAudioManager = class {
522
533
  */
523
534
  getAmplitude() {
524
535
  if (!this.analyserNode) return 0;
525
- const dataArray = new Uint8Array(this.analyserNode.frequencyBinCount);
526
- this.analyserNode.getByteTimeDomainData(dataArray);
527
- const rms = calculateRMS(dataArray);
536
+ if (!this.amplitudeBuffer || this.amplitudeBuffer.length !== this.analyserNode.frequencyBinCount) {
537
+ this.amplitudeBuffer = new Uint8Array(this.analyserNode.frequencyBinCount);
538
+ }
539
+ this.analyserNode.getByteTimeDomainData(this.amplitudeBuffer);
540
+ const rms = calculateRMS(this.amplitudeBuffer);
528
541
  return Math.min(rms * 10, 1);
529
542
  }
530
543
  /**
@@ -631,6 +644,14 @@ function base64ToUint8Array(base64) {
631
644
  }
632
645
  return bytes;
633
646
  }
647
+ function uint8ArrayToBase64(bytes) {
648
+ let binaryString = "";
649
+ const chunkSize = 32768;
650
+ for (let i = 0; i < bytes.length; i += chunkSize) {
651
+ binaryString += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
652
+ }
653
+ return btoa(binaryString);
654
+ }
634
655
  function normalizeVisemes(payload) {
635
656
  if (!Array.isArray(payload)) return [];
636
657
  const normalized = [];
@@ -667,6 +688,7 @@ function extractVisemePayload(msg) {
667
688
  var VoiceAgentClient = class {
668
689
  ws = null;
669
690
  apiKey;
691
+ agentId = "";
670
692
  prompt;
671
693
  voice;
672
694
  language;
@@ -699,6 +721,7 @@ var VoiceAgentClient = class {
699
721
  serverUrl;
700
722
  constructor(config) {
701
723
  this.apiKey = config.apiKey;
724
+ this.agentId = config.agentId || "";
702
725
  this.prompt = config.prompt;
703
726
  this.voice = config.voice || "F1" /* F1 */;
704
727
  this.language = config.language || "en" /* ENGLISH */;
@@ -743,6 +766,10 @@ var VoiceAgentClient = class {
743
766
  const separator = url.includes("?") ? "&" : "?";
744
767
  url += `${separator}api_key=${this.apiKey}`;
745
768
  }
769
+ if (this.agentId) {
770
+ const separator = url.includes("?") ? "&" : "?";
771
+ url += `${separator}agent_id=${this.agentId}`;
772
+ }
746
773
  const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
747
774
  sdkTrace("ws.connect", {
748
775
  endpoint: this.serverUrl,
@@ -1362,6 +1389,164 @@ var TTSClient = class {
1362
1389
  });
1363
1390
  }
1364
1391
  };
1392
+ var STTClient = class {
1393
+ apiKey;
1394
+ baseUrl;
1395
+ constructor(config) {
1396
+ this.apiKey = config.apiKey;
1397
+ this.baseUrl = wsToHttp(config.serverUrl || DEFAULT_URLS.STT).replace(/\/ws\/stt\/?$/, "");
1398
+ }
1399
+ /**
1400
+ * Transcribe a complete audio clip. Pass a Blob/File (e.g. from a file
1401
+ * input or MediaRecorder) for WAV/compressed audio, or a raw PCM16
1402
+ * buffer with `format: "pcm16"` and `sampleRate` set.
1403
+ */
1404
+ async transcribe(options) {
1405
+ const url = `${this.baseUrl}/stt/transcribe`;
1406
+ let res;
1407
+ if (typeof Blob !== "undefined" && options.audio instanceof Blob) {
1408
+ const form = new FormData();
1409
+ form.append("audio", options.audio, "audio.wav");
1410
+ if (options.language) form.append("lang", options.language);
1411
+ if (options.sampleRate) form.append("sample_rate", String(options.sampleRate));
1412
+ res = await fetch(url, {
1413
+ method: "POST",
1414
+ headers: { "X-API-Key": this.apiKey },
1415
+ body: form
1416
+ });
1417
+ } else {
1418
+ const bytes = options.audio instanceof Uint8Array ? options.audio : new Uint8Array(options.audio);
1419
+ res = await fetch(url, {
1420
+ method: "POST",
1421
+ headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json" },
1422
+ body: JSON.stringify({
1423
+ audio: uint8ArrayToBase64(bytes),
1424
+ format: options.format || "pcm16",
1425
+ sample_rate: options.sampleRate,
1426
+ lang: options.language
1427
+ })
1428
+ });
1429
+ }
1430
+ if (!res.ok) {
1431
+ const detail = await res.text().catch(() => "");
1432
+ throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
1433
+ detail,
1434
+ retryable: res.status >= 500
1435
+ });
1436
+ }
1437
+ const data = await res.json();
1438
+ return {
1439
+ text: data.text ?? "",
1440
+ latencyMs: data.latency_ms ?? 0,
1441
+ engine: data.engine ?? "",
1442
+ sampleRate: data.sample_rate ?? 0,
1443
+ language: data.language ?? "",
1444
+ durationSeconds: data.duration_seconds ?? 0,
1445
+ segments: data.segments
1446
+ };
1447
+ }
1448
+ };
1449
+ var SpeechToTextClient = class {
1450
+ apiKey;
1451
+ serverUrl;
1452
+ language;
1453
+ vad;
1454
+ onPartialTranscript;
1455
+ onFinalTranscript;
1456
+ onError;
1457
+ onStatusChange;
1458
+ ws = null;
1459
+ audioManager = null;
1460
+ isConnected = false;
1461
+ constructor(config) {
1462
+ this.apiKey = config.apiKey;
1463
+ this.serverUrl = config.serverUrl || DEFAULT_URLS.STT;
1464
+ this.language = config.language;
1465
+ this.vad = config.vad || "silero";
1466
+ this.onPartialTranscript = config.onPartialTranscript;
1467
+ this.onFinalTranscript = config.onFinalTranscript;
1468
+ this.onError = config.onError;
1469
+ this.onStatusChange = config.onStatusChange;
1470
+ }
1471
+ /**
1472
+ * Connect and start streaming microphone audio.
1473
+ * @param customAudioManager Optional replacement for the default audio hardware handler (e.g. NodeAudioManager for CLI use)
1474
+ */
1475
+ async connect(customAudioManager) {
1476
+ this.audioManager = customAudioManager || (typeof window !== "undefined" ? new BrowserAudioManager() : null);
1477
+ if (!this.audioManager) {
1478
+ throw new LokutorError("internal.error", "No audio manager available \u2014 pass one explicitly outside the browser (e.g. NodeAudioManager).");
1479
+ }
1480
+ await this.audioManager.init();
1481
+ this.onStatusChange?.("connecting");
1482
+ return new Promise((resolve, reject) => {
1483
+ let settled = false;
1484
+ const settle = (fn) => {
1485
+ if (!settled) {
1486
+ settled = true;
1487
+ fn();
1488
+ }
1489
+ };
1490
+ try {
1491
+ let url = this.serverUrl;
1492
+ const separator = url.includes("?") ? "&" : "?";
1493
+ url += `${separator}api_key=${this.apiKey}`;
1494
+ this.ws = new WebSocket(url);
1495
+ this.ws.onopen = async () => {
1496
+ this.isConnected = true;
1497
+ this.onStatusChange?.("connected");
1498
+ this.ws.send(JSON.stringify({ lang: this.language || "en" /* ENGLISH */, vad: this.vad }));
1499
+ await this.audioManager.startMicrophone((data) => {
1500
+ if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
1501
+ this.ws.send(data);
1502
+ }
1503
+ });
1504
+ settle(() => resolve(true));
1505
+ };
1506
+ this.ws.onmessage = (event) => {
1507
+ if (typeof event.data !== "string") return;
1508
+ try {
1509
+ const msg = JSON.parse(event.data);
1510
+ if (msg.type === "transcript") {
1511
+ if (msg.isFinal) {
1512
+ this.onFinalTranscript?.(msg.data ?? "");
1513
+ } else {
1514
+ this.onPartialTranscript?.(msg.data ?? "");
1515
+ }
1516
+ } else if (msg.type === "error") {
1517
+ this.onError?.(new LokutorError("internal.error", msg.data ?? "STT error"));
1518
+ }
1519
+ } catch {
1520
+ }
1521
+ };
1522
+ this.ws.onerror = (err) => {
1523
+ const lokutorErr = new LokutorError("internal.error", "WebSocket error", { original: err });
1524
+ this.onError?.(lokutorErr);
1525
+ settle(() => reject(lokutorErr));
1526
+ };
1527
+ this.ws.onclose = () => {
1528
+ this.isConnected = false;
1529
+ this.onStatusChange?.("disconnected");
1530
+ };
1531
+ } catch (err) {
1532
+ settle(() => reject(err));
1533
+ }
1534
+ });
1535
+ }
1536
+ /** Force-finalize whatever utterance is currently in progress. */
1537
+ endUtterance() {
1538
+ if (this.ws?.readyState === WebSocket.OPEN) {
1539
+ this.ws.send(JSON.stringify({ type: "end" }));
1540
+ }
1541
+ }
1542
+ disconnect() {
1543
+ this.isConnected = false;
1544
+ this.audioManager?.stopMicrophone();
1545
+ this.audioManager?.cleanup();
1546
+ this.ws?.close();
1547
+ this.ws = null;
1548
+ }
1549
+ };
1365
1550
  async function simpleConversation(config) {
1366
1551
  const client = new VoiceAgentClient(config);
1367
1552
  await client.connect();
@@ -1371,6 +1556,102 @@ async function simpleTTS(options) {
1371
1556
  const client = new TTSClient({ apiKey: options.apiKey });
1372
1557
  return client.synthesize(options);
1373
1558
  }
1559
+ async function simpleTranscribe(options) {
1560
+ const client = new STTClient({ apiKey: options.apiKey, serverUrl: options.serverUrl });
1561
+ return client.transcribe(options);
1562
+ }
1563
+
1564
+ // src/node-audio.ts
1565
+ function optionalImport(moduleName) {
1566
+ return import(moduleName);
1567
+ }
1568
+ var NodeAudioManager = class {
1569
+ speaker = null;
1570
+ recorder = null;
1571
+ recordingStream = null;
1572
+ isMuted = false;
1573
+ isListening = false;
1574
+ constructor() {
1575
+ }
1576
+ async init() {
1577
+ try {
1578
+ const Speaker = await optionalImport("speaker").catch(() => null);
1579
+ if (!Speaker) {
1580
+ console.warn('\u26A0\uFE0F Package "speaker" is missing. Hardware output will be disabled.');
1581
+ console.warn("\u{1F449} Run: npm install speaker");
1582
+ }
1583
+ } catch (e) {
1584
+ console.error("Error initializing Node audio:", e);
1585
+ }
1586
+ }
1587
+ async startMicrophone(onAudioInput) {
1588
+ if (this.isListening) return;
1589
+ try {
1590
+ const recorder = await optionalImport("node-record-lpcm16").catch(() => null);
1591
+ if (!recorder) {
1592
+ throw new Error('Package "node-record-lpcm16" is missing. Microphone input failed.\n\u{1F449} Run: npm install node-record-lpcm16');
1593
+ }
1594
+ console.log("\u{1F3A4} Starting microphone (Node.js)...");
1595
+ this.recordingStream = recorder.record({
1596
+ sampleRate: AUDIO_CONFIG.SAMPLE_RATE,
1597
+ threshold: 0,
1598
+ verbose: false,
1599
+ recordProgram: "sox"
1600
+ // default
1601
+ });
1602
+ this.recordingStream.stream().on("data", (chunk) => {
1603
+ if (!this.isMuted && onAudioInput) {
1604
+ onAudioInput(new Uint8Array(chunk));
1605
+ }
1606
+ });
1607
+ this.isListening = true;
1608
+ } catch (e) {
1609
+ console.error("Failed to start microphone:", e.message);
1610
+ throw e;
1611
+ }
1612
+ }
1613
+ stopMicrophone() {
1614
+ if (this.recordingStream) {
1615
+ this.recordingStream.stop();
1616
+ this.recordingStream = null;
1617
+ }
1618
+ this.isListening = false;
1619
+ }
1620
+ async playAudio(pcm16Data) {
1621
+ try {
1622
+ if (!this.speaker) {
1623
+ const Speaker = (await optionalImport("speaker")).default;
1624
+ this.speaker = new Speaker({
1625
+ channels: AUDIO_CONFIG.CHANNELS,
1626
+ bitDepth: 16,
1627
+ sampleRate: AUDIO_CONFIG.SPEAKER_SAMPLE_RATE
1628
+ });
1629
+ }
1630
+ this.speaker.write(Buffer.from(pcm16Data));
1631
+ } catch (e) {
1632
+ console.error("NodeAudioManager: speaker playback failed:", e);
1633
+ }
1634
+ }
1635
+ stopPlayback() {
1636
+ if (this.speaker) {
1637
+ this.speaker.end();
1638
+ this.speaker = null;
1639
+ }
1640
+ }
1641
+ cleanup() {
1642
+ this.stopMicrophone();
1643
+ this.stopPlayback();
1644
+ }
1645
+ isMicMuted() {
1646
+ return this.isMuted;
1647
+ }
1648
+ setMuted(muted) {
1649
+ this.isMuted = muted;
1650
+ }
1651
+ getAmplitude() {
1652
+ return 0;
1653
+ }
1654
+ };
1374
1655
 
1375
1656
  // src/conversational-panel.ts
1376
1657
  var PANEL_CSS = (
@@ -1466,7 +1747,7 @@ var PANEL_CSS = (
1466
1747
  }
1467
1748
  .cv-curtain-btn svg { transition: transform 0.3s ease; }
1468
1749
  .cv-curtain-btn:hover svg { transform: translateX(4px); }
1469
- .cv-header {
1750
+ .cv-error {
1470
1751
  display: flex;
1471
1752
  flex-direction: column;
1472
1753
  align-items: center;
@@ -1592,12 +1873,47 @@ var PANEL_CSS = (
1592
1873
  }
1593
1874
  .cv-error.is-visible { display: flex; }
1594
1875
  .cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
1876
+ .cv-voice-picker {
1877
+ display: flex;
1878
+ flex-wrap: wrap;
1879
+ justify-content: center;
1880
+ gap: 0.35rem;
1881
+ z-index: 2;
1882
+ }
1883
+ .cv-voice-btn {
1884
+ padding: 0.25rem 0.5rem;
1885
+ border-radius: 100px;
1886
+ border: 1px solid rgba(255,255,255,0.15);
1887
+ background: rgba(255,255,255,0.05);
1888
+ color: rgba(255,255,255,0.6);
1889
+ font-size: 0.65rem;
1890
+ font-weight: 600;
1891
+ cursor: pointer;
1892
+ transition: all 0.2s ease;
1893
+ }
1894
+ .cv-voice-btn:hover {
1895
+ background: rgba(255,255,255,0.12);
1896
+ color: #fff;
1897
+ }
1898
+ .cv-voice-btn.is-selected {
1899
+ background: #fff;
1900
+ color: #000;
1901
+ border-color: #fff;
1902
+ }
1903
+ .cv-voice-label {
1904
+ font-size: 0.55rem;
1905
+ text-transform: uppercase;
1906
+ letter-spacing: 0.08em;
1907
+ color: rgba(255,255,255,0.35);
1908
+ z-index: 2;
1909
+ }
1595
1910
 
1596
1911
  /* === COMPACT MODE: < 300px width === */
1597
1912
  @container (max-width: 299px) {
1598
1913
  .cv-curtain-content { padding: 0.5rem; gap: 0.3rem; }
1599
1914
  .cv-curtain-title { font-size: 0.8rem; }
1600
1915
  .cv-curtain-desc { display: none; }
1916
+ .cv-voice-picker { display: none; }
1601
1917
  .cv-curtain-btn { padding: 0.3rem 0.6rem; font-size: 0.6rem; gap: 0.3rem; }
1602
1918
  .cv-curtain-btn svg { display: none; }
1603
1919
  .cv-visualizer-wrap { width: 60px; height: 60px; }
@@ -1682,11 +1998,13 @@ var ConversationalPanel = class {
1682
1998
  isRunning = false;
1683
1999
  _locked = false;
1684
2000
  _lastSpeechTime = 0;
1685
- // Cached DOM refs
2001
+ selectedVoice = "M1";
1686
2002
  el;
1687
2003
  curtain;
1688
2004
  curtainTitle;
1689
2005
  curtainDesc;
2006
+ curtainBg;
2007
+ curtainOverlay;
1690
2008
  startBtn;
1691
2009
  errorEl;
1692
2010
  errorText;
@@ -1706,6 +2024,7 @@ var ConversationalPanel = class {
1706
2024
  constructor(cfg) {
1707
2025
  this.cfg = cfg;
1708
2026
  this.container = cfg.container;
2027
+ this.selectedVoice = cfg.voice || "M1";
1709
2028
  injectStyles();
1710
2029
  this.buildDOM();
1711
2030
  }
@@ -1721,9 +2040,9 @@ var ConversationalPanel = class {
1721
2040
  <div class="cv-curtain-bg"></div>
1722
2041
  <div class="cv-curtain-overlay"></div>
1723
2042
  <div class="cv-curtain-content">
1724
- <h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
1725
- <p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
1726
- <button class="cv-curtain-btn">
2043
+ <h3 class="cv-curtain-title">${this.esc(this.cfg.title || "Voice Chat")}</h3>
2044
+ <p class="cv-curtain-desc">${this.esc(this.cfg.description || "")}</p>
2045
+ <button class="cv-curtain-btn">
1727
2046
  <span>Start Conversation</span>
1728
2047
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
1729
2048
  <path d="M5 12h14M12 5l7 7-7 7"/>
@@ -1741,7 +2060,7 @@ var ConversationalPanel = class {
1741
2060
  </div>
1742
2061
  </div>
1743
2062
  <div class="cv-header">
1744
- <h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
2063
+ <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")} <span class="cv-timer">00:00</span></h2>
1745
2064
  </div>
1746
2065
  <div class="cv-visualizer-wrap">
1747
2066
  <canvas class="cv-canvas"></canvas>
@@ -1819,7 +2138,7 @@ var ConversationalPanel = class {
1819
2138
  apiKey: this.cfg.apiKey,
1820
2139
  tools: this.cfg.tools,
1821
2140
  prompt: this.cfg.prompt,
1822
- voice: this.cfg.voice || "M1",
2141
+ voice: this.selectedVoice,
1823
2142
  language: this.cfg.language || "en",
1824
2143
  onStatusChange: (status) => {
1825
2144
  this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
@@ -1952,6 +2271,7 @@ var ConversationalPanel = class {
1952
2271
  const h2 = this.el.querySelector(".cv-title");
1953
2272
  if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
1954
2273
  }
2274
+ /** Select a voice from the picker */
1955
2275
  /** Update description text */
1956
2276
  setDescription(desc) {
1957
2277
  this.cfg.description = desc;
@@ -2082,6 +2402,9 @@ var ConversationalPanel = class {
2082
2402
  DEFAULT_URLS,
2083
2403
  Language,
2084
2404
  LokutorError,
2405
+ NodeAudioManager,
2406
+ STTClient,
2407
+ SpeechToTextClient,
2085
2408
  StreamResampler,
2086
2409
  TTSClient,
2087
2410
  VoiceAgentClient,
@@ -2097,5 +2420,6 @@ var ConversationalPanel = class {
2097
2420
  resample,
2098
2421
  resampleWithAntiAliasing,
2099
2422
  simpleConversation,
2100
- simpleTTS
2423
+ simpleTTS,
2424
+ simpleTranscribe
2101
2425
  });