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
|
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
@@ -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)),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
+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