@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.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;
@@ -1484,12 +1765,6 @@ var PANEL_CSS = (
1484
1765
  gap: 1rem;
1485
1766
  letter-spacing: -0.02em;
1486
1767
  }
1487
- .cv-title .cv-timer {
1488
- font-variant-numeric: tabular-nums;
1489
- color: var(--cv-accent);
1490
- font-weight: 400;
1491
- opacity: 0.8;
1492
- }
1493
1768
  .cv-visualizer-wrap {
1494
1769
  position: absolute;
1495
1770
  top: 50%;
@@ -1592,18 +1867,52 @@ var PANEL_CSS = (
1592
1867
  }
1593
1868
  .cv-error.is-visible { display: flex; }
1594
1869
  .cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
1870
+ .cv-voice-picker {
1871
+ display: flex;
1872
+ flex-wrap: wrap;
1873
+ justify-content: center;
1874
+ gap: 0.35rem;
1875
+ z-index: 2;
1876
+ }
1877
+ .cv-voice-btn {
1878
+ padding: 0.25rem 0.5rem;
1879
+ border-radius: 100px;
1880
+ border: 1px solid rgba(255,255,255,0.15);
1881
+ background: rgba(255,255,255,0.05);
1882
+ color: rgba(255,255,255,0.6);
1883
+ font-size: 0.65rem;
1884
+ font-weight: 600;
1885
+ cursor: pointer;
1886
+ transition: all 0.2s ease;
1887
+ }
1888
+ .cv-voice-btn:hover {
1889
+ background: rgba(255,255,255,0.12);
1890
+ color: #fff;
1891
+ }
1892
+ .cv-voice-btn.is-selected {
1893
+ background: #fff;
1894
+ color: #000;
1895
+ border-color: #fff;
1896
+ }
1897
+ .cv-voice-label {
1898
+ font-size: 0.55rem;
1899
+ text-transform: uppercase;
1900
+ letter-spacing: 0.08em;
1901
+ color: rgba(255,255,255,0.35);
1902
+ z-index: 2;
1903
+ }
1595
1904
 
1596
1905
  /* === COMPACT MODE: < 300px width === */
1597
1906
  @container (max-width: 299px) {
1598
1907
  .cv-curtain-content { padding: 0.5rem; gap: 0.3rem; }
1599
1908
  .cv-curtain-title { font-size: 0.8rem; }
1600
1909
  .cv-curtain-desc { display: none; }
1910
+ .cv-voice-picker { display: none; }
1601
1911
  .cv-curtain-btn { padding: 0.3rem 0.6rem; font-size: 0.6rem; gap: 0.3rem; }
1602
1912
  .cv-curtain-btn svg { display: none; }
1603
1913
  .cv-visualizer-wrap { width: 60px; height: 60px; }
1604
1914
  .cv-header { padding-top: 0.75rem; }
1605
1915
  .cv-title { font-size: 0.7rem; gap: 0.3rem; }
1606
- .cv-title .cv-timer { display: none; }
1607
1916
  .cv-controls { gap: 0.6rem; padding-bottom: 0.6rem; }
1608
1917
  .cv-pill { padding: 0.15rem 0.4rem; gap: 0.3rem; }
1609
1918
  .cv-btn { padding: 0.2rem 0.4rem; font-size: 0.6rem; gap: 0; }
@@ -1621,7 +1930,6 @@ var PANEL_CSS = (
1621
1930
  .cv-visualizer-wrap { width: clamp(80px, 35cqw, 140px); height: clamp(80px, 35cqw, 140px); }
1622
1931
  .cv-header { padding-top: 0.8rem; }
1623
1932
  .cv-title { font-size: clamp(0.7rem, 2.5cqw, 1rem); gap: 0.4rem; }
1624
- .cv-title .cv-timer { font-size: 0.65em; }
1625
1933
  .cv-controls { gap: clamp(0.6rem, 1.5cqw, 1rem); padding-bottom: clamp(0.6rem, 1.5cqw, 1rem); }
1626
1934
  .cv-pill { padding: clamp(0.15rem, 0.5cqw, 0.25rem) clamp(0.35rem, 1cqw, 0.6rem); font-size: 0.65rem; gap: 0.3rem; }
1627
1935
  .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); }
@@ -1639,7 +1947,6 @@ var PANEL_CSS = (
1639
1947
  .cv-visualizer-wrap { width: clamp(100px, 40cqw, 200px); height: clamp(100px, 40cqw, 200px); }
1640
1948
  .cv-header { padding-top: clamp(1rem, 1.5cqw, 1.5rem); }
1641
1949
  .cv-title { font-size: clamp(0.9rem, 3cqw, 1.3rem); gap: clamp(0.4rem, 1cqw, 0.75rem); }
1642
- .cv-title .cv-timer { font-size: 0.85em; }
1643
1950
  .cv-controls { gap: clamp(0.8rem, 1.5cqw, 1.2rem); padding-bottom: clamp(0.8rem, 1.5cqw, 1.2rem); }
1644
1951
  .cv-pill { padding: clamp(0.2rem, 0.75cqw, 0.3rem) clamp(0.5rem, 1.2cqw, 0.75rem); font-size: 0.7rem; }
1645
1952
  .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); }
