Add native OpenClaw Athena Talk plugin

This commit is contained in:
Mikei386
2026-09-15 23:00:56 +02:00
parent 6cacfc1dc8
commit 724e20fec0
8 changed files with 5212 additions and 23 deletions
@@ -0,0 +1 @@
node_modules/
+21 -6
View File
@@ -17,8 +17,10 @@ while a response is being transcribed, generated, synthesized, or played. This
prevents speaker feedback from aborting TTS. Spoken interruption (barge-in) is prevents speaker feedback from aborting TTS. Spoken interruption (barge-in) is
therefore disabled; wait until playback finishes before speaking again. 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. `modelProvider` names an existing OpenClaw
`models.providers.athena` base URL and API key; no second key copy is required. model provider whose Athena base URL and API key are reused at runtime; no
second key copy is required. If it is omitted, the plugin checks `athena`,
`llama-cpp`, and `openai` in that order.
Recommended `talk.realtime` configuration: Recommended `talk.realtime` configuration:
@@ -27,12 +29,13 @@ Recommended `talk.realtime` configuration:
"provider": "athena-talk", "provider": "athena-talk",
"model": "athena-local", "model": "athena-local",
"speakerVoice": "alloy", "speakerVoice": "alloy",
"language": "de",
"mode": "realtime", "mode": "realtime",
"transport": "gateway-relay", "transport": "gateway-relay",
"brain": "agent-consult", "brain": "agent-consult",
"providers": { "providers": {
"athena-talk": { "athena-talk": {
"modelProvider": "llama-cpp",
"language": "de",
"vadThreshold": 0.018, "vadThreshold": 0.018,
"silenceDurationMs": 750, "silenceDurationMs": 750,
"prefixPaddingMs": 300, "prefixPaddingMs": 300,
@@ -42,6 +45,18 @@ Recommended `talk.realtime` configuration:
} }
``` ```
Install from the OpenClaw container with `openclaw plugins install <path>` and The plugin requires OpenClaw 2026.9.4 or newer. Build and validate it before
restart the gateway once. The plugin is stored in OpenClaw's persistent data installation:
directory, so normal image updates do not remove it.
```sh
npm install
npm run build
npm run check
openclaw plugins install . --force --accept-capabilities
openclaw plugins inspect athena-talk --runtime --json
```
Restart the gateway once if the installation does not trigger an automatic
reload. The managed plugin copy is stored in OpenClaw's persistent data
directory, so normal image updates do not remove it. Hermes remains unchanged;
both clients reuse the same OpenAI-compatible Athena speech endpoints.
+24 -7
View File
@@ -6,13 +6,25 @@ function record(value) {
? value ? value
: {}; : {};
} }
function resolveModelProvider(cfg, requested) {
const providers = record(record(cfg).models).providers;
const requestedId = typeof requested === "string" ? requested.trim() : "";
if (requestedId)
return record(record(providers)[requestedId]);
for (const id of ["athena", "llama-cpp", "openai"]) {
const candidate = record(record(providers)[id]);
if (typeof candidate.baseUrl === "string" && candidate.baseUrl.trim())
return candidate;
}
return {};
}
function resolveConfig(req) { function resolveConfig(req) {
const raw = record(req.providerConfig); const raw = record(req.providerConfig);
const modelProviders = record(record(req.cfg).models).providers; const modelProvider = resolveModelProvider(req.cfg, raw.modelProvider);
const athena = record(record(modelProviders).athena);
return { return {
baseUrl: String(raw.baseUrl || athena.baseUrl || "http://192.168.1.212:8081/v1").replace(/\/$/, ""), modelProvider: String(raw.modelProvider || ""),
apiKey: String(raw.apiKey || athena.apiKey || ""), baseUrl: String(raw.baseUrl || modelProvider.baseUrl || "http://192.168.1.212:8081/v1").replace(/\/$/, ""),
apiKey: String(raw.apiKey || modelProvider.apiKey || ""),
voice: String(raw.voice || req.voice || "alloy"), voice: String(raw.voice || req.voice || "alloy"),
language: String(raw.language || req.language || "de"), language: String(raw.language || req.language || "de"),
vadThreshold: Number(raw.vadThreshold ?? 0.018), vadThreshold: Number(raw.vadThreshold ?? 0.018),
@@ -145,6 +157,10 @@ class AthenaTalkBridge {
sendAudio(chunk) { sendAudio(chunk) {
if (!this.isConnected() || chunk.length === 0) if (!this.isConnected() || chunk.length === 0)
return; 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) if (this.transcribing || this.active)
return; return;
const voiced = pcmRms(chunk) >= this.cfg.vadThreshold; const voiced = pcmRms(chunk) >= this.cfg.vadThreshold;
@@ -225,7 +241,8 @@ class AthenaTalkBridge {
} }
async transcribe(pcm) { async transcribe(pcm) {
const form = new FormData(); const form = new FormData();
form.append("file", new Blob([wavFromPcm16(pcm)], { type: "audio/wav" }), "talk.wav"); const wavBytes = Uint8Array.from(wavFromPcm16(pcm));
form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav");
form.append("model", "whisper-1"); form.append("model", "whisper-1");
form.append("language", this.cfg.language); form.append("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
@@ -303,6 +320,7 @@ class AthenaTalkBridge {
const response = await fetch(`${this.cfg.baseUrl}/audio/speech`, { const response = await fetch(`${this.cfg.baseUrl}/audio/speech`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", ...this.authHeaders() }, headers: { "Content-Type": "application/json", ...this.authHeaders() },
// Athena normalizes the request and serves it through Qwen3-TTS.
body: JSON.stringify({ voice: this.cfg.voice, input: text, response_format: "wav" }), body: JSON.stringify({ voice: this.cfg.voice, input: text, response_format: "wav" }),
signal, signal,
}); });
@@ -338,8 +356,7 @@ export default definePluginEntry({
resolveConfig: ({ rawConfig }) => record(rawConfig), resolveConfig: ({ rawConfig }) => record(rawConfig),
isConfigured: ({ providerConfig, cfg }) => { isConfigured: ({ providerConfig, cfg }) => {
const raw = record(providerConfig); const raw = record(providerConfig);
const athena = record(record(record(cfg).models).providers).athena; return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
return Boolean(raw.baseUrl || record(athena).baseUrl);
}, },
createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)), createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)),
}); });
+22 -10
View File
@@ -4,6 +4,7 @@ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 } as const; const AUDIO_FORMAT = { encoding: "pcm16", sampleRateHz: 24000, channels: 1 } as const;
type ProviderConfig = { type ProviderConfig = {
modelProvider?: string;
baseUrl?: string; baseUrl?: string;
apiKey?: string; apiKey?: string;
voice?: string; voice?: string;
@@ -20,13 +21,24 @@ function record(value: unknown): Record<string, any> {
: {}; : {};
} }
function resolveModelProvider(cfg: unknown, requested?: unknown): Record<string, any> {
const providers = record(record(cfg).models).providers;
const requestedId = typeof requested === "string" ? requested.trim() : "";
if (requestedId) return record(record(providers)[requestedId]);
for (const id of ["athena", "llama-cpp", "openai"]) {
const candidate = record(record(providers)[id]);
if (typeof candidate.baseUrl === "string" && candidate.baseUrl.trim()) return candidate;
}
return {};
}
function resolveConfig(req: any): Required<ProviderConfig> { function resolveConfig(req: any): Required<ProviderConfig> {
const raw = record(req.providerConfig); const raw = record(req.providerConfig);
const modelProviders = record(record(req.cfg).models).providers; const modelProvider = resolveModelProvider(req.cfg, raw.modelProvider);
const athena = record(record(modelProviders).athena);
return { return {
baseUrl: String(raw.baseUrl || athena.baseUrl || "http://192.168.1.212:8081/v1").replace(/\/$/, ""), modelProvider: String(raw.modelProvider || ""),
apiKey: String(raw.apiKey || athena.apiKey || ""), baseUrl: String(raw.baseUrl || modelProvider.baseUrl || "http://192.168.1.212:8081/v1").replace(/\/$/, ""),
apiKey: String(raw.apiKey || modelProvider.apiKey || ""),
voice: String(raw.voice || req.voice || "alloy"), voice: String(raw.voice || req.voice || "alloy"),
language: String(raw.language || req.language || "de"), language: String(raw.language || req.language || "de"),
vadThreshold: Number(raw.vadThreshold ?? 0.018), vadThreshold: Number(raw.vadThreshold ?? 0.018),
@@ -238,7 +250,8 @@ class AthenaTalkBridge {
private async transcribe(pcm: Buffer): Promise<string> { private async transcribe(pcm: Buffer): Promise<string> {
const form = new FormData(); const form = new FormData();
form.append("file", new Blob([wavFromPcm16(pcm)], { type: "audio/wav" }), "talk.wav"); const wavBytes = Uint8Array.from(wavFromPcm16(pcm));
form.append("file", new Blob([wavBytes], { type: "audio/wav" }), "talk.wav");
form.append("model", "whisper-1"); form.append("model", "whisper-1");
form.append("language", this.cfg.language); form.append("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, { const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
@@ -341,13 +354,12 @@ export default definePluginEntry({
supportsToolCalls: true, supportsToolCalls: true,
supportsSessionResumption: false, supportsSessionResumption: false,
}, },
resolveConfig: ({ rawConfig }) => record(rawConfig), resolveConfig: ({ rawConfig }: { rawConfig: unknown }) => record(rawConfig),
isConfigured: ({ providerConfig, cfg }) => { isConfigured: ({ providerConfig, cfg }: { providerConfig: unknown; cfg: unknown }) => {
const raw = record(providerConfig); const raw = record(providerConfig);
const athena = record(record(record(cfg).models).providers).athena; return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
return Boolean(raw.baseUrl || record(athena).baseUrl);
}, },
createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)), createBridge: (req: any) => new AthenaTalkBridge(req, resolveConfig(req)),
} as any); } as any);
}, },
}); });
@@ -0,0 +1,18 @@
{
"id": "athena-talk",
"name": "Athena Local Talk",
"description": "Private OpenClaw Talk provider using Athena Whisper and Qwen3-TTS.",
"activation": {
"onStartup": true
},
"contracts": {
"realtimeVoiceProviders": [
"athena-talk"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,38 @@
{
"name": "@casaderoll/openclaw-athena-talk",
"version": "1.0.0",
"private": true,
"description": "Local OpenClaw Talk provider backed by Athena Whisper and Qwen3-TTS",
"type": "module",
"files": [
"dist",
"README.md",
"openclaw.plugin.json"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"check": "tsc -p tsconfig.json --noEmit",
"pack": "npm pack"
},
"peerDependencies": {
"openclaw": ">=2026.9.4"
},
"devDependencies": {
"@types/node": "^24.0.0",
"openclaw": "2026.9.4",
"typescript": "^5.9.0"
},
"openclaw": {
"extensions": [
"./dist/index.js"
],
"compat": {
"pluginApi": ">=2026.9.4",
"minGatewayVersion": "2026.9.4"
},
"build": {
"openclawVersion": "2026.9.4",
"pluginSdkVersion": "2026.9.4"
}
}
}
@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": [
"ES2022",
"DOM"
],
"types": [
"node"
],
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"outDir": "dist",
"rootDir": ".",
"declaration": false,
"sourceMap": false
},
"include": [
"index.ts"
]
}