@ericdisero/aurora-shared 0.1.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 (44) hide show
  1. package/README.md +9 -0
  2. package/dist/audio/ffmpeg.d.ts +21 -0
  3. package/dist/audio/ffmpeg.js +112 -0
  4. package/dist/audio/wav.d.ts +15 -0
  5. package/dist/audio/wav.js +159 -0
  6. package/dist/config.d.ts +14 -0
  7. package/dist/config.js +50 -0
  8. package/dist/db.d.ts +3 -0
  9. package/dist/db.js +121 -0
  10. package/dist/index.d.ts +7 -0
  11. package/dist/index.js +8 -0
  12. package/dist/jobs.d.ts +45 -0
  13. package/dist/jobs.js +220 -0
  14. package/dist/operations/index.d.ts +12 -0
  15. package/dist/operations/index.js +848 -0
  16. package/dist/paths.d.ts +17 -0
  17. package/dist/paths.js +79 -0
  18. package/dist/providers/mvsep.d.ts +27 -0
  19. package/dist/providers/mvsep.js +112 -0
  20. package/dist/providers/suno.d.ts +89 -0
  21. package/dist/providers/suno.js +309 -0
  22. package/dist/sidecars.d.ts +20 -0
  23. package/dist/sidecars.js +109 -0
  24. package/dist/skills/content.d.ts +1 -0
  25. package/dist/skills/content.js +9 -0
  26. package/dist/split.d.ts +24 -0
  27. package/dist/split.js +162 -0
  28. package/dist/stack.d.ts +19 -0
  29. package/dist/stack.js +139 -0
  30. package/dist/storage/assets.d.ts +30 -0
  31. package/dist/storage/assets.js +103 -0
  32. package/dist/storage/projects.d.ts +12 -0
  33. package/dist/storage/projects.js +85 -0
  34. package/dist/storage/references.d.ts +10 -0
  35. package/dist/storage/references.js +54 -0
  36. package/dist/storage/stems.d.ts +13 -0
  37. package/dist/storage/stems.js +41 -0
  38. package/dist/types.d.ts +72 -0
  39. package/dist/types.js +5 -0
  40. package/package.json +51 -0
  41. package/skills/aurora-cost-discipline.md +31 -0
  42. package/skills/aurora-music-production.md +43 -0
  43. package/skills/aurora-split-and-stems.md +33 -0
  44. package/skills/aurora-suno-prompting.md +35 -0