@@ -1655,7 +1962,6 @@ var PANEL_CSS = (
1655
1962
  .cv-visualizer-wrap { width: clamp(140px, 45cqw, 300px); height: clamp(140px, 45cqw, 300px); }
1656
1963
  .cv-header { padding-top: clamp(1.5rem, 2cqw, 2rem); }
1657
1964
  .cv-title { font-size: clamp(1rem, 3cqw, 1.8rem); gap: clamp(0.75rem, 1.5cqw, 1.2rem); }
1658
- .cv-title .cv-timer { font-size: 0.9em; }
1659
1965
  .cv-controls { gap: clamp(1.2rem, 2cqw, 1.8rem); padding-bottom: clamp(1.2rem, 2cqw, 1.8rem); }
1660
1966
  .cv-pill { padding: clamp(0.3rem, 0.8cqw, 0.4rem) clamp(0.75rem, 1.2cqw, 1rem); font-size: 0.85rem; gap: 0.75rem; }
1661
1967
  .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); }
@@ -1682,15 +1988,16 @@ var ConversationalPanel = class {
1682
1988
  isRunning = false;
1683
1989
  _locked = false;
1684
1990
  _lastSpeechTime = 0;
1685
- // Cached DOM refs
1991
+ selectedVoice = "M1";
1686
1992
  el;
1687
1993
  curtain;
1688
1994
  curtainTitle;
1689
1995
  curtainDesc;
1996
+ curtainBg;
1997
+ curtainOverlay;
1690
1998
  startBtn;
1691
1999
  errorEl;
1692
2000
  errorText;
1693
- timerEl;
1694
2001
  canvas;
1695
2002
  muteBtn;
1696
2003
  muteSvg;
@@ -1706,6 +2013,7 @@ var ConversationalPanel = class {
1706
2013
  constructor(cfg) {
1707
2014
  this.cfg = cfg;
1708
2015
  this.container = cfg.container;
2016
+ this.selectedVoice = cfg.voice || "M1";
1709
2017
  injectStyles();
1710
2018
  this.buildDOM();
1711
2019
  }
@@ -1721,9 +2029,9 @@ var ConversationalPanel = class {
1721
2029
  <div class="cv-curtain-bg"></div>
1722
2030
  <div class="cv-curtain-overlay"></div>
1723
2031
  <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">
2032
+ <h3 class="cv-curtain-title">${this.esc(this.cfg.title || "Voice Chat")}</h3>
2033
+ <p class="cv-curtain-desc">${this.esc(this.cfg.description || "")}</p>
2034
+ <button class="cv-curtain-btn">
1727
2035
  <span>Start Conversation</span>
1728
2036
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
1729
2037
  <path d="M5 12h14M12 5l7 7-7 7"/>
@@ -1741,7 +2049,7 @@ var ConversationalPanel = class {
1741
2049
  </div>
1742
2050
  </div>
1743
2051
  <div class="cv-header">
1744
- <h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
2052
+ <h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")}</h2>
1745
2053
  </div>
1746
2054
  <div class="cv-visualizer-wrap">
1747
2055
  <canvas class="cv-canvas"></canvas>
@@ -1772,7 +2080,6 @@ var ConversationalPanel = class {
1772
2080
  this.startBtn = this.el.querySelector(".cv-curtain-btn");
1773
2081
  this.errorEl = this.el.querySelector(".cv-error");
1774
2082
  this.errorText = this.el.querySelector(".cv-error-text");
1775
- this.timerEl = this.el.querySelector(".cv-timer");
1776
2083
  this.canvas = this.el.querySelector(".cv-canvas");
1777
2084
  this.muteBtn = this.el.querySelector(".cv-btn--mute");
1778
2085
  this.muteSvg = this.muteBtn.querySelector("svg");
@@ -1819,7 +2126,7 @@ var ConversationalPanel = class {
1819
2126
  apiKey: this.cfg.apiKey,
1820
2127
  tools: this.cfg.tools,
1821
2128
  prompt: this.cfg.prompt,
1822
- voice: this.cfg.voice || "M1",
2129
+ voice: this.selectedVoice,
1823
2130
  language: this.cfg.language || "en",
1824
2131
  onStatusChange: (status) => {
1825
2132
  this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
@@ -1950,8 +2257,9 @@ var ConversationalPanel = class {
1950
2257
  this.cfg.title = title;
1951
2258
  this.curtainTitle.textContent = title;
1952
2259
  const h2 = this.el.querySelector(".cv-title");
1953
- if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
2260
+ if (h2) h2.textContent = title;
1954
2261
  }
2262
+ /** Select a voice from the picker */
1955
2263
  /** Update description text */
1956
2264
  setDescription(desc) {
1957
2265
  this.cfg.description = desc;
@@ -1979,21 +2287,13 @@ var ConversationalPanel = class {
1979
2287
  this.el.remove();
1980
2288
  }
1981
2289
  // ─── internal helpers ────────────────────────────────────
1982
- fmt(s) {
1983
- const m = Math.floor(s / 60).toString().padStart(2, "0");
1984
- const sec = (s % 60).toString().padStart(2, "0");
1985
- return `${m}:${sec}`;
1986
- }
1987
2290
  startTimer() {
1988
2291
  let elapsed = 0;
1989
2292
  const maxDur = this.cfg.maxDuration || 300;
1990
2293
  const silentMax = this.cfg.silenceTimeout || 60;
1991
2294
  this._lastSpeechTime = Date.now();
1992
- this.timerEl.textContent = this.fmt(maxDur);
1993
2295
  this.timerTicker = window.setInterval(() => {
1994
2296
  elapsed++;
1995
- const remaining = Math.max(0, maxDur - elapsed);
1996
- this.timerEl.textContent = this.fmt(remaining);
1997
2297
  if (elapsed >= maxDur) {
1998
2298
  this._locked = true;
1999
2299
  this.stop();
@@ -2082,6 +2382,9 @@ var ConversationalPanel = class {
2082
2382
  DEFAULT_URLS,
2083
2383
  Language,
2084
2384
  LokutorError,
2385
+ NodeAudioManager,
2386
+ STTClient,
2387
+ SpeechToTextClient,
2085
2388
  StreamResampler,
2086
2389
  TTSClient,
2087
2390
  VoiceAgentClient,
@@ -2097,5 +2400,6 @@ var ConversationalPanel = class {
2097
2400
  resample,
2098
2401
  resampleWithAntiAliasing,
2099
2402
  simpleConversation,
2100
- simpleTTS
2403
+ simpleTTS,
2404
+ simpleTranscribe
2101
2405
  });