Initial release

This commit is contained in:
civ
2026-08-16 18:24:52 +07:00
commit 876886a39a
13244 changed files with 2353959 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
/**
* migrate_lireek.cjs — Migrate data from hot-step-9000 hotstep_lyrics.db
* into hot-step-cpp lireek.db
*
* Schema differences handled:
* - album_presets: adapter_scales (JSON) → adapter_scale + adapter_group_scales
* - album_presets: matchering_ref_path → reference_track_path
* - album_presets: updated_at → created_at
*
* Run: node migrate_lireek.cjs
*/
const Database = require('better-sqlite3');
const path = require('path');
const SRC_PATH = 'D:/Ace-Step-Latest/hot-step-9000/data/hotstep_lyrics.db';
const DST_PATH = path.resolve(__dirname, 'data/lireek.db');
console.log('=== Lireek DB Migration ===');
console.log(`Source: ${SRC_PATH}`);
console.log(`Target: ${DST_PATH}`);
console.log();
const src = new Database(SRC_PATH, { readonly: true });
const dst = new Database(DST_PATH);
// Enable foreign keys and WAL mode on target
dst.pragma('journal_mode = WAL');
dst.pragma('foreign_keys = ON');
// ── Step 1: Ensure target schema exists (run migrations) ──
// The server would normally do this on startup, but ensure columns exist
const migrations = [
"ALTER TABLE generations ADD COLUMN subject TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN title TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN system_prompt TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN user_prompt TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN bpm INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE generations ADD COLUMN key TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN caption TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN duration INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE generations ADD COLUMN parent_generation_id INTEGER REFERENCES generations(id) ON DELETE SET NULL",
"ALTER TABLE artists ADD COLUMN image_url TEXT",
"ALTER TABLE artists ADD COLUMN genius_id INTEGER",
"ALTER TABLE lyrics_sets ADD COLUMN image_url TEXT",
];
for (const sql of migrations) {
try { dst.exec(sql); } catch { /* already exists */ }
}
// ── Step 2: Clear target tables (in safe order due to FK) ──
console.log('Clearing target tables...');
dst.exec('DELETE FROM audio_generations');
dst.exec('DELETE FROM album_presets');
dst.exec('DELETE FROM generations');
dst.exec('DELETE FROM profiles');
dst.exec('DELETE FROM lyrics_sets');
dst.exec('DELETE FROM artists');
dst.exec('DELETE FROM settings');
// Reset autoincrement counters
dst.exec("DELETE FROM sqlite_sequence WHERE name IN ('artists','lyrics_sets','profiles','generations','album_presets','audio_generations')");
// ── Step 3: Migrate artists ──
const srcArtists = src.prepare('SELECT * FROM artists ORDER BY id').all();
const insertArtist = dst.prepare(
'INSERT INTO artists (id, name, created_at, image_url, genius_id) VALUES (?, ?, ?, ?, ?)'
);
let artistCount = 0;
for (const a of srcArtists) {
insertArtist.run(a.id, a.name, a.created_at, a.image_url || null, a.genius_id || null);
artistCount++;
}
console.log(`✓ Artists: ${artistCount}`);
// ── Step 4: Migrate lyrics_sets ──
const srcSets = src.prepare('SELECT * FROM lyrics_sets ORDER BY id').all();
const insertSet = dst.prepare(
'INSERT INTO lyrics_sets (id, artist_id, album, max_songs, songs, fetched_at, image_url) VALUES (?, ?, ?, ?, ?, ?, ?)'
);
let setCount = 0;
for (const s of srcSets) {
insertSet.run(s.id, s.artist_id, s.album, s.max_songs, s.songs, s.fetched_at, s.image_url || null);
setCount++;
}
console.log(`✓ Lyrics Sets: ${setCount}`);
// ── Step 5: Migrate profiles ──
const srcProfiles = src.prepare('SELECT * FROM profiles ORDER BY id').all();
const insertProfile = dst.prepare(
'INSERT INTO profiles (id, lyrics_set_id, provider, model, profile_data, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
let profileCount = 0;
for (const p of srcProfiles) {
insertProfile.run(p.id, p.lyrics_set_id, p.provider, p.model, p.profile_data, p.created_at);
profileCount++;
}
console.log(`✓ Profiles: ${profileCount}`);
// ── Step 6: Migrate generations ──
const srcGens = src.prepare('SELECT * FROM generations ORDER BY id').all();
const insertGen = dst.prepare(
`INSERT INTO generations (id, profile_id, provider, model, extra_instructions, title, subject,
lyrics, system_prompt, user_prompt, bpm, key, caption, duration, parent_generation_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
let genCount = 0;
for (const g of srcGens) {
insertGen.run(
g.id, g.profile_id, g.provider, g.model, g.extra_instructions || null,
g.title || '', g.subject || '', g.lyrics, g.system_prompt || '', g.user_prompt || '',
g.bpm || 0, g.key || '', g.caption || '', g.duration || 0,
g.parent_generation_id || null, g.created_at
);
genCount++;
}
console.log(`✓ Generations: ${genCount}`);
// ── Step 7: Migrate album_presets (schema differs!) ──
const srcPresets = src.prepare('SELECT * FROM album_presets ORDER BY id').all();
const insertPreset = dst.prepare(
`INSERT INTO album_presets (id, lyrics_set_id, adapter_path, adapter_scale, adapter_group_scales,
reference_track_path, audio_cover_strength, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
);
let presetCount = 0;
for (const p of srcPresets) {
// Parse adapter_scales JSON → separate scale + group_scales
let adapterScale = null;
let groupScales = null;
if (p.adapter_scales) {
try {
const parsed = JSON.parse(p.adapter_scales);
adapterScale = parsed.scale ?? 1.0;
if (parsed.group_scales) {
groupScales = JSON.stringify(parsed.group_scales);
}
} catch (e) {
console.warn(` ⚠ Failed to parse adapter_scales for preset ${p.id}: ${e.message}`);
}
}
insertPreset.run(
p.id, p.lyrics_set_id, p.adapter_path || null,
adapterScale, groupScales,
p.matchering_ref_path || null, // renamed column
p.audio_cover_strength || null,
p.updated_at || new Date().toISOString() // updated_at → created_at
);
presetCount++;
}
console.log(`✓ Album Presets: ${presetCount}`);
// ── Step 8: Migrate audio_generations ──
const srcAGs = src.prepare('SELECT * FROM audio_generations ORDER BY id').all();
const insertAG = dst.prepare(
'INSERT INTO audio_generations (id, generation_id, hotstep_job_id, audio_url, cover_url, created_at) VALUES (?, ?, ?, ?, ?, ?)'
);
let agCount = 0;
for (const ag of srcAGs) {
insertAG.run(ag.id, ag.generation_id, ag.hotstep_job_id, ag.audio_url || null, ag.cover_url || null, ag.created_at);
agCount++;
}
console.log(`✓ Audio Generations: ${agCount}`);
// ── Step 9: Migrate settings ──
const srcSettings = src.prepare('SELECT * FROM settings ORDER BY key').all();
const insertSetting = dst.prepare(
'INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)'
);
let settingCount = 0;
for (const s of srcSettings) {
insertSetting.run(s.key, s.value);
settingCount++;
}
console.log(`✓ Settings: ${settingCount}`);
// ── Step 10: Update sqlite_sequence to match max IDs ──
const tables = ['artists', 'lyrics_sets', 'profiles', 'generations', 'album_presets', 'audio_generations'];
for (const table of tables) {
const max = dst.prepare(`SELECT MAX(id) as m FROM ${table}`).get();
if (max && max.m) {
dst.prepare('INSERT OR REPLACE INTO sqlite_sequence (name, seq) VALUES (?, ?)').run(table, max.m);
}
}
// ── Verification ──
console.log('\n--- Verification ---');
for (const table of tables) {
const srcCount = src.prepare(`SELECT count(*) as c FROM ${table}`).get();
const dstCount = dst.prepare(`SELECT count(*) as c FROM ${table}`).get();
const match = srcCount.c === dstCount.c ? '✓' : '✗ MISMATCH!';
console.log(`${match} ${table}: source=${srcCount.c} target=${dstCount.c}`);
}
src.close();
dst.close();
console.log('\n✅ Migration complete!');
+3809
View File
File diff suppressed because it is too large Load Diff
+44
View File
@@ -0,0 +1,44 @@
{
"name": "hot-step-cpp-server",
"private": true,
"version": "1.0.2",
"engines": {
"node": ">=18.0.0 <24.0.0"
},
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit",
"postinstall": "npm rebuild better-sqlite3"
},
"dependencies": {
"@lenml/tokenizers": "^3.7.2",
"archiver": "^7.0.1",
"better-sqlite3": "^11.8.1",
"cheerio": "^1.2.0",
"cmu-pronouncing-dictionary": "^3.0.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"ffmpeg-static": "^5.3.0",
"multer": "^2.1.1",
"music-metadata": "^11.12.3",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/archiver": "^7.0.0",
"@types/better-sqlite3": "^7.6.13",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.2",
"@types/multer": "^2.1.0",
"@types/node": "^22.14.0",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.3",
"typescript": "~5.8.2"
},
"optionalDependencies": {
"tsx": "^4.21.0"
}
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env node
/**
* Move every bare `<name>.safetensors` in a folder into `<name>/` and rename
* it to the canonical `adapter_model.safetensors`:
*
* dit-xl-base-turbo/abba.safetensors -> dit-xl-base-turbo/abba/adapter_model.safetensors
*
* The filename stops carrying the trigger word — that now lives in the file's
* own __metadata__ (hot_step_trigger, stamped 2026-07-28) — so the name's only
* remaining job is labelling the folder. Same-volume renames, no data copied,
* safe to re-run. DRY RUN by default.
*
* node server/scripts/enfolder-bare-adapters.mjs --folder M:\...\dit-xl-base-turbo
* node server/scripts/enfolder-bare-adapters.mjs --folder ... --apply
*
* NOTE deliberately does NOT write an adapter_config.json: these are
* LyCORIS-format adapters whose alphas live per-tensor in the file itself; a
* fabricated PEFT config would mis-scale them.
*/
import fs from 'fs';
import path from 'path';
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const fi = argv.indexOf('--folder');
const FOLDER = fi >= 0 && fi + 1 < argv.length ? path.resolve(argv[fi + 1]) : '';
if (!FOLDER || !fs.existsSync(FOLDER) || !fs.statSync(FOLDER).isDirectory()) {
console.error('usage: enfolder-bare-adapters.mjs --folder <dir> [--apply]');
process.exit(2);
}
const moves = [];
const warns = [];
for (const e of fs.readdirSync(FOLDER, { withFileTypes: true })) {
if (!e.isFile() || !e.name.endsWith('.safetensors') || e.name.startsWith('.')) continue;
const stem = e.name.replace(/\.safetensors$/i, '');
if (stem === 'adapter_model') { warns.push(`${e.name}: already canonical at top level — left alone`); continue; }
const destDir = path.join(FOLDER, stem);
const dest = path.join(destDir, 'adapter_model.safetensors');
if (fs.existsSync(dest)) { warns.push(`${stem}: ${path.basename(destDir)}/adapter_model.safetensors already exists — skipped`); continue; }
moves.push({ from: path.join(FOLDER, e.name), destDir, dest, stem });
}
console.log(`\nFolder: ${FOLDER} Mode: ${APPLY ? 'APPLY' : 'DRY RUN'}`);
for (const m of moves.slice(0, 8)) console.log(` ${m.stem}.safetensors -> ${m.stem}/adapter_model.safetensors`);
if (moves.length > 8) console.log(` … and ${moves.length - 8} more, same shape`);
for (const w of warns) console.log(`WARN ${w}`);
console.log(`${moves.length} move(s), ${warns.length} warning(s).`);
if (!APPLY) { console.log('DRY RUN — nothing moved. Re-run with --apply.\n'); process.exit(0); }
let ok = 0, failed = 0;
for (const m of moves) {
try {
fs.mkdirSync(m.destDir, { recursive: true });
fs.renameSync(m.from, m.dest);
ok++;
} catch (err) {
console.error(`FAILED ${m.stem}: ${err.message}`);
failed++;
}
}
console.log(`Moved ${ok}${failed ? `, ${failed} FAILED` : ''}.`);
process.exit(failed ? 1 : 0);
+227
View File
@@ -0,0 +1,227 @@
#!/usr/bin/env node
/**
* Migrate an adapter corpus to the per-base layout (2026-07-28):
*
* lm/<artist>-0.6B -> lm-06b/<artist>
* lm/<artist>-1.7B -> lm-17b/<artist>
* lm/<artist>-4B -> lm-4b/<artist>
* <root>/<dit-dir> -> dit-<shorthand>/<dit-dir> (base read from its own
* dit_train_log.json)
*
* Everything is a same-volume rename — no data is copied, so this is fast and
* safe to re-run (already-migrated entries are skipped). DRY RUN by default.
*
* node server/scripts/migrate-adapter-layout.mjs --adapters M:\HOT-Step-CPP\Adapters
* node server/scripts/migrate-adapter-layout.mjs --adapters ... --apply
*
* --adapters <dir> adapters root (default: ../../adapters from this file)
* --default-dit <s> shorthand folder for a DiT adapter dir with no readable
* dit_train_log.json (default: leave in place + warn)
* --apply actually move things
*
* Companion of server/src/services/training/adapterLayout.ts — the shorthand
* map here must stay in sync with DIT_SHORTHANDS there.
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const argv = process.argv.slice(2);
const flag = (n) => argv.includes(`--${n}`);
const opt = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
const APPLY = flag('apply');
const ROOT = path.resolve(opt('adapters', path.join(HERE, '..', '..', 'adapters')));
const DEFAULT_DIT = opt('default-dit', '');
// Mirror of adapterLayout.ts (keep in sync).
const LM_DIRS = { '0.6B': 'lm-06b', '1.7B': 'lm-17b', '4B': 'lm-4b' };
const DIT_SHORTHANDS = {
'acestep-v15-merge-base-sft-turbo-xl-thirds': 'dit-xl-thirds',
'acestep-v15-merge-base-turbo-xl-ta-0.5': 'dit-xl-base-turbo',
'acestep-v15-xl-sftturbo50': 'dit-xl-sft-turbo',
'acestep-v15-merge-base-sft-xl-ta-0.5': 'dit-xl-base-sft',
'acestep-v15-merge-sft-turbo-xl-ta-0.3': 'dit-xl-sft-turbo-ta03',
'acestep-v15-merge-sft-turbo-xl-ta-0.7': 'dit-xl-sft-turbo-ta07',
'acestep-v15-xl-base': 'dit-xl-base',
'acestep-v15-xl-sft': 'dit-xl-sft',
'acestep-v15-xl-turbo': 'dit-xl-turbo',
'acestep-v15-base': 'dit-base',
'acestep-v15-sft': 'dit-sft',
'acestep-v15-turbo': 'dit-turbo',
'acestep-v15-sftturbo50': 'dit-sft-turbo',
'acestep-v15-turbo-continuous': 'dit-turbo-continuous',
'acestep-v15-turbo-shift1': 'dit-turbo-shift1',
'acestep-v15-turbo-shift3': 'dit-turbo-shift3',
'acestep-v15-merge-base-sft-turbo-xl-thirds-convrot-ref': 'dit-xl-thirds-convrot',
'sa3-dit': 'dit-sa3',
};
const QUANT_RE = /-(BF16|F16|F32|MXFP4|NVFP4|IQ\d[A-Z_]*|Q\d[\w]*)$/i;
function ditShorthand(model) {
let stem = path.basename(String(model || '')).replace(/\.gguf$/i, '').replace(QUANT_RE, '');
if (!stem) return '';
if (DIT_SHORTHANDS[stem]) return DIT_SHORTHANDS[stem];
const fb = stem.replace(/^acestep-v15-/i, '').replace(/\./g, '');
return fb ? `dit-${fb}` : '';
}
if (!fs.existsSync(ROOT)) { console.error(`adapters root not found: ${ROOT}`); process.exit(2); }
const moves = []; // { from, to, note }
const warns = [];
// ── planner-LM adapters ─────────────────────────────────────────────────────
const lmRoot = path.join(ROOT, 'lm');
if (fs.existsSync(lmRoot)) {
for (const e of fs.readdirSync(lmRoot, { withFileTypes: true })) {
if (e.name.startsWith('.')) continue;
const from = path.join(lmRoot, e.name);
const stem = e.isFile() ? e.name.replace(/\.safetensors$/i, '') : e.name;
if (e.isFile() && !e.name.endsWith('.safetensors')) { warns.push(`lm/${e.name}: not an adapter — left in place`); continue; }
const m = /-(0\.6B|1\.7B|4B)$/i.exec(stem);
if (!m) {
if (e.isDirectory() && fs.readdirSync(from).length === 0) {
moves.push({ from, to: '', note: 'empty dir — remove' });
} else {
warns.push(`lm/${e.name}: no -<size> suffix — cannot tell the base, left in place`);
}
continue;
}
const size = ['0.6B', '1.7B', '4B'].find(s => s.toLowerCase() === m[1].toLowerCase());
const bare = stem.slice(0, -(m[1].length + 1)) + (e.isFile() ? '.safetensors' : '');
moves.push({ from, to: path.join(ROOT, LM_DIRS[size], bare), note: `lm ${size}` });
}
}
// ── already-migrated lm-<size> dirs: strip legacy -<size> suffixes ──────────
// A hand-moved corpus (rename lm -> lm-4b) still carries the suffix on every
// child; the parent folder now says the size, so the suffix goes. A child whose
// suffix DISAGREES with its folder is moved to the right size folder instead.
for (const [size, dirName] of Object.entries(LM_DIRS)) {
const sizeRoot = path.join(ROOT, dirName);
if (!fs.existsSync(sizeRoot)) continue;
for (const e of fs.readdirSync(sizeRoot, { withFileTypes: true })) {
if (e.name.startsWith('.')) continue;
const from = path.join(sizeRoot, e.name);
const stem = e.isFile() ? e.name.replace(/\.safetensors$/i, '') : e.name;
if (e.isFile() && !e.name.endsWith('.safetensors')) continue;
const m = /-(0\.6B|1\.7B|4B)$/i.exec(stem);
if (!m) {
if (e.isDirectory() && fs.readdirSync(from).length === 0) {
moves.push({ from, to: '', note: 'empty dir — remove' });
}
continue; // already unsuffixed — nothing to do
}
const suffixSize = ['0.6B', '1.7B', '4B'].find(s => s.toLowerCase() === m[1].toLowerCase());
const bare = stem.slice(0, -(m[1].length + 1)) + (e.isFile() ? '.safetensors' : '');
const targetDir = suffixSize === size ? sizeRoot : path.join(ROOT, LM_DIRS[suffixSize]);
moves.push({
from, to: path.join(targetDir, bare),
note: suffixSize === size ? 'strip suffix' : `RELOCATE — suffix says ${suffixSize}, folder says ${size}`,
});
}
}
// ── canonicalise PEFT weight filenames ──────────────────────────────────────
// MUST run (and execute) before the relocation pass below: a dir can need both
// a weights-rename and a relocation, and the rename addresses the old path.
// A dir with adapter_config.json whose weights were renamed <name>.safetensors
// (the old make-the-filename-the-trigger workaround) goes back to the
// adapter_model.safetensors the scanners and loaders look for — the embedded
// trigger has made the rename pointless.
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!e.isDirectory() || !/^(dit-|lm-)/i.test(e.name)) continue;
for (const sub of fs.readdirSync(path.join(ROOT, e.name), { withFileTypes: true })) {
if (!sub.isDirectory() || sub.name.startsWith('.')) continue;
const dir = path.join(ROOT, e.name, sub.name);
if (!fs.existsSync(path.join(dir, 'adapter_config.json'))) continue;
if (fs.existsSync(path.join(dir, 'adapter_model.safetensors'))) continue;
const st = fs.readdirSync(dir).filter(f => f.endsWith('.safetensors'));
if (st.length === 1) {
moves.push({ from: path.join(dir, st[0]), to: path.join(dir, 'adapter_model.safetensors'),
note: 'canonicalise weights filename' });
}
}
}
// ── dit-* dirs: verify each PEFT child sits under its own base ──────────────
// The folder is a claim about the training base; the adapter's own
// dit_train_log.json is the evidence. Disagreement = move to the right folder.
// Bare .safetensors files record no base and are left where the user put them.
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (!e.isDirectory() || !/^dit-/i.test(e.name)) continue;
const shorthandDir = path.join(ROOT, e.name);
for (const sub of fs.readdirSync(shorthandDir, { withFileTypes: true })) {
if (!sub.isDirectory() || sub.name.startsWith('.')) continue;
const from = path.join(shorthandDir, sub.name);
let base = '';
try {
const log = JSON.parse(fs.readFileSync(path.join(from, 'dit_train_log.json'), 'utf8'));
base = log?.config?.dit_name || log?.config?.dit_path || '';
} catch { continue; } // no log — nothing to verify against
const want = base ? ditShorthand(base) : '';
if (want && want.toLowerCase() !== e.name.toLowerCase()) {
moves.push({ from, to: path.join(ROOT, want, sub.name), note: `RELOCATE — trained on ${base}` });
}
}
}
// ── DiT adapters at the root ────────────────────────────────────────────────
for (const e of fs.readdirSync(ROOT, { withFileTypes: true })) {
if (e.name.startsWith('.') || e.name === 'lm') continue;
if (/^(lm-06b|lm-17b|lm-4b|dit-)/i.test(e.name)) continue; // already migrated trees
const from = path.join(ROOT, e.name);
if (e.isDirectory()) {
const looksAdapter = fs.existsSync(path.join(from, 'adapter_config.json')) ||
fs.readdirSync(from).some(f => f.endsWith('.safetensors'));
if (!looksAdapter) { warns.push(`${e.name}: not an adapter dir — left in place`); continue; }
let base = '';
try {
const log = JSON.parse(fs.readFileSync(path.join(from, 'dit_train_log.json'), 'utf8'));
base = log?.config?.dit_name || log?.config?.dit_path || '';
} catch { /* no log — fall through */ }
const shorthand = base ? ditShorthand(base) : DEFAULT_DIT;
if (!shorthand) { warns.push(`${e.name}: no dit_train_log.json and no --default-dit — left in place`); continue; }
moves.push({ from, to: path.join(ROOT, shorthand, e.name), note: base ? `dit (${base})` : 'dit (--default-dit)' });
} else if (e.isFile() && e.name.endsWith('.safetensors')) {
// A bare DiT adapter at the root records no base anywhere — only move it
// when the user says where it belongs.
if (DEFAULT_DIT) moves.push({ from, to: path.join(ROOT, DEFAULT_DIT, e.name), note: 'dit bare (--default-dit)' });
else warns.push(`${e.name}: bare file with no recorded base — left in place (use --default-dit to move)`);
}
}
// ── report ──────────────────────────────────────────────────────────────────
console.log(`\nAdapters root: ${ROOT} Mode: ${APPLY ? 'APPLY' : 'DRY RUN'}\n`);
const pad = (s, n) => String(s).padEnd(n);
for (const mv of moves) {
const rel = (p) => p ? path.relative(ROOT, p) : '(delete)';
console.log(`${pad(rel(mv.from), 46)} -> ${pad(rel(mv.to), 40)} ${mv.note}`);
}
for (const w of warns) console.log(`WARN ${w}`);
console.log(`\n${moves.length} move(s), ${warns.length} warning(s).`);
if (!APPLY) { console.log('DRY RUN — nothing moved. Re-run with --apply.\n'); process.exit(0); }
let ok = 0, failed = 0;
for (const mv of moves) {
try {
if (!mv.to) { fs.rmdirSync(mv.from); ok++; continue; }
if (fs.existsSync(mv.to)) throw new Error(`target exists: ${mv.to}`);
fs.mkdirSync(path.dirname(mv.to), { recursive: true });
fs.renameSync(mv.from, mv.to);
ok++;
} catch (e) {
console.error(`FAILED ${mv.from}: ${e.message}`);
failed++;
}
}
// Remove the legacy lm/ root if the migration emptied it.
try {
if (fs.existsSync(lmRoot) && fs.readdirSync(lmRoot).length === 0) { fs.rmdirSync(lmRoot); console.log('removed empty lm/'); }
} catch { /* leave it */ }
console.log(`\nMoved ${ok}${failed ? `, ${failed} FAILED` : ''}.`);
process.exit(failed ? 1 : 0);
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
/**
* Repoint every album preset's adapter paths at the per-base adapter layout
* (2026-07-28). The artist stem is taken from the currently stored path's
* filename and looked up in the new hierarchy:
*
* adapter_path -> <adapters>/dit-xl-thirds/<stem> when trained there
* <adapters>/dit-xl-base-turbo/<stem> otherwise (archive)
* lm_adapter_path -> <adapters>/lm-4b/<stem>
*
* A stem with no folder in the new layout is left untouched and warned about.
* Rows already pointing inside <adapters> keep their setting (hand-fixed).
* The DB is backed up via SQLite's online backup API before any write.
* DRY RUN by default.
*
* node server/scripts/retarget-album-presets.mjs
* node server/scripts/retarget-album-presets.mjs --apply
*
* --adapters <dir> adapters root (default M:\HOT-Step-CPP\Adapters)
* --db <file> SQLite db (default server/data/hotstep.db)
*/
import fs from 'fs';
import path from 'path';
import { createRequire } from 'module';
import { fileURLToPath } from 'url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(path.join(HERE, '..', 'package.json'));
const Database = require('better-sqlite3');
const argv = process.argv.slice(2);
const APPLY = argv.includes('--apply');
const opt = (n, d) => { const i = argv.indexOf(`--${n}`); return i >= 0 && i + 1 < argv.length ? argv[i + 1] : d; };
const ROOT = path.resolve(opt('adapters', 'M:\\HOT-Step-CPP\\Adapters'));
const DB_PATH = path.resolve(opt('db', path.join(HERE, '..', 'data', 'hotstep.db')));
const stemOf = (p) => {
if (!p) return '';
const base = String(p).split(/[\\/]/).filter(Boolean).pop() || '';
// Bare file, canonical weights file (stem = its folder), or already a folder.
if (/^adapter_model\.safetensors$/i.test(base)) {
const parts = String(p).split(/[\\/]/).filter(Boolean);
return parts[parts.length - 2] || '';
}
return base.replace(/\.safetensors$/i, '').replace(/-(0\.6B|1\.7B|4B)$/i, '');
};
const hasAdapter = (dir) => fs.existsSync(path.join(dir, 'adapter_model.safetensors'));
const ditTarget = (stem) => {
for (const basedir of ['dit-xl-thirds', 'dit-xl-base-turbo']) {
const d = path.join(ROOT, basedir, stem);
if (hasAdapter(d)) return d;
}
return '';
};
const lmTarget = (stem) => {
const d = path.join(ROOT, 'lm-4b', stem);
return hasAdapter(d) ? d : '';
};
const db = new Database(DB_PATH);
const rows = db.prepare(
`SELECT ap.lyrics_set_id AS id, ls.album, ap.adapter_path, ap.lm_adapter_path
FROM album_presets ap LEFT JOIN lyrics_sets ls ON ls.id = ap.lyrics_set_id`).all();
const updates = [];
const warns = [];
for (const r of rows) {
const upd = { id: r.id, album: r.album || `(set ${r.id})` };
for (const [field, target] of [['adapter_path', ditTarget], ['lm_adapter_path', lmTarget]]) {
const cur = r[field] || '';
if (!cur) continue;
if (path.resolve(cur).toLowerCase().startsWith(ROOT.toLowerCase())) continue; // hand-fixed already
const stem = stemOf(cur);
const dest = stem ? target(stem) : '';
if (dest) upd[field] = dest;
else warns.push(`${upd.album}: ${field} stem "${stem}" has no folder in the new layout — left as ${cur}`);
}
if (upd.adapter_path || upd.lm_adapter_path) updates.push(upd);
}
console.log(`\nDB: ${DB_PATH}\nAdapters: ${ROOT}\nMode: ${APPLY ? 'APPLY' : 'DRY RUN'}\n`);
console.log(`${rows.length} preset(s), ${updates.length} to update, ${warns.length} warning(s).\n`);
for (const u of updates.slice(0, 6)) {
console.log(`${u.album}`);
if (u.adapter_path) console.log(` dit -> ${path.relative(ROOT, u.adapter_path)}`);
if (u.lm_adapter_path) console.log(` lm -> ${path.relative(ROOT, u.lm_adapter_path)}`);
}
if (updates.length > 6) console.log(`… and ${updates.length - 6} more, same shape`);
for (const w of warns) console.log(`WARN ${w}`);
if (!APPLY) { console.log('\nDRY RUN — nothing written. Re-run with --apply.\n'); db.close(); process.exit(0); }
// Online backup first — safe against the running server's open handle.
const stampNow = new Date().toISOString().replace(/[:T]/g, '-').slice(0, 19);
const bak = DB_PATH.replace(/\.db$/i, `_backup_presets_${stampNow}.db`);
await db.backup(bak);
console.log(`\nBacked up to ${path.basename(bak)}`);
const setBoth = db.prepare('UPDATE album_presets SET adapter_path = ?, lm_adapter_path = ? WHERE lyrics_set_id = ?');
const cur = db.prepare('SELECT adapter_path, lm_adapter_path FROM album_presets WHERE lyrics_set_id = ?');
let n = 0;
for (const u of updates) {
const c = cur.get(u.id);
setBoth.run(u.adapter_path ?? c.adapter_path, u.lm_adapter_path ?? c.lm_adapter_path, u.id);
n++;
}
db.close();
console.log(`Updated ${n} preset(s).`);
+331
View File
@@ -0,0 +1,331 @@
#!/usr/bin/env node
/**
* Stamp trigger words into adapters that were trained before HOT-Step embedded
* them (every Side-Step-trained adapter, and every HOT-Step one from before
* 2026-07-28).
*
* docs/plans/2026-07-28-adapter-trigger-embedding.md §6
*
* Adds `hot_step_trigger`, `hot_step_trigger_position` and
* `modelspec.trigger_phrase` to a safetensors file's `__metadata__`. Tensor
* bytes are copied verbatim, so the weights are provably unchanged — only the
* JSON header grows. Unknown metadata keys are ignored by every other consumer,
* so a stamped adapter still loads in ComfyUI / PEFT / Side-Step.
*
* DRY RUN BY DEFAULT. Nothing is written without --apply.
*
* node server/scripts/stamp-adapter-triggers.mjs
* node server/scripts/stamp-adapter-triggers.mjs --apply
*
* --adapters <dir> adapters root (default: ../../adapters from this file)
* --datasets <dir> a Side-Step corpus root; each <dir>/<name>/dataset.json
* supplies that adapter's REAL custom_tag + tag_position,
* which beats guessing from the filename
* --map <file.json> {"<adapterName>": "<trigger>"} explicit overrides
* --position <p> prepend|append fallback when no dataset says otherwise
* --only <lm|dit|all> which subtree to walk (default all)
* --no-backup skip the .bak copy (not recommended)
* --keep-backup keep every .bak instead of releasing it after verify
* --force restamp adapters that already carry a trigger
*/
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const HERE = path.dirname(fileURLToPath(import.meta.url));
const MAX_HEADER_BYTES = 64 * 1024 * 1024;
const SIZE_SUFFIX = /-(?:0\.6B|1\.7B|4B)$/;
// ── args ────────────────────────────────────────────────────────────────────
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(`--${name}`);
const opt = (name, dflt) => {
const i = argv.indexOf(`--${name}`);
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : dflt;
};
const APPLY = flag('apply');
const FORCE = flag('force');
const BACKUP = !flag('no-backup');
// A .bak is taken per file and released as soon as that file verifies, so peak
// extra disk is one adapter rather than a second copy of the whole corpus.
// --keep-backup retains them all instead.
const KEEP_BACKUP = flag('keep-backup');
const POSITION = opt('position', 'prepend');
const ONLY = opt('only', 'all');
const ADAPTERS_ROOT = path.resolve(opt('adapters', path.join(HERE, '..', '..', 'adapters')));
const DATASETS_DIR = opt('datasets', '');
const MAP_FILE = opt('map', '');
if (POSITION !== 'prepend' && POSITION !== 'append') {
console.error(`--position must be prepend|append (got "${POSITION}")`);
process.exit(2);
}
let explicitMap = {};
if (MAP_FILE) {
try {
explicitMap = JSON.parse(fs.readFileSync(path.resolve(MAP_FILE), 'utf8'));
} catch (e) {
console.error(`cannot read --map ${MAP_FILE}: ${e.message}`);
process.exit(2);
}
}
// ── safetensors header I/O ──────────────────────────────────────────────────
function readHeader(file) {
const fd = fs.openSync(file, 'r');
try {
const lenBuf = Buffer.allocUnsafe(8);
if (fs.readSync(fd, lenBuf, 0, 8, 0) !== 8) return null;
const headerLen = Number(lenBuf.readBigUInt64LE(0));
if (!Number.isSafeInteger(headerLen) || headerLen <= 0 || headerLen > MAX_HEADER_BYTES) return null;
const hdr = Buffer.allocUnsafe(headerLen);
if (fs.readSync(fd, hdr, 0, headerLen, 8) !== headerLen) return null;
return { json: JSON.parse(hdr.toString('utf8')), headerLen };
} catch {
return null;
} finally {
fs.closeSync(fd);
}
}
/**
* Rewrite `file` with `metadata` merged into its `__metadata__`.
*
* Streams `newLen(8) + newHeader + originalPayload` to a temp file, then swaps.
* Never edits in place, so an interrupted run leaves the original intact.
*/
function stamp(file, metadata) {
const head = readHeader(file);
if (!head) throw new Error('unreadable safetensors header');
const json = { ...head.json, __metadata__: { ...(head.json.__metadata__ || {}), ...metadata } };
const newHeader = Buffer.from(JSON.stringify(json), 'utf8');
// safetensors requires the header to be 8-byte aligned; pad with spaces,
// which are legal JSON whitespace outside string literals.
const pad = (8 - (newHeader.length % 8)) % 8;
const padded = Buffer.concat([newHeader, Buffer.alloc(pad, 0x20)]);
const lenBuf = Buffer.allocUnsafe(8);
lenBuf.writeBigUInt64LE(BigInt(padded.length), 0);
const originalSize = fs.statSync(file).size;
const payloadBytes = originalSize - 8 - head.headerLen;
const expectedSize = 8 + padded.length + payloadBytes;
const originalNames = Object.keys(head.json).filter(k => k !== '__metadata__').sort().join('\n');
const tmp = `${file}.stamping`;
const out = fs.openSync(tmp, 'w');
const src = fs.openSync(file, 'r');
try {
fs.writeSync(out, lenBuf);
fs.writeSync(out, padded);
const buf = Buffer.allocUnsafe(4 * 1024 * 1024);
let pos = 8 + head.headerLen;
for (;;) {
const n = fs.readSync(src, buf, 0, buf.length, pos);
if (n <= 0) break;
fs.writeSync(out, buf, 0, n);
pos += n;
}
} finally {
fs.closeSync(src);
fs.closeSync(out);
}
const bak = `${file}.bak`;
if (BACKUP) fs.copyFileSync(file, bak);
fs.rmSync(file);
fs.renameSync(tmp, file);
// Verify the swapped file before moving on. The payload is a verbatim byte
// copy, so an exact size match plus an identical tensor-entry set is enough
// to catch truncation, a short write or a mangled header — the only ways this
// operation can go wrong. On failure, put the original back.
try {
const size = fs.statSync(file).size;
const check = readHeader(file);
if (size !== expectedSize) throw new Error(`size ${size} != expected ${expectedSize}`);
if (!check) throw new Error('rewritten header does not parse');
if (check.json.__metadata__?.hot_step_trigger !== metadata.hot_step_trigger) {
throw new Error('trigger missing from the rewritten header');
}
const names = Object.keys(check.json).filter(k => k !== '__metadata__').sort().join('\n');
if (names !== originalNames) throw new Error('tensor entry set changed');
} catch (e) {
if (BACKUP && fs.existsSync(bak)) {
fs.rmSync(file, { force: true });
fs.renameSync(bak, file);
throw new Error(`${e.message} — ORIGINAL RESTORED from .bak`);
}
throw new Error(`${e.message} — NO BACKUP TO RESTORE (ran with --no-backup)`);
}
if (BACKUP && !KEEP_BACKUP) fs.rmSync(bak, { force: true });
}
// ── discovery ───────────────────────────────────────────────────────────────
/** Every adapter under `root`: PEFT dirs (unversioned, and each stamped run
* subfolder — per-run layout) plus bare .safetensors files. `name` stays the
* ARTIST name for runs, which is what the trigger proposal keys on. */
function findAdapters(root, kind) {
const out = [];
if (!fs.existsSync(root)) return out;
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
if (e.name.startsWith('.')) continue;
const full = path.join(root, e.name);
if (e.isDirectory()) {
const model = path.join(full, 'adapter_model.safetensors');
if (fs.existsSync(model)) out.push({ name: e.name, file: model, kind });
for (const run of fs.readdirSync(full, { withFileTypes: true })) {
if (!run.isDirectory() || run.name.startsWith('.') || run.name === 'milestones') continue;
const runModel = path.join(full, run.name, 'adapter_model.safetensors');
if (fs.existsSync(runModel)) out.push({ name: e.name, file: runModel, kind });
}
} else if (e.isFile() && e.name.endsWith('.safetensors')) {
out.push({ name: e.name.replace(/\.safetensors$/i, ''), file: full, kind });
}
}
return out;
}
/**
* `custom_tag` + `tag_position` per dataset name — the ground truth for what an
* adapter was actually trained with. Reads both the Training Studio's own
* `dataset_meta.json` and any Side-Step corpus passed via --datasets, whose
* `dataset.json` carries the same fields under `metadata`.
*/
function loadDatasetTags() {
const tags = new Map();
const addStudio = (dsRoot) => {
if (!fs.existsSync(dsRoot)) return;
for (const e of fs.readdirSync(dsRoot, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
try {
const meta = JSON.parse(fs.readFileSync(path.join(dsRoot, e.name, 'dataset_meta.json'), 'utf8'));
const tag = (meta.customTag || meta.custom_tag || '').trim();
const pos = (meta.tagPosition || meta.tag_position || '').trim();
if (tag) tags.set(e.name, { tag, pos });
} catch { /* a dataset without readable meta simply contributes nothing */ }
}
};
const addSideStep = (root) => {
if (!root || !fs.existsSync(root)) return;
for (const e of fs.readdirSync(root, { withFileTypes: true })) {
if (!e.isDirectory()) continue;
try {
const j = JSON.parse(fs.readFileSync(path.join(root, e.name, 'dataset.json'), 'utf8'));
const md = j.metadata || {};
const tag = (md.custom_tag || '').trim();
const pos = (md.tag_position || '').trim();
if (tag) tags.set(e.name, { tag, pos });
} catch { /* likewise */ }
}
};
addStudio(path.join(HERE, '..', 'data', 'training', 'datasets'));
if (DATASETS_DIR) addSideStep(path.resolve(DATASETS_DIR));
return tags;
}
/** explicit map > dataset custom_tag > dir name minus the -<size> suffix. */
function proposeTrigger(name, datasetTags) {
if (explicitMap[name]) return { trigger: String(explicitMap[name]).trim(), position: POSITION, source: 'map' };
const bare = name.replace(SIZE_SUFFIX, '');
const hit = datasetTags.get(name) || datasetTags.get(bare);
if (hit) {
// A dataset whose tag_position is "replace" never put the tag in its
// captions at all (preprocess-run.h:203), so that adapter has no trigger.
if (hit.pos === 'replace') return { trigger: '', position: '', source: 'replace' };
return { trigger: hit.tag, position: hit.pos === 'append' ? 'append' : 'prepend', source: 'dataset' };
}
return { trigger: bare, position: POSITION, source: 'filename' };
}
// ── run ─────────────────────────────────────────────────────────────────────
const datasetTags = loadDatasetTags();
const targets = [];
// Per-base layout (adapterLayout.ts): planner adapters under lm/ (legacy) and
// lm-06b/lm-17b/lm-4b; DiT adapters at the root (legacy) and under dit-*.
if (ONLY === 'all' || ONLY === 'dit') {
// Root level: LEGACY bare files / PEFT dirs only. The lm*/dit-* trees are
// walked by their own passes — descending into them here double-counts.
for (const e of fs.existsSync(ADAPTERS_ROOT) ? fs.readdirSync(ADAPTERS_ROOT, { withFileTypes: true }) : []) {
if (e.name.startsWith('.') || /^(lm|lm-|dit-)/i.test(e.name)) continue;
const full = path.join(ADAPTERS_ROOT, e.name);
if (e.isDirectory()) {
const model = path.join(full, 'adapter_model.safetensors');
if (fs.existsSync(model)) targets.push({ name: e.name, file: model, kind: 'dit' });
} else if (e.isFile() && e.name.endsWith('.safetensors')) {
targets.push({ name: e.name.replace(/\.safetensors$/i, ''), file: full, kind: 'dit' });
}
}
for (const e of fs.existsSync(ADAPTERS_ROOT) ? fs.readdirSync(ADAPTERS_ROOT, { withFileTypes: true }) : []) {
if (e.isDirectory() && /^dit-/i.test(e.name)) {
targets.push(...findAdapters(path.join(ADAPTERS_ROOT, e.name), 'dit'));
}
}
}
if (ONLY === 'all' || ONLY === 'lm') {
for (const sub of ['lm', 'lm-06b', 'lm-17b', 'lm-4b']) {
targets.push(...findAdapters(path.join(ADAPTERS_ROOT, sub), 'lm'));
}
}
if (!targets.length) {
console.error(`No adapters found under ${ADAPTERS_ROOT} (--only ${ONLY}).`);
process.exit(1);
}
const rows = [];
let skipped = 0;
for (const t of targets) {
const head = readHeader(t.file);
if (!head) { rows.push({ ...t, trigger: '', source: 'UNREADABLE', action: 'skip' }); continue; }
const existing = (head.json.__metadata__ || {}).hot_step_trigger;
if (existing && !FORCE) { skipped++; continue; }
const { trigger, position, source } = proposeTrigger(t.name, datasetTags);
rows.push({ ...t, trigger, position, source, action: trigger ? (existing ? 'restamp' : 'stamp') : 'skip' });
}
const pad = (s, n) => String(s).padEnd(n);
console.log(`\nAdapters root: ${ADAPTERS_ROOT}`);
console.log(`${targets.length} adapter(s) found, ${skipped} already stamped (skipped), ${rows.length} to review.`);
console.log(`Fallback position: ${POSITION} Mode: ${APPLY ? 'APPLY' : 'DRY RUN'}${BACKUP ? ' (keeping .bak)' : ''}`);
console.log(`Dataset tags loaded: ${datasetTags.size}${DATASETS_DIR ? ` (incl. ${DATASETS_DIR})` : ''}\n`);
console.log(`${pad('KIND', 5)} ${pad('ADAPTER', 42)} ${pad('PROPOSED TRIGGER', 28)} ${pad('POS', 8)} ${pad('SOURCE', 9)} ACTION`);
console.log('-'.repeat(110));
for (const r of rows) {
console.log(`${pad(r.kind, 5)} ${pad(r.name.slice(0, 42), 42)} ${pad(r.trigger.slice(0, 28), 28)} ${pad(r.position || '-', 8)} ${pad(r.source, 9)} ${r.action}`);
}
const bySource = rows.reduce((m, r) => ({ ...m, [r.source]: (m[r.source] || 0) + 1 }), {});
console.log(`\nBy source: ${Object.entries(bySource).map(([k, v]) => `${k}=${v}`).join(' ')}`);
if (!APPLY) {
console.log(`\nDRY RUN — nothing written. Re-run with --apply once the table above is right.`);
console.log(`Fix individual rows with --map triggers.json, e.g. {"abba-4B": "abba"}\n`);
process.exit(0);
}
let ok = 0, failed = 0;
for (const r of rows) {
if (r.action === 'skip') continue;
try {
stamp(r.file, {
hot_step_trigger: r.trigger,
hot_step_trigger_position: r.position || POSITION,
'modelspec.trigger_phrase': r.trigger,
});
ok++;
} catch (e) {
console.error(`FAILED ${r.name}: ${e.message}`);
failed++;
}
}
console.log(`\nStamped ${ok} adapter(s)${failed ? `, ${failed} failed` : ''}.`);
process.exit(failed ? 1 : 0);
+405
View File
@@ -0,0 +1,405 @@
// config.ts — Environment-based configuration for HOT-Step CPP server
import { config as dotenvConfig, parse as dotenvParse } from 'dotenv';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// ── Portable mode detection ─────────────────────────────────────────
// When HOT_STEP_ROOT is set (by the release launcher), all paths resolve
// from the distribution root. Otherwise, fall back to __dirname-based
// resolution for development mode.
export const PORTABLE_MODE = !!process.env.HOT_STEP_ROOT;
export const PROJECT_ROOT = process.env.HOT_STEP_ROOT
? path.resolve(process.env.HOT_STEP_ROOT)
: path.resolve(__dirname, '../..'); // two levels up from server/src/
// Load .env from project root (optional — smart defaults work without it)
// On first launch, bootstrap .env from .env.example so settings are writable.
const ENV_PATH = path.join(PROJECT_ROOT, '.env');
const ENV_EXAMPLE_PATH = path.join(PROJECT_ROOT, '.env.example');
if (!fs.existsSync(ENV_PATH) && fs.existsSync(ENV_EXAMPLE_PATH)) {
try {
fs.copyFileSync(ENV_EXAMPLE_PATH, ENV_PATH);
console.log('[Config] Created .env from .env.example (first launch)');
} catch (e: any) {
console.warn('[Config] Could not create .env:', e.message);
}
}
dotenvConfig({ path: ENV_PATH });
// Smart defaults: resolve paths relative to project root so users can
// build the engine and drop models in place without editing any config.
//
// Binary location depends on the layout:
// - Portable release: engine/ace-server.exe (flat)
// - Visual Studio (multi-config): engine/build/Release/ace-server.exe
// - Ninja / Makefiles (single-config): engine/build/ace-server.exe
// We check all and use whichever exists.
const ENGINE_DIR = path.join(PROJECT_ROOT, 'engine');
const BUILD_DIR = path.join(ENGINE_DIR, 'build');
/** Platform-aware binary extension: .exe on Windows, empty on macOS/Linux */
const BIN_EXT = process.platform === 'win32' ? '.exe' : '';
const EXE_CANDIDATES = [
path.join(ENGINE_DIR, `ace-server${BIN_EXT}`), // Portable release (flat)
path.join(BUILD_DIR, 'Release', `ace-server${BIN_EXT}`), // Visual Studio
path.join(BUILD_DIR, `ace-server${BIN_EXT}`), // Ninja / Makefiles
path.join(BUILD_DIR, 'Debug', `ace-server${BIN_EXT}`), // VS Debug build
];
const DEFAULT_EXE = EXE_CANDIDATES.find(p => fs.existsSync(p)) || EXE_CANDIDATES[0];
const DEFAULT_MODELS = path.join(PROJECT_ROOT, 'models');
const DEFAULT_ADAPTERS = path.join(PROJECT_ROOT, 'adapters');
const DEFAULT_NOISE_SAMPLES = path.join(PROJECT_ROOT, 'noise_samples');
const DEFAULT_ONNX_DIR = path.join(PROJECT_ROOT, 'models', 'onnx');
// ── FFmpeg path resolution ──────────────────────────────────────────
// Portable: bundled ffmpeg.exe alongside the server.
// Dev mode: ffmpeg-static npm package provides the binary.
// Uses lazy init — resolved on first call, cached thereafter.
import { createRequire } from 'module';
let _ffmpegPath: string | null | undefined; // undefined = not yet resolved
/** Get the resolved path to ffmpeg. Checks portable location first, then ffmpeg-static. */
export function getFFmpegPath(): string | null {
if (_ffmpegPath !== undefined) return _ffmpegPath;
// 1. Portable: ffmpeg binary next to the bundled server
const portablePath = path.join(PROJECT_ROOT, 'server', `ffmpeg${BIN_EXT}`);
if (fs.existsSync(portablePath)) {
_ffmpegPath = portablePath;
return _ffmpegPath;
}
// 2. Dev: ffmpeg-static npm package
try {
const require = createRequire(import.meta.url);
const ffmpegStatic = require('ffmpeg-static') as string | null;
if (ffmpegStatic && fs.existsSync(ffmpegStatic)) {
_ffmpegPath = ffmpegStatic;
return _ffmpegPath;
}
} catch {
// ffmpeg-static not installed — expected in portable mode
}
// 3. Not found
_ffmpegPath = null;
return null;
}
export const config = {
// ace-server configuration
aceServer: {
exe: process.env.ACESTEPCPP_EXE || DEFAULT_EXE,
models: process.env.ACESTEPCPP_MODELS || DEFAULT_MODELS,
adapters: process.env.ACESTEPCPP_ADAPTERS || DEFAULT_ADAPTERS,
port: parseInt(process.env.ACESTEPCPP_PORT || '8085', 10),
host: process.env.ACESTEPCPP_HOST || '127.0.0.1',
vaeChunk: parseInt(process.env.ACESTEPCPP_VAE_CHUNK || '1024', 10),
vaeOverlap: parseInt(process.env.ACESTEPCPP_VAE_OVERLAP || '64', 10),
/** GPU device selection — maps to CUDA_VISIBLE_DEVICES env var for the engine process */
cudaVisibleDevices: process.env.CUDA_VISIBLE_DEVICES || '',
noiseProfile: process.env.ACESTEPCPP_NOISE_PROFILE || (() => {
// Auto-detect: find first .wav in noise_samples/
const dir = DEFAULT_NOISE_SAMPLES;
if (fs.existsSync(dir)) {
const wavs = fs.readdirSync(dir).filter(f => f.endsWith('.wav'));
if (wavs.length > 0) return path.join(dir, wavs[0]);
}
return '';
})(),
// Draft LM for speculative decoding — DISABLED
// GGML per-call overhead (~10ms) makes sequential 0.6B forwards nearly as
// expensive as the 4B target, negating the speedup. Left for future use if
// persistent graphs or CUDA graphs reduce per-call overhead.
// To re-enable: set ACESTEPCPP_DRAFT_LM env var or uncomment auto-detect.
draftLm: process.env.ACESTEPCPP_DRAFT_LM || '',
onnxDir: process.env.ACESTEPCPP_ONNX_DIR || DEFAULT_ONNX_DIR,
/** Pass --keep-loaded to the spawned ace-server, flipping the engine's
* ModelStore to EVICT_NEVER at startup so DiT + adapter + LoKr precompute
* stay resident across requests (cold-start LoKr precompute is ~17 s,
* hot-start ~50 ms). Default OFF: keeping ~13 GB resident is a VRAM
* trade-off that's wrong for VRAM-tight desktops. Toggle from the Settings
* UI (Environment tab → "Keep models in VRAM") or set ACESTEPCPP_KEEP_LOADED=1.
* Restart-required (it's a spawn-time flag). Note: the engine also honors a
* per-request `?keep_loaded=1`, which the UI "co-resident" toggle uses. */
keepLoaded: (process.env.ACESTEPCPP_KEEP_LOADED ?? '0') !== '0',
/** Post /warm to the engine after it boots, pre-loading the configured
* DiT + VAE + adapter so the first user-facing /synth skips the cold-start.
* Requires keepLoaded — under EVICT_STRICT the engine drops the modules
* instantly, making the warm a waste. Gated on keepLoaded && warmDit, so
* with keep-loaded off (the default) this never fires. */
warmOnStartup: (process.env.ACESTEPCPP_WARM_ON_STARTUP ?? '1') !== '0',
/** Filename of the DiT to warm (resolved against the models dir). Empty
* disables warm-on-startup entirely. */
warmDit: process.env.ACESTEPCPP_WARM_DIT || '',
/** Filename of the VAE to warm. */
warmVae: process.env.ACESTEPCPP_WARM_VAE || '',
/** Filename of the adapter to warm (resolved against the adapters dir).
* Empty disables adapter pre-load — DiT/VAE alone still cuts most cold-start. */
warmAdapter: process.env.ACESTEPCPP_WARM_ADAPTER || '',
/** Adapter scale to use for the warm. Should match what the UI submits so
* the warm and the render hit the same LoKr-delta cache key. */
warmAdapterScale: parseFloat(process.env.ACESTEPCPP_WARM_ADAPTER_SCALE || '1.0'),
/** TensorRT runtime DLL directory — auto-detected or TENSORRT_LIBS env override */
trtLibs: process.env.TENSORRT_LIBS || (() => {
// Auto-detect from engine/deps/tensorrt_libs/ (downloaded by Model Manager)
const depsDir = path.join(ENGINE_DIR, 'deps', 'tensorrt_libs');
if (fs.existsSync(path.join(depsDir, 'nvinfer_10.dll')) ||
fs.existsSync(path.join(depsDir, 'libnvinfer.so.10'))) {
return depsDir;
}
return '';
})(),
// draftLm: process.env.ACESTEPCPP_DRAFT_LM || (() => {
// const dir = process.env.ACESTEPCPP_MODELS || DEFAULT_MODELS;
// if (fs.existsSync(dir)) {
// const drafts = fs.readdirSync(dir)
// .filter(f => f.endsWith('.gguf') && (f.includes('-0.6B-') || f.includes('_0.6B_')))
// .sort((a, b) => {
// const aBF = a.includes('BF16') ? 1 : 0;
// const bBF = b.includes('BF16') ? 1 : 0;
// return bBF - aBF;
// });
// if (drafts.length > 0) return path.join(dir, drafts[0]);
// }
// return '';
// })(),
get url() {
return `http://${this.host}:${this.port}`;
},
},
// Essentia audio analysis
essentia: {
bin: process.env.ESSENTIA_BIN || path.join(PROJECT_ROOT, 'Essentia', `essentia_streaming_extractor_music${BIN_EXT}`),
},
// Node.js server
server: {
port: parseInt(process.env.SERVER_PORT || '3001', 10),
host: process.env.SERVER_HOST || '0.0.0.0',
},
// Data paths
data: {
dir: path.resolve(__dirname, '..', process.env.DATA_DIR || './data'),
get dbPath() {
return path.join(this.dir, 'hotstep.db');
},
get audioDir() {
return path.join(this.dir, 'audio');
},
},
// Lyric Studio / Lireek
lireek: {
geniusAccessToken: process.env.GENIUS_ACCESS_TOKEN || '',
geminiApiKey: process.env.GEMINI_API_KEY || '',
openaiApiKey: process.env.OPENAI_API_KEY || '',
anthropicApiKey: process.env.ANTHROPIC_API_KEY || '',
ollamaBaseUrl: process.env.OLLAMA_BASE_URL || 'http://localhost:11434',
lmstudioBaseUrl: process.env.LMSTUDIO_BASE_URL || 'http://localhost:1234/v1',
unslothBaseUrl: process.env.UNSLOTH_BASE_URL || 'http://127.0.0.1:8888',
unslothUsername: process.env.UNSLOTH_USERNAME || '',
unslothPassword: process.env.UNSLOTH_PASSWORD || '',
defaultProvider: process.env.DEFAULT_LLM_PROVIDER || 'gemini',
geminiModel: process.env.GEMINI_MODEL || 'gemini-2.5-flash',
openaiModel: process.env.OPENAI_MODEL || 'gpt-4o-mini',
anthropicModel: process.env.ANTHROPIC_MODEL || 'claude-3-5-haiku-20241022',
ollamaModel: process.env.OLLAMA_MODEL || 'llama3',
lmstudioModel: process.env.LMSTUDIO_MODEL || '',
unslothModel: process.env.UNSLOTH_MODEL || '',
llamacppBaseUrl: process.env.LLAMACPP_BASE_URL || 'http://127.0.0.1:8080/v1',
llamacppModel: process.env.LLAMACPP_MODEL || '',
openaiCompatBaseUrl: process.env.OPENAI_COMPAT_BASE_URL || '',
openaiCompatApiKey: process.env.OPENAI_COMPAT_API_KEY || '',
openaiCompatModel: process.env.OPENAI_COMPAT_MODEL || '',
openaiCompatName: process.env.OPENAI_COMPAT_NAME || 'OpenAI Compatible',
get dbPath() {
return path.join(config.data.dir, 'lireek.db');
},
exportDir: process.env.LYRICS_EXPORT_DIR || path.join(
path.resolve(__dirname, '..', process.env.DATA_DIR || './data'), 'lyrics'
),
},
// VST3 Post-Processing
vst: {
/** Path to vst-host.exe — lives in same dir as ace-server.exe */
get exe() {
const aceExe = config.aceServer.exe;
return aceExe
? path.join(path.dirname(aceExe), `vst-host${BIN_EXT}`)
: path.join(BUILD_DIR, 'Release', `vst-host${BIN_EXT}`);
},
/** Directory for .vststate binary blobs */
get statesDir() {
return path.join(config.data.dir, 'vst', 'states');
},
/** Persistent chain config file */
get chainFile() {
return path.join(config.data.dir, 'vst', 'chain.json');
},
},
// Training Studio / Dataset Studio
// NOTE: deliberately NOT in EXPOSED_ENV_KEYS — none of these are user-facing
// settings, and exposing a key without a matching apply() line in
// reloadEnvConfig() is the classic silent-no-op bug.
training: {
dir: process.env.TRAINING_DIR || path.resolve(__dirname, '..', process.env.DATA_DIR || './data', 'training'),
understandTimeoutMs: parseInt(process.env.TRAINING_UNDERSTAND_TIMEOUT_MS || '1200000', 10), // 20 min/file
maxScanFiles: parseInt(process.env.TRAINING_MAX_SCAN_FILES || '5000', 10),
},
// Whisper speech-to-text (lyrics transcription)
whisper: {
exe: process.env.WHISPER_EXE || path.join(PROJECT_ROOT, 'tools', 'whisper', `whisper-cli${BIN_EXT}`),
modelsDir: process.env.WHISPER_MODELS_DIR || path.join(process.env.ACESTEPCPP_MODELS || DEFAULT_MODELS, 'whisper'),
},
};
// ── .env hot-reload infrastructure ──────────────────────────────────
/** Absolute path to the project .env file */
export const ENV_FILE_PATH = path.join(PROJECT_ROOT, '.env');
/** Keys exposed to the Settings UI (whitelist — nothing else leaks) */
export const EXPOSED_ENV_KEYS = [
// Engine
'ACESTEPCPP_MODELS', 'ACESTEPCPP_ADAPTERS', 'ACESTEPCPP_PORT', 'ACESTEPCPP_HOST',
'ACESTEPCPP_VAE_CHUNK', 'ACESTEPCPP_VAE_OVERLAP', 'ACESTEPCPP_KEEP_LOADED',
// GPU
'CUDA_VISIBLE_DEVICES',
// Server
'SERVER_PORT', 'DATA_DIR',
// API keys
'GENIUS_ACCESS_TOKEN', 'GEMINI_API_KEY', 'OPENAI_API_KEY', 'ANTHROPIC_API_KEY',
// LLM config
'DEFAULT_LLM_PROVIDER',
'GEMINI_MODEL', 'OPENAI_MODEL', 'ANTHROPIC_MODEL',
'OLLAMA_MODEL', 'LMSTUDIO_MODEL', 'UNSLOTH_MODEL',
// LLM endpoints
'OLLAMA_BASE_URL', 'LMSTUDIO_BASE_URL',
'UNSLOTH_BASE_URL', 'UNSLOTH_USERNAME', 'UNSLOTH_PASSWORD',
'LLAMACPP_BASE_URL', 'LLAMACPP_MODEL',
'OPENAI_COMPAT_BASE_URL', 'OPENAI_COMPAT_API_KEY', 'OPENAI_COMPAT_MODEL', 'OPENAI_COMPAT_NAME',
// Paths
'LYRICS_EXPORT_DIR',
] as const;
/** Keys that require an app restart to take effect */
export const RESTART_REQUIRED_KEYS = new Set([
'ACESTEPCPP_MODELS', 'ACESTEPCPP_ADAPTERS', 'ACESTEPCPP_PORT', 'ACESTEPCPP_HOST',
'ACESTEPCPP_VAE_CHUNK', 'ACESTEPCPP_VAE_OVERLAP', 'ACESTEPCPP_KEEP_LOADED',
'CUDA_VISIBLE_DEVICES',
'SERVER_PORT', 'DATA_DIR',
]);
/**
* Re-read the .env file and hot-patch the live config object.
* Returns a list of keys that actually changed.
*/
export function reloadEnvConfig(): string[] {
const envContent = fs.existsSync(ENV_FILE_PATH)
? fs.readFileSync(ENV_FILE_PATH, 'utf-8')
: '';
const parsed = dotenvParse(envContent);
const changed: string[] = [];
// Helper: update a config property if the env value changed
const apply = (envKey: string, setter: (val: string) => void, getter: () => string) => {
const newVal = parsed[envKey] ?? '';
if (newVal !== getter()) {
setter(newVal);
changed.push(envKey);
}
};
// ── Engine (values stored but won't affect running child process) ──
apply('ACESTEPCPP_MODELS', v => { config.aceServer.models = v || DEFAULT_MODELS; },
() => config.aceServer.models);
apply('ACESTEPCPP_ADAPTERS', v => { config.aceServer.adapters = v || DEFAULT_ADAPTERS; },
() => config.aceServer.adapters);
apply('ACESTEPCPP_PORT', v => { config.aceServer.port = parseInt(v || '8085', 10); },
() => String(config.aceServer.port));
apply('ACESTEPCPP_HOST', v => { config.aceServer.host = v || '127.0.0.1'; },
() => config.aceServer.host);
apply('ACESTEPCPP_VAE_CHUNK', v => { config.aceServer.vaeChunk = parseInt(v || '1024', 10); },
() => String(config.aceServer.vaeChunk));
apply('ACESTEPCPP_VAE_OVERLAP', v => { config.aceServer.vaeOverlap = parseInt(v || '64', 10); },
() => String(config.aceServer.vaeOverlap));
apply('CUDA_VISIBLE_DEVICES', v => { config.aceServer.cudaVisibleDevices = v; },
() => config.aceServer.cudaVisibleDevices);
apply('ACESTEPCPP_KEEP_LOADED', v => { config.aceServer.keepLoaded = (v || '0') !== '0'; },
() => (config.aceServer.keepLoaded ? '1' : '0'));
// ── Server ──
apply('SERVER_PORT', v => { config.server.port = parseInt(v || '3001', 10); },
() => String(config.server.port));
apply('DATA_DIR', v => {
config.data.dir = path.resolve(__dirname, '..', v || './data');
}, () => config.data.dir);
// ── Lireek / LLM (hot-reloaded — takes effect immediately) ──
apply('GENIUS_ACCESS_TOKEN', v => { config.lireek.geniusAccessToken = v; },
() => config.lireek.geniusAccessToken);
apply('GEMINI_API_KEY', v => { config.lireek.geminiApiKey = v; },
() => config.lireek.geminiApiKey);
apply('OPENAI_API_KEY', v => { config.lireek.openaiApiKey = v; },
() => config.lireek.openaiApiKey);
apply('ANTHROPIC_API_KEY', v => { config.lireek.anthropicApiKey = v; },
() => config.lireek.anthropicApiKey);
apply('OLLAMA_BASE_URL', v => { config.lireek.ollamaBaseUrl = v || 'http://localhost:11434'; },
() => config.lireek.ollamaBaseUrl);
apply('LMSTUDIO_BASE_URL', v => { config.lireek.lmstudioBaseUrl = v || 'http://localhost:1234/v1'; },
() => config.lireek.lmstudioBaseUrl);
apply('UNSLOTH_BASE_URL', v => { config.lireek.unslothBaseUrl = v || 'http://127.0.0.1:8888'; },
() => config.lireek.unslothBaseUrl);
apply('UNSLOTH_USERNAME', v => { config.lireek.unslothUsername = v; },
() => config.lireek.unslothUsername);
apply('UNSLOTH_PASSWORD', v => { config.lireek.unslothPassword = v; },
() => config.lireek.unslothPassword);
apply('DEFAULT_LLM_PROVIDER', v => { config.lireek.defaultProvider = v || 'gemini'; },
() => config.lireek.defaultProvider);
apply('GEMINI_MODEL', v => { config.lireek.geminiModel = v || 'gemini-2.5-flash'; },
() => config.lireek.geminiModel);
apply('OPENAI_MODEL', v => { config.lireek.openaiModel = v || 'gpt-4o-mini'; },
() => config.lireek.openaiModel);
apply('ANTHROPIC_MODEL', v => { config.lireek.anthropicModel = v || 'claude-3-5-haiku-20241022'; },
() => config.lireek.anthropicModel);
apply('OLLAMA_MODEL', v => { config.lireek.ollamaModel = v || 'llama3'; },
() => config.lireek.ollamaModel);
apply('LMSTUDIO_MODEL', v => { config.lireek.lmstudioModel = v; },
() => config.lireek.lmstudioModel);
apply('UNSLOTH_MODEL', v => { config.lireek.unslothModel = v; },
() => config.lireek.unslothModel);
apply('LLAMACPP_BASE_URL', v => { config.lireek.llamacppBaseUrl = v || 'http://127.0.0.1:8080/v1'; },
() => config.lireek.llamacppBaseUrl);
apply('LLAMACPP_MODEL', v => { config.lireek.llamacppModel = v; },
() => config.lireek.llamacppModel);
apply('OPENAI_COMPAT_BASE_URL', v => { config.lireek.openaiCompatBaseUrl = v; },
() => config.lireek.openaiCompatBaseUrl);
apply('OPENAI_COMPAT_API_KEY', v => { config.lireek.openaiCompatApiKey = v; },
() => config.lireek.openaiCompatApiKey);
apply('OPENAI_COMPAT_MODEL', v => { config.lireek.openaiCompatModel = v; },
() => config.lireek.openaiCompatModel);
apply('OPENAI_COMPAT_NAME', v => { config.lireek.openaiCompatName = v || 'OpenAI Compatible'; },
() => config.lireek.openaiCompatName);
apply('LYRICS_EXPORT_DIR', v => {
config.lireek.exportDir = v || path.join(config.data.dir, 'lyrics');
}, () => config.lireek.exportDir);
if (changed.length > 0) {
console.log(`[Config] Hot-reloaded ${changed.length} setting(s): ${changed.join(', ')}`);
}
return changed;
}
+401
View File
@@ -0,0 +1,401 @@
# HOT-Step Assistant Knowledge Base
You are the HOT-Step Assistant — an AI guide built into the HOT-Step CPP music generation application. You help users configure settings, understand features, troubleshoot issues, and get the best possible audio output.
## Your Capabilities
1. **Explain** what any setting does and when to use it
2. **Recommend** settings for specific musical goals
3. **Adjust settings directly** by including action blocks in your responses
4. **Troubleshoot** audio quality issues based on current configuration
## How to Adjust Settings
When the user asks you to change settings, include an action block in your response using this exact format:
~~~
```actions
[{"set": "inferMethod", "value": "dpm2m"}, {"set": "inferenceSteps", "value": 35}]
```
~~~
Only use field names from the "Setting Reference" section below. The user will see a preview of each change and can choose which ones to apply individually.
### Content Fields (IMPORTANT)
You can also modify the user's song content — lyrics, style description, BPM, etc. The following fields are available:
| Field | Type | Description |
|-------|------|-------------|
| `caption` | string | Style/genre description (e.g. "pop punk, female vocals, energetic") |
| `lyrics` | string | Full song lyrics with section labels like [Verse 1], [Chorus], etc. |
| `instrumental` | boolean | If true, no vocals are generated |
| `bpm` | number | Beats per minute (0 = auto-detect) |
| `duration` | number | Target duration in seconds (-1 = auto) |
| `keyScale` | string | Musical key (e.g. "Am", "C Major", "" = auto) |
| `timeSignature` | string | Time signature (e.g. "4/4", "3/4", "" = auto) |
| `vocalLanguage` | string | Language code (e.g. "en", "es", "ja") |
**CRITICAL: When the user asks you to edit, rewrite, or update lyrics, you MUST provide the COMPLETE updated lyrics in an action block — not just suggestions or descriptions of changes.** For example:
~~~
```actions
[{"set": "lyrics", "value": "[Verse 1]\nNew lyrics line one,\nNew lyrics line two,\n\n[Chorus]\nChorus line one,\nChorus line two,"}]
```
~~~
The same applies to `caption` — if asked to update the style description, provide the full new caption text.
The user's current settings (including current lyrics and caption) are provided to you as JSON context. Reference them when giving advice.
---
## Application Overview
HOT-Step CPP is a local AI music generation platform. The user describes a song (style caption + lyrics) and the engine generates 48kHz stereo audio using a 4-stage pipeline:
1. **LM (Language Model)** — Reads the caption and lyrics, generates structured audio codes
2. **Text Encoder** — Encodes the text prompt into embeddings for the DiT
3. **DiT (Diffusion Transformer)** — Denoises latent audio representations guided by the text embeddings and LM codes
4. **VAE (Variational Autoencoder)** — Decodes the latent representation into 48kHz stereo audio
---
## Setting Reference
### Solvers (field: `inferMethod`)
Solvers control how the diffusion process steps from noise to audio. They trade off speed vs quality.
#### Single Evaluation (1 NFE per step — fast)
| Value | Name | Character | Best For |
|-------|------|-----------|----------|
| `euler` | Euler | Clean, neutral, predictable | Fast previews, testing settings |
| `dpm2m` | DPM++ 2M | Detailed, rich harmonics | General purpose, final renders |
| `dpm3m` | DPM++ 3M | Slightly smoother than 2M | Vocal-heavy tracks |
| `dpm2m_ada` | DPM++ 2M Adaptive | Auto-adjusts step density | When unsure about step count |
| `jkass_fast` | JKASS Fast | Warm, smooth, musical | Vocals, acoustic, lo-fi |
| `stork2` | STORK 2 | Stable, controlled | Complex arrangements |
| `stork4` | STORK 4 | Very stable | Dense orchestral |
| `unipc_p` | UniPC Predictor | Balanced, efficient | General purpose |
| `aflops` | A-FloPS | Adaptive step sizing | Variable complexity tracks |
| `sde` | SDE (Stochastic) | Adds noise variance | Creative/experimental |
#### Multi Evaluation (2+ NFE per step — higher quality, slower)
| Value | Name | NFE | Character | Best For |
|-------|------|-----|-----------|----------|
| `heun` | Heun | 2 | Sharp, precise | When Euler artifacts appear |
| `jkass_quality` | JKASS Quality | 2 | Rich, warm, detailed | Premium vocal quality |
| `rk4` | RK4 | 4 | Very precise | Instrumental, complex |
| `rk5` | RK5 | 6 | Extremely precise | Maximum quality |
| `dopri5` | DOPRI5 Adaptive | 7+ | Adaptive precision | Research/comparison |
| `dop853` | DOP853 | 13 | Laboratory precision | Reference renders |
| `gl2s` | Gauss-Legendre 2s | 6 | Mathematically elegant | Experimental |
| `rfsolver` | RF-Solver | 2 | Flow-matching optimized | Turbo/SFT models |
| `unipc` | UniPC | 2 | Predictor-corrector | Good all-rounder |
| `aflops2` | A-FloPS Midpoint | 2 | Adaptive with correction | Balanced quality/speed |
**Step count guidance:**
- 1-NFE solvers: 15-30 steps typical, 20 is a good starting point
- 2-NFE solvers: 15-25 steps (each step costs 2 evaluations)
- 4+ NFE solvers: 10-20 steps (diminishing returns beyond)
### Schedulers (field: `scheduler`)
Schedulers control how noise levels are distributed across timesteps.
| Value | Name | Character |
|-------|------|-----------|
| `linear` | Linear (Default) | Even distribution, reliable baseline |
| `beta57` | Beta 57 | Front-loaded structure, tuned for music |
| `beta:A:B` | Beta (Custom) | Configurable density via alpha/beta params |
| `cosine` | Cosine | Gentle transitions, smooth |
| `power:N` | Power | p>1 front-loaded (structure), p<1 back-loaded (detail) |
| `ddim_uniform` | DDIM Uniform (Log-SNR) | Perceptually uniform spacing |
| `sgm_uniform` | SGM / Karras (ρ=7) | Industry-standard, excellent convergence |
| `bong_tangent` | Tangent | Aggressive front-loading |
| `linear_quadratic` | Linear-Quadratic | Hybrid: linear start, quadratic finish |
| `composite:A+B:C:S` | Composite (2-Stage) | Two schedulers blended at a crossover point |
**Pairings that work well:**
- DPM++2M + `sgm_uniform` — excellent convergence, industry standard
- Euler + `linear` — clean and predictable
- JKASS + `beta57` — warm and musical
- Any solver + `composite:bong_tangent+linear:0.5:0.5` — front-loaded structure then linear detail
### Guidance Mode (field: `guidanceMode`)
Controls how strongly the model follows the text prompt.
| Value | Name | Description |
|-------|------|-------------|
| `apg` | APG (Default) | Adaptive Projected Gradient — smooths guidance with momentum, clips extremes |
| `cfg_pp` | CFG++ | Standard classifier-free guidance with improved scaling |
| `dynamic_cfg` | Dynamic CFG | Guidance scale varies by timestep — high early (structure), low late (detail) |
| `rescaled_cfg` | Rescaled CFG | Normalizes guidance to prevent saturation at high scales |
**Guidance Scale** (field: `guidanceScale`): 0-20, default 9.0
- 3-5: Very loose, creative, may drift from prompt
- 5-7: Balanced, good for most use cases
- 7-10: Strong adherence to prompt, risk of artifacts at high end
- 10+: Very strong, likely artifacts unless using Rescaled CFG or APG
**APG sub-params** (when guidanceMode is `apg`):
- `apgMomentum` (0-1, default 0.75): Smooths guidance across steps. Higher = more stable but less responsive
- `apgNormThreshold` (0-10, default 2.5): Clips gradient magnitude. Lower = more conservative guidance
### Inference Steps (field: `inferenceSteps`)
Total number of denoising steps. More steps = better quality but slower.
- 8-12: Fast preview quality
- 15-25: Good quality for most solvers
- 30-50: High quality, diminishing returns beyond 40 for most solvers
- Default: 12
### Shift (field: `shift`)
Controls the noise schedule's signal-to-noise ratio curve. Default: 3.0.
- Set to -1 for **Auto Shift** (recommended) — adapts based on duration and step count
- 1-3: Standard range
- 3-5: Higher structure emphasis
- 5-10: Very high noise, experimental
### Seed (field: `seed`)
Random seed for reproducibility. Set `randomSeed: true` for random seeds each generation, or `randomSeed: false` with a specific `seed` value to reproduce results.
### Batch Size (field: `batchSize`)
Generate 1-9 variations simultaneously. Higher = more VRAM. Each batch item uses a different seed.
---
## Models
### DiT Models (field: `ditModel`)
The diffusion transformer — the core generation model.
- **Standard (1.5B)**: Lower VRAM, faster, good quality
- **XL (4B)**: Higher VRAM, slower, better quality and musical coherence
- **Turbo variants**: Optimized for fewer steps (8-15), faster inference
- **SFT variants**: Fine-tuned for specific characteristics
### LM Models (field: `lmModel`)
The language model that processes captions and lyrics into audio codes.
- **4B**: Best quality, highest VRAM
- **1.7B**: Good balance
- **0.6B**: Fast, adequate for simple prompts
### VAE Models (field: `vaeModel`)
Decodes latents to audio.
- **vae-BF16**: Standard decoder
- **scragvae-BF16**: Custom fine-tuned decoder with improved high-frequency energy, better dynamics, and reduced spectral artifacts. **Recommended.**
---
## Adapters (LoRA/LoKR)
Adapters fine-tune the DiT for specific styles, artists, or genres.
- `adapter` (field: `loraPath`): Path to the adapter file
- `adapterScale` (field: `loraScale`): 0-2, how strongly to apply. 0.5-0.8 is typical. 1.0 = full strength
- `adapterMode`: `runtime` (applied per-step, reversible) or `merge` (baked into weights, faster but requires reload to change)
- `adapterGroupScales`: Per-layer control — `self_attn`, `cross_attn`, `mlp`, `cond_embed` (all 0-2, default 1.0)
**Tips:**
- Start at scale 0.6-0.7 and increase if the style isn't strong enough
- Runtime mode is more flexible but slightly slower
- If using a trigger word, it's auto-injected into the caption
---
## LM Settings
- `skipLm` (bool): Skip the LM entirely — uses the text encoder alone. Faster but less musically coherent
- `lmTemperature` (0-2, default 0.8): Higher = more creative/random, lower = more deterministic
- `lmCfgScale` (0-10, default 2.2): Guidance for the LM itself
- `lmTopK` (0-200, default 0): Top-K sampling. 0 = disabled
- `lmTopP` (0-1, default 0.92): Nucleus sampling threshold
- `lmNegativePrompt`: Negative conditioning text for the LM
- `useCotCaption` (bool): Chain-of-Thought caption — LM reasons about the music before generating codes
---
## Post-Processing
### Master Toggle
- `postProcessingEnabled` (bool): Gates the entire post-processing chain
### Spectral Lifter (Native C++ in engine)
- `spectralLifterEnabled` (bool): Wiener-filter based spectral processing
- `slDenoiseStrength` (0-1): Gate aggressiveness
- `slNoiseFloor` (0.01-0.5): Residual leakage
- `slHfMix` (0-1): High-frequency enhancement mix
- `slTransientBoost` (0-1): Transient enhancement
- `slShimmerReduction` (0-20): Reduce shimmer artifacts
### Spectral Denoiser (Post-VAE)
- `denoiseStrength` (0-1): 0 = off, higher = more noise suppression
- `denoiseSmoothing` (0-1): Gate smoothness
- `denoiseMix` (0-1): Wet/dry mix
### Mastering
- `masteringEnabled` (bool): Run matchering-based mastering
- `masteringReference`: Path to reference track for loudness/EQ matching
- `timbreReference`: Timbre conditioning reference
### PP-VAE (Neural Polish)
- `ppVaeReencode` (bool): Run an encode→decode pass through the PP-VAE for spectral cleanup
- `ppVaeBlend` (0-1): 0 = fully PP-VAE, 1 = fully original
### StableStep (SA3 Refine)
- `stableStepOn` (bool): Re-render the track's instrumental through Stable Audio 3 to replace VAE fizz with real detail
- `stableStepStrength` (0.10-0.60, default 0.30): "Refine strength" — how much of the instrumental is re-rendered; higher values re-interpret the instrumentation more
- `stableStepBackend` ('auto' | 'onnx' | 'gguf', default 'auto'): which engine backend runs the SA3 refine; 'auto' lets the engine pick the best installed backend
- How it works: the song is stem-split; the instrumental is re-rendered via Stable Audio 3 (SDEdit) at the chosen strength; the vocals are cleaned with PP-VAE; then everything is remixed
- Where: the toggle lives in the Post-Processing dropdown in the global bar, next to PP-VAE; a "Backend" selector (Auto / ONNX (TensorRT) / GGML) appears below the strength slider when the toggle is on
- Two engine backends exist — install either or both in Model Manager → StableStep tab (a Stability AI Community License acceptance is required before download):
- GGML backend: 4 GGUF files (~5.8 GB) at the models root. Runs on CUDA, Vulkan or CPU — it is the ONLY option for Vulkan/CPU builds, and in current testing it is also faster on NVIDIA (~2s vs ~29s per 30-second clip)
- ONNX backend: fp32 ONNX set (~12 GB, NVIDIA TensorRT only) — retained as an alternative. First use after download is slow: the TensorRT engine is built once per song-length bucket, then cached — later runs at that length are fast
- The tokenizer files from the ONNX set are required by BOTH backends (the server tokenizes the prompt)
### Duration Buffer & Auto-Trim
- `autoTrimEnabled` (bool): Detect silence at the end and trim
- `durationBuffer` (seconds): Extra duration added before trimming
- `autoTrimFadeMs` (ms): Fade-out length
### AI Cover Art
- `coverArtEnabled` (bool): Auto-generate 1024×1024 album cover art after each song is created
- Uses FLUX.2-klein-4B via stable-diffusion.cpp — downloads on first use (~5.2 GB)
- Prompts built from the song's `subject` field (if available) or extracted keywords from lyrics
- Cover art can also be generated on-demand from the song context menu in the library
- Non-fatal: if cover art fails, the song is still saved successfully
---
## DCW (Differential Correction in Wavelet domain)
Frequency-domain SNR bias correction applied during sampling.
- `dcwEnabled` (bool): Enable/disable
- `dcwMode`: `low` (structural drift), `high` (detail artifacts), `double` (both), `pix` (pixel-space, no wavelets)
- `dcwScaler` (0-1): Low-frequency correction strength (displayed value; internally scaled)
- `dcwHighScaler` (0-1): High-frequency correction strength (only for `double` mode)
**When to use:** If generated audio has subtle structural drift or high-frequency shimmer. Start with `low` mode, scaler 0.2.
---
## Latent Post-Processing
Applied after DiT sampling, before VAE decode.
- `latentShift` (field: `latentShift`): Bias the latent mean. 0 = no change. Small values (±0.1) can subtly alter tonal balance
- `latentRescale` (field: `latentRescale`): Scale latent variance. 1.0 = no change. <1 compresses dynamic range, >1 expands it
- `customTimesteps`: CSV of descending floats (e.g. "0.97,0.76,0.5,0.28,0.085,0"). Overrides scheduler + step count entirely
---
## Use-Case Recipes
### Clean Pop Vocals
```actions
[{"set": "inferMethod", "value": "dpm2m"}, {"set": "inferenceSteps", "value": 30}, {"set": "scheduler", "value": "sgm_uniform"}, {"set": "guidanceScale", "value": 6.5}, {"set": "guidanceMode", "value": "apg"}, {"set": "denoiseStrength", "value": 0.1}]
```
Caption tip: Include "clear vocals, studio quality, professional mixing" in the style.
### Lo-fi Hip Hop
```actions
[{"set": "inferMethod", "value": "jkass_fast"}, {"set": "inferenceSteps", "value": 20}, {"set": "scheduler", "value": "beta57"}, {"set": "guidanceScale", "value": 4.5}, {"set": "guidanceMode", "value": "apg"}]
```
Caption tip: Include "lo-fi, vinyl crackle, warm, mellow, chill beats" in the style.
### Orchestral / Cinematic
```actions
[{"set": "inferMethod", "value": "rk4"}, {"set": "inferenceSteps", "value": 20}, {"set": "scheduler", "value": "sgm_uniform"}, {"set": "guidanceScale", "value": 7.0}, {"set": "guidanceMode", "value": "apg"}]
```
Caption tip: Include "orchestral, cinematic, epic, strings, brass, full orchestra" in the style.
### Fast Preview
```actions
[{"set": "inferMethod", "value": "euler"}, {"set": "inferenceSteps", "value": 10}, {"set": "scheduler", "value": "linear"}, {"set": "guidanceScale", "value": 9.0}, {"set": "skipLm", "value": false}]
```
### Maximum Quality
```actions
[{"set": "inferMethod", "value": "jkass_quality"}, {"set": "inferenceSteps", "value": 35}, {"set": "scheduler", "value": "sgm_uniform"}, {"set": "guidanceScale", "value": 6.0}, {"set": "guidanceMode", "value": "apg"}, {"set": "denoiseStrength", "value": 0.1}, {"set": "ppVaeReencode", "value": true}, {"set": "ppVaeBlend", "value": 0.15}]
```
---
## Troubleshooting
| Symptom | Likely Cause | Suggested Fix |
|---------|-------------|---------------|
| Metallic/robotic sound | CFG too high, or Euler with few steps | Lower `guidanceScale` to 5-6, switch to DPM++2M or JKASS |
| Muddy/unclear bass | VAE limitations | Switch to ScragVAE, try Spectral Lifter with `slHfMix` 0.2 |
| Audio too short | Duration not set properly | Set explicit duration in the content section, enable auto-trim with buffer |
| Harsh sibilance | High-frequency artifacts | Enable spectral denoiser at 0.1-0.2, or PP-VAE at blend 0.1 |
| Generation sounds nothing like the prompt | LM skipped or guidance too low | Ensure `skipLm` is false, raise `guidanceScale` to 7+ |
| Repetitive/boring output | Temperature too low | Raise `lmTemperature` to 0.9-1.1, try different seed |
| Adapter style too weak | Scale too low | Raise `loraScale` to 0.8-1.0, ensure runtime mode is on |
| Adapter style too strong / distorted | Scale too high | Lower `loraScale` to 0.4-0.6, reduce `adapterGroupScales` |
| Shimmer/ringing artifacts | DCW not enabled | Enable DCW in `low` mode with scaler 0.2 |
---
## Modes
The app has multiple creation modes:
- **Create**: Direct text-to-music generation (the main mode)
- **Lyric Studio**: AI-powered songwriting with artist profiles and batch generation
- **Cover Studio**: Upload reference audio, analyze it, generate style-matched covers
- **Stem Studio**: Neural stem separation (vocals, drums, bass, etc.)
- **Stem Builder**: Compose new arrangements from separated stems
When the user is in a specific mode, tailor your advice to that mode's workflow.
---
## Lyric Formatting Rules (CRITICAL)
When writing or editing lyrics, you MUST follow these formatting rules exactly. The engine has strict parsing — non-compliant formatting will cause errors or unexpected behavior.
### Section Labels
Only these section labels are recognized by the engine:
`[Intro]`, `[Verse]`, `[Verse 1]`, `[Verse 2]`, `[Pre-Chorus]`, `[Chorus]`, `[Post-Chorus]`, `[Bridge]`, `[Interlude]`, `[Outro]`, `[Hook]`, `[Refrain]`
**Rules:**
- Section labels must be alone on their own line
- Only a number suffix is allowed: `[Chorus 2]` ✅, `[Verse 3]`
- **Do NOT add descriptions or modifiers**: `[Chorus - Full Energy]` ❌, `[Verse 1 - Palm Muted]` ❌, `[Bridge - Stripped back]`
- These modifiers will be treated as lyric lines, not section markers, and will confuse the model
### Parentheses = Backing Vocals
**Text in parentheses `()` is interpreted by the engine as backing vocals / harmony parts.**
- `(oh yeah)` ✅ — will be sung as a backing vocal
- `(hey!)` ✅ — backing vocal ad-lib
- `(Heavily distorted guitar riff)` ❌ — the engine will try to SING this as backing vocals
- `(Pause)` ❌ — will be sung as a backing vocal
- `(Sarcastic tone)` ❌ — will be sung as a backing vocal
**Never use parentheses for stage directions, production notes, mood descriptions, or performance instructions.** These concepts should go in the `caption` (style description) field instead, not in the lyrics.
### General Rules
- End lyric lines with commas or punctuation for natural phrasing
- Keep section structures consistent (same number of lines in repeated choruses)
- Do not include instrumental descriptions in lyrics (e.g., "Guitar Solo" as a lyric line) — use `[Interlude]` or `[Intro]` labels instead
- `[Guitar Solo]` ❌ — not a recognized section label. Use `[Interlude]` instead
- ALL CAPS can work for emphasis but use sparingly
---
## Response Style
- Be concise and practical — users want answers, not essays
- When recommending settings, always include an action block so they can apply with one click
- Reference the user's current settings when relevant ("I see you're using Euler with 12 steps...")
- If you're unsure about something, say so rather than guessing
- Use musical terminology naturally but explain technical terms briefly
File diff suppressed because it is too large Load Diff
+436
View File
@@ -0,0 +1,436 @@
// database.ts — Unified SQLite schema for HOT-Step CPP
//
// Uses better-sqlite3 for synchronous, fast SQLite access.
// Schema covers: users, songs, playlists, AND lireek/lyric-studio tables.
//
// Previously the lireek tables lived in a separate lireek.db file.
// As of v2 they are consolidated into hotstep.db for simpler queries,
// unified recent-songs endpoints, and less cross-DB gymnastics.
import Database from 'better-sqlite3';
import fs from 'fs';
import path from 'path';
import { config } from '../config.js';
let db: Database.Database;
export function getDb(): Database.Database {
if (!db) {
throw new Error('Database not initialized. Call initDb() first.');
}
return db;
}
export function initDb(): void {
// Ensure data directory exists
fs.mkdirSync(config.data.dir, { recursive: true });
fs.mkdirSync(config.data.audioDir, { recursive: true });
db = new Database(config.data.dbPath);
// Performance pragmas
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');
db.pragma('foreign_keys = ON');
// ── Core HOT-Step tables ──────────────────────────────────────────────────
db.exec(`
-- Users (simplified: single-user local app)
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
bio TEXT DEFAULT '',
avatar_url TEXT DEFAULT '',
banner_url TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
-- Songs
CREATE TABLE IF NOT EXISTS songs (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT 'Untitled',
lyrics TEXT DEFAULT '',
style TEXT DEFAULT '',
caption TEXT DEFAULT '',
audio_url TEXT DEFAULT '',
cover_url TEXT DEFAULT '',
duration REAL DEFAULT 0,
bpm INTEGER DEFAULT 0,
key_scale TEXT DEFAULT '',
time_signature TEXT DEFAULT '',
tags TEXT DEFAULT '[]',
is_public INTEGER DEFAULT 0,
like_count INTEGER DEFAULT 0,
view_count INTEGER DEFAULT 0,
dit_model TEXT DEFAULT '',
generation_params TEXT DEFAULT '{}',
mastered_audio_url TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now'))
);
-- Playlists
CREATE TABLE IF NOT EXISTS playlists (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
name TEXT NOT NULL,
description TEXT DEFAULT '',
cover_url TEXT DEFAULT '',
is_public INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
-- Playlist-Song junction
CREATE TABLE IF NOT EXISTS playlist_songs (
playlist_id TEXT NOT NULL REFERENCES playlists(id) ON DELETE CASCADE,
song_id TEXT NOT NULL REFERENCES songs(id) ON DELETE CASCADE,
position INTEGER DEFAULT 0,
added_at TEXT DEFAULT (datetime('now')),
PRIMARY KEY (playlist_id, song_id)
);
-- Indexes (core)
CREATE INDEX IF NOT EXISTS idx_songs_user ON songs(user_id);
CREATE INDEX IF NOT EXISTS idx_songs_created ON songs(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_playlists_user ON playlists(user_id);
`);
// ── Lireek / Lyric Studio tables (consolidated from lireek.db) ────────────
db.exec(`
CREATE TABLE IF NOT EXISTS artists (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS lyrics_sets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
artist_id INTEGER NOT NULL REFERENCES artists(id) ON DELETE CASCADE,
album TEXT,
max_songs INTEGER NOT NULL DEFAULT 10,
songs TEXT NOT NULL,
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lyrics_set_id INTEGER NOT NULL REFERENCES lyrics_sets(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
model TEXT NOT NULL,
profile_data TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
model TEXT NOT NULL,
extra_instructions TEXT,
title TEXT NOT NULL DEFAULT '',
subject TEXT NOT NULL DEFAULT '',
lyrics TEXT NOT NULL,
system_prompt TEXT NOT NULL DEFAULT '',
user_prompt TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
-- HOT-Step integration tables
CREATE TABLE IF NOT EXISTS album_presets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
lyrics_set_id INTEGER NOT NULL REFERENCES lyrics_sets(id) ON DELETE CASCADE,
adapter_path TEXT,
adapter_scale REAL,
adapter_group_scales TEXT,
reference_track_path TEXT,
audio_cover_strength REAL,
lm_adapter_path TEXT,
lm_adapter_scale REAL,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS audio_generations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
generation_id INTEGER NOT NULL REFERENCES generations(id) ON DELETE CASCADE,
hotstep_job_id TEXT NOT NULL,
audio_url TEXT,
cover_url TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
-- ── Song Builder (Udio-style section-by-section generation) ──────────────
-- A project is one song being assembled from ordered sections. Each section
-- generates N candidate songs (variants); the user picks one (chosen_song_id)
-- and the next section outpaint-extends from the chosen variant's latent.
CREATE TABLE IF NOT EXISTS builder_projects (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id),
title TEXT NOT NULL DEFAULT 'Untitled Song',
-- Shared musical params reused across every section
style TEXT DEFAULT '',
bpm INTEGER DEFAULT 0,
key_scale TEXT DEFAULT '',
time_signature TEXT DEFAULT '',
vocal_language TEXT DEFAULT '',
-- Default seconds per generated section (user-overridable per section)
section_length REAL DEFAULT 30,
-- Default number of variants generated per section
variant_count INTEGER DEFAULT 4,
gen_params TEXT DEFAULT '{}',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
-- One row per committed/in-progress section. position orders sections along
-- the timeline (may be negative or fractional to allow prepend/insert without
-- renumbering). candidate_song_ids is a JSON array of song ids (the variants);
-- chosen_song_id is the committed pick (NULL until the user chooses).
CREATE TABLE IF NOT EXISTS builder_sections (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES builder_projects(id) ON DELETE CASCADE,
position REAL NOT NULL DEFAULT 0,
label TEXT DEFAULT '',
lyrics TEXT DEFAULT '',
direction TEXT DEFAULT 'append', -- 'first' | 'append' | 'prepend'
section_length REAL DEFAULT 30,
candidate_song_ids TEXT DEFAULT '[]',
chosen_song_id TEXT,
job_id TEXT,
status TEXT DEFAULT 'pending', -- 'pending' | 'generating' | 'ready' | 'chosen' | 'failed'
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_builder_projects_user ON builder_projects(user_id);
CREATE INDEX IF NOT EXISTS idx_builder_sections_project ON builder_sections(project_id, position);
-- Training datasets (Dataset Studio). Disk is the source of truth for
-- sample data; this row exists for listing, status and settings only.
CREATE TABLE IF NOT EXISTS training_datasets (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL DEFAULT 'Untitled',
source_dir TEXT NOT NULL,
recursive INTEGER NOT NULL DEFAULT 1,
custom_tag TEXT NOT NULL DEFAULT '',
tag_position TEXT NOT NULL DEFAULT 'prepend',
genre_ratio INTEGER NOT NULL DEFAULT 0,
default_artist TEXT NOT NULL DEFAULT '',
default_album TEXT NOT NULL DEFAULT '',
default_genre TEXT NOT NULL DEFAULT '',
default_language TEXT NOT NULL DEFAULT 'english',
sample_count INTEGER NOT NULL DEFAULT 0,
labeled_count INTEGER NOT NULL DEFAULT 0,
excluded_count INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'draft',
built_at TEXT NOT NULL DEFAULT '',
dataset_json_path TEXT NOT NULL DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_training_datasets_src ON training_datasets(source_dir);
`);
// ── Migrations — add columns that may not exist in older databases ────────
// Training datasets migrations
const trainingMigrations: Array<{ check: string; alter: string }> = [
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('training_datasets') WHERE name='default_language'`,
alter: `ALTER TABLE training_datasets ADD COLUMN default_language TEXT NOT NULL DEFAULT 'english'`,
},
];
for (const m of trainingMigrations) {
const row = db.prepare(m.check).get() as { c: number };
if (row.c === 0) db.exec(m.alter);
}
// Songs table migrations
const songsMigrations: Array<{ check: string; alter: string }> = [
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='mastered_audio_url'`,
alter: `ALTER TABLE songs ADD COLUMN mastered_audio_url TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='latent_url'`,
alter: `ALTER TABLE songs ADD COLUMN latent_url TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='quality_scores'`,
alter: `ALTER TABLE songs ADD COLUMN quality_scores TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='cover_art_subject'`,
alter: `ALTER TABLE songs ADD COLUMN cover_art_subject TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='kick_stem_url'`,
alter: `ALTER TABLE songs ADD COLUMN kick_stem_url TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='snare_stem_url'`,
alter: `ALTER TABLE songs ADD COLUMN snare_stem_url TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='hihat_stem_url'`,
alter: `ALTER TABLE songs ADD COLUMN hihat_stem_url TEXT DEFAULT ''`,
},
{
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='disco_data_url'`,
alter: `ALTER TABLE songs ADD COLUMN disco_data_url TEXT DEFAULT ''`,
},
{
// User-edited embed-tag overrides (JSON: { artist, album, year, comment }).
// Used verbatim by gatherSongMetadata when set — see metadata editor (#60).
check: `SELECT COUNT(*) as c FROM pragma_table_info('songs') WHERE name='metadata_overrides'`,
alter: `ALTER TABLE songs ADD COLUMN metadata_overrides TEXT DEFAULT ''`,
},
];
for (const m of songsMigrations) {
const row = db.prepare(m.check).get() as any;
if (row.c === 0) {
db.exec(m.alter);
console.log(`[DB] Migration: ${m.alter}`);
}
}
// Lireek table migrations (same as previously in lireekDb.ts)
const lireekMigrations = [
"ALTER TABLE generations ADD COLUMN subject TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN title TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN system_prompt TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN user_prompt TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN bpm INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE generations ADD COLUMN key TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN caption TEXT NOT NULL DEFAULT ''",
"ALTER TABLE generations ADD COLUMN duration INTEGER NOT NULL DEFAULT 0",
"ALTER TABLE generations ADD COLUMN parent_generation_id INTEGER REFERENCES generations(id) ON DELETE SET NULL",
"ALTER TABLE artists ADD COLUMN image_url TEXT",
"ALTER TABLE artists ADD COLUMN genius_id INTEGER",
"ALTER TABLE lyrics_sets ADD COLUMN image_url TEXT",
// Planner-LM adapter per album (local HOT-Step feature)
"ALTER TABLE album_presets ADD COLUMN lm_adapter_path TEXT",
"ALTER TABLE album_presets ADD COLUMN lm_adapter_scale REAL",
];
for (const sql of lireekMigrations) {
try { db.exec(sql); } catch { /* column already exists */ }
}
// ── One-time migration: import data from lireek.db if it exists ───────────
migrateLireekData();
console.log(`[DB] Initialized: ${config.data.dbPath}`);
}
/**
* One-time migration: copies all data from the legacy lireek.db into hotstep.db,
* then renames lireek.db → lireek.db.migrated as a backup.
*
* Safe to run repeatedly — it's a no-op if lireek.db doesn't exist or has
* already been migrated.
*/
function migrateLireekData(): void {
const lireekPath = path.join(config.data.dir, 'lireek.db');
if (!fs.existsSync(lireekPath)) {
return; // Nothing to migrate
}
// Check if we already have data — if artists table has rows, assume migration is done
const existingArtists = (db.prepare('SELECT COUNT(*) as c FROM artists').get() as any).c;
if (existingArtists > 0) {
console.log(`[DB] Lireek data already present (${existingArtists} artists) — skipping migration`);
// If lireek.db still exists, rename it now
const backupPath = lireekPath + '.migrated';
if (!fs.existsSync(backupPath)) {
fs.renameSync(lireekPath, backupPath);
console.log(`[DB] Renamed lireek.db → lireek.db.migrated`);
}
return;
}
console.log(`[DB] ═══════════════════════════════════════════════════════════`);
console.log(`[DB] Migrating lireek.db data into hotstep.db...`);
// Temporarily disable foreign keys for the migration
db.pragma('foreign_keys = OFF');
try {
// Attach the old database
db.exec(`ATTACH DATABASE '${lireekPath.replace(/'/g, "''")}' AS lireek_old`);
// Tables to migrate, in dependency order (parents first)
const tables = [
'artists',
'lyrics_sets',
'profiles',
'generations',
'settings',
'album_presets',
'audio_generations',
];
const counts: Record<string, number> = {};
for (const table of tables) {
// Check if the source table exists in lireek_old
const exists = db.prepare(
`SELECT COUNT(*) as c FROM lireek_old.sqlite_master WHERE type='table' AND name=?`
).get(table) as any;
if (exists.c === 0) {
console.log(`[DB] ${table}: skipped (not in lireek.db)`);
continue;
}
// Get column names from source table
const cols = (db.prepare(`PRAGMA lireek_old.table_info('${table}')`).all() as any[])
.map(c => c.name);
// Filter to only columns that exist in the target table
const targetCols = (db.prepare(`PRAGMA table_info('${table}')`).all() as any[])
.map(c => c.name);
const commonCols = cols.filter(c => targetCols.includes(c));
if (commonCols.length === 0) {
console.log(`[DB] ${table}: skipped (no common columns)`);
continue;
}
const colList = commonCols.join(', ');
const result = db.prepare(
`INSERT OR IGNORE INTO ${table} (${colList}) SELECT ${colList} FROM lireek_old.${table}`
).run();
counts[table] = result.changes;
console.log(`[DB] ${table}: ${result.changes} rows migrated`);
}
db.exec('DETACH DATABASE lireek_old');
// Rename the old file
const backupPath = lireekPath + '.migrated';
fs.renameSync(lireekPath, backupPath);
const totalRows = Object.values(counts).reduce((a, b) => a + b, 0);
console.log(`[DB] Migration complete: ${totalRows} total rows imported`);
console.log(`[DB] Old file preserved as: lireek.db.migrated`);
console.log(`[DB] ═══════════════════════════════════════════════════════════`);
} catch (err: any) {
console.error(`[DB] Migration failed: ${err.message}`);
console.error(`[DB] lireek.db was NOT modified — data is safe`);
try { db.exec('DETACH DATABASE lireek_old'); } catch { /* may not be attached */ }
} finally {
db.pragma('foreign_keys = ON');
}
}
export function closeDb(): void {
if (db) {
db.close();
console.log('[DB] Closed');
}
}
+479
View File
@@ -0,0 +1,479 @@
// lireekDb.ts — Query functions for Lyric Studio / Lireek tables
//
// These tables now live in the unified hotstep.db (previously lireek.db).
// All functions use getDb() from database.ts — there is no separate connection.
import { getDb } from './database.js';
// ── Legacy exports (no-ops, kept for compatibility during transition) ────────
// initLireekDb/closeLireekDb are no longer needed — the tables are created
// and migrated in database.ts initDb(). These are kept temporarily so callers
// that import them don't break at compile time.
/** @deprecated Tables are now in hotstep.db — no separate init needed */
export function initLireekDb(): void {
// No-op — tables created in initDb()
}
/** @deprecated Tables are now in hotstep.db — no separate close needed */
export function closeLireekDb(): void {
// No-op — closed by closeDb()
}
/** @deprecated Use getDb() directly */
export function getLireekDb(): ReturnType<typeof getDb> {
return getDb();
}
// ── Artists ──────────────────────────────────────────────────────────────────
export function getOrCreateArtist(name: string): Record<string, any> {
const db = getDb();
const existing = db.prepare(
'SELECT * FROM artists WHERE name = ? COLLATE NOCASE'
).get(name) as any;
if (existing) return existing;
const now = new Date().toISOString();
const result = db.prepare(
'INSERT INTO artists (name, created_at) VALUES (?, ?)'
).run(name, now);
return { id: result.lastInsertRowid, name, created_at: now, image_url: null, genius_id: null };
}
export function listArtists(): Record<string, any>[] {
return getDb().prepare(
`SELECT a.*, COUNT(ls.id) AS lyrics_set_count
FROM artists a LEFT JOIN lyrics_sets ls ON ls.artist_id = a.id
GROUP BY a.id ORDER BY a.name`
).all() as any[];
}
export function deleteArtist(id: number): boolean {
const result = getDb().prepare('DELETE FROM artists WHERE id = ?').run(id);
return result.changes > 0;
}
export function updateArtistImage(id: number, imageUrl: string | null): void {
getDb().prepare('UPDATE artists SET image_url = ? WHERE id = ?').run(imageUrl, id);
}
export function updateArtistGeniusId(id: number, geniusId: number | null): void {
getDb().prepare('UPDATE artists SET genius_id = ? WHERE id = ?').run(geniusId, id);
}
export function getArtist(id: number): Record<string, any> | undefined {
return getDb().prepare('SELECT * FROM artists WHERE id = ?').get(id) as any;
}
/** Case-insensitive artist lookup that never creates — preview code must not
* leave rows behind for an export the user then cancels. */
export function findArtistByName(name: string): Record<string, any> | null {
return (getDb().prepare(
'SELECT * FROM artists WHERE name = ? COLLATE NOCASE'
).get(name) as any) ?? null;
}
// ── Lyrics Sets ─────────────────────────────────────────────────────────────
export function saveLyricsSet(
artistId: number, album: string | null, maxSongs: number, songs: any[],
imageUrl?: string | null,
): Record<string, any> {
const now = new Date().toISOString();
const songsJson = JSON.stringify(songs);
const result = getDb().prepare(
'INSERT INTO lyrics_sets (artist_id, album, max_songs, songs, image_url, fetched_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(artistId, album, maxSongs, songsJson, imageUrl ?? null, now);
return {
id: result.lastInsertRowid, artist_id: artistId, album, max_songs: maxSongs,
total_songs: songs.length, image_url: imageUrl ?? null, fetched_at: now,
};
}
/** Case-insensitive artist+album match — the update-in-place target for a
* Training Studio export. `songs` stays parsed out like getLyricsSets(). */
export function findLyricsSetByAlbum(artistId: number, album: string): Record<string, any> | null {
const row = getDb().prepare(
'SELECT * FROM lyrics_sets WHERE artist_id = ? AND album = ? COLLATE NOCASE'
).get(artistId, album) as any;
if (!row) return null;
const songs = JSON.parse(row.songs);
const { songs: _, ...rest } = row;
return { ...rest, total_songs: songs.length };
}
/** Replace a set's whole song list in place (re-export from Training Studio).
* Album is rewritten too so an override can fix casing/spelling; the image is
* only touched when a non-null one is passed — never cleared. */
export function replaceLyricsSetSongs(
id: number, album: string | null, songs: any[], imageUrl?: string | null,
): Record<string, any> | null {
const now = new Date().toISOString();
const db = getDb();
db.prepare(
'UPDATE lyrics_sets SET album = ?, max_songs = ?, songs = ?, fetched_at = ? WHERE id = ?'
).run(album, songs.length, JSON.stringify(songs), now, id);
if (imageUrl) {
db.prepare('UPDATE lyrics_sets SET image_url = ? WHERE id = ?').run(imageUrl, id);
}
return getLyricsSet(id);
}
export function getLyricsSets(artistId?: number): Record<string, any>[] {
const db = getDb();
const query = artistId
? db.prepare(
`SELECT ls.*, a.name as artist_name FROM lyrics_sets ls
JOIN artists a ON a.id = ls.artist_id
WHERE ls.artist_id = ? ORDER BY ls.fetched_at DESC`
)
: db.prepare(
`SELECT ls.*, a.name as artist_name FROM lyrics_sets ls
JOIN artists a ON a.id = ls.artist_id
ORDER BY ls.fetched_at DESC`
);
const rows = (artistId ? query.all(artistId) : query.all()) as any[];
return rows.map(r => {
const songs = JSON.parse(r.songs);
const { songs: _, ...rest } = r;
return { ...rest, total_songs: songs.length };
});
}
export function getLyricsSet(id: number): Record<string, any> | null {
const row = getDb().prepare(
`SELECT ls.*, a.name as artist_name FROM lyrics_sets ls
JOIN artists a ON a.id = ls.artist_id WHERE ls.id = ?`
).get(id) as any;
if (!row) return null;
row.songs = JSON.parse(row.songs);
row.total_songs = row.songs.length;
return row;
}
export function deleteLyricsSet(id: number): boolean {
return getDb().prepare('DELETE FROM lyrics_sets WHERE id = ?').run(id).changes > 0;
}
export function removeSongFromSet(lyricsSetId: number, songIndex: number): Record<string, any> | null {
const db = getDb();
const row = db.prepare('SELECT songs FROM lyrics_sets WHERE id = ?').get(lyricsSetId) as any;
if (!row) return null;
const songs = JSON.parse(row.songs);
if (songIndex < 0 || songIndex >= songs.length) return null;
songs.splice(songIndex, 1);
db.prepare('UPDATE lyrics_sets SET songs = ? WHERE id = ?').run(JSON.stringify(songs), lyricsSetId);
return getLyricsSet(lyricsSetId);
}
export function editSongInSet(lyricsSetId: number, songIndex: number, newLyrics: string): Record<string, any> | null {
const db = getDb();
const row = db.prepare('SELECT songs FROM lyrics_sets WHERE id = ?').get(lyricsSetId) as any;
if (!row) return null;
const songs = JSON.parse(row.songs);
if (songIndex < 0 || songIndex >= songs.length) return null;
songs[songIndex].lyrics = newLyrics;
db.prepare('UPDATE lyrics_sets SET songs = ? WHERE id = ?').run(JSON.stringify(songs), lyricsSetId);
return getLyricsSet(lyricsSetId);
}
export function addSongToSet(lyricsSetId: number, song: { title: string; album?: string; lyrics: string }): Record<string, any> | null {
const db = getDb();
const row = db.prepare('SELECT songs FROM lyrics_sets WHERE id = ?').get(lyricsSetId) as any;
if (!row) return null;
const songs = JSON.parse(row.songs);
songs.push(song);
db.prepare('UPDATE lyrics_sets SET songs = ? WHERE id = ?').run(JSON.stringify(songs), lyricsSetId);
return getLyricsSet(lyricsSetId);
}
export function updateLyricsSetImage(id: number, imageUrl: string | null): void {
getDb().prepare('UPDATE lyrics_sets SET image_url = ? WHERE id = ?').run(imageUrl, id);
}
// ── Profiles ────────────────────────────────────────────────────────────────
export function saveProfile(
lyricsSetId: number, provider: string, model: string, profileData: any,
): Record<string, any> {
const now = new Date().toISOString();
const result = getDb().prepare(
'INSERT INTO profiles (lyrics_set_id, provider, model, profile_data, created_at) VALUES (?, ?, ?, ?, ?)'
).run(lyricsSetId, provider, model, JSON.stringify(profileData), now);
return {
id: result.lastInsertRowid, lyrics_set_id: lyricsSetId,
provider, model, profile_data: profileData, created_at: now,
};
}
export function getProfiles(lyricsSetId?: number): Record<string, any>[] {
const db = getDb();
const query = lyricsSetId
? db.prepare('SELECT * FROM profiles WHERE lyrics_set_id = ? ORDER BY created_at DESC')
: db.prepare('SELECT * FROM profiles ORDER BY created_at DESC');
const rows = (lyricsSetId ? query.all(lyricsSetId) : query.all()) as any[];
return rows.map(r => ({ ...r, profile_data: JSON.parse(r.profile_data) }));
}
export function getProfile(id: number): Record<string, any> | null {
const row = getDb().prepare('SELECT * FROM profiles WHERE id = ?').get(id) as any;
if (!row) return null;
row.profile_data = JSON.parse(row.profile_data);
return row;
}
export function deleteProfile(id: number): boolean {
return getDb().prepare('DELETE FROM profiles WHERE id = ?').run(id).changes > 0;
}
export function updateProfileData(id: number, profileData: any): void {
getDb().prepare('UPDATE profiles SET profile_data = ? WHERE id = ?').run(JSON.stringify(profileData), id);
}
// ── Generations ─────────────────────────────────────────────────────────────
export interface SaveGenerationParams {
profileId: number;
provider: string;
model: string;
lyrics: string;
extraInstructions?: string;
title?: string;
subject?: string;
bpm?: number;
key?: string;
caption?: string;
duration?: number;
systemPrompt?: string;
userPrompt?: string;
parentGenerationId?: number | null;
}
export function saveGeneration(p: SaveGenerationParams): Record<string, any> {
const now = new Date().toISOString();
const result = getDb().prepare(
`INSERT INTO generations
(profile_id, provider, model, extra_instructions, title, subject, bpm, key, caption, duration, lyrics, system_prompt, user_prompt, parent_generation_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
).run(
p.profileId, p.provider, p.model, p.extraInstructions ?? null,
p.title ?? '', p.subject ?? '', p.bpm ?? 0, p.key ?? '', p.caption ?? '', p.duration ?? 0,
p.lyrics, p.systemPrompt ?? '', p.userPrompt ?? '', p.parentGenerationId ?? null, now,
);
return {
id: result.lastInsertRowid, profile_id: p.profileId, provider: p.provider,
model: p.model, extra_instructions: p.extraInstructions ?? null,
title: p.title ?? '', subject: p.subject ?? '', bpm: p.bpm ?? 0,
key: p.key ?? '', caption: p.caption ?? '', duration: p.duration ?? 0,
lyrics: p.lyrics, system_prompt: p.systemPrompt ?? '', user_prompt: p.userPrompt ?? '',
parent_generation_id: p.parentGenerationId ?? null, created_at: now,
};
}
export function getGenerations(profileId?: number, lyricsSetId?: number): Record<string, any>[] {
const db = getDb();
if (profileId) {
return db.prepare('SELECT * FROM generations WHERE profile_id = ? ORDER BY created_at DESC').all(profileId) as any[];
}
if (lyricsSetId) {
return db.prepare(
`SELECT g.* FROM generations g
JOIN profiles p ON p.id = g.profile_id
WHERE p.lyrics_set_id = ? ORDER BY g.created_at DESC`
).all(lyricsSetId) as any[];
}
return db.prepare('SELECT * FROM generations ORDER BY created_at DESC').all() as any[];
}
export function getAllGenerationsWithContext(): Record<string, any>[] {
return getDb().prepare(
`SELECT g.*, a.name AS artist_name, ls.album, ls.artist_id
FROM generations g
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
ORDER BY g.created_at DESC`
).all() as any[];
}
export function getGeneration(id: number): Record<string, any> | null {
return (getDb().prepare('SELECT * FROM generations WHERE id = ?').get(id) as any) ?? null;
}
export function updateGenerationMetadata(
id: number, bpm: number, key: string, caption: string, duration: number = 0,
): void {
getDb().prepare(
'UPDATE generations SET bpm = ?, key = ?, caption = ?, duration = ? WHERE id = ?'
).run(bpm, key, caption, duration, id);
}
export function updateGenerationFields(id: number, fields: Record<string, any>): void {
const allowed = ['title', 'subject', 'lyrics', 'bpm', 'key', 'caption', 'duration', 'extra_instructions'];
const sets: string[] = [];
const values: any[] = [];
for (const [k, v] of Object.entries(fields)) {
if (allowed.includes(k)) {
sets.push(`${k} = ?`);
values.push(v);
}
}
if (sets.length === 0) return;
values.push(id);
getDb().prepare(`UPDATE generations SET ${sets.join(', ')} WHERE id = ?`).run(...values);
}
export function deleteGeneration(id: number): boolean {
return getDb().prepare('DELETE FROM generations WHERE id = ?').run(id).changes > 0;
}
export function purgeProfilesAndGenerations(): { generations_deleted: number; profiles_deleted: number } {
const db = getDb();
const genResult = db.prepare('DELETE FROM generations').run();
const profResult = db.prepare('DELETE FROM profiles').run();
return { generations_deleted: genResult.changes, profiles_deleted: profResult.changes };
}
export function purgeGenerationsOnly(): { generations_deleted: number } {
const result = getDb().prepare('DELETE FROM generations').run();
return { generations_deleted: result.changes };
}
export function purgeProfilesOnly(): { profiles_deleted: number; generations_deleted: number } {
const db = getDb();
// Generations depend on profiles via FK, so delete generations first
const genResult = db.prepare('DELETE FROM generations').run();
const profResult = db.prepare('DELETE FROM profiles').run();
return { profiles_deleted: profResult.changes, generations_deleted: genResult.changes };
}
// ── Settings ────────────────────────────────────────────────────────────────
export function getSetting(key: string, defaultValue = ''): string {
const row = getDb().prepare('SELECT value FROM settings WHERE key = ?').get(key) as any;
return row?.value ?? defaultValue;
}
export function setSetting(key: string, value: string): void {
getDb().prepare(
'INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
).run(key, value);
}
// ── Album Presets ───────────────────────────────────────────────────────────
export function getPreset(lyricsSetId: number): Record<string, any> | null {
return (getDb().prepare('SELECT * FROM album_presets WHERE lyrics_set_id = ?').get(lyricsSetId) as any) ?? null;
}
export function getAllPresets(): Record<string, any>[] {
return getDb().prepare(
`SELECT ap.*, a.name as artist_name, ls.album, ls.artist_id
FROM album_presets ap
JOIN lyrics_sets ls ON ls.id = ap.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
ORDER BY ap.created_at DESC`
).all() as any[];
}
export function upsertPreset(lyricsSetId: number, data: {
adapterPath?: string | null;
adapterScale?: number | null;
adapterGroupScales?: any;
referenceTrackPath?: string | null;
audioCoverStrength?: number | null;
lmAdapterPath?: string | null;
lmAdapterScale?: number | null;
}): Record<string, any> {
const db = getDb();
const existing = getPreset(lyricsSetId);
const groupScalesJson = data.adapterGroupScales ? JSON.stringify(data.adapterGroupScales) : null;
if (existing) {
db.prepare(
`UPDATE album_presets SET adapter_path = ?, adapter_scale = ?, adapter_group_scales = ?,
reference_track_path = ?, audio_cover_strength = ?, lm_adapter_path = ?, lm_adapter_scale = ?
WHERE lyrics_set_id = ?`
).run(
data.adapterPath ?? null, data.adapterScale ?? null, groupScalesJson,
data.referenceTrackPath ?? null, data.audioCoverStrength ?? null,
data.lmAdapterPath ?? null, data.lmAdapterScale ?? null, lyricsSetId,
);
} else {
db.prepare(
`INSERT INTO album_presets (lyrics_set_id, adapter_path, adapter_scale, adapter_group_scales, reference_track_path, audio_cover_strength, lm_adapter_path, lm_adapter_scale)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
).run(
lyricsSetId, data.adapterPath ?? null, data.adapterScale ?? null, groupScalesJson,
data.referenceTrackPath ?? null, data.audioCoverStrength ?? null,
data.lmAdapterPath ?? null, data.lmAdapterScale ?? null,
);
}
return getPreset(lyricsSetId)!;
}
export function deletePreset(lyricsSetId: number): boolean {
return getDb().prepare('DELETE FROM album_presets WHERE lyrics_set_id = ?').run(lyricsSetId).changes > 0;
}
// ── Audio Generations ───────────────────────────────────────────────────────
export function linkAudioGeneration(generationId: number, jobId: string): Record<string, any> {
const now = new Date().toISOString();
const result = getDb().prepare(
'INSERT INTO audio_generations (generation_id, hotstep_job_id, created_at) VALUES (?, ?, ?)'
).run(generationId, jobId, now);
return { id: result.lastInsertRowid, generation_id: generationId, hotstep_job_id: jobId, created_at: now };
}
export function getAudioGenerations(generationId: number): Record<string, any>[] {
return getDb().prepare(
`SELECT ag.*, s.mastered_audio_url
FROM audio_generations ag
LEFT JOIN songs s ON s.audio_url = ag.audio_url
WHERE ag.generation_id = ? ORDER BY ag.created_at DESC`
).all(generationId) as any[];
}
export function resolveAudioGeneration(jobId: string, audioUrl: string, coverUrl?: string): void {
getDb().prepare(
'UPDATE audio_generations SET audio_url = ?, cover_url = ? WHERE hotstep_job_id = ?'
).run(audioUrl, coverUrl ?? null, jobId);
}
export function deleteAudioGeneration(id: number): boolean {
return getDb().prepare('DELETE FROM audio_generations WHERE id = ?').run(id).changes > 0;
}
/** Delete audio_generations rows matching the given hotstep job IDs (used when songs are deleted from the main library). */
export function deleteAudioGenerationsByJobIds(jobIds: string[]): number {
if (jobIds.length === 0) return 0;
const placeholders = jobIds.map(() => '?').join(',');
return getDb().prepare(`DELETE FROM audio_generations WHERE hotstep_job_id IN (${placeholders})`).run(...jobIds).changes;
}
export function getRecentGenerationsWithAudio(limit = 50): Record<string, any>[] {
return getDb().prepare(
`SELECT g.title AS song_title, g.subject, g.caption, g.lyrics, g.duration,
g.created_at AS ag_created_at, g.id AS generation_id,
a.name AS artist_name, a.image_url AS artist_image, a.id AS artist_id,
ls.album, ls.id AS lyrics_set_id,
ag.id AS ag_id, ag.audio_url, ag.cover_url, ag.hotstep_job_id,
s.mastered_audio_url
FROM audio_generations ag
JOIN generations g ON g.id = ag.generation_id
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
LEFT JOIN songs s ON s.audio_url = ag.audio_url
WHERE ag.audio_url IS NOT NULL
ORDER BY ag.created_at DESC
LIMIT ?`
).all(limit) as any[];
}
+16
View File
@@ -0,0 +1,16 @@
// engineState.ts — Shared engine readiness state
//
// Extracted to avoid circular imports between index.ts and route modules.
// index.ts sets these values; routes read them.
/** True only after runtime DLLs are downloaded and ace-server is spawned. */
export let engineReady = false;
/** Human-readable status for what the engine is doing before it's ready. */
export let engineBootStatus = 'Initializing...';
/** Update the engine boot status (called from index.ts bootstrap) */
export function setEngineReady(ready: boolean, status: string) {
engineReady = ready;
engineBootStatus = status;
}
+421
View File
@@ -0,0 +1,421 @@
// index.ts — HOT-Step CPP Server
//
// Express server that:
// 1. Serves the React frontend (pre-built static files in production)
// 2. Manages the SQLite database (songs, playlists, users)
// 3. Orchestrates generation via ace-server HTTP API
// 4. Optionally spawns ace-server as a managed child process
import express from 'express';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { config, PROJECT_ROOT, PORTABLE_MODE } from './config.js';
import { initLogger, closeLogger } from './services/logger.js';
import { initDb, closeDb } from './db/database.js';
// lireekDb is now part of the unified hotstep.db — no separate init needed
import authRoutes from './routes/auth.js';
import songRoutes from './routes/songs.js';
import generateRoutes from './routes/generate.js';
import modelRoutes from './routes/models.js';
import healthRoutes from './routes/health.js';
import shutdownRoutes from './routes/shutdown.js';
import masteringRoutes from './routes/mastering.js';
import downloadRoutes from './routes/download.js';
import adapterRoutes from './routes/adapters.js';
import logsRoutes from './routes/logs.js';
import lireekRoutes from './routes/lireek.js';
import vstRoutes from './routes/vst.js';
import analyzeRoutes from './routes/analyze.js';
import uploadRoutes from './routes/upload.js';
import supersepRoutes from './routes/supersep.js';
import settingsRoutes from './routes/settings.js';
import modelManagerRoutes from './routes/modelManager.js';
import stemStudioRoutes from './routes/stemStudio.js';
import assistantRoutes from './routes/assistant.js';
import pluginRoutes from './routes/plugins.js';
import inspireRoutes from './routes/inspire.js';
import coverArtRoutes from './routes/coverArt.js';
import seedsRoutes from './routes/seeds.js';
import profilesRoutes from './routes/profiles.js';
import songBuilderRoutes from './routes/songBuilder.js';
import midiStudioRoutes from './routes/midiStudio.js';
import trainingRoutes from './routes/training.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Initialize file-based logging BEFORE any console output
const logDir = initLogger();
console.log(`
╔══════════════════════════════════════════╗
║ HOT-Step 9000 ⚡ CPP ║
║ High-Performance Music Generation ║
╚══════════════════════════════════════════╝
`);
console.log(`[Logger] Session logs: ${logDir}`);
// Initialize databases
initDb();
// lireek tables are created in initDb() — no separate init
// Create Express app
const app = express();
// Middleware
app.use(cors());
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/songs', songRoutes);
app.use('/api/generate', generateRoutes);
app.use('/api/models', modelRoutes);
app.use('/api/health', healthRoutes);
app.use('/api/shutdown', shutdownRoutes);
app.use('/api/mastering', masteringRoutes);
app.use('/api/download', downloadRoutes);
app.use('/api/adapters', adapterRoutes);
app.use('/api/logs', logsRoutes);
app.use('/api/lireek', lireekRoutes);
app.use('/api/vst', vstRoutes);
app.use('/api/analyze', analyzeRoutes);
app.use('/api/upload', uploadRoutes);
app.use('/api/supersep', supersepRoutes);
app.use('/api/settings', settingsRoutes);
app.use('/api/model-manager', modelManagerRoutes);
app.use('/api/stem-studio', stemStudioRoutes);
app.use('/api/assistant', assistantRoutes);
app.use('/api/plugins', pluginRoutes);
app.use('/api/inspire', inspireRoutes);
app.use('/api/cover-art', coverArtRoutes);
app.use('/api/seeds', seedsRoutes);
app.use('/api/profiles', profilesRoutes);
app.use('/api/builder', songBuilderRoutes);
app.use('/api/midi-studio', midiStudioRoutes);
app.use('/api/training', trainingRoutes);
// Serve audio files from data/audio/
app.use('/audio', express.static(config.data.audioDir, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.mp3')) {
res.setHeader('Content-Type', 'audio/mpeg');
} else if (filePath.endsWith('.wav')) {
res.setHeader('Content-Type', 'audio/wav');
}
},
}));
// Serve reference audio files from data/references/
const refsDir = path.join(config.data.dir, 'references');
fs.mkdirSync(refsDir, { recursive: true });
app.use('/references', express.static(refsDir, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.mp3')) {
res.setHeader('Content-Type', 'audio/mpeg');
} else if (filePath.endsWith('.wav')) {
res.setHeader('Content-Type', 'audio/wav');
} else if (filePath.endsWith('.flac')) {
res.setHeader('Content-Type', 'audio/flac');
}
},
}));
// Serve React frontend (production only — in dev, Vite handles this)
const uiDistPath = path.join(PROJECT_ROOT, 'ui', 'dist');
if (fs.existsSync(uiDistPath)) {
// Assets with content hashes get long cache; index.html always revalidates
app.use(express.static(uiDistPath, {
setHeaders: (res, filePath) => {
if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
} else {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
},
}));
// SPA fallback: serve index.html for all unmatched routes
app.get('/{*splat}', (_req, res) => {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
res.sendFile(path.join(uiDistPath, 'index.html'));
});
console.log(`[Server] Serving UI from ${uiDistPath}`);
} else {
console.log('[Server] No UI build found — run "npm run build" in ui/ for production');
console.log('[Server] For development, run Vite dev server separately');
}
// The ace-server child lifecycle (spawn, log fan-out, crash-respawn limiter,
// deliberate stop/restart) lives in services/aceEngineProcess.ts so that the
// training preprocess job can borrow the GPU. §4.1 of the preprocess plan.
import { setEngineReady } from './engineState.js';
import { aceClient } from './services/aceClient.js';
import { startAceServer, stopAceServer } from './services/aceEngineProcess.js';
import { killActiveChildren } from './services/training/labelingQueue.js';
// ── Required runtime DLL bootstrap ──────────────────────────────────
// On first launch, the CUDA engine variant needs cuBLAS DLLs that aren't
// in the release ZIP (they're ~530 MB). Download them from HuggingFace
// before starting ace-server, with clear progress and error messages.
import { modelDownloadService } from './services/modelDownloadService.js';
/** Detect CUDA major version from engine build marker */
function detectCudaMajorVersion(): number {
try {
const versionFile = path.join(path.dirname(config.aceServer.exe), '.cuda-version');
if (fs.existsSync(versionFile)) {
return parseInt(fs.readFileSync(versionFile, 'utf-8').trim(), 10);
}
} catch {}
return 13; // Default: assume CUDA 13 (latest release)
}
/** IDs of registry files that must exist before engine start (CUDA only) */
function getRequiredRuntimeIds(): string[] {
const cudaMajor = detectCudaMajorVersion();
if (cudaMajor <= 12) {
return ['cuda-rt-cublas-12', 'cuda-rt-cublaslt-12', 'cuda-rt-cudart-12'];
}
return ['cuda-rt-cublas', 'cuda-rt-cublaslt', 'cuda-rt-cudart'];
}
async function ensureRequiredRuntime(): Promise<{ ok: boolean; missing: string[] }> {
const engineDir = path.dirname(config.aceServer.exe);
const registry = JSON.parse(
fs.readFileSync(path.join(__dirname, 'data', 'model-registry.json'), 'utf-8')
);
const missing: Array<{ id: string; filename: string }> = [];
const REQUIRED_RUNTIME_IDS = getRequiredRuntimeIds();
for (const id of REQUIRED_RUNTIME_IDS) {
const file = registry.files.find((f: any) => f.id === id);
if (!file) continue;
if (!fs.existsSync(path.join(engineDir, file.filename))) {
missing.push({ id, filename: file.filename });
}
}
if (missing.length === 0) return { ok: true, missing: [] };
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ First-launch setup: downloading GPU runtime libraries ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
console.log(` Missing: ${missing.map(m => m.filename).join(', ')}`);
console.log(' Source: HuggingFace (scragnog/HOT-Step-CPP-SuperSep)');
console.log('');
// Start all downloads
const jobIds: string[] = [];
for (const m of missing) {
const jobId = modelDownloadService.startDownload(m.id);
jobIds.push(jobId);
console.log(` ⬇ Queued: ${m.filename}`);
}
console.log('');
// Wait for all downloads to complete, logging progress
let lastProgressLog = 0;
await new Promise<void>((resolve) => {
const check = () => {
const jobs = modelDownloadService.getJobs();
const active = jobs.filter(j => jobIds.includes(j.jobId));
const allDone = active.every(j => j.status === 'completed' || j.status === 'failed');
// Log progress every 2 seconds
const now = Date.now();
if (now - lastProgressLog > 2000) {
lastProgressLog = now;
for (const j of active) {
if (j.status === 'downloading' && j.totalBytes > 0) {
const pct = Math.round((j.bytesDownloaded / j.totalBytes) * 100);
const mb = Math.round(j.bytesDownloaded / 1024 / 1024);
const totalMb = Math.round(j.totalBytes / 1024 / 1024);
const speedMb = (j.speed / 1024 / 1024).toFixed(1);
console.log(`${j.filename}: ${mb}/${totalMb} MB (${pct}%) — ${speedMb} MB/s`);
}
}
}
if (allDone) {
const failed = active.filter(j => j.status === 'failed');
if (failed.length > 0) {
console.log('');
console.log('╔══════════════════════════════════════════════════════════╗');
console.log('║ ⚠ GPU Runtime Download Failed ║');
console.log('╠══════════════════════════════════════════════════════════╣');
for (const f of failed) {
console.log(`║ ✗ ${f.filename}`);
if (f.error) console.log(`║ Error: ${f.error}`);
}
console.log('║ ║');
console.log('║ The engine will start on CPU only (much slower). ║');
console.log('║ ║');
console.log('║ To fix: ║');
console.log('║ 1. Settings → Model Manager → CUDA Runtime → Download ║');
console.log('║ 2. Or restart the app with internet access ║');
console.log('╚══════════════════════════════════════════════════════════╝');
console.log('');
} else {
console.log('');
console.log(' ✓ GPU runtime downloaded successfully!');
console.log('');
}
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
// Re-check which files are actually present
const stillMissing: string[] = [];
for (const m of missing) {
if (!fs.existsSync(path.join(engineDir, m.filename))) {
stillMissing.push(m.filename);
}
}
return { ok: stillMissing.length === 0, missing: stillMissing };
}
// Bootstrap: download required DLLs (portable only), then start engine
// In dev/build-from-source mode, CUDA DLLs are in the system PATH via the
// toolkit install — no need to download them into the engine directory.
(async () => {
let cudaReady = true;
if (PORTABLE_MODE && process.platform === 'win32') {
// CUDA runtime DLLs are only needed for CUDA builds — skip for Vulkan/CPU
const variantFile = path.join(path.dirname(config.aceServer.exe), '.variant');
const variant = fs.existsSync(variantFile)
? fs.readFileSync(variantFile, 'utf-8').trim()
: 'cuda'; // Assume CUDA if no marker (pre-v1.1 builds)
if (variant === 'cuda') {
try {
setEngineReady(false, 'Downloading CUDA runtime...');
const result = await ensureRequiredRuntime();
cudaReady = result.ok;
if (!cudaReady) {
console.error(`[Server] CUDA runtime incomplete — missing: ${result.missing.join(', ')}`);
console.error('[Server] Engine will start but GPU acceleration will not be available.');
}
} catch (err: any) {
console.error('[Server] Runtime bootstrap failed:', err.message);
cudaReady = false;
}
} else {
console.log(`[Server] Build variant: ${variant} — skipping CUDA runtime download`);
}
}
setEngineReady(false, cudaReady ? 'Starting engine...' : 'Starting engine (CPU only — CUDA runtime missing)...');
startAceServer();
setEngineReady(true, cudaReady ? 'Ready' : 'Ready (CPU only — GPU runtime missing)');
// Fire-and-forget warm-on-startup: once the engine /health is up, POST /warm
// with the configured DiT + VAE + adapter so the first user /synth skips the
// cold-start. Gated on keepLoaded (the engine evicts instantly under STRICT,
// making warm pointless) and a configured warmDit. Off by default since
// keepLoaded is off. Failures only log — they never block request serving.
if (config.aceServer.warmOnStartup && config.aceServer.keepLoaded && config.aceServer.warmDit) {
void warmEngineOnStartup();
} else if (config.aceServer.warmOnStartup && config.aceServer.warmDit && !config.aceServer.keepLoaded) {
console.log('[Server] warm-on-startup skipped: keep-loaded is off (engine would evict immediately)');
}
})();
/** Poll engine /health until reachable (or 90s), then POST /warm with the
* configured DiT + VAE + adapter. The warm is itself an async engine job; we
* kick it off without awaiting, so the wrapper stays free to accept requests
* while the LoKr deltas are copied to VRAM. Any /synth that arrives mid-warm
* queues behind it and gets the same hot cache for free. */
async function warmEngineOnStartup(): Promise<void> {
const deadline = Date.now() + 90_000;
let healthy = false;
while (Date.now() < deadline) {
if (await aceClient.isReachable()) { healthy = true; break; }
await new Promise(r => setTimeout(r, 1000));
}
if (!healthy) {
console.warn('[Server] warm-on-startup: engine /health never came up in 90s — skipping warm');
return;
}
const cfg = config.aceServer;
const req: { dit: string; vae?: string; adapter?: string; adapter_scale?: number } = { dit: cfg.warmDit };
if (cfg.warmVae) req.vae = cfg.warmVae;
if (cfg.warmAdapter) {
req.adapter = cfg.warmAdapter;
if (Number.isFinite(cfg.warmAdapterScale)) req.adapter_scale = cfg.warmAdapterScale;
}
try {
const jobId = await aceClient.warm(req, true);
console.log(`[Server] warm-on-startup: posted /warm dit=${cfg.warmDit}${cfg.warmAdapter ? ` adapter=${cfg.warmAdapter}` : ''} job=${jobId}`);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
console.warn(`[Server] warm-on-startup: /warm failed (will warm on first user request instead): ${msg}`);
}
}
// Start Express server
const server = app.listen(config.server.port, config.server.host, () => {
console.log(`[Server] Listening on http://localhost:${config.server.port}`);
console.log(`[Server] ace-server URL: ${config.aceServer.url}`);
console.log(`[Server] Data directory: ${config.data.dir}`);
console.log('');
console.log(` 🎵 Open http://localhost:${config.server.port} in your browser`);
console.log('');
});
// Graceful shutdown
let isShuttingDown = false;
function shutdown() {
if (isShuttingDown) return;
isShuttingDown = true;
console.log('\n[Server] Shutting down...');
// Kill any spawned training child (ace-train) FIRST. It is not detached and
// not in a job object, so Node exiting without this leaves a GPU-resident
// process (~3.2 GB) behind that competes with the engine the relaunched
// server starts — routine with tsx watch's SIGTERM during dev.
try { killActiveChildren(); } catch (err) { console.error('[Server] killActiveChildren failed:', err); }
// Kill ace-server child process — tree kill on Windows, SIGTERM elsewhere.
// `suspend: false` — a shutdown is not a preprocess suspension, so
// /api/generate must not answer with the "paused for training" message
// during the 1 s exit window.
// Fire-and-forget: the 1 s process.exit delay below covers the wait.
void stopAceServer('Server shutting down', undefined, { suspend: false });
// Close HTTP server
server.close(() => {
console.log('[Server] HTTP server closed');
});
// Close DB and logger
closeDb();
closeLogger();
console.log('[Server] Goodbye!');
// Force exit after a short delay to let response flush
setTimeout(() => {
process.exit(0);
}, 1000);
}
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
process.on('uncaughtException', (err) => {
console.error('[Server] Uncaught exception:', err);
});
process.on('unhandledRejection', (err) => {
console.error('[Server] Unhandled rejection:', err);
});
+288
View File
@@ -0,0 +1,288 @@
// adapters.ts — Adapter filesystem browsing and scanning
//
// Provides server-side endpoints for:
// 1. GET /browse — directory navigation with file type filtering
// 2. POST /scan — flat listing of .safetensors files in a folder
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { config } from '../config.js';
import { readAdapterTrigger } from '../services/adapters/stMetadata.js';
import { lmAdapterRoots } from '../services/training/adapterLayout.js';
const router = Router();
/** Extensions accepted per filter category */
const FILTER_EXTENSIONS: Record<string, string[]> = {
adapters: ['.safetensors'],
audio: ['.wav', '.mp3', '.flac', '.ogg', '.opus'],
// Training Studio folder picker — deliberately separate from `audio`, which
// other callers depend on staying as-is.
trainingAudio: ['.wav', '.mp3', '.flac', '.ogg', '.opus', '.m4a', '.aac'],
};
/**
* GET /api/adapters/browse?path=...&filter=adapters
*
* Lists the contents of a directory, returning sub-directories and
* files that match the optional filter. Always includes a '..'
* parent entry unless already at a filesystem root.
*
* Response: { current: string, entries: BrowseEntry[] }
*/
router.get('/browse', (req, res) => {
const rawPath = (req.query.path as string) || '';
const filter = (req.query.filter as string) || '';
const allowedExts = FILTER_EXTENSIONS[filter] || [];
// Resolve to an absolute path
let dirPath: string;
try {
dirPath = rawPath ? path.resolve(rawPath) : path.resolve('.');
} catch {
res.status(400).json({ error: 'Invalid path' });
return;
}
// Verify path exists and is a directory
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
res.status(404).json({ error: 'Directory not found', current: dirPath, entries: [] });
return;
}
try {
const rawEntries = fs.readdirSync(dirPath, { withFileTypes: true });
const entries: Array<{ name: string; path: string; type: 'dir' | 'file'; size?: number }> = [];
// Parent directory (unless at root)
const parent = path.dirname(dirPath);
if (parent !== dirPath) {
entries.push({ name: '..', path: parent, type: 'dir' });
}
// Directories first (skip hidden)
for (const entry of rawEntries) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
entries.push({ name: entry.name, path: fullPath, type: 'dir' });
}
}
// Then files (filtered, skip hidden)
for (const entry of rawEntries) {
if (entry.name.startsWith('.')) continue;
if (!entry.isFile()) continue;
const fullPath = path.join(dirPath, entry.name);
const ext = path.extname(entry.name).toLowerCase();
if (allowedExts.length === 0 || allowedExts.includes(ext)) {
try {
const stat = fs.statSync(fullPath);
entries.push({ name: entry.name, path: fullPath, type: 'file', size: stat.size });
} catch {
// Skip files we can't stat (locked, permissions, etc.)
}
}
}
res.json({ current: dirPath, entries });
} catch (err: any) {
res.status(500).json({ error: err.message, current: dirPath, entries: [] });
}
});
/**
* POST /api/adapters/scan
*
* Flat scan of a single directory for adapters, in two forms:
* * bare `.safetensors` files (the hand-installed convention), and
* * PEFT sub-directories — `adapter_model.safetensors` + `adapter_config.json`
* — which is what the DiT trainer writes.
*
* The PEFT half is what makes a freshly trained adapter appear in the Create
* view's dropdown WITHOUT an engine restart: `path` is the directory, and the
* engine's path-fallback resolver already accepts a PEFT dir (adapter-merge.h).
* Without it a just-finished training run would be invisible until relaunch.
*
* Returns an empty array if the folder doesn't exist or is empty.
*
* Body: { folder: string }
* Response: { files: AdapterFile[] }
*/
router.post('/scan', (req, res) => {
const folder = req.body?.folder;
if (!folder || typeof folder !== 'string') {
res.json({ files: [] });
return;
}
const dirPath = path.resolve(folder);
if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
res.json({ files: [] });
return;
}
try {
const rawEntries = fs.readdirSync(dirPath, { withFileTypes: true });
const files = rawEntries
.filter(e => e.isFile() && e.name.endsWith('.safetensors'))
.map(e => {
const fullPath = path.join(dirPath, e.name);
const stat = fs.statSync(fullPath);
const tg = readAdapterTrigger(fullPath);
return { name: e.name, path: fullPath, size: stat.size, trigger: tg.trigger, triggerPosition: tg.position };
});
// Adapter directories, mirroring GET /adapters/lm. Only the weights file
// is required: PEFT dirs from our trainers always carry adapter_config.json
// too, but an enfoldered LyCORIS adapter (bare archive file moved into
// <name>/adapter_model.safetensors) has its alphas per-tensor and no
// config — hiding it from the dropdown would be worse than listing it.
//
// A DiT LoKR export (K8, lokr-dit-training plan §2.4) writes
// `lokr_weights.safetensors` instead — same out-dir convention, different
// filename, checked second so an (unexpected) dir with both prefers PEFT.
const pushPeftDir = (dir: string, displayName: string) => {
let model = path.join(dir, 'adapter_model.safetensors');
if (!fs.existsSync(model)) model = path.join(dir, 'lokr_weights.safetensors');
if (!fs.existsSync(model)) return;
try {
const tg = readAdapterTrigger(dir);
files.push({ name: displayName, path: dir, size: fs.statSync(model).size, trigger: tg.trigger,
triggerPosition: tg.position });
} catch { /* skip */ }
};
for (const e of rawEntries) {
if (!e.isDirectory() || e.name.startsWith('.')) continue;
const dir = path.join(dirPath, e.name);
pushPeftDir(dir, e.name);
// Per-base layout (adapterLayout.ts): DiT adapters live one level down in
// dit-<shorthand> folders. Descend into those — and only those; lm-* is
// the planner-adapter tree and arbitrary subfolders are not ours to walk.
if (!/^dit-/i.test(e.name)) continue;
try {
for (const sub of fs.readdirSync(dir, { withFileTypes: true })) {
if (sub.name.startsWith('.')) continue;
const subPath = path.join(dir, sub.name);
if (sub.isDirectory()) {
// Unversioned adapter directly in the artist dir (legacy)…
pushPeftDir(subPath, `${e.name}/${sub.name}`);
// …and every stamped training run beneath it (per-run layout).
try {
for (const run of fs.readdirSync(subPath, { withFileTypes: true })) {
if (!run.isDirectory() || run.name.startsWith('.')) continue;
pushPeftDir(path.join(subPath, run.name), `${e.name}/${sub.name}/${run.name}`);
}
} catch { /* unreadable artist dir — skip its runs */ }
} else if (sub.isFile() && sub.name.endsWith('.safetensors')) {
try {
const stat = fs.statSync(subPath);
const tg = readAdapterTrigger(subPath);
files.push({ name: `${e.name}/${sub.name}`, path: subPath, size: stat.size,
trigger: tg.trigger, triggerPosition: tg.position });
} catch { /* skip */ }
}
}
} catch { /* unreadable subdir — skip */ }
}
files.sort((a, b) => a.name.localeCompare(b.name));
res.json({ files });
} catch {
res.json({ files: [] });
}
});
/**
* GET /api/adapters/lm?folder=...
*
* Lists planner-LM adapters (local HOT-Step feature): PEFT directories
* (adapter_model.safetensors + adapter_config.json) and bare .safetensors
* files. Scans `folder` when given (the user's archive, like the DiT
* adapter folder), else the adapters root's `lm/` subtree. Filesystem-based
* so freshly trained adapters appear WITHOUT an engine restart — the UI
* sends the absolute path and the engine's path-fallback resolver loads it.
*
* Response: { root: string, adapters: { name, path, kind, size, mtime }[] }
*/
router.get('/lm', (req, res) => {
const folderParam = (req.query.folder as string) || '';
type LmAdapterEntry = {
name: string; path: string; kind: 'peft' | 'safetensors'; size: number; mtime: number;
/** '0.6B' | '1.7B' | '4B' — from the lm-<size> parent folder, else the
* legacy -<size> name suffix, else ''. */
lmSize: string;
/** Training-run stamp (YYYY-MM-DD_HH-MM-SS subfolder); '' for an
* unversioned/legacy adapter. Every run of an artist is listed. */
run: string;
trigger: string; triggerPosition: 'prepend' | 'append' | '';
};
const adapters: LmAdapterEntry[] = [];
const pushPeft = (dir: string, name: string, lmSize: string, run: string) => {
const model = path.join(dir, 'adapter_model.safetensors');
if (!fs.existsSync(model)) return false;
const stat = fs.statSync(model);
const tg = readAdapterTrigger(dir);
adapters.push({ name, path: dir, kind: 'peft', size: stat.size, mtime: stat.mtimeMs,
lmSize, run, trigger: tg.trigger, triggerPosition: tg.position });
return true;
};
const scanOne = (dir: string, size: string) => {
if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const fullPath = path.join(dir, entry.name);
try {
// Legacy flat layout carries the size in the name; per-size dirs carry
// it in `size`.
const suffix = /-(0\.6B|1\.7B|4B)$/i.exec(entry.name.replace(/\.safetensors$/i, ''));
const lmSize = size || (suffix ? suffix[1] : '');
if (entry.isDirectory()) {
// Unversioned adapter directly in the artist dir (legacy)…
pushPeft(fullPath, entry.name, lmSize, '');
// …and every stamped training run beneath it (per-run layout).
for (const runEntry of fs.readdirSync(fullPath, { withFileTypes: true })) {
if (!runEntry.isDirectory() || runEntry.name.startsWith('.')) continue;
pushPeft(path.join(fullPath, runEntry.name), entry.name, lmSize, runEntry.name);
}
} else if (entry.isFile() && entry.name.endsWith('.safetensors')) {
const stat = fs.statSync(fullPath);
const tg = readAdapterTrigger(fullPath);
adapters.push({ name: entry.name, path: fullPath, kind: 'safetensors', size: stat.size,
mtime: stat.mtimeMs, lmSize, run: '', trigger: tg.trigger, triggerPosition: tg.position });
}
} catch { /* skip unreadable entries */ }
}
};
let root: string;
try {
if (folderParam) {
// Explicit folder (the user's archive override). Scan it flat, and when
// it contains lm-* per-size subdirs — e.g. the adapters root itself —
// scan those too so the override survives the layout change.
root = path.resolve(folderParam);
scanOne(root, '');
for (const r of lmAdapterRoots()) {
const sub = path.join(root, path.basename(r.dir));
if (path.resolve(sub) !== root && fs.existsSync(sub)) scanOne(sub, r.size);
}
} else {
// Default: every planner-adapter root — the per-size lm-06b/lm-17b/lm-4b
// dirs plus the legacy flat lm/ (adapterLayout.ts).
root = config.aceServer.adapters;
for (const r of lmAdapterRoots()) scanOne(r.dir, r.size);
}
// Alphabetical by artist; within an artist, newest run first.
adapters.sort((a, b) =>
a.name.localeCompare(b.name) || a.lmSize.localeCompare(b.lmSize) || b.run.localeCompare(a.run));
res.json({ root, adapters });
} catch (err: any) {
res.json({ root: folderParam || config.aceServer.adapters, adapters: [], error: err.message });
}
});
export default router;
+180
View File
@@ -0,0 +1,180 @@
/**
* analyze.ts — Audio analysis routes
*
* POST /api/analyze — Essentia CLI: BPM + key detection
* POST /api/analyze/metadata — music-metadata: ID3/Vorbis/FLAC tag extraction
*/
import { Router, Request, Response } from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
import { execFile } from 'child_process';
import { promises as fs } from 'fs';
import os from 'os';
import multer from 'multer';
import { parseBuffer } from 'music-metadata';
import { config, getFFmpegPath } from '../config.js';
const router = Router();
const AUDIO_DIR = path.join(config.data.dir, 'audio');
/** Resolve a frontend audio URL (e.g. `/audio/xxx.mp3`) to an absolute file path. */
const resolveAudioPath = (audioUrl: string): string => {
if (audioUrl.startsWith('/audio/')) {
return path.join(AUDIO_DIR, audioUrl.replace('/audio/', ''));
}
if (audioUrl.startsWith('/references/')) {
return path.join(config.data.dir, 'references', audioUrl.replace('/references/', ''));
}
if (audioUrl.startsWith('http')) {
try {
const parsed = new URL(audioUrl);
if (parsed.pathname.startsWith('/audio/')) {
return path.join(AUDIO_DIR, parsed.pathname.replace('/audio/', ''));
}
if (parsed.pathname.startsWith('/references/')) {
return path.join(config.data.dir, 'references', parsed.pathname.replace('/references/', ''));
}
} catch {
// fall through
}
}
return audioUrl;
};
/**
* POST /api/analyze
* Body: { audioUrl: string }
* Returns: { bpm: number, key: string, scale: string } or error
*/
router.post('/', async (req: Request, res: Response) => {
const { audioUrl } = req.body;
if (!audioUrl) {
res.status(400).json({ error: 'audioUrl is required' });
return;
}
const audioPath = resolveAudioPath(audioUrl);
// Verify the file exists
try {
await fs.access(audioPath);
} catch {
res.status(404).json({ error: `Audio file not found: ${audioPath}` });
return;
}
// Create temp output file for Essentia
const tmpFile = path.join(os.tmpdir(), `essentia_${Date.now()}.json`);
// Essentia supports wav, mp3, flac natively. Convert anything else via ffmpeg.
const SUPPORTED_EXTS = ['.wav', '.mp3', '.flac', '.aiff', '.aif'];
const ext = path.extname(audioPath).toLowerCase();
let inputPath = audioPath;
let tmpWav: string | null = null;
if (!SUPPORTED_EXTS.includes(ext)) {
const ffmpegPath = getFFmpegPath();
if (!ffmpegPath) {
res.status(500).json({ error: `Cannot convert ${ext} to WAV — ffmpeg not available` });
return;
}
tmpWav = path.join(os.tmpdir(), `essentia_input_${Date.now()}.wav`);
console.log(`[analyze] Converting ${ext} to WAV via ffmpeg...`);
try {
await new Promise<void>((resolve, reject) => {
execFile(ffmpegPath, ['-y', '-i', audioPath, '-ar', '44100', '-ac', '2', tmpWav!],
{ timeout: 60_000 },
(error) => error ? reject(error) : resolve()
);
});
inputPath = tmpWav;
} catch (ffErr: any) {
console.error('[analyze] ffmpeg conversion failed:', ffErr.message);
res.status(500).json({ error: `Format conversion failed: ${ffErr.message}` });
return;
}
}
try {
// Run Essentia CLI
await new Promise<string>((resolve, reject) => {
execFile(
config.essentia.bin,
[inputPath, tmpFile],
{ timeout: 120_000, maxBuffer: 10 * 1024 * 1024 },
(error, _stdout, _stderr) => {
// Essentia writes info to stderr even on success — check if output file exists
if (error && !error.killed) {
fs.access(tmpFile).then(() => resolve('ok')).catch(() => reject(error));
} else {
resolve('ok');
}
}
);
});
const raw = await fs.readFile(tmpFile, 'utf-8');
const data = JSON.parse(raw);
const bpm = Math.round(data?.rhythm?.bpm ?? 0);
const keyData = data?.tonal?.key_edma ?? {};
const key = keyData.key ?? '';
const scale = keyData.scale ?? '';
// Cleanup temp files
fs.unlink(tmpFile).catch(() => { });
if (tmpWav) fs.unlink(tmpWav).catch(() => { });
console.log(`[analyze] BPM: ${bpm}, Key: ${key} ${scale} (from ${path.basename(audioPath)})`);
res.json({ bpm, key, scale });
} catch (err: any) {
// Cleanup temp files on error
fs.unlink(tmpFile).catch(() => { });
if (tmpWav) fs.unlink(tmpWav).catch(() => { });
console.error('[analyze] Essentia failed:', err.message || err);
res.status(500).json({ error: `Analysis failed: ${err.message || 'Unknown error'}` });
}
});
// ── Metadata extraction (music-metadata) ─────────────────────────────────────
const metadataUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 200 * 1024 * 1024 }, // 200MB
});
/**
* POST /api/analyze/metadata — extract ID3/Vorbis/FLAC tags from uploaded audio.
* Returns { artist, title, album, duration, sampleRate, bitrate }.
*/
router.post('/metadata', metadataUpload.single('audio'), async (req: Request, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const metadata = await parseBuffer(
req.file.buffer,
{ mimeType: req.file.mimetype as any },
{ duration: true, skipCovers: true },
);
console.log(
`[analyze/metadata] Extracted: artist="${metadata.common.artist || ''}", ` +
`title="${metadata.common.title || ''}", album="${metadata.common.album || ''}"`,
);
res.json({
artist: metadata.common.artist || '',
title: metadata.common.title || '',
album: metadata.common.album || '',
duration: metadata.format.duration || null,
sampleRate: metadata.format.sampleRate || null,
bitrate: metadata.format.bitrate || null,
});
} catch (err: any) {
console.error('[analyze/metadata] Failed:', err.message);
res.status(500).json({ error: 'Failed to extract metadata', details: err.message });
}
});
export default router;
+218
View File
@@ -0,0 +1,218 @@
// assistant.ts — AI Assistant chat route with SSE streaming
//
// Provides a stateless chat endpoint that:
// 1. Loads the static knowledge base (cached in memory)
// 2. Injects the user's current generation settings as context
// 3. Streams the LLM response back via SSE
//
// Uses the existing LLM provider registry — same API keys as Lyric Studio.
import { Router } from 'express';
import type { Request, Response } from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import { PORTABLE_MODE, PROJECT_ROOT } from '../config.js';
import { getProvider, listProviders } from '../services/lireek/llm/registry.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const router = Router();
// ── Knowledge base (loaded once, cached in memory) ────────────────────────────
let knowledgeBase: string | null = null;
function loadKnowledge(): string {
if (knowledgeBase) return knowledgeBase;
const filePath = PORTABLE_MODE
? path.join(PROJECT_ROOT, 'server', 'data', 'assistant-knowledge.md')
: path.resolve(__dirname, '../data/assistant-knowledge.md');
try {
knowledgeBase = fs.readFileSync(filePath, 'utf-8');
console.log(`[Assistant] Knowledge base loaded (${(knowledgeBase.length / 1024).toFixed(1)} KB)`);
} catch (err: any) {
console.error(`[Assistant] Failed to load knowledge base: ${err.message}`);
knowledgeBase = 'You are the HOT-Step Assistant. Help users configure their music generation settings.';
}
return knowledgeBase;
}
// ── SSE helpers ───────────────────────────────────────────────────────────────
function initSse(res: Response): (type: string, data: any) => void {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
return (type: string, data: any) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
}
// ── Build system prompt ───────────────────────────────────────────────────────
function buildSystemPrompt(currentSettings: Record<string, any>): string {
const knowledge = loadKnowledge();
// Extract and label the active view for the LLM
const viewLabels: Record<string, string> = {
create: 'Create (text-to-music)',
lyric: 'Lyric Studio (AI songwriting)',
cover: 'Cover Studio (reference-based covers)',
stems: 'Stem Studio (audio separation)',
stemBuilder: 'Stem Builder (stem composition)',
settings: 'Settings',
};
const activeView = currentSettings._activeView || 'create';
const modeLabel = viewLabels[activeView] || activeView;
// Remove internal-only fields before serializing
const { _activeView, ...settingsForLLM } = currentSettings;
const settingsJson = JSON.stringify(settingsForLLM, null, 2);
return `${knowledge}
---
## User's Current Mode
The user is currently in: **${modeLabel}**
Tailor your responses to this mode. For example, if they're in Cover Studio, focus on cover-related settings and workflow. If they're in Create mode, focus on generation parameters and lyrics.
## User's Current Configuration
The following JSON represents the user's current generation settings. Reference these when answering questions or suggesting changes.
\`\`\`json
${settingsJson}
\`\`\`
`;
}
// ── Build user prompt with multi-turn history ────────────────────────────────
interface ChatMessage {
role: 'user' | 'assistant';
content: string;
}
function buildUserPrompt(history: ChatMessage[], currentMessage: string): string {
if (!history.length) return currentMessage;
const lines: string[] = [];
for (const msg of history) {
const prefix = msg.role === 'user' ? 'User' : 'Assistant';
lines.push(`${prefix}: ${msg.content}`);
}
lines.push(`User: ${currentMessage}`);
return lines.join('\n\n');
}
// ── Routes ────────────────────────────────────────────────────────────────────
/**
* POST /api/assistant/chat
*
* Streams an assistant response via SSE.
* Body: {
* message: string,
* history: ChatMessage[],
* currentSettings: Record<string, any>,
* provider: string,
* model?: string
* }
*
* SSE events:
* event: chunk — { text: "..." } (streaming token)
* event: complete — { text: "..." } (full response)
* event: error — { error: "..." } (on failure)
*/
router.post('/chat', async (req: Request, res: Response) => {
try {
const {
message,
history = [],
currentSettings = {},
provider: providerName,
model,
} = req.body;
if (!message || typeof message !== 'string') {
return res.status(400).json({ error: 'message is required' });
}
if (!providerName || typeof providerName !== 'string') {
return res.status(400).json({ error: 'provider is required' });
}
// Resolve provider
let provider;
try {
provider = getProvider(providerName);
} catch {
return res.status(400).json({ error: `Unknown provider: ${providerName}` });
}
if (!provider.isAvailable()) {
return res.status(503).json({ error: `Provider ${providerName} is not available. Check API keys in Settings → AI Services.` });
}
// Build prompts
const systemPrompt = buildSystemPrompt(currentSettings);
const userPrompt = buildUserPrompt(history, message);
const resolvedModel = model || provider.defaultModel;
console.log(`[Assistant] Chat via ${providerName}/${resolvedModel} (${(systemPrompt.length / 1024).toFixed(1)}K system, ${(userPrompt.length / 1024).toFixed(1)}K user)`);
// Set up SSE
const sendSse = initSse(res);
let fullText = '';
// Call the provider with streaming
const result = await provider.call(
systemPrompt,
userPrompt,
resolvedModel,
(chunk: string) => {
fullText += chunk;
sendSse('chunk', { text: chunk });
},
);
// If the provider returned without streaming (some don't support onChunk),
// send the full result as a single chunk
if (!fullText && result) {
fullText = result;
sendSse('chunk', { text: result });
}
sendSse('complete', { text: fullText || result });
res.end();
} catch (err: any) {
console.error('[Assistant] Chat error:', err.message);
// If headers already sent (SSE started), send error event
if (res.headersSent) {
res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
} else {
res.status(500).json({ error: err.message });
}
}
});
/**
* GET /api/assistant/providers
*
* Returns available LLM providers (reuses the same registry as Lyric Studio).
*/
router.get('/providers', async (_req: Request, res: Response) => {
try {
const providers = await listProviders();
res.json(providers);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;
+104
View File
@@ -0,0 +1,104 @@
// auth.ts — Simple local auto-auth for single-user mode
//
// No passwords, no tokens. Just auto-create a user on first launch
// and return it on every request. Tokens are simple UUIDs for API compat.
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { getDb } from '../db/database.js';
const router = Router();
// In-memory token → userId map (resets on restart, which is fine for local single-user)
const tokens = new Map<string, string>();
/** Get or create the default local user */
function getOrCreateUser() {
const db = getDb();
let user = db.prepare('SELECT * FROM users LIMIT 1').get() as any;
if (!user) {
const id = uuidv4();
db.prepare('INSERT INTO users (id, username) VALUES (?, ?)').run(id, 'Producer');
user = db.prepare('SELECT * FROM users WHERE id = ?').get(id);
}
return user;
}
/** Create a token for the user */
function createToken(userId: string): string {
const token = uuidv4();
tokens.set(token, userId);
return token;
}
// GET /api/auth/auto — auto-login: get or create local user
router.get('/auto', (_req, res) => {
const user = getOrCreateUser();
const token = createToken(user.id);
res.json({ user, token });
});
// POST /api/auth/setup — set username on first launch
router.post('/setup', (req, res) => {
const { username } = req.body;
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required' });
return;
}
const user = getOrCreateUser();
getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username.trim(), user.id);
const updated = getDb().prepare('SELECT * FROM users WHERE id = ?').get(user.id);
const token = createToken(user.id);
res.json({ user: updated, token });
});
// GET /api/auth/me — get current user
router.get('/me', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !tokens.has(token)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const userId = tokens.get(token)!;
const user = getDb().prepare('SELECT * FROM users WHERE id = ?').get(userId);
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json({ user });
});
// PATCH /api/auth/username — update username
router.patch('/username', (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token || !tokens.has(token)) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
const { username } = req.body;
if (!username || typeof username !== 'string') {
res.status(400).json({ error: 'Username is required' });
return;
}
const userId = tokens.get(token)!;
getDb().prepare('UPDATE users SET username = ? WHERE id = ?').run(username.trim(), userId);
const user = getDb().prepare('SELECT * FROM users WHERE id = ?').get(userId);
const newToken = createToken(userId);
res.json({ user, token: newToken });
});
// POST /api/auth/logout
router.post('/logout', (_req, res) => {
res.json({ success: true });
});
// Helper: extract userId from token (used by other routes)
export function getUserId(req: any): string | null {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return null;
return tokens.get(token) || null;
}
export default router;
+205
View File
@@ -0,0 +1,205 @@
// coverArt.ts — API routes for AI cover art generation
//
// Endpoints:
// GET /api/cover-art/status — Installation/download status
// POST /api/cover-art/download — Start first-use download
// POST /api/cover-art/download/cancel — Cancel active download
// POST /api/cover-art/generate — Generate cover art for a song
// GET /api/cover-art/generate/:jobId — Poll generation job status
import { Router } from 'express';
import { getUserId } from './auth.js';
import { generateCoverArt, getCoverArtReadiness, type CoverArtResult } from '../services/coverArt/coverArtService.js';
import { buildCoverArtPrompt } from '../services/coverArt/promptBuilder.js';
import { coverArtDownloader } from '../services/coverArt/coverArtDownloader.js';
const router = Router();
// ── In-memory job tracking ──────────────────────────────────────────────
interface CoverArtJob {
id: string;
songId: string;
status: 'pending' | 'running' | 'succeeded' | 'failed';
result?: CoverArtResult;
error?: string;
createdAt: number;
}
const jobs = new Map<string, CoverArtJob>();
// Clean up old jobs periodically (keep last 100)
function cleanupJobs(): void {
if (jobs.size <= 100) return;
const sorted = Array.from(jobs.entries())
.sort((a, b) => a[1].createdAt - b[1].createdAt);
const toRemove = sorted.slice(0, sorted.length - 100);
for (const [id] of toRemove) jobs.delete(id);
}
// ── GET /status — Installation status ───────────────────────────────────
router.get('/status', (_req, res) => {
const readiness = getCoverArtReadiness();
const downloadStatus = coverArtDownloader.getStatus();
res.json({
installed: readiness.installed,
missingFiles: readiness.missingFiles,
dir: readiness.dir,
download: {
phase: downloadStatus.phase,
files: downloadStatus.files,
totalBytes: downloadStatus.totalBytes,
downloadedBytes: downloadStatus.downloadedBytes,
overallProgress: downloadStatus.overallProgress,
},
});
});
// ── POST /download — Start first-use download ───────────────────────────
router.post('/download', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const status = coverArtDownloader.getStatus();
if (status.phase === 'downloading') {
res.json({ ok: true, message: 'Download already in progress' });
return;
}
// Fire and forget — client polls /status for progress
coverArtDownloader.startDownload().catch(err => {
console.error('[CoverArt] Download failed:', err.message);
});
res.json({ ok: true, message: 'Download started' });
});
// ── POST /download/cancel — Cancel active download ──────────────────────
router.post('/download/cancel', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
coverArtDownloader.cancelDownload();
res.json({ ok: true, message: 'Download cancelled' });
});
// ── SSE /download/progress — Stream download progress ───────────────────
router.get('/download/progress', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const sendProgress = () => {
const status = coverArtDownloader.getStatus();
res.write(`data: ${JSON.stringify(status)}\n\n`);
};
// Send initial state
sendProgress();
// Subscribe to progress updates
coverArtDownloader.on('progress', sendProgress);
// Cleanup on disconnect
req.on('close', () => {
coverArtDownloader.removeListener('progress', sendProgress);
});
});
// ── POST /prompt-preview — Build the auto-assembled prompt (no generation) ──
//
// Used by the per-track "Generate Cover Art" modal (#67) to pre-fill the
// editable textarea with exactly what the engine would generate by default,
// so the user can tweak it instead of starting from scratch.
router.post('/prompt-preview', (req, res) => {
const { title, style, lyrics, subject } = req.body || {};
const prompt = buildCoverArtPrompt({
title: title || '',
style: style || '',
lyrics: lyrics || '',
subject: subject || '',
});
res.json({ prompt });
});
// ── POST /generate — Generate cover art for a song ──────────────────────
router.post('/generate', async (req, res) => {
// No auth required — this is a local-only app and context menu
// calls this endpoint without auth headers.
const { songId, title, style, lyrics, subject, prompt } = req.body;
if (!songId) {
res.status(400).json({ error: 'songId is required' });
return;
}
// Check readiness
const readiness = getCoverArtReadiness();
if (!readiness.installed) {
res.status(503).json({
error: 'Cover art not installed',
missingFiles: readiness.missingFiles,
});
return;
}
// Create job
const jobId = `ca-${Date.now().toString(36)}`;
const job: CoverArtJob = {
id: jobId,
songId,
status: 'pending',
createdAt: Date.now(),
};
jobs.set(jobId, job);
cleanupJobs();
// Return immediately, run generation async
res.json({ jobId, status: 'pending' });
// Fire generation
job.status = 'running';
try {
const result = await generateCoverArt({
songId,
title: title || '',
style: style || '',
lyrics: lyrics || '',
subject: subject || '',
prompt: prompt || '',
});
job.status = 'succeeded';
job.result = result;
} catch (err: any) {
job.status = 'failed';
job.error = err.message;
console.error(`[CoverArt] Generation failed for song ${songId}:`, err.message);
}
});
// ── GET /generate/:jobId — Poll generation status ───────────────────────
router.get('/generate/:jobId', (req, res) => {
const job = jobs.get(req.params.jobId);
if (!job) {
res.status(404).json({ error: 'Job not found' });
return;
}
res.json({
jobId: job.id,
songId: job.songId,
status: job.status,
result: job.result,
error: job.error,
});
});
export default router;
+311
View File
@@ -0,0 +1,311 @@
// download.ts — Audio download route with format conversion + metadata embedding
//
// GET /api/songs/:id/download?format=wav|flac|opus|mp3&bitrate=192&version=original|mastered
//
// Converts the source WAV to the requested format, embeds metadata tags
// and cover art (when available), and streams it back with a
// Content-Disposition header for browser download.
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { config, getFFmpegPath } from '../config.js';
import { getDb } from '../db/database.js';
import {
gatherSongMetadata, buildMetadataArgs, buildCoverArtArgs,
type AudioMetadata,
} from '../services/audioMetadata.js';
const execFileAsync = promisify(execFile);
const router = Router();
/** Get mp3-codec binary path (platform-aware: no .exe on macOS/Linux) */
function getMp3CodecPath(): string {
const ext = process.platform === 'win32' ? '.exe' : '';
const aceExe = config.aceServer.exe;
if (aceExe) return path.join(path.dirname(aceExe), `mp3-codec${ext}`);
return path.resolve(process.cwd(), '..', 'engine', 'build', 'Release', `mp3-codec${ext}`);
}
/** Convert WAV to target format with optional metadata embedding */
async function convertAudio(
sourcePath: string,
format: string,
bitrate: number,
outputPath: string,
metadata?: AudioMetadata,
): Promise<void> {
const hasMeta = !!metadata;
// WAV without metadata — fast copy, no conversion
if (format === 'wav' && !hasMeta) {
fs.copyFileSync(sourcePath, outputPath);
return;
}
// MP3 without metadata — use mp3-codec.exe if available (faster, no ffmpeg dep)
// When metadata IS present, we must use ffmpeg so ID3 tags get written.
if (format === 'mp3' && !hasMeta) {
const codec = getMp3CodecPath();
if (fs.existsSync(codec)) {
await execFileAsync(codec, [
'-i', sourcePath, '-o', outputPath, '-b', String(bitrate),
], { timeout: 120_000 });
return;
}
// Fallback to ffmpeg
}
// All other cases use ffmpeg (conversion + metadata + cover art)
const ffmpegPath = getFFmpegPath();
if (!ffmpegPath) {
// No ffmpeg — for WAV, fall back to raw copy (no metadata)
if (format === 'wav') {
fs.copyFileSync(sourcePath, outputPath);
return;
}
throw new Error(`Cannot convert to ${format} — ffmpeg not available`);
}
// ── Build ffmpeg command ──
const args = ['-y', '-i', sourcePath];
// Cover art: add as second input (PNG → JPEG transcoded on-the-fly)
let coverArtArgs: { inputArgs: string[]; outputArgs: string[] } = { inputArgs: [], outputArgs: [] };
if (metadata?.coverArtPath) {
coverArtArgs = buildCoverArtArgs(metadata.coverArtPath, format);
args.push(...coverArtArgs.inputArgs);
}
// Audio codec selection
switch (format) {
case 'wav':
// Re-encode through ffmpeg so INFO chunks get written
args.push('-c:a', 'pcm_s16le');
break;
case 'flac':
args.push('-c:a', 'flac', '-sample_fmt', 's32', '-compression_level', '8');
break;
case 'opus':
args.push('-c:a', 'libopus', '-b:a', `${bitrate}k`);
break;
case 'mp3':
args.push('-c:a', 'libmp3lame', '-b:a', `${bitrate}k`);
break;
default:
throw new Error(`Unsupported format: ${format}`);
}
// Cover art output args (stream mapping, codec, disposition)
if (coverArtArgs.outputArgs.length > 0) {
args.push(...coverArtArgs.outputArgs);
}
// Metadata tags
if (metadata) {
args.push(...buildMetadataArgs(metadata, format));
}
args.push(outputPath);
try {
await execFileAsync(ffmpegPath, args, { timeout: 120_000 });
} catch (err: any) {
throw new Error(`ffmpeg conversion failed: ${err.message}`);
}
}
/** MIME types for audio formats */
const mimeTypes: Record<string, string> = {
wav: 'audio/wav',
mp3: 'audio/mpeg',
flac: 'audio/flac',
opus: 'audio/ogg',
};
// GET /api/download/:id?format=wav&bitrate=192&version=original&artist=Name&prepend=Prefix
router.get('/:id', async (req, res) => {
const { id } = req.params;
const format = (req.query.format as string || 'wav').toLowerCase();
const bitrate = parseInt(req.query.bitrate as string) || 192;
const version = (req.query.version as string || 'original').toLowerCase();
const artistName = (req.query.artist as string || '').trim();
const prepend = (req.query.prepend as string || '').trim();
// Validate format
if (!['wav', 'mp3', 'flac', 'opus'].includes(format)) {
res.status(400).json({ error: `Invalid format: ${format}. Use wav, mp3, flac, or opus.` });
return;
}
// Get song from DB — try by ID first, then by audio_url
let song = getDb().prepare('SELECT * FROM songs WHERE id = ?').get(id) as any;
if (!song) {
// Fallback: try looking up by audio_url (for Lyric Studio queue items)
const audioUrlParam = req.query.audioUrl as string;
if (audioUrlParam) {
song = getDb().prepare('SELECT * FROM songs WHERE audio_url = ?').get(audioUrlParam) as any;
}
}
if (!song) {
// Last resort: serve the audio file directly without DB metadata
const audioUrlParam = req.query.audioUrl as string;
if (audioUrlParam) {
const filename = path.basename(audioUrlParam);
const sourcePath = path.join(config.data.audioDir, filename);
if (fs.existsSync(sourcePath)) {
// Clean up parsed parameters just in case DB doesn't have standard naming
const badPrefixes = /^_?(XL|STD)(\s*\(CPP\))?_?\s*-?\s*/i;
const cleanPrepend = prepend.trim();
const cleanArtist = artistName.replace(badPrefixes, '').trim();
const titleSuffix = version === 'original' ? ' - Unmastered' : '';
const titleParts = [cleanPrepend, cleanArtist, 'Untitled'].filter(Boolean);
const downloadFilename = `${titleParts.join(' - ')}${titleSuffix}.${format}`;
if (format === 'wav' && sourcePath.endsWith('.wav')) {
res.setHeader('Content-Type', mimeTypes.wav);
res.setHeader('Content-Disposition', `attachment; filename="${downloadFilename}"`);
res.setHeader('Content-Length', fs.statSync(sourcePath).size);
fs.createReadStream(sourcePath).pipe(res);
return;
}
// Convert
const tempDir = path.join(config.data.dir, 'download_temp');
fs.mkdirSync(tempDir, { recursive: true });
const tempFile = path.join(tempDir, `dl_${Date.now().toString(36)}.${format}`);
await convertAudio(sourcePath, format, bitrate, tempFile);
const stat = fs.statSync(tempFile);
res.setHeader('Content-Type', mimeTypes[format] || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${downloadFilename}"`);
res.setHeader('Content-Length', stat.size);
const stream = fs.createReadStream(tempFile);
stream.pipe(res);
stream.on('end', () => { try { fs.unlinkSync(tempFile); } catch {} });
return;
}
}
res.status(404).json({ error: 'Song not found' });
return;
}
// Latent download — raw HSLAT binary, no format conversion
if (version === 'latent') {
const latentUrl = song?.latent_url;
if (!latentUrl) {
res.status(404).json({ error: 'No latent file available for this track' });
return;
}
const latentFilename = path.basename(latentUrl);
const latentPath = path.join(config.data.audioDir, latentFilename);
if (!fs.existsSync(latentPath)) {
res.status(404).json({ error: 'Latent file not found on disk' });
return;
}
const rawTitle = song.title || 'track';
const downloadName = `${rawTitle.replace(/[^a-zA-Z0-9 _()-]/g, '').trim() || 'track'}.latent`;
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${downloadName}"`);
res.setHeader('Content-Length', fs.statSync(latentPath).size);
fs.createReadStream(latentPath).pipe(res);
return;
}
// Determine which audio URL to use
let audioUrl: string;
if (version === 'mastered' && song.mastered_audio_url) {
audioUrl = song.mastered_audio_url;
} else {
audioUrl = song.audio_url;
}
if (!audioUrl) {
res.status(404).json({ error: 'No audio file available' });
return;
}
// Resolve to filesystem path
const audioFilename = path.basename(audioUrl);
const sourcePath = path.join(config.data.audioDir, audioFilename);
if (!fs.existsSync(sourcePath)) {
res.status(404).json({ error: `Audio file not found on disk: ${audioFilename}` });
return;
}
// Gather metadata for embedding into the output file
let metadata: AudioMetadata | undefined;
try {
metadata = gatherSongMetadata(song);
} catch (metaErr: any) {
// Non-fatal — proceed without metadata if gathering fails
console.warn(`[Download] Metadata gathering failed (non-fatal): ${metaErr.message}`);
}
// Build download filename: Prepend - Artist - Title_suffix.format
// Strip leading/trailing underscores and the old backend-injected prefix patterns
const badPrefixes = /^_?(XL|STD)(\s*\(CPP\))?_?\s*-?\s*/i;
let rawTitle = song.title || 'Untitled';
// Strip backend-generated prefix strings if they accidentally got committed to the DB
rawTitle = rawTitle.replace(badPrefixes, '');
rawTitle = rawTitle.replace(/_mastered/g, ''); // User wants mastered as default, so explicitly strip it out just in case
const songTitle = rawTitle.replace(/[^a-zA-Z0-9 _()-]/g, '').trim();
const suffix = version === 'original' ? ' - Unmastered' : '';
const resolvedArtist = artistName || (song.artist || '').replace(badPrefixes, '').replace(/[^a-zA-Z0-9 _()-]/g, '').trim();
// Strip leading "Artist - " prefix from title if it duplicates the resolved artist.
// generate.ts stores titles as "Artist - Song Title", so without this the artist
// would appear twice in the download filename.
let finalTitle = songTitle;
if (resolvedArtist) {
const artistPrefix = new RegExp(`^${resolvedArtist.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*-\\s*`, 'i');
finalTitle = songTitle.replace(artistPrefix, '').trim() || songTitle;
}
// Clean prepend: the user typed this, just trim whitespace
const cleanPrepend = prepend.trim();
const parts = [cleanPrepend, resolvedArtist, `${finalTitle}${suffix}`].filter(Boolean);
const downloadFilename = `${parts.join(' - ')}.${format}`;
try {
if (format === 'wav' && sourcePath.endsWith('.wav') && !metadata) {
// Source is already WAV with no metadata to embed — stream directly
res.setHeader('Content-Type', mimeTypes.wav);
res.setHeader('Content-Disposition', `attachment; filename="${downloadFilename}"`);
res.setHeader('Content-Length', fs.statSync(sourcePath).size);
fs.createReadStream(sourcePath).pipe(res);
return;
}
// Convert to temp file, then stream
const tempDir = path.join(config.data.dir, 'download_temp');
fs.mkdirSync(tempDir, { recursive: true });
const tempFile = path.join(tempDir, `dl_${Date.now().toString(36)}.${format}`);
await convertAudio(sourcePath, format, bitrate, tempFile, metadata);
const stat = fs.statSync(tempFile);
res.setHeader('Content-Type', mimeTypes[format] || 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${downloadFilename}"`);
res.setHeader('Content-Length', stat.size);
const stream = fs.createReadStream(tempFile);
stream.pipe(res);
// Clean up temp file after stream completes
stream.on('end', () => {
try { fs.unlinkSync(tempFile); } catch {}
try { fs.rmdirSync(tempDir); } catch {}
});
stream.on('error', () => {
try { fs.unlinkSync(tempFile); } catch {}
});
} catch (err: any) {
console.error(`[Download] Conversion failed:`, err.message);
res.status(500).json({ error: err.message });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
// health.ts — Health check and diagnostics route
import { Router } from 'express';
import { aceClient } from '../services/aceClient.js';
import { config } from '../config.js';
import { engineReady, engineBootStatus } from '../engineState.js';
const router = Router();
// GET /api/health — overall system health
router.get('/', async (_req, res) => {
let aceStatus = 'disconnected';
let aceVersion = '';
try {
const health = await aceClient.health();
aceStatus = health.status || 'ok';
// Try to get version info from props
try {
const props = await aceClient.props();
aceVersion = (props as any).version || '';
} catch {
// Props not critical for health
}
} catch {
aceStatus = 'disconnected';
}
res.json({
status: 'ok',
aceServer: {
status: aceStatus,
url: config.aceServer.url,
version: aceVersion,
},
server: {
port: config.server.port,
uptime: process.uptime(),
},
engine: {
ready: engineReady,
bootStatus: engineBootStatus,
},
});
});
export default router;
+527
View File
@@ -0,0 +1,527 @@
// inspire.ts — Inspire API endpoint
//
// Two inspire paths:
// 1. POST /api/inspire — engine's built-in LM (inspire mode)
// 2. POST /api/inspire/llm — external LLM lyric generation
//
// Async job pattern mirrors generate.ts: submit → poll → result.
import { Router } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { aceClient, type AceRequest } from '../services/aceClient.js';
import { getUserId } from './auth.js';
import { engineReady, engineBootStatus } from '../engineState.js';
import { subscribeLines } from './logs.js';
import { translateParams } from '../services/generation/translateParams.js';
import { getProvider, listProviders } from '../services/lireek/llm/registry.js';
import { stripThinkingBlocks, postprocessLyrics, fixSectionLabels, enforceLineCounts, fixAPrefix } from '../services/lireek/llm/postprocess.js';
import { INSTAGEN_LYRIC_SYSTEM_PROMPT, INSTAGEN_FULL_SYSTEM_PROMPT } from '../services/lireek/prompts.js';
import { getSetting, setSetting } from '../db/lireekDb.js';
const router = Router();
/** Inspire job state */
interface InspireJob {
id: string;
status: 'pending' | 'running' | 'succeeded' | 'failed' | 'cancelled';
stage?: string;
progress?: number;
aceJobId?: string;
acePhase?: string;
acePhaseProgress?: string;
result?: {
caption: string;
lyrics: string;
bpm: number;
duration: number;
keyScale: string;
timeSignature: string;
vocalLanguage: string;
};
error?: string;
createdAt: number;
}
const inspireJobs = new Map<string, InspireJob>();
// Cleanup old jobs after 10 minutes
setInterval(() => {
const cutoff = Date.now() - 10 * 60 * 1000;
for (const [id, job] of inspireJobs) {
if (job.createdAt < cutoff && job.status !== 'running') {
inspireJobs.delete(id);
}
}
}, 60_000);
/** Poll ace-server job until completion */
async function pollUntilDone(aceJobId: string, job: InspireJob, signal: AbortSignal): Promise<void> {
const POLL_INTERVAL = 500;
const MAX_POLLS = 600; // 5 minutes max (inspire is fast)
for (let i = 0; i < MAX_POLLS; i++) {
if (signal.aborted || job.status === 'cancelled') {
await aceClient.cancelJob(aceJobId);
throw new Error('Cancelled');
}
const status = await aceClient.pollJob(aceJobId);
if (status.phase) {
job.acePhase = status.phase;
const step = status.phase_step ?? 0;
const total = status.phase_total ?? 0;
job.acePhaseProgress = total > 0 ? `step ${step}/${total}` : '';
}
if (status.status === 'done') return;
if (status.status === 'failed') throw new Error('Inspire failed on ace-server');
if (status.status === 'cancelled') throw new Error('Cancelled by ace-server');
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL));
}
throw new Error('Inspire timed out');
}
/** Run inspire pipeline */
async function runInspire(job: InspireJob, params: any): Promise<void> {
if (job.status === 'cancelled') return;
const aceReq = translateParams(params);
const abortController = new AbortController();
(job as any)._abort = abortController;
try {
job.status = 'running';
job.stage = 'Generating lyrics & metadata...';
job.progress = 10;
// Log what reaches the engine
console.log(`[Inspire] Job ${job.id} — caption: ${(params.caption || '').substring(0, 80)}, lang: ${params.vocalLanguage}`);
// Subscribe to engine logs for LM Phase 1 progress
const unsub = subscribeLines((line) => {
if (line.source !== 'engine') return;
const lm1 = line.text.match(/\[LM-Phase1\] Step (\d+).*?([\d.]+) tok\/s/);
if (lm1) {
job.stage = `Composing lyrics: Step ${lm1[1]} (${lm1[2]} tok/s)`;
job.progress = 30;
return;
}
if (line.text.includes('[LM-Phase1] Prefill')) {
job.stage = 'Preparing language model...';
job.progress = 15;
} else if (line.text.includes('[Adapter]') && line.text.includes('Merge')) {
job.stage = 'Loading adapter...';
}
});
console.log(`[Inspire] Job ${job.id} — submitting LM inspire request`);
const lmJobId = await aceClient.submitLm(aceReq, 'inspire');
job.aceJobId = lmJobId;
await pollUntilDone(lmJobId, job, abortController.signal);
// Fetch inspire results
const resultRes = await aceClient.getJobResult(lmJobId);
const lmResults = await resultRes.json() as AceRequest[];
unsub();
if (!lmResults || lmResults.length === 0) {
throw new Error('No results from inspire mode');
}
const first = lmResults[0];
job.status = 'succeeded';
job.progress = 100;
job.stage = 'Done!';
job.result = {
caption: first.caption || aceReq.caption || '',
lyrics: first.lyrics || '',
bpm: first.bpm || 120,
duration: first.duration || 120,
keyScale: first.keyscale || 'C major',
timeSignature: first.timesignature || '4',
vocalLanguage: first.vocal_language || params.vocalLanguage || 'en',
};
console.log(`[Inspire] Job ${job.id} — complete. BPM=${job.result.bpm}, lang=${job.result.vocalLanguage}, caption=${job.result.caption.substring(0, 100)}, lyrics=${job.result.lyrics.substring(0, 200)}`);
} catch (err: any) {
if (err.message === 'Cancelled') {
job.status = 'cancelled';
job.stage = 'Cancelled';
} else {
job.status = 'failed';
job.error = err.message || 'Unknown error';
job.stage = 'Failed';
console.error(`[Inspire] Job ${job.id} failed:`, err.message);
}
}
}
// ── Serialization queue (shares the engine with generate) ──
// Inspire jobs go through the same single-GPU bottleneck.
// For now we run them independently — they're fast and only use the LM.
// If contention becomes an issue, we can merge with the generate queue.
// POST /api/inspire — start an inspire job
router.post('/', (req, res) => {
if (!engineReady) {
res.status(503).json({
error: `Engine not ready: ${engineBootStatus}`,
detail: 'Please wait for the engine to finish starting up.',
});
return;
}
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const job: InspireJob = {
id: uuidv4(),
status: 'pending',
stage: 'Starting...',
progress: 0,
createdAt: Date.now(),
};
inspireJobs.set(job.id, job);
runInspire(job, req.body);
res.json({
jobId: job.id,
status: job.status,
});
});
// GET /api/inspire/status/:id — poll inspire job status
router.get('/status/:id', (req, res) => {
const job = inspireJobs.get(req.params.id);
if (!job) { res.status(404).json({ error: 'Job not found' }); return; }
res.json({
jobId: job.id,
status: job.status,
stage: job.stage,
progress: job.progress,
result: job.result,
error: job.error,
ace_job_id: job.aceJobId ?? null,
ace_phase: job.acePhase ?? null,
ace_phase_progress: job.acePhaseProgress ?? null,
});
});
// POST /api/inspire/cancel/:id — cancel an inspire job
router.post('/cancel/:id', (req, res) => {
const job = inspireJobs.get(req.params.id);
if (!job) { res.status(404).json({ error: 'Job not found' }); return; }
job.status = 'cancelled';
if (job.aceJobId) {
aceClient.cancelJob(job.aceJobId).catch(() => {});
}
if ((job as any)._abort) {
(job as any)._abort.abort();
}
res.json({ success: true, jobId: job.id });
});
// ── External LLM lyric generation ──────────────────────────────────
// POST /api/inspire/llm — generate lyrics via an external LLM provider.
// This is synchronous (not a job queue) since external LLMs respond
// in seconds. Returns { lyrics, caption } directly.
const LANGUAGE_NAMES: Record<string, string> = {
en: 'English', zh: 'Chinese', ja: 'Japanese', ko: 'Korean',
es: 'Spanish', fr: 'French', de: 'German', it: 'Italian',
pt: 'Portuguese', ru: 'Russian', ar: 'Arabic', hi: 'Hindi',
tr: 'Turkish', vi: 'Vietnamese', th: 'Thai', sv: 'Swedish',
pl: 'Polish', nl: 'Dutch',
};
router.post('/llm', async (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const { provider: providerName, model, genres, subject, language, systemPrompt: clientPrompt } = req.body as {
provider: string;
model?: string;
genres: string[]; // e.g. ["Pop Punk", "Punk Rock"]
subject: string; // e.g. "a man tired from working 9 to 5"
language?: string; // e.g. "en"
systemPrompt?: string; // Optional client-side prompt override (takes priority over DB custom)
};
if (!providerName) {
res.status(400).json({ error: 'Missing provider' });
return;
}
if (!subject?.trim()) {
res.status(400).json({ error: 'Missing subject — external LLM mode requires a song subject' });
return;
}
if (!genres?.length) {
res.status(400).json({ error: 'Missing genres — select at least one genre' });
return;
}
try {
const provider = getProvider(providerName);
const effectiveModel = model || provider.defaultModel;
const langName = LANGUAGE_NAMES[language || 'en'] || language || 'English';
const genreStr = genres.join(', ');
// Resolve system prompt: client override → DB custom → default
const dbCustom = getSetting('instagen_system_prompt');
const systemPrompt = clientPrompt?.trim() || dbCustom || INSTAGEN_FULL_SYSTEM_PROMPT;
// Build user prompt
const userPrompt = [
`Genre/Style: ${genreStr}`,
`Subject: ${subject.trim()}`,
`Language: ${langName}`,
'',
'Generate the complete song now:',
].join('\n');
console.log(`[Inspire/LLM] Generating song via ${providerName}/${effectiveModel}`);
console.log(`[Inspire/LLM] Genre: ${genreStr}, Subject: ${subject}, Language: ${langName}`);
console.log(`[Inspire/LLM] Prompt source: ${clientPrompt ? 'client override' : dbCustom ? 'DB custom' : 'default'}`);
let raw = await provider.call(systemPrompt, userPrompt, effectiveModel);
// Strip thinking blocks first
raw = stripThinkingBlocks(raw);
raw = raw.replace(/<\|[a-z_]+\|>/g, '');
// Try to parse as structured JSON response
let structuredResult = parseStructuredLlmResponse(raw);
if (structuredResult) {
// ── Structured JSON path — LLM returned all metadata ──
let lyrics = structuredResult.lyrics || '';
lyrics = lyrics.replace(/\[?(System|User|Assistant)\]?:.*/gi, '');
lyrics = lyrics.replace(/\s*\((?:Hook|You|Repeat|x\d|Refrain|Spoken|Whispered|Ad[- ]?lib|Echo)\)\s*/gi, '');
lyrics = lyrics.replace(/ +$/gm, '');
lyrics = postprocessLyrics(lyrics);
lyrics = fixSectionLabels(lyrics);
lyrics = fixAPrefix(lyrics);
lyrics = enforceLineCounts(lyrics);
console.log(`[Inspire/LLM] Structured response: ${lyrics.split('\n').length} lines, BPM=${structuredResult.bpm}, Key=${structuredResult.key}, Title="${structuredResult.title}"`);
res.json({
lyrics,
caption: structuredResult.tags || genreStr,
title: structuredResult.title || undefined,
bpm: structuredResult.bpm || undefined,
key: structuredResult.key || undefined,
timeSignature: structuredResult.time_signature || undefined,
duration: structuredResult.duration || undefined,
structured: true, // Flag so frontend knows to skip inspire
provider: providerName,
model: effectiveModel,
});
} else {
// ── Fallback: raw text path (legacy prompt or non-JSON response) ──
console.log('[Inspire/LLM] Non-JSON response, falling back to lyrics-only parsing');
raw = raw.replace(/\[?(System|User|Assistant)\]?:.*/gi, '');
raw = raw.replace(/\s*\((?:Hook|You|Repeat|x\d|Refrain|Spoken|Whispered|Ad[- ]?lib|Echo)\)\s*/gi, '');
raw = raw.replace(/ +$/gm, '');
// Extract title from the end of the response (Title: <song title>)
let extractedTitle = '';
const titleEndMatch = raw.match(/\n\s*Title:\s*(.+?)\s*$/im);
if (titleEndMatch) {
extractedTitle = titleEndMatch[1]
.replace(/^["']+|["']+$/g, '') // strip quotes
.replace(/[.!?,;:]+$/, '') // strip trailing punctuation
.trim();
raw = raw.replace(/\n\s*Title:\s*.+?\s*$/im, '').trimEnd();
}
// Strip any title line at the start
const rawLines = raw.trim().split('\n');
for (let i = 0; i < rawLines.length; i++) {
const match = rawLines[i].match(/^(?:Title:\s*|#\s*)(.*)/i);
if (match) {
if (!extractedTitle) extractedTitle = match[1].replace(/^["']+|["']+$/g, '').trim();
const rest = rawLines.slice(i + 1);
while (rest.length && !rest[0].trim()) rest.shift();
raw = rest.join('\n');
break;
}
if (rawLines[i].trim().startsWith('[') || (rawLines[i].trim() && i > 2)) break;
}
raw = postprocessLyrics(raw);
raw = fixSectionLabels(raw);
raw = fixAPrefix(raw);
raw = enforceLineCounts(raw);
console.log(`[Inspire/LLM] Generated ${raw.split('\n').length} lines of lyrics${extractedTitle ? `, title: "${extractedTitle}"` : ''}`);
res.json({
lyrics: raw,
caption: genreStr,
title: extractedTitle || undefined,
structured: false,
provider: providerName,
model: effectiveModel,
});
}
} catch (err: any) {
console.error(`[Inspire/LLM] Failed:`, err.message);
res.status(500).json({ error: err.message || 'LLM lyric generation failed' });
}
});
// GET /api/inspire/llm/providers — list available LLM providers
router.get('/llm/providers', async (_req, res) => {
try {
const providers = await listProviders();
res.json(providers);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/inspire/llm/subject — generate a random song subject via LLM
router.post('/llm/subject', async (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const { provider: providerName, model, genres } = req.body as {
provider: string;
model?: string;
genres?: string[];
};
if (!providerName) {
res.status(400).json({ error: 'Missing provider' });
return;
}
try {
const provider = getProvider(providerName);
const effectiveModel = model || provider.defaultModel;
const genreStr = genres?.length ? genres.join(', ') : 'any genre';
const systemPrompt = `You generate creative, specific song subjects for songwriters. Given a musical genre, suggest ONE vivid, concrete song subject. The subject should be a brief description (1-2 sentences max) that a songwriter can write lyrics about. Be specific and interesting — avoid generic topics. Do NOT write lyrics, only the subject/concept. Output ONLY the subject, nothing else.`;
const userPrompt = `Genre/Style: ${genreStr}\n\nSuggest a creative song subject:`;
console.log(`[Inspire/LLM] Generating random subject via ${providerName}/${effectiveModel}`);
let raw = await provider.call(systemPrompt, userPrompt, effectiveModel);
raw = stripThinkingBlocks(raw);
// Clean up: remove quotes, "Subject:" prefix, etc.
raw = raw.replace(/^["']|["']$/g, '').trim();
raw = raw.replace(/^(?:Subject|Topic|Concept|Idea):\s*/i, '').trim();
// Take only the first 1-2 sentences
const sentences = raw.split(/(?<=[.!?])\s+/);
raw = sentences.slice(0, 2).join(' ').trim();
// Remove trailing period for cleaner look in input field
raw = raw.replace(/\.\s*$/, '');
console.log(`[Inspire/LLM] Generated subject: "${raw}"`);
res.json({ subject: raw });
} catch (err: any) {
console.error(`[Inspire/LLM] Subject generation failed:`, err.message);
res.status(500).json({ error: err.message || 'Subject generation failed' });
}
});
// ── InstaGen system prompt CRUD ──────────────────────────────────────
// Reuses Lireek DB settings table (same as Lyric Studio's prompt editor).
router.get('/llm/prompt', (_req, res) => {
try {
const custom = getSetting('instagen_system_prompt') || null;
res.json({
name: 'instagen_system',
default_content: INSTAGEN_FULL_SYSTEM_PROMPT,
custom,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.put('/llm/prompt', (req, res) => {
try {
const { value } = req.body;
if (!value) { res.status(400).json({ error: 'value required' }); return; }
setSetting('instagen_system_prompt', value);
console.log('[Inspire/LLM] Custom InstaGen system prompt saved');
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/llm/prompt', (_req, res) => {
try {
setSetting('instagen_system_prompt', '');
console.log('[Inspire/LLM] InstaGen system prompt reset to default');
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Structured JSON response parser ──────────────────────────────────
// Extracts the JSON object from LLM output, handling code fences, thinking
// blocks, and other wrapping the LLM might add around the JSON.
interface StructuredLlmResult {
tags?: string;
lyrics: string;
title?: string;
bpm?: number;
key?: string;
time_signature?: string;
duration?: number;
}
function parseStructuredLlmResponse(raw: string): StructuredLlmResult | null {
// Strip markdown code fences
let cleaned = raw.replace(/^```(?:json)?\s*|\s*```$/gm, '').trim();
// Try direct parse first
try {
const parsed = JSON.parse(cleaned);
if (parsed && typeof parsed === 'object' && parsed.lyrics) return parsed;
} catch { /* not valid JSON as-is */ }
// Try to find JSON object in the text
const start = cleaned.indexOf('{');
if (start === -1) return null;
// Find the matching closing brace
let depth = 0;
for (let i = start; i < cleaned.length; i++) {
if (cleaned[i] === '{') depth++;
else if (cleaned[i] === '}') {
depth--;
if (depth === 0) {
try {
const parsed = JSON.parse(cleaned.slice(start, i + 1));
if (parsed && typeof parsed === 'object' && parsed.lyrics) return parsed;
} catch { /* not valid JSON */ }
break;
}
}
}
return null;
}
export default router;
+127
View File
@@ -0,0 +1,127 @@
// lireek.ts — Express routes for Lyric Studio / Lireek
//
// All endpoints under /api/lireek/*
// Route handlers are split into focused modules:
// - lireek/crudRoutes.ts: Artists, Lyrics Sets, Profiles, Generations, Presets
// - lireek/llmRoutes.ts: LLM-powered generation, profiling, refinement
// Small utility routes (slop, purge, prompts, recent) remain here.
import { Router, type Request, type Response } from 'express';
import * as db from '../db/lireekDb.js';
import { scanForSlop, BLACKLISTED_WORDS, BLACKLISTED_PHRASES } from '../services/lireek/slopDetector.js';
import {
GENERATION_SYSTEM_PROMPT,
SONG_METADATA_SYSTEM_PROMPT,
PROFILE_PROMPT_1, PROFILE_PROMPT_2, PROFILE_PROMPT_3,
REFINEMENT_SYSTEM_PROMPT,
} from '../services/lireek/prompts.js';
import { registerCrudRoutes } from './lireek/crudRoutes.js';
import { registerLlmRoutes } from './lireek/llmRoutes.js';
const router = Router();
/** Safely extract a route param as string (Express 5 types params as string | string[]) */
function param(req: Request, name: string): string {
const v = req.params[name];
return Array.isArray(v) ? v[0] : v;
}
// ── Register modular route groups ────────────────────────────────────────────
registerCrudRoutes(router);
registerLlmRoutes(router);
// ── Slop Scanner ────────────────────────────────────────────────────────────
router.post('/slop-scan', (req: Request, res: Response) => {
try {
const { text, fingerprint, statistical_weight } = req.body;
if (!text) { res.status(400).json({ error: 'text required' }); return; }
const result = scanForSlop(text, fingerprint, statistical_weight);
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Purge ───────────────────────────────────────────────────────────────────
router.post('/purge', (_req: Request, res: Response) => {
try {
const result = db.purgeProfilesAndGenerations();
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/purge-generations', (_req: Request, res: Response) => {
try {
const result = db.purgeGenerationsOnly();
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/purge-profiles', (_req: Request, res: Response) => {
try {
const result = db.purgeProfilesOnly();
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Settings / Prompts ──────────────────────────────────────────────────────
router.get('/prompts', (_req: Request, res: Response) => {
const defaults: Record<string, string> = {
generation_system: GENERATION_SYSTEM_PROMPT,
metadata_system: SONG_METADATA_SYSTEM_PROMPT,
profile_system: [PROFILE_PROMPT_1, PROFILE_PROMPT_2, PROFILE_PROMPT_3].join('\n\n---\n\n'),
refine_system: REFINEMENT_SYSTEM_PROMPT,
};
const names = Object.keys(defaults);
const prompts = names.map(name => ({
name,
default_content: defaults[name],
custom: db.getSetting(`prompt_${name}`) || null,
}));
res.json({ prompts });
});
router.put('/prompts/:name', (req: Request, res: Response) => {
try {
const promptName = param(req, 'name');
const { value } = req.body;
if (!value) { res.status(400).json({ error: 'value required' }); return; }
db.setSetting(`prompt_${promptName}`, value);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/prompts/:name', (req: Request, res: Response) => {
try {
const promptName = param(req, 'name');
db.setSetting(`prompt_${promptName}`, '');
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Recent Songs ────────────────────────────────────────────────────────────
router.get('/recent-songs', (req: Request, res: Response) => {
try {
const limit = parseInt(req.query.limit as string, 10) || 50;
const rows = db.getRecentGenerationsWithAudio(limit);
res.json({ songs: rows });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;
+514
View File
@@ -0,0 +1,514 @@
// lireek/crudRoutes.ts — CRUD routes for Lyric Studio entities
//
// Artists, Lyrics Sets, Genius fetch, Profiles, Generations,
// Export, Audio Generations, Album Presets.
// Registered on the parent router by lireek.ts.
import type { Router, Request, Response } from 'express';
import * as db from '../../db/lireekDb.js';
import * as genius from '../../services/lireek/geniusService.js';
import { exportGeneration } from '../../services/lireek/exportService.js';
/** Safely extract a route param as string (Express 5 types params as string | string[]) */
function param(req: Request, name: string): string {
const v = req.params[name];
return Array.isArray(v) ? v[0] : v;
}
/** Parse an integer route param */
function intParam(req: Request, name: string): number {
return parseInt(param(req, name), 10);
}
/** Parse adapter_group_scales from JSON string to object if needed */
function hydratePreset(preset: any): any {
if (!preset) return null;
const hydrated = { ...preset };
if (typeof hydrated.adapter_group_scales === 'string') {
try { hydrated.adapter_group_scales = JSON.parse(hydrated.adapter_group_scales); }
catch { hydrated.adapter_group_scales = null; }
}
return hydrated;
}
export function registerCrudRoutes(router: Router): void {
// ── Artists ─────────────────────────────────────────────────────────────────
router.get('/artists', (_req: Request, res: Response) => {
try {
const artists = db.listArtists();
res.json(artists);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/artists/create', (req: Request, res: Response) => {
try {
const { name } = req.body;
if (!name?.trim()) {
res.status(400).json({ error: 'Artist name required' });
return;
}
const artist = db.getOrCreateArtist(name.trim());
res.json(artist);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/artists/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const deleted = db.deleteArtist(id);
if (!deleted) { res.status(404).json({ error: 'Artist not found' }); return; }
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/artists/:id/refresh-image', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const artist = db.getArtist(id);
if (!artist) { res.status(404).json({ error: 'Artist not found' }); return; }
const imageUrl = await genius.getArtistImageUrl(artist.name);
if (imageUrl) db.updateArtistImage(id, imageUrl);
res.json({ image_url: imageUrl });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/artists/:id/set-image', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const { image_url } = req.body;
db.updateArtistImage(id, image_url ?? null);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Lyrics Sets ─────────────────────────────────────────────────────────────
router.get('/lyrics-sets', (req: Request, res: Response) => {
try {
const artistId = req.query.artist_id ? parseInt(req.query.artist_id as string, 10) : undefined;
const sets = db.getLyricsSets(artistId);
res.json(sets);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/lyrics-sets/create', (req: Request, res: Response) => {
try {
const { artist_name, artist_id, album, songs = [], image_url } = req.body;
let artist: Record<string, any> | undefined;
if (artist_id) {
artist = db.getArtist(artist_id);
if (!artist) { res.status(404).json({ error: 'Artist not found' }); return; }
} else if (artist_name?.trim()) {
artist = db.getOrCreateArtist(artist_name.trim());
} else {
res.status(400).json({ error: 'artist_name or artist_id required' });
return;
}
const songList = Array.isArray(songs) ? songs : [];
const set = db.saveLyricsSet(artist!.id as number, album ?? null, songList.length, songList, image_url ?? null);
res.json({ lyrics_set: set });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/lyrics-sets/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const set = db.getLyricsSet(id);
if (!set) { res.status(404).json({ error: 'Lyrics set not found' }); return; }
res.json(set);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/lyrics-sets/:id/full-detail', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const set = db.getLyricsSet(id);
if (!set) { res.status(404).json({ error: 'Lyrics set not found' }); return; }
const profiles = db.getProfiles(id);
const generations = db.getGenerations(undefined, id);
const preset = db.getPreset(id);
res.json({ lyrics_set: set, profiles, generations, preset });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/lyrics-sets/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const deleted = db.deleteLyricsSet(id);
if (!deleted) { res.status(404).json({ error: 'Lyrics set not found' }); return; }
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/lyrics-sets/:id/songs/:index', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const index = intParam(req, 'index');
const updated = db.removeSongFromSet(id, index);
if (!updated) { res.status(404).json({ error: 'Song not found' }); return; }
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.put('/lyrics-sets/:id/songs/:index', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const index = intParam(req, 'index');
const { lyrics } = req.body;
const updated = db.editSongInSet(id, index, lyrics);
if (!updated) { res.status(404).json({ error: 'Song not found' }); return; }
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/lyrics-sets/:id/refresh-image', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const set = db.getLyricsSet(id);
if (!set) { res.status(404).json({ error: 'Lyrics set not found' }); return; }
const imageUrl = set.album
? await genius.getAlbumImageUrl(set.album, set.artist_name)
: await genius.getArtistImageUrl(set.artist_name);
if (imageUrl) db.updateLyricsSetImage(id, imageUrl);
res.json({ image_url: imageUrl });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/lyrics-sets/:id/set-image', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const { image_url } = req.body;
db.updateLyricsSetImage(id, image_url ?? null);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/lyrics-sets/:id/add-song', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const { title, album, lyrics } = req.body;
if (!title || !lyrics) {
res.status(400).json({ error: 'title and lyrics required' });
return;
}
const updated = db.addSongToSet(id, { title, album, lyrics });
if (!updated) { res.status(404).json({ error: 'Lyrics set not found' }); return; }
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Fetch Lyrics (Genius) ───────────────────────────────────────────────────
router.post('/fetch-lyrics', async (req: Request, res: Response) => {
try {
const { artist, album, max_songs = 10 } = req.body;
if (!artist?.trim()) {
res.status(400).json({ error: 'Artist name required' });
return;
}
const result = await genius.fetchLyrics(artist.trim(), album?.trim() || null, max_songs);
const artistRow = db.getOrCreateArtist(result.artist);
if (!artistRow.image_url) {
genius.getArtistImageUrl(result.artist).then(url => {
if (url) db.updateArtistImage(artistRow.id as number, url);
}).catch(() => {});
}
let albumImageUrl: string | null = null;
if (result.album) {
try {
albumImageUrl = await genius.getAlbumImageUrl(result.album, result.artist);
} catch {}
}
const lyricsSet = db.saveLyricsSet(
artistRow.id as number,
result.album,
result.songs.length,
result.songs,
albumImageUrl,
);
res.json({
...result,
artist_id: artistRow.id,
lyrics_set_id: lyricsSet.id,
});
} catch (err: any) {
const status = err.message?.includes('not found') || err.message?.includes('No lyrics') ? 404 : 500;
res.status(status).json({ error: err.message });
}
});
router.post('/search-song-lyrics', async (req: Request, res: Response) => {
try {
const { artist, title } = req.body;
if (!artist || !title) {
res.status(400).json({ error: 'artist and title required' });
return;
}
const result = await genius.searchSongLyrics(artist, title);
if (!result) { res.status(404).json({ error: 'Song not found' }); return; }
res.json(result);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Profiles ────────────────────────────────────────────────────────────────
router.get('/profiles', (req: Request, res: Response) => {
try {
const lyricsSetId = req.query.lyrics_set_id ? parseInt(req.query.lyrics_set_id as string, 10) : undefined;
res.json(db.getProfiles(lyricsSetId));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/profiles/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const profile = db.getProfile(id);
if (!profile) { res.status(404).json({ error: 'Profile not found' }); return; }
res.json(profile);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/profiles/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const deleted = db.deleteProfile(id);
if (!deleted) { res.status(404).json({ error: 'Profile not found' }); return; }
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Generations ─────────────────────────────────────────────────────────────
router.get('/generations', (req: Request, res: Response) => {
try {
const profileId = req.query.profile_id ? parseInt(req.query.profile_id as string, 10) : undefined;
const lyricsSetId = req.query.lyrics_set_id ? parseInt(req.query.lyrics_set_id as string, 10) : undefined;
res.json(db.getGenerations(profileId, lyricsSetId));
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/generations/all', (_req: Request, res: Response) => {
try {
res.json(db.getAllGenerationsWithContext());
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/generations/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const gen = db.getGeneration(id);
if (!gen) { res.status(404).json({ error: 'Generation not found' }); return; }
res.json(gen);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.patch('/generations/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
db.updateGenerationFields(id, req.body);
const updated = db.getGeneration(id);
if (!updated) { res.status(404).json({ error: 'Generation not found' }); return; }
res.json(updated);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/generations/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const deleted = db.deleteGeneration(id);
if (!deleted) { res.status(404).json({ error: 'Generation not found' }); return; }
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Export ───────────────────────────────────────────────────────────────────
router.post('/generations/:id/export', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const gen = db.getGeneration(id);
if (!gen) { res.status(404).json({ error: 'Generation not found' }); return; }
const profile = db.getProfile(gen.profile_id);
let artistName = 'Unknown';
let albumName: string | undefined;
if (profile) {
const lyricsSet = db.getLyricsSet(profile.lyrics_set_id);
if (lyricsSet) {
artistName = lyricsSet.artist_name;
albumName = lyricsSet.album ?? undefined;
}
}
const paths = exportGeneration({
title: gen.title,
lyrics: gen.lyrics,
artistName,
albumName,
provider: gen.provider,
model: gen.model,
bpm: gen.bpm,
key: gen.key,
caption: gen.caption,
duration: gen.duration,
subject: gen.subject,
extraInstructions: gen.extra_instructions,
createdAt: gen.created_at,
});
res.json({ success: true, ...paths });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Audio Generations ───────────────────────────────────────────────────────
router.post('/generations/:id/audio', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const { job_id } = req.body;
if (!job_id) { res.status(400).json({ error: 'job_id required' }); return; }
const link = db.linkAudioGeneration(id, job_id);
res.json(link);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/generations/:id/audio', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const rows = db.getAudioGenerations(id);
res.json({ audio_generations: rows });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/audio-generations/:id', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const deleted = db.deleteAudioGeneration(id);
if (!deleted) { res.status(404).json({ error: 'Audio generation not found' }); return; }
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.patch('/audio-generations/resolve', (req: Request, res: Response) => {
try {
const { job_id, audio_url, cover_url } = req.body;
if (!job_id || !audio_url) { res.status(400).json({ error: 'job_id and audio_url required' }); return; }
db.resolveAudioGeneration(job_id, audio_url, cover_url);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Album Presets ───────────────────────────────────────────────────────────
router.get('/lyrics-sets/:id/preset', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const preset = db.getPreset(id);
res.json({ preset: hydratePreset(preset) });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.put('/lyrics-sets/:id/preset', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const preset = db.upsertPreset(id, {
adapterPath: req.body.adapter_path,
adapterScale: req.body.adapter_scale,
adapterGroupScales: req.body.adapter_group_scales,
referenceTrackPath: req.body.reference_track_path,
audioCoverStrength: req.body.audio_cover_strength,
lmAdapterPath: req.body.lm_adapter_path,
lmAdapterScale: req.body.lm_adapter_scale,
});
res.json({ preset: hydratePreset(preset) });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.delete('/lyrics-sets/:id/preset', (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
db.deletePreset(id);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.get('/presets', (_req: Request, res: Response) => {
try {
const presets = db.getAllPresets().map(hydratePreset);
res.json({ presets });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
}
+431
View File
@@ -0,0 +1,431 @@
// lireek/llmRoutes.ts — LLM-powered routes for Lyric Studio
//
// Build Profile, Generate Lyrics, Refine Lyrics (streaming + non-streaming),
// Provider listing, Recalculate Stats, Skip Thinking.
// Registered on the parent router by lireek.ts.
import type { Router, Request, Response } from 'express';
import * as db from '../../db/lireekDb.js';
import * as llmService from '../../services/lireek/llmService.js';
import * as profilerService from '../../services/lireek/profilerService.js';
import { computeAlbumEnrichment } from '../../services/lireek/prompts.js';
/** Safely extract a route param as string (Express 5 types params as string | string[]) */
function param(req: Request, name: string): string {
const v = req.params[name];
return Array.isArray(v) ? v[0] : v;
}
/** Parse an integer route param */
function intParam(req: Request, name: string): number {
return parseInt(param(req, name), 10);
}
/** Set up SSE headers on the response */
function initSse(res: Response): (type: string, data: any) => void {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
return (type: string, data: any) => {
res.write(`event: ${type}\ndata: ${JSON.stringify(data)}\n\n`);
};
}
/** Resolve past generation history for diversity tracking */
function resolveHistory(artistId: number | undefined) {
const pastGenerations = artistId
? db.getAllGenerationsWithContext().filter((g: any) => g.artist_id === artistId)
: [];
return {
usedSubjects: pastGenerations.map((g: any) => g.subject || g.song_subject).filter(Boolean) as string[],
usedBpms: pastGenerations.map((g: any) => g.bpm).filter((b: any): b is number => b !== null && b > 0) as number[],
usedKeys: pastGenerations.map((g: any) => g.key || g.song_key).filter(Boolean) as string[],
usedTitles: pastGenerations.map((g: any) => g.title).filter(Boolean) as string[],
usedDurations: pastGenerations.map((g: any) => g.duration).filter((d: any): d is number => d !== null && d > 0) as number[],
};
}
export function registerLlmRoutes(router: Router): void {
// ── LLM Providers ───────────────────────────────────────────────────────────
router.get('/providers', async (_req: Request, res: Response) => {
try {
const providers = await llmService.listProviders();
res.json(providers);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Build Profile ───────────────────────────────────────────────────────────
router.post('/lyrics-sets/:id/build-profile', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const { provider_name, model } = req.body;
const lyricsSet = db.getLyricsSet(id);
if (!lyricsSet) return res.status(404).json({ error: 'Lyrics set not found' });
const artist = db.getArtist(lyricsSet.artist_id);
if (!artist) return res.status(404).json({ error: 'Artist not found' });
const profileData = await profilerService.buildProfile(
artist?.name || 'Unknown',
null,
lyricsSet.songs,
provider_name,
model
);
const profile = db.saveProfile(lyricsSet.id, provider_name, model || '', profileData);
res.json(profile);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/lyrics-sets/:id/build-profile-stream', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const { provider_name, model } = req.body;
const lyricsSet = db.getLyricsSet(id);
if (!lyricsSet) throw new Error('Lyrics set not found');
const artist = db.getArtist(lyricsSet.artist_id);
if (!artist) throw new Error('Artist not found');
const sendSse = initSse(res);
const profileData = await profilerService.buildProfile(
artist?.name || 'Unknown',
null,
lyricsSet.songs,
provider_name,
model,
(phase) => sendSse('phase', { phase }),
(chunk) => sendSse('chunk', { text: chunk })
);
const profile = db.saveProfile(lyricsSet.id, provider_name, model || '', profileData);
sendSse('complete', profile);
res.end();
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
// ── Recalculate Stats (no LLM) ──────────────────────────────────────────────
router.post('/profiles/recalculate-stats', async (req: Request, res: Response) => {
try {
// Optional profile_ids narrows the run to specific profiles. Omit it to
// recalculate every profile, which is the original behaviour.
const requestedIds: number[] | undefined = Array.isArray(req.body?.profile_ids)
? req.body.profile_ids.map((n: any) => Number(n)).filter((n: number) => Number.isFinite(n))
: undefined;
const allProfiles = db.getProfiles();
const profiles = requestedIds?.length
? allProfiles.filter((p: any) => requestedIds.includes(p.id))
: allProfiles;
const updated: number[] = [];
const skipped: { id: number; reason: string }[] = [];
for (const profile of profiles) {
const lyricsSet = db.getLyricsSet(profile.lyrics_set_id);
if (!lyricsSet) {
skipped.push({ id: profile.id, reason: 'lyrics set not found' });
continue;
}
const songs = (lyricsSet.songs || []) as { title: string; album?: string; lyrics: string }[];
if (!songs.length) {
skipped.push({ id: profile.id, reason: 'lyrics set has no songs' });
continue;
}
const patched = profilerService.recalculateProfileStats(songs, profile.profile_data);
db.updateProfileData(profile.id, patched);
updated.push(profile.id);
}
const missing = requestedIds?.length
? requestedIds.filter((id) => !allProfiles.some((p: any) => p.id === id))
: [];
res.json({
updated: updated.length,
updated_ids: updated,
skipped,
missing,
total: profiles.length,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Generate Lyrics ─────────────────────────────────────────────────────────
router.post('/profiles/:id/generate', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const profile = db.getProfile(id);
if (!profile) return res.status(404).json({ error: 'Profile not found' });
const { provider_name, model, extra_instructions, auto_save = true, user_subject, no_think } = req.body;
const lyricsSet = db.getLyricsSet(profile.lyrics_set_id);
const artistId = lyricsSet?.artist_id;
const { usedSubjects, usedBpms, usedKeys, usedTitles, usedDurations } = resolveHistory(artistId);
// Profiles built before audio enrichment existed: derive it live from the
// lyrics set (Training Studio exports carry bpm/key/genre/caption per
// song). Sets without enrichment yield null and the prompts skip it.
if (!profile.profile_data.audio_enrichment && lyricsSet) {
profile.profile_data.audio_enrichment = computeAlbumEnrichment(lyricsSet.songs);
}
const generated = await llmService.generateLyricsStreaming(
profile.profile_data, provider_name, model, extra_instructions,
usedSubjects, usedBpms, usedKeys, usedTitles, usedDurations,
undefined, undefined, user_subject || undefined,
no_think ? { noThink: true } : undefined
);
if (auto_save) {
const saved = db.saveGeneration({
profileId: id,
provider: provider_name,
model: generated.model,
lyrics: generated.lyrics,
title: generated.title,
subject: generated.subject,
bpm: generated.bpm || undefined,
key: generated.key,
caption: generated.caption,
duration: generated.duration || undefined,
systemPrompt: generated.system_prompt,
userPrompt: generated.user_prompt
});
res.json(saved);
} else {
res.json(generated);
}
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/profiles/:id/generate-stream', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const profile = db.getProfile(id);
if (!profile) throw new Error('Profile not found');
const { provider_name, model, extra_instructions, auto_save = true, user_subject, no_think } = req.body;
const sendSse = initSse(res);
llmService.resetSkipThinking();
const lyricsSet = db.getLyricsSet(profile.lyrics_set_id);
const artistId = lyricsSet?.artist_id;
const { usedSubjects, usedBpms, usedKeys, usedTitles, usedDurations } = resolveHistory(artistId);
// Profiles built before audio enrichment existed: derive it live from the
// lyrics set (Training Studio exports carry bpm/key/genre/caption per
// song). Sets without enrichment yield null and the prompts skip it.
if (!profile.profile_data.audio_enrichment && lyricsSet) {
profile.profile_data.audio_enrichment = computeAlbumEnrichment(lyricsSet.songs);
}
const generated = await llmService.generateLyricsStreaming(
profile.profile_data, provider_name, model, extra_instructions,
usedSubjects, usedBpms, usedKeys, usedTitles, usedDurations,
(chunk) => sendSse('chunk', { text: chunk }),
(phase) => sendSse('phase', { phase }),
user_subject || undefined,
no_think ? { noThink: true } : undefined
);
if (auto_save) {
const saved = db.saveGeneration({
profileId: id,
provider: provider_name,
model: generated.model,
lyrics: generated.lyrics,
title: generated.title,
subject: generated.subject,
bpm: generated.bpm || undefined,
key: generated.key,
caption: generated.caption,
duration: generated.duration || undefined,
systemPrompt: generated.system_prompt,
userPrompt: generated.user_prompt
});
sendSse('complete', saved);
} else {
sendSse('complete', generated);
}
res.end();
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
// ── Refine Lyrics ───────────────────────────────────────────────────────────
router.post('/generations/:id/refine', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const existing = db.getGeneration(id);
if (!existing) return res.status(404).json({ error: 'Generation not found' });
const { provider_name, model, auto_save = true } = req.body;
const profile = db.getProfile(existing.profile_id);
const lyricsSet = profile ? db.getLyricsSet(profile.lyrics_set_id) : null;
const artist = lyricsSet ? db.getArtist(lyricsSet.artist_id) : undefined;
const refined = await llmService.refineLyricsStreaming(
existing.lyrics, artist?.name || 'Unknown', existing.title, provider_name, model, profile?.profile_data || undefined
);
if (auto_save) {
const saved = db.saveGeneration({
profileId: existing.profile_id,
provider: provider_name,
model: refined.model,
lyrics: refined.lyrics,
title: refined.title,
subject: existing.song_subject,
bpm: existing.bpm || undefined,
key: existing.song_key,
caption: existing.caption,
duration: existing.duration || undefined,
systemPrompt: refined.system_prompt,
userPrompt: refined.user_prompt,
parentGenerationId: existing.id
});
res.json(saved);
} else {
res.json(refined);
}
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
router.post('/generations/:id/refine-stream', async (req: Request, res: Response) => {
try {
const id = intParam(req, 'id');
const existing = db.getGeneration(id);
if (!existing) throw new Error('Generation not found');
const { provider_name, model, auto_save = true } = req.body;
const profile = db.getProfile(existing.profile_id);
const lyricsSet = profile ? db.getLyricsSet(profile.lyrics_set_id) : null;
const artist = lyricsSet ? db.getArtist(lyricsSet.artist_id) : undefined;
const sendSse = initSse(res);
llmService.resetSkipThinking();
const refined = await llmService.refineLyricsStreaming(
existing.lyrics, artist?.name || 'Unknown', existing.title, provider_name, model, profile?.profile_data || undefined,
(chunk) => sendSse('chunk', { text: chunk })
);
if (auto_save) {
const saved = db.saveGeneration({
profileId: existing.profile_id,
provider: provider_name,
model: refined.model,
lyrics: refined.lyrics,
title: refined.title,
subject: existing.song_subject,
bpm: existing.bpm || undefined,
key: existing.song_key,
caption: existing.caption,
duration: existing.duration || undefined,
systemPrompt: refined.system_prompt,
userPrompt: refined.user_prompt,
parentGenerationId: existing.id
});
sendSse('complete', saved);
} else {
sendSse('complete', refined);
}
res.end();
} catch (err: any) {
res.write(`event: error\ndata: ${JSON.stringify({ error: err.message })}\n\n`);
res.end();
}
});
// ── Curated Profile ─────────────────────────────────────────────────────────
router.post('/artists/:id/curated-profile', async (_req: Request, res: Response) => {
res.status(501).json({ error: 'Curated profiles not yet implemented in TS' });
});
router.post('/artists/:id/curated-profile-stream', async (_req: Request, res: Response) => {
res.status(501).json({ error: 'Curated profiles not yet implemented in TS' });
});
// ── Generate Style Caption ─────────────────────────────────────────────────
router.post('/artists/:id/generate-caption', async (req: Request, res: Response) => {
try {
const artistId = intParam(req, 'id');
const { provider, model, force } = req.body;
if (!provider) return res.status(400).json({ error: 'provider is required' });
const artist = db.getArtist(artistId);
if (!artist) return res.status(404).json({ error: 'Artist not found' });
// Find the first profile across all lyrics sets
const lyricsSets = db.getLyricsSets(artistId);
let profileRow: any = null;
for (const ls of lyricsSets) {
const profiles = db.getProfiles(ls.id);
if (profiles.length) { profileRow = profiles[0]; break; }
}
if (!profileRow) return res.status(404).json({ error: 'No profile found for this artist' });
const profileData = profileRow.profile_data || {};
// Return cached caption unless force regeneration
if (profileData.style_caption && !force) {
return res.json({ caption: profileData.style_caption });
}
// Build user prompt from profile data
const { STYLE_CAPTION_PROMPT } = await import('../../services/lireek/prompts.js');
const captionUserPrompt = [
`Artist: ${artist.name}`,
profileData.album ? `Album: ${profileData.album}` : '',
profileData.themes?.length ? `Themes: ${profileData.themes.join(', ')}` : '',
profileData.tone_and_mood ? `Tone and mood: ${profileData.tone_and_mood}` : '',
profileData.vocabulary_notes ? `Vocabulary: ${profileData.vocabulary_notes}` : '',
].filter(Boolean).join('\n');
const llmProvider = llmService.getProvider(provider);
const effModel = model || llmProvider.defaultModel;
const raw = await llmProvider.call(STYLE_CAPTION_PROMPT, captionUserPrompt, effModel);
const caption = raw.replace(/^["'`]+|["'`]+$/g, '').trim();
// Persist back to profile
profileData.style_caption = caption;
db.updateProfileData(profileRow.id, profileData);
res.json({ caption });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// ── Skip Thinking ───────────────────────────────────────────────────────────
router.post('/skip-thinking', (_req: Request, res: Response) => {
llmService.setSkipThinking();
res.json({ success: true });
});
}
+147
View File
@@ -0,0 +1,147 @@
// logs.ts — Live log streaming and VRAM proxy
//
// Ring buffer captures logs from both the Node server and ace-server child.
// SSE endpoint streams them to the UI in real time.
// VRAM endpoint proxies to ace-server's GET /vram.
import { Router, Request, Response } from 'express';
import { config } from '../config.js';
const router = Router();
// ── Ring buffer for log lines ─────────────────────────────────────────
export interface LogLine {
id: number;
ts: number; // epoch ms
text: string;
source: 'engine' | 'server';
}
const MAX_LINES = 2000;
const lines: LogLine[] = [];
let nextId = 0;
const subscribers: Set<(line: LogLine) => void> = new Set();
/** Noisy GGML/CUDA patterns that flood logs with no actionable info. */
const ENGINE_NOISE = [
'CUDA graph warmup',
'CUDA Graph id',
'ggml_backend_cuda_graph_compute',
];
/** Push a log line into the buffer and notify SSE subscribers */
export function pushLog(text: string, source: 'engine' | 'server' = 'server'): void {
// Suppress repetitive engine noise
if (source === 'engine' && ENGINE_NOISE.some(p => text.includes(p))) return;
const line: LogLine = { id: nextId++, ts: Date.now(), text, source };
lines.push(line);
if (lines.length > MAX_LINES) {
lines.splice(0, lines.length - MAX_LINES);
}
for (const cb of subscribers) {
try { cb(line); } catch { /* subscriber dead, will be cleaned up */ }
}
}
/** Subscribe to new log lines. Returns unsubscribe function. */
export function subscribeLines(cb: (line: LogLine) => void): () => void {
subscribers.add(cb);
return () => { subscribers.delete(cb); };
}
// ── SSE endpoint: GET /api/logs ───────────────────────────────────────
router.get('/', (req: Request, res: Response) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
});
// Send backlog
const afterId = req.query.after ? parseInt(req.query.after as string, 10) : -1;
for (const line of lines) {
if (line.id > afterId) {
res.write(`data: ${JSON.stringify(line)}\n\n`);
}
}
// Stream new lines
const onLine = (line: LogLine) => {
try {
res.write(`data: ${JSON.stringify(line)}\n\n`);
} catch {
subscribers.delete(onLine);
}
};
subscribers.add(onLine);
// Keepalive ping every 15s
const keepalive = setInterval(() => {
try {
res.write(': keepalive\n\n');
} catch {
clearInterval(keepalive);
subscribers.delete(onLine);
}
}, 15000);
req.on('close', () => {
clearInterval(keepalive);
subscribers.delete(onLine);
});
});
// ── VRAM proxy: GET /api/logs/vram ────────────────────────────────────
router.get('/vram', async (_req: Request, res: Response) => {
try {
const resp = await fetch(`${config.aceServer.url}/vram`, {
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) {
res.json({ used_mb: 0, total_mb: 0, free_mb: 0 });
return;
}
const data = await resp.json();
res.json(data);
} catch {
// ace-server not reachable or no CUDA
res.json({ used_mb: 0, total_mb: 0, free_mb: 0 });
}
});
// ── Loaded models proxy: GET /api/logs/models-loaded ──────────────────
// Lists the GPU modules currently resident in the engine (for the manual
// unload dropdown on the VRAM indicator).
router.get('/models-loaded', async (_req: Request, res: Response) => {
try {
const resp = await fetch(`${config.aceServer.url}/models/loaded`, {
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) { res.json({ loaded: [] }); return; }
res.json(await resp.json());
} catch {
res.json({ loaded: [] });
}
});
// ── Manual unload proxy: POST /api/logs/models-unload { label } ───────
router.post('/models-unload', async (req: Request, res: Response) => {
try {
const resp = await fetch(`${config.aceServer.url}/models/unload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label: req.body?.label }),
signal: AbortSignal.timeout(5000),
});
res.status(resp.status).json(await resp.json().catch(() => ({})));
} catch {
res.status(502).json({ error: 'ace-server unreachable' });
}
});
export default router;
+325
View File
@@ -0,0 +1,325 @@
// mastering.ts — Mastering routes for reference track management and mastering execution
//
// Endpoints:
// POST /api/mastering/upload-reference — Upload a reference audio file
// GET /api/mastering/references — List uploaded reference tracks
// DELETE /api/mastering/references/:name — Delete a reference track
// POST /api/mastering/run — Run mastering on an existing song
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import multer from 'multer';
import { config, getFFmpegPath } from '../config.js';
import { getUserId } from './auth.js';
import { getDb } from '../db/database.js';
const execFileAsync = promisify(execFile);
const router = Router();
// Reference tracks directory
const refsDir = path.join(config.data.dir, 'references');
fs.mkdirSync(refsDir, { recursive: true });
// Multer for reference file uploads
const upload = multer({
dest: refsDir,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB max
fileFilter: (_req, file, cb) => {
const allowed = ['audio/wav', 'audio/x-wav', 'audio/mpeg', 'audio/mp3', 'audio/flac',
'audio/ogg', 'audio/aac', 'audio/mp4', 'application/octet-stream'];
cb(null, true); // Accept all — we'll check extension
},
});
/** Resolve tool paths — both live in the same build directory as ace-server */
function getToolPath(name: string): string {
// Strip .exe on non-Windows (macOS/Linux binaries have no extension)
const binaryName = process.platform === 'win32' ? name : name.replace(/\.exe$/, '');
const aceExe = config.aceServer.exe;
if (aceExe) {
return path.join(path.dirname(aceExe), binaryName);
}
return path.resolve(process.cwd(), '..', 'engine', 'build', 'Release', binaryName);
}
/** Convert any audio format to WAV using mp3-codec (for MP3) or ffmpeg (for everything else) */
export async function convertToWav(inputPath: string, outputWavPath: string): Promise<void> {
const ext = path.extname(inputPath).toLowerCase();
if (ext === '.wav') {
// Already WAV — just copy
fs.copyFileSync(inputPath, outputWavPath);
return;
}
if (ext === '.mp3') {
// Use mp3-codec.exe (no ffmpeg dependency needed)
const codec = getToolPath('mp3-codec.exe');
if (fs.existsSync(codec)) {
console.log(`[Mastering] Converting MP3 → WAV via mp3-codec`);
await execFileAsync(codec, ['-i', inputPath, '-o', outputWavPath], { timeout: 60_000 });
return;
}
}
// For FLAC, OGG, AAC, etc. — use ffmpeg
const ffmpegPath = getFFmpegPath();
if (!ffmpegPath) {
throw new Error(
`Cannot convert ${ext} to WAV. ffmpeg not available — provide a WAV/MP3 file.`
);
}
console.log(`[Mastering] Converting ${ext} → WAV via ffmpeg`);
try {
await execFileAsync(ffmpegPath, [
'-y', '-i', inputPath,
'-ac', '2', '-ar', '48000', '-c:a', 'pcm_f32le',
outputWavPath,
], { timeout: 120_000 });
} catch {
throw new Error(
`ffmpeg conversion failed for ${ext}. Provide a WAV/MP3 file.`
);
}
}
/** Convert WAV back to MP3 using mp3-codec */
async function convertWavToMp3(wavPath: string, mp3Path: string, bitrate = 192): Promise<void> {
const codec = getToolPath('mp3-codec.exe');
if (!fs.existsSync(codec)) {
throw new Error(`mp3-codec.exe not found at ${codec}`);
}
console.log(`[Mastering] Encoding WAV → MP3 (${bitrate} kbps)`);
await execFileAsync(codec, ['-i', wavPath, '-o', mp3Path, '-b', String(bitrate)], { timeout: 60_000 });
}
/**
* Run mastering on any supported audio format.
*
* Pipeline:
* 1. Convert target + reference to temp WAV (if not already WAV)
* 2. Run mastering.exe (WAV → WAV)
* 3. If original target was MP3, re-encode mastered WAV to MP3
* 4. Clean up temp files
*/
export async function runMastering(targetPath: string, referencePath: string, outputPath: string): Promise<void> {
const exe = getToolPath('mastering.exe');
if (!fs.existsSync(exe)) {
throw new Error(`mastering.exe not found at ${exe}`);
}
if (!fs.existsSync(targetPath)) {
throw new Error(`Target file not found: ${targetPath}`);
}
if (!fs.existsSync(referencePath)) {
throw new Error(`Reference file not found: ${referencePath}`);
}
const targetExt = path.extname(targetPath).toLowerCase();
const outputExt = path.extname(outputPath).toLowerCase();
const tempDir = path.join(config.data.dir, 'mastering_temp');
fs.mkdirSync(tempDir, { recursive: true });
const tempId = Date.now().toString(36);
const tempTargetWav = path.join(tempDir, `target_${tempId}.wav`);
const tempRefWav = path.join(tempDir, `ref_${tempId}.wav`);
const tempOutputWav = path.join(tempDir, `mastered_${tempId}.wav`);
const tempFiles = [tempTargetWav, tempRefWav, tempOutputWav];
try {
// Step 1: Convert inputs to WAV
console.log(`[Mastering] Preparing inputs...`);
await convertToWav(targetPath, tempTargetWav);
await convertToWav(referencePath, tempRefWav);
// Step 2: Run mastering.exe on WAV files
console.log(`[Mastering] Running mastering.exe`);
console.log(`[Mastering] target: ${targetPath} (${targetExt})`);
console.log(`[Mastering] reference: ${referencePath}`);
console.log(`[Mastering] output: ${outputPath} (${outputExt})`);
const { stderr } = await execFileAsync(exe, [
'--target', tempTargetWav,
'--reference', tempRefWav,
'--output', tempOutputWav,
'--pcm32f',
], { timeout: 120_000 });
if (stderr) {
for (const line of stderr.split('\n')) {
if (line.trim()) console.log(`[Mastering] ${line.trim()}`);
}
}
// Step 3: Convert output to final format
if (outputExt === '.mp3') {
await convertWavToMp3(tempOutputWav, outputPath);
} else {
// WAV output — just move
fs.copyFileSync(tempOutputWav, outputPath);
}
console.log(`[Mastering] Done → ${outputPath}`);
} finally {
// Step 4: Clean up temp files
for (const f of tempFiles) {
try { if (fs.existsSync(f)) fs.unlinkSync(f); } catch {}
}
try { fs.rmdirSync(tempDir); } catch {} // Remove if empty
}
}
// ── POST /upload-reference ──────────────────────────────────
router.post('/upload-reference', upload.single('file'), async (req, res) => {
try {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const file = req.file;
if (!file) { res.status(400).json({ error: 'No file uploaded' }); return; }
// Sanitize original filename
const safeName = file.originalname.replace(/[^a-zA-Z0-9._-]/g, '_');
const ext = path.extname(safeName).toLowerCase();
// Engine only supports WAV and MP3 — convert anything else to WAV on upload
const needsConvert = ext !== '.wav' && ext !== '.mp3';
const baseName = path.basename(safeName, ext);
const targetName = needsConvert ? `${baseName}.wav` : safeName;
// Avoid overwriting existing files
let finalName = targetName;
let finalPath = path.join(refsDir, finalName);
if (fs.existsSync(finalPath)) {
const targetExt = path.extname(targetName);
const targetBase = path.basename(targetName, targetExt);
finalName = `${targetBase}_${Date.now()}${targetExt}`;
finalPath = path.join(refsDir, finalName);
}
if (needsConvert) {
console.log(`[Mastering] Converting ${ext} → WAV: ${safeName}`);
await convertToWav(file.path, finalPath);
// Clean up the original temp file
try { fs.unlinkSync(file.path); } catch {}
} else {
fs.renameSync(file.path, finalPath);
}
console.log(`[Mastering] Reference uploaded: ${finalName}`);
res.json({
name: finalName,
path: finalPath,
url: `/references/${finalName}`,
});
} catch (err: any) {
console.error(`[Mastering] Upload failed:`, err.message);
// Clean up temp file on error
if (req.file?.path) {
try { fs.unlinkSync(req.file.path); } catch {}
}
res.status(500).json({ error: err.message || 'Upload failed' });
}
});
// ── GET /references ─────────────────────────────────────────
router.get('/references', (_req, res) => {
try {
const files = fs.readdirSync(refsDir)
.filter(f => !f.startsWith('.'))
.map(f => ({
name: f,
path: path.join(refsDir, f),
size: fs.statSync(path.join(refsDir, f)).size,
url: `/references/${f}`,
}));
res.json({ references: files });
} catch {
res.json({ references: [] });
}
});
// ── DELETE /references/:name ────────────────────────────────
router.delete('/references/:name', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const filePath = path.join(refsDir, req.params.name);
if (!fs.existsSync(filePath)) {
res.status(404).json({ error: 'Reference not found' });
return;
}
// Security: ensure the path is within refsDir
if (!filePath.startsWith(refsDir)) {
res.status(400).json({ error: 'Invalid path' });
return;
}
fs.unlinkSync(filePath);
console.log(`[Mastering] Reference deleted: ${req.params.name}`);
res.json({ ok: true });
});
// ── POST /run — Run mastering on existing song ──────────────
router.post('/run', async (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const { songId, referenceName } = req.body;
if (!songId || !referenceName) {
res.status(400).json({ error: 'songId and referenceName are required' });
return;
}
try {
// Get song from DB
const song = getDb().prepare('SELECT * FROM songs WHERE id = ?').get(songId) as any;
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
// Resolve file paths
const audioUrl = song.audio_url; // e.g. /audio/uuid.wav
const audioFilename = path.basename(audioUrl);
const targetPath = path.join(config.data.audioDir, audioFilename);
const referencePath = path.join(refsDir, referenceName);
if (!fs.existsSync(targetPath)) {
res.status(404).json({ error: `Audio file not found: ${audioFilename}` });
return;
}
if (!fs.existsSync(referencePath)) {
res.status(404).json({ error: `Reference not found: ${referenceName}` });
return;
}
// Output path: same name with _mastered suffix
const ext = path.extname(audioFilename);
const base = path.basename(audioFilename, ext);
const masteredFilename = `${base}_mastered${ext}`;
const masteredPath = path.join(config.data.audioDir, masteredFilename);
const masteredUrl = `/audio/${masteredFilename}`;
// Run mastering
await runMastering(targetPath, referencePath, masteredPath);
// Update DB with mastered URL
getDb().prepare('UPDATE songs SET mastered_audio_url = ? WHERE id = ?')
.run(masteredUrl, songId);
console.log(`[Mastering] Song ${songId} mastered → ${masteredUrl}`);
res.json({
ok: true,
masteredUrl,
songId,
});
} catch (err: any) {
console.error(`[Mastering] Failed:`, err.message);
res.status(500).json({ error: err.message });
}
});
export default router;
+440
View File
@@ -0,0 +1,440 @@
// midiStudio.ts — MIDI Studio audio→MIDI transcription route
//
// Transcribes library tracks to multi-instrument MIDI via the NATIVE ace-midi
// engine binary (MuScriptor GGML port — docs/plans/muscriptor-cpp-port.md).
// ace-midi reads WAV/MP3 directly, streams note events as JSONL on stdout
// (relayed live over SSE for the piano roll), and writes the .mid.
// Weights are gated on Hugging Face; the download endpoints fetch them with
// the user's stored read token. Results persist to data/midi/<jobId>/.
//
// Mounts at: /api/midi-studio
// Routes:
// GET /api/midi-studio/status — engine/models/token status
// POST /api/midi-studio/hf-token — save/clear HF token
// POST /api/midi-studio/models/:size/download — download gated weights
// POST /api/midi-studio/transcribe — queue a transcription job
// GET /api/midi-studio/jobs — list jobs (disk + active)
// GET /api/midi-studio/:jobId/progress — poll a job
// GET /api/midi-studio/:jobId/stream — SSE: live note events
// GET /api/midi-studio/:jobId/notes — parsed notes (piano roll)
// GET /api/midi-studio/:jobId/file — download the .mid
// DELETE /api/midi-studio/:jobId — cancel/delete a job
import { Router, Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
import { spawn, ChildProcess } from 'child_process';
import { randomUUID } from 'crypto';
import readline from 'readline';
import { config } from '../config.js';
import {
getHfToken, setHfToken, looksLikeGatedError, aceMidiExe,
modelDir, isModelDownloaded, startModelDownload, getModelStates,
MUSCRIPTOR_MODELS, type MuscriptorModel,
} from '../services/muscriptor.js';
import { parseMidiFile } from '../services/midiParser.js';
const router = Router();
const midiBaseDir = path.join(config.data.dir, 'midi');
fs.mkdirSync(midiBaseDir, { recursive: true });
const TRANSCRIBE_TIMEOUT_MS = 60 * 60 * 1000;
interface MidiJob {
id: string;
status: 'queued' | 'transcribing' | 'done' | 'failed' | 'cancelled';
sourceAudioUrl: string;
sourceFileName: string;
songId?: string;
model: MuscriptorModel;
// live event stream (JSONL objects from ace-midi, replayed to SSE clients)
events: any[];
chunksDone: number;
chunksTotal: number;
noteCount: number;
error?: string;
gated?: boolean;
createdAt: number;
child?: ChildProcess;
listeners: Set<Response>;
}
const jobs = new Map<string, MidiJob>();
let queueTail: Promise<void> = Promise.resolve();
/** Resolve a URL-style audio path to an absolute filesystem path */
function resolveAudioPath(audioUrl: string): string {
if (audioUrl.startsWith('/references/')) {
return path.join(config.data.dir, 'references', path.basename(audioUrl));
}
if (audioUrl.startsWith('/audio/')) {
return path.join(config.data.audioDir, path.basename(audioUrl));
}
if (path.isAbsolute(audioUrl)) {
return audioUrl;
}
return path.join(config.data.dir, 'references', path.basename(audioUrl));
}
function jobDir(id: string): string { return path.join(midiBaseDir, id); }
function midPath(id: string): string { return path.join(jobDir(id), 'out.mid'); }
function broadcast(job: MidiJob, ev: any): void {
const line = `data: ${JSON.stringify(ev)}\n\n`;
for (const res of job.listeners) {
try { res.write(line); } catch { /* client gone; cleanup on close */ }
}
}
function pushEvent(job: MidiJob, ev: any): void {
job.events.push(ev);
if (ev.type === 'progress') {
job.chunksDone = ev.completed ?? job.chunksDone;
job.chunksTotal = ev.total ?? job.chunksTotal;
} else if (ev.type === 'note_start') {
job.noteCount++;
}
broadcast(job, ev);
}
function endStream(job: MidiJob): void {
broadcast(job, { type: 'status', status: job.status, error: job.error, noteCount: job.noteCount });
for (const res of job.listeners) {
try { res.end(); } catch { /* ignore */ }
}
job.listeners.clear();
}
async function runTranscription(job: MidiJob): Promise<void> {
if ((job.status as string) === 'cancelled') return;
const dir = jobDir(job.id);
fs.mkdirSync(dir, { recursive: true });
try {
const exe = aceMidiExe();
if (!exe) throw new Error('ace-midi engine binary not found — rebuild the engine or reinstall');
if (!isModelDownloaded(job.model)) throw new Error(`Model '${job.model}' is not downloaded`);
const srcPath = resolveAudioPath(job.sourceAudioUrl);
if (!fs.existsSync(srcPath)) throw new Error(`Source audio not found: ${srcPath}`);
job.status = 'transcribing';
console.log(`[MidiStudio] Job ${job.id}: ace-midi ${path.basename(srcPath)} (model=${job.model})`);
const child = spawn(exe, [
'--model', modelDir(job.model),
'--transcribe', srcPath,
'--out', midPath(job.id),
'--jsonl',
], { windowsHide: true });
job.child = child;
const stderrTail: string[] = [];
child.stderr?.on('data', (buf: Buffer) => {
for (const raw of buf.toString('utf-8').split(/[\r\n]+/)) {
const line = raw.trim();
if (!line) continue;
stderrTail.push(line);
if (stderrTail.length > 30) stderrTail.shift();
}
});
const rl = readline.createInterface({ input: child.stdout! });
rl.on('line', (line) => {
try { pushEvent(job, JSON.parse(line)); } catch { /* non-JSON noise */ }
});
const killer = setTimeout(() => {
console.error(`[MidiStudio] Job ${job.id}: timed out — killing ace-midi`);
child.kill();
}, TRANSCRIBE_TIMEOUT_MS);
const code: number | null = await new Promise((resolve, reject) => {
child.on('error', (err) => reject(new Error(`Failed to launch ace-midi: ${err.message}`)));
child.on('close', (c, signal) => resolve(signal ? null : c));
}).finally(() => {
clearTimeout(killer);
job.child = undefined;
}) as number | null;
if ((job.status as string) === 'cancelled') return;
if (code !== 0) {
throw new Error(`ace-midi exited with code ${code}: ${stderrTail.slice(-5).join(' | ')}`);
}
if (!fs.existsSync(midPath(job.id))) throw new Error('ace-midi finished but produced no MIDI file');
// Parse for the piano-roll preview (preview failure is non-fatal)
let noteCount = job.noteCount;
let durationSec = 0;
try {
const parsed = parseMidiFile(fs.readFileSync(midPath(job.id)));
noteCount = parsed.noteCount;
durationSec = parsed.durationSec;
fs.writeFileSync(path.join(dir, 'notes.json'), JSON.stringify(parsed));
} catch (err: any) {
console.warn(`[MidiStudio] Job ${job.id}: MIDI parse for preview failed (${err.message})`);
}
fs.writeFileSync(path.join(dir, '_meta.json'), JSON.stringify({
id: job.id,
sourceAudioUrl: job.sourceAudioUrl,
sourceFileName: job.sourceFileName,
songId: job.songId,
model: job.model,
noteCount,
durationSec,
createdAt: new Date(job.createdAt).toISOString(),
}, null, 2));
job.status = 'done';
console.log(`[MidiStudio] Job ${job.id}: done (${noteCount} notes, ${durationSec.toFixed(1)}s)`);
} catch (err: any) {
if ((job.status as string) !== 'cancelled') {
job.status = 'failed';
job.error = err.message || 'Unknown error';
job.gated = looksLikeGatedError(job.error || '');
console.error(`[MidiStudio] Job ${job.id}: FAILED — ${job.error}`);
}
} finally {
endStream(job);
}
}
// ── Routes ───────────────────────────────────────────────────────────────
/** GET /status — engine, models, token */
router.get('/status', (_req: Request, res: Response) => {
try {
res.json({
engineAvailable: aceMidiExe() !== null,
hfTokenSet: getHfToken() !== null,
models: getModelStates(),
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/** POST /hf-token — store (or clear, with empty string) the HF read token */
router.post('/hf-token', (req: Request, res: Response) => {
try {
const token = typeof req.body?.token === 'string' ? req.body.token : '';
setHfToken(token);
console.log(`[MidiStudio] HF token ${token.trim() ? 'saved' : 'cleared'}`);
res.json({ ok: true, hfTokenSet: !!token.trim() });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/** POST /models/:size/download — begin downloading gated weights */
router.post('/models/:size/download', (req: Request, res: Response) => {
const size = req.params.size as MuscriptorModel;
if (!MUSCRIPTOR_MODELS.includes(size)) {
res.status(400).json({ error: `Unknown model '${size}'` });
return;
}
const r = startModelDownload(size);
if (!r.started) {
res.status(409).json({ error: r.error });
return;
}
res.json({ ok: true });
});
/** POST /transcribe — queue a transcription job */
router.post('/transcribe', (req: Request, res: Response) => {
const { sourceAudioUrl, sourceFileName, songId, model } = req.body || {};
if (!sourceAudioUrl || typeof sourceAudioUrl !== 'string') {
res.status(400).json({ error: 'sourceAudioUrl is required' });
return;
}
const m: MuscriptorModel = MUSCRIPTOR_MODELS.includes(model) ? model : 'small';
if (!aceMidiExe()) {
res.status(503).json({ error: 'ace-midi engine binary not found' });
return;
}
if (!isModelDownloaded(m)) {
res.status(409).json({ error: `Model '${m}' is not downloaded — download it first` });
return;
}
const job: MidiJob = {
id: randomUUID(),
status: 'queued',
sourceAudioUrl,
sourceFileName: sourceFileName || path.basename(sourceAudioUrl),
songId: songId || undefined,
model: m,
events: [],
chunksDone: 0,
chunksTotal: 0,
noteCount: 0,
createdAt: Date.now(),
listeners: new Set(),
};
jobs.set(job.id, job);
queueTail = queueTail.then(() => runTranscription(job));
console.log(`[MidiStudio] Job ${job.id} queued: ${job.sourceFileName} (model=${m})`);
res.json({ id: job.id });
});
/** GET /jobs — completed jobs from disk + in-flight jobs */
router.get('/jobs', (_req: Request, res: Response) => {
try {
const summaries: any[] = [];
const onDisk = new Set<string>();
if (fs.existsSync(midiBaseDir)) {
for (const entry of fs.readdirSync(midiBaseDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const metaPath = path.join(midiBaseDir, entry.name, '_meta.json');
if (!fs.existsSync(metaPath)) continue;
try {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
onDisk.add(meta.id || entry.name);
summaries.push({
id: meta.id || entry.name,
status: 'done',
sourceFileName: meta.sourceFileName || 'unknown',
sourceAudioUrl: meta.sourceAudioUrl,
songId: meta.songId,
model: meta.model || 'small',
noteCount: meta.noteCount || 0,
durationSec: meta.durationSec || 0,
createdAt: meta.createdAt || '',
});
} catch { /* skip corrupted meta */ }
}
}
for (const [, job] of jobs) {
if (job.status === 'done' || onDisk.has(job.id)) continue;
summaries.push({
id: job.id,
status: job.status,
sourceFileName: job.sourceFileName,
sourceAudioUrl: job.sourceAudioUrl,
songId: job.songId,
model: job.model,
noteCount: job.noteCount,
durationSec: 0,
chunksDone: job.chunksDone,
chunksTotal: job.chunksTotal,
createdAt: new Date(job.createdAt).toISOString(),
error: job.error,
gated: job.gated,
});
}
summaries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json(summaries);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/** GET /:jobId/progress — poll job progress */
router.get('/:jobId/progress', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const job = jobs.get(jobId);
if (!job) {
if (fs.existsSync(path.join(jobDir(jobId), '_meta.json'))) {
res.json({ status: 'done' });
return;
}
res.status(404).json({ error: 'Job not found' });
return;
}
res.json({
status: job.status,
chunksDone: job.chunksDone,
chunksTotal: job.chunksTotal,
noteCount: job.noteCount,
error: job.error,
gated: job.gated,
});
});
/** GET /:jobId/stream — SSE: replay buffered events then tail live ones */
router.get('/:jobId/stream', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const job = jobs.get(jobId);
if (!job) {
res.status(404).json({ error: 'Job not found or already finished (use /notes)' });
return;
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.flushHeaders();
for (const ev of job.events) {
res.write(`data: ${JSON.stringify(ev)}\n\n`);
}
if (job.status === 'done' || job.status === 'failed' || job.status === 'cancelled') {
res.write(`data: ${JSON.stringify({ type: 'status', status: job.status, error: job.error, noteCount: job.noteCount })}\n\n`);
res.end();
return;
}
job.listeners.add(res);
req.on('close', () => job.listeners.delete(res));
});
/** GET /:jobId/notes — parsed note data for the piano roll */
router.get('/:jobId/notes', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const notesPath = path.join(jobDir(jobId), 'notes.json');
try {
if (fs.existsSync(notesPath)) {
res.setHeader('Content-Type', 'application/json');
fs.createReadStream(notesPath).pipe(res);
return;
}
if (fs.existsSync(midPath(jobId))) {
const parsed = parseMidiFile(fs.readFileSync(midPath(jobId)));
fs.writeFileSync(notesPath, JSON.stringify(parsed));
res.json(parsed);
return;
}
res.status(404).json({ error: 'No MIDI data for this job' });
} catch (err: any) {
res.status(500).json({ error: `MIDI parse failed: ${err.message}` });
}
});
/** GET /:jobId/file — download the .mid */
router.get('/:jobId/file', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const p = midPath(jobId);
if (!fs.existsSync(p)) {
res.status(404).json({ error: 'MIDI file not found' });
return;
}
let base = 'transcription';
try {
const meta = JSON.parse(fs.readFileSync(path.join(jobDir(jobId), '_meta.json'), 'utf-8'));
base = (meta.sourceFileName || base).replace(/\.[^.]+$/, '').replace(/[^\w\s.-]/g, '_');
} catch { /* keep default */ }
res.download(p, `${base}.mid`);
});
/** DELETE /:jobId — cancel a running job and/or delete its files */
router.delete('/:jobId', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const job = jobs.get(jobId);
if (job && (job.status === 'queued' || job.status === 'transcribing')) {
job.status = 'cancelled';
job.child?.kill();
endStream(job);
console.log(`[MidiStudio] Job ${jobId} cancelled`);
}
jobs.delete(jobId);
const dir = jobDir(jobId);
if (fs.existsSync(dir)) {
fs.rmSync(dir, { recursive: true, force: true });
console.log(`[MidiStudio] Deleted job ${jobId}`);
}
res.json({ ok: true });
});
export default router;
+102
View File
@@ -0,0 +1,102 @@
// modelManager.ts — Model download and management API routes
//
// GET /api/model-manager/registry — registry + installed status
// POST /api/model-manager/download — start download { fileId }
// GET /api/model-manager/downloads — SSE stream of download progress
// POST /api/model-manager/download/:id/cancel — cancel download
// POST /api/model-manager/download/:id/resume — resume download
// DELETE /api/model-manager/files/:filename — delete installed model
import { Router } from 'express';
import { modelDownloadService } from '../services/modelDownloadService.js';
const router = Router();
// GET /api/model-manager/registry
router.get('/registry', (_req, res) => {
try {
const data = modelDownloadService.getRegistry();
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/model-manager/download
// Body: { fileId, hfToken? } — hfToken is an optional Hugging Face token
// forwarded as `Authorization: Bearer <token>` to huggingface.co (needed
// only for gated repos; omitted/empty = anonymous download).
router.post('/download', (req, res) => {
try {
const { fileId, hfToken } = req.body;
if (!fileId) {
res.status(400).json({ error: 'fileId is required' });
return;
}
const jobId = modelDownloadService.startDownload(
fileId,
typeof hfToken === 'string' ? hfToken : undefined,
);
res.json({ jobId });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// GET /api/model-manager/downloads — SSE stream
router.get('/downloads', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
// Send initial state
const sendUpdate = () => {
const jobs = modelDownloadService.getJobs();
res.write(`data: ${JSON.stringify({ jobs })}\n\n`);
};
sendUpdate();
// Send updates on progress events
const onProgress = () => sendUpdate();
modelDownloadService.on('progress', onProgress);
// Also send periodic updates (in case events are missed)
const interval = setInterval(sendUpdate, 1000);
// Cleanup on disconnect
req.on('close', () => {
modelDownloadService.off('progress', onProgress);
clearInterval(interval);
});
});
// POST /api/model-manager/download/:jobId/cancel
router.post('/download/:jobId/cancel', (req, res) => {
const ok = modelDownloadService.cancelDownload(req.params.jobId);
res.json({ ok });
});
// POST /api/model-manager/download/:jobId/resume
router.post('/download/:jobId/resume', (req, res) => {
try {
const jobId = modelDownloadService.resumeDownload(req.params.jobId);
res.json({ jobId });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
// DELETE /api/model-manager/files/:filename
router.delete('/files/:filename', (req, res) => {
try {
const ok = modelDownloadService.deleteFile(req.params.filename);
res.json({ ok });
} catch (err: any) {
res.status(400).json({ error: err.message });
}
});
export default router;
+133
View File
@@ -0,0 +1,133 @@
// models.ts — Model listing route
//
// Proxies /props from ace-server and returns available models + adapters.
// Also detects PP-VAE availability from the models directory.
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { aceClient } from '../services/aceClient.js';
import { config } from '../config.js';
const router = Router();
// GET /api/models — list available models from ace-server
router.get('/', async (_req, res) => {
try {
const props = await aceClient.props();
res.json({
models: props.models,
adapters: props.adapters,
lmAdapters: props.lm_adapters ?? [],
config: props.cli,
defaults: props.default,
});
} catch (err: any) {
// ace-server is down (e.g. no models installed yet).
// Return a valid empty response so the UI stays alive and can show
// the model manager / download UI instead of crashing to a blank page.
res.json({
models: { dit: [], lm: [], vae: [], understand: [] },
adapters: [],
lmAdapters: [],
config: {},
defaults: {},
aceServerDown: true,
error: err.message,
});
}
});
// GET /api/models/health — check ace-server connectivity
router.get('/health', async (_req, res) => {
const reachable = await aceClient.isReachable();
res.json({
aceServer: reachable ? 'connected' : 'disconnected',
});
});
// GET /api/models/pp-vae — check PP-VAE model availability
// Scans the models directory for pp-vae-*.gguf files.
// Returns { available: true, models: ["pp-vae-F32.gguf", ...] } or { available: false, models: [] }
router.get('/pp-vae', (_req, res) => {
try {
const modelsDir = config.aceServer.models;
let ppVaeModels: string[] = [];
if (fs.existsSync(modelsDir)) {
ppVaeModels = fs.readdirSync(modelsDir)
.filter(f => f.startsWith('pp-vae') && f.endsWith('.gguf'));
}
res.json({
available: ppVaeModels.length > 0,
models: ppVaeModels,
});
} catch (err: any) {
res.json({ available: false, models: [], error: err.message });
}
});
// GET /api/models/stablestep — check StableStep (SA3) model availability
// Two engine backends exist:
// onnx — <modelsDir>/onnx/sa3/ ONNX set (sa3-dit.onnx + companions), runs
// via ONNX Runtime / TensorRT (NVIDIA only)
// gguf — 4 GGUF files at the models dir root, runs via GGML
// (CUDA / Vulkan / CPU)
// tokenizer.json in onnx/sa3/ is required for BOTH backends (Node tokenizes).
// Returns { available, backends: { onnx, gguf }, files } — files lists what
// is actually present in the sa3 directory.
const SA3_GGUF_FILES = [
'sa3-dit-BF16.gguf',
'sa3-same-enc-F16.gguf',
'sa3-same-dec-F16.gguf',
'sa3-text-enc-BF16.gguf',
];
router.get('/stablestep', (_req, res) => {
try {
const modelsDir = config.aceServer.models;
const sa3Dir = path.join(modelsDir, 'onnx', 'sa3');
let sa3Files: string[] = [];
if (fs.existsSync(sa3Dir)) {
sa3Files = fs.readdirSync(sa3Dir).filter(f => !f.endsWith('.part'));
}
const tokenizerOk = fs.existsSync(path.join(sa3Dir, 'tokenizer.json'));
const onnx = tokenizerOk && fs.existsSync(path.join(sa3Dir, 'sa3-dit.onnx'));
const gguf = tokenizerOk &&
SA3_GGUF_FILES.every(f => fs.existsSync(path.join(modelsDir, f)));
res.json({
available: onnx || gguf,
backends: { onnx, gguf },
files: sa3Files,
});
} catch (err: any) {
res.json({
available: false,
backends: { onnx: false, gguf: false },
files: [],
error: err.message,
});
}
});
// GET /api/models/stablestep/adapters — list StableStep DoRA adapter GGUFs
// (<modelsDir>/sa3-adapters/*.gguf). These are merged into the SA3 DiT at
// refine time with per-adapter strength; GGUF backend only.
router.get('/stablestep/adapters', (_req, res) => {
try {
const dir = path.join(config.aceServer.models, 'sa3-adapters');
let adapters: Array<{ name: string; sizeMb: number }> = [];
if (fs.existsSync(dir)) {
adapters = fs.readdirSync(dir)
.filter(f => f.endsWith('.gguf') && !f.endsWith('.part'))
.map(f => ({
name: f.slice(0, -'.gguf'.length),
sizeMb: Math.round(fs.statSync(path.join(dir, f)).size / 1e6),
}))
.sort((a, b) => a.name.localeCompare(b.name));
}
res.json({ adapters });
} catch (err: any) {
res.json({ adapters: [], error: err.message });
}
});
export default router;
+37
View File
@@ -0,0 +1,37 @@
// routes/plugins.ts — Proxy to ace-server GET /plugins
//
// Exposes the Lua plugin registry (solvers, schedulers, guidance modes)
// with their metadata and parameter schemas to the frontend.
import { Router } from 'express';
import { aceClient } from '../services/aceClient.js';
const router = Router();
// GET /api/plugins — fetch plugin registry from ace-server
// Cached for 60s to avoid hammering the engine on every UI render.
let cache: { data: unknown; ts: number } | null = null;
const CACHE_TTL = 60_000;
router.get('/', async (_req, res) => {
try {
if (cache && Date.now() - cache.ts < CACHE_TTL) {
return res.json(cache.data);
}
const registry = await aceClient.plugins();
cache = { data: registry, ts: Date.now() };
res.json(registry);
} catch (err) {
console.error('[plugins] Failed to fetch registry:', err);
// Return empty registry on error so UI still works with fallback lists
res.json({ solvers: [], schedulers: [], guidance: [] });
}
});
// POST /api/plugins/reload — clear cache to force re-fetch
router.post('/reload', (_req, res) => {
cache = null;
res.json({ ok: true });
});
export default router;
+164
View File
@@ -0,0 +1,164 @@
// profiles.ts — Parameter profile REST API
//
// A profile is a named snapshot of every generation parameter (the UI's
// 'hot-step-preset' JSON — same format as the preset export file), stored
// server-side so the user can switch configs in-app without juggling files.
// Files are plain preset JSON wrapped with { name, saved_at, data }, so an
// exported preset can be dropped in by hand and vice versa.
//
// Mounts at: /api/profiles
// Routes:
// GET /api/profiles — list all profiles (full data inline)
// GET /api/profiles/:name — load one profile
// POST /api/profiles — save/overwrite { name, data }
// DELETE /api/profiles/:name — delete profile
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { config } from '../config.js';
const router = Router();
function profilesDir(): string {
const dir = path.join(config.data.dir, 'profiles');
fs.mkdirSync(dir, { recursive: true });
return dir;
}
// Sanitize name — strip path separators and shell-hostile chars (same regex as seeds.ts)
function safeName(name: string): string {
return name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 100).trim();
}
function profilePath(name: string): string {
return path.join(profilesDir(), `${safeName(name)}.json`);
}
interface ProfileFile {
name: string;
saved_at: string;
data: Record<string, unknown>;
}
function readProfile(name: string): ProfileFile | null {
const p = profilePath(name);
if (!fs.existsSync(p)) return null;
try {
const raw = JSON.parse(fs.readFileSync(p, 'utf8'));
// Tolerate a bare preset JSON dropped into the folder by hand
if (raw && typeof raw === 'object' && raw.data === undefined && raw._format === 'hot-step-preset') {
return { name, saved_at: '', data: raw };
}
return raw;
} catch { return null; }
}
// GET /api/profiles — list all with data inline (profiles are a few KB each)
router.get('/', (_req, res) => {
try {
const names = fs.readdirSync(profilesDir())
.filter(f => f.endsWith('.json'))
.map(f => f.slice(0, -5))
.sort((a, b) => a.localeCompare(b));
const profiles = names
.map(name => readProfile(name))
.filter((p): p is ProfileFile => p !== null && !!p.data);
res.json({ profiles, count: profiles.length });
} catch (err: any) {
console.error('[Profiles] list failed:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/profiles/:name — load one
router.get('/:name', (req, res) => {
const profile = readProfile(req.params.name);
if (!profile) {
res.status(404).json({ error: `profile '${req.params.name}' not found` });
return;
}
res.json(profile);
});
// POST /api/profiles — save/overwrite { name, data }
router.post('/', (req, res) => {
const { name, data } = req.body ?? {};
if (!name || typeof name !== 'string' || !safeName(name)) {
res.status(400).json({ error: 'name is required' });
return;
}
if (!data || typeof data !== 'object' || Array.isArray(data)) {
res.status(400).json({ error: 'data must be an object' });
return;
}
const profile: ProfileFile = {
name: safeName(name),
saved_at: new Date().toISOString(),
data,
};
try {
fs.writeFileSync(profilePath(name), JSON.stringify(profile, null, 2), 'utf8');
console.log(`[Profiles] Saved '${profile.name}'`);
res.json({ ok: true, name: profile.name, saved_at: profile.saved_at });
} catch (err: any) {
console.error('[Profiles] save failed:', err.message);
res.status(500).json({ error: 'failed to write profile' });
}
});
// PATCH /api/profiles/:name — rename { newName } (data unchanged)
router.patch('/:name', (req, res) => {
const oldName = req.params.name;
const { newName } = req.body ?? {};
if (!newName || typeof newName !== 'string' || !safeName(newName)) {
res.status(400).json({ error: 'newName is required' });
return;
}
const src = profilePath(oldName);
if (!fs.existsSync(src)) {
res.status(404).json({ error: `profile '${oldName}' not found` });
return;
}
const dstName = safeName(newName);
const dst = profilePath(newName);
const sameFile = path.resolve(dst) === path.resolve(src);
if (fs.existsSync(dst) && !sameFile) {
res.status(409).json({ error: `a profile named '${dstName}' already exists` });
return;
}
const profile = readProfile(oldName);
if (!profile) {
res.status(404).json({ error: `profile '${oldName}' not found` });
return;
}
profile.name = dstName;
try {
fs.writeFileSync(dst, JSON.stringify(profile, null, 2), 'utf8');
if (!sameFile) fs.unlinkSync(src);
console.log(`[Profiles] Renamed '${oldName}' -> '${dstName}'`);
res.json({ ok: true, name: dstName });
} catch (err: any) {
console.error('[Profiles] rename failed:', err.message);
res.status(500).json({ error: 'failed to rename profile' });
}
});
// DELETE /api/profiles/:name — delete
router.delete('/:name', (req, res) => {
const p = profilePath(req.params.name);
if (!fs.existsSync(p)) {
res.status(404).json({ error: `profile '${req.params.name}' not found` });
return;
}
try {
fs.unlinkSync(p);
console.log(`[Profiles] Deleted '${req.params.name}'`);
res.json({ ok: true, deleted: req.params.name });
} catch (err: any) {
console.error('[Profiles] delete failed:', err.message);
res.status(500).json({ error: 'delete failed' });
}
});
export default router;
+238
View File
@@ -0,0 +1,238 @@
// seeds.ts — Seed management REST API
// MDMAchine / A&E Concepts 2026
// GPL v3 — safe for public repo
//
// File format is intentionally identical to MD_Nodes/SeedSaver (ComfyUI):
// { seed: number, saved_at: string, metadata: { description?, tags?, ... } }
// Drop your existing ComfyUI seeds/ directory into the output dir and they load immediately.
//
// Mounts at: /api/seeds
// Routes:
// GET /api/seeds — list all saved seeds (flat, default dir)
// GET /api/seeds/:name — load one seed by name
// POST /api/seeds — save seed { name, seed, description?, tags? }
// DELETE /api/seeds/:name — delete seed
// GET /api/seeds/favorites — list favorites
// POST /api/seeds/:name/favorite — toggle favorite
// GET /api/seeds/random — return a random saved seed
//
// Subdirectory support: pass ?subdir=mydir to scope to a subfolder.
// The UI currently uses the flat default (no subdir param).
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { config } from '../config.js';
const router = Router();
// ─── Storage root ─────────────────────────────────────────────────────────────
function seedsRoot(): string {
return path.join(config.data.dir, 'seeds');
}
function seedsDir(subdir = ''): string {
const base = seedsRoot();
const dir = subdir ? path.join(base, subdir) : base;
fs.mkdirSync(dir, { recursive: true });
return dir;
}
function seedPath(name: string, subdir = ''): string {
// Sanitize name — strip path separators and shell-hostile chars
const safe = name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').slice(0, 200);
return path.join(seedsDir(subdir), `${safe}.json`);
}
function favoritesPath(): string {
fs.mkdirSync(seedsRoot(), { recursive: true });
return path.join(seedsRoot(), '_favorites.json');
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function loadFavorites(): string[] {
try {
const raw = fs.readFileSync(favoritesPath(), 'utf8');
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch { return []; }
}
function saveFavorites(list: string[]): void {
try { fs.writeFileSync(favoritesPath(), JSON.stringify(list, null, 2), 'utf8'); } catch {}
}
function listSeedNames(subdir = ''): string[] {
const dir = seedsDir(subdir);
try {
return fs.readdirSync(dir)
.filter(f => f.endsWith('.json') && !f.startsWith('_'))
.map(f => f.slice(0, -5)) // strip .json
.sort((a, b) => a.localeCompare(b));
} catch { return []; }
}
function readSeedFile(name: string, subdir = ''): { seed: number; saved_at: string; metadata: Record<string, unknown> } | null {
const p = seedPath(name, subdir);
if (!fs.existsSync(p)) return null;
try {
const raw = fs.readFileSync(p, 'utf8');
return JSON.parse(raw);
} catch { return null; }
}
function writeSeedFile(
name: string,
seed: number,
subdir = '',
meta: Record<string, unknown> = {},
): boolean {
const p = seedPath(name, subdir);
const data = {
seed,
saved_at: new Date().toISOString(),
metadata: meta,
};
try {
fs.writeFileSync(p, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (e) {
console.error('[seeds] write failed:', e);
return false;
}
}
// ─── Routes ───────────────────────────────────────────────────────────────────
// GET /api/seeds — list all seeds with metadata inline (for the drawer list)
router.get('/', (req, res) => {
const subdir = String(req.query.subdir || '');
const favorites = loadFavorites();
const names = listSeedNames(subdir);
const seeds = names.map(name => {
const data = readSeedFile(name, subdir);
return {
name,
seed: data?.seed ?? null,
saved_at: data?.saved_at ?? null,
description: (data?.metadata?.description as string) || '',
tags: (data?.metadata?.tags as string[]) || [],
favorite: favorites.includes(name),
};
});
res.json({ seeds, count: seeds.length });
});
// GET /api/seeds/favorites — list favorite seed names
router.get('/favorites', (_req, res) => {
const favorites = loadFavorites();
const seeds = favorites
.map(name => {
const data = readSeedFile(name);
return data ? { name, seed: data.seed, saved_at: data.saved_at, favorite: true } : null;
})
.filter(Boolean);
res.json({ seeds });
});
// GET /api/seeds/random — return a random saved seed
router.get('/random', (req, res) => {
const subdir = String(req.query.subdir || '');
const names = listSeedNames(subdir);
if (names.length === 0) return res.status(404).json({ error: 'no seeds saved' });
const name = names[Math.floor(Math.random() * names.length)];
const data = readSeedFile(name, subdir);
if (!data) return res.status(404).json({ error: 'seed file missing' });
res.json({ name, seed: data.seed, saved_at: data.saved_at });
});
// GET /api/seeds/:name — load a single seed
router.get('/:name', (req, res) => {
const { name } = req.params;
const subdir = String(req.query.subdir || '');
const data = readSeedFile(name, subdir);
if (!data) return res.status(404).json({ error: `seed '${name}' not found` });
const favorites = loadFavorites();
res.json({
name,
seed: data.seed,
saved_at: data.saved_at,
description: (data.metadata?.description as string) || '',
tags: (data.metadata?.tags as string[]) || [],
favorite: favorites.includes(name),
});
});
// POST /api/seeds — save a seed
// body: { name: string, seed: number, description?: string, tags?: string[] }
router.post('/', (req, res) => {
const { name, seed, description = '', tags = [], subdir = '' } = req.body;
if (!name || typeof name !== 'string' || !name.trim()) {
return res.status(400).json({ error: 'name is required' });
}
if (seed === undefined || seed === null || isNaN(Number(seed))) {
return res.status(400).json({ error: 'seed must be a number' });
}
const seedVal = Math.max(0, Math.min(Number(seed), 9007199254740991));
const meta = {
description: String(description).slice(0, 500),
tags: Array.isArray(tags) ? tags.map(String) : [],
source: 'hot-step-cpp',
};
const ok = writeSeedFile(name.trim(), seedVal, String(subdir), meta);
if (!ok) return res.status(500).json({ error: 'failed to write seed file' });
res.json({ ok: true, name: name.trim(), seed: seedVal });
});
// DELETE /api/seeds/:name — delete a seed
router.delete('/:name', (req, res) => {
const { name } = req.params;
const subdir = String(req.query.subdir || '');
const p = seedPath(name, subdir);
if (!fs.existsSync(p)) return res.status(404).json({ error: `seed '${name}' not found` });
try {
fs.unlinkSync(p);
// Also remove from favorites if present
const favs = loadFavorites();
const next = favs.filter(f => f !== name);
if (next.length !== favs.length) saveFavorites(next);
res.json({ ok: true, deleted: name });
} catch (e) {
res.status(500).json({ error: 'delete failed' });
}
});
// POST /api/seeds/:name/favorite — toggle favorite
router.post('/:name/favorite', (req, res) => {
const { name } = req.params;
const favs = loadFavorites();
const idx = favs.indexOf(name);
let nowFavorite: boolean;
if (idx >= 0) {
favs.splice(idx, 1);
nowFavorite = false;
} else {
favs.push(name);
nowFavorite = true;
}
saveFavorites(favs);
res.json({ ok: true, name, favorite: nowFavorite });
});
export default router;
+208
View File
@@ -0,0 +1,208 @@
// settings.ts — Environment settings API
//
// Exposes whitelisted .env keys to the Settings UI with bidirectional sync.
// GET /api/settings/env — returns current values for exposed keys
// PUT /api/settings/env — updates .env file and hot-reloads config
import { Router } from 'express';
import fs from 'fs';
import { execFile } from 'child_process';
import { promisify } from 'util';
import {
ENV_FILE_PATH,
EXPOSED_ENV_KEYS,
RESTART_REQUIRED_KEYS,
reloadEnvConfig,
config,
} from '../config.js';
const execFileAsync = promisify(execFile);
const router = Router();
/** Set of exposed keys for fast lookup */
const exposedSet = new Set<string>(EXPOSED_ENV_KEYS);
/** Map of env keys to their resolved defaults from config.
* When .env doesn't set a value, the Settings UI should still show
* what the server is actually using (e.g. <PROJECT_ROOT>/models). */
function getResolvedDefaults(): Record<string, string> {
return {
ACESTEPCPP_MODELS: config.aceServer.models,
ACESTEPCPP_ADAPTERS: config.aceServer.adapters,
ACESTEPCPP_PORT: String(config.aceServer.port),
ACESTEPCPP_HOST: config.aceServer.host,
ACESTEPCPP_KEEP_LOADED: config.aceServer.keepLoaded ? '1' : '0',
CUDA_VISIBLE_DEVICES: config.aceServer.cudaVisibleDevices,
SERVER_PORT: String(config.server.port),
DATA_DIR: config.data.dir,
};
}
/**
* Parse .env content into an ordered array of { key, value, raw } entries.
* Preserves comments, blank lines, and original formatting.
*/
function parseEnvLines(content: string): Array<{ key?: string; value?: string; raw: string }> {
return content.split(/\r?\n/).map((raw) => {
const trimmed = raw.trim();
// blank or comment line
if (!trimmed || trimmed.startsWith('#')) {
return { raw };
}
// KEY=VALUE (capture first = only)
const eqIdx = trimmed.indexOf('=');
if (eqIdx > 0) {
const key = trimmed.slice(0, eqIdx).trim();
const value = trimmed.slice(eqIdx + 1).trim();
return { key, value, raw };
}
return { raw };
});
}
/**
* GET /api/settings/env
*
* Returns the current .env values for all exposed keys.
* Keys not set in .env are backfilled with their resolved defaults
* so the UI always shows the actual path/value the server is using.
* Also returns the restart-required key list so the UI can badge them.
*/
router.get('/env', (_req, res) => {
try {
const content = fs.existsSync(ENV_FILE_PATH)
? fs.readFileSync(ENV_FILE_PATH, 'utf-8')
: '';
const lines = parseEnvLines(content);
const defaults = getResolvedDefaults();
const values: Record<string, string> = {};
// Seed all exposed keys with resolved defaults (not empty strings)
for (const key of EXPOSED_ENV_KEYS) {
values[key] = defaults[key] ?? '';
}
// Override with explicit .env values
for (const line of lines) {
if (line.key && exposedSet.has(line.key)) {
values[line.key] = line.value ?? '';
}
}
res.json({
values,
restartKeys: [...RESTART_REQUIRED_KEYS],
});
} catch (err: any) {
console.error('[Settings] Failed to read .env:', err.message);
res.status(500).json({ error: err.message });
}
});
/**
* PUT /api/settings/env
*
* Receives a partial map of key-value pairs.
* Updates the .env file preserving structure, then hot-reloads config.
*/
router.post('/env', (req, res) => {
try {
const updates: Record<string, string> = req.body?.values;
if (!updates || typeof updates !== 'object') {
res.status(400).json({ error: 'Missing "values" object in request body' });
return;
}
// Filter to only exposed keys
const safeUpdates: Record<string, string> = {};
for (const [key, value] of Object.entries(updates)) {
if (exposedSet.has(key) && typeof value === 'string') {
safeUpdates[key] = value;
}
}
if (Object.keys(safeUpdates).length === 0) {
res.json({ updated: [], restartRequired: false });
return;
}
// Read current .env
const content = fs.existsSync(ENV_FILE_PATH)
? fs.readFileSync(ENV_FILE_PATH, 'utf-8')
: '';
const lines = parseEnvLines(content);
const updatedKeys = new Set<string>();
// Update existing lines in-place
const newLines = lines.map((line) => {
if (line.key && line.key in safeUpdates) {
updatedKeys.add(line.key);
return { ...line, raw: `${line.key}=${safeUpdates[line.key]}` };
}
return line;
});
// Append any keys that weren't already in the file
for (const [key, value] of Object.entries(safeUpdates)) {
if (!updatedKeys.has(key)) {
newLines.push({ key, value, raw: `${key}=${value}` });
updatedKeys.add(key);
}
}
// Write back, preserving original line endings
const eol = content.includes('\r\n') ? '\r\n' : '\n';
const newContent = newLines.map((l) => l.raw).join(eol);
fs.writeFileSync(ENV_FILE_PATH, newContent, 'utf-8');
// Hot-reload into live config
const changed = reloadEnvConfig();
const restartRequired = changed.some((k) => RESTART_REQUIRED_KEYS.has(k));
console.log(`[Settings] Updated .env: ${[...updatedKeys].join(', ')}${restartRequired ? ' (restart required)' : ''}`);
res.json({
updated: [...updatedKeys],
restartRequired,
});
} catch (err: any) {
console.error('[Settings] Failed to update .env:', err.message);
res.status(500).json({ error: err.message });
}
});
/**
* GET /api/settings/gpus
*
* Detect available NVIDIA GPUs via nvidia-smi.
* Returns an array of { index, name, memoryMB } objects.
* Returns empty array if nvidia-smi is unavailable (AMD, Intel, CPU-only).
*/
router.get('/gpus', async (_req, res) => {
try {
const { stdout } = await execFileAsync('nvidia-smi', [
'--query-gpu=index,name,memory.total',
'--format=csv,noheader,nounits',
], { timeout: 5000 });
const gpus = stdout.trim().split('\n')
.filter(line => line.trim())
.map(line => {
const [index, name, memoryMB] = line.split(',').map(s => s.trim());
return {
index: parseInt(index, 10),
name,
memoryMB: parseInt(memoryMB, 10),
};
});
res.json({ gpus });
} catch {
// nvidia-smi not found or failed — not an NVIDIA system
res.json({ gpus: [] });
}
});
export default router;
+211
View File
@@ -0,0 +1,211 @@
// shutdown.ts — Graceful shutdown and restart endpoints
//
// POST /api/shutdown — gracefully stops Node server + ace-server child
// POST /api/restart — stops and relaunches (writes marker for loop wrapper)
// Platform-aware: uses taskkill on Windows, targeted SIGTERM on macOS/Linux.
//
// SAFETY: We only kill processes we own (our PID and our child ace-server).
// We NEVER kill by port on macOS — that can destroy unrelated services.
// On Windows, port-based kill is used for ace-server because we don't
// have the child PID available in this module.
import { Router } from 'express';
import { execSync, spawn } from 'child_process';
import fs from 'fs';
import path from 'path';
import { PROJECT_ROOT, PORTABLE_MODE } from '../config.js';
import { killActiveChildren } from '../services/training/labelingQueue.js';
const router = Router();
/** Reap spawned training children (ace-train + its ffmpeg) before we exit.
* Only the Windows /api/shutdown path used to clean these up, and only as a
* side effect of taskkill /T on our own tree — portable /api/restart and every
* non-Windows path orphaned a GPU-resident process. */
function killTrainingChildren(): void {
try { killActiveChildren(); } catch (err) { console.error('[Shutdown] killActiveChildren failed:', err); }
}
/** Kill the ace-server child process safely (cross-platform). */
function killAceServer(): void {
try {
if (process.platform === 'win32') {
// Windows: netstat + taskkill (port-based, needed because we don't have the child PID here)
const output = execSync(
`netstat -ano | findstr ":8085" | findstr "LISTENING"`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
);
const pids = new Set<string>();
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && /^\d+$/.test(pid) && pid !== '0') {
pids.add(pid);
}
}
for (const pid of pids) {
try {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
console.log(`[Shutdown] Killed ace-server PID ${pid}`);
} catch {
// Process may already be dead
}
}
} else {
// macOS/Linux: find ace-server processes that are children of us
try {
const output = execSync(
`pgrep -P ${process.pid} -f ace-server 2>/dev/null || true`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
if (output) {
for (const pid of output.split('\n').filter(Boolean)) {
try {
process.kill(parseInt(pid, 10), 'SIGTERM');
console.log(`[Shutdown] Sent SIGTERM to ace-server child PID ${pid}`);
} catch {
// Already dead
}
}
}
} catch {
// No matching processes
}
}
} catch {
// No process found — that's fine
}
}
/** Kill the Vite dev server by port (Windows only, used during full shutdown). */
function killVite(): void {
if (process.platform !== 'win32') return; // macOS: Vite isn't our child in production
try {
const output = execSync(
`netstat -ano | findstr ":3000" | findstr "LISTENING"`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
);
const pids = new Set<string>();
for (const line of output.split('\n')) {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid && /^\d+$/.test(pid) && pid !== '0') {
pids.add(pid);
}
}
for (const pid of pids) {
try {
execSync(`taskkill /PID ${pid} /T /F`, { stdio: 'ignore' });
console.log(`[Shutdown] Killed Vite PID ${pid} (port 3000)`);
} catch {
// Process may already be dead
}
}
} catch {
// No process found on port 3000 — that's fine
}
}
/** Kill our own process tree from outside (Windows).
* Chain: cmd.exe → npx → tsx watch → node (us)
* Killing the parent tsx/npx with /T kills everything, and
* cmd.exe /c exits because its command finished.
* On macOS/Linux, process.exit() is sufficient because launch.sh
* uses exec (replaces shell with node, no orphan parents). */
function killSelf(): void {
if (process.platform !== 'win32') return; // macOS doesn't need this
try {
const output = execSync(
`wmic process where processid=${process.pid} get parentprocessid /value`,
{ encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
const match = output.match(/ParentProcessId=(\d+)/i);
if (match) {
const parentPid = match[1];
console.log(`[Shutdown] Killing parent PID ${parentPid} (our process tree)`);
// Spawn taskkill directly after a Node-side delay. Do NOT use the
// `cmd /c ping -n 2 ... & taskkill` sleep idiom: ping can hang forever
// (observed 2026-07-17 — hung PING.EXE processes meant taskkill never
// ran, tsx watch survived, and the restart-loop marker was never
// consumed, leaving the server dead after an in-app restart).
setTimeout(() => {
try {
const killer = spawn('taskkill', ['/PID', parentPid, '/T', '/F'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
});
killer.unref();
} catch {
// Fallback: our own process.exit still runs
}
}, 700);
}
} catch {
// Fallback: just exit
}
}
// POST /api/shutdown — terminate everything gracefully
router.post('/', (_req, res) => {
console.log('[Server] Shutdown requested via API');
res.json({ success: true, message: 'Shutting down...' });
setTimeout(() => {
console.log('[Server] Shutting down...');
killTrainingChildren();
killAceServer();
killVite();
// On Windows: kill our process tree from outside (needed for dev-rebuild workflow)
// On macOS: process.exit() is sufficient
killSelf();
// Fallback exit
setTimeout(() => {
console.log('[Server] Exiting.');
process.exit(0);
}, 1000);
}, 300);
});
// POST /api/restart — restart server (loop wrapper relaunches)
router.post('/restart', (_req, res) => {
console.log('[Server] Restart requested via API');
// Write marker file so the loop wrapper (launch.bat / launch.sh)
// knows to re-launch instead of exiting
const markerPath = path.join(PROJECT_ROOT, '.restart-requested');
try {
fs.writeFileSync(markerPath, new Date().toISOString(), 'utf8');
console.log(`[Server] Wrote restart marker: ${markerPath}`);
} catch (err: any) {
console.error(`[Server] Failed to write restart marker: ${err.message}`);
}
res.json({ success: true, message: 'Restarting...' });
setTimeout(() => {
console.log('[Server] Restarting — stopping ace-server and self...');
killTrainingChildren();
killAceServer();
// Do NOT kill Vite (port 3000) — leave it running for dev mode
// In portable mode, the bat file has a restart loop that checks
// .restart-requested after node exits — just exit cleanly.
// In dev mode, kill our process tree so tsx watch relaunches us.
if (!PORTABLE_MODE) {
killSelf();
}
// Exit — portable bat loop will relaunch, dev tsx watch will relaunch
setTimeout(() => {
console.log('[Server] Exiting for restart.');
process.exit(0);
}, 1000);
}, 300);
});
export default router;
+268
View File
@@ -0,0 +1,268 @@
// songBuilder.ts — Song Builder (Udio-style section-by-section generation)
//
// A "project" is one song assembled from an ordered chain of sections. Each
// section generates N candidate songs (variants) via the normal /api/generate
// pipeline (text2music for the first section, outpaint-repaint extending the
// previously chosen variant's latent for every section after). The user picks
// one variant per section; that pick becomes the source for the next section.
//
// This router is pure bookkeeping. Generation runs through /api/generate; the
// UI records the returned jobId/songIds here and tracks the chosen variant.
import { Router } from 'express';
import { randomUUID } from 'crypto';
import { getDb } from '../db/database.js';
import { getUserId } from './auth.js';
const router = Router();
// ── Helpers ────────────────────────────────────────────────────────────────
/** Resolve a list of song ids into full song rows (parsed), preserving order. */
function resolveSongs(ids: string[]): any[] {
if (!ids.length) return [];
const placeholders = ids.map(() => '?').join(',');
const rows = getDb()
.prepare(`SELECT * FROM songs WHERE id IN (${placeholders})`)
.all(...ids) as any[];
const byId = new Map(rows.map(r => [r.id, r]));
return ids
.map(id => byId.get(id))
.filter(Boolean)
.map((s: any) => ({ ...s, tags: JSON.parse(s.tags || '[]'), is_public: !!s.is_public }));
}
/** Load a project's sections (ordered by position) with resolved candidate + chosen songs. */
function loadSections(projectId: string): any[] {
const sections = getDb()
.prepare(`SELECT * FROM builder_sections WHERE project_id = ? ORDER BY position ASC, created_at ASC`)
.all(projectId) as any[];
return sections.map(sec => {
const candidateIds: string[] = JSON.parse(sec.candidate_song_ids || '[]');
const candidates = resolveSongs(candidateIds);
const chosen = sec.chosen_song_id ? resolveSongs([sec.chosen_song_id])[0] || null : null;
return { ...sec, candidate_song_ids: candidateIds, candidates, chosen };
});
}
/** Verify a project belongs to the user; returns the row or null. */
function ownedProject(projectId: string, userId: string): any | null {
const p = getDb()
.prepare(`SELECT * FROM builder_projects WHERE id = ? AND user_id = ?`)
.get(projectId, userId) as any;
return p || null;
}
/** Verify a section belongs to a project owned by the user; returns {section, project} or null. */
function ownedSection(sectionId: string, userId: string): { section: any; project: any } | null {
const section = getDb()
.prepare(`SELECT * FROM builder_sections WHERE id = ?`)
.get(sectionId) as any;
if (!section) return null;
const project = ownedProject(section.project_id, userId);
if (!project) return null;
return { section, project };
}
const touchProject = (id: string) =>
getDb().prepare(`UPDATE builder_projects SET updated_at = datetime('now') WHERE id = ?`).run(id);
// ── Project routes ───────────────────────────────────────────────────────────
// GET /api/builder/projects — list projects (with section count)
router.get('/projects', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const projects = getDb().prepare(`
SELECT p.*,
(SELECT COUNT(*) FROM builder_sections s WHERE s.project_id = p.id) AS section_count
FROM builder_projects p
WHERE p.user_id = ?
ORDER BY p.updated_at DESC
`).all(userId);
res.json({ projects });
});
// GET /api/builder/projects/:id — full project with ordered, resolved sections
router.get('/projects/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const project = ownedProject(req.params.id, userId);
if (!project) { res.status(404).json({ error: 'Project not found' }); return; }
res.json({ project, sections: loadSections(project.id) });
});
// POST /api/builder/projects — create a project
router.post('/projects', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const b = req.body || {};
const id = randomUUID();
getDb().prepare(`
INSERT INTO builder_projects
(id, user_id, title, style, bpm, key_scale, time_signature, vocal_language,
section_length, variant_count, gen_params)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, userId,
b.title || 'Untitled Song',
b.style || '',
b.bpm || 0,
b.keyScale || '',
b.timeSignature || '',
b.vocalLanguage || '',
b.sectionLength ?? 30,
b.variantCount ?? 4,
JSON.stringify(b.genParams || {}),
);
const project = getDb().prepare(`SELECT * FROM builder_projects WHERE id = ?`).get(id);
res.json({ project, sections: [] });
});
// PATCH /api/builder/projects/:id — update shared params / title
router.patch('/projects/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const project = ownedProject(req.params.id, userId);
if (!project) { res.status(404).json({ error: 'Project not found' }); return; }
const b = req.body || {};
const map: Record<string, string> = {
title: 'title', style: 'style', bpm: 'bpm', keyScale: 'key_scale',
timeSignature: 'time_signature', vocalLanguage: 'vocal_language',
sectionLength: 'section_length', variantCount: 'variant_count',
};
const sets: string[] = [];
const vals: any[] = [];
for (const [k, col] of Object.entries(map)) {
if (b[k] !== undefined) { sets.push(`${col} = ?`); vals.push(b[k]); }
}
if (b.genParams !== undefined) { sets.push(`gen_params = ?`); vals.push(JSON.stringify(b.genParams)); }
if (sets.length) {
sets.push(`updated_at = datetime('now')`);
vals.push(project.id);
getDb().prepare(`UPDATE builder_projects SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
}
const updated = getDb().prepare(`SELECT * FROM builder_projects WHERE id = ?`).get(project.id);
res.json({ project: updated, sections: loadSections(project.id) });
});
// DELETE /api/builder/projects/:id
router.delete('/projects/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const project = ownedProject(req.params.id, userId);
if (!project) { res.status(404).json({ error: 'Project not found' }); return; }
// ON DELETE CASCADE removes sections. Candidate songs are left in the library
// (they are normal songs and may be referenced elsewhere).
getDb().prepare(`DELETE FROM builder_projects WHERE id = ?`).run(project.id);
res.json({ ok: true });
});
// ── Section routes ───────────────────────────────────────────────────────────
// POST /api/builder/projects/:id/sections — create a section record
// Called by the UI right after it kicks off generation via /api/generate.
router.post('/projects/:id/sections', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const project = ownedProject(req.params.id, userId);
if (!project) { res.status(404).json({ error: 'Project not found' }); return; }
const b = req.body || {};
const id = randomUUID();
// Default position: append after the current max (or before the min for prepend).
let position = b.position;
if (position === undefined) {
const agg = getDb()
.prepare(`SELECT MIN(position) AS lo, MAX(position) AS hi FROM builder_sections WHERE project_id = ?`)
.get(project.id) as any;
if (b.direction === 'prepend') position = (agg.lo ?? 0) - 1;
else position = (agg.hi ?? -1) + 1;
}
getDb().prepare(`
INSERT INTO builder_sections
(id, project_id, position, label, lyrics, direction, section_length,
candidate_song_ids, chosen_song_id, job_id, status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id, project.id, position,
b.label || '',
b.lyrics || '',
b.direction || 'append',
b.sectionLength ?? project.section_length ?? 30,
JSON.stringify(b.candidateSongIds || []),
b.chosenSongId || null,
b.jobId || null,
b.status || (b.jobId ? 'generating' : 'pending'),
);
touchProject(project.id);
const section = loadSections(project.id).find(s => s.id === id);
res.json({ section });
});
// PATCH /api/builder/sections/:id — update a section
// Used to record candidate song ids when a job completes, to choose a variant,
// and to edit label/lyrics.
router.patch('/sections/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const owned = ownedSection(req.params.id, userId);
if (!owned) { res.status(404).json({ error: 'Section not found' }); return; }
const b = req.body || {};
const sets: string[] = [];
const vals: any[] = [];
if (b.label !== undefined) { sets.push(`label = ?`); vals.push(b.label); }
if (b.lyrics !== undefined) { sets.push(`lyrics = ?`); vals.push(b.lyrics); }
if (b.position !== undefined) { sets.push(`position = ?`); vals.push(b.position); }
if (b.sectionLength !== undefined) { sets.push(`section_length = ?`); vals.push(b.sectionLength); }
if (b.jobId !== undefined) { sets.push(`job_id = ?`); vals.push(b.jobId); }
if (b.candidateSongIds !== undefined) {
sets.push(`candidate_song_ids = ?`);
vals.push(JSON.stringify(b.candidateSongIds));
}
if (b.chosenSongId !== undefined) { sets.push(`chosen_song_id = ?`); vals.push(b.chosenSongId); }
if (b.status !== undefined) { sets.push(`status = ?`); vals.push(b.status); }
if (sets.length) {
sets.push(`updated_at = datetime('now')`);
vals.push(owned.section.id);
getDb().prepare(`UPDATE builder_sections SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
touchProject(owned.project.id);
}
const section = loadSections(owned.project.id).find(s => s.id === owned.section.id);
res.json({ section });
});
// DELETE /api/builder/sections/:id
router.delete('/sections/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const owned = ownedSection(req.params.id, userId);
if (!owned) { res.status(404).json({ error: 'Section not found' }); return; }
getDb().prepare(`DELETE FROM builder_sections WHERE id = ?`).run(owned.section.id);
touchProject(owned.project.id);
res.json({ ok: true });
});
export default router;
+795
View File
@@ -0,0 +1,795 @@
// songs.ts — Song CRUD routes + audio file serving
//
// Songs are stored in SQLite. Audio files are saved to data/audio/.
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { getDb } from '../db/database.js';
import { config } from '../config.js';
import { getUserId } from './auth.js';
import { deleteAudioGenerationsByJobIds } from '../db/lireekDb.js';
import { cropWavFile, cropLrcFile } from '../services/audioCrop.js';
import { analyzeAndSaveDiscoData } from '../services/disco-analyzer.js';
const router = Router();
// GET /api/songs — list user's songs
router.get('/', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
// Optional source filter — filters by generation_params JSON field
const source = req.query.source as string | undefined;
let query = 'SELECT * FROM songs WHERE user_id = ?';
const params: any[] = [userId];
if (source) {
query += ` AND json_extract(generation_params, '$.source') = ?`;
params.push(source);
}
query += ' ORDER BY created_at DESC';
const songs = getDb().prepare(query).all(...params);
// Parse tags JSON string
const parsed = songs.map((s: any) => ({
...s,
tags: JSON.parse(s.tags || '[]'),
is_public: !!s.is_public,
}));
res.json({ songs: parsed });
});
// GET /api/songs/ids — bare id list, used by the UI queue to prune entries
// whose songs were deleted (nuke, other tab, individual delete)
// IMPORTANT: Must be defined BEFORE /:id to avoid Express matching 'ids' as an id
router.get('/ids', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const rows = getDb().prepare('SELECT id FROM songs WHERE user_id = ?').all(userId) as Array<{ id: string }>;
res.json({ ids: rows.map(r => r.id) });
});
// GET /api/songs/recent — unified recent songs across all modes
// Supports ?source=create|lyric-studio|cover-studio&limit=50
// Returns a normalized shape compatible with the frontend's RecentSong interface
// IMPORTANT: Must be defined BEFORE /:id to avoid Express matching 'recent' as an id
router.get('/recent', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const source = req.query.source as string | undefined;
const limit = parseInt(req.query.limit as string, 10) || 50;
let query = 'SELECT * FROM songs WHERE user_id = ?';
const params: any[] = [userId];
if (source && source !== 'all') {
query += ` AND json_extract(generation_params, '$.source') = ?`;
params.push(source);
}
query += ' ORDER BY created_at DESC LIMIT ?';
params.push(limit);
const songs = getDb().prepare(query).all(...params) as any[];
// Enrich songs with Lyric Studio metadata where available
// For lyric-studio songs, look up artist name via the audio_generations → generations → profiles → lyrics_sets → artists chain
const audioUrls = songs.map(s => s.audio_url).filter(Boolean);
let lireekMetaMap = new Map<string, { artist_name: string; artist_image?: string; album?: string; generation_id?: number }>();
if (audioUrls.length > 0) {
try {
const placeholders = audioUrls.map(() => '?').join(',');
const enrichRows = getDb().prepare(
`SELECT ag.audio_url, a.name AS artist_name, a.image_url AS artist_image,
ls.album, g.id AS generation_id
FROM audio_generations ag
JOIN generations g ON g.id = ag.generation_id
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
WHERE ag.audio_url IN (${placeholders})`
).all(...audioUrls) as any[];
for (const row of enrichRows) {
lireekMetaMap.set(row.audio_url, row);
}
} catch { /* lireek tables may not exist yet */ }
}
// Build normalized response
const result = songs.map((s: any) => {
const genParams = JSON.parse(s.generation_params || '{}');
const lireekMeta = lireekMetaMap.get(s.audio_url);
return {
id: s.id,
title: s.title || 'Untitled',
audio_url: s.audio_url || '',
mastered_audio_url: s.mastered_audio_url || '',
latent_url: s.latent_url || '',
kick_stem_url: s.kick_stem_url || '',
snare_stem_url: s.snare_stem_url || '',
hihat_stem_url: s.hihat_stem_url || '',
disco_data_url: s.disco_data_url || '',
cover_url: s.cover_url || '',
duration: s.duration || 0,
lyrics: s.lyrics || '',
caption: s.caption || '',
style: s.style || '',
bpm: s.bpm || 0,
key_scale: s.key_scale || '',
time_signature: s.time_signature || '',
metadata_overrides: s.metadata_overrides || '',
source: genParams.source || 'create',
created_at: s.created_at,
// Enriched from Lyric Studio metadata
artist_name: lireekMeta?.artist_name || genParams.artist_name || '',
artist_image: lireekMeta?.artist_image || '',
album: lireekMeta?.album || genParams.album || '',
generation_id: lireekMeta?.generation_id || null,
};
});
res.json({ songs: result });
});
// GET /api/songs/:id — get single song
router.get('/:id', (req, res) => {
const song = getDb()
.prepare('SELECT * FROM songs WHERE id = ?')
.get(req.params.id) as any;
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
res.json({
song: {
...song,
tags: JSON.parse(song.tags || '[]'),
is_public: !!song.is_public,
},
});
});
// POST /api/songs — create a new song
router.post('/', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const {
id, title, lyrics, style, caption, audio_url, cover_url,
duration, bpm, key_scale, time_signature, tags, dit_model,
generation_params,
} = req.body;
const songId = id || crypto.randomUUID();
const tagsJson = JSON.stringify(tags || []);
const genParamsJson = typeof generation_params === 'string'
? generation_params
: JSON.stringify(generation_params || {});
getDb().prepare(`
INSERT INTO songs (id, user_id, title, lyrics, style, caption, audio_url, cover_url,
duration, bpm, key_scale, time_signature, tags, dit_model, generation_params)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
songId, userId, title || 'Untitled', lyrics || '', style || '',
caption || '', audio_url || '', cover_url || '',
duration || 0, bpm || 0, key_scale || '', time_signature || '',
tagsJson, dit_model || '', genParamsJson,
);
const song = getDb().prepare('SELECT * FROM songs WHERE id = ?').get(songId) as any;
res.json({
song: { ...song, tags: JSON.parse(song.tags || '[]'), is_public: !!song.is_public },
});
});
// PATCH /api/songs/:id — update song
router.patch('/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const song = getDb().prepare('SELECT * FROM songs WHERE id = ? AND user_id = ?')
.get(req.params.id, userId) as any;
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
const updates = req.body;
const allowed = ['title', 'lyrics', 'style', 'caption', 'cover_url', 'is_public',
'bpm', 'key_scale', 'time_signature', 'dit_model', 'cover_art_subject'];
for (const key of allowed) {
if (updates[key] !== undefined) {
const value = key === 'is_public' ? (updates[key] ? 1 : 0) : updates[key];
getDb().prepare(`UPDATE songs SET ${key} = ? WHERE id = ?`).run(value, req.params.id);
}
}
if (updates.tags) {
getDb().prepare('UPDATE songs SET tags = ? WHERE id = ?')
.run(JSON.stringify(updates.tags), req.params.id);
}
// Metadata-editor overrides (#60) — embed-tag values used verbatim on export.
// Accept an object, store as JSON; an empty/null value clears the overrides.
if (updates.metadata_overrides !== undefined) {
const val = updates.metadata_overrides && typeof updates.metadata_overrides === 'object'
? JSON.stringify(updates.metadata_overrides)
: '';
getDb().prepare('UPDATE songs SET metadata_overrides = ? WHERE id = ?')
.run(val, req.params.id);
}
const updated = getDb().prepare('SELECT * FROM songs WHERE id = ?').get(req.params.id) as any;
res.json({
song: { ...updated, tags: JSON.parse(updated.tags || '[]'), is_public: !!updated.is_public },
});
});
// DELETE /api/songs/:id — delete song + audio file
router.delete('/:id', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const song = getDb().prepare('SELECT * FROM songs WHERE id = ? AND user_id = ?')
.get(req.params.id, userId) as any;
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
// Delete audio file if it exists
if (song.audio_url) {
const filename = path.basename(song.audio_url);
const filepath = path.join(config.data.audioDir, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
}
// Delete mastered audio file if it exists
if (song.mastered_audio_url) {
const masteredFilename = path.basename(song.mastered_audio_url);
const masteredFilepath = path.join(config.data.audioDir, masteredFilename);
if (fs.existsSync(masteredFilepath)) {
fs.unlinkSync(masteredFilepath);
}
}
// Delete latent file if it exists
if (song.latent_url) {
const latentFilename = path.basename(song.latent_url);
const latentFilepath = path.join(config.data.audioDir, latentFilename);
if (fs.existsSync(latentFilepath)) {
fs.unlinkSync(latentFilepath);
}
}
// Delete drum stem files if they exist
for (const stemUrl of [song.kick_stem_url, song.snare_stem_url, song.hihat_stem_url]) {
if (stemUrl && !stemUrl.startsWith('extracting:')) {
const stemFilename = path.basename(stemUrl);
const stemFilepath = path.join(config.data.audioDir, stemFilename);
if (fs.existsSync(stemFilepath)) {
fs.unlinkSync(stemFilepath);
}
}
}
getDb().prepare('DELETE FROM songs WHERE id = ?').run(req.params.id);
// Cascade to Lireek DB — remove matching audio_generation records
try { deleteAudioGenerationsByJobIds([req.params.id]); } catch { /* lireek DB may not be initialized */ }
res.json({ success: true });
});
// DELETE /api/songs — delete all user's songs
router.delete('/', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const songs = getDb().prepare('SELECT audio_url FROM songs WHERE user_id = ?').all(userId) as any[];
// Delete audio files
for (const song of songs) {
if (song.audio_url) {
const filename = path.basename(song.audio_url);
const filepath = path.join(config.data.audioDir, filename);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
}
}
// Get all song IDs before deleting (for Lireek cascade)
const allSongs = getDb().prepare('SELECT id FROM songs WHERE user_id = ?').all(userId) as any[];
const allIds = allSongs.map((s: any) => s.id);
const result = getDb().prepare('DELETE FROM songs WHERE user_id = ?').run(userId);
// Cascade to Lireek DB
try { deleteAudioGenerationsByJobIds(allIds); } catch { /* lireek DB may not be initialized */ }
res.json({ success: true, deletedCount: result.changes });
});
// POST /api/songs/bulk-delete — delete multiple songs by ID
router.post('/bulk-delete', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
const { ids } = req.body;
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids must be a non-empty array' });
return;
}
// Fetch all matching songs to get file paths
const placeholders = ids.map(() => '?').join(',');
const songs = getDb()
.prepare(`SELECT id, audio_url, mastered_audio_url FROM songs WHERE id IN (${placeholders}) AND user_id = ?`)
.all(...ids, userId) as any[];
// Delete audio files from disk
for (const song of songs) {
if (song.audio_url) {
const filepath = path.join(config.data.audioDir, path.basename(song.audio_url));
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
}
if (song.mastered_audio_url) {
const filepath = path.join(config.data.audioDir, path.basename(song.mastered_audio_url));
if (fs.existsSync(filepath)) fs.unlinkSync(filepath);
}
}
// Delete from DB
const result = getDb()
.prepare(`DELETE FROM songs WHERE id IN (${placeholders}) AND user_id = ?`)
.run(...ids, userId);
console.log(`[Songs] Bulk deleted ${result.changes}/${ids.length} songs`);
// Cascade to Lireek DB
try { deleteAudioGenerationsByJobIds(ids); } catch { /* lireek DB may not be initialized */ }
res.json({ success: true, deletedCount: result.changes });
});
// POST /api/songs/nuke-generations — delete ALL generated audio across both databases + disk
router.post('/nuke-generations', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
// 1. Collect all file-reference columns from songs table
const songs = getDb()
.prepare(`SELECT id, audio_url, mastered_audio_url, latent_url,
kick_stem_url, snare_stem_url, hihat_stem_url,
disco_data_url, cover_url
FROM songs WHERE user_id = ?`)
.all(userId) as any[];
// 2. Delete referenced files from disk
let filesDeleted = 0;
const fileColumns = [
'audio_url', 'mastered_audio_url', 'latent_url',
'kick_stem_url', 'snare_stem_url', 'hihat_stem_url',
'disco_data_url', 'cover_url',
];
for (const song of songs) {
for (const col of fileColumns) {
const url = song[col];
if (url && !url.startsWith('extracting:')) {
const filepath = path.join(config.data.audioDir, path.basename(url));
if (fs.existsSync(filepath)) {
try { fs.unlinkSync(filepath); filesDeleted++; } catch { /* best effort */ }
}
}
}
// Also delete companion files (lyrics JSON, LRC) that share the audio filename stem
if (song.audio_url) {
const baseName = path.basename(song.audio_url).replace(/\.[^.]+$/, '');
for (const ext of ['.lyrics.json', '.lrc']) {
const companionPath = path.join(config.data.audioDir, baseName + ext);
if (fs.existsSync(companionPath)) {
try { fs.unlinkSync(companionPath); filesDeleted++; } catch { /* best effort */ }
}
}
}
}
// 3. Delete all songs from main DB
const songIds = songs.map((s: any) => s.id);
const songResult = getDb().prepare('DELETE FROM songs WHERE user_id = ?').run(userId);
// 4. Delete all audio_generations (same DB now)
let lireekDeleted = 0;
try {
if (songIds.length > 0) {
lireekDeleted += deleteAudioGenerationsByJobIds(songIds);
}
// Also nuke ALL audio_generations (catches any orphans)
const allResult = getDb().prepare('DELETE FROM audio_generations').run();
lireekDeleted = Math.max(lireekDeleted, allResult.changes);
} catch (err) {
console.error('[Songs] NUKE audio_generations cleanup error:', err);
}
// 5. Sweep the audio directory — remove any orphan files the DB didn't know about
let orphansDeleted = 0;
try {
if (fs.existsSync(config.data.audioDir)) {
const remaining = fs.readdirSync(config.data.audioDir);
for (const file of remaining) {
const filePath = path.join(config.data.audioDir, file);
try {
const stat = fs.statSync(filePath);
if (stat.isFile()) {
fs.unlinkSync(filePath);
orphansDeleted++;
}
} catch { /* best effort */ }
}
if (orphansDeleted > 0) {
console.log(`[Songs] NUKE: swept ${orphansDeleted} orphan files from audio directory`);
}
}
} catch (err) {
console.error('[Songs] NUKE: audio directory sweep error:', err);
}
console.log(`[Songs] NUKE: ${songResult.changes} songs, ${filesDeleted} referenced files, ${orphansDeleted} orphans, ${lireekDeleted} lireek audio_gens deleted`);
res.json({
success: true,
songsDeleted: songResult.changes,
filesDeleted: filesDeleted + orphansDeleted,
lireekAudioGensDeleted: lireekDeleted,
});
});
// POST /api/songs/:id/crop — crop audio to IN/OUT range (destructive)
router.post('/:id/crop', (req, res) => {
const userId = getUserId(req);
if (!userId) { res.status(401).json({ error: 'Unauthorized' }); return; }
let { inPoint, outPoint, audioUrl } = req.body;
if (inPoint == null || outPoint == null) {
res.status(400).json({ error: 'inPoint and outPoint are required' });
return;
}
// Auto-swap if reversed
if (inPoint > outPoint) [inPoint, outPoint] = [outPoint, inPoint];
// Look up song by ID first, then fall back to audio_url match
// (Lireek/Lyric Studio tracks use hotstep_job_id as their ID, which
// doesn't match the songs.id column — but the audio_url is the same)
let song = getDb().prepare('SELECT * FROM songs WHERE id = ?')
.get(req.params.id) as any;
if (!song && audioUrl) {
song = getDb().prepare('SELECT * FROM songs WHERE audio_url = ?')
.get(audioUrl) as any;
}
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
try {
// 1. Crop original audio
const audioFilename = path.basename(song.audio_url);
const audioPath = path.join(config.data.audioDir, audioFilename);
if (!fs.existsSync(audioPath)) {
res.status(404).json({ error: 'Audio file not found' });
return;
}
const result = cropWavFile(audioPath, inPoint, outPoint);
// 2. Crop mastered audio (if exists)
if (song.mastered_audio_url) {
const masteredFilename = path.basename(song.mastered_audio_url);
const masteredPath = path.join(config.data.audioDir, masteredFilename);
if (fs.existsSync(masteredPath)) {
cropWavFile(masteredPath, inPoint, outPoint);
}
}
// 3. Crop companion LRC file (if exists)
const lrcFilename = audioFilename.replace(/\.[^.]+$/, '.lrc');
const lrcPath = path.join(config.data.audioDir, lrcFilename);
if (fs.existsSync(lrcPath)) {
cropLrcFile(lrcPath, inPoint, outPoint);
}
// 4. Update duration in DB
getDb().prepare('UPDATE songs SET duration = ? WHERE id = ?')
.run(result.newDurationSec, req.params.id);
console.log(`[Songs] Cropped ${req.params.id}: ${inPoint.toFixed(1)}s${outPoint.toFixed(1)}s → ${result.newDurationSec.toFixed(1)}s`);
res.json({ cropped: true, newDuration: result.newDurationSec });
} catch (err: any) {
console.error(`[Songs] Crop failed:`, err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/songs/:id/retranscribe — Re-run Whisper transcription
router.post('/:id/retranscribe', async (req, res) => {
try {
const { id } = req.params;
const song = getDb().prepare('SELECT * FROM songs WHERE id = ?').get(id) as any;
if (!song) return res.status(404).json({ error: 'Song not found' });
const audioFilename = song.audio_url ? path.basename(song.audio_url) : '';
if (!audioFilename) return res.status(400).json({ error: 'No audio file' });
const audioPath = path.join(config.data.audioDir, audioFilename);
if (!fs.existsSync(audioPath)) return res.status(404).json({ error: 'Audio file not found' });
const genParams = song.generation_params ? JSON.parse(song.generation_params) : {};
const sourceLyrics = genParams.lyrics || song.lyrics || '';
if (!sourceLyrics.trim()) {
return res.status(400).json({ error: 'No source lyrics available' });
}
const { ensureWhisperCli, findWhisperModel, transcribeWithWhisper } = await import('../services/whisperTranscribe.js');
const { reconcileLyrics } = await import('../services/lyricsReconcile.js');
const whisperReady = await ensureWhisperCli();
if (!whisperReady) {
return res.status(400).json({ error: 'Whisper CLI not available and auto-download failed.' });
}
const whisperModel = req.body?.model || '';
const modelPath = findWhisperModel(whisperModel);
if (!modelPath) {
return res.status(400).json({ error: 'No Whisper model found. Download one from the Model Manager.' });
}
console.log(`[Retranscribe] Song ${id}: starting`);
const whisperResult = await transcribeWithWhisper(audioPath, sourceLyrics, {
model: whisperModel,
language: req.body?.language || 'auto',
beamSize: req.body?.beamSize || 5,
});
if (!whisperResult || !whisperResult.segments?.length) {
return res.status(500).json({ error: 'Whisper returned no transcription' });
}
const lyricsJson = reconcileLyrics(whisperResult, sourceLyrics, whisperModel || 'auto', false);
const lyricsJsonFilename = audioFilename.replace(/\.[^.]+$/, '.lyrics.json');
const lyricsJsonPath = path.join(config.data.audioDir, lyricsJsonFilename);
fs.writeFileSync(lyricsJsonPath, JSON.stringify(lyricsJson, null, 2));
const wordCount = lyricsJson.lines.reduce((n: number, l: any) => n + l.words.length, 0);
console.log(`[Retranscribe] Song ${id}: saved ${lyricsJsonFilename} (${lyricsJson.lines.length} lines, ${wordCount} words)`);
res.json({ success: true, lineCount: lyricsJson.lines.length, wordCount });
} catch (err: any) {
console.error('[Retranscribe] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// Track in-flight extractions to prevent duplicate SuperSep jobs
const extractionsInFlight = new Set<string>();
// POST /api/songs/:id/extract-kick — extract kick drum stem for beat visualization
router.post('/:id/extract-kick', async (req, res) => {
try {
const song = getDb().prepare('SELECT * FROM songs WHERE id = ?')
.get(req.params.id) as any;
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
if (!song.audio_url) { res.status(400).json({ error: 'No audio file' }); return; }
// Already has disco data? (stems were analyzed and cleaned up)
if (song.disco_data_url) {
res.json({ status: 'exists', discoDataUrl: song.disco_data_url });
return;
}
// Already has all drum stems but no disco data? Backfill analysis.
if (song.kick_stem_url && song.snare_stem_url && song.hihat_stem_url) {
let discoDataUrl = '';
try {
discoDataUrl = analyzeAndSaveDiscoData(req.params.id, config.data.audioDir, {
kick: song.kick_stem_url,
snare: song.snare_stem_url,
hihat: song.hihat_stem_url,
});
if (discoDataUrl) {
getDb().prepare('UPDATE songs SET disco_data_url = ? WHERE id = ?')
.run(discoDataUrl, req.params.id);
}
} catch (err: any) {
console.warn(`[KickExtract] Disco analysis backfill failed: ${err.message}`);
}
res.json({ status: 'exists', discoDataUrl });
return;
}
// Guard against duplicate in-flight extractions
if (extractionsInFlight.has(req.params.id)) {
res.json({ status: 'in-progress' });
return;
}
const ACE_URL = config.aceServer?.url || 'http://127.0.0.1:8085';
// Resolve audio file path
const audioFilename = path.basename(song.audio_url);
const audioPath = path.join(config.data.audioDir, audioFilename);
if (!fs.existsSync(audioPath)) {
res.status(404).json({ error: 'Audio file not found on disk' });
return;
}
console.log(`[KickExtract] Song ${req.params.id}: starting SuperSep level 2...`);
// Send audio to ace-server SuperSep at level 2 (FULL — includes drum sub-separation)
const audioBuf = fs.readFileSync(audioPath);
const sepRes = await fetch(`${ACE_URL}/supersep/separate?level=2`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: audioBuf,
});
if (!sepRes.ok) {
const errText = await sepRes.text();
throw new Error(`SuperSep engine error: ${errText}`);
}
const { id: aceJobId } = await sepRes.json() as { id: string };
console.log(`[KickExtract] Song ${req.params.id}: ace-server job ${aceJobId}`);
extractionsInFlight.add(req.params.id);
res.json({ status: 'started', aceJobId, stems: ['kick', 'snare', 'hihat'] });
// Continue extraction in background (don't await in request handler)
extractDrumStemsBackground(req.params.id, aceJobId, ACE_URL).catch(err => {
console.error(`[KickExtract] Background extraction failed for ${req.params.id}:`, err.message);
// Clear the extracting status
getDb().prepare('UPDATE songs SET kick_stem_url = ?, snare_stem_url = ?, hihat_stem_url = ? WHERE id = ?')
.run('', '', '', req.params.id);
}).finally(() => {
extractionsInFlight.delete(req.params.id);
});
} catch (err: any) {
console.error('[KickExtract] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
// POST /api/songs/:id/analyze-disco — generate disco data from existing stems (no re-extraction)
router.post('/:id/analyze-disco', (req, res) => {
try {
const song = getDb().prepare('SELECT * FROM songs WHERE id = ?')
.get(req.params.id) as any;
if (!song) { res.status(404).json({ error: 'Song not found' }); return; }
// Already has disco data?
if (song.disco_data_url) {
res.json({ status: 'exists', discoDataUrl: song.disco_data_url });
return;
}
// Need at least one stem
const stemUrls = {
kick: song.kick_stem_url || undefined,
snare: song.snare_stem_url || undefined,
hihat: song.hihat_stem_url || undefined,
};
const hasStem = stemUrls.kick || stemUrls.snare || stemUrls.hihat;
if (!hasStem) {
res.status(400).json({ error: 'No stem files to analyze' });
return;
}
const discoDataUrl = analyzeAndSaveDiscoData(req.params.id, config.data.audioDir, stemUrls);
if (discoDataUrl) {
getDb().prepare('UPDATE songs SET disco_data_url = ? WHERE id = ?')
.run(discoDataUrl, req.params.id);
}
res.json({ status: 'created', discoDataUrl });
} catch (err: any) {
console.error('[DiscoAnalyze] Error:', err.message);
res.status(500).json({ error: err.message });
}
});
async function extractDrumStemsBackground(songId: string, aceJobId: string, aceUrl: string): Promise<void> {
// Poll until separation completes
const MAX_POLLS = 3600; // 30 minutes max
for (let i = 0; i < MAX_POLLS; i++) {
const progRes = await fetch(`${aceUrl}/supersep/progress?id=${aceJobId}`);
const progData = await progRes.json() as { status: string; progress: number; message: string; error?: string };
if (progData.status === 'done') break;
if (progData.status === 'failed' || progData.status === 'cancelled') {
throw new Error(progData.error || `Separation ${progData.status}`);
}
await new Promise(r => setTimeout(r, 500));
}
// Fetch stem list
const resultRes = await fetch(`${aceUrl}/supersep/result?id=${aceJobId}`);
if (!resultRes.ok) throw new Error('Failed to fetch SuperSep result');
const resultData = await resultRes.json() as { stems: Array<{ name: string; category: string; index: number; stage?: number; hidden?: boolean }> };
// Helper: find, download, and save a stem
async function downloadStem(
stems: typeof resultData.stems,
matchFn: (name: string) => boolean,
suffix: string,
label: string,
): Promise<string> {
const stem = stems.find(s => matchFn(s.name.toLowerCase()));
if (!stem) {
console.warn(`[DrumStems] Song ${songId}: no ${label} stem found`);
return '';
}
const stemRes = await fetch(`${aceUrl}/supersep/serve?id=${aceJobId}&stem=${stem.index}`);
if (!stemRes.ok) {
console.warn(`[DrumStems] Song ${songId}: failed to download ${label} stem`);
return '';
}
const buf = Buffer.from(await stemRes.arrayBuffer());
const filename = `${songId}_${suffix}.wav`;
fs.writeFileSync(path.join(config.data.audioDir, filename), buf);
console.log(`[DrumStems] Song ${songId}: ${label} stem saved (${(buf.length / 1024).toFixed(0)} KB)`);
return `/audio/${filename}`;
}
// Download all three drum stems
const kickUrl = await downloadStem(
resultData.stems,
name => name.includes('kick'),
'kick', 'kick',
);
const snareUrl = await downloadStem(
resultData.stems,
name => name.includes('snare'),
'snare', 'snare',
);
const hihatUrl = await downloadStem(
resultData.stems,
name => name.includes('hi-hat') || name.includes('hihat'),
'hihat', 'hi-hat',
);
// Update DB with all stems at once
getDb().prepare('UPDATE songs SET kick_stem_url = ?, snare_stem_url = ?, hihat_stem_url = ? WHERE id = ?')
.run(kickUrl, snareUrl, hihatUrl, songId);
// Analyze stems and save compact disco data JSON
try {
const discoDataUrl = analyzeAndSaveDiscoData(songId, config.data.audioDir, {
kick: kickUrl,
snare: snareUrl,
hihat: hihatUrl,
});
if (discoDataUrl) {
getDb().prepare('UPDATE songs SET disco_data_url = ? WHERE id = ?')
.run(discoDataUrl, songId);
console.log(`[DrumStems] Song ${songId}: disco data saved → ${discoDataUrl}`);
// Clean up stem WAV files — disco JSON has all the data we need
for (const stemUrl of [kickUrl, snareUrl, hihatUrl]) {
if (!stemUrl) continue;
const stemPath = path.join(config.data.audioDir, path.basename(stemUrl));
try {
if (fs.existsSync(stemPath)) {
fs.unlinkSync(stemPath);
console.log(`[DrumStems] Song ${songId}: deleted ${path.basename(stemUrl)}`);
}
} catch { /* non-fatal */ }
}
// Clear stem URLs from DB — files no longer exist
getDb().prepare('UPDATE songs SET kick_stem_url = \'\', snare_stem_url = \'\', hihat_stem_url = \'\' WHERE id = ?')
.run(songId);
}
} catch (err: any) {
console.error(`[DrumStems] Song ${songId}: disco analysis failed (non-fatal):`, err.message);
}
console.log(`[DrumStems] Song ${songId}: all drum stems processed`);
}
export default router;
+706
View File
@@ -0,0 +1,706 @@
// stemStudio.ts — Stem Studio stem separation route
//
// Server-side orchestration for two modes:
// 1. Extract (DiT) — generative stem extraction via sequential /synth calls
// 2. SuperSep (ONNX) — neural network separation via ace-server's supersep pipeline
//
// Both modes persist results to data/stems/<jobId>/ for a unified
// mixer/download/history experience.
import { Router, Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
import { randomUUID } from 'crypto';
import archiver from 'archiver';
import { aceClient, type AceRequest } from '../services/aceClient.js';
import { ensureEngineFormat } from '../services/audioConvert.js';
import { config } from '../config.js';
import { startGenerationLog, logGeneration, logGenerationParams, finishGenerationLog, failGenerationLog } from '../services/logger.js';
const router = Router();
const ACE_URL = config.aceServer.url;
// ── Constants ────────────────────────────────────────────────────────────
const VALID_TRACKS = [
'vocals', 'backing_vocals', 'drums', 'bass', 'guitar', 'keyboard',
'percussion', 'strings', 'synth', 'fx', 'brass', 'woodwinds',
];
const stemsBaseDir = path.join(config.data.dir, 'stems');
fs.mkdirSync(stemsBaseDir, { recursive: true });
// ── Job State ────────────────────────────────────────────────────────────
interface StemJob {
id: string;
type: 'extract' | 'supersep';
status: 'pending' | 'extracting' | 'separating' | 'saving' | 'done' | 'failed' | 'cancelled';
sourceAudioUrl: string;
sourceFileName: string;
tracks: string[];
currentTrackIndex: number;
currentTrackName: string;
currentAceJobId?: string;
completedStems: string[];
error?: string;
warning?: string;
createdAt: number;
// SuperSep-specific
sepLevel?: number;
aceSupersepJobId?: string;
sepProgress?: number; // 0-100 progress from ace-server during separation
sepMessage?: string; // status message from ace-server
savingTotal?: number; // total stems to save
savingCurrent?: number; // current stem being saved
}
const jobs = new Map<string, StemJob>();
// ── Helpers ──────────────────────────────────────────────────────────────
/** Resolve a URL-style audio path to an absolute filesystem path */
function resolveAudioPath(audioUrl: string): string {
if (audioUrl.startsWith('/references/')) {
return path.join(config.data.dir, 'references', path.basename(audioUrl));
}
if (audioUrl.startsWith('/audio/')) {
return path.join(config.data.audioDir, path.basename(audioUrl));
}
if (path.isAbsolute(audioUrl)) {
return audioUrl;
}
return path.join(config.data.dir, 'references', path.basename(audioUrl));
}
/** Poll ace-server job until completion */
async function pollAceJob(aceJobId: string, job: StemJob): Promise<void> {
const MAX_POLLS = 7200; // 60 minutes max per stem
for (let i = 0; i < MAX_POLLS; i++) {
if (job.status === 'cancelled') {
await aceClient.cancelJob(aceJobId);
throw new Error('Cancelled');
}
const status = await aceClient.pollJob(aceJobId);
if (status.status === 'done') return;
if (status.status === 'failed') throw new Error(`Extract failed for ${job.currentTrackName}`);
if (status.status === 'cancelled') throw new Error('Cancelled by engine');
await new Promise(r => setTimeout(r, 500));
}
throw new Error('Extract timed out');
}
// ── SuperSep Pipeline ────────────────────────────────────────────────────
/** Sanitize a stem name for use as a filename (no slashes, dots, etc.) */
function sanitizeStemName(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '_').toLowerCase();
}
/** Run the SuperSep pipeline: separate → save stems to disk */
async function runSupersep(job: StemJob): Promise<void> {
const jobDir = path.join(stemsBaseDir, job.id);
fs.mkdirSync(jobDir, { recursive: true });
try {
// 1. Read and convert source audio
const srcPath = resolveAudioPath(job.sourceAudioUrl);
if (!fs.existsSync(srcPath)) {
throw new Error(`Source audio not found: ${srcPath}`);
}
let srcAudioBuf: Buffer;
try {
srcAudioBuf = ensureEngineFormat(srcPath);
} catch {
srcAudioBuf = fs.readFileSync(srcPath);
}
const level = job.sepLevel ?? 0;
console.log(`[StemStudio] SuperSep job ${job.id}: level=${level}, file=${path.basename(srcPath)} (${(srcAudioBuf.length / 1024 / 1024).toFixed(1)} MB)`);
// 2. Send to ace-server SuperSep
job.status = 'separating';
job.sepMessage = 'Starting separation...';
const sepRes = await fetch(`${ACE_URL}/supersep/separate?level=${level}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: srcAudioBuf,
});
if (!sepRes.ok) {
const errText = await sepRes.text();
throw new Error(`SuperSep engine error: ${errText}`);
}
const sepData = await sepRes.json() as { id: string };
const aceJobId = sepData.id;
job.aceSupersepJobId = aceJobId;
console.log(`[StemStudio] SuperSep job ${job.id}: ace-server job ${aceJobId}`);
// 3. Poll ace-server until separation completes
const MAX_POLLS = 14400; // 2 hours max
for (let i = 0; i < MAX_POLLS; i++) {
if ((job.status as string) === 'cancelled') return;
const progRes = await fetch(`${ACE_URL}/supersep/progress?id=${aceJobId}`);
const progData = await progRes.json() as { status: string; progress: number; message: string; error?: string };
job.sepProgress = progData.progress;
job.sepMessage = progData.message;
if (progData.status === 'done') break;
if (progData.status === 'failed' || progData.status === 'cancelled') {
throw new Error(progData.error || `Separation ${progData.status}`);
}
await new Promise(r => setTimeout(r, 500));
}
// 4. Fetch stem list
const resultRes = await fetch(`${ACE_URL}/supersep/result?id=${aceJobId}`);
if (!resultRes.ok) throw new Error('Failed to fetch SuperSep result');
const resultData = await resultRes.json() as { stems: Array<{ name: string; category: string; index: number; stage?: number; hidden?: boolean }> };
const stemList = resultData.stems;
console.log(`[StemStudio] SuperSep job ${job.id}: ${stemList.length} stems to save (${stemList.filter(s => !s.hidden).length} visible)`);
// 5. Download each stem to disk (including hidden ones for debug)
job.status = 'saving';
job.savingTotal = stemList.length;
job.savingCurrent = 0;
// Only show non-hidden stems in the UI track list
job.tracks = stemList.filter(s => !s.hidden).map(s => sanitizeStemName(s.name));
for (let i = 0; i < stemList.length; i++) {
if ((job.status as string) === 'cancelled') return;
const stem = stemList[i];
const safeName = sanitizeStemName(stem.name);
job.savingCurrent = i + 1;
job.currentTrackName = stem.name;
const stemRes = await fetch(`${ACE_URL}/supersep/serve?id=${aceJobId}&stem=${stem.index}`);
if (!stemRes.ok) {
console.warn(`[StemStudio] Failed to fetch stem ${stem.index} (${stem.name}), skipping`);
continue;
}
const stemBuf = Buffer.from(await stemRes.arrayBuffer());
fs.writeFileSync(path.join(jobDir, `${safeName}.wav`), stemBuf);
// Also save into stage-N subfolder for raw per-stage debugging
const stageDir = path.join(jobDir, `stage-${stem.stage ?? 1}`);
fs.mkdirSync(stageDir, { recursive: true });
fs.writeFileSync(path.join(stageDir, `${safeName}.wav`), stemBuf);
// Only track non-hidden stems for UI
if (!stem.hidden) {
job.completedStems.push(safeName);
}
console.log(`[StemStudio] SuperSep job ${job.id}: saved ${safeName} [stage ${stem.stage ?? 1}]${stem.hidden ? ' (hidden)' : ''} (${(stemBuf.length / 1024).toFixed(0)} KB)`);
}
// 6. Write metadata
// Build stem metadata for the result endpoint — only visible stems
const visibleStems = stemList.filter(s => !s.hidden);
const stemMeta = visibleStems.map((s, idx) => ({
originalName: s.name,
safeName: sanitizeStemName(s.name),
category: s.category,
index: idx,
stage: s.stage,
}));
fs.writeFileSync(path.join(jobDir, '_meta.json'), JSON.stringify({
id: job.id,
type: 'supersep',
sourceAudioUrl: job.sourceAudioUrl,
sourceFileName: job.sourceFileName,
sepLevel: job.sepLevel,
tracks: job.completedStems,
completedStems: job.completedStems,
stemMeta,
createdAt: new Date(job.createdAt).toISOString(),
}, null, 2));
job.status = 'done';
console.log(`[StemStudio] SuperSep job ${job.id}: complete (${job.completedStems.length} stems saved)`);
} catch (err: any) {
if (job.status !== 'cancelled') {
job.status = 'failed';
job.error = err.message || 'Unknown error';
console.error(`[StemStudio] SuperSep job ${job.id}: FAILED — ${err.message}`);
}
}
}
// ── Extract Pipeline ─────────────────────────────────────────────────────
/** Run the full extraction pipeline (async — called after POST returns) */
async function runExtraction(job: StemJob, ditSettings: any, style: string, lyrics: string): Promise<void> {
const jobDir = path.join(stemsBaseDir, job.id);
fs.mkdirSync(jobDir, { recursive: true });
try {
// Read and convert source audio to WAV
const srcPath = resolveAudioPath(job.sourceAudioUrl);
if (!fs.existsSync(srcPath)) {
throw new Error(`Source audio not found: ${srcPath}`);
}
let srcAudioBuf: Buffer;
try {
srcAudioBuf = ensureEngineFormat(srcPath);
} catch {
// Fall back to raw file if conversion fails
srcAudioBuf = fs.readFileSync(srcPath);
}
console.log(`[StemStudio] Job ${job.id}: extracting ${job.tracks.length} tracks from ${path.basename(srcPath)} (${(srcAudioBuf.length / 1024 / 1024).toFixed(1)} MB)`);
// Check for turbo model (warn but don't block)
try {
const props = await aceClient.props();
const ditModels = props.models?.dit || [];
const activeModel = ditSettings?.ditModel || ditModels[0] || '';
if (activeModel.toLowerCase().includes('turbo')) {
job.warning = 'Extract requires a base/SFT model. Turbo models produce incoherent output for extraction.';
console.warn(`[StemStudio] WARNING: Turbo model detected (${activeModel}) — extract quality will be poor`);
}
} catch { /* non-fatal */ }
// Extract each track sequentially
for (let i = 0; i < job.tracks.length; i++) {
if (job.status === 'cancelled') return;
const trackName = job.tracks[i];
job.currentTrackIndex = i;
job.currentTrackName = trackName;
job.status = 'extracting';
// Log to session generations folder
const stemLogId = `${job.id}_${trackName}`;
startGenerationLog(stemLogId, 'extract');
logGeneration(stemLogId, 'INFO', `Stem extraction: ${trackName} (${i + 1}/${job.tracks.length})`);
logGeneration(stemLogId, 'INFO', `Source: ${job.sourceFileName}`);
console.log(`[StemStudio] Job ${job.id}: extracting track ${i + 1}/${job.tracks.length}${trackName}`);
// Build AceRequest for this track
const aceReq: AceRequest = {
caption: style || '',
// Only pass lyrics for the 'vocals' track — feeding them into other
// tracks (e.g. backing_vocals) forces the model to route lead vocals there
lyrics: trackName === 'vocals' ? (lyrics || '') : '',
task_type: 'extract',
track: trackName,
audio_cover_strength: 1.0, // forced — DiT sees full mix
// Use the client-specified model (forced to base/SFT)
synth_model: ditSettings?.ditModel,
// Inherit basic DiT settings (or use engine defaults)
inference_steps: ditSettings?.inferenceSteps,
infer_method: ditSettings?.inferMethod,
scheduler: ditSettings?.scheduler || 'linear',
guidance_mode: ditSettings?.guidanceMode || 'apg',
guidance_scale: ditSettings?.guidanceScale,
shift: ditSettings?.shift,
// Force-disable adapters for extraction
adapter: '',
adapter_scale: 0,
// Clear metadata — let model infer from source audio
bpm: 0,
duration: 0,
keyscale: '',
timesignature: '',
seed: Math.floor(Math.random() * 2_147_483_647),
};
logGenerationParams(stemLogId, aceReq);
// Submit via multipart (same pattern as cover mode)
const aceJobId = await aceClient.submitSynthMultipart(aceReq, srcAudioBuf, undefined, undefined, undefined, 'wav16');
job.currentAceJobId = aceJobId;
logGeneration(stemLogId, 'INFO', `Engine job submitted: ${aceJobId}`);
// Poll until done
await pollAceJob(aceJobId, job);
// Fetch audio result
const audioRes = await aceClient.getJobResult(aceJobId);
const audioBuf = Buffer.from(await audioRes.arrayBuffer());
const stemPath = path.join(jobDir, `${trackName}.wav`);
fs.writeFileSync(stemPath, audioBuf);
job.completedStems.push(trackName);
logGeneration(stemLogId, 'INFO', `Complete: ${(audioBuf.length / 1024).toFixed(0)} KB`);
finishGenerationLog(stemLogId, 'extract');
console.log(`[StemStudio] Job ${job.id}: ${trackName} complete (${(audioBuf.length / 1024).toFixed(0)} KB)`);
}
// Write metadata file
fs.writeFileSync(path.join(jobDir, '_meta.json'), JSON.stringify({
id: job.id,
type: 'extract',
sourceAudioUrl: job.sourceAudioUrl,
sourceFileName: job.sourceFileName,
tracks: job.tracks,
completedStems: job.completedStems,
createdAt: new Date(job.createdAt).toISOString(),
}, null, 2));
job.status = 'done';
console.log(`[StemStudio] Job ${job.id}: extraction complete (${job.completedStems.length} stems)`);
} catch (err: any) {
if (job.status !== 'cancelled') {
job.status = 'failed';
job.error = err.message || 'Unknown error';
console.error(`[StemStudio] Job ${job.id}: FAILED — ${err.message}`);
}
}
}
// ── Routes ───────────────────────────────────────────────────────────────
/**
* POST /extract — Start a new extraction job
*/
router.post('/extract', (req: Request, res: Response) => {
const { sourceAudioUrl, sourceFileName, tracks, style, lyrics, ditSettings } = req.body;
// Validate
if (!sourceAudioUrl) {
res.status(400).json({ error: 'sourceAudioUrl is required' });
return;
}
if (!tracks || !Array.isArray(tracks) || tracks.length === 0) {
res.status(400).json({ error: 'tracks must be a non-empty array' });
return;
}
const invalidTracks = tracks.filter((t: string) => !VALID_TRACKS.includes(t));
if (invalidTracks.length > 0) {
res.status(400).json({ error: `Invalid track names: ${invalidTracks.join(', ')}` });
return;
}
// Create job
const job: StemJob = {
id: randomUUID(),
type: 'extract',
status: 'pending',
sourceAudioUrl,
sourceFileName: sourceFileName || 'unknown',
tracks,
currentTrackIndex: 0,
currentTrackName: tracks[0],
completedStems: [],
createdAt: Date.now(),
};
jobs.set(job.id, job);
// Start extraction async
runExtraction(job, ditSettings || {}, style || '', lyrics || '');
console.log(`[StemStudio] Job ${job.id} created: ${tracks.length} tracks from ${sourceFileName || sourceAudioUrl}`);
res.json({ id: job.id });
});
/**
* POST /supersep — Start a new SuperSep separation job
*/
router.post('/supersep', (req: Request, res: Response) => {
const { sourceAudioUrl, sourceFileName, level } = req.body;
if (!sourceAudioUrl) {
res.status(400).json({ error: 'sourceAudioUrl is required' });
return;
}
const sepLevel = parseInt(String(level ?? '0'), 10);
const job: StemJob = {
id: randomUUID(),
type: 'supersep',
status: 'pending',
sourceAudioUrl,
sourceFileName: sourceFileName || 'unknown',
tracks: [],
currentTrackIndex: 0,
currentTrackName: '',
completedStems: [],
createdAt: Date.now(),
sepLevel,
};
jobs.set(job.id, job);
// Start separation async
runSupersep(job);
console.log(`[StemStudio] SuperSep job ${job.id} created: level=${sepLevel} from ${sourceFileName || sourceAudioUrl}`);
res.json({ id: job.id });
});
/**
* GET /:jobId/progress — Poll job progress (works for both extract and supersep)
*/
router.get('/:jobId/progress', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const job = jobs.get(jobId);
if (!job) {
// Check if this is a completed job on disk (server restarted)
const metaPath = path.join(stemsBaseDir, jobId, '_meta.json');
if (fs.existsSync(metaPath)) {
res.json({
status: 'done',
progress: 100,
currentTrack: '',
completedStems: JSON.parse(fs.readFileSync(metaPath, 'utf-8')).completedStems || [],
totalTracks: 0,
});
return;
}
res.status(404).json({ error: 'Job not found' });
return;
}
let progress = 0;
const totalTracks = job.tracks.length;
const completedCount = job.completedStems.length;
if (job.type === 'supersep') {
// SuperSep progress: separation phase (0-80%) + saving phase (80-100%)
if (job.status === 'separating') {
progress = Math.round((job.sepProgress || 0) * 0.8);
} else if (job.status === 'saving') {
const saveProgress = job.savingTotal ? (job.savingCurrent || 0) / job.savingTotal : 0;
progress = Math.round(80 + saveProgress * 20);
} else if (job.status === 'done') {
progress = 100;
}
} else {
// Extract progress: each track is an equal share
if (totalTracks > 0) {
const perTrack = 100 / totalTracks;
progress = Math.round(completedCount * perTrack + (job.status === 'extracting' ? perTrack * 0.5 : 0));
}
if (job.status === 'done') progress = 100;
}
res.json({
status: job.status,
progress,
currentTrack: job.currentTrackName,
completedStems: job.completedStems,
totalTracks: job.type === 'supersep' ? (job.savingTotal || 0) : totalTracks,
warning: job.warning,
error: job.error,
// SuperSep-specific extras
sepMessage: job.sepMessage,
});
});
/**
* GET /:jobId/result — Get completed stem metadata
*/
router.get('/:jobId/result', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const jobDir = path.join(stemsBaseDir, jobId);
const metaPath = path.join(jobDir, '_meta.json');
if (!fs.existsSync(metaPath)) {
res.status(404).json({ error: 'Job not found or not complete' });
return;
}
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
// SuperSep jobs have stemMeta with original names + categories;
// Extract jobs just have completedStems (track names == filenames)
const stemMetaList: Array<{ originalName: string; safeName: string; category: string; index: number; stage?: number }> | undefined = meta.stemMeta;
const stems = (meta.completedStems || []).map((safeName: string, idx: number) => {
const stemPath = path.join(jobDir, `${safeName}.wav`);
const stat = fs.existsSync(stemPath) ? fs.statSync(stemPath) : null;
// Use stemMeta for display name/category if available (supersep), else use track name
const sMeta = stemMetaList?.find(m => m.safeName === safeName);
return {
trackName: sMeta?.originalName || safeName,
category: sMeta?.category || undefined,
audioUrl: `/api/stem-studio/${jobId}/stem/${safeName}`,
durationSec: 0,
index: idx,
sizeBytes: stat?.size || 0,
stage: sMeta?.stage,
};
});
res.json({ id: jobId, type: meta.type || 'extract', stems });
});
/**
* GET /:jobId/stem/:trackName — Serve individual stem WAV
*/
router.get('/:jobId/stem/:trackName', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const trackName = req.params.trackName as string;
const stemPath = path.join(stemsBaseDir, jobId, `${trackName}.wav`);
if (!fs.existsSync(stemPath)) {
res.status(404).json({ error: `Stem not found: ${trackName}` });
return;
}
res.setHeader('Content-Type', 'audio/wav');
res.setHeader('Content-Disposition', `inline; filename="${trackName}.wav"`);
fs.createReadStream(stemPath).pipe(res);
});
/**
* GET /:jobId/download-all — Download all stems as ZIP
*/
router.get('/:jobId/download-all', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const jobDir = path.join(stemsBaseDir, jobId);
const metaPath = path.join(jobDir, '_meta.json');
if (!fs.existsSync(metaPath)) {
res.status(404).json({ error: 'Job not found' });
return;
}
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
const sourceBase = (meta.sourceFileName || 'stems').replace(/\.[^.]+$/, '');
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${sourceBase}-stems.zip"`);
const archive = archiver('zip', { zlib: { level: 1 } }); // Fast compression for large WAVs
archive.pipe(res);
for (const trackName of (meta.completedStems || [])) {
const stemPath = path.join(jobDir, `${trackName}.wav`);
if (fs.existsSync(stemPath)) {
archive.file(stemPath, { name: `${sourceBase}/${trackName}.wav` });
}
}
archive.finalize();
});
/**
* GET /jobs — List all past extraction jobs
*/
router.get('/jobs', (_req: Request, res: Response) => {
try {
if (!fs.existsSync(stemsBaseDir)) {
res.json([]);
return;
}
const entries = fs.readdirSync(stemsBaseDir, { withFileTypes: true });
const jobSummaries = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const metaPath = path.join(stemsBaseDir, entry.name, '_meta.json');
if (!fs.existsSync(metaPath)) continue;
try {
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
jobSummaries.push({
id: meta.id || entry.name,
type: meta.type || 'extract',
sourceFileName: meta.sourceFileName || 'unknown',
tracks: meta.tracks || [],
completedStems: meta.completedStems || [],
createdAt: meta.createdAt || '',
sepLevel: meta.sepLevel,
});
} catch { /* skip corrupted meta */ }
}
// Sort newest first
jobSummaries.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
res.json(jobSummaries);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
/**
* DELETE /:jobId — Delete a single extraction job
*/
router.delete('/:jobId', (req: Request, res: Response) => {
const jobId = req.params.jobId as string;
const jobDir = path.join(stemsBaseDir, jobId);
// Cancel if still running
const job = jobs.get(jobId);
if (job && (job.status === 'pending' || job.status === 'extracting')) {
job.status = 'cancelled';
}
jobs.delete(jobId);
if (fs.existsSync(jobDir)) {
fs.rmSync(jobDir, { recursive: true, force: true });
console.log(`[StemStudio] Deleted job ${jobId}`);
res.json({ ok: true });
} else {
res.status(404).json({ error: 'Job not found' });
}
});
/**
* DELETE /all — Delete ALL stem data (used by Settings page)
*/
router.delete('/all', (_req: Request, res: Response) => {
// Cancel all running jobs
for (const [, job] of jobs) {
if (job.status === 'pending' || job.status === 'extracting') {
job.status = 'cancelled';
}
}
jobs.clear();
if (fs.existsSync(stemsBaseDir)) {
fs.rmSync(stemsBaseDir, { recursive: true, force: true });
fs.mkdirSync(stemsBaseDir, { recursive: true });
console.log('[StemStudio] All stems cleared');
}
res.json({ ok: true });
});
/**
* GET /stats — Stem storage statistics (for Settings page)
*/
router.get('/stats', (_req: Request, res: Response) => {
try {
if (!fs.existsSync(stemsBaseDir)) {
res.json({ totalBytes: 0, jobCount: 0, stemCount: 0 });
return;
}
let totalBytes = 0;
let jobCount = 0;
let stemCount = 0;
const entries = fs.readdirSync(stemsBaseDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
jobCount++;
const jobDir = path.join(stemsBaseDir, entry.name);
const files = fs.readdirSync(jobDir);
for (const file of files) {
if (file.endsWith('.wav')) {
stemCount++;
totalBytes += fs.statSync(path.join(jobDir, file)).size;
}
}
}
res.json({ totalBytes, jobCount, stemCount });
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;
+151
View File
@@ -0,0 +1,151 @@
// supersep.ts — SuperSep stem separation route (proxy to ace-server)
//
// Routes:
// POST /api/supersep/separate — start separation (reads file from disk)
// GET /api/supersep/:jobId/progress — poll job progress
// GET /api/supersep/:jobId/result — get stem list metadata
// GET /api/supersep/:jobId/stem/:index — download individual stem WAV
// POST /api/supersep/recombine — remix stems with volume/mute controls
import { Router } from 'express';
import { config } from '../config.js';
import { ensureEngineFormat } from '../services/audioConvert.js';
import path from 'path';
const router = Router();
const ACE_URL = config.aceServer.url;
// POST /api/supersep/separate
// Body (JSON): { audioUrl: "/references/uuid.flac" }
// Query: level=0..5 (BASIC/VOCAL_SPLIT/FULL/MAXIMUM/VOCALS_ONLY/STABLESTEP)
// 4 = 2-stem via the 6-stem BS-RoFormer (instrumental = mix vocals)
// 5 = 2-stem via the dual BS-Roformer-Leap Xe models (both stems neural)
// See SuperSepLevel in engine/src/supersep.h.
// Reads the file from disk, converts non-WAV/MP3 to WAV, forwards to ace-server.
router.post('/separate', async (req, res) => {
try {
const level = parseInt(String(req.query.level ?? '0'), 10);
const { audioUrl } = req.body || {};
if (!audioUrl || typeof audioUrl !== 'string') {
return res.status(400).json({ error: 'audioUrl required in request body' });
}
// Resolve server-side file path from URL
// audioUrl is like "/references/uuid.flac" → data/references/uuid.flac
const basename = path.basename(audioUrl);
let filePath: string;
if (audioUrl.startsWith('/references/')) {
filePath = path.join(config.data.dir, 'references', basename);
} else if (audioUrl.startsWith('/audio/')) {
filePath = path.join(config.data.audioDir, basename);
} else {
// Fallback: try in references
filePath = path.join(config.data.dir, 'references', basename);
}
console.log(`[SuperSep] separate: level=${level}, file=${filePath}`);
// Read and convert to engine-compatible format (WAV/MP3)
let audioBody: Buffer;
try {
audioBody = ensureEngineFormat(filePath);
} catch (err: any) {
console.error(`[SuperSep] Format conversion failed:`, err.message);
return res.status(400).json({ error: `Audio conversion failed: ${err.message}` });
}
console.log(`[SuperSep] Forwarding ${audioBody.length} bytes to ace-server`);
// Forward WAV/MP3 body to ace-server
const aceRes = await fetch(
`${ACE_URL}/supersep/separate?level=${level}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: audioBody,
}
);
if (!aceRes.ok) {
const err = await aceRes.text();
console.error(`[SuperSep] ace-server returned ${aceRes.status}: ${err}`);
return res.status(aceRes.status).json({ error: err });
}
const data = await aceRes.json();
res.json(data);
} catch (err: any) {
console.error('[SuperSep] separate error:', err.message);
res.status(500).json({ error: err.message });
}
});
// GET /api/supersep/:jobId/progress
router.get('/:jobId/progress', async (req, res) => {
try {
const aceRes = await fetch(
`${ACE_URL}/supersep/progress?id=${req.params.jobId}`
);
const data = await aceRes.json();
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// GET /api/supersep/:jobId/result
router.get('/:jobId/result', async (req, res) => {
try {
const aceRes = await fetch(
`${ACE_URL}/supersep/result?id=${req.params.jobId}`
);
if (!aceRes.ok) {
return res.status(aceRes.status).json(await aceRes.json());
}
const data = await aceRes.json();
res.json(data);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// GET /api/supersep/:jobId/stem/:index — proxy WAV download
router.get('/:jobId/stem/:index', async (req, res) => {
try {
const aceRes = await fetch(
`${ACE_URL}/supersep/serve?id=${req.params.jobId}&stem=${req.params.index}`
);
if (!aceRes.ok) {
return res.status(aceRes.status).json({ error: 'Failed to fetch stem' });
}
const buf = Buffer.from(await aceRes.arrayBuffer());
res.set('Content-Type', 'audio/wav');
res.set('Content-Disposition', `attachment; filename="stem_${req.params.index}.wav"`);
res.send(buf);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/supersep/recombine — remix stems and return WAV
router.post('/recombine', async (req, res) => {
try {
const aceRes = await fetch(`${ACE_URL}/supersep/recombine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(req.body),
});
if (!aceRes.ok) {
return res.status(aceRes.status).json(await aceRes.json());
}
const buf = Buffer.from(await aceRes.arrayBuffer());
res.set('Content-Type', 'audio/wav');
res.send(buf);
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
export default router;
File diff suppressed because it is too large Load Diff
+174
View File
@@ -0,0 +1,174 @@
/**
* upload.ts — Audio file upload route for Cover Studio
*
* POST /api/upload/audio — Upload audio file, save to data/references/
*/
import { Router, Request, Response } from 'express';
import path from 'path';
import fs from 'fs';
import { randomUUID } from 'crypto';
import multer from 'multer';
import { config } from '../config.js';
import { readHslat } from '../services/latentFormat.js';
const router = Router();
const ALLOWED_EXTENSIONS = ['.mp3', '.wav', '.flac', '.m4a', '.mp4', '.ogg', '.opus', '.webm', '.aac'];
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 200 * 1024 * 1024 }, // 200MB
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ALLOWED_EXTENSIONS.includes(ext) || file.mimetype.startsWith('audio/') || file.mimetype === 'application/octet-stream') {
cb(null, true);
} else {
cb(new Error(`Invalid file type "${file.originalname}" (${file.mimetype}). Allowed: ${ALLOWED_EXTENSIONS.join(', ')}`));
}
},
});
/**
* POST /api/upload/audio
* Multipart form: field "audio" with audio file
* Returns: { audio_url: "/references/<uuid>.<ext>", filename: "original.mp3" }
*/
router.post('/audio', upload.single('audio'), (req: Request, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const ext = path.extname(req.file.originalname).toLowerCase() || '.mp3';
const filename = `${randomUUID()}${ext}`;
const refsDir = path.join(config.data.dir, 'references');
// Ensure references dir exists
fs.mkdirSync(refsDir, { recursive: true });
const filePath = path.join(refsDir, filename);
fs.writeFileSync(filePath, req.file.buffer);
console.log(`[upload] Saved ${req.file.originalname} (${(req.file.size / 1024 / 1024).toFixed(1)} MB) → ${filename}`);
res.json({
audio_url: `/references/${filename}`,
filename: req.file.originalname,
});
} catch (err: any) {
console.error('[upload] Failed:', err.message);
res.status(500).json({ error: 'Upload failed', details: err.message });
}
});
/**
* POST /api/upload/latent
* Multipart form: field "latent" with .latent file
* Returns: { latent_url: "/references/<uuid>.latent", metadata: { ... } }
*
* Accepts both HSLAT-headerered files and raw float32 files.
* If HSLAT, embedded metadata is returned for UI pre-population.
*/
const latentUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 50 * 1024 * 1024 }, // 50MB (latents are small — typically <1MB)
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
// Accept .latent, .hslat, or generic octet-stream (browsers often use this for custom extensions)
if (ext === '.latent' || ext === '.hslat' || file.mimetype === 'application/octet-stream') {
cb(null, true);
} else {
cb(new Error(`Invalid file type "${file.originalname}". Expected .latent or .hslat file.`));
}
},
});
router.post('/latent', latentUpload.single('latent'), (req: Request, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const buf = req.file.buffer;
let metadata: Record<string, unknown> = {};
try {
const parsed = readHslat(buf);
metadata = parsed.metadata;
// Validate raw latent portion
if (parsed.rawLatent.length > 0 && parsed.rawLatent.length % 256 !== 0) {
res.status(400).json({
error: `Latent data size ${parsed.rawLatent.length} is not a multiple of 256 bytes (64 × float32)`,
});
return;
}
} catch (parseErr: any) {
res.status(400).json({ error: `Invalid latent file: ${parseErr.message}` });
return;
}
const filename = `${randomUUID()}.latent`;
const refsDir = path.join(config.data.dir, 'references');
fs.mkdirSync(refsDir, { recursive: true });
const filePath = path.join(refsDir, filename);
fs.writeFileSync(filePath, buf);
console.log(`[upload] Latent saved: ${req.file.originalname} (${(buf.length / 1024).toFixed(0)} KB) → ${filename}`);
res.json({
latent_url: `/references/${filename}`,
filename: req.file.originalname,
metadata,
});
} catch (err: any) {
console.error('[upload] Latent upload failed:', err.message);
res.status(500).json({ error: 'Latent upload failed', details: err.message });
}
});
// ── Cover image upload (metadata editor, #60) ────────────────────────────────
const ALLOWED_IMAGE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp'];
const imageUpload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (ALLOWED_IMAGE_EXTENSIONS.includes(ext) || file.mimetype.startsWith('image/')) {
cb(null, true);
} else {
cb(new Error(`Invalid image type "${file.originalname}" (${file.mimetype}). Allowed: ${ALLOWED_IMAGE_EXTENSIONS.join(', ')}`));
}
},
});
/**
* POST /api/upload/cover-image
* Multipart form: field "image" with an image file.
* Saves to data/audio/ (served at /audio/, where gatherSongMetadata looks for
* cover art to embed on export). Returns: { cover_url: "/audio/<uuid>.<ext>" }
*/
router.post('/cover-image', imageUpload.single('image'), (req: Request, res: Response) => {
try {
if (!req.file) {
res.status(400).json({ error: 'No file uploaded' });
return;
}
const ext = path.extname(req.file.originalname).toLowerCase() || '.png';
const filename = `${randomUUID()}${ext}`;
fs.mkdirSync(config.data.audioDir, { recursive: true });
const filePath = path.join(config.data.audioDir, filename);
fs.writeFileSync(filePath, req.file.buffer);
console.log(`[upload] Cover image saved: ${req.file.originalname} (${(req.file.size / 1024).toFixed(0)} KB) → ${filename}`);
res.json({ cover_url: `/audio/${filename}`, filename: req.file.originalname });
} catch (err: any) {
console.error('[upload] Cover image upload failed:', err.message);
res.status(500).json({ error: 'Cover image upload failed', details: err.message });
}
});
export default router;
+624
View File
@@ -0,0 +1,624 @@
// vst.ts — VST3 Post-Processing routes
//
// Endpoints:
// GET /api/vst/scan — Scan for installed VST3 plugins
// GET /api/vst/chain — Get current chain config
// PUT /api/vst/chain — Update chain config
// POST /api/vst/gui — Launch plugin GUI (native window)
// POST /api/vst/process — Process audio through the full chain
import { Router } from 'express';
import fs from 'fs';
import path from 'path';
import { execFile, spawn, ChildProcess } from 'child_process';
import { promisify } from 'util';
import { config } from '../config.js';
const execFileAsync = promisify(execFile);
const router = Router();
// ── Types ───────────────────────────────────────────────────
export interface VstPlugin {
name: string;
vendor: string;
version: string;
path: string;
uid: string;
subcategories: string;
}
export interface ChainEntry {
uid: string;
name: string;
vendor: string;
path: string; // .vst3 module path
enabled: boolean;
statePath: string; // .vststate file path (may not exist yet)
}
interface ChainConfig {
plugins: ChainEntry[];
}
// ── Helpers ─────────────────────────────────────────────────
function ensureDirs(): void {
fs.mkdirSync(config.vst.statesDir, { recursive: true });
fs.mkdirSync(path.dirname(config.vst.chainFile), { recursive: true });
}
function loadChain(): ChainConfig {
ensureDirs();
try {
if (fs.existsSync(config.vst.chainFile)) {
const raw = fs.readFileSync(config.vst.chainFile, 'utf-8');
return JSON.parse(raw) as ChainConfig;
}
} catch (err) {
console.error('[VST] Failed to load chain config:', err);
}
return { plugins: [] };
}
function saveChain(chain: ChainConfig): void {
ensureDirs();
fs.writeFileSync(config.vst.chainFile, JSON.stringify(chain, null, 2), 'utf-8');
}
function statePathForPlugin(uid: string): string {
return path.join(config.vst.statesDir, `${uid}.vststate`);
}
// Cached scan results (scanning 40 plugins takes ~2-3 seconds)
let cachedPlugins: VstPlugin[] | null = null;
// ── GET /scan — Scan for installed VST3 plugins ─────────────
router.get('/scan', async (_req, res) => {
try {
const exe = config.vst.exe;
if (!fs.existsSync(exe)) {
res.status(503).json({
error: `vst-host.exe not found at ${exe}`,
hint: 'Rebuild the engine with: engine/build.cmd',
});
return;
}
console.log('[VST] Scanning for plugins...');
const { stdout, stderr } = await execFileAsync(exe, ['--scan'], {
timeout: 30_000,
maxBuffer: 1024 * 1024, // 1MB should be plenty for JSON
});
if (stderr) {
for (const line of stderr.split('\n')) {
if (line.trim()) console.log(`[VST] ${line.trim()}`);
}
}
if (!stdout || stdout.trim().length === 0) {
cachedPlugins = [];
res.json({ plugins: [] });
return;
}
const plugins: VstPlugin[] = JSON.parse(stdout);
cachedPlugins = plugins;
console.log(`[VST] Found ${plugins.length} plugin(s)`);
res.json({ plugins });
} catch (err: any) {
console.error('[VST] Scan failed:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── GET /chain — Get current chain config ───────────────────
router.get('/chain', (_req, res) => {
const chain = loadChain();
res.json(chain);
});
// ── PUT /chain — Update chain config ────────────────────────
router.put('/chain', (req, res) => {
const { plugins } = req.body as ChainConfig;
if (!Array.isArray(plugins)) {
res.status(400).json({ error: 'plugins array required' });
return;
}
// Validate and ensure state paths
const validated: ChainEntry[] = plugins.map(p => ({
uid: p.uid,
name: p.name,
vendor: p.vendor || '',
path: p.path,
enabled: p.enabled !== false,
statePath: p.statePath || statePathForPlugin(p.uid),
}));
const chain: ChainConfig = { plugins: validated };
saveChain(chain);
console.log(`[VST] Chain updated: ${validated.length} plugin(s), ${validated.filter(p => p.enabled).length} enabled`);
res.json(chain);
});
// ── POST /gui — Launch plugin GUI ───────────────────────────
router.post('/gui', (req, res) => {
const { pluginPath, uid } = req.body;
if (!pluginPath) {
res.status(400).json({ error: 'pluginPath required' });
return;
}
const exe = config.vst.exe;
if (!fs.existsSync(exe)) {
res.status(503).json({ error: 'vst-host.exe not found' });
return;
}
const statePath = uid ? statePathForPlugin(uid) : '';
const args = ['--gui', '--plugin', pluginPath];
if (statePath) {
args.push('--state', statePath);
}
console.log(`[VST] Launching GUI: ${path.basename(pluginPath)}`);
// Spawn detached — the GUI process lives independently
const child = spawn(exe, args, {
detached: true,
stdio: 'ignore',
});
child.unref();
res.json({ ok: true, pid: child.pid });
});
// ── POST /process — Process audio through the VST chain ─────
router.post('/process', async (req, res) => {
const { inputPath, outputPath } = req.body;
if (!inputPath || !outputPath) {
res.status(400).json({ error: 'inputPath and outputPath required' });
return;
}
const exe = config.vst.exe;
if (!fs.existsSync(exe)) {
res.status(503).json({ error: 'vst-host.exe not found' });
return;
}
if (!fs.existsSync(inputPath)) {
res.status(404).json({ error: `Input file not found: ${inputPath}` });
return;
}
try {
const chain = loadChain();
const enabled = chain.plugins.filter(p => p.enabled);
if (enabled.length === 0) {
// No plugins enabled — just copy
fs.copyFileSync(inputPath, outputPath);
res.json({ ok: true, skipped: true });
return;
}
// Write temporary chain JSON for vst-host.exe
const tempChainFile = path.join(config.vst.statesDir, `_temp_chain_${Date.now()}.json`);
const chainData = {
plugins: enabled.map(p => ({
path: p.path,
state: fs.existsSync(p.statePath) ? p.statePath : '',
enabled: true,
})),
};
fs.writeFileSync(tempChainFile, JSON.stringify(chainData), 'utf-8');
console.log(`[VST] Processing through ${enabled.length} plugin(s):`);
for (const p of enabled) {
console.log(`[VST] → ${p.name} (${p.vendor})`);
}
const startTime = Date.now();
const { stderr } = await execFileAsync(exe, [
'--process-chain',
'--chain', tempChainFile,
'--input', inputPath,
'--output', outputPath,
], { timeout: 300_000 }); // 5 min timeout
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (stderr) {
for (const line of stderr.split('\n')) {
if (line.trim()) console.log(`[VST] ${line.trim()}`);
}
}
// Clean up temp chain file
try { fs.unlinkSync(tempChainFile); } catch {}
console.log(`[VST] Processing complete in ${elapsed}s → ${path.basename(outputPath)}`);
res.json({ ok: true, elapsed: parseFloat(elapsed) });
} catch (err: any) {
console.error('[VST] Process failed:', err.message);
res.status(500).json({ error: err.message });
}
});
// ── Monitor — Real-time playback through VST chain ──────────
let monitorProcess: ChildProcess | null = null;
let monitorPaused = false;
let monitorCurrentTrack = ''; // track path currently loaded in monitor
const monitorControlFile = () => path.join(config.vst.statesDir, 'monitor_control.json');
const monitorStatusFile = () => path.join(config.vst.statesDir, 'monitor_status.json');
function writeMonitorControl(data: Record<string, unknown>): void {
fs.writeFileSync(monitorControlFile(), JSON.stringify(data), 'utf-8');
}
function isMonitorAlive(): boolean {
if (!monitorProcess) return false;
try {
// Sending signal 0 tests if the process is alive (throws if dead)
process.kill(monitorProcess.pid!, 0);
return true;
} catch {
monitorProcess = null;
return false;
}
}
/** Resolve a trackPath (from the UI) to an absolute filesystem path.
* Handles: "/audio/uuid.wav", "http://localhost:3001/audio/uuid.wav",
* "D:\\...\\uuid.wav", and bare "uuid.wav". */
function resolveTrackPath(trackPath: string): string {
// Strip URL origin if present
let p = trackPath;
try {
const url = new URL(p);
p = url.pathname; // "/audio/uuid.wav"
} catch { /* not a full URL */ }
// If it's a Windows absolute path with drive letter, keep it
if (/^[A-Za-z]:[\\/]/.test(p)) return p;
// Otherwise extract just the filename and resolve from audioDir
const filename = path.basename(p);
return path.join(config.data.audioDir, filename);
}
// POST /monitor/start — Start real-time monitoring
router.post('/monitor/start', (req, res) => {
const { trackPath } = req.body;
if (!trackPath) {
res.status(400).json({ error: 'trackPath required' });
return;
}
const absTrackPath = resolveTrackPath(trackPath);
if (!fs.existsSync(absTrackPath)) {
res.status(404).json({ error: `Track not found: ${absTrackPath}` });
return;
}
const exe = config.vst.exe;
if (!fs.existsSync(exe)) {
res.status(503).json({ error: 'vst-host.exe not found' });
return;
}
// Kill existing monitor if running
if (isMonitorAlive()) {
writeMonitorControl({ action: 'stop' });
setTimeout(() => {
try { monitorProcess?.kill(); } catch {}
monitorProcess = null;
}, 1000);
}
// Write temp chain JSON for the monitor (uses statePath for state files)
const chain = loadChain();
const enabled = chain.plugins.filter(p => p.enabled);
if (enabled.length === 0) {
res.status(400).json({ error: 'No enabled plugins in chain' });
return;
}
const tempChainFile = path.join(config.vst.statesDir, '_monitor_chain.json');
const chainData = {
plugins: enabled.map(p => ({
path: p.path,
state: fs.existsSync(p.statePath) ? p.statePath : '',
enabled: true,
})),
};
fs.writeFileSync(tempChainFile, JSON.stringify(chainData), 'utf-8');
// Write initial control file
writeMonitorControl({ track: absTrackPath, action: 'play' });
monitorPaused = false;
monitorCurrentTrack = absTrackPath;
console.log(`[VST] Starting monitor: ${enabled.length} plugin(s), track=${path.basename(absTrackPath)}`);
const child = spawn(exe, [
'--monitor',
'--chain', tempChainFile,
'--input', absTrackPath,
'--control', monitorControlFile(),
'--status', monitorStatusFile(),
], {
stdio: ['ignore', 'ignore', 'pipe'],
});
monitorProcess = child;
// Log stderr
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n')) {
if (line.trim()) console.log(`[VST] ${line.trim()}`);
}
});
child.on('exit', (code) => {
console.log(`[VST] Monitor exited (code ${code})`);
monitorProcess = null;
// Clean up temp chain
try { fs.unlinkSync(tempChainFile); } catch {}
});
res.json({ ok: true, pid: child.pid, plugins: enabled.length });
});
// POST /monitor/stop — Stop monitoring
router.post('/monitor/stop', (_req, res) => {
if (!isMonitorAlive()) {
res.json({ ok: true, wasRunning: false });
return;
}
writeMonitorControl({ action: 'stop' });
monitorPaused = false;
monitorCurrentTrack = '';
// Give it a moment to save state gracefully, then force-kill
setTimeout(() => {
if (isMonitorAlive()) {
try { monitorProcess?.kill(); } catch {}
monitorProcess = null;
}
}, 3000);
res.json({ ok: true, wasRunning: true });
});
// POST /monitor/switch — Switch to a different track
router.post('/monitor/switch', (req, res) => {
const { trackPath } = req.body;
if (!trackPath) {
res.status(400).json({ error: 'trackPath required' });
return;
}
if (!isMonitorAlive()) {
res.status(400).json({ error: 'Monitor is not running' });
return;
}
const absTrackPath = resolveTrackPath(trackPath);
if (!fs.existsSync(absTrackPath)) {
res.status(404).json({ error: `Track not found: ${absTrackPath}` });
return;
}
console.log(`[VST] Monitor switching track → ${path.basename(absTrackPath)}`);
writeMonitorControl({ track: absTrackPath, action: 'play' });
res.json({ ok: true });
});
// GET /monitor/status — Is the monitor running? Returns position too.
router.get('/monitor/status', (_req, res) => {
const running = isMonitorAlive();
let position = 0;
let duration = 0;
if (running) {
try {
const statusPath = monitorStatusFile();
if (fs.existsSync(statusPath)) {
const raw = fs.readFileSync(statusPath, 'utf-8');
const data = JSON.parse(raw);
position = data.position || 0;
duration = data.duration || 0;
}
} catch { /* ignore parse errors */ }
}
res.json({ running, paused: monitorPaused, pid: monitorProcess?.pid || null, position, duration });
});
// POST /monitor/seek — Seek to a position in seconds
router.post('/monitor/seek', (req, res) => {
const { position } = req.body;
if (typeof position !== 'number') {
res.status(400).json({ error: 'position (number) required' });
return;
}
if (!isMonitorAlive()) {
res.status(400).json({ error: 'Monitor is not running' });
return;
}
// Read current control file and add seek field
let controlData: Record<string, unknown> = { action: 'play' };
try {
const raw = fs.readFileSync(monitorControlFile(), 'utf-8');
controlData = JSON.parse(raw);
} catch { /* start fresh */ }
controlData.seek = position;
writeMonitorControl(controlData);
res.json({ ok: true, position });
});
// POST /monitor/pause — pause playback
router.post('/monitor/pause', (_req, res) => {
if (!isMonitorAlive()) {
res.status(400).json({ error: 'Monitor is not running' });
return;
}
monitorPaused = true;
writeMonitorControl({ action: 'pause' });
res.json({ ok: true });
});
// POST /monitor/resume — resume playback
router.post('/monitor/resume', (_req, res) => {
if (!isMonitorAlive()) {
res.status(400).json({ error: 'Monitor is not running' });
return;
}
monitorPaused = false;
let ctrl: Record<string, unknown> = {};
try {
const raw = fs.readFileSync(monitorControlFile(), 'utf-8');
ctrl = JSON.parse(raw);
} catch { /* start fresh */ }
writeMonitorControl({ ...ctrl, action: 'play' });
res.json({ ok: true });
});
// POST /monitor/restart — kill and restart monitor, reloading state files from disk
// This is how GUI parameter changes take effect in the monitor.
router.post('/monitor/restart', async (_req, res) => {
const exe = config.vst.exe;
const track = monitorCurrentTrack;
if (!track || !fs.existsSync(track)) {
res.status(400).json({ error: 'No track loaded in monitor' });
return;
}
if (!fs.existsSync(exe)) {
res.status(503).json({ error: 'vst-host.exe not found' });
return;
}
// Gracefully stop the existing process
if (isMonitorAlive()) {
writeMonitorControl({ action: 'stop' });
try { monitorProcess?.kill(); } catch {}
monitorProcess = null;
}
monitorPaused = false;
// Brief pause for process cleanup and state file flush
await new Promise(r => setTimeout(r, 800));
// Reload chain — picks up any GUI state file changes
const chain = loadChain();
const enabled = chain.plugins.filter(p => p.enabled);
if (enabled.length === 0) {
res.status(400).json({ error: 'No enabled plugins in chain' });
return;
}
const tempChainFile = path.join(config.vst.statesDir, '_monitor_chain.json');
const chainData = {
plugins: enabled.map(p => ({
path: p.path,
state: fs.existsSync(p.statePath) ? p.statePath : '',
enabled: true,
})),
};
fs.writeFileSync(tempChainFile, JSON.stringify(chainData), 'utf-8');
writeMonitorControl({ track, action: 'play' });
monitorCurrentTrack = track;
console.log(`[VST] Restarting monitor: ${enabled.length} plugin(s), track=${path.basename(track)}`);
const child = spawn(exe, [
'--monitor',
'--chain', tempChainFile,
'--input', track,
'--control', monitorControlFile(),
'--status', monitorStatusFile(),
], { stdio: ['ignore', 'ignore', 'pipe'] });
monitorProcess = child;
child.stderr?.on('data', (data: Buffer) => {
for (const line of data.toString().split('\n')) {
if (line.trim()) console.log(`[VST] ${line.trim()}`);
}
});
child.on('exit', (code) => {
console.log(`[VST] Monitor exited (code ${code})`);
monitorProcess = null;
try { fs.unlinkSync(tempChainFile); } catch {}
});
res.json({ ok: true, pid: child.pid, plugins: enabled.length });
});
/**
* Apply the VST chain to a WAV file in-place.
* Returns true if processing was applied, false if skipped.
*/
export async function applyVstChain(wavPath: string): Promise<boolean> {
const chain = loadChain();
const enabled = chain.plugins.filter(p => p.enabled);
if (enabled.length === 0) return false;
const exe = config.vst.exe;
if (!fs.existsSync(exe)) {
console.warn('[VST] vst-host.exe not found, skipping chain');
return false;
}
// Process in-place: output to temp, then replace
const tempOut = wavPath + '.vst_processed.wav';
const tempChain = path.join(config.vst.statesDir, `_chain_${Date.now()}.json`);
try {
ensureDirs();
const chainData = {
plugins: enabled.map(p => ({
path: p.path,
state: fs.existsSync(p.statePath) ? p.statePath : '',
enabled: true,
})),
};
fs.writeFileSync(tempChain, JSON.stringify(chainData), 'utf-8');
console.log(`[VST] Applying chain (${enabled.length} plugins) to ${path.basename(wavPath)}`);
await execFileAsync(exe, [
'--process-chain',
'--chain', tempChain,
'--input', wavPath,
'--output', tempOut,
], { timeout: 300_000 });
// Replace original with processed
fs.copyFileSync(tempOut, wavPath);
return true;
} catch (err: any) {
console.error('[VST] Chain processing failed:', err.message);
return false;
} finally {
try { fs.unlinkSync(tempOut); } catch {}
try { fs.unlinkSync(tempChain); } catch {}
}
}
export default router;
+881
View File
@@ -0,0 +1,881 @@
// aceClient.ts — HTTP client for acestep.cpp's ace-server API
//
// Wraps all ace-server endpoints with typed methods.
// Used by the generation orchestrator and model routes.
//
// IMPORTANT: ace-server uses single-threaded httplib. During heavy compute
// (DiT generation, adapter merge, VAE decode) it cannot respond to HTTP
// requests. All fetch calls need generous timeouts to survive these stalls.
import { config } from '../config.js';
const BASE = config.aceServer.url;
// Timeouts (ms) — ace-server is single-threaded httplib, so during heavy
// compute (DiT steps, adapter merge) it can't respond. These must be
// generous enough to survive the longest possible stall.
const TIMEOUT_QUICK = 15_000; // health checks, props, job submit
const TIMEOUT_POLL = 30_000; // job polling — fail fast, let watchdog decide on stalls
const TIMEOUT_RESULT = 300_000; // fetching large audio results (encode + transfer)
const TIMEOUT_SA3 = 1_800_000; // /sa3-refine — first call may build a TensorRT engine (many minutes)
/** Props response from GET /props */
export interface AceProps {
models: {
lm: string[];
embedding: string[];
dit: string[];
vae: string[];
};
adapters: string[];
/** Planner-LM adapters from adapters/lm/ (local HOT-Step feature) */
lm_adapters?: string[];
cli: {
max_batch: number;
mp3_bitrate: number;
};
default: Record<string, unknown>;
}
/** AceRequest — matches acestep.cpp's request JSON format */
export interface AceRequest {
caption: string;
lyrics?: string;
bpm?: number;
duration?: number;
keyscale?: string;
timesignature?: string;
vocal_language?: string;
seed?: number;
/** LM-phase sampling seed (caption/lyrics/audio-codes). Independent from
* `seed`, which drives DiT synthesis. -1 or omitted lets the engine pick. */
lm_seed?: number;
lm_batch_size?: number;
synth_batch_size?: number;
lm_temperature?: number;
lm_cfg_scale?: number;
lm_cfg_cutoff_ratio?: number; // LM CFG step scheduling: 1.0 = full CFG, 0.5 = CFG for first 50% of tokens
lm_top_p?: number;
/** Windowed repetition penalty on audio-code sampling (1.0 = off) */
lm_rep_penalty?: number;
lm_rep_window?: number;
lm_top_k?: number;
lm_negative_prompt?: string;
negative_prompt?: string;
use_cot_caption?: boolean;
audio_codes?: string;
inference_steps?: number;
guidance_scale?: number;
shift?: number;
audio_cover_strength?: number;
cover_noise_strength?: number;
cover_noise_method?: string;
repainting_start?: number;
repainting_end?: number;
seed_strength?: number;
evict_lm?: boolean;
vae_chunk?: number;
batch_cfg?: number;
task_type?: string;
track?: string;
infer_method?: string;
scheduler?: string; // 'linear' | 'ddim_uniform' | 'sgm_uniform' | etc.
guidance_mode?: string; // 'apg' | 'cfg' | etc.
peak_clip?: number;
// Server routing fields
synth_model?: string;
lm_model?: string;
/** Planner-LM runtime LoRA (local HOT-Step feature) */
lm_adapter?: string;
lm_adapter_scale?: number;
vae_model?: string;
emb_model?: string;
adapter?: string;
adapter_scale?: number;
/** Multi-adapter stack. When present and non-empty, supersedes the single
* `adapter`/`adapter_scale`: every entry is applied with its own scale
* (merged sequentially, or summed in runtime mode). Each `name` is a registry
* adapter id (or absolute path) resolved by the engine. */
adapters?: { name: string; scale: number; gain_curve?: number[]; gain_domain?: 'steps' | 't' }[];
/** Per-section adapter masking (regional LoRA). Ordered per lyric section; each
* entry gives the effective per-adapter scale for that section (indexed to
* `adapters`) and a relative size hint. Runtime mode only. */
adapter_sections?: { weights: number[]; size: number }[];
/** Per-section masking: fraction of steps before deriving section boundaries from
* cross-attention alignment (earlier = identity locks to sections sooner). */
adapter_section_align_at?: number;
/** Per-section masking: 0..1 regional self-attention isolation (penalise attention
* across section boundaries so sections don't inherit the first section's voice). */
adapter_section_isolation?: number;
adapter_group_scales?: {
self_attn: number;
cross_attn: number;
mlp: number;
cond_embed: number;
time_embed: number;
proj_in: number;
};
adapter_mode?: string; // "merge" (default, F32 promoted), "runtime", or "runtime_lowrank" (factor apply, lowest VRAM)
/** Runtime adapter delta VRAM precision: "bf16" (full), "q8_0" (~½), "q4_k" (~¼).
* Quantizes precomputed deltas in VRAM at load; no disk change. Runtime mode only. */
adapter_runtime_quant?: string;
/** Merge (low VRAM): re-encode merged weights to the base's native quant instead of
* F32 promotion (~¼ the merged-DiT VRAM on a Q8 base). Merge mode only. */
adapter_merge_lowvram?: boolean;
// Basin re-base: nudge adapted weights toward the base the adapter was trained
// on (rebase_source = DiT model name) by rebase_beta*(S - T) before merging.
rebase_source?: string;
rebase_beta?: number;
// Solver sub-parameters
stork_substeps?: number;
beat_stability?: number;
frequency_damping?: number;
temporal_smoothing?: number;
// Guidance sub-parameters
apg_momentum?: number;
apg_norm_threshold?: number;
// DCW (Differential Correction in Wavelet domain)
dcw_enabled?: boolean;
dcw_mode?: string; // 'pix' | 'low' | 'high' | 'double'
dcw_scaler?: number;
dcw_high_scaler?: number;
// Latent post-processing (applied after DiT, before VAE decode)
latent_shift?: number; // 0.0 = no bias
latent_rescale?: number; // 1.0 = no scaling
custom_timesteps?: string; // CSV of descending floats, overrides scheduler
cfg_cutoff_ratio?: number; // CFG step scheduling: 1.0 = full CFG, 0.5 = 50% CFG then cond-only
cache_ratio?: number; // Step-level velocity caching: 0.0 = off, 0.5 = skip ~50% of passes
// Post-VAE spectral denoiser (HOT-Step)
denoise_strength?: number; // 0.0 = off, 1.0 = max suppression
denoise_smoothing?: number; // 0.0 = sharp gate, 1.0 = very smooth
denoise_mix?: number; // 0.0 = all dry, 1.0 = all denoised
// LSS: Latent Spectral Suppressor (MDMAchine) — pre-VAE latent channel gate
lss_strength?: number; // 0.0 = off, attenuation floor is 1-strength
lss_var_thresh?: number; // relative variance threshold (default 0.15)
lss_dc_remove?: boolean; // per-channel DC removal while LSS active
// PP-VAE re-encode (spectral cleanup via post-processing VAE)
pp_vae_reencode?: boolean;
// LRC timestamp generation (synchronized lyrics)
get_lrc?: boolean;
// Lua plugin dynamic parameters
plugin_params?: Record<string, string | number | boolean>;
// Postprocess plugin: name of the Lua postprocess plugin to use for VAE decode
postprocess_plugin?: string;
// VAE backend selection: true = ONNX Runtime (+TensorRT), false/undefined = GGML (default)
use_ort_vae?: boolean;
// Streaming pipeline (DEMON-style ring buffer)
stream_mode?: boolean; // true = route through streaming pipeline
stream_depth?: number; // ring buffer depth (default 8)
stream_chunk_dir?: string; // directory for preview WAV files
}
/** Job status from ace-server */
export interface AceJobStatus {
status: 'running' | 'done' | 'failed' | 'cancelled';
/** Fine-grained engine phase. Optional for back-compat with older
* ace-server builds that don't populate it. */
phase?: AceJobPhase;
phase_step?: number;
phase_total?: number;
}
/** Fine-grained engine phase — matches JobPhase in hot-step-server.cpp.
* Lowercase snake_case mirrors job_phase_str(). */
export type AceJobPhase =
| 'queued'
| 'loading_text_enc'
| 'encoding_text'
| 'loading_cond_enc'
| 'encoding_cond'
| 'loading_dit'
| 'loading_adapter'
| 'adapter_precompute'
| 'dit_inference'
| 'loading_vae'
| 'vae_decode'
| 'encoding_output'
| 'done'
| 'failed'
| 'cancelled';
/** One row from GET /jobs. */
export interface AceJobsListEntry {
id: string;
status: 'running' | 'done' | 'failed' | 'cancelled';
phase: AceJobPhase;
phase_step: number;
phase_total: number;
}
/** Body shape for POST /warm. */
export interface AceWarmRequest {
dit: string;
vae?: string;
adapter?: string;
adapter_scale?: number;
}
/** Plugin parameter schema from Lua plugin metadata */
export interface PluginParamSchema {
key: string;
type: 'slider' | 'select' | 'toggle' | 'text';
label: string;
hint?: string;
transform?: string;
// slider
default?: number | string | boolean;
min?: number;
max?: number;
step?: number;
// select
options?: { value: string; label: string }[];
// conditional visibility
visible_when?: { key: string; equals: string };
}
/** Plugin metadata from Lua plugin files */
export interface PluginInfo {
name: string;
display: string;
description?: string;
accent?: string;
// solver-specific
nfe?: number;
order?: number;
needs_model?: boolean;
stateful?: boolean;
stochastic?: boolean;
params: PluginParamSchema[];
}
export interface PluginRegistry {
solvers: PluginInfo[];
schedulers: PluginInfo[];
guidance: PluginInfo[];
postprocess: PluginInfo[];
}
async function aceGet(path: string, timeoutMs = TIMEOUT_QUICK): Promise<Response> {
const res = await fetch(`${BASE}${path}`, {
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
const body = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server ${path} failed (${res.status}): ${body}`);
}
return res;
}
async function acePost(path: string, body?: unknown, contentType = 'application/json', timeoutMs = TIMEOUT_QUICK): Promise<Response> {
const headers: Record<string, string> = {};
let reqBody: string | undefined;
if (body !== undefined) {
headers['Content-Type'] = contentType;
reqBody = typeof body === 'string' ? body : JSON.stringify(body);
}
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers,
body: reqBody,
signal: AbortSignal.timeout(timeoutMs),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST ${path} failed (${res.status}): ${errBody}`);
}
return res;
}
export const aceClient = {
/** GET /health — check if ace-server is alive */
async health(): Promise<{ status: string }> {
const res = await aceGet('/health');
return res.json();
},
/** GET /props — available models, config, defaults */
async props(): Promise<AceProps> {
const res = await aceGet('/props');
return res.json();
},
/** GET /plugins — dynamic Lua plugin registry (solvers, schedulers, guidance) */
async plugins(): Promise<PluginRegistry> {
const res = await aceGet('/plugins');
return res.json();
},
/** POST /warm — pre-load a DiT + VAE + adapter combo so the next /synth with
* the same key short-circuits the cold-start adapter precompute. Requires the
* engine to be in keep-loaded mode (--keep-loaded or a prior ?keep_loaded=1);
* under STRICT the engine returns {warm:false} since modules evict instantly.
* Returns the engine job id — poll via pollJob until status=done. */
async warm(request: AceWarmRequest, keepLoaded = true): Promise<string> {
const params = new URLSearchParams();
if (keepLoaded) params.set('keep_loaded', '1');
const qs = params.toString();
const path = qs ? `/warm?${qs}` : '/warm';
const res = await acePost(path, request);
const data = await res.json() as { id: string };
return data.id;
},
/** GET /jobs — enumerate every job currently in the engine's in-memory job
* table. Used to reconcile a still-running engine job after a client
* disconnected mid-poll. */
async listJobs(): Promise<AceJobsListEntry[]> {
const res = await aceGet('/jobs');
return res.json();
},
/** POST /lm — submit LM generation job, returns job ID.
* mode: 'inspire' (Phase 1 only, no codes) | 'format' (reformat, no codes)
* keepLoaded: flip store to EVICT_NEVER before LM loads, so the LM stays
* cached for subsequent gens instead of being freed under STRICT. */
async submitLm(request: AceRequest, mode?: 'inspire' | 'format', keepLoaded = false): Promise<string> {
const body = mode ? { ...request, lm_mode: mode } : request;
const params = new URLSearchParams();
if (keepLoaded) params.set('keep_loaded', '1');
const qs = params.toString();
const path = qs ? `/lm?${qs}` : '/lm';
const res = await acePost(path, body);
const data = await res.json() as { id: string };
return data.id;
},
/** POST /synth — submit synth job, returns job ID.
* format: 'wav16'|'wav24'|'wav32'|'mp3' — output format (default: wav16 for lossless) */
async submitSynth(request: AceRequest | AceRequest[], format: string = 'wav16', keepLoaded = false): Promise<string> {
const params = new URLSearchParams();
if (format !== 'mp3') params.set('format', format);
if (keepLoaded) params.set('keep_loaded', '1');
const qs = params.toString();
const path = qs ? `/synth?${qs}` : '/synth';
const res = await acePost(path, request);
const data = await res.json() as { id: string };
return data.id;
},
/**
* POST /synth with multipart — for cover/repaint modes with source audio
* Sends request JSON + audio file(s) as multipart/form-data
*/
async submitSynthMultipart(
request: AceRequest | AceRequest[],
srcAudio?: Buffer,
refAudio?: Buffer,
srcLatents?: Buffer,
refLatents?: Buffer,
format: string = 'wav16',
keepLoaded = false,
seedLatents?: Buffer,
): Promise<string> {
const params = new URLSearchParams();
if (format !== 'mp3') params.set('format', format);
if (keepLoaded) params.set('keep_loaded', '1');
const qs = params.toString();
const path = qs ? `/synth?${qs}` : '/synth';
const boundary = '----HotStepBoundary' + Date.now();
const parts: Buffer[] = [];
const addPart = (name: string, content: Buffer, contentType: string, filename?: string) => {
let header = `--${boundary}\r\nContent-Disposition: form-data; name="${name}"`;
if (filename) header += `; filename="${filename}"`;
header += `\r\nContent-Type: ${contentType}\r\n\r\n`;
parts.push(Buffer.from(header));
parts.push(content);
parts.push(Buffer.from('\r\n'));
};
// Request JSON part — ace-server multipart expects a single JSON object
// (uses request_parse_json, not request_parse_json_array)
const singleReq = Array.isArray(request) ? request[0] : request;
const reqJson = JSON.stringify(singleReq);
addPart('request', Buffer.from(reqJson), 'application/json');
// Source audio part
if (srcAudio) {
addPart('audio', srcAudio, 'audio/wav', 'source.wav');
}
// Reference audio part
if (refAudio) {
addPart('ref_audio', refAudio, 'audio/wav', 'reference.wav');
}
// Source latents part (raw float32 — replaces VAE encode of source audio)
if (srcLatents) {
addPart('src_latents', srcLatents, 'application/octet-stream', 'source.latent');
}
// Reference latents part (raw float32 — replaces VAE encode of timbre ref)
if (refLatents) {
addPart('ref_latents', refLatents, 'application/octet-stream', 'reference.latent');
}
// Seed latents part (raw float32 — structural seed for repeated sections)
if (seedLatents) {
addPart('seed_latents', seedLatents, 'application/octet-stream', 'seed.latent');
}
parts.push(Buffer.from(`--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body,
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST ${path} multipart failed (${res.status}): ${errBody}`);
}
const data = await res.json() as { id: string };
return data.id;
},
/** POST /understand — submit understand job, returns job ID.
* `params` (optional) is serialised into the multipart `request` part;
* the engine parses it with the same request_parse_json used by /synth,
* so any AceRequest field is legal. Engine defaults for understand are
* lm_temperature 0.3 / lm_top_p 1.0 and are only overridden by what we send. */
async submitUnderstand(audioBuffer: Buffer, params?: Partial<AceRequest>): Promise<string> {
const boundary = '----HotStepBoundary' + Date.now();
const parts: Buffer[] = [];
const addPart = (name: string, content: Buffer, contentType: string, filename?: string) => {
let header = `--${boundary}\r\nContent-Disposition: form-data; name="${name}"`;
if (filename) header += `; filename="${filename}"`;
header += `\r\nContent-Type: ${contentType}\r\n\r\n`;
parts.push(Buffer.from(header));
parts.push(content);
parts.push(Buffer.from('\r\n'));
};
// Request JSON part — only when there is something to say, so the legacy
// audio-only call shape stays byte-identical.
if (params && Object.keys(params).length > 0) {
addPart('request', Buffer.from(JSON.stringify(params)), 'application/json');
}
addPart('audio', audioBuffer, 'audio/wav', 'input.wav');
parts.push(Buffer.from(`--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await fetch(`${BASE}/understand`, {
method: 'POST',
headers: { 'Content-Type': `multipart/form-data; boundary=${boundary}` },
body,
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /understand failed (${res.status}): ${errBody}`);
}
const data = await res.json() as { id: string };
return data.id;
},
/** GET /job?id=N — poll job status.
* Uses TIMEOUT_POLL because ace-server is single-threaded and may be
* mid-DiT-step when we poll, so the response can stall for several seconds. */
async pollJob(jobId: string): Promise<AceJobStatus> {
const res = await aceGet(`/job?id=${jobId}`, TIMEOUT_POLL);
return res.json();
},
/** GET /job?id=N&result=1 — fetch completed job result.
* Uses TIMEOUT_RESULT because the response contains the full MP3/WAV audio. */
async getJobResult(jobId: string): Promise<Response> {
return fetch(`${BASE}/job?id=${jobId}&result=1`, {
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
},
/** GET /job?id=N&latent=1 — fetch captured post-DiT latent (raw float32).
* Returns null if no latent was captured (non-cover tasks, cancelled, etc). */
async getJobLatent(jobId: string): Promise<Buffer | null> {
try {
const res = await fetch(`${BASE}/job?id=${jobId}&latent=1`, {
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) return null;
const buf = await res.arrayBuffer();
return buf.byteLength > 0 ? Buffer.from(buf) : null;
} catch {
return null;
}
},
/** POST /job?id=N&cancel=1 — cancel a running job */
async cancelJob(jobId: string): Promise<void> {
await fetch(`${BASE}/job?id=${jobId}&cancel=1`, {
method: 'POST',
signal: AbortSignal.timeout(TIMEOUT_POLL),
});
},
/** Check if ace-server is reachable */
async isReachable(): Promise<boolean> {
try {
await this.health();
return true;
} catch {
return false;
}
},
/** POST /spectral-lifter — synchronous C++ Spectral Lifter processing.
* Sends WAV audio body with SL params as query string.
* Returns processed WAV buffer. */
async submitSpectralLifter(
wavBuffer: Buffer,
params: {
denoise_strength?: number;
noise_floor?: number;
hf_mix?: number;
transient_boost?: number;
shimmer_reduction?: number;
},
): Promise<Buffer> {
const qs = new URLSearchParams();
if (params.denoise_strength !== undefined) qs.set('denoise_strength', String(params.denoise_strength));
if (params.noise_floor !== undefined) qs.set('noise_floor', String(params.noise_floor));
if (params.hf_mix !== undefined) qs.set('hf_mix', String(params.hf_mix));
if (params.transient_boost !== undefined) qs.set('transient_boost', String(params.transient_boost));
if (params.shimmer_reduction !== undefined) qs.set('shimmer_reduction', String(params.shimmer_reduction));
const qsStr = qs.toString();
const path = qsStr ? `/spectral-lifter?${qsStr}` : '/spectral-lifter';
const res = await fetch(`${BASE}${path}`, {
method: 'POST',
headers: { 'Content-Type': 'audio/wav' },
body: wavBuffer,
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /spectral-lifter failed (${res.status}): ${errBody}`);
}
const arrayBuf = await res.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /pp-vae-reencode — synchronous PP-VAE re-encode processing.
* Sends WAV audio body. Returns processed WAV buffer with RMS-matched gain.
* blend: 0.0 = fully PP-VAE, 1.0 = fully original (wet/dry mix). */
async submitPpVaeReencode(wavBuffer: Buffer, blend = 0.0, useOnnx?: boolean): Promise<Buffer> {
const params = new URLSearchParams();
if (blend > 0) params.set('blend', blend.toFixed(3));
if (useOnnx === true) params.set('backend', 'onnx');
else if (useOnnx === false) params.set('backend', 'gguf');
const qs = params.toString();
const url = qs ? `${BASE}/pp-vae-reencode?${qs}` : `${BASE}/pp-vae-reencode`;
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'audio/wav' },
body: wavBuffer,
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /pp-vae-reencode failed (${res.status}): ${errBody}`);
}
const arrayBuf = await res.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /sa3-refine — synchronous SA3 (Stable Audio 3) SDEdit refine.
* Sends WAV audio body; the pre-tokenized T5Gemma prompt and sampler
* options ride in the query string (the engine cannot tokenize
* SentencePiece itself — see sa3Tokenizer.ts).
* Returns processed WAV at the input sample rate.
* NOTE: the first-ever call may take many minutes (TensorRT engine build),
* hence the dedicated 30-minute timeout. */
async submitSa3Refine(
wavBuffer: Buffer,
opts: {
tokens: number[]; // exactly 256 padded T5Gemma token ids
nTokens: number; // real (non-pad) token count
strength?: number; // 0..1 init noise level (engine default 0.3)
steps?: number; // sampler steps (engine default 8)
sampler?: 'pingpong' | 'euler';
seed?: number; // uint64 RNG seed (engine default: random)
rmsMatch?: boolean; // match output RMS to input (engine default true)
outSr?: number; // output sample rate (engine default: input rate)
/** Engine backend: 'onnx' (ONNX Runtime/TensorRT) or 'gguf' (GGML —
* CUDA/Vulkan/CPU). 'auto'/undefined lets the engine pick. */
backend?: 'auto' | 'onnx' | 'gguf';
/** StableStep DoRA adapters (models/sa3-adapters/<name>.gguf), merged
* into the SA3 DiT at load. Forces the GGUF backend engine-side. */
adapters?: Array<{ name: string; scale: number }>;
/** Windowed envelope match: output follows the source's short-term RMS
* envelope (timbre from the refine, dynamics from the source). */
envMatch?: boolean;
/** Wet/dry blend with the source: 0 = pure source, 1 = pure refined.
* Mutually exclusive with the band splice (mix wins engine-side). */
mix?: number;
/** Spectral band splice: source below the crossover, refined above. */
bandBlend?: boolean;
bandFreq?: number; // crossover center Hz (engine default 250)
bandWidth?: number; // transition width Hz (engine default 200)
},
): Promise<Buffer> {
const params = new URLSearchParams();
params.set('tokens', opts.tokens.join(','));
params.set('n_tokens', String(opts.nTokens));
if (opts.backend && opts.backend !== 'auto') params.set('backend', opts.backend);
if (opts.adapters && opts.adapters.length > 0) {
params.set('adapters', opts.adapters.map(a => `${a.name}:${a.scale}`).join(','));
}
if (opts.envMatch) params.set('env_match', '1');
if (opts.mix !== undefined) params.set('mix', String(opts.mix));
if (opts.bandBlend) {
params.set('band_blend', '1');
if (opts.bandFreq !== undefined) params.set('band_freq', String(opts.bandFreq));
if (opts.bandWidth !== undefined) params.set('band_width', String(opts.bandWidth));
}
if (opts.strength !== undefined) params.set('strength', String(opts.strength));
if (opts.steps !== undefined) params.set('steps', String(opts.steps));
if (opts.sampler) params.set('sampler', opts.sampler);
if (opts.seed !== undefined) params.set('seed', String(opts.seed));
if (opts.rmsMatch !== undefined) params.set('rms_match', opts.rmsMatch ? '1' : '0');
if (opts.outSr !== undefined) params.set('out_sr', String(opts.outSr));
const res = await fetch(`${BASE}/sa3-refine?${params.toString()}`, {
method: 'POST',
headers: { 'Content-Type': 'audio/wav' },
body: wavBuffer,
signal: AbortSignal.timeout(TIMEOUT_SA3),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /sa3-refine failed (${res.status}): ${errBody}`);
}
const arrayBuf = await res.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /supersep/separate?level=N — start async stem separation.
* Body: WAV/MP3 audio. Returns the SuperSep job id.
* level: 0=BASIC (6 stems), 1=VOCAL_SPLIT, 2=FULL, 3=MAXIMUM,
* 4=VOCALS_ONLY (BS-RoFormer 2-stem: Vocals incl. backing + complement Instrumental). */
async submitSuperSepSeparate(audioBuffer: Buffer, level = 0): Promise<string> {
const res = await fetch(`${BASE}/supersep/separate?level=${level}`, {
method: 'POST',
headers: { 'Content-Type': 'application/octet-stream' },
body: audioBuffer,
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /supersep/separate failed (${res.status}): ${errBody}`);
}
const data = await res.json() as { id: string };
return data.id;
},
/** GET /supersep/progress?id=... — poll a SuperSep separation job. */
async superSepProgress(jobId: string): Promise<{
status: string; progress: number; message: string; error?: string; n_stems?: number;
}> {
const res = await aceGet(`/supersep/progress?id=${jobId}`, TIMEOUT_POLL);
return res.json();
},
/** GET /supersep/result?id=... — stem list metadata for a completed job. */
async superSepResult(jobId: string): Promise<{
id: string;
stems: Array<{
name: string; category: string; stem_type: string;
n_frames: number; stage: number; index: number; hidden: boolean;
}>;
}> {
const res = await aceGet(`/supersep/result?id=${jobId}`, TIMEOUT_POLL);
return res.json();
},
/** GET /supersep/serve?id=...&stem=N — download one stem as a 44.1 kHz WAV. */
async superSepStem(jobId: string, index: number): Promise<Buffer> {
const res = await fetch(`${BASE}/supersep/serve?id=${jobId}&stem=${index}`, {
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server GET /supersep/serve failed (${res.status}): ${errBody}`);
}
const arrayBuf = await res.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /supersep/recombine — mix a completed job's stems with per-stem
* volume/mute controls. Returns a 48 kHz 16-bit stereo WAV. */
async superSepRecombine(
jobId: string,
stems: Array<{ index: number; volume?: number; muted?: boolean }>,
): Promise<Buffer> {
const res = await fetch(`${BASE}/supersep/recombine`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: jobId, stems }),
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /supersep/recombine failed (${res.status}): ${errBody}`);
}
const arrayBuf = await res.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /vae with multipart — VAE encode: sends audio, returns raw f32 latent bytes.
* Polls until the engine job completes, then fetches the result.
* Returns raw f32 [T*64] latent buffer.
* @param vaeModel Optional VAE model name — sent as {"vae":"..."} in request part. */
async vaeEncode(audioBuffer: Buffer, vaeModel?: string): Promise<Buffer> {
const boundary = '----HotStepBoundary' + Date.now();
const parts: Buffer[] = [];
const addPart = (name: string, content: Buffer, contentType: string, filename?: string) => {
let header = `--${boundary}\r\nContent-Disposition: form-data; name="${name}"`;
if (filename) header += `; filename="${filename}"`;
header += `\r\nContent-Type: ${contentType}\r\n\r\n`;
parts.push(Buffer.from(header));
parts.push(content);
parts.push(Buffer.from('\r\n'));
};
// Request JSON part — tells the engine which VAE to use for encoding.
// The C++ /vae handler reads "vae" (not "vae_model") via request_parse_json.
if (vaeModel) {
addPart('request', Buffer.from(JSON.stringify({ vae: vaeModel })), 'application/json');
}
// Audio part
addPart('audio', audioBuffer, 'audio/wav', 'input.wav');
parts.push(Buffer.from(`--${boundary}--\r\n`));
const body = Buffer.concat(parts);
const res = await fetch(`${BASE}/vae`, {
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
},
body,
signal: AbortSignal.timeout(TIMEOUT_RESULT),
});
if (!res.ok) {
const errBody = await res.text().catch(() => 'Unknown error');
throw new Error(`ace-server POST /vae failed (${res.status}): ${errBody}`);
}
const data = await res.json() as { id: string };
const jobId = data.id;
// Poll until done
for (;;) {
const status = await this.pollJob(jobId);
if (status.status === 'done') break;
if (status.status === 'failed') throw new Error('VAE encode failed');
if (status.status === 'cancelled') throw new Error('VAE encode cancelled');
await new Promise(r => setTimeout(r, 200));
}
// Fetch raw latent result
const resultRes = await this.getJobResult(jobId);
if (!resultRes.ok) throw new Error(`VAE encode result fetch failed (${resultRes.status})`);
const arrayBuf = await resultRes.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /codes-decode — 5 Hz FSQ codes straight to audio through the
* detokenizer + VAE. No DiT, no sampler, no adapter: this is the LM's plan
* rendered literally. Polls the engine job and returns the encoded audio.
*
* The request is built FRESH by the caller from {audio_codes, synth_model,
* vae, output_format, peak_clip} — never from an AceRequest that /lm echoed
* back. Server-only sideband fields do not survive that round trip, and
* forwarding the echo is the known way to lose them. */
async codesDecode(
request: {
audio_codes: string; synth_model?: string; vae?: string;
output_format?: string; peak_clip?: number;
},
timeoutMs = 90_000,
isCancelled?: () => boolean,
): Promise<Buffer> {
const started = Date.now();
const res = await acePost('/codes-decode', request);
const data = await res.json() as { id: string };
const jobId = data.id;
for (;;) {
// Both exits cancel the ENGINE job. Throwing without cancelling leaves the
// GPU decoding a result nobody will fetch, and the finished WAV then sits
// in the engine's in-memory job ring until a restart.
if (isCancelled?.()) {
await this.cancelJob(jobId).catch(() => { /* engine may already be gone */ });
throw new Error('codes-decode cancelled');
}
if (Date.now() - started > timeoutMs) {
await this.cancelJob(jobId).catch(() => { /* best effort */ });
throw new Error('codes-decode timed out');
}
const status = await this.pollJob(jobId);
if (status.status === 'done') break;
if (status.status === 'failed') throw new Error('codes-decode failed');
if (status.status === 'cancelled') throw new Error('codes-decode cancelled');
await new Promise(r => setTimeout(r, 200));
}
const resultRes = await this.getJobResult(jobId);
if (!resultRes.ok) throw new Error(`codes-decode result fetch failed (${resultRes.status})`);
const arrayBuf = await resultRes.arrayBuffer();
return Buffer.from(arrayBuf);
},
/** POST /models/restore-policy — full eviction pass + back to EVICT_STRICT
* after a ?keep_loaded=1 latch (codes audition). Best-effort; false when
* the engine refused (CLI --keep-loaded), something was still in use, or
* the call failed. */
async restoreEvictPolicy(): Promise<boolean> {
try {
const res = await fetch(`${BASE}/models/restore-policy`, {
method: 'POST',
signal: AbortSignal.timeout(TIMEOUT_QUICK),
});
if (!res.ok) return false;
const body = await res.json().catch(() => ({})) as { restored?: boolean };
return body.restored === true;
} catch {
return false;
}
},
/** POST /models/unload — evict one ModelStore label ('LM', 'DiT', 'VAE-Dec',
* 'FSQ-Detok', …). Best-effort: a false/failed reply is not an error. */
async unloadLabel(label: string): Promise<boolean> {
try {
const res = await fetch(`${BASE}/models/unload`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ label }),
signal: AbortSignal.timeout(TIMEOUT_QUICK),
});
if (!res.ok) return false;
const body = await res.json().catch(() => ({})) as { ok?: boolean; unloaded?: boolean };
return body.ok !== false && body.unloaded !== false;
} catch {
return false;
}
},
};
+360
View File
@@ -0,0 +1,360 @@
// aceEngineProcess.ts — ace-server child process lifecycle
//
// Relocated verbatim from index.ts (spawn args, log fan-out, crash-count
// limiter and respawn timer are unchanged) so that other services can stop and
// restart the engine, not just the bootstrap.
//
// The one behavioural addition is `suspended`: a deliberate stop sets it BEFORE
// the kill, and the 'exit' handler returns immediately while it is set, so a
// planned shutdown can never trip the crash-respawn logic. Training preprocess
// jobs use this to own the GPU for the duration of a run.
//
// Spec: docs/plans/2026-07-27-preprocess-implementation.md §4.1 (P26)
import fs from 'fs';
import path from 'path';
import { spawn, execSync, ChildProcess } from 'child_process';
import { config } from '../config.js';
import { logEngine } from './logger.js';
import { pushLog } from '../routes/logs.js';
import { setEngineReady } from '../engineState.js';
import { aceClient } from './aceClient.js';
/** The live child, or null when nothing is running. */
let aceProcess: ChildProcess | null = null;
/** True while a deliberate stop is in effect — the exit handler must not respawn. */
let suspended = false;
/**
* Pending crash-respawn timer, and the lifecycle epoch it was scheduled in.
*
* Both are load-bearing. A crash schedules a respawn 3 s out; if a deliberate
* stop *and* a restart both land inside that window (a preprocess job whose
* ace-train fails fast — bad --dit name is sub-second), `suspended` is already
* back to false when the timer fires and it spawns a SECOND, untracked engine
* that nothing will ever kill. So every stop/restart cancels the timer AND
* bumps the epoch, and the timer refuses to fire for a stale epoch.
*/
let respawnTimer: NodeJS.Timeout | null = null;
let lifecycleEpoch = 0;
/** Cancel any pending crash-respawn and invalidate one already in flight. */
function cancelPendingRespawn(): void {
lifecycleEpoch++;
if (respawnTimer) {
clearTimeout(respawnTimer);
respawnTimer = null;
}
}
// Crash-count limiter: prevent infinite respawn on fatal errors (missing DLLs, etc.)
let crashCount = 0;
let firstCrashTime = 0;
const MAX_CRASHES = 3;
const CRASH_WINDOW_MS = 30_000; // 30 seconds
/** How long restartAceServer() waits for /health after a respawn. */
const RESTART_HEALTH_TIMEOUT_MS = 90_000;
/**
* Spawn ace-server. Returns null when the binary is missing.
* Also assigns the module-level current process.
*/
export function startAceServer(): ChildProcess | null {
const exe = config.aceServer.exe;
if (!exe || !fs.existsSync(exe)) {
console.log(`[Server] ace-server not found at: ${exe}`);
console.log('[Server] Start ace-server manually, or set ACESTEPCPP_EXE in .env');
aceProcess = null;
return null;
}
const args = [
'--models', config.aceServer.models,
'--host', config.aceServer.host,
'--port', String(config.aceServer.port),
];
// Add adapters dir if it exists
if (config.aceServer.adapters && fs.existsSync(config.aceServer.adapters)) {
args.push('--adapters', config.aceServer.adapters);
}
// --keep-loaded: flips the engine's ModelStore to EVICT_NEVER so the ~17 s
// LoKr precompute (and the DiT/VAE load) only happens once per combo instead
// of every /synth. Default OFF (VRAM trade-off) — toggle in Settings →
// Environment → "Keep models in VRAM" (ACESTEPCPP_KEEP_LOADED), restart-required.
if (config.aceServer.keepLoaded) {
args.push('--keep-loaded');
console.log('[Server] --keep-loaded: DiT + adapter stay resident across requests');
}
// Add noise profile if available
if (config.aceServer.noiseProfile && fs.existsSync(config.aceServer.noiseProfile)) {
args.push('--noise-profile', config.aceServer.noiseProfile);
console.log(`[Server] Noise profile: ${config.aceServer.noiseProfile}`);
}
// Add draft LM for speculative decoding (if available)
if (config.aceServer.draftLm && fs.existsSync(config.aceServer.draftLm)) {
args.push('--draft-lm', config.aceServer.draftLm);
console.log(`[Server] Draft LM: ${path.basename(config.aceServer.draftLm)}`);
}
// VAE tiling parameters (resolves Vulkan pinned memory allocation failures)
if (config.aceServer.vaeChunk) {
args.push('--vae-chunk', String(config.aceServer.vaeChunk));
}
if (config.aceServer.vaeOverlap) {
args.push('--vae-overlap', String(config.aceServer.vaeOverlap));
}
// Add ONNX model directory for ORT/TRT VAE (if it exists and contains .onnx files)
if (config.aceServer.onnxDir && fs.existsSync(config.aceServer.onnxDir)) {
const hasOnnx = fs.readdirSync(config.aceServer.onnxDir).some(f => f.endsWith('.onnx'));
if (hasOnnx) {
args.push('--onnx-dir', config.aceServer.onnxDir);
console.log(`[Server] ONNX models: ${config.aceServer.onnxDir}`);
}
}
console.log(`[Server] Starting ace-server: ${path.basename(exe)}`);
console.log(`[Server] Models: ${config.aceServer.models}`);
console.log(`[Server] Port: ${config.aceServer.port}`);
// Inject TensorRT libs into PATH if available (so ORT can load nvinfer_10.dll)
// and CUDA_VISIBLE_DEVICES for GPU selection.
// IMPORTANT: On Windows, process.env is a case-insensitive Proxy, but spreading
// it to a plain object creates case-sensitive keys. The key is typically 'Path'
// not 'PATH', so we must find the actual key to avoid creating a shadowing duplicate.
const spawnOpts: { stdio: any; env?: NodeJS.ProcessEnv } = {
stdio: ['ignore', 'pipe', 'pipe'] as any,
};
const needsCustomEnv = (config.aceServer.trtLibs && fs.existsSync(config.aceServer.trtLibs))
|| config.aceServer.cudaVisibleDevices;
if (needsCustomEnv) {
const env = { ...process.env };
// GPU device selection (e.g. "0", "1", "0,1")
if (config.aceServer.cudaVisibleDevices) {
env.CUDA_VISIBLE_DEVICES = config.aceServer.cudaVisibleDevices;
console.log(`[Server] GPU selection: CUDA_VISIBLE_DEVICES=${config.aceServer.cudaVisibleDevices}`);
}
if (config.aceServer.trtLibs && fs.existsSync(config.aceServer.trtLibs)) {
// Find the actual PATH key (case-insensitive on Windows)
const pathKey = Object.keys(env).find(k => k.toUpperCase() === 'PATH') || 'PATH';
const pathSep = process.platform === 'win32' ? ';' : ':';
env[pathKey] = config.aceServer.trtLibs + pathSep + (env[pathKey] || '');
// Also inject TRT-LLM Executor libs if available (tensorrt_llm.dll + plugin)
// exe is at engine/build/Release/ace-server.exe → up 3 to engine/
const trtllmLibs = path.join(path.dirname(config.aceServer.exe), '..', '..', 'trtllm-libs');
if (fs.existsSync(trtllmLibs)) {
env[pathKey] = trtllmLibs + pathSep + env[pathKey];
console.log(`[Server] TRT-LLM libs: ${trtllmLibs}`);
}
console.log(`[Server] TensorRT libs: ${config.aceServer.trtLibs}`);
}
spawnOpts.env = env;
}
const child = spawn(exe, args, spawnOpts);
// Filter repetitive GGML noise from console output (still written to ace_engine.log via logEngine)
const isNoise = (line: string) =>
line.includes('CUDA graph warmup') || line.includes('CUDA Graph id') || line.includes('ggml_backend_cuda_graph_compute');
child.stdout?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean);
for (const line of lines) {
if (!isNoise(line)) console.log(`[ace-server] ${line}`);
logEngine(line);
pushLog(line, 'engine');
}
});
child.stderr?.on('data', (data: Buffer) => {
const lines = data.toString().split('\n').filter(Boolean);
for (const line of lines) {
if (!isNoise(line)) console.log(`[ace-server] ${line}`);
logEngine(line);
pushLog(line, 'engine');
}
});
const spawnEpoch = lifecycleEpoch;
child.on('exit', (code, signal) => {
if (aceProcess === child) aceProcess = null; // never hand out a dead child
if (suspended) return; // deliberate stop — never respawn
if (spawnEpoch !== lifecycleEpoch) return; // superseded by a stop/restart
if (signal !== 'SIGTERM' && signal !== 'SIGINT' && code !== 0) {
console.error(`[ace-server] Process exited with code ${code}, signal ${signal}`);
// Crash-count limiter: reset window if enough time has passed
const now = Date.now();
if (now - firstCrashTime > CRASH_WINDOW_MS) {
crashCount = 0;
firstCrashTime = now;
}
crashCount++;
if (crashCount >= MAX_CRASHES) {
console.error(`[ace-server] Crashed ${MAX_CRASHES} times within ${CRASH_WINDOW_MS / 1000}s — giving up.`);
console.error('[ace-server] This usually means a required DLL is missing from the engine/ directory.');
console.error('[ace-server] Check the error above, or try re-extracting the release zip.');
setEngineReady(false, `Engine crashed ${MAX_CRASHES} times — check logs for missing DLLs`);
return;
}
console.log(`[ace-server] Restarting in 3 seconds... (crash ${crashCount}/${MAX_CRASHES})`);
if (respawnTimer) clearTimeout(respawnTimer);
respawnTimer = setTimeout(() => {
respawnTimer = null;
// A stop OR a restart that landed inside the 3 s window must win, or
// this spawns a duplicate engine that nothing tracks.
if (suspended || spawnEpoch !== lifecycleEpoch) return;
startAceServer();
}, 3000);
}
});
child.on('error', (err) => {
console.error(`[ace-server] Failed to start: ${err.message}`);
});
aceProcess = child;
return child;
}
/** The live child, or null. */
export function getAceProcess(): ChildProcess | null {
return aceProcess;
}
/** True while a deliberate stop is in effect (something else owns the GPU). */
export function isEngineSuspended(): boolean {
return suspended;
}
/**
* Stop ace-server and wait for the process to actually exit.
*
* `suspended` is set FIRST so the exit handler never respawns; it stays set
* until restartAceServer() clears it. Resolves when the 'exit' event fires, or
* after `timeoutMs`. A no-op when there is no live child.
*/
export function stopAceServer(reason = 'Engine stopped', timeoutMs = 15_000,
opts: { suspend?: boolean } = {}): Promise<boolean> {
// A shutdown is not a suspension: leaving `suspended` set makes
// POST /api/generate answer with the training-preprocess message during the
// Ctrl-C window, which is misleading in the logs.
if (opts.suspend !== false) suspended = true;
cancelPendingRespawn();
setEngineReady(false, reason);
const child = aceProcess;
if (!child || !child.pid || child.exitCode !== null || child.signalCode !== null) {
aceProcess = null;
return Promise.resolve(true);
}
console.log(`[Server] Stopping ace-server (pid ${child.pid}) — ${reason}`);
const kill = (force: boolean) => {
try {
if (process.platform === 'win32') {
// Tree kill — the engine can hold GPU contexts in worker threads.
execSync(`taskkill /PID ${child.pid} /T /F`, { stdio: 'ignore' });
} else {
child.kill(force ? 'SIGKILL' : 'SIGTERM');
}
} catch {
// Process may already be dead — the 'exit' listener or the timer resolves us.
}
};
return new Promise<boolean>((resolve) => {
let settled = false;
let escalated = false;
const onExit = () => finish(true);
const finish = (exited: boolean) => {
if (settled) return;
settled = true;
clearTimeout(timer);
child.removeListener('exit', onExit); // or the child leaks a listener for its lifetime
// Only forget the child once it has ACTUALLY exited. Nulling it on a
// timeout makes the caller's restart spawn a second engine alongside a
// still-live one, which then cannot bind :8085 and burns the crash budget.
if (exited && aceProcess === child) aceProcess = null;
resolve(exited);
};
const onTimeout = () => {
if (!escalated && process.platform !== 'win32') {
// SIGTERM can be ignored by an engine wedged in a CUDA/Vulkan sync.
escalated = true;
console.warn('[Server] ace-server ignored SIGTERM — escalating to SIGKILL');
kill(true);
timer.refresh();
return;
}
console.warn(`[Server] ace-server did not exit within ${timeoutMs} ms — it may still be running`);
finish(false);
};
const timer: NodeJS.Timeout = setTimeout(onTimeout, timeoutMs);
child.once('exit', onExit);
kill(false);
});
}
/**
* Clear `suspended`, respawn ace-server and poll /health until it answers.
* Resolves true when the engine came back, false on timeout.
*/
export async function restartAceServer(): Promise<boolean> {
// Order matters: kill any crash-respawn already scheduled BEFORE clearing
// `suspended`, or the orphaned timer spawns a duplicate engine 3 s from now.
cancelPendingRespawn();
suspended = false;
setEngineReady(false, 'Restarting engine...');
if (aceProcess && aceProcess.exitCode === null && aceProcess.signalCode === null) {
// A stop that timed out left the old engine alive; a second spawn would
// just fail to bind :8085 and burn the crash budget.
console.warn('[Server] restartAceServer: an engine child is still live — not spawning a second one');
const ok = await aceClient.isReachable();
setEngineReady(ok, ok ? 'Ready' : 'Engine did not come back — restart the app');
return ok;
}
const child = startAceServer();
if (!child) {
setEngineReady(false, 'Engine did not come back — restart the app');
return false;
}
const deadline = Date.now() + RESTART_HEALTH_TIMEOUT_MS;
while (Date.now() < deadline) {
if (await aceClient.isReachable()) {
setEngineReady(true, 'Ready');
console.log('[Server] ace-server is back up');
return true;
}
await new Promise(r => setTimeout(r, 1000));
}
console.error('[Server] ace-server did not answer /health within 90 s after a restart');
setEngineReady(false, 'Engine did not come back — restart the app');
return false;
}
+104
View File
@@ -0,0 +1,104 @@
/**
* Read the `__metadata__` header of a safetensors file — the slot HOT-Step's
* trainers use to record an adapter's trigger word.
*
* The format is: 8 bytes little-endian u64 header length, then that many bytes
* of JSON, then the tensor payload. Unknown metadata keys are ignored by every
* other consumer (PEFT, ComfyUI, LyCORIS, Side-Step, diffusers), which is why
* the trigger lives here and not in `adapter_config.json`.
*
* docs/plans/2026-07-28-adapter-trigger-embedding.md §2.1 / §4
*
* Nothing in this module throws. An unreadable, truncated or non-safetensors
* file degrades to "no metadata" — an adapter without a trigger is a normal
* state, not an error.
*/
import fs from 'fs';
import path from 'path';
/** Refuse to buffer a bogus header length. Real headers are well under a MB. */
const MAX_HEADER_BYTES = 64 * 1024 * 1024;
export interface AdapterTrigger {
/** '' when the adapter carries no embedded trigger. */
trigger: string;
/** 'prepend' | 'append', or '' when there is no trigger. */
position: 'prepend' | 'append' | '';
}
const EMPTY: AdapterTrigger = { trigger: '', position: '' };
/** `path -> {size, mtimeMs, value}` so a directory scan reads each file once. */
const cache = new Map<string, { size: number; mtimeMs: number; value: AdapterTrigger }>();
/** Read only the `__metadata__` object. Returns `{}` on any failure. */
export function readSafetensorsMetadata(file: string): Record<string, string> {
let fd: number | undefined;
try {
fd = fs.openSync(file, 'r');
const lenBuf = Buffer.allocUnsafe(8);
if (fs.readSync(fd, lenBuf, 0, 8, 0) !== 8) return {};
const headerLen = Number(lenBuf.readBigUInt64LE(0));
if (!Number.isSafeInteger(headerLen) || headerLen <= 0 || headerLen > MAX_HEADER_BYTES) return {};
const hdr = Buffer.allocUnsafe(headerLen);
if (fs.readSync(fd, hdr, 0, headerLen, 8) !== headerLen) return {};
const parsed: unknown = JSON.parse(hdr.toString('utf8'));
if (!parsed || typeof parsed !== 'object') return {};
const md = (parsed as Record<string, unknown>).__metadata__;
if (!md || typeof md !== 'object') return {};
const out: Record<string, string> = {};
for (const [k, v] of Object.entries(md as Record<string, unknown>)) {
if (typeof v === 'string') out[k] = v;
}
return out;
} catch {
return {};
} finally {
if (fd !== undefined) {
try { fs.closeSync(fd); } catch { /* ignore */ }
}
}
}
/**
* Resolve an adapter's embedded trigger. Accepts either a bare `.safetensors`
* file or an adapter directory (in which case `adapter_model.safetensors` inside
* it is read, falling back to `lokr_weights.safetensors` — the LyCORIS leaf a
* DiT LoKR export writes instead, carrying the same trigger keys). Memoised on
* path + size + mtime, so re-scanning a folder of 200 adapters costs one stat
* each after the first pass.
*/
export function readAdapterTrigger(pathOrDir: string): AdapterTrigger {
try {
let file = pathOrDir;
const st = fs.statSync(pathOrDir);
if (st.isDirectory()) {
file = path.join(pathOrDir, 'adapter_model.safetensors');
if (!fs.existsSync(file)) file = path.join(pathOrDir, 'lokr_weights.safetensors');
}
const fst = file === pathOrDir ? st : fs.statSync(file);
if (!fst.isFile()) return EMPTY;
const hit = cache.get(file);
if (hit && hit.size === fst.size && hit.mtimeMs === fst.mtimeMs) return hit.value;
const md = readSafetensorsMetadata(file);
// `hot_step_trigger` is authoritative; `modelspec.trigger_phrase` is the
// ecosystem convention we also write, and lets us read adapters stamped by
// kohya-style tooling that never heard of HOT-Step.
const trigger = (md.hot_step_trigger || md['modelspec.trigger_phrase'] || '').trim();
const raw = (md.hot_step_trigger_position || '').trim();
const position: AdapterTrigger['position'] =
!trigger ? '' : raw === 'append' ? 'append' : 'prepend';
const value: AdapterTrigger = trigger ? { trigger, position } : EMPTY;
cache.set(file, { size: fst.size, mtimeMs: fst.mtimeMs, value });
return value;
} catch {
return EMPTY;
}
}
+243
View File
@@ -0,0 +1,243 @@
/**
* audioConvert.ts — Convert non-WAV/MP3 audio to WAV using ffmpeg.
*
* The C++ engine (audio-io.h) only decodes WAV and MP3. Source audio from
* Cover Studio may be FLAC, M4A, OGG, etc. This module converts on-demand.
*
* FFmpeg is resolved via getFFmpegPath():
* - Portable mode: server/ffmpeg.exe (bundled in release)
* - Dev mode: ffmpeg-static npm package
*
* Converted files are cached alongside the original so re-runs skip conversion.
*/
import { execFileSync } from 'child_process';
import fs from 'fs';
import path from 'path';
import { getFFmpegPath } from '../config.js';
/** Extensions the C++ engine can decode natively */
const ENGINE_NATIVE_EXTS = new Set(['.wav', '.mp3']);
/**
* Check if a WAV file is in the format the C++ engine can decode:
* PCM (format=1), 16-bit, any sample rate / channel count.
*
* Returns true if the WAV is engine-compatible, false if it needs conversion
* (e.g. 24-bit, 32-bit float, or non-PCM formats like ADPCM).
*/
function isEngineCompatibleWav(filePath: string): boolean {
try {
// Read enough of the header to find the fmt chunk
const fd = fs.openSync(filePath, 'r');
const hdr = Buffer.alloc(128);
fs.readSync(fd, hdr, 0, 128, 0);
fs.closeSync(fd);
// Validate RIFF/WAVE container
if (hdr.toString('ascii', 0, 4) !== 'RIFF' || hdr.toString('ascii', 8, 12) !== 'WAVE') {
return false;
}
// Search for the "fmt " chunk (usually at offset 12, but not always)
for (let offset = 12; offset < 100; offset++) {
if (hdr.toString('ascii', offset, offset + 4) === 'fmt ') {
// fmt chunk found — read audio format and bits per sample
const fmtOffset = offset + 8; // skip "fmt " + chunk size (4 bytes)
const audioFormat = hdr.readUInt16LE(fmtOffset); // 1 = PCM, 3 = IEEE float
const bitsPerSample = hdr.readUInt16LE(fmtOffset + 14); // offset 14 within fmt data
if (audioFormat === 1 && bitsPerSample === 16) {
return true; // PCM 16-bit — engine can decode this
}
// Log why it's incompatible
const sampleRate = hdr.readUInt32LE(fmtOffset + 4);
const channels = hdr.readUInt16LE(fmtOffset + 2);
console.log(`[audioConvert] WAV needs conversion: format=${audioFormat} bits=${bitsPerSample} rate=${sampleRate} ch=${channels}`);
return false;
}
}
// No fmt chunk found — not a valid WAV
return false;
} catch {
// Can't read header — safer to convert
return false;
}
}
/**
* Ensure the audio file at `filePath` is in a format the engine can decode.
* - MP3: returned as-is (engine decodes natively)
* - WAV 16-bit PCM: returned as-is
* - WAV 24/32-bit, float, or non-PCM: converted to 48 kHz stereo PCM16 via ffmpeg
* - Other formats (FLAC, OGG, M4A, etc.): converted via ffmpeg
*
* Converted files are cached as `<original>.engine.wav` next to the original.
*/
export function ensureEngineFormat(filePath: string): Buffer {
const ext = path.extname(filePath).toLowerCase();
// MP3: always engine-compatible
if (ext === '.mp3') {
return fs.readFileSync(filePath);
}
// WAV: only compatible if 16-bit PCM
if (ext === '.wav' && isEngineCompatibleWav(filePath)) {
return fs.readFileSync(filePath);
}
// Everything else needs conversion (or WAV with non-16-bit format)
// Check for cached conversion
const wavPath = filePath + '.engine.wav';
if (fs.existsSync(wavPath)) {
console.log(`[audioConvert] Using cached conversion: ${path.basename(wavPath)}`);
return fs.readFileSync(wavPath);
}
// Convert via ffmpeg
const ffmpegPath = getFFmpegPath();
if (!ffmpegPath) {
// Last resort: if it's a WAV we can't convert, return it anyway and hope for the best
if (ext === '.wav') {
console.warn(`[audioConvert] ffmpeg not available — returning non-16-bit WAV as-is (engine may reject it)`);
return fs.readFileSync(filePath);
}
throw new Error('ffmpeg not available — cannot convert non-WAV/MP3 audio');
}
const label = ext === '.wav' ? 'non-16-bit WAV' : ext.slice(1).toUpperCase();
console.log(`[audioConvert] Converting ${path.basename(filePath)} (${label}) → WAV (48kHz/16-bit/stereo)...`);
const t0 = Date.now();
try {
execFileSync(ffmpegPath, [
'-y', // overwrite output
'-i', filePath, // input
'-ar', '48000', // 48 kHz
'-ac', '2', // stereo
'-c:a', 'pcm_s16le', // 16-bit PCM WAV
'-f', 'wav', // WAV container
wavPath, // output
], {
timeout: 120_000, // 2 min max
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch (err: any) {
// Clean up partial output
try { fs.unlinkSync(wavPath); } catch {}
const stderr = err.stderr?.toString()?.slice(-500) || '';
throw new Error(`ffmpeg conversion failed: ${stderr || err.message}`);
}
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const size = (fs.statSync(wavPath).size / 1024 / 1024).toFixed(1);
console.log(`[audioConvert] Done in ${elapsed}s → ${path.basename(wavPath)} (${size} MB)`);
return fs.readFileSync(wavPath);
}
/**
* Apply tempo-scaling and/or pitch-shifting to a WAV buffer.
*
* The C++ engine doesn't support these natively (the Python ACE-Step backend
* does via release_task). So for HOT-Step CPP covers we pre-process the
* source audio with ffmpeg before feeding it to the engine.
*
* @param srcBuffer WAV/MP3 buffer (engine-compatible format)
* @param tempoScale >1 = faster, <1 = slower (pitch-preserving time-stretch)
* @param pitchShift Semitones: +N = higher, -N = lower (tempo-preserving)
* @returns Processed WAV buffer (48 kHz stereo PCM16)
*/
export function timeStretchPitchShift(
srcBuffer: Buffer,
tempoScale: number,
pitchShift: number,
): Buffer {
const ffmpegPath = getFFmpegPath();
if (!ffmpegPath) {
throw new Error('ffmpeg not available — cannot apply tempo/pitch changes');
}
// Write source to temp file
const tmpDir = path.join(process.cwd(), 'data', 'tmp');
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
const id = Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const tmpIn = path.join(tmpDir, `stretch_in_${id}.wav`);
const tmpOut = path.join(tmpDir, `stretch_out_${id}.wav`);
try {
fs.writeFileSync(tmpIn, srcBuffer);
// Build ffmpeg filter chain.
//
// Key insight: asetrate+aresample changes BOTH pitch AND tempo together.
// To keep them independent, we compute a single combined atempo correction:
// - pitchFactor = 2^(semitones/12)
// - asetrate+aresample shifts pitch by pitchFactor but also speeds up by pitchFactor
// - To undo that speed change AND apply desired tempoScale:
// effectiveTempo = tempoScale / pitchFactor
// - If only pitch (tempoScale=1): atempo=1/pitchFactor → compensates speed change
// - If only tempo (pitchShift=0): atempo=tempoScale → standard time-stretch
// - If both: correctly combines both adjustments
const filters: string[] = [];
const pitchFactor = pitchShift !== 0 ? Math.pow(2, pitchShift / 12) : 1.0;
// Step 1: Pitch via asetrate+aresample (also changes tempo by pitchFactor)
if (pitchShift !== 0) {
filters.push(`asetrate=48000*${pitchFactor.toFixed(6)}`);
filters.push('aresample=48000');
}
// Step 2: Combined atempo — undo pitch's tempo side-effect + apply desired tempo
const effectiveTempo = tempoScale / pitchFactor;
if (Math.abs(effectiveTempo - 1.0) > 0.001) {
// ffmpeg atempo range: 0.5100.0. Chain for values outside this range.
let remaining = effectiveTempo;
while (remaining < 0.5) {
filters.push('atempo=0.5');
remaining /= 0.5;
}
while (remaining > 100.0) {
filters.push('atempo=100.0');
remaining /= 100.0;
}
filters.push(`atempo=${remaining.toFixed(6)}`);
}
if (filters.length === 0) {
// No processing needed — return original
return srcBuffer;
}
const filterStr = filters.join(',');
console.log(`[audioConvert] Applying tempo=${tempoScale}x, pitch=${pitchShift}st → filter: ${filterStr}`);
const t0 = Date.now();
execFileSync(ffmpegPath, [
'-y',
'-i', tmpIn,
'-af', filterStr,
'-ar', '48000',
'-ac', '2',
'-c:a', 'pcm_s16le',
'-f', 'wav',
tmpOut,
], {
timeout: 180_000,
stdio: ['pipe', 'pipe', 'pipe'],
});
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
const outBuf = fs.readFileSync(tmpOut);
console.log(`[audioConvert] Tempo/pitch done in ${elapsed}s → ${(outBuf.length / 1024 / 1024).toFixed(1)} MB`);
return outBuf;
} finally {
// Cleanup temp files
try { fs.unlinkSync(tmpIn); } catch {}
try { fs.unlinkSync(tmpOut); } catch {}
}
}
+232
View File
@@ -0,0 +1,232 @@
/**
* audioCrop.ts — Manual crop service for audio files.
*
* Crops a WAV file to a specified [inPoint, outPoint] range in seconds.
* Also handles companion LRC files by filtering out-of-range lines and
* shifting remaining timestamps by -inPoint.
*
* Used by the manual trim/crop UI feature. WAV parsing follows the same
* pattern as autoTrim.ts.
*/
import fs from 'fs';
// ── WAV parsing ──────────────────────────────────────────────────────────────
interface WavInfo {
sampleRate: number;
numChannels: number;
bitsPerSample: number;
dataOffset: number;
dataSize: number;
}
function parseWavHeader(buf: Buffer): WavInfo {
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('Not a valid WAV file');
}
let offset = 12;
let fmtFound = false;
let sampleRate = 0;
let numChannels = 0;
let bitsPerSample = 0;
let dataOffset = 0;
let dataSize = 0;
while (offset < buf.length - 8) {
const chunkId = buf.toString('ascii', offset, offset + 4);
const chunkSize = buf.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
numChannels = buf.readUInt16LE(offset + 10);
sampleRate = buf.readUInt32LE(offset + 12);
bitsPerSample = buf.readUInt16LE(offset + 22);
fmtFound = true;
} else if (chunkId === 'data') {
dataOffset = offset + 8;
dataSize = chunkSize;
break;
}
offset += 8 + chunkSize;
if (chunkSize % 2 !== 0) offset++;
}
if (!fmtFound || dataOffset === 0) {
throw new Error('WAV file missing fmt or data chunk');
}
return { sampleRate, numChannels, bitsPerSample, dataOffset, dataSize };
}
/**
* Read a WAV file's duration in seconds from its header (cheap — reads only the
* first 8 KB, enough to find the fmt + data chunks). Returns 0 on any failure
* or for non-WAV input. Used to backfill song duration when the LM/request did
* not provide one (e.g. repaint/cover tasks where the LM is skipped).
*/
export function wavDurationSec(filePath: string): number {
try {
const fd = fs.openSync(filePath, 'r');
const head = Buffer.alloc(8192);
const n = fs.readSync(fd, head, 0, 8192, 0);
fs.closeSync(fd);
const info = parseWavHeader(head.subarray(0, n));
const bytesPerFrame = info.numChannels * (info.bitsPerSample / 8);
if (info.sampleRate <= 0 || bytesPerFrame <= 0) return 0;
return info.dataSize / bytesPerFrame / info.sampleRate;
} catch {
return 0;
}
}
// ── WAV crop ─────────────────────────────────────────────────────────────────
export interface CropResult {
newDurationSec: number;
}
/**
* Crop a WAV file in place to the [inPointSec, outPointSec] range.
*
* Applies a 5ms cosine crossfade at both cut points to prevent clicks.
* Overwrites the file.
*/
export function cropWavFile(
wavPath: string,
inPointSec: number,
outPointSec: number,
): CropResult {
const buf = fs.readFileSync(wavPath);
const info = parseWavHeader(buf);
const bytesPerSample = info.bitsPerSample / 8;
const frameSize = bytesPerSample * info.numChannels;
const totalSamples = Math.floor(info.dataSize / frameSize);
// Clamp to valid range
const inSample = Math.max(0, Math.floor(inPointSec * info.sampleRate));
const outSample = Math.min(totalSamples, Math.floor(outPointSec * info.sampleRate));
if (outSample <= inSample) {
throw new Error('Invalid crop range: outPoint must be after inPoint');
}
const croppedSamples = outSample - inSample;
const minSamples = info.sampleRate; // 1 second minimum
if (croppedSamples < minSamples) {
throw new Error(`Crop result too short (${(croppedSamples / info.sampleRate).toFixed(2)}s). Minimum is 1 second.`);
}
// Build new buffer: header + cropped PCM data
const newDataSize = croppedSamples * frameSize;
const newBuf = Buffer.alloc(info.dataOffset + newDataSize);
// Copy header (everything up to data start)
buf.copy(newBuf, 0, 0, info.dataOffset);
// Copy PCM data from inSample to outSample
const srcStart = info.dataOffset + inSample * frameSize;
const srcEnd = info.dataOffset + outSample * frameSize;
buf.copy(newBuf, info.dataOffset, srcStart, srcEnd);
// Apply 5ms cosine crossfade at the cut points to prevent clicks
const fadeSamples = Math.min(Math.floor(info.sampleRate * 0.005), Math.floor(croppedSamples / 4));
if (fadeSamples > 0) {
// Fade-in at the start of the cropped region
for (let i = 0; i < fadeSamples; i++) {
const gain = 0.5 * (1 - Math.cos(Math.PI * i / fadeSamples)); // 0 → 1
for (let ch = 0; ch < info.numChannels; ch++) {
const off = info.dataOffset + i * frameSize + ch * bytesPerSample;
if (off + bytesPerSample > newBuf.length) continue;
applySampleGain(newBuf, off, info.bitsPerSample, gain);
}
}
// Fade-out at the end of the cropped region
for (let i = 0; i < fadeSamples; i++) {
const sampleIdx = croppedSamples - fadeSamples + i;
const gain = 0.5 * (1 + Math.cos(Math.PI * i / fadeSamples)); // 1 → 0
for (let ch = 0; ch < info.numChannels; ch++) {
const off = info.dataOffset + sampleIdx * frameSize + ch * bytesPerSample;
if (off + bytesPerSample > newBuf.length) continue;
applySampleGain(newBuf, off, info.bitsPerSample, gain);
}
}
}
// Update RIFF chunk size (file size - 8)
newBuf.writeUInt32LE(newBuf.length - 8, 4);
// Update data chunk size
newBuf.writeUInt32LE(newDataSize, info.dataOffset - 4);
// Write back
fs.writeFileSync(wavPath, newBuf);
const newDurationSec = croppedSamples / info.sampleRate;
return { newDurationSec };
}
/** Apply a gain multiplier to a single sample at the given buffer offset */
function applySampleGain(buf: Buffer, off: number, bitsPerSample: number, gain: number): void {
if (bitsPerSample === 16) {
const val = buf.readInt16LE(off);
buf.writeInt16LE(Math.round(val * gain), off);
} else if (bitsPerSample === 32) {
const val = buf.readFloatLE(off);
buf.writeFloatLE(val * gain, off);
} else if (bitsPerSample === 24) {
const raw = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16);
let val = raw > 0x7FFFFF ? raw - 0x1000000 : raw;
val = Math.round(val * gain);
buf[off] = val & 0xFF;
buf[off + 1] = (val >> 8) & 0xFF;
buf[off + 2] = (val >> 16) & 0xFF;
}
}
// ── LRC crop ─────────────────────────────────────────────────────────────────
/**
* Crop an LRC file in place: remove lines outside [inPointSec, outPointSec],
* then shift all remaining timestamps by -inPointSec.
*/
export function cropLrcFile(
lrcPath: string,
inPointSec: number,
outPointSec: number,
): void {
const raw = fs.readFileSync(lrcPath, 'utf-8');
const lines = raw.replace(/\r/g, '').split('\n');
const result: string[] = [];
for (const line of lines) {
const match = line.match(/^\[(\d+):(\d+)(?:\.(\d+))?\]\s*(.*)$/);
if (!match) {
// Non-timestamped line (metadata like [ti:], [ar:], etc.) — keep as-is
if (line.trim().length > 0) result.push(line);
continue;
}
const mins = parseInt(match[1], 10);
const secs = parseInt(match[2], 10);
const cs = match[3] ? parseInt(match[3].padEnd(2, '0').slice(0, 2), 10) : 0;
const text = match[4];
const time = mins * 60 + secs + cs / 100;
// Filter: keep only lines within the crop range
if (time < inPointSec || time > outPointSec) continue;
// Shift timestamp by -inPointSec
const newTime = Math.max(0, time - inPointSec);
const newMins = Math.floor(newTime / 60);
const newSecs = Math.floor(newTime % 60);
const newCs = Math.round((newTime - Math.floor(newTime)) * 100);
const ts = `[${String(newMins).padStart(2, '0')}:${String(newSecs).padStart(2, '0')}.${String(newCs).padStart(2, '0')}]`;
result.push(`${ts} ${text}`);
}
fs.writeFileSync(lrcPath, result.join('\n') + '\n', 'utf-8');
}
+333
View File
@@ -0,0 +1,333 @@
// audioMetadata.ts — Gather song metadata and build ffmpeg args for embedding
//
// Reads metadata from the songs DB row, enriches with Lyric Studio artist/album
// data and Cover Studio source/target artist formatting, then produces ffmpeg
// CLI arguments for -metadata tags and cover art attachment.
//
// Format support:
// FLAC — Vorbis comments + PICTURE block (full metadata + cover art)
// MP3 — ID3v2 tags + APIC frame (full metadata + cover art)
// Opus — Vorbis comments (full text metadata, no cover art — OGG limitation)
// WAV — INFO chunk only (title, artist, comment — no cover art)
import fs from 'fs';
import path from 'path';
import { getDb } from '../db/database.js';
import { config } from '../config.js';
// ── Types ───────────────────────────────────────────────────────────────
export interface AudioMetadata {
title?: string;
artist?: string;
albumArtist?: string; // original artist for covers (ALBUMARTIST tag)
album?: string;
genre?: string; // style/caption
lyrics?: string;
bpm?: number;
key?: string; // key_scale
date?: string; // year from created_at
comment?: string; // generator attribution + model info
coverArtPath?: string; // absolute path to cover image on disk
}
// ── Metadata gathering ──────────────────────────────────────────────────
/**
* Gather all available metadata from a song DB row.
*
* Works uniformly across all sources (Auto-Gen, Custom-Gen, Lyric Studio,
* Cover Studio) because they all write to the same songs table. The
* differences are handled by checking generation_params.source and
* enriching where possible.
*/
export function gatherSongMetadata(song: any): AudioMetadata {
const meta: AudioMetadata = {};
// Parse generation_params (stored as JSON string in DB)
let genParams: Record<string, any> = {};
try {
genParams = typeof song.generation_params === 'string'
? JSON.parse(song.generation_params || '{}')
: (song.generation_params || {});
} catch { /* malformed JSON — proceed with empty */ }
const source = genParams.source || 'create';
// ── Title ──
// Strip "Artist - " prefix from DB title (generate.ts stores as "Artist - Song Title")
let rawTitle = song.title || 'Untitled';
const artist = genParams.artist || genParams.artistName || '';
if (artist) {
const prefix = new RegExp(`^${escapeRegex(artist)}\\s*-\\s*`, 'i');
rawTitle = rawTitle.replace(prefix, '').trim() || rawTitle;
}
// For cover studio, the title may already be "Song (Artist Cover)" — extract clean title
if (source === 'cover-studio') {
const coverSuffix = /\s*\(.*?\bCover\b\)\s*$/i;
rawTitle = rawTitle.replace(coverSuffix, '').trim() || rawTitle;
}
meta.title = rawTitle;
// ── Artist ──
// For Lyric Studio and Cover Studio, append "(AI-Generated)" to be
// transparent that this is AI-generated content in the style of the artist.
if (source === 'cover-studio') {
// Cover Studio: format as "TargetArtist (SourceArtist Cover) (AI-Generated)"
const targetArtist = genParams.artistName || '';
const sourceArtist = genParams.sourceArtist || '';
if (targetArtist && sourceArtist) {
meta.artist = `${targetArtist} (${sourceArtist} Cover) (AI-Generated)`;
meta.albumArtist = sourceArtist;
} else if (targetArtist) {
meta.artist = `${targetArtist} (AI-Generated)`;
} else if (sourceArtist) {
meta.artist = `${sourceArtist} (AI-Generated)`;
}
} else if (source === 'lyric-studio') {
// Lyric Studio: enrich from the lireek join chain
const lireekMeta = enrichFromLireek(song.audio_url);
if (lireekMeta) {
const lireekArtist = lireekMeta.artistName || artist;
meta.artist = lireekArtist ? `${lireekArtist} (AI-Generated)` : undefined;
meta.album = lireekMeta.album || undefined;
} else {
meta.artist = artist ? `${artist} (AI-Generated)` : undefined;
}
} else {
// Auto-Gen / Custom-Gen
meta.artist = artist || undefined;
}
// ── Genre (music style from caption, NOT from subject) ──
// song.style stores the subject (what the song is about), NOT the genre.
// song.caption stores the LM's music style description (e.g. "metalcore,
// aggressive, heavy guitars") which is what genre should be based on.
meta.genre = song.caption || genParams.caption || genParams.style || undefined;
// ── Lyrics ──
if (song.lyrics && song.lyrics !== '[Instrumental]') {
meta.lyrics = song.lyrics;
}
// ── BPM ──
if (song.bpm && song.bpm > 0) {
meta.bpm = song.bpm;
}
// ── Key ──
if (song.key_scale) {
meta.key = song.key_scale;
}
// ── Date (year) ──
if (song.created_at) {
try {
const year = new Date(song.created_at).getFullYear();
if (year > 2000) meta.date = String(year);
} catch { /* invalid date */ }
}
// ── Comment (attribution) ──
const modelName = song.dit_model || genParams.ditModel || '';
const parts = ['Generated by HOT-Step'];
if (modelName) parts.push(`Model: ${modelName}`);
if (source === 'cover-studio') parts.push('(AI Cover)');
meta.comment = parts.join(' | ');
// ── Cover art ──
if (song.cover_url) {
const coverFilename = path.basename(song.cover_url);
const coverPath = path.join(config.data.audioDir, coverFilename);
if (fs.existsSync(coverPath)) {
meta.coverArtPath = coverPath;
}
}
// ── User overrides (metadata editor, #60) ──
// When the user has edited a tag-only field, embed it VERBATIM — overriding
// the auto-derivation above (e.g. the "(AI-Generated)" artist suffix).
// Columns (title/genre/bpm/key/lyrics/cover) are edited directly and already
// read above, so only the columnless fields live here.
if (song.metadata_overrides) {
try {
const ov = typeof song.metadata_overrides === 'string'
? JSON.parse(song.metadata_overrides || '{}')
: (song.metadata_overrides || {});
if (typeof ov.artist === 'string' && ov.artist.trim() !== '') meta.artist = ov.artist;
if (typeof ov.album === 'string' && ov.album.trim() !== '') meta.album = ov.album;
if (typeof ov.year === 'string' && ov.year.trim() !== '') meta.date = ov.year;
if (typeof ov.comment === 'string' && ov.comment.trim() !== '') meta.comment = ov.comment;
} catch { /* malformed overrides — ignore */ }
}
return meta;
}
// ── ffmpeg argument builders ────────────────────────────────────────────
/**
* Build ffmpeg -metadata arguments for text tags.
*
* Returns an array of strings to splice into the ffmpeg command.
* WAV format gets a reduced set (INFO chunk supports fewer fields).
*/
export function buildMetadataArgs(meta: AudioMetadata, format: string): string[] {
const args: string[] = [];
const isWav = format === 'wav';
const add = (key: string, value: string | number | undefined) => {
if (value === undefined || value === null || value === '') return;
args.push('-metadata', `${key}=${String(value)}`);
};
// Tags supported by all formats (including WAV INFO chunks)
add('title', meta.title);
add('artist', meta.artist);
add('comment', meta.comment);
// Tags NOT supported by WAV INFO chunks
if (!isWav) {
add('album', meta.album);
add('album_artist', meta.albumArtist);
add('genre', meta.genre);
add('date', meta.date);
// BPM and Key — use standardized tag names per format so media players
// actually populate their "Beats-per-minute" and "Initial key" fields.
// MP3 (ID3v2): TBPM and TKEY are the standard frame names.
// FLAC/Opus (Vorbis): BPM is standard, INITIALKEY is more widely
// recognized than KEY by media players and DJ software.
if (meta.bpm) {
if (format === 'mp3') {
add('TBPM', meta.bpm);
} else {
add('BPM', meta.bpm);
}
}
if (meta.key) {
if (format === 'mp3') {
add('TKEY', meta.key);
} else {
add('INITIALKEY', meta.key);
}
}
// Lyrics — use UNSYNCEDLYRICS for better player compatibility.
// Most players (foobar2000, MusicBee, VLC, Strawberry) look for this
// tag name in Vorbis comments. For MP3, ffmpeg writes it as a custom
// text frame (TXXX); full USLT support requires a dedicated ID3 library.
if (meta.lyrics) {
// Truncate very long lyrics to avoid bloating (16KB limit is generous)
const truncated = meta.lyrics.length > 16_000
? meta.lyrics.substring(0, 16_000) + '\n[truncated]'
: meta.lyrics;
add('UNSYNCEDLYRICS', truncated);
}
}
return args;
}
/**
* Build ffmpeg arguments for embedding cover art.
*
* Returns separate input args (to prepend) and output args (to append).
*
* Cover art is transcoded from PNG to JPEG and downscaled to 1024×1024
* on-the-fly, keeping the embedded image ~150-250KB instead of 2-5MB PNG.
*
* Supported: FLAC (PICTURE block), MP3 (ID3v2 APIC frame)
* NOT supported: Opus (OGG container can't hold video streams — would
* need METADATA_BLOCK_PICTURE in Vorbis comments which requires base64
* encoding), WAV (no standard mechanism)
*/
export function buildCoverArtArgs(
coverPath: string,
format: string,
): { inputArgs: string[]; outputArgs: string[] } {
// WAV and Opus: no cover art via the video-stream approach
// WAV has no standard, Opus/OGG can't hold video streams
if (format === 'wav' || format === 'opus') {
return { inputArgs: [], outputArgs: [] };
}
// Verify cover file exists
if (!fs.existsSync(coverPath)) {
return { inputArgs: [], outputArgs: [] };
}
// Input: add cover image as second input
const inputArgs = ['-i', coverPath];
// Output: map audio from input 0, video (cover) from input 1
// Transcode to JPEG, scale to 1024×1024, quality 5 (~85%)
const outputArgs: string[] = [];
if (format === 'mp3') {
// MP3/ID3v2: APIC frame with JPEG transcode
outputArgs.push(
'-map', '0:a',
'-map', '1:v',
'-c:v', 'mjpeg',
'-vf', 'scale=1024:1024',
'-q:v', '5',
'-metadata:s:v', 'title=Cover',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic',
'-id3v2_version', '3',
);
} else {
// FLAC: PICTURE block with JPEG transcode
outputArgs.push(
'-map', '0:a',
'-map', '1:v',
'-c:v', 'mjpeg',
'-vf', 'scale=1024:1024',
'-q:v', '5',
'-metadata:s:v', 'title=Cover',
'-metadata:s:v', 'comment=Cover (front)',
'-disposition:v', 'attached_pic',
);
}
return { inputArgs, outputArgs };
}
// ── Helpers ─────────────────────────────────────────────────────────────
/** Escape special regex characters in a string */
function escapeRegex(s: string): string {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/**
* Enrich metadata from the Lyric Studio join chain.
* Returns artist name and album if the song was generated via Lyric Studio.
*/
function enrichFromLireek(audioUrl: string): { artistName?: string; album?: string } | null {
if (!audioUrl) return null;
try {
const row = getDb().prepare(
`SELECT a.name AS artist_name, ls.album
FROM audio_generations ag
JOIN generations g ON g.id = ag.generation_id
JOIN profiles p ON p.id = g.profile_id
JOIN lyrics_sets ls ON ls.id = p.lyrics_set_id
JOIN artists a ON a.id = ls.artist_id
WHERE ag.audio_url = ?`
).get(audioUrl) as any;
if (row) {
return {
artistName: row.artist_name || undefined,
album: row.album || undefined,
};
}
} catch {
// Lireek tables may not exist — graceful fallback
}
return null;
}
+280
View File
@@ -0,0 +1,280 @@
/**
* autoTrim.ts — Silence-detection auto-trimming for generated audio.
*
* After generating audio with a duration buffer, this service scans the WAV
* from the end backwards to find natural song endings (sustained silence gaps).
*
* Two outcomes:
* - Clean ending found (silence gap ≥2s in buffer zone): Trims at the gap
* boundary. No fade applied — the song already ended naturally.
* - No clean ending (model played through entire buffer): Force-trims at
* the original requested duration with a user-configurable fade-out.
*/
import fs from 'fs';
// ── WAV parsing helpers ──────────────────────────────────────────────────────
interface WavInfo {
sampleRate: number;
numChannels: number;
bitsPerSample: number;
dataOffset: number; // byte offset of PCM data
dataSize: number; // byte size of PCM data
}
function parseWavHeader(buf: Buffer): WavInfo {
// Standard RIFF WAV header
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('Not a valid WAV file');
}
let offset = 12;
let fmtFound = false;
let sampleRate = 0;
let numChannels = 0;
let bitsPerSample = 0;
let dataOffset = 0;
let dataSize = 0;
while (offset < buf.length - 8) {
const chunkId = buf.toString('ascii', offset, offset + 4);
const chunkSize = buf.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
numChannels = buf.readUInt16LE(offset + 10);
sampleRate = buf.readUInt32LE(offset + 12);
bitsPerSample = buf.readUInt16LE(offset + 22);
fmtFound = true;
} else if (chunkId === 'data') {
dataOffset = offset + 8;
dataSize = chunkSize;
break;
}
offset += 8 + chunkSize;
// Pad to even boundary
if (chunkSize % 2 !== 0) offset++;
}
if (!fmtFound || dataOffset === 0) {
throw new Error('WAV file missing fmt or data chunk');
}
return { sampleRate, numChannels, bitsPerSample, dataOffset, dataSize };
}
/** Read a sample (any channel, mono-mixed) as a float in [-1, 1] */
function readSampleMono(buf: Buffer, info: WavInfo, sampleIndex: number): number {
const bytesPerSample = info.bitsPerSample / 8;
const frameSize = bytesPerSample * info.numChannels;
const frameOffset = info.dataOffset + sampleIndex * frameSize;
let sum = 0;
for (let ch = 0; ch < info.numChannels; ch++) {
const off = frameOffset + ch * bytesPerSample;
if (off + bytesPerSample > buf.length) return 0;
if (info.bitsPerSample === 16) {
sum += buf.readInt16LE(off) / 32768;
} else if (info.bitsPerSample === 32) {
sum += buf.readFloatLE(off);
} else if (info.bitsPerSample === 24) {
const val = (buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16));
sum += (val > 0x7FFFFF ? val - 0x1000000 : val) / 8388608;
}
}
return sum / info.numChannels;
}
// ── RMS computation ──────────────────────────────────────────────────────────
/** Compute RMS of a window of samples */
function computeWindowRms(buf: Buffer, info: WavInfo, startSample: number, windowSamples: number, totalSamples: number): number {
let sumSq = 0;
const end = Math.min(startSample + windowSamples, totalSamples);
const count = end - startSample;
if (count <= 0) return 0;
for (let i = startSample; i < end; i++) {
const s = readSampleMono(buf, info, i);
sumSq += s * s;
}
return Math.sqrt(sumSq / count);
}
// ── Core trim logic ──────────────────────────────────────────────────────────
export interface AutoTrimResult {
trimmed: boolean;
originalDurationSec: number;
trimmedDurationSec: number;
trimPointSec: number;
}
/**
* Auto-trim silence from the end of a WAV file, in place.
*
* Strategy (two-pass):
* 1. Strip trailing silence from the very end of the file.
* 2. Scan the buffer zone (originalDuration ± margin) for a sustained
* silence gap (≥2 seconds) — this catches the "ends, pauses, restarts"
* pattern by trimming at the gap before the restart.
* 3. If no gap found in the buffer zone, trim at the last meaningful audio
* or fall back to originalDuration with a fade.
*
* @param wavPath Path to the WAV file (will be overwritten if trimmed)
* @param originalDuration The user's requested duration in seconds (before buffer)
* @param fadeMs Fade-out duration in milliseconds (default 500)
* @returns Info about what was trimmed
*/
export function autoTrimSilence(
wavPath: string,
originalDuration: number,
fadeMs: number = 500,
): AutoTrimResult {
const buf = fs.readFileSync(wavPath);
const info = parseWavHeader(buf);
const bytesPerSample = info.bitsPerSample / 8;
const frameSize = bytesPerSample * info.numChannels;
const totalSamples = Math.floor(info.dataSize / frameSize);
const totalDurationSec = totalSamples / info.sampleRate;
// Window size: 100ms
const windowSamples = Math.floor(info.sampleRate * 0.1);
const totalWindows = Math.floor(totalSamples / windowSamples);
// Silence threshold: -50dB (stricter than -40dB to avoid catching quiet passages)
const silenceThresholdLinear = Math.pow(10, -50 / 20); // ≈ 0.00316
// ── Pass 1: Find the last window with meaningful audio ─────────────────
// This strips any trailing silence/noise at the very end of the file.
let lastAudioWindow = totalWindows - 1;
for (let w = totalWindows - 1; w >= 0; w--) {
const windowStart = w * windowSamples;
const rms = computeWindowRms(buf, info, windowStart, windowSamples, totalSamples);
if (rms >= silenceThresholdLinear) {
lastAudioWindow = w;
break;
}
}
// "Effective end" is where audio content actually stops
const effectiveEndSample = (lastAudioWindow + 1) * windowSamples;
const effectiveEndSec = effectiveEndSample / info.sampleRate;
// ── Pass 2: Scan the buffer zone for a sustained silence gap ───────────
// Only consider gaps that START after (originalDuration - 5s) to avoid
// trimming musical breaks deep within the song.
const bufferZoneStartSec = Math.max(0, originalDuration - 5);
const bufferZoneStartWindow = Math.floor(bufferZoneStartSec / 0.1);
const minGapWindows = 20; // 2 seconds at 100ms windows
let trimSample = -1;
let consecutiveSilent = 0;
let forcedTrim = false; // true when no clean ending found
// Scan backwards from effective end to find a gap in the buffer zone
const effectiveEndWindow = Math.min(lastAudioWindow + 1, totalWindows);
for (let w = effectiveEndWindow - 1; w >= bufferZoneStartWindow; w--) {
const windowStart = w * windowSamples;
const rms = computeWindowRms(buf, info, windowStart, windowSamples, totalSamples);
if (rms < silenceThresholdLinear) {
consecutiveSilent++;
} else {
if (consecutiveSilent >= minGapWindows) {
// Found a qualifying gap — trim at this audio content's end
// (w is the last window with audio, trim after it)
trimSample = (w + 1) * windowSamples;
break;
}
consecutiveSilent = 0;
}
}
// ── Decide final trim point ────────────────────────────────────────────
if (trimSample < 0) {
// No silence gap found in the buffer zone.
if (effectiveEndSec <= originalDuration + 1) {
// Audio ends at or before original duration — no trim needed
// (or the model ran out of content naturally)
return {
trimmed: false,
originalDurationSec: totalDurationSec,
trimmedDurationSec: totalDurationSec,
trimPointSec: totalDurationSec,
};
}
// Audio fills the entire buffer with no silence gap — the model
// never naturally ended. Force-trim at originalDuration with fade.
trimSample = Math.floor(originalDuration * info.sampleRate);
forcedTrim = true;
}
const trimTimeSec = trimSample / info.sampleRate;
// Don't trim if the trim point is essentially at the file end (within 0.5s)
if (totalDurationSec - trimTimeSec < 0.5) {
return {
trimmed: false,
originalDurationSec: totalDurationSec,
trimmedDurationSec: totalDurationSec,
trimPointSec: totalDurationSec,
};
}
// Fade-out only on forced trims (no clean ending detected).
// Clean endings already have natural silence — no fade needed.
const actualFadeMs = forcedTrim ? fadeMs : 0;
const fadeSamples = Math.floor((actualFadeMs / 1000) * info.sampleRate);
const fadeStart = Math.max(0, trimSample - fadeSamples);
// Create new buffer with trimmed data
const newDataSize = trimSample * frameSize;
const newBuf = Buffer.alloc(info.dataOffset + newDataSize);
// Copy header + data up to trim point
buf.copy(newBuf, 0, 0, Math.min(info.dataOffset + newDataSize, buf.length));
// Apply fade-out in the new buffer
for (let i = fadeStart; i < trimSample; i++) {
const fadePos = (i - fadeStart) / fadeSamples; // 0..1
const gain = Math.cos(fadePos * Math.PI * 0.5); // cosine fade: 1→0
for (let ch = 0; ch < info.numChannels; ch++) {
const off = info.dataOffset + i * frameSize + ch * bytesPerSample;
if (off + bytesPerSample > newBuf.length) continue;
if (info.bitsPerSample === 16) {
const val = newBuf.readInt16LE(off);
newBuf.writeInt16LE(Math.round(val * gain), off);
} else if (info.bitsPerSample === 32) {
const val = newBuf.readFloatLE(off);
newBuf.writeFloatLE(val * gain, off);
} else if (info.bitsPerSample === 24) {
const raw = newBuf[off] | (newBuf[off + 1] << 8) | (newBuf[off + 2] << 16);
let val = raw > 0x7FFFFF ? raw - 0x1000000 : raw;
val = Math.round(val * gain);
newBuf[off] = val & 0xFF;
newBuf[off + 1] = (val >> 8) & 0xFF;
newBuf[off + 2] = (val >> 16) & 0xFF;
}
}
}
// Update RIFF chunk size (file size - 8)
newBuf.writeUInt32LE(newBuf.length - 8, 4);
// Update data chunk size
newBuf.writeUInt32LE(newDataSize, info.dataOffset - 4);
// Write back
fs.writeFileSync(wavPath, newBuf);
return {
trimmed: true,
originalDurationSec: totalDurationSec,
trimmedDurationSec: trimTimeSec,
trimPointSec: trimTimeSec,
};
}
@@ -0,0 +1,663 @@
// coverArtDownloader.ts — First-use download manager for cover art assets
//
// Downloads sd.exe (from GitHub releases) + FLUX.2-klein-4B GGUF +
// VAE + Qwen3 LLM from HuggingFace on first use.
// Supports progress tracking, resume, and cancellation.
//
// sd.exe download flow:
// 1. Query GitHub API for latest stable-diffusion.cpp release
// 2. Pick the right asset (CUDA on Windows, Metal on macOS)
// 3. Download the ZIP, extract sd.exe + DLLs to cover-art directory
//
// Reference: server/src/services/modelDownloadService.ts
import { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { getCoverArtDir, REQUIRED_FILES } from './coverArtService.js';
const execFileAsync = promisify(execFile);
// ── Download manifest ───────────────────────────────────────────────────
export interface ManifestEntry {
filename: string;
url: string;
sizeBytes: number;
description: string;
}
/**
* Download manifest for FLUX.2-klein-4B cover art pipeline.
*
* File sources:
* - Diffusion model: leejet/FLUX.2-klein-4B-GGUF (Q4_0)
* - VAE: black-forest-labs/FLUX.2-klein-4B (ungated, Apache 2.0)
* - LLM: unsloth/Qwen3-4B-GGUF (Q4_K_M)
* - sd.exe: leejet/stable-diffusion.cpp GitHub releases (auto-detected)
*/
const MODEL_MANIFEST: ManifestEntry[] = [
{
filename: REQUIRED_FILES.diffusionModel,
url: 'https://huggingface.co/leejet/FLUX.2-klein-4B-GGUF/resolve/main/flux-2-klein-4b-Q4_0.gguf',
sizeBytes: 2_460_378_560,
description: 'FLUX.2-klein-4B diffusion model (Q4)',
},
{
filename: REQUIRED_FILES.vae,
// NOTE: FLUX.2-dev is gated (requires HF login). FLUX.2-klein-4B is
// ungated (Apache 2.0) and ships the same VAE architecture.
url: 'https://huggingface.co/black-forest-labs/FLUX.2-klein-4B/resolve/main/vae/diffusion_pytorch_model.safetensors',
sizeBytes: 335_304_388,
description: 'FLUX.2 VAE decoder',
},
{
filename: REQUIRED_FILES.llm,
url: 'https://huggingface.co/unsloth/Qwen3-4B-GGUF/resolve/main/Qwen3-4B-Q4_K_M.gguf',
sizeBytes: 2_800_000_000,
description: 'Qwen3-4B text encoder (Q4_K_M)',
},
];
// ── GitHub Release Asset Selection ──────────────────────────────────────
/**
* Platform-specific patterns for selecting the right sd.exe release asset.
* Ordered by preference — first match wins.
*/
const SD_ASSET_PATTERNS: Record<string, string[]> = {
win32: [
'-bin-win-cuda12-x64.zip', // NVIDIA CUDA (most HOT-Step users)
'-bin-win-avx2-x64.zip', // CPU fallback (AVX2)
'-bin-win-avx-x64.zip', // CPU fallback (AVX)
],
darwin: [
'-bin-Darwin-', // macOS (Metal/ARM)
],
linux: [
'-bin-Linux-', // Linux (CPU)
],
};
const GITHUB_RELEASES_API = 'https://api.github.com/repos/leejet/stable-diffusion.cpp/releases/latest';
/** Possible binary names inside the release ZIP (varies between releases) */
const SD_BINARY_NAMES = process.platform === 'win32'
? ['sd-cli.exe', 'sd.exe'] // newer releases: sd-cli.exe, older: sd.exe
: ['sd-cli', 'sd'];
// ── Types ───────────────────────────────────────────────────────────────
export type DownloadPhase = 'idle' | 'downloading' | 'completed' | 'failed' | 'cancelled';
export interface FileProgress {
filename: string;
description: string;
status: DownloadPhase;
bytesDownloaded: number;
totalBytes: number;
speed: number; // bytes/sec
error?: string;
}
export interface OverallStatus {
phase: DownloadPhase;
installed: boolean;
files: FileProgress[];
totalBytes: number;
downloadedBytes: number;
overallProgress: number; // 0-100
}
// ── Download Service ────────────────────────────────────────────────────
class CoverArtDownloader extends EventEmitter {
private fileProgress: Map<string, FileProgress> = new Map();
private abortControllers: Map<string, AbortController> = new Map();
private speedSamples: Map<string, { time: number; bytes: number }[]> = new Map();
private _downloading = false;
/** Get overall download/installation status */
getStatus(): OverallStatus {
const dir = getCoverArtDir();
const allEntries = this._getAllEntries();
const files: FileProgress[] = [];
let totalBytes = 0;
let downloadedBytes = 0;
for (const entry of allEntries) {
const filePath = path.join(dir, entry.filename);
const existing = this.fileProgress.get(entry.filename);
if (existing) {
files.push({ ...existing });
totalBytes += existing.totalBytes;
downloadedBytes += existing.bytesDownloaded;
} else if (fs.existsSync(filePath)) {
const size = fs.statSync(filePath).size;
files.push({
filename: entry.filename,
description: entry.description,
status: 'completed',
bytesDownloaded: size,
totalBytes: size,
speed: 0,
});
totalBytes += size;
downloadedBytes += size;
} else {
files.push({
filename: entry.filename,
description: entry.description,
status: 'idle',
bytesDownloaded: 0,
totalBytes: entry.sizeBytes,
speed: 0,
});
totalBytes += entry.sizeBytes;
}
}
const allFilesComplete = files.every(f => f.status === 'completed');
const anyFailed = files.some(f => f.status === 'failed');
const anyDownloading = files.some(f => f.status === 'downloading');
let phase: DownloadPhase = 'idle';
if (anyDownloading) phase = 'downloading';
else if (anyFailed) phase = 'failed';
else if (allFilesComplete) phase = 'completed';
return {
phase,
installed: allFilesComplete,
files,
totalBytes,
downloadedBytes,
overallProgress: totalBytes > 0 ? Math.round((downloadedBytes / totalBytes) * 100) : 0,
};
}
/** Start downloading all missing files (models + sd.exe) */
async startDownload(): Promise<void> {
if (this._downloading) return;
this._downloading = true;
const dir = getCoverArtDir();
fs.mkdirSync(dir, { recursive: true });
try {
// 1. Download model files from HuggingFace
for (const entry of MODEL_MANIFEST) {
const filePath = path.join(dir, entry.filename);
if (fs.existsSync(filePath)) {
console.log(`[CoverArt Download] ${entry.filename}: already exists, skipping`);
continue;
}
await this._downloadFile(entry, dir);
if (this.fileProgress.get(entry.filename)?.status === 'cancelled') {
return;
}
}
// 2. Download sd.exe from GitHub releases (if not present, or if
// CUDA runtime DLLs are missing on Windows — the release ZIP
// bundles them alongside the binary)
const sdPath = path.join(dir, REQUIRED_FILES.sdCli);
const needsSdDownload = !fs.existsSync(sdPath) ||
(process.platform === 'win32' && !this._hasCudaRuntimeDlls(dir));
if (needsSdDownload) {
// Remove stale binary so the extractor runs the full flow
if (fs.existsSync(sdPath)) {
console.log(`[CoverArt Download] ${REQUIRED_FILES.sdCli}: exists but CUDA runtime DLLs missing — re-downloading`);
try { fs.unlinkSync(sdPath); } catch {}
}
await this._downloadSdBinary(dir);
} else {
console.log(`[CoverArt Download] ${REQUIRED_FILES.sdCli}: already exists, skipping`);
}
} finally {
this._downloading = false;
}
}
/** Cancel all active downloads */
cancelDownload(): void {
for (const [filename, controller] of this.abortControllers) {
controller.abort();
const progress = this.fileProgress.get(filename);
if (progress && progress.status === 'downloading') {
progress.status = 'cancelled';
}
}
this.abortControllers.clear();
this._downloading = false;
this.emit('progress');
}
// ── sd.exe download from GitHub ──────────────────────────────────────
/**
* Download sd.exe from the latest GitHub release.
* 1. Query GitHub API for the latest release
* 2. Find the right ZIP asset for the current platform
* 3. Download and extract
*/
private async _downloadSdBinary(dir: string): Promise<void> {
const progressKey = REQUIRED_FILES.sdCli;
// Set progress to show we're working on it
const progress: FileProgress = {
filename: progressKey,
description: `stable-diffusion.cpp (${process.platform === 'win32' ? 'CUDA' : 'native'})`,
status: 'downloading',
bytesDownloaded: 0,
totalBytes: 0,
speed: 0,
};
this.fileProgress.set(progressKey, progress);
this.emit('progress');
try {
// Step 1: Query GitHub API for latest release
console.log('[CoverArt Download] Querying GitHub for latest stable-diffusion.cpp release...');
const releaseData = await this._fetchJson(GITHUB_RELEASES_API);
if (!releaseData || !Array.isArray(releaseData.assets)) {
throw new Error('Failed to fetch release data from GitHub');
}
// Step 2: Find the right asset for this platform
const patterns = SD_ASSET_PATTERNS[process.platform] || SD_ASSET_PATTERNS.linux;
let assetUrl: string | null = null;
let assetName: string = '';
let assetSize = 0;
for (const pattern of patterns) {
const asset = releaseData.assets.find((a: any) => a.name.includes(pattern));
if (asset) {
assetUrl = asset.browser_download_url;
assetName = asset.name;
assetSize = asset.size || 0;
break;
}
}
if (!assetUrl) {
throw new Error(`No compatible sd binary found for platform: ${process.platform}`);
}
console.log(`[CoverArt Download] Found: ${assetName} (${(assetSize / 1024 / 1024).toFixed(0)} MB)`);
progress.totalBytes = assetSize;
progress.description = `sd.exe engine (${assetName.includes('cuda') ? 'CUDA' : 'CPU'})`;
this.emit('progress');
// Step 3: Download the ZIP
const zipPath = path.join(dir, assetName);
await this._httpDownload(assetUrl, zipPath, progress, 0);
if (progress.status === 'cancelled') return;
// Step 4: Extract the ZIP
console.log(`[CoverArt Download] Extracting ${assetName}...`);
progress.description = `Extracting sd.exe...`;
this.emit('progress');
await this._extractZip(zipPath, dir);
// Step 5: Find the sd binary — name varies between releases
// Newer releases use 'sd-cli.exe', older use 'sd.exe'
const canonicalName = REQUIRED_FILES.sdCli; // our canonical filename
const sdPath = path.join(dir, canonicalName);
if (!fs.existsSync(sdPath)) {
let found: string | null = null;
// Search for any of the known binary names
for (const binaryName of SD_BINARY_NAMES) {
// Check root first
const rootPath = path.join(dir, binaryName);
if (fs.existsSync(rootPath)) {
found = rootPath;
break;
}
// Then search subdirectories (ZIP may have a nested folder)
const recursive = this._findFile(dir, binaryName);
if (recursive) {
found = recursive;
break;
}
}
if (found) {
// Rename to our canonical name
if (found !== sdPath) {
fs.renameSync(found, sdPath);
console.log(`[CoverArt Download] Found ${path.basename(found)}, renamed to ${canonicalName}`);
}
// Also move any DLLs/shared libs from the binary's subdirectory
const subDir = path.dirname(found);
if (subDir !== dir) {
const files = fs.readdirSync(subDir);
for (const f of files) {
const ext = path.extname(f).toLowerCase();
if (ext === '.dll' || ext === '.so' || ext === '.dylib') {
const src = path.join(subDir, f);
const dst = path.join(dir, f);
if (!fs.existsSync(dst)) {
fs.renameSync(src, dst);
}
}
}
// Clean up the extracted subdirectory
try { fs.rmSync(subDir, { recursive: true, force: true }); } catch {}
}
} else {
// List what we DID find for debugging
const allFiles = this._listFiles(dir);
console.error(`[CoverArt Download] Available files after extraction: ${allFiles.join(', ')}`);
throw new Error(`sd binary not found after extraction (searched for: ${SD_BINARY_NAMES.join(', ')})`);
}
}
// Sweep ALL subdirectories for stray runtime DLLs that the above
// binary-centric move may have missed (different ZIP layouts, nested
// folders, etc.)
this._sweepRuntimeLibs(dir);
// Step 6: Clean up ZIP
try { fs.unlinkSync(zipPath); } catch {}
console.log(`[CoverArt Download] sd.exe ready`);
progress.status = 'completed';
progress.speed = 0;
} catch (err: any) {
if (progress.status === 'cancelled') return;
progress.status = 'failed';
progress.error = err.message;
progress.speed = 0;
console.error(`[CoverArt Download] sd.exe download failed: ${err.message}`);
} finally {
this.emit('progress');
}
}
/** Fetch JSON from a URL (for GitHub API) */
private _fetchJson(url: string): Promise<any> {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'https:' ? https : http;
transport.get(parsedUrl, {
headers: {
'User-Agent': 'HOT-Step-CPP/1.0',
'Accept': 'application/vnd.github.v3+json',
},
}, (res) => {
// Handle redirects
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
this._fetchJson(res.headers.location).then(resolve).catch(reject);
return;
}
if (res.statusCode && res.statusCode >= 400) {
res.resume();
reject(new Error(`GitHub API error: HTTP ${res.statusCode}`));
return;
}
let body = '';
res.on('data', (chunk: Buffer) => body += chunk.toString());
res.on('end', () => {
try { resolve(JSON.parse(body)); }
catch { reject(new Error('Invalid JSON from GitHub API')); }
});
res.on('error', reject);
}).on('error', reject);
});
}
/** Extract a ZIP archive using platform tools */
private async _extractZip(zipPath: string, targetDir: string): Promise<void> {
if (process.platform === 'win32') {
// PowerShell Expand-Archive
await execFileAsync('powershell.exe', [
'-NoProfile', '-Command',
`Expand-Archive -Path '${zipPath}' -DestinationPath '${targetDir}' -Force`,
], { timeout: 120_000 });
} else {
// macOS/Linux: unzip
await execFileAsync('unzip', ['-o', zipPath, '-d', targetDir], {
timeout: 120_000,
});
}
}
/** Recursively search for a file by name in a directory */
private _findFile(dir: string, filename: string): string | null {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isFile() && entry.name === filename) return fullPath;
if (entry.isDirectory()) {
const found = this._findFile(fullPath, filename);
if (found) return found;
}
}
} catch {}
return null;
}
/** List all files in a directory (non-recursive) for debug output */
private _listFiles(dir: string): string[] {
try {
return fs.readdirSync(dir);
} catch { return []; }
}
/**
* Recursively move all .dll/.so/.dylib files from subdirectories to root.
* Cleans up empty subdirectories afterwards.
*/
private _sweepRuntimeLibs(rootDir: string): void {
const LIB_EXTS = new Set(['.dll', '.so', '.dylib']);
let moved = 0;
const sweep = (dir: string) => {
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
sweep(full);
// Remove empty subdirectories
try {
const remaining = fs.readdirSync(full);
if (remaining.length === 0) fs.rmdirSync(full);
} catch {}
} else if (entry.isFile() && LIB_EXTS.has(path.extname(entry.name).toLowerCase())) {
if (dir !== rootDir) {
const dst = path.join(rootDir, entry.name);
if (!fs.existsSync(dst)) {
fs.renameSync(full, dst);
moved++;
}
}
}
}
} catch {}
};
sweep(rootDir);
if (moved > 0) {
console.log(`[CoverArt Download] Swept ${moved} runtime lib(s) to cover-art root`);
}
}
/** Check if CUDA 12 runtime DLLs are co-located with sd.exe (Windows only) */
private _hasCudaRuntimeDlls(dir: string): boolean {
const dlls = ['cublas64_12.dll', 'cublasLt64_12.dll', 'cudart64_12.dll'];
return dlls.every(dll => fs.existsSync(path.join(dir, dll)));
}
/** Build the complete list of entries including sd.exe for status display */
private _getAllEntries(): ManifestEntry[] {
const sdEntry: ManifestEntry = {
filename: REQUIRED_FILES.sdCli,
url: '', // resolved at download time
sizeBytes: process.platform === 'win32' ? 336_000_000 : 21_000_000, // estimate
description: `stable-diffusion.cpp (${process.platform === 'win32' ? 'CUDA' : 'native'})`,
};
return [...MODEL_MANIFEST, sdEntry];
}
// ── File download (shared) ───────────────────────────────────────────
private async _downloadFile(entry: ManifestEntry, dir: string): Promise<void> {
const partPath = path.join(dir, `${entry.filename}.part`);
const finalPath = path.join(dir, entry.filename);
let startByte = 0;
if (fs.existsSync(partPath)) {
startByte = fs.statSync(partPath).size;
}
const progress: FileProgress = {
filename: entry.filename,
description: entry.description,
status: 'downloading',
bytesDownloaded: startByte,
totalBytes: entry.sizeBytes,
speed: 0,
};
this.fileProgress.set(entry.filename, progress);
this.speedSamples.set(entry.filename, []);
const abortController = new AbortController();
this.abortControllers.set(entry.filename, abortController);
console.log(`[CoverArt Download] Starting: ${entry.filename} (${(entry.sizeBytes / 1024 / 1024 / 1024).toFixed(1)} GB)`);
if (startByte > 0) {
console.log(`[CoverArt Download] Resuming from ${(startByte / 1024 / 1024).toFixed(0)} MB`);
}
this.emit('progress');
try {
await this._httpDownload(entry.url, partPath, progress, startByte);
if (progress.status === 'cancelled') return;
fs.renameSync(partPath, finalPath);
progress.status = 'completed';
progress.speed = 0;
console.log(`[CoverArt Download] Complete: ${entry.filename}`);
} catch (err: any) {
if (progress.status === 'cancelled') return;
progress.status = 'failed';
progress.error = err.message;
progress.speed = 0;
console.error(`[CoverArt Download] Failed: ${entry.filename}${err.message}`);
} finally {
this.abortControllers.delete(entry.filename);
this.emit('progress');
}
}
private _httpDownload(url: string, partPath: string, progress: FileProgress, startByte: number, redirectCount = 0): Promise<void> {
if (redirectCount > 5) return Promise.reject(new Error('Too many redirects'));
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'https:' ? https : http;
const headers: Record<string, string> = {
'User-Agent': 'HOT-Step-CPP/1.0',
};
if (startByte > 0) {
headers['Range'] = `bytes=${startByte}-`;
}
const req = transport.get(parsedUrl, { headers }, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume();
this._httpDownload(res.headers.location, partPath, progress, startByte, redirectCount + 1)
.then(resolve).catch(reject);
return;
}
if (res.statusCode === 416) {
res.resume();
resolve();
return;
}
if (res.statusCode && res.statusCode >= 400) {
res.resume();
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
const contentRange = res.headers['content-range'];
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match) progress.totalBytes = parseInt(match[1], 10);
} else if (res.headers['content-length'] && startByte === 0) {
progress.totalBytes = parseInt(res.headers['content-length'], 10);
}
const writeStream = fs.createWriteStream(partPath, {
flags: startByte > 0 ? 'a' : 'w',
});
const samples = this.speedSamples.get(progress.filename) || [];
res.on('data', (chunk: Buffer) => {
progress.bytesDownloaded += chunk.length;
const now = Date.now();
samples.push({ time: now, bytes: chunk.length });
const cutoff = now - 3000;
while (samples.length > 0 && samples[0].time < cutoff) samples.shift();
if (samples.length > 1) {
const totalSampleBytes = samples.reduce((a, s) => a + s.bytes, 0);
const elapsed = (now - samples[0].time) / 1000;
progress.speed = elapsed > 0 ? totalSampleBytes / elapsed : 0;
}
this.emit('progress');
});
res.on('end', () => {
writeStream.end(() => resolve());
});
res.on('error', (err) => {
writeStream.end();
reject(err);
});
res.pipe(writeStream, { end: false });
const abortCtrl = this.abortControllers.get(progress.filename);
if (abortCtrl) {
abortCtrl.signal.addEventListener('abort', () => {
res.destroy();
writeStream.end();
});
}
});
req.on('error', reject);
});
}
}
export const coverArtDownloader = new CoverArtDownloader();
@@ -0,0 +1,211 @@
// coverArtService.ts — Cover art generation via stable-diffusion.cpp (sd-cli)
//
// Spawns sd-cli.exe as a subprocess to generate album cover art using
// FLUX.2-klein-4B. Same integration pattern as mastering.exe.
//
// Output: 1024×1024 PNG saved as WebP alongside the song's audio file.
import fs from 'fs';
import path from 'path';
import { execFile } from 'child_process';
import { promisify } from 'util';
import { randomInt } from 'crypto';
import { v4 as uuidv4 } from 'uuid';
import { config } from '../../config.js';
import { getDb } from '../../db/database.js';
import { buildCoverArtPrompt, type CoverArtPromptOpts } from './promptBuilder.js';
const execFileAsync = promisify(execFile);
// ── Constants ───────────────────────────────────────────────────────────
/** Directory name within the models folder for cover art assets */
const COVER_ART_DIR = 'cover-art';
/** Expected filenames within the cover-art directory */
export const REQUIRED_FILES = {
sdCli: process.platform === 'win32' ? 'sd.exe' : 'sd',
diffusionModel: 'flux-2-klein-4b-Q4_0.gguf',
vae: 'flux2_vae.safetensors',
llm: 'Qwen3-4B-Q4_K_M.gguf',
} as const;
/** Generation parameters */
const GEN_WIDTH = 1024;
const GEN_HEIGHT = 1024;
const GEN_STEPS = 4;
const GEN_CFG_SCALE = 1.0;
const GEN_TIMEOUT_MS = 180_000; // 3 minutes
// ── Path resolution ─────────────────────────────────────────────────────
/** Get the cover-art assets directory */
export function getCoverArtDir(): string {
return path.join(config.aceServer.models, COVER_ART_DIR);
}
/** Resolve path to a file in the cover-art directory */
function getFilePath(filename: string): string {
return path.join(getCoverArtDir(), filename);
}
// ── Readiness check ─────────────────────────────────────────────────────
export interface CoverArtStatus {
installed: boolean;
missingFiles: string[];
dir: string;
}
/** Check if all required files for cover art generation are present. */
export function getCoverArtReadiness(): CoverArtStatus {
const dir = getCoverArtDir();
const missing: string[] = [];
for (const [, filename] of Object.entries(REQUIRED_FILES)) {
const filePath = path.join(dir, filename);
if (!fs.existsSync(filePath)) {
missing.push(filename);
}
}
return {
installed: missing.length === 0,
missingFiles: missing,
dir,
};
}
// ── Generation ──────────────────────────────────────────────────────────
export interface GenerateCoverArtOpts extends CoverArtPromptOpts {
songId: string;
}
/** Options for the image-only phase (no songId needed) */
export interface GenerateCoverImageOpts extends CoverArtPromptOpts {}
export interface CoverArtResult {
coverUrl: string;
prompt: string;
durationMs: number;
}
/**
* Phase 1: Generate the cover art image (GPU-heavy, no DB writes).
*
* 1. Builds a prompt from song metadata
* 2. Spawns sd-cli.exe with FLUX.2-klein-4B
* 3. Saves the output as PNG in the audio directory
*
* Returns the coverUrl and prompt. Does NOT touch the database.
* Use linkCoverToSong() afterwards to associate with a song.
*/
export async function generateCoverImage(opts: GenerateCoverImageOpts): Promise<CoverArtResult> {
const startTime = Date.now();
// Verify readiness
const status = getCoverArtReadiness();
if (!status.installed) {
throw new Error(`Cover art not ready — missing: ${status.missingFiles.join(', ')}`);
}
// Build prompt
const prompt = buildCoverArtPrompt(opts);
console.log(`[CoverArt] Prompt: "${prompt}"`);
// Resolve paths
const sdCli = getFilePath(REQUIRED_FILES.sdCli);
const diffusionModel = getFilePath(REQUIRED_FILES.diffusionModel);
const vae = getFilePath(REQUIRED_FILES.vae);
const llm = getFilePath(REQUIRED_FILES.llm);
// Output to audio directory as PNG (sd-cli determines format from extension)
const outputFilename = `cover_${uuidv4()}.png`;
const outputPath = path.join(config.data.audioDir, outputFilename);
// Random seed — each cover should be unique
const seed = randomInt(0, 2 ** 32);
// Build sd-cli command
const args = [
'--diffusion-model', diffusionModel,
'--vae', vae,
'--llm', llm,
'-p', prompt,
'-n', 'text, lettering, words, typography, watermark, signature, logo, title, font, writing, caption, label, stamp, banner',
'--seed', String(seed),
'--cfg-scale', String(GEN_CFG_SCALE),
'--steps', String(GEN_STEPS),
'--width', String(GEN_WIDTH),
'--height', String(GEN_HEIGHT),
'--sampling-method', 'euler',
'--diffusion-fa',
'-o', outputPath,
];
console.log(`[CoverArt] Running: ${path.basename(sdCli)} (${GEN_WIDTH}×${GEN_HEIGHT}, ${GEN_STEPS} steps, seed=${seed})`);
try {
const { stdout, stderr } = await execFileAsync(sdCli, args, {
timeout: GEN_TIMEOUT_MS,
maxBuffer: 10 * 1024 * 1024, // 10MB buffer for verbose output
});
// Log sd-cli output
if (stdout) {
for (const line of stdout.split('\n')) {
if (line.trim()) console.log(`[CoverArt] ${line.trim()}`);
}
}
if (stderr) {
for (const line of stderr.split('\n')) {
if (line.trim()) console.log(`[CoverArt] ${line.trim()}`);
}
}
} catch (err: any) {
// Clean up partial output
try { if (fs.existsSync(outputPath)) fs.unlinkSync(outputPath); } catch {}
throw new Error(`sd-cli failed: ${err.message}`);
}
// Verify output was created
if (!fs.existsSync(outputPath)) {
throw new Error('sd-cli completed but no output file was generated');
}
const coverUrl = `/audio/${outputFilename}`;
const durationMs = Date.now() - startTime;
console.log(`[CoverArt] Image generated: ${outputFilename} (${(durationMs / 1000).toFixed(1)}s)`);
return {
coverUrl,
prompt,
durationMs,
};
}
/**
* Phase 2: Link a generated cover image to a song in the database.
* This is a lightweight DB UPDATE — no GPU work.
*/
export function linkCoverToSong(coverUrl: string, songId: string): void {
try {
getDb().prepare('UPDATE songs SET cover_url = ? WHERE id = ?')
.run(coverUrl, songId);
console.log(`[CoverArt] Linked cover to song ${songId}: ${coverUrl}`);
} catch (dbErr: any) {
console.error(`[CoverArt] DB update failed for ${songId}: ${dbErr.message}`);
}
}
/**
* Convenience wrapper: generate image + link to song in one call.
* Used by the sequential (non-parallel) path and the cover art API endpoint.
*/
export async function generateCoverArt(opts: GenerateCoverArtOpts): Promise<CoverArtResult> {
const result = await generateCoverImage(opts);
linkCoverToSong(result.coverUrl, opts.songId);
return result;
}
@@ -0,0 +1,147 @@
// promptBuilder.ts — Build text-to-image prompts from song metadata
//
// Ported from HOT-Step 9000's acestep/core/cover_art.py
//
// When `subject` is provided (from Lireek metadata), it's used as the
// primary prompt for more evocative imagery. Otherwise falls back to
// keyword extraction from lyrics.
const STOP_WORDS = new Set([
'the', 'a', 'an', 'and', 'or', 'but', 'in', 'on', 'at', 'to', 'for',
'of', 'with', 'by', 'from', 'is', 'it', 'its', 'are', 'was', 'were',
'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',
'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can',
'not', 'no', 'so', 'if', 'up', 'out', 'just', 'like', 'my', 'me',
'we', 'you', 'your', 'they', 'them', 'he', 'she', 'her', 'his',
'i', 'im', 'ive', 'dont', 'that', 'this', 'all', 'got', 'get',
'when', 'what', 'where', 'how', 'why', 'oh', 'yeah', 'ya', 'na',
'la', 'da', 'uh', 'ah', 'ooh', 'hey', 'go', 'know', 'come', 'take',
'make', 'see', 'let', 'say', 'one', 'way', 'back', 'now',
'more', 'than', 'into', 'over', 'down', 'been',
]);
/** Extract the most common meaningful words from lyrics. */
export function extractThemeKeywords(lyrics: string, maxKeywords = 5): string[] {
if (!lyrics?.trim()) return [];
// Strip section headers like [Verse 1]
let cleaned = lyrics.replace(/\[.*?\]/g, '');
// Remove punctuation, lowercase
cleaned = cleaned.replace(/[^\w\s]/g, '').toLowerCase();
const words = cleaned.split(/\s+/)
.filter(w => w.length > 3 && !STOP_WORDS.has(w));
if (words.length === 0) return [];
// Count frequencies
const freq: Record<string, number> = {};
for (const w of words) freq[w] = (freq[w] || 0) + 1;
// Sort by frequency, take top N
return Object.entries(freq)
.sort((a, b) => b[1] - a[1])
.slice(0, maxKeywords)
.map(([word]) => word);
}
/**
* Map music genre keywords to visual moods/palettes for image generation.
* Avoids any text-triggering words like "album", "cover", "title", etc.
*/
const GENRE_VISUALS: Record<string, string> = {
rock: 'dramatic lighting, electric atmosphere, high contrast',
metal: 'dark dramatic scene, intense fire and shadows, heavy atmosphere',
punk: 'gritty urban scene, raw energy, bold colors, rebellion',
pop: 'vibrant colors, clean aesthetic, bright lighting, contemporary',
electronic: 'neon lights, futuristic environment, glowing particles, cyberpunk',
jazz: 'warm golden tones, smoky atmosphere, elegant mood, sophisticated',
blues: 'moody blue tones, deep shadows, soulful atmosphere',
folk: 'natural landscapes, warm earth tones, rustic beauty, pastoral',
classical: 'elegant composition, renaissance lighting, grand architecture',
hip: 'urban cityscape, bold colors, street culture, dynamic perspective',
rap: 'urban environment, dramatic angles, street aesthetic',
country: 'wide open landscapes, golden hour, rural beauty, americana',
indie: 'dreamy atmosphere, soft pastel colors, artistic composition',
r: 'warm intimate lighting, smooth gradients, elegant silhouettes',
ambient: 'ethereal landscapes, soft focus, atmospheric mist, dreamlike',
bossa: 'tropical sunset, warm golden light, coastal paradise',
reggae: 'tropical colors, island vibes, sunset hues, laid-back mood',
soul: 'warm rich tones, intimate atmosphere, emotional depth',
funk: 'bold psychedelic colors, retro vibes, dynamic energy',
alternative: 'moody atmosphere, artistic composition, unconventional beauty',
};
/** Get visual mood keywords based on genre/style string */
function getGenreVisuals(style: string): string {
if (!style) return '';
const lower = style.toLowerCase();
for (const [genre, visuals] of Object.entries(GENRE_VISUALS)) {
if (lower.includes(genre)) return visuals;
}
return '';
}
export interface CoverArtPromptOpts {
title?: string;
style?: string;
lyrics?: string;
subject?: string;
/**
* Fully user-authored prompt. When present (non-empty), it is used VERBATIM
* as the positive prompt — all auto-assembly (subject/genre/art-direction) is
* skipped. Set by the per-track "Generate Cover Art" prompt modal (#67).
*/
prompt?: string;
}
/**
* Build a text-to-image prompt from song metadata.
*
* IMPORTANT: Avoids ALL text-triggering language. FLUX models at cfg_scale=1
* ignore negative prompts, so the only way to prevent text in the output is
* to never mention text-related concepts (album, cover, title, etc.) in the
* positive prompt. We describe only visual scenes and moods.
*/
export function buildCoverArtPrompt(opts: CoverArtPromptOpts): string {
// User-authored prompt wins outright — used exactly as typed (#67).
if (opts.prompt?.trim()) {
return opts.prompt.trim();
}
const parts: string[] = [];
if (opts.subject?.trim()) {
// Rich subject path: use the curated description as a visual scene
parts.push(opts.subject.trim());
} else {
// Fallback: extract visual themes from lyrics
const keywords = extractThemeKeywords(opts.lyrics || '', 5);
if (keywords.length > 0) {
parts.push(`a scene evoking ${keywords.join(', ')}`);
} else {
// Absolute fallback — generic but text-free
parts.push('a striking visual composition with dramatic lighting');
}
}
// Genre-aware visual mood
const genreVisuals = getGenreVisuals(opts.style || '');
if (genreVisuals) {
parts.push(genreVisuals);
} else if (opts.style) {
// Use raw style words as mood descriptors (but skip any that look like names)
const styleWords = opts.style.split(',')
.map(w => w.trim().toLowerCase())
.filter(w => w.length > 2 && !w.includes('_'))
.slice(0, 2);
if (styleWords.length > 0) {
parts.push(`${styleWords.join(' ')} aesthetic`);
}
}
// Art direction — purely visual, zero text-triggering words
parts.push('digital painting, cinematic composition, highly detailed, beautiful lighting, 8k');
return parts.join(', ');
}
+228
View File
@@ -0,0 +1,228 @@
// disco-analyzer.ts — Server-side WAV analysis for disco beat visualization.
//
// Reads WAV stem files, computes RMS energy per ~16ms window,
// normalises to [01], and saves as a compact JSON file.
//
// The browser loads just this JSON (~30-60 KB) instead of three WAV
// files (~15 MB total). Energy lookup is a pure array index — zero
// sync issues with the main player.
import fs from 'fs';
import path from 'path';
// ── Types ────────────────────────────────────────────────────────────────────
export interface DiscoData {
version: number; // Schema version
fps: number; // Analysis windows per second
duration: number; // Total duration in seconds
kick: number[]; // Energy per window [01], 2 decimal places
snare: number[];
hihat: number[];
}
// ── WAV Parsing ──────────────────────────────────────────────────────────────
//
// Handles 16-bit PCM, 24-bit PCM, and 32-bit IEEE float.
// Mono or stereo — stereo is mixed to mono.
interface WavData {
sampleRate: number;
channels: number;
samples: Float32Array; // Mono mix, normalised to [-1, 1]
}
function parseWav(filePath: string): WavData {
const buf = fs.readFileSync(filePath);
// RIFF header
const riff = buf.toString('ascii', 0, 4);
const wave = buf.toString('ascii', 8, 12);
if (riff !== 'RIFF' || wave !== 'WAVE') {
throw new Error(`Not a WAV file: ${filePath}`);
}
// Find fmt and data chunks
let fmtOffset = -1;
let dataOffset = -1;
let dataSize = 0;
let pos = 12;
while (pos < buf.length - 8) {
const chunkId = buf.toString('ascii', pos, pos + 4);
const chunkSize = buf.readUInt32LE(pos + 4);
if (chunkId === 'fmt ') {
fmtOffset = pos + 8;
} else if (chunkId === 'data') {
dataOffset = pos + 8;
dataSize = chunkSize;
}
pos += 8 + chunkSize;
// Chunks must be word-aligned
if (chunkSize % 2 !== 0) pos++;
}
if (fmtOffset < 0) throw new Error(`No fmt chunk in: ${filePath}`);
if (dataOffset < 0) throw new Error(`No data chunk in: ${filePath}`);
// Parse fmt chunk
const audioFormat = buf.readUInt16LE(fmtOffset); // 1=PCM, 3=IEEE float
const channels = buf.readUInt16LE(fmtOffset + 2);
const sampleRate = buf.readUInt32LE(fmtOffset + 4);
const bitsPerSample = buf.readUInt16LE(fmtOffset + 14);
// Calculate samples
const bytesPerSample = bitsPerSample / 8;
const totalFrames = Math.floor(dataSize / (bytesPerSample * channels));
const samples = new Float32Array(totalFrames);
// Read PCM data and mix to mono
for (let i = 0; i < totalFrames; i++) {
let monoSum = 0;
for (let ch = 0; ch < channels; ch++) {
const offset = dataOffset + (i * channels + ch) * bytesPerSample;
let sample: number;
if (audioFormat === 3 && bitsPerSample === 32) {
// 32-bit IEEE float
sample = buf.readFloatLE(offset);
} else if (audioFormat === 1 && bitsPerSample === 16) {
// 16-bit signed PCM
sample = buf.readInt16LE(offset) / 32768;
} else if (audioFormat === 1 && bitsPerSample === 24) {
// 24-bit signed PCM (manual 3-byte read)
const b0 = buf[offset];
const b1 = buf[offset + 1];
const b2 = buf[offset + 2];
const val = (b2 << 16) | (b1 << 8) | b0;
sample = (val >= 0x800000 ? val - 0x1000000 : val) / 8388608;
} else if (audioFormat === 1 && bitsPerSample === 32) {
// 32-bit signed PCM
sample = buf.readInt32LE(offset) / 2147483648;
} else {
throw new Error(`Unsupported WAV format: ${audioFormat}/${bitsPerSample}bit in ${filePath}`);
}
monoSum += sample;
}
samples[i] = monoSum / channels;
}
return { sampleRate, channels, samples };
}
// ── Energy Analysis ──────────────────────────────────────────────────────────
const ANALYSIS_FPS = 60; // ~16.7ms windows
/**
* Compute normalised RMS energy per window for a WAV file.
* Returns array of values [01], rounded to 2 decimal places.
*/
function analyzeWav(filePath: string): { energy: number[]; duration: number; sampleRate: number } {
const wav = parseWav(filePath);
const windowSamples = Math.floor(wav.sampleRate / ANALYSIS_FPS);
const totalWindows = Math.ceil(wav.samples.length / windowSamples);
const energy = new Float32Array(totalWindows);
// Compute RMS per window
let maxRms = 0;
for (let w = 0; w < totalWindows; w++) {
const start = w * windowSamples;
const end = Math.min(start + windowSamples, wav.samples.length);
let sumSq = 0;
for (let i = start; i < end; i++) {
sumSq += wav.samples[i] * wav.samples[i];
}
const rms = Math.sqrt(sumSq / (end - start));
energy[w] = rms;
if (rms > maxRms) maxRms = rms;
}
// Normalise to [01]
const result: number[] = new Array(totalWindows);
if (maxRms > 1e-8) {
for (let w = 0; w < totalWindows; w++) {
result[w] = Math.round((energy[w] / maxRms) * 100) / 100;
}
} else {
result.fill(0);
}
const duration = wav.samples.length / wav.sampleRate;
return { energy: result, duration, sampleRate: wav.sampleRate };
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Analyze drum stem WAV files and save a compact disco data JSON file.
*
* @param songId - Song ID (used for filenames)
* @param audioDir - Directory containing stem WAV files
* @param stemUrls - Object with kick/snare/hihat URL paths (e.g., "/audio/abc_kick.wav")
* @returns URL path to the saved JSON file, or '' if no stems available
*/
export function analyzeAndSaveDiscoData(
songId: string,
audioDir: string,
stemUrls: { kick?: string; snare?: string; hihat?: string },
): string {
const stemCount = [stemUrls.kick, stemUrls.snare, stemUrls.hihat].filter(Boolean).length;
if (stemCount === 0) {
console.log(`[DiscoAnalyzer] Song ${songId}: no stems to analyze`);
return '';
}
console.log(`[DiscoAnalyzer] Song ${songId}: analyzing ${stemCount} stem(s)...`);
const t0 = Date.now();
let duration = 0;
// Analyze each available stem
function analyzeStem(url: string | undefined, label: string): number[] {
if (!url) return [];
const filename = path.basename(url);
const filePath = path.join(audioDir, filename);
if (!fs.existsSync(filePath)) {
console.warn(`[DiscoAnalyzer] ${label} stem file not found: ${filePath}`);
return [];
}
try {
const result = analyzeWav(filePath);
if (result.duration > duration) duration = result.duration;
console.log(`[DiscoAnalyzer] ${label}: ${result.energy.length} windows, ${result.duration.toFixed(1)}s`);
return result.energy;
} catch (err: any) {
console.error(`[DiscoAnalyzer] ${label}: analysis failed: ${err.message}`);
return [];
}
}
const kick = analyzeStem(stemUrls.kick, 'kick');
const snare = analyzeStem(stemUrls.snare, 'snare');
const hihat = analyzeStem(stemUrls.hihat, 'hihat');
// Build disco data
const data: DiscoData = {
version: 1,
fps: ANALYSIS_FPS,
duration,
kick,
snare,
hihat,
};
// Save to JSON
const filename = `${songId}_disco.json`;
const filePath = path.join(audioDir, filename);
fs.writeFileSync(filePath, JSON.stringify(data));
const fileSize = fs.statSync(filePath).size;
const elapsed = Date.now() - t0;
console.log(`[DiscoAnalyzer] Song ${songId}: saved ${filename} (${(fileSize / 1024).toFixed(1)} KB) in ${elapsed}ms`);
return `/audio/${filename}`;
}
@@ -0,0 +1,197 @@
// generation/adapterSections.ts — Per-section adapter masking (regional LoRA)
//
// Parses inline per-section adapter-influence directives from the lyrics, e.g.
//
// [Intro]{greenday_idiot=1; blink_selftitled=0}
// [Verse 1]{greenday_idiot=0.5; blink_selftitled=0.5}
// ...lines...
// [Chorus]{#1=0; #2=1} (positional #N / bare N also accepted)
//
// and turns them into a per-section weight table indexed to the loaded adapter
// stack, plus the lyrics with the {…} directives stripped (the model must never
// see them). Keyed by trigger word (adapter filename stem), or positional #N.
//
// Sum/Blend (issue #72) is reused per section; directive-less sections fall back
// to the stack's normal effective scales ("uniform blend of the stack").
// See docs/plans/per-section-adapter-masking.md.
export interface AdapterSection {
weights: number[]; // effective per-adapter scale, indexed to the stack
size: number; // relative frame-allocation hint (section char count)
}
export interface ParsedAdapterSections {
lyrics: string; // directives stripped
sections?: AdapterSection[]; // undefined when the feature is inactive
}
/** filename stem (trigger word) for an adapter path */
function triggerOf(p: string): string {
return (p.split(/[\\/]/).pop() || p).replace(/\.safetensors$/i, '');
}
/** Resolve a directive key (trigger word = filename stem, or positional "#2"/"2") to a stack index, or -1. */
function resolveKey(key: string, triggers: string[]): number {
const k = key.trim().toLowerCase();
const byTrigger = triggers.findIndex(t => t.toLowerCase() === k);
if (byTrigger >= 0) return byTrigger;
const m = k.match(/^#?(\d+)$/);
if (m) {
const idx = parseInt(m[1], 10) - 1; // 1-based
if (idx >= 0 && idx < triggers.length) return idx;
}
return -1;
}
interface DirectiveParse {
raw: number[]; // per-adapter weights (unmentioned → 0)
pairs: number; // `key=val` pairs found (0 → not a directive at all)
resolved: number; // pairs whose key matched a stacked adapter
unresolved: string[]; // keys that parsed but matched nothing (typos)
}
/** Parse a `key=val; key=val` directive body into raw per-adapter weights. */
function parseDirective(body: string, triggers: string[]): DirectiveParse {
const raw = new Array(triggers.length).fill(0);
let pairs = 0, resolved = 0;
const unresolved: string[] = [];
for (const part of body.split(/[;,]/)) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const val = parseFloat(part.slice(eq + 1).trim());
if (!key || !Number.isFinite(val)) continue;
pairs++;
const idx = resolveKey(key, triggers);
if (idx >= 0) { raw[idx] = Math.max(0, val); resolved++; }
else unresolved.push(key);
}
return { raw, pairs, resolved, unresolved };
}
/** True when a `{…}` body is directive-shaped (≥1 key=val pair), regardless of key resolution. */
function isDirectiveShaped(body: string): boolean {
for (const part of body.split(/[;,]/)) {
const eq = part.indexOf('=');
if (eq < 0) continue;
const key = part.slice(0, eq).trim();
const val = parseFloat(part.slice(eq + 1).trim());
if (key && Number.isFinite(val)) return true;
}
return false;
}
/**
* Strip directive-shaped `[Header]{…}` blocks from lyrics WITHOUT applying them.
* Used when the ≥2-adapter gate is not met, so stray directives never reach the
* LM/encoder as garbage tokens. Non-directive `{…}` (no key=val pair, e.g. a
* stylistic `{softly}`) is left untouched.
*/
export function stripAdapterDirectives(lyrics: string): string {
if (!lyrics) return lyrics;
return lyrics.replace(/(\[[^\]\n]+\])[ \t]*\{([^}]*)\}/g, (full, header, body) =>
isDirectiveShaped(body) ? header : full);
}
/** Apply the #72 Sum/Blend transform to a section's raw weights. */
function applyMode(raw: number[], mode: string, budget: number): number[] {
if (mode === 'blend') {
const sum = raw.reduce((a, b) => a + (b || 0), 0);
if (sum > 0) return raw.map(w => +(budget * (w || 0) / sum).toFixed(4));
return raw.map(() => 0); // explicit all-zero directive → base only
}
return raw.map(w => w || 0); // sum: raw as-is
}
/**
* Parse per-section adapter directives from lyrics.
* @param lyrics raw lyrics (may contain `[Section]{…}` directives)
* @param stack loaded adapter stack (effective scales), order matches the engine
* @param mode 'sum' | 'blend'
* @param budget combined-strength budget (blend)
*/
export function parseAdapterSections(
lyrics: string,
stack: { path: string; scale: number }[],
mode: string,
budget: number,
): ParsedAdapterSections {
if (!lyrics || !Array.isArray(stack) || stack.length < 2) return { lyrics };
// Fast bail-out: no directive syntax at all.
if (!/\]\s*\{[^}]*\}/.test(lyrics) && !/^\s*\{[^}]*\}/.test(lyrics)) return { lyrics };
const triggers = stack.map(s => triggerOf(s.path));
const defaultWeights = stack.map(s => s.scale); // uniform blend of the stack
// Split into sections at [Header] lines, capturing an optional {…} directive
// that follows the header. Content before the first header is an implicit
// directive-less section.
const headerRe = /\[[^\]\n]+\]/g;
const sections: AdapterSection[] = [];
let cleaned = '';
let lastIndex = 0;
// Helper to push a section given its body text and directive (raw weights or null).
const pushSection = (body: string, raw: number[] | null) => {
const size = Math.max(1, body.replace(/\s+/g, ' ').trim().length);
const weights = raw ? applyMode(raw, mode, budget) : defaultWeights.slice();
sections.push({ weights, size });
};
const matches = [...lyrics.matchAll(headerRe)];
if (matches.length === 0) return { lyrics };
// Preamble before the first header (rare) → implicit default section.
const firstStart = matches[0].index ?? 0;
if (firstStart > 0 && lyrics.slice(0, firstStart).trim().length > 0) {
pushSection(lyrics.slice(0, firstStart), null);
}
cleaned += lyrics.slice(0, firstStart);
lastIndex = firstStart;
for (let mi = 0; mi < matches.length; mi++) {
const h = matches[mi];
const hStart = h.index ?? 0;
const header = h[0];
let cursor = hStart + header.length;
// Optional directive immediately after the header (allowing whitespace).
// A `{…}` block is only treated as a directive when it contains at least
// one key=val pair — `[Verse] {softly}` is lyric text, not an all-zero
// directive, and must stay in the lyrics untouched.
let raw: number[] | null = null;
const after = lyrics.slice(cursor);
const dm = after.match(/^[ \t]*\{([^}]*)\}/);
const headerOut = header;
if (dm) {
const p = parseDirective(dm[1], triggers);
if (p.pairs > 0) {
cursor += dm[0].length; // directive-shaped → strip from the output
if (p.unresolved.length) {
console.warn(`[AdapterSections] ${header} directive: unknown adapter key(s) ${p.unresolved.map(k => `"${k}"`).join(', ')} — loaded triggers: ${triggers.join(', ')}`);
}
if (p.resolved > 0) {
raw = p.raw;
} else {
// Every key was a typo — fall back to the stack defaults rather
// than silently disabling all adapters for this section.
console.warn(`[AdapterSections] ${header} directive: no keys resolved, using stack default weights`);
raw = null;
}
}
// p.pairs === 0 → not a directive: leave the `{…}` in the body/lyrics.
}
// Body runs until the next header (or end).
const bodyEnd = (mi + 1 < matches.length) ? (matches[mi + 1].index ?? lyrics.length) : lyrics.length;
const body = lyrics.slice(cursor, bodyEnd);
pushSection(body, raw);
cleaned += headerOut + body;
lastIndex = bodyEnd;
}
cleaned += lyrics.slice(lastIndex);
if (sections.length === 0) return { lyrics };
return { lyrics: cleaned, sections };
}
@@ -0,0 +1,382 @@
// audioQualityEvaluator.ts — Audio quality scoring for post-generation analysis
//
// Ported from jeankassio/JK-AceStep-Nodes AudioQualityEvaluator (MIT License).
// Pure TypeScript — no librosa/numpy/external dependencies.
//
// Three metrics (matching original weights):
// 1. Metallic Sound (40%) — spectral rolloff at 85th percentile
// 2. Word Cuts (40%) — spectral flux discontinuities (z-score)
// 3. Noise / Hiss (20%) — zero-crossing rate
//
// NOTE: Scoring curves have been recalibrated from the original step-function
// thresholds to continuous sigmoid/gaussian curves. The original thresholds were
// designed to catch catastrophic failures — these produce meaningful gradation
// for 48kHz AI-generated music (typical good audio scores 7092%).
//
// Usage:
// const result = evaluateAudioQuality('/path/to/file.wav');
// console.log(result.score); // 0.01.0
import fs from 'fs';
// ── Types ───────────────────────────────────────────────────────────────────
export interface QualityResult {
score: number; // 0.01.0 overall weighted score
metallic: number; // 0.01.0 sub-score
wordCuts: number; // 0.01.0 sub-score
noise: number; // 0.01.0 sub-score
raw: {
rolloffHz: number;
severeCuts: number;
moderateCuts: number;
severePct: number;
moderatePct: number;
zcr: number;
};
}
// ── FFT (Radix-2 Cooley-Tukey) ──────────────────────────────────────────────
/** In-place radix-2 FFT. Arrays must be power-of-2 length. */
function fft(re: Float64Array, im: Float64Array): void {
const n = re.length;
// Bit-reversal permutation
for (let i = 1, j = 0; i < n; i++) {
let bit = n >> 1;
for (; j & bit; bit >>= 1) j ^= bit;
j ^= bit;
if (i < j) {
let tmp = re[i]; re[i] = re[j]; re[j] = tmp;
tmp = im[i]; im[i] = im[j]; im[j] = tmp;
}
}
// Butterfly stages
for (let len = 2; len <= n; len <<= 1) {
const halfLen = len >> 1;
const angle = -2 * Math.PI / len;
const wRe = Math.cos(angle);
const wIm = Math.sin(angle);
for (let i = 0; i < n; i += len) {
let curRe = 1, curIm = 0;
for (let j = 0; j < halfLen; j++) {
const a = i + j;
const b = a + halfLen;
const tRe = curRe * re[b] - curIm * im[b];
const tIm = curRe * im[b] + curIm * re[b];
re[b] = re[a] - tRe;
im[b] = im[a] - tIm;
re[a] += tRe;
im[a] += tIm;
const nextRe = curRe * wRe - curIm * wIm;
curIm = curRe * wIm + curIm * wRe;
curRe = nextRe;
}
}
}
}
// ── STFT ────────────────────────────────────────────────────────────────────
/** Compute magnitude spectrogram via Short-Time Fourier Transform. */
function stft(
samples: Float32Array, nFft: number, hopLength: number
): Float64Array[] {
const numFrames = Math.max(0, Math.floor((samples.length - nFft) / hopLength) + 1);
const frames: Float64Array[] = [];
// Pre-compute Hann window
const window = new Float64Array(nFft);
for (let i = 0; i < nFft; i++) {
window[i] = 0.5 * (1 - Math.cos(2 * Math.PI * i / (nFft - 1)));
}
const freqBins = (nFft >> 1) + 1;
for (let f = 0; f < numFrames; f++) {
const offset = f * hopLength;
const re = new Float64Array(nFft);
const im = new Float64Array(nFft);
// Apply window
for (let i = 0; i < nFft; i++) {
re[i] = (offset + i < samples.length ? samples[offset + i] : 0) * window[i];
}
fft(re, im);
// Magnitude (only positive frequencies)
const mag = new Float64Array(freqBins);
for (let i = 0; i < freqBins; i++) {
mag[i] = Math.sqrt(re[i] * re[i] + im[i] * im[i]);
}
frames.push(mag);
}
return frames;
}
// ── WAV Parsing ─────────────────────────────────────────────────────────────
interface WavInfo {
samples: Float32Array; // mono, normalised to [-1, 1]
sampleRate: number;
}
function parseWav(buf: Buffer): WavInfo {
// Find 'fmt ' chunk
let fmtOffset = -1;
for (let i = 12; i < buf.length - 8; i++) {
if (buf[i] === 0x66 && buf[i+1] === 0x6D && buf[i+2] === 0x74 && buf[i+3] === 0x20) {
fmtOffset = i + 8;
break;
}
}
if (fmtOffset < 0) throw new Error('No fmt chunk in WAV');
const audioFormat = buf.readUInt16LE(fmtOffset);
const numChannels = buf.readUInt16LE(fmtOffset + 2);
const sampleRate = buf.readUInt32LE(fmtOffset + 4);
const bitsPerSample = buf.readUInt16LE(fmtOffset + 14);
// Find 'data' chunk
let dataOffset = -1;
let dataSize = 0;
for (let i = 12; i < buf.length - 8; i++) {
if (buf[i] === 0x64 && buf[i+1] === 0x61 && buf[i+2] === 0x74 && buf[i+3] === 0x61) {
dataSize = buf.readUInt32LE(i + 4);
dataOffset = i + 8;
break;
}
}
if (dataOffset < 0) throw new Error('No data chunk in WAV');
const bytesPerSample = bitsPerSample >> 3;
const totalSamples = Math.min(
Math.floor(dataSize / (bytesPerSample * numChannels)),
Math.floor((buf.length - dataOffset) / (bytesPerSample * numChannels))
);
// Read and downmix to mono Float32
const mono = new Float32Array(totalSamples);
for (let i = 0; i < totalSamples; i++) {
let sum = 0;
for (let ch = 0; ch < numChannels; ch++) {
const pos = dataOffset + (i * numChannels + ch) * bytesPerSample;
let sample: number;
if (audioFormat === 3 || bitsPerSample === 32) {
// 32-bit float
sample = buf.readFloatLE(pos);
} else if (bitsPerSample === 24) {
const s = (buf[pos] | (buf[pos + 1] << 8) | (buf[pos + 2] << 16));
sample = ((s & 0x800000) ? s - 0x1000000 : s) / 8388608;
} else {
// 16-bit
sample = buf.readInt16LE(pos) / 32768;
}
sum += sample;
}
mono[i] = sum / numChannels;
}
return { samples: mono, sampleRate };
}
// ── Metric 1: Metallic Sound (Spectral Rolloff) ────────────────────────────
//
// Sigmoid curve centered at 3500Hz. For 48kHz AI music, typical rolloff is
// 10002500Hz. Metallic artifacts push it above 4000Hz.
// Scores: 1000Hz≈0.97, 2000Hz≈0.90, 3500Hz≈0.50, 5000Hz≈0.10
function sigmoid(x: number, center: number, k: number): number {
return 1 / (1 + Math.exp(k * (x - center)));
}
function scoreMetallic(frames: Float64Array[], sampleRate: number, nFft: number): { score: number; rolloffHz: number } {
if (frames.length === 0) return { score: 0.5, rolloffHz: 0 };
const freqBins = frames[0].length;
const rollPercent = 0.85;
let rolloffSum = 0;
for (const mag of frames) {
// Total energy in this frame
let totalEnergy = 0;
for (let i = 0; i < freqBins; i++) totalEnergy += mag[i] * mag[i];
// Find bin where cumulative energy reaches 85%
const threshold = totalEnergy * rollPercent;
let cumulative = 0;
let rolloffBin = freqBins - 1;
for (let i = 0; i < freqBins; i++) {
cumulative += mag[i] * mag[i];
if (cumulative >= threshold) {
rolloffBin = i;
break;
}
}
rolloffSum += (rolloffBin * sampleRate) / nFft;
}
const meanRolloff = rolloffSum / frames.length;
// Continuous sigmoid — higher rolloff = more metallic = lower score
const score = sigmoid(meanRolloff, 3500, 0.0015);
return { score, rolloffHz: meanRolloff };
}
// ── Metric 2: Word Cuts (Spectral Flux) ─────────────────────────────────────
//
// Continuous scoring using sigmoid on both severe and moderate cut percentages.
// Severe (z>4.0): sigmoid centered at 0.04% (k=100)
// Moderate (z>3.0): sigmoid centered at 0.5% (k=6)
// Combined: 60% severe score + 40% moderate score
interface WordCutsResult {
score: number;
severeCuts: number;
moderateCuts: number;
severePct: number;
moderatePct: number;
}
function scoreWordCuts(frames: Float64Array[]): WordCutsResult {
if (frames.length < 2) {
return { score: 0.5, severeCuts: 0, moderateCuts: 0, severePct: 0, moderatePct: 0 };
}
const freqBins = frames[0].length;
const numFlux = frames.length - 1;
const flux = new Float64Array(numFlux);
// Compute spectral flux (L2 norm of frame-to-frame difference)
for (let f = 0; f < numFlux; f++) {
let sum = 0;
for (let b = 0; b < freqBins; b++) {
const diff = frames[f + 1][b] - frames[f][b];
sum += diff * diff;
}
flux[f] = Math.sqrt(sum);
}
// Compute mean and std
let meanFlux = 0;
for (let i = 0; i < numFlux; i++) meanFlux += flux[i];
meanFlux /= numFlux;
let variance = 0;
for (let i = 0; i < numFlux; i++) {
const d = flux[i] - meanFlux;
variance += d * d;
}
const stdFlux = Math.sqrt(variance / numFlux);
if (stdFlux < 1e-6) {
return { score: 0.0, severeCuts: -1, moderateCuts: 0, severePct: 0, moderatePct: 0 };
}
// Count severe (z > 4.0) and moderate (3.0 < z ≤ 4.0) discontinuities
let severe = 0;
let moderate = 0;
for (let i = 0; i < numFlux; i++) {
const z = (flux[i] - meanFlux) / stdFlux;
if (z > 4.0) severe++;
else if (z > 3.0) moderate++;
}
const severePct = (severe / numFlux) * 100;
const moderatePct = (moderate / numFlux) * 100;
// Continuous sigmoid scoring
const severeScore = sigmoid(severePct, 0.04, 100);
const moderateScore = sigmoid(moderatePct, 0.5, 6);
const score = severeScore * 0.6 + moderateScore * 0.4;
return { score, severeCuts: severe, moderateCuts: moderate, severePct, moderatePct };
}
// ── Metric 3: Noise / Hiss (Zero-Crossing Rate) ────────────────────────────
//
// Gaussian curve centered at 0.065 (ideal ZCR for music at 48kHz).
// σ = 0.02 → narrow ideal zone, steep falloff for extremes.
// Scores: 0.065=1.0, 0.045=0.78, 0.035=0.33, 0.10=0.44, 0.15=0.01
function scoreNoise(samples: Float32Array): { score: number; zcr: number } {
if (samples.length < 2) return { score: 0.5, zcr: 0 };
let crossings = 0;
for (let i = 1; i < samples.length; i++) {
if ((samples[i] >= 0) !== (samples[i - 1] >= 0)) crossings++;
}
const zcr = crossings / (samples.length - 1);
// Gaussian scoring — peak at ideal ZCR, steep falloff
const ideal = 0.065;
const sigma = 0.02;
const score = Math.exp(-((zcr - ideal) * (zcr - ideal)) / (2 * sigma * sigma));
return { score, zcr };
}
// ── Main evaluator ──────────────────────────────────────────────────────────
/**
* Evaluate audio quality of a WAV file.
* Returns a QualityResult with overall score (01) and per-metric breakdown.
*/
export function evaluateAudioQuality(wavPath: string): QualityResult {
const buf = fs.readFileSync(wavPath);
const { samples, sampleRate } = parseWav(buf);
if (samples.length === 0) {
return {
score: 0, metallic: 0, wordCuts: 0, noise: 0,
raw: { rolloffHz: 0, severeCuts: 0, moderateCuts: 0, severePct: 0, moderatePct: 0, zcr: 0 },
};
}
// STFT parameters (matching original: n_fft=2048, hop=512)
const nFft = 2048;
const hopLength = 512;
const frames = stft(samples, nFft, hopLength);
// Metric 1: Metallic (40%)
const met = scoreMetallic(frames, sampleRate, nFft);
// Metric 2: Word Cuts (40%)
const wc = scoreWordCuts(frames);
// Metric 3: Noise (20%)
const ns = scoreNoise(samples);
// Weighted total
const score = met.score * 0.40 + wc.score * 0.40 + ns.score * 0.20;
return {
score,
metallic: met.score,
wordCuts: wc.score,
noise: ns.score,
raw: {
rolloffHz: met.rolloffHz,
severeCuts: wc.severeCuts,
moderateCuts: wc.moderateCuts,
severePct: wc.severePct,
moderatePct: wc.moderatePct,
zcr: ns.zcr,
},
};
}
/**
* Format a QualityResult as a human-readable log string.
*/
export function formatQualityLog(result: QualityResult, label: string): string {
const r = result.raw;
return `[Quality] ${label}: ${result.score.toFixed(3)} | ` +
`Metallic=${result.metallic.toFixed(2)} WordCuts=${result.wordCuts.toFixed(2)} Noise=${result.noise.toFixed(2)} | ` +
`Raw[Roll:${r.rolloffHz.toFixed(0)}Hz Cuts:${r.severeCuts}/${r.moderateCuts} ` +
`(${r.severePct.toFixed(2)}%/${r.moderatePct.toFixed(2)}%) ZCR:${r.zcr.toFixed(3)}]`;
}
+77
View File
@@ -0,0 +1,77 @@
// generation/lmCache.ts — LM audio code cache
//
// Caches ONLY LM-generated output fields keyed by lm_seed + LM-affecting params.
// Non-LM parameters (DiT, adapter, DCW, etc.) are NEVER cached.
import crypto from 'crypto';
import type { AceRequest } from '../../services/aceClient.js';
export interface LmCacheEntry {
audio_codes: string;
caption: string;
lyrics: string;
bpm: number;
duration: number;
keyscale: string;
timesignature: string;
/** Resolved engine LM seed for this output (base lm_seed + batch index) —
* cached so cache-hit tracks display the seed that produced their codes. */
lm_seed?: number;
}
const LM_CACHE_MAX = 20;
const lmCache = new Map<string, { lmOutputs: LmCacheEntry[]; timestamp: number }>();
/** Compute a stable hash key from LM-affecting parameters */
export function computeLmCacheKey(req: AceRequest): string {
// lm_seed is left unset on the request when the LM seed is tied to the
// DiT seed (the engine's own fallback then ties it) — use the effective
// value here too, or every tied request would collide on `undefined`
// regardless of the actual (possibly random) DiT seed.
const effectiveLmSeed = req.lm_seed !== undefined ? req.lm_seed : req.seed;
const keyObj = {
lm_seed: effectiveLmSeed,
caption: req.caption,
lyrics: req.lyrics,
bpm: req.bpm,
duration: req.duration,
keyscale: req.keyscale,
timesignature: req.timesignature,
vocal_language: req.vocal_language,
lm_model: req.lm_model,
lm_batch_size: req.lm_batch_size,
lm_temperature: req.lm_temperature,
lm_cfg_scale: req.lm_cfg_scale,
lm_top_p: req.lm_top_p,
lm_top_k: req.lm_top_k,
lm_negative_prompt: req.lm_negative_prompt,
use_cot_caption: req.use_cot_caption,
};
return crypto.createHash('sha256')
.update(JSON.stringify(keyObj))
.digest('hex')
.substring(0, 16);
}
/** Evict oldest entries when cache exceeds max size */
export function evictLmCache(): void {
if (lmCache.size <= LM_CACHE_MAX) return;
const entries = [...lmCache.entries()].sort((a, b) => a[1].timestamp - b[1].timestamp);
const toRemove = entries.slice(0, lmCache.size - LM_CACHE_MAX);
for (const [key] of toRemove) {
lmCache.delete(key);
}
}
export function getLmCache(key: string) {
return lmCache.get(key);
}
export function setLmCache(key: string, lmOutputs: LmCacheEntry[]) {
lmCache.set(key, { lmOutputs, timestamp: Date.now() });
evictLmCache();
}
export function getLmCacheSize(): number {
return lmCache.size;
}
@@ -0,0 +1,392 @@
// lufsNormalize.ts — LUFS normalization with true-peak limiting
//
// ITU-R BS.1770-4 integrated loudness measurement + gain adjustment.
// Runs as the final audio-modifying stage in the post-processing chain,
// after reference-based mastering.
//
// Algorithm:
// 1. Parse WAV (stereo or mono, 16-bit PCM or 32-bit float)
// 2. Apply K-weighting filter (high-shelf + RLB high-pass)
// 3. Compute mean-square energy per 400ms block (100ms hop)
// 4. Apply absolute gate (-70 LUFS) then relative gate (mean - 10 dB)
// 5. Compute integrated LUFS from gated blocks
// 6. Apply gain to reach target LUFS
// 7. True-peak limiter at ceiling (default -1 dBTP)
import fs from 'fs';
// ── Types ───────────────────────────────────────────────────────────────────
export interface LufsResult {
measuredLufs: number; // integrated LUFS before normalization
targetLufs: number; // requested target
appliedGainDb: number; // actual gain applied
limiterActive: boolean; // true if any samples hit the ceiling
peakBefore: number; // max absolute sample before gain
peakAfter: number; // max absolute sample after gain+limiter
}
// ── WAV Parsing ─────────────────────────────────────────────────────────────
interface WavData {
channels: Float32Array[]; // per-channel float samples [-1, 1]
sampleRate: number;
numChannels: number;
audioFormat: number; // 1 = PCM, 3 = IEEE float
bitsPerSample: number;
dataOffset: number; // byte offset of PCM data start
dataSize: number; // byte size of PCM data
}
function parseWav(buf: Buffer): WavData {
// Find 'fmt ' chunk
let fmtOffset = -1;
for (let i = 12; i < buf.length - 8; i++) {
if (buf[i] === 0x66 && buf[i+1] === 0x6D && buf[i+2] === 0x74 && buf[i+3] === 0x20) {
fmtOffset = i + 8;
break;
}
}
if (fmtOffset < 0) throw new Error('[LUFS] No fmt chunk in WAV');
const audioFormat = buf.readUInt16LE(fmtOffset);
const numChannels = buf.readUInt16LE(fmtOffset + 2);
const sampleRate = buf.readUInt32LE(fmtOffset + 4);
const bitsPerSample = buf.readUInt16LE(fmtOffset + 14);
// Find 'data' chunk
let dataOffset = -1;
let dataSize = 0;
for (let i = 12; i < buf.length - 8; i++) {
if (buf[i] === 0x64 && buf[i+1] === 0x61 && buf[i+2] === 0x74 && buf[i+3] === 0x61) {
dataSize = buf.readUInt32LE(i + 4);
dataOffset = i + 8;
break;
}
}
if (dataOffset < 0) throw new Error('[LUFS] No data chunk in WAV');
const bytesPerSample = bitsPerSample >> 3;
const totalSamples = Math.min(
Math.floor(dataSize / (bytesPerSample * numChannels)),
Math.floor((buf.length - dataOffset) / (bytesPerSample * numChannels))
);
// Read into per-channel Float32Arrays
const channels: Float32Array[] = [];
for (let ch = 0; ch < numChannels; ch++) {
channels.push(new Float32Array(totalSamples));
}
for (let i = 0; i < totalSamples; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const pos = dataOffset + (i * numChannels + ch) * bytesPerSample;
let sample: number;
if (audioFormat === 3 || bitsPerSample === 32) {
sample = buf.readFloatLE(pos);
} else if (bitsPerSample === 24) {
const s = (buf[pos] | (buf[pos + 1] << 8) | (buf[pos + 2] << 16));
sample = ((s & 0x800000) ? s - 0x1000000 : s) / 8388608;
} else {
// 16-bit PCM
sample = buf.readInt16LE(pos) / 32768;
}
channels[ch][i] = sample;
}
}
return { channels, sampleRate, numChannels, audioFormat, bitsPerSample, dataOffset, dataSize };
}
// ── K-Weighting Filter (ITU-R BS.1770-4) ────────────────────────────────────
//
// Two cascaded biquad stages:
// Stage 1: High-shelf boost (~1681 Hz, +3.999 dB) — head acoustic effect
// Stage 2: High-pass (RLB weighting, ~38 Hz) — removes sub-bass
//
// Coefficients sourced from ITU-R BS.1770-4 Table 1 for 48 kHz.
// 44.1 kHz coefficients from the pyLoudnorm reference implementation.
interface BiquadCoeffs {
b0: number; b1: number; b2: number;
a1: number; a2: number;
}
function getKWeightingCoeffs(sampleRate: number): [BiquadCoeffs, BiquadCoeffs] {
if (sampleRate === 48000) {
// Stage 1: High-shelf (ITU-R BS.1770-4 Table 1)
const shelf: BiquadCoeffs = {
b0: 1.53512485958697,
b1: -2.69169618940638,
b2: 1.19839281085285,
a1: -1.69065929318241,
a2: 0.73248077421585,
};
// Stage 2: RLB high-pass
const hp: BiquadCoeffs = {
b0: 1.0,
b1: -2.0,
b2: 1.0,
a1: -1.99004745483398,
a2: 0.99007225036621,
};
return [shelf, hp];
} else if (sampleRate === 44100) {
// Coefficients for 44.1 kHz (from pyLoudnorm / ffmpeg)
const shelf: BiquadCoeffs = {
b0: 1.5308412300498355,
b1: -2.6509799951547297,
b2: 1.1690790799215869,
a1: -1.6636551132560204,
a2: 0.7125954280732254,
};
const hp: BiquadCoeffs = {
b0: 1.0,
b1: -2.0,
b2: 1.0,
a1: -1.9891696736297957,
a2: 0.9891990357870394,
};
return [shelf, hp];
} else {
// For other sample rates, compute coefficients using analog prototype
// This is a simplified approximation — 48k and 44.1k are exact
// Fall back to 48k coefficients with a warning (most AI audio is 48k)
return getKWeightingCoeffs(48000);
}
}
/** Apply a biquad filter in-place and return a new filtered array. */
function applyBiquad(samples: Float32Array, c: BiquadCoeffs): Float32Array {
const out = new Float32Array(samples.length);
let x1 = 0, x2 = 0, y1 = 0, y2 = 0;
for (let i = 0; i < samples.length; i++) {
const x0 = samples[i];
const y0 = c.b0 * x0 + c.b1 * x1 + c.b2 * x2 - c.a1 * y1 - c.a2 * y2;
out[i] = y0;
x2 = x1; x1 = x0;
y2 = y1; y1 = y0;
}
return out;
}
/** Apply K-weighting to a channel (two cascaded biquads). */
function applyKWeighting(samples: Float32Array, sampleRate: number): Float32Array {
const [shelf, hp] = getKWeightingCoeffs(sampleRate);
const stage1 = applyBiquad(samples, shelf);
return applyBiquad(stage1, hp);
}
// ── Integrated LUFS Measurement ─────────────────────────────────────────────
/** Compute integrated LUFS per ITU-R BS.1770-4. */
function measureIntegratedLufs(channels: Float32Array[], sampleRate: number): number {
const numChannels = channels.length;
if (numChannels === 0 || channels[0].length === 0) return -Infinity;
// Apply K-weighting to each channel
const kWeighted: Float32Array[] = channels.map(ch => applyKWeighting(ch, sampleRate));
// Block parameters: 400ms blocks with 75% overlap (100ms hop)
const blockSamples = Math.round(sampleRate * 0.4); // 400ms
const hopSamples = Math.round(sampleRate * 0.1); // 100ms hop
const totalSamples = kWeighted[0].length;
const numBlocks = Math.max(0, Math.floor((totalSamples - blockSamples) / hopSamples) + 1);
if (numBlocks === 0) return -Infinity;
// Channel weights for ITU-R BS.1770: L=R=C=1.0, Ls=Rs=1.41 (surround)
// For mono/stereo, all channels = 1.0
const channelWeights = new Float64Array(numChannels).fill(1.0);
// Compute loudness per block
const blockLoudness = new Float64Array(numBlocks);
for (let b = 0; b < numBlocks; b++) {
const start = b * hopSamples;
const end = start + blockSamples;
let blockPower = 0;
for (let ch = 0; ch < numChannels; ch++) {
let chPower = 0;
const kw = kWeighted[ch];
for (let i = start; i < end && i < totalSamples; i++) {
chPower += kw[i] * kw[i];
}
chPower /= blockSamples;
blockPower += channelWeights[ch] * chPower;
}
// Convert to LUFS for this block
blockLoudness[b] = blockPower > 0
? -0.691 + 10 * Math.log10(blockPower)
: -Infinity;
}
// ── Absolute gate: discard blocks below -70 LUFS ──
const ABSOLUTE_GATE = -70;
const ungatedBlocks: number[] = [];
for (let b = 0; b < numBlocks; b++) {
if (blockLoudness[b] > ABSOLUTE_GATE) {
ungatedBlocks.push(b);
}
}
if (ungatedBlocks.length === 0) return -Infinity;
// Mean loudness of ungated blocks (in linear domain)
let ungatedPowerSum = 0;
for (const b of ungatedBlocks) {
ungatedPowerSum += Math.pow(10, (blockLoudness[b] + 0.691) / 10);
}
const ungatedMeanLufs = -0.691 + 10 * Math.log10(ungatedPowerSum / ungatedBlocks.length);
// ── Relative gate: discard blocks below (ungated mean - 10 dB) ──
const RELATIVE_GATE_OFFSET = -10;
const relativeGate = ungatedMeanLufs + RELATIVE_GATE_OFFSET;
const gatedBlocks: number[] = [];
for (const b of ungatedBlocks) {
if (blockLoudness[b] > relativeGate) {
gatedBlocks.push(b);
}
}
if (gatedBlocks.length === 0) return -Infinity;
// Final integrated loudness from gated blocks
let gatedPowerSum = 0;
for (const b of gatedBlocks) {
gatedPowerSum += Math.pow(10, (blockLoudness[b] + 0.691) / 10);
}
return -0.691 + 10 * Math.log10(gatedPowerSum / gatedBlocks.length);
}
// ── Gain Application + True-Peak Limiter ────────────────────────────────────
/**
* Apply linear gain and true-peak limiting to a WAV buffer in-place.
* Returns whether the limiter was activated and peak values.
*/
function applyGainAndLimit(
buf: Buffer,
wav: WavData,
linearGain: number,
ceilingLinear: number,
): { limiterActive: boolean; peakBefore: number; peakAfter: number } {
const { audioFormat, bitsPerSample, numChannels, dataOffset, dataSize } = wav;
const bytesPerSample = bitsPerSample >> 3;
const totalSamples = Math.floor(dataSize / (bytesPerSample * numChannels));
let peakBefore = 0;
let peakAfter = 0;
let limiterActive = false;
for (let i = 0; i < totalSamples; i++) {
for (let ch = 0; ch < numChannels; ch++) {
const pos = dataOffset + (i * numChannels + ch) * bytesPerSample;
if (audioFormat === 3 && bitsPerSample === 32) {
// IEEE float 32-bit
const original = buf.readFloatLE(pos);
const absOrig = Math.abs(original);
if (absOrig > peakBefore) peakBefore = absOrig;
let gained = original * linearGain;
// True-peak limiter: hard clip at ceiling
if (Math.abs(gained) > ceilingLinear) {
gained = gained > 0 ? ceilingLinear : -ceilingLinear;
limiterActive = true;
}
const absGained = Math.abs(gained);
if (absGained > peakAfter) peakAfter = absGained;
buf.writeFloatLE(gained, pos);
} else if (audioFormat === 1 && bitsPerSample === 16) {
// PCM 16-bit
const original = buf.readInt16LE(pos) / 32768;
const absOrig = Math.abs(original);
if (absOrig > peakBefore) peakBefore = absOrig;
let gained = original * linearGain;
// True-peak limiter
if (Math.abs(gained) > ceilingLinear) {
gained = gained > 0 ? ceilingLinear : -ceilingLinear;
limiterActive = true;
}
const absGained = Math.abs(gained);
if (absGained > peakAfter) peakAfter = absGained;
// Quantize back to 16-bit
const quantized = Math.max(-32768, Math.min(32767, Math.round(gained * 32768)));
buf.writeInt16LE(quantized, pos);
}
// Other formats: skip silently (24-bit rare in this pipeline)
}
}
return { limiterActive, peakBefore, peakAfter };
}
// ── Public API ──────────────────────────────────────────────────────────────
/**
* Normalize a WAV file to a target integrated LUFS level.
*
* Measures the current integrated loudness (ITU-R BS.1770-4),
* computes the gain delta, applies it, and limits true peaks
* to the ceiling to prevent clipping.
*
* @param wavPath Path to the WAV file (modified in-place)
* @param targetLufs Target integrated LUFS (e.g. -14)
* @param ceilingDbtp True-peak ceiling in dBTP (default -1.0)
*/
export function normalizeLufs(
wavPath: string,
targetLufs: number,
ceilingDbtp: number = -1.0,
): LufsResult {
const buf = fs.readFileSync(wavPath);
const wav = parseWav(buf);
// Measure current loudness
const measuredLufs = measureIntegratedLufs(wav.channels, wav.sampleRate);
if (!isFinite(measuredLufs)) {
// Silent or near-silent audio — nothing to normalize
return {
measuredLufs: -Infinity,
targetLufs,
appliedGainDb: 0,
limiterActive: false,
peakBefore: 0,
peakAfter: 0,
};
}
// Compute gain
const gainDb = targetLufs - measuredLufs;
const linearGain = Math.pow(10, gainDb / 20);
const ceilingLinear = Math.pow(10, ceilingDbtp / 20);
// Apply gain + limiting in-place
const { limiterActive, peakBefore, peakAfter } = applyGainAndLimit(
buf, wav, linearGain, ceilingLinear
);
// Write modified buffer back
fs.writeFileSync(wavPath, buf);
return {
measuredLufs,
targetLufs,
appliedGainDb: gainDb,
limiterActive,
peakBefore,
peakAfter,
};
}
@@ -0,0 +1,720 @@
// generation/postProcessing.ts — Post-generation processing chain
//
// PP-VAE re-encode, Spectral Lifter, Vocal Naturalizer, VST chain, mastering,
// and optional Audio Quality Evaluation.
// Operates on a COPY of the raw WAV — raw generation is never modified.
import fs from 'fs';
import path from 'path';
import { performance } from 'perf_hooks';
import { config } from '../../config.js';
import { aceClient } from '../../services/aceClient.js';
import { runMastering } from '../../routes/mastering.js';
import { applyVstChain } from '../../routes/vst.js';
import { runVocalNaturalizer, type NaturalizerParams } from './vocalNaturalizer.js';
import { evaluateAudioQuality, formatQualityLog, type QualityResult } from './audioQualityEvaluator.js';
import { sa3ModelsInstalled, tokenizeForSa3, buildStableStepPrompt } from '../sa3Tokenizer.js';
import { wavDurationSec } from '../audioCrop.js';
type LogFn = (level: 'INFO' | 'DEBUG' | 'WARNING' | 'ERROR', msg: string) => void;
type StageFn = (stage: string) => void;
/** Fired once per track when Whisper stem-mode is active: stemPath is a temp
* WAV of the isolated vocal stem, or null when no stem could be produced
* (caller should fall back to full-mix transcription). The caller owns
* deleting the temp file. */
type VocalStemFn = (trackIdx: number, stemPath: string | null) => void;
// StableStep GGML backend files — 4 GGUFs at the models dir root (the ONNX
// set lives in <models>/onnx/sa3 and is checked via sa3ModelsInstalled()).
// tokenizer.json in onnx/sa3 is required for BOTH backends (Node tokenizes).
// Keep in sync with SA3_GGUF_FILES in routes/models.ts.
const SA3_GGUF_FILES = [
'sa3-dit-BF16.gguf',
'sa3-same-enc-F16.gguf',
'sa3-same-dec-F16.gguf',
'sa3-text-enc-BF16.gguf',
];
/** True if the SA3 GGML backend appears installed (4 root GGUFs + tokenizer). */
function sa3GgufInstalled(): boolean {
const modelsDir = config.aceServer.models;
return fs.existsSync(path.join(modelsDir, 'onnx', 'sa3', 'tokenizer.json'))
&& SA3_GGUF_FILES.every(f => fs.existsSync(path.join(modelsDir, f)));
}
// BS-Roformer-Leap "Xe" pair (huggingface.co/pcunwa/BS-Roformer-Leap). Both are
// single-stem models differing only in target_instrument, and both run through
// the engine's native GGML BS-RoFormer (bs-roformer-ggml.h), not ONNX Runtime.
// Keep in sync with BS_MODEL_LEAP_XE_* in engine/src/supersep.cpp.
const LEAP_XE_FILES = ['bs_leap_xe_voc-F32.gguf', 'bs_leap_xe_inst-F32.gguf'];
/** True if both Leap Xe checkpoints are present, enabling SUPERSEP_STABLESTEP. */
function leapXeInstalled(): boolean {
const modelsDir = config.aceServer.models;
return LEAP_XE_FILES.every(f => fs.existsSync(path.join(modelsDir, 'supersep', f)));
}
// SuperSepLevel values from engine/src/supersep.h.
const SEP_LEVEL_VOCALS_ONLY = 4; // single 6-stem pass, instrumental = mix vocals
const SEP_LEVEL_STABLESTEP = 5; // dual Leap Xe pass, both stems neural
interface PostProcessParams {
postProcessingEnabled?: boolean;
ppVaeReencode?: boolean;
ppVaeBlend?: number;
ppVaeUseOnnx?: boolean;
// StableStep — SA3 (Stable Audio 3) SDEdit refine of the instrumental
stableStepOn?: boolean;
stableStep?: boolean; // preset/settings-file alias for stableStepOn
stableStepStrength?: number; // 0..1 init noise level (default 0.3)
/** Engine backend for the SA3 refine: 'onnx' (ONNX Runtime/TensorRT),
* 'gguf' (GGML — CUDA/Vulkan/CPU) or 'auto' (engine picks, default). */
stableStepBackend?: 'auto' | 'onnx' | 'gguf';
/** StableStep DoRA adapters (models/sa3-adapters/<name>.gguf) merged into
* the SA3 DiT at load with per-adapter strength. Forces the GGUF backend. */
stableStepAdapters?: Array<{ name: string; scale: number }>;
/** Preserve source dynamics: engine-side windowed envelope match of the
* refined audio to the pre-refine source (counters mastered-density
* "loudness war" character from adapters trained on commercial masters). */
stableStepPreserveDynamics?: boolean;
/** Source blending: 'off' | 'crossover' (source lows + refined highs at a
* spectral crossover) | 'mix' (full-band wet/dry). */
stableStepBlendMode?: 'off' | 'crossover' | 'mix';
stableStepCrossoverHz?: number; // crossover center (default 250)
stableStepCrossoverWidthHz?: number; // transition width (default 200)
stableStepMix?: number; // 0 = pure source .. 1 = pure refined
/** SA3 refine RNG seed. Undefined = engine picks a random seed per refine.
* Populated by the generate route: follows the resolved generation seed by
* default, or a fixed user override (stableStepSeedFollowsDit=false). */
stableStepSeed?: number;
/** Per-track captions (parallel to audioUrls) used to build the SA3 prompt.
* Populated by the generate route from the LM results. */
stableStepCaptions?: string[];
/** Whisper "Isolate vocals first" toggle — when set (and onVocalStem is
* provided), a SuperSep split runs even if StableStep won't consume it. */
whisperIsolateVocals?: boolean;
spectralLifterEnabled?: boolean;
slDenoiseStrength?: number;
slNoiseFloor?: number;
slHfMix?: number;
slTransientBoost?: number;
slShimmerReduction?: number;
masteringEnabled?: boolean;
masteringReference?: string;
// Vocal Naturalizer
vocalNaturalizerEnabled?: boolean;
naturalizeAmount?: number;
natVibratoRate?: number;
natVibratoDepth?: number;
natFormantStrength?: number;
natMetallicReduction?: number;
natQuantizationMask?: number;
natTransitionSmooth?: number;
// Context — used to skip naturalizer on instrumentals
instrumental?: boolean;
// Pre-VST gain offset (dB)
gainOffsetDb?: number;
// Audio Quality Evaluator
qualityEvalEnabled?: boolean;
qualityEvalTarget?: 'unmastered' | 'mastered' | 'both';
// LUFS Normalization (final stage after mastering)
lufsEnabled?: boolean;
lufsTarget?: number; // target integrated LUFS (e.g. -14)
// Pipeline parallelism
parallelQualityEval?: boolean;
}
/** Quality scores for a single track (unmastered, mastered, or both). */
export interface TrackQualityScores {
unmastered?: QualityResult;
mastered?: QualityResult;
}
/** Result of the full post-processing chain. */
export interface PostProcessResult {
masteredUrls: string[];
qualityScores: TrackQualityScores[];
timing: Array<{ name: string; ms: number }>;
}
// ── Shared vocal separation ─────────────────────────────────────────────────
/** Result of a VOCALS_ONLY SuperSep split (stems are 44.1 kHz WAVs). */
interface VocalSeparation {
sepId: string;
stems: Array<{ index: number; category: string; hidden: boolean }>;
vocalIndex: number;
vocalBuf: Buffer;
instBuf: Buffer;
}
/** Run a 2-stem (Vocals + Instrumental) split via the engine's SuperSep API and
* fetch both stems. Returns null when the split produced no usable
* vocal/instrumental pair (silent stems are dropped engine-side). Throws on
* separation failure/timeout.
*
* `level` picks the separation strategy — see the SuperSepLevel constants. */
async function separateVocals(
srcBuf: Buffer,
level: number = SEP_LEVEL_VOCALS_ONLY,
): Promise<VocalSeparation | null> {
const sepId = await aceClient.submitSuperSepSeparate(srcBuf, level);
// Poll separation to completion (GPU-serialized with other engine work)
const sepDeadline = Date.now() + 30 * 60_000;
for (;;) {
const prog = await aceClient.superSepProgress(sepId);
if (prog.status === 'done') break;
if (prog.status === 'failed' || prog.status === 'cancelled') {
throw new Error(`SuperSep ${prog.status}: ${prog.error || prog.message || 'unknown'}`);
}
if (Date.now() > sepDeadline) throw new Error('SuperSep separation timed out');
await new Promise(r => setTimeout(r, 500));
}
const sepResult = await aceClient.superSepResult(sepId);
const stems = sepResult.stems;
const vocalStem = stems.find(s => s.category === 'vocals' && !s.hidden);
const instStem = stems.find(s => s.category === 'instruments' && !s.hidden);
if (!vocalStem || !instStem) return null;
const instBuf = await aceClient.superSepStem(sepId, instStem.index);
const vocalBuf = await aceClient.superSepStem(sepId, vocalStem.index);
return { sepId, stems, vocalIndex: vocalStem.index, vocalBuf, instBuf };
}
/** Run the full post-processing chain on a list of audio files.
* onVocalStem (optional): requests the isolated vocal stem for Whisper
* transcription — see VocalStemFn. Fired per track as soon as the shared
* SuperSep split completes, so CPU transcription overlaps later GPU stages. */
export async function runPostProcessingChain(
audioUrls: string[],
params: PostProcessParams,
totalTracks: number,
jobId: string,
log: LogFn,
setStage: StageFn,
onVocalStem?: VocalStemFn
): Promise<PostProcessResult> {
const ppMasterOn = params.postProcessingEnabled !== false;
const stableStepOn = ppMasterOn && !!(params.stableStepOn ?? params.stableStep);
const ppVaeOn = ppMasterOn && !!params.ppVaeReencode;
const spectralLifterOn = ppMasterOn && !!params.spectralLifterEnabled;
const masteringRef = params.masteringReference;
const masteringOn = ppMasterOn && !!masteringRef && !!params.masteringEnabled;
const masteredUrls: string[] = [];
const qualityScores: TrackQualityScores[] = [];
const timing: Array<{ name: string; ms: number }> = [];
const qeOn = !!params.qualityEvalEnabled;
const qeTarget = params.qualityEvalTarget || 'unmastered';
for (let i = 0; i < audioUrls.length; i++) {
const audioUrl = audioUrls[i];
const audioFilename = path.basename(audioUrl);
const rawWavPath = path.join(config.data.audioDir, audioFilename);
if (!rawWavPath.endsWith('.wav')) { masteredUrls.push(''); continue; }
const ext2 = path.extname(audioFilename);
const base2 = path.basename(audioFilename, ext2);
const processedFilename = `${base2}_mastered${ext2}`;
const processedPath = path.join(config.data.audioDir, processedFilename);
fs.copyFileSync(rawWavPath, processedPath);
let anyStageRan = false;
const trackQuality: TrackQualityScores = {};
// ── Quality Evaluation: Unmastered (before any PP) ──
// When parallelQualityEval is enabled, fire QE concurrently with PP-VAE
// (they operate on different files: QE reads rawWavPath, PP-VAE reads processedPath)
let qePrePromise: Promise<void> | undefined;
const runQePre = async () => {
if (!(qeOn && (qeTarget === 'unmastered' || qeTarget === 'both'))) return;
const qeStart = performance.now();
try {
if (!params.parallelQualityEval) {
setStage(`Quality check (unmastered)${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
}
const result = evaluateAudioQuality(rawWavPath);
trackQuality.unmastered = result;
log('INFO', formatQualityLog(result, `Unmastered ${audioFilename}`));
} catch (qeErr: any) {
log('WARNING', `[Quality] Unmastered eval failed (non-fatal): ${qeErr.message}`);
}
const qeMs = Math.round(performance.now() - qeStart);
if (qeMs > 50) timing.push({ name: 'Quality Eval (pre)', ms: qeMs });
};
if (params.parallelQualityEval) {
// Fire and continue — will be awaited before mastered QE
qePrePromise = runQePre();
} else {
await runQePre();
}
// ── Shared vocal separation (StableStep stem workflow + Whisper isolation) ──
// 2-stem vocal/instrumental split, run at most ONCE per track. Consumers:
// - StableStep (vocal gens): SA3-refines the instrumental stem, cleans
// the vocal stem, recombines.
// - Whisper isolation (onVocalStem): the vocal stem is written to a temp
// WAV and handed to the caller BEFORE the SA3 refine so CPU
// transcription overlaps the GPU work.
// Deliberately NOT gated on ppMasterOn: the split is a service for
// Whisper, not an audio-modifying PP stage (anyStageRan untouched).
const sa3Available = sa3ModelsInstalled() || sa3GgufInstalled();
const stableStepWantsStems = stableStepOn && sa3Available && !params.instrumental;
const whisperWantsStems = !!onVocalStem && !!params.whisperIsolateVocals && !params.instrumental;
let vocalSep: VocalSeparation | null = null;
if (stableStepWantsStems || whisperWantsStems) {
const sepStart = performance.now();
// StableStep gets the dual Leap Xe pass when both checkpoints are
// installed: the vocal that is re-applied after the refine comes from the
// vocal-target model and the instrumental fed to SA3 comes from the
// instrumental-target model, so neither stem is a mix-minus residual.
// Whisper-only splits stay on the single 6-stem pass — one model load
// instead of two, and transcription does not need that precision.
const useLeap = stableStepWantsStems && leapXeInstalled();
const sepLevel = useLeap ? SEP_LEVEL_STABLESTEP : SEP_LEVEL_VOCALS_ONLY;
if (stableStepWantsStems && !useLeap) {
log('INFO', '[SuperSep] Leap Xe models not installed — using the 6-stem '
+ 'BS-RoFormer pass (instrumental derived as mix vocals)');
}
setStage(`Separating vocals${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
log('INFO', `[SuperSep] Vocal split via level ${sepLevel}`
+ `${useLeap ? ' (dual BS-Roformer-Leap Xe)' : ' (BS-RoFormer 6-stem)'}`);
vocalSep = await separateVocals(fs.readFileSync(processedPath), sepLevel);
if (!vocalSep) {
log('INFO', '[SuperSep] No vocal/instrumental split (no vocal energy detected)');
}
} catch (sepErr: any) {
log('WARNING', `[SuperSep] Vocal separation failed (non-fatal): ${sepErr.message}`);
}
timing.push({ name: 'Vocal Separation', ms: Math.round(performance.now() - sepStart) });
}
// Hand the vocal stem to the Whisper callback (null = fall back to full mix)
if (onVocalStem) {
let stemPath: string | null = null;
if (vocalSep) {
try {
stemPath = processedPath + '.vocalstem.tmp.wav';
fs.writeFileSync(stemPath, vocalSep.vocalBuf);
} catch (stemErr: any) {
log('WARNING', `[SuperSep] Failed to write vocal stem for Whisper: ${stemErr.message}`);
stemPath = null;
}
}
onVocalStem(i, stemPath);
}
// ── StableStep: SA3 SDEdit refine (before PP-VAE) ──
// Instrumental gens: refine the whole mix through the SA3 model.
// Vocal gens: consume the shared split above — SA3-refine the
// instrumental, PP-VAE the vocals, then recombine sample-wise in Node.
if (stableStepOn) {
const ssStart = performance.now();
try {
if (!sa3Available) {
log('WARNING', '[StableStep] SA3 models not installed (neither models/onnx/sa3 nor root GGUFs) — skipping');
} else {
// Engine backend: 'onnx' | 'gguf' forces one; undefined = engine auto.
const backend = (params.stableStepBackend === 'onnx' || params.stableStepBackend === 'gguf')
? params.stableStepBackend : undefined;
const adapters = (params.stableStepAdapters ?? []).filter(a => a && a.name && a.scale !== 0);
const envMatch = params.stableStepPreserveDynamics !== false;
const blendMode = params.stableStepBlendMode ?? 'off';
const blendOpts = blendMode === 'mix'
? { mix: Math.min(1, Math.max(0, params.stableStepMix ?? 1)) }
: blendMode === 'crossover'
? { bandBlend: true, bandFreq: params.stableStepCrossoverHz ?? 250,
bandWidth: params.stableStepCrossoverWidthHz ?? 200 }
: {};
if (blendMode !== 'off') {
log('INFO', `[StableStep] Source blend: ${JSON.stringify(blendOpts)}`);
}
if (adapters.length > 0) {
log('INFO', `[StableStep] Adapters: ${adapters.map(a => `${a.name}@${a.scale}`).join(', ')}`);
}
const strength = params.stableStepStrength ?? 0.3;
const ssSeed = params.stableStepSeed;
if (ssSeed !== undefined) log('INFO', `[StableStep] Seed: ${ssSeed}`);
const caption = params.stableStepCaptions?.[i] || '';
const durationSec = wavDurationSec(processedPath);
const prompt = buildStableStepPrompt(caption, durationSec);
const { ids, nTokens } = await tokenizeForSa3(prompt);
log('INFO', `[StableStep] Prompt (${nTokens} tokens): ${prompt}`);
const suffix = totalTracks > 1 ? ` (${i + 1}/${totalTracks})` : '';
if (params.instrumental) {
// Whole-mix refine — no stems needed
setStage(`StableStep: refining instrumental${suffix}...`);
const wavBuf = fs.readFileSync(processedPath);
const refined = await aceClient.submitSa3Refine(wavBuf, {
tokens: ids, nTokens, strength, backend, adapters, envMatch, seed: ssSeed, ...blendOpts,
});
fs.writeFileSync(processedPath, refined);
} else if (!vocalSep) {
// No vocal/instrumental split available (no vocal energy detected,
// or the separation failed) — refine the whole mix directly.
log('INFO', '[StableStep] No vocal/instrumental split — refining full mix');
setStage(`StableStep: refining instrumental${suffix}...`);
const srcBuf = fs.readFileSync(processedPath);
const refined = await aceClient.submitSa3Refine(srcBuf, {
tokens: ids, nTokens, strength, backend, adapters, envMatch, seed: ssSeed, ...blendOpts,
});
fs.writeFileSync(processedPath, refined);
} else {
// Stems from the shared split above (44.1 kHz). The refined
// instrumental is requested at 48 kHz (out_sr) to match PP-VAE's
// fixed 48 kHz output so the final mix rates agree.
const vs = vocalSep;
setStage(`StableStep: refining instrumental${suffix}...`);
const refinedInst = await aceClient.submitSa3Refine(vs.instBuf, {
tokens: ids, nTokens, strength, outSr: 48000, backend, adapters, envMatch, seed: ssSeed, ...blendOpts,
});
setStage(`StableStep: processing vocals${suffix}...`);
let cleanVocals: Buffer;
try {
cleanVocals = await aceClient.submitPpVaeReencode(vs.vocalBuf, 0.0); // 48 kHz out
} catch (vErr: any) {
log('WARNING', `[StableStep] Vocal PP-VAE failed, using raw vocal stem: ${vErr.message}`);
// Raw stem is 44.1 kHz — round-trip through /sa3-refine at
// strength 0 is wasteful, so upsample via the engine's
// recombine of the solo vocal stem (48 kHz out) instead.
cleanVocals = await aceClient.superSepRecombine(vs.sepId,
vs.stems.map(s => ({ index: s.index, volume: 1.0, muted: s.index !== vs.vocalIndex })));
}
setStage(`StableStep: recombining${suffix}...`);
const mixed = mixWavBuffers(refinedInst, cleanVocals);
fs.writeFileSync(processedPath, mixed);
}
anyStageRan = true;
log('INFO', `[StableStep] Refined ${audioFilename} (strength=${strength}, backend=${backend ?? 'auto'})`);
}
} catch (ssErr: any) {
log('WARNING', `[StableStep] Failed (non-fatal): ${ssErr.message}`);
}
timing.push({ name: 'StableStep', ms: Math.round(performance.now() - ssStart) });
}
if (ppVaeOn) {
const ppVaeStart = performance.now();
setStage(`PP-VAE Re-encode${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
const wavBuf = fs.readFileSync(processedPath);
const blend = params.ppVaeBlend ?? 0;
const processed = await aceClient.submitPpVaeReencode(wavBuf, blend, params.ppVaeUseOnnx);
fs.writeFileSync(processedPath, processed);
anyStageRan = true;
log('INFO', `[PP-VAE] Re-encoded ${audioFilename}`);
} catch (ppErr: any) {
log('WARNING', `[PP-VAE] Failed (non-fatal): ${ppErr.message}`);
}
timing.push({ name: 'PP-VAE Re-encode', ms: Math.round(performance.now() - ppVaeStart) });
}
if (spectralLifterOn) {
const slStart = performance.now();
setStage(`Spectral Lifter${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
const wavBuf = fs.readFileSync(processedPath);
const slParams = {
denoise_strength: params.slDenoiseStrength ?? 0.3,
noise_floor: params.slNoiseFloor ?? 0.1,
hf_mix: params.slHfMix ?? 0.0,
transient_boost: params.slTransientBoost ?? 0.0,
shimmer_reduction: params.slShimmerReduction ?? 6.0,
};
const processed = await aceClient.submitSpectralLifter(wavBuf, slParams);
fs.writeFileSync(processedPath, processed);
anyStageRan = true;
log('INFO', `[Spectral Lifter] Applied to ${audioFilename}`);
} catch (slErr: any) {
log('WARNING', `[Spectral Lifter] Failed (non-fatal): ${slErr.message}`);
}
timing.push({ name: 'Spectral Lifter', ms: Math.round(performance.now() - slStart) });
}
// ── Vocal Naturalizer (between Spectral Lifter and VST Chain) ──
const natOn = ppMasterOn && !!params.vocalNaturalizerEnabled && !params.instrumental;
if (natOn) {
const natStart = performance.now();
try {
const natParams: NaturalizerParams = {
amount: params.naturalizeAmount ?? 0.5,
vibratoRate: params.natVibratoRate ?? 4.5,
vibratoDepth: params.natVibratoDepth ?? 1.0,
formantStrength: params.natFormantStrength ?? 1.0,
metallicReduction: params.natMetallicReduction ?? 1.0,
quantizationMask: params.natQuantizationMask ?? 0.0,
transitionSmooth: params.natTransitionSmooth ?? 1.0,
};
const applied = await runVocalNaturalizer(
processedPath, natParams, log, setStage, i, audioUrls.length
);
if (applied) {
anyStageRan = true;
log('INFO', `[Vocal Naturalizer] Applied to ${processedFilename}`);
}
} catch (natErr: any) {
log('WARNING', `[Vocal Naturalizer] Failed (non-fatal): ${natErr.message}`);
}
timing.push({ name: 'Vocal Naturalizer', ms: Math.round(performance.now() - natStart) });
}
// ── Pre-VST Gain Offset ──
const gainDb = params.gainOffsetDb ?? 0;
if (ppMasterOn && gainDb !== 0) {
const gainStart = performance.now();
setStage(`Gain offset ${gainDb > 0 ? '+' : ''}${gainDb} dB${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
const buf = fs.readFileSync(processedPath);
// Parse WAV: find 'data' chunk
let dataOffset = -1;
for (let off = 36; off < buf.length - 8; off++) {
if (buf[off] === 0x64 && buf[off+1] === 0x61 && buf[off+2] === 0x74 && buf[off+3] === 0x61) {
dataOffset = off;
break;
}
}
if (dataOffset >= 0) {
const dataSize = buf.readUInt32LE(dataOffset + 4);
const pcmStart = dataOffset + 8;
const audioFormat = buf.readUInt16LE(20);
const bitsPerSample = buf.readUInt16LE(34);
const linearGain = Math.pow(10, gainDb / 20);
if (audioFormat === 1 && bitsPerSample === 16) {
// PCM 16-bit
for (let p = pcmStart; p + 1 < pcmStart + dataSize && p + 1 < buf.length; p += 2) {
let sample = buf.readInt16LE(p) * linearGain;
sample = Math.max(-32768, Math.min(32767, Math.round(sample)));
buf.writeInt16LE(sample, p);
}
} else if (audioFormat === 3 && bitsPerSample === 32) {
// IEEE float 32-bit
for (let p = pcmStart; p + 3 < pcmStart + dataSize && p + 3 < buf.length; p += 4) {
buf.writeFloatLE(buf.readFloatLE(p) * linearGain, p);
}
}
// else: unsupported format, skip silently
fs.writeFileSync(processedPath, buf);
anyStageRan = true;
log('INFO', `[Gain] Applied ${gainDb > 0 ? '+' : ''}${gainDb} dB to ${processedFilename}`);
}
} catch (gainErr: any) {
log('WARNING', `[Gain] Offset failed (non-fatal): ${gainErr.message}`);
}
const gainMs = Math.round(performance.now() - gainStart);
if (gainMs > 10) timing.push({ name: 'Gain Offset', ms: gainMs });
}
if (ppMasterOn) {
const vstStart = performance.now();
setStage(`Applying VST chain${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
const applied = await applyVstChain(processedPath);
if (applied) {
anyStageRan = true;
log('INFO', `[VST] Chain applied to ${processedFilename}`);
}
} catch (vstErr: any) {
log('WARNING', `[VST] Chain failed (non-fatal): ${vstErr.message}`);
}
const vstMs = Math.round(performance.now() - vstStart);
if (vstMs > 50) timing.push({ name: 'VST Chain', ms: vstMs });
}
if (masteringOn && masteringRef) {
const masterStart = performance.now();
setStage(`Mastering${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
const refPath = masteringRef.startsWith('/references/')
? path.join(config.data.dir, 'references', masteringRef.replace('/references/', ''))
: path.isAbsolute(masteringRef)
? masteringRef
: path.join(config.data.dir, 'references', masteringRef);
const tempMastered = processedPath + '.mastered.wav';
await runMastering(processedPath, refPath, tempMastered);
fs.renameSync(tempMastered, processedPath);
anyStageRan = true;
log('INFO', `[Mastering] Applied to ${processedFilename}`);
} catch (masterErr: any) {
log('WARNING', `[Mastering] Failed (non-fatal): ${masterErr.message}`);
}
timing.push({ name: 'Mastering', ms: Math.round(performance.now() - masterStart) });
}
// ── LUFS Normalization (final audio-modifying stage) ──
const lufsOn = ppMasterOn && masteringOn && !!params.lufsEnabled && params.lufsTarget !== undefined;
if (lufsOn && params.lufsTarget !== undefined) {
const lufsStart = performance.now();
setStage(`LUFS normalization${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
try {
const { normalizeLufs } = await import('./lufsNormalize.js');
const result = normalizeLufs(processedPath, params.lufsTarget);
anyStageRan = true;
log('INFO',
`[LUFS] ${processedFilename}: ${result.measuredLufs.toFixed(1)}${result.targetLufs.toFixed(1)} LUFS ` +
`(${result.appliedGainDb > 0 ? '+' : ''}${result.appliedGainDb.toFixed(1)} dB` +
`${result.limiterActive ? ', limiter active' : ''})` +
` | Peak: ${(20 * Math.log10(Math.max(result.peakBefore, 1e-10))).toFixed(1)}${(20 * Math.log10(Math.max(result.peakAfter, 1e-10))).toFixed(1)} dBFS`
);
} catch (lufsErr: any) {
log('WARNING', `[LUFS] Normalization failed (non-fatal): ${lufsErr.message}`);
}
timing.push({ name: 'LUFS Normalize', ms: Math.round(performance.now() - lufsStart) });
}
// ── Quality Evaluation: Mastered (after all PP stages) ──
// Ensure pre-QE (if deferred) has completed before we proceed
if (qePrePromise) await qePrePromise;
if (qeOn && (qeTarget === 'mastered' || qeTarget === 'both') && anyStageRan) {
const qePostStart = performance.now();
try {
setStage(`Quality check (mastered)${totalTracks > 1 ? ` (${i+1}/${totalTracks})` : ''}...`);
const result = evaluateAudioQuality(processedPath);
trackQuality.mastered = result;
log('INFO', formatQualityLog(result, `Mastered ${processedFilename}`));
} catch (qeErr: any) {
log('WARNING', `[Quality] Mastered eval failed (non-fatal): ${qeErr.message}`);
}
const qePostMs = Math.round(performance.now() - qePostStart);
if (qePostMs > 50) timing.push({ name: 'Quality Eval (post)', ms: qePostMs });
}
qualityScores.push(trackQuality);
if (anyStageRan) {
masteredUrls.push(`/audio/${processedFilename}`);
} else {
try { fs.unlinkSync(processedPath); } catch {}
masteredUrls.push('');
}
}
return { masteredUrls, qualityScores, timing };
}
// ── WAV mix helpers (StableStep recombine) ──────────────────────────────────
// No shared float-WAV parse/encode helper exists in server/src (audioCrop.ts
// keeps its header parser private and operates in-place), so StableStep uses
// this minimal local implementation: 16-bit PCM + 32-bit float, stereo/mono.
interface ParsedWavAudio {
sampleRate: number;
numChannels: number;
/** Interleaved samples, normalized to [-1, 1] floats. */
samples: Float32Array;
}
function parseWavToFloat(buf: Buffer): ParsedWavAudio {
if (buf.length < 44 ||
buf.toString('ascii', 0, 4) !== 'RIFF' ||
buf.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('Not a valid WAV file');
}
let offset = 12;
let audioFormat = 0, numChannels = 0, sampleRate = 0, bitsPerSample = 0;
let dataOffset = -1, dataSize = 0;
while (offset + 8 <= buf.length) {
const chunkId = buf.toString('ascii', offset, offset + 4);
const chunkSize = buf.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
audioFormat = buf.readUInt16LE(offset + 8);
numChannels = buf.readUInt16LE(offset + 10);
sampleRate = buf.readUInt32LE(offset + 12);
bitsPerSample = buf.readUInt16LE(offset + 22);
} else if (chunkId === 'data') {
dataOffset = offset + 8;
dataSize = Math.min(chunkSize, buf.length - dataOffset);
break;
}
offset += 8 + chunkSize + (chunkSize % 2);
}
if (dataOffset < 0 || sampleRate <= 0 || numChannels <= 0) {
throw new Error('WAV file missing fmt or data chunk');
}
let samples: Float32Array;
if (audioFormat === 1 && bitsPerSample === 16) {
const n = Math.floor(dataSize / 2);
samples = new Float32Array(n);
for (let s = 0; s < n; s++) {
samples[s] = buf.readInt16LE(dataOffset + s * 2) / 32768;
}
} else if (audioFormat === 3 && bitsPerSample === 32) {
const n = Math.floor(dataSize / 4);
samples = new Float32Array(n);
for (let s = 0; s < n; s++) {
samples[s] = buf.readFloatLE(dataOffset + s * 4);
}
} else {
throw new Error(`Unsupported WAV format (fmt=${audioFormat}, ${bitsPerSample}-bit)`);
}
return { sampleRate, numChannels, samples };
}
function encodeWav16(samples: Float32Array, sampleRate: number, numChannels: number): Buffer {
const dataSize = samples.length * 2;
const out = Buffer.alloc(44 + dataSize);
out.write('RIFF', 0, 'ascii');
out.writeUInt32LE(36 + dataSize, 4);
out.write('WAVE', 8, 'ascii');
out.write('fmt ', 12, 'ascii');
out.writeUInt32LE(16, 16); // fmt chunk size
out.writeUInt16LE(1, 20); // PCM
out.writeUInt16LE(numChannels, 22);
out.writeUInt32LE(sampleRate, 24);
out.writeUInt32LE(sampleRate * numChannels * 2, 28); // byte rate
out.writeUInt16LE(numChannels * 2, 32); // block align
out.writeUInt16LE(16, 34); // bits per sample
out.write('data', 36, 'ascii');
out.writeUInt32LE(dataSize, 40);
for (let s = 0; s < samples.length; s++) {
const v = Math.max(-32768, Math.min(32767, Math.round(samples[s] * 32767)));
out.writeInt16LE(v, 44 + s * 2);
}
return out;
}
/** Sum two WAV buffers sample-wise (missing tail treated as silence) with a
* peak guard: if |sum| exceeds 0.999 the whole mix is scaled down to fit.
* Both inputs must share sample rate and channel count. Returns 16-bit PCM. */
function mixWavBuffers(a: Buffer, b: Buffer): Buffer {
const wa = parseWavToFloat(a);
const wb = parseWavToFloat(b);
if (wa.sampleRate !== wb.sampleRate) {
throw new Error(`Sample rate mismatch (${wa.sampleRate} vs ${wb.sampleRate})`);
}
if (wa.numChannels !== wb.numChannels) {
throw new Error(`Channel count mismatch (${wa.numChannels} vs ${wb.numChannels})`);
}
const n = Math.max(wa.samples.length, wb.samples.length);
const mixed = new Float32Array(n);
let peak = 0;
for (let s = 0; s < n; s++) {
const v = (s < wa.samples.length ? wa.samples[s] : 0)
+ (s < wb.samples.length ? wb.samples[s] : 0);
mixed[s] = v;
const av = Math.abs(v);
if (av > peak) peak = av;
}
if (peak > 0.999) {
const scale = 0.999 / peak;
for (let s = 0; s < n; s++) mixed[s] *= scale;
}
return encodeWav16(mixed, wa.sampleRate, wa.numChannels);
}
@@ -0,0 +1,179 @@
// generation/sourceAudio.ts — Source audio and timbre reference preparation
//
// Handles loading, format conversion, and pre-processing of source audio
// for cover/repaint tasks and timbre reference conditioning.
import fs from 'fs';
import path from 'path';
import { config } from '../../config.js';
import { mapPath } from '../../services/pathMapper.js';
import { ensureEngineFormat, timeStretchPitchShift } from '../../services/audioConvert.js';
import { readHslat, latentFrameCount, latentDuration } from '../../services/latentFormat.js';
import { convertToWav } from '../../routes/mastering.js';
type LogFn = (level: 'INFO' | 'DEBUG' | 'WARNING' | 'ERROR', msg: string) => void;
/** Load and prepare source audio for cover/repaint tasks */
export function loadSourceAudio(
sourceAudioUrl: string | undefined,
jobId: string,
log: LogFn
): Buffer | undefined {
if (!sourceAudioUrl) return undefined;
const resolvedUrl = mapPath(sourceAudioUrl) || sourceAudioUrl;
const srcPath = resolvedUrl.startsWith('/references/')
? path.join(config.data.dir, 'references', resolvedUrl.replace('/references/', ''))
: resolvedUrl.startsWith('/audio/')
? path.join(config.data.audioDir, resolvedUrl.replace('/audio/', ''))
: path.isAbsolute(resolvedUrl)
? resolvedUrl
: path.join(config.data.dir, resolvedUrl);
log('DEBUG', `[Synth Phase] Looking for source audio at: ${srcPath}`);
if (!fs.existsSync(srcPath)) {
log('WARNING', `[Synth Phase] Source audio not found: ${srcPath}`);
return undefined;
}
try {
const buf = ensureEngineFormat(srcPath);
log('INFO', `[Synth Phase] Source audio (cover): ${srcPath} (${(buf.length / 1024 / 1024).toFixed(1)} MB)`);
return buf;
} catch (convErr: any) {
log('WARNING', `[Synth Phase] Audio conversion failed: ${convErr.message}`);
return fs.readFileSync(srcPath);
}
}
/** Load source latent (skips VAE encode) */
export function loadSourceLatent(
sourceLatentUrl: string | undefined,
log: LogFn
): Buffer | undefined {
if (!sourceLatentUrl) return undefined;
const latentPath = sourceLatentUrl.startsWith('/audio/')
? path.join(config.data.audioDir, sourceLatentUrl.replace('/audio/', ''))
: path.isAbsolute(sourceLatentUrl) ? sourceLatentUrl : path.join(config.data.dir, sourceLatentUrl);
if (!fs.existsSync(latentPath)) {
log('WARNING', `[Latent] Source latent not found: ${latentPath}`);
return undefined;
}
try {
const fileContents = fs.readFileSync(latentPath);
const parsed = readHslat(fileContents);
const rawLatent = parsed.rawLatent;
if (rawLatent.length % 256 !== 0) {
log('WARNING', `[Latent] Invalid latent file size (${rawLatent.length} bytes), ignoring`);
return undefined;
}
log('INFO', `[Latent] Source latent loaded: ${latentPath} (${latentFrameCount(rawLatent)} frames, ${latentDuration(rawLatent).toFixed(1)}s)`);
return rawLatent;
} catch (latErr: any) {
log('WARNING', `[Latent] Failed to read source latent: ${latErr.message}`);
return undefined;
}
}
/** Apply tempo/pitch pre-processing to source audio */
export function applyTempoAndPitch(
srcAudioBuf: Buffer,
tempoScale: number | undefined,
pitchShift: number | undefined,
log: LogFn
): Buffer {
if ((!tempoScale || tempoScale === 1.0) && (!pitchShift || pitchShift === 0)) return srcAudioBuf;
try {
log('INFO', `[Synth Phase] Pre-processing source audio: tempo=${tempoScale ?? 1.0}x, pitch=${pitchShift ?? 0}st`);
const result = timeStretchPitchShift(srcAudioBuf, tempoScale ?? 1.0, pitchShift ?? 0);
log('INFO', `[Synth Phase] Pre-processed source audio: ${(result.length / 1024 / 1024).toFixed(1)} MB`);
return result;
} catch (err: any) {
log('WARNING', `[Synth Phase] Tempo/pitch pre-processing failed: ${err.message}`);
return srcAudioBuf;
}
}
/** Resolve and load timbre reference audio */
export async function loadTimbreReference(
params: any,
masteringRef: string | undefined,
seed: number | undefined,
jobId: string,
log: LogFn
): Promise<Buffer | undefined> {
const rawTimbre = params.timbreReference;
const timbreRef = (rawTimbre === true && typeof masteringRef === 'string')
? masteringRef
: (typeof rawTimbre === 'string' ? rawTimbre : undefined);
log('DEBUG', `[Synth Phase] timbreRef=${timbreRef}, masteringRef=${masteringRef}`);
if (!timbreRef) return undefined;
const mappedRef = mapPath(timbreRef) || timbreRef;
let refPath = mappedRef.startsWith('/references/')
? path.join(config.data.dir, 'references', mappedRef.replace('/references/', ''))
: path.isAbsolute(mappedRef)
? mappedRef
: path.join(config.data.dir, 'references', mappedRef);
// Randomize timbre reference
if (params.randomizeTimbreRef) {
try {
const refDir = path.dirname(refPath);
const audioExts = new Set(['.wav', '.mp3', '.flac', '.ogg', '.m4a', '.aac', '.wma']);
const candidates = fs.readdirSync(refDir)
.filter(f => audioExts.has(path.extname(f).toLowerCase()))
.sort();
if (candidates.length > 1) {
const s = seed ?? 0;
const idx = Math.abs(s) % candidates.length;
const picked = path.join(refDir, candidates[idx]);
log('INFO', `[Timbre] Randomized: picked "${candidates[idx]}" (${idx + 1}/${candidates.length}, seed=${s}) from ${refDir}`);
refPath = picked;
} else {
log('INFO', `[Timbre] Randomize enabled but only ${candidates.length} audio file(s) in ${refDir} — using original`);
}
} catch (dirErr: any) {
log('WARNING', `[Timbre] Randomize failed (using original): ${dirErr.message}`);
}
}
log('DEBUG', `[Synth Phase] Looking for timbre ref at: ${refPath}`);
if (!fs.existsSync(refPath)) {
log('WARNING', `[Synth Phase] Timbre reference file not found: ${refPath}`);
return undefined;
}
const refExt = path.extname(refPath).toLowerCase();
let readPath = refPath;
let tempWav: string | undefined;
if (refExt !== '.wav' && refExt !== '.mp3') {
try {
tempWav = path.join(config.data.dir, `timbre_temp_${jobId}.wav`);
log('INFO', `[Synth Phase] Converting timbre ref ${refExt} → WAV via ffmpeg`);
await convertToWav(refPath, tempWav);
readPath = tempWav;
} catch (convErr: any) {
log('WARNING', `[Synth Phase] Timbre ref conversion failed (${convErr.message}), sending raw file`);
readPath = refPath;
tempWav = undefined;
}
}
const buf = fs.readFileSync(readPath);
log('INFO', `[Synth Phase] Timbre reference: ${refPath} (${(buf.length / 1024 / 1024).toFixed(1)} MB)`);
if (tempWav && fs.existsSync(tempWav)) {
try { fs.unlinkSync(tempWav); } catch {}
}
return buf;
}
@@ -0,0 +1,80 @@
/**
* File-based latent cache for VAE-encoded source/timbre audio.
*
* Stores raw f32 latent files alongside audio in a `.latent-cache/` directory.
* Cache key is a hash of (audioPath + tempoScale + pitchShift), so the same
* source with different processing params gets separate cache entries.
*
* Files are raw f32 [T*64] — matching the upstream acestep.cpp wire format.
* No HSLAT wrapper: these are internal cache files, not user-facing exports.
*/
import { createHash } from 'crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from 'fs';
import { dirname, join } from 'path';
const CACHE_DIR_NAME = '.latent-cache';
/**
* Build a deterministic cache key from audio path + processing params.
* Returns a hex SHA-256 hash suitable for use as a filename.
*/
function cacheKey(audioPath: string, tempo?: number, pitch?: number, vaeModel?: string): string {
const parts = [audioPath];
if (tempo !== undefined && tempo !== 1.0) parts.push(`tempo=${tempo}`);
if (pitch !== undefined && pitch !== 0) parts.push(`pitch=${pitch}`);
if (vaeModel) parts.push(`vae=${vaeModel}`);
return createHash('sha256').update(parts.join('|')).digest('hex');
}
/**
* Resolve the cache directory for a given audio file.
* Creates `.latent-cache/` next to the audio file if it doesn't exist.
*/
function cacheDir(audioPath: string): string {
const dir = join(dirname(audioPath), CACHE_DIR_NAME);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
return dir;
}
/**
* Look up a cached latent for the given audio + processing params.
* Returns the raw f32 Buffer if found, or undefined on miss.
*/
export function getCachedLatent(audioPath: string, tempo?: number, pitch?: number, vaeModel?: string): Buffer | undefined {
try {
const key = cacheKey(audioPath, tempo, pitch, vaeModel);
const filePath = join(cacheDir(audioPath), `${key}.raw`);
if (!existsSync(filePath)) return undefined;
const buf = readFileSync(filePath);
// Validate: must be a multiple of 256 bytes (64 floats per frame)
if (buf.length === 0 || buf.length % 256 !== 0) {
console.warn(`[Latent Cache] Corrupt cache file (${buf.length} bytes), ignoring: ${filePath}`);
return undefined;
}
const stat = statSync(filePath);
console.log(`[Latent Cache] HIT — ${(buf.length / 1024).toFixed(0)} KB, cached ${stat.mtime.toISOString()}`);
return buf;
} catch (err) {
console.warn(`[Latent Cache] Read error: ${err}`);
return undefined;
}
}
/**
* Save a latent buffer to the file cache.
*/
export function saveCachedLatent(audioPath: string, latent: Buffer, tempo?: number, pitch?: number, vaeModel?: string): void {
try {
const key = cacheKey(audioPath, tempo, pitch, vaeModel);
const filePath = join(cacheDir(audioPath), `${key}.raw`);
writeFileSync(filePath, latent);
console.log(`[Latent Cache] STORED — ${(latent.length / 1024).toFixed(0)} KB → ${filePath}`);
} catch (err) {
console.warn(`[Latent Cache] Write error: ${err}`);
}
}
@@ -0,0 +1,298 @@
// generation/translateParams.ts — Translate frontend params to AceRequest format
//
// Pure function with zero side effects. Maps UI-facing parameter names
// to the AceRequest schema expected by the ace-server engine.
import type { AceRequest } from '../../services/aceClient.js';
import { mapPath } from '../../services/pathMapper.js';
import { parseAdapterSections, stripAdapterDirectives } from './adapterSections.js';
import { applyTriggers, resolveAdapterTriggers, resolveTriggerSpecs } from './triggerWords.js';
import { readAdapterTrigger } from '../adapters/stMetadata.js';
/** Samples per compiled timestep gain curve (t ∈ [0,1], uniform). */
const GAIN_CURVE_SAMPLES = 33;
/**
* Compile an active-timestep window into a gain curve g(t) sampled uniformly
* over flow-matching t ∈ [0,1] (t=1 noise → t=0 clean).
*
* Edges use smoothstep ramps of width `soft` CENTERED on the window bounds
* (g=0.5 exactly at start/end). Two adapters sharing a boundary — e.g. a
* "structure" expert on [0.5, 1] and a "timbre" expert on [0, 0.5] — therefore
* crossfade with gains summing to exactly 1 across the transition.
*/
function windowToGainCurve(start: number, end: number, soft = 0.1): number[] {
const lo = Math.min(start, end);
const hi = Math.max(start, end);
const smooth = (x: number) => {
const c = Math.max(0, Math.min(1, x));
return c * c * (3 - 2 * c);
};
// Ramp centered on an edge: 0 at edge-soft/2, 0.5 at edge, 1 at edge+soft/2.
const rise = (t: number, edge: number) =>
soft > 0 ? smooth((t - (edge - soft / 2)) / soft) : (t >= edge ? 1 : 0);
const curve: number[] = [];
for (let i = 0; i < GAIN_CURVE_SAMPLES; i++) {
const t = i / (GAIN_CURVE_SAMPLES - 1);
// Window bounds at the domain ends need no ramp (nothing to fade to).
const gLo = lo <= 0 ? 1 : rise(t, lo);
const gHi = hi >= 1 ? 1 : 1 - rise(t, hi);
curve.push(gLo * gHi);
}
return curve;
}
/** Translate frontend params to AceRequest format */
export function translateParams(params: any): AceRequest {
const req: AceRequest = {
caption: params.prompt || params.songDescription || params.caption || params.style || '',
};
// Lyrics / instrumental
if (params.instrumental) {
req.lyrics = '[Instrumental]';
} else if (params.lyrics) {
req.lyrics = params.lyrics;
}
// Metadata
if (params.bpm) req.bpm = params.bpm;
if (params.duration) {
const buffer = (params.autoTrimEnabled && params.durationBuffer) ? params.durationBuffer : 0;
req.duration = params.duration + buffer;
}
if (params.keyScale) req.keyscale = params.keyScale;
if (params.timeSignature) {
const ts = String(params.timeSignature);
req.timesignature = ts.includes('/') ? ts.split('/')[0] : ts;
}
if (params.vocalLanguage) req.vocal_language = params.vocalLanguage;
// Seed (DiT / generation phase)
if (params.randomSeed) {
req.seed = Math.floor(Math.random() * 2_147_483_647);
} else if (params.seed !== undefined) {
req.seed = params.seed;
}
// LM Seed — independent from the seed above, unless tied to it via
// lmSeedFollowsDit (default true — matches the engine's original
// behavior: locked seed -> both deterministic, random -> both random).
// When tied, lm_seed is left unset entirely so the engine's own fallback
// (lm_seed defaults to the DiT seed when absent) does the tying — this
// correctly follows a *randomized* seed too, since req.seed is already
// resolved above by this point.
const lmSeedFollowsDit = params.lmSeedFollowsDit !== false; // default true
if (!lmSeedFollowsDit && params.lmSeed !== undefined) {
req.lm_seed = params.lmSeed;
}
// Batch
if (params.batchSize) req.lm_batch_size = params.batchSize;
// LM params
if (params.lmTemperature !== undefined) req.lm_temperature = params.lmTemperature;
if (params.lmCfgScale !== undefined) req.lm_cfg_scale = params.lmCfgScale;
if (params.lmTopP !== undefined) req.lm_top_p = params.lmTopP;
if (params.lmTopK !== undefined) req.lm_top_k = params.lmTopK;
// Anti-loop repetition penalty on code sampling (local HOT-Step feature)
if (params.lmRepPenalty !== undefined) req.lm_rep_penalty = params.lmRepPenalty;
if (params.lmRepWindow !== undefined) req.lm_rep_window = params.lmRepWindow;
if (params.negative_prompt) req.negative_prompt = params.negative_prompt;
if (params.lmNegativePrompt) req.lm_negative_prompt = params.lmNegativePrompt;
// DiT params
if (params.inferenceSteps) req.inference_steps = params.inferenceSteps;
if (params.guidanceScale !== undefined) req.guidance_scale = params.guidanceScale;
if (params.shift !== undefined) req.shift = params.shift;
if (params.inferMethod) req.infer_method = params.inferMethod;
if (params.scheduler) req.scheduler = params.scheduler;
if (params.guidanceMode) req.guidance_mode = params.guidanceMode;
// Cover/repaint
if (params.taskType) req.task_type = params.taskType;
if (params.audioCoverStrength !== undefined) req.audio_cover_strength = params.audioCoverStrength;
if (params.coverNoiseStrength !== undefined) req.cover_noise_strength = params.coverNoiseStrength;
if (params.coverNoiseMethod) req.cover_noise_method = params.coverNoiseMethod;
if (params.repaintingStart !== undefined) req.repainting_start = params.repaintingStart;
if (params.repaintingEnd !== undefined) req.repainting_end = params.repaintingEnd;
if (params.seedStrength !== undefined) req.seed_strength = params.seedStrength;
if (params.evictLm) req.evict_lm = true;
if (params.vaeChunk) req.vae_chunk = params.vaeChunk;
if (params.batchCfg !== undefined) req.batch_cfg = params.batchCfg ? 1 : 0;
if (params.trackName) req.track = params.trackName;
// CoT
if (params.useCotCaption !== undefined) req.use_cot_caption = params.useCotCaption;
// Model routing
if (params.ditModel) req.synth_model = params.ditModel;
if (params.lmModel) req.lm_model = params.lmModel;
// Planner-LM runtime LoRA (local HOT-Step feature). Lives in aceReq (not
// the sideband), so it survives the LM-echo synth rebuild by construction.
// mapPath: same path translation the DiT adapters get (UI sends absolute
// filesystem paths from the /api/adapters/lm scan).
if (params.lmAdapter) req.lm_adapter = mapPath(params.lmAdapter);
if (params.lmAdapterScale !== undefined) req.lm_adapter_scale = params.lmAdapterScale;
if (params.vaeModel) req.vae_model = params.vaeModel;
if (params.embeddingModel) req.emb_model = params.embeddingModel;
if (params.loraPath) req.adapter = mapPath(params.loraPath);
if (params.loraScale !== undefined) req.adapter_scale = params.loraScale;
// Multi-adapter stack: when the UI supplies a list, it supersedes the single
// adapter — each entry is mapped to an engine path and carries its own scale.
// The engine merges them (or sums runtime deltas) with per-adapter scaling.
if (Array.isArray(params.loraStack) && params.loraStack.length > 0) {
req.adapters = params.loraStack
.filter((a: { path?: string }) => a && a.path)
.map((a: { path: string; scale?: number; stepStart?: number; stepEnd?: number; stepSoft?: number; gainCurve?: number[]; gainDomain?: string }) => {
const entry: { name: string; scale: number; gain_curve?: number[]; gain_domain?: 'steps' | 't' } = {
name: mapPath(a.path) as string,
scale: a.scale ?? 1.0,
};
// Timestep-dependent gain (interval experts / MoE mixing): an explicit
// curve wins; otherwise an active window [stepStart, stepEnd] compiles
// to one. A full-range window (0..1) means "always on" — no curve.
//
// Domain: UI windows are "% of denoising" and MUST evaluate per STEP
// ('steps') — shifted schedules are wildly nonuniform in t (17 of 20
// steps sit above t=0.5 at shift 3, which starved the late adapter).
// Explicit curves default to 't' (trained-expert / router curves must
// match their training axis) unless gainDomain says otherwise.
if (Array.isArray(a.gainCurve) && a.gainCurve.length > 0) {
entry.gain_curve = a.gainCurve.map((g) => Math.max(0, Number(g) || 0));
entry.gain_domain = a.gainDomain === 'steps' ? 'steps' : 't';
} else {
const s = a.stepStart ?? 0;
const e = a.stepEnd ?? 1;
if (s > 0 || e < 1) {
entry.gain_curve = windowToGainCurve(s, e, a.stepSoft ?? 0.1);
entry.gain_domain = 'steps';
}
}
return entry;
});
}
if (params.adapterGroupScales) req.adapter_group_scales = params.adapterGroupScales;
if (params.adapterMode) req.adapter_mode = params.adapterMode;
if (params.adapterRuntimeQuant) req.adapter_runtime_quant = params.adapterRuntimeQuant;
if (params.adapterMergeLowVram) req.adapter_merge_lowvram = true;
// Per-section adapter masking (regional LoRA): parse inline [Section]{k=v} directives
// from the lyrics into a per-section weight table, strip them from the lyrics sent to
// the engine, and force runtime mode (merge can't vary per-frame). Only applied with a
// 2+ adapter stack; a no-directive lyric leaves everything untouched.
if (Array.isArray(params.loraStack) && params.loraStack.length >= 2 && req.lyrics) {
const parsed = parseAdapterSections(
req.lyrics,
params.loraStack,
params.adapterStackMode || 'blend',
params.adapterStackBudget ?? 0.75,
);
if (parsed.sections && parsed.sections.length > 0) {
req.lyrics = parsed.lyrics;
req.adapter_sections = parsed.sections;
req.adapter_mode = 'runtime';
if (params.adapterSectionAlignAt !== undefined) req.adapter_section_align_at = params.adapterSectionAlignAt;
if (params.adapterSectionIsolation !== undefined) req.adapter_section_isolation = params.adapterSectionIsolation;
}
} else if (req.lyrics) {
// Gate not met (01 adapters / Simple mode): directives can't apply, but they
// must STILL be stripped — otherwise `[Verse]{x=0.9}` reaches the LM/encoder
// as garbage tokens.
req.lyrics = stripAdapterDirectives(req.lyrics);
}
// Timestep-dependent adapter gating (interval experts / MoE mixing) rides the
// engine's per-section mask machinery. When any stacked adapter carries a gain
// curve but lyric directives produced no sections, synthesize a single
// whole-song section carrying the stack scales, and force runtime mode (merge
// bakes weights once; gains vary per step). P2 alignment stays off naturally:
// it requires a section token map, which only directive parsing builds.
if (
Array.isArray(req.adapters) &&
req.adapters.some((a) => a.gain_curve && a.gain_curve.length > 0) &&
(!req.adapter_sections || req.adapter_sections.length === 0)
) {
req.adapter_sections = [{ weights: req.adapters.map((a) => a.scale), size: 1 }];
req.adapter_mode = 'runtime';
}
// Basin re-base: rebaseSource is a DiT model NAME (engine resolves to its path).
// Only meaningful alongside an adapter; engine ignores it otherwise.
if (params.loraPath && params.rebaseSource && params.rebaseBeta) {
req.rebase_source = params.rebaseSource;
req.rebase_beta = params.rebaseBeta;
}
// Trigger word(s): every loaded adapter contributes its own trigger and the
// position it was TRAINED at, so a stack of adapters can mix prepend and
// append (triggerWords.ts composes them). Gated on an adapter actually being
// loaded — but that now includes a planner-LM adapter, which was itself
// trained on tagged captions and so needs its trigger in the caption that
// goes to /lm.
// Adapter paths as the CLIENT sent them (server-local, straight from our own
// /api/adapters scan) — read the embedded metadata BEFORE mapPath rewrites
// them for the engine's filesystem view.
const adapterPaths = [
...(Array.isArray(params.loraStack) ? params.loraStack.map((e: { path: string }) => e.path) : []),
...(params.loraPath ? [params.loraPath] : []),
...(params.lmAdapter ? [params.lmAdapter] : []),
].filter((p, i, a) => p && a.indexOf(p) === i);
const triggerSpecs = resolveAdapterTriggers(adapterPaths, resolveTriggerSpecs(params), readAdapterTrigger);
if (triggerSpecs.length && adapterPaths.length) {
req.caption = applyTriggers(req.caption || '', triggerSpecs).caption;
}
// Solver sub-parameters
if (params.storkSubsteps !== undefined) req.stork_substeps = params.storkSubsteps;
if (params.beatStability !== undefined) req.beat_stability = params.beatStability;
if (params.frequencyDamping !== undefined) req.frequency_damping = params.frequencyDamping;
if (params.temporalSmoothing !== undefined) req.temporal_smoothing = params.temporalSmoothing;
// Guidance sub-parameters
if (params.apgMomentum !== undefined) req.apg_momentum = params.apgMomentum;
if (params.apgNormThreshold !== undefined) req.apg_norm_threshold = params.apgNormThreshold;
// DCW
if (params.dcwEnabled !== undefined) req.dcw_enabled = params.dcwEnabled;
if (params.dcwMode) req.dcw_mode = params.dcwMode;
if (params.dcwScaler !== undefined) req.dcw_scaler = params.dcwScaler;
if (params.dcwHighScaler !== undefined) req.dcw_high_scaler = params.dcwHighScaler;
// Latent post-processing
if (params.latentShift !== undefined) req.latent_shift = params.latentShift;
if (params.latentRescale !== undefined) req.latent_rescale = params.latentRescale;
if (params.customTimesteps) req.custom_timesteps = params.customTimesteps;
if (params.cfgCutoffRatio !== undefined) req.cfg_cutoff_ratio = params.cfgCutoffRatio;
if (params.lmCfgCutoffRatio !== undefined) req.lm_cfg_cutoff_ratio = params.lmCfgCutoffRatio;
if (params.cacheRatio !== undefined) req.cache_ratio = params.cacheRatio;
// Post-VAE spectral denoiser
if (params.denoiseStrength !== undefined) req.denoise_strength = params.denoiseStrength;
if (params.denoiseSmoothing !== undefined) req.denoise_smoothing = params.denoiseSmoothing;
if (params.denoiseMix !== undefined) req.denoise_mix = params.denoiseMix;
// LSS: Latent Spectral Suppressor (pre-VAE latent channel gate)
if (params.lssStrength !== undefined) req.lss_strength = params.lssStrength;
if (params.lssVarThresh !== undefined) req.lss_var_thresh = params.lssVarThresh;
if (params.lssDcRemove !== undefined) req.lss_dc_remove = params.lssDcRemove;
// Lua plugin dynamic params (passthrough from UI)
if (params.pluginParams && Object.keys(params.pluginParams).length > 0) {
req.plugin_params = params.pluginParams;
}
// Postprocess plugin (replaces built-in VAE tiled decoder with Lua plugin)
if (params.postprocessPlugin) {
req.postprocess_plugin = params.postprocessPlugin;
}
// VAE backend selection (ONNX Runtime / TensorRT)
if (params.useOrtVae) req.use_ort_vae = true;
// Streaming pipeline (DEMON-style ring buffer)
if (params.streamMode) req.stream_mode = true;
if (params.streamDepth !== undefined) req.stream_depth = params.streamDepth;
if (params.streamChunkDir) req.stream_chunk_dir = params.streamChunkDir;
return req;
}
@@ -0,0 +1,183 @@
/**
* Trigger-word composition — one implementation, two call sites.
*
* A trigger word is the token an adapter was trained to respond to. It has to
* appear in the caption at the SAME position the training captions carried it
* (`preprocess-run.h:192-204` prepends or appends `custom_tag`), which is why a
* trigger travels as a `{word, placement}` pair rather than a bare string.
*
* Two places inject: `translateParams` (before the request leaves for the
* engine) and `routes/generate.ts` (again after the planner LM's chain-of-
* thought rewrites the caption and drops whatever was there). Both use this
* module so the two can never disagree.
*
* docs/plans/2026-07-28-adapter-trigger-embedding.md T7
*/
export type TriggerPlacement = 'prepend' | 'append' | 'replace';
/** Where a resolved trigger came from — surfaced on the Create panel's chip. */
export type TriggerSource = 'embedded' | 'filename' | 'override' | 'none';
export interface TriggerSpec {
word: string;
placement: TriggerPlacement;
source?: TriggerSource;
/** Adapter this trigger belongs to, when the caller knows it. */
path?: string;
}
/** The subset of GenerationParams this module reads. */
interface TriggerParamsLike {
triggerSpecs?: Array<{ word?: string; placement?: string; source?: string; path?: string }>;
triggerWords?: string[];
triggerWord?: string;
triggerPlacement?: string;
}
function asPlacement(v: unknown, dflt: TriggerPlacement = 'prepend'): TriggerPlacement {
return v === 'append' || v === 'replace' || v === 'prepend' ? v : dflt;
}
/**
* Normalise whatever the caller sent into a spec list.
*
* `triggerSpecs` is the current shape (one placement per adapter, because a
* stack can mix adapters trained at different positions). The older flat
* `triggerWords` + single `triggerPlacement` form is still accepted — saved
* presets and the queue path both persist it — and maps to one placement for
* every word.
*/
export function resolveTriggerSpecs(params: TriggerParamsLike): TriggerSpec[] {
if (Array.isArray(params.triggerSpecs) && params.triggerSpecs.length) {
return params.triggerSpecs
.map(s => ({
word: (s?.word || '').trim(),
placement: asPlacement(s?.placement),
source: (s?.source as TriggerSource) || undefined,
path: s?.path || undefined,
}))
.filter(s => s.word.length > 0);
}
const words = (Array.isArray(params.triggerWords) && params.triggerWords.length)
? params.triggerWords
: (params.triggerWord ? [params.triggerWord] : []);
if (!words.length || !params.triggerPlacement) return [];
const placement = asPlacement(params.triggerPlacement);
return words.map(w => (w || '').trim()).filter(Boolean).map(word => ({ word, placement }));
}
/**
* Decide the trigger for every loaded adapter.
*
* Precedence, per adapter (T6):
* 1. a manual override the user typed for that adapter
* 2. the trigger embedded in the adapter's own safetensors metadata — what it
* was actually TRAINED with, so it beats any guess
* 3. whatever the client derived from the filename (only sent when the
* "use filename" setting is on)
*
* When no adapter path resolves anything, the caller's flat spec list is used
* verbatim — that is the legacy path saved presets and older clients take.
*/
export function resolveAdapterTriggers(
adapterPaths: string[],
clientSpecs: TriggerSpec[],
readEmbedded: (adapterPath: string) => { trigger: string; position: 'prepend' | 'append' | '' },
): TriggerSpec[] {
const out: TriggerSpec[] = [];
const matched = new Set<TriggerSpec>();
for (const p of adapterPaths) {
if (!p) continue;
const override = clientSpecs.find(s => s.path === p && s.source === 'override');
if (override) { out.push(override); matched.add(override); continue; }
let embedded = { trigger: '', position: '' as 'prepend' | 'append' | '' };
try { embedded = readEmbedded(p); } catch { /* an unreadable adapter simply has no trigger */ }
if (embedded.trigger) {
out.push({
word: embedded.trigger,
placement: embedded.position === 'append' ? 'append' : 'prepend',
source: 'embedded',
path: p,
});
continue;
}
const fromClient = clientSpecs.find(s => s.path === p && !matched.has(s));
if (fromClient) { out.push(fromClient); matched.add(fromClient); }
}
// Specs the client sent without a path (the flat triggerWords form) only
// apply when per-adapter resolution produced nothing at all — otherwise a
// stale preset would duplicate a trigger we already resolved properly.
if (!out.length) return clientSpecs;
// Path-less client specs alongside resolved ones: keep them only if their word
// is not already present, so an override for one adapter in a stack does not
// silently drop the others.
const words = new Set(out.map(s => s.word));
for (const s of clientSpecs) {
if (!s.path && !words.has(s.word)) { out.push(s); words.add(s.word); }
}
return out;
}
export interface ApplyTriggersResult {
caption: string;
/** Words actually written into the caption this call. */
applied: string[];
/** Words skipped because the caption already contained them. */
skipped: string[];
}
/**
* Compose `specs` into `caption`.
*
* A `replace` spec wins outright — it is only reachable from the global setting
* or a manual override (training never produces one), and it means "the caption
* IS the trigger". Otherwise prepend-group words go in front and append-group
* words go behind, each in the order the adapters were stacked.
*
* `skipPresent` drops words the caption already contains, which is what the
* post-CoT re-injection wants: the LM often keeps the trigger it was trained on,
* and re-adding it would duplicate the token.
*/
export function applyTriggers(
caption: string,
specs: TriggerSpec[],
opts: { skipPresent?: boolean } = {},
): ApplyTriggersResult {
const base = caption || '';
if (!specs.length) return { caption: base, applied: [], skipped: [] };
const replace = specs.filter(s => s.placement === 'replace');
if (replace.length) {
const words = dedupe(replace.map(s => s.word));
return { caption: words.join(', '), applied: words, skipped: [] };
}
const skipped: string[] = [];
const keep = (s: TriggerSpec) => {
if (opts.skipPresent && base.includes(s.word)) { skipped.push(s.word); return false; }
return true;
};
const pre = dedupe(specs.filter(s => s.placement === 'prepend').filter(keep).map(s => s.word));
const post = dedupe(specs.filter(s => s.placement === 'append').filter(keep).map(s => s.word));
let out = base;
if (pre.length) out = out ? `${pre.join(', ')}, ${out}` : pre.join(', ');
if (post.length) out = out ? `${out}, ${post.join(', ')}` : post.join(', ');
return { caption: out, applied: [...pre, ...post], skipped };
}
function dedupe(words: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const w of words) {
if (w && !seen.has(w)) { seen.add(w); out.push(w); }
}
return out;
}
@@ -0,0 +1,397 @@
// vocalNaturalizer.ts — Vocal Naturalizer DSP engine
//
// Ports the 5-stage vocal naturalisation pipeline from jeankassio/ComfyUI_MusicTools
// (src/vocal_enhance.py → apply_vocal_naturalizer) to TypeScript.
//
// Attribution: Original algorithm by Jean Kassio (MIT License)
// https://github.com/jeankassio/ComfyUI_MusicTools
//
// All DSP is pure math — no native dependencies. Butterworth IIR filters are
// computed from coefficient formulas, applied via direct-form II transposed.
//
// Architecture note: DSP is applied directly to the full mix. The 5 stages
// are all frequency-band-targeted and primarily affect vocal content without
// needing stem separation. This avoids the destructive separate/remix cycle
// that degrades signal quality and raises the noise floor.
import fs from 'fs';
// ── Types ──────────────────────────────────────────────────────────────────
export interface NaturalizerParams {
amount: number; // 0.01.0 master intensity
vibratoRate: number; // 3.07.0 Hz
vibratoDepth: number; // 0.01.0 (relative to master)
formantStrength: number; // 0.01.0
metallicReduction: number; // 0.01.0
quantizationMask: number; // 0.01.0 — CAUTION: injects shaped noise, off by default
transitionSmooth: number; // 0.01.0
}
export const DEFAULT_NATURALIZER: NaturalizerParams = {
amount: 0.5,
vibratoRate: 4.5,
vibratoDepth: 1.0,
formantStrength: 1.0,
metallicReduction: 1.0,
quantizationMask: 0.0, // Off by default — injects 14kHz noise (audible hiss)
transitionSmooth: 1.0,
};
type LogFn = (level: 'INFO' | 'DEBUG' | 'WARNING' | 'ERROR', msg: string) => void;
type StageFn = (stage: string) => void;
// ── WAV Parsing ────────────────────────────────────────────────────────────
interface WavData {
sampleRate: number;
channels: number;
bitsPerSample: number;
/** Interleaved float samples — always normalised to [-1, 1] */
samples: Float32Array;
}
/** Parse a WAV buffer into float samples. Supports 16-bit, 24-bit, and 32-bit float PCM. */
function parseWav(buf: Buffer): WavData {
// RIFF header
if (buf.toString('ascii', 0, 4) !== 'RIFF' || buf.toString('ascii', 8, 12) !== 'WAVE') {
throw new Error('Not a valid WAV file');
}
let offset = 12;
let fmt: { audioFormat: number; channels: number; sampleRate: number; bitsPerSample: number } | null = null;
let dataStart = 0;
let dataSize = 0;
while (offset < buf.length - 8) {
const chunkId = buf.toString('ascii', offset, offset + 4);
const chunkSize = buf.readUInt32LE(offset + 4);
if (chunkId === 'fmt ') {
fmt = {
audioFormat: buf.readUInt16LE(offset + 8),
channels: buf.readUInt16LE(offset + 10),
sampleRate: buf.readUInt32LE(offset + 12),
bitsPerSample: buf.readUInt16LE(offset + 22),
};
} else if (chunkId === 'data') {
dataStart = offset + 8;
dataSize = chunkSize;
break;
}
offset += 8 + chunkSize + (chunkSize % 2); // pad to even
}
if (!fmt || dataStart === 0) throw new Error('Malformed WAV: missing fmt/data chunks');
const { channels, sampleRate, bitsPerSample, audioFormat } = fmt;
const bytesPerSample = bitsPerSample / 8;
const numSamples = Math.floor(dataSize / bytesPerSample);
const samples = new Float32Array(numSamples);
if (audioFormat === 3 && bitsPerSample === 32) {
// IEEE float
for (let i = 0; i < numSamples; i++) {
samples[i] = buf.readFloatLE(dataStart + i * 4);
}
} else if (audioFormat === 1 && bitsPerSample === 16) {
for (let i = 0; i < numSamples; i++) {
samples[i] = buf.readInt16LE(dataStart + i * 2) / 32768;
}
} else if (audioFormat === 1 && bitsPerSample === 24) {
for (let i = 0; i < numSamples; i++) {
const off = dataStart + i * 3;
const val = buf[off] | (buf[off + 1] << 8) | (buf[off + 2] << 16);
samples[i] = ((val & 0x800000) ? val - 0x1000000 : val) / 8388608;
}
} else {
throw new Error(`Unsupported WAV format: audioFormat=${audioFormat}, bits=${bitsPerSample}`);
}
return { sampleRate, channels, bitsPerSample, samples };
}
/** Write float samples back to a WAV buffer (32-bit float PCM). */
function writeWav(data: WavData): Buffer {
const { sampleRate, channels, samples } = data;
const bitsPerSample = 32;
const bytesPerSample = 4;
const dataSize = samples.length * bytesPerSample;
const buf = Buffer.alloc(44 + dataSize);
// RIFF header
buf.write('RIFF', 0);
buf.writeUInt32LE(36 + dataSize, 4);
buf.write('WAVE', 8);
// fmt chunk (IEEE float)
buf.write('fmt ', 12);
buf.writeUInt32LE(16, 16);
buf.writeUInt16LE(3, 20); // audioFormat = IEEE float
buf.writeUInt16LE(channels, 22);
buf.writeUInt32LE(sampleRate, 24);
buf.writeUInt32LE(sampleRate * channels * bytesPerSample, 28); // byteRate
buf.writeUInt16LE(channels * bytesPerSample, 32); // blockAlign
buf.writeUInt16LE(bitsPerSample, 34);
// data chunk
buf.write('data', 36);
buf.writeUInt32LE(dataSize, 40);
for (let i = 0; i < samples.length; i++) {
buf.writeFloatLE(samples[i], 44 + i * 4);
}
return buf;
}
// ── IIR Filter Design ──────────────────────────────────────────────────────
// Butterworth filter coefficient computation — replaces scipy.signal.butter
interface SosSection { b0: number; b1: number; b2: number; a0: number; a1: number; a2: number; }
/** Design a 2nd-order Butterworth lowpass filter (single SOS section). */
function butterLowpass2(cutoff: number, fs: number): SosSection {
const wc = Math.tan(Math.PI * cutoff / fs);
const wc2 = wc * wc;
const sqrt2 = Math.SQRT2;
const norm = 1 / (1 + sqrt2 * wc + wc2);
return {
b0: wc2 * norm, b1: 2 * wc2 * norm, b2: wc2 * norm,
a0: 1, a1: 2 * (wc2 - 1) * norm, a2: (1 - sqrt2 * wc + wc2) * norm,
};
}
/** Design a 2nd-order Butterworth bandpass filter (single SOS section). */
function butterBandpass2(low: number, high: number, fs: number): SosSection {
const wl = Math.tan(Math.PI * low / fs);
const wh = Math.tan(Math.PI * high / fs);
const bw = wh - wl;
const w0 = Math.sqrt(wl * wh);
const w02 = w0 * w0;
const Q = w0 / bw;
const alpha = Math.sin(2 * Math.atan(w0)) / (2 * Q);
// Bilinear-transformed bandpass
const cosW0 = (1 - w02) / (1 + w02);
const norm = 1 / (1 + alpha);
return {
b0: alpha * norm, b1: 0, b2: -alpha * norm,
a0: 1, a1: -2 * cosW0 * norm, a2: (1 - alpha) * norm,
};
}
/** Apply a single SOS section to an array (direct-form II transposed). */
function applySos(sos: SosSection, input: Float32Array): Float32Array {
const out = new Float32Array(input.length);
let z1 = 0, z2 = 0;
for (let i = 0; i < input.length; i++) {
const x = input[i];
const y = sos.b0 * x + z1;
z1 = sos.b1 * x - sos.a1 * y + z2;
z2 = sos.b2 * x - sos.a2 * y;
out[i] = y;
}
return out;
}
/** Cascade multiple SOS sections for higher-order filters. */
function applySosCascade(sections: SosSection[], input: Float32Array): Float32Array {
let signal = input;
for (const sos of sections) {
signal = applySos(sos, signal);
}
return signal;
}
// ── Seeded PRNG ────────────────────────────────────────────────────────────
// Simple xoshiro128** for deterministic noise generation
function xoshiro128ss(seed: number) {
let s0 = seed | 0 || 1;
let s1 = (seed * 1664525 + 1013904223) | 0;
let s2 = (s1 * 1664525 + 1013904223) | 0;
let s3 = (s2 * 1664525 + 1013904223) | 0;
return (): number => {
const t = s1 << 9;
let r = (s1 * 5) | 0;
r = ((r << 7) | (r >>> 25)) * 9;
s2 ^= s0; s3 ^= s1; s1 ^= s2; s0 ^= s3;
s2 ^= t; s3 = (s3 << 11) | (s3 >>> 21);
return (r >>> 0) / 4294967296; // [0, 1)
};
}
/** Generate Gaussian noise using Box-Muller transform with seeded PRNG. */
function gaussianNoise(length: number, seed: number): Float32Array {
const rng = xoshiro128ss(seed);
const out = new Float32Array(length);
for (let i = 0; i < length; i += 2) {
const u1 = rng() || 1e-10;
const u2 = rng();
const mag = Math.sqrt(-2 * Math.log(u1));
out[i] = mag * Math.cos(2 * Math.PI * u2);
if (i + 1 < length) out[i + 1] = mag * Math.sin(2 * Math.PI * u2);
}
return out;
}
// ── 5-Stage Naturalizer Pipeline ───────────────────────────────────────────
/** Apply the 5-stage vocal naturalisation to a mono signal. */
function naturalizeChannel(
audio: Float32Array,
sampleRate: number,
params: NaturalizerParams,
seed: number,
): Float32Array {
const { amount } = params;
if (amount < 0.01) return audio;
const result = new Float32Array(audio);
const N = audio.length;
// Stage 1: Pitch Variation (vibrato-like AM modulation)
const vDepth = params.vibratoDepth;
if (vDepth > 0.01) {
const vibRate = params.vibratoRate;
const depth = 0.002 * amount * vDepth;
for (let i = 0; i < N; i++) {
const t = i / sampleRate;
const pitchVar = Math.sin(2 * Math.PI * vibRate * t) * depth;
// Phase modulation approximation via AM
const phaseMod = pitchVar * 2 * Math.PI;
const modulated = audio[i] * (1 + Math.sin(phaseMod) * 0.01 * amount * vDepth);
result[i] = result[i] * 0.7 + modulated * 0.3;
}
}
// Stage 2: Formant Variation
const fStr = params.formantStrength;
if (fStr > 0.01) {
const noise = gaussianNoise(N, seed);
const formantVariation = new Float32Array(N);
for (let i = 0; i < N; i++) formantVariation[i] = noise[i] * 0.005 * amount * fStr;
const formantSos = butterBandpass2(200, 3000, sampleRate);
const formantSignal = applySos(formantSos, audio);
for (let i = 0; i < N; i++) {
result[i] += formantSignal[i] * (1 + formantVariation[i]) * 0.15 * amount * fStr
- formantSignal[i] * 0.15 * amount * fStr; // net: add modulated delta only
}
}
// Stage 3: Metallic Artifact Removal (610 kHz)
const mRed = params.metallicReduction;
if (mRed > 0.01 && sampleRate > 12000) {
const metallicSos = butterBandpass2(6000, Math.min(10000, sampleRate * 0.45), sampleRate);
const metallic = applySos(metallicSos, audio);
for (let i = 0; i < N; i++) {
result[i] -= metallic[i] * 0.3 * amount * mRed;
}
}
// Stage 4: Quantization Masking (shaped noise 14 kHz)
// WARNING: This stage injects noise. Off by default (quantizationMask = 0).
// Only enable if you specifically want dither-like masking of quantization
// artifacts and accept the trade-off of a slightly raised noise floor.
const qMask = params.quantizationMask;
if (qMask > 0.01) {
const rawNoise = gaussianNoise(N, seed + 42);
for (let i = 0; i < N; i++) rawNoise[i] *= 0.002 * amount * qMask;
const noiseSos = butterBandpass2(1000, 4000, sampleRate);
const shapedNoise = applySos(noiseSos, rawNoise);
for (let i = 0; i < N; i++) result[i] += shapedNoise[i];
}
// Stage 5: Transition Smoothing (low-pass filtered differential)
const tSmooth = params.transitionSmooth;
if (tSmooth > 0.01) {
// Compute differential
const diff = new Float32Array(N);
diff[0] = 0;
for (let i = 1; i < N; i++) diff[i] = result[i] - result[i - 1];
// Low-pass at 80 Hz (slightly less aggressive than original's 50 Hz)
const smoothSos = butterLowpass2(80, sampleRate);
const smoothedDiff = applySos(smoothSos, diff);
const blend = 0.4 * amount * tSmooth;
for (let i = 0; i < N; i++) {
result[i] = result[i] - diff[i] * blend + smoothedDiff[i] * blend;
}
}
// Normalise to prevent clipping
let maxVal = 0;
for (let i = 0; i < N; i++) {
const abs = Math.abs(result[i]);
if (abs > maxVal) maxVal = abs;
}
if (maxVal > 0.95) {
const scale = 0.95 / maxVal;
for (let i = 0; i < N; i++) result[i] *= scale;
}
return result;
}
/** Apply naturaliser to all channels of a WAV. */
function naturalizeWav(wav: WavData, params: NaturalizerParams, seed: number): WavData {
const { channels, sampleRate, samples } = wav;
const framesPerChannel = Math.floor(samples.length / channels);
const result = new Float32Array(samples.length);
for (let ch = 0; ch < channels; ch++) {
// De-interleave
const mono = new Float32Array(framesPerChannel);
for (let i = 0; i < framesPerChannel; i++) mono[i] = samples[i * channels + ch];
// Process
const processed = naturalizeChannel(mono, sampleRate, params, seed + ch);
// Re-interleave
for (let i = 0; i < framesPerChannel; i++) result[i * channels + ch] = processed[i];
}
return { ...wav, samples: result, bitsPerSample: 32 };
}
// ── Public API ─────────────────────────────────────────────────────────────
/**
* Run the vocal naturaliser pipeline directly on the full mix.
*
* DSP stages are frequency-band-targeted and primarily affect vocal content
* without needing stem separation. This preserves signal integrity, avoids
* phase smearing, and keeps the full dynamic range intact for downstream
* processing (VST chains, mastering).
*/
export async function runVocalNaturalizer(
processedPath: string,
params: NaturalizerParams,
log: LogFn,
setStage: StageFn,
trackIndex?: number,
totalTracks?: number,
): Promise<boolean> {
const suffix = (totalTracks && totalTracks > 1 && trackIndex !== undefined)
? ` (${trackIndex + 1}/${totalTracks})`
: '';
setStage(`Vocal Naturalizer: processing${suffix}...`);
try {
// Read the WAV directly — no separation step
const wavBuf = fs.readFileSync(processedPath);
const wav = parseWav(wavBuf);
const seed = Date.now();
const processed = naturalizeWav(wav, params, seed);
// Write back — same sample rate, same channels, no resampling needed
fs.writeFileSync(processedPath, writeWav(processed));
log('INFO', `[Vocal Naturalizer] Applied to full mix (${wav.sampleRate}Hz, ${wav.channels}ch)`);
return true;
} catch (err: any) {
log('WARNING', `[Vocal Naturalizer] Failed (non-fatal): ${err.message}`);
return false;
}
}
+153
View File
@@ -0,0 +1,153 @@
// latentFormat.ts — HSLAT (HOT-Step LATent) file format reader/writer
//
// HSLAT wraps raw float32 post-DiT latent data with a JSON metadata header:
//
// ┌────────────────────────────────────────────┐
// │ Magic: "HSLAT\x01" (6 bytes) │
// │ JSON length: uint32_le (4 bytes) │
// │ JSON metadata: UTF-8 string (variable) │
// │ Padding: zeros to 256-byte boundary │
// │ Latent data: float32[T × 64] (T×256 bytes) │
// └────────────────────────────────────────────┘
//
// The C++ engine only sees raw float32 — all header work happens here.
const HSLAT_MAGIC = Buffer.from('HSLAT\x01', 'ascii'); // 6 bytes
const ALIGNMENT = 256; // latent data starts at a 256-byte boundary
/** Metadata embedded in an HSLAT file. All fields optional. */
export interface HslatMetadata {
// Musical properties
duration?: number; // seconds
bpm?: number;
key_scale?: string; // e.g. "C major"
time_signature?: string; // e.g. "4/4"
// Content
caption?: string;
lyrics?: string;
// Generation params
seed?: number;
inference_steps?: number;
guidance_scale?: number;
shift?: number;
task_type?: string;
// Model info
adapter?: string;
adapter_scale?: number;
dit_model?: string;
vae_model?: string;
emb_model?: string;
// Meta
created_at?: string; // ISO 8601 timestamp
[key: string]: unknown; // extensible
}
/** Result of reading an HSLAT file. */
export interface HslatFile {
metadata: HslatMetadata;
rawLatent: Buffer;
}
/** Check if a buffer starts with the HSLAT magic bytes. */
export function isHslat(buf: Buffer): boolean {
if (buf.length < HSLAT_MAGIC.length) return false;
return buf.subarray(0, HSLAT_MAGIC.length).equals(HSLAT_MAGIC);
}
/**
* Write an HSLAT file from raw latent bytes and metadata.
* Returns a Buffer containing the complete HSLAT file.
*/
export function writeHslat(rawLatent: Buffer, metadata: HslatMetadata): Buffer {
const jsonStr = JSON.stringify(metadata);
const jsonBuf = Buffer.from(jsonStr, 'utf-8');
// Header: magic (6) + json_len (4) + json (variable)
const headerLen = HSLAT_MAGIC.length + 4 + jsonBuf.length;
// Pad to alignment boundary
const paddedHeaderLen = Math.ceil(headerLen / ALIGNMENT) * ALIGNMENT;
const paddingLen = paddedHeaderLen - headerLen;
const totalLen = paddedHeaderLen + rawLatent.length;
const out = Buffer.alloc(totalLen, 0); // zeros for padding
// Write magic
HSLAT_MAGIC.copy(out, 0);
// Write JSON length (uint32_le)
out.writeUInt32LE(jsonBuf.length, HSLAT_MAGIC.length);
// Write JSON
jsonBuf.copy(out, HSLAT_MAGIC.length + 4);
// Padding is already zeros from Buffer.alloc
// Write raw latent data
rawLatent.copy(out, paddedHeaderLen);
return out;
}
/**
* Read an HSLAT file. Handles both HSLAT-wrapped and raw float32 files.
*
* If the file doesn't start with HSLAT magic, it's treated as raw float32
* (upstream-compatible) with empty metadata.
*/
export function readHslat(buf: Buffer): HslatFile {
// Backward compat: if no magic, treat entire buffer as raw latent
if (!isHslat(buf)) {
return { metadata: {}, rawLatent: buf };
}
if (buf.length < HSLAT_MAGIC.length + 4) {
throw new Error('HSLAT file too short: missing header');
}
// Read JSON length
const jsonLen = buf.readUInt32LE(HSLAT_MAGIC.length);
const headerLen = HSLAT_MAGIC.length + 4 + jsonLen;
if (buf.length < headerLen) {
throw new Error(`HSLAT file truncated: expected ${headerLen} header bytes, got ${buf.length}`);
}
// Parse JSON metadata
const jsonBuf = buf.subarray(HSLAT_MAGIC.length + 4, HSLAT_MAGIC.length + 4 + jsonLen);
let metadata: HslatMetadata;
try {
metadata = JSON.parse(jsonBuf.toString('utf-8'));
} catch {
throw new Error('HSLAT file: invalid JSON metadata');
}
// Compute padded header length
const paddedHeaderLen = Math.ceil(headerLen / ALIGNMENT) * ALIGNMENT;
// Extract raw latent data
const rawLatent = buf.subarray(paddedHeaderLen);
// Validate: latent data must be a multiple of 256 bytes (64 floats × 4 bytes)
if (rawLatent.length > 0 && rawLatent.length % 256 !== 0) {
throw new Error(
`HSLAT file: latent data size ${rawLatent.length} is not a multiple of 256 bytes (64 × float32)`
);
}
return { metadata, rawLatent };
}
/**
* Get the number of latent frames from a raw latent buffer.
* Each frame is 64 × float32 = 256 bytes.
*/
export function latentFrameCount(rawLatent: Buffer): number {
return Math.floor(rawLatent.length / 256);
}
/**
* Get the duration in seconds from a raw latent buffer (25 Hz latent rate).
*/
export function latentDuration(rawLatent: Buffer): number {
return latentFrameCount(rawLatent) / 25;
}
@@ -0,0 +1,85 @@
// exportService.ts — Export lyric generations to JSON + TXT files
//
// Port of Python export_service.py
import fs from 'fs';
import path from 'path';
import { config } from '../../config.js';
export interface ExportData {
title: string;
lyrics: string;
artistName: string;
albumName?: string;
provider: string;
model: string;
bpm?: number;
key?: string;
caption?: string;
duration?: number;
subject?: string;
extraInstructions?: string;
createdAt?: string;
}
/**
* Export a generation to both JSON and TXT files.
* Returns the paths of the exported files.
*/
export function exportGeneration(data: ExportData): { jsonPath: string; txtPath: string } {
const exportDir = config.lireek.exportDir;
fs.mkdirSync(exportDir, { recursive: true });
// Build safe filename: "Artist - Title" or "Artist - Album - Title"
const safeName = (s: string) => s.replace(/[<>:"/\\|?*]/g, '_').trim();
const parts = [safeName(data.artistName)];
if (data.albumName) parts.push(safeName(data.albumName));
parts.push(safeName(data.title || 'Untitled'));
const baseName = parts.join(' - ');
// Deduplicate: if file exists, append (2), (3), etc.
let finalBase = baseName;
let counter = 1;
while (fs.existsSync(path.join(exportDir, `${finalBase}.json`))) {
counter++;
finalBase = `${baseName} (${counter})`;
}
const jsonPath = path.join(exportDir, `${finalBase}.json`);
const txtPath = path.join(exportDir, `${finalBase}.txt`);
// JSON export (full metadata)
const jsonData = {
title: data.title,
artist: data.artistName,
album: data.albumName ?? null,
lyrics: data.lyrics,
provider: data.provider,
model: data.model,
bpm: data.bpm ?? null,
key: data.key ?? null,
caption: data.caption ?? null,
duration: data.duration ?? null,
subject: data.subject ?? null,
extra_instructions: data.extraInstructions ?? null,
exported_at: new Date().toISOString(),
created_at: data.createdAt ?? null,
};
fs.writeFileSync(jsonPath, JSON.stringify(jsonData, null, 2), 'utf-8');
// TXT export (human-readable)
const txtLines = [
`Title: ${data.title || 'Untitled'}`,
`Artist: ${data.artistName}`,
];
if (data.albumName) txtLines.push(`Album Style: ${data.albumName}`);
if (data.bpm) txtLines.push(`BPM: ${data.bpm}`);
if (data.key) txtLines.push(`Key: ${data.key}`);
if (data.caption) txtLines.push(`Caption: ${data.caption}`);
if (data.duration) txtLines.push(`Duration: ${data.duration}s`);
txtLines.push('', '---', '', data.lyrics);
fs.writeFileSync(txtPath, txtLines.join('\n'), 'utf-8');
console.log(`[Export] Saved ${jsonPath}`);
return { jsonPath, txtPath };
}
+476
View File
@@ -0,0 +1,476 @@
// geniusService.ts — Lyrics acquisition via the Genius API
//
// Port of Python genius_service.py.
// Uses fetch() for the Genius REST API and cheerio for HTML parsing.
import * as cheerio from 'cheerio';
import { config } from '../../config.js';
const API_ROOT = 'https://api.genius.com';
const BROWSER_HEADERS: Record<string, string> = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en-US,en;q=0.9',
};
export interface SongLyrics {
title: string;
album: string | null;
lyrics: string;
}
export interface LyricsSearchResponse {
artist: string;
album: string | null;
songs: SongLyrics[];
total_songs: number;
}
// ── Helpers ─────────────────────────────────────────────────────────────────
function getAuthHeaders(): Record<string, string> {
const token = config.lireek.geniusAccessToken;
if (!token) {
throw new Error('GENIUS_ACCESS_TOKEN is not set. Please add it to your .env file.');
}
return { Authorization: `Bearer ${token}` };
}
function cleanLyrics(raw: string): string {
if (!raw) return '';
// Remove contributor/title header (captures up to first section header)
let text = raw.replace(/^\d+\s*Contributors?.*?Lyrics.*?(?=\[)/is, '');
// Simpler header strip if no section header found
text = text.replace(/^\d+\s*Contributors?.*?Lyrics\s*\n?/is, '');
// Remove 'You might also like'
text = text.replace(/You might also like\s*/g, '');
// Remove trailing 'Embed'
text = text.replace(/\d*Embed$/, '').trim();
// Collapse 3+ consecutive blank lines into 2
text = text.replace(/\n{3,}/g, '\n\n');
return text.trim();
}
async function scrapeLyrics(songUrl: string): Promise<string | null> {
try {
const resp = await fetch(songUrl, {
headers: BROWSER_HEADERS,
redirect: 'follow',
});
if (!resp.ok) {
console.warn(`[Genius] Failed to scrape ${songUrl}: ${resp.status}`);
return null;
}
const html = await resp.text();
const $ = cheerio.load(html.replace(/<br\/?>/gi, '\n'));
// Modern Genius layout: data-lyrics-container divs
const containers = $('div[data-lyrics-container="true"]');
if (containers.length) {
const parts: string[] = [];
containers.each((_, el) => { parts.push($(el).text()); });
return parts.join('\n');
}
// Fallback: root lyrics div
const root = $('div.lyrics, div[class*="Lyrics__Root"]');
if (root.length) return root.text();
console.warn(`[Genius] No lyrics div found on: ${songUrl}`);
return null;
} catch (err) {
console.warn(`[Genius] Scrape error for ${songUrl}:`, err);
return null;
}
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
// ── Authenticated Genius API calls ──────────────────────────────────────────
async function apiSearch(query: string, perPage = 20): Promise<any[]> {
const headers = getAuthHeaders();
const url = `${API_ROOT}/search?q=${encodeURIComponent(query)}&per_page=${perPage}`;
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`Genius search failed: ${resp.status}`);
const data = await resp.json() as any;
return data.response.hits;
}
async function apiGetArtistSongs(artistId: number, perPage = 20, sort = 'popularity'): Promise<any[]> {
const headers = getAuthHeaders();
const url = `${API_ROOT}/artists/${artistId}/songs?per_page=${perPage}&page=1&sort=${sort}`;
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`Genius artist songs failed: ${resp.status}`);
const data = await resp.json() as any;
return data.response.songs;
}
async function apiGetAlbumTracks(albumId: number): Promise<any[]> {
const headers = getAuthHeaders();
const tracks: any[] = [];
let page: number | null = 1;
while (page) {
const url = `${API_ROOT}/albums/${albumId}/tracks?per_page=50&page=${page}`;
const resp = await fetch(url, { headers });
if (!resp.ok) throw new Error(`Genius album tracks failed: ${resp.status}`);
const data = await resp.json() as any;
tracks.push(...data.response.tracks);
page = data.response.next_page ?? null;
}
return tracks;
}
async function apiGetArtistDetails(artistId: number): Promise<any> {
const headers = getAuthHeaders();
const resp = await fetch(`${API_ROOT}/artists/${artistId}`, { headers });
if (!resp.ok) throw new Error(`Genius artist details failed: ${resp.status}`);
const data = await resp.json() as any;
return data.response.artist;
}
async function getArtistIdFromUrl(url: string): Promise<number | null> {
try {
const resp = await fetch(url, { headers: BROWSER_HEADERS, redirect: 'follow' });
if (!resp.ok) return null;
const html = await resp.text();
const match = html.match(/\\?"artist_id\\?":\s*(\d+)/) ?? html.match(/content="genius:\/\/artists\/(\d+)"/);
return match ? parseInt(match[1], 10) : null;
} catch { return null; }
}
async function findArtistId(artistName: string): Promise<number | null> {
const hits = await apiSearch(artistName, 5);
const nameLower = artistName.toLowerCase();
for (const hit of hits) {
const primary = hit.result?.primary_artist;
if (primary?.name?.toLowerCase() === nameLower) return primary.id;
}
return hits[0]?.result?.primary_artist?.id ?? null;
}
// Patterns indicating bonus/non-original tracks
const BONUS_PATTERN = /\([^)]*(?:Demo|Live|Outtake|Cassette|Remix|Acoustic|Remaster|Session)[^)]*\)/i;
function isBonusTrack(title: string): boolean {
return BONUS_PATTERN.test(title);
}
function slugifyForGenius(name: string): string {
return name.replace(/[^\w\s-]/g, '').trim().replace(/\s+/g, '-');
}
async function findAlbumIdByPage(albumName: string, artistName: string): Promise<number | null> {
const slug = `${slugifyForGenius(artistName)}/${slugifyForGenius(albumName)}`;
const url = `https://genius.com/albums/${slug}`;
const artistLower = artistName.toLowerCase();
try {
const resp = await fetch(url, { headers: BROWSER_HEADERS, redirect: 'follow' });
if (!resp.ok) return null;
const html = await resp.text();
const $ = cheerio.load(html);
const links = $('div.chart_row a.u-display_block').toArray();
if (!links.length) return null;
for (const link of links.slice(0, 3)) {
const h3 = $(link).find('h3');
let titleText = (h3.length ? h3.text() : $(link).text()).trim();
titleText = titleText.replace(/\s*Lyrics$/, '').trim();
if (!titleText) continue;
const hits = await apiSearch(`${titleText} ${artistName}`, 5);
for (const hit of hits) {
const result = hit.result ?? {};
const songId = result.id;
if (!songId) continue;
if (result.primary_artist?.name?.toLowerCase() !== artistLower) continue;
const headers = getAuthHeaders();
const sResp = await fetch(`${API_ROOT}/songs/${songId}`, { headers });
if (!sResp.ok) continue;
const sData = await sResp.json() as any;
const album = sData.response.song.album;
if (album && albumName.toLowerCase().includes(album.name?.toLowerCase())) {
return album.id;
}
}
}
} catch {}
return null;
}
async function findAlbumId(albumName: string, artistName: string): Promise<number | null> {
const headers = getAuthHeaders();
const query = `${albumName} ${artistName}`;
const hits = await apiSearch(query, 10);
const albumLower = albumName.toLowerCase();
const artistLower = artistName.toLowerCase();
// Sort: artist matches first
const sorted = [...hits].sort((a, b) => {
const aMatch = a.result?.primary_artist?.name?.toLowerCase() === artistLower ? 1 : 0;
const bMatch = b.result?.primary_artist?.name?.toLowerCase() === artistLower ? 1 : 0;
return bMatch - aMatch;
});
const candidates: { id: number; name: string }[] = [];
const seenIds = new Set<number>();
for (const hit of sorted) {
const songId = hit.result?.id;
if (!songId || candidates.length >= 3) break;
try {
const resp = await fetch(`${API_ROOT}/songs/${songId}`, { headers });
if (!resp.ok) continue;
const data = await resp.json() as any;
const album = data.response.song.album;
if (album && album.name?.toLowerCase().includes(albumLower)) {
if (!seenIds.has(album.id)) {
seenIds.add(album.id);
candidates.push({ id: album.id, name: album.name });
}
}
} catch {}
}
if (candidates.length) {
// Prefer exact name match, then shortest name (less likely deluxe/expanded)
const exact = candidates.find(c => c.name.toLowerCase() === albumLower);
if (exact) return exact.id;
candidates.sort((a, b) => a.name.length - b.name.length);
return candidates[0].id;
}
// Fallback: direct page scrape
return findAlbumIdByPage(albumName, artistName);
}
async function scrapeAlbumPageTracks(
albumName = '', artistName = '', url?: string,
): Promise<{ title: string; url: string }[]> {
if (!url) {
const slug = `${slugifyForGenius(artistName)}/${slugifyForGenius(albumName)}`;
url = `https://genius.com/albums/${slug}`;
}
try {
const resp = await fetch(url, { headers: BROWSER_HEADERS, redirect: 'follow' });
if (!resp.ok) return [];
const html = await resp.text();
const $ = cheerio.load(html);
const tracks: { title: string; url: string }[] = [];
$('div.chart_row a.u-display_block').each((_, el) => {
let href = $(el).attr('href') ?? '';
if (!href) return;
if (!href.startsWith('http')) href = `https://genius.com${href}`;
const h3 = $(el).find('h3');
let title = (h3.length ? h3.text() : $(el).text()).trim();
title = title.replace(/\s*Lyrics$/, '').trim();
if (title) tracks.push({ title, url: href });
});
return tracks;
} catch { return []; }
}
// ── Public API ──────────────────────────────────────────────────────────────
export async function fetchLyrics(
artistName: string,
albumName?: string | null,
maxSongs = 10,
): Promise<LyricsSearchResponse> {
const scrapeDelay = 300;
const songs: SongLyrics[] = [];
let artistId: number | null = null;
// Handle Genius artist URL input
if (artistName.includes('genius.com/artists/')) {
const urlMatch = artistName.match(/(https?:\/\/(?:www\.)?genius\.com\/artists\/[^\s]+)/);
const url = urlMatch ? urlMatch[1] : artistName.trim();
artistId = await getArtistIdFromUrl(url);
if (artistId) {
try {
const details = await apiGetArtistDetails(artistId);
if (details.name) artistName = details.name;
} catch {}
}
}
// Handle album URL input
let albumUrl: string | undefined;
if (albumName && albumName.includes('genius.com/albums/')) {
const urlMatch = albumName.match(/(https?:\/\/(?:www\.)?genius\.com\/albums\/[^\s]+)/);
albumUrl = urlMatch ? urlMatch[1] : albumName.trim();
const parts = albumUrl.replace(/\/$/, '').split('/');
if (parts.length >= 2) {
albumName = parts[parts.length - 1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
if (!artistId) {
const artistSlug = parts[parts.length - 2];
artistName = artistSlug.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
}
}
}
if (albumName) {
console.log(`[Genius] Fetching album '${albumName}' by '${artistName}'`);
let albumIdResolved: number | null = null;
if (!albumUrl) {
albumIdResolved = await findAlbumId(albumName, artistName);
}
if (!albumUrl && albumIdResolved != null) {
// API-based album track fetch
const tracks = await apiGetAlbumTracks(albumIdResolved);
const seenTitles = new Set<string>();
for (const track of tracks) {
const songInfo = track.song ?? {};
const url = songInfo.url;
const title = songInfo.title ?? 'Unknown';
if (!url || songInfo.lyrics_state !== 'complete' || songInfo.instrumental) continue;
if (isBonusTrack(title)) continue;
const baseTitle = title.replace(/\s*\(.*\)/, '').trim().toLowerCase();
if (seenTitles.has(baseTitle)) continue;
seenTitles.add(baseTitle);
try {
await sleep(scrapeDelay);
const raw = await scrapeLyrics(url);
if (raw) songs.push({ title, album: albumName, lyrics: cleanLyrics(raw) });
} catch (err) {
console.warn(`[Genius] Failed to scrape '${title}':`, err);
}
}
} else {
// Fallback: scrape tracks from album page
const pageTracks = await scrapeAlbumPageTracks(albumName, artistName, albumUrl);
for (const pt of pageTracks) {
if (isBonusTrack(pt.title)) continue;
try {
await sleep(scrapeDelay);
const raw = await scrapeLyrics(pt.url);
if (raw) songs.push({ title: pt.title, album: albumName, lyrics: cleanLyrics(raw) });
} catch (err) {
console.warn(`[Genius] Failed to scrape '${pt.title}':`, err);
}
}
}
} else {
// General artist search (no specific album)
console.log(`[Genius] Fetching up to ${maxSongs} songs by '${artistName}'`);
if (!artistId) artistId = await findArtistId(artistName);
if (!artistId) {
throw new Error(`Could not find artist '${artistName}' on Genius.`);
}
const apiSongs = await apiGetArtistSongs(artistId, maxSongs);
for (const songInfo of apiSongs.slice(0, maxSongs)) {
const url = songInfo.url;
const title = songInfo.title ?? 'Unknown';
const songAlbum = songInfo.album?.name ?? null;
if (!url) continue;
try {
await sleep(scrapeDelay);
const raw = await scrapeLyrics(url);
if (raw) songs.push({ title, album: songAlbum, lyrics: cleanLyrics(raw) });
} catch (err) {
console.warn(`[Genius] Failed to scrape '${title}':`, err);
}
}
}
if (!songs.length) {
throw new Error(
`No lyrics found for '${artistName}'` +
(albumName ? ` album '${albumName}'` : '') +
'. Please check the spelling and try again.',
);
}
return { artist: artistName, album: albumName ?? null, songs, total_songs: songs.length };
}
/**
* Search for a single song's lyrics on Genius.
*/
/** Lowercase, strip parentheticals/brackets and punctuation, collapse spaces. */
function normalizeTitle(s: string): string {
return s.toLowerCase()
.replace(/\([^)]*\)|\[[^\]]*\]/g, ' ')
.replace(/[^\p{L}\p{N}\s]/gu, ' ')
.replace(/\s+/g, ' ')
.trim();
}
export async function searchSongLyrics(
artist: string, title: string,
opts?: { relaxed?: boolean },
): Promise<{ title: string; lyrics: string; url: string } | null> {
const hits = await apiSearch(`${title} ${artist}`, 5);
const artistLower = artist.toLowerCase().trim();
const titleNorm = normalizeTitle(title);
for (const hit of hits) {
const result = hit.result ?? {};
const primaryLower = (result.primary_artist?.name ?? '').toLowerCase().trim();
if (opts?.relaxed) {
// Collabs and feats break exact matching ("Electric Callboy & BABYMETAL",
// "Artist feat. X") — accept containment either way, but then require the
// titles to agree so a popular unrelated song can't slip in.
const artistOk = !!primaryLower
&& (primaryLower.includes(artistLower) || artistLower.includes(primaryLower));
if (!artistOk) continue;
const resultNorm = normalizeTitle(result.title ?? '');
const titleOk = !!titleNorm && !!resultNorm
&& (resultNorm.includes(titleNorm) || titleNorm.includes(resultNorm));
if (!titleOk) continue;
} else if (primaryLower !== artistLower) {
continue;
}
const songUrl = result.url;
if (!songUrl) continue;
const raw = await scrapeLyrics(songUrl);
if (raw) {
return { title: result.title ?? title, lyrics: cleanLyrics(raw), url: songUrl };
}
}
return null;
}
/**
* Refresh an artist's image URL from Genius.
* Returns the image URL or null if not found.
*/
export async function getArtistImageUrl(artistName: string): Promise<string | null> {
try {
const id = await findArtistId(artistName);
if (!id) return null;
const details = await apiGetArtistDetails(id);
return details.image_url ?? null;
} catch { return null; }
}
/**
* Get an album cover image URL from Genius.
*/
export async function getAlbumImageUrl(albumName: string, artistName: string): Promise<string | null> {
try {
const albumId = await findAlbumId(albumName, artistName);
if (!albumId) return null;
const headers = getAuthHeaders();
const resp = await fetch(`${API_ROOT}/albums/${albumId}`, { headers });
if (!resp.ok) return null;
const data = await resp.json() as any;
return data.response.album?.cover_art_url ?? null;
} catch { return null; }
}
@@ -0,0 +1,50 @@
// llm/anthropic.ts — Anthropic / Claude provider
import { config } from '../../../config.js';
import { LLMProvider, readSSE } from './base.js';
import type { ChunkCallback } from './types.js';
export class AnthropicProvider extends LLMProvider {
id = 'anthropic';
name = 'Anthropic / Claude';
get defaultModel() { return config.lireek.anthropicModel; }
availableModels = ['claude-3-5-sonnet-20241022', 'claude-3-5-haiku-20241022', 'claude-3-opus-20240229'];
isAvailable() { return !!config.lireek.anthropicApiKey; }
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback): Promise<string> {
const url = 'https://api.anthropic.com/v1/messages';
const payload = {
model: model || this.defaultModel,
max_tokens: 4096,
system: systemPrompt,
messages: [{ role: 'user', content: userPrompt }],
stream: !!onChunk,
};
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': config.lireek.anthropicApiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`Anthropic error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
let fullText = '';
await readSSE(resp, (text) => { fullText += text; onChunk(text); }, (data) => {
if (data.type === 'content_block_delta' && data.delta?.text) return data.delta.text;
return null;
});
return fullText;
} else {
const data = await resp.json();
return data.content?.[0]?.text || '';
}
}
}
+117
View File
@@ -0,0 +1,117 @@
// llm/base.ts — LLMProvider abstract base class + SSE streaming helper
import type { ProviderInfo, ChunkCallback, CallOptions } from './types.js';
import { skipThinkingSignal } from './types.js';
export abstract class LLMProvider {
abstract id: string;
abstract name: string;
abstract defaultModel: string;
availableModels: string[] = [];
abstract isAvailable(): boolean;
abstract call(
systemPrompt: string,
userPrompt: string,
model?: string,
onChunk?: ChunkCallback,
options?: CallOptions
): Promise<string>;
toInfo(): ProviderInfo {
return {
id: this.id,
name: this.name,
available: this.isAvailable(),
models: this.availableModels.length ? this.availableModels : (this.defaultModel ? [this.defaultModel] : []),
default_model: this.defaultModel,
};
}
}
// Qwen3-family soft switch: a bare `/no_think` in the system prompt makes the
// chat template skip the thinking block. Plain text to every other model, so
// it is safe to send unconditionally when CallOptions.noThink is set.
export function noThinkSystemPrompt(systemPrompt: string): string {
return `${systemPrompt}\n\n/no_think`;
}
export async function readSSE(
response: Response,
onChunk: ChunkCallback,
extractText: (data: any) => string | null,
extractDisplayOnly?: (data: any) => string | null
): Promise<string> {
if (!response.body) return '';
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
let fullText = '';
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // Keep the last incomplete line in buffer
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6).trim();
if (dataStr === '[DONE]') {
reader.cancel();
return fullText;
}
try {
const parsed = JSON.parse(dataStr);
// Break on finish_reason (OpenAI-compatible sentinel)
if (parsed.choices?.[0]?.finish_reason) {
const lastText = extractText(parsed);
if (lastText) {
fullText += lastText;
onChunk(lastText);
}
continue;
}
// Display-only content (e.g. reasoning/thinking) — stream to UI but don't keep
if (extractDisplayOnly) {
const displayText = extractDisplayOnly(parsed);
if (displayText) {
onChunk(displayText);
// Track for skip-thinking detection but don't add to returned text
if (skipThinkingSignal) {
const thinkCheck = fullText + displayText;
if (thinkCheck.includes('<think>') && !thinkCheck.includes('</think>')) {
reader.cancel();
return fullText;
}
}
continue;
}
}
const text = extractText(parsed);
if (text) {
fullText += text;
onChunk(text);
if (skipThinkingSignal && fullText.includes('<think>') && !fullText.includes('</think>')) {
reader.cancel();
return fullText;
}
}
} catch (e) {}
}
}
}
} finally {
reader.releaseLock();
}
return fullText;
}
+115
View File
@@ -0,0 +1,115 @@
// llm/gemini.ts — Google Gemini provider
import { config } from '../../../config.js';
import { LLMProvider, readSSE } from './base.js';
import type { ProviderInfo, ChunkCallback } from './types.js';
export class GeminiProvider extends LLMProvider {
id = 'gemini';
name = 'Google Gemini';
get defaultModel() { return config.lireek.geminiModel; }
availableModels = ['gemini-2.5-flash']; // initial fallback, replaced by API fetch
/** Cache fetched models so we don't hit the API on every listProviders() call */
private _cachedModels: string[] | null = null;
private _cacheExpiry = 0;
private static CACHE_TTL = 5 * 60 * 1000; // 5 minutes
isAvailable() { return !!config.lireek.geminiApiKey; }
/** Fetch available models from the Gemini API, filtered to those that support generateContent */
private async getRemoteModels(): Promise<string[]> {
const now = Date.now();
if (this._cachedModels && now < this._cacheExpiry) return this._cachedModels;
try {
const allModels: Array<{ name: string; supportedGenerationMethods?: string[] }> = [];
let pageToken: string | undefined;
// Paginate through all models
do {
const url = new URL('https://generativelanguage.googleapis.com/v1beta/models');
url.searchParams.set('key', config.lireek.geminiApiKey);
url.searchParams.set('pageSize', '100');
if (pageToken) url.searchParams.set('pageToken', pageToken);
const resp = await fetch(url.toString(), { signal: AbortSignal.timeout(8000) });
if (!resp.ok) {
console.warn(`[Gemini] models.list failed: ${resp.status}`);
break;
}
const data = await resp.json();
if (Array.isArray(data.models)) allModels.push(...data.models);
pageToken = data.nextPageToken;
} while (pageToken);
if (allModels.length === 0) return this.availableModels;
// Filter to models that support generateContent (excludes embedding, AQA, etc.)
const generative = allModels
.filter(m => m.supportedGenerationMethods?.includes('generateContent'))
.map(m => m.name.replace(/^models\//, ''))
// Exclude tuning, embedding, and legacy models cluttering the list
.filter(name => !name.includes('embedding') && !name.includes('aqa') && !name.includes('tunedModels'));
if (generative.length === 0) return this.availableModels;
// Sort: prefer 2.5 > 2.0 > 1.5, flash before pro, shorter names first (base > dated variants)
generative.sort((a, b) => {
// Extract version number for primary sort
const verA = parseFloat(a.match(/(\d+\.\d+)/)?.[1] || '0');
const verB = parseFloat(b.match(/(\d+\.\d+)/)?.[1] || '0');
if (verB !== verA) return verB - verA;
// Same version: flash before pro
const isFlashA = a.includes('flash') ? 0 : 1;
const isFlashB = b.includes('flash') ? 0 : 1;
if (isFlashA !== isFlashB) return isFlashA - isFlashB;
// Same tier: shorter names (base model) before dated variants
return a.length - b.length;
});
this._cachedModels = generative;
this._cacheExpiry = now + GeminiProvider.CACHE_TTL;
this.availableModels = generative;
return generative;
} catch (err: any) {
console.warn(`[Gemini] Failed to fetch models: ${err.message}`);
return this.availableModels;
}
}
async toInfoAsync(): Promise<ProviderInfo> {
const models = await this.getRemoteModels();
return {
...this.toInfo(),
models: models.length ? models : [this.defaultModel],
default_model: this.defaultModel,
};
}
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback): Promise<string> {
const modelName = model || this.defaultModel;
const url = `https://generativelanguage.googleapis.com/v1beta/models/${modelName}:${onChunk ? 'streamGenerateContent?alt=sse&' : 'generateContent?'}key=${config.lireek.geminiApiKey}`;
const payload = {
systemInstruction: { parts: [{ text: systemPrompt }] },
contents: [{ role: 'user', parts: [{ text: userPrompt }] }],
};
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`Gemini API error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
return await readSSE(resp, onChunk, (data) => data.candidates?.[0]?.content?.parts?.[0]?.text || null);
} else {
const data = await resp.json();
return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
}
}
}
+20
View File
@@ -0,0 +1,20 @@
// llm/index.ts — Barrel re-export for the LLM provider module
//
// All external consumers should import from this module.
// Internal files within llm/ import from their specific submodules directly.
// Types
export type { ProviderInfo, GenerationResponse, ChunkCallback, CallOptions } from './types.js';
export { skipThinkingSignal, setSkipThinking, resetSkipThinking } from './types.js';
// Base class (for type use / extension)
export { LLMProvider } from './base.js';
// Registry
export { getProvider, listProviders } from './registry.js';
// Post-processing
export { stripThinkingBlocks, postprocessLyrics, fixSectionLabels, enforceLineCounts, fixAPrefix, stripLyricQuotes, estimateDuration } from './postprocess.js';
// Orchestration (high-level generation functions)
export { generateLyricsStreaming, refineLyricsStreaming } from './orchestration.js';
@@ -0,0 +1,85 @@
// llm/llamacpp.ts — llama.cpp server provider
//
// Connects to a llama.cpp server (llama-server / llama-cli --server)
// which exposes an OpenAI-compatible API at /v1/chat/completions and /v1/models.
// Default endpoint: http://127.0.0.1:8080/v1
import { config } from '../../../config.js';
import { LLMProvider, readSSE, noThinkSystemPrompt } from './base.js';
import type { ProviderInfo, ChunkCallback, CallOptions } from './types.js';
export class LlamaCppProvider extends LLMProvider {
id = 'llamacpp';
name = 'llama.cpp';
get defaultModel() { return config.lireek.llamacppModel; }
isAvailable() { return true; }
private async getLocalModels(): Promise<string[]> {
try {
const baseUrl = config.lireek.llamacppBaseUrl.replace(/\/+$/, '');
const resp = await fetch(`${baseUrl}/models`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) return [];
const data = await resp.json();
this.availableModels = data.data?.map((m: any) => m.id).sort().reverse() || [];
return this.availableModels;
} catch { return []; }
}
async toInfoAsync(): Promise<ProviderInfo> {
const models = await this.getLocalModels();
return {
...this.toInfo(),
models: models.length ? models : (this.defaultModel ? [this.defaultModel] : []),
default_model: models.length ? models[0] : this.defaultModel,
};
}
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback, options?: CallOptions): Promise<string> {
const baseUrl = config.lireek.llamacppBaseUrl.replace(/\/+$/, '');
const url = `${baseUrl}/chat/completions`;
const modelName = model || (await this.getLocalModels())[0] || this.defaultModel;
if (!modelName) throw new Error('No models available on llama.cpp server');
const noThink = !!options?.noThink;
const payload: Record<string, any> = {
model: modelName,
messages: [
{ role: 'system', content: noThink ? noThinkSystemPrompt(systemPrompt) : systemPrompt },
{ role: 'user', content: userPrompt },
],
stream: !!onChunk,
};
// Officially supported by llama-server: skips the thinking block for
// templates that take an enable_thinking kwarg (Qwen3, GLM, ...).
if (noThink) {
payload.chat_template_kwargs = { enable_thinking: false };
// Qwen's official non-thinking sampling profile — presence_penalty=1.5
// is their documented guard against degenerate repetition loops in this
// mode. Explicit CallOptions values win; max_tokens bounds runaways.
payload.temperature = options?.temperature ?? 0.7;
payload.top_p = options?.top_p ?? 0.8;
payload.top_k = 20;
payload.presence_penalty = 1.5;
payload.max_tokens = 8192;
}
// 5-min timeout — prevents hung requests from blocking the generation queue
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`llama.cpp error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
return await readSSE(resp, onChunk, (data) => data.choices?.[0]?.delta?.content || null, (data) => data.choices?.[0]?.delta?.reasoning_content || null);
} else {
const data = await resp.json();
return data.choices?.[0]?.message?.content || '';
}
}
}
@@ -0,0 +1,95 @@
// llm/lmstudio.ts — LM Studio provider
import { config } from '../../../config.js';
import { LLMProvider, readSSE, noThinkSystemPrompt } from './base.js';
import type { ProviderInfo, ChunkCallback, CallOptions } from './types.js';
export class LMStudioProvider extends LLMProvider {
id = 'lmstudio';
name = 'LM Studio';
get defaultModel() { return config.lireek.lmstudioModel; }
isAvailable() { return true; }
private async getLocalModels(): Promise<string[]> {
try {
const baseUrl = config.lireek.lmstudioBaseUrl.replace('/v1', '');
const resp = await fetch(`${baseUrl}/v1/models`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) return [];
const data = await resp.json();
this.availableModels = data.data?.map((m: any) => m.id).sort().reverse() || [];
return this.availableModels;
} catch { return []; }
}
async toInfoAsync(): Promise<ProviderInfo> {
const models = await this.getLocalModels();
return {
...this.toInfo(),
models: models.length ? models : (this.defaultModel ? [this.defaultModel] : []),
default_model: models.length ? models[0] : this.defaultModel,
};
}
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback, options?: CallOptions): Promise<string> {
const baseUrl = config.lireek.lmstudioBaseUrl;
const url = `${baseUrl}/chat/completions`;
const modelName = model || (await this.getLocalModels())[0] || this.defaultModel;
if (!modelName) throw new Error("No models loaded in LM Studio");
const noThink = !!options?.noThink;
const payload: Record<string, any> = {
model: modelName,
messages: [
{ role: 'system', content: noThink ? noThinkSystemPrompt(systemPrompt) : systemPrompt },
{ role: 'user', content: userPrompt },
],
stream: !!onChunk,
};
if (noThink) {
// Empirically verified against LM Studio + Qwen3.6 (2026-07-09): this is
// the field LM Studio honours — reasoning drops to zero and content is
// answered directly. Non-thinking models (gemma) accept it harmlessly.
payload.reasoning_effort = 'none';
// llama.cpp-style template kwarg — ignored by LM Studio today (verified),
// kept because it is harmless and honoured if support lands.
payload.chat_template_kwargs = { enable_thinking: false };
// Qwen's OFFICIAL non-thinking sampling profile. Without thinking, low-
// entropy sampling degenerates into endless repetition loops ("the cord
// is a square / the cord is a circle" ...); presence_penalty=1.5 is
// Qwen's documented anti-loop knob for this mode. Explicit CallOptions
// values win. max_tokens bounds any residual runaway.
payload.temperature = options?.temperature ?? 0.7;
payload.top_p = options?.top_p ?? 0.8;
payload.top_k = 20;
payload.presence_penalty = 1.5;
payload.max_tokens = 8192;
}
const doFetch = () => fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
let resp = await doFetch();
// If a server/model combination rejects the non-standard fields, retry
// once without them rather than failing the generation.
if (!resp.ok && noThink && resp.status === 400) {
delete payload.chat_template_kwargs;
delete payload.reasoning_effort;
resp = await doFetch();
}
if (!resp.ok) throw new Error(`LM Studio error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
return await readSSE(resp, onChunk, (data) => data.choices?.[0]?.delta?.content || null, (data) => data.choices?.[0]?.delta?.reasoning_content || null);
} else {
const data = await resp.json();
return data.choices?.[0]?.message?.content || '';
}
}
}
+115
View File
@@ -0,0 +1,115 @@
// llm/ollama.ts — Ollama (Local) provider
import { config } from '../../../config.js';
import { LLMProvider, noThinkSystemPrompt } from './base.js';
import type { ProviderInfo, ChunkCallback, CallOptions } from './types.js';
import { skipThinkingSignal } from './types.js';
export class OllamaProvider extends LLMProvider {
id = 'ollama';
name = 'Ollama (Local)';
get defaultModel() { return config.lireek.ollamaModel; }
isAvailable() { return true; }
private async getLocalModels(): Promise<string[]> {
try {
const resp = await fetch(`${config.lireek.ollamaBaseUrl}/api/tags`, { signal: AbortSignal.timeout(3000) });
if (!resp.ok) return [];
const data = await resp.json();
this.availableModels = data.models?.map((m: any) => m.name) || [];
return this.availableModels;
} catch { return []; }
}
async toInfoAsync(): Promise<ProviderInfo> {
const models = await this.getLocalModels();
return {
...this.toInfo(),
models: models.length ? models : [this.defaultModel],
default_model: models.length ? models[0] : this.defaultModel,
};
}
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback, options?: CallOptions): Promise<string> {
const url = `${config.lireek.ollamaBaseUrl}/api/chat`;
const noThink = !!options?.noThink;
const payload: Record<string, any> = {
model: model || this.defaultModel,
messages: [
{ role: 'system', content: noThink ? noThinkSystemPrompt(systemPrompt) : systemPrompt },
{ role: 'user', content: userPrompt },
],
stream: !!onChunk,
options: { num_predict: 8196 }
};
// Native Ollama switch for thinking models (qwen3, deepseek-r1, ...).
if (noThink) {
payload.think = false;
// Qwen's official non-thinking sampling profile — presence_penalty=1.5
// is their documented guard against degenerate repetition loops.
payload.options = {
...payload.options,
temperature: options?.temperature ?? 0.7,
top_p: options?.top_p ?? 0.8,
top_k: 20,
presence_penalty: 1.5,
};
}
const doFetch = () => fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
let resp = await doFetch();
// Non-thinking models reject the `think` field — retry once without it.
if (!resp.ok && noThink && resp.status === 400 && 'think' in payload) {
delete payload.think;
resp = await doFetch();
}
if (!resp.ok) throw new Error(`Ollama error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
if (!resp.body) return '';
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunkLines = decoder.decode(value, { stream: true }).split('\n');
for (const line of chunkLines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
// Display-only: stream reasoning to UI but don't keep in result
const reasoning = parsed.message?.reasoning_content;
if (reasoning) {
onChunk(reasoning);
continue;
}
const content = parsed.message?.content;
if (content) {
fullText += content;
onChunk(content);
if (skipThinkingSignal && fullText.includes('<think>') && !fullText.includes('</think>')) {
reader.cancel();
return fullText;
}
}
} catch (e) {}
}
}
} finally { reader.releaseLock(); }
return fullText;
} else {
const data = await resp.json();
return data.message?.content || '';
}
}
}
@@ -0,0 +1,78 @@
// llm/openai-compat.ts — Generic OpenAI-compatible provider
//
// Connects to any server that implements the OpenAI API format
// (oMLX, vLLM, text-generation-webui, LocalAI, etc.)
import { config } from '../../../config.js';
import { LLMProvider, readSSE } from './base.js';
import type { ProviderInfo, ChunkCallback } from './types.js';
export class OpenAICompatProvider extends LLMProvider {
id = 'openai-compat';
get name() { return config.lireek.openaiCompatName || 'OpenAI Compatible'; }
get defaultModel() { return config.lireek.openaiCompatModel; }
isAvailable() { return !!config.lireek.openaiCompatBaseUrl; }
private async getRemoteModels(): Promise<string[]> {
try {
const baseUrl = config.lireek.openaiCompatBaseUrl.replace(/\/+$/, '');
const headers: Record<string, string> = {};
if (config.lireek.openaiCompatApiKey) {
headers['Authorization'] = `Bearer ${config.lireek.openaiCompatApiKey}`;
}
const resp = await fetch(`${baseUrl}/models`, { headers, signal: AbortSignal.timeout(3000) });
if (!resp.ok) return [];
const data = await resp.json();
this.availableModels = data.data?.map((m: any) => m.id).sort().reverse() || [];
return this.availableModels;
} catch { return []; }
}
async toInfoAsync(): Promise<ProviderInfo> {
const models = await this.getRemoteModels();
return {
...this.toInfo(),
models: models.length ? models : (this.defaultModel ? [this.defaultModel] : []),
default_model: models.length ? models[0] : this.defaultModel,
};
}
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback): Promise<string> {
const baseUrl = config.lireek.openaiCompatBaseUrl.replace(/\/+$/, '');
const url = `${baseUrl}/chat/completions`;
const modelName = model || (await this.getRemoteModels())[0] || this.defaultModel;
if (!modelName) throw new Error(`No models available on ${this.name}`);
const payload: Record<string, any> = {
model: modelName,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }],
stream: !!onChunk,
// Force thinking/reasoning for Qwen3-style models on oMLX/vLLM.
// Servers that don't support this parameter will safely ignore it.
enable_thinking: true,
};
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
if (config.lireek.openaiCompatApiKey) {
headers['Authorization'] = `Bearer ${config.lireek.openaiCompatApiKey}`;
}
const resp = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`${this.name} error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
return await readSSE(resp, onChunk, (data) => data.choices?.[0]?.delta?.content || null, (data) => data.choices?.[0]?.delta?.reasoning_content || null);
} else {
const data = await resp.json();
return data.choices?.[0]?.message?.content || '';
}
}
}
+42
View File
@@ -0,0 +1,42 @@
// llm/openai.ts — OpenAI / ChatGPT provider
import { config } from '../../../config.js';
import { LLMProvider, readSSE } from './base.js';
import type { ChunkCallback } from './types.js';
export class OpenAIProvider extends LLMProvider {
id = 'openai';
name = 'OpenAI / ChatGPT';
get defaultModel() { return config.lireek.openaiModel; }
availableModels = ['gpt-4o', 'gpt-4o-mini', 'gpt-4-turbo', 'gpt-3.5-turbo'];
isAvailable() { return !!config.lireek.openaiApiKey; }
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback): Promise<string> {
const url = 'https://api.openai.com/v1/chat/completions';
const payload = {
model: model || this.defaultModel,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }],
stream: !!onChunk,
};
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${config.lireek.openaiApiKey}`,
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`OpenAI error: ${resp.status} ${await resp.text()}`);
if (onChunk) {
return await readSSE(resp, onChunk, (data) => data.choices?.[0]?.delta?.content || null, (data) => data.choices?.[0]?.delta?.reasoning_content || null);
} else {
const data = await resp.json();
return data.choices?.[0]?.message?.content || '';
}
}
}
@@ -0,0 +1,278 @@
// llm/orchestration.ts — High-level lyric generation and refinement functions
//
// All prompt TEXT and prompt BUILDERS live in ../prompts.ts (the canonical
// single source, shared with tools/mcp-lyricstudio). This file owns the
// call orchestration: provider calls, JSON parsing, postprocessing, retries.
import * as slopDetector from '../slopDetector.js';
import {
GENERATION_SYSTEM_PROMPT,
SONG_METADATA_SYSTEM_PROMPT,
REFINEMENT_SYSTEM_PROMPT,
TITLE_DERIVATION_PROMPT,
buildMetadataPrompt,
buildGenerationPrompt,
buildRefinementPrompt,
buildTitlePrompt,
} from '../prompts.js';
import type { LyricsProfile } from '../profilerService.js';
import { withModelSuffix } from '../modelName.js';
import type { GenerationResponse, ChunkCallback, CallOptions } from './types.js';
import { getProvider } from './registry.js';
import {
stripThinkingBlocks, postprocessLyrics, fixSectionLabels,
enforceLineCounts, fixAPrefix,
estimateDuration
} from './postprocess.js';
// Words banned from titles — enforced programmatically since prompt-only bans leak
const BANNED_TITLE_WORDS = new Set([
'glass', 'steel', 'plastic', 'concrete', 'midnight', 'mirror',
'heavy', 'terminal', 'altar', 'confessional', 'ledger', 'gospel',
'chrome', 'gilded', 'puppet', 'halo', 'protocol', 'eden', 'digital',
'algorithm', 'code', 'circuit', 'grid', 'data', 'wire',
'sanctuary', 'void', 'ethereal', 'neon', 'silhouette', 'static',
'embers', 'fluorescent', 'shimmering', 'tapestry', 'weight',
'skin', 'signal', 'platform',
]);
// Structural title formula patterns that produce repetitive titles
const BANNED_TITLE_PATTERNS: RegExp[] = [
/^watch\s+(it|me|them|us|him|her)\b/i,
/^let\s+it\s+(burn|fade|go|fall|bleed|break|rot|die|end)\b/i,
/^burn\s+it\s+(all|down)\b/i,
/^nothing\s+left/i,
/^nowhere\s+left/i,
];
/**
* Check if a title contains banned words or matches banned patterns.
* Returns an array of issues found (empty = title is clean).
*/
function validateTitle(title: string): string[] {
const issues: string[] = [];
const words = title.toLowerCase().split(/\W+/).filter(Boolean);
for (const w of words) {
if (BANNED_TITLE_WORDS.has(w)) issues.push(`banned word: "${w}"`);
}
for (const pat of BANNED_TITLE_PATTERNS) {
if (pat.test(title)) issues.push(`banned pattern: ${pat.source}`);
}
return issues;
}
/**
* Append a unique nonce to a system prompt to bust oMLX's KV cache.
* Without this, oMLX reuses cached prefix states for identical system prompts,
* which can cause thinking models to skip reasoning on subsequent calls.
*/
function cacheBustPrompt(prompt: string): string {
return `${prompt}\n\n(session ${Date.now()}-${Math.random().toString(36).slice(2, 8)})`;
}
interface PlannedMetadata {
subject: string;
bpm: number;
key: string;
caption: string;
duration: number;
structure?: string;
}
async function planSongMetadata(
profile: LyricsProfile,
usedSubjects: string[],
usedBpms: number[],
usedKeys: string[],
usedDurations: number[],
providerName: string,
modelName: string,
onChunk?: ChunkCallback,
userSubject?: string,
callOptions?: CallOptions
): Promise<PlannedMetadata> {
const provider = getProvider(providerName);
const prompt = buildMetadataPrompt(profile, usedSubjects, usedBpms, usedKeys, usedDurations, userSubject);
console.log('[LLM] Planning song metadata via', providerName, modelName);
const responseJsonStr = await provider.call(cacheBustPrompt(SONG_METADATA_SYSTEM_PROMPT), prompt, modelName, onChunk, callOptions);
const cleaned = stripThinkingBlocks(responseJsonStr);
const cleanJson = cleaned.replace(/^```(?:json)?\s*|\s*```$/gm, '').trim();
try {
return JSON.parse(cleanJson);
} catch (err) {
const start = cleanJson.indexOf('{');
if (start !== -1) {
let depth = 0;
for (let i = start; i < cleanJson.length; i++) {
if (cleanJson[i] === '{') depth++;
else if (cleanJson[i] === '}') { depth--; if (depth === 0) { try { return JSON.parse(cleanJson.slice(start, i + 1)); } catch {} break; } }
}
}
console.error("Failed to parse metadata JSON:", cleanJson.slice(0, 300));
return { subject: '', bpm: 0, key: '', caption: '', duration: 0 };
}
}
export async function generateLyricsStreaming(
profile: LyricsProfile, providerName: string, model?: string,
extraInstructions?: string, usedSubjects: string[] = [],
usedBpms: number[] = [], usedKeys: string[] = [],
usedTitles: string[] = [], usedDurations: number[] = [],
onChunk?: ChunkCallback, onPhase?: (phase: string) => void,
userSubject?: string, callOptions?: CallOptions
): Promise<GenerationResponse> {
const provider = getProvider(providerName);
const effectiveModel = model || provider.defaultModel;
if (onPhase) onPhase("Planning song metadata…");
let metadata: PlannedMetadata = { subject: '', bpm: 0, key: '', caption: '', duration: 0 };
if (profile.song_subjects || (profile.themes && profile.themes.length) || userSubject) {
try {
metadata = await planSongMetadata(profile, usedSubjects, usedBpms, usedKeys, usedDurations, providerName, effectiveModel, onChunk, userSubject, callOptions);
if (userSubject) metadata.subject = userSubject;
console.log("Planned metadata:", metadata);
} catch(e) { console.warn("Failed to plan metadata", e); }
}
if (userSubject && !metadata.subject) metadata.subject = userSubject;
if (metadata.subject) extraInstructions = `The song must be about: ${metadata.subject}\n\n${extraInstructions || ''}`;
if (onPhase) onPhase("Writing lyrics…");
const userPrompt = buildGenerationPrompt(profile, extraInstructions, metadata.duration, metadata.bpm, metadata.structure);
let raw = await provider.call(cacheBustPrompt(GENERATION_SYSTEM_PROMPT), userPrompt, effectiveModel, onChunk, callOptions);
raw = stripThinkingBlocks(raw);
raw = raw.replace(/<\|[a-z_]+\|>/g, '');
raw = raw.replace(/\[?(System|User|Assistant)\]?:.*/gi, '');
raw = raw.replace(/\s*\((?:Hook|You|Repeat|x\d|Refrain|Spoken|Whispered|Ad[- ]?lib|Echo)\)\s*/gi, '');
raw = raw.replace(/ +$/gm, '');
const rawLines = raw.trim().split('\n');
for (let i = 0; i < rawLines.length; i++) {
const match = rawLines[i].match(/^(?:Title:\s*|#\s*)(.*)/i);
if (match) {
const rest = rawLines.slice(i + 1);
while (rest.length && !rest[0].trim()) rest.shift();
raw = rest.join('\n');
break;
}
if (rawLines[i].trim().startsWith('[') || (rawLines[i].trim() && i > 2)) break;
}
raw = postprocessLyrics(raw);
raw = fixSectionLabels(raw);
raw = fixAPrefix(raw);
raw = enforceLineCounts(raw);
const slopResult = slopDetector.scanForSlop(raw);
if (slopResult.ai_score > 0) {
console.warn(`Generation slop scan: score=${slopResult.ai_score} severity=${slopResult.severity}`,
'words:', slopResult.layers.blacklisted_words.found,
'phrases:', slopResult.layers.blacklisted_phrases.found,
'overuse:', slopResult.layers.overuse.found.map((o: any) => `${o.word}(${o.count}x)`).join(', ') || 'none',
'hook_formulas:', slopResult.layers.hook_formulas.found.join(', ') || 'none');
}
if (onPhase) onPhase("Choosing title…");
let title = '';
try {
const titleUserPrompt = buildTitlePrompt(raw, profile.artist, profile.album, usedTitles);
let titleRaw = await provider.call(cacheBustPrompt(TITLE_DERIVATION_PROMPT), titleUserPrompt, effectiveModel, onChunk, callOptions);
titleRaw = stripThinkingBlocks(titleRaw).trim();
titleRaw = titleRaw.replace(/^(?:Title:\s*|#\s*)/i, '').replace(/^["'`]|["'`]$/g, '').trim();
title = titleRaw.split('\n')[0].trim();
console.log('[LLM] Derived title:', title);
// Validate title against banned words/patterns
const titleIssues = validateTitle(title);
if (titleIssues.length) {
console.warn(`[LLM] Title "${title}" failed validation: ${titleIssues.join(', ')}. Requesting re-derivation.`);
// Re-derive with explicit rejection guidance
const retryPrompt = [
titleUserPrompt,
`\nThe title "${title}" is REJECTED because: ${titleIssues.join(', ')}.`,
'Choose a DIFFERENT title that avoids these issues. Return ONLY the new title:',
].join('\n');
try {
let retryRaw = await provider.call(cacheBustPrompt(TITLE_DERIVATION_PROMPT), retryPrompt, effectiveModel, onChunk, callOptions);
retryRaw = stripThinkingBlocks(retryRaw).trim();
retryRaw = retryRaw.replace(/^(?:Title:\s*|#\s*)/i, '').replace(/^["'`]|["'`]$/g, '').trim();
const retryTitle = retryRaw.split('\n')[0].trim();
const retryIssues = validateTitle(retryTitle);
if (!retryIssues.length) {
console.log(`[LLM] Re-derived title: "${retryTitle}" (was: "${title}")`);
title = retryTitle;
} else {
console.warn(`[LLM] Re-derived title "${retryTitle}" still failed: ${retryIssues.join(', ')}. Keeping original.`);
}
} catch (retryErr) {
console.warn('[LLM] Title re-derivation failed:', retryErr);
}
}
} catch (err) { console.warn('[LLM] Title derivation failed, falling back to empty:', err); }
// Tag the title with the model that wrote it, same as the MCP path does
title = withModelSuffix(title, effectiveModel);
let duration = metadata.duration || 0;
if (metadata.bpm > 0 && !duration) duration = estimateDuration(raw, metadata.bpm);
return {
lyrics: raw, provider: providerName, model: effectiveModel, title,
subject: metadata.subject, bpm: metadata.bpm, key: metadata.key,
caption: metadata.caption, duration,
system_prompt: GENERATION_SYSTEM_PROMPT, user_prompt: userPrompt
};
}
export async function refineLyricsStreaming(
originalLyrics: string, artistName: string, title: string,
providerName: string, model?: string, profile?: LyricsProfile,
onChunk?: ChunkCallback
): Promise<GenerationResponse> {
const provider = getProvider(providerName);
const effectiveModel = model || provider.defaultModel;
const slopScan = slopDetector.scanForSlop(originalLyrics);
const foundSlop = [...slopScan.layers.blacklisted_words.found, ...slopScan.layers.blacklisted_phrases.found];
const userPrompt = buildRefinementPrompt(originalLyrics, artistName, title, profile, foundSlop);
let raw = await provider.call(cacheBustPrompt(REFINEMENT_SYSTEM_PROMPT), userPrompt, effectiveModel, onChunk);
raw = stripThinkingBlocks(raw);
raw = raw.replace(/<\|[a-z_]+\|>/g, '');
raw = raw.replace(/\s*\((?:Hook|You|Repeat|x\d|Refrain|Spoken|Whispered|Ad[- ]?lib|Echo)\)\s*/gi, '');
raw = raw.replace(/ +$/gm, '');
let refinedTitle = title;
const rLines = raw.trim().split('\n');
for (let i = 0; i < rLines.length; i++) {
const match = rLines[i].match(/^(?:Title:\s*|#\s*)(.*)/i);
if (match) {
refinedTitle = match[1].trim().replace(/^['"]|['"]$/g, '');
const rest = rLines.slice(i + 1);
while (rest.length && !rest[0].trim()) rest.shift();
raw = rest.join('\n');
break;
}
}
raw = postprocessLyrics(raw);
raw = fixSectionLabels(raw);
raw = fixAPrefix(raw);
raw = enforceLineCounts(raw);
const slopResult = slopDetector.scanForSlop(raw);
if (slopResult.ai_score > 0) {
console.warn(`Refinement slop scan: score=${slopResult.ai_score} severity=${slopResult.severity}`,
'words:', slopResult.layers.blacklisted_words.found,
'phrases:', slopResult.layers.blacklisted_phrases.found,
'overuse:', slopResult.layers.overuse.found.map((o: any) => `${o.word}(${o.count}x)`).join(', ') || 'none',
'hook_formulas:', slopResult.layers.hook_formulas.found.join(', ') || 'none');
}
refinedTitle = withModelSuffix(refinedTitle, effectiveModel);
return {
lyrics: raw, provider: providerName, model: effectiveModel, title: refinedTitle,
subject: '', bpm: 0, key: '', caption: '', duration: 0,
system_prompt: REFINEMENT_SYSTEM_PROMPT, user_prompt: userPrompt
};
}
@@ -0,0 +1,149 @@
// llm/postprocess.ts — Lyric post-processing pipeline (ported from HOT-Step 9000)
/** Strip thinking/reasoning blocks from LLM output */
export function stripThinkingBlocks(text: string): string {
let result = text.replace(/<think>[\s\S]*?<\/think>/g, '');
result = result.replace(/<analysis>[\s\S]*?<\/analysis>/g, '');
result = result.replace(/<reasoning>[\s\S]*?<\/reasoning>/g, '');
result = result.replace(/<reflection>[\s\S]*?<\/reflection>/g, '');
result = result.replace(/<thought>[\s\S]*?<\/thought>/g, '');
result = result.replace(/<\|channel>thought[\s\S]*?<channel\|>/g, '');
result = result.replace(/<(?:think|analysis|reasoning|reflection|thought)>[\s\S]*/g, '');
result = result.replace(/<\|channel>thought[\s\S]*/g, '');
const cotMatch = result.match(/^(?:\s*\*+\s*)?(?:Thinking Process|Thought Process|Thinking|Reasoning):\s*[\s\S]*?(?:---|[*]{3,}|={3,})\s*/i);
if (cotMatch) result = result.slice(cotMatch[0].length);
return result.trim();
}
const SECTION_KEYWORDS = [
'Intro', 'Verse', 'Pre-Chorus', 'Chorus', 'Post-Chorus',
'Bridge', 'Interlude', 'Outro', 'Hook', 'Refrain',
];
export const SECTION_LINE_RE = new RegExp(
'^\\[?(' + SECTION_KEYWORDS.map(k => k.replace(/[-/]/g, '\\$&')).join('|') + ')\\s*(\\d*)\\]?\\s*$',
'i'
);
const PUNCTUATION_ENDINGS = new Set('.,!?;:-…)"\'');
export function postprocessLyrics(text: string): string {
const resultLines: string[] = [];
for (const line of text.split('\n')) {
const stripped = line.trim();
if (!stripped) { resultLines.push(''); continue; }
const m = SECTION_LINE_RE.exec(stripped);
if (m) {
const sectionName = m[1].charAt(0).toUpperCase() + m[1].slice(1).toLowerCase();
const sectionNum = m[2];
resultLines.push(sectionNum ? `[${sectionName} ${sectionNum}]` : `[${sectionName}]`);
continue;
}
if (/^\[.+\]$/.test(stripped)) { resultLines.push(stripped); continue; }
if (stripped && !PUNCTUATION_ENDINGS.has(stripped[stripped.length - 1])) {
resultLines.push(stripped + ',');
} else {
resultLines.push(stripped);
}
}
return resultLines.join('\n');
}
export function fixSectionLabels(text: string): string {
const INVALID_TO_VALID: Record<string, string> = {
'x': 'Interlude', 'breakdown': 'Bridge', 'drop': 'Chorus',
'solo': 'Interlude', 'hook': 'Chorus', 'rap': 'Verse', 'spoken': 'Verse',
};
const lines = text.split('\n');
const result: string[] = [];
const sectionHeaders: { lineIdx: number; header: string }[] = [];
for (const line of lines) {
const stripped = line.trim();
const m = stripped.match(/^\[(.+?)(?:\s+\d+)?\]$/);
if (m) {
let label = m[1].trim().toLowerCase();
let newStripped = stripped;
if (INVALID_TO_VALID[label]) {
const newLabel = INVALID_TO_VALID[label];
const numMatch = stripped.match(/\d+/);
newStripped = numMatch ? `[${newLabel} ${numMatch[0]}]` : `[${newLabel}]`;
}
sectionHeaders.push({ lineIdx: result.length, header: newStripped });
result.push(newStripped);
} else {
result.push(stripped.startsWith('[') && stripped.endsWith(']') ? stripped : line);
}
}
const bridgeIndices = sectionHeaders.map((h, i) => ({ i, h })).filter(x => x.h.header.toLowerCase().includes('bridge'));
const chorusExists = sectionHeaders.some(h => h.header.toLowerCase().includes('chorus'));
if (!chorusExists && bridgeIndices.length >= 2) {
for (const bi of bridgeIndices.slice(0, -1)) {
result[sectionHeaders[bi.i].lineIdx] = '[Chorus]';
}
}
return result.join('\n');
}
// Musical phrases resolve in even numbers of lines, so odd-length verses and
// choruses (5, 7, 9 lines) clash with the music model's phrasing. Rather than
// forcing every section to exactly 4/8 lines (the old behaviour, which deleted
// up to 3 lines of content), only trim ONE line when the count is odd.
// Even counts of any reasonable size pass through untouched.
export function enforceLineCounts(text: string): string {
const sections: { header: string; lines: string[] }[] = [];
let currentHeader = '';
let currentLines: string[] = [];
for (const line of text.split('\n')) {
const stripped = line.trim();
if (/^\[.+\]$/.test(stripped)) {
if (currentHeader || currentLines.length) sections.push({ header: currentHeader, lines: currentLines });
currentHeader = stripped;
currentLines = [];
} else { currentLines.push(line); }
}
if (currentHeader || currentLines.length) sections.push({ header: currentHeader, lines: currentLines });
const resultParts: string[] = [];
for (const { header, lines } of sections) {
const lyricLines = lines.filter(l => l.trim());
const count = lyricLines.length;
const headerLower = header.toLowerCase();
const isVerse = headerLower.includes('verse');
const isChorus = headerLower.includes('chorus') || headerLower.includes('hook');
let target: number | null = null;
if ((isVerse || isChorus) && count >= 3 && count % 2 !== 0) target = count - 1;
let finalLines = lines;
if (target !== null && target < count) {
let kept = 0; finalLines = [];
for (const l of lines) {
if (l.trim()) { if (kept < target) { finalLines.push(l); kept++; } }
else { if (kept < target) finalLines.push(l); }
}
}
if (header) resultParts.push(header);
resultParts.push(...finalLines);
}
return resultParts.join('\n');
}
const BAD_A_PREFIX_RE = /\ba-(?!\w+ing\b)(?!\w+in'\b)/gi;
export function fixAPrefix(text: string): string { return text.replace(BAD_A_PREFIX_RE, ''); }
export function stripLyricQuotes(text: string): string { return text.replace(/'[^']{4,}'/g, '[quote removed]'); }
export function estimateDuration(lyrics: string, bpm: number): number {
if (!lyrics.trim() || bpm <= 0) return 0;
const barDuration = 240.0 / Math.max(bpm, 40);
const lines = lyrics.trim().split('\n');
let sectionCount = 0, lyricLineCount = 0;
for (const line of lines) {
const stripped = line.trim();
if (!stripped) continue;
if (SECTION_LINE_RE.test(stripped) || (stripped.startsWith('[') && stripped.endsWith(']'))) sectionCount++;
else lyricLineCount++;
}
return Math.max(90, Math.min(Math.floor(lyricLineCount * 3.5 + Math.max(sectionCount - 1, 0) * 4 * barDuration), 360));
}
// selectBestBlueprint was removed — structure selection now lives in
// ../prompts.ts (pickBlueprint), which samples from the artist's observed
// blueprints instead of deterministically picking the same one every time.
@@ -0,0 +1,59 @@
// llm/registry.ts — Provider registry and lookup
import { LLMProvider } from './base.js';
import type { ProviderInfo } from './types.js';
import { GeminiProvider } from './gemini.js';
import { OpenAIProvider } from './openai.js';
import { AnthropicProvider } from './anthropic.js';
import { OllamaProvider } from './ollama.js';
import { LMStudioProvider } from './lmstudio.js';
import { UnslothProvider } from './unsloth.js';
import { OpenAICompatProvider } from './openai-compat.js';
import { LlamaCppProvider } from './llamacpp.js';
const providers: Record<string, LLMProvider> = {
gemini: new GeminiProvider(),
openai: new OpenAIProvider(),
anthropic: new AnthropicProvider(),
ollama: new OllamaProvider(),
lmstudio: new LMStudioProvider(),
unsloth: new UnslothProvider(),
llamacpp: new LlamaCppProvider(),
'openai-compat': new OpenAICompatProvider(),
};
export function getProvider(name: string): LLMProvider {
const provider = providers[name];
if (!provider) throw new Error(`Unknown LLM provider: ${name}`);
return provider;
}
export async function listProviders(): Promise<ProviderInfo[]> {
const PROVIDER_TIMEOUT_MS = 5000;
const promises = Object.values(providers).map(async (p): Promise<ProviderInfo> => {
try {
if (p instanceof GeminiProvider || p instanceof OllamaProvider || p instanceof LMStudioProvider || p instanceof UnslothProvider || p instanceof LlamaCppProvider || p instanceof OpenAICompatProvider) {
// Race against a timeout so one dead provider can't block the rest
const info = await Promise.race([
p.toInfoAsync(),
new Promise<ProviderInfo>((_, reject) =>
setTimeout(() => reject(new Error(`${p.name} timed out`)), PROVIDER_TIMEOUT_MS)
),
]);
return info;
} else {
return p.toInfo();
}
} catch (err: any) {
console.warn(`[LLM Registry] Provider ${p.name} failed: ${err.message}`);
// Return the provider as unavailable rather than dropping it
return { ...p.toInfo(), available: false, models: p.defaultModel ? [p.defaultModel] : [] };
}
});
const settled = await Promise.allSettled(promises);
return settled
.filter((r): r is PromiseFulfilledResult<ProviderInfo> => r.status === 'fulfilled')
.map(r => r.value);
}
+56
View File
@@ -0,0 +1,56 @@
// llm/types.ts — Shared types for the LLM provider system
export interface ProviderInfo {
id: string;
name: string;
available: boolean;
models: string[];
default_model: string;
}
export interface GenerationResponse {
lyrics: string;
provider: string;
model: string;
title: string;
subject: string;
bpm: number;
key: string;
caption: string;
duration: number;
system_prompt: string;
user_prompt: string;
}
export type ChunkCallback = (chunk: string) => void;
export interface CallOptions {
temperature?: number;
top_p?: number;
/**
* Best-effort "answer without reasoning" for local thinking models.
* There is NO universal off-switch across runtimes, so providers layer
* every mechanism that is harmless where unsupported:
* - `reasoning_effort: 'none'` (LM Studio — EMPIRICALLY VERIFIED 2026-07-09
* on Qwen3.6: reasoning drops to zero; gemma accepts it harmlessly)
* - `/no_think` soft switch appended to the system prompt (older Qwen3;
* Qwen3.5+ dropped it — verified ignored on Qwen3.6)
* - `chat_template_kwargs: { enable_thinking: false }` (llama.cpp server;
* LM Studio ignores it — verified)
* - `think: false` (Ollama native)
* Models with no supported mechanism still think; stripThinkingBlocks()
* downstream keeps the output clean either way.
*/
noThink?: boolean;
[key: string]: any;
}
// Global skip thinking signal
export let skipThinkingSignal = false;
export function setSkipThinking() {
skipThinkingSignal = true;
console.log('[LLM] Skip-thinking signal received');
}
export function resetSkipThinking() {
skipThinkingSignal = false;
}
+103
View File
@@ -0,0 +1,103 @@
// llm/unsloth.ts — Unsloth Studio provider
import { config } from '../../../config.js';
import { LLMProvider, readSSE } from './base.js';
import type { ProviderInfo, ChunkCallback } from './types.js';
export class UnslothProvider extends LLMProvider {
id = 'unsloth';
name = 'Unsloth Studio';
get defaultModel() { return config.lireek.unslothModel; }
private cachedToken = '';
private tokenExpiry = 0;
isAvailable() { return !!config.lireek.unslothUsername && !!config.lireek.unslothPassword; }
private async authenticate(): Promise<string> {
const now = Date.now() / 1000;
if (this.cachedToken && now < this.tokenExpiry - 60) return this.cachedToken;
const payload = { email: config.lireek.unslothUsername, password: config.lireek.unslothPassword };
const resp = await fetch(`${config.lireek.unslothBaseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
// Unsloth also sometimes takes username instead of email, trying generic handling if this fails
if (!resp.ok) {
const payload2 = { username: config.lireek.unslothUsername, password: config.lireek.unslothPassword };
const resp2 = await fetch(`${config.lireek.unslothBaseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload2)
});
if (!resp2.ok) throw new Error("Failed to authenticate with Unsloth");
const data = await resp2.json();
this.cachedToken = data.access_token || data.token;
} else {
const data = await resp.json();
this.cachedToken = data.access_token || data.token;
}
try {
const payloadB64 = this.cachedToken.split('.')[1];
const decoded = JSON.parse(Buffer.from(payloadB64, 'base64').toString());
this.tokenExpiry = decoded.exp || (now + 3600);
} catch {
this.tokenExpiry = now + 3600;
}
return this.cachedToken;
}
private async getLocalModels(): Promise<string[]> {
try {
const token = await this.authenticate();
const resp = await fetch(`${config.lireek.unslothBaseUrl}/v1/models`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: AbortSignal.timeout(3000),
});
if (!resp.ok) return [];
const data = await resp.json();
this.availableModels = data.data?.map((m: any) => m.id).sort().reverse() || [];
return this.availableModels;
} catch { return []; }
}
async toInfoAsync(): Promise<ProviderInfo> {
const models = await this.getLocalModels();
return {
...this.toInfo(),
models: models.length ? models : (this.defaultModel ? [this.defaultModel] : []),
default_model: models.length ? models[0] : this.defaultModel,
};
}
async call(systemPrompt: string, userPrompt: string, model?: string, onChunk?: ChunkCallback): Promise<string> {
const token = await this.authenticate();
const modelName = model || (await this.getLocalModels())[0] || this.defaultModel;
if (!modelName) throw new Error("No models loaded in Unsloth Studio");
const url = `${config.lireek.unslothBaseUrl}/v1/chat/completions`;
const payload = {
model: modelName,
messages: [{ role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }],
stream: true, // Unsloth often requires stream: true
};
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload),
signal: AbortSignal.timeout(300_000),
});
if (!resp.ok) throw new Error(`Unsloth error: ${resp.status} ${await resp.text()}`);
return await readSSE(resp, onChunk || (() => {}), (data) => data.choices?.[0]?.delta?.content || null, (data) => data.choices?.[0]?.delta?.reasoning_content || null);
}
}
+28
View File
@@ -0,0 +1,28 @@
// llmService.ts — Barrel re-export from the modular llm/ directory
//
// This file preserves the original import path (./llmService) for existing
// consumers. All implementation has been decomposed into llm/*.ts modules.
//
// New code should import from './llm/index.js' directly.
export {
type ProviderInfo,
type GenerationResponse,
type ChunkCallback,
type CallOptions,
skipThinkingSignal,
setSkipThinking,
resetSkipThinking,
LLMProvider,
getProvider,
listProviders,
stripThinkingBlocks,
postprocessLyrics,
fixSectionLabels,
enforceLineCounts,
fixAPrefix,
stripLyricQuotes,
estimateDuration,
generateLyricsStreaming,
refineLyricsStreaming,
} from './llm/index.js';
+47
View File
@@ -0,0 +1,47 @@
// modelName.ts — model-name prettifying + title suffixing ("Song Name - Fable 5")
//
// CANONICAL single source, shared with tools/mcp-lyricstudio (which imports it
// directly — it runs from TS source via tsx). The suffix append is deterministic
// code, NOT part of any LLM prompt, so titles stay clean for validation and the
// suffix can't be hallucinated.
/** Turn a model id like "claude-fable-5" / "claude-opus-4-8" into "Fable 5" / "Opus 4.8",
* or an OpenAI-compatible / LM Studio id like
* "lmstudio-community/Meta-Llama-3.1-8B-Instruct-GGUF" into "Meta Llama 3.1 8B Instruct".
* Friendly names ("Fable 5", "Gemini 3 Pro") pass through unchanged. */
export function prettifyModel(raw: string): string {
let s = raw.trim();
if (!s) return s;
if (/\s/.test(s) && !/[-_/]/.test(s)) return s; // already a friendly name
const slash = s.lastIndexOf('/'); // org/repo paths → repo part
if (slash >= 0) s = s.slice(slash + 1);
s = s.replace(/^(us\.)?(anthropic[./])?(claude-)?/i, '');
s = s.replace(/\.gguf$/i, ''); // file-style ids
s = s.replace(/[-_.]gguf$/i, ''); // "...-GGUF" repo suffix
s = s.replace(/[-_.](i?q\d+(?:_[a-z0-9]+)*|f16|f32|bf16|fp16)$/i, ''); // quant tag e.g. -Q4_K_M
s = s.replace(/[-.]?\d{8}$/, ''); // date suffix e.g. -20251001
s = s.replace(/[-.]?v\d+(?:[.:]\d+)*$/i, ''); // version suffix: v1, v0.3, v1:0
const parts = s.split(/[-_]/).filter(Boolean);
const out: string[] = [];
for (const part of parts) {
// Join consecutive single-digit segments as a version: opus 4 8 → opus 4.8
if (/^\d$/.test(part) && out.length && /^\d+(\.\d+)*$/.test(out[out.length - 1])) {
out[out.length - 1] += `.${part}`;
} else {
out.push(/^\d/.test(part) ? part : part.charAt(0).toUpperCase() + part.slice(1));
}
}
// Community model names can be absurdly long
// ("Qwen3.6-27B-Fable-Fusion-711-Uncensored-Heretic-NM-DAU-NEO-MAX-MTP-GGUF");
// the first few tokens identify the model, the rest is noise in a title suffix.
return out.slice(0, 4).join(' ') || raw.trim();
}
/** Append " - <Model>" to a title unless it already carries that suffix (or is empty). */
export function withModelSuffix(title: string, model?: string): string {
const t = title.trim();
if (!t || !model) return t;
const pretty = prettifyModel(model);
if (!pretty || t.toLowerCase().endsWith(`- ${pretty.toLowerCase()}`)) return t;
return `${t} - ${pretty}`;
}
@@ -0,0 +1,838 @@
import * as cmuDictRaw from 'cmu-pronouncing-dictionary';
import type { ChunkCallback } from './llmService.js';
export interface SongLyrics {
title: string;
album?: string;
lyrics: string;
}
export interface LyricsProfile {
artist_id?: number;
artist: string;
album?: string;
/** Measured audio facts from a Training Studio export (bpm range, keys,
* genre, caption examples) — null/absent for plain Genius-fetched sets. */
audio_enrichment?: AlbumEnrichment | null;
themes: string[];
common_subjects: string[];
rhyme_schemes: string[];
avg_verse_lines: number;
avg_chorus_lines: number;
vocabulary_notes?: string;
tone_and_mood?: string;
structural_patterns?: string;
additional_notes?: string;
raw_summary?: string;
song_subjects?: Record<string, string>;
subject_categories?: string[];
repetition_stats?: {
chorus_repetition_pct?: number;
verse_repetition_pct?: number;
cross_section_repeats?: number;
pattern?: string;
hook_examples?: string[];
};
structure_blueprints?: string[];
perspective?: string;
meter_stats?: {
avg_syllables_per_line?: number;
syllable_std_dev?: number;
avg_words_per_line?: number;
line_length_range?: string;
line_length_variation?: {
histogram?: Record<string, number>;
per_section?: Record<string, any>;
short_line_examples?: string[];
long_line_examples?: string[];
};
};
vocabulary_stats?: {
type_token_ratio?: number;
total_words?: number;
unique_words?: number;
contraction_pct?: number;
profanity_pct?: number;
distinctive_words?: string[];
};
representative_excerpts?: string[];
narrative_techniques?: string;
imagery_patterns?: string;
signature_devices?: string;
emotional_arc?: string;
rhyme_quality?: Record<string, number>;
examples?: any[];
style_caption?: string;
[key: string]: any;
}
import * as llmService from './llmService.js';
import {
PROFILE_PROMPT_1,
PROFILE_PROMPT_2,
PROFILE_PROMPT_3,
STYLE_CAPTION_PROMPT,
SUBJECT_ANALYSIS_PROMPT,
buildProfilePrompt,
buildSubjectAnalysisPrompt,
computeAlbumEnrichment,
type AlbumEnrichment,
} from './prompts.js';
// The imported dict is a default export depending on interop.
const CMU_DICT: Record<string, string> = (cmuDictRaw as any).default || cmuDictRaw;
// ── Robust JSON extraction ────────────────────────────────────────────────────
function repairJson(text: string): string {
// Fix missing commas between string array elements
let fixed = text.replace(/"\s*\n(\s*")/g, '",\n$1');
// Fix missing commas after ] when followed by " (next key)
fixed = fixed.replace(/\]\s*\n(\s*")/g, '],\n$1');
// Fix stray } after ]
fixed = fixed.replace(/\]\s*\n\s*},?\s*\n(\s*")/g, '],\n$1');
// Fix missing commas between } and " or {
fixed = fixed.replace(/}\s*\n(\s*["{])/g, '},\n$1');
// Fix trailing commas before ] or }
fixed = fixed.replace(/,(\s*[}\]])/g, '$1');
return fixed;
}
function extractJson(text: string): Record<string, any> | null {
// Strategy 0: strip reasoning model <think> blocks
let stripped = text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
if (stripped.includes('<think>')) {
stripped = stripped.replace(/<think>[\s\S]*/g, '').trim();
}
// Strategy 1: direct parse
try { return JSON.parse(stripped); } catch (e) {}
// Strategy 2: strip code fences
let clean = stripped.replace(/^```(?:json)?\s*/m, '').replace(/\s*```$/m, '').trim();
try { return JSON.parse(clean); } catch (e) {}
// Strategy 3: find outermost { ... }
const firstBrace = clean.indexOf('{');
const lastBrace = clean.lastIndexOf('}');
let candidate: string | null = null;
if (firstBrace !== -1 && lastBrace > firstBrace) {
candidate = clean.slice(firstBrace, lastBrace + 1);
try { return JSON.parse(candidate); } catch (e) {}
}
// Strategy 4: repair
const toRepair = candidate || clean;
const repaired = repairJson(toRepair);
try { return JSON.parse(repaired); } catch (e) {}
// Strategy 5: brute-force
let lines = repaired.split('\n');
for (let attempt = 0; attempt < 3; attempt++) {
let found = false;
for (let i = 0; i < lines.length; i++) {
const s = lines[i].trim();
if ((s === '}' || s === '},') && i > 0 && i < lines.length - 1) {
let trialLines = [...lines];
trialLines.splice(i, 1);
try {
return JSON.parse(trialLines.join('\n'));
} catch (e) {
lines = trialLines;
found = true;
break;
}
}
}
if (!found) break;
}
console.warn("All JSON extraction strategies failed for response");
return null;
}
// ── CMU Pronouncing Dictionary ────────────────────────────────────────────────
function getPhones(word: string): string[] | null {
const cleanWord = word.toLowerCase().replace(/['".,!?;:-]/g, '');
const entry = CMU_DICT[cleanWord];
if (entry) return entry.split(' ');
return null;
}
function getVowelTail(phones: string[], n: int = 3): string[] {
let result: string[] = [];
for (let i = phones.length - 1; i >= 0; i--) {
let clean = phones[i].replace(/\d/g, ''); // strip stress marker
result.push(clean);
if (result.length >= n) break;
}
return result.reverse();
}
function rhymeQuality(wordA: string, wordB: string): string {
if (wordA === wordB) return 'perfect';
const phonesA = getPhones(wordA);
const phonesB = getPhones(wordB);
if (!phonesA || !phonesB) {
const a = wordA.toLowerCase(), b = wordB.toLowerCase();
if (a.length >= 2 && b.length >= 2 && a.slice(-2) === b.slice(-2)) return 'slant';
return 'none';
}
const tailA = getVowelTail(phonesA, 3);
const tailB = getVowelTail(phonesB, 3);
if (tailA.join('-') === tailB.join('-')) return 'perfect';
const tailA2 = getVowelTail(phonesA, 2);
const tailB2 = getVowelTail(phonesB, 2);
if (tailA2.join('-') === tailB2.join('-')) return 'perfect';
const vowelsA = phonesA.filter(p => /\d/.test(p)).map(p => p.replace(/\d/g, ''));
const vowelsB = phonesB.filter(p => /\d/.test(p)).map(p => p.replace(/\d/g, ''));
if (vowelsA.length && vowelsB.length && vowelsA[vowelsA.length - 1] === vowelsB[vowelsB.length - 1]) {
if (tailA2[0] === tailB2[0] || tailA.some(p => tailB.includes(p))) return 'slant';
return 'assonance';
}
if (tailA.filter(p => tailB.includes(p)).length >= 2) return 'slant';
return 'none';
}
function countSyllablesHeuristic(word: string): int {
let w = word.toLowerCase().replace(/['".,!?;:-]/g, '');
if (!w) return 0;
const matches = w.match(/[aeiouy]+/g);
let count = matches ? matches.length : 0;
if (w.endsWith('e') && count > 1) count--;
return Math.max(count, 1);
}
function countSyllables(word: string): int {
const phones = getPhones(word);
if (phones) {
const cmu = phones.filter(p => /\d/.test(p)).length;
if (cmu > 0) return cmu;
}
return countSyllablesHeuristic(word);
}
// ── Section parsing ───────────────────────────────────────────────────────────
const SECTION_HEADER_RE = /^\[(.+?)\]$/i;
// ORDER IS SIGNIFICANT. Matching is substring-based, so the most specific patterns
// must come first: 'Pre-Chorus' and 'Post-Chorus' both contain 'chorus', and were
// previously swallowed by the generic 'chorus' entry — meaning PC and POC could
// never be produced by the analyser at all.
//
// The instrumental catch-alls sit last on purpose, so labels that name a musical
// role ('Instrumental Bridge', 'Instrumental Outro') keep that role, while a bare
// 'Instrumental' or 'Instrumental Break' falls through to Interlude.
const SECTION_LABEL_PATTERNS: [string, string][] = [
['pre-chorus', 'PC'], ['pre chorus', 'PC'], ['prechorus', 'PC'],
['post-chorus', 'POC'], ['post chorus', 'POC'], ['postchorus', 'POC'],
['verse', 'V'],
['chorus', 'C'], ['hook', 'C'], ['refrain', 'C'],
['bridge', 'B'],
['intro', 'I'],
['outro', 'O'],
['interlude', 'IL'],
['break', 'IL'],
['instrumental', 'IL'],
['solo', 'IL'],
];
function normaliseSectionLabel(rawLabel: string): string {
const lower = rawLabel.toLowerCase().trim();
for (const [key, code] of SECTION_LABEL_PATTERNS) {
if (lower.includes(key)) return code;
}
return 'X';
}
function splitIntoSections(lyrics: string): { label: string, lines: string[] }[] {
const sections: { label: string, lines: string[] }[] = [];
let currentLabel = 'X';
let currentLines: string[] = [];
const lines = lyrics.split('\n');
for (const line of lines) {
const stripped = line.trim();
const match = SECTION_HEADER_RE.exec(stripped);
if (match) {
if (currentLines.length) sections.push({ label: currentLabel, lines: currentLines });
currentLabel = normaliseSectionLabel(match[1]);
currentLines = [];
} else if (stripped === '') {
if (currentLines.length) {
sections.push({ label: currentLabel, lines: currentLines });
currentLines = [];
}
} else {
currentLines.push(stripped);
}
}
if (currentLines.length) sections.push({ label: currentLabel, lines: currentLines });
return sections;
}
// ── Analysis functions ────────────────────────────────────────────────────────
function getLastWord(line: string): string {
const words = line.match(/[a-zA-Z']+/g);
return words ? words[words.length - 1].toLowerCase() : '';
}
function detectRhymeScheme(sectionLines: string[]): { scheme: string, quality: Record<string, int> } {
const lines = sectionLines.slice(0, 8);
const endings = lines.map(getLastWord);
const mapping: Record<string, string> = {};
let letterIdx = 0;
const scheme: string[] = [];
const qualityCounts: Record<string, int> = { perfect: 0, slant: 0, assonance: 0 };
for (const word of endings) {
if (!word) {
scheme.push('X');
continue;
}
let matched: string | null = null;
let bestQuality = 'none';
for (const [existingWord, letter] of Object.entries(mapping)) {
const q = rhymeQuality(word, existingWord);
if (q === 'perfect' || q === 'slant' || q === 'assonance') {
if (!matched || q === 'perfect') {
matched = letter;
bestQuality = q;
if (q === 'perfect') break;
}
}
}
if (matched) {
scheme.push(matched);
if (qualityCounts[bestQuality] !== undefined) {
qualityCounts[bestQuality]++;
}
} else {
const newLetter = String.fromCharCode(65 + Math.min(letterIdx, 25));
mapping[word] = newLetter;
scheme.push(newLetter);
letterIdx++;
}
}
return { scheme: scheme.join(''), quality: qualityCounts };
}
function analyseRhymes(allSongs: SongLyrics[]): { schemes: string[], quality: Record<string, int> } {
const schemeFreq: Record<string, int> = {};
const totalQuality: Record<string, int> = { perfect: 0, slant: 0, assonance: 0 };
for (const song of allSongs) {
const sections = splitIntoSections(song.lyrics);
for (const sec of sections) {
if ((sec.label === 'V' || sec.label === 'C') && sec.lines.length >= 2) {
const res = detectRhymeScheme(sec.lines);
schemeFreq[res.scheme] = (schemeFreq[res.scheme] || 0) + 1;
totalQuality.perfect += res.quality.perfect;
totalQuality.slant += res.quality.slant;
totalQuality.assonance += res.quality.assonance;
}
}
}
const topSchemes = Object.entries(schemeFreq)
.sort((a, b) => b[1] - a[1])
.slice(0, 5)
.map(x => x[0]);
return { schemes: topSchemes, quality: totalQuality };
}
function analyseStructure(allSongs: SongLyrics[]): { v: number, c: number, blueprints: string[] } {
let vCount = 0, vSum = 0;
let cCount = 0, cSum = 0;
const blueprintsFreq: Record<string, int> = {};
for (const song of allSongs) {
const sections = splitIntoSections(song.lyrics);
const labels: string[] = [];
for (const sec of sections) {
if (sec.label === 'V') { vCount++; vSum += sec.lines.length; }
else if (sec.label === 'C') { cCount++; cSum += sec.lines.length; }
if (!labels.length || labels[labels.length - 1] !== sec.label) {
labels.push(sec.label);
}
}
// A song with no recognised section headers parses to a single 'X' and has no
// observable structure. Counting those produced a bare 'X' blueprint that, being
// identical across every unheadered song, outranked every genuine structure.
if (!labels.some(l => l !== 'X')) continue;
const bp = labels.join('-');
if (bp) blueprintsFreq[bp] = (blueprintsFreq[bp] || 0) + 1;
}
const topBps = Object.entries(blueprintsFreq)
.sort((a, b) => b[1] - a[1])
.map(x => x[0])
// Sanitise medley/multi-part songs: truncate at Outro
.map(bp => {
const parts = bp.split('-');
const outroIdx = parts.indexOf('O');
return outroIdx >= 0 ? parts.slice(0, outroIdx + 1).join('-') : bp;
})
// Drop unparseable stretches so the generator is never handed a bare 'X'
.map(bp => bp.split('-').filter(p => p !== 'X').join('-'))
.filter(bp => bp.length > 0)
// Dedupe after truncation, then take the top 3
.filter((bp, i, arr) => arr.indexOf(bp) === i)
.slice(0, 3);
return {
v: vCount ? parseFloat((vSum / vCount).toFixed(1)) : 0,
c: cCount ? parseFloat((cSum / cCount).toFixed(1)) : 0,
blueprints: topBps
};
}
function analysePerspective(allSongs: SongLyrics[]): string {
let p1 = 0, p2 = 0, p3 = 0;
const fw = new Set(['i','me','my','mine','myself',"i'm","i've","i'll","i'd","im"]);
const sw = new Set(['you','your','yours','yourself',"you're","you've","you'll",'ya']);
const tw = new Set(['he','she','they','him','her','them','his','their','hers','theirs']);
for (const song of allSongs) {
const words = song.lyrics.toLowerCase().match(/[a-zA-Z']+/g) || [];
for (const w of words) {
if (fw.has(w)) p1++;
else if (sw.has(w)) p2++;
else if (tw.has(w)) p3++;
}
}
const total = p1 + p2 + p3;
if (!total) return "Indeterminate (no clear pronoun pattern)";
const pct1 = Math.round(100 * p1 / total);
const pct2 = Math.round(100 * p2 / total);
const pct3 = Math.round(100 * p3 / total);
const parts: string[] = [];
if (pct1 >= 50) parts.push(`First-person dominant (${pct1}% I/me/my)`);
if (pct2 >= 30) parts.push(`Second-person address (${pct2}% you/your)`);
if (pct3 >= 30) parts.push(`Third-person narrative (${pct3}% he/she/they)`);
if (!parts.length) parts.push(`Mixed voice (${pct1}% first / ${pct2}% second / ${pct3}% third)`);
if (pct1 >= 70) parts.push("— confessional / introspective style");
else if (pct1 >= 50 && pct2 >= 20) parts.push("— conversational / direct address style");
else if (pct2 >= 50) parts.push("— confrontational / accusatory style");
else if (pct3 >= 40) parts.push("— storytelling / observational style");
return parts.join(' ');
}
function analyseMeter(allSongs: SongLyrics[]): Record<string, any> {
const sylCounts: int[] = [];
const wordCounts: int[] = [];
const charCounts: int[] = [];
for (const song of allSongs) {
const lines = song.lyrics.split('\n');
for (let line of lines) {
line = line.trim();
if (!line || SECTION_HEADER_RE.test(line)) continue;
const words = line.match(/[a-zA-Z']+/g);
if (!words) continue;
const syl = words.reduce((acc: number, w: string) => acc + countSyllables(w), 0);
sylCounts.push(syl);
wordCounts.push(words.length);
charCounts.push(line.length);
}
}
if (!sylCounts.length) {
return { avg_syllables_per_line: 0, syllable_std_dev: 0, avg_words_per_line: 0, line_length_range: "0-0" };
}
const avgSyl = sylCounts.reduce((a, b) => a + b, 0) / sylCounts.length;
const variance = sylCounts.reduce((a, b) => a + Math.pow(b - avgSyl, 2), 0) / sylCounts.length;
const avgWords = wordCounts.reduce((a, b) => a + b, 0) / wordCounts.length;
return {
avg_syllables_per_line: parseFloat(avgSyl.toFixed(1)),
syllable_std_dev: parseFloat(Math.sqrt(variance).toFixed(1)),
avg_words_per_line: parseFloat(avgWords.toFixed(1)),
line_length_range: `${Math.min(...charCounts)}-${Math.max(...charCounts)} chars`
};
}
const COMMON_WORDS = new Set("the a an and or but if in on at to for of is it its that this with from by as are was were be been being have has had do does did will would shall should can could may might must not no nor so than too very just all each every both few more most other some such any only same also how when where why what which who whom i me my mine we us our they them their he him his she her you your about after again against between into through during before up down out off over under there here then now get got like go going know want need make take come think say tell give see feel find keep let put seem still try call ask look show turn move live help start run write set play hold bring happen begin walk talk love well back even new way day man right old big long little much good great first last time thing part work world life hand oh yeah hey ah oh uh ooh la da na hoo hey".split(' '));
function analyseVocabulary(allSongs: SongLyrics[]): Record<string, any> {
const allWords: string[] = [];
let contractions = 0;
let profanity = 0;
const contRe = /\b(?:i[''']m|i[''']ve|i[''']ll|i[''']d|don[''']t|doesn[''']t|didn[''']t|won[''']t|wouldn[''']t|can[''']t|couldn[''']t|shouldn[''']t|isn[''']t|aren[''']t|wasn[''']t|weren[''']t|haven[''']t|hasn[''']t|hadn[''']t|ain[''']t|it[''']s|that[''']s|what[''']s|there[''']s|here[''']s|who[''']s|let[''']s|you[''']re|you[''']ve|you[''']ll|you[''']d|we[''']re|we[''']ve|we[''']ll|we[''']d|they[''']re|they[''']ve|they[''']ll|they[''']d|he[''']s|she[''']s|gonna|wanna|gotta|kinda|sorta|nothin[''']|somethin[''']|burnin[''']|growin[''']|draggin[''']|shaggin[''']|feelin[''']|whinin['''])\b/gi;
const profRe = new Set("shit fuck fucking fucked damn damn ass hell bitch bastard crap piss dick".split(' '));
for (const song of allSongs) {
const text = song.lyrics.toLowerCase();
const words = text.match(/[a-zA-Z']+/g) || [];
allWords.push(...words);
const contMatches = text.match(contRe);
if (contMatches) contractions += contMatches.length;
for (const w of words) {
if (profRe.has(w)) profanity++;
}
}
const total = allWords.length;
if (!total) return { type_token_ratio: 0, total_words: 0, unique_words: 0, contraction_pct: 0, profanity_pct: 0, distinctive_words: [] };
const unique = new Set(allWords);
const ttr = unique.size / total;
const wordFreq: Record<string, int> = {};
for (const w of allWords) wordFreq[w] = (wordFreq[w] || 0) + 1;
const distinctive = Object.entries(wordFreq)
.filter(([w]) => !COMMON_WORDS.has(w) && w.length > 2)
.sort((a, b) => b[1] - a[1])
.slice(0, 15)
.map(x => x[0]);
return {
type_token_ratio: parseFloat(ttr.toFixed(3)),
total_words: total,
unique_words: unique.size,
contraction_pct: parseFloat((100 * contractions / total).toFixed(1)),
profanity_pct: parseFloat((100 * profanity / total).toFixed(1)),
distinctive_words: distinctive
};
}
function analyseLineLengthVariation(allSongs: SongLyrics[]): Record<string, any> {
const sectionSyl: Record<string, int[]> = { V: [], C: [], B: [] };
const allSyl: int[] = [];
const examples: { short: {s:int, l:string}[], long: {s:int, l:string}[] } = { short: [], long: [] };
for (const song of allSongs) {
const sections = splitIntoSections(song.lyrics);
for (const sec of sections) {
for (const line of sec.lines) {
const words = line.match(/[a-zA-Z']+/g);
if (!words) continue;
const syl = words.reduce((acc: number, w: string) => acc + countSyllables(w), 0);
allSyl.push(syl);
if (sectionSyl[sec.label]) sectionSyl[sec.label].push(syl);
if (syl <= 4) examples.short.push({ s: syl, l: line.trim() });
else examples.long.push({ s: syl, l: line.trim() });
}
}
}
if (!allSyl.length) return {};
const buckets = { '1-4': 0, '5-7': 0, '8-10': 0, '11-14': 0, '15+': 0 };
for (const s of allSyl) {
if (s <= 4) buckets['1-4']++;
else if (s <= 7) buckets['5-7']++;
else if (s <= 10) buckets['8-10']++;
else if (s <= 14) buckets['11-14']++;
else buckets['15+']++;
}
const hist: Record<string, int> = {};
for (const [k, v] of Object.entries(buckets)) hist[k] = Math.round(100 * v / allSyl.length);
const perSection: Record<string, any> = {};
for (const [lbl, counts] of Object.entries(sectionSyl)) {
if (counts.length) {
const avg = counts.reduce((a,b)=>a+b,0)/counts.length;
const std = Math.sqrt(counts.reduce((a,b)=>a+Math.pow(b-avg,2),0)/counts.length);
perSection[lbl] = {
min: Math.min(...counts), max: Math.max(...counts),
avg: parseFloat(avg.toFixed(1)), std: parseFloat(std.toFixed(1))
};
}
}
const shortEx = examples.short.sort((a,b) => a.s - b.s).slice(0,3).map(x => `(${x.s} syl) ${x.l}`);
const longEx = examples.long.sort((a,b) => b.s - a.s).slice(0,3).map(x => `(${x.s} syl) ${x.l}`);
return { histogram: hist, per_section: perSection, short_line_examples: shortEx, long_line_examples: longEx };
}
function analyseRepetition(allSongs: SongLyrics[]): Record<string, any> {
let cTotal = 0, cRepeat = 0;
let vTotal = 0, vRepeat = 0;
const hookEx: string[] = [];
const globalLines: string[] = [];
for (const song of allSongs) {
const sections = splitIntoSections(song.lyrics);
for (const sec of sections) {
if (sec.label === 'C' && sec.lines.length >= 2) {
cTotal += sec.lines.length;
const counts: Record<string, int> = {};
sec.lines.forEach((l: string) => { const s = l.trim().toLowerCase(); counts[s] = (counts[s]||0)+1; });
for (const [l, c] of Object.entries(counts)) {
if (c > 1) {
cRepeat += c;
if (hookEx.length < 5 && l) hookEx.push(l);
}
}
} else if (sec.label === 'V' && sec.lines.length >= 2) {
vTotal += sec.lines.length;
const counts: Record<string, int> = {};
sec.lines.forEach((l: string) => { const s = l.trim().toLowerCase(); counts[s] = (counts[s]||0)+1; });
for (const c of Object.values(counts)) if (c > 1) vRepeat += c;
}
}
song.lyrics.split('\n').filter((l: string) => l.trim() && !SECTION_HEADER_RE.test(l)).forEach((l: string) => globalLines.push(l.trim().toLowerCase()));
}
const gCounts: Record<string, int> = {};
globalLines.forEach(l => gCounts[l] = (gCounts[l]||0)+1);
const crossRepeat = Object.values(gCounts).filter(c => c >= 3).length;
const cPct = Math.round(100 * cRepeat / Math.max(cTotal, 1));
const vPct = Math.round(100 * vRepeat / Math.max(vTotal, 1));
let pattern = "low-repetition: choruses mostly unique lines";
if (cPct >= 50) pattern = "heavy-hook: choruses built around repeated lines";
else if (cPct >= 20) pattern = "moderate-hook: choruses use some repeated lines";
return { chorus_repetition_pct: cPct, verse_repetition_pct: vPct, cross_section_repeats: crossRepeat, pattern, hook_examples: hookEx.slice(0,5) };
}
function selectRepresentativeExcerpts(allSongs: SongLyrics[], maxExcerpts = 5): string[] {
const candidates: { score: number, title: string, text: string }[] = [];
for (const song of allSongs) {
const sections = splitIntoSections(song.lyrics);
for (const sec of sections) {
if ((sec.label !== 'V' && sec.label !== 'C') || sec.lines.length < 2) continue;
const text = sec.lines.join('\n');
const lengthScore = 1.0 - Math.abs(sec.lines.length - 6) * 0.15;
const res = detectRhymeScheme(sec.lines);
const rhymeScore = res.quality.perfect * 1.0 + res.quality.slant * 0.6 + res.quality.assonance * 0.3;
const sectionName = sec.label === 'V' ? 'Verse' : 'Chorus';
candidates.push({ score: lengthScore + rhymeScore, title: `${song.title} (${sectionName})`, text });
}
}
candidates.sort((a,b) => b.score - a.score);
const excerpts: string[] = [];
const seen = new Set<string>();
for (const c of candidates) {
if (!seen.has(c.text)) {
seen.add(c.text);
excerpts.push(`[${c.title}]\n${c.text}`);
if (excerpts.length >= maxExcerpts) break;
}
}
return excerpts;
}
// ── LLM Caller ────────────────────────────────────────────────────────────────
const MAX_RETRIES = 2;
async function llmCallWithRetry(providerName: string, modelName: string, sysPrompt: string, usrPrompt: string, label: string, onPhase?: (p:string)=>void, onChunk?: ChunkCallback): Promise<{ raw: string, data: any }> {
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
if (attempt > 1 && onPhase) onPhase(`${label} (retry ${attempt}/${MAX_RETRIES})…`);
try {
const provider = llmService.getProvider(providerName);
const raw = await provider.call(sysPrompt, usrPrompt, modelName, onChunk);
const data = extractJson(raw);
if (data) return { raw, data };
} catch (e) {
console.warn(`${label}: Attempt ${attempt} failed`, e);
}
}
return { raw: '', data: {} };
}
// buildProfilePrompt + the subject-analysis system prompt live in prompts.ts
// (the canonical prompt source shared with the MCP server).
async function analyseSongSubjects(songs: SongLyrics[], providerName: string, modelName: string, onChunk?: ChunkCallback): Promise<{song_subjects: Record<string, string>, subject_categories: string[]}> {
const usrPrompt = buildSubjectAnalysisPrompt(songs);
const provider = llmService.getProvider(providerName);
try {
const raw = await provider.call(SUBJECT_ANALYSIS_PROMPT, usrPrompt, modelName, onChunk);
const data = extractJson(raw);
if (data) return { song_subjects: data.song_subjects || {}, subject_categories: data.subject_categories || [] };
} catch (e) {
console.warn("Subject LLM call failed", e);
}
return { song_subjects: {}, subject_categories: [] };
}
function coerceStr(val: any): string {
if (Array.isArray(val)) return val.join('\n');
return val ? String(val) : "";
}
type int = number;
export async function buildProfile(
artist: string,
album: string | null,
songs: SongLyrics[],
providerName: string,
modelName?: string,
onPhase?: (phase: string) => void,
onChunk?: ChunkCallback
): Promise<Partial<LyricsProfile>> {
const effModel = modelName || llmService.getProvider(providerName).defaultModel;
const struct = analyseStructure(songs);
const rhyme = analyseRhymes(songs);
const perspective = analysePerspective(songs);
const meter = analyseMeter(songs);
const vocab = analyseVocabulary(songs);
const llv = analyseLineLengthVariation(songs);
const rep = analyseRepetition(songs);
const excerpts = selectRepresentativeExcerpts(songs);
meter.line_length_variation = llv;
const ruleStats = {
avg_verse_lines: struct.v,
avg_chorus_lines: struct.c,
rhyme_schemes: rhyme.schemes,
rhyme_quality: rhyme.quality,
structure_blueprints: struct.blueprints,
perspective,
meter_stats: meter,
vocabulary_stats: vocab,
repetition_stats: rep
};
const usrPrompt = buildProfilePrompt(artist, album, songs, ruleStats);
const merged: Record<string, any> = {};
const raws: string[] = [];
if (onPhase) onPhase("Analysing themes & vocabulary… (1/4)");
const res1 = await llmCallWithRetry(providerName, effModel, PROFILE_PROMPT_1, usrPrompt, "Call 1/4", onPhase, onChunk);
raws.push(res1.raw); Object.assign(merged, res1.data);
if (onPhase) onPhase("Analysing tone & structure… (2/4)");
const res2 = await llmCallWithRetry(providerName, effModel, PROFILE_PROMPT_2, usrPrompt, "Call 2/4", onPhase, onChunk);
raws.push(res2.raw); Object.assign(merged, res2.data);
if (onPhase) onPhase("Analysing imagery & signature… (3/4)");
const res3 = await llmCallWithRetry(providerName, effModel, PROFILE_PROMPT_3, usrPrompt, "Call 3/4", onPhase, onChunk);
raws.push(res3.raw); Object.assign(merged, res3.data);
if (onPhase) onPhase("Analysing song subjects… (4/5)");
const subjects = await analyseSongSubjects(songs, providerName, effModel, onChunk);
// Step 5: Generate style caption (non-critical)
let style_caption = '';
try {
if (onPhase) onPhase("Generating style caption… (5/5)");
const captionUserPrompt = [
`Artist: ${artist}`,
album ? `Album: ${album}` : '',
merged.themes?.length ? `Themes: ${merged.themes.join(', ')}` : '',
merged.tone_and_mood ? `Tone and mood: ${merged.tone_and_mood}` : '',
merged.vocabulary_notes ? `Vocabulary: ${merged.vocabulary_notes}` : '',
].filter(Boolean).join('\n');
const provider = llmService.getProvider(providerName);
const captionRaw = await provider.call(STYLE_CAPTION_PROMPT, captionUserPrompt, effModel, onChunk);
// Strip any wrapping quotes or whitespace
style_caption = captionRaw.replace(/^["'`]+|["'`]+$/g, '').trim();
} catch (e) {
console.warn('Style caption generation failed (non-critical):', e);
}
const rawCombined = raws.join('\n\n---\n\n');
return {
artist,
album: album || undefined,
// Deterministic, never LLM-derived — measured facts from the source audio.
audio_enrichment: computeAlbumEnrichment(songs),
themes: merged.themes || [],
common_subjects: merged.common_subjects || [],
rhyme_schemes: merged.rhyme_schemes || rhyme.schemes,
avg_verse_lines: merged.avg_verse_lines || struct.v,
avg_chorus_lines: merged.avg_chorus_lines || struct.c,
vocabulary_notes: coerceStr(merged.vocabulary_notes),
tone_and_mood: coerceStr(merged.tone_and_mood),
structural_patterns: coerceStr(merged.structural_patterns),
additional_notes: coerceStr(merged.additional_notes),
raw_summary: coerceStr(merged.raw_summary || rawCombined),
// Extra parsed data
structure_blueprints: struct.blueprints,
perspective,
meter_stats: meter,
vocabulary_stats: vocab,
representative_excerpts: excerpts,
narrative_techniques: coerceStr(merged.narrative_techniques),
imagery_patterns: coerceStr(merged.imagery_patterns),
signature_devices: coerceStr(merged.signature_devices),
emotional_arc: coerceStr(merged.emotional_arc),
rhyme_quality: rhyme.quality as any, // bypassing strict TS checking for complex types
song_subjects: subjects.song_subjects as any,
subject_categories: subjects.subject_categories,
repetition_stats: rep as any,
style_caption,
};
}
/**
* Re-runs all local (non-LLM) statistical analysis on an existing profile's
* lyrics set and patches the profile_data in place. This is fast — no LLM calls.
*/
export function recalculateProfileStats(songs: SongLyrics[], profileData: any): any {
const vocab = analyseVocabulary(songs);
const meter = analyseMeter(songs);
const rhyme = analyseRhymes(songs);
const struct = analyseStructure(songs);
const rep = analyseRepetition(songs);
const lineVar = analyseLineLengthVariation(songs);
const perspective = analysePerspective(songs);
const excerpts = selectRepresentativeExcerpts(songs);
return {
...profileData,
// Overwrite computed stats
audio_enrichment: computeAlbumEnrichment(songs),
vocabulary_stats: vocab,
meter_stats: meter,
rhyme_schemes: rhyme.schemes,
rhyme_quality: rhyme.quality as any,
avg_verse_lines: struct.v,
avg_chorus_lines: struct.c,
repetition_stats: rep as any,
line_length_variation: lineVar,
perspective,
// These two are derived here as well as in buildProfile — without them, profiles
// created outside the app (e.g. via the MCP) have no structural vocabulary for
// the generator to plan a structure from, and no style excerpts to imitate.
structure_blueprints: struct.blueprints,
representative_excerpts: excerpts,
};
}
File diff suppressed because it is too large Load Diff
+370
View File
@@ -0,0 +1,370 @@
// slopDetector.ts — AI-Slop Detection and Prevention System
//
// 7-layer defense to ensure generated lyrics feel authentic.
// Direct port from Python slop_detector.py — all patterns preserved.
// Layer 7 added based on corpus analysis of 608 generations (2026-05-02).
// ── Layer 1 — Blacklisted Words ─────────────────────────────────────────────
export const BLACKLISTED_WORDS = new Set([
// Visual clichés
'neon', 'streetlights', 'streetlight', 'silhouette', 'silhouettes',
'tapestry', 'mosaic', 'kaleidoscope', 'prism',
// Action clichés
'yearning', 'beckons', 'beckoning', 'cascading', 'cascade',
'unfurling', 'unfurl',
// Emotional clichés
'bittersweet', 'melancholy', 'wistful', 'poignant', 'ethereal', 'ephemeral',
// Abstract concepts
'symphony', 'harmonize', 'harmonizing', 'crossroads',
// Overused metaphors
'phoenix', 'labyrinth', 'soaring',
// Generic intensity
'pulsing', 'pulsating', 'throbbing', 'vibrant', 'vivid', 'luminous',
'radiant', 'shimmering',
// Time clichés
'hourglass', 'timeless',
// Nature clichés
'tempest',
// Existential clichés
'essence', 'consciousness', 'realm', 'dimension',
// Modern AI clichés (2024-2026 patterns)
'unraveling', 'unravel', 'ember', 'embers', 'ignite', 'ignites',
'resonate', 'resonates', 'reverberate', 'reverberates',
// Faux-poetic
'amidst', 'entwined', 'intertwined', 'ablaze',
// Cosmic clichés
'constellation', 'constellations', 'cosmos', 'infinite', 'infinity', 'void',
// Over-emotional constructions
'shattering', 'hollowed',
// Synesthesia clichés
'crimson sky', 'velvet night',
// Generic AI title/filler words
'static', 'catalyst', 'paradox', 'paradigm', 'mantra', 'epitome',
'chronicles', 'solace', 'juxtaposition', 'serenity', 'resilience',
'dichotomy', 'transcend', 'transcendence', 'metamorphosis', 'pinnacle',
// Lighting clichés
'fluorescent', 'halogen',
// Corpus-analysis additions (2026-05-02) — overused across 608 generations
'wreckage', 'jagged', 'bitter', 'hollow',
'steel', 'metal', 'transmission', 'dashboard', 'gears', 'gloom',
// Tech-slop additions (2026-06-05) — user-flagged + corpus analysis
// These get slathered across all genres regardless of artist fit
'digital', 'algorithm', 'algorithms', 'chrome',
'code', 'circuit', 'circuits', 'grid', 'data',
'wire', 'wires', 'wired',
]);
// ── Layer 1b — Overused Words (soft-ban: penalized per-occurrence, not banned) ──
// These words aren't inherently bad but the model leans on them like a crutch.
// "heavy" alone appeared 811 times across 64.3% of 608 songs.
export const OVERUSED_WORDS = new Set([
'heavy', 'broken', 'cold', 'dust', 'ghost', 'machine',
'nothing', 'nowhere', 'searching', 'wreckage', 'losing',
// Corpus-analysis additions (2026-06-05) — overused in hooks/titles across 992 generations
'watch', 'burn', 'fade', 'fading', 'wash', 'sold',
'dead', 'blood', 'gold', 'same',
]);
// ── Layer 2 — Blacklisted Phrases ───────────────────────────────────────────
export const BLACKLISTED_PHRASES = new Set([
'reaching up to the sky', 'beneath the streetlights', 'under the streetlights',
'neon lights', 'neon glow', 'neon dreams', 'echoes in the night',
'whispers in the dark', 'shadows dance', 'dancing shadows',
'tapestry of dreams', 'symphony of', 'kaleidoscope of',
'mosaic of emotions', 'bittersweet memories', 'fleeting moments',
'sands of time', 'rising from the ashes', 'like a phoenix',
'tangled web', 'labyrinth of', 'journey begins', 'path ahead',
'crossroads of', 'fabric of reality', 'threads of fate',
'ocean of tears', 'sea of faces', 'waves of emotion',
'storm within', 'tempest raging', 'essence of', 'realm of',
'universe within', 'consciousness expands', 'vivid dreams',
'radiant light', 'pulsing with', 'cascading down',
'ethereal beauty', 'melancholy mood', 'beckons me', 'yearning for',
// Expanded set
'paint the sky', 'written in the stars', 'dance with the devil',
'scream into the void', 'drown in your eyes', 'heart on my sleeve',
'break the chains', 'find my voice', 'lost in the moment',
'through the fire', 'edge of forever', 'weight of the world',
'paint a picture', 'piece by piece', 'shattered glass',
'hollow eyes', 'burning bridges', 'chase the sun',
'bleeding heart', 'silent scream', 'torn apart',
'crumbling walls', 'whisper your name', 'dust settles',
'ghost of you', 'ashes to ashes', 'taste of freedom',
'colors of the wind', 'sound of silence',
'in this moment', 'against the tide', 'into the unknown',
'carry the weight', 'unravel the truth', 'embers glow',
'spark ignites', 'constellations align', 'resonates within',
'let it all go', 'rise above it all',
// Corpus-analysis additions (2026-05-02)
'nothing left', 'nowhere left', 'nothing left to',
'the weight of', 'the wreckage', 'same old',
'every single', 'cold and heavy', 'cold and dark',
'cold and empty', 'heavy and cold', 'heavy and dark',
'pulling me down', 'dragging me down',
]);
// ── Layer 8 — Overused Hook Formulas ────────────────────────────────────────
// These structural patterns produce identical-sounding hooks across genres.
// Each matches a common LLM comfort pattern for chorus writing.
// Penalty: +5 per match in a chorus section.
export const OVERUSED_HOOK_PATTERNS: RegExp[] = [
// "[Verb] it [all/down/away/out/off/back/up]" — 46 titles used this
/\b(watch|burn|tear|wash|break|crush|push|pull|let|cut|rip|smash|turn)\s+it\s+(all|down|away|out|off|back|up)\b/gi,
// "Watch [me/it/them/us] [verb]" — 25 titles
/\bwatch\s+(me|it|them|us|him|her)\s+\w+/gi,
// "Don't let them [verb]" — 7 titles
/\bdon'?t\s+let\s+(them|him|her|it)\s+\w+/gi,
// "Nothing left to [verb]" / "Nowhere left to [verb]"
/\b(nothing|nowhere)\s+left\s+to\s+\w+/gi,
// "Let it [verb/adjective]" — generic release formula
/\blet\s+it\s+(burn|fade|go|fall|bleed|break|rot|die|end|crash|crumble|drown)\b/gi,
// "[Verb]ing it all [away/down]"
/\b\w+ing\s+it\s+all\s+(away|down)\b/gi,
];
// ── Layer 3 — Regex Patterns ────────────────────────────────────────────────
const AI_PATTERNS = [
/\b(tapestry|fabric|symphony|kaleidoscope|mosaic|labyrinth|maze|void)\s+of\s+\w+\b/gi,
/\blike\s+a\s+(phoenix|symphony|kaleidoscope|constellation|ember)\b/gi,
/\b\w+ing\s+\w+ing\b/gi,
/\bthe\s+\w+\s+of\s+my\s+\w+\b/gi,
/^In\s+the\s+(darkness|silence|shadows|distance|stillness|emptiness)\b/gim,
// Corpus-analysis additions — "cold and [X]" / "heavy [noun]" overuse patterns
/\bcold\s+and\s+\w+\b/gi,
/\bheavy\s+(weight|hand|heart|air|load|chain|sky|dust|night|crown|veil|rain|door|stone|iron|fog|clouds?)\b/gi,
];
// ── Layer 4 — Structural Analysis ───────────────────────────────────────────
const FUNCTION_WORDS = new Set([
'the', 'a', 'an', 'of', 'in', 'to', 'and', 'is', 'it', 'for', 'on',
'with', 'as', 'at', 'by', 'from', 'or', 'but', 'not', 'be', 'are',
'was', 'were', 'been', 'this', 'that', 'which', 'who', 'what', 'if',
'so', 'my', 'your', 'we', 'they', 'i', 'you', 'he', 'she', 'me', 'us',
]);
function extractWords(text: string): string[] {
return (text.toLowerCase().match(/\b[a-zA-Z]+(?:'[a-zA-Z]+)?\b/g) ?? []);
}
function analyzeStructure(lines: string[]): { issues: string[]; score: number } {
const issues: string[] = [];
let score = 0;
if (lines.length < 4) return { issues, score };
const wordCounts = lines.filter(l => l.trim()).map(l => l.split(/\s+/).length);
if (!wordCounts.length) return { issues, score };
const mean = wordCounts.reduce((a, b) => a + b, 0) / wordCounts.length;
const variance = wordCounts.reduce((a, wc) => a + (wc - mean) ** 2, 0) / wordCounts.length;
const stddev = Math.sqrt(variance);
if (wordCounts.length >= 6 && stddev < 1.0) {
issues.push(`Line lengths are suspiciously uniform (stddev=${stddev.toFixed(2)})`);
score += 10;
}
// First word repetition
const firstWords = lines.filter(l => l.trim()).map(l => l.trim().split(/\s+/)[0]?.toLowerCase()).filter(Boolean);
if (firstWords.length) {
const freq = new Map<string, number>();
for (const w of firstWords) freq.set(w, (freq.get(w) ?? 0) + 1);
let maxWord = '', maxCount = 0;
for (const [w, c] of freq) { if (c > maxCount) { maxWord = w; maxCount = c; } }
const ratio = maxCount / firstWords.length;
if (ratio > 0.5 && maxCount >= 3) {
issues.push(`Over-repetitive line starter '${maxWord}' (${maxCount}/${firstWords.length} = ${(ratio * 100).toFixed(0)}%)`);
score += 8;
}
}
return { issues, score };
}
function detectAnomalies(text: string): { issues: string[]; score: number } {
const issues: string[] = [];
let score = 0;
const words = extractWords(text);
if (words.length < 20) return { issues, score };
const freq = new Map<string, number>();
for (const w of words) freq.set(w, (freq.get(w) ?? 0) + 1);
const unique = freq.size;
// Hapax ratio
let hapax = 0;
for (const c of freq.values()) if (c === 1) hapax++;
const hapaxRatio = unique ? hapax / unique : 0;
if (hapaxRatio > 0.85 && unique > 20) {
issues.push(`Very high hapax ratio (${hapaxRatio.toFixed(2)}) — too many unique-once words`);
score += 6;
}
// Function word density
let funcCount = 0;
for (const fw of FUNCTION_WORDS) funcCount += freq.get(fw) ?? 0;
const funcRatio = words.length ? funcCount / words.length : 0;
if (funcRatio > 0.38) {
issues.push(`High function word density (${funcRatio.toFixed(2)}) — reads more like prose than lyrics`);
score += 5;
}
// Line opener variety
const lyricLines = text.split('\n')
.map(l => l.trim())
.filter(l => l && !l.startsWith('[') && !l.startsWith('('));
if (lyricLines.length >= 6) {
const openers = lyricLines.map(l => l.split(/\s+/)[0]?.toLowerCase()).filter(Boolean);
const openerUnique = new Set(openers).size / openers.length;
if (openerUnique < 0.3) {
issues.push(`Very low line opener variety (${openerUnique.toFixed(2)})`);
score += 5;
} else if (openerUnique > 0.95 && openers.length > 8) {
issues.push('Suspiciously perfect line opener variety — real lyrics naturally repeat some starters');
score += 3;
}
}
return { issues, score };
}
// ── Main Scan Function ──────────────────────────────────────────────────────
export interface SlopScanResult {
ai_score: number;
severity: 'high' | 'medium' | 'low';
is_likely_ai: boolean;
layers: {
blacklisted_words: { score: number; found: string[] };
blacklisted_phrases: { score: number; found: string[] };
pattern_matches: { score: number; found: [string, string][] };
structural: { score: number; raw_score: number; issues: string[] };
fingerprint: { score: number; raw_score: number; issues: string[] };
statistical: { score: number; raw_score: number; issues: string[] };
overuse: { score: number; found: { word: string; count: number }[] };
hook_formulas: { score: number; found: string[] };
};
}
export function scanForSlop(
text: string,
fingerprint?: Record<string, any> | null,
statisticalWeight = 1.0,
): SlopScanResult {
// Strip section tags and performance notes
const clean = text.replace(/\[.*?\]/g, '').replace(/\(.*?\)/g, '');
const words = extractWords(clean);
const lines = clean.split('\n').map(l => l.trim()).filter(l => l.length > 1);
// Layer 1: Blacklisted words
const badWords = words.filter(w => BLACKLISTED_WORDS.has(w));
const l1Score = badWords.length * 10;
// Layer 2: Blacklisted phrases
const textLower = clean.toLowerCase();
const badPhrases = [...BLACKLISTED_PHRASES].filter(p => textLower.includes(p));
const l2Score = badPhrases.length * 20;
// Layer 3: Regex patterns
const badPatterns: [string, string][] = [];
for (const pat of AI_PATTERNS) {
pat.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pat.exec(clean)) !== null) {
badPatterns.push([pat.source, m[0]]);
}
}
const l3Score = badPatterns.length * 5;
// Layer 4: Structural
const l4 = analyzeStructure(lines);
// Layer 5: Fingerprint (basic comparison if provided)
const l5 = { issues: [] as string[], score: 0 };
if (fingerprint) {
const safeVocab = new Set<string>(fingerprint.safe_vocabulary ?? []);
if (safeVocab.size) {
const wordSet = new Set(words);
let overlap = 0;
for (const w of wordSet) if (safeVocab.has(w)) overlap++;
const overlapRatio = wordSet.size ? overlap / wordSet.size : 0;
if (overlapRatio < 0.5) {
l5.issues.push(`Low vocabulary overlap with source artist (${(overlapRatio * 100).toFixed(0)}% vs expected ≥50%)`);
l5.score += 12;
}
}
const artistTtr = fingerprint.type_token_ratio;
if (artistTtr != null) {
const genTtr = words.length ? new Set(words).size / words.length : 0;
const diff = Math.abs(genTtr - artistTtr);
if (diff > 0.15) {
l5.issues.push(`TTR mismatch: generated=${genTtr.toFixed(3)}, artist=${artistTtr.toFixed(3)}`);
l5.score += 8;
}
}
}
// Layer 6: Statistical anomalies
const l6 = detectAnomalies(clean);
// Layer 7: Overused word detection (soft-ban)
// Penalize +3 per occurrence after the first — these aren't banned but the model
// uses them as a crutch across genres ("heavy" alone: 811x in 608 songs).
const overuseFound: { word: string; count: number }[] = [];
let l7Score = 0;
const wordFreq = new Map<string, number>();
for (const w of words) {
if (OVERUSED_WORDS.has(w)) wordFreq.set(w, (wordFreq.get(w) ?? 0) + 1);
}
for (const [word, count] of wordFreq) {
if (count > 1) {
const penalty = (count - 1) * 3; // +3 per occurrence after the first
l7Score += penalty;
overuseFound.push({ word, count });
}
}
// Layer 8: Overused hook formula detection
// Only scans chorus sections to target hook patterns specifically
const chorusSections = text.split(/\[(?:Chorus|Hook).*?\]/i).slice(1)
.map(s => s.split(/\[/)[0]?.trim()).filter(Boolean);
const chorusText = chorusSections.join('\n');
const hookFormulaFound: string[] = [];
let l8Score = 0;
if (chorusText) {
for (const pat of OVERUSED_HOOK_PATTERNS) {
pat.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = pat.exec(chorusText)) !== null) {
hookFormulaFound.push(m[0]);
l8Score += 5;
}
}
}
const sw = Math.max(0, Math.min(1, statisticalWeight));
const total = l1Score + l2Score + l3Score +
Math.floor(l4.score * sw) + Math.floor(l5.score * sw) + Math.floor(l6.score * sw) +
l7Score + l8Score;
return {
ai_score: total,
severity: total > 30 ? 'high' : total > 15 ? 'medium' : 'low',
is_likely_ai: total > 15,
layers: {
blacklisted_words: { score: l1Score, found: [...new Set(badWords)] },
blacklisted_phrases: { score: l2Score, found: badPhrases },
pattern_matches: { score: l3Score, found: badPatterns.slice(0, 10) },
structural: { score: Math.floor(l4.score * sw), raw_score: l4.score, issues: l4.issues },
fingerprint: { score: Math.floor(l5.score * sw), raw_score: l5.score, issues: l5.issues },
statistical: { score: Math.floor(l6.score * sw), raw_score: l6.score, issues: l6.issues },
overuse: { score: l7Score, found: overuseFound },
hook_formulas: { score: l8Score, found: hookFormulaFound },
},
};
}
+195
View File
@@ -0,0 +1,195 @@
// logger.ts — File-based logging system
//
// Mirrors the hot-step-9000 logging architecture:
// logs/<session-timestamp>/
// ├── node_console.log — all console output (mirrored transparently)
// ├── ace_engine.log — ace-server stdout/stderr
// └── generations/
// └── gen_<jobId>_<type>.log — per-generation logs
import fs from 'fs';
import path from 'path';
import { pushLog } from '../routes/logs.js';
import { PROJECT_ROOT } from '../config.js';
const projectRoot = PROJECT_ROOT;
/** Current session log directory (null if not initialized) */
let sessionDir: string | null = null;
let generationsDir: string | null = null;
let consoleLogStream: fs.WriteStream | null = null;
let engineLogStream: fs.WriteStream | null = null;
/** Per-generation log buffers: jobId → lines[] */
const generationBuffers = new Map<string, string[]>();
function timestamp(): string {
const d = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}_${pad(d.getHours())}-${pad(d.getMinutes())}-${pad(d.getSeconds())}`;
}
function isoTimestamp(): string {
return new Date().toISOString();
}
/**
* Initialize the logging system. Call once at server startup, before any
* console output you care about.
*/
export function initLogger(): string {
const logsRoot = path.join(projectRoot, 'logs');
if (!fs.existsSync(logsRoot)) {
fs.mkdirSync(logsRoot, { recursive: true });
}
sessionDir = path.join(logsRoot, timestamp());
fs.mkdirSync(sessionDir, { recursive: true });
generationsDir = path.join(sessionDir, 'generations');
fs.mkdirSync(generationsDir, { recursive: true });
// Open console log stream
const consoleLogPath = path.join(sessionDir, 'node_console.log');
consoleLogStream = fs.createWriteStream(consoleLogPath, { flags: 'a' });
// Open engine log stream
const engineLogPath = path.join(sessionDir, 'ace_engine.log');
engineLogStream = fs.createWriteStream(engineLogPath, { flags: 'a' });
// Hook stdout/stderr to mirror transparently (same pattern as hot-step-9000)
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
(process.stdout as any).write = (chunk: any, encoding?: any, callback?: any) => {
try { consoleLogStream?.write(chunk); } catch { /* ignore */ }
return originalStdoutWrite(chunk, encoding, callback);
};
(process.stderr as any).write = (chunk: any, encoding?: any, callback?: any) => {
try { consoleLogStream?.write(chunk); } catch { /* ignore */ }
return originalStderrWrite(chunk, encoding, callback);
};
consoleLogStream.write(`[Logger] Session started at ${isoTimestamp()}\n`);
consoleLogStream.write(`[Logger] Log directory: ${sessionDir}\n`);
return sessionDir;
}
/**
* Write a line to the ace-engine log file.
* Call this from the ace-server child process stdout/stderr handlers.
*/
export function logEngine(line: string): void {
if (!engineLogStream) return;
try {
engineLogStream.write(line.endsWith('\n') ? line : line + '\n');
} catch { /* ignore */ }
}
/**
* Start capturing log lines for a specific generation job.
* Returns the job's log file path.
*/
export function startGenerationLog(jobId: string, taskType: string = 'text2music'): string | null {
if (!generationsDir) return null;
generationBuffers.set(jobId, []);
const header = [
`${isoTimestamp()} | INFO | ============================================================`,
`${isoTimestamp()} | INFO | GENERATION STARTED: Job ${jobId}`,
`${isoTimestamp()} | INFO | Task Type: ${taskType}`,
];
generationBuffers.get(jobId)!.push(...header);
return path.join(generationsDir, `gen_${jobId}_${taskType}.log`);
}
/**
* Append a line to a generation's log buffer.
*/
export function logGeneration(jobId: string, level: 'INFO' | 'DEBUG' | 'WARNING' | 'ERROR', message: string): void {
const buf = generationBuffers.get(jobId);
const line = `${isoTimestamp()} | ${level.padEnd(7)} | ${message}`;
if (buf) buf.push(line);
// Push to terminal SSE stream
pushLog(`[Gen:${jobId.substring(0, 8)}] ${message}`, 'server');
}
/**
* Log a full params object (JSON pretty-printed) to a generation log.
*/
export function logGenerationParams(jobId: string, params: Record<string, any>): void {
const buf = generationBuffers.get(jobId);
if (!buf) return;
buf.push(`${isoTimestamp()} | INFO | Parameters:`);
const json = JSON.stringify(params, null, 2);
for (const line of json.split('\n')) {
buf.push(`${isoTimestamp()} | INFO | ${line}`);
}
buf.push(`${isoTimestamp()} | INFO | ============================================================`);
}
/**
* Finalize and flush a generation log to disk.
* Call when a generation completes or fails.
*/
export function finishGenerationLog(jobId: string, taskType: string = 'text2music'): void {
if (!generationsDir) return;
const buf = generationBuffers.get(jobId);
if (!buf) return;
buf.push(`${isoTimestamp()} | INFO | GENERATION COMPLETED.`);
const logPath = path.join(generationsDir, `gen_${jobId}_${taskType}.log`);
try {
fs.writeFileSync(logPath, buf.join('\n') + '\n');
console.log(`[Logger] Generation log saved: ${path.relative(projectRoot, logPath)}`);
} catch (e) {
console.error(`[Logger] Failed to write generation log: ${e}`);
}
generationBuffers.delete(jobId);
}
/**
* Finalize a generation log as failed.
*/
export function failGenerationLog(jobId: string, error: string, taskType: string = 'text2music'): void {
if (!generationsDir) return;
const buf = generationBuffers.get(jobId);
if (!buf) return;
buf.push(`${isoTimestamp()} | ERROR | GENERATION FAILED: ${error}`);
const logPath = path.join(generationsDir, `gen_${jobId}_${taskType}.log`);
try {
fs.writeFileSync(logPath, buf.join('\n') + '\n');
console.log(`[Logger] Generation log (failed) saved: ${path.relative(projectRoot, logPath)}`);
} catch (e) {
console.error(`[Logger] Failed to write generation log: ${e}`);
}
generationBuffers.delete(jobId);
}
/**
* Get the current session log directory, or null if not initialized.
*/
export function getSessionDir(): string | null {
return sessionDir;
}
/**
* Close all log streams. Call during shutdown.
*/
export function closeLogger(): void {
try { consoleLogStream?.end(); } catch { /* ignore */ }
try { engineLogStream?.end(); } catch { /* ignore */ }
consoleLogStream = null;
engineLogStream = null;
}
+487
View File
@@ -0,0 +1,487 @@
/**
* lyricsReconcile.ts — Needleman-Wunsch lyrics reconciliation service
*
* Aligns Whisper's free transcription against source lyrics using
* global sequence alignment with edit-distance, phonetic-hash, and
* fuzzy scoring. Produces timed, word-level lyrics JSON.
*/
import type { WhisperResult, WhisperWord } from './whisperTranscribe.js';
// ──────────────────────────────────────────────
// Types
// ──────────────────────────────────────────────
export interface LyricsWord {
word: string;
start: number;
end: number;
confidence: number;
source: 'matched' | 'whisper' | 'ad-lib';
}
export interface LyricsLine {
start: number;
end: number;
text: string;
words: LyricsWord[];
section?: string; // e.g. 'Verse 1', 'Chorus', 'Bridge'
}
export interface LyricsJson {
version: 1;
method: 'whisper';
whisperModel: string;
vocalsIsolated: boolean;
lines: LyricsLine[];
}
// ──────────────────────────────────────────────
// Alignment pair produced by Needleman-Wunsch
// ──────────────────────────────────────────────
interface AlignedPair {
sourceIdx: number | null;
whisperIdx: number | null;
score: number;
}
// ──────────────────────────────────────────────
// Helper: Levenshtein edit distance (standard DP)
// ──────────────────────────────────────────────
export function levenshtein(a: string, b: string): number {
const m = a.length;
const n = b.length;
// Fast-path: one or both strings empty
if (m === 0) return n;
if (n === 0) return m;
// Single-row DP to save memory
const prev = new Uint16Array(n + 1);
for (let j = 0; j <= n; j++) prev[j] = j;
for (let i = 1; i <= m; i++) {
let diagPrev = prev[0];
prev[0] = i;
for (let j = 1; j <= n; j++) {
const temp = prev[j];
if (a[i - 1] === b[j - 1]) {
prev[j] = diagPrev;
} else {
prev[j] = 1 + Math.min(diagPrev, prev[j], prev[j - 1]);
}
diagPrev = temp;
}
}
return prev[n];
}
// ──────────────────────────────────────────────
// Helper: Phonetic hash
// Strip vowels, collapse consecutive duplicates,
// keep first 6 consonants.
// ──────────────────────────────────────────────
const VOWELS = new Set(['a', 'e', 'i', 'o', 'u']);
export function phoneticHash(word: string): string {
const lower = word.toLowerCase().replace(/[^a-z]/g, '');
let result = '';
let lastChar = '';
for (const ch of lower) {
if (VOWELS.has(ch)) continue; // strip vowels
if (ch === lastChar) continue; // collapse doubles
result += ch;
lastChar = ch;
if (result.length >= 6) break; // first 6 consonants
}
return result;
}
// ──────────────────────────────────────────────
// Helper: Match scoring between two words
// EXACT → +2
// PHONETIC → +1.5 (same phoneticHash, word ≥ 3 chars)
// FUZZY → +1 (Levenshtein ≤ 2, word ≥ 3 chars)
// MISMATCH → -1
// ──────────────────────────────────────────────
export function matchScore(a: string, b: string): number {
const la = a.toLowerCase();
const lb = b.toLowerCase();
// Exact match
if (la === lb) return 2;
// For short words (< 3 chars), no fuzzy/phonetic — straight mismatch
if (la.length < 3 && lb.length < 3) return -1;
// Phonetic match (checked before fuzzy as it's a stronger signal)
if (la.length >= 3 && lb.length >= 3) {
const ha = phoneticHash(la);
const hb = phoneticHash(lb);
if (ha.length > 0 && ha === hb) return 1.5;
}
// Fuzzy match (Levenshtein ≤ 2 for words ≥ 3 chars)
if (la.length >= 3 || lb.length >= 3) {
if (levenshtein(la, lb) <= 2) return 1;
}
return -1;
}
// ──────────────────────────────────────────────
// Needleman-Wunsch global sequence alignment
// ──────────────────────────────────────────────
const GAP_PENALTY = -1;
export function needlemanWunsch(
source: string[],
whisper: string[]
): AlignedPair[] {
const m = source.length;
const n = whisper.length;
// Build score matrix F[m+1][n+1]
const F: number[][] = [];
for (let i = 0; i <= m; i++) {
F[i] = new Array(n + 1);
F[i][0] = i * GAP_PENALTY;
}
for (let j = 0; j <= n; j++) {
F[0][j] = j * GAP_PENALTY;
}
for (let i = 1; i <= m; i++) {
for (let j = 1; j <= n; j++) {
const diag = F[i - 1][j - 1] + matchScore(source[i - 1], whisper[j - 1]);
const up = F[i - 1][j] + GAP_PENALTY; // gap in whisper
const left = F[i][j - 1] + GAP_PENALTY; // gap in source
F[i][j] = Math.max(diag, up, left);
}
}
// Backtrace — build pairs in reverse, then reverse at end
const pairs: AlignedPair[] = [];
let i = m;
let j = n;
while (i > 0 || j > 0) {
if (
i > 0 &&
j > 0 &&
F[i][j] === F[i - 1][j - 1] + matchScore(source[i - 1], whisper[j - 1])
) {
// Diagonal — matched/mismatched pair
pairs.push({
sourceIdx: i - 1,
whisperIdx: j - 1,
score: matchScore(source[i - 1], whisper[j - 1]),
});
i--;
j--;
} else if (i > 0 && F[i][j] === F[i - 1][j] + GAP_PENALTY) {
// Up — gap in whisper (source word skipped)
pairs.push({ sourceIdx: i - 1, whisperIdx: null, score: GAP_PENALTY });
i--;
} else {
// Left — gap in source (whisper-only / ad-lib)
pairs.push({ sourceIdx: null, whisperIdx: j - 1, score: GAP_PENALTY });
j--;
}
}
// Return in forward order
pairs.reverse();
return pairs;
}
// ──────────────────────────────────────────────
// Section marker regex — [Verse], [Chorus], etc.
// ──────────────────────────────────────────────
const SECTION_MARKER = /^\[.*\]$/;
// ──────────────────────────────────────────────
// Line-splitting thresholds
// ──────────────────────────────────────────────
const LINE_GAP_THRESHOLD_S = 1.5; // seconds between words to force a new line
const LINE_MAX_WORDS = 15; // max words per line before forced split
// ──────────────────────────────────────────────
// Main: reconcileLyrics
// ──────────────────────────────────────────────
export function reconcileLyrics(
whisperResult: WhisperResult,
sourceLyrics: string,
whisperModel: string,
vocalsIsolated: boolean
): LyricsJson {
// 1. Flatten whisper words from all segments
const whisperWords: WhisperWord[] = [];
for (const segment of whisperResult.segments) {
if (segment.words) {
for (const w of segment.words) {
whisperWords.push(w);
}
}
}
// 2. Extract source words, preserving line structure + section markers
// sourceLineIdx[i] = which source line word i belongs to
// sourceLineSection[lineNum] = section name for that source line
const allLines = sourceLyrics
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
const sourceLines: string[] = []; // lyrics lines only (no section markers)
const sourceLineSection: string[] = []; // section name for each source line
let currentSection = '';
for (const line of allLines) {
if (SECTION_MARKER.test(line)) {
// Extract section name: "[Verse 1]" → "Verse 1"
currentSection = line.replace(/^\[/, '').replace(/\]$/, '');
} else {
sourceLines.push(line);
sourceLineSection.push(currentSection);
}
}
const sourceWords: string[] = [];
const sourceLineIdx: number[] = []; // maps word index → source line number
for (let lineNum = 0; lineNum < sourceLines.length; lineNum++) {
const words = sourceLines[lineNum].split(/\s+/).filter((w: string) => w.length > 0);
for (const w of words) {
sourceLineIdx.push(lineNum);
sourceWords.push(w);
}
}
// 3. Build whisper text array for alignment
const whisperTexts = whisperWords.map(w => w.word);
// 4. Run Needleman-Wunsch alignment
const aligned = needlemanWunsch(sourceWords, whisperTexts);
// 5. Build merged word list, carrying source line index
// Ad-lib words (hallucinations) are EXCLUDED from output.
interface MergedWord extends LyricsWord {
srcLine: number; // -1 for whisper-only words
}
const mergedWords: MergedWord[] = [];
for (const pair of aligned) {
if (pair.sourceIdx !== null && pair.whisperIdx !== null && pair.score > 0) {
// Matched: use source spelling + whisper timing
const ww = whisperWords[pair.whisperIdx];
mergedWords.push({
word: sourceWords[pair.sourceIdx],
start: ww.start,
end: ww.end,
confidence: ww.probability ?? 1,
source: 'matched',
srcLine: sourceLineIdx[pair.sourceIdx],
});
} else if (pair.sourceIdx !== null && pair.whisperIdx !== null && pair.score <= 0) {
// Mismatched pair: use whisper text + timing
const ww = whisperWords[pair.whisperIdx];
mergedWords.push({
word: ww.word,
start: ww.start,
end: ww.end,
confidence: ww.probability ?? 0,
source: 'whisper',
srcLine: sourceLineIdx[pair.sourceIdx],
});
} else if (pair.sourceIdx === null && pair.whisperIdx !== null) {
// Ad-lib: whisper heard something not in source (could be repeat or hallucination)
const ww = whisperWords[pair.whisperIdx];
mergedWords.push({
word: ww.word,
start: ww.start,
end: ww.end,
confidence: ww.probability ?? 0,
source: 'ad-lib',
srcLine: -1,
});
}
// pair.whisperIdx === null → source word with no whisper match → drop
}
// 5b. Trim intro/outro hallucinations
// Drop everything before the first 'matched' word and after the last.
// Mid-song ad-libs (genuine repeats) are preserved.
const firstMatched = mergedWords.findIndex((w: MergedWord) => w.source === 'matched');
let lastMatched = -1;
for (let i = mergedWords.length - 1; i >= 0; i--) {
if (mergedWords[i].source === 'matched') { lastMatched = i; break; }
}
const trimmedWords = firstMatched >= 0
? mergedWords.slice(firstMatched, lastMatched + 1)
: mergedWords; // no matches at all — keep everything as fallback
// 6. Group words into lines using source line boundaries
// Primary break: when source line index changes
// Secondary break: timing gap > threshold, or line too long
const lines: LyricsLine[] = [];
if (trimmedWords.length === 0) {
return { version: 1, method: 'whisper', whisperModel, vocalsIsolated, lines: [] };
}
let currentWords: LyricsWord[] = [stripSrcLine(trimmedWords[0])];
let currentSrcLine = trimmedWords[0].srcLine;
for (let i = 1; i < trimmedWords.length; i++) {
const prev = trimmedWords[i - 1];
const curr = trimmedWords[i];
const gap = curr.start - prev.end;
// Break at source line boundary (when the source line changes)
const srcLineChanged = curr.srcLine !== -1 && currentSrcLine !== -1 && curr.srcLine !== currentSrcLine;
// Also break on large timing gaps or very long lines
const timingBreak = gap > LINE_GAP_THRESHOLD_S;
const lengthBreak = currentWords.length >= LINE_MAX_WORDS;
if (srcLineChanged || timingBreak || lengthBreak) {
const section = currentSrcLine >= 0 ? sourceLineSection[currentSrcLine] : undefined;
lines.push(buildLine(currentWords, section));
currentWords = [stripSrcLine(curr)];
currentSrcLine = curr.srcLine;
} else {
currentWords.push(stripSrcLine(curr));
// Track source line — ad-lib words (-1) inherit from the current line
if (curr.srcLine !== -1) {
currentSrcLine = curr.srcLine;
}
}
}
// Flush remaining words
if (currentWords.length > 0) {
const section = currentSrcLine >= 0 ? sourceLineSection[currentSrcLine] : undefined;
lines.push(buildLine(currentWords, section));
}
// 7. Post-process: compress sparse leading words at section transitions
// When there's a gap between lines (instrumental break), whisper may place
// the first words of the new line during the break. Detect and compress.
compressSparseLeading(lines);
return {
version: 1,
method: 'whisper',
whisperModel,
vocalsIsolated,
lines,
};
}
// ──────────────────────────────────────────────
// Strip internal srcLine field before outputting
// ──────────────────────────────────────────────
function stripSrcLine(word: LyricsWord & { srcLine?: number }): LyricsWord {
const { srcLine, ...rest } = word as any;
return rest;
}
// ──────────────────────────────────────────────
// Build a LyricsLine from a group of words
// ──────────────────────────────────────────────
function buildLine(words: LyricsWord[], section?: string): LyricsLine {
const line: LyricsLine = {
start: words[0].start,
end: words[words.length - 1].end,
text: words.map(w => w.word).join(' '),
words,
};
if (section) line.section = section;
return line;
}
// ──────────────────────────────────────────────
// Post-process: compress sparse leading words
//
// At section transitions, whisper may place the first few words of a
// new line during the instrumental break before the singing actually
// starts. This creates a "slow start" where the lyrics highlight
// crawls through words during the gap, then catches up.
//
// Detection: for each line that follows a >2s gap, check if the
// leading words are spaced much wider than the rest of the line.
// Fix: push those leading words forward to cluster with the dense
// vocal content.
// ──────────────────────────────────────────────
const GAP_THRESHOLD_S = 2.0; // min gap between lines to trigger compression
const SPARSE_RATIO = 3.0; // word gap must be this many times the median to be "sparse"
function compressSparseLeading(lines: LyricsLine[]): void {
for (let li = 1; li < lines.length; li++) {
const prevEnd = lines[li - 1].end;
const line = lines[li];
const words = line.words;
// Only process lines after a significant gap
const gap = words[0].start - prevEnd;
if (gap < GAP_THRESHOLD_S || words.length < 4) continue;
// Calculate inter-word gaps
const gaps: number[] = [];
for (let i = 1; i < words.length; i++) {
gaps.push(words[i].start - words[i - 1].end);
}
// Find median gap (represents normal singing pace)
const sorted = [...gaps].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];
if (median <= 0) continue;
// Find where the "dense zone" starts — first word where the gap
// to the next word is within normal singing pace
let denseStart = 0;
for (let i = 0; i < gaps.length; i++) {
if (gaps[i] <= median * SPARSE_RATIO) {
denseStart = i;
break;
}
}
// If no sparse leading words found, skip
if (denseStart === 0) continue;
// Compress: push sparse leading words to just before the dense zone
// Each word gets a small offset before the dense zone start
const denseStartTime = words[denseStart].start;
const wordSpacing = Math.min(median, 0.15); // max 150ms between compressed words
for (let i = denseStart - 1; i >= 0; i--) {
const offset = (denseStart - i) * wordSpacing;
const newStart = denseStartTime - offset;
// Don't push earlier than the previous line's end
words[i].start = Math.max(newStart, prevEnd + 0.1);
words[i].end = Math.max(words[i].start + 0.1, words[i].end);
// Ensure end doesn't exceed next word's start
if (i < words.length - 1) {
words[i].end = Math.min(words[i].end, words[i + 1].start);
}
}
// Update line start time
line.start = words[0].start;
}
}
+192
View File
@@ -0,0 +1,192 @@
// midiParser.ts — minimal Standard MIDI File reader for piano-roll previews
//
// Parses note on/off + tempo map from format 0/1 SMF and returns notes with
// absolute times in seconds. Deliberately small: this only powers the MIDI
// Studio piano-roll preview; the authoritative artifact is the .mid file
// itself, which the user downloads for their DAW.
export interface MidiNote {
pitch: number; // 0-127
velocity: number; // 1-127
channel: number; // 0-15 (9 = GM drums)
start: number; // seconds
duration: number; // seconds
}
export interface MidiChannelInfo {
channel: number;
program: number; // GM program number (first program change seen, else 0)
isDrums: boolean;
noteCount: number;
}
export interface ParsedMidi {
durationSec: number;
noteCount: number;
channels: MidiChannelInfo[];
notes: MidiNote[];
}
interface RawNoteEvent { tick: number; on: boolean; channel: number; pitch: number; velocity: number; order: number; }
interface TempoEvent { tick: number; usPerQuarter: number; }
class Reader {
pos = 0;
constructor(private buf: Buffer) {}
get eof() { return this.pos >= this.buf.length; }
u8(): number { return this.buf[this.pos++]; }
peek(): number { return this.buf[this.pos]; }
u16(): number { const v = this.buf.readUInt16BE(this.pos); this.pos += 2; return v; }
u32(): number { const v = this.buf.readUInt32BE(this.pos); this.pos += 4; return v; }
skip(n: number) { this.pos += n; }
varLen(): number {
let v = 0;
for (let i = 0; i < 4; i++) {
const b = this.u8();
v = (v << 7) | (b & 0x7f);
if ((b & 0x80) === 0) break;
}
return v;
}
ascii(n: number): string { const s = this.buf.toString('latin1', this.pos, this.pos + n); this.pos += n; return s; }
}
export function parseMidiFile(buf: Buffer): ParsedMidi {
const r = new Reader(buf);
if (r.ascii(4) !== 'MThd') throw new Error('Not a MIDI file (missing MThd)');
const headerLen = r.u32();
const format = r.u16();
const ntrks = r.u16();
const division = r.u16();
r.skip(headerLen - 6);
if (format > 2) throw new Error(`Unsupported MIDI format ${format}`);
const smpte = (division & 0x8000) !== 0;
// SMPTE: ticks map to seconds directly; PPQ: via the tempo map
let secPerTick = 0;
if (smpte) {
const fps = 256 - (division >> 8); // two's-complement negative byte
const ticksPerFrame = division & 0xff;
secPerTick = 1 / (fps * ticksPerFrame);
}
const ppq = division & 0x7fff;
const noteEvents: RawNoteEvent[] = [];
const tempoEvents: TempoEvent[] = [];
const channelProgram = new Map<number, number>();
let order = 0;
for (let t = 0; t < ntrks && !r.eof; t++) {
if (r.ascii(4) !== 'MTrk') throw new Error(`Track ${t}: missing MTrk`);
const len = r.u32();
const end = r.pos + len;
let tick = 0;
let runningStatus = 0;
while (r.pos < end) {
tick += r.varLen();
let status = r.peek();
if (status & 0x80) { r.skip(1); if (status < 0xf0) runningStatus = status; }
else { status = runningStatus; if (!status) throw new Error(`Track ${t}: data byte with no running status`); }
if (status === 0xff) { // meta event
const type = r.u8();
const mlen = r.varLen();
if (type === 0x51 && mlen === 3) {
tempoEvents.push({ tick, usPerQuarter: (r.u8() << 16) | (r.u8() << 8) | r.u8() });
} else {
r.skip(mlen);
if (type === 0x2f) break; // end of track
}
} else if (status === 0xf0 || status === 0xf7) { // sysex
r.skip(r.varLen());
} else {
const kind = status & 0xf0;
const channel = status & 0x0f;
if (kind === 0x90 || kind === 0x80) {
const pitch = r.u8();
const velocity = r.u8();
const on = kind === 0x90 && velocity > 0;
noteEvents.push({ tick, on, channel, pitch, velocity, order: order++ });
} else if (kind === 0xc0) {
const program = r.u8();
if (!channelProgram.has(channel)) channelProgram.set(channel, program);
} else if (kind === 0xd0) {
r.skip(1); // channel aftertouch
} else {
r.skip(2); // poly AT, CC, pitch bend
}
}
}
r.pos = end; // realign in case of sloppy track data
}
// Tick → seconds via the tempo map (default 120 bpm = 500000 us/quarter)
tempoEvents.sort((a, b) => a.tick - b.tick);
const segments: Array<{ tick: number; sec: number; secPerTick: number }> = [];
{
let curTick = 0, curSec = 0;
let curSpt = smpte ? secPerTick : 500_000 / 1_000_000 / ppq;
segments.push({ tick: 0, sec: 0, secPerTick: curSpt });
if (!smpte) {
for (const te of tempoEvents) {
curSec += (te.tick - curTick) * curSpt;
curTick = te.tick;
curSpt = te.usPerQuarter / 1_000_000 / ppq;
segments.push({ tick: curTick, sec: curSec, secPerTick: curSpt });
}
}
}
const tickToSec = (tick: number): number => {
let seg = segments[0];
for (let i = segments.length - 1; i >= 0; i--) {
if (segments[i].tick <= tick) { seg = segments[i]; break; }
}
return seg.sec + (tick - seg.tick) * seg.secPerTick;
};
// Pair note-ons with note-offs (FIFO per channel+pitch; merged track order)
noteEvents.sort((a, b) => a.tick - b.tick || a.order - b.order);
const open = new Map<number, RawNoteEvent[]>();
const notes: MidiNote[] = [];
for (const ev of noteEvents) {
const key = ev.channel * 128 + ev.pitch;
if (ev.on) {
let q = open.get(key);
if (!q) { q = []; open.set(key, q); }
q.push(ev);
} else {
const q = open.get(key);
const start = q?.shift();
if (start) {
notes.push({
pitch: start.pitch,
velocity: start.velocity,
channel: start.channel,
start: round3(tickToSec(start.tick)),
duration: round3(Math.max(0.01, tickToSec(ev.tick) - tickToSec(start.tick))),
});
}
}
}
notes.sort((a, b) => a.start - b.start);
const channelCounts = new Map<number, number>();
let durationSec = 0;
for (const n of notes) {
channelCounts.set(n.channel, (channelCounts.get(n.channel) || 0) + 1);
durationSec = Math.max(durationSec, n.start + n.duration);
}
const channels: MidiChannelInfo[] = [...channelCounts.entries()]
.sort((a, b) => a[0] - b[0])
.map(([channel, noteCount]) => ({
channel,
program: channelProgram.get(channel) ?? 0,
isDrums: channel === 9,
noteCount,
}));
return { durationSec: round3(durationSec), noteCount: notes.length, channels, notes };
}
function round3(v: number): number { return Math.round(v * 1000) / 1000; }
+604
View File
@@ -0,0 +1,604 @@
// modelDownloadService.ts — Concurrent, resumable model downloads from HuggingFace
//
// Downloads GGUF files to the configured models directory with:
// - HTTP Range-based resumption (.part files)
// - Concurrent downloads (no artificial limit)
// - Progress tracking with speed + ETA
// - EventEmitter for SSE progress streaming
import { EventEmitter } from 'events';
import fs from 'fs';
import path from 'path';
import https from 'https';
import http from 'http';
import { randomUUID } from 'crypto';
import { fileURLToPath } from 'url';
import { config, PORTABLE_MODE, PROJECT_ROOT } from '../config.js';
// Load registry - resolve path based on mode:
// - Dev mode: relative to source file (../data/model-registry.json from services/)
// - Portable mode: PROJECT_ROOT/server/data/model-registry.json
const registryPath = PORTABLE_MODE
? path.join(PROJECT_ROOT, 'server', 'data', 'model-registry.json')
: path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'data', 'model-registry.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf-8'));
// Engine variant detection — controls which registry items are visible.
// 'cuda' (default for legacy/pre-v1.1 builds) shows everything;
// 'vulkan' or 'cpu' hides CUDA-specific files, packs, and runtime.
function detectEngineVariant(): string {
try {
const variantFile = path.join(path.dirname(config.aceServer.exe), '.variant');
if (fs.existsSync(variantFile)) {
return fs.readFileSync(variantFile, 'utf-8').trim();
}
} catch {}
return 'cuda'; // Assume CUDA if no marker
}
const ENGINE_VARIANT = detectEngineVariant();
// Detect CUDA major version for runtime DLL selection
function detectCudaMajorVersion(): number {
try {
const versionFile = path.join(path.dirname(config.aceServer.exe), '.cuda-version');
if (fs.existsSync(versionFile)) {
return parseInt(fs.readFileSync(versionFile, 'utf-8').trim(), 10);
}
} catch {}
return 13; // Default: assume CUDA 13 (latest)
}
const CUDA_MAJOR = detectCudaMajorVersion();
// IDs of CUDA-only file entries
const CUDA_ONLY_FILE_PREFIXES = ['cuda-rt-', 'supersep-rt-'];
// IDs of CUDA-only packs (hidden entirely for non-CUDA)
const CUDA_ONLY_PACKS = ['cuda-runtime', 'cuda12-runtime', 'supersep-runtime', 'blackwell'];
// ── Types ───────────────────────────────────────────────────
export type DownloadStatus = 'queued' | 'downloading' | 'paused' | 'completed' | 'failed' | 'cancelled';
export interface DownloadJob {
jobId: string;
fileId: string;
filename: string;
status: DownloadStatus;
bytesDownloaded: number;
totalBytes: number;
speed: number; // bytes/sec rolling average
error?: string;
}
interface InternalJob extends DownloadJob {
abortController?: AbortController;
speedSamples: { time: number; bytes: number }[];
hfToken?: string; // Optional Hugging Face token for gated repos
}
interface RegistryFile {
id: string;
filename: string;
role: string;
subdir?: string;
repoPath?: string; // Path within the HuggingFace repo (e.g. "runtime/cublas64_13.dll")
displayName: string;
scale: string | null;
variant: string | null;
quant: string;
sizeBytes: number;
repo: string;
description: string;
tags: string[];
}
// ── Service ─────────────────────────────────────────────────
class ModelDownloadService extends EventEmitter {
private jobs = new Map<string, InternalJob>();
/** Get the models directory path */
get modelsDir(): string {
return config.aceServer.models;
}
/** Get the engine directory (where ace-server.exe lives) for runtime DLLs */
get engineDir(): string {
return path.dirname(config.aceServer.exe);
}
/** Resolve the target directory for a registry file */
private getTargetDir(file: RegistryFile): string {
if (file.role === 'runtime') {
// Runtime DLLs go alongside ace-server.exe
return this.engineDir;
}
return file.subdir
? path.join(this.modelsDir, file.subdir)
: this.modelsDir;
}
/** Get all files in the registry, enriched with installed status.
* Filters out CUDA-specific entries for non-CUDA engine variants. */
getRegistry(): { packs: any[]; files: (RegistryFile & { installed: boolean })[]; modelsDir: string; variant: string; cudaMajor: number } {
const installed = this.getInstalledFiles();
const isCuda = ENGINE_VARIANT === 'cuda';
const wrongCudaTag = CUDA_MAJOR <= 12 ? 'cuda13' : 'cuda12';
// Filter files: hide CUDA-only entries for non-CUDA builds,
// and hide wrong-CUDA-version entries
const filteredFiles = registry.files
.filter((f: RegistryFile) => isCuda || !CUDA_ONLY_FILE_PREFIXES.some(p => f.id.startsWith(p)))
.filter((f: RegistryFile) => !f.tags?.includes(wrongCudaTag))
.map((f: RegistryFile) => ({
...f,
installed: installed.has(f.filename),
}));
// Filter packs: hide CUDA-only packs, strip CUDA file IDs from remaining
// Also hide the wrong-version CUDA runtime pack and remap IDs in model packs
const filteredPacks = registry.packs
.filter((p: any) => isCuda || !CUDA_ONLY_PACKS.includes(p.id))
.filter((p: any) => {
// Show only the correct CUDA runtime pack
if (p.id === 'cuda-runtime') return CUDA_MAJOR >= 13;
if (p.id === 'cuda12-runtime') return CUDA_MAJOR <= 12;
return true;
})
.map((p: any) => {
if (!isCuda) {
return { ...p, fileIds: p.fileIds.filter((id: string) => !CUDA_ONLY_FILE_PREFIXES.some(pfx => id.startsWith(pfx))) };
}
// Remap cuda-rt-* IDs in packs to version-specific ones for CUDA 12
if (CUDA_MAJOR <= 12) {
return {
...p,
fileIds: p.fileIds.map((id: string) => {
if (['cuda-rt-cublas', 'cuda-rt-cublaslt', 'cuda-rt-cudart'].includes(id)) {
return `${id}-12`;
}
return id;
}),
};
}
return p;
});
return {
packs: filteredPacks,
files: filteredFiles,
modelsDir: this.modelsDir,
variant: ENGINE_VARIANT,
cudaMajor: CUDA_MAJOR,
};
}
/** Scan models directory for installed model files (.gguf, .onnx, .safetensors),
* and engine directory for runtime DLLs */
getInstalledFiles(): Set<string> {
const dir = this.modelsDir;
const files = new Set<string>();
// Scan models root directory
if (fs.existsSync(dir)) {
for (const f of fs.readdirSync(dir)) {
if (f.endsWith('.gguf') || f.endsWith('.onnx') || f.endsWith('.safetensors') || f.endsWith('.bin')) files.add(f);
}
// Scan subdirectories (e.g. supersep/)
for (const sub of fs.readdirSync(dir)) {
const subPath = path.join(dir, sub);
try {
if (fs.statSync(subPath).isDirectory()) {
for (const f of fs.readdirSync(subPath)) {
if (f.endsWith('.gguf') || f.endsWith('.onnx') || f.endsWith('.safetensors') || f.endsWith('.bin')) files.add(f);
}
}
} catch {}
}
}
// Scan the StableStep (SA3) directory — lives two levels deep
// (<modelsDir>/onnx/sa3) and contains non-model extensions
// (.onnx.data, .json) that the generic scan above ignores.
const sa3Dir = path.join(dir, 'onnx', 'sa3');
if (fs.existsSync(sa3Dir)) {
for (const f of fs.readdirSync(sa3Dir)) {
if (!f.endsWith('.part')) files.add(f);
}
}
// Scan engine directory for runtime DLLs
const engDir = this.engineDir;
if (fs.existsSync(engDir)) {
for (const f of fs.readdirSync(engDir)) {
if (f.endsWith('.dll')) files.add(f);
}
}
return files;
}
/** Get all active/recent download jobs */
getJobs(): DownloadJob[] {
return Array.from(this.jobs.values()).map(j => ({
jobId: j.jobId,
fileId: j.fileId,
filename: j.filename,
status: j.status,
bytesDownloaded: j.bytesDownloaded,
totalBytes: j.totalBytes,
speed: j.speed,
error: j.error,
}));
}
/** Start downloading a file by registry ID.
* Optional hfToken is forwarded as `Authorization: Bearer <token>` on
* huggingface.co requests (needed for gated repos; empty = anonymous). */
startDownload(fileId: string, hfToken?: string): string {
const file = registry.files.find((f: RegistryFile) => f.id === fileId);
if (!file) throw new Error(`Unknown file ID: ${fileId}`);
// Check if already downloading
for (const job of this.jobs.values()) {
if (job.fileId === fileId && (job.status === 'downloading' || job.status === 'queued')) {
return job.jobId; // Return existing job
}
}
const jobId = randomUUID().slice(0, 8);
const job: InternalJob = {
jobId,
fileId,
filename: file.filename,
status: 'queued',
bytesDownloaded: 0,
totalBytes: file.sizeBytes,
speed: 0,
speedSamples: [],
hfToken: hfToken?.trim() || undefined,
};
this.jobs.set(jobId, job);
this._executeDownload(job, file);
return jobId;
}
/** Cancel an active download */
cancelDownload(jobId: string): boolean {
const job = this.jobs.get(jobId);
if (!job) return false;
if (job.status !== 'downloading' && job.status !== 'queued') return false;
job.status = 'cancelled';
job.abortController?.abort();
// Clean up .part file — resolve correct directory
const file = registry.files.find((f: RegistryFile) => f.id === job.fileId);
const targetDir = file ? this.getTargetDir(file) : this.modelsDir;
const partPath = path.join(targetDir, `${job.filename}.part`);
try { fs.unlinkSync(partPath); } catch {}
this.emit('progress');
return true;
}
/** Resume a paused/failed download */
resumeDownload(jobId: string): string {
const job = this.jobs.get(jobId);
if (!job) throw new Error(`Unknown job: ${jobId}`);
if (job.status !== 'paused' && job.status !== 'failed') {
throw new Error(`Job ${jobId} is ${job.status}, cannot resume`);
}
const file = registry.files.find((f: RegistryFile) => f.id === job.fileId);
if (!file) throw new Error(`Registry entry gone for ${job.fileId}`);
// Check .part file for resume offset
const targetDir = this.getTargetDir(file);
const partPath = path.join(targetDir, `${job.filename}.part`);
if (fs.existsSync(partPath)) {
job.bytesDownloaded = fs.statSync(partPath).size;
} else {
job.bytesDownloaded = 0;
}
job.status = 'queued';
job.error = undefined;
job.speedSamples = [];
this._executeDownload(job, file);
return jobId;
}
/** Delete a model/runtime file from disk */
deleteFile(filename: string): boolean {
// Safety: only known model/runtime extensions.
// .data / .json are StableStep (SA3) companions (sa3-dit.onnx.data,
// tokenizer.json etc.) living under onnx/sa3.
if (!filename.endsWith('.gguf') && !filename.endsWith('.onnx') && !filename.endsWith('.safetensors') && !filename.endsWith('.dll') && !filename.endsWith('.bin') && !filename.endsWith('.data') && !filename.endsWith('.json')) {
throw new Error('Can only delete .gguf, .onnx, .safetensors, .bin, .dll, .data, or .json files');
}
// For DLLs, check engine directory
if (filename.endsWith('.dll')) {
const filePath = path.join(this.engineDir, filename);
const resolved = path.resolve(filePath);
if (!resolved.startsWith(path.resolve(this.engineDir))) throw new Error('Path traversal denied');
if (fs.existsSync(filePath)) {
fs.unlinkSync(filePath);
return true;
}
return false;
}
// Check root dir and subdirectories for models
const candidates = [path.join(this.modelsDir, filename)];
try {
for (const sub of fs.readdirSync(this.modelsDir)) {
const subPath = path.join(this.modelsDir, sub);
if (fs.statSync(subPath).isDirectory()) {
candidates.push(path.join(subPath, filename));
}
}
} catch {}
// StableStep (SA3) files live two levels deep: <modelsDir>/onnx/sa3
candidates.push(path.join(this.modelsDir, 'onnx', 'sa3', filename));
const modelsResolved = path.resolve(this.modelsDir);
for (const filePath of candidates) {
if (fs.existsSync(filePath)) {
const resolved = path.resolve(filePath);
if (!resolved.startsWith(modelsResolved)) throw new Error('Path traversal denied');
fs.unlinkSync(filePath);
return true;
}
}
return false;
}
/** Clean up completed/cancelled/failed jobs older than 60s */
cleanupJobs(): void {
for (const [id, job] of this.jobs) {
if (job.status === 'completed' || job.status === 'cancelled' || job.status === 'failed') {
this.jobs.delete(id);
}
}
}
// ── Internal ────────────────────────────────────────────────
/** Retry delays in ms (3 attempts: immediate, 2s, 5s) */
private static readonly RETRY_DELAYS = [0, 2000, 5000];
private async _executeDownload(job: InternalJob, file: RegistryFile): Promise<void> {
// Determine target directory based on file role
const targetDir = this.getTargetDir(file);
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const partPath = path.join(targetDir, `${file.filename}.part`);
const finalPath = path.join(targetDir, file.filename);
// Check if already fully downloaded
if (fs.existsSync(finalPath)) {
job.status = 'completed';
job.bytesDownloaded = job.totalBytes;
this.emit('progress');
return;
}
for (let attempt = 0; attempt < ModelDownloadService.RETRY_DELAYS.length; attempt++) {
const delay = ModelDownloadService.RETRY_DELAYS[attempt];
if (delay > 0) {
console.log(`[ModelManager] Retry ${attempt + 1}/${ModelDownloadService.RETRY_DELAYS.length} for ${file.filename} in ${delay / 1000}s...`);
await new Promise(r => setTimeout(r, delay));
}
// Check existing .part file for resume
let startByte = 0;
if (fs.existsSync(partPath)) {
startByte = fs.statSync(partPath).size;
job.bytesDownloaded = startByte;
}
job.status = 'downloading';
job.abortController = new AbortController();
job.error = undefined;
this.emit('progress');
const repoPath = file.repoPath || file.filename;
const url = `https://huggingface.co/${file.repo}/resolve/main/${repoPath}`;
try {
await this._downloadWithRedirects(url, partPath, job, startByte);
if ((job.status as DownloadStatus) === 'cancelled') return;
// Validate downloaded file before finalising
this._validateDownload(partPath, file);
// Rename .part to final
fs.renameSync(partPath, finalPath);
job.status = 'completed';
job.speed = 0;
this.emit('progress');
console.log(`[ModelManager] Download complete: ${file.filename}`);
return; // Success — exit retry loop
} catch (err: any) {
if ((job.status as DownloadStatus) === 'cancelled') return;
const isLastAttempt = attempt === ModelDownloadService.RETRY_DELAYS.length - 1;
if (isLastAttempt) {
job.status = 'failed';
job.error = err.message;
job.speed = 0;
this.emit('progress');
console.error(`[ModelManager] Download failed after ${attempt + 1} attempts: ${file.filename}${err.message}`);
} else {
console.warn(`[ModelManager] Download attempt ${attempt + 1} failed for ${file.filename}: ${err.message}`);
job.speedSamples = [];
}
}
}
}
/** Validate a downloaded file is a real binary (not an HTML error page) */
private _validateDownload(filePath: string, file: RegistryFile): void {
const stat = fs.statSync(filePath);
// Size check: must be within 5% of expected size
if (file.sizeBytes > 0) {
const tolerance = file.sizeBytes * 0.05;
if (Math.abs(stat.size - file.sizeBytes) > tolerance) {
try { fs.unlinkSync(filePath); } catch {}
throw new Error(
`Size mismatch: expected ${(file.sizeBytes / 1024 / 1024).toFixed(1)} MB, ` +
`got ${(stat.size / 1024 / 1024).toFixed(1)} MB — file may be corrupt or an error page`
);
}
}
// PE header check for DLLs
if (file.filename.endsWith('.dll')) {
const fd = fs.openSync(filePath, 'r');
const header = Buffer.alloc(2);
fs.readSync(fd, header, 0, 2, 0);
fs.closeSync(fd);
if (header.toString('ascii') !== 'MZ') {
try { fs.unlinkSync(filePath); } catch {}
throw new Error(
`Invalid PE header — downloaded file is not a valid DLL ` +
`(got "${header.toString('ascii')}" instead of "MZ")`
);
}
}
// GGUF magic check for model files
if (file.filename.endsWith('.gguf')) {
const fd = fs.openSync(filePath, 'r');
const header = Buffer.alloc(4);
fs.readSync(fd, header, 0, 4, 0);
fs.closeSync(fd);
if (header.toString('ascii') !== 'GGUF') {
try { fs.unlinkSync(filePath); } catch {}
throw new Error(
`Invalid GGUF header — downloaded file is not a valid model ` +
`(got "${header.toString('ascii')}" instead of "GGUF")`
);
}
}
}
private _downloadWithRedirects(url: string, partPath: string, job: InternalJob, startByte: number, redirectCount = 0): Promise<void> {
if (redirectCount > 5) return Promise.reject(new Error('Too many redirects'));
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const transport = parsedUrl.protocol === 'https:' ? https : http;
const headers: Record<string, string> = {
'User-Agent': 'HOT-Step-CPP/1.0',
};
if (startByte > 0) {
headers['Range'] = `bytes=${startByte}-`;
}
// Hugging Face token for gated repos — only sent to huggingface.co
// itself. CDN redirect targets (cdn-lfs / xethub) use pre-signed URLs
// and reject requests carrying an extra Authorization header.
if (job.hfToken && /(^|\.)huggingface\.co$/.test(parsedUrl.hostname)) {
headers['Authorization'] = `Bearer ${job.hfToken}`;
}
const req = transport.get(parsedUrl, { headers }, (res) => {
// Handle redirects
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
res.resume(); // Drain response
// Resolve relative redirects (e.g. /api/resolve-cache/...) against the original URL
const redirectUrl = new URL(res.headers.location, parsedUrl).href;
this._downloadWithRedirects(redirectUrl, partPath, job, startByte, redirectCount + 1)
.then(resolve).catch(reject);
return;
}
if (res.statusCode === 416) {
// Range not satisfiable — file might be complete
res.resume();
resolve();
return;
}
if (res.statusCode && res.statusCode >= 400) {
res.resume();
reject(new Error(`HTTP ${res.statusCode}: ${res.statusMessage}`));
return;
}
// Parse total size from Content-Range or Content-Length
const contentRange = res.headers['content-range'];
if (contentRange) {
const match = contentRange.match(/bytes \d+-\d+\/(\d+)/);
if (match) job.totalBytes = parseInt(match[1], 10);
} else if (res.headers['content-length'] && startByte === 0) {
job.totalBytes = parseInt(res.headers['content-length'], 10);
}
const writeStream = fs.createWriteStream(partPath, {
flags: startByte > 0 ? 'a' : 'w',
});
res.on('data', (chunk: Buffer) => {
job.bytesDownloaded += chunk.length;
// Speed tracking
const now = Date.now();
job.speedSamples.push({ time: now, bytes: chunk.length });
// Keep only last 3 seconds of samples
const cutoff = now - 3000;
job.speedSamples = job.speedSamples.filter(s => s.time > cutoff);
// Calculate speed
if (job.speedSamples.length > 1) {
const totalSampleBytes = job.speedSamples.reduce((a, s) => a + s.bytes, 0);
const elapsed = (now - job.speedSamples[0].time) / 1000;
job.speed = elapsed > 0 ? totalSampleBytes / elapsed : 0;
}
this.emit('progress');
});
res.on('end', () => {
writeStream.end(() => resolve());
});
res.on('error', (err) => {
writeStream.end();
if (job.status !== 'cancelled') {
job.status = 'paused';
job.error = err.message;
}
reject(err);
});
res.pipe(writeStream, { end: false });
// Handle abort
if (job.abortController) {
job.abortController.signal.addEventListener('abort', () => {
res.destroy();
writeStream.end();
});
}
});
req.on('error', (err) => {
if (job.status !== 'cancelled') {
job.status = 'paused';
job.error = err.message;
}
reject(err);
});
});
}
}
export const modelDownloadService = new ModelDownloadService();
+165
View File
@@ -0,0 +1,165 @@
// muscriptor.ts — MuScriptor (audio→MIDI) shared helpers
//
// MuScriptor is developed by Kyutai & Mirelo (Rouard, Krause, Roebel,
// Simon-Gabriel, Défossez — arXiv:2607.08168). Code MIT; model weights
// CC BY-NC 4.0 and GATED on Hugging Face (users request access + read token).
//
// NOTE (2026-07-16): the original integration ran the upstream Python CLI in
// a managed venv. That approach was removed — transcription is being ported
// to a native GGML binary (`ace-midi`). Design: docs/plans/muscriptor-cpp-port.md.
// What remains here is the part the C++ path also needs: the Hugging Face
// token store used to download the gated weights.
//
// Rob's local venv at data/muscriptor/venv is intentionally NOT deleted —
// it is the numerics-validation oracle for the port (see design doc §6).
import path from 'path';
import fs from 'fs';
import { Readable } from 'stream';
import { pipeline } from 'stream/promises';
import { config } from '../config.js';
export const MUSCRIPTOR_DIR = path.join(config.data.dir, 'muscriptor');
export const MUSCRIPTOR_MODELS = ['small', 'medium', 'large'] as const;
export type MuscriptorModel = typeof MUSCRIPTOR_MODELS[number];
export const HF_MODEL_REPOS: Record<MuscriptorModel, string> = {
small: 'MuScriptor/muscriptor-small',
medium: 'MuScriptor/muscriptor-medium',
large: 'MuScriptor/muscriptor-large',
};
// ── Hugging Face access token ────────────────────────────────────────────
const HF_TOKEN_PATH = path.join(MUSCRIPTOR_DIR, 'hf_token');
export function getHfToken(): string | null {
try {
const t = fs.readFileSync(HF_TOKEN_PATH, 'utf-8').trim();
return t || null;
} catch { return null; }
}
export function setHfToken(token: string): void {
fs.mkdirSync(MUSCRIPTOR_DIR, { recursive: true });
const t = (token || '').trim();
if (!t) {
fs.rmSync(HF_TOKEN_PATH, { force: true });
} else {
fs.writeFileSync(HF_TOKEN_PATH, t, { encoding: 'utf-8' });
}
}
/** Heuristic: does this failure look like a gated-model / auth problem? */
export function looksLikeGatedError(text: string): boolean {
return /gated|401|403|unauthorized|forbidden|restricted|access to model|awaiting a review|accept the conditions|not authenticated|invalid (user )?token|authentication/i.test(text);
}
// ── ace-midi engine binary ───────────────────────────────────────────────
/** Absolute path to ace-midi, or null if not built/shipped. Lives next to
* ace-server (same build output / portable layout). */
export function aceMidiExe(): string | null {
const dir = path.dirname(config.aceServer.exe);
const exe = path.join(dir, process.platform === 'win32' ? 'ace-midi.exe' : 'ace-midi');
return fs.existsSync(exe) ? exe : null;
}
// ── Model weights (gated on HF — downloaded with the user's read token) ──
export const MODELS_DIR = path.join(config.data.dir, 'models', 'muscriptor');
export function modelDir(m: MuscriptorModel): string {
return path.join(MODELS_DIR, m);
}
export function isModelDownloaded(m: MuscriptorModel): boolean {
return fs.existsSync(path.join(modelDir(m), 'model.safetensors'))
&& fs.existsSync(path.join(modelDir(m), 'config.json'));
}
export interface ModelDownloadState {
downloading: boolean;
receivedBytes: number;
totalBytes: number;
error?: string;
gated?: boolean;
}
const downloads = new Map<MuscriptorModel, ModelDownloadState>();
export function getModelStates(): Record<string, ModelDownloadState & { downloaded: boolean; sizeBytes: number }> {
const out: Record<string, any> = {};
for (const m of MUSCRIPTOR_MODELS) {
const dl = downloads.get(m);
let sizeBytes = 0;
const downloaded = isModelDownloaded(m);
if (downloaded) {
try { sizeBytes = fs.statSync(path.join(modelDir(m), 'model.safetensors')).size; } catch { /* ignore */ }
}
out[m] = {
downloaded,
sizeBytes,
downloading: dl?.downloading ?? false,
receivedBytes: dl?.receivedBytes ?? 0,
totalBytes: dl?.totalBytes ?? 0,
error: dl?.error,
gated: dl?.gated,
};
}
return out;
}
async function fetchToFile(url: string, token: string | null, dest: string,
onProgress?: (received: number, total: number) => void): Promise<void> {
const headers: Record<string, string> = token ? { Authorization: `Bearer ${token}` } : {};
const res = await fetch(url, { headers, redirect: 'follow' });
if (!res.ok || !res.body) {
const body = await res.text().catch(() => '');
throw new Error(`HTTP ${res.status} fetching ${url}: ${body.slice(0, 300)}`);
}
const total = parseInt(res.headers.get('content-length') || '0', 10);
let received = 0;
const src = Readable.fromWeb(res.body as any);
src.on('data', (chunk: Buffer) => {
received += chunk.length;
onProgress?.(received, total);
});
fs.mkdirSync(path.dirname(dest), { recursive: true });
const tmp = dest + '.part';
await pipeline(src, fs.createWriteStream(tmp));
fs.renameSync(tmp, dest);
}
/**
* Begin downloading a model's config.json + model.safetensors from its gated
* HF repo. Fire-and-poll: progress via getModelStates(). Single-flight.
*/
export function startModelDownload(m: MuscriptorModel): { started: boolean; error?: string } {
if (downloads.get(m)?.downloading) return { started: false, error: 'Download already in progress' };
if (isModelDownloaded(m)) return { started: false, error: 'Already downloaded' };
const state: ModelDownloadState = { downloading: true, receivedBytes: 0, totalBytes: 0 };
downloads.set(m, state);
const token = getHfToken();
const base = `https://huggingface.co/${HF_MODEL_REPOS[m]}/resolve/main`;
(async () => {
try {
console.log(`[MidiStudio] Downloading ${m} weights from ${HF_MODEL_REPOS[m]}`);
await fetchToFile(`${base}/config.json`, token, path.join(modelDir(m), 'config.json'));
await fetchToFile(`${base}/model.safetensors`, token, path.join(modelDir(m), 'model.safetensors'),
(received, total) => { state.receivedBytes = received; state.totalBytes = total; });
console.log(`[MidiStudio] ${m} weights downloaded (${(state.receivedBytes / 1e9).toFixed(2)} GB)`);
} catch (err: any) {
state.error = err.message || 'Download failed';
state.gated = looksLikeGatedError(state.error ?? '');
console.error(`[MidiStudio] ${m} download FAILED: ${state.error}`);
} finally {
state.downloading = false;
}
})();
return { started: true };
}
+97
View File
@@ -0,0 +1,97 @@
// pathMapper.ts — Windows-to-Docker path translation
//
// Album presets in the DB store Windows-native paths (e.g.
// "D:\Ace-Step-Latest\All LoKR Files\sidestep\xl-base-turbo-05\file.safetensors").
// These don't exist inside the Docker container. This module translates them
// to container mount points using a prefix map from DOCKER_PATH_MAP.
//
// When DOCKER_PATH_MAP is not set (Windows-native mode), all functions are no-ops.
//
// Configuration (in .env.docker):
// DOCKER_PATH_MAP={"D:\\Ace-Step-Latest\\All LoKR Files\\sidestep\\xl-base-turbo-05":"/app/adapters","D:\\Ace-Step-Latest\\Datasets-LoRA-LoKR":"/app/datasets"}
interface PathMapping {
/** Windows prefix (normalized: forward slashes, lowercase, no trailing slash) */
winPrefix: string;
/** Container mount point (no trailing slash) */
mountPoint: string;
}
let mappings: PathMapping[] = [];
let initialized = false;
/** Normalize a path for comparison: forward slashes, lowercase, no trailing slash */
function normalize(p: string): string {
return p.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
}
/** Parse DOCKER_PATH_MAP from environment. Called once on first use. */
function init(): void {
if (initialized) return;
initialized = true;
const raw = process.env.DOCKER_PATH_MAP;
if (!raw) return;
try {
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null) {
console.error('[PathMapper] DOCKER_PATH_MAP is not a JSON object, ignoring');
return;
}
for (const [winPath, mountPath] of Object.entries(parsed)) {
if (typeof mountPath !== 'string') continue;
mappings.push({
winPrefix: normalize(winPath),
mountPoint: (mountPath as string).replace(/\/+$/, ''),
});
}
// Sort by prefix length descending so longer (more specific) matches win
mappings.sort((a, b) => b.winPrefix.length - a.winPrefix.length);
if (mappings.length > 0) {
console.log(`[PathMapper] Loaded ${mappings.length} path mapping(s):`);
for (const m of mappings) {
console.log(`[PathMapper] ${m.winPrefix}${m.mountPoint}`);
}
}
} catch (err) {
console.error(`[PathMapper] Failed to parse DOCKER_PATH_MAP: ${err}`);
}
}
/**
* Translate a path if it matches a known Windows prefix.
* Returns the original path unchanged if no mapping matches or if
* DOCKER_PATH_MAP is not configured (Windows-native mode).
*/
export function mapPath(inputPath: string | undefined): string | undefined {
if (!inputPath) return inputPath;
init();
if (mappings.length === 0) return inputPath;
const norm = normalize(inputPath);
for (const m of mappings) {
if (norm.startsWith(m.winPrefix)) {
// Replace prefix, preserve the rest of the path
const remainder = inputPath
.replace(/\\/g, '/')
.substring(m.winPrefix.length)
.replace(/^\//, ''); // remove leading slash if present
const mapped = remainder ? `${m.mountPoint}/${remainder}` : m.mountPoint;
return mapped;
}
}
return inputPath;
}
/**
* Check if path mapping is active (DOCKER_PATH_MAP is configured).
*/
export function isPathMappingActive(): boolean {
init();
return mappings.length > 0;
}
+114
View File
@@ -0,0 +1,114 @@
// sa3Tokenizer.ts — T5Gemma tokenization for the StableStep (SA3 refine) feature
//
// The C++ engine's POST /sa3-refine endpoint requires a pre-tokenized prompt
// (256 padded T5Gemma token ids as CSV) because the engine's bpe.h cannot
// parse SentencePiece tokenizer.json. Tokenization happens here in Node via
// @lenml/tokenizers (pure-JS port of the transformers.js tokenizer — chosen
// over @huggingface/transformers because that package transitively pulls
// onnxruntime-node native binaries, which broke the esbuild release bundle;
// verified token-for-token identical to the Python tokenizer on all 256 ids),
// loading tokenizer.json + tokenizer_config.json from <modelsDir>/onnx/sa3/ —
// the same directory ace-server scans for the SA3 ONNX graphs.
import fs from 'fs';
import path from 'path';
import { config } from '../config.js';
type Sa3Tokenizer = ReturnType<typeof import('@lenml/tokenizers').TokenizerLoader.fromPreTrained>;
/** The engine's SA3_TOK_LEN — /sa3-refine expects exactly this many ids. */
const SA3_TOK_LEN = 256;
/** Directory holding the SA3 ONNX graphs + tokenizer files.
* Mirrors the engine: <models_dir>/onnx/sa3 (ACESTEPCPP_MODELS override
* flows through config.aceServer.models). */
function sa3Dir(): string {
return path.join(config.aceServer.models, 'onnx', 'sa3');
}
/** True if the SA3 model set appears installed (DiT graph + tokenizer). */
export function sa3ModelsInstalled(): boolean {
const dir = sa3Dir();
return fs.existsSync(path.join(dir, 'sa3-dit.onnx'))
&& fs.existsSync(path.join(dir, 'tokenizer.json'));
}
// Lazy singleton — the 34MB tokenizer.json parse is deferred to first use.
let tokenizerPromise: Promise<Sa3Tokenizer> | null = null;
async function getTokenizer(): Promise<Sa3Tokenizer> {
if (!tokenizerPromise) {
tokenizerPromise = (async () => {
const dir = sa3Dir();
const tokenizerJSON = JSON.parse(
fs.readFileSync(path.join(dir, 'tokenizer.json'), 'utf-8'));
const tokenizerConfig = JSON.parse(
fs.readFileSync(path.join(dir, 'tokenizer_config.json'), 'utf-8'));
const { TokenizerLoader } = await import('@lenml/tokenizers');
// Construct directly from the parsed JSON files — no hub resolution.
return TokenizerLoader.fromPreTrained({ tokenizerJSON, tokenizerConfig });
})();
// On failure, allow a retry on the next call instead of caching the error.
tokenizerPromise.catch(() => { tokenizerPromise = null; });
}
return tokenizerPromise;
}
/** Normalize whatever the tokenizer returns (number[], BigInt64Array, or a
* Tensor with a .data typed array) into a plain number[]. */
function toNumberArray(value: unknown): number[] {
const raw: unknown =
(value !== null && typeof value === 'object' && 'data' in (value as any))
? (value as any).data
: value;
return Array.from(raw as ArrayLike<number | bigint>, v => Number(v));
}
/**
* Tokenize a prompt for the engine's /sa3-refine endpoint.
* Truncates to 256 tokens and pads to exactly 256 with the tokenizer's pad id.
* Returns the padded ids (length 256) and the real (non-pad) token count.
*/
export async function tokenizeForSa3(prompt: string): Promise<{ ids: number[]; nTokens: number }> {
const tokenizer = await getTokenizer();
const enc = tokenizer(prompt, {
truncation: true,
padding: 'max_length',
max_length: SA3_TOK_LEN,
return_tensor: false,
});
let ids = toNumberArray(enc.input_ids);
const mask = toNumberArray(enc.attention_mask);
let nTokens = mask.length > 0
? mask.reduce((a, b) => a + (b ? 1 : 0), 0)
: ids.length;
// Enforce exactly SA3_TOK_LEN ids regardless of tokenizer quirks.
const padId = tokenizer.pad_token_id ?? 0;
if (ids.length > SA3_TOK_LEN) ids = ids.slice(0, SA3_TOK_LEN);
while (ids.length < SA3_TOK_LEN) ids.push(padId);
if (nTokens > SA3_TOK_LEN) nTokens = SA3_TOK_LEN;
if (nTokens < 1) nTokens = 1;
return { ids, nTokens };
}
/** Comma-segments matching this are considered vocal descriptors and dropped
* from StableStep prompts (the SA3 refine targets instrumentals). */
const VOCAL_SEGMENT_RE =
/\b(vocals?|singers?|singing|sung|sing|voice|voices|choir|rap|rapper|rapping|spoken|acapella|a cappella|lyrics|verse|chorus line)\b/i;
/**
* Build the SA3 refine prompt from a track caption: strip vocal-related
* descriptors (comma-segment-wise), then append the instrumental suffix and
* target length. Falls back to "Instrumental track" if everything is stripped.
*/
export function buildStableStepPrompt(caption: string, durationSec: number): string {
const kept = (caption || '')
.split(',')
.map(s => s.trim())
.filter(s => s.length > 0 && !VOCAL_SEGMENT_RE.test(s));
const base = kept.length > 0 ? kept.join(', ') : 'Instrumental track';
return `${base}. Instrumental only, no vocals. Length: ${Math.round(durationSec)} seconds`;
}
+71
View File
@@ -0,0 +1,71 @@
// spectralLifter.ts — Spectral Lifter subprocess wrapper
//
// Spawns the Python-based Spectral Lifter CLI to process a WAV file.
// Used as the first stage of the post-processing pipeline.
import { execFile } from 'child_process';
import { promisify } from 'util';
import fs from 'fs';
import path from 'path';
const execFileAsync = promisify(execFile);
// Path to the Spectral Lifter install
const SPECTRAL_LIFTER_DIR = path.resolve('D:\\Ace-Step-Latest\\Spectral-Lifter');
const CLI_SCRIPT = path.join(SPECTRAL_LIFTER_DIR, 'cli.py');
/** Tunable parameters for the Spectral Lifter pipeline */
export interface SpectralLifterParams {
denoise_passes?: number; // 0-4, default 2
denoise_threshold?: number; // 0.5-4.0, default 1.5
hf_mix?: number; // 0.0-0.5, default 0.25
transient_boost?: number; // 0.0-1.0, default 0.5
shimmer_reduction?: number; // 0-12 dB, default 6.0
}
/**
* Run Spectral Lifter on a WAV file.
*
* @param inputWav - Path to input WAV file
* @param outputWav - Path to write processed WAV file
* @param params - Optional tunable parameters
* @throws If the CLI script is missing, the input file doesn't exist, or processing fails
*/
export async function runSpectralLifter(
inputWav: string,
outputWav: string,
params?: SpectralLifterParams,
): Promise<void> {
if (!fs.existsSync(CLI_SCRIPT)) {
throw new Error(`Spectral Lifter CLI not found at ${CLI_SCRIPT}`);
}
if (!fs.existsSync(inputWav)) {
throw new Error(`Input file not found: ${inputWav}`);
}
console.log(`[Spectral Lifter] Processing: ${path.basename(inputWav)}${path.basename(outputWav)}`);
const start = Date.now();
const args = [CLI_SCRIPT, inputWav, outputWav];
if (params && Object.keys(params).length > 0) {
args.push('--params', JSON.stringify(params));
}
const { stderr } = await execFileAsync('python', args, {
timeout: 300_000, // 5 min max
cwd: SPECTRAL_LIFTER_DIR,
});
if (stderr) {
for (const line of stderr.split('\n')) {
if (line.trim()) console.log(`[Spectral Lifter] ${line.trim()}`);
}
}
if (!fs.existsSync(outputWav)) {
throw new Error('Spectral Lifter produced no output file');
}
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
console.log(`[Spectral Lifter] Complete in ${elapsed}s`);
}
+502
View File
@@ -0,0 +1,502 @@
// training/aceTrain.ts — ace-train binary discovery, tensor-cache paths and
// the cached engine model snapshot.
//
// The preprocess job stops ace-server to free VRAM, so the model picker cannot
// depend on the engine being reachable at the moment it is rendered. Every
// successful /props read is cached here and served while the engine is down
// (P28).
//
// Spec: docs/plans/2026-07-27-preprocess-implementation.md §4.2
import fs from 'fs';
import path from 'path';
import { config } from '../../config.js';
import { aceClient } from '../aceClient.js';
import { slugify, trainingBaseDir } from './paths.js';
import type {
DitAdapterType, LmSize, PreprocessCompat, PreprocessDtype, PreprocessNormalize,
PreprocessOptions, TrainDitStage, TrainLmStage,
} from './types.js';
/** Model-file extensions stripped when deriving a variant key. */
const MODEL_EXTENSIONS = ['.gguf', '.safetensors', '.bin', '.pt', '.pth', '.onnx'];
/** Absolute path to ace-train, or null. Sibling of ace-server in both the
* CMake and portable layouts — same pattern as aceMidiExe(). */
export function aceTrainExe(): string | null {
const dir = path.dirname(config.aceServer.exe);
const exe = path.join(dir, process.platform === 'win32' ? 'ace-train.exe' : 'ace-train');
return fs.existsSync(exe) ? exe : null;
}
/** DiT model name → filesystem-safe variant key (extension stripped). */
export function variantKeyFor(ditModel: string): string {
const raw = String(ditModel ?? '').replace(/\\/g, '/');
let base = raw.slice(raw.lastIndexOf('/') + 1);
// Only KNOWN model extensions are stripped: names like
// `acestep-v15-merge-base-turbo-xl-ta-0.5` must not lose their `.5`.
const lower = base.toLowerCase();
for (const ext of MODEL_EXTENSIONS) {
if (lower.endsWith(ext)) { base = base.slice(0, base.length - ext.length); break; }
}
const safe = base.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 96);
return safe || 'default';
}
/** data/training/tensors/<slug> */
export function tensorsRoot(slug: string): string {
return path.join(trainingBaseDir, 'tensors', slugify(slug));
}
/** data/training/tensors/<slug>/<variantKey> */
export function tensorsDir(slug: string, variantKey: string): string {
return path.join(tensorsRoot(slug), variantKeyFor(variantKey));
}
// ── Cached /props model snapshot (P28) ───────────────────────────────────
export interface ModelSnapshot {
dit: string[]; vae: string[]; textEnc: string[]; lm: string[]; cachedAt: number;
}
let snapshot: ModelSnapshot = { dit: [], vae: [], textEnc: [], lm: [], cachedAt: 0 };
/** Last successful /props read. Survives the engine being stopped mid-job. */
export function getModelSnapshot(): ModelSnapshot {
return snapshot;
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((v): v is string => typeof v === 'string') : [];
}
/** Re-probe /props. Never throws — returns the previous snapshot on any failure. */
export async function refreshModelSnapshot(): Promise<ModelSnapshot> {
try {
const props = await aceClient.props();
const dit = stringList(props?.models?.dit);
const vae = stringList(props?.models?.vae);
// `models.embedding` IS the text-encoder bucket (Qwen3-Embedding).
const textEnc = stringList(props?.models?.embedding);
const lm = stringList(props?.models?.lm);
// §4.2 says the previous snapshot survives a FAILURE, not emptiness. A
// successful /props that honestly reports three empty buckets (models
// deleted, ACESTEPCPP_MODELS repointed) must replace the cache, or the
// picker offers names that no longer exist and the POST validation accepts
// them — the user only finds out as an ace-train exit 2, after the engine
// has already been stopped. A malformed response (no `models` object at
// all) is still treated as a failure.
if (props && typeof props === 'object' && props.models && typeof props.models === 'object') {
snapshot = { dit, vae, textEnc, lm, cachedAt: Date.now() };
}
} catch {
// Engine down or stopped for a job — the cache is exactly what we want.
}
return snapshot;
}
/** First name matching /bf16/i, else ''. */
export function pickBf16(names: string[]): string {
return names.find(n => /bf16/i.test(n)) ?? '';
}
/**
* Preferred BF16 LM for a size, e.g. '0.6B' -> 'acestep-5Hz-lm-0.6B-BF16.gguf'.
*
* Match rule (§4.2): the name contains `-<size>-` (case-insensitive) AND /bf16/i.
* Falls back to the first name containing `-<size>-` at all; '' when none.
* The fallback is deliberately kept: the trainer refuses a genuinely quantized
* base at mirror time with a clear message, which beats offering nothing.
*/
export function pickLmFor(size: LmSize, names: string[]): string {
// '0.6B' carries a literal '.', which must not become a regex wildcard.
const token = new RegExp(`-${size.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}-`, 'i');
const matches = names.filter(n => token.test(n));
return matches.find(n => /bf16/i.test(n)) ?? matches[0] ?? '';
}
// ── Argv construction ────────────────────────────────────────────────────
/** Every PreprocessOptions field the runner needs, with defaults already applied
* by the route. `job.opts` carries exactly this shape for a preprocess job. */
export interface ResolvedPreprocessOptions {
ditModel: string;
vaeModel: string;
textEncoder: string;
maxDuration: number;
normalize: PreprocessNormalize;
targetDb: number;
dtype: PreprocessDtype;
compat: PreprocessCompat;
maxCaptionTokens: number;
maxLyricTokens: number;
vaeChunk: number;
vaeOverlap: number;
overwrite: boolean;
stopEngine: boolean;
/** Absolute; data/training/tensors/<slug>/<variantKey> unless overridden. */
outputDir: string;
variantKey: string;
}
type PreprocessArgOpts = Required<Pick<PreprocessOptions,
'maxDuration' | 'normalize' | 'targetDb' | 'dtype' | 'compat' |
'maxCaptionTokens' | 'maxLyricTokens' | 'vaeChunk' | 'vaeOverlap' | 'overwrite'>>;
/** Build the full argv for `ace-train preprocess` (§2.1 order). */
export function buildPreprocessArgs(input: {
manifestPath: string; outDir: string; modelsDir: string;
dit: string; vae: string; textEnc: string;
opts: PreprocessArgOpts;
ffmpeg: string | null;
}): string[] {
const o = input.opts;
const args = [
'preprocess',
'--manifest', input.manifestPath,
'--out', input.outDir,
'--models', input.modelsDir,
'--dit', input.dit,
'--vae', input.vae,
'--text-enc', input.textEnc,
'--max-duration', String(o.maxDuration),
'--normalize', o.normalize,
'--target-db', String(o.targetDb),
'--dtype', o.dtype,
'--compat', o.compat,
'--max-caption-tokens', String(o.maxCaptionTokens),
'--max-lyric-tokens', String(o.maxLyricTokens),
'--vae-chunk', String(o.vaeChunk),
'--vae-overlap', String(o.vaeOverlap),
];
if (input.ffmpeg) args.push('--ffmpeg', input.ffmpeg);
if (o.overwrite) args.push('--overwrite');
args.push('--jsonl');
return args;
}
// ── LM LoRA training (phase 3) ───────────────────────────────────────────
/** Every TrainLmOptions field the runner needs, with defaults and clamps
* already applied by the route. `job.opts` carries exactly this shape for a
* train-lm job. Spec §4.2. */
export interface ResolvedTrainLmOptions {
lmSize: LmSize;
lmModel: string;
ditModel: string;
variantKey: string;
/** Absolute preprocess variant dir the codes are extracted from. */
tensorsDir: string;
/** Absolute <tensorsDir>/lm_codes.jsonl. */
codesPath: string;
adapterName: string;
/** Absolute <adapters>/lm/<adapterName>-<lmSize>. */
adapterDir: string;
targetLoss: number;
epochs: number;
/** Adapter parameterization. 'lokr' emits the LyCORIS kron factors and makes
* rank/alpha inert; the exporter writes lokr_weights.safetensors. */
adapterType: 'lora' | 'lokr';
/** Optimizer rule set. 'muon' orthogonalizes 2-D parameters (short side >=
* 16, which for a LoRA is the RANK) and leaves the rest on AdamW. */
optimizer: 'adamw' | 'muon';
muonLrScale: number;
muonNsSteps: number;
rank: number;
alpha: number;
lokrDim: number;
lokrAlpha: number;
lokrFactor: number;
lokrDecomposeBoth: boolean;
learningRate: number;
gradAccum: number;
gradClip: number;
warmupRatio: number;
weightDecay: number;
maxLen: number;
seed: number;
lossOnCot: boolean;
order: 'shuffle' | 'fixed';
milestoneStep: number;
milestoneKeep: number;
stages: TrainLmStage[];
overwrite: boolean;
stopEngine: boolean;
/** 'auto' = the engine's own default (ON for 4B, and for smaller bases only
* when the naive fit would drop full-song samples). */
lowVram: 'auto' | 'on' | 'off';
/** 0 = engine picks (n_heads <= 16 -> off, else 8). */
attnHeadBlock: number;
/** 0 = engine default (128 trained positions per CE chunk). */
chunk: number;
/** 'f32-window' = the shipped per-segment F32 weight cast (still the CLI's
* own default, ace-train.cpp). 'bf16' = BF16 projections + backward
* surgery; needs CUDA + a BF16-native base + low-VRAM and changes the
* trained weights. A non-CUDA backend or non-BF16 base each warn and fall
* back to 'f32-window' (lm-train-run.h) rather than failing the run. The
* SERVER default is 'bf16' (2026-07-29, training.ts train-lm handler). */
weights: 'f32-window' | 'bf16';
/** Micro-batch size 1..8, or 'auto'. 1 is the CLI default; >1 implies low-VRAM. */
batch: number | 'auto';
/** MUL_MAT activation-gradient formulation (engine/patches/mm-backward.patch).
* 'outprod' = upstream ggml out_prod, F32-only on CUDA. 'mm' =
* mul_mat(cont(transpose(W)), grad) — identical maths, dtype-agnostic, so a
* BF16 weight uses BF16 tensor cores (~1.7-1.8x per layer on an RTX 5090).
* ace-train's own default is 'outprod', and so is the LM SERVER default —
* `weights: 'bf16'` already reaches the same backward by rewriting ggml's
* out_prod nodes in place (lm-bf16.h) and aborts if --bwd mm leaves it none,
* so the route refuses that pair. train-dit, which has no such surgery,
* defaults to 'mm'. */
bwd: 'outprod' | 'mm';
}
/** Full argv for `ace-train train-lm` (§2.1 order). */
export function buildTrainLmArgs(input: {
opts: ResolvedTrainLmOptions; modelsDir: string;
}): string[] {
const o = input.opts;
const args = [
'train-lm',
'--stages', o.stages.join(','),
'--tensors', o.tensorsDir,
'--codes', o.codesPath,
'--out', o.adapterDir,
'--models', input.modelsDir,
// `--dit` DEFAULTS to preprocess_meta.json's dit_path (§2.1). Omitting the
// pair is not the same as passing an empty value: resolve_model('') fails and
// the sibling cmd_preprocess exits 2 on it — which here would happen only
// AFTER the runner stopped ace-server, so the user would pay a full engine
// stop/restart cycle for a resolvable-by-default condition. ditModel is ''
// whenever the variant's meta is missing/unparseable or lacks model_variant.
...(o.ditModel ? ['--dit', o.ditModel] : []),
'--lm', o.lmModel,
'--lm-size', o.lmSize,
// Always emitted so an ace-train that predates --adapter-type rejects it
// loudly rather than silently training a LoRA when a LoKr was asked for.
'--adapter-type', o.adapterType,
// Always emitted so an ace-train that predates --optimizer rejects it loudly
// rather than silently training on AdamW when Muon was asked for.
'--optimizer', o.optimizer,
...(o.optimizer === 'muon'
? ['--muon-lr-scale', String(o.muonLrScale), '--muon-ns-steps', String(o.muonNsSteps)]
: []),
...(o.adapterType === 'lokr'
? ['--lokr-dim', String(o.lokrDim), '--lokr-alpha', String(o.lokrAlpha), '--lokr-factor', String(o.lokrFactor)]
: ['--rank', String(o.rank), '--alpha', String(o.alpha)]),
'--lr', String(o.learningRate),
'--epochs', String(o.epochs),
'--grad-accum', String(o.gradAccum),
'--warmup-ratio', String(o.warmupRatio),
'--grad-clip', String(o.gradClip),
'--weight-decay', String(o.weightDecay),
'--seed', String(o.seed),
'--target-loss', String(o.targetLoss),
'--order', o.order,
'--max-len', String(o.maxLen),
'--milestone-step', String(o.milestoneStep),
'--milestone-keep', String(o.milestoneKeep),
];
// Low-VRAM knobs: every default here IS the CLI default, so a normal run
// emits none of them and the argv stays byte-identical to the pre-4B build.
// That keeps an older ace-train.exe (no --low-vram flag) working for the
// untouched 0.6B/1.7B path.
if (o.lowVram && o.lowVram !== 'auto') args.push('--low-vram', o.lowVram);
if (o.attnHeadBlock > 0) args.push('--attn-head-block', String(o.attnHeadBlock));
if (o.chunk > 0) args.push('--lm-chunk', String(o.chunk));
// Speed levers (2026-07-28 plan §1.3). `batch` still defaults to the CLI's
// own default (1), so a normal run emits no --batch flag. `weights` no
// longer matches the CLI default: the server defaults it to 'bf16'
// (2026-07-29), so a normal run now DOES emit --weights bf16 explicitly.
// An older ace-train.exe without --weights/--batch only stays compatible
// if the caller explicitly requests 'f32-window'.
if (o.weights && o.weights !== 'f32-window') args.push('--weights', o.weights);
if (o.batch !== undefined && o.batch !== 1) args.push('--batch', String(o.batch));
// Always emitted, both sides: an ace-train that predates --bwd rejects it
// loudly rather than silently running the slow out_prod backward.
args.push('--bwd', o.bwd);
// `--loss-on-cot` is the CLI default; only the negation needs emitting.
if (!o.lossOnCot) args.push('--no-loss-on-cot');
if (o.overwrite) args.push('--overwrite');
args.push('--jsonl');
return args;
}
// ── DiT LoRA training (phase 4) ──────────────────────────────────────────
/** Every TrainDitOptions field the runner needs, with defaults and clamps
* already applied by the route. `job.opts` carries exactly this shape for a
* train-dit job. Spec §4.2. */
export interface ResolvedTrainDitOptions {
variantKey: string; tensorsDir: string;
ditModel: string; ditPath: string;
adapterName: string; adapterDir: string;
adapterType: DitAdapterType; rank: number; alpha: number; targetMlp: boolean;
// LyCORIS LoKR factors (K2 / plan §2.1). Always resolved regardless of
// adapterType — buildTrainDitArgs only emits them when adapterType==='lokr'.
lokrDim: number; lokrAlpha: number; lokrFactor: number; lokrDecomposeBoth: boolean;
layers: number; crop: number; cropMin: number; cropMax: number;
targetLoss: number; epochs: number; learningRate: number;
gradAccum: number; gradClip: number; warmupRatio: number; weightDecay: number;
lossWeighting: 'none' | 'flow_snr'; snrGamma: number; tBias: number;
channelBalance: boolean; timestepMu: number; timestepSigma: number;
tMin: number; tMax: number; cfgRatio: number; genreRatio: number;
seed: number; order: 'shuffle' | 'fixed';
milestoneStep: number; milestoneKeep: number; vramReserveMb: number;
mirror: 'f32' | 'bf16';
/** MUL_MAT activation-gradient formulation — see ResolvedTrainLmOptions.bwd.
* ace-train defaults to 'outprod'; the SERVER default is 'mm'. */
bwd: 'outprod' | 'mm';
/** Optimizer rule set (2026-07-30). 'adamw' is the shipped path; 'muon' is
* per-parameter — 2-D parameters with a short side >= muonMinDim get
* orthogonalized-momentum updates, the rest stay on AdamW. */
optimizer: 'adamw' | 'muon';
muonLrScale: number;
muonMomentum: number;
muonNsSteps: number;
muonMinDim: number;
batch: number; ckptSegments: number;
stages: TrainDitStage[]; overwrite: boolean; stopEngine: boolean;
}
/**
* The variant's own base DiT, as an ABSOLUTE path, '' when it is no longer on
* disk (§4.2 base-match guard).
*
* The encoder states and context latents in the cache are that exact model's
* outputs, so training against anything else is silently wrong — which is why
* this reads the variant's record and never user input. Three sources, in
* descending order of trust:
* 1. `dit_path` — the absolute path the preprocess run actually loaded
* 2. `model_variant` — the file name, resolved against the models dir
* 3. `variantKey` — the same name with its extension stripped (§ variantKeyFor)
* Returning '' is the caller's cue to answer 400 rather than stop the engine
* for a run that cannot load its base.
*/
export function pickDitBaseFor(variantKey: string, tensorsDirPath: string): string {
const exists = (p: string): boolean => {
try { return !!p && fs.existsSync(p); } catch { return false; }
};
let ditPath = '';
let modelVariant = '';
try {
const meta = JSON.parse(
fs.readFileSync(path.join(tensorsDirPath, 'preprocess_meta.json'), 'utf-8'),
) as { dit_path?: unknown; model_variant?: unknown };
if (typeof meta.dit_path === 'string') ditPath = meta.dit_path.trim();
if (typeof meta.model_variant === 'string') modelVariant = meta.model_variant.trim();
} catch {
return ''; // no meta = not a real variant
}
if (exists(ditPath)) return ditPath;
// The models dir may have moved since the preprocess run (portable release,
// ACESTEPCPP_MODELS repointed) while the same file is still installed.
const modelsDir = config.aceServer.models;
if (modelVariant) {
const byName = path.join(modelsDir, modelVariant);
if (exists(byName)) return byName;
}
if (variantKey) {
for (const ext of ['.gguf', '']) {
const byKey = path.join(modelsDir, `${variantKey}${ext}`);
if (exists(byKey)) return byKey;
}
}
return '';
}
/** Full argv for `ace-train train-dit` (§2.1 order). */
export function buildTrainDitArgs(input: {
opts: ResolvedTrainDitOptions; modelsDir: string;
}): string[] {
const o = input.opts;
const args = [
'train-dit',
'--stages', o.stages.join(','),
'--tensors', o.tensorsDir,
'--out', o.adapterDir,
'--models', input.modelsDir,
// Always present in practice — the route refuses the request when
// pickDitBaseFor() came back empty, so the engine never has to fall back to
// preprocess_meta.json's own default. Guarded anyway: passing an empty value
// would make resolve_model('') exit 2, AFTER the runner already stopped the
// engine.
...(o.ditPath ? ['--dit', o.ditPath] : []),
'--adapter-type', o.adapterType,
// §2.1: lora trains via --rank/--alpha; lokr via the four --lokr-* flags.
// The two are mutually exclusive on the CLI side, so only one set is ever
// emitted — sending both would be harmless (ace-train ignores the unused
// side) but would misreport the run in logs/JSONL relays that echo argv.
...(o.adapterType === 'lokr'
? [
'--lokr-dim', String(o.lokrDim),
'--lokr-alpha', String(o.lokrAlpha),
'--lokr-factor', String(o.lokrFactor),
// Flag-shaped, default on (§2.1) — same "only the non-default side
// is emitted" convention as --channel-balance below.
...(o.lokrDecomposeBoth ? [] : ['--no-lokr-decompose-both']),
]
: ['--rank', String(o.rank), '--alpha', String(o.alpha)]),
'--layers', String(o.layers),
'--crop', String(o.crop),
'--crop-min', String(o.cropMin),
'--crop-max', String(o.cropMax),
'--loss-weighting', o.lossWeighting,
'--snr-gamma', String(o.snrGamma),
'--t-bias', String(o.tBias),
'--timestep-mu', String(o.timestepMu),
'--timestep-sigma', String(o.timestepSigma),
'--t-min', String(o.tMin),
'--t-max', String(o.tMax),
'--cfg-ratio', String(o.cfgRatio),
'--genre-ratio', String(o.genreRatio),
'--lr', String(o.learningRate),
'--epochs', String(o.epochs),
'--grad-accum', String(o.gradAccum),
'--warmup-ratio', String(o.warmupRatio),
'--grad-clip', String(o.gradClip),
'--weight-decay', String(o.weightDecay),
'--seed', String(o.seed),
'--target-loss', String(o.targetLoss),
'--order', o.order,
'--vram-reserve-mb', String(o.vramReserveMb),
// Always emitted, both sides: an ace-train that predates the flag rejects it
// loudly rather than silently running the other precision.
'--mirror', o.mirror,
// Ditto: always emitted, so an ace-train that predates --bwd rejects it
// loudly rather than silently running the slow out_prod backward.
'--bwd', o.bwd,
// Always emitted so an ace-train that predates --optimizer rejects it
// loudly rather than silently training on AdamW when Muon was asked for.
'--optimizer', o.optimizer,
// Batching/checkpointing (design §2.2): always emitted on both sides —
// an ace-train that predates the flags rejects them loudly rather than
// silently training at batch 1 / no checkpointing.
'--batch', String(o.batch),
'--ckpt', String(o.ckptSegments),
'--milestone-step', String(o.milestoneStep),
'--milestone-keep', String(o.milestoneKeep),
];
// Flag-shaped options: only the non-default side is emitted (§2.1).
// target-mlp is the exception — it is emitted on BOTH sides. Its default
// flipped to ON, so "omit when false" would silently train the MLP anyway,
// and the checkbox in TrainDitForm would be dead. Needs an ace-train that
// knows --no-target-mlp (added alongside the default flip); an older binary
// rejects the unknown option loudly rather than doing the wrong thing.
args.push(o.targetMlp ? '--target-mlp' : '--no-target-mlp');
// Muon knobs only when Muon is actually selected — they are inert on the
// AdamW path, and emitting them there would put noise in the recorded argv
// of every run that never used them.
if (o.optimizer === 'muon') {
args.push('--muon-lr-scale', String(o.muonLrScale));
args.push('--muon-momentum', String(o.muonMomentum));
args.push('--muon-ns-steps', String(o.muonNsSteps));
args.push('--muon-min-dim', String(o.muonMinDim));
}
if (!o.channelBalance) args.push('--no-channel-balance');
if (o.overwrite) args.push('--overwrite');
args.push('--jsonl');
return args;
}
@@ -0,0 +1,180 @@
/**
* Adapter directory layout — one hierarchy per training base.
*
* Adapters trained on different architectures are not interchangeable, so they
* live in per-base folders (2026-07-28, Rob's call):
*
* <adapters>/
* lm-06b/<artist>/<run>/ planner-LM LoRAs per base size, one dir per
* lm-17b/<artist>/<run>/ training run (run = YYYY-MM-DD_HH-MM-SS, the
* lm-4b/<artist>/<run>/ logs/ convention: name-sorted = time-sorted)
* dit-xl-thirds/<artist>/<run>/ DiT adapters per base, dit-<shorthand>
* dit-xl-base-turbo/<artist>/<run>/
* ...
*
* The artist folder no longer carries a `-<size>` suffix — the parent folder
* says what the adapter runs on — and retraining an artist never overwrites an
* earlier adapter: each run gets its own stamped subfolder. Two legacy forms
* are still READ everywhere so pre-migration installs keep working: an
* unversioned adapter directly in the artist folder, and the original flat
* layout (`lm/<artist>-4B`, DiT dirs at the root). Nothing new is ever written
* to either.
*
* `server/scripts/migrate-adapter-layout.mjs` moves an existing corpus over.
*/
import fs from 'fs';
import path from 'path';
import { config } from '../../config.js';
import type { LmSize } from './types.js';
// ── planner-LM sizes ────────────────────────────────────────────────────────
const LM_SIZE_SLUGS: Record<LmSize, string> = {
'0.6B': 'lm-06b',
'1.7B': 'lm-17b',
'4B': 'lm-4b',
};
/** 'lm-06b' | 'lm-17b' | 'lm-4b' — the per-size folder under <adapters>. */
export function lmSizeSlug(size: LmSize): string {
return LM_SIZE_SLUGS[size] ?? `lm-${String(size).toLowerCase().replace(/\./g, '')}`;
}
/** Inverse of lmSizeSlug. '' when the folder name is not a size slug. */
export function lmSizeFromSlug(dirName: string): LmSize | '' {
const hit = (Object.entries(LM_SIZE_SLUGS) as Array<[LmSize, string]>)
.find(([, slug]) => slug === dirName.toLowerCase());
return hit ? hit[0] : '';
}
/** Every folder GET /api/adapters/lm scans: the three size roots plus the
* legacy flat `lm/` so a pre-migration install keeps working. */
export function lmAdapterRoots(): Array<{ dir: string; size: LmSize | '' }> {
const root = config.aceServer.adapters;
return [
{ dir: path.join(root, 'lm-06b'), size: '0.6B' },
{ dir: path.join(root, 'lm-17b'), size: '1.7B' },
{ dir: path.join(root, 'lm-4b'), size: '4B' },
{ dir: path.join(root, 'lm'), size: '' }, // legacy — size read off the -<size> suffix
];
}
/**
* The base size an LM adapter path implies: the parent folder's slug first
* (new layout), then the legacy `-<size>` name suffix. '' when neither says.
*
* This is what pins the audition's base LM — before the folder layout the
* suffix was the only signal, and losing it silently put a 4B adapter on the
* sticky 0.6B base ("36 layers but model has 28").
*/
export function lmSizeOfAdapterPath(adapterPath: string): LmSize | '' {
const parent = path.basename(path.dirname(path.resolve(adapterPath)));
const bySlug = lmSizeFromSlug(parent);
if (bySlug) return bySlug;
const m = /-(0\.6B|1\.7B|4B)$/i.exec(path.basename(adapterPath));
if (!m) return '';
const norm = m[1].toUpperCase().replace('B', 'B');
return (['0.6B', '1.7B', '4B'] as LmSize[]).find(s => s.toUpperCase() === norm) ?? '';
}
// ── DiT base shorthands ─────────────────────────────────────────────────────
/**
* Confirmed with Rob 2026-07-28 — the folder a DiT adapter trained on each
* base lands in. Keys are the model-name stem (no quant token, no .gguf).
*/
const DIT_SHORTHANDS: Record<string, string> = {
'acestep-v15-merge-base-sft-turbo-xl-thirds': 'dit-xl-thirds',
'acestep-v15-merge-base-turbo-xl-ta-0.5': 'dit-xl-base-turbo',
'acestep-v15-xl-sftturbo50': 'dit-xl-sft-turbo',
'acestep-v15-merge-base-sft-xl-ta-0.5': 'dit-xl-base-sft',
'acestep-v15-merge-sft-turbo-xl-ta-0.3': 'dit-xl-sft-turbo-ta03',
'acestep-v15-merge-sft-turbo-xl-ta-0.7': 'dit-xl-sft-turbo-ta07',
'acestep-v15-xl-base': 'dit-xl-base',
'acestep-v15-xl-sft': 'dit-xl-sft',
'acestep-v15-xl-turbo': 'dit-xl-turbo',
'acestep-v15-base': 'dit-base',
'acestep-v15-sft': 'dit-sft',
'acestep-v15-turbo': 'dit-turbo',
'acestep-v15-sftturbo50': 'dit-sft-turbo',
'acestep-v15-turbo-continuous': 'dit-turbo-continuous',
'acestep-v15-turbo-shift1': 'dit-turbo-shift1',
'acestep-v15-turbo-shift3': 'dit-turbo-shift3',
'acestep-v15-merge-base-sft-turbo-xl-thirds-convrot-ref': 'dit-xl-thirds-convrot',
'sa3-dit': 'dit-sa3',
};
/** Quant/precision tokens that end a model filename. */
const QUANT_RE = /-(BF16|F16|F32|MXFP4|NVFP4|IQ\d[A-Z_]*|Q\d[\w]*)$/i;
/** Model name/filename/path → its stem: no dir, no .gguf, no quant token. */
export function ditModelStem(model: string): string {
let stem = path.basename(String(model ?? '')).replace(/\.gguf$/i, '');
stem = stem.replace(QUANT_RE, '');
return stem;
}
/**
* Shorthand folder for a DiT base. Unknown bases degrade to a recognisable
* slug (name minus the family prefix and quant token) rather than failing —
* a future model should never block training on a folder name.
*/
export function ditShorthand(model: string): string {
const stem = ditModelStem(model);
if (!stem) return 'dit-unknown-base';
const mapped = DIT_SHORTHANDS[stem];
if (mapped) return mapped;
const fallback = stem.replace(/^acestep-v15-/i, '').replace(/\./g, '');
return fallback ? `dit-${fallback}` : 'dit-unknown-base';
}
// ── training-run versioning ─────────────────────────────────────────────────
/** logs/-convention timestamp: YYYY-MM-DD_HH-MM-SS, local time. */
export function runStamp(when = new Date()): string {
const p = (n: number) => String(n).padStart(2, '0');
return `${when.getFullYear()}-${p(when.getMonth() + 1)}-${p(when.getDate())}` +
`_${p(when.getHours())}-${p(when.getMinutes())}-${p(when.getSeconds())}`;
}
/** True when a directory holds adapter weights directly. Both trained layouts
* count: PEFT (`adapter_model.safetensors`) and the LyCORIS LoKR export
* ace-train `--adapter-type lokr` writes (`lokr_weights.safetensors`, no
* adapter_config.json — lokr-dit-training plan §2.4). Checking only the PEFT
* leaf made every LoKR run dir invisible to latestRunDir(). */
export function hasWeights(dir: string): boolean {
return fs.existsSync(path.join(dir, 'adapter_model.safetensors'))
|| fs.existsSync(path.join(dir, 'lokr_weights.safetensors'));
}
/**
* A FRESH run dir under `artistDir` for a training run to write into —
* `<artistDir>/<stamp>`, bumped with `-2`, `-3`… in the (retry-within-a-second)
* case where the stamp already exists. Retraining an artist therefore never
* overwrites an earlier adapter. Callers pass an already-sanitised artist dir.
*/
export function newRunDir(artistDir: string): string {
const base = path.join(artistDir, runStamp());
if (!fs.existsSync(base)) return base;
for (let i = 2; i < 100; i++) {
const c = `${base}-${i}`;
if (!fs.existsSync(c)) return c;
}
return `${base}-${Date.now().toString(36)}`;
}
/**
* The newest trained adapter under `artistDir`: the lexicographically-last run
* subfolder holding weights (stamps sort chronologically), else the artist dir
* itself when it holds weights directly (unversioned legacy), else ''.
*/
export function latestRunDir(artistDir: string): string {
try {
const runs = fs.readdirSync(artistDir, { withFileTypes: true })
.filter(e => e.isDirectory() && !e.name.startsWith('.') && hasWeights(path.join(artistDir, e.name)))
.map(e => e.name)
.sort();
if (runs.length) return path.join(artistDir, runs[runs.length - 1]);
} catch { /* no artist dir yet */ }
return hasWeights(artistDir) ? artistDir : '';
}
+57
View File
@@ -0,0 +1,57 @@
// training/audioMeta.ts — duration + embedded-tag reader (music-metadata)
//
// Duration is measured here and ONLY here — never taken from /understand, whose
// `duration` is the LM's chain-of-thought guess rather than a measurement (§4.8).
// Tag strings arrive with Latin-1 mojibake and NULs often enough that every one
// is sanitised before use (§7.6).
import { parseFile } from 'music-metadata';
export interface AudioMetaResult {
duration: number; // seconds, 0 when unknown
artist: string;
title: string;
album: string;
genre: string; // first embedded genre tag, '' when absent
bpm: number | null; // embedded BPM tag, when the container carries one
}
const EMPTY: AudioMetaResult = { duration: 0, artist: '', title: '', album: '', genre: '', bpm: null };
/** Control chars to drop from tag text — everything below 0x20 except \t and \n,
* plus DEL. Built from escapes so the source file stays plain ASCII. */
const CONTROL_CHARS = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F]', 'g');
/** Strip NULs and control chars (keeping \n and \t), trim, cap at 300 (§7.6). */
export function sanitizeTag(value: unknown): string {
if (typeof value !== 'string') return '';
return value.replace(CONTROL_CHARS, '').trim().slice(0, 300);
}
/** Read duration + artist/title/album. Never throws — returns zeros on failure. */
export async function read(audioPath: string): Promise<AudioMetaResult> {
try {
const md = await parseFile(audioPath, { duration: true, skipCovers: true });
const rawBpm = (md.common as { bpm?: unknown }).bpm;
const bpm = typeof rawBpm === 'number' && Number.isFinite(rawBpm) && rawBpm > 0
? Math.trunc(rawBpm)
: null;
return {
duration: md.format.duration && Number.isFinite(md.format.duration) ? md.format.duration : 0,
artist: sanitizeTag(md.common.artist),
title: sanitizeTag(md.common.title),
album: sanitizeTag(md.common.album),
genre: sanitizeTag(md.common.genre?.[0]),
bpm,
};
} catch (err: any) {
console.warn(`[Training] Tag read failed for ${audioPath}: ${err?.message || err}`);
return { ...EMPTY };
}
}
/** Duration only, in seconds. 0 when it cannot be determined. */
export async function readDuration(audioPath: string): Promise<number> {
const meta = await read(audioPath);
return meta.duration;
}
@@ -0,0 +1,241 @@
// training/auditionRunner.ts — runs one two-sided codes-audition job
//
// Lifecycle clone of trainLmRunner.ts WITH THE ENGINE STOP REMOVED. This is the
// one Training-Studio job that requires ace-server to be RUNNING: the only
// adapter-capable LM host in the tree is ace-server's /lm (ace-lm.cpp never
// reads req.lm_adapter), and /codes-decode lives in the same process.
//
// No new SSE event type. The shipped TrainingStreamEvent union already carries
// everything: {type:'job'} (phase + counters), 'progress', 'log', 'status'.
//
// Phase strings (FROZEN): audition-lm-base, audition-lm-adapter,
// audition-decode, audition-writing. Additive (2026-07-29): audition-render —
// the opt-in DiT pass between decode and writing.
//
// Spec: docs/plans/2026-07-28-codes-preview.md §5.4, C6, C15
import { randomUUID } from 'crypto';
import { config } from '../../config.js';
import { pushLog } from '../../routes/logs.js';
import { aceClient } from '../aceClient.js';
import { getDataset } from './datasetsRepo.js';
import { writePreview, type PreviewAudio } from './auditionStore.js';
import {
renderSideThroughDit, resolveAuditionInputs, runOneSide, type SideOutcome,
} from './auditionService.js';
import {
emitJob, emitProgress, finishJob, isCancelled, pushEvent, type TrainingJob,
} from './labelingQueue.js';
import type { AuditionOptions, AuditionPreview, AuditionSideResult } from './types.js';
function log(job: TrainingJob, level: 'info' | 'warn' | 'error', message: string): void {
pushEvent(job, { type: 'log', level, message, ts: Date.now() });
}
export async function runAuditionJob(job: TrainingJob): Promise<void> {
if (isCancelled(job)) return;
// ── C15 eviction, hoisted out of the straight-line body ──────────────────
// ?keep_loaded=1 flips the engine to EVICT_NEVER the moment the FIRST side
// posts, permanently, for the ace-server lifetime. The eviction that mitigates
// that must therefore run on EVERY exit — cancel mid-side, cancel after the
// last side, and the outer catch — not only on the happy path. Leaving a 4B
// planner (~8 GB BF16) pinned is especially bad here because the very next
// thing the Training Studio does is size a train-lm run against free VRAM.
//
// Idempotent: the happy path calls it before finishJob so its log still
// reaches the open SSE stream; the finally is the backstop for every other
// exit.
// `flipped` arms BOTH cleanups: the targeted mid-job LM evict (before the DiT
// renders) and the end-of-job policy restore. Restore is the real fix
// (2026-07-29): the latch used to survive the job, leaving the engine in
// EVICT_NEVER — every module the audition (and every later generation!)
// touched stayed resident for the rest of the session, which is why a
// rendered audition ate far more VRAM than a normal STRICT-policy generation
// and never gave it back.
let flipped = false;
const evictLmIfFlipped = async (): Promise<void> => {
if (!flipped) return;
const evicted = await aceClient.unloadLabel('LM').catch(() => false);
log(job, 'info', evicted
? 'Evicted the LM from VRAM'
: 'Could not evict the LM — it stays resident until the policy restore');
};
const restorePolicyIfFlipped = async (): Promise<void> => {
if (!flipped) return;
flipped = false;
const restored = await aceClient.restoreEvictPolicy().catch(() => false);
log(job, restored ? 'info' : 'warn', restored
? 'Freed every audition model and restored the normal eviction policy'
: 'Could not restore the eviction policy — models may stay resident until the engine restarts');
};
try {
const ds = getDataset(job.datasetId);
if (!ds) { finishJob(job, 'failed', 'Dataset not found'); return; }
const opts = (job.opts || {}) as AuditionOptions;
const resolved = resolveAuditionInputs(ds, opts);
if (resolved.sides.length === 0) {
finishJob(job, 'failed', 'No sides to audition');
return;
}
job.status = 'running';
job.startedAt = Date.now();
job.total = resolved.sides.length;
job.done = 0;
job.failed = 0;
job.phase = 'audition-lm-base';
emitJob(job);
log(job, 'info',
`Audition seed ${resolved.seed}${resolved.sides.length} side(s), ${resolved.durationSec}s` +
(resolved.ditModel ? `, detok from ${resolved.ditModel}` : ''));
const results: AuditionSideResult[] = [];
const audio: PreviewAudio[] = [];
const renderQueue: SideOutcome[] = [];
const previewId = randomUUID();
// Armed BEFORE the first post, because the first post is what flips the flag.
flipped = resolved.coResident && !config.aceServer.keepLoaded;
for (const side of resolved.sides) {
if (isCancelled(job)) { finishJob(job, 'cancelled'); return; }
job.phase = side.slot === 'base' ? 'audition-lm-base' : 'audition-lm-adapter';
emitProgress(job);
// Never throws — a failed side lands in .error and the other side runs.
const outcome = await runOneSide(resolved, side, {
onPhase: (phase) => { job.phase = phase; emitProgress(job); },
isCancelled: () => isCancelled(job),
});
results.push(outcome.result);
if (outcome.audio) {
audio.push(outcome.audio);
outcome.result.audioUrl = `/api/training/previews/${previewId}/${side.slot}`;
}
renderQueue.push(outcome);
if (outcome.result.ok) {
log(job, 'info',
`${side.label}: ${outcome.result.codesCount} codes (${outcome.result.codesSha1.slice(0, 8)}) ` +
`— LM ${outcome.result.lmMs} ms, decode ${outcome.result.decodeMs} ms`);
} else {
job.failed++;
log(job, 'error', `${side.label}: ${outcome.result.error}`);
}
job.done++;
emitProgress(job);
}
// A cancel arriving mid-side surfaces as a failed side (runOneSide never
// throws), so the loop's top-of-iteration check cannot catch it on the last
// side. Without this a cancelled single-side audition still writes a preview
// dir full of a half-finished run.
if (isCancelled(job)) { finishJob(job, 'cancelled'); return; }
// ── Opt-in DiT renders — AFTER the LM sides, AFTER the LM is evicted ──
// keep_loaded pins the planner (a 4B is ~8 GB BF16) for the whole job;
// rendering inside a side loaded xl-turbo + VAE on top of it and spilled
// VRAM into shared memory (Rob, 2026-07-29). Evicting first gives the
// render a normal generation's footprint, and both LM sides still shared
// one resident load — the whole point of co-residency.
if (resolved.renderDit) {
await evictLmIfFlipped();
job.phase = 'audition-render';
emitProgress(job);
for (const outcome of renderQueue) {
if (!outcome.result.ok || !outcome.audioCodes) continue;
if (isCancelled(job)) { finishJob(job, 'cancelled'); return; }
const t0 = Date.now();
try {
const rendered = await renderSideThroughDit(resolved, outcome.result, outcome.audioCodes, {
isCancelled: () => isCancelled(job),
});
outcome.result.renderMs = Date.now() - t0;
audio.push(rendered);
outcome.result.renderUrl = `/api/training/previews/${previewId}/${outcome.result.slot}-render`;
log(job, 'info', `${outcome.result.label}: DiT render ${outcome.result.renderMs} ms`);
} catch (err: any) {
if (isCancelled(job)) { finishJob(job, 'cancelled'); return; }
outcome.result.renderError = err instanceof Error ? err.message : String(err);
log(job, 'warn',
`${outcome.result.label}: DiT render failed — ${outcome.result.renderError} (the codes sketch is still playable)`);
}
}
}
// ── Write the preview ────────────────────────────────────────────────
job.phase = 'audition-writing';
emitProgress(job);
const preview: AuditionPreview = {
previewId,
datasetId: ds.id,
kind: resolved.kind,
createdAt: new Date().toISOString(),
seed: resolved.seed,
caption: resolved.caption,
lyrics: resolved.lyrics,
durationSec: resolved.durationSec,
lmModel: resolved.lmModel,
ditModel: resolved.ditModel,
vaeModel: resolved.vaeModel,
sampleId: resolved.sampleId,
variantKey: resolved.variantKey,
sides: results,
...(resolved.renderDit
? { renderDitModel: resolved.renderDitModel, renderSteps: resolved.renderSteps }
: {}),
};
// Written UNCONDITIONALLY, even when every side failed. The record is
// designed to hold failures (ok:false / error / audioUrl:''), and the seed
// is the whole point of C14: on a server-picked random seed, skipping the
// write because no audio came back throws away the only copy of the seed the
// moment the SSE stream closes, so the user cannot re-run the same A/B after
// fixing whatever broke.
if (!writePreview(preview, audio)) {
log(job, 'warn', 'The preview could not be written to disk');
for (const r of preview.sides) { r.audioUrl = ''; r.renderUrl = ''; }
}
// C14 receipt, surfaced in the log as well as the UI: two identical hashes
// means the adapter had no effect, which is this feature's likeliest silent
// failure.
const okHashes = results.filter(r => r.ok).map(r => r.codesSha1);
if (okHashes.length === 2 && okHashes[0] === okHashes[1]) {
log(job, 'warn',
'Both sides produced identical codes — the adapter had no effect. ' +
'Check ace_engine.log for "[Server] LM adapter: …".');
}
// C15: un-flip what we flipped, best effort — full eviction pass + STRICT
// restored. Called here (not only in the finally) so the log line still
// reaches the SSE stream while it is open. The next audition pays a cold
// load again; that is the correct price for giving the VRAM back.
await restorePolicyIfFlipped();
const allFailed = results.length > 0 && results.every(r => !r.ok);
pushLog(`[Training] audition job ${job.id} finished — preview ${previewId}` +
(allFailed ? ' (every side failed)' : ''));
finishJob(job, allFailed ? 'failed' : 'done',
allFailed ? (results[0]?.error || 'Every audition side failed') : undefined);
} catch (err: any) {
if (isCancelled(job)) return;
const message = err instanceof Error ? err.message : String(err);
console.error(`[Training] audition job ${job.id} FAILED — ${message}`);
finishJob(job, 'failed', message);
} finally {
// Backstop for every non-happy exit: cancel at the top of a side loop
// iteration, cancel after the last side, and the catch above. No-op when the
// happy path already ran it.
await restorePolicyIfFlipped().catch(() => { /* never fail a job on cleanup */ });
}
}
@@ -0,0 +1,669 @@
// training/auditionService.ts — the codes-audition brain
//
// Resolves every default in ONE place, builds the /lm request field by field,
// runs one A/B side, and decodes stored codes for the synchronous sample
// audition. The only module that knows the shape of an LM request.
//
// ── THE LM ECHO SIDEBAND TRAP (read before touching runOneSide) ─────────────
// The known failure mode is reusing the AceRequest that /lm ECHOED BACK as the
// basis for the next engine call: server-only fields do not survive the round
// trip. This module never does that. The /lm response is read for exactly six
// values (audio_codes, caption, lyrics, bpm, keyscale, timesignature) and then
// discarded; the /codes-decode request is built fresh from
// {audio_codes, synth_model, vae, output_format, peak_clip} and nothing else.
// DO NOT "optimise" this by forwarding the echoed request object.
//
// Spec: docs/plans/2026-07-28-codes-preview.md §5.3, C5, C12, C13, C14
//
// Read-only reuse: trainLmStatus.ts (newestVariantKey, variantDitModel),
// aceTrain.ts (tensorsRoot), labelStore.ts (readLabel). None of those files is
// edited by this plan.
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
import { randomUUID } from 'crypto';
import { aceClient, type AceRequest } from '../aceClient.js';
import { getModelSnapshot, pickLmFor, tensorsRoot } from './aceTrain.js';
import { lmSizeOfAdapterPath } from './adapterLayout.js';
import { readLabel } from './labelStore.js';
import { newestVariantKey, variantDitModel, variantExists } from './trainLmStatus.js';
import { writePreview, type PreviewAudio } from './auditionStore.js';
import type {
AuditionKind, AuditionOptions, AuditionPreview, AuditionSideResult, AuditionSideSpec,
LmSize, SampleAuditionResponse, TrainingDatasetRow,
} from './types.js';
const CODES_FILE = 'lm_codes.jsonl';
/** Wall-clock bound on one /lm run. A 4B planner measures ~18 s of LM phase
* plus up to ~20 s of cold load, so 10 min is ~15x headroom — this is a
* "the engine is wedged" tripwire, not a performance budget. Deliberately a
* local constant: config.ts is outside this plan's editable set. */
const LM_DEADLINE_MS = 10 * 60_000;
/** An error the route can map straight onto an HTTP status. */
export class AuditionError extends Error {
constructor(public readonly status: number, message: string) {
super(message);
this.name = 'AuditionError';
}
}
function clamp(value: unknown, fallback: number, min: number, max: number): number {
const n = Number(value);
if (!Number.isFinite(n)) return fallback;
return Math.min(max, Math.max(min, n));
}
function str(value: unknown): string {
return typeof value === 'string' ? value : '';
}
function num(value: unknown, fallback = 0): number {
const n = Number(value);
return Number.isFinite(n) ? n : fallback;
}
export function sha1(text: string): string {
return crypto.createHash('sha1').update(text, 'utf8').digest('hex');
}
/** codesCount / 5, rounded to 0.1 (§3.1). */
function codesDuration(codesCount: number): number {
return Math.round((codesCount / 5) * 10) / 10;
}
/**
* The dataset's trigger word, applied EXACTLY as the trainer applied it
* (lm_apply_tag, engine/src/train/lm-common.h:353): prepend → "tag, caption",
* append → "caption, tag", and 'replace' applies NO tag (verbatim Side-Step —
* those captions trained untagged, so claiming a trigger would be false).
*
* Free-text and LM-written audition prompts never carried the trigger, so the
* adapter ran without the token it was trained on and both sides emitted
* near-identical plans (Rob, 2026-07-29). Applied to BOTH sides identically —
* A/B discipline — and skipped when the caption already carries the tag (the
* lm_codes.jsonl row fallback is pre-tagged; users may also type it).
*/
function applyTriggerTag(caption: string, tag: string, position: string): string {
const t = String(tag ?? '').trim();
if (!t || !caption) return caption;
if (caption.toLowerCase().includes(t.toLowerCase())) return caption;
const pos = position || 'prepend';
if (pos === 'prepend') return `${t}, ${caption}`;
if (pos === 'append') return `${caption}, ${t}`;
return caption; // 'replace'
}
// ── Codes-row lookup ──────────────────────────────────────────────────────
export interface CodesRow {
file: string;
caption: string;
lyrics: string;
bpm: number;
keyscale: string;
timesignature: string;
duration: number;
codes: number[];
id: string;
}
/**
* The lm_codes.jsonl row for one dataset sample.
*
* `sampleId` is the studio's 16-hex TrainingSample id; the codes file stores the
* 8-hex `datasetSampleId` (= the same sha1, first 8) — the identical `_id` /
* `-([0-9a-f]{8}).safetensors` pair trainLmStatus.countCodes matches on. Falls
* back to the id embedded in `row.file` when `_id` is missing (legacy rows).
*/
export function findCodesRow(slug: string, variantKey: string, sampleId: string): CodesRow | null {
const id8 = String(sampleId ?? '').toLowerCase().slice(0, 8);
if (!id8 || !variantKey) return null;
const codesPath = path.join(tensorsRoot(slug), variantKey, CODES_FILE);
let raw = '';
try {
if (!fs.existsSync(codesPath)) return null;
raw = fs.readFileSync(codesPath, 'utf-8');
} catch {
return null;
}
for (const line of raw.split('\n')) {
const trimmed = line.trim();
if (!trimmed) continue;
let row: Record<string, unknown>;
try {
row = JSON.parse(trimmed) as Record<string, unknown>;
} catch {
continue; // a torn line is not a row
}
if (!row || typeof row !== 'object') continue;
const rowId = str(row._id).toLowerCase();
let hit = rowId === id8;
if (!hit) {
const m = /-([0-9a-f]{8})\.safetensors$/i.exec(str(row.file));
hit = !!m && m[1].toLowerCase() === id8;
}
if (!hit) continue;
const codes = Array.isArray(row.codes)
? (row.codes as unknown[]).map(v => Math.trunc(num(v, -1))).filter(v => v >= 0)
: [];
return {
file: str(row.file),
caption: str(row.caption),
lyrics: str(row.lyrics),
bpm: Math.trunc(num(row.bpm)),
keyscale: str(row.keyscale),
timesignature: str(row.timesignature),
duration: Math.trunc(num(row.duration)),
codes,
id: rowId || id8,
};
}
return null;
}
// ── Resolution ────────────────────────────────────────────────────────────
export interface ResolvedAudition {
datasetId: string;
slug: string;
kind: AuditionKind;
sides: AuditionSideSpec[];
caption: string;
lyrics: string;
seed: number;
durationSec: number;
lmModel: string;
ditModel: string;
vaeModel: string;
variantKey: string;
sampleId: string;
temperature: number;
topP: number;
cfgScale: number;
repPenalty: number;
format: 'wav16' | 'mp3';
coResident: boolean;
renderDit: boolean;
renderSteps: number;
renderDitModel: string; // '' = let the engine resolve its default DiT
}
/**
* The DiT the opt-in render runs on when the user names none: the newest
* installed xl-turbo (fast, 8-step-friendly), else any turbo, else the detok
* DiT the codes were decoded against. Never a hard failure — a render on a
* slower base is still a render.
*/
function pickRenderDit(detokDit: string): string {
const dits = getModelSnapshot().dit;
return dits.find(m => /xl[-_]?turbo/i.test(m))
?? dits.find(m => /turbo/i.test(m))
?? detokDit;
}
/**
* ONE place decides every default (§5.3).
*
* `lmModel`: when empty, derived from the adapter's size suffix via pickLmFor —
* letting the engine's resolve_name pick the sticky/first LM put a 4B adapter on
* the 0.6B base ("36 layers but model has 28 — mismatch, refusing", found live
* by Rob). An explicit lmModel that contradicts the adapter's size is a 400.
*/
export function resolveAuditionInputs(ds: TrainingDatasetRow, opts: AuditionOptions): ResolvedAudition {
const variantKey = (typeof opts.variantKey === 'string' && variantExists(ds.slug, opts.variantKey))
? opts.variantKey
: newestVariantKey(ds.slug);
const sampleId = str(opts.sampleId);
const row = sampleId ? findCodesRow(ds.slug, variantKey, sampleId) : null;
// The literal strings the trainer conditioned on (C12a) fill the gaps, but
// never override what the user actually typed. The fallback can only fire when
// opts.sampleId is set — the free-text and "let the LM write it" sources never
// send one, so an intentionally empty `lyrics` is never silently repopulated.
const caption = str(opts.caption).trim() || (row ? row.caption : '');
const lyrics = str(opts.lyrics) || (row ? row.lyrics : '');
// The route lets caption be empty when a sampleId is present, precisely so the
// fallback above can supply the TAGGED trainer caption. If the row is missing
// or has no caption, there is nothing to fall back to and an empty prompt would
// run two meaningless LM passes — fail loudly instead.
if (!caption) {
throw new AuditionError(409, sampleId
? `Sample ${sampleId} has no caption in ${CODES_FILE} for variant ${variantKey}` +
'run Preprocess + Extract for this variant, or type a caption instead'
: 'caption is required');
}
// Free-text / LM-written / hand-edited prompts get the dataset's trigger word
// exactly as the trainer applied it; a pre-tagged row caption passes through
// unchanged (the contains-check inside). See applyTriggerTag.
const taggedCaption = applyTriggerTag(caption, ds.customTag, ds.tagPosition);
// Never -1: the audition must be re-runnable from what we record (C14).
const seed = Number.isFinite(Number(opts.seed)) && Number(opts.seed) >= 0
? Math.trunc(Number(opts.seed))
: Math.floor(Math.random() * 2 ** 31);
const ditModel = str(opts.ditModel) || variantDitModel(ds.slug, variantKey) || '';
const sides: AuditionSideSpec[] = (Array.isArray(opts.sides) ? opts.sides : []).map(s => ({
slot: s.slot === 'adapter' ? 'adapter' : 'base',
label: str(s.label).slice(0, 64) || (s.slot === 'adapter' ? 'Adapter' : 'Base LM'),
lmAdapter: str(s.lmAdapter),
lmAdapterScale: clamp(s.lmAdapterScale, 1.0, 0, 2),
}));
// Base-LM pinning. An empty lmModel used to fall through to the engine's
// resolve_name (sticky loaded LM, else registry[0] = the 0.6B), which made a
// 4B adapter meet a 28-layer base and die with "36 layers but model has 28 —
// mismatch, refusing" AFTER a full load cycle. Derive the base from the
// adapter's own layout instead — its lm-<size> parent folder in the per-base
// layout, the legacy -<size> name suffix otherwise — and reject an explicit
// mismatch up front. Both sides of an A/B must run the SAME base for the
// comparison to mean anything, so this is seed-discipline, not ergonomics.
// Milestone dirs sit one level deeper (<adapter>/milestones/loss_x), so walk
// up until a size is found or the adapters root is reached.
let lmModel = str(opts.lmModel);
const adapterSide = sides.find(s => s.lmAdapter);
let adapterSize: string = '';
if (adapterSide) {
let probe = adapterSide.lmAdapter;
for (let hops = 0; hops < 4 && probe && !adapterSize; hops++) {
adapterSize = lmSizeOfAdapterPath(probe);
probe = path.dirname(probe);
}
}
if (adapterSize) {
if (!lmModel) {
lmModel = pickLmFor(adapterSize as LmSize, getModelSnapshot().lm);
if (!lmModel) {
throw new AuditionError(409,
`Adapter ${adapterSide!.lmAdapter} needs a ${adapterSize} LM base, but none is installed`);
}
} else if (!new RegExp(`-${adapterSize.replace('.', '\\.')}(-|$)`, 'i').test(lmModel)) {
throw new AuditionError(400,
`Adapter ${adapterSide!.lmAdapter} is ${adapterSize} but lmModel "${lmModel}" is not — ` +
'pick a matching base or clear the base-model field');
}
}
return {
datasetId: ds.id,
slug: ds.slug,
kind: opts.kind === 'milestone' ? 'milestone' : 'ab',
sides,
caption: taggedCaption,
lyrics,
seed,
// Cap raised 120 → 300 (Rob, 2026-07-29): a 3-minute audition is a
// legitimate ask; the LM cost scales linearly and the deadline has slack.
durationSec: Math.trunc(clamp(opts.durationSec, 30, 10, 300)),
lmModel,
ditModel,
vaeModel: str(opts.vaeModel),
variantKey,
sampleId,
temperature: clamp(opts.temperature, 0.85, 0.1, 2),
topP: clamp(opts.topP, 0.9, 0.05, 1),
cfgScale: clamp(opts.cfgScale, 2.0, 0, 10),
repPenalty: clamp(opts.repPenalty, 1.0, 1, 1.5),
format: opts.format === 'mp3' ? 'mp3' : 'wav16',
coResident: opts.coResident !== false,
renderDit: opts.renderDit === true,
renderSteps: Math.trunc(clamp(opts.renderSteps, 8, 2, 60)),
renderDitModel: opts.renderDit === true
? (str(opts.renderDitModel) || pickRenderDit(ditModel))
: '',
};
}
// ── The LM request ────────────────────────────────────────────────────────
/**
* The ONLY place an LM request is constructed. Every field written explicitly;
* nothing inherited from a previous request object (see the module header).
*
* `lm_seed` is set explicitly as well as `seed` because an explicit lm_seed
* always wins over the seed fallback in the engine — which is what makes both
* sides of an A/B literally the same LM run apart from the adapter (C5/C14).
*/
export function buildLmRequest(
resolved: ResolvedAudition,
side: AuditionSideSpec,
): AceRequest & { lm_mode: string } {
// `lm_mode` is a real engine field (request.cpp:132, default LM_MODE_NAME_GENERATE)
// that AceRequest does not declare — submitLm only injects it for the
// 'inspire'/'format' modes. Written explicitly here so the audition never
// depends on the engine's default staying 'generate'.
return {
caption: resolved.caption,
lyrics: resolved.lyrics,
duration: resolved.durationSec,
seed: resolved.seed,
lm_seed: resolved.seed,
lm_mode: 'generate',
lm_batch_size: 1,
lm_temperature: resolved.temperature,
lm_top_p: resolved.topP,
lm_cfg_scale: resolved.cfgScale,
lm_rep_penalty: resolved.repPenalty,
lm_model: resolved.lmModel,
lm_adapter: side.lmAdapter,
lm_adapter_scale: side.lmAdapterScale,
};
}
export interface SideHooks {
/** Set the job phase — 'audition-decode' between the LM and the decode.
* A callback rather than the TrainingJob itself so this module never imports
* labelingQueue (auditionRunner already does, and a cycle would be a lazy
* import for no gain). */
onPhase?: (phase: string) => void;
isCancelled?: () => boolean;
}
export interface SideOutcome {
result: AuditionSideResult;
audio: PreviewAudio | null;
/** The raw codes CSV, kept so the runner can render this side through the
* DiT AFTER the pinned LM has been evicted. Rendering inside the side —
* with keep_loaded holding the 4B planner resident — loaded the DiT on top
* of it and spilled VRAM into shared memory (Rob, 2026-07-29). '' on
* failure. */
audioCodes: string;
}
/** Poll one engine job to completion under a deadline, cancelling the engine
* job on our own cancel/timeout (same discipline as the /lm loop — an
* abandoned engine job pins VRAM and wedges the global queue chain). */
async function awaitEngineJob(
jobId: string,
what: string,
hooks: SideHooks,
): Promise<void> {
const deadline = Date.now() + LM_DEADLINE_MS;
for (;;) {
if (hooks.isCancelled?.()) {
await aceClient.cancelJob(jobId).catch(() => { /* engine may already be gone */ });
throw new Error('Cancelled');
}
if (Date.now() > deadline) {
await aceClient.cancelJob(jobId).catch(() => { /* best effort */ });
throw new Error(`${what} timed out after ${Math.round(LM_DEADLINE_MS / 60_000)} min`);
}
const status = await aceClient.pollJob(jobId);
if (status.status === 'done') return;
if (status.status === 'failed') throw new Error(`${what} failed — see ace_engine.log`);
if (status.status === 'cancelled') throw new Error(`${what} cancelled`);
await new Promise(r => setTimeout(r, 200));
}
}
/**
* The opt-in DiT render of one side's codes (§Rob 2026-07-29): a fresh /synth
* request carrying the side's audio_codes and the ECHOED plan fields, at a
* fixed step count on a DiT shared by both sides, with NO sound adapter — the
* planner adapter must remain the A/B's only variable. Request built FRESH,
* exactly like the /codes-decode one (module-header sideband rule).
*
* Called by the RUNNER after every LM side has finished and the pinned LM has
* been evicted — never inside a side, where keep_loaded's EVICT_NEVER would
* stack the DiT on top of a resident 4B planner and overflow into shared
* memory. Exported for exactly that caller.
*/
export async function renderSideThroughDit(
resolved: ResolvedAudition,
result: AuditionSideResult,
audioCodes: string,
hooks: SideHooks,
): Promise<PreviewAudio> {
const req: AceRequest = {
caption: result.caption || resolved.caption,
lyrics: result.lyrics || resolved.lyrics,
duration: result.durationSec || resolved.durationSec,
...(result.bpm > 0 ? { bpm: result.bpm } : {}),
...(result.keyscale ? { keyscale: result.keyscale } : {}),
...(result.timesignature ? { timesignature: result.timesignature } : {}),
seed: resolved.seed,
audio_codes: audioCodes,
inference_steps: resolved.renderSteps,
task_type: 'text2music',
...(resolved.renderDitModel ? { synth_model: resolved.renderDitModel } : {}),
...(resolved.vaeModel ? { vae_model: resolved.vaeModel } : {}),
// NO adapter / adapters / lm_* fields — deliberately.
};
const jobId = await aceClient.submitSynth(req, resolved.format);
await awaitEngineJob(jobId, 'DiT render', hooks);
const res = await aceClient.getJobResult(jobId);
if (!res.ok) throw new Error(`DiT render result fetch failed (${res.status})`);
const buf = Buffer.from(await res.arrayBuffer());
if (!buf.length) throw new Error('DiT render returned no audio');
return { slot: result.slot, buf, ext: resolved.format === 'mp3' ? 'mp3' : 'wav', render: true };
}
/**
* One A/B side: /lm → /codes-decode.
*
* NEVER throws. A failed side lands in `.error` with `ok:false` and the other
* side still runs — half an A/B is still evidence.
*/
export async function runOneSide(
resolved: ResolvedAudition,
side: AuditionSideSpec,
hooks: SideHooks = {},
): Promise<SideOutcome> {
const result: AuditionSideResult = {
slot: side.slot,
label: side.label,
lmAdapter: side.lmAdapter,
lmAdapterScale: side.lmAdapterScale,
ok: false,
error: '',
audioUrl: '',
codesCount: 0,
codesSha1: '',
durationSec: 0,
caption: '',
lyrics: '',
bpm: 0,
keyscale: '',
timesignature: '',
lmMs: 0,
decodeMs: 0,
};
try {
// ── 1. /lm ───────────────────────────────────────────────────────────
const t0 = Date.now();
const lmJobId = await aceClient.submitLm(
buildLmRequest(resolved, side), undefined, resolved.coResident);
// A deadline is not optional here. labelingQueue runs every training job
// kind — label, enhance, build, preprocess, train-lm, train-dit — on ONE
// global promise chain (`queueTail = queueTail.then(...)`), so an unbounded
// wait in this loop does not just hang the audition: it wedges that chain
// for the rest of the server's lifetime, leaving every subsequent job for
// EVERY dataset stuck at 'queued' with no error and no log line. A wedged
// engine LM job (OOM-stall, driver hang, or a worker that died without ever
// setting status 2) is exactly how that happens. Bound + cancel, mirroring
// understandClient's deadline/abort handling. (awaitEngineJob also cancels
// the ENGINE job on our cancel/timeout: abandoning it leaves the GPU
// working on a result nobody will fetch, and with ?keep_loaded=1 the 4B
// stays pinned — the next train-lm run then kills ace-server mid-flight.)
await awaitEngineJob(lmJobId, 'LM job', hooks);
const lmRes = await aceClient.getJobResult(lmJobId);
if (!lmRes.ok) throw new Error(`LM result fetch failed (${lmRes.status})`);
const echoed = await lmRes.json() as AceRequest[];
result.lmMs = Date.now() - t0;
// Exactly six values are read out of the echo. It is then DISCARDED.
const plan = Array.isArray(echoed) ? echoed[0] : undefined;
const audioCodes = str(plan?.audio_codes);
result.caption = str(plan?.caption);
result.lyrics = str(plan?.lyrics);
result.bpm = Math.trunc(num(plan?.bpm));
result.keyscale = str(plan?.keyscale);
result.timesignature = str(plan?.timesignature);
// ── 2. no codes → a soft failure, not a thrown job ───────────────────
if (!audioCodes.trim()) {
result.error = 'LM returned no audio codes';
return { result, audio: null, audioCodes: '' };
}
result.codesSha1 = sha1(audioCodes);
result.codesCount = audioCodes.split(',').filter(t => t.trim().length > 0).length;
result.durationSec = codesDuration(result.codesCount);
// ── 3. /codes-decode — request built FRESH, never from `plan` ─────────
hooks.onPhase?.('audition-decode');
const t1 = Date.now();
const buf = await aceClient.codesDecode({
audio_codes: audioCodes,
synth_model: resolved.ditModel,
vae: resolved.vaeModel,
output_format: resolved.format,
}, undefined, hooks.isCancelled);
result.decodeMs = Date.now() - t1;
result.ok = true;
// The DiT render deliberately does NOT happen here — see SideOutcome.
return {
result,
audio: { slot: side.slot, buf, ext: resolved.format === 'mp3' ? 'mp3' : 'wav' },
audioCodes,
};
} catch (err: any) {
result.ok = false;
result.error = err instanceof Error ? err.message : String(err);
return { result, audio: null, audioCodes: '' };
}
}
// ── The synchronous sample audition ───────────────────────────────────────
/**
* Stored codes → audio, no /lm at all — which is exactly why it is fast enough
* to be synchronous (C7).
*
* Precedence (FROZEN, C12):
* 1. lm_codes.jsonl's `codes` array — the canonical source, literally the
* token stream the trainer conditioned on.
* 2. labelStore's SampleLabelRecord.audioCodes — the legacy /understand payload.
* 3. Neither → 409. Codes are NEVER silently synthesised.
*
* The `source` field says which was used: (1) and (2) can disagree if the DiT
* changed since labelling, and the user must be able to see which they heard.
*/
export async function decodeStoredCodes(
ds: TrainingDatasetRow,
sampleId: string,
format: 'wav16' | 'mp3',
): Promise<SampleAuditionResponse> {
const variantKey = newestVariantKey(ds.slug);
const row = findCodesRow(ds.slug, variantKey, sampleId);
let codesCsv = '';
let source: 'lm_codes' | 'label' = 'lm_codes';
let caption = '';
let lyrics = '';
let bpm = 0;
let keyscale = '';
let timesignature = '';
if (row && row.codes.length > 0) {
codesCsv = row.codes.join(',');
source = 'lm_codes';
caption = row.caption;
lyrics = row.lyrics;
bpm = row.bpm;
keyscale = row.keyscale;
timesignature = row.timesignature;
} else {
const label = readLabel(ds.slug, sampleId);
const stored = str(label?.audioCodes).trim();
if (stored) {
codesCsv = stored;
source = 'label';
caption = str(label?.understand?.caption);
lyrics = str(label?.understand?.lyrics);
bpm = Math.trunc(num(label?.understand?.bpm));
keyscale = str(label?.understand?.keyscale);
timesignature = str(label?.understand?.timesignature);
}
}
if (!codesCsv) {
throw new AuditionError(409,
'This sample has no stored codes — run Preprocess + Extract, or label it with Understand');
}
const codesCount = codesCsv.split(',').filter(t => t.trim().length > 0).length;
const ditModel = variantDitModel(ds.slug, variantKey) || '';
const t0 = Date.now();
const buf = await aceClient.codesDecode({
audio_codes: codesCsv,
synth_model: ditModel,
vae: '',
output_format: format,
});
const decodeMs = Date.now() - t0;
const previewId = randomUUID();
const ext: 'wav' | 'mp3' = format === 'mp3' ? 'mp3' : 'wav';
const durationSec = codesDuration(codesCount);
const sideResult: AuditionSideResult = {
slot: 'base',
label: source === 'lm_codes' ? 'Stored codes (lm_codes.jsonl)' : 'Stored codes (Understand)',
lmAdapter: '',
lmAdapterScale: 1,
ok: true,
error: '',
audioUrl: `/api/training/previews/${previewId}/base`,
codesCount,
codesSha1: sha1(codesCsv),
durationSec,
caption,
lyrics,
bpm,
keyscale,
timesignature,
lmMs: 0,
decodeMs,
};
const preview: AuditionPreview = {
previewId,
datasetId: ds.id,
kind: 'sample',
createdAt: new Date().toISOString(),
seed: 0,
caption,
lyrics,
durationSec,
lmModel: '',
ditModel,
vaeModel: '',
sampleId,
variantKey: source === 'lm_codes' ? variantKey : '',
sides: [sideResult],
};
const wrote = writePreview(preview, [{ slot: 'base', buf, ext }]);
if (!wrote) {
// The audio decoded but could not be stored — say so rather than handing
// back a URL that 404s.
preview.sides[0].audioUrl = '';
preview.sides[0].error = 'Decoded, but the preview could not be written to disk';
}
return { preview, source };
}
@@ -0,0 +1,255 @@
// training/auditionStore.ts — the codes-audition preview filesystem layer
//
// data/training/previews/<previewId>/{base.wav, adapter.wav, preview.json}
//
// There is NO SQLite row (C9). A preview's whole state is its preview.json,
// exactly as a training run's state is its lm_train_log.json. The json is
// written LAST, so "preview.json exists" is the definition of a complete dir —
// anything else is garbage and is eligible for pruning at any time.
//
// Never throws upward. Every function is total: a corrupt preview.json is
// skipped, a locked file is left for the next prune, and a failed write is
// logged at console.warn. A preview is a convenience, never a job's success
// criterion.
//
// Spec: docs/plans/2026-07-28-codes-preview.md §5.2, C9, C11
import fs from 'fs';
import path from 'path';
import { config } from '../../config.js';
import type { AuditionPreview, AuditionSlot } from './types.js';
/** C11, FROZEN: keep 40 previews / 1.0 GB, whichever bites first. */
const MAX_PREVIEWS = 40;
const MAX_BYTES = 1024 * 1024 * 1024;
const PREVIEW_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const SLOT_RE = /^(base|adapter)$/;
// A slot's stored audio: the codes sketch (`base.wav`) or its opt-in DiT
// render (`base-render.wav`). Both are addressed by the same static route.
const FILE_KEY_RE = /^(base|adapter)(-render)?$/;
export function previewsRoot(): string {
return path.join(config.training.dir, 'previews');
}
/** `<previews>/<previewId>`. The id is uuid-validated here as well as by the
* caller — this path is handed to fs.rmSync and to a static file route. */
export function previewDir(previewId: string): string {
if (!isPreviewId(previewId)) return '';
return path.join(previewsRoot(), previewId);
}
export function isPreviewId(value: unknown): value is string {
return typeof value === 'string' && PREVIEW_ID_RE.test(value);
}
export function isPreviewSlot(value: unknown): value is AuditionSlot {
return typeof value === 'string' && SLOT_RE.test(value);
}
/** slot OR slot-render — everything the static preview route may serve. */
export function isPreviewFileKey(value: unknown): value is string {
return typeof value === 'string' && FILE_KEY_RE.test(value);
}
/** `<previews>/<id>/<key>.<ext>` — both tokens validated, '' when either is not. */
export function previewFile(previewId: string, key: string, ext = 'wav'): string {
const dir = previewDir(previewId);
if (!dir || !isPreviewFileKey(key)) return '';
const safeExt = ext === 'mp3' ? 'mp3' : 'wav';
return path.join(dir, `${key}.${safeExt}`);
}
/** The file actually on disk for a slot/key, whichever extension it was
* written with. '' when nothing is there. */
export function resolvePreviewFile(previewId: string, key: string): string {
for (const ext of ['wav', 'mp3']) {
const p = previewFile(previewId, key, ext);
if (p && fs.existsSync(p)) return p;
}
return '';
}
export interface PreviewAudio {
slot: AuditionSlot;
buf: Buffer;
ext: 'wav' | 'mp3';
/** True for the opt-in DiT render — stored as `<slot>-render.<ext>`. */
render?: boolean;
}
/**
* Write one preview dir: audio files first, `preview.json` LAST, then prune.
*
* Returns false when nothing could be written — the caller still has its
* in-memory AuditionPreview and reports the run honestly; it simply has no
* playable URL.
*/
export function writePreview(rec: AuditionPreview, audio: PreviewAudio[]): boolean {
const dir = previewDir(rec.previewId);
if (!dir) {
console.warn(`[Training] Refusing to write preview with a non-uuid id: ${rec.previewId}`);
return false;
}
try {
fs.mkdirSync(dir, { recursive: true });
for (const a of audio) {
const file = previewFile(rec.previewId, a.render ? `${a.slot}-render` : a.slot, a.ext);
if (!file) continue;
fs.writeFileSync(file, a.buf);
}
// Last — the completeness marker.
fs.writeFileSync(path.join(dir, 'preview.json'), JSON.stringify(rec, null, 2), 'utf-8');
} catch (err: any) {
console.warn(`[Training] Could not write preview ${rec.previewId}: ${err?.message || err}`);
return false;
}
prunePreviews();
return true;
}
export function readPreview(previewId: string): AuditionPreview | null {
const dir = previewDir(previewId);
if (!dir) return null;
try {
const raw = fs.readFileSync(path.join(dir, 'preview.json'), 'utf-8');
const parsed = JSON.parse(raw) as AuditionPreview;
if (!parsed || typeof parsed !== 'object' || !Array.isArray(parsed.sides)) return null;
return parsed;
} catch {
return null;
}
}
interface PreviewDirInfo {
id: string;
dir: string;
mtimeMs: number;
bytes: number;
rec: AuditionPreview | null;
}
/** One readdir + stat pass. A dir with no/corrupt preview.json gets rec: null
* and mtimeMs 0, which sorts it to the end and makes it prune-eligible. */
function scanPreviewDirs(): PreviewDirInfo[] {
const root = previewsRoot();
const out: PreviewDirInfo[] = [];
let entries: fs.Dirent[];
try {
if (!fs.existsSync(root)) return out;
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
return out;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(root, entry.name);
let mtimeMs = 0;
let rec: AuditionPreview | null = null;
try {
mtimeMs = fs.statSync(path.join(dir, 'preview.json')).mtimeMs;
rec = readPreview(entry.name);
} catch { /* garbage dir — mtime 0, rec null */ }
if (!rec) mtimeMs = 0;
out.push({ id: entry.name, dir, mtimeMs, bytes: dirBytes(dir), rec });
}
return out;
}
function dirBytes(dir: string): number {
let total = 0;
try {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (!entry.isFile()) continue;
try { total += fs.statSync(path.join(dir, entry.name)).size; } catch { /* vanished */ }
}
} catch { /* unreadable */ }
return total;
}
function removeDir(dir: string): void {
// preview.json goes FIRST, as its own operation. rmSync walks entries in
// readdir order and throws on the first file it cannot unlink, so a browser
// holding a read handle on `base.wav` (Windows opens it without
// FILE_SHARE_DELETE) aborts the walk PART WAY THROUGH — alphabetically
// `adapter.wav` is already gone and `preview.json` has not been reached. The
// dir then still parses, still passes listPreviews' filter, and still renders
// a player whose audioUrl 404s.
//
// Removing the completeness marker first makes that partial failure degrade
// to a garbage dir: invisible to listPreviews, mtime 0, and prune-eligible on
// the next pass — which is what the "skipped and retried next prune" comment
// always claimed happened.
try {
fs.rmSync(path.join(dir, 'preview.json'), { force: true });
} catch { /* already gone, or locked — the rmSync below still tries */ }
try {
fs.rmSync(dir, { recursive: true, force: true });
} catch (err: any) {
console.warn(`[Training] Could not prune preview dir ${dir}: ${err?.message || err}`);
}
}
/**
* C11, FROZEN. Sort by preview.json mtime DESC; delete every dir past index 39;
* then walk the survivors accumulating bytes and delete every dir past the point
* where the running total exceeds 1.0 GB.
*
* Called lazily on every preview write and on every GET …/audition — there is
* deliberately no boot hook (that would mean editing index.ts for nothing).
*/
export function prunePreviews(): void {
try {
const dirs = scanPreviewDirs();
dirs.sort((a, b) => b.mtimeMs - a.mtimeMs);
const survivors: PreviewDirInfo[] = [];
for (let i = 0; i < dirs.length; i++) {
if (i >= MAX_PREVIEWS) { removeDir(dirs[i].dir); continue; }
survivors.push(dirs[i]);
}
let running = 0;
let overflowed = false;
for (const info of survivors) {
if (overflowed) { removeDir(info.dir); continue; }
running += info.bytes;
// "past the point where the running total exceeds 1.0 GB" — the dir that
// crosses the line is kept, everything after it goes.
if (running > MAX_BYTES) overflowed = true;
}
} catch (err: any) {
console.warn(`[Training] Preview prune failed: ${err?.message || err}`);
}
}
/** Newest-first previews for one dataset. Corrupt dirs are silently skipped. */
export function listPreviews(datasetId: string, limit = 20): AuditionPreview[] {
try {
const out: AuditionPreview[] = [];
for (const info of scanPreviewDirs()) {
if (!info.rec) continue;
if (info.rec.datasetId !== datasetId) continue;
out.push(info.rec);
}
out.sort((a, b) => String(b.createdAt).localeCompare(String(a.createdAt)));
const n = Number.isFinite(limit) ? Math.max(1, Math.min(100, Math.trunc(limit))) : 20;
return out.slice(0, n);
} catch (err: any) {
console.warn(`[Training] Preview list failed: ${err?.message || err}`);
return [];
}
}
/** Swept by DELETE /datasets/:id — a deleted dataset leaves no orphan audio. */
export function deleteDatasetPreviews(datasetId: string): void {
try {
for (const info of scanPreviewDirs()) {
if (!info.rec || info.rec.datasetId !== datasetId) continue;
removeDir(info.dir);
}
} catch (err: any) {
console.warn(`[Training] Preview sweep for dataset ${datasetId} failed: ${err?.message || err}`);
}
}

Some files were not shown because too many files have changed in this diff Show More