Add native OpenClaw Athena Talk plugin
This commit is contained in:
@@ -0,0 +1 @@
|
||||
node_modules/
|
||||
@@ -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
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
+5063
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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user