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.
Files changed (35) hide show
  1. package/README.md +90 -20
  2. package/bin/cli.js +26 -14
  3. package/layers/faculties/music/SKILL.md +100 -90
  4. package/layers/faculties/music/faculty.json +4 -4
  5. package/layers/faculties/music/scripts/compose.js +298 -0
  6. package/layers/faculties/music/scripts/compose.sh +141 -74
  7. package/layers/faculties/selfie/faculty.json +1 -1
  8. package/layers/faculties/voice/SKILL.md +10 -8
  9. package/layers/faculties/voice/faculty.json +2 -2
  10. package/layers/soul/README.md +31 -4
  11. package/layers/soul/constitution.md +136 -0
  12. package/lib/contributor.js +22 -14
  13. package/lib/downloader.js +6 -1
  14. package/lib/generator.js +54 -12
  15. package/lib/installer.js +22 -12
  16. package/lib/publisher/clawhub.js +4 -3
  17. package/lib/switcher.js +174 -0
  18. package/lib/utils.js +19 -0
  19. package/package.json +7 -7
  20. package/presets/ai-girlfriend/manifest.json +2 -3
  21. package/presets/health-butler/manifest.json +1 -1
  22. package/presets/life-assistant/manifest.json +1 -1
  23. package/presets/samantha/manifest.json +9 -3
  24. package/presets/samantha/persona.json +2 -2
  25. package/skills/open-persona/SKILL.md +125 -0
  26. package/skills/open-persona/references/CONTRIBUTE.md +38 -0
  27. package/skills/open-persona/references/FACULTIES.md +26 -0
  28. package/skills/open-persona/references/HEARTBEAT.md +35 -0
  29. package/templates/identity.template.md +3 -2
  30. package/templates/skill.template.md +9 -1
  31. package/templates/soul-injection.template.md +33 -5
  32. package/layers/faculties/soul-evolution/SKILL.md +0 -41
  33. package/layers/faculties/soul-evolution/faculty.json +0 -9
  34. package/skill/SKILL.md +0 -170
  35. /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 — Suno AI music generation + optional OpenClaw delivery
2
+ # OpenPersona Music Faculty — ElevenLabs Music API (music_v1)
3
3
  #
4
- # Usage: ./compose.sh <prompt> [--lyrics <lyrics>] [--channel <channel>] [--caption <caption>]
4
+ # Usage: ./compose.sh <prompt> [options]
5
5
  #
6
6
  # Arguments:
7
- # prompt - Music style/mood description (required)
8
- # --lyrics - Song lyrics (optional; omit for instrumental)
9
- # --channel - OpenClaw channel to send to (optional)
10
- # --caption - Message caption (optional)
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
- # SUNO_API_KEY - Suno API key (required)
14
- # OPENCLAW_GATEWAY_TOKEN - OpenClaw gateway token (optional)
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, contemplative"
18
- # ./compose.sh "indie folk ballad" --lyrics "[Verse]\nI saw you there..."
19
- # ./compose.sh "upbeat pop" --channel "#general" --caption "Made this for you!"
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 "${SUNO_API_KEY:-}" ]; then
34
- log_error "SUNO_API_KEY environment variable not set"
35
- echo "Get your API key from: https://suno.com"
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
- LYRICS=""
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
- --lyrics) LYRICS="$2"; shift 2 ;;
54
- --channel) CHANNEL="$2"; shift 2 ;;
55
- --caption) CAPTION="$2"; shift 2 ;;
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> [--lyrics <lyrics>] [--channel <channel>] [--caption <caption>]"
87
+ echo "Usage: $0 <prompt> [--instrumental] [--plan] [--duration <secs>] [--format <format>] [--output <path>]"
66
88
  echo ""
67
- echo "Examples:"
68
- echo " $0 \"soft ambient piano, contemplative, late night\""
69
- echo " $0 \"indie folk ballad\" --lyrics \"[Verse] I saw you there...\""
70
- echo " $0 \"upbeat pop\" --channel \"#general\" --caption \"New song!\""
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 mode ---
75
- if [ -n "$LYRICS" ]; then
76
- INSTRUMENTAL=false
77
- log_info "Mode: Song with lyrics"
78
- else
79
- INSTRUMENTAL=true
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
- log_info "Prompt: $PROMPT"
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
- # --- Build payload ---
86
- if [ "$INSTRUMENTAL" = true ]; then
87
- JSON_PAYLOAD=$(jq -n \
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
- # --- Call Suno API ---
98
- log_info "Composing... (this may take 30-60 seconds)"
113
+ # --- Optional: Generate composition plan ---
114
+ COMPOSITION_PLAN=""
115
+ if [ "$PLAN_MODE" = true ]; then
116
+ log_step "Generating composition plan..."
99
117
 
100
- RESPONSE=$(curl -s -X POST "https://api.suno.ai/v1/generation" \
101
- -H "Authorization: Bearer $SUNO_API_KEY" \
102
- -H "Content-Type: application/json" \
103
- -d "$JSON_PAYLOAD")
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
- # --- Check for errors ---
106
- if echo "$RESPONSE" | jq -e '.error // .detail' > /dev/null 2>&1; then
107
- ERROR_MSG=$(echo "$RESPONSE" | jq -r '.error // .detail // "Unknown error"')
108
- log_error "Composition failed: $ERROR_MSG"
109
- exit 1
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
- AUDIO_URL=$(echo "$RESPONSE" | jq -r '.[0].audio_url // .audio_url // empty')
113
- TITLE=$(echo "$RESPONSE" | jq -r '.[0].title // .title // "Untitled"')
114
- DURATION=$(echo "$RESPONSE" | jq -r '.[0].duration // .duration // "unknown"')
154
+ # --- Compose the music ---
155
+ # Try /v1/music first (compose), fallback to /v1/music/stream
156
+ log_step "Composing..."
115
157
 
116
- if [ -z "$AUDIO_URL" ]; then
117
- log_error "Failed to extract audio URL from response"
118
- log_error "Response: $RESPONSE"
119
- exit 1
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
- log_info "Composed: $TITLE ($DURATION seconds)"
123
- log_info "Audio: $AUDIO_URL"
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:-🎵 $TITLE}"
128
- log_info "Sending to channel: $CHANNEL"
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 "$AUDIO_URL"
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 "$AUDIO_URL" \
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 url "$AUDIO_URL" \
156
- --arg title "$TITLE" \
157
- --arg duration "$DURATION" \
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
- audio_url: $url,
164
- title: $title,
165
- duration_seconds: $duration,
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(curl:*)", "WebFetch"],
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 | Latency |
8
- |----------|----------------|----------|---------|
9
- | **ElevenLabs** | `TTS_API_KEY` | Highest naturalness, emotional range, voice cloning | Medium |
10
- | **OpenAI TTS** | `TTS_API_KEY` | Low latency, good quality, easy integration | Low |
11
- | **Qwen3-TTS** | (local, no key) | Self-hosted, full control, no API costs | Varies |
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
- - Supports emotion tags in text: `[happy]`, `[sad]`, `[whisper]`
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, or Qwen3-TTS",
5
- "allowedTools": ["Bash(curl:*)", "Bash(npx:*)", "WebFetch"],
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"]