@inetafrica/open-claudia 3.1.10 → 3.1.12
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/core/actions.js +21 -0
- package/core/handlers.js +43 -0
- package/core/media.js +50 -3
- package/core/router.js +33 -1
- package/core/tts-voice.js +98 -0
- package/package.json +1 -1
- package/test-provider-language.js +10 -1
package/core/actions.js
CHANGED
|
@@ -416,6 +416,27 @@ async function handleAction(envelope) {
|
|
|
416
416
|
await send(`Tooling mode: ${m}${m === "relaxed" ? " ⚠️ deny-gate is log-only; ad-hoc scripting allowed" : " — tool-first enforced"}`);
|
|
417
417
|
return;
|
|
418
418
|
}
|
|
419
|
+
if (d.startsWith("vc:")) {
|
|
420
|
+
if (!isChatOwner(envelope.channelId)) return send("Owner only — the voice is shared across this bot.");
|
|
421
|
+
const ttsVoice = require("./tts-voice");
|
|
422
|
+
const rest = d.slice(3);
|
|
423
|
+
if (rest === "clone") {
|
|
424
|
+
state.pendingVoiceClone = { at: Date.now() };
|
|
425
|
+
return send("🎙️ Send me a voice note now — 5–15s of clear speech and I'll clone it as your voice. Send /voice to cancel.");
|
|
426
|
+
}
|
|
427
|
+
state.pendingVoiceClone = null;
|
|
428
|
+
if (rest === "default") {
|
|
429
|
+
ttsVoice.clearVoice();
|
|
430
|
+
return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
|
|
431
|
+
}
|
|
432
|
+
if (rest.startsWith("p:")) {
|
|
433
|
+
const preset = ttsVoice.presetByKey(rest.slice(2));
|
|
434
|
+
if (!preset) return send("That voice button is stale. Open /voice again.");
|
|
435
|
+
ttsVoice.setVoice(preset.id, preset.label);
|
|
436
|
+
return send(`🎙️ Voice set: ${preset.label}`);
|
|
437
|
+
}
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
419
440
|
if (d.startsWith("apr:")) {
|
|
420
441
|
// Approval buttons. Owner-only: the exact payload was shown in the prompt;
|
|
421
442
|
// decide() is idempotent so double-taps can't flip it. Two shapes share
|
package/core/handlers.js
CHANGED
|
@@ -1322,6 +1322,49 @@ register({
|
|
|
1322
1322
|
},
|
|
1323
1323
|
});
|
|
1324
1324
|
|
|
1325
|
+
register({
|
|
1326
|
+
name: "voice", description: "Pick the Pocket TTS voice, or clone your own", args: "[<preset>|<hf://…>|clone|default]", ownerOnly: true,
|
|
1327
|
+
handler: async (env, { tail }) => {
|
|
1328
|
+
if (!authorized(env)) return;
|
|
1329
|
+
const ttsVoice = require("./tts-voice");
|
|
1330
|
+
const state = currentState();
|
|
1331
|
+
const arg = (tail || "").trim();
|
|
1332
|
+
if (arg) {
|
|
1333
|
+
const low = arg.toLowerCase();
|
|
1334
|
+
if (low === "clone") {
|
|
1335
|
+
state.pendingVoiceClone = { at: Date.now() };
|
|
1336
|
+
return send("🎙️ Send me a voice note now — 5–15s of clear speech and I'll clone it as your voice. Send /voice to cancel.");
|
|
1337
|
+
}
|
|
1338
|
+
state.pendingVoiceClone = null;
|
|
1339
|
+
if (low === "default" || low === "reset") {
|
|
1340
|
+
ttsVoice.clearVoice();
|
|
1341
|
+
return send(`🎙️ Voice reset to default (${ttsVoice.getVoiceLabel()}).`);
|
|
1342
|
+
}
|
|
1343
|
+
const preset = ttsVoice.presetByKey(low);
|
|
1344
|
+
if (preset) {
|
|
1345
|
+
ttsVoice.setVoice(preset.id, preset.label);
|
|
1346
|
+
return send(`🎙️ Voice set: ${preset.label}`);
|
|
1347
|
+
}
|
|
1348
|
+
// Anything else is treated as a raw voice reference pocket-tts understands:
|
|
1349
|
+
// a built-in shorthand, an hf:// path, an http(s) URL, or a stored id.
|
|
1350
|
+
ttsVoice.setVoice(arg, null);
|
|
1351
|
+
return send(`🎙️ Voice set: ${arg}`);
|
|
1352
|
+
}
|
|
1353
|
+
state.pendingVoiceClone = null;
|
|
1354
|
+
const rows = ttsVoice.presets().map((p) => [{ text: p.label, callback_data: `vc:p:${p.key}` }]);
|
|
1355
|
+
rows.push([
|
|
1356
|
+
{ text: "🎙️ Clone from a clip", callback_data: "vc:clone" },
|
|
1357
|
+
{ text: "↩︎ Default", callback_data: "vc:default" },
|
|
1358
|
+
]);
|
|
1359
|
+
send(
|
|
1360
|
+
`🎙️ Voice: ${ttsVoice.getVoiceLabel()}\n\n` +
|
|
1361
|
+
"Pick a voice — it takes effect immediately, no restart. Or tap Clone from a clip (or send /voice clone) to use your own recorded voice.\n\n" +
|
|
1362
|
+
"For any other voice: /voice hf://kyutai/tts-voices/<folder>/<clip>.wav",
|
|
1363
|
+
{ keyboard: { inline_keyboard: rows } },
|
|
1364
|
+
);
|
|
1365
|
+
},
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1325
1368
|
register({
|
|
1326
1369
|
name: "budget", description: "Set max spend for next task", args: "[$N]",
|
|
1327
1370
|
handler: async (env, { tail }) => {
|
package/core/media.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
const fs = require("fs");
|
|
5
5
|
const path = require("path");
|
|
6
6
|
const { execSync } = require("child_process");
|
|
7
|
-
const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL, SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN,
|
|
7
|
+
const { WHISPER_CLI, WHISPER_MODEL, FFMPEG, TEMP_DIR, ELEVENLABS_API_KEY, ELEVENLABS_VOICE_ID, ELEVENLABS_MODEL, SPEECH_SERVICE_URL, SPEECH_API_KEY, AGENTSPACE_POD_TOKEN, TTS_PROVIDER } = require("./config");
|
|
8
|
+
const ttsVoice = require("./tts-voice");
|
|
8
9
|
|
|
9
10
|
const TTS_CMD = process.platform === "darwin" ? "say" : null;
|
|
10
11
|
|
|
@@ -108,7 +109,7 @@ async function pocketToVoice(clean) {
|
|
|
108
109
|
const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/audio/speech`, {
|
|
109
110
|
method: "POST",
|
|
110
111
|
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
|
|
111
|
-
body: JSON.stringify({ input: clean, voice:
|
|
112
|
+
body: JSON.stringify({ input: clean, voice: ttsVoice.getVoice(), response_format: "wav" }),
|
|
112
113
|
signal: AbortSignal.timeout(60000),
|
|
113
114
|
});
|
|
114
115
|
if (!res.ok) {
|
|
@@ -129,6 +130,52 @@ async function pocketToVoice(clean) {
|
|
|
129
130
|
}
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
// Upload a reference clip to the speech gateway for voice cloning. The gateway
|
|
134
|
+
// stores the clip on the TTS pod's model-cache volume and returns a voice id
|
|
135
|
+
// usable as the `voice` field on later /v1/audio/speech calls; pocket-tts clones
|
|
136
|
+
// from it. Returns the id string, or null on any failure.
|
|
137
|
+
async function cloneVoiceFromClip(clipPath) {
|
|
138
|
+
const token = speechBearerToken();
|
|
139
|
+
if (!SPEECH_SERVICE_URL || !token || !clipPath) return null;
|
|
140
|
+
// Telegram voice notes arrive as ogg/opus, which pocket-tts can't decode as a
|
|
141
|
+
// reference prompt — transcode to 24kHz mono wav (the model's native rate)
|
|
142
|
+
// first. Fall back to the raw clip only if ffmpeg is unavailable.
|
|
143
|
+
let wavPath = null;
|
|
144
|
+
let uploadPath = clipPath;
|
|
145
|
+
if (FFMPEG) {
|
|
146
|
+
try {
|
|
147
|
+
wavPath = path.join(TEMP_DIR, `voice-clone-${Date.now()}-${Math.random().toString(36).slice(2, 6)}.wav`);
|
|
148
|
+
execSync(`"${FFMPEG}" -i "${clipPath}" -ar 24000 -ac 1 -y "${wavPath}" 2>/dev/null`, { timeout: 30000 });
|
|
149
|
+
uploadPath = wavPath;
|
|
150
|
+
} catch (e) {
|
|
151
|
+
console.error("Voice clone transcode error:", e.message);
|
|
152
|
+
wavPath = null;
|
|
153
|
+
uploadPath = clipPath;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
const body = new FormData();
|
|
158
|
+
body.append("file", new Blob([fs.readFileSync(uploadPath)]), path.basename(uploadPath));
|
|
159
|
+
const res = await fetch(`${SPEECH_SERVICE_URL.replace(/\/$/, "")}/v1/voices`, {
|
|
160
|
+
method: "POST",
|
|
161
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
162
|
+
body,
|
|
163
|
+
signal: AbortSignal.timeout(120000),
|
|
164
|
+
});
|
|
165
|
+
if (!res.ok) {
|
|
166
|
+
console.error(`Voice clone upload failed: HTTP ${res.status}`);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
const payload = await res.json().catch(() => ({}));
|
|
170
|
+
return String(payload.voice_id || payload.id || "").trim() || null;
|
|
171
|
+
} catch (e) {
|
|
172
|
+
console.error("Voice clone error:", e.message);
|
|
173
|
+
return null;
|
|
174
|
+
} finally {
|
|
175
|
+
if (wavPath) { try { fs.unlinkSync(wavPath); } catch (e) {} }
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
132
179
|
// True when at least one textToVoice route can produce audio. Mirrors the
|
|
133
180
|
// pocket -> ElevenLabs -> say fallback chain; local Whisper is deliberately
|
|
134
181
|
// irrelevant here (hosted pods speak via the gateway without it).
|
|
@@ -203,4 +250,4 @@ async function synthSentenceMp3(text) {
|
|
|
203
250
|
}
|
|
204
251
|
}
|
|
205
252
|
|
|
206
|
-
module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, speechBearerToken, canSpeak, TTS_CMD };
|
|
253
|
+
module.exports = { transcribeAudio, transcribeAudioLocal, textToVoice, splitSentences, synthSentenceMp3, pocketToVoice, cloneVoiceFromClip, speechBearerToken, canSpeak, TTS_CMD };
|
package/core/router.js
CHANGED
|
@@ -13,7 +13,7 @@ const introFlow = require("./intro-flow");
|
|
|
13
13
|
const { send, deleteMessage } = require("./io");
|
|
14
14
|
const { currentState, saveState, autoLinkOwnerChannel } = require("./state");
|
|
15
15
|
const { admitRunContext, runAgent } = require("./runner");
|
|
16
|
-
const { transcribeAudio } = require("./media");
|
|
16
|
+
const { transcribeAudio, cloneVoiceFromClip } = require("./media");
|
|
17
17
|
const { vault } = require("./vault-store");
|
|
18
18
|
const { isOnboarded, handleOnboarding } = require("./onboarding");
|
|
19
19
|
const {
|
|
@@ -185,6 +185,13 @@ async function handleVoice(envelope) {
|
|
|
185
185
|
if (media.size && media.size > MAX_VOICE_SIZE) {
|
|
186
186
|
return send(`Voice note too large (${Math.round(media.size / 1024 / 1024)}MB). Max: ${MAX_VOICE_SIZE / 1024 / 1024}MB`);
|
|
187
187
|
}
|
|
188
|
+
// /voice clone armed this channel: the next clip is a cloning sample, not a
|
|
189
|
+
// prompt. Consume the flag (fresh only) before the normal transcribe path.
|
|
190
|
+
if (state.pendingVoiceClone) {
|
|
191
|
+
const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
|
|
192
|
+
state.pendingVoiceClone = null;
|
|
193
|
+
if (fresh) return captureVoiceClone(envelope, media);
|
|
194
|
+
}
|
|
188
195
|
const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
|
|
189
196
|
purpose: "foreground",
|
|
190
197
|
attachments: [{ type: "voice", mimeType: media.mimeType || "audio/ogg", fileName: media.fileName || null }],
|
|
@@ -216,6 +223,11 @@ async function handleAudio(envelope) {
|
|
|
216
223
|
try {
|
|
217
224
|
const media = envelope.media?.[0];
|
|
218
225
|
if (!media) return;
|
|
226
|
+
if (state.pendingVoiceClone) {
|
|
227
|
+
const fresh = Date.now() - state.pendingVoiceClone.at < 5 * 60 * 1000;
|
|
228
|
+
state.pendingVoiceClone = null;
|
|
229
|
+
if (fresh) return captureVoiceClone(envelope, media);
|
|
230
|
+
}
|
|
219
231
|
const runContext = admitRunContext("", state.currentSession.dir, envelope.messageId, {
|
|
220
232
|
purpose: "foreground",
|
|
221
233
|
attachments: [{ type: "audio", mimeType: media.mimeType || null, fileName: media.fileName || null }],
|
|
@@ -233,6 +245,26 @@ async function handleAudio(envelope) {
|
|
|
233
245
|
} catch (err) { await send(`Audio failed: ${err.message}`); }
|
|
234
246
|
}
|
|
235
247
|
|
|
248
|
+
// A clip the owner sent right after /voice clone. Upload it to the speech
|
|
249
|
+
// gateway (which stores it on the TTS pod and returns a voice id), then set that
|
|
250
|
+
// id as the active voice. Never transcribed or sent to the agent.
|
|
251
|
+
async function captureVoiceClone(envelope, media) {
|
|
252
|
+
try {
|
|
253
|
+
envelope.adapter.typing(envelope.channelId).catch(() => {});
|
|
254
|
+
await send("🎙️ Got it — cloning your voice…");
|
|
255
|
+
const clipPath = await envelope.adapter.downloadMedia(media);
|
|
256
|
+
const voiceId = await cloneVoiceFromClip(clipPath);
|
|
257
|
+
try { fs.unlinkSync(clipPath); } catch (e) {}
|
|
258
|
+
if (!voiceId) {
|
|
259
|
+
return send("Couldn't process that clip for cloning. Send a clear 5–15s recording and try /voice clone again.");
|
|
260
|
+
}
|
|
261
|
+
require("./tts-voice").setVoice(voiceId, "your cloned voice");
|
|
262
|
+
return send("✅ Cloned. I'll speak in that voice from now on — change it anytime with /voice.");
|
|
263
|
+
} catch (err) {
|
|
264
|
+
await send(`Voice clone failed: ${err.message}`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
236
268
|
async function handlePhoto(envelope) {
|
|
237
269
|
if (isDuplicate(envelope)) return;
|
|
238
270
|
const state = currentState();
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// Live-read, persisted Pocket TTS voice selection.
|
|
4
|
+
//
|
|
5
|
+
// The active voice overrides the POCKET_TTS_VOICE env default and is read at
|
|
6
|
+
// synth time (media.js), so /voice takes effect immediately with no restart.
|
|
7
|
+
// The choice survives restarts via a small JSON file in the bot config dir;
|
|
8
|
+
// env stays the deploy-time default/floor and is used only when no override is
|
|
9
|
+
// set. A cloned-clip voice is just another voice string (a server-issued id).
|
|
10
|
+
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
const { CONFIG_DIR, POCKET_TTS_VOICE } = require("./config");
|
|
14
|
+
const { atomicWriteFileSync } = require("./fsutil");
|
|
15
|
+
|
|
16
|
+
const STORE = path.join(CONFIG_DIR, "tts-voice.json");
|
|
17
|
+
|
|
18
|
+
// Curated shortlist for the /voice picker. `id` is exactly what pocket-tts's
|
|
19
|
+
// get_state_for_audio_prompt resolves: the built-in "alba" shorthand or an
|
|
20
|
+
// hf:// reference into the kyutai/tts-voices repo. Every path here was verified
|
|
21
|
+
// against that repo's file tree, so the buttons always resolve.
|
|
22
|
+
const PRESETS = [
|
|
23
|
+
{ key: "alba", label: "Alba (default)", id: "alba" },
|
|
24
|
+
{ key: "announcer", label: "Alba · announcer", id: "hf://kyutai/tts-voices/alba-mackenna/announcer.wav" },
|
|
25
|
+
{ key: "merchant", label: "Alba · merchant", id: "hf://kyutai/tts-voices/alba-mackenna/merchant.wav" },
|
|
26
|
+
{ key: "expresso", label: "Expresso", id: "hf://kyutai/tts-voices/expresso/ex01-ex02_default_001_channel1_168s.wav" },
|
|
27
|
+
{ key: "enunciated", label: "Expresso · clear", id: "hf://kyutai/tts-voices/expresso/ex01-ex02_enunciated_001_channel1_432s.wav" },
|
|
28
|
+
{ key: "vctk-p225", label: "VCTK p225", id: "hf://kyutai/tts-voices/vctk/p225_023.wav" },
|
|
29
|
+
{ key: "vctk-p226", label: "VCTK p226", id: "hf://kyutai/tts-voices/vctk/p226_023.wav" },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// undefined = not loaded yet; null = loaded, no override (use env default);
|
|
33
|
+
// { voice, label } = an active override.
|
|
34
|
+
let cache;
|
|
35
|
+
|
|
36
|
+
function defaultVoice() {
|
|
37
|
+
return (POCKET_TTS_VOICE && String(POCKET_TTS_VOICE).trim()) || "alba";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function load() {
|
|
41
|
+
if (cache !== undefined) return cache;
|
|
42
|
+
try {
|
|
43
|
+
const raw = JSON.parse(fs.readFileSync(STORE, "utf-8"));
|
|
44
|
+
if (raw && typeof raw.voice === "string" && raw.voice.trim()) {
|
|
45
|
+
cache = { voice: raw.voice.trim(), label: typeof raw.label === "string" && raw.label ? raw.label : null };
|
|
46
|
+
return cache;
|
|
47
|
+
}
|
|
48
|
+
} catch (e) { /* no store yet, or malformed — fall back to default */ }
|
|
49
|
+
cache = null;
|
|
50
|
+
return cache;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// The active voice string handed to the speech gateway.
|
|
54
|
+
function getVoice() {
|
|
55
|
+
const s = load();
|
|
56
|
+
return (s && s.voice) || defaultVoice();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Human-friendly label for the active voice (preset label, custom note, or id).
|
|
60
|
+
function getVoiceLabel() {
|
|
61
|
+
const s = load();
|
|
62
|
+
if (s) return s.label || s.voice;
|
|
63
|
+
const dv = defaultVoice();
|
|
64
|
+
const p = PRESETS.find((x) => x.id === dv || x.key === dv);
|
|
65
|
+
return p ? p.label : dv;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// True when the active voice is an explicit override (not the env default).
|
|
69
|
+
function isOverridden() {
|
|
70
|
+
return !!load();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setVoice(voice, label = null) {
|
|
74
|
+
const v = String(voice || "").trim();
|
|
75
|
+
if (!v) throw new Error("voice is required");
|
|
76
|
+
cache = { voice: v, label: label ? String(label).trim() : null };
|
|
77
|
+
atomicWriteFileSync(STORE, JSON.stringify(cache, null, 2), { backup: false, mode: 0o600 });
|
|
78
|
+
return cache;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Drop the override and fall back to the env/config default.
|
|
82
|
+
function clearVoice() {
|
|
83
|
+
cache = null;
|
|
84
|
+
try { fs.unlinkSync(STORE); } catch (e) { /* already absent */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function presets() { return PRESETS.slice(); }
|
|
88
|
+
function presetByKey(key) { return PRESETS.find((p) => p.key === String(key || "").toLowerCase()) || null; }
|
|
89
|
+
|
|
90
|
+
// Test seam: forget the in-memory cache so the next read re-consults disk.
|
|
91
|
+
function _resetCache() { cache = undefined; }
|
|
92
|
+
|
|
93
|
+
module.exports = {
|
|
94
|
+
getVoice, getVoiceLabel, isOverridden,
|
|
95
|
+
setVoice, clearVoice,
|
|
96
|
+
presets, presetByKey, defaultVoice,
|
|
97
|
+
STORE, _resetCache,
|
|
98
|
+
};
|
package/package.json
CHANGED
|
@@ -195,7 +195,16 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
195
195
|
// bot process advertised voice as enabled. Synthesis moves into the credentialed
|
|
196
196
|
// bot process behind a loopback "say" kind; the CLI now sends only text; the R8
|
|
197
197
|
// external-contact egress guard is preserved. Plus this version-pin bump.
|
|
198
|
-
|
|
198
|
+
// 3.1.11 was cut for the /voice picker + voice cloning feature but its build
|
|
199
|
+
// failed on this very version pin (still 3.1.10): the package.json bump landed
|
|
200
|
+
// without the matching pin update, so the docker stage was skipped and no image
|
|
201
|
+
// was published. Never retag a failed version — re-cut as +1.
|
|
202
|
+
// 3.1.12 approved by Sumeet 2026-07-21: re-cut of 3.1.11. Owner-gated /voice
|
|
203
|
+
// preset picker over the Pocket TTS catalog (live-read, persists across restart,
|
|
204
|
+
// Custom hf:// escape hatch) plus voice cloning — send a clip and it's transcoded
|
|
205
|
+
// (ogg->24kHz mono wav) and registered via the speech gateway /v1/voices as the
|
|
206
|
+
// active stored:<digest> voice. Plus this version-pin bump.
|
|
207
|
+
assert.strictEqual(pkg.version, "3.1.12", "release version must remain unchanged without explicit approval");
|
|
199
208
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
200
209
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
201
210
|
}
|