@homie-rocks/studio 0.49.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/bin/homie-studio.mjs +5 -5
- package/functions/FUNCTIONS.md +1 -1
- package/guides/app/SKILL.md +107 -1
- package/guides/art/SKILL.md +154 -0
- package/guides/art/references/RIGHTS.md +20 -0
- package/guides/art/scripts/art.mjs +308 -0
- package/guides/models/SKILL.md +198 -0
- package/guides/models/references/RIGHTS.md +46 -0
- package/guides/models/references/models.json +175 -0
- package/guides/models/scripts/models.mjs +483 -0
- package/guides/music/SKILL.md +197 -0
- package/guides/music/references/COMPOSITION.md +59 -0
- package/guides/music/references/GAME-AUDIO.md +50 -0
- package/guides/music/references/RIGHTS.md +43 -0
- package/guides/music/scripts/lib/audio.mjs +258 -0
- package/guides/music/scripts/lib/eleven.mjs +260 -0
- package/guides/music/scripts/lib/studio.mjs +147 -0
- package/guides/music/scripts/music.mjs +644 -0
- package/guides/plan/SKILL.md +126 -0
- package/guides/plan/references/CODEX.md +105 -0
- package/guides/plan/references/INTERVIEW.md +86 -0
- package/guides/playtest/SKILL.md +205 -0
- package/guides/playtest/references/METHOD.md +124 -0
- package/guides/playtest/references/REVIEWER.md +78 -0
- package/guides/playtest/scripts/lib/exit.mjs +31 -0
- package/guides/playtest/scripts/lib/judge.mjs +502 -0
- package/guides/playtest/scripts/lib/pixels.mjs +128 -0
- package/guides/playtest/scripts/lib/preflight.mjs +69 -0
- package/guides/playtest/scripts/playtest.mjs +870 -0
- package/guides/shop/SKILL.md +4 -0
- package/guides/sound/SKILL.md +156 -0
- package/guides/sound/references/GAME-SOUND.md +100 -0
- package/guides/sound/references/SCORE.md +131 -0
- package/guides/sound/references/TRAPS.md +93 -0
- package/guides/sound/scripts/lib/measure.mjs +252 -0
- package/guides/sound/scripts/lib/score.mjs +415 -0
- package/guides/sound/scripts/lib/sfx.mjs +222 -0
- package/guides/sound/scripts/lib/synth.mjs +509 -0
- package/guides/sound/scripts/sound.mjs +520 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/check.mjs +10 -4
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/handoff.mjs +1 -1
- package/lib/mcp-tools.mjs +10 -9
- package/lib/mcp.mjs +1 -1
- package/lib/scaffold.mjs +13 -5
- package/lib/studio.mjs +3 -3
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/worker/build-id.mjs +1 -1
- package/worker/functions-schema.mjs +15 -0
- package/worker/shop-page.mjs +1 -1
- package/worker/shop.mjs +6 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* music.mjs — songs and game scores for a Homie studio, made with ElevenLabs
|
|
4
|
+
* Music through the creator's own account, mastered, looped on bar lines, and
|
|
5
|
+
* published as a song page on the studio's site.
|
|
6
|
+
*
|
|
7
|
+
* check the provider road, the plan, credits left, the rights, ffmpeg, the studio
|
|
8
|
+
* quote --seconds <s> [--vocals] the credit cost before anything is rendered
|
|
9
|
+
* budget <slug> --cap <credits> the cap the person approved for this song (refuses anything past it)
|
|
10
|
+
* plan <slug> --spec <spec.json> a composition plan with bar-exact sections (free, local)
|
|
11
|
+
* render <slug> [--dry-run] [--yes] ONE paid render of the plan (or --prompt/--seconds); --dry-run sends nothing
|
|
12
|
+
* lyrics <slug> [--render <n>] the transcription check: is every planned line actually sung?
|
|
13
|
+
* reconcile <slug> settle receipts the account had not reported yet (its balance can lag)
|
|
14
|
+
* master <slug> [--lufs -14] [--tp -1] two-pass loudness to a target, measured after
|
|
15
|
+
* loop <slug> --bars <n> [--from-bar <k>] a seamless loop cut on bar lines (WAV + OGG), seam measured
|
|
16
|
+
* stems <slug> [--yes] stems from the provider (paid; priced by measurement)
|
|
17
|
+
* analyze <file> duration, loudness, tempo and beat grid of any audio file
|
|
18
|
+
* add <slug> --title "<t>" [--blurb] [--kind song|score|loop] [--for-game <id>] [--publish]
|
|
19
|
+
* the entry in music/manifest.json from what is in music/<slug>/
|
|
20
|
+
* publish <slug> media to R2 when the studio has it, rebuild and redeploy, check the page
|
|
21
|
+
*
|
|
22
|
+
* Everything prints a few lines; --json prints the result. Nothing prints a key.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { join, resolve } from 'node:path';
|
|
27
|
+
import { beatsOf, decodeMono, loudness, need, probe, run } from './lib/audio.mjs';
|
|
28
|
+
import { composeDetailed, dryRun, quoteCredits, rightsFor, road, separateStems, subscription, transcribe } from './lib/eleven.mjs';
|
|
29
|
+
import { SLUG, checkBudget, getEntry, readBudget, readJson, receipt, rel, requireStudio, setBudget, studioHasMediaPages, upsertEntry, writeJson } from './lib/studio.mjs';
|
|
30
|
+
|
|
31
|
+
const argv = process.argv.slice(2);
|
|
32
|
+
const flags = new Map();
|
|
33
|
+
const pos = [];
|
|
34
|
+
const BOOL = new Set(['json', 'yes', 'dry-run', 'vocals', 'publish', 'instrumental', 'again', 'no-deploy']);
|
|
35
|
+
for (let i = 0; i < argv.length; i++) {
|
|
36
|
+
const a = argv[i];
|
|
37
|
+
if (a.startsWith('--')) {
|
|
38
|
+
const [k, v] = a.slice(2).split(/=(.*)/s, 2);
|
|
39
|
+
if (v !== undefined) flags.set(k, v);
|
|
40
|
+
else if (!BOOL.has(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith('--')) flags.set(k, argv[++i]);
|
|
41
|
+
else flags.set(k, true);
|
|
42
|
+
} else pos.push(a);
|
|
43
|
+
}
|
|
44
|
+
const asJson = flags.has('json');
|
|
45
|
+
const say = (line) => { if (!asJson) process.stderr.write(`${line}\n`); };
|
|
46
|
+
const OUTPUT_FORMAT = 'mp3_48000_192';
|
|
47
|
+
|
|
48
|
+
function jobDir(root, slug) {
|
|
49
|
+
if (!SLUG.test(String(slug ?? ''))) throw new Error('name the song with a slug: lowercase letters, digits and hyphens (it becomes /music/<slug>/)');
|
|
50
|
+
const dir = join(root, 'music', slug);
|
|
51
|
+
mkdirSync(join(dir, 'work'), { recursive: true });
|
|
52
|
+
return dir;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/* ---------------------------------------------------------------- check / quote / budget */
|
|
56
|
+
|
|
57
|
+
async function check() {
|
|
58
|
+
const r = road();
|
|
59
|
+
const out = { ok: true, command: 'check', road: r.road, cli: r.cli ?? null, ffmpeg: need('ffmpeg') && need('ffprobe') };
|
|
60
|
+
if (!r.road) { out.ok = false; out.why = r.why; }
|
|
61
|
+
else {
|
|
62
|
+
try { const s = await subscription(r); out.plan = s; out.rights = rightsFor(s.tier); } catch (error) { out.ok = false; out.why = error.message; }
|
|
63
|
+
}
|
|
64
|
+
let root = null;
|
|
65
|
+
try { root = requireStudio(); } catch (error) { out.studio = { ok: false, why: error.message }; }
|
|
66
|
+
if (root) out.studio = { ok: true, root, pages: studioHasMediaPages(root) };
|
|
67
|
+
if (!out.ffmpeg) { out.ok = false; out.why = [out.why, 'ffmpeg is not installed (macOS: `brew install ffmpeg`; the person approves the install)'].filter(Boolean).join('; '); }
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function quote() {
|
|
72
|
+
const seconds = Number(flags.get('seconds'));
|
|
73
|
+
if (!(seconds >= 3 && seconds <= 600)) throw new Error('--seconds must be 3..600 (ElevenLabs Music renders 3 s to 10 min)');
|
|
74
|
+
const q = quoteCredits(seconds, { vocals: flags.has('vocals') });
|
|
75
|
+
let plan = null;
|
|
76
|
+
try { plan = await subscription(); } catch { /* quote without the account */ }
|
|
77
|
+
const fits = plan ? q.total <= plan.remaining : null;
|
|
78
|
+
const cap = flags.has('budget') ? Number(flags.get('budget')) : null;
|
|
79
|
+
const longest = cap ? Math.floor(cap / (q.total / seconds)) : null;
|
|
80
|
+
return { ok: true, command: 'quote', seconds, credits: q, plan, fitsInPlan: fits, budget: cap, longestWithinBudget: longest };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function budget(root) {
|
|
84
|
+
const slug = pos[1];
|
|
85
|
+
const cap = Number(flags.get('cap'));
|
|
86
|
+
if (!(cap > 0)) throw new Error('--cap <credits>: the most the person agreed to spend on this song');
|
|
87
|
+
const b = setBudget(jobDir(root, slug), { provider: 'elevenlabs', unit: 'credits', cap, approvedBy: flags.get('by') ?? 'the person, in chat', note: flags.get('note') ?? null });
|
|
88
|
+
return { ok: true, command: 'budget', slug, budget: b };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* ---------------------------------------------------------------- plan */
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* spec.json: { title, bpm, key?, styles[], avoid[], sections: [ { name, bars, lines[] | instrumental: true, styles[], avoid[] } ] }
|
|
95
|
+
* Every section is a whole number of bars at the song's tempo, so the plan's
|
|
96
|
+
* section boundaries are bar lines: loops and video cuts can land on them.
|
|
97
|
+
*/
|
|
98
|
+
export function buildPlan(spec) {
|
|
99
|
+
const bpm = Number(spec.bpm);
|
|
100
|
+
if (!(bpm >= 50 && bpm <= 220)) throw new Error('spec.bpm must be 50..220');
|
|
101
|
+
const beats = Number(spec.beatsPerBar ?? 4);
|
|
102
|
+
const barMs = (60_000 / bpm) * beats;
|
|
103
|
+
const sections = Array.isArray(spec.sections) ? spec.sections : [];
|
|
104
|
+
if (!sections.length) throw new Error('spec.sections is empty');
|
|
105
|
+
const chunks = [];
|
|
106
|
+
const warnings = [];
|
|
107
|
+
let bar = 0;
|
|
108
|
+
const lyrics = [];
|
|
109
|
+
sections.forEach((s, i) => {
|
|
110
|
+
const bars = Number(s.bars);
|
|
111
|
+
if (!(Number.isInteger(bars) && bars >= 1)) throw new Error(`section ${i + 1} (${s.name}): bars must be a whole number`);
|
|
112
|
+
const start = Math.round(bar * barMs); const end = Math.round((bar + bars) * barMs);
|
|
113
|
+
const ms = end - start;
|
|
114
|
+
if (ms < 3000) throw new Error(`section ${i + 1} (${s.name}) is ${ms} ms; ElevenLabs needs 3000 ms or more per section (at ${bpm} BPM that is ${Math.ceil(3000 / barMs)} bars): merge it or lengthen it`);
|
|
115
|
+
if (ms > 120_000) throw new Error(`section ${i + 1} (${s.name}) is over 120 s; split it`);
|
|
116
|
+
const lines = Array.isArray(s.lines) ? s.lines.map((l) => String(l).trim()).filter(Boolean) : [];
|
|
117
|
+
for (const l of lines) if (l.length > 200) throw new Error(`a line in ${s.name} is over 200 characters`);
|
|
118
|
+
if (lines.length > bars * 2) warnings.push(`${s.name}: ${lines.length} lines in ${bars} bars will be rushed (about one or two lines a bar is singable)`);
|
|
119
|
+
const instrumental = Boolean(s.instrumental) || !lines.length;
|
|
120
|
+
const first = i === 0;
|
|
121
|
+
const pos = [
|
|
122
|
+
...(first ? [...(spec.styles ?? []), `${Math.round(bpm * 100) / 100} BPM`, ...(spec.key ? [spec.key] : [])] : [`${Math.round(bpm * 100) / 100} BPM`]),
|
|
123
|
+
...(s.styles ?? []),
|
|
124
|
+
...(instrumental ? ['instrumental'] : []),
|
|
125
|
+
].map(String);
|
|
126
|
+
const neg = [...(spec.avoid ?? []), ...(s.avoid ?? []), ...(instrumental ? ['vocals', 'singing'] : [])].map(String);
|
|
127
|
+
const name = String(s.name ?? `Section ${i + 1}`).slice(0, 60);
|
|
128
|
+
chunks.push({ text: instrumental ? `[${name}]\n{instrumental, no vocals}` : `[${name}]\n${lines.join('\n')}`, duration_ms: ms, positive_styles: [...new Set(pos)], negative_styles: [...new Set(neg)] });
|
|
129
|
+
if (!instrumental) lyrics.push({ section: name, startBar: bar, lines });
|
|
130
|
+
bar += bars;
|
|
131
|
+
});
|
|
132
|
+
const totalMs = chunks.reduce((a, c) => a + c.duration_ms, 0);
|
|
133
|
+
if (totalMs > 600_000) throw new Error('the song is over 10 minutes');
|
|
134
|
+
return { plan: { chunks }, bpm, beatsPerBar: beats, barMs, bars: bar, totalMs, lyrics, vocals: lyrics.length > 0, warnings };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function plan(root) {
|
|
138
|
+
const slug = pos[1];
|
|
139
|
+
const dir = jobDir(root, slug);
|
|
140
|
+
const specFile = flags.get('spec') ? resolve(String(flags.get('spec'))) : join(dir, 'spec.json');
|
|
141
|
+
const spec = readJson(specFile, null);
|
|
142
|
+
if (!spec) throw new Error(`no spec at ${specFile} (see references/COMPOSITION.md for its shape)`);
|
|
143
|
+
if (specFile !== join(dir, 'spec.json')) writeJson(join(dir, 'spec.json'), spec);
|
|
144
|
+
const p = buildPlan(spec);
|
|
145
|
+
writeJson(join(dir, 'plan.json'), { title: spec.title ?? slug, bpm: p.bpm, key: spec.key ?? null, beatsPerBar: p.beatsPerBar, bars: p.bars, totalMs: p.totalMs, vocals: p.vocals, lyrics: p.lyrics, composition_plan: p.plan });
|
|
146
|
+
const q = quoteCredits(p.totalMs / 1000, { vocals: p.vocals });
|
|
147
|
+
return { ok: true, command: 'plan', slug, seconds: p.totalMs / 1000, bars: p.bars, bpm: p.bpm, vocals: p.vocals, sections: p.plan.chunks.map((c) => ({ text: c.text.split('\n')[0], ms: c.duration_ms })), warnings: p.warnings, quote: q, file: rel(root, join(dir, 'plan.json')) };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ---------------------------------------------------------------- render */
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* ElevenLabs' balance can lag a render by several seconds (measured: unchanged right after a render,
|
|
154
|
+
* +445 a minute later). Wait up to ~40 s for it to move; null when it has not (the receipt then holds
|
|
155
|
+
* the quote and is marked pending, and `reconcile` settles it later).
|
|
156
|
+
*/
|
|
157
|
+
async function settle(r, beforeUsed) {
|
|
158
|
+
for (let i = 0; i < 10; i++) {
|
|
159
|
+
const s = await subscription(r).catch(() => null);
|
|
160
|
+
if (s && s.used > beforeUsed) return { delta: s.used - beforeUsed, after: s };
|
|
161
|
+
await new Promise((ok) => setTimeout(ok, Number(process.env.MUSIC_SETTLE_MS ?? 4000)));
|
|
162
|
+
}
|
|
163
|
+
return { delta: null, after: await subscription(r).catch(() => null) };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function nextRender(dir) {
|
|
167
|
+
const ns = readdirSync(join(dir, 'work')).map((f) => /^render-(\d+)\./.exec(f)?.[1]).filter(Boolean).map(Number);
|
|
168
|
+
return ns.length ? Math.max(...ns) + 1 : 1;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function render(root) {
|
|
172
|
+
const slug = pos[1];
|
|
173
|
+
const dir = jobDir(root, slug);
|
|
174
|
+
const p = readJson(join(dir, 'plan.json'), null);
|
|
175
|
+
let body; let seconds; let vocals = false;
|
|
176
|
+
if (flags.has('prompt')) {
|
|
177
|
+
seconds = Number(flags.get('seconds'));
|
|
178
|
+
if (!(seconds >= 3 && seconds <= 600)) throw new Error('--seconds 3..600 with --prompt');
|
|
179
|
+
if (!flags.has('instrumental')) throw new Error('a prompt-only render is for instrumentals (--instrumental): words go through a composition plan (`plan`), because a prompt with lyrics has come back with nothing sung');
|
|
180
|
+
body = { prompt: String(flags.get('prompt')), music_length_ms: Math.round(seconds * 1000), force_instrumental: true, model_id: 'music_v2' };
|
|
181
|
+
} else {
|
|
182
|
+
if (!p) throw new Error(`no plan for ${slug}: run \`plan ${slug} --spec <spec.json>\` first (or --prompt ... --instrumental --seconds n)`);
|
|
183
|
+
seconds = p.totalMs / 1000; vocals = p.vocals;
|
|
184
|
+
body = { model_id: 'music_v2', composition_plan: p.composition_plan, with_timestamps: true };
|
|
185
|
+
}
|
|
186
|
+
const q = quoteCredits(seconds, { vocals });
|
|
187
|
+
if (flags.has('dry-run')) {
|
|
188
|
+
const d = dryRun(body, { outputFormat: OUTPUT_FORMAT });
|
|
189
|
+
return { ok: d.ok, command: 'render', dryRun: true, slug, seconds, quote: q, ...d };
|
|
190
|
+
}
|
|
191
|
+
const b = checkBudget(dir, q.music, { provider: 'elevenlabs', unit: 'credits' });
|
|
192
|
+
if (!flags.has('yes')) {
|
|
193
|
+
return { ok: false, command: 'render', slug, needs: 'approval', seconds, quote: q, budget: { cap: b.cap, spent: b.spent }, why: `This render costs about ${q.music} credits (${q.basis}). Tell the person that, and render with --yes once they say go.` };
|
|
194
|
+
}
|
|
195
|
+
const n = nextRender(dir);
|
|
196
|
+
const pending = join(dir, 'work', `render-${n}.pending`);
|
|
197
|
+
const stale = readdirSync(join(dir, 'work')).find((f) => f.endsWith('.pending'));
|
|
198
|
+
if (stale && !flags.has('again')) throw new Error(`work/${stale} says an earlier render was sent and never came back; it may have been billed. Check the account's history, then run again with --again if the person agrees`);
|
|
199
|
+
if (stale) rmSync(join(dir, 'work', stale));
|
|
200
|
+
const r = road();
|
|
201
|
+
const before = await subscription(r);
|
|
202
|
+
if (before.remaining < q.music) throw new Error(`the account has ${before.remaining} credits left and this needs about ${q.music}; nothing was sent (ElevenLabs does not go over on this plan: ${before.canGoOver ? 'it can' : 'it cannot'})`);
|
|
203
|
+
writeJson(pending, { at: new Date().toISOString(), seconds, quote: q.music });
|
|
204
|
+
say(`rendering ${seconds} s (about ${q.music} credits)…`);
|
|
205
|
+
const started = Date.now();
|
|
206
|
+
const res = await composeDetailed(body, { outputFormat: OUTPUT_FORMAT });
|
|
207
|
+
rmSync(pending, { force: true });
|
|
208
|
+
if (!res.audio?.length) throw new Error('the answer had no audio in it');
|
|
209
|
+
const audio = join(dir, 'work', `render-${n}.mp3`);
|
|
210
|
+
writeFileSync(audio, res.audio);
|
|
211
|
+
writeJson(join(dir, 'work', `render-${n}.json`), { songId: res.songId, composition_plan: res.json?.composition_plan ?? null, song_metadata: res.json?.song_metadata ?? null, words: res.json?.words_timestamps ?? null });
|
|
212
|
+
const { delta: measured, after } = await settle(r, before.used);
|
|
213
|
+
const facts = probe(audio);
|
|
214
|
+
const rights = rightsFor(before.tier);
|
|
215
|
+
// The budget counts the larger of the quote and the measurement until the account has reported the real cost.
|
|
216
|
+
const line = receipt(root, dir, {
|
|
217
|
+
provider: 'elevenlabs', model: 'music_v2', requestId: res.songId, cost: measured ?? q.music, unit: 'credits', pending: measured === null,
|
|
218
|
+
quoted: q.music, measured, balanceBefore: before.used, seconds: facts.duration, artifact: rel(root, audio), plan: before.tier, rights,
|
|
219
|
+
});
|
|
220
|
+
const out = { n, file: rel(root, audio), seconds: facts.duration, askedSeconds: seconds, songId: res.songId, words: res.json?.words_timestamps?.length ?? 0, creditsQuoted: q.music, creditsMeasured: measured, creditsLeft: after?.remaining ?? null, tier: before.tier, rights, ms: Date.now() - started, receiptAt: line.at };
|
|
221
|
+
writeJson(join(dir, 'render.json'), { ...out, at: new Date().toISOString() });
|
|
222
|
+
const note = measured === null
|
|
223
|
+
? 'the account had not reported this render\'s cost yet: the quote is counted against the budget; run `reconcile` in a minute for the real number'
|
|
224
|
+
: Math.abs(measured - q.music) > q.music * 0.5 ? 'the measured cost is far from the quote: another job on the same account may have spent at the same time' : undefined;
|
|
225
|
+
return { ok: true, command: 'render', slug, ...out, note };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* ---------------------------------------------------------------- lyrics check */
|
|
229
|
+
|
|
230
|
+
const norm = (w) => String(w).toLowerCase().replace(/[’`]/g, "'").replace(/[^a-z0-9']/g, '').replace(/^'+|'+$/g, '');
|
|
231
|
+
const tokens = (text) => String(text).split(/\s+/).map(norm).filter(Boolean);
|
|
232
|
+
|
|
233
|
+
/** Longest common subsequence length of two token lists. */
|
|
234
|
+
function lcs(a, b) {
|
|
235
|
+
const dp = new Array(b.length + 1).fill(0);
|
|
236
|
+
for (let i = 1; i <= a.length; i++) {
|
|
237
|
+
let prev = 0;
|
|
238
|
+
for (let j = 1; j <= b.length; j++) { const t = dp[j]; dp[j] = a[i - 1] === b[j - 1] ? prev + 1 : Math.max(dp[j], dp[j - 1]); prev = t; }
|
|
239
|
+
}
|
|
240
|
+
return dp[b.length];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** For each planned line: how much of it the transcript heard, in order. A line passes at 75 %. */
|
|
244
|
+
export function matchLyrics(lines, heardWords, { pass = 0.75 } = {}) {
|
|
245
|
+
const heard = heardWords.map((w) => ({ ...w, t: norm(w.text ?? w.word ?? '') })).filter((w) => w.t);
|
|
246
|
+
const seq = heard.map((w) => w.t);
|
|
247
|
+
let at = 0;
|
|
248
|
+
const rows = [];
|
|
249
|
+
for (const line of lines) {
|
|
250
|
+
const want = tokens(line);
|
|
251
|
+
let best = { score: 0, from: at, to: at };
|
|
252
|
+
const span = want.length * 2 + 6;
|
|
253
|
+
for (let s = Math.max(0, at - 4); s <= Math.min(seq.length, at + 60); s++) {
|
|
254
|
+
const got = lcs(want, seq.slice(s, s + span));
|
|
255
|
+
if (got > best.score) best = { score: got, from: s, to: Math.min(seq.length, s + span) };
|
|
256
|
+
if (got === want.length) break;
|
|
257
|
+
}
|
|
258
|
+
const frac = want.length ? best.score / want.length : 1;
|
|
259
|
+
const window = heard.slice(best.from, best.to);
|
|
260
|
+
const firstHit = window.find((w) => want.includes(w.t));
|
|
261
|
+
rows.push({ line, heard: window.map((w) => w.text ?? w.word).join(' '), matched: +frac.toFixed(2), ok: frac >= pass, at: firstHit?.start ?? null });
|
|
262
|
+
if (frac >= pass) at = best.from + Math.max(1, Math.round(best.score));
|
|
263
|
+
}
|
|
264
|
+
return rows;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
async function lyricsCheck(root) {
|
|
268
|
+
const slug = pos[1];
|
|
269
|
+
const dir = jobDir(root, slug);
|
|
270
|
+
const p = readJson(join(dir, 'plan.json'), null);
|
|
271
|
+
if (!p?.vocals) throw new Error('this song has no sung lines in its plan');
|
|
272
|
+
const last = readJson(join(dir, 'render.json'), null);
|
|
273
|
+
const n = flags.has('render') ? Number(flags.get('render')) : last?.n;
|
|
274
|
+
const file = flags.get('file') ? resolve(String(flags.get('file'))) : join(dir, 'work', `render-${n}.mp3`);
|
|
275
|
+
if (!existsSync(file)) throw new Error(`no audio at ${rel(root, file)}`);
|
|
276
|
+
const seconds = probe(file).duration;
|
|
277
|
+
const cost = Math.ceil(seconds * 0.5);
|
|
278
|
+
const b = readBudget(dir);
|
|
279
|
+
if (b) checkBudget(dir, cost, { provider: 'elevenlabs', unit: 'credits' });
|
|
280
|
+
const r = road();
|
|
281
|
+
const before = await subscription(r).catch(() => null);
|
|
282
|
+
const t = await transcribe(file, { language: flags.get('language') ?? null });
|
|
283
|
+
const measured = before ? (await settle(r, before.used)).delta : null;
|
|
284
|
+
receipt(root, dir, { provider: 'elevenlabs', model: 'scribe_v2', requestId: t.transcription_id ?? null, cost: measured ?? cost, unit: 'credits', pending: measured === null, quoted: cost, measured, balanceBefore: before?.used ?? null, seconds, artifact: rel(root, file) });
|
|
285
|
+
const words = (t.words ?? []).filter((w) => (w.type ?? 'word') === 'word');
|
|
286
|
+
const lines = p.lyrics.flatMap((s) => s.lines);
|
|
287
|
+
const rows = matchLyrics(lines, words);
|
|
288
|
+
const out = { ok: rows.every((x) => x.ok), command: 'lyrics', slug, file: rel(root, file), heard: t.text ?? '', lines: rows, missing: rows.filter((x) => !x.ok).map((x) => x.line), creditsMeasured: measured };
|
|
289
|
+
writeJson(join(dir, `lyrics-check-${n ?? 'file'}.json`), out);
|
|
290
|
+
return out;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/* ---------------------------------------------------------------- master */
|
|
294
|
+
|
|
295
|
+
function master(root) {
|
|
296
|
+
const slug = pos[1];
|
|
297
|
+
const dir = jobDir(root, slug);
|
|
298
|
+
const last = readJson(join(dir, 'render.json'), null);
|
|
299
|
+
const src = flags.get('in') ? resolve(String(flags.get('in'))) : last ? join(root, last.file) : null;
|
|
300
|
+
if (!src || !existsSync(src)) throw new Error('nothing to master: render first, or --in <file>');
|
|
301
|
+
const I = Number(flags.get('lufs') ?? -14); const TP = Number(flags.get('tp') ?? -1); const LRA = Number(flags.get('lra') ?? 11);
|
|
302
|
+
const fadeOut = Number(flags.get('fade-out') ?? 0);
|
|
303
|
+
const dur = probe(src).duration;
|
|
304
|
+
const pre = ['highpass=f=24:poles=2', ...(fadeOut > 0 ? [`afade=t=out:st=${Math.max(0, dur - fadeOut).toFixed(3)}:d=${fadeOut}`] : [])].join(',');
|
|
305
|
+
const measure = run('ffmpeg', ['-hide_banner', '-i', src, '-af', `${pre},loudnorm=I=${I}:TP=${TP}:LRA=${LRA}:print_format=json`, '-f', 'null', '-']);
|
|
306
|
+
const text = measure.stderr;
|
|
307
|
+
let m = {};
|
|
308
|
+
try { m = JSON.parse(text.slice(text.lastIndexOf('{'), text.lastIndexOf('}') + 1)); } catch { /* reported below */ }
|
|
309
|
+
if (!m.input_i) throw new Error('the loudness measurement failed');
|
|
310
|
+
const wav = join(dir, `${slug}-master.wav`);
|
|
311
|
+
const mp3 = join(dir, `${slug}.mp3`);
|
|
312
|
+
const second = `${pre},loudnorm=I=${I}:TP=${TP}:LRA=${LRA}:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true,alimiter=limit=${(10 ** ((TP - 0.3) / 20)).toFixed(4)}:attack=3:release=60:level=false`;
|
|
313
|
+
const a = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', src, '-af', second, '-ar', '48000', '-ac', '2', '-c:a', 'pcm_s24le', wav]);
|
|
314
|
+
if (a.code !== 0) throw new Error(`mastering failed: ${a.stderr.trim().split('\n').pop()}`);
|
|
315
|
+
const b = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', wav, '-c:a', 'libmp3lame', '-b:a', '256k', '-ar', '48000', mp3]);
|
|
316
|
+
if (b.code !== 0) throw new Error(`the mp3 encode failed: ${b.stderr.trim().split('\n').pop()}`);
|
|
317
|
+
const lw = loudness(wav); const lm = loudness(mp3);
|
|
318
|
+
const out = { ok: Math.abs(lw.lufs - I) <= 1 && lm.truePeak <= TP + 0.6, command: 'master', slug, target: { lufs: I, truePeak: TP }, before: { lufs: Number(m.input_i), truePeak: Number(m.input_tp) }, master: { file: rel(root, wav), ...lw }, mp3: { file: rel(root, mp3), ...lm }, seconds: probe(mp3).duration };
|
|
319
|
+
writeJson(join(dir, 'master.json'), out);
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ---------------------------------------------------------------- loop */
|
|
324
|
+
|
|
325
|
+
function f32(file, { start, duration, rate = 48000 }) {
|
|
326
|
+
const r = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-ss', String(start), '-t', String(duration), '-i', file, '-ac', '2', '-ar', String(rate), '-f', 'f32le', '-']);
|
|
327
|
+
if (r.code !== 0) throw new Error(`could not read ${file}: ${r.stderr.trim().split('\n').pop()}`);
|
|
328
|
+
const b = r.stdout;
|
|
329
|
+
return new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength - (b.byteLength % 8)));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function loop(root) {
|
|
333
|
+
const slug = pos[1];
|
|
334
|
+
const dir = jobDir(root, slug);
|
|
335
|
+
const p = readJson(join(dir, 'plan.json'), null);
|
|
336
|
+
const src = flags.get('in') ? resolve(String(flags.get('in'))) : existsSync(join(dir, `${slug}-master.wav`)) ? join(dir, `${slug}-master.wav`) : null;
|
|
337
|
+
if (!src) throw new Error('loop a mastered file (run master first) or --in <file>');
|
|
338
|
+
const bpm = Number(flags.get('bpm') ?? p?.bpm);
|
|
339
|
+
if (!(bpm > 0)) throw new Error('--bpm is needed (the plan has it when there is one)');
|
|
340
|
+
const beatsPerBar = Number(flags.get('beats-per-bar') ?? p?.beatsPerBar ?? 4);
|
|
341
|
+
const bars = Number(flags.get('bars'));
|
|
342
|
+
if (!(Number.isInteger(bars) && bars >= 1)) throw new Error('--bars <n>: how many bars the loop holds');
|
|
343
|
+
const fromBar = Number(flags.get('from-bar') ?? 0);
|
|
344
|
+
const rate = 48000;
|
|
345
|
+
const beat = 60 / bpm; const bar = beat * beatsPerBar;
|
|
346
|
+
// Bar lines: the song's measured beat grid at the planned tempo; with a plan the render starts on a downbeat,
|
|
347
|
+
// so the first bar line is the grid's phase folded into one beat.
|
|
348
|
+
const grid = beatsOf(src, { bpm });
|
|
349
|
+
const offset = flags.has('offset') ? Number(flags.get('offset')) : grid.phase % beat;
|
|
350
|
+
const start = offset + fromBar * bar;
|
|
351
|
+
const len = Math.round(bars * bar * rate);
|
|
352
|
+
const xf = Math.round(Number(flags.get('crossfade-ms') ?? 30) / 1000 * rate);
|
|
353
|
+
const dur = probe(src).duration;
|
|
354
|
+
if (start + (len + xf) / rate > dur + 1e-3) throw new Error(`bars ${fromBar}..${fromBar + bars} (plus the crossfade) run past the end of the file (${dur.toFixed(2)} s)`);
|
|
355
|
+
const seg = f32(src, { start: start.toFixed(6), duration: ((len + xf) / rate + 0.05).toFixed(6), rate });
|
|
356
|
+
const out = new Float32Array(len * 2);
|
|
357
|
+
out.set(seg.subarray(0, len * 2));
|
|
358
|
+
// The tail that would follow the loop's end fades out over its head, so the wrap continues the music.
|
|
359
|
+
for (let i = 0; i < xf; i++) {
|
|
360
|
+
const gIn = Math.sin((Math.PI / 2) * (i / xf)); const gOut = Math.cos((Math.PI / 2) * (i / xf));
|
|
361
|
+
for (let c = 0; c < 2; c++) out[i * 2 + c] = seg[i * 2 + c] * gIn + seg[(len + i) * 2 + c] * gOut;
|
|
362
|
+
}
|
|
363
|
+
// The seam: the jump at the wrap against the file's ordinary sample-to-sample steps.
|
|
364
|
+
const steps = [];
|
|
365
|
+
for (let i = 1; i < len; i += 97) steps.push(Math.abs(out[i * 2] - out[(i - 1) * 2]));
|
|
366
|
+
steps.sort((a, b) => a - b);
|
|
367
|
+
const typical = steps[Math.floor(steps.length * 0.99)] || 1e-6;
|
|
368
|
+
const wrap = Math.max(Math.abs(out[0] - out[(len - 1) * 2]), Math.abs(out[1] - out[(len - 1) * 2 + 1]));
|
|
369
|
+
const name = flags.get('name') ?? `${slug}-loop-${bars}bars`;
|
|
370
|
+
const raw = join(dir, 'work', `${name}.f32`);
|
|
371
|
+
writeFileSync(raw, Buffer.from(out.buffer));
|
|
372
|
+
const wav = join(dir, `${name}.wav`); const ogg = join(dir, `${name}.ogg`);
|
|
373
|
+
const w = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'f32le', '-ar', String(rate), '-ac', '2', '-i', raw, '-c:a', 'pcm_s16le', wav]);
|
|
374
|
+
// A compressed copy: Vorbis where ffmpeg has it, else Opus (both in Ogg, both decode to the exact length).
|
|
375
|
+
let codec = 'vorbis';
|
|
376
|
+
let o = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'f32le', '-ar', String(rate), '-ac', '2', '-i', raw, '-c:a', 'libvorbis', '-q:a', '6', ogg]);
|
|
377
|
+
if (o.code !== 0) { codec = 'opus'; o = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-f', 'f32le', '-ar', String(rate), '-ac', '2', '-i', raw, '-c:a', 'libopus', '-b:a', '160k', ogg]); }
|
|
378
|
+
rmSync(raw, { force: true });
|
|
379
|
+
if (w.code !== 0) throw new Error(`the loop encode failed: ${w.stderr.trim()}`);
|
|
380
|
+
// Played three times over, the beat must keep landing on the grid across both seams.
|
|
381
|
+
const three = join(dir, 'work', `${name}-x3.wav`);
|
|
382
|
+
run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-stream_loop', '2', '-i', wav, '-c:a', 'pcm_s16le', three]);
|
|
383
|
+
const g3 = beatsOf(three, { bpm });
|
|
384
|
+
const ph = ((g3.phase % beat) + beat) % beat;
|
|
385
|
+
const drift = Math.min(ph, beat - ph); // the loop starts on a beat, so the grid of three passes sits at 0 (mod a beat)
|
|
386
|
+
rmSync(three, { force: true });
|
|
387
|
+
const seam = { wrapJump: +wrap.toFixed(4), typicalStep99: +typical.toFixed(4), ok: wrap <= typical * 1.5 };
|
|
388
|
+
const res = {
|
|
389
|
+
ok: seam.ok && o.code === 0, command: 'loop', slug, bars, fromBar, bpm, seconds: len / rate, samples: len, startInSource: +start.toFixed(4),
|
|
390
|
+
files: { wav: rel(root, wav), ogg: o.code === 0 ? rel(root, ogg) : null }, oggCodec: o.code === 0 ? codec : null, seam, gridConfidenceLooped: g3.confidence, gridPhaseLoopedMs: Math.round(drift * 1000),
|
|
391
|
+
playback: 'WebAudio: decode the WAV (or the OGG) and play it with source.loop = true; loopStart 0 and loopEnd at the full length. Never use an mp3 for a seamless loop: encoders add silence at the ends.',
|
|
392
|
+
};
|
|
393
|
+
writeJson(join(dir, `${name}.json`), res);
|
|
394
|
+
return res;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/* ---------------------------------------------------------------- stems */
|
|
398
|
+
|
|
399
|
+
async function stems(root) {
|
|
400
|
+
const slug = pos[1];
|
|
401
|
+
const dir = jobDir(root, slug);
|
|
402
|
+
const src = flags.get('in') ? resolve(String(flags.get('in'))) : join(dir, `${slug}-master.wav`);
|
|
403
|
+
if (!existsSync(src)) throw new Error('stems of what? master first, or --in <file>');
|
|
404
|
+
const est = Math.ceil(probe(src).duration * 10);
|
|
405
|
+
checkBudget(dir, est, { provider: 'elevenlabs', unit: 'credits' });
|
|
406
|
+
if (!flags.has('yes')) return { ok: false, command: 'stems', needs: 'approval', why: `Stem separation is paid and its price is not published per second; this job's budget allows it (a guess of ${est} credits is held against the cap). Ask the person, then run with --yes.` };
|
|
407
|
+
const r = road();
|
|
408
|
+
const before = await subscription(r);
|
|
409
|
+
const zip = join(dir, 'work', 'stems.zip');
|
|
410
|
+
await separateStems(src, zip);
|
|
411
|
+
const after = await subscription(r).catch(() => null);
|
|
412
|
+
const measured = after ? after.used - before.used : null;
|
|
413
|
+
receipt(root, dir, { provider: 'elevenlabs', model: 'stem-separation', cost: measured ?? est, unit: 'credits', estimated: measured === null, artifact: rel(root, zip) });
|
|
414
|
+
const outDir = join(dir, 'stems');
|
|
415
|
+
mkdirSync(outDir, { recursive: true });
|
|
416
|
+
const u = spawnSync('unzip', ['-o', '-q', zip, '-d', outDir]);
|
|
417
|
+
if (u.status !== 0) throw new Error('could not unzip the stems');
|
|
418
|
+
return { ok: true, command: 'stems', slug, files: readdirSync(outDir).map((f) => rel(root, join(outDir, f))), creditsMeasured: measured };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/* ---------------------------------------------------------------- reconcile */
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Settle pending receipts: the account's usage now, minus its usage before this song's first pending call,
|
|
425
|
+
* is what those calls cost (plus anything else the account did meanwhile, which is said, not hidden).
|
|
426
|
+
*/
|
|
427
|
+
async function reconcile(root) {
|
|
428
|
+
const slug = pos[1];
|
|
429
|
+
const dir = jobDir(root, slug);
|
|
430
|
+
const b = readJson(join(dir, 'budget.json'), null);
|
|
431
|
+
if (!b) throw new Error('this song has no budget.json');
|
|
432
|
+
const pendingIdx = b.calls.map((c, i) => (c.pending ? i : -1)).filter((i) => i >= 0);
|
|
433
|
+
const now = await subscription();
|
|
434
|
+
if (!pendingIdx.length) return { ok: true, command: 'reconcile', slug, pending: 0, spent: b.spent, creditsLeft: now.remaining };
|
|
435
|
+
const first = b.calls[pendingIdx[0]];
|
|
436
|
+
if (first.balanceBefore == null) throw new Error('the pending call has no balance reading to settle from');
|
|
437
|
+
const settledBefore = b.calls.slice(pendingIdx[0]).filter((c) => !c.pending).reduce((a, c) => a + (c.cost || 0), 0);
|
|
438
|
+
const delta = now.used - first.balanceBefore - settledBefore;
|
|
439
|
+
const quoted = pendingIdx.reduce((a, i) => a + (b.calls[i].quoted ?? b.calls[i].cost), 0);
|
|
440
|
+
if (delta <= 0) return { ok: false, command: 'reconcile', slug, why: 'the account still shows no change since the render; try again in a minute' };
|
|
441
|
+
for (const i of pendingIdx) {
|
|
442
|
+
const c = b.calls[i];
|
|
443
|
+
const share = Math.round(delta * ((c.quoted ?? c.cost) / quoted));
|
|
444
|
+
c.measured = share; c.cost = share; c.pending = false; c.reconciledAt = new Date().toISOString();
|
|
445
|
+
}
|
|
446
|
+
b.spent = b.calls.reduce((a, c) => a + (c.cost || 0), 0);
|
|
447
|
+
writeJson(join(dir, 'budget.json'), b);
|
|
448
|
+
receipt(root, dir, { provider: 'elevenlabs', model: 'reconcile', cost: 0, unit: 'credits', note: `settled ${pendingIdx.length} pending call(s): ${delta} credits measured against ${quoted} quoted (anything else the account did in between is included)`, measuredTotal: delta });
|
|
449
|
+
return { ok: true, command: 'reconcile', slug, settled: pendingIdx.length, measured: delta, quoted, spent: b.spent, cap: b.cap, creditsLeft: now.remaining };
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* ---------------------------------------------------------------- analyze */
|
|
453
|
+
|
|
454
|
+
function analyze() {
|
|
455
|
+
const file = resolve(String(pos[1] ?? ''));
|
|
456
|
+
if (!existsSync(file)) throw new Error('usage: analyze <audio file>');
|
|
457
|
+
const f = probe(file);
|
|
458
|
+
const l = loudness(file);
|
|
459
|
+
const g = beatsOf(file, { bpm: flags.has('bpm') ? Number(flags.get('bpm')) : null });
|
|
460
|
+
const x = decodeMono(file, { rate: 8000 });
|
|
461
|
+
let peak = 0; for (const v of x) peak = Math.max(peak, Math.abs(v));
|
|
462
|
+
return { ok: true, command: 'analyze', file, seconds: f.duration, audio: f.audio, loudness: l, samplePeakDb: +(20 * Math.log10(peak + 1e-12)).toFixed(2), tempo: { bpm: g.bpm, confidence: g.tempoConfidence, phase: +g.phase.toFixed(4), phaseFrom: g.phaseFrom, firstBeats: g.beats.slice(0, 8) } };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/* ---------------------------------------------------------------- add / publish */
|
|
466
|
+
|
|
467
|
+
function add(root) {
|
|
468
|
+
const slug = pos[1];
|
|
469
|
+
const dir = jobDir(root, slug);
|
|
470
|
+
const p = readJson(join(dir, 'plan.json'), null);
|
|
471
|
+
const r = readJson(join(dir, 'render.json'), null);
|
|
472
|
+
const m = readJson(join(dir, 'master.json'), null);
|
|
473
|
+
const title = flags.get('title') ?? p?.title;
|
|
474
|
+
if (!title) throw new Error('--title "<title>"');
|
|
475
|
+
const files = [];
|
|
476
|
+
const mp3 = join(dir, `${slug}.mp3`);
|
|
477
|
+
if (!existsSync(mp3)) throw new Error(`music/${slug}/${slug}.mp3 is missing: master the render first`);
|
|
478
|
+
files.push({ role: 'audio', path: rel(root, mp3), type: 'audio/mpeg', bytes: statSync(mp3).size });
|
|
479
|
+
if (existsSync(join(dir, `${slug}-master.wav`))) files.push({ role: 'master', path: rel(root, join(dir, `${slug}-master.wav`)), type: 'audio/wav', public: false });
|
|
480
|
+
for (const f of readdirSync(dir)) {
|
|
481
|
+
const lj = /^(.*-loop-(\d+)bars)\.json$/.exec(f);
|
|
482
|
+
if (!lj) continue;
|
|
483
|
+
const info = readJson(join(dir, f), {});
|
|
484
|
+
for (const [ext, type] of [['ogg', 'audio/ogg'], ['wav', 'audio/wav']]) {
|
|
485
|
+
const lf = join(dir, `${lj[1]}.${ext}`);
|
|
486
|
+
if (existsSync(lf)) files.push({ role: 'loop', name: `Loop, ${lj[2]} bars (${ext.toUpperCase()})`, path: rel(root, lf), type, bars: Number(lj[2]), bytes: statSync(lf).size, loopSeconds: info.seconds ?? null });
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
if (existsSync(join(dir, 'stems'))) for (const f of readdirSync(join(dir, 'stems'))) files.push({ role: 'stem', name: f.replace(/\.[a-z0-9]+$/i, ''), path: rel(root, join(dir, 'stems', f)) });
|
|
490
|
+
const cover = ['cover.jpg', 'cover.png', 'cover.webp'].map((c) => join(dir, c)).find(existsSync);
|
|
491
|
+
if (cover) files.push({ role: 'cover', path: rel(root, cover) });
|
|
492
|
+
const rights = r?.rights ?? null;
|
|
493
|
+
const lyrics = p?.vocals ? p.lyrics.map((s) => s.lines.join('\n')).join('\n\n') : null;
|
|
494
|
+
const entry = {
|
|
495
|
+
slug, kind: flags.get('kind') ?? (flags.has('for-game') ? 'score' : 'song'), title: String(title), blurb: flags.get('blurb') ?? '',
|
|
496
|
+
published: flags.has('publish'), duration: m?.seconds ?? r?.seconds ?? null, bpm: p?.bpm ?? null, key: p?.key ?? null,
|
|
497
|
+
loudness: m ? { lufs: m.mp3.lufs, truePeak: m.mp3.truePeak } : null, files, lyrics,
|
|
498
|
+
credits: flags.get('credits') ?? 'Music made with Eleven Music (ElevenLabs).',
|
|
499
|
+
rights: rights ? { provider: 'elevenlabs', plan: rights.plan, commercial: rights.commercial, attribution: rights.attribution } : null,
|
|
500
|
+
...(flags.has('for-game') ? { for: { game: String(flags.get('for-game')) } } : {}),
|
|
501
|
+
made: r ? { provider: 'elevenlabs', model: 'music_v2', at: r.at, receipt: rel(root, join(dir, 'budget.json')), songId: r.songId ?? null } : null,
|
|
502
|
+
};
|
|
503
|
+
const saved = upsertEntry(root, 'music', entry);
|
|
504
|
+
return { ok: true, command: 'add', slug, published: saved.published, files: files.map((f) => `${f.role} ${f.path}${f.public === false ? ' (kept off the site)' : ''}`), manifest: 'music/manifest.json' };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function studioCli(root, args) {
|
|
508
|
+
const bin = join(root, 'node_modules', '.bin', 'homie-studio');
|
|
509
|
+
const r = spawnSync(bin, [...args, '--json'], { cwd: root, encoding: 'utf8', timeout: 15 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
510
|
+
let j = null; try { j = JSON.parse(r.stdout); } catch { /* */ }
|
|
511
|
+
return { code: r.status ?? 1, json: j, err: (r.stderr ?? '').trim() };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The studio's live address: its own domain, else the address its last deploy printed (kept on this computer). */
|
|
515
|
+
function liveSite(root, studio) {
|
|
516
|
+
const cf = studio.cloudflare ?? {};
|
|
517
|
+
if (cf.domain) return `https://${String(cf.domain).replace(/^https?:\/\//, '').replace(/\/+$/, '')}`;
|
|
518
|
+
return cf.url ?? readJson(join(root, '.studio', 'local.json'), {})?.url ?? null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Publish a song or video: its page goes live. Big media goes to the studio's R2 when it has storage: from
|
|
523
|
+
* @homie-rocks/studio 0.18.0 with `media move` (and every deploy), each file uploaded, read back and checked by SHA-256
|
|
524
|
+
* before the site stops carrying it, at the same address; an older studio uploads with `media put` (/media/<key>).
|
|
525
|
+
*/
|
|
526
|
+
export async function publishEntry(root, kind, slug, { deploy = true } = {}) {
|
|
527
|
+
const pages = studioHasMediaPages(root);
|
|
528
|
+
if (!pages.ok) return { ok: false, command: 'publish', why: pages.why };
|
|
529
|
+
const entry = getEntry(root, kind, slug);
|
|
530
|
+
if (!entry) return { ok: false, command: 'publish', why: `no ${kind} entry "${slug}" (run add first)` };
|
|
531
|
+
if (!entry.published) upsertEntry(root, kind, { slug, published: true });
|
|
532
|
+
const studio = readJson(join(root, 'studio.json'), {});
|
|
533
|
+
const hasR2 = Boolean(studio.cloudflare?.r2 && (studio.cloudflare?.created ?? []).includes(`r2:${studio.cloudflare.r2}`));
|
|
534
|
+
const uploaded = [];
|
|
535
|
+
let r2 = null;
|
|
536
|
+
if (hasR2 && !pages.move) {
|
|
537
|
+
for (const f of entry.files ?? []) {
|
|
538
|
+
if (f.public === false || f.key || !f.path || !existsSync(join(root, f.path))) continue;
|
|
539
|
+
const put = studioCli(root, ['media', 'put', f.path]);
|
|
540
|
+
if (put.code !== 0 || !put.json?.ok) return { ok: false, command: 'publish', why: `uploading ${f.path} to R2 failed: ${put.json?.why ?? put.err.split('\n').pop()}` };
|
|
541
|
+
uploaded.push(put.json.key);
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
const site = liveSite(root, studio);
|
|
545
|
+
if (!deploy || !site) {
|
|
546
|
+
if (hasR2 && pages.move) {
|
|
547
|
+
const mv = studioCli(root, ['media', 'move']);
|
|
548
|
+
r2 = { moved: (mv.json?.moved ?? []).map((m) => m.path), failed: mv.json?.failed ?? [], ...(mv.json ? {} : { why: mv.err.split('\n').pop() }) };
|
|
549
|
+
}
|
|
550
|
+
const b = studioCli(root, ['build']);
|
|
551
|
+
return { ok: b.code === 0, command: 'publish', slug, uploaded, ...(r2 ? { r2 } : {}), built: b.json?.[kind === 'music' ? 'songs' : 'videos'] ?? null, live: null, next: site ? 'deploy with `npm run deploy`' : 'the studio is not online yet: the publish skill puts it on its own Cloudflare (npm run deploy), and the page is then at /' + kind + '/' + slug + '/' };
|
|
552
|
+
}
|
|
553
|
+
// A 0.18.0 deploy moves the big media to R2 itself (checked by SHA-256) before it builds.
|
|
554
|
+
const d = studioCli(root, ['deploy']);
|
|
555
|
+
if (d.code !== 0 || !d.json?.ok) return { ok: false, command: 'publish', why: `deploy failed: ${d.json?.why ?? d.err.split('\n').pop()}`, needs: d.json?.needs ?? null };
|
|
556
|
+
if (d.json.media) r2 = { moved: (d.json.media.moved ?? []).map((m) => m.path), failed: d.json.media.failed ?? [] };
|
|
557
|
+
const page = `${d.json.url}/${kind}/${slug}/`;
|
|
558
|
+
const check = await fetch(page).then((r) => r.status).catch(() => 0);
|
|
559
|
+
const main = (entry.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
|
|
560
|
+
const fresh = getEntry(root, kind, slug);
|
|
561
|
+
const mainNow = (fresh.files ?? []).find((f) => f.role === main?.role);
|
|
562
|
+
// A file moved to R2 keeps its own address; only a `media put` key lives at /media/<key>.
|
|
563
|
+
const mediaUrl = mainNow?.key ? `${d.json.url}/media/${mainNow.key}` : mainNow?.path ? `${d.json.url}/${mainNow.path.split('/').map(encodeURIComponent).join('/')}` : null;
|
|
564
|
+
const media = mediaUrl ? await fetch(mediaUrl, { headers: { range: 'bytes=0-1023' } }).then((r) => r.status).catch(() => 0) : null;
|
|
565
|
+
return { ok: check === 200 && (media === 206 || media === 200), command: 'publish', slug, uploaded, ...(r2 ? { r2 } : {}), page, pageStatus: check, media: mediaUrl, mediaStatus: media, from: mainNow?.r2 ? 'r2' : mainNow?.key ? 'r2 (/media/)' : 'site' };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* ---------------------------------------------------------------- main */
|
|
569
|
+
|
|
570
|
+
function print(r) {
|
|
571
|
+
if (asJson) { process.stdout.write(`${JSON.stringify(r, null, 2)}\n`); return; }
|
|
572
|
+
if (r.ok === false && r.why) { process.stdout.write(`music: ${r.why}\n`); if (r.command !== 'lyrics') return; }
|
|
573
|
+
const L = [];
|
|
574
|
+
switch (r.command) {
|
|
575
|
+
case 'check':
|
|
576
|
+
L.push(`ElevenLabs: ${r.road ? `connected (${r.road === 'cli' ? `the elevenlabs CLI, ${r.cli}` : r.via === 'stripe-projects' ? 'the key Stripe Projects keeps for this studio' : r.source ?? 'ELEVENLABS_API_KEY'})` : 'not connected'}`);
|
|
577
|
+
if (r.plan) L.push(`Plan: ${r.plan.tier} (${r.plan.status}); ${r.plan.remaining} credits left of ${r.plan.limit}; resets ${r.plan.resetAt ?? '?'}; ${r.plan.canGoOver ? 'CAN go over (usage-based billing)' : 'cannot go over'}`);
|
|
578
|
+
if (r.rights) L.push(`Rights: ${r.rights.words}`);
|
|
579
|
+
L.push(`ffmpeg: ${r.ffmpeg ? 'yes' : 'NO'}`, `Studio: ${r.studio?.ok ? r.studio.root : r.studio?.why}${r.studio?.pages && !r.studio.pages.ok ? ` (song pages: ${r.studio.pages.why})` : ''}`);
|
|
580
|
+
break;
|
|
581
|
+
case 'quote':
|
|
582
|
+
L.push(`${r.seconds} s: about ${r.credits.total} credits (${r.credits.music} music${r.credits.lyricCheck ? ` + ${r.credits.lyricCheck} lyric check` : ''}); ${r.credits.basis}.`);
|
|
583
|
+
if (r.plan) L.push(`The account has ${r.plan.remaining} credits left (${r.plan.tier}).`);
|
|
584
|
+
if (r.budget) L.push(`Within a budget of ${r.budget} credits the longest render is about ${r.longestWithinBudget} s.`);
|
|
585
|
+
break;
|
|
586
|
+
case 'plan':
|
|
587
|
+
L.push(`Plan: ${r.seconds} s, ${r.bars} bars at ${r.bpm} BPM${r.vocals ? ', with vocals' : ', instrumental'}; about ${r.quote.total} credits.`, ...r.sections.map((s) => ` ${s.text} ${s.ms} ms`), ...r.warnings.map((w) => ` warning: ${w}`), `Wrote ${r.file}`);
|
|
588
|
+
break;
|
|
589
|
+
case 'render':
|
|
590
|
+
if (r.dryRun) L.push(`Dry run (nothing sent): ${r.ok ? 'the request is valid' : r.why}; it would cost about ${r.quote.music} credits.`);
|
|
591
|
+
else if (r.needs) L.push(r.why);
|
|
592
|
+
else L.push(`Rendered ${r.file}: ${r.seconds.toFixed(2)} s (asked ${r.askedSeconds} s). Credits: quoted ${r.creditsQuoted}, measured ${r.creditsMeasured ?? 'unknown'}; ${r.creditsLeft ?? '?'} left. Plan ${r.tier}: ${r.rights.commercial ? 'commercial licence' : 'NO commercial licence'}.`, ...(r.note ? [r.note] : []));
|
|
593
|
+
break;
|
|
594
|
+
case 'lyrics':
|
|
595
|
+
L.push(`${r.ok ? 'Every planned line is sung.' : `NOT every line is sung: ${r.missing.length} missing.`}`, ...r.lines.map((x) => ` ${x.ok ? 'ok ' : 'MISS'} ${Math.round(x.matched * 100)}% "${x.line}" heard: "${x.heard}"`));
|
|
596
|
+
break;
|
|
597
|
+
case 'master':
|
|
598
|
+
L.push(`Mastered to ${r.master.lufs} LUFS, ${r.master.truePeak} dBTP (target ${r.target.lufs} / ${r.target.truePeak}); the mp3 reads ${r.mp3.lufs} LUFS, ${r.mp3.truePeak} dBTP. ${r.master.file}, ${r.mp3.file}`);
|
|
599
|
+
break;
|
|
600
|
+
case 'loop':
|
|
601
|
+
L.push(`Loop: ${r.bars} bars from bar ${r.fromBar}, ${r.seconds.toFixed(3)} s (${r.samples} samples at 48 kHz). Seam ${r.seam.ok ? 'clean' : 'AUDIBLE'} (jump ${r.seam.wrapJump} vs ${r.seam.typicalStep99}). ${r.files.wav}${r.files.ogg ? `, ${r.files.ogg}` : ''}`, r.playback);
|
|
602
|
+
break;
|
|
603
|
+
case 'analyze':
|
|
604
|
+
L.push(`${r.seconds.toFixed(2)} s, ${r.loudness.lufs} LUFS, ${r.loudness.truePeak} dBTP; tempo ${r.tempo.bpm} BPM (confidence ${r.tempo.confidence}), first beat ${r.tempo.phase} s`);
|
|
605
|
+
break;
|
|
606
|
+
default:
|
|
607
|
+
L.push(JSON.stringify(r, null, 2));
|
|
608
|
+
}
|
|
609
|
+
process.stdout.write(`${L.join('\n')}\n`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
async function main() {
|
|
613
|
+
const cmd = pos[0];
|
|
614
|
+
if (!cmd || cmd === 'help' || flags.has('help')) {
|
|
615
|
+
process.stdout.write(`${readFileSync(new URL(import.meta.url), 'utf8').split('*/')[0].split('\n').slice(2).map((l) => l.replace(/^ \* ?/, '')).join('\n')}\n`);
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
if (cmd === 'check') return check();
|
|
619
|
+
if (cmd === 'quote') return quote();
|
|
620
|
+
if (cmd === 'analyze') return analyze();
|
|
621
|
+
const root = requireStudio();
|
|
622
|
+
if (cmd === 'budget') return budget(root);
|
|
623
|
+
if (cmd === 'plan') return plan(root);
|
|
624
|
+
if (cmd === 'render') return render(root);
|
|
625
|
+
if (cmd === 'lyrics') return lyricsCheck(root);
|
|
626
|
+
if (cmd === 'reconcile') return reconcile(root);
|
|
627
|
+
if (cmd === 'master') return master(root);
|
|
628
|
+
if (cmd === 'loop') return loop(root);
|
|
629
|
+
if (cmd === 'stems') return stems(root);
|
|
630
|
+
if (cmd === 'add') return add(root);
|
|
631
|
+
if (cmd === 'publish') return publishEntry(root, 'music', pos[1], { deploy: !flags.has('no-deploy') });
|
|
632
|
+
return { ok: false, command: cmd, why: `unknown command "${cmd}" (music.mjs help)` };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('music.mjs')) {
|
|
636
|
+
try {
|
|
637
|
+
const r = await main();
|
|
638
|
+
if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
|
|
639
|
+
} catch (error) {
|
|
640
|
+
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
641
|
+
process.exitCode = 1;
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|