@lokutor/sdk 1.1.42 → 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.d.mts +151 -6
- package/dist/index.d.ts +151 -6
- package/dist/index.js +341 -16
- package/dist/index.mjs +336 -15
- package/package.json +1 -2
- package/src/browser-audio.ts +32 -5
- package/src/client.ts +219 -0
- package/src/conversational-panel.ts +59 -14
- package/src/index.ts +15 -1
- package/src/node-audio.ts +15 -3
- package/src/types.ts +59 -0
package/dist/index.mjs
CHANGED
|
@@ -60,7 +60,8 @@ var AUDIO_CONFIG = {
|
|
|
60
60
|
};
|
|
61
61
|
var DEFAULT_URLS = {
|
|
62
62
|
VOICE_AGENT: "wss://api.lokutor.com/ws/agent",
|
|
63
|
-
TTS: "wss://api.lokutor.com/ws/tts"
|
|
63
|
+
TTS: "wss://api.lokutor.com/ws/tts",
|
|
64
|
+
STT: "wss://api.lokutor.com/ws/stt"
|
|
64
65
|
};
|
|
65
66
|
var LokutorError = class extends Error {
|
|
66
67
|
code;
|
|
@@ -240,6 +241,10 @@ var BrowserAudioManager = class {
|
|
|
240
241
|
mediaStreamAudioSourceNode = null;
|
|
241
242
|
scriptProcessor = null;
|
|
242
243
|
analyserNode = null;
|
|
244
|
+
// Reused across getAmplitude() calls instead of allocating a new
|
|
245
|
+
// Uint8Array on every call — this runs once per animation frame (~60/sec)
|
|
246
|
+
// from the visualizer, on the same main thread as audio capture.
|
|
247
|
+
amplitudeBuffer = null;
|
|
243
248
|
mediaStream = null;
|
|
244
249
|
resampler = null;
|
|
245
250
|
// Playback scheduling
|
|
@@ -306,7 +311,7 @@ var BrowserAudioManager = class {
|
|
|
306
311
|
}
|
|
307
312
|
});
|
|
308
313
|
this.mediaStreamAudioSourceNode = this.audioContext.createMediaStreamSource(this.mediaStream);
|
|
309
|
-
const bufferSize =
|
|
314
|
+
const bufferSize = 1024;
|
|
310
315
|
this.scriptProcessor = this.audioContext.createScriptProcessor(
|
|
311
316
|
bufferSize,
|
|
312
317
|
1,
|
|
@@ -340,7 +345,6 @@ var BrowserAudioManager = class {
|
|
|
340
345
|
*/
|
|
341
346
|
_processAudioInput(event) {
|
|
342
347
|
if (!this.onAudioInput || !this.audioContext || !this.isListening) return;
|
|
343
|
-
if (this.isMuted) return;
|
|
344
348
|
const inputBuffer = event.inputBuffer;
|
|
345
349
|
const inputData = inputBuffer.getChannelData(0);
|
|
346
350
|
const outputBuffer = event.outputBuffer;
|
|
@@ -352,6 +356,9 @@ var BrowserAudioManager = class {
|
|
|
352
356
|
processedData = this.resampler.process(processedData);
|
|
353
357
|
}
|
|
354
358
|
if (processedData.length === 0) return;
|
|
359
|
+
if (this.isMuted) {
|
|
360
|
+
processedData = new Float32Array(processedData.length);
|
|
361
|
+
}
|
|
355
362
|
const int16Data = float32ToPcm16(processedData);
|
|
356
363
|
const uint8Data = new Uint8Array(
|
|
357
364
|
int16Data.buffer,
|
|
@@ -475,9 +482,11 @@ var BrowserAudioManager = class {
|
|
|
475
482
|
*/
|
|
476
483
|
getAmplitude() {
|
|
477
484
|
if (!this.analyserNode) return 0;
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
485
|
+
if (!this.amplitudeBuffer || this.amplitudeBuffer.length !== this.analyserNode.frequencyBinCount) {
|
|
486
|
+
this.amplitudeBuffer = new Uint8Array(this.analyserNode.frequencyBinCount);
|
|
487
|
+
}
|
|
488
|
+
this.analyserNode.getByteTimeDomainData(this.amplitudeBuffer);
|
|
489
|
+
const rms = calculateRMS(this.amplitudeBuffer);
|
|
481
490
|
return Math.min(rms * 10, 1);
|
|
482
491
|
}
|
|
483
492
|
/**
|
|
@@ -584,6 +593,14 @@ function base64ToUint8Array(base64) {
|
|
|
584
593
|
}
|
|
585
594
|
return bytes;
|
|
586
595
|
}
|
|
596
|
+
function uint8ArrayToBase64(bytes) {
|
|
597
|
+
let binaryString = "";
|
|
598
|
+
const chunkSize = 32768;
|
|
599
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
600
|
+
binaryString += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
|
|
601
|
+
}
|
|
602
|
+
return btoa(binaryString);
|
|
603
|
+
}
|
|
587
604
|
function normalizeVisemes(payload) {
|
|
588
605
|
if (!Array.isArray(payload)) return [];
|
|
589
606
|
const normalized = [];
|
|
@@ -620,6 +637,7 @@ function extractVisemePayload(msg) {
|
|
|
620
637
|
var VoiceAgentClient = class {
|
|
621
638
|
ws = null;
|
|
622
639
|
apiKey;
|
|
640
|
+
agentId = "";
|
|
623
641
|
prompt;
|
|
624
642
|
voice;
|
|
625
643
|
language;
|
|
@@ -652,6 +670,7 @@ var VoiceAgentClient = class {
|
|
|
652
670
|
serverUrl;
|
|
653
671
|
constructor(config) {
|
|
654
672
|
this.apiKey = config.apiKey;
|
|
673
|
+
this.agentId = config.agentId || "";
|
|
655
674
|
this.prompt = config.prompt;
|
|
656
675
|
this.voice = config.voice || "F1" /* F1 */;
|
|
657
676
|
this.language = config.language || "en" /* ENGLISH */;
|
|
@@ -696,6 +715,10 @@ var VoiceAgentClient = class {
|
|
|
696
715
|
const separator = url.includes("?") ? "&" : "?";
|
|
697
716
|
url += `${separator}api_key=${this.apiKey}`;
|
|
698
717
|
}
|
|
718
|
+
if (this.agentId) {
|
|
719
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
720
|
+
url += `${separator}agent_id=${this.agentId}`;
|
|
721
|
+
}
|
|
699
722
|
const redactedUrl = url.replace(/api_key=[^&]+/, "api_key=***");
|
|
700
723
|
sdkTrace("ws.connect", {
|
|
701
724
|
endpoint: this.serverUrl,
|
|
@@ -1315,6 +1338,164 @@ var TTSClient = class {
|
|
|
1315
1338
|
});
|
|
1316
1339
|
}
|
|
1317
1340
|
};
|
|
1341
|
+
var STTClient = class {
|
|
1342
|
+
apiKey;
|
|
1343
|
+
baseUrl;
|
|
1344
|
+
constructor(config) {
|
|
1345
|
+
this.apiKey = config.apiKey;
|
|
1346
|
+
this.baseUrl = wsToHttp(config.serverUrl || DEFAULT_URLS.STT).replace(/\/ws\/stt\/?$/, "");
|
|
1347
|
+
}
|
|
1348
|
+
/**
|
|
1349
|
+
* Transcribe a complete audio clip. Pass a Blob/File (e.g. from a file
|
|
1350
|
+
* input or MediaRecorder) for WAV/compressed audio, or a raw PCM16
|
|
1351
|
+
* buffer with `format: "pcm16"` and `sampleRate` set.
|
|
1352
|
+
*/
|
|
1353
|
+
async transcribe(options) {
|
|
1354
|
+
const url = `${this.baseUrl}/stt/transcribe`;
|
|
1355
|
+
let res;
|
|
1356
|
+
if (typeof Blob !== "undefined" && options.audio instanceof Blob) {
|
|
1357
|
+
const form = new FormData();
|
|
1358
|
+
form.append("audio", options.audio, "audio.wav");
|
|
1359
|
+
if (options.language) form.append("lang", options.language);
|
|
1360
|
+
if (options.sampleRate) form.append("sample_rate", String(options.sampleRate));
|
|
1361
|
+
res = await fetch(url, {
|
|
1362
|
+
method: "POST",
|
|
1363
|
+
headers: { "X-API-Key": this.apiKey },
|
|
1364
|
+
body: form
|
|
1365
|
+
});
|
|
1366
|
+
} else {
|
|
1367
|
+
const bytes = options.audio instanceof Uint8Array ? options.audio : new Uint8Array(options.audio);
|
|
1368
|
+
res = await fetch(url, {
|
|
1369
|
+
method: "POST",
|
|
1370
|
+
headers: { "X-API-Key": this.apiKey, "Content-Type": "application/json" },
|
|
1371
|
+
body: JSON.stringify({
|
|
1372
|
+
audio: uint8ArrayToBase64(bytes),
|
|
1373
|
+
format: options.format || "pcm16",
|
|
1374
|
+
sample_rate: options.sampleRate,
|
|
1375
|
+
lang: options.language
|
|
1376
|
+
})
|
|
1377
|
+
});
|
|
1378
|
+
}
|
|
1379
|
+
if (!res.ok) {
|
|
1380
|
+
const detail = await res.text().catch(() => "");
|
|
1381
|
+
throw new LokutorError("internal.error", `HTTP ${res.status} from ${url}`, {
|
|
1382
|
+
detail,
|
|
1383
|
+
retryable: res.status >= 500
|
|
1384
|
+
});
|
|
1385
|
+
}
|
|
1386
|
+
const data = await res.json();
|
|
1387
|
+
return {
|
|
1388
|
+
text: data.text ?? "",
|
|
1389
|
+
latencyMs: data.latency_ms ?? 0,
|
|
1390
|
+
engine: data.engine ?? "",
|
|
1391
|
+
sampleRate: data.sample_rate ?? 0,
|
|
1392
|
+
language: data.language ?? "",
|
|
1393
|
+
durationSeconds: data.duration_seconds ?? 0,
|
|
1394
|
+
segments: data.segments
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
};
|
|
1398
|
+
var SpeechToTextClient = class {
|
|
1399
|
+
apiKey;
|
|
1400
|
+
serverUrl;
|
|
1401
|
+
language;
|
|
1402
|
+
vad;
|
|
1403
|
+
onPartialTranscript;
|
|
1404
|
+
onFinalTranscript;
|
|
1405
|
+
onError;
|
|
1406
|
+
onStatusChange;
|
|
1407
|
+
ws = null;
|
|
1408
|
+
audioManager = null;
|
|
1409
|
+
isConnected = false;
|
|
1410
|
+
constructor(config) {
|
|
1411
|
+
this.apiKey = config.apiKey;
|
|
1412
|
+
this.serverUrl = config.serverUrl || DEFAULT_URLS.STT;
|
|
1413
|
+
this.language = config.language;
|
|
1414
|
+
this.vad = config.vad || "silero";
|
|
1415
|
+
this.onPartialTranscript = config.onPartialTranscript;
|
|
1416
|
+
this.onFinalTranscript = config.onFinalTranscript;
|
|
1417
|
+
this.onError = config.onError;
|
|
1418
|
+
this.onStatusChange = config.onStatusChange;
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* Connect and start streaming microphone audio.
|
|
1422
|
+
* @param customAudioManager Optional replacement for the default audio hardware handler (e.g. NodeAudioManager for CLI use)
|
|
1423
|
+
*/
|
|
1424
|
+
async connect(customAudioManager) {
|
|
1425
|
+
this.audioManager = customAudioManager || (typeof window !== "undefined" ? new BrowserAudioManager() : null);
|
|
1426
|
+
if (!this.audioManager) {
|
|
1427
|
+
throw new LokutorError("internal.error", "No audio manager available \u2014 pass one explicitly outside the browser (e.g. NodeAudioManager).");
|
|
1428
|
+
}
|
|
1429
|
+
await this.audioManager.init();
|
|
1430
|
+
this.onStatusChange?.("connecting");
|
|
1431
|
+
return new Promise((resolve, reject) => {
|
|
1432
|
+
let settled = false;
|
|
1433
|
+
const settle = (fn) => {
|
|
1434
|
+
if (!settled) {
|
|
1435
|
+
settled = true;
|
|
1436
|
+
fn();
|
|
1437
|
+
}
|
|
1438
|
+
};
|
|
1439
|
+
try {
|
|
1440
|
+
let url = this.serverUrl;
|
|
1441
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
1442
|
+
url += `${separator}api_key=${this.apiKey}`;
|
|
1443
|
+
this.ws = new WebSocket(url);
|
|
1444
|
+
this.ws.onopen = async () => {
|
|
1445
|
+
this.isConnected = true;
|
|
1446
|
+
this.onStatusChange?.("connected");
|
|
1447
|
+
this.ws.send(JSON.stringify({ lang: this.language || "en" /* ENGLISH */, vad: this.vad }));
|
|
1448
|
+
await this.audioManager.startMicrophone((data) => {
|
|
1449
|
+
if (this.isConnected && this.ws?.readyState === WebSocket.OPEN) {
|
|
1450
|
+
this.ws.send(data);
|
|
1451
|
+
}
|
|
1452
|
+
});
|
|
1453
|
+
settle(() => resolve(true));
|
|
1454
|
+
};
|
|
1455
|
+
this.ws.onmessage = (event) => {
|
|
1456
|
+
if (typeof event.data !== "string") return;
|
|
1457
|
+
try {
|
|
1458
|
+
const msg = JSON.parse(event.data);
|
|
1459
|
+
if (msg.type === "transcript") {
|
|
1460
|
+
if (msg.isFinal) {
|
|
1461
|
+
this.onFinalTranscript?.(msg.data ?? "");
|
|
1462
|
+
} else {
|
|
1463
|
+
this.onPartialTranscript?.(msg.data ?? "");
|
|
1464
|
+
}
|
|
1465
|
+
} else if (msg.type === "error") {
|
|
1466
|
+
this.onError?.(new LokutorError("internal.error", msg.data ?? "STT error"));
|
|
1467
|
+
}
|
|
1468
|
+
} catch {
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
this.ws.onerror = (err) => {
|
|
1472
|
+
const lokutorErr = new LokutorError("internal.error", "WebSocket error", { original: err });
|
|
1473
|
+
this.onError?.(lokutorErr);
|
|
1474
|
+
settle(() => reject(lokutorErr));
|
|
1475
|
+
};
|
|
1476
|
+
this.ws.onclose = () => {
|
|
1477
|
+
this.isConnected = false;
|
|
1478
|
+
this.onStatusChange?.("disconnected");
|
|
1479
|
+
};
|
|
1480
|
+
} catch (err) {
|
|
1481
|
+
settle(() => reject(err));
|
|
1482
|
+
}
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/** Force-finalize whatever utterance is currently in progress. */
|
|
1486
|
+
endUtterance() {
|
|
1487
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
1488
|
+
this.ws.send(JSON.stringify({ type: "end" }));
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
disconnect() {
|
|
1492
|
+
this.isConnected = false;
|
|
1493
|
+
this.audioManager?.stopMicrophone();
|
|
1494
|
+
this.audioManager?.cleanup();
|
|
1495
|
+
this.ws?.close();
|
|
1496
|
+
this.ws = null;
|
|
1497
|
+
}
|
|
1498
|
+
};
|
|
1318
1499
|
async function simpleConversation(config) {
|
|
1319
1500
|
const client = new VoiceAgentClient(config);
|
|
1320
1501
|
await client.connect();
|
|
@@ -1324,6 +1505,102 @@ async function simpleTTS(options) {
|
|
|
1324
1505
|
const client = new TTSClient({ apiKey: options.apiKey });
|
|
1325
1506
|
return client.synthesize(options);
|
|
1326
1507
|
}
|
|
1508
|
+
async function simpleTranscribe(options) {
|
|
1509
|
+
const client = new STTClient({ apiKey: options.apiKey, serverUrl: options.serverUrl });
|
|
1510
|
+
return client.transcribe(options);
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// src/node-audio.ts
|
|
1514
|
+
function optionalImport(moduleName) {
|
|
1515
|
+
return import(moduleName);
|
|
1516
|
+
}
|
|
1517
|
+
var NodeAudioManager = class {
|
|
1518
|
+
speaker = null;
|
|
1519
|
+
recorder = null;
|
|
1520
|
+
recordingStream = null;
|
|
1521
|
+
isMuted = false;
|
|
1522
|
+
isListening = false;
|
|
1523
|
+
constructor() {
|
|
1524
|
+
}
|
|
1525
|
+
async init() {
|
|
1526
|
+
try {
|
|
1527
|
+
const Speaker = await optionalImport("speaker").catch(() => null);
|
|
1528
|
+
if (!Speaker) {
|
|
1529
|
+
console.warn('\u26A0\uFE0F Package "speaker" is missing. Hardware output will be disabled.');
|
|
1530
|
+
console.warn("\u{1F449} Run: npm install speaker");
|
|
1531
|
+
}
|
|
1532
|
+
} catch (e) {
|
|
1533
|
+
console.error("Error initializing Node audio:", e);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
async startMicrophone(onAudioInput) {
|
|
1537
|
+
if (this.isListening) return;
|
|
1538
|
+
try {
|
|
1539
|
+
const recorder = await optionalImport("node-record-lpcm16").catch(() => null);
|
|
1540
|
+
if (!recorder) {
|
|
1541
|
+
throw new Error('Package "node-record-lpcm16" is missing. Microphone input failed.\n\u{1F449} Run: npm install node-record-lpcm16');
|
|
1542
|
+
}
|
|
1543
|
+
console.log("\u{1F3A4} Starting microphone (Node.js)...");
|
|
1544
|
+
this.recordingStream = recorder.record({
|
|
1545
|
+
sampleRate: AUDIO_CONFIG.SAMPLE_RATE,
|
|
1546
|
+
threshold: 0,
|
|
1547
|
+
verbose: false,
|
|
1548
|
+
recordProgram: "sox"
|
|
1549
|
+
// default
|
|
1550
|
+
});
|
|
1551
|
+
this.recordingStream.stream().on("data", (chunk) => {
|
|
1552
|
+
if (!this.isMuted && onAudioInput) {
|
|
1553
|
+
onAudioInput(new Uint8Array(chunk));
|
|
1554
|
+
}
|
|
1555
|
+
});
|
|
1556
|
+
this.isListening = true;
|
|
1557
|
+
} catch (e) {
|
|
1558
|
+
console.error("Failed to start microphone:", e.message);
|
|
1559
|
+
throw e;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
stopMicrophone() {
|
|
1563
|
+
if (this.recordingStream) {
|
|
1564
|
+
this.recordingStream.stop();
|
|
1565
|
+
this.recordingStream = null;
|
|
1566
|
+
}
|
|
1567
|
+
this.isListening = false;
|
|
1568
|
+
}
|
|
1569
|
+
async playAudio(pcm16Data) {
|
|
1570
|
+
try {
|
|
1571
|
+
if (!this.speaker) {
|
|
1572
|
+
const Speaker = (await optionalImport("speaker")).default;
|
|
1573
|
+
this.speaker = new Speaker({
|
|
1574
|
+
channels: AUDIO_CONFIG.CHANNELS,
|
|
1575
|
+
bitDepth: 16,
|
|
1576
|
+
sampleRate: AUDIO_CONFIG.SPEAKER_SAMPLE_RATE
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
this.speaker.write(Buffer.from(pcm16Data));
|
|
1580
|
+
} catch (e) {
|
|
1581
|
+
console.error("NodeAudioManager: speaker playback failed:", e);
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
stopPlayback() {
|
|
1585
|
+
if (this.speaker) {
|
|
1586
|
+
this.speaker.end();
|
|
1587
|
+
this.speaker = null;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
cleanup() {
|
|
1591
|
+
this.stopMicrophone();
|
|
1592
|
+
this.stopPlayback();
|
|
1593
|
+
}
|
|
1594
|
+
isMicMuted() {
|
|
1595
|
+
return this.isMuted;
|
|
1596
|
+
}
|
|
1597
|
+
setMuted(muted) {
|
|
1598
|
+
this.isMuted = muted;
|
|
1599
|
+
}
|
|
1600
|
+
getAmplitude() {
|
|
1601
|
+
return 0;
|
|
1602
|
+
}
|
|
1603
|
+
};
|
|
1327
1604
|
|
|
1328
1605
|
// src/conversational-panel.ts
|
|
1329
1606
|
var PANEL_CSS = (
|
|
@@ -1419,7 +1696,7 @@ var PANEL_CSS = (
|
|
|
1419
1696
|
}
|
|
1420
1697
|
.cv-curtain-btn svg { transition: transform 0.3s ease; }
|
|
1421
1698
|
.cv-curtain-btn:hover svg { transform: translateX(4px); }
|
|
1422
|
-
.cv-
|
|
1699
|
+
.cv-error {
|
|
1423
1700
|
display: flex;
|
|
1424
1701
|
flex-direction: column;
|
|
1425
1702
|
align-items: center;
|
|
@@ -1545,12 +1822,47 @@ var PANEL_CSS = (
|
|
|
1545
1822
|
}
|
|
1546
1823
|
.cv-error.is-visible { display: flex; }
|
|
1547
1824
|
.cv-error-icon { color: var(--cv-accent); flex-shrink: 0; }
|
|
1825
|
+
.cv-voice-picker {
|
|
1826
|
+
display: flex;
|
|
1827
|
+
flex-wrap: wrap;
|
|
1828
|
+
justify-content: center;
|
|
1829
|
+
gap: 0.35rem;
|
|
1830
|
+
z-index: 2;
|
|
1831
|
+
}
|
|
1832
|
+
.cv-voice-btn {
|
|
1833
|
+
padding: 0.25rem 0.5rem;
|
|
1834
|
+
border-radius: 100px;
|
|
1835
|
+
border: 1px solid rgba(255,255,255,0.15);
|
|
1836
|
+
background: rgba(255,255,255,0.05);
|
|
1837
|
+
color: rgba(255,255,255,0.6);
|
|
1838
|
+
font-size: 0.65rem;
|
|
1839
|
+
font-weight: 600;
|
|
1840
|
+
cursor: pointer;
|
|
1841
|
+
transition: all 0.2s ease;
|
|
1842
|
+
}
|
|
1843
|
+
.cv-voice-btn:hover {
|
|
1844
|
+
background: rgba(255,255,255,0.12);
|
|
1845
|
+
color: #fff;
|
|
1846
|
+
}
|
|
1847
|
+
.cv-voice-btn.is-selected {
|
|
1848
|
+
background: #fff;
|
|
1849
|
+
color: #000;
|
|
1850
|
+
border-color: #fff;
|
|
1851
|
+
}
|
|
1852
|
+
.cv-voice-label {
|
|
1853
|
+
font-size: 0.55rem;
|
|
1854
|
+
text-transform: uppercase;
|
|
1855
|
+
letter-spacing: 0.08em;
|
|
1856
|
+
color: rgba(255,255,255,0.35);
|
|
1857
|
+
z-index: 2;
|
|
1858
|
+
}
|
|
1548
1859
|
|
|
1549
1860
|
/* === COMPACT MODE: < 300px width === */
|
|
1550
1861
|
@container (max-width: 299px) {
|
|
1551
1862
|
.cv-curtain-content { padding: 0.5rem; gap: 0.3rem; }
|
|
1552
1863
|
.cv-curtain-title { font-size: 0.8rem; }
|
|
1553
1864
|
.cv-curtain-desc { display: none; }
|
|
1865
|
+
.cv-voice-picker { display: none; }
|
|
1554
1866
|
.cv-curtain-btn { padding: 0.3rem 0.6rem; font-size: 0.6rem; gap: 0.3rem; }
|
|
1555
1867
|
.cv-curtain-btn svg { display: none; }
|
|
1556
1868
|
.cv-visualizer-wrap { width: 60px; height: 60px; }
|
|
@@ -1635,11 +1947,13 @@ var ConversationalPanel = class {
|
|
|
1635
1947
|
isRunning = false;
|
|
1636
1948
|
_locked = false;
|
|
1637
1949
|
_lastSpeechTime = 0;
|
|
1638
|
-
|
|
1950
|
+
selectedVoice = "M1";
|
|
1639
1951
|
el;
|
|
1640
1952
|
curtain;
|
|
1641
1953
|
curtainTitle;
|
|
1642
1954
|
curtainDesc;
|
|
1955
|
+
curtainBg;
|
|
1956
|
+
curtainOverlay;
|
|
1643
1957
|
startBtn;
|
|
1644
1958
|
errorEl;
|
|
1645
1959
|
errorText;
|
|
@@ -1659,6 +1973,7 @@ var ConversationalPanel = class {
|
|
|
1659
1973
|
constructor(cfg) {
|
|
1660
1974
|
this.cfg = cfg;
|
|
1661
1975
|
this.container = cfg.container;
|
|
1976
|
+
this.selectedVoice = cfg.voice || "M1";
|
|
1662
1977
|
injectStyles();
|
|
1663
1978
|
this.buildDOM();
|
|
1664
1979
|
}
|
|
@@ -1674,9 +1989,9 @@ var ConversationalPanel = class {
|
|
|
1674
1989
|
<div class="cv-curtain-bg"></div>
|
|
1675
1990
|
<div class="cv-curtain-overlay"></div>
|
|
1676
1991
|
<div class="cv-curtain-content">
|
|
1677
|
-
<h3 class="cv-curtain-title">${this.esc(this.cfg.title)}</h3>
|
|
1678
|
-
<p class="cv-curtain-desc">${this.esc(this.cfg.description)}</p>
|
|
1679
|
-
|
|
1992
|
+
<h3 class="cv-curtain-title">${this.esc(this.cfg.title || "Voice Chat")}</h3>
|
|
1993
|
+
<p class="cv-curtain-desc">${this.esc(this.cfg.description || "")}</p>
|
|
1994
|
+
<button class="cv-curtain-btn">
|
|
1680
1995
|
<span>Start Conversation</span>
|
|
1681
1996
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" width="20" height="20">
|
|
1682
1997
|
<path d="M5 12h14M12 5l7 7-7 7"/>
|
|
@@ -1694,7 +2009,7 @@ var ConversationalPanel = class {
|
|
|
1694
2009
|
</div>
|
|
1695
2010
|
</div>
|
|
1696
2011
|
<div class="cv-header">
|
|
1697
|
-
<h2 class="cv-title">${this.esc(this.cfg.title)} <span class="cv-timer">00:00</span></h2>
|
|
2012
|
+
<h2 class="cv-title">${this.esc(this.cfg.title || "Voice Chat")} <span class="cv-timer">00:00</span></h2>
|
|
1698
2013
|
</div>
|
|
1699
2014
|
<div class="cv-visualizer-wrap">
|
|
1700
2015
|
<canvas class="cv-canvas"></canvas>
|
|
@@ -1772,7 +2087,7 @@ var ConversationalPanel = class {
|
|
|
1772
2087
|
apiKey: this.cfg.apiKey,
|
|
1773
2088
|
tools: this.cfg.tools,
|
|
1774
2089
|
prompt: this.cfg.prompt,
|
|
1775
|
-
voice: this.
|
|
2090
|
+
voice: this.selectedVoice,
|
|
1776
2091
|
language: this.cfg.language || "en",
|
|
1777
2092
|
onStatusChange: (status) => {
|
|
1778
2093
|
this.el.classList.remove("cv-is-speaking", "cv-is-thinking");
|
|
@@ -1780,6 +2095,7 @@ var ConversationalPanel = class {
|
|
|
1780
2095
|
else if (status === "thinking") this.el.classList.add("cv-is-thinking");
|
|
1781
2096
|
},
|
|
1782
2097
|
onTranscription: (text) => {
|
|
2098
|
+
this._lastSpeechTime = Date.now();
|
|
1783
2099
|
this.onTranscription?.(text);
|
|
1784
2100
|
},
|
|
1785
2101
|
onResponse: (text) => {
|
|
@@ -1904,6 +2220,7 @@ var ConversationalPanel = class {
|
|
|
1904
2220
|
const h2 = this.el.querySelector(".cv-title");
|
|
1905
2221
|
if (h2) h2.innerHTML = `${this.esc(title)} <span class="cv-timer">${this.timerEl?.textContent || "00:00"}</span>`;
|
|
1906
2222
|
}
|
|
2223
|
+
/** Select a voice from the picker */
|
|
1907
2224
|
/** Update description text */
|
|
1908
2225
|
setDescription(desc) {
|
|
1909
2226
|
this.cfg.description = desc;
|
|
@@ -1939,7 +2256,7 @@ var ConversationalPanel = class {
|
|
|
1939
2256
|
startTimer() {
|
|
1940
2257
|
let elapsed = 0;
|
|
1941
2258
|
const maxDur = this.cfg.maxDuration || 300;
|
|
1942
|
-
const silentMax = this.cfg.silenceTimeout ||
|
|
2259
|
+
const silentMax = this.cfg.silenceTimeout || 60;
|
|
1943
2260
|
this._lastSpeechTime = Date.now();
|
|
1944
2261
|
this.timerEl.textContent = this.fmt(maxDur);
|
|
1945
2262
|
this.timerTicker = window.setInterval(() => {
|
|
@@ -2033,6 +2350,9 @@ export {
|
|
|
2033
2350
|
DEFAULT_URLS,
|
|
2034
2351
|
Language,
|
|
2035
2352
|
LokutorError,
|
|
2353
|
+
NodeAudioManager,
|
|
2354
|
+
STTClient,
|
|
2355
|
+
SpeechToTextClient,
|
|
2036
2356
|
StreamResampler,
|
|
2037
2357
|
TTSClient,
|
|
2038
2358
|
VoiceAgentClient,
|
|
@@ -2048,5 +2368,6 @@ export {
|
|
|
2048
2368
|
resample,
|
|
2049
2369
|
resampleWithAntiAliasing,
|
|
2050
2370
|
simpleConversation,
|
|
2051
|
-
simpleTTS
|
|
2371
|
+
simpleTTS,
|
|
2372
|
+
simpleTranscribe
|
|
2052
2373
|
};
|
package/package.json
CHANGED
package/src/browser-audio.ts
CHANGED
|
@@ -30,6 +30,10 @@ export class BrowserAudioManager {
|
|
|
30
30
|
private mediaStreamAudioSourceNode: MediaStreamAudioSourceNode | null = null;
|
|
31
31
|
private scriptProcessor: ScriptProcessorNode | null = null;
|
|
32
32
|
private analyserNode: AnalyserNode | null = null;
|
|
33
|
+
// Reused across getAmplitude() calls instead of allocating a new
|
|
34
|
+
// Uint8Array on every call — this runs once per animation frame (~60/sec)
|
|
35
|
+
// from the visualizer, on the same main thread as audio capture.
|
|
36
|
+
private amplitudeBuffer: Uint8Array<ArrayBuffer> | null = null;
|
|
33
37
|
private mediaStream: MediaStream | null = null;
|
|
34
38
|
private resampler: StreamResampler | null = null;
|
|
35
39
|
|
|
@@ -123,7 +127,17 @@ export class BrowserAudioManager {
|
|
|
123
127
|
// Create script processor for PCM extraction
|
|
124
128
|
// Note: ScriptProcessorNode is deprecated but widely supported.
|
|
125
129
|
// AudioWorklet would be better but requires additional setup.
|
|
126
|
-
|
|
130
|
+
//
|
|
131
|
+
// 1024 samples is ~21ms at a typical 48kHz hardware rate — matched to
|
|
132
|
+
// AUDIO_CONFIG.CHUNK_DURATION_MS (20ms), the granularity the backend's
|
|
133
|
+
// VAD is tuned around. The previous 4096 was ~85ms of audio buffered
|
|
134
|
+
// before a single byte reached the server: on top of adding raw
|
|
135
|
+
// latency, ScriptProcessorNode callbacks run on the main thread, so
|
|
136
|
+
// that much buffering meant audio could arrive in large, uneven
|
|
137
|
+
// bursts whenever the main thread was briefly busy (a re-render, the
|
|
138
|
+
// visualizer) instead of a steady stream — exactly the kind of
|
|
139
|
+
// mistimed input that can trip server-side VAD into a false barge-in.
|
|
140
|
+
const bufferSize = 1024;
|
|
127
141
|
this.scriptProcessor = this.audioContext!.createScriptProcessor(
|
|
128
142
|
bufferSize,
|
|
129
143
|
1, // input channels
|
|
@@ -165,7 +179,6 @@ export class BrowserAudioManager {
|
|
|
165
179
|
*/
|
|
166
180
|
private _processAudioInput(event: AudioProcessingEvent): void {
|
|
167
181
|
if (!this.onAudioInput || !this.audioContext || !this.isListening) return;
|
|
168
|
-
if (this.isMuted) return;
|
|
169
182
|
|
|
170
183
|
const inputBuffer = event.inputBuffer;
|
|
171
184
|
const inputData = inputBuffer.getChannelData(0);
|
|
@@ -185,6 +198,18 @@ export class BrowserAudioManager {
|
|
|
185
198
|
|
|
186
199
|
if (processedData.length === 0) return; // Need more data for resampler
|
|
187
200
|
|
|
201
|
+
// While muted, keep sending — silent — chunks instead of sending
|
|
202
|
+
// nothing at all (the old `if (this.isMuted) return` above this).
|
|
203
|
+
// The server's turn-taking/VAD is purely reactive to incoming chunks:
|
|
204
|
+
// if the client stops sending entirely mid-utterance, the server never
|
|
205
|
+
// observes the silence it needs to close out the turn, so muting
|
|
206
|
+
// mid-sentence left the conversation stuck instead of handing off to
|
|
207
|
+
// the agent. Explicitly zeroed here rather than relying solely on the
|
|
208
|
+
// disabled MediaStreamTrack to already read as silence.
|
|
209
|
+
if (this.isMuted) {
|
|
210
|
+
processedData = new Float32Array(processedData.length);
|
|
211
|
+
}
|
|
212
|
+
|
|
188
213
|
// Convert Float32 to Int16 PCM
|
|
189
214
|
const int16Data = float32ToPcm16(processedData);
|
|
190
215
|
const uint8Data = new Uint8Array(
|
|
@@ -339,10 +364,12 @@ export class BrowserAudioManager {
|
|
|
339
364
|
getAmplitude(): number {
|
|
340
365
|
if (!this.analyserNode) return 0;
|
|
341
366
|
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
if (!this.amplitudeBuffer || this.amplitudeBuffer.length !== this.analyserNode.frequencyBinCount) {
|
|
368
|
+
this.amplitudeBuffer = new Uint8Array(this.analyserNode.frequencyBinCount);
|
|
369
|
+
}
|
|
370
|
+
this.analyserNode.getByteTimeDomainData(this.amplitudeBuffer);
|
|
344
371
|
|
|
345
|
-
const rms = calculateRMS(
|
|
372
|
+
const rms = calculateRMS(this.amplitudeBuffer);
|
|
346
373
|
return Math.min(rms * 10, 1); // Boost for visualization
|
|
347
374
|
}
|
|
348
375
|
|