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
therefore disabled; wait until playback finishes before speaking again.
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.
No public speech provider is used. `modelProvider` names an existing OpenClaw
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:
@@ -27,12 +29,13 @@ Recommended `talk.realtime` configuration:
"provider": "athena-talk",
"model": "athena-local",
"speakerVoice": "alloy",
"language": "de",
"mode": "realtime",
"transport": "gateway-relay",
"brain": "agent-consult",
"providers": {
"athena-talk": {
"modelProvider": "llama-cpp",
"language": "de",
"vadThreshold": 0.018,
"silenceDurationMs": 750,
"prefixPaddingMs": 300,
@@ -42,6 +45,18 @@ Recommended `talk.realtime` configuration:
}
```
Install from the OpenClaw container with `openclaw plugins install <path>` and
restart the gateway once. The plugin is stored in OpenClaw's persistent data
directory, so normal image updates do not remove it.
The plugin requires OpenClaw 2026.9.4 or newer. Build and validate it before
installation:
```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
: {};
}
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) {
const raw = record(req.providerConfig);
const modelProviders = record(record(req.cfg).models).providers;
const athena = record(record(modelProviders).athena);
const modelProvider = resolveModelProvider(req.cfg, raw.modelProvider);
return {
baseUrl: String(raw.baseUrl || athena.baseUrl || "http://192.168.1.212:8081/v1").replace(/\/$/, ""),
apiKey: String(raw.apiKey || athena.apiKey || ""),
modelProvider: String(raw.modelProvider || ""),
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"),
language: String(raw.language || req.language || "de"),
vadThreshold: Number(raw.vadThreshold ?? 0.018),
@@ -145,6 +157,10 @@ class AthenaTalkBridge {
sendAudio(chunk) {
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;
@@ -225,7 +241,8 @@ class AthenaTalkBridge {
}
async transcribe(pcm) {
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("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
@@ -303,6 +320,7 @@ class AthenaTalkBridge {
const response = await fetch(`${this.cfg.baseUrl}/audio/speech`, {
method: "POST",
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" }),
signal,
});
@@ -338,8 +356,7 @@ export default definePluginEntry({
resolveConfig: ({ rawConfig }) => record(rawConfig),
isConfigured: ({ providerConfig, cfg }) => {
const raw = record(providerConfig);
const athena = record(record(record(cfg).models).providers).athena;
return Boolean(raw.baseUrl || record(athena).baseUrl);
return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
},
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;
type ProviderConfig = {
modelProvider?: string;
baseUrl?: string;
apiKey?: 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> {
const raw = record(req.providerConfig);
const modelProviders = record(record(req.cfg).models).providers;
const athena = record(record(modelProviders).athena);
const modelProvider = resolveModelProvider(req.cfg, raw.modelProvider);
return {
baseUrl: String(raw.baseUrl || athena.baseUrl || "http://192.168.1.212:8081/v1").replace(/\/$/, ""),
apiKey: String(raw.apiKey || athena.apiKey || ""),
modelProvider: String(raw.modelProvider || ""),
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"),
language: String(raw.language || req.language || "de"),
vadThreshold: Number(raw.vadThreshold ?? 0.018),
@@ -238,7 +250,8 @@ class AthenaTalkBridge {
private async transcribe(pcm: Buffer): Promise<string> {
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("language", this.cfg.language);
const response = await fetch(`${this.cfg.baseUrl}/audio/transcriptions`, {
@@ -341,13 +354,12 @@ export default definePluginEntry({
supportsToolCalls: true,
supportsSessionResumption: false,
},
resolveConfig: ({ rawConfig }) => record(rawConfig),
isConfigured: ({ providerConfig, cfg }) => {
resolveConfig: ({ rawConfig }: { rawConfig: unknown }) => record(rawConfig),
isConfigured: ({ providerConfig, cfg }: { providerConfig: unknown; cfg: unknown }) => {
const raw = record(providerConfig);
const athena = record(record(record(cfg).models).providers).athena;
return Boolean(raw.baseUrl || record(athena).baseUrl);
return Boolean(raw.baseUrl || resolveModelProvider(cfg, raw.modelProvider).baseUrl);
},
createBridge: (req) => new AthenaTalkBridge(req, resolveConfig(req)),
createBridge: (req: any) => new AthenaTalkBridge(req, resolveConfig(req)),
} 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"
]
}