openpersona 0.3.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.
@@ -1,45 +1,46 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * OpenPersona Music Faculty — Suno AI music generation via sunoapi.org
3
+ * OpenPersona Music Faculty — ElevenLabs Music API (music_v1)
4
4
  *
5
5
  * Usage:
6
6
  * node compose.js "a soft piano piece about starlight"
7
- * node compose.js "[Verse] lyrics..." --style "indie folk" --title "Sunlight"
8
- * node compose.js "dreamy lo-fi" --instrumental --model V5
9
- * node compose.js "upbeat pop" --output ./song.mp3
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
10
  *
11
11
  * Environment:
12
- * SUNO_API_KEY - API key from sunoapi.org (required)
13
- * SUNO_MODEL - Default model (V4, V4_5, V4_5PLUS, V4_5ALL, V5)
12
+ * ELEVENLABS_API_KEY - ElevenLabs API key (shared with voice faculty)
14
13
  */
15
14
 
16
15
  const https = require('https');
17
- const http = require('http');
18
16
  const fs = require('fs');
19
17
  const path = require('path');
20
18
 
21
- const API_BASE = 'https://api.sunoapi.org';
19
+ const API_BASE = 'https://api.elevenlabs.io';
20
+ const DEFAULT_FORMAT = 'mp3_44100_128';
22
21
 
23
22
  // --- Argument parsing ---
