Stabilize Athena Talk audio delivery
This commit is contained in:
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user