Smooth Talk playback and gate audio tail

This commit is contained in:
Mikei386 committed 2026-09-03 23:19:51 +02:00
1 parent 65ce9642cc
commit 023c2ee40d
4 files changed
+22 -10

No files matched your search

+6 -2
View File
@@ -237,14 +237,18 @@ class AthenaTalkBridge {
const pcm = await this.synthesize(text, controller.signal); const pcm = await this.synthesize(text, controller.signal);
if (generation !== this.generation || !this.isConnected()) if (generation !== this.generation || !this.isConnected())
return; return;
const frameBytes = 24000 * 2 / 10; const frameBytes = 24000 * 2 / 50;
const playbackStartedAt = Date.now();
for (let offset = 0; offset < pcm.length; offset += frameBytes) { for (let offset = 0; offset < pcm.length; offset += frameBytes) {
if (controller.signal.aborted || generation !== this.generation || !this.isConnected()) { if (controller.signal.aborted || generation !== this.generation || !this.isConnected()) {
return; return;
} }
this.req.onAudio?.(pcm.subarray(offset, Math.min(offset + frameBytes, pcm.length))); this.req.onAudio?.(pcm.subarray(offset, Math.min(offset + frameBytes, pcm.length)));
await new Promise((resolve) => setTimeout(resolve, 80)); await new Promise((resolve) => setTimeout(resolve, 18));
} }
const audioDurationMs = pcm.length / (24000 * 2) * 1000;
const remainingPlaybackMs = Math.max(0, audioDurationMs - (Date.now() - playbackStartedAt));
await new Promise((resolve) => setTimeout(resolve, remainingPlaybackMs + 300));
this.req.onResponseDone?.({ status: "completed", responseId }); this.req.onResponseDone?.({ status: "completed", responseId });
this.req.onEvent?.({ direction: "server", type: "response.done", responseId }); this.req.onEvent?.({ direction: "server", type: "response.done", responseId });
} }
+14 -6
View File
@@ -246,18 +246,26 @@ class AthenaTalkBridge {
this.req.onTranscript?.("assistant", text, true); this.req.onTranscript?.("assistant", text, true);
const pcm = await this.synthesize(text, controller.signal); const pcm = await this.synthesize(text, controller.signal);
if (generation !== this.generation || !this.isConnected()) return; if (generation !== this.generation || !this.isConnected()) return;
// Feed the relay 100 ms batches slightly faster than realtime. OpenClaw // Feed OpenClaw in its native 20 ms frame size. Sending larger bursts
// splits these into its native 20 ms frames. The small lead gives the // made the relay split several frames at once; slow-client protection
// client a jitter buffer and avoids the crackling caused by timer and // could then drop individual frames and produce audible holes.
// WebSocket scheduling gaps at exact 20 ms pacing. const frameBytes = 24000 * 2 / 50;
const frameBytes = 24000 * 2 / 10; const playbackStartedAt = Date.now();
for (let offset = 0; offset < pcm.length; offset += frameBytes) { for (let offset = 0; offset < pcm.length; offset += frameBytes) {
if (controller.signal.aborted || generation !== this.generation || !this.isConnected()) { if (controller.signal.aborted || generation !== this.generation || !this.isConnected()) {
return; return;
} }
this.req.onAudio?.(pcm.subarray(offset, Math.min(offset + frameBytes, pcm.length))); this.req.onAudio?.(pcm.subarray(offset, Math.min(offset + frameBytes, pcm.length)));
await new Promise((resolve) => setTimeout(resolve, 80)); // Run just ahead of realtime so the client builds a small jitter
// buffer without flooding the WebSocket queue.
await new Promise((resolve) => setTimeout(resolve, 18));
} }
// Transmission is intentionally a little faster than playback. Keep the
// microphone gated until the client has consumed that buffered tail;
// otherwise the last spoken words are transcribed again as user input.
const audioDurationMs = pcm.length / (24000 * 2) * 1000;
const remainingPlaybackMs = Math.max(0, audioDurationMs - (Date.now() - playbackStartedAt));
await new Promise((resolve) => setTimeout(resolve, remainingPlaybackMs + 300));
this.req.onResponseDone?.({ status: "completed", responseId }); this.req.onResponseDone?.({ status: "completed", responseId });
this.req.onEvent?.({ direction: "server", type: "response.done", responseId }); this.req.onEvent?.({ direction: "server", type: "response.done", responseId });
} catch (error) { } catch (error) {
@@ -2,7 +2,7 @@
"id": "athena-talk", "id": "athena-talk",
"name": "Athena Local Talk", "name": "Athena Local Talk",
"description": "Local German voice conversations through Athena without a public speech provider.", "description": "Local German voice conversations through Athena without a public speech provider.",
"version": "0.1.2", "version": "0.1.3",
"enabledByDefault": true, "enabledByDefault": true,
"activation": { "activation": {
"onStartup": true, "onStartup": true,
@@ -1,6 +1,6 @@
{ {
"name": "openclaw-plugin-athena-talk", "name": "openclaw-plugin-athena-talk",
"version": "0.1.2", "version": "0.1.3",
"description": "Private realtime Talk bridge for Athena STT, OpenClaw agent consult, and Athena TTS.", "description": "Private realtime Talk bridge for Athena STT, OpenClaw agent consult, and Athena TTS.",
"type": "module", "type": "module",
"private": true, "private": true,