24
23
  function parseArgs(args) {
25
24
  const opts = {
26
25
  prompt: '',
27
- style: '',
28
- title: '',
29
26
  instrumental: false,
30
- model: process.env.SUNO_MODEL || 'V4_5ALL',
27
+ plan: false,
28
+ duration: null, // seconds; null = let model decide
29
+ format: DEFAULT_FORMAT,
31
30
  output: null,
32
- timeout: 180,
31
+ channel: '',
32
+ caption: '',
33
33
  };
34
34
  let i = 0;
35
35
  while (i < args.length) {
36
36
  switch (args[i]) {
37
- case '--style': opts.style = args[++i]; break;
38
- case '--title': opts.title = args[++i]; break;
39
37
  case '--instrumental': opts.instrumental = true; break;
40
- case '--model': opts.model = args[++i]; 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
41
  case '--output': opts.output = args[++i]; break;
42
- case '--timeout': opts.timeout = parseInt(args[++i], 10); break;
42
+ case '--channel': opts.channel = args[++i]; break;
43
+ case '--caption': opts.caption = args[++i]; break;
43
44
  case '--help':
44
45
  case '-h':
45
46
  printUsage();
@@ -58,26 +59,48 @@ function printUsage() {
58
59
  Usage: node compose.js <prompt> [options]
59
60
 
60
61
  Options:
61
- --style <style> Music style/genre (enables custom mode)
62
- --title <title> Song title (enables custom mode)
63
62
  --instrumental Generate instrumental only (no vocals)
64
- --model <model> Suno model: V4, V4_5, V4_5PLUS, V4_5ALL, V5 (default: V4_5ALL)
65
- --output <path> Download audio to file
66
- --timeout <seconds> Max wait time (default: 180)
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
67
71
 
68
72
  Examples:
69
73
  node compose.js "a soft ambient piano piece about starlight"
70
- node compose.js "[Verse] I don't have hands..." --style "indie folk" --title "Sunlight"
71
- node compose.js "dreamy lo-fi beats" --instrumental --model V5
72
- node compose.js "upbeat pop" --output ./my-song.mp3
74
+ node compose.js "indie folk ballad" --plan --output ./song.mp3
75
+ node compose.js "dreamy lo-fi beats" --instrumental --duration 60
73
76
  `);
74
77
  }
75
78
 
76
- // --- HTTP helper ---
77
- function request(url, options = {}) {
79
+ // --- HTTP helpers ---
80
+ function apiRequest(endpoint, options = {}) {
78
81
  return new Promise((resolve, reject) => {
79
- const mod = url.startsWith('https') ? https : http;
80
- const req = mod.request(url, options, (res) => {
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
+ }
81
104
  const chunks = [];
82
105
  res.on('data', (c) => chunks.push(c));
83
106
  res.on('end', () => {
@@ -90,37 +113,37 @@ function request(url, options = {}) {
90
113
  });
91
114
  });
92
115
  req.on('error', reject);
93
- if (options.body) req.write(options.body);
116
+ if (options.body) req.write(typeof options.body === 'string' ? options.body : JSON.stringify(options.body));
94
117
  req.end();
95
118
  });
96
119
  }
97
120
 
98
- function sleep(ms) {
99
- return new Promise((r) => setTimeout(r, ms));
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
+ });
100
129
  }
101
130
 
102
- function downloadFile(url, dest) {
131
+ function streamToBuffer(stream) {
103
132
  return new Promise((resolve, reject) => {
104
- const mod = url.startsWith('https') ? https : http;
105
- const file = fs.createWriteStream(dest);
106
- mod.get(url, (res) => {
107
- if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
108
- // Follow redirect
109
- downloadFile(res.headers.location, dest).then(resolve).catch(reject);
110
- return;
111
- }
112
- res.pipe(file);
113
- file.on('finish', () => { file.close(); resolve(); });
114
- }).on('error', (e) => { fs.unlink(dest, () => {}); reject(e); });
133
+ const chunks = [];
134
+ stream.on('data', (c) => chunks.push(c));
135
+ stream.on('end', () => resolve(Buffer.concat(chunks)));
136
+ stream.on('error', reject);
115
137
  });
116
138
  }
117
139
 
118
140
  // --- Main ---
119
141
  async function main() {
120
- const apiKey = process.env.SUNO_API_KEY;
142
+ const apiKey = process.env.ELEVENLABS_API_KEY;
121
143
  if (!apiKey) {
122
- console.error('Error: SUNO_API_KEY environment variable not set');
123
- console.error('Get your API key from: https://sunoapi.org/api-key');
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)');
124
147
  process.exit(1);
125
148
  }
126
149
 
@@ -130,122 +153,140 @@ async function main() {
130
153
  process.exit(1);
131
154
  }
132
155
 
133
- const customMode = !!(opts.style || opts.title);
134
-
135
- // Build payload
136
- const payload = {
137
- customMode,
138
- instrumental: opts.instrumental,
139
- model: opts.model,
140
- callBackUrl: '',
141
- };
142
-
143
- if (customMode) {
144
- payload.style = opts.style || 'pop';
145
- payload.title = opts.title || 'Untitled';
146
- if (!opts.instrumental) {
147
- payload.prompt = opts.prompt;
148
- }
149
- } else {
150
- payload.prompt = opts.prompt;
151
- }
152
-
153
- const modeLabel = customMode ? 'Custom' : 'Simple';
154
156
  const typeLabel = opts.instrumental ? 'Instrumental' : 'Song';
155
- console.log(`🎵 Mode: ${modeLabel} | Type: ${typeLabel} | Model: ${opts.model}`);
157
+ const modeLabel = opts.plan ? 'Plan' : 'Simple';
158
+ console.log(`🎵 Mode: ${modeLabel} | Type: ${typeLabel}`);
156
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
+ }
157
174
 
158
- // Submit generation request
159
- console.log('⏳ Submitting composition request...');
160
-
161
- const genRes = await request(`${API_BASE}/api/v1/generate`, {
162
- method: 'POST',
163
- headers: {
164
- 'Authorization': `Bearer ${apiKey}`,
165
- 'Content-Type': 'application/json',
166
- },
167
- body: JSON.stringify(payload),
168
- });
175
+ const planRes = await apiRequest('/v1/music/plan', {
176
+ apiKey,
177
+ body: planPayload,
178
+ });
169
179
 
170
- if (genRes.data.code !== 200) {
171
- console.error(`Error: API returned code ${genRes.data.code}: ${genRes.data.msg || 'Unknown error'}`);
172
- process.exit(1);
173
- }
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
+ }
174
185
 
175
- const taskId = genRes.data?.data?.taskId;
176
- if (!taskId) {
177
- console.error('Error: No taskId in response');
178
- console.error(JSON.stringify(genRes.data, null, 2));
179
- process.exit(1);
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'}`);
180
190
  }
181
191
 
182
- console.log(` Task ID: ${taskId}`);
183
- console.log('⏳ Composing... (usually 30-60 seconds)');
192
+ // Step 2: Stream the music
193
+ console.log('⏳ Composing...');
184
194
 
185
- // Poll for completion
186
- const pollInterval = 5000;
187
- let elapsed = 0;
188
- let result = null;
195
+ const streamPayload = { model_id: 'music_v1' };
189
196
 
190
- while (elapsed < opts.timeout * 1000) {
191
- await sleep(pollInterval);
192
- elapsed += pollInterval;
193
-
194
- const statusRes = await request(
195
- `${API_BASE}/api/v1/generate/record-info?taskId=${encodeURIComponent(taskId)}`,
196
- {
197
- method: 'GET',
198
- headers: { 'Authorization': `Bearer ${apiKey}` },
199
- }
200
- );
201
-
202
- if (statusRes.data.code !== 200) {
203
- process.stdout.write(`\r Waiting... (${Math.round(elapsed / 1000)}s)`);
204
- continue;
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;
205
203
  }
206
-
207
- const tracks = statusRes.data?.data?.data;
208
- if (tracks && tracks.length > 0 && tracks[0].audio_url) {
209
- result = tracks[0];
210
- break;
204
+ if (opts.instrumental) {
205
+ streamPayload.force_instrumental = true;
211
206
  }
212
-
213
- process.stdout.write(`\r Waiting... (${Math.round(elapsed / 1000)}s)`);
214
207
  }
215
208
 
216
- console.log('');
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
+ });
217
221
 
218
- if (!result) {
219
- console.error(`Error: Timed out after ${opts.timeout}s. Task ${taskId} may still be processing.`);
220
- process.exit(1);
221
- }
222
+ if (composeRes.status === 200) {
223
+ usedEndpoint = ep;
224
+ break;
225
+ }
222
226
 
223
- console.log(`✅ Composed: ${result.title || 'Untitled'} (${result.duration || '?'}s)`);
224
- console.log(` Audio: ${result.audio_url}`);
225
- if (result.stream_audio_url) {
226
- console.log(` Stream: ${result.stream_audio_url}`);
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...`);
227
241
  }
228
242
 
229
- // Download if output specified
230
- if (opts.output) {
231
- const outPath = path.resolve(opts.output);
232
- console.log(`📥 Downloading to ${outPath}...`);
233
- await downloadFile(result.audio_url, outPath);
234
- console.log(' Download complete.');
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
+ }
235
273
  }
236
274
 
237
275
  // Output JSON result
238
276
  const output = {
239
277
  success: true,
240
- audio_url: result.audio_url,
241
- stream_url: result.stream_audio_url || '',
242
- title: result.title || 'Untitled',
243
- duration_seconds: result.duration || 0,
278
+ file: outPath,
279
+ size_mb: parseFloat(sizeMb),
280
+ format: opts.format,
244
281
  prompt: opts.prompt,
245
- model: opts.model,
246
282
  instrumental: opts.instrumental,
247
- custom_mode: customMode,
248
- task_id: taskId,
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,
249
290
  };
250
291
 
251
292
  console.log('\n' + JSON.stringify(output, null, 2));