@@ -0,0 +1,848 @@
1
+ // Operations layer — the ONE place every Aurora agent tool is defined. Both the
2
+ // MCP server and the CLI register these as their tool / command surface
3
+ // (slates-mcp's single-registry rule, carried over). Every operation:
4
+ // - has a stable string id (= the MCP tool name)
5
+ // - has a Zod input schema (MCP tool definition AND CLI argument parsing)
6
+ // - works directly against Aurora's userData DB + project folders (standalone
7
+ // v1 — no running app required; renderer-bound mastering ops are deferred
8
+ // to the app-integration follow-up)
9
+ import { join, extname, basename, dirname } from 'node:path';
10
+ import { existsSync } from 'node:fs';
11
+ import { z } from 'zod';
12
+ import { v4 as uuidv4 } from 'uuid';
13
+ import { getDbPath, getProjectsDirectory, getUserDataDir } from '../paths.js';
14
+ import { getMvsepKey, getSunoKey, getKieKey } from '../config.js';
15
+ import { createCover, createGeneration, createSoundsGeneration, createWavConversion, downloadTo, getRemainingCredits, host, pollWavConversion, uploadAudioFile } from '../providers/suno.js';
16
+ import { getMvsepUserInfo } from '../providers/mvsep.js';
17
+ import { createProject, deleteProject, getProject, getProjectDirectory, listProjects, renameProject } from '../storage/projects.js';
18
+ import { addFileAsset, deleteAsset, getAsset, insertAsset, listAssets, updateAssetPath } from '../storage/assets.js';
19
+ import { getProjectStems, getStems } from '../storage/stems.js';
20
+ import { advanceJob, listJobs, loadJob, newJobManifest, saveJob } from '../jobs.js';
21
+ import { createSplitJobs, prepareSplit } from '../split.js';
22
+ import { probeDurationSeconds, standardizeToWav, convertToMp3, pitchShift } from '../audio/ffmpeg.js';
23
+ import { runRipMidi, runRvcUpscale } from '../sidecars.js';
24
+ import { addLane, exportStackBundle, laneNameFromPath, loadStack, removeLane, updateLane } from '../stack.js';
25
+ import { SKILLS } from '../skills/content.js';
26
+ function ok(data, text) {
27
+ return { text: text ?? JSON.stringify(data, null, 2), data };
28
+ }
29
+ // Latest Suno model (wire enum verified vs the sunoapi.org OpenAPI spec
30
+ // 2026-06-10: V4 | V4_5 | V4_5PLUS | V4_5ALL | V5 | V5_5). Sounds stays
31
+ // locked to V5 by its own docs.
32
+ const DEFAULT_GEN_MODEL = 'V5_5';
33
+ const MAX_COVER_REFERENCE_SECONDS = 8 * 60;
34
+ const BACKGROUND_DESCRIBE = 'Submit and return immediately with a jobId instead of blocking. Poll aurora_get_job_status ' +
35
+ 'every 10-20s. Status includes streamUrls you can hand the user to LISTEN mid-generation, ' +
36
+ 'before files land. Jobs survive process restarts (provider-side state).';
37
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
38
+ /** Blocking wrapper: advance the job every 5s up to ~12 min, then degrade
39
+ * gracefully to "still running" instead of erroring (MCP clients can time out
40
+ * long tool calls — the job itself is provider-side and loses nothing). */
41
+ async function awaitJob(m) {
42
+ const MAX_WAIT_MS = 12 * 60 * 1000;
43
+ const start = Date.now();
44
+ let current = m;
45
+ while (current.status === 'running' && Date.now() - start < MAX_WAIT_MS) {
46
+ await sleep(5000);
47
+ current = await advanceJob(current);
48
+ }
49
+ return current;
50
+ }
51
+ function jobSummary(m) {
52
+ return {
53
+ jobId: m.jobId,
54
+ kind: m.kind,
55
+ status: m.status,
56
+ stage: m.stage,
57
+ error: m.error,
58
+ projectId: m.projectId,
59
+ assetIds: m.assetIds,
60
+ stems: m.stems,
61
+ streamUrls: m.streamUrls && m.streamUrls.length > 0 ? m.streamUrls : undefined,
62
+ lastProviderStatus: m.lastStatus,
63
+ createdAt: m.createdAt,
64
+ updatedAt: m.updatedAt
65
+ };
66
+ }
67
+ function jobText(m) {
68
+ if (m.status === 'done') {
69
+ const assets = m.assetIds.length > 0 ? ` ${m.assetIds.length} asset(s): ${m.assetIds.join(', ')}.` : '';
70
+ const stems = m.stems.length > 0 ? ` ${m.stems.length} stem(s) landed.` : '';
71
+ return `Job ${m.jobId} complete.${assets}${stems} Files are on disk in the project folder.`;
72
+ }
73
+ if (m.status === 'error')
74
+ return `Job ${m.jobId} FAILED: ${m.error}`;
75
+ const stream = m.streamUrls && m.streamUrls.length > 0
76
+ ? ` Stream preview available NOW (play these URLs for the user before files land): ${m.streamUrls.join(' , ')}`
77
+ : '';
78
+ return `Job ${m.jobId} still running — ${m.stage}.${stream} Poll aurora_get_job_status again in 10-20s.`;
79
+ }
80
+ async function resolveProjectOrCreate(projectId, fallbackName) {
81
+ if (projectId) {
82
+ const p = getProject(projectId);
83
+ if (!p)
84
+ throw new Error(`Project not found: ${projectId}. Use aurora_list_projects.`);
85
+ return p.id;
86
+ }
87
+ const created = await createProject(fallbackName);
88
+ return created.id;
89
+ }
90
+ function sanitizeFileName(name) {
91
+ return name.replace(/[\\/:*?"<>|]/g, '_').trim() || 'track';
92
+ }
93
+ /** Resolve an op input that may be an assetId or a raw file path. */
94
+ function resolveAudioInput(input) {
95
+ if (input.assetId) {
96
+ const asset = getAsset(input.assetId);
97
+ if (!asset)
98
+ throw new Error(`Asset not found: ${input.assetId}. Use aurora_list_assets.`);
99
+ if (!existsSync(asset.path))
100
+ throw new Error(`Asset audio file is missing on disk: ${asset.path}`);
101
+ return { path: asset.path, asset };
102
+ }
103
+ if (input.path) {
104
+ if (!existsSync(input.path))
105
+ throw new Error(`File not found: ${input.path}`);
106
+ return { path: input.path, asset: null };
107
+ }
108
+ throw new Error('Provide either assetId or path.');
109
+ }
110
+ // ── Identity / workspace ────────────────────────────────────────
111
+ const getCredits = {
112
+ id: 'aurora_get_credits',
113
+ description: 'Cloud balances: Suno provider credits (sunoapi.org/kie.ai) and MVSEP premium minutes. ' +
114
+ 'FREE call — run it before any paid generation or split, and after, to log real spend.',
115
+ input: z.object({}).strict(),
116
+ async run() {
117
+ const result = {};
118
+ try {
119
+ result.sunoCredits = await getRemainingCredits();
120
+ result.sunoProvider = host();
121
+ }
122
+ catch (err) {
123
+ result.sunoError = err instanceof Error ? err.message : String(err);
124
+ }
125
+ if (getMvsepKey()) {
126
+ try {
127
+ const info = await getMvsepUserInfo();
128
+ result.mvsepPremiumMinutes = info.premiumMinutes;
129
+ result.mvsepPremiumEnabled = info.premiumEnabled;
130
+ }
131
+ catch (err) {
132
+ result.mvsepError = err instanceof Error ? err.message : String(err);
133
+ }
134
+ }
135
+ else {
136
+ result.mvsepError = 'MVSEP_API_KEY not configured';
137
+ }
138
+ return ok(result);
139
+ }
140
+ };
141
+ const getWorkspaceState = {
142
+ id: 'aurora_get_workspace_state',
143
+ description: "Snapshot of the user's Aurora workspace: userData location, projects root, key status, " +
144
+ 'projects list, optional per-project assets+stems. Call once at the start of a session.',
145
+ input: z.object({ projectId: z.string().optional() }),
146
+ async run(input) {
147
+ const projects = listProjects();
148
+ let activeProject;
149
+ if (input.projectId) {
150
+ const p = getProject(input.projectId);
151
+ if (!p)
152
+ throw new Error(`Project not found: ${input.projectId}`);
153
+ activeProject = {
154
+ ...p,
155
+ directory: getProjectDirectory(p.id),
156
+ assets: listAssets(p.id),
157
+ stems: getProjectStems(p.id)
158
+ };
159
+ }
160
+ return ok({
161
+ userData: getUserDataDir(),
162
+ database: getDbPath(),
163
+ projectsRoot: getProjectsDirectory(),
164
+ keys: {
165
+ suno: Boolean(getSunoKey() || getKieKey()),
166
+ mvsep: Boolean(getMvsepKey())
167
+ },
168
+ projects,
169
+ activeProject
170
+ });
171
+ }
172
+ };
173
+ // ── Projects ────────────────────────────────────────────────────
174
+ const createProjectOp = {
175
+ id: 'aurora_create_project',
176
+ description: 'Create a new Aurora project (a container of audio assets, with a human-readable folder on disk).',
177
+ input: z.object({ name: z.string().min(1).describe('Project name, e.g. "Midnight Drive"') }),
178
+ async run(input) {
179
+ const project = await createProject(input.name);
180
+ return ok({ project, directory: getProjectDirectory(project.id) });
181
+ }
182
+ };
183
+ const listProjectsOp = {
184
+ id: 'aurora_list_projects',
185
+ description: 'List every Aurora project (id, name, folder name, timestamps), newest first.',
186
+ input: z.object({}).strict(),
187
+ async run() {
188
+ return ok({ projects: listProjects(), projectsRoot: getProjectsDirectory() });
189
+ }
190
+ };
191
+ const renameProjectOp = {
192
+ id: 'aurora_rename_project',
193
+ description: 'Rename a project (display name only — the on-disk folder keeps its name).',
194
+ input: z.object({ projectId: z.string(), name: z.string().min(1) }),
195
+ async run(input) {
196
+ return ok({ project: renameProject(input.projectId, input.name) });
197
+ }
198
+ };
199
+ const deleteProjectOp = {
200
+ id: 'aurora_delete_project',
201
+ description: 'DELETE a project: its DB rows AND its entire folder on disk (all audio files). Irreversible. ' +
202
+ 'Requires confirm:true — ask the user first.',
203
+ input: z.object({
204
+ projectId: z.string(),
205
+ confirm: z.boolean().optional().describe('Must be true. Confirm with the user before calling.')
206
+ }),
207
+ async run(input) {
208
+ const project = getProject(input.projectId);
209
+ if (!project)
210
+ throw new Error(`Project not found: ${input.projectId}`);
211
+ if (!input.confirm) {
212
+ return ok({ wouldDelete: { project, directory: getProjectDirectory(project.id) } }, `NOT deleted. This would remove project "${project.name}" and its entire folder ` +
213
+ `${getProjectDirectory(project.id)} from disk. Re-call with confirm:true after the user agrees.`);
214
+ }
215
+ await deleteProject(input.projectId);
216
+ return ok({ deleted: project.id }, `Deleted project "${project.name}" and its folder.`);
217
+ }
218
+ };
219
+ const listAssetsOp = {
220
+ id: 'aurora_list_assets',
221
+ description: 'All assets in a project (generations, covers, imports, references, masters) with their on-disk ' +
222
+ 'paths, plus every split stem grouped by source asset.',
223
+ input: z.object({ projectId: z.string() }),
224
+ async run(input) {
225
+ const project = getProject(input.projectId);
226
+ if (!project)
227
+ throw new Error(`Project not found: ${input.projectId}`);
228
+ const assets = listAssets(input.projectId);
229
+ const stems = getProjectStems(input.projectId);
230
+ const stemsByAsset = {};
231
+ for (const s of stems) {
232
+ ;
233
+ (stemsByAsset[s.assetId] ??= []).push(s);
234
+ }
235
+ return ok({
236
+ project,
237
+ directory: getProjectDirectory(project.id),
238
+ assets: assets.map((a) => ({ ...a, stems: stemsByAsset[a.id] ?? [] }))
239
+ });
240
+ }
241
+ };
242
+ // ── Asset management ────────────────────────────────────────────
243
+ const importFileOp = {
244
+ id: 'aurora_import_file',
245
+ description: "Copy an external audio file into a project as an 'import' asset (lands in <project>/imports/). " +
246
+ 'Any asset can then be split, covered, stacked, or pitch-shifted.',
247
+ input: z.object({
248
+ projectId: z.string(),
249
+ filePath: z.string().describe('Absolute path to the audio file to import')
250
+ }),
251
+ async run(input) {
252
+ if (!existsSync(input.filePath))
253
+ throw new Error(`File not found: ${input.filePath}`);
254
+ const asset = await addFileAsset({ projectId: input.projectId, kind: 'import', filePath: input.filePath });
255
+ return ok({ asset });
256
+ }
257
+ };
258
+ const addReferenceOp = {
259
+ id: 'aurora_add_reference',
260
+ description: "Copy an audio file into a project as a 'reference' asset (lands in <project>/references/ and " +
261
+ "registers in the global reference library the mastering flow keys off). References can be split too.",
262
+ input: z.object({
263
+ projectId: z.string(),
264
+ filePath: z.string().describe('Absolute path to the reference audio file')
265
+ }),
266
+ async run(input) {
267
+ if (!existsSync(input.filePath))
268
+ throw new Error(`File not found: ${input.filePath}`);
269
+ const asset = await addFileAsset({ projectId: input.projectId, kind: 'reference', filePath: input.filePath });
270
+ return ok({ asset });
271
+ }
272
+ };
273
+ const deleteAssetOp = {
274
+ id: 'aurora_delete_asset',
275
+ description: 'DELETE an asset: its DB row, its audio file on disk, its stems folder, and any linked reference ' +
276
+ 'row. Irreversible. Requires confirm:true — ask the user first.',
277
+ input: z.object({
278
+ assetId: z.string(),
279
+ confirm: z.boolean().optional().describe('Must be true. Confirm with the user before calling.')
280
+ }),
281
+ async run(input) {
282
+ const asset = getAsset(input.assetId);
283
+ if (!asset)
284
+ throw new Error(`Asset not found: ${input.assetId}`);
285
+ if (!input.confirm) {
286
+ return ok({ wouldDelete: asset }, `NOT deleted. This would remove "${asset.name}" (${asset.path}) and its stems from disk. ` +
287
+ 'Re-call with confirm:true after the user agrees.');
288
+ }
289
+ await deleteAsset(input.assetId);
290
+ return ok({ deleted: asset.id }, `Deleted asset "${asset.name}".`);
291
+ }
292
+ };
293
+ const fetchWavOp = {
294
+ id: 'aurora_fetch_wav',
295
+ description: 'Upgrade a generation/cover asset from MP3 to provider WAV (uses the taskId+audioId stored at ' +
296
+ 'generation time; ~0.4 Suno credits per conversion). The asset re-points at the WAV; the MP3 stays on disk.',
297
+ input: z.object({ assetId: z.string() }),
298
+ async run(input) {
299
+ const asset = getAsset(input.assetId);
300
+ if (!asset)
301
+ throw new Error(`Asset not found: ${input.assetId}`);
302
+ const origin = (asset.origin ?? {});
303
+ if (!origin.taskId || !origin.audioId) {
304
+ throw new Error('This asset has no provider taskId/audioId in its origin metadata (probably an import, or a ' +
305
+ 'legacy generation) — the provider WAV conversion needs both. Use aurora_convert for a local ffmpeg WAV instead.');
306
+ }
307
+ const wavTaskId = await createWavConversion(origin.taskId, origin.audioId);
308
+ const wavUrl = await pollWavConversion(wavTaskId);
309
+ const wavPath = join(dirname(asset.path), `${basename(asset.path, extname(asset.path))}.wav`);
310
+ await downloadTo(wavUrl, wavPath);
311
+ const updated = updateAssetPath(asset.id, wavPath);
312
+ return ok({ asset: updated }, `WAV fetched: ${wavPath} (asset re-pointed; MP3 kept on disk).`);
313
+ }
314
+ };
315
+ // ── Generation (long ops — background-capable) ──────────────────
316
+ const generateOp = {
317
+ id: 'aurora_generate',
318
+ description: 'Generate a full music track via Suno (2 variations land as project assets, MP3 + WAV-upgradeable). ' +
319
+ 'Takes 1-3 minutes. PAID (Suno credits) — check aurora_get_credits first. ' +
320
+ BACKGROUND_DESCRIBE,
321
+ input: z.object({
322
+ prompt: z.string().describe('Lyrics in custom mode (style/title set), else a track description'),
323
+ style: z.string().optional().describe('Music style (implies custom mode)'),
324
+ title: z.string().optional().describe('Track title (implies custom mode)'),
325
+ instrumental: z.boolean().optional().describe('Generate without vocals (default false)'),
326
+ model: z.string().optional().describe(`Suno model id (default ${DEFAULT_GEN_MODEL})`),
327
+ vocalGender: z.enum(['male', 'female']).optional(),
328
+ negativeTags: z.string().optional().describe('Styles to exclude, ONE comma-separated string'),
329
+ projectId: z.string().optional().describe('Target project (auto-created from the title/prompt when omitted)'),
330
+ background: z.boolean().optional().describe('Return a jobId immediately instead of waiting')
331
+ }),
332
+ async run(input) {
333
+ const customMode = Boolean(input.style || input.title);
334
+ const model = (input.model ?? DEFAULT_GEN_MODEL).replace(/\./g, '_');
335
+ const baseName = input.title?.trim() || input.prompt.slice(0, 60).trim() || 'Generated track';
336
+ const projectId = await resolveProjectOrCreate(input.projectId, baseName);
337
+ const taskId = await createGeneration({
338
+ prompt: input.prompt,
339
+ style: input.style,
340
+ title: input.title,
341
+ instrumental: input.instrumental ?? false,
342
+ customMode,
343
+ model,
344
+ vocalGender: input.vocalGender,
345
+ negativeTags: input.negativeTags
346
+ });
347
+ const manifest = newJobManifest('generate', `gen-${uuidv4().slice(0, 8)}`, projectId, baseName, {
348
+ prompt: input.prompt,
349
+ style: input.style,
350
+ title: input.title,
351
+ instrumental: input.instrumental ?? false,
352
+ model,
353
+ vocalGender: input.vocalGender ?? null,
354
+ negativeTags: input.negativeTags
355
+ }, { taskId });
356
+ await saveJob(manifest);
357
+ if (input.background) {
358
+ return ok(jobSummary(manifest), jobText(manifest));
359
+ }
360
+ const finished = await awaitJob(manifest);
361
+ return ok(jobSummary(finished), jobText(finished));
362
+ }
363
+ };
364
+ const soundsOp = {
365
+ id: 'aurora_sounds',
366
+ description: 'Generate a sample / one-shot / loop via Suno Sounds (key + tempo lockable; 2 variations land as ' +
367
+ 'project assets). Fast (~20-30s) and cheap (~2.5 Suno credits). sunoapi.org only. ' +
368
+ BACKGROUND_DESCRIBE,
369
+ input: z.object({
370
+ prompt: z.string().max(500).describe('Sound description, e.g. "huge cinematic braam, dark low brass"'),
371
+ soundKey: z.string().optional().describe('Pitch lock, e.g. C, Cm, F#, F#m (default Any)'),
372
+ tempo: z.number().int().min(1).max(300).optional().describe('BPM lock; omit for auto'),
373
+ loop: z.boolean().optional().describe('Generate as a loopable sound'),
374
+ projectId: z.string().optional(),
375
+ background: z.boolean().optional()
376
+ }),
377
+ async run(input) {
378
+ const baseName = input.prompt.slice(0, 60).trim() || 'Sound';
379
+ const projectId = await resolveProjectOrCreate(input.projectId, baseName);
380
+ const taskId = await createSoundsGeneration({
381
+ prompt: input.prompt,
382
+ soundKey: input.soundKey,
383
+ soundTempo: input.tempo,
384
+ soundLoop: input.loop
385
+ });
386
+ const manifest = newJobManifest('sounds', `snd-${uuidv4().slice(0, 8)}`, projectId, baseName, {
387
+ prompt: input.prompt,
388
+ instrumental: true,
389
+ model: 'V5',
390
+ soundKey: input.soundKey,
391
+ soundTempo: input.tempo,
392
+ soundLoop: input.loop ?? false
393
+ }, { taskId });
394
+ await saveJob(manifest);
395
+ if (input.background) {
396
+ return ok(jobSummary(manifest), jobText(manifest));
397
+ }
398
+ const finished = await awaitJob(manifest);
399
+ return ok(jobSummary(finished), jobText(finished));
400
+ }
401
+ };
402
+ const coverOp = {
403
+ id: 'aurora_cover',
404
+ description: 'Cover a track (Suno upload-and-cover style transform): same musical content, new style. Source is a ' +
405
+ 'project asset or an external file (max 8 minutes). 2 variations land as cover assets linked to the ' +
406
+ 'source. PAID (~12 Suno credits + ~0.4/WAV) — check aurora_get_credits first. ' +
407
+ BACKGROUND_DESCRIBE,
408
+ input: z.object({
409
+ sourceAssetId: z.string().optional().describe('Project asset to transform'),
410
+ sourcePath: z.string().optional().describe('OR an external audio file path'),
411
+ prompt: z.string().describe('What the cover should sound like'),
412
+ style: z.string().optional().describe('Target style (custom mode needs BOTH style and title)'),
413
+ title: z.string().optional(),
414
+ instrumental: z.boolean().optional(),
415
+ model: z.string().optional().describe(`Suno model id (default ${DEFAULT_GEN_MODEL})`),
416
+ vocalGender: z.enum(['male', 'female']).optional(),
417
+ negativeTags: z.string().optional().describe('Styles to exclude, ONE comma-separated string'),
418
+ audioWeight: z.number().min(0).max(1).optional().describe('0..1 — 0 = new style dominates, 1 = stay close to the source'),
419
+ projectId: z.string().optional(),
420
+ background: z.boolean().optional(),
421
+ fetchWav: z
422
+ .boolean()
423
+ .optional()
424
+ .describe('Blocking mode only: also fetch the provider WAV per variation (default true; ~0.4 credits each)')
425
+ }),
426
+ async run(input) {
427
+ const sourceAsset = input.sourceAssetId ? getAsset(input.sourceAssetId) : null;
428
+ if (input.sourceAssetId && !sourceAsset)
429
+ throw new Error(`Asset not found: ${input.sourceAssetId}`);
430
+ const sourcePath = sourceAsset?.path ?? input.sourcePath;
431
+ if (!sourcePath || !existsSync(sourcePath)) {
432
+ throw new Error('Cover source not found — pass sourceAssetId (a project asset) or sourcePath (a file).');
433
+ }
434
+ const customMode = Boolean(input.style || input.title);
435
+ if (customMode && (!input.style || !input.title)) {
436
+ throw new Error('Custom mode needs BOTH a style and a title (you set only one).');
437
+ }
438
+ const model = (input.model ?? DEFAULT_GEN_MODEL).replace(/\./g, '_');
439
+ // 8-minute reference cap (pre-checked; the provider enforces it too).
440
+ const duration = await probeDurationSeconds(sourcePath);
441
+ if (duration !== null && duration > MAX_COVER_REFERENCE_SECONDS) {
442
+ throw new Error(`Reference audio is ${Math.round(duration)}s — covers cap the input at 8 minutes. Export a shorter section.`);
443
+ }
444
+ const baseName = input.title?.trim() || `${basename(sourcePath, extname(sourcePath))} cover`.trim();
445
+ const projectId = input.projectId
446
+ ? (getProject(input.projectId)?.id ??
447
+ (() => {
448
+ throw new Error(`Project not found: ${input.projectId}`);
449
+ })())
450
+ : (sourceAsset?.projectId ?? (await createProject(baseName)).id);
451
+ // AIFF/FLAC → standardized WAV for upload (undocumented containers). The
452
+ // temp file is disposable the moment the upload returns — clean it on every
453
+ // path (the leak here was a fresh-eyes review finding).
454
+ let uploadUrl;
455
+ let tempUpload = null;
456
+ try {
457
+ let uploadSource = sourcePath;
458
+ const ext = extname(sourcePath).toLowerCase();
459
+ if (ext !== '.wav' && ext !== '.mp3') {
460
+ const { tmpdir } = await import('node:os');
461
+ tempUpload = join(tmpdir(), `aurora-cover-upload-${Date.now()}.wav`);
462
+ await standardizeToWav(sourcePath, tempUpload);
463
+ uploadSource = tempUpload;
464
+ }
465
+ uploadUrl = await uploadAudioFile(uploadSource);
466
+ }
467
+ finally {
468
+ if (tempUpload) {
469
+ const { rm } = await import('node:fs/promises');
470
+ await rm(tempUpload, { force: true }).catch(() => { });
471
+ }
472
+ }
473
+ const taskId = await createCover({
474
+ uploadUrl,
475
+ prompt: input.prompt,
476
+ style: input.style,
477
+ title: input.title,
478
+ instrumental: input.instrumental ?? false,
479
+ customMode,
480
+ model,
481
+ vocalGender: input.vocalGender,
482
+ negativeTags: input.negativeTags,
483
+ audioWeight: input.audioWeight
484
+ });
485
+ const manifest = newJobManifest('cover', `cov-${uuidv4().slice(0, 8)}`, projectId, baseName, {
486
+ prompt: input.prompt,
487
+ style: input.style,
488
+ title: input.title,
489
+ instrumental: input.instrumental ?? false,
490
+ model,
491
+ vocalGender: input.vocalGender ?? null,
492
+ negativeTags: input.negativeTags,
493
+ audioWeight: input.audioWeight
494
+ }, { taskId, sourceAssetId: sourceAsset?.id ?? null });
495
+ await saveJob(manifest);
496
+ if (input.background) {
497
+ return ok(jobSummary(manifest), jobText(manifest));
498
+ }
499
+ const finished = await awaitJob(manifest);
500
+ // Blocking-mode WAV stage (mirrors the app's runCover best-effort WAVs).
501
+ const wavNotes = [];
502
+ if (finished.status === 'done' && (input.fetchWav ?? true)) {
503
+ for (const assetId of finished.assetIds) {
504
+ try {
505
+ await fetchWavOp.run({ assetId });
506
+ wavNotes.push(`${assetId}: WAV fetched`);
507
+ }
508
+ catch (err) {
509
+ wavNotes.push(`${assetId}: WAV failed (${err instanceof Error ? err.message : err}) — MP3 kept; retry with aurora_fetch_wav`);
510
+ }
511
+ }
512
+ }
513
+ const summary = jobSummary(finished);
514
+ if (wavNotes.length > 0)
515
+ summary.wavStage = wavNotes;
516
+ return ok(summary, `${jobText(finished)}${wavNotes.length > 0 ? ` WAV stage: ${wavNotes.join('; ')}` : ''}`);
517
+ }
518
+ };
519
+ const splitOp = {
520
+ id: 'aurora_split',
521
+ description: 'Split ANY project asset into 7 stems (vocals, kick, snare, toms, hats, bass, everything-else) via ' +
522
+ '3 MVSEP jobs + local phase-cancellation. Takes 3-5+ minutes. PAID (REAL MVSEP credits — do not ' +
523
+ 're-split an asset that already has stems; check aurora_list_assets first). Stems land ' +
524
+ 'PROGRESSIVELY as each MVSEP job finishes. ' +
525
+ BACKGROUND_DESCRIBE,
526
+ input: z.object({
527
+ assetId: z.string().describe('The asset to split (generation, cover, import, or reference)'),
528
+ background: z.boolean().optional().describe('Strongly recommended — splits often exceed blocking-call ceilings')
529
+ }),
530
+ async run(input) {
531
+ const existing = getStems(input.assetId);
532
+ if (existing.length >= 7) {
533
+ return ok({ stems: existing }, 'This asset ALREADY has a full 7-stem split — returning the existing stems instead of spending ' +
534
+ 'MVSEP credits again. Delete the stems first if you really want a re-split.');
535
+ }
536
+ const prep = await prepareSplit(input.assetId);
537
+ const hashes = await createSplitJobs(prep.audioBytes);
538
+ const manifest = newJobManifest('split', `spl-${uuidv4().slice(0, 8)}`, prep.asset.projectId, prep.asset.name, { assetId: input.assetId }, { assetId: input.assetId, stemsDir: prep.stemsDir, hashes });
539
+ manifest.stage = 'separating (0/3 jobs landed)';
540
+ await saveJob(manifest);
541
+ if (input.background) {
542
+ return ok(jobSummary(manifest), jobText(manifest));
543
+ }
544
+ const finished = await awaitJob(manifest);
545
+ return ok(jobSummary(finished), jobText(finished));
546
+ }
547
+ };
548
+ const getJobStatusOp = {
549
+ id: 'aurora_get_job_status',
550
+ description: 'Poll a background job (generate / sounds / cover / split). Advances the job: downloads and ' +
551
+ 'registers whatever the provider has finished since the last poll (split stems land progressively). ' +
552
+ 'Returns streamUrls for in-progress generations — playable immediately.',
553
+ input: z.object({ jobId: z.string() }),
554
+ async run(input) {
555
+ const manifest = await loadJob(input.jobId);
556
+ if (!manifest)
557
+ throw new Error(`Job not found: ${input.jobId}. Use aurora_list_jobs.`);
558
+ const advanced = manifest.status === 'running' ? await advanceJob(manifest) : manifest;
559
+ return ok(jobSummary(advanced), jobText(advanced));
560
+ }
561
+ };
562
+ const listJobsOp = {
563
+ id: 'aurora_list_jobs',
564
+ description: 'List all background jobs (newest first) with status and landed outputs.',
565
+ input: z.object({}).strict(),
566
+ async run() {
567
+ const jobs = await listJobs();
568
+ return ok({ jobs: jobs.map(jobSummary) });
569
+ }
570
+ };
571
+ // ── Audio utilities (local ffmpeg — free) ───────────────────────
572
+ const pitchShiftOp = {
573
+ id: 'aurora_pitch_shift',
574
+ description: 'Pitch-shift an asset or audio file by +/- semitones (local ffmpeg, FREE, output locked to 44.1kHz). ' +
575
+ 'Default varispeed (tempo shifts with pitch); preserveTempo keeps tempo constant. If the input was a ' +
576
+ 'project asset, the result registers as a new import asset in the same project.',
577
+ input: z.object({
578
+ assetId: z.string().optional(),
579
+ path: z.string().optional().describe('OR an absolute file path'),
580
+ semitones: z.number().describe('e.g. 3, -2, 0.5'),
581
+ preserveTempo: z.boolean().optional(),
582
+ format: z.enum(['wav', 'mp3']).optional().describe('Output format (default wav)')
583
+ }),
584
+ async run(input) {
585
+ const { path, asset } = resolveAudioInput(input);
586
+ const format = input.format ?? 'wav';
587
+ const stem = basename(path, extname(path));
588
+ const sign = input.semitones >= 0 ? '+' : '';
589
+ const outPath = join(dirname(path), `${stem}${sign}${input.semitones}st.${format}`);
590
+ const engine = await pitchShift(path, outPath, input.semitones, input.preserveTempo ?? false, format);
591
+ let newAsset = null;
592
+ if (asset) {
593
+ newAsset = insertAsset({
594
+ projectId: asset.projectId,
595
+ kind: 'import',
596
+ name: basename(outPath, extname(outPath)),
597
+ path: outPath,
598
+ origin: { tool: 'pitch_shift', semitones: input.semitones, engine, sourceAssetId: asset.id },
599
+ sourceAssetId: asset.id
600
+ });
601
+ }
602
+ return ok({ outputPath: outPath, engine, asset: newAsset });
603
+ }
604
+ };
605
+ const convertOp = {
606
+ id: 'aurora_convert',
607
+ description: 'Convert an asset or audio file to WAV (44.1kHz stereo float32) or MP3 (320k CBR) via local ffmpeg ' +
608
+ '(FREE). If the input was a project asset, the result registers as a new import asset.',
609
+ input: z.object({
610
+ assetId: z.string().optional(),
611
+ path: z.string().optional(),
612
+ to: z.enum(['wav', 'mp3'])
613
+ }),
614
+ async run(input) {
615
+ const { path, asset } = resolveAudioInput(input);
616
+ const stem = basename(path, extname(path));
617
+ const sameExt = extname(path).toLowerCase() === `.${input.to}`;
618
+ const outPath = join(dirname(path), `${stem}${sameExt ? '-converted' : ''}.${input.to}`);
619
+ if (input.to === 'wav')
620
+ await standardizeToWav(path, outPath);
621
+ else
622
+ await convertToMp3(path, outPath);
623
+ let newAsset = null;
624
+ if (asset) {
625
+ newAsset = insertAsset({
626
+ projectId: asset.projectId,
627
+ kind: 'import',
628
+ name: basename(outPath, extname(outPath)),
629
+ path: outPath,
630
+ origin: { tool: 'convert', to: input.to, sourceAssetId: asset.id },
631
+ sourceAssetId: asset.id
632
+ });
633
+ }
634
+ return ok({ outputPath: outPath, asset: newAsset });
635
+ }
636
+ };
637
+ // ── Sidecars (local Python — free, require the aurora repo) ─────
638
+ const rvcUpscaleOp = {
639
+ id: 'aurora_rvc_upscale',
640
+ description: "RVC vocal upscale (local Python sidecar — FREE, but needs the aurora repo + sidecar deps; set the " +
641
+ 'AURORA_REPO env var). Input: a vocals stem (assetId + stemType "vocals") or any WAV path. Output ' +
642
+ 'lands next to the input as <name>_upscaled.wav.',
643
+ input: z.object({
644
+ assetId: z.string().optional().describe('Asset whose vocals stem to upscale'),
645
+ stemType: z.string().optional().describe('Stem to pick from the asset (default "vocals")'),
646
+ path: z.string().optional().describe('OR a direct WAV path'),
647
+ model: z.string().optional().describe("'jb' (default) or 'purposeaudacity'"),
648
+ f0UpKey: z.number().optional().describe('Pitch shift in semitones (default 0)')
649
+ }),
650
+ async run(input) {
651
+ let inputPath = input.path;
652
+ if (input.assetId) {
653
+ const stems = getStems(input.assetId);
654
+ const want = input.stemType ?? 'vocals';
655
+ const stem = stems.find((s) => s.stemType === want);
656
+ if (!stem) {
657
+ throw new Error(`Asset ${input.assetId} has no "${want}" stem. Split it first (aurora_split) or pass a direct path.`);
658
+ }
659
+ inputPath = stem.path;
660
+ }
661
+ if (!inputPath)
662
+ throw new Error('Provide assetId (+stemType) or path.');
663
+ const outPath = join(dirname(inputPath), `${basename(inputPath, extname(inputPath))}_upscaled.wav`);
664
+ await runRvcUpscale({ inputPath, outputPath: outPath, model: input.model, f0UpKey: input.f0UpKey });
665
+ return ok({ outputPath: outPath });
666
+ }
667
+ };
668
+ const ripMidiOp = {
669
+ id: 'aurora_rip_midi',
670
+ description: 'Rip MIDI from an audio stem or file (local Python sidecar — FREE, but needs the aurora repo + ' +
671
+ 'sidecar deps; set AURORA_REPO). drums→onset detection, mono→CREPE, poly→Basic Pitch. Output: <name>.mid ' +
672
+ 'next to the input.',
673
+ input: z.object({
674
+ assetId: z.string().optional(),
675
+ stemType: z.string().optional().describe('Which stem of the asset (e.g. "bass", "kick")'),
676
+ path: z.string().optional(),
677
+ mode: z.enum(['poly', 'mono', 'auto']).optional().describe('Transcription path (default auto)'),
678
+ instrument: z.string().optional().describe('Instrument hint for auto-routing, e.g. "bass", "kick"')
679
+ }),
680
+ async run(input) {
681
+ let inputPath = input.path;
682
+ let instrument = input.instrument;
683
+ if (input.assetId) {
684
+ const stems = getStems(input.assetId);
685
+ if (!input.stemType)
686
+ throw new Error('Pass stemType with assetId (e.g. "bass", "kick").');
687
+ const stem = stems.find((s) => s.stemType === input.stemType);
688
+ if (!stem)
689
+ throw new Error(`Asset ${input.assetId} has no "${input.stemType}" stem.`);
690
+ inputPath = stem.path;
691
+ instrument ??= input.stemType;
692
+ }
693
+ if (!inputPath)
694
+ throw new Error('Provide assetId+stemType or path.');
695
+ const outPath = join(dirname(inputPath), `${basename(inputPath, extname(inputPath))}.mid`);
696
+ await runRipMidi({ inputPath, outputPath: outPath, mode: input.mode ?? 'auto', instrument });
697
+ return ok({ outputPath: outPath });
698
+ }
699
+ };
700
+ // ── Stack (minimal layer view) ──────────────────────────────────
701
+ const stackListOp = {
702
+ id: 'aurora_stack_list',
703
+ description: "A project's stack lanes (the minimal layer view: name, path, offset, gain, mute/solo).",
704
+ input: z.object({ projectId: z.string() }),
705
+ async run(input) {
706
+ return ok({ lanes: await loadStack(input.projectId) });
707
+ }
708
+ };
709
+ const stackAddLaneOp = {
710
+ id: 'aurora_stack_add_lane',
711
+ description: 'Add a lane to the project stack from an asset, a stem (assetId + stemType), or a raw path. The app ' +
712
+ 'shows the same stack live (stack.json in the project folder).',
713
+ input: z.object({
714
+ projectId: z.string(),
715
+ assetId: z.string().optional(),
716
+ stemType: z.string().optional().describe('With assetId: pick that stem instead of the asset audio'),
717
+ path: z.string().optional(),
718
+ name: z.string().optional(),
719
+ offsetSec: z.number().min(0).optional().describe('Clip start offset from timeline zero (default 0)'),
720
+ gainDb: z.number().optional().describe('Lane gain in dB (default 0)')
721
+ }),
722
+ async run(input) {
723
+ let path = input.path;
724
+ let sourceId;
725
+ let name = input.name;
726
+ if (input.assetId) {
727
+ if (input.stemType) {
728
+ const stem = getStems(input.assetId).find((s) => s.stemType === input.stemType);
729
+ if (!stem)
730
+ throw new Error(`Asset ${input.assetId} has no "${input.stemType}" stem.`);
731
+ path = stem.path;
732
+ sourceId = stem.id;
733
+ name ??= input.stemType;
734
+ }
735
+ else {
736
+ const asset = getAsset(input.assetId);
737
+ if (!asset)
738
+ throw new Error(`Asset not found: ${input.assetId}`);
739
+ path = asset.path;
740
+ sourceId = asset.id;
741
+ name ??= asset.name;
742
+ }
743
+ }
744
+ if (!path)
745
+ throw new Error('Provide assetId (optionally with stemType) or path.');
746
+ name ??= laneNameFromPath(path);
747
+ const lane = await addLane(input.projectId, {
748
+ name,
749
+ path,
750
+ sourceId,
751
+ offsetSec: input.offsetSec,
752
+ gainDb: input.gainDb
753
+ });
754
+ return ok({ lane });
755
+ }
756
+ };
757
+ const stackUpdateLaneOp = {
758
+ id: 'aurora_stack_update_lane',
759
+ description: "Update a stack lane's offset, gain, mute, solo, or name.",
760
+ input: z.object({
761
+ projectId: z.string(),
762
+ laneId: z.string(),
763
+ offsetSec: z.number().min(0).optional(),
764
+ gainDb: z.number().optional(),
765
+ mute: z.boolean().optional(),
766
+ solo: z.boolean().optional(),
767
+ name: z.string().optional()
768
+ }),
769
+ async run(input) {
770
+ const { projectId, laneId, ...patch } = input;
771
+ const lane = await updateLane(projectId, laneId, patch);
772
+ return ok({ lane });
773
+ }
774
+ };
775
+ const stackRemoveLaneOp = {
776
+ id: 'aurora_stack_remove_lane',
777
+ description: 'Remove a lane from the project stack.',
778
+ input: z.object({ projectId: z.string(), laneId: z.string() }),
779
+ async run(input) {
780
+ await removeLane(input.projectId, input.laneId);
781
+ return ok({ removed: input.laneId });
782
+ }
783
+ };
784
+ const stackExportOp = {
785
+ id: 'aurora_stack_export',
786
+ description: 'Export the stack as an aligned multi-WAV bundle: one 32-bit float WAV per audible lane, padded ' +
787
+ 'with leading silence to common timeline zero — drop them all at time zero in any DAW and ' +
788
+ 'everything lines up sample-accurately. Lands in <project>/stack-export/.',
789
+ input: z.object({ projectId: z.string() }),
790
+ async run(input) {
791
+ const paths = await exportStackBundle(input.projectId);
792
+ return ok({ paths }, `Exported ${paths.length} aligned WAV(s):\n${paths.join('\n')}`);
793
+ }
794
+ };
795
+ // ── Skills delivery (MCP-only clients) ──────────────────────────
796
+ const getPromptingGuideOp = {
797
+ id: 'aurora_get_prompting_guide',
798
+ description: 'Read a bundled Aurora skill/guide (workflow recipes, Suno prompting, cost discipline, stems). ' +
799
+ 'Call with no topic to list available guides. CLI users: `aurora install-skills` installs these ' +
800
+ 'into .claude/skills/ instead.',
801
+ input: z.object({
802
+ topic: z.string().optional().describe('Guide name (from the no-topic listing) or a keyword')
803
+ }),
804
+ async run(input) {
805
+ const names = Object.keys(SKILLS);
806
+ if (!input.topic) {
807
+ return ok({ guides: names }, `Available guides:\n${names.join('\n')}\nCall again with topic:<name>.`);
808
+ }
809
+ const exact = SKILLS[input.topic];
810
+ if (exact)
811
+ return ok({ name: input.topic, content: exact }, exact);
812
+ const fuzzy = names.find((n) => n.includes(input.topic.toLowerCase()));
813
+ if (fuzzy)
814
+ return ok({ name: fuzzy, content: SKILLS[fuzzy] }, SKILLS[fuzzy]);
815
+ throw new Error(`No guide matching "${input.topic}". Available: ${names.join(', ')}`);
816
+ }
817
+ };
818
+ // ── Registry ────────────────────────────────────────────────────
819
+ export const ALL_OPERATIONS = [
820
+ getCredits,
821
+ getWorkspaceState,
822
+ createProjectOp,
823
+ listProjectsOp,
824
+ renameProjectOp,
825
+ deleteProjectOp,
826
+ listAssetsOp,
827
+ importFileOp,
828
+ addReferenceOp,
829
+ deleteAssetOp,
830
+ fetchWavOp,
831
+ generateOp,
832
+ soundsOp,
833
+ coverOp,
834
+ splitOp,
835
+ getJobStatusOp,
836
+ listJobsOp,
837
+ pitchShiftOp,
838
+ convertOp,
839
+ rvcUpscaleOp,
840
+ ripMidiOp,
841
+ stackListOp,
842
+ stackAddLaneOp,
843
+ stackUpdateLaneOp,
844
+ stackRemoveLaneOp,
845
+ stackExportOp,
846
+ getPromptingGuideOp
847
+ ];
848
+ //# sourceMappingURL=index.js.map