@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.
- package/README.md +9 -0
- package/dist/audio/ffmpeg.d.ts +21 -0
- package/dist/audio/ffmpeg.js +112 -0
- package/dist/audio/wav.d.ts +15 -0
- package/dist/audio/wav.js +159 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.js +50 -0
- package/dist/db.d.ts +3 -0
- package/dist/db.js +121 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/jobs.d.ts +45 -0
- package/dist/jobs.js +220 -0
- package/dist/operations/index.d.ts +12 -0
- package/dist/operations/index.js +848 -0
- package/dist/paths.d.ts +17 -0
- package/dist/paths.js +79 -0
- package/dist/providers/mvsep.d.ts +27 -0
- package/dist/providers/mvsep.js +112 -0
- package/dist/providers/suno.d.ts +89 -0
- package/dist/providers/suno.js +309 -0
- package/dist/sidecars.d.ts +20 -0
- package/dist/sidecars.js +109 -0
- package/dist/skills/content.d.ts +1 -0
- package/dist/skills/content.js +9 -0
- package/dist/split.d.ts +24 -0
- package/dist/split.js +162 -0
- package/dist/stack.d.ts +19 -0
- package/dist/stack.js +139 -0
- package/dist/storage/assets.d.ts +30 -0
- package/dist/storage/assets.js +103 -0
- package/dist/storage/projects.d.ts +12 -0
- package/dist/storage/projects.js +85 -0
- package/dist/storage/references.d.ts +10 -0
- package/dist/storage/references.js +54 -0
- package/dist/storage/stems.d.ts +13 -0
- package/dist/storage/stems.js +41 -0
- package/dist/types.d.ts +72 -0
- package/dist/types.js +5 -0
- package/package.json +51 -0
- package/skills/aurora-cost-discipline.md +31 -0
- package/skills/aurora-music-production.md +43 -0
- package/skills/aurora-split-and-stems.md +33 -0
- package/skills/aurora-suno-prompting.md +35 -0
package/dist/sidecars.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// Python sidecar spawns (RVC vocal upscale + MIDI rip) — mirrors aurora's
|
|
2
|
+
// resolve/spawn pattern (src/main/rvc/upscale.ts, src/main/midi/rip.ts) without
|
|
3
|
+
// Electron. The sidecars live in the aurora repo (dev) or the installed app's
|
|
4
|
+
// resources (packaged); neither ships in this npm package. Resolution order:
|
|
5
|
+
// 1. AURORA_RVC_SIDECAR / AURORA_MIDI_SIDECAR — direct path to the exe
|
|
6
|
+
// 2. AURORA_REPO/<sidecar>/dist/<exe> (frozen dev build)
|
|
7
|
+
// 3. AURORA_REPO/<sidecar>/main.py via python (dev, deps installed)
|
|
8
|
+
// 4. installed app resources (best-effort known install locations)
|
|
9
|
+
// Missing → a clear, actionable error (the sidecars also need their Python
|
|
10
|
+
// deps / PyInstaller freeze — see aurora/CLAUDE.md Status).
|
|
11
|
+
import { spawn } from 'node:child_process';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { mkdir } from 'node:fs/promises';
|
|
14
|
+
import { homedir } from 'node:os';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
function resolveSidecar(kind) {
|
|
17
|
+
const dirName = kind === 'rvc' ? 'sidecar-rvc' : 'sidecar-midi';
|
|
18
|
+
const exeName = (kind === 'rvc' ? 'aurora-rvc' : 'aurora-midi') + (process.platform === 'win32' ? '.exe' : '');
|
|
19
|
+
const searched = [];
|
|
20
|
+
const direct = process.env[kind === 'rvc' ? 'AURORA_RVC_SIDECAR' : 'AURORA_MIDI_SIDECAR'];
|
|
21
|
+
if (direct) {
|
|
22
|
+
searched.push(direct);
|
|
23
|
+
if (existsSync(direct))
|
|
24
|
+
return { resolved: { command: direct, baseArgs: [] }, searched };
|
|
25
|
+
}
|
|
26
|
+
const repo = process.env.AURORA_REPO;
|
|
27
|
+
if (repo) {
|
|
28
|
+
const frozen = join(repo, dirName, 'dist', exeName);
|
|
29
|
+
searched.push(frozen);
|
|
30
|
+
if (existsSync(frozen))
|
|
31
|
+
return { resolved: { command: frozen, baseArgs: [] }, searched };
|
|
32
|
+
const mainPy = join(repo, dirName, 'main.py');
|
|
33
|
+
searched.push(mainPy);
|
|
34
|
+
if (existsSync(mainPy)) {
|
|
35
|
+
const python = process.platform === 'win32' ? 'python' : 'python3';
|
|
36
|
+
return { resolved: { command: python, baseArgs: [mainPy] }, searched };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Installed-app resources (packaged layouts).
|
|
40
|
+
const installCandidates = process.platform === 'win32'
|
|
41
|
+
? [join(homedir(), 'AppData', 'Local', 'Programs', 'Aurora', 'resources', dirName, exeName)]
|
|
42
|
+
: process.platform === 'darwin'
|
|
43
|
+
? [join('/Applications', 'Aurora.app', 'Contents', 'Resources', dirName, exeName)]
|
|
44
|
+
: [];
|
|
45
|
+
for (const c of installCandidates) {
|
|
46
|
+
searched.push(c);
|
|
47
|
+
if (existsSync(c))
|
|
48
|
+
return { resolved: { command: c, baseArgs: [] }, searched };
|
|
49
|
+
}
|
|
50
|
+
return { resolved: null, searched };
|
|
51
|
+
}
|
|
52
|
+
function runSidecar(resolved, args) {
|
|
53
|
+
return new Promise((resolve, reject) => {
|
|
54
|
+
const proc = spawn(resolved.command, [...resolved.baseArgs, ...args], { windowsHide: true });
|
|
55
|
+
let stderr = '';
|
|
56
|
+
proc.stderr.on('data', (d) => {
|
|
57
|
+
stderr += d.toString();
|
|
58
|
+
});
|
|
59
|
+
proc.stdout.on('data', () => {
|
|
60
|
+
// JSON-lines progress — consumed silently in the MCP (blocking call).
|
|
61
|
+
});
|
|
62
|
+
proc.on('error', reject);
|
|
63
|
+
proc.on('close', (code) => {
|
|
64
|
+
if (code === 0)
|
|
65
|
+
resolve({ stderrTail: stderr.slice(-2000) });
|
|
66
|
+
else
|
|
67
|
+
reject(new Error(`sidecar exited ${code}: ${stderr.slice(-2000)}`));
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
export async function runRvcUpscale(params) {
|
|
72
|
+
const { resolved, searched } = resolveSidecar('rvc');
|
|
73
|
+
if (!resolved) {
|
|
74
|
+
throw new Error('RVC sidecar not found. Set AURORA_REPO to your aurora checkout (or AURORA_RVC_SIDECAR to the ' +
|
|
75
|
+
`frozen exe). Searched: ${searched.join(' | ') || '(no hints set)'}. ` +
|
|
76
|
+
'Note: the sidecar needs its Python deps installed (see aurora/sidecar-rvc/).');
|
|
77
|
+
}
|
|
78
|
+
if (!existsSync(params.inputPath))
|
|
79
|
+
throw new Error(`Input not found: ${params.inputPath}`);
|
|
80
|
+
await mkdir(dirname(params.outputPath), { recursive: true });
|
|
81
|
+
const args = ['--in', params.inputPath, '--out', params.outputPath, '--model', params.model || 'jb'];
|
|
82
|
+
if (typeof params.f0UpKey === 'number')
|
|
83
|
+
args.push('--f0-up-key', String(params.f0UpKey));
|
|
84
|
+
await runSidecar(resolved, args);
|
|
85
|
+
if (!existsSync(params.outputPath)) {
|
|
86
|
+
throw new Error(`RVC sidecar finished but produced no output at ${params.outputPath}`);
|
|
87
|
+
}
|
|
88
|
+
return params.outputPath;
|
|
89
|
+
}
|
|
90
|
+
export async function runRipMidi(params) {
|
|
91
|
+
const { resolved, searched } = resolveSidecar('midi');
|
|
92
|
+
if (!resolved) {
|
|
93
|
+
throw new Error('MIDI sidecar not found. Set AURORA_REPO to your aurora checkout (or AURORA_MIDI_SIDECAR to the ' +
|
|
94
|
+
`frozen exe). Searched: ${searched.join(' | ') || '(no hints set)'}. ` +
|
|
95
|
+
'Note: the sidecar needs its Python 3.9 deps installed (see aurora/sidecar-midi/).');
|
|
96
|
+
}
|
|
97
|
+
if (!existsSync(params.inputPath))
|
|
98
|
+
throw new Error(`Input not found: ${params.inputPath}`);
|
|
99
|
+
await mkdir(dirname(params.outputPath), { recursive: true });
|
|
100
|
+
const args = ['--in', params.inputPath, '--out', params.outputPath, '--mode', params.mode];
|
|
101
|
+
if (params.instrument)
|
|
102
|
+
args.push('--instrument', params.instrument);
|
|
103
|
+
await runSidecar(resolved, args);
|
|
104
|
+
if (!existsSync(params.outputPath)) {
|
|
105
|
+
throw new Error(`MIDI sidecar finished but produced no .mid at ${params.outputPath}`);
|
|
106
|
+
}
|
|
107
|
+
return params.outputPath;
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=sidecars.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const SKILLS: Record<string, string>;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// GENERATED — do not edit. Source: packages/shared/skills/*.md
|
|
2
|
+
// Regenerated by scripts/embed-skills.mjs on every build.
|
|
3
|
+
export const SKILLS = {
|
|
4
|
+
"aurora-cost-discipline": "---\nname: aurora-cost-discipline\ndescription: Credit and spend discipline for every paid Aurora operation (Suno generation/cover/sounds/WAV, MVSEP splits). Fires before any aurora_generate, aurora_cover, aurora_sounds, aurora_split, or aurora_fetch_wav call.\n---\n\n# Aurora Cost Discipline\n\nTwo metered providers sit behind Aurora's cloud ops. Spend is real money. The rules:\n\n## Always\n\n1. **`aurora_get_credits` BEFORE the first paid call of a session** — and after a batch, to log actual spend.\n2. **Never re-split.** `aurora_split` burns real MVSEP credits; the op refuses when 7 stems already exist — don't work around it. Check `aurora_list_assets` first.\n3. **Batch authorization, not per-call nagging.** When the user approves a multi-generation plan (\"make me 4 braams and a riser\"), that approval covers the enumerated batch — don't re-confirm each call. NEW spend beyond the approved batch needs a fresh ask.\n\n## Known costs (sunoapi.org credits, measured 2026-06-10)\n\n| Op | Cost |\n|---|---|\n| `aurora_sounds` | ~2.5 credits (~$0.0125) — the cheap verification + layer tool |\n| `aurora_cover` | ~12 credits + ~0.4 per WAV fetch |\n| `aurora_fetch_wav` | ~0.4 credits per conversion |\n| `aurora_generate` | not yet measured — check credits before/after and report the delta |\n| `aurora_split` | MVSEP credits, priced by audio duration (separate balance) |\n| `aurora_get_credits`, all local ffmpeg/stack/project ops | FREE |\n\n## Cheap-first ladder\n\n- Verifying a pipeline or experimenting? `aurora_sounds` first (2.5 credits), full `aurora_generate` only when the user wants a track.\n- Audition MP3s before paying for WAV upgrades; `aurora_fetch_wav` only the keepers.\n- Local ops (`aurora_pitch_shift`, `aurora_convert`, stack everything) cost nothing — prefer them over regenerating.\n",
|
|
5
|
+
"aurora-music-production": "---\nname: aurora-music-production\ndescription: End-to-end Aurora workflow — create a project, generate or cover tracks, manufacture sounds, split into 7 stems, layer in the stack, export aligned WAVs for the DAW. Use when driving Aurora (the AI audio workbench) for any music production task.\n---\n\n# Aurora Music Production Workflow\n\nAurora is the desktop layer between AI music generation and a real DAW: generate AI music, split anything into stems, keep it all organized. Files on disk ARE the product — everything you create lands in a real project folder the user can open, play, and drag into their DAW.\n\n## Session start\n\n1. `aurora_get_workspace_state` — projects list, key status, folder locations. Once per session.\n2. `aurora_get_credits` — Suno credits + MVSEP minutes. ALWAYS before paid calls (see aurora-cost-discipline).\n\n## The verbs\n\n- **Generate** (`aurora_generate`) — full track from a prompt. 2 variations land as assets. 1-3 min.\n- **Cover** (`aurora_cover`) — style-transform an existing asset or file: same musical content, new style. `audioWeight` is the dial: 0 = new style dominates, 1 = stay close to the source.\n- **Sounds** (`aurora_sounds`) — samples, one-shots, loops with key/tempo lock. Fast (~20-30s), cheap (~2.5 credits). The layer-manufacturing tool: braams, booms, transitions, textures.\n- **Split** (`aurora_split`) — ANY asset → 7 stems (vocals, kick, snare, toms, hats, bass, everything-else). REAL MVSEP credits; never re-split (the op refuses if 7 stems exist).\n- **Stack** (`aurora_stack_*`) — layer assets/stems on lanes with offsets and gain, then `aurora_stack_export` for a sample-aligned multi-WAV bundle (drop at time zero in any DAW).\n\n## Long-op discipline\n\nGeneration and splits take minutes. Prefer `background: true` + `aurora_get_job_status` polling every 10-20s:\n\n- Status responses include `streamUrls` while a generation is still cooking — give the user the link, they can LISTEN ~30-45s in, minutes before files land.\n- Split stems land PROGRESSIVELY: vocals/kick/snare/toms/hats/bass appear as each MVSEP job finishes; everything-else (ee) lands last.\n- Jobs survive restarts — `aurora_list_jobs` recovers anything in flight.\n\n## Files + organization\n\n- Project folder: `generations/ covers/ imports/ references/ stems/<asset>/ masters/ stack-export/ stack.json`.\n- MP3 lands first; `aurora_fetch_wav` upgrades a generation/cover to provider WAV (~0.4 credits).\n- `aurora_pitch_shift` and `aurora_convert` are FREE local ffmpeg ops.\n- Mastering (analyze → mix → export) lives in the Aurora app window — point the user there once stems exist; it is not agent-drivable yet.\n\n## Suno prompting quick rules\n\n- Custom mode = set `style` AND `title` together; then `prompt` carries the LYRICS.\n- Non-custom mode: `prompt` is a track description.\n- `negativeTags` is ONE comma-separated string (\"Heavy Metal, Upbeat Drums\").\n- Sounds prompts: concrete and physical (\"huge cinematic braam, dark low brass, trailer hit\"), max 500 chars, lock `soundKey`/`tempo` when the track they'll sit in is known.\n",
|
|
6
|
+
"aurora-split-and-stems": "---\nname: aurora-split-and-stems\ndescription: How Aurora's 7-stem split works (3 MVSEP jobs + phase cancellation), what the stems are, progressive landing, cost rules, and where stems live on disk. Use when calling aurora_split or working with split stems.\n---\n\n# Aurora Split & Stems\n\n## The 7 stems\n\n`vocals, kick, snare, toms, hats, bass, ee` (everything-else). Only 5 come from MVSEP; **hats** and **ee** are synthesized locally by phase cancellation (hats = drums − kick − snare − toms; ee = original − vocals − drums − bass). This is why ee always lands LAST.\n\n## How a split runs\n\n3 parallel MVSEP jobs (vocals model, drum separation, bass model) on one standardized 44.1kHz float32 WAV. Stems land **progressively** as each job finishes:\n\n- vocals job → `vocals`\n- drums job → `kick`, `snare`, `toms`, `hats`\n- bass job → `bass`\n- all three done → `ee`\n\nWith `background: true`, `aurora_get_job_status` shows the per-job landing state — the user can start auditioning early stems while the rest cook. Typical total: 3-5 minutes (longer if the MVSEP queue is busy — free-tier keys run 1 concurrent job, so the 3 jobs may serialize).\n\n## Cost rules\n\n- REAL MVSEP credits, priced by audio duration. Check `aurora_get_credits` (mvsepPremiumMinutes) first.\n- **Never re-split**: the op returns existing stems instead of spending again when a full set exists.\n- Any asset kind splits: generations, covers, imports, AND references (split-a-reference is a first-class loop for studying an arrangement).\n\n## On disk\n\nStems live at `<project>/stems/<asset-slug>-<id6>/*.wav` — 32-bit float, sample-aligned by construction. They are DAW-ready files: stack them (`aurora_stack_add_lane` with `stemType`), pitch them (`aurora_pitch_shift`), rip MIDI from them (`aurora_rip_midi`), or point the user at the folder.\n\nMastering against a reference (analyze → mix → export) happens in the Aurora app window from any split set — not agent-drivable yet.\n",
|
|
7
|
+
"aurora-suno-prompting": "---\nname: aurora-suno-prompting\ndescription: Prompting guide for Aurora's Suno-backed generation ops — generate (full tracks), cover (style transforms, the audioWeight dial), and sounds (samples/one-shots/loops with key+tempo lock). Use when writing prompts for aurora_generate, aurora_cover, or aurora_sounds.\n---\n\n# Suno Prompting for Aurora\n\n## aurora_generate — full tracks\n\nTwo modes, switched by whether you set `style`/`title`:\n\n- **Description mode** (no style/title): `prompt` describes the track — genre, mood, instrumentation, tempo feel, structure. One coherent paragraph beats keyword soup.\n- **Custom mode** (`style` AND `title` set): `prompt` carries the LYRICS; `style` carries the genre/production language. The provider requires BOTH style and title together.\n\nControls: `instrumental: true` for no vocals; `vocalGender` male/female; `negativeTags` as ONE comma-separated string of styles to avoid (\"Heavy Metal, Upbeat Drums\").\n\n## aurora_cover — style transforms\n\nThe source's musical content (melody, structure) is kept; the style is replaced.\n\n- `audioWeight` is the single most important dial: **0 = the new style dominates, 1 = stay close to the source.** 0.5-0.7 is the useful middle for \"same song, new genre\".\n- Custom mode rules are the same: `style` + `title` together, prompt describes the transformation target.\n- Source cap: 8 minutes. Project asset (`sourceAssetId`) or external file (`sourcePath`).\n\n## aurora_sounds — samples, one-shots, loops\n\nThe layer-manufacturing tool. Prompts are short (max 500 chars), physical, and concrete:\n\n- Name the sound type: braam, boom, riser, downer, whoosh, impact, drone, texture, loop.\n- Describe the material: \"dark low brass\", \"metallic scrape\", \"sub-heavy 808\", \"airy granular pad\".\n- Context helps: \"trailer hit\", \"transition\", \"intro swell\".\n- Lock `soundKey` (e.g. \"Cm\", \"F#\") and `tempo` (BPM) when the destination track is known — this is the point of the tool.\n- `loop: true` for loopable textures/grooves.\n\nEach call returns 2 variations — audition both before generating more.\n",
|
|
8
|
+
};
|
|
9
|
+
//# sourceMappingURL=content.js.map
|
package/dist/split.d.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { MvsepJobSpec, ProjectAsset, ProjectStem, SeparationResultFile } from './types.js';
|
|
2
|
+
export type SplitJobName = 'vocals' | 'drumsep' | 'bass';
|
|
3
|
+
export declare const JOB_SPECS: Record<SplitJobName, MvsepJobSpec>;
|
|
4
|
+
export interface SplitPreparation {
|
|
5
|
+
asset: ProjectAsset;
|
|
6
|
+
stemsDir: string;
|
|
7
|
+
originalPath: string;
|
|
8
|
+
audioBytes: Buffer;
|
|
9
|
+
}
|
|
10
|
+
/** Step 0: resolve the asset + standardize its audio (the single preprocessed
|
|
11
|
+
* file all 3 jobs AND the ee reference use). */
|
|
12
|
+
export declare function prepareSplit(assetId: string): Promise<SplitPreparation>;
|
|
13
|
+
/** Step 1: fire the 3 CREATE jobs with the contract's 2s stagger. Returns the
|
|
14
|
+
* provider hashes (persist these — they survive process restarts). */
|
|
15
|
+
export declare function createSplitJobs(audioBytes: Buffer): Promise<Record<SplitJobName, string>>;
|
|
16
|
+
/** Land ONE finished job's stems (progressive). Returns the stem rows created.
|
|
17
|
+
* Idempotent per job — re-running re-downloads and upserts in place. */
|
|
18
|
+
export declare function landSplitJob(job: SplitJobName, files: SeparationResultFile[], asset: ProjectAsset, stemsDir: string): Promise<ProjectStem[]>;
|
|
19
|
+
/** Final step once ALL THREE jobs have landed: synthesize ee
|
|
20
|
+
* (original − vocals − drums-bus − bass), then drop the drums-bus intermediate. */
|
|
21
|
+
export declare function finalizeSplit(asset: ProjectAsset, stemsDir: string): Promise<ProjectStem>;
|
|
22
|
+
/** Blocking end-to-end split with progressive landing — each job's stems
|
|
23
|
+
* register as that job finishes. Returns all 7 stem rows. */
|
|
24
|
+
export declare function runSplitBlocking(assetId: string, onProgress?: (stage: string) => void): Promise<ProjectStem[]>;
|
package/dist/split.js
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// 3-job MVSEP split orchestration — port of aurora/src/main/split/orchestrate.ts
|
|
2
|
+
// (real-call verified 2026-06-10), restructured for PER-STEM PROGRESSIVE
|
|
3
|
+
// LANDING: each MVSEP job's stems register the moment that job finishes instead
|
|
4
|
+
// of gating on all three. Dependency graph:
|
|
5
|
+
// vocals job (40) → vocals
|
|
6
|
+
// drumsep job (37) → kick, snare, toms, + hats (= drums − kick − snare − toms)
|
|
7
|
+
// bass job (41) → bass
|
|
8
|
+
// ee → needs ALL THREE (original − vocals − drums − bass)
|
|
9
|
+
// Contract: aurora/docs/build-specs/mvsep-separation-contract.md.
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { awaitSeparationResult, createSeparationJob } from './providers/mvsep.js';
|
|
14
|
+
import { getAsset, getAssetStemsDir } from './storage/assets.js';
|
|
15
|
+
import { upsertStem } from './storage/stems.js';
|
|
16
|
+
import { standardizeToWav } from './audio/ffmpeg.js';
|
|
17
|
+
import { decodeWavFile, encodeWavFloat32File, subtractWavs } from './audio/wav.js';
|
|
18
|
+
const CONCURRENT_DELAY_MS = 2000; // contract CONCURRENT_DELAY=2
|
|
19
|
+
const OUTPUT_FORMAT_WAV32 = '4';
|
|
20
|
+
const IS_DEMO = '0';
|
|
21
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
22
|
+
// The 3 CREATE jobs (contract Part A; re-verified vs live algorithms 2026-06-10).
|
|
23
|
+
export const JOB_SPECS = {
|
|
24
|
+
// BS Roformer ver 2025.07 → vocals + instrumental.
|
|
25
|
+
vocals: { sep_type: '40', output_format: OUTPUT_FORMAT_WAV32, is_demo: IS_DEMO, add_opt1: '81' },
|
|
26
|
+
// DrumSep MelBand Roformer 6-stem. add_opt1=7 is MANDATORY.
|
|
27
|
+
drumsep: { sep_type: '37', output_format: OUTPUT_FORMAT_WAV32, is_demo: IS_DEMO, add_opt1: '7', add_opt2: '0' },
|
|
28
|
+
// MVSep Bass, BS Roformer SW + SCNet XL → bass + other.
|
|
29
|
+
bass: { sep_type: '41', output_format: OUTPUT_FORMAT_WAV32, is_demo: IS_DEMO, add_opt1: '5', add_opt2: '0' }
|
|
30
|
+
};
|
|
31
|
+
/** Find a file by lowercased-filename predicate, else throw a clear error. */
|
|
32
|
+
function pickFile(files, match) {
|
|
33
|
+
const hit = files.find((f) => match(f.filename.toLowerCase()) || match(f.url.toLowerCase()));
|
|
34
|
+
if (!hit) {
|
|
35
|
+
throw new Error(`Could not locate expected stem in MVSEP result. Files: ${files.map((f) => f.filename).join(', ')}`);
|
|
36
|
+
}
|
|
37
|
+
return hit.url;
|
|
38
|
+
}
|
|
39
|
+
async function downloadTo(url, destPath) {
|
|
40
|
+
const res = await fetch(url);
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw new Error(`Stem download failed (HTTP ${res.status}): ${url}`);
|
|
43
|
+
const buf = Buffer.from(await res.arrayBuffer());
|
|
44
|
+
await writeFile(destPath, buf);
|
|
45
|
+
}
|
|
46
|
+
/** Step 0: resolve the asset + standardize its audio (the single preprocessed
|
|
47
|
+
* file all 3 jobs AND the ee reference use). */
|
|
48
|
+
export async function prepareSplit(assetId) {
|
|
49
|
+
const asset = getAsset(assetId);
|
|
50
|
+
if (!asset)
|
|
51
|
+
throw new Error(`Asset not found: ${assetId}`);
|
|
52
|
+
if (!existsSync(asset.path)) {
|
|
53
|
+
throw new Error(`Asset audio file is missing on disk: ${asset.path}`);
|
|
54
|
+
}
|
|
55
|
+
const stemsDir = getAssetStemsDir(asset);
|
|
56
|
+
await mkdir(stemsDir, { recursive: true });
|
|
57
|
+
const originalPath = join(stemsDir, 'original.wav');
|
|
58
|
+
await standardizeToWav(asset.path, originalPath);
|
|
59
|
+
const audioBytes = await readFile(originalPath);
|
|
60
|
+
return { asset, stemsDir, originalPath, audioBytes };
|
|
61
|
+
}
|
|
62
|
+
/** Step 1: fire the 3 CREATE jobs with the contract's 2s stagger. Returns the
|
|
63
|
+
* provider hashes (persist these — they survive process restarts). */
|
|
64
|
+
export async function createSplitJobs(audioBytes) {
|
|
65
|
+
const vocals = await createSeparationJob(audioBytes, JOB_SPECS.vocals);
|
|
66
|
+
await sleep(CONCURRENT_DELAY_MS);
|
|
67
|
+
const drumsep = await createSeparationJob(audioBytes, JOB_SPECS.drumsep);
|
|
68
|
+
await sleep(CONCURRENT_DELAY_MS);
|
|
69
|
+
const bass = await createSeparationJob(audioBytes, JOB_SPECS.bass);
|
|
70
|
+
return { vocals: vocals.hash, drumsep: drumsep.hash, bass: bass.hash };
|
|
71
|
+
}
|
|
72
|
+
/** Land ONE finished job's stems (progressive). Returns the stem rows created.
|
|
73
|
+
* Idempotent per job — re-running re-downloads and upserts in place. */
|
|
74
|
+
export async function landSplitJob(job, files, asset, stemsDir) {
|
|
75
|
+
const { projectId } = asset;
|
|
76
|
+
const rows = [];
|
|
77
|
+
if (job === 'vocals') {
|
|
78
|
+
const url = pickFile(files, (n) => /vocal/.test(n) && !/no_vocal|instrumental/.test(n));
|
|
79
|
+
const dest = join(stemsDir, 'vocals.wav');
|
|
80
|
+
await downloadTo(url, dest);
|
|
81
|
+
rows.push(upsertStem({ projectId, assetId: asset.id, stemType: 'vocals', path: dest, origin: 'mvsep' }));
|
|
82
|
+
return rows;
|
|
83
|
+
}
|
|
84
|
+
if (job === 'bass') {
|
|
85
|
+
const url = pickFile(files, (n) => /bass/.test(n) && !/no_bass|other/.test(n));
|
|
86
|
+
const dest = join(stemsDir, 'bass.wav');
|
|
87
|
+
await downloadTo(url, dest);
|
|
88
|
+
rows.push(upsertStem({ projectId, assetId: asset.id, stemType: 'bass', path: dest, origin: 'mvsep' }));
|
|
89
|
+
return rows;
|
|
90
|
+
}
|
|
91
|
+
// drumsep: kick, snare, toms + the full drums bus → hats by phase cancel.
|
|
92
|
+
const kickFile = join(stemsDir, 'kick.wav');
|
|
93
|
+
const snareFile = join(stemsDir, 'snare.wav');
|
|
94
|
+
const tomsFile = join(stemsDir, 'toms.wav');
|
|
95
|
+
const drumsBusFile = join(stemsDir, 'drums-bus.wav');
|
|
96
|
+
await Promise.all([
|
|
97
|
+
downloadTo(pickFile(files, (n) => /kick/.test(n)), kickFile),
|
|
98
|
+
downloadTo(pickFile(files, (n) => /snare/.test(n)), snareFile),
|
|
99
|
+
downloadTo(pickFile(files, (n) => /tom/.test(n)), tomsFile),
|
|
100
|
+
downloadTo(pickFile(files, (n) => /drums\.wav/.test(n)), drumsBusFile)
|
|
101
|
+
]);
|
|
102
|
+
rows.push(upsertStem({ projectId, assetId: asset.id, stemType: 'kick', path: kickFile, origin: 'mvsep' }));
|
|
103
|
+
rows.push(upsertStem({ projectId, assetId: asset.id, stemType: 'snare', path: snareFile, origin: 'mvsep' }));
|
|
104
|
+
rows.push(upsertStem({ projectId, assetId: asset.id, stemType: 'toms', path: tomsFile, origin: 'mvsep' }));
|
|
105
|
+
// hats = drums − kick − snare − toms (only needs this job's files).
|
|
106
|
+
const [drumsBus, kick, snare, toms] = await Promise.all([
|
|
107
|
+
decodeWavFile(drumsBusFile),
|
|
108
|
+
decodeWavFile(kickFile),
|
|
109
|
+
decodeWavFile(snareFile),
|
|
110
|
+
decodeWavFile(tomsFile)
|
|
111
|
+
]);
|
|
112
|
+
const hats = subtractWavs(drumsBus, kick, snare, toms);
|
|
113
|
+
const hatsFile = join(stemsDir, 'hats.wav');
|
|
114
|
+
await encodeWavFloat32File(hatsFile, hats.channels, hats.sampleRate);
|
|
115
|
+
rows.push(upsertStem({ projectId, assetId: asset.id, stemType: 'hats', path: hatsFile, origin: 'synthesized' }));
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
/** Final step once ALL THREE jobs have landed: synthesize ee
|
|
119
|
+
* (original − vocals − drums-bus − bass), then drop the drums-bus intermediate. */
|
|
120
|
+
export async function finalizeSplit(asset, stemsDir) {
|
|
121
|
+
const originalPath = join(stemsDir, 'original.wav');
|
|
122
|
+
const drumsBusFile = join(stemsDir, 'drums-bus.wav');
|
|
123
|
+
const [original, vocals, drumsBus, bass] = await Promise.all([
|
|
124
|
+
decodeWavFile(originalPath),
|
|
125
|
+
decodeWavFile(join(stemsDir, 'vocals.wav')),
|
|
126
|
+
decodeWavFile(drumsBusFile),
|
|
127
|
+
decodeWavFile(join(stemsDir, 'bass.wav'))
|
|
128
|
+
]);
|
|
129
|
+
const ee = subtractWavs(original, vocals, drumsBus, bass);
|
|
130
|
+
const eeFile = join(stemsDir, 'ee.wav');
|
|
131
|
+
await encodeWavFloat32File(eeFile, ee.channels, ee.sampleRate);
|
|
132
|
+
const row = upsertStem({
|
|
133
|
+
projectId: asset.projectId,
|
|
134
|
+
assetId: asset.id,
|
|
135
|
+
stemType: 'ee',
|
|
136
|
+
path: eeFile,
|
|
137
|
+
origin: 'synthesized'
|
|
138
|
+
});
|
|
139
|
+
await unlink(drumsBusFile).catch(() => { });
|
|
140
|
+
return row;
|
|
141
|
+
}
|
|
142
|
+
/** Blocking end-to-end split with progressive landing — each job's stems
|
|
143
|
+
* register as that job finishes. Returns all 7 stem rows. */
|
|
144
|
+
export async function runSplitBlocking(assetId, onProgress) {
|
|
145
|
+
onProgress?.('Standardizing audio');
|
|
146
|
+
const prep = await prepareSplit(assetId);
|
|
147
|
+
onProgress?.('Submitting 3 MVSEP jobs');
|
|
148
|
+
const hashes = await createSplitJobs(prep.audioBytes);
|
|
149
|
+
const rows = [];
|
|
150
|
+
const land = async (job) => {
|
|
151
|
+
const files = await awaitSeparationResult({ hash: hashes[job] }, (s) => onProgress?.(`${job}: ${s}`));
|
|
152
|
+
onProgress?.(`${job}: downloading stems`);
|
|
153
|
+
rows.push(...(await landSplitJob(job, files, prep.asset, prep.stemsDir)));
|
|
154
|
+
onProgress?.(`${job}: landed`);
|
|
155
|
+
};
|
|
156
|
+
await Promise.all([land('vocals'), land('drumsep'), land('bass')]);
|
|
157
|
+
onProgress?.('Synthesizing everything-else (ee)');
|
|
158
|
+
rows.push(await finalizeSplit(prep.asset, prep.stemsDir));
|
|
159
|
+
onProgress?.('Split complete');
|
|
160
|
+
return rows;
|
|
161
|
+
}
|
|
162
|
+
//# sourceMappingURL=split.js.map
|
package/dist/stack.d.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { StackLane } from './types.js';
|
|
2
|
+
export declare function loadStack(projectId: string): Promise<StackLane[]>;
|
|
3
|
+
export declare function saveStack(projectId: string, lanes: StackLane[]): Promise<void>;
|
|
4
|
+
export declare function addLane(projectId: string, lane: {
|
|
5
|
+
name: string;
|
|
6
|
+
path: string;
|
|
7
|
+
sourceId?: string;
|
|
8
|
+
color?: string;
|
|
9
|
+
offsetSec?: number;
|
|
10
|
+
gainDb?: number;
|
|
11
|
+
}): Promise<StackLane>;
|
|
12
|
+
export declare function updateLane(projectId: string, laneId: string, patch: Partial<Pick<StackLane, 'offsetSec' | 'gainDb' | 'mute' | 'solo' | 'name'>>): Promise<StackLane>;
|
|
13
|
+
export declare function removeLane(projectId: string, laneId: string): Promise<void>;
|
|
14
|
+
/** Export one padded 32f WAV per audible lane into <project>/stack-export/.
|
|
15
|
+
* Non-WAV / non-44.1k lanes are standardized first (the renderer's
|
|
16
|
+
* decodeAudioData resamples to the 44.1k context; ffmpeg does it here). */
|
|
17
|
+
export declare function exportStackBundle(projectId: string): Promise<string[]>;
|
|
18
|
+
/** Resolve a friendly lane name for an audio path (filename sans extension). */
|
|
19
|
+
export declare function laneNameFromPath(path: string): string;
|
package/dist/stack.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Stack (minimal layer view) — file-level port. stack.json lives in the project
|
|
2
|
+
// folder (same shape the app's stackStore persists), and the export-bundle math
|
|
3
|
+
// is the Node port of stackStore.exportBundle: one 32-bit float WAV per audible
|
|
4
|
+
// lane, padded with leading silence to common timeline zero (sample-accurate
|
|
5
|
+
// offset rounding, gain baked in, muted/solo-excluded lanes skipped).
|
|
6
|
+
import { join, basename, extname } from 'node:path';
|
|
7
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
11
|
+
import { getProjectDirectory } from './storage/projects.js';
|
|
12
|
+
import { standardizeToWav } from './audio/ffmpeg.js';
|
|
13
|
+
import { decodeWavFile, encodeWavFloat32File } from './audio/wav.js';
|
|
14
|
+
const SAMPLE_RATE = 44100;
|
|
15
|
+
function stackPath(projectId) {
|
|
16
|
+
return join(getProjectDirectory(projectId), 'stack.json');
|
|
17
|
+
}
|
|
18
|
+
export async function loadStack(projectId) {
|
|
19
|
+
const file = stackPath(projectId);
|
|
20
|
+
if (!existsSync(file))
|
|
21
|
+
return [];
|
|
22
|
+
try {
|
|
23
|
+
const parsed = JSON.parse(await readFile(file, 'utf-8'));
|
|
24
|
+
return Array.isArray(parsed.lanes) ? parsed.lanes : [];
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export async function saveStack(projectId, lanes) {
|
|
31
|
+
const dir = getProjectDirectory(projectId);
|
|
32
|
+
await mkdir(dir, { recursive: true });
|
|
33
|
+
await writeFile(stackPath(projectId), JSON.stringify({ lanes }, null, 2));
|
|
34
|
+
}
|
|
35
|
+
export async function addLane(projectId, lane) {
|
|
36
|
+
if (!existsSync(lane.path))
|
|
37
|
+
throw new Error(`Lane audio file not found: ${lane.path}`);
|
|
38
|
+
const lanes = await loadStack(projectId);
|
|
39
|
+
const full = {
|
|
40
|
+
id: uuidv4(),
|
|
41
|
+
sourceId: lane.sourceId,
|
|
42
|
+
name: lane.name,
|
|
43
|
+
path: lane.path,
|
|
44
|
+
color: lane.color,
|
|
45
|
+
gainDb: lane.gainDb ?? 0,
|
|
46
|
+
mute: false,
|
|
47
|
+
solo: false,
|
|
48
|
+
offsetSec: Math.max(0, lane.offsetSec ?? 0)
|
|
49
|
+
};
|
|
50
|
+
lanes.push(full);
|
|
51
|
+
await saveStack(projectId, lanes);
|
|
52
|
+
return full;
|
|
53
|
+
}
|
|
54
|
+
export async function updateLane(projectId, laneId, patch) {
|
|
55
|
+
const lanes = await loadStack(projectId);
|
|
56
|
+
const lane = lanes.find((l) => l.id === laneId);
|
|
57
|
+
if (!lane)
|
|
58
|
+
throw new Error(`Lane not found: ${laneId}`);
|
|
59
|
+
if (patch.offsetSec !== undefined)
|
|
60
|
+
lane.offsetSec = Math.max(0, patch.offsetSec);
|
|
61
|
+
if (patch.gainDb !== undefined)
|
|
62
|
+
lane.gainDb = patch.gainDb;
|
|
63
|
+
if (patch.mute !== undefined)
|
|
64
|
+
lane.mute = patch.mute;
|
|
65
|
+
if (patch.solo !== undefined)
|
|
66
|
+
lane.solo = patch.solo;
|
|
67
|
+
if (patch.name !== undefined)
|
|
68
|
+
lane.name = patch.name;
|
|
69
|
+
await saveStack(projectId, lanes);
|
|
70
|
+
return lane;
|
|
71
|
+
}
|
|
72
|
+
export async function removeLane(projectId, laneId) {
|
|
73
|
+
const lanes = await loadStack(projectId);
|
|
74
|
+
await saveStack(projectId, lanes.filter((l) => l.id !== laneId));
|
|
75
|
+
}
|
|
76
|
+
function dbToLin(db) {
|
|
77
|
+
return Math.pow(10, db / 20);
|
|
78
|
+
}
|
|
79
|
+
function sanitize(name) {
|
|
80
|
+
return name.replace(/[\\/:*?"<>|]/g, '_').trim() || 'lane';
|
|
81
|
+
}
|
|
82
|
+
/** Export one padded 32f WAV per audible lane into <project>/stack-export/.
|
|
83
|
+
* Non-WAV / non-44.1k lanes are standardized first (the renderer's
|
|
84
|
+
* decodeAudioData resamples to the 44.1k context; ffmpeg does it here). */
|
|
85
|
+
export async function exportStackBundle(projectId) {
|
|
86
|
+
const lanes = await loadStack(projectId);
|
|
87
|
+
if (lanes.length === 0)
|
|
88
|
+
throw new Error('Stack is empty — add lanes before exporting.');
|
|
89
|
+
const anySolo = lanes.some((l) => l.solo);
|
|
90
|
+
const audible = lanes.filter((l) => !(l.mute || (anySolo && !l.solo)));
|
|
91
|
+
if (audible.length === 0)
|
|
92
|
+
throw new Error('No audible lanes (everything muted or solo-excluded).');
|
|
93
|
+
const exportDir = join(getProjectDirectory(projectId), 'stack-export');
|
|
94
|
+
await mkdir(exportDir, { recursive: true });
|
|
95
|
+
const paths = [];
|
|
96
|
+
const cleanups = [];
|
|
97
|
+
try {
|
|
98
|
+
for (const lane of audible) {
|
|
99
|
+
if (!existsSync(lane.path)) {
|
|
100
|
+
throw new Error(`Lane "${lane.name}" points at a missing file: ${lane.path}`);
|
|
101
|
+
}
|
|
102
|
+
let wavSource = lane.path;
|
|
103
|
+
if (extname(lane.path).toLowerCase() !== '.wav') {
|
|
104
|
+
wavSource = join(tmpdir(), `aurora-stack-${uuidv4()}.wav`);
|
|
105
|
+
await standardizeToWav(lane.path, wavSource);
|
|
106
|
+
cleanups.push(wavSource);
|
|
107
|
+
}
|
|
108
|
+
let decoded = await decodeWavFile(wavSource);
|
|
109
|
+
if (decoded.sampleRate !== SAMPLE_RATE) {
|
|
110
|
+
const resampled = join(tmpdir(), `aurora-stack-${uuidv4()}.wav`);
|
|
111
|
+
await standardizeToWav(wavSource, resampled);
|
|
112
|
+
cleanups.push(resampled);
|
|
113
|
+
decoded = await decodeWavFile(resampled);
|
|
114
|
+
}
|
|
115
|
+
const offsetSamples = Math.round(lane.offsetSec * decoded.sampleRate);
|
|
116
|
+
const totalLength = offsetSamples + decoded.frames;
|
|
117
|
+
const lin = dbToLin(lane.gainDb);
|
|
118
|
+
const out = decoded.channels.map((src) => {
|
|
119
|
+
const dst = new Float32Array(totalLength);
|
|
120
|
+
for (let i = 0; i < src.length; i++)
|
|
121
|
+
dst[offsetSamples + i] = src[i] * lin;
|
|
122
|
+
return dst;
|
|
123
|
+
});
|
|
124
|
+
const dest = join(exportDir, `${sanitize(lane.name)}.wav`);
|
|
125
|
+
await encodeWavFloat32File(dest, out, decoded.sampleRate);
|
|
126
|
+
paths.push(dest);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
for (const f of cleanups)
|
|
131
|
+
await rm(f, { force: true }).catch(() => { });
|
|
132
|
+
}
|
|
133
|
+
return paths;
|
|
134
|
+
}
|
|
135
|
+
/** Resolve a friendly lane name for an audio path (filename sans extension). */
|
|
136
|
+
export function laneNameFromPath(path) {
|
|
137
|
+
return basename(path, extname(path));
|
|
138
|
+
}
|
|
139
|
+
//# sourceMappingURL=stack.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { AssetKind, ProjectAsset } from '../types.js';
|
|
2
|
+
export declare function listAssets(projectId: string): ProjectAsset[];
|
|
3
|
+
export declare function getAsset(id: string): ProjectAsset | null;
|
|
4
|
+
/** The kind subfolder inside the project dir, created on demand. */
|
|
5
|
+
export declare function ensureKindDir(projectId: string, kind: AssetKind): Promise<string>;
|
|
6
|
+
/** Where an asset's split stems land: <project>/stems/<asset-slug>-<shortid>/. */
|
|
7
|
+
export declare function getAssetStemsDir(asset: ProjectAsset): string;
|
|
8
|
+
/** Non-clobbering destination filename inside a kind dir. */
|
|
9
|
+
export declare function uniqueDestPath(dir: string, fileName: string): string;
|
|
10
|
+
export declare function insertAsset(params: {
|
|
11
|
+
projectId: string;
|
|
12
|
+
kind: AssetKind;
|
|
13
|
+
name: string;
|
|
14
|
+
path: string;
|
|
15
|
+
origin?: unknown;
|
|
16
|
+
sourceAssetId?: string | null;
|
|
17
|
+
refId?: string | null;
|
|
18
|
+
}): ProjectAsset;
|
|
19
|
+
/** Copy an external file into the project as an import or reference asset.
|
|
20
|
+
* References ALSO get a reference_tracks row (the global curve cache). */
|
|
21
|
+
export declare function addFileAsset(params: {
|
|
22
|
+
projectId: string;
|
|
23
|
+
kind: 'import' | 'reference';
|
|
24
|
+
filePath: string;
|
|
25
|
+
}): Promise<ProjectAsset>;
|
|
26
|
+
/** Point an asset at a new file (e.g. after a WAV fetch upgrades the MP3). */
|
|
27
|
+
export declare function updateAssetPath(id: string, path: string): ProjectAsset;
|
|
28
|
+
/** Delete an asset: row, stems rows, its audio file, its stems folder, and its
|
|
29
|
+
* linked reference row if any. */
|
|
30
|
+
export declare function deleteAsset(id: string): Promise<void>;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Port of aurora/src/main/storage/assets.ts — identical kind subfolders, asset
|
|
2
|
+
// rows, stems-dir naming, and delete semantics.
|
|
3
|
+
import { join, basename, extname } from 'node:path';
|
|
4
|
+
import { mkdir, rm, copyFile } from 'node:fs/promises';
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
7
|
+
import { getDb } from '../db.js';
|
|
8
|
+
import { getProjectDirectory, slugify, touchProject } from './projects.js';
|
|
9
|
+
import { addReference, deleteReference } from './references.js';
|
|
10
|
+
const KIND_DIRS = {
|
|
11
|
+
generation: 'generations',
|
|
12
|
+
cover: 'covers',
|
|
13
|
+
import: 'imports',
|
|
14
|
+
reference: 'references',
|
|
15
|
+
master: 'masters'
|
|
16
|
+
};
|
|
17
|
+
function rowToAsset(row) {
|
|
18
|
+
return {
|
|
19
|
+
id: row.id,
|
|
20
|
+
projectId: row.project_id,
|
|
21
|
+
kind: row.kind,
|
|
22
|
+
name: row.name,
|
|
23
|
+
path: row.path,
|
|
24
|
+
origin: row.origin ? JSON.parse(row.origin) : null,
|
|
25
|
+
sourceAssetId: row.source_asset_id,
|
|
26
|
+
refId: row.ref_id,
|
|
27
|
+
createdAt: row.created_at
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
export function listAssets(projectId) {
|
|
31
|
+
const rows = getDb()
|
|
32
|
+
.prepare('SELECT * FROM project_assets WHERE project_id = ? ORDER BY created_at DESC')
|
|
33
|
+
.all(projectId);
|
|
34
|
+
return rows.map(rowToAsset);
|
|
35
|
+
}
|
|
36
|
+
export function getAsset(id) {
|
|
37
|
+
const row = getDb().prepare('SELECT * FROM project_assets WHERE id = ?').get(id);
|
|
38
|
+
return row ? rowToAsset(row) : null;
|
|
39
|
+
}
|
|
40
|
+
/** The kind subfolder inside the project dir, created on demand. */
|
|
41
|
+
export async function ensureKindDir(projectId, kind) {
|
|
42
|
+
const dir = join(getProjectDirectory(projectId), KIND_DIRS[kind]);
|
|
43
|
+
await mkdir(dir, { recursive: true });
|
|
44
|
+
return dir;
|
|
45
|
+
}
|
|
46
|
+
/** Where an asset's split stems land: <project>/stems/<asset-slug>-<shortid>/. */
|
|
47
|
+
export function getAssetStemsDir(asset) {
|
|
48
|
+
return join(getProjectDirectory(asset.projectId), 'stems', `${slugify(asset.name, 40)}-${asset.id.slice(0, 6)}`);
|
|
49
|
+
}
|
|
50
|
+
/** Non-clobbering destination filename inside a kind dir. */
|
|
51
|
+
export function uniqueDestPath(dir, fileName) {
|
|
52
|
+
const ext = extname(fileName);
|
|
53
|
+
const stem = basename(fileName, ext);
|
|
54
|
+
let candidate = join(dir, fileName);
|
|
55
|
+
for (let i = 2; existsSync(candidate); i++) {
|
|
56
|
+
candidate = join(dir, `${stem}-${i}${ext}`);
|
|
57
|
+
}
|
|
58
|
+
return candidate;
|
|
59
|
+
}
|
|
60
|
+
export function insertAsset(params) {
|
|
61
|
+
const id = uuidv4();
|
|
62
|
+
getDb()
|
|
63
|
+
.prepare(`INSERT INTO project_assets (id, project_id, kind, name, path, origin, source_asset_id, ref_id, created_at)
|
|
64
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
65
|
+
.run(id, params.projectId, params.kind, params.name, params.path, params.origin ? JSON.stringify(params.origin) : null, params.sourceAssetId ?? null, params.refId ?? null, Date.now());
|
|
66
|
+
touchProject(params.projectId);
|
|
67
|
+
return getAsset(id);
|
|
68
|
+
}
|
|
69
|
+
/** Copy an external file into the project as an import or reference asset.
|
|
70
|
+
* References ALSO get a reference_tracks row (the global curve cache). */
|
|
71
|
+
export async function addFileAsset(params) {
|
|
72
|
+
const dir = await ensureKindDir(params.projectId, params.kind);
|
|
73
|
+
const dest = uniqueDestPath(dir, basename(params.filePath));
|
|
74
|
+
await copyFile(params.filePath, dest);
|
|
75
|
+
const name = basename(dest, extname(dest));
|
|
76
|
+
let refId = null;
|
|
77
|
+
if (params.kind === 'reference') {
|
|
78
|
+
const ref = await addReference(dest, { copy: false });
|
|
79
|
+
refId = ref.id;
|
|
80
|
+
}
|
|
81
|
+
return insertAsset({ projectId: params.projectId, kind: params.kind, name, path: dest, refId });
|
|
82
|
+
}
|
|
83
|
+
/** Point an asset at a new file (e.g. after a WAV fetch upgrades the MP3). */
|
|
84
|
+
export function updateAssetPath(id, path) {
|
|
85
|
+
getDb().prepare('UPDATE project_assets SET path = ? WHERE id = ?').run(path, id);
|
|
86
|
+
return getAsset(id);
|
|
87
|
+
}
|
|
88
|
+
/** Delete an asset: row, stems rows, its audio file, its stems folder, and its
|
|
89
|
+
* linked reference row if any. */
|
|
90
|
+
export async function deleteAsset(id) {
|
|
91
|
+
const asset = getAsset(id);
|
|
92
|
+
if (!asset)
|
|
93
|
+
return;
|
|
94
|
+
const db = getDb();
|
|
95
|
+
db.prepare('DELETE FROM project_stems WHERE asset_id = ?').run(id);
|
|
96
|
+
db.prepare('DELETE FROM project_assets WHERE id = ?').run(id);
|
|
97
|
+
await rm(asset.path, { force: true }).catch(() => { });
|
|
98
|
+
await rm(getAssetStemsDir(asset), { recursive: true, force: true }).catch(() => { });
|
|
99
|
+
if (asset.refId)
|
|
100
|
+
await deleteReference(asset.refId).catch(() => { });
|
|
101
|
+
touchProject(asset.projectId);
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=assets.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Project } from '../types.js';
|
|
2
|
+
/** Filesystem-safe slug for human-readable project folders. */
|
|
3
|
+
export declare function slugify(input: string, max?: number): string;
|
|
4
|
+
/** Absolute path to a project's directory. Legacy projects use their uuid dir. */
|
|
5
|
+
export declare function getProjectDirectory(projectId: string): string;
|
|
6
|
+
export declare function listProjects(): Project[];
|
|
7
|
+
export declare function getProject(id: string): Project | null;
|
|
8
|
+
export declare function createProject(name: string): Promise<Project>;
|
|
9
|
+
/** Rename the project (DB name only — the on-disk folder keeps its name). */
|
|
10
|
+
export declare function renameProject(id: string, name: string): Project;
|
|
11
|
+
export declare function touchProject(id: string): void;
|
|
12
|
+
export declare function deleteProject(id: string): Promise<void>;
|