@alfe.ai/openclaw-voice 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -38
- package/dist/index.d.cts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/plugin.d.cts +23 -49
- package/dist/plugin.d.ts +23 -49
- package/dist/plugin2.cjs +236 -262
- package/dist/plugin2.d.cts +2 -0
- package/dist/plugin2.d.ts +2 -0
- package/dist/plugin2.js +238 -264
- package/openclaw.plugin.json +3 -12
- package/package.json +5 -4
package/dist/plugin2.cjs
CHANGED
|
@@ -1,12 +1,32 @@
|
|
|
1
1
|
let _sinclair_typebox = require("@sinclair/typebox");
|
|
2
2
|
let _alfe_ai_config = require("@alfe.ai/config");
|
|
3
3
|
let _alfe_ai_agent_api_client = require("@alfe.ai/agent-api-client");
|
|
4
|
+
let _alfe_ai_openclaw_plugin_kit = require("@alfe.ai/openclaw-plugin-kit");
|
|
5
|
+
let node_module = require("node:module");
|
|
6
|
+
let node_fs = require("node:fs");
|
|
4
7
|
let node_fs_promises = require("node:fs/promises");
|
|
5
8
|
let node_path = require("node:path");
|
|
6
|
-
let node_module = require("node:module");
|
|
7
9
|
//#region src/audio.ts
|
|
10
|
+
const MAX_WAV_PCM_BYTES = 4294967259;
|
|
11
|
+
const SUPPORTED_BIT_DEPTHS = new Set([
|
|
12
|
+
8,
|
|
13
|
+
16,
|
|
14
|
+
24,
|
|
15
|
+
32
|
|
16
|
+
]);
|
|
17
|
+
function validatePcmFraming(framing, pcmLength) {
|
|
18
|
+
const { sampleRate, channels, bitDepth } = framing;
|
|
19
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 384e3) throw new Error("WAV sample rate must be an integer between 8000 and 384000 Hz.");
|
|
20
|
+
if (!Number.isInteger(channels) || channels < 1 || channels > 32) throw new Error("WAV channel count must be an integer between 1 and 32.");
|
|
21
|
+
if (!SUPPORTED_BIT_DEPTHS.has(bitDepth)) throw new Error("WAV bit depth must be one of 8, 16, 24, or 32.");
|
|
22
|
+
const blockAlign = channels * (bitDepth / 8);
|
|
23
|
+
if (pcmLength % blockAlign !== 0) throw new Error("PCM byte length must contain a whole number of sample frames.");
|
|
24
|
+
if (pcmLength > MAX_WAV_PCM_BYTES) throw new Error("PCM payload is too large for a RIFF/WAV container.");
|
|
25
|
+
if (sampleRate * blockAlign > 4294967295) throw new Error("WAV byte rate exceeds the RIFF field limit.");
|
|
26
|
+
}
|
|
8
27
|
/** Prepend a canonical 44-byte PCM WAV header to raw PCM samples. */
|
|
9
28
|
function pcmToWav(pcm, framing) {
|
|
29
|
+
validatePcmFraming(framing, pcm.length);
|
|
10
30
|
const { sampleRate, channels, bitDepth } = framing;
|
|
11
31
|
const blockAlign = channels * (bitDepth / 8);
|
|
12
32
|
const byteRate = sampleRate * blockAlign;
|
|
@@ -27,35 +47,59 @@ function pcmToWav(pcm, framing) {
|
|
|
27
47
|
return Buffer.concat([header, pcm]);
|
|
28
48
|
}
|
|
29
49
|
/**
|
|
30
|
-
* Parse a WAV buffer into its PCM payload + framing. Returns `null` when
|
|
31
|
-
* buffer is not
|
|
32
|
-
*
|
|
33
|
-
*
|
|
50
|
+
* Parse a WAV buffer into its PCM payload + framing. Returns `null` only when
|
|
51
|
+
* the buffer is not RIFF data (caller may then treat it as raw PCM). A RIFF
|
|
52
|
+
* buffer that claims to be WAV but is truncated, unsupported, or internally
|
|
53
|
+
* inconsistent throws instead of silently uploading container bytes as PCM.
|
|
34
54
|
*/
|
|
35
55
|
function parseWav(buf) {
|
|
36
|
-
if (buf.length <
|
|
37
|
-
if (buf.
|
|
38
|
-
if (buf.toString("ascii", 8, 12) !== "WAVE")
|
|
56
|
+
if (buf.length < 4 || buf.toString("ascii", 0, 4) !== "RIFF") return null;
|
|
57
|
+
if (buf.length < 12) throw new Error("Malformed WAV: truncated RIFF header.");
|
|
58
|
+
if (buf.toString("ascii", 8, 12) !== "WAVE") throw new Error("Unsupported RIFF container: expected WAVE.");
|
|
59
|
+
const declaredEnd = buf.readUInt32LE(4) + 8;
|
|
60
|
+
if (declaredEnd !== buf.length) throw new Error("Malformed WAV: RIFF size does not match the file length.");
|
|
39
61
|
let sampleRate = 0;
|
|
40
62
|
let channels = 0;
|
|
41
63
|
let bitDepth = 0;
|
|
42
64
|
let pcm = null;
|
|
65
|
+
let blockAlign = 0;
|
|
66
|
+
let byteRate = 0;
|
|
67
|
+
let sawFmt = false;
|
|
68
|
+
let sawData = false;
|
|
43
69
|
let offset = 12;
|
|
44
|
-
while (offset
|
|
70
|
+
while (offset < declaredEnd) {
|
|
71
|
+
if (offset + 8 > declaredEnd) throw new Error("Malformed WAV: truncated chunk header.");
|
|
45
72
|
const chunkId = buf.toString("ascii", offset, offset + 4);
|
|
46
73
|
const chunkSize = buf.readUInt32LE(offset + 4);
|
|
47
74
|
const bodyStart = offset + 8;
|
|
48
|
-
|
|
75
|
+
const bodyEnd = bodyStart + chunkSize;
|
|
76
|
+
const nextOffset = bodyEnd + chunkSize % 2;
|
|
77
|
+
if (bodyEnd > declaredEnd || nextOffset > declaredEnd) throw new Error(`Malformed WAV: truncated ${chunkId} chunk.`);
|
|
78
|
+
if (chunkId === "fmt ") {
|
|
79
|
+
if (sawFmt) throw new Error("Malformed WAV: duplicate fmt chunk.");
|
|
80
|
+
if (chunkSize < 16) throw new Error("Malformed WAV: fmt chunk is too short.");
|
|
81
|
+
if (buf.readUInt16LE(bodyStart) !== 1) throw new Error("Unsupported WAV encoding: only integer PCM is accepted.");
|
|
49
82
|
channels = buf.readUInt16LE(bodyStart + 2);
|
|
50
83
|
sampleRate = buf.readUInt32LE(bodyStart + 4);
|
|
84
|
+
byteRate = buf.readUInt32LE(bodyStart + 8);
|
|
85
|
+
blockAlign = buf.readUInt16LE(bodyStart + 12);
|
|
51
86
|
bitDepth = buf.readUInt16LE(bodyStart + 14);
|
|
87
|
+
sawFmt = true;
|
|
52
88
|
} else if (chunkId === "data") {
|
|
53
|
-
|
|
54
|
-
pcm = buf.subarray(bodyStart,
|
|
89
|
+
if (sawData) throw new Error("Malformed WAV: duplicate data chunk.");
|
|
90
|
+
pcm = buf.subarray(bodyStart, bodyEnd);
|
|
91
|
+
sawData = true;
|
|
55
92
|
}
|
|
56
|
-
offset =
|
|
93
|
+
offset = nextOffset;
|
|
57
94
|
}
|
|
58
|
-
if (!
|
|
95
|
+
if (!sawFmt || !sawData || pcm === null) throw new Error("Malformed WAV: both fmt and data chunks are required.");
|
|
96
|
+
validatePcmFraming({
|
|
97
|
+
sampleRate,
|
|
98
|
+
channels,
|
|
99
|
+
bitDepth
|
|
100
|
+
}, pcm.length);
|
|
101
|
+
const expectedBlockAlign = channels * (bitDepth / 8);
|
|
102
|
+
if (blockAlign !== expectedBlockAlign || byteRate !== sampleRate * expectedBlockAlign) throw new Error("Malformed WAV: byte rate or block alignment is inconsistent with its framing.");
|
|
59
103
|
return {
|
|
60
104
|
pcm,
|
|
61
105
|
sampleRate,
|
|
@@ -64,6 +108,83 @@ function parseWav(buf) {
|
|
|
64
108
|
};
|
|
65
109
|
}
|
|
66
110
|
//#endregion
|
|
111
|
+
//#region src/workspace-files.ts
|
|
112
|
+
const MAX_TOOL_PATH_LENGTH = 1024;
|
|
113
|
+
function isWithin(root, candidate) {
|
|
114
|
+
const rel = (0, node_path.relative)(root, candidate);
|
|
115
|
+
return rel === "" || !(0, node_path.isAbsolute)(rel) && rel !== ".." && !rel.startsWith(`..${node_path.sep}`);
|
|
116
|
+
}
|
|
117
|
+
function assertRelativeToolPath(input, label) {
|
|
118
|
+
if (input.length === 0) throw new Error(`${label} must not be empty.`);
|
|
119
|
+
if (input.length > MAX_TOOL_PATH_LENGTH) throw new Error(`${label} is too long (maximum ${String(MAX_TOOL_PATH_LENGTH)} characters).`);
|
|
120
|
+
if (input.includes("\0")) throw new Error(`${label} contains a null byte.`);
|
|
121
|
+
if ((0, node_path.isAbsolute)(input) || /^[A-Za-z]:[\\/]/u.test(input) || input.startsWith("\\\\")) throw new Error(`${label} must be relative to the workspace.`);
|
|
122
|
+
if (input.split(/[\\/]/u).includes("..")) throw new Error(`${label} must not contain parent-directory traversal.`);
|
|
123
|
+
}
|
|
124
|
+
async function workspaceRoot() {
|
|
125
|
+
return (0, node_fs_promises.realpath)(process.cwd());
|
|
126
|
+
}
|
|
127
|
+
async function readWorkspaceFile(input, maxBytes) {
|
|
128
|
+
assertRelativeToolPath(input, "Path");
|
|
129
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 1) throw new Error("Maximum file size must be a positive safe integer.");
|
|
130
|
+
const root = await workspaceRoot();
|
|
131
|
+
const lexicalPath = (0, node_path.resolve)(root, input);
|
|
132
|
+
if (!isWithin(root, lexicalPath)) throw new Error("Path escapes the workspace.");
|
|
133
|
+
const absolutePath = await (0, node_fs_promises.realpath)(lexicalPath);
|
|
134
|
+
if (!isWithin(root, absolutePath)) throw new Error("Path resolves outside the workspace.");
|
|
135
|
+
const handle = await (0, node_fs_promises.open)(absolutePath, node_fs.constants.O_RDONLY | node_fs.constants.O_NOFOLLOW);
|
|
136
|
+
try {
|
|
137
|
+
const before = await handle.stat();
|
|
138
|
+
if (!before.isFile()) throw new Error("Path must refer to a regular file.");
|
|
139
|
+
if (before.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
|
|
140
|
+
const data = await handle.readFile();
|
|
141
|
+
const after = await handle.stat();
|
|
142
|
+
if (data.length > maxBytes || after.size > maxBytes) throw new Error(`File is too large (maximum ${String(maxBytes)} bytes).`);
|
|
143
|
+
return {
|
|
144
|
+
absolutePath,
|
|
145
|
+
data
|
|
146
|
+
};
|
|
147
|
+
} finally {
|
|
148
|
+
await handle.close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
async function resolveWorkspaceOutputPath(input) {
|
|
152
|
+
assertRelativeToolPath(input, "Output path");
|
|
153
|
+
const root = await workspaceRoot();
|
|
154
|
+
const lexicalPath = (0, node_path.resolve)(root, input);
|
|
155
|
+
if (!isWithin(root, lexicalPath)) throw new Error("Output path escapes the workspace.");
|
|
156
|
+
const parent = await (0, node_fs_promises.realpath)((0, node_path.dirname)(lexicalPath));
|
|
157
|
+
if (!isWithin(root, parent)) throw new Error("Output path resolves outside the workspace.");
|
|
158
|
+
const absolutePath = (0, node_path.join)(parent, (0, node_path.basename)(lexicalPath));
|
|
159
|
+
try {
|
|
160
|
+
await (0, node_fs_promises.lstat)(absolutePath);
|
|
161
|
+
throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
|
|
162
|
+
} catch (error) {
|
|
163
|
+
if (error.code !== "ENOENT") throw error;
|
|
164
|
+
}
|
|
165
|
+
return absolutePath;
|
|
166
|
+
}
|
|
167
|
+
async function writeWorkspaceFileExclusive(input, data) {
|
|
168
|
+
const absolutePath = await resolveWorkspaceOutputPath(input);
|
|
169
|
+
let handle;
|
|
170
|
+
try {
|
|
171
|
+
handle = await (0, node_fs_promises.open)(absolutePath, "wx", 384);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (error.code === "EEXIST") throw new Error(`Refusing to overwrite existing workspace file: ${input}`);
|
|
174
|
+
throw error;
|
|
175
|
+
}
|
|
176
|
+
let completed = false;
|
|
177
|
+
try {
|
|
178
|
+
await handle.writeFile(data);
|
|
179
|
+
await handle.sync();
|
|
180
|
+
completed = true;
|
|
181
|
+
return absolutePath;
|
|
182
|
+
} finally {
|
|
183
|
+
await handle.close();
|
|
184
|
+
if (!completed) await (0, node_fs_promises.unlink)(absolutePath).catch(() => void 0);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
67
188
|
//#region src/plugin.ts
|
|
68
189
|
/**
|
|
69
190
|
* @alfe/voice-plugin — OpenClaw native plugin
|
|
@@ -78,10 +199,14 @@ function parseWav(buf) {
|
|
|
78
199
|
* This plugin provides:
|
|
79
200
|
* - voice_tts tool — text → audio file via POST /voice/tts (agent-authed)
|
|
80
201
|
* - voice_stt tool — audio file → transcript via POST /voice/stt (agent-authed)
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
202
|
+
*
|
|
203
|
+
* The plugin is intentionally tool-only. It has no daemon connection,
|
|
204
|
+
* gateway RPC, message hook, or plugin-config credential path.
|
|
205
|
+
*
|
|
206
|
+
* voice_hangup / voice_transfer / voice_dtmf were removed deliberately — they
|
|
207
|
+
* threw unconditionally ("requires a channel service"), which poisons the
|
|
208
|
+
* model's tool selection with always-failing options. Re-add per-channel when
|
|
209
|
+
* the twilio-adapter/Discord dispatch actually lands (voice-engineer roadmap).
|
|
85
210
|
*
|
|
86
211
|
* The voice_tts/voice_stt tools source URL + agent key from ~/.alfe/config.toml
|
|
87
212
|
* via `@alfe.ai/config` (resolveConfig) and call through `AgentApiClient` — the
|
|
@@ -89,90 +214,10 @@ function parseWav(buf) {
|
|
|
89
214
|
* or hit localhost.
|
|
90
215
|
*/
|
|
91
216
|
const pkg = (0, node_module.createRequire)(require("url").pathToFileURL(__filename).href)("../package.json");
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
"voice.hangup"
|
|
97
|
-
];
|
|
98
|
-
let voiceServiceUrl = "";
|
|
99
|
-
let voiceServiceApiKey = "";
|
|
100
|
-
async function voiceApi(method, path, body) {
|
|
101
|
-
const url = `${voiceServiceUrl}${path}`;
|
|
102
|
-
const headers = { "Content-Type": "application/json" };
|
|
103
|
-
if (voiceServiceApiKey) headers["x-api-key"] = voiceServiceApiKey;
|
|
104
|
-
const res = await fetch(url, {
|
|
105
|
-
method,
|
|
106
|
-
headers,
|
|
107
|
-
body: body ? JSON.stringify(body) : void 0
|
|
108
|
-
});
|
|
109
|
-
const json = await res.json();
|
|
110
|
-
if (!res.ok) {
|
|
111
|
-
const errorMsg = typeof json.error === "string" ? json.error : `Voice service returned ${String(res.status)}`;
|
|
112
|
-
throw new Error(errorMsg);
|
|
113
|
-
}
|
|
114
|
-
return json;
|
|
115
|
-
}
|
|
116
|
-
let daemonIpcClient = null;
|
|
117
|
-
async function connectToDaemon(socketPath, log) {
|
|
118
|
-
try {
|
|
119
|
-
const IPCClientCtor = (await import("@alfe.ai/openclaw")).IPCClient;
|
|
120
|
-
const client = new IPCClientCtor(socketPath, log);
|
|
121
|
-
client.on("connected", async () => {
|
|
122
|
-
log.info("Connected to Alfe daemon — registering voice capabilities...");
|
|
123
|
-
const response = await client.request("capability.register", {
|
|
124
|
-
plugin: "@alfe.ai/openclaw-voice",
|
|
125
|
-
capabilities: [...VOICE_CAPABILITIES]
|
|
126
|
-
});
|
|
127
|
-
if (response.ok) log.info("Voice capabilities registered with daemon");
|
|
128
|
-
else log.warn(`Failed to register voice capabilities: ${response.error?.message ?? "unknown"}`);
|
|
129
|
-
});
|
|
130
|
-
client.on("disconnected", (reason) => {
|
|
131
|
-
log.warn(`Disconnected from Alfe daemon: ${String(reason)}`);
|
|
132
|
-
});
|
|
133
|
-
client.on("error", (err) => {
|
|
134
|
-
log.debug(`Daemon IPC error: ${err.message}`);
|
|
135
|
-
});
|
|
136
|
-
client.start();
|
|
137
|
-
return client;
|
|
138
|
-
} catch {
|
|
139
|
-
log.info("Alfe daemon not available — voice plugin running standalone");
|
|
140
|
-
return null;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
function ok(data) {
|
|
144
|
-
return {
|
|
145
|
-
content: [{
|
|
146
|
-
type: "text",
|
|
147
|
-
text: JSON.stringify(data)
|
|
148
|
-
}],
|
|
149
|
-
details: data
|
|
150
|
-
};
|
|
151
|
-
}
|
|
152
|
-
function errResult(message) {
|
|
153
|
-
return {
|
|
154
|
-
content: [{
|
|
155
|
-
type: "text",
|
|
156
|
-
text: JSON.stringify({ error: message })
|
|
157
|
-
}],
|
|
158
|
-
details: { error: message }
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
function defineTool(def) {
|
|
162
|
-
return {
|
|
163
|
-
name: def.name,
|
|
164
|
-
description: def.description,
|
|
165
|
-
label: def.name,
|
|
166
|
-
parameters: def.parameters,
|
|
167
|
-
execute: async (_toolCallId, params) => {
|
|
168
|
-
try {
|
|
169
|
-
return ok(await def.handler(params));
|
|
170
|
-
} catch (e) {
|
|
171
|
-
return errResult(e.message);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
};
|
|
175
|
-
}
|
|
217
|
+
const MAX_AUDIO_FILE_BYTES = 10 * 1024 * 1024;
|
|
218
|
+
const MAX_TTS_RESPONSE_BYTES = 32 * 1024 * 1024;
|
|
219
|
+
const VOICE_ID_PATTERN = "^[A-Za-z0-9_-]{1,200}$";
|
|
220
|
+
const VOICE_ID_REGEX = /^[A-Za-z0-9_-]{1,200}$/u;
|
|
176
221
|
let voiceClient = null;
|
|
177
222
|
function getVoiceClient() {
|
|
178
223
|
if (voiceClient) return voiceClient;
|
|
@@ -183,106 +228,99 @@ function getVoiceClient() {
|
|
|
183
228
|
});
|
|
184
229
|
return voiceClient;
|
|
185
230
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
parameters: _sinclair_typebox.Type.Object({
|
|
195
|
-
text: _sinclair_typebox.Type.String({ description: "Text to synthesize (1–5000 characters)." }),
|
|
196
|
-
outputPath: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Where to write the audio file (absolute, or relative to the working directory). Defaults to alfe-tts-<timestamp>.wav in the working directory." })),
|
|
197
|
-
format: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("wav"), _sinclair_typebox.Type.Literal("pcm")], { description: "Output container. 'wav' (default) is a playable file; 'pcm' is headerless 24kHz/mono/16-bit raw PCM." })),
|
|
198
|
-
voiceId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "ElevenLabs voice ID. Platform default when omitted." })),
|
|
199
|
-
model: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("eleven_turbo_v2_5"), _sinclair_typebox.Type.Literal("eleven_multilingual_v2")], { description: "TTS model. eleven_turbo_v2_5 (lower latency) when omitted." }))
|
|
200
|
-
}),
|
|
201
|
-
handler: async (params) => {
|
|
202
|
-
const text = params.text;
|
|
203
|
-
const format = params.format ?? "wav";
|
|
204
|
-
const result = await getVoiceClient().tts({
|
|
205
|
-
text,
|
|
206
|
-
voiceId: params.voiceId,
|
|
207
|
-
model: params.model
|
|
208
|
-
});
|
|
209
|
-
const bytes = format === "wav" ? pcmToWav(result.audio, {
|
|
210
|
-
sampleRate: result.sampleRate,
|
|
211
|
-
channels: result.channels,
|
|
212
|
-
bitDepth: result.bitDepth
|
|
213
|
-
}) : result.audio;
|
|
214
|
-
const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
|
|
215
|
-
const absolutePath = resolveWorkspacePath(outputPath);
|
|
216
|
-
await (0, node_fs_promises.writeFile)(absolutePath, bytes);
|
|
217
|
-
return {
|
|
218
|
-
path: outputPath,
|
|
219
|
-
absolutePath,
|
|
220
|
-
format,
|
|
221
|
-
sampleRate: result.sampleRate,
|
|
222
|
-
channels: result.channels,
|
|
223
|
-
bitDepth: result.bitDepth,
|
|
224
|
-
bytes: bytes.length,
|
|
225
|
-
characters: text.length
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
}),
|
|
229
|
-
defineTool({
|
|
230
|
-
name: "voice_stt",
|
|
231
|
-
description: "Transcribe an audio file to text using the Alfe voice service (Deepgram). Accepts a WAV file or headerless mono 16-bit PCM. Billed per audio-second to your tenant credit pool.",
|
|
232
|
-
parameters: _sinclair_typebox.Type.Object({
|
|
233
|
-
path: _sinclair_typebox.Type.String({ description: "Path to the audio file (absolute, or relative to the working directory). WAV or raw linear16 mono PCM." }),
|
|
234
|
-
sampleRate: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Number({ description: "Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000." }))
|
|
231
|
+
const voiceTools = [(0, _alfe_ai_openclaw_plugin_kit.defineTool)({
|
|
232
|
+
name: "voice_tts",
|
|
233
|
+
description: "Convert text to speech using the Alfe voice service (ElevenLabs). Writes a playable audio file to the workspace and returns its path. Billed per character to your tenant credit pool.",
|
|
234
|
+
parameters: _sinclair_typebox.Type.Object({
|
|
235
|
+
text: _sinclair_typebox.Type.String({
|
|
236
|
+
minLength: 1,
|
|
237
|
+
maxLength: 5e3,
|
|
238
|
+
description: "Text to synthesize (1–5000 characters)."
|
|
235
239
|
}),
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
sampleRate = params.sampleRate ?? 24e3;
|
|
248
|
-
}
|
|
249
|
-
return getVoiceClient().stt({
|
|
250
|
-
audio,
|
|
251
|
-
sampleRate
|
|
252
|
-
});
|
|
253
|
-
}
|
|
254
|
-
}),
|
|
255
|
-
defineTool({
|
|
256
|
-
name: "voice_hangup",
|
|
257
|
-
description: "Hang up the current voice call or leave the voice channel. Use when the conversation is done or the user asks you to leave.",
|
|
258
|
-
parameters: _sinclair_typebox.Type.Object({ sessionId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({ description: "Voice session ID." })) }),
|
|
259
|
-
handler: () => {
|
|
260
|
-
throw new Error("voice_hangup requires a channel service (Discord/Twilio). The voice service no longer manages channels directly.");
|
|
261
|
-
}
|
|
240
|
+
outputPath: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({
|
|
241
|
+
minLength: 1,
|
|
242
|
+
maxLength: 1024,
|
|
243
|
+
description: "New workspace-relative audio file to create. Existing files are never overwritten. Defaults to alfe-tts-<timestamp>.wav."
|
|
244
|
+
})),
|
|
245
|
+
format: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("wav"), _sinclair_typebox.Type.Literal("pcm")], { description: "Output container. 'wav' (default) is a playable file; 'pcm' is headerless 24kHz/mono/16-bit raw PCM." })),
|
|
246
|
+
voiceId: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.String({
|
|
247
|
+
pattern: VOICE_ID_PATTERN,
|
|
248
|
+
description: "ElevenLabs voice ID. Platform default when omitted."
|
|
249
|
+
})),
|
|
250
|
+
model: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Union([_sinclair_typebox.Type.Literal("eleven_turbo_v2_5"), _sinclair_typebox.Type.Literal("eleven_multilingual_v2")], { description: "TTS model. eleven_turbo_v2_5 (lower latency) when omitted." }))
|
|
262
251
|
}),
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
252
|
+
handler: async (params) => {
|
|
253
|
+
const text = params.text.trim();
|
|
254
|
+
if (text.length < 1 || text.length > 5e3) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_tts text must contain 1–5000 characters.");
|
|
255
|
+
const voiceId = params.voiceId;
|
|
256
|
+
if (voiceId !== void 0 && !VOICE_ID_REGEX.test(voiceId)) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_tts voiceId contains unsupported characters.");
|
|
257
|
+
const format = params.format ?? "wav";
|
|
258
|
+
const outputPath = params.outputPath ?? `alfe-tts-${String(Date.now())}.${format}`;
|
|
259
|
+
await resolveWorkspaceOutputPath(outputPath);
|
|
260
|
+
const result = await getVoiceClient().tts({
|
|
261
|
+
text,
|
|
262
|
+
voiceId,
|
|
263
|
+
model: params.model
|
|
264
|
+
});
|
|
265
|
+
if (result.audio.length === 0 || result.audio.length > MAX_TTS_RESPONSE_BYTES) throw new Error(`voice_tts returned an invalid audio payload size (${String(result.audio.length)} bytes).`);
|
|
266
|
+
validatePcmFraming({
|
|
267
|
+
sampleRate: result.sampleRate,
|
|
268
|
+
channels: result.channels,
|
|
269
|
+
bitDepth: result.bitDepth
|
|
270
|
+
}, result.audio.length);
|
|
271
|
+
const bytes = format === "wav" ? pcmToWav(result.audio, {
|
|
272
|
+
sampleRate: result.sampleRate,
|
|
273
|
+
channels: result.channels,
|
|
274
|
+
bitDepth: result.bitDepth
|
|
275
|
+
}) : result.audio;
|
|
276
|
+
return {
|
|
277
|
+
path: outputPath,
|
|
278
|
+
absolutePath: await writeWorkspaceFileExclusive(outputPath, bytes),
|
|
279
|
+
format,
|
|
280
|
+
sampleRate: result.sampleRate,
|
|
281
|
+
channels: result.channels,
|
|
282
|
+
bitDepth: result.bitDepth,
|
|
283
|
+
bytes: bytes.length,
|
|
284
|
+
characters: text.length
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
}), (0, _alfe_ai_openclaw_plugin_kit.defineTool)({
|
|
288
|
+
name: "voice_stt",
|
|
289
|
+
description: "Transcribe an audio file to text using the Alfe voice service (Deepgram). Accepts a WAV file or headerless mono 16-bit PCM. Billed per audio-second to your tenant credit pool.",
|
|
290
|
+
parameters: _sinclair_typebox.Type.Object({
|
|
291
|
+
path: _sinclair_typebox.Type.String({
|
|
292
|
+
minLength: 1,
|
|
293
|
+
maxLength: 1024,
|
|
294
|
+
description: "Workspace-relative path to a WAV or raw linear16 mono PCM file (maximum 10 MiB)."
|
|
269
295
|
}),
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
296
|
+
sampleRate: _sinclair_typebox.Type.Optional(_sinclair_typebox.Type.Number({
|
|
297
|
+
minimum: 8e3,
|
|
298
|
+
maximum: 48e3,
|
|
299
|
+
multipleOf: 1,
|
|
300
|
+
description: "Sample rate in Hz (8000–48000). Only used for headerless PCM input; ignored for WAV (its header wins). Defaults to 24000."
|
|
301
|
+
}))
|
|
273
302
|
}),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
303
|
+
handler: async (params) => {
|
|
304
|
+
const { data: raw } = await readWorkspaceFile(params.path, MAX_AUDIO_FILE_BYTES);
|
|
305
|
+
const wav = parseWav(raw);
|
|
306
|
+
let audio;
|
|
307
|
+
let sampleRate;
|
|
308
|
+
if (wav) {
|
|
309
|
+
if (wav.bitDepth !== 16 || wav.channels !== 1) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)(`voice_stt needs 16-bit mono audio; got ${String(wav.bitDepth)}-bit / ${String(wav.channels)}-channel WAV. Re-export as 16-bit mono.`);
|
|
310
|
+
audio = wav.pcm;
|
|
311
|
+
sampleRate = wav.sampleRate;
|
|
312
|
+
} else {
|
|
313
|
+
audio = raw;
|
|
314
|
+
sampleRate = params.sampleRate ?? 24e3;
|
|
283
315
|
}
|
|
284
|
-
|
|
285
|
-
|
|
316
|
+
if (!Number.isInteger(sampleRate) || sampleRate < 8e3 || sampleRate > 48e3) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_stt sampleRate must be an integer between 8000 and 48000 Hz.");
|
|
317
|
+
if (audio.length === 0 || audio.length % 2 !== 0) throw (0, _alfe_ai_openclaw_plugin_kit.publicToolError)("voice_stt needs non-empty 16-bit PCM with an even byte length.");
|
|
318
|
+
return getVoiceClient().stt({
|
|
319
|
+
audio,
|
|
320
|
+
sampleRate
|
|
321
|
+
});
|
|
322
|
+
}
|
|
323
|
+
})];
|
|
286
324
|
const plugin = {
|
|
287
325
|
id: "@alfe.ai/openclaw-voice",
|
|
288
326
|
name: "Alfe Voice Plugin",
|
|
@@ -293,76 +331,12 @@ const plugin = {
|
|
|
293
331
|
const log = api.logger;
|
|
294
332
|
for (const tool of voiceTools) api.registerTool(tool);
|
|
295
333
|
log.info(`Registered ${String(voiceTools.length)} voice tools: ${voiceTools.map((t) => t.name).join(", ")}`);
|
|
296
|
-
const fullConfig = api.config ?? {};
|
|
297
|
-
const pluginConfig = fullConfig.plugins?.entries?.["@alfe.ai/openclaw-voice"]?.config ?? fullConfig.plugins?.entries?.["voice-gateway"]?.config ?? {};
|
|
298
|
-
const startVoiceService = () => {
|
|
299
|
-
if (globalThis.__voiceGatewayActivated === true) {
|
|
300
|
-
log.debug("Alfe Voice plugin already activated — skipping duplicate");
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
globalThis.__voiceGatewayActivated = true;
|
|
304
|
-
log.info("Alfe Voice plugin activating...");
|
|
305
|
-
voiceServiceUrl = pluginConfig.voiceServiceUrl ?? `http://localhost:${String(pluginConfig.voiceServicePort ?? "3100")}`;
|
|
306
|
-
voiceServiceApiKey = pluginConfig.voiceServiceApiKey ?? "";
|
|
307
|
-
log.info(`Voice service: ${voiceServiceUrl}`);
|
|
308
|
-
let alfeConfig = null;
|
|
309
|
-
try {
|
|
310
|
-
alfeConfig = (0, _alfe_ai_config.resolveConfig)();
|
|
311
|
-
} catch {}
|
|
312
|
-
connectToDaemon(pluginConfig.daemonSocket ?? alfeConfig?.socketPath ?? _alfe_ai_config.DEFAULT_SOCKET_PATH, log).then((client) => {
|
|
313
|
-
daemonIpcClient = client;
|
|
314
|
-
}).catch((err) => {
|
|
315
|
-
log.debug(`Daemon connect failed: ${err.message}`);
|
|
316
|
-
});
|
|
317
|
-
};
|
|
318
|
-
const stopVoiceService = () => {
|
|
319
|
-
globalThis.__voiceGatewayActivated = false;
|
|
320
|
-
if (daemonIpcClient) {
|
|
321
|
-
try {
|
|
322
|
-
daemonIpcClient.stop();
|
|
323
|
-
log.info("Disconnected from Alfe daemon");
|
|
324
|
-
} catch (err) {
|
|
325
|
-
log.debug(`Error disconnecting from daemon: ${err.message}`);
|
|
326
|
-
}
|
|
327
|
-
daemonIpcClient = null;
|
|
328
|
-
}
|
|
329
|
-
log.info("Alfe Voice plugin deactivated");
|
|
330
|
-
};
|
|
331
|
-
api.registerGatewayMethod("voice.speak", async (...args) => {
|
|
332
|
-
const { text } = args[0];
|
|
333
|
-
log.info(`voice.speak RPC → text=${text?.slice(0, 50) ?? "(none)"}...`);
|
|
334
|
-
if (!text) throw new Error("voice.speak requires text");
|
|
335
|
-
return await voiceApi("POST", "/voice/tts", { text });
|
|
336
|
-
});
|
|
337
|
-
log.info("Registered gateway RPC method: voice.speak");
|
|
338
|
-
api.on("message_received", (...eventArgs) => {
|
|
339
|
-
const event = eventArgs[0];
|
|
340
|
-
if (eventArgs[1].channelId.includes("voice")) log.debug(`Voice-related message from ${event.from}: ${event.content.slice(0, 100)}`);
|
|
341
|
-
});
|
|
342
|
-
api.registerService({
|
|
343
|
-
id: "alfe-voice-daemon",
|
|
344
|
-
start: () => {
|
|
345
|
-
startVoiceService();
|
|
346
|
-
},
|
|
347
|
-
stop: () => {
|
|
348
|
-
stopVoiceService();
|
|
349
|
-
}
|
|
350
|
-
});
|
|
351
334
|
log.info("Alfe Voice plugin activated");
|
|
352
335
|
},
|
|
353
336
|
deactivate(api) {
|
|
354
|
-
globalThis.__voiceGatewayActivated = false;
|
|
355
337
|
const log = api.logger;
|
|
356
338
|
log.info("Alfe Voice plugin deactivating...");
|
|
357
|
-
|
|
358
|
-
try {
|
|
359
|
-
daemonIpcClient.stop();
|
|
360
|
-
log.info("Disconnected from Alfe daemon");
|
|
361
|
-
} catch (err) {
|
|
362
|
-
log.debug(`Error disconnecting from daemon: ${err.message}`);
|
|
363
|
-
}
|
|
364
|
-
daemonIpcClient = null;
|
|
365
|
-
}
|
|
339
|
+
voiceClient = null;
|
|
366
340
|
log.info("Alfe Voice plugin deactivated");
|
|
367
341
|
}
|
|
368
342
|
};
|