openpersona 0.2.0 → 0.4.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 +90 -20
- package/bin/cli.js +26 -14
- package/layers/faculties/music/SKILL.md +100 -90
- package/layers/faculties/music/faculty.json +4 -4
- package/layers/faculties/music/scripts/compose.js +298 -0
- package/layers/faculties/music/scripts/compose.sh +141 -74
- package/layers/faculties/selfie/faculty.json +1 -1
- package/layers/faculties/voice/SKILL.md +10 -8
- package/layers/faculties/voice/faculty.json +2 -2
- package/layers/soul/README.md +31 -4
- package/layers/soul/constitution.md +136 -0
- package/lib/contributor.js +22 -14
- package/lib/downloader.js +6 -1
- package/lib/generator.js +54 -12
- package/lib/installer.js +22 -12
- package/lib/publisher/clawhub.js +4 -3
- package/lib/switcher.js +174 -0
- package/lib/utils.js +19 -0
- package/package.json +7 -7
- package/presets/ai-girlfriend/manifest.json +2 -3
- package/presets/health-butler/manifest.json +1 -1
- package/presets/life-assistant/manifest.json +1 -1
- package/presets/samantha/manifest.json +9 -3
- package/presets/samantha/persona.json +2 -2
- package/skills/open-persona/SKILL.md +125 -0
- package/skills/open-persona/references/CONTRIBUTE.md +38 -0
- package/skills/open-persona/references/FACULTIES.md +26 -0
- package/skills/open-persona/references/HEARTBEAT.md +35 -0
- package/templates/identity.template.md +3 -2
- package/templates/skill.template.md +9 -1
- package/templates/soul-injection.template.md +33 -5
- package/layers/faculties/soul-evolution/SKILL.md +0 -41
- package/layers/faculties/soul-evolution/faculty.json +0 -9
- package/skill/SKILL.md +0 -170
- /package/layers/{faculties/soul-evolution → soul}/soul-state.template.json +0 -0
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* OpenPersona Music Faculty — ElevenLabs Music API (music_v1)
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* node compose.js "a soft piano piece about starlight"
|
|
7
|
+
* node compose.js "dreamy lo-fi beats" --instrumental
|
|
8
|
+
* node compose.js "indie folk ballad" --plan
|
|
9
|
+
* node compose.js "upbeat pop" --output ./song.mp3 --duration 60
|
|
10
|
+
*
|
|
11
|
+
* Environment:
|
|
12
|
+
* ELEVENLABS_API_KEY - ElevenLabs API key (shared with voice faculty)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const https = require('https');
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
|
|
19
|
+
const API_BASE = 'https://api.elevenlabs.io';
|
|
20
|
+
const DEFAULT_FORMAT = 'mp3_44100_128';
|
|
21
|
+
|
|
22
|
+
// --- Argument parsing ---
|
|
23
|
+
function parseArgs(args) {
|
|
24
|
+
const opts = {
|
|
25
|
+
prompt: '',
|
|
26
|
+
instrumental: false,
|
|
27
|
+
plan: false,
|
|
28
|
+
duration: null, // seconds; null = let model decide
|
|
29
|
+
format: DEFAULT_FORMAT,
|
|
30
|
+
output: null,
|
|
31
|
+
channel: '',
|
|
32
|
+
caption: '',
|
|
33
|
+
};
|
|
34
|
+
let i = 0;
|
|
35
|
+
while (i < args.length) {
|
|
36
|
+
switch (args[i]) {
|
|
37
|
+
case '--instrumental': opts.instrumental = true; break;
|
|
38
|
+
case '--plan': opts.plan = true; break;
|
|
39
|
+
case '--duration': opts.duration = parseInt(args[++i], 10); break;
|
|
40
|
+
case '--format': opts.format = args[++i]; break;
|
|
41
|
+
case '--output': opts.output = args[++i]; break;
|
|
42
|
+
case '--channel': opts.channel = args[++i]; break;
|
|
43
|
+
case '--caption': opts.caption = args[++i]; break;
|
|
44
|
+
case '--help':
|
|
45
|
+
case '-h':
|
|
46
|
+
printUsage();
|
|
47
|
+
process.exit(0);
|
|
48
|
+
default:
|
|
49
|
+
if (!opts.prompt) opts.prompt = args[i];
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
i++;
|
|
53
|
+
}
|
|
54
|
+
return opts;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printUsage() {
|
|
58
|
+
console.log(`
|
|
59
|
+
Usage: node compose.js <prompt> [options]
|
|
60
|
+
|
|
61
|
+
Options:
|
|
62
|
+
--instrumental Generate instrumental only (no vocals)
|
|
63
|
+
--plan Use composition plan mode (structured sections)
|
|
64
|
+
--duration <secs> Song length in seconds (3-600, default: auto)
|
|
65
|
+
--format <format> Output format (default: mp3_44100_128)
|
|
66
|
+
--output <path> Save audio to file
|
|
67
|
+
--channel <channel> Send to OpenClaw channel
|
|
68
|
+
--caption <text> Message caption for channel
|
|
69
|
+
|
|
70
|
+
Formats: mp3_44100_128, mp3_44100_192, mp3_44100_64, pcm_44100, opus_48000_128
|
|
71
|
+
|
|
72
|
+
Examples:
|
|
73
|
+
node compose.js "a soft ambient piano piece about starlight"
|
|
74
|
+
node compose.js "indie folk ballad" --plan --output ./song.mp3
|
|
75
|
+
node compose.js "dreamy lo-fi beats" --instrumental --duration 60
|
|
76
|
+
`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// --- HTTP helpers ---
|
|
80
|
+
function apiRequest(endpoint, options = {}) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const url = new URL(endpoint, API_BASE);
|
|
83
|
+
if (options.query) {
|
|
84
|
+
for (const [k, v] of Object.entries(options.query)) {
|
|
85
|
+
url.searchParams.set(k, v);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const reqOpts = {
|
|
90
|
+
method: options.method || 'POST',
|
|
91
|
+
headers: {
|
|
92
|
+
'xi-api-key': options.apiKey,
|
|
93
|
+
'Content-Type': 'application/json',
|
|
94
|
+
...options.headers,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const req = https.request(url, reqOpts, (res) => {
|
|
99
|
+
// For streaming audio, return the raw response
|
|
100
|
+
if (options.stream) {
|
|
101
|
+
resolve({ status: res.statusCode, headers: res.headers, stream: res });
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const chunks = [];
|
|
105
|
+
res.on('data', (c) => chunks.push(c));
|
|
106
|
+
res.on('end', () => {
|
|
107
|
+
const body = Buffer.concat(chunks).toString();
|
|
108
|
+
try {
|
|
109
|
+
resolve({ status: res.statusCode, data: JSON.parse(body) });
|
|
110
|
+
} catch {
|
|
111
|
+
resolve({ status: res.statusCode, data: body });
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
req.on('error', reject);
|
|
116
|
+
if (options.body) req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
|
|
117
|
+
req.end();
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function streamToFile(stream, filePath) {
|
|
122
|
+
return new Promise((resolve, reject) => {
|
|
123
|
+
const file = fs.createWriteStream(filePath);
|
|
124
|
+
stream.pipe(file);
|
|
125
|
+
file.on('finish', () => { file.close(); resolve(); });
|
|
126
|
+
file.on('error', (e) => { fs.unlink(filePath, () => {}); reject(e); });
|
|
127
|
+
stream.on('error', (e) => { fs.unlink(filePath, () => {}); reject(e); });
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function streamToBuffer(stream) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
const chunks = [];
|
|
134
|
+
stream.on('data', (c) => chunks.push(c));
|
|
135
|
+
stream.on('end', () => resolve(Buffer.concat(chunks)));
|
|
136
|
+
stream.on('error', reject);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// --- Main ---
|
|
141
|
+
async function main() {
|
|
142
|
+
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
143
|
+
if (!apiKey) {
|
|
144
|
+
console.error('Error: ELEVENLABS_API_KEY environment variable not set');
|
|
145
|
+
console.error('Get your API key from: https://elevenlabs.io');
|
|
146
|
+
console.error('(Same key used by the voice faculty)');
|
|
147
|
+
process.exit(1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
151
|
+
if (!opts.prompt) {
|
|
152
|
+
printUsage();
|
|
153
|
+
process.exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const typeLabel = opts.instrumental ? 'Instrumental' : 'Song';
|
|
157
|
+
const modeLabel = opts.plan ? 'Plan' : 'Simple';
|
|
158
|
+
console.log(`🎵 Mode: ${modeLabel} | Type: ${typeLabel}`);
|
|
159
|
+
console.log(` Prompt: ${opts.prompt.slice(0, 100)}${opts.prompt.length > 100 ? '...' : ''}`);
|
|
160
|
+
if (opts.duration) console.log(` Duration: ${opts.duration}s`);
|
|
161
|
+
|
|
162
|
+
let compositionPlan = null;
|
|
163
|
+
|
|
164
|
+
// Step 1 (optional): Generate composition plan
|
|
165
|
+
if (opts.plan) {
|
|
166
|
+
console.log('📝 Generating composition plan...');
|
|
167
|
+
const planPayload = {
|
|
168
|
+
prompt: opts.prompt,
|
|
169
|
+
model_id: 'music_v1',
|
|
170
|
+
};
|
|
171
|
+
if (opts.duration) {
|
|
172
|
+
planPayload.music_length_ms = opts.duration * 1000;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const planRes = await apiRequest('/v1/music/plan', {
|
|
176
|
+
apiKey,
|
|
177
|
+
body: planPayload,
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
if (planRes.status !== 200) {
|
|
181
|
+
console.error(`Error: Plan API returned ${planRes.status}`);
|
|
182
|
+
console.error(typeof planRes.data === 'string' ? planRes.data : JSON.stringify(planRes.data, null, 2));
|
|
183
|
+
process.exit(1);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
compositionPlan = planRes.data;
|
|
187
|
+
console.log(' Plan generated:');
|
|
188
|
+
console.log(` Styles: ${compositionPlan.positive_global_styles?.join(', ') || 'auto'}`);
|
|
189
|
+
console.log(` Sections: ${compositionPlan.sections?.map(s => s.section_name).join(' → ') || 'auto'}`);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Step 2: Stream the music
|
|
193
|
+
console.log('⏳ Composing...');
|
|
194
|
+
|
|
195
|
+
const streamPayload = { model_id: 'music_v1' };
|
|
196
|
+
|
|
197
|
+
if (compositionPlan) {
|
|
198
|
+
streamPayload.composition_plan = compositionPlan;
|
|
199
|
+
} else {
|
|
200
|
+
streamPayload.prompt = opts.prompt;
|
|
201
|
+
if (opts.duration) {
|
|
202
|
+
streamPayload.music_length_ms = opts.duration * 1000;
|
|
203
|
+
}
|
|
204
|
+
if (opts.instrumental) {
|
|
205
|
+
streamPayload.force_instrumental = true;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Try /v1/music first (compose, returns full file), fallback to /v1/music/stream
|
|
210
|
+
const endpoints = ['/v1/music', '/v1/music/stream'];
|
|
211
|
+
let composeRes = null;
|
|
212
|
+
let usedEndpoint = '';
|
|
213
|
+
|
|
214
|
+
for (const ep of endpoints) {
|
|
215
|
+
composeRes = await apiRequest(ep, {
|
|
216
|
+
apiKey,
|
|
217
|
+
body: streamPayload,
|
|
218
|
+
query: { output_format: opts.format },
|
|
219
|
+
stream: true,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
if (composeRes.status === 200) {
|
|
223
|
+
usedEndpoint = ep;
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Read error for diagnostics, try next endpoint
|
|
228
|
+
const errBuf = await streamToBuffer(composeRes.stream);
|
|
229
|
+
if (ep === endpoints[endpoints.length - 1]) {
|
|
230
|
+
// Last endpoint, report error
|
|
231
|
+
const errStr = errBuf.toString();
|
|
232
|
+
console.error(`Error: Music API returned ${composeRes.status}`);
|
|
233
|
+
try {
|
|
234
|
+
console.error(JSON.stringify(JSON.parse(errStr), null, 2));
|
|
235
|
+
} catch {
|
|
236
|
+
console.error(errStr);
|
|
237
|
+
}
|
|
238
|
+
process.exit(1);
|
|
239
|
+
}
|
|
240
|
+
console.log(` ${ep} returned ${composeRes.status}, trying next endpoint...`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Get song_id from response headers
|
|
244
|
+
const songId = composeRes.headers['song-id'] || composeRes.headers['x-song-id'] || '';
|
|
245
|
+
|
|
246
|
+
// Determine output path
|
|
247
|
+
const ext = opts.format.startsWith('pcm') ? 'wav'
|
|
248
|
+
: opts.format.startsWith('opus') ? 'ogg'
|
|
249
|
+
: 'mp3';
|
|
250
|
+
const outPath = opts.output
|
|
251
|
+
? path.resolve(opts.output)
|
|
252
|
+
: path.resolve(`composition-${Date.now()}.${ext}`);
|
|
253
|
+
|
|
254
|
+
await streamToFile(composeRes.stream, outPath);
|
|
255
|
+
|
|
256
|
+
const stats = fs.statSync(outPath);
|
|
257
|
+
const sizeMb = (stats.size / (1024 * 1024)).toFixed(2);
|
|
258
|
+
|
|
259
|
+
console.log(`✅ Composed! Saved to: ${outPath} (${sizeMb} MB)`);
|
|
260
|
+
if (songId) console.log(` Song ID: ${songId}`);
|
|
261
|
+
|
|
262
|
+
// Send via OpenClaw (if channel provided)
|
|
263
|
+
if (opts.channel) {
|
|
264
|
+
console.log(`📤 Sending to channel: ${opts.channel}`);
|
|
265
|
+
try {
|
|
266
|
+
const { execFileSync } = require('child_process');
|
|
267
|
+
const message = opts.caption || '🎵 New composition';
|
|
268
|
+
execFileSync('openclaw', ['message', 'send', '--channel', opts.channel, '--message', message, '--media', outPath], { stdio: 'inherit' });
|
|
269
|
+
console.log(` Sent to ${opts.channel}`);
|
|
270
|
+
} catch {
|
|
271
|
+
console.log(' OpenClaw CLI not available, skipping channel send.');
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Output JSON result
|
|
276
|
+
const output = {
|
|
277
|
+
success: true,
|
|
278
|
+
file: outPath,
|
|
279
|
+
size_mb: parseFloat(sizeMb),
|
|
280
|
+
format: opts.format,
|
|
281
|
+
prompt: opts.prompt,
|
|
282
|
+
instrumental: opts.instrumental,
|
|
283
|
+
plan_mode: opts.plan,
|
|
284
|
+
duration_requested: opts.duration || 'auto',
|
|
285
|
+
song_id: songId || null,
|
|
286
|
+
plan: compositionPlan ? {
|
|
287
|
+
styles: compositionPlan.positive_global_styles || [],
|
|
288
|
+
sections: (compositionPlan.sections || []).map(s => s.section_name),
|
|
289
|
+
} : null,
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
console.log('\n' + JSON.stringify(output, null, 2));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
main().catch((e) => {
|
|
296
|
+
console.error('Error:', e.message);
|
|
297
|
+
process.exit(1);
|
|
298
|
+
});
|
|
@@ -1,38 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# OpenPersona Music Faculty —
|
|
2
|
+
# OpenPersona Music Faculty — ElevenLabs Music API (music_v1)
|
|
3
3
|
#
|
|
4
|
-
# Usage: ./compose.sh <prompt> [
|
|
4
|
+
# Usage: ./compose.sh <prompt> [options]
|
|
5
5
|
#
|
|
6
6
|
# Arguments:
|
|
7
|
-
# prompt
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# --
|
|
7
|
+
# prompt - Music description (required)
|
|
8
|
+
#
|
|
9
|
+
# Options:
|
|
10
|
+
# --instrumental - Generate instrumental only (no vocals)
|
|
11
|
+
# --plan - Use composition plan mode (structured sections)
|
|
12
|
+
# --duration <secs> - Song length in seconds (3-600, default: auto)
|
|
13
|
+
# --format <format> - Output format (default: mp3_44100_128)
|
|
14
|
+
# --output <path> - Save audio to file (default: ./composition-<timestamp>.mp3)
|
|
15
|
+
# --channel <channel> - OpenClaw channel to send to (optional)
|
|
16
|
+
# --caption <caption> - Message caption (optional)
|
|
11
17
|
#
|
|
12
18
|
# Environment variables:
|
|
13
|
-
#
|
|
14
|
-
# OPENCLAW_GATEWAY_TOKEN
|
|
19
|
+
# ELEVENLABS_API_KEY - ElevenLabs API key (shared with voice faculty)
|
|
20
|
+
# OPENCLAW_GATEWAY_TOKEN - OpenClaw gateway token (optional)
|
|
15
21
|
#
|
|
16
22
|
# Examples:
|
|
17
|
-
# ./compose.sh "soft ambient piano
|
|
18
|
-
# ./compose.sh "
|
|
19
|
-
# ./compose.sh "
|
|
23
|
+
# ./compose.sh "a soft ambient piano piece about starlight"
|
|
24
|
+
# ./compose.sh "dreamy lo-fi beats" --instrumental --duration 60
|
|
25
|
+
# ./compose.sh "indie folk ballad" --plan --output ./song.mp3
|
|
26
|
+
# ./compose.sh "upbeat pop" --channel "#music" --caption "New song!"
|
|
20
27
|
|
|
21
28
|
set -euo pipefail
|
|
22
29
|
|
|
23
30
|
RED='\033[0;31m'
|
|
24
31
|
GREEN='\033[0;32m'
|
|
25
32
|
YELLOW='\033[1;33m'
|
|
33
|
+
CYAN='\033[0;36m'
|
|
26
34
|
NC='\033[0m'
|
|
27
35
|
|
|
28
36
|
log_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
|
29
37
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
30
38
|
log_error() { echo -e "${RED}[ERROR]${NC} $1" >&2; }
|
|
39
|
+
log_step() { echo -e "${CYAN}[STEP]${NC} $1"; }
|
|
40
|
+
|
|
41
|
+
# --- API Configuration ---
|
|
42
|
+
API_BASE="https://api.elevenlabs.io"
|
|
43
|
+
DEFAULT_FORMAT="mp3_44100_128"
|
|
31
44
|
|
|
32
45
|
# --- Preflight ---
|
|
33
|
-
if [ -z "${
|
|
34
|
-
log_error "
|
|
35
|
-
echo "Get your API key from: https://
|
|
46
|
+
if [ -z "${ELEVENLABS_API_KEY:-}" ]; then
|
|
47
|
+
log_error "ELEVENLABS_API_KEY environment variable not set"
|
|
48
|
+
echo "Get your API key from: https://elevenlabs.io"
|
|
49
|
+
echo "(Same key used by the voice faculty)"
|
|
36
50
|
exit 1
|
|
37
51
|
fi
|
|
38
52
|
|
|
@@ -44,15 +58,23 @@ fi
|
|
|
44
58
|
|
|
45
59
|
# --- Parse arguments ---
|
|
46
60
|
PROMPT=""
|
|
47
|
-
|
|
61
|
+
INSTRUMENTAL=false
|
|
62
|
+
PLAN_MODE=false
|
|
63
|
+
DURATION=""
|
|
64
|
+
FORMAT="$DEFAULT_FORMAT"
|
|
65
|
+
OUTPUT=""
|
|
48
66
|
CHANNEL=""
|
|
49
67
|
CAPTION=""
|
|
50
68
|
|
|
51
69
|
while [[ $# -gt 0 ]]; do
|
|
52
70
|
case $1 in
|
|
53
|
-
--
|
|
54
|
-
--
|
|
55
|
-
--
|
|
71
|
+
--instrumental) INSTRUMENTAL=true; shift ;;
|
|
72
|
+
--plan) PLAN_MODE=true; shift ;;
|
|
73
|
+
--duration) DURATION="$2"; shift 2 ;;
|
|
74
|
+
--format) FORMAT="$2"; shift 2 ;;
|
|
75
|
+
--output) OUTPUT="$2"; shift 2 ;;
|
|
76
|
+
--channel) CHANNEL="$2"; shift 2 ;;
|
|
77
|
+
--caption) CAPTION="$2"; shift 2 ;;
|
|
56
78
|
*)
|
|
57
79
|
if [ -z "$PROMPT" ]; then
|
|
58
80
|
PROMPT="$1"
|
|
@@ -62,89 +84,130 @@ while [[ $# -gt 0 ]]; do
|
|
|
62
84
|
done
|
|
63
85
|
|
|
64
86
|
if [ -z "$PROMPT" ]; then
|
|
65
|
-
echo "Usage: $0 <prompt> [--
|
|
87
|
+
echo "Usage: $0 <prompt> [--instrumental] [--plan] [--duration <secs>] [--format <format>] [--output <path>]"
|
|
66
88
|
echo ""
|
|
67
|
-
echo "
|
|
68
|
-
echo "
|
|
69
|
-
echo "
|
|
70
|
-
echo " $0 \"
|
|
89
|
+
echo "Simple mode: $0 \"a soft piano piece about starlight\""
|
|
90
|
+
echo "Instrumental: $0 \"dreamy lo-fi beats\" --instrumental"
|
|
91
|
+
echo "Plan mode: $0 \"indie folk ballad\" --plan"
|
|
92
|
+
echo "With duration: $0 \"cinematic orchestra\" --duration 120"
|
|
71
93
|
exit 1
|
|
72
94
|
fi
|
|
73
95
|
|
|
74
|
-
# --- Determine
|
|
75
|
-
if [ -
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
log_info "Mode: Instrumental"
|
|
96
|
+
# --- Determine output file ---
|
|
97
|
+
if [ -z "$OUTPUT" ]; then
|
|
98
|
+
EXT="mp3"
|
|
99
|
+
if [[ "$FORMAT" == pcm_* ]]; then EXT="wav"; fi
|
|
100
|
+
if [[ "$FORMAT" == opus_* ]]; then EXT="ogg"; fi
|
|
101
|
+
OUTPUT="./composition-$(date +%s).${EXT}"
|
|
81
102
|
fi
|
|
82
103
|
|
|
83
|
-
|
|
104
|
+
TYPE_LABEL="Song"
|
|
105
|
+
if [ "$INSTRUMENTAL" = true ]; then TYPE_LABEL="Instrumental"; fi
|
|
106
|
+
MODE_LABEL="Simple"
|
|
107
|
+
if [ "$PLAN_MODE" = true ]; then MODE_LABEL="Plan"; fi
|
|
84
108
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
--arg prompt "$PROMPT" \
|
|
89
|
-
'{prompt: $prompt, make_instrumental: true, wait_audio: true}')
|
|
90
|
-
else
|
|
91
|
-
JSON_PAYLOAD=$(jq -n \
|
|
92
|
-
--arg prompt "$PROMPT" \
|
|
93
|
-
--arg lyrics "$LYRICS" \
|
|
94
|
-
'{prompt: $prompt, lyrics: $lyrics, make_instrumental: false, wait_audio: true}')
|
|
95
|
-
fi
|
|
109
|
+
log_info "Mode: $MODE_LABEL | Type: $TYPE_LABEL | Format: $FORMAT"
|
|
110
|
+
log_info "Prompt: ${PROMPT:0:100}..."
|
|
111
|
+
if [ -n "$DURATION" ]; then log_info "Duration: ${DURATION}s"; fi
|
|
96
112
|
|
|
97
|
-
# ---
|
|
98
|
-
|
|
113
|
+
# --- Optional: Generate composition plan ---
|
|
114
|
+
COMPOSITION_PLAN=""
|
|
115
|
+
if [ "$PLAN_MODE" = true ]; then
|
|
116
|
+
log_step "Generating composition plan..."
|
|
99
117
|
|
|
100
|
-
|
|
101
|
-
-
|
|
102
|
-
|
|
103
|
-
|
|
118
|
+
PLAN_PAYLOAD=$(jq -n --arg prompt "$PROMPT" '{prompt: $prompt, model_id: "music_v1"}')
|
|
119
|
+
if [ -n "$DURATION" ]; then
|
|
120
|
+
PLAN_PAYLOAD=$(echo "$PLAN_PAYLOAD" | jq --argjson ms "$((DURATION * 1000))" '.music_length_ms = $ms')
|
|
121
|
+
fi
|
|
104
122
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
123
|
+
PLAN_RESPONSE=$(curl -s -X POST "$API_BASE/v1/music/plan" \
|
|
124
|
+
-H "xi-api-key: $ELEVENLABS_API_KEY" \
|
|
125
|
+
-H "Content-Type: application/json" \
|
|
126
|
+
-d "$PLAN_PAYLOAD")
|
|
127
|
+
|
|
128
|
+
# Check for error
|
|
129
|
+
if echo "$PLAN_RESPONSE" | jq -e '.detail' &> /dev/null; then
|
|
130
|
+
log_error "Plan API error: $(echo "$PLAN_RESPONSE" | jq -r '.detail // .message // "Unknown error"')"
|
|
131
|
+
exit 1
|
|
132
|
+
fi
|
|
133
|
+
|
|
134
|
+
COMPOSITION_PLAN="$PLAN_RESPONSE"
|
|
135
|
+
STYLES=$(echo "$COMPOSITION_PLAN" | jq -r '.positive_global_styles // [] | join(", ")')
|
|
136
|
+
SECTIONS=$(echo "$COMPOSITION_PLAN" | jq -r '.sections // [] | map(.section_name) | join(" → ")')
|
|
137
|
+
log_info "Plan generated — Styles: $STYLES"
|
|
138
|
+
log_info "Sections: $SECTIONS"
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
# --- Build stream payload ---
|
|
142
|
+
if [ -n "$COMPOSITION_PLAN" ]; then
|
|
143
|
+
STREAM_PAYLOAD=$(jq -n --argjson plan "$COMPOSITION_PLAN" '{model_id: "music_v1", composition_plan: $plan}')
|
|
144
|
+
else
|
|
145
|
+
STREAM_PAYLOAD=$(jq -n --arg prompt "$PROMPT" '{model_id: "music_v1", prompt: $prompt}')
|
|
146
|
+
if [ -n "$DURATION" ]; then
|
|
147
|
+
STREAM_PAYLOAD=$(echo "$STREAM_PAYLOAD" | jq --argjson ms "$((DURATION * 1000))" '.music_length_ms = $ms')
|
|
148
|
+
fi
|
|
149
|
+
if [ "$INSTRUMENTAL" = true ]; then
|
|
150
|
+
STREAM_PAYLOAD=$(echo "$STREAM_PAYLOAD" | jq '.force_instrumental = true')
|
|
151
|
+
fi
|
|
110
152
|
fi
|
|
111
153
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
154
|
+
# --- Compose the music ---
|
|
155
|
+
# Try /v1/music first (compose), fallback to /v1/music/stream
|
|
156
|
+
log_step "Composing..."
|
|
115
157
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
158
|
+
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$OUTPUT" \
|
|
159
|
+
-X POST "$API_BASE/v1/music?output_format=$FORMAT" \
|
|
160
|
+
-H "xi-api-key: $ELEVENLABS_API_KEY" \
|
|
161
|
+
-H "Content-Type: application/json" \
|
|
162
|
+
-d "$STREAM_PAYLOAD")
|
|
163
|
+
|
|
164
|
+
if [ "$HTTP_CODE" != "200" ]; then
|
|
165
|
+
log_warn "/v1/music returned HTTP $HTTP_CODE, trying /v1/music/stream..."
|
|
166
|
+
rm -f "$OUTPUT"
|
|
167
|
+
|
|
168
|
+
HTTP_CODE=$(curl -s -w "%{http_code}" -o "$OUTPUT" \
|
|
169
|
+
-X POST "$API_BASE/v1/music/stream?output_format=$FORMAT" \
|
|
170
|
+
-H "xi-api-key: $ELEVENLABS_API_KEY" \
|
|
171
|
+
-H "Content-Type: application/json" \
|
|
172
|
+
-d "$STREAM_PAYLOAD")
|
|
173
|
+
|
|
174
|
+
if [ "$HTTP_CODE" != "200" ]; then
|
|
175
|
+
ERROR_BODY=$(cat "$OUTPUT" 2>/dev/null || echo "Unknown error")
|
|
176
|
+
rm -f "$OUTPUT"
|
|
177
|
+
log_error "Music API returned HTTP $HTTP_CODE"
|
|
178
|
+
log_error "$ERROR_BODY"
|
|
179
|
+
exit 1
|
|
180
|
+
fi
|
|
120
181
|
fi
|
|
121
182
|
|
|
122
|
-
|
|
123
|
-
|
|
183
|
+
# --- Check file ---
|
|
184
|
+
FILE_SIZE=$(stat -f%z "$OUTPUT" 2>/dev/null || stat -c%s "$OUTPUT" 2>/dev/null || echo "0")
|
|
185
|
+
SIZE_MB=$(echo "scale=2; $FILE_SIZE / 1048576" | bc 2>/dev/null || echo "?")
|
|
186
|
+
|
|
187
|
+
log_info "Composed! Saved to: $OUTPUT (${SIZE_MB} MB)"
|
|
124
188
|
|
|
125
189
|
# --- Send via OpenClaw (if channel provided) ---
|
|
126
190
|
if [ -n "$CHANNEL" ]; then
|
|
127
|
-
MESSAGE="${CAPTION:-🎵
|
|
128
|
-
|
|
191
|
+
MESSAGE="${CAPTION:-🎵 New composition}"
|
|
192
|
+
log_step "Sending to channel: $CHANNEL"
|
|
129
193
|
|
|
130
194
|
if command -v openclaw &> /dev/null; then
|
|
131
195
|
openclaw message send \
|
|
132
|
-
--action send \
|
|
133
196
|
--channel "$CHANNEL" \
|
|
134
197
|
--message "$MESSAGE" \
|
|
135
|
-
--media "$
|
|
198
|
+
--media "$OUTPUT" || log_warn "Failed to send via OpenClaw"
|
|
136
199
|
else
|
|
137
200
|
GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-http://localhost:18789}"
|
|
138
201
|
SEND_PAYLOAD=$(jq -n \
|
|
139
202
|
--arg channel "$CHANNEL" \
|
|
140
203
|
--arg message "$MESSAGE" \
|
|
141
|
-
--arg media "$
|
|
204
|
+
--arg media "$OUTPUT" \
|
|
142
205
|
'{action: "send", channel: $channel, message: $message, media: $media}')
|
|
143
206
|
|
|
144
207
|
curl -s -X POST "$GATEWAY_URL/message" \
|
|
145
208
|
-H "Content-Type: application/json" \
|
|
146
209
|
${OPENCLAW_GATEWAY_TOKEN:+-H "Authorization: Bearer $OPENCLAW_GATEWAY_TOKEN"} \
|
|
147
|
-
-d "$SEND_PAYLOAD"
|
|
210
|
+
-d "$SEND_PAYLOAD" || log_warn "Failed to send via gateway"
|
|
148
211
|
fi
|
|
149
212
|
log_info "Sent to $CHANNEL"
|
|
150
213
|
fi
|
|
@@ -152,18 +215,22 @@ fi
|
|
|
152
215
|
# --- Output result ---
|
|
153
216
|
echo ""
|
|
154
217
|
jq -n \
|
|
155
|
-
--arg
|
|
156
|
-
--arg
|
|
157
|
-
--arg
|
|
218
|
+
--arg file "$OUTPUT" \
|
|
219
|
+
--arg size "$SIZE_MB" \
|
|
220
|
+
--arg format "$FORMAT" \
|
|
158
221
|
--arg prompt "$PROMPT" \
|
|
159
222
|
--argjson instrumental "$INSTRUMENTAL" \
|
|
223
|
+
--argjson plan_mode "$PLAN_MODE" \
|
|
224
|
+
--arg duration "${DURATION:-auto}" \
|
|
160
225
|
--arg channel "${CHANNEL:-none}" \
|
|
161
226
|
'{
|
|
162
227
|
success: true,
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
228
|
+
file: $file,
|
|
229
|
+
size_mb: $size,
|
|
230
|
+
format: $format,
|
|
166
231
|
prompt: $prompt,
|
|
167
232
|
instrumental: $instrumental,
|
|
233
|
+
plan_mode: $plan_mode,
|
|
234
|
+
duration_requested: $duration,
|
|
168
235
|
channel: $channel
|
|
169
236
|
}'
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "selfie",
|
|
3
3
|
"dimension": "expression",
|
|
4
4
|
"description": "AI selfie generation via fal.ai Grok Imagine",
|
|
5
|
-
"allowedTools": ["Bash(
|
|
5
|
+
"allowedTools": ["Bash(bash scripts/generate-image.sh:*)", "Bash(openclaw message:*)"],
|
|
6
6
|
"envVars": ["FAL_KEY"],
|
|
7
7
|
"triggers": ["send a selfie", "take a pic", "what do you look like", "show me a photo"],
|
|
8
8
|
"files": ["SKILL.md", "scripts/generate-image.sh"]
|
|
@@ -4,11 +4,13 @@ Give your persona a real voice. Convert text to natural speech using TTS provide
|
|
|
4
4
|
|
|
5
5
|
## Supported Providers
|
|
6
6
|
|
|
7
|
-
| Provider | Env Var for Key | Best For |
|
|
8
|
-
|
|
9
|
-
| **ElevenLabs** | `
|
|
10
|
-
| **OpenAI TTS** | `TTS_API_KEY` | Low latency, good quality, easy integration |
|
|
11
|
-
| **Qwen3-TTS** | (local, no key) | Self-hosted, full control, no API costs |
|
|
7
|
+
| Provider | Env Var for Key | Best For | Status |
|
|
8
|
+
|----------|----------------|----------|--------|
|
|
9
|
+
| **ElevenLabs** | `ELEVENLABS_API_KEY` | Highest naturalness, emotional range, voice cloning | ✅ Verified |
|
|
10
|
+
| **OpenAI TTS** | `TTS_API_KEY` | Low latency, good quality, easy integration | ⚠️ Unverified |
|
|
11
|
+
| **Qwen3-TTS** | (local, no key) | Self-hosted, full control, no API costs | ⚠️ Unverified |
|
|
12
|
+
|
|
13
|
+
> **Note:** Only ElevenLabs has been tested end-to-end. OpenAI TTS and Qwen3-TTS have code paths in `speak.sh` but have not been verified against live APIs. Use the JS SDK (`speak.js`) for the most reliable experience — it only supports ElevenLabs.
|
|
12
14
|
|
|
13
15
|
The provider is set via `TTS_PROVIDER` environment variable: `elevenlabs`, `openai`, or `qwen3`.
|
|
14
16
|
|
|
@@ -37,13 +39,13 @@ Write what you want to say. Keep it natural — write as you'd speak, not as you
|
|
|
37
39
|
- Supports emotion control: `stability` (0-1), `similarity_boost` (0-1)
|
|
38
40
|
- Lower stability = more expressive/emotional; higher = more consistent
|
|
39
41
|
|
|
40
|
-
**OpenAI TTS:**
|
|
42
|
+
**OpenAI TTS:** ⚠️ Unverified
|
|
41
43
|
- `TTS_VOICE_ID` — One of: `alloy`, `echo`, `fable`, `onyx`, `nova`, `shimmer`
|
|
42
44
|
- Model: `tts-1` (fast) or `tts-1-hd` (high quality)
|
|
43
45
|
|
|
44
|
-
**Qwen3-TTS:**
|
|
46
|
+
**Qwen3-TTS:** ⚠️ Unverified
|
|
45
47
|
- Local deployment, voice configured at setup
|
|
46
|
-
-
|
|
48
|
+
- Assumes OpenAI-compatible API at `http://localhost:8080`
|
|
47
49
|
|
|
48
50
|
### Step 3: Generate Audio
|
|
49
51
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "voice",
|
|
3
3
|
"dimension": "expression",
|
|
4
|
-
"description": "Text-to-speech voice synthesis — give your persona a real voice via ElevenLabs, OpenAI TTS
|
|
5
|
-
"allowedTools": ["Bash(
|
|
4
|
+
"description": "Text-to-speech voice synthesis — give your persona a real voice via ElevenLabs (verified), with experimental OpenAI TTS and Qwen3-TTS support",
|
|
5
|
+
"allowedTools": ["Bash(node scripts/speak.js:*)", "Bash(bash scripts/speak.sh:*)", "Bash(openclaw message:*)"],
|
|
6
6
|
"envVars": ["TTS_PROVIDER", "TTS_API_KEY", "TTS_VOICE_ID"],
|
|
7
7
|
"triggers": ["say this out loud", "speak to me", "read this aloud", "send a voice message", "I want to hear your voice"],
|
|
8
8
|
"files": ["SKILL.md", "scripts/speak.sh", "scripts/speak.js"]
|