Stabilize Athena Talk audio delivery

This commit is contained in:
Mikei386
2026-09-03 23:11:44 +02:00
parent df41175960
commit 65ce9642cc
5 changed files with 42 additions and 27 deletions
@@ -9,6 +9,11 @@ Athena speech stack:
tools, tools,
4. Athena XTTS/Piper returns PCM audio to the Talk client. 4. Athena XTTS/Piper returns PCM audio to the Talk client.
The provider intentionally uses half-duplex audio: microphone input is paused
while a response is being transcribed, generated, synthesized, or played. This
prevents speaker feedback from aborting TTS. Spoken interruption (barge-in) is
therefore disabled; wait until playback finishes before speaking again.
No public speech provider is used. The provider reuses the already configured No public speech provider is used. The provider reuses the already configured
`models.providers.athena` base URL and API key; no second key copy is required. `models.providers.athena` base URL and API key; no second key copy is required.
+15 -11
View File
@@ -107,6 +107,7 @@ class AthenaTalkBridge {
prefixBytes = 0; prefixBytes = 0;
silenceTimer; silenceTimer;
active; active;
transcribing = false;
generation = 0; generation = 0;
constructor(req, cfg) { constructor(req, cfg) {
this.req = req; this.req = req;
@@ -122,6 +123,8 @@ class AthenaTalkBridge {
sendAudio(chunk) { sendAudio(chunk) {
if (!this.isConnected() || chunk.length === 0) if (!this.isConnected() || chunk.length === 0)
return; return;
if (this.transcribing || this.active)
return;
const voiced = pcmRms(chunk) >= this.cfg.vadThreshold; const voiced = pcmRms(chunk) >= this.cfg.vadThreshold;
const maxPrefix = Math.round(24000 * 2 * this.cfg.prefixPaddingMs / 1000); const maxPrefix = Math.round(24000 * 2 * this.cfg.prefixPaddingMs / 1000);
if (!this.speaking) { if (!this.speaking) {
@@ -132,12 +135,6 @@ class AthenaTalkBridge {
} }
if (!voiced) if (!voiced)
return; return;
if (this.active) {
this.active.abort();
this.active = undefined;
this.generation += 1;
this.req.onClearAudio?.("barge-in");
}
this.speaking = true; this.speaking = true;
this.speech = this.prefix; this.speech = this.prefix;
this.prefix = []; this.prefix = [];
@@ -187,17 +184,22 @@ class AthenaTalkBridge {
this.speech = []; this.speech = [];
if (pcm.length < 24000 * 2 * 0.25) if (pcm.length < 24000 * 2 * 0.25)
return; return;
this.transcribing = true;
try { try {
const text = await this.transcribe(pcm); const text = await this.transcribe(pcm);
if (!text || !this.isConnected()) if (!text || !this.isConnected())
return; return;
this.req.onTranscript?.("user", text, true); this.req.onTranscript?.("user", text, true);
this.transcribing = false;
await this.answer(text); await this.answer(text);
} }
catch (error) { catch (error) {
if (error.name !== "AbortError") if (error.name !== "AbortError")
this.req.onError?.(error); this.req.onError?.(error);
} }
finally {
this.transcribing = false;
}
} }
async transcribe(pcm) { async transcribe(pcm) {
const form = new FormData(); const form = new FormData();
@@ -217,7 +219,8 @@ class AthenaTalkBridge {
async answer(prompt) { async answer(prompt) {
if (!this.req.runAgentConsult) if (!this.req.runAgentConsult)
throw new Error("OpenClaw agent-consult is unavailable"); throw new Error("OpenClaw agent-consult is unavailable");
this.active?.abort(); if (this.active)
return;
const controller = new AbortController(); const controller = new AbortController();
this.active = controller; this.active = controller;
const generation = ++this.generation; const generation = ++this.generation;
@@ -229,17 +232,18 @@ class AthenaTalkBridge {
const text = String(result?.text || "").trim(); const text = String(result?.text || "").trim();
if (!text) if (!text)
return; return;
this.req.onEvent?.({ direction: "server", type: "response.created", responseId });
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()) if (generation !== this.generation || !this.isConnected())
return; return;
const frameBytes = 24000 * 2 / 50; const frameBytes = 24000 * 2 / 10;
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, 20)); await new Promise((resolve) => setTimeout(resolve, 80));
} }
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 });
@@ -289,8 +293,8 @@ export default definePluginEntry({
transports: ["gateway-relay"], transports: ["gateway-relay"],
inputAudioFormats: [AUDIO_FORMAT], inputAudioFormats: [AUDIO_FORMAT],
outputAudioFormats: [AUDIO_FORMAT], outputAudioFormats: [AUDIO_FORMAT],
supportsBargeIn: true, supportsBargeIn: false,
handlesInputAudioBargeIn: true, handlesInputAudioBargeIn: false,
supportsToolCalls: true, supportsToolCalls: true,
supportsSessionResumption: false, supportsSessionResumption: false,
}, },
+20 -14
View File
@@ -120,6 +120,7 @@ class AthenaTalkBridge {
private prefixBytes = 0; private prefixBytes = 0;
private silenceTimer?: NodeJS.Timeout; private silenceTimer?: NodeJS.Timeout;
private active?: AbortController; private active?: AbortController;
private transcribing = false;
private generation = 0; private generation = 0;
constructor(private readonly req: any, private readonly cfg: Required<ProviderConfig>) {} constructor(private readonly req: any, private readonly cfg: Required<ProviderConfig>) {}
@@ -136,6 +137,11 @@ class AthenaTalkBridge {
sendAudio(chunk: Buffer): void { sendAudio(chunk: Buffer): void {
if (!this.isConnected() || chunk.length === 0) return; if (!this.isConnected() || chunk.length === 0) return;
// Half-duplex by design: while Athena is transcribing, consulting the
// agent, synthesizing, or playing a reply, microphone input is ignored.
// This prevents speaker feedback and background noise from cancelling the
// response that is currently being delivered.
if (this.transcribing || this.active) return;
const voiced = pcmRms(chunk) >= this.cfg.vadThreshold; const voiced = pcmRms(chunk) >= this.cfg.vadThreshold;
const maxPrefix = Math.round(24000 * 2 * this.cfg.prefixPaddingMs / 1000); const maxPrefix = Math.round(24000 * 2 * this.cfg.prefixPaddingMs / 1000);
@@ -146,12 +152,6 @@ class AthenaTalkBridge {
this.prefixBytes -= this.prefix.shift()!.length; this.prefixBytes -= this.prefix.shift()!.length;
} }
if (!voiced) return; if (!voiced) return;
if (this.active) {
this.active.abort();
this.active = undefined;
this.generation += 1;
this.req.onClearAudio?.("barge-in");
}
this.speaking = true; this.speaking = true;
this.speech = this.prefix; this.speech = this.prefix;
this.prefix = []; this.prefix = [];
@@ -201,13 +201,17 @@ class AthenaTalkBridge {
this.speech = []; this.speech = [];
if (pcm.length < 24000 * 2 * 0.25) return; if (pcm.length < 24000 * 2 * 0.25) return;
this.transcribing = true;
try { try {
const text = await this.transcribe(pcm); const text = await this.transcribe(pcm);
if (!text || !this.isConnected()) return; if (!text || !this.isConnected()) return;
this.req.onTranscript?.("user", text, true); this.req.onTranscript?.("user", text, true);
this.transcribing = false;
await this.answer(text); await this.answer(text);
} catch (error) { } catch (error) {
if ((error as Error).name !== "AbortError") this.req.onError?.(error as Error); if ((error as Error).name !== "AbortError") this.req.onError?.(error as Error);
} finally {
this.transcribing = false;
} }
} }
@@ -228,7 +232,7 @@ class AthenaTalkBridge {
private async answer(prompt: string): Promise<void> { private async answer(prompt: string): Promise<void> {
if (!this.req.runAgentConsult) throw new Error("OpenClaw agent-consult is unavailable"); if (!this.req.runAgentConsult) throw new Error("OpenClaw agent-consult is unavailable");
this.active?.abort(); if (this.active) return;
const controller = new AbortController(); const controller = new AbortController();
this.active = controller; this.active = controller;
const generation = ++this.generation; const generation = ++this.generation;
@@ -238,19 +242,21 @@ class AthenaTalkBridge {
if (generation !== this.generation || !this.isConnected()) return; if (generation !== this.generation || !this.isConnected()) return;
const text = String(result?.text || "").trim(); const text = String(result?.text || "").trim();
if (!text) return; if (!text) return;
this.req.onEvent?.({ direction: "server", type: "response.created", responseId });
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;
const frameBytes = 24000 * 2 / 50; // Feed the relay 100 ms batches slightly faster than realtime. OpenClaw
// splits these into its native 20 ms frames. The small lead gives the
// client a jitter buffer and avoids the crackling caused by timer and
// WebSocket scheduling gaps at exact 20 ms pacing.
const frameBytes = 24000 * 2 / 10;
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)));
// Gateway relay drops audio frames when its WebSocket queue is full. await new Promise((resolve) => setTimeout(resolve, 80));
// Pace 20 ms PCM frames in realtime instead of flooding the complete
// synthesized response into the socket in a few milliseconds.
await new Promise((resolve) => setTimeout(resolve, 20));
} }
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 });
@@ -299,8 +305,8 @@ export default definePluginEntry({
transports: ["gateway-relay"], transports: ["gateway-relay"],
inputAudioFormats: [AUDIO_FORMAT], inputAudioFormats: [AUDIO_FORMAT],
outputAudioFormats: [AUDIO_FORMAT], outputAudioFormats: [AUDIO_FORMAT],
supportsBargeIn: true, supportsBargeIn: false,
handlesInputAudioBargeIn: true, handlesInputAudioBargeIn: false,
supportsToolCalls: true, supportsToolCalls: true,
supportsSessionResumption: false, supportsSessionResumption: false,
}, },
@@ -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.1", "version": "0.1.2",
"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.1", "version": "0.1.2",
"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,