@homie-rocks/studio 0.48.4 → 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 +46 -0
- package/app-starters/gathering/README.md +4 -0
- package/app-starters/gathering/app.json +162 -0
- package/app-starters/gathering/index.html +3 -0
- package/app-starters/gathering/map/main.json +1 -0
- package/app-starters/gathering/src/move.ts +3 -0
- package/app-starters/gathering/src/rules.ts +21 -0
- package/app-starters/gathering/src/view.ts +74 -0
- package/app-starters/giving/README.md +5 -0
- package/app-starters/giving/app.json +162 -0
- package/app-starters/giving/index.html +3 -0
- package/app-starters/giving/map/main.json +1 -0
- package/app-starters/giving/src/move.ts +3 -0
- package/app-starters/giving/src/rules.ts +21 -0
- package/app-starters/giving/src/view.ts +74 -0
- package/app-starters/jobs/README.md +4 -0
- package/app-starters/jobs/app.json +162 -0
- package/app-starters/jobs/index.html +3 -0
- package/app-starters/jobs/map/main.json +1 -0
- package/app-starters/jobs/src/move.ts +3 -0
- package/app-starters/jobs/src/rules.ts +21 -0
- package/app-starters/jobs/src/view.ts +74 -0
- package/app-starters/quiz/README.md +4 -0
- package/app-starters/quiz/app.json +162 -0
- package/app-starters/quiz/index.html +3 -0
- package/app-starters/quiz/map/main.json +1 -0
- package/app-starters/quiz/src/move.ts +3 -0
- package/app-starters/quiz/src/rules.ts +21 -0
- package/app-starters/quiz/src/view.ts +74 -0
- package/app-starters/welcome/app.json +25 -2
- package/app-starters/welcome/index.html +1 -1
- package/app-starters/welcome/src/view.ts +20 -8
- package/bin/homie-studio.mjs +20 -16
- package/functions/FUNCTIONS.md +1 -1
- package/guides/app/SKILL.md +178 -0
- 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/parts/SKILL.md +158 -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 +153 -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/studio-setup/SKILL.md +419 -0
- package/guides/style/SKILL.md +105 -0
- package/lib/app-needs.mjs +63 -0
- package/lib/authoring-guides.mjs +5 -4
- package/lib/build.mjs +5 -4
- package/lib/check-site.mjs +6 -6
- package/lib/check.mjs +18 -5
- package/lib/codex.mjs +95 -18
- package/lib/decisions.mjs +9 -7
- package/lib/dev.mjs +4 -2
- package/lib/doctor.mjs +1 -1
- package/lib/handoff.mjs +13 -8
- package/lib/mcp-tools.mjs +19 -17
- package/lib/mcp.mjs +1 -1
- package/lib/parts.mjs +6 -6
- package/lib/scaffold.mjs +45 -11
- package/lib/shop-links.mjs +2 -2
- package/lib/shop.mjs +5 -3
- package/lib/studio.mjs +7 -6
- package/lib/template-history.json +84 -0
- package/package.json +4 -4
- package/shop/SHOP.md +4 -0
- package/shop/shop.ts +4 -4
- package/worker/app-format.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/functions-schema.mjs +15 -0
- package/worker/index.mjs +2 -1
- package/worker/parts.mjs +4 -4
- package/worker/shop-page.mjs +22 -16
- package/worker/shop-rules.mjs +4 -3
- package/worker/shop.mjs +6 -2
- package/worker/site.mjs +4 -2
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* sound.mjs: sound effects and synthesized music for a Homie studio, made on this computer.
|
|
4
|
+
* No provider, no account, no money: every file is rendered from a JSON spec by plain JavaScript,
|
|
5
|
+
* then measured (and encoded) with ffmpeg.
|
|
6
|
+
*
|
|
7
|
+
* check node, ffmpeg and its encoders, the studio
|
|
8
|
+
* presets the effect presets, the instruments and the grooves
|
|
9
|
+
* sfx <set> [--kit arcade|platformer|shooter|party|ui] [--only a,b] [--spec file]
|
|
10
|
+
* [--style clean|retro|soft] [--variants n] [--for-game <id>]
|
|
11
|
+
* a set of effects: music/<set>/sfx/<name>-<v>.wav, sfx.json, a reel to listen to
|
|
12
|
+
* score <slug> --spec <score.json> [--lufs -14] [--loops a,b|all] [--loop-stems]
|
|
13
|
+
* a synthesized score: master, page mp3, stems, seamless section loops
|
|
14
|
+
* mix <slug> --spec <mix.json> parts (files, ffmpeg aevalsrc expressions, effects) placed, panned, faded, summed
|
|
15
|
+
* analyze <file> [--kind sfx|music|capture] [--sheet out.png]
|
|
16
|
+
* loudness (also through a phone speaker), peaks, clipping, DC, late start,
|
|
17
|
+
* gaps, bands, stereo, onsets, hum: numbers and plain-words warnings
|
|
18
|
+
* wire <slug...> --game <id> copy sets and loops into the game with sound.js (a player) and sound.json
|
|
19
|
+
* add <slug> --title "<Title>" [--blurb] [--for-game <id>] [--publish]
|
|
20
|
+
* the manifest entry, so the studio's site gets a page for it
|
|
21
|
+
* publish <slug> rebuild and redeploy the site (the music skill's publish)
|
|
22
|
+
*
|
|
23
|
+
* Every command takes --json. Run it from inside the studio (the folder with studio.json).
|
|
24
|
+
*/
|
|
25
|
+
import { spawnSync } from 'node:child_process';
|
|
26
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, statfsSync, writeFileSync } from 'node:fs';
|
|
27
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { need, probe, run } from '../../music/scripts/lib/audio.mjs';
|
|
30
|
+
import { experienceDir, experienceJson, SLUG, findStudio, readJson, rel, upsertEntry, writeJson } from '../../music/scripts/lib/studio.mjs';
|
|
31
|
+
import { measure, r128, sheetPng, warnings } from './lib/measure.mjs';
|
|
32
|
+
import { INSTRUMENTS, loop as loopSection, master as masterMix, normalise, render as renderScore, seamWarning } from './lib/score.mjs';
|
|
33
|
+
import { KITS, LEVELS, PRESETS, renderEffect } from './lib/sfx.mjs';
|
|
34
|
+
import { RATE, addInto, dbToGain, dcBlock, levels as levelsOf, limit, peakOf, scale, stereo, wavBytes } from './lib/synth.mjs';
|
|
35
|
+
|
|
36
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
const flags = new Map();
|
|
39
|
+
const pos = [];
|
|
40
|
+
for (let i = 0; i < argv.length; i++) {
|
|
41
|
+
const a = argv[i];
|
|
42
|
+
if (a.startsWith('--')) { const k = a.slice(2); const v = argv[i + 1]; if (v === undefined || v.startsWith('--')) flags.set(k, true); else { flags.set(k, v); i++; } } else pos.push(a);
|
|
43
|
+
}
|
|
44
|
+
const JSON_OUT = flags.has('json');
|
|
45
|
+
const print = (o) => {
|
|
46
|
+
if (JSON_OUT) { process.stdout.write(`${JSON.stringify(o, null, 2)}\n`); return; }
|
|
47
|
+
if (o.ok === false) { process.stdout.write(`sound: ${o.why}\n`); return; }
|
|
48
|
+
const lines = [];
|
|
49
|
+
for (const [k, v] of Object.entries(o)) if (k !== 'ok' && k !== 'command') lines.push(`${k}: ${typeof v === 'object' ? JSON.stringify(v) : v}`);
|
|
50
|
+
process.stdout.write(`${lines.join('\n')}\n`);
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
function studioRoot() {
|
|
54
|
+
const root = findStudio();
|
|
55
|
+
if (!root) throw new Error('not inside a Homie studio (no studio.json here or above). The studio-setup skill makes one.');
|
|
56
|
+
return root;
|
|
57
|
+
}
|
|
58
|
+
function jobDir(root, slug) {
|
|
59
|
+
if (!SLUG.test(String(slug ?? ''))) throw new Error('give a slug: lowercase letters, digits and hyphens (up to 40), e.g. "theme" or "gem-rush-sfx"');
|
|
60
|
+
const d = join(root, 'music', slug);
|
|
61
|
+
mkdirSync(d, { recursive: true });
|
|
62
|
+
return d;
|
|
63
|
+
}
|
|
64
|
+
function freeGb(dir) { try { const f = statfsSync(dir); return (f.bavail * f.bsize) / 1e9; } catch { return 99; } }
|
|
65
|
+
function guardDisk(dir, needGb = 2) { if (freeGb(dir) < needGb + 8) throw new Error(`only ${freeGb(dir).toFixed(1)} GB free on this disk; free some space first (renders stop below 10 GB)`); }
|
|
66
|
+
const writeWav = (file, buf, opts) => { mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, wavBytes(buf, opts)); return file; };
|
|
67
|
+
|
|
68
|
+
function encode(src, out, kind) {
|
|
69
|
+
const args = ['-hide_banner', '-loglevel', 'error', '-y', '-i', src];
|
|
70
|
+
if (kind === 'mp3') args.push('-c:a', 'libmp3lame', '-b:a', '192k', '-ar', '48000', out);
|
|
71
|
+
else if (kind === 'ogg') {
|
|
72
|
+
let r = run('ffmpeg', [...args, '-c:a', 'libvorbis', '-q:a', '6', out]);
|
|
73
|
+
if (r.code === 0) return { file: out, codec: 'vorbis' };
|
|
74
|
+
r = run('ffmpeg', [...args, '-c:a', 'libopus', '-b:a', '160k', out]);
|
|
75
|
+
if (r.code !== 0) throw new Error(`ffmpeg could not encode ${out}: ${r.stderr.trim().split('\n').pop()}`);
|
|
76
|
+
return { file: out, codec: 'opus' };
|
|
77
|
+
}
|
|
78
|
+
const r = run('ffmpeg', args);
|
|
79
|
+
if (r.code !== 0) throw new Error(`ffmpeg could not encode ${out}: ${r.stderr.trim().split('\n').pop()}`);
|
|
80
|
+
return { file: out, codec: kind };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Bring a stereo buffer to an integrated loudness (measured by ffmpeg's R128), peaks held under the ceiling. */
|
|
84
|
+
function toLoudness(buf, target, ceilingDb, work) {
|
|
85
|
+
const tmp = join(work, `loud-${process.pid}.wav`);
|
|
86
|
+
let lufs = null;
|
|
87
|
+
for (let pass = 0; pass < 3; pass++) {
|
|
88
|
+
writeWav(tmp, buf, { bits: 24 });
|
|
89
|
+
lufs = r128(tmp).lufs;
|
|
90
|
+
if (lufs === null || !Number.isFinite(lufs)) break;
|
|
91
|
+
const d = target - lufs;
|
|
92
|
+
if (Math.abs(d) < 0.4) break;
|
|
93
|
+
scale(buf, dbToGain(d));
|
|
94
|
+
limit(buf, { ceiling: dbToGain(ceilingDb), releaseMs: 150, lookaheadMs: 5 });
|
|
95
|
+
}
|
|
96
|
+
rmSync(tmp, { force: true });
|
|
97
|
+
return buf;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* ---------------------------------------------------------------- check / presets */
|
|
101
|
+
|
|
102
|
+
function check() {
|
|
103
|
+
const ffmpeg = need('ffmpeg') && need('ffprobe');
|
|
104
|
+
const enc = ffmpeg ? run('ffmpeg', ['-hide_banner', '-encoders']).stdout.toString() : '';
|
|
105
|
+
const root = findStudio();
|
|
106
|
+
const node = Number(process.versions.node.split('.')[0]);
|
|
107
|
+
return {
|
|
108
|
+
ok: ffmpeg && node >= 22, command: 'check', node: process.versions.node, ffmpeg,
|
|
109
|
+
encoders: { mp3: /libmp3lame/.test(enc), vorbis: /libvorbis/.test(enc), opus: /libopus/.test(enc) },
|
|
110
|
+
studio: root ? { root, games: existsSync(join(root, 'games')) ? readdirSync(join(root, 'games')).filter((d) => existsSync(join(root, 'games', d, 'game.json'))) : [] } : null,
|
|
111
|
+
provider: 'none: everything is synthesized here, nothing is sent anywhere, nothing costs money',
|
|
112
|
+
...(ffmpeg ? {} : { why: 'ffmpeg is missing: brew install ffmpeg (macOS) or the system package; the person approves the install' }),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function presets() {
|
|
117
|
+
return {
|
|
118
|
+
ok: true, command: 'presets',
|
|
119
|
+
effects: Object.fromEntries(Object.entries(PRESETS).map(([k, v]) => [k, `${v.about} (${v.level})`])),
|
|
120
|
+
kits: KITS, levels: LEVELS,
|
|
121
|
+
instruments: Object.fromEntries(Object.entries(INSTRUMENTS).map(([k, v]) => [k, `${v.about} (${v.role})`])),
|
|
122
|
+
grooves: ['four', 'backbeat', 'half', 'break', 'shuffle', 'hats', 'pulse', 'none'],
|
|
123
|
+
patterns: ['root', 'root8', 'pulse', 'octaves', 'chords', 'stabs', 'arp', 'arp-down', 'arp-updown', 'or notes: "A4 . C5 - | E5*2 D5 C5 ."'],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/* ---------------------------------------------------------------- sfx */
|
|
128
|
+
|
|
129
|
+
function sfxSpec() {
|
|
130
|
+
if (flags.has('spec')) {
|
|
131
|
+
const s = readJson(resolve(String(flags.get('spec'))), null);
|
|
132
|
+
if (!s) throw new Error(`could not read ${flags.get('spec')} as JSON`);
|
|
133
|
+
return s;
|
|
134
|
+
}
|
|
135
|
+
const kit = String(flags.get('kit') ?? 'arcade');
|
|
136
|
+
const names = flags.has('only') ? String(flags.get('only')).split(',').map((x) => x.trim()).filter(Boolean) : KITS[kit];
|
|
137
|
+
if (!names) throw new Error(`no kit "${kit}" (${Object.keys(KITS).join(', ')})`);
|
|
138
|
+
return { style: flags.get('style') ?? 'clean', variants: Number(flags.get('variants') ?? 3), effects: names.map((n) => ({ name: n })) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const ONE_TAKE = new Set(['win', 'lose', 'go', 'countdown', 'alarm', 'teleport', 'powerup', 'confirm', 'back', 'deny']);
|
|
142
|
+
|
|
143
|
+
function sfx(root) {
|
|
144
|
+
const set = pos[1];
|
|
145
|
+
const dir = jobDir(root, set);
|
|
146
|
+
guardDisk(dir);
|
|
147
|
+
const spec = sfxSpec();
|
|
148
|
+
const style = flags.get('style') ?? spec.style ?? 'clean';
|
|
149
|
+
const variants = Math.max(1, Math.min(8, Number(flags.get('variants') ?? spec.variants ?? 3)));
|
|
150
|
+
const out = join(dir, 'sfx');
|
|
151
|
+
rmSync(out, { recursive: true, force: true });
|
|
152
|
+
mkdirSync(out, { recursive: true });
|
|
153
|
+
const made = [];
|
|
154
|
+
const reel = [];
|
|
155
|
+
for (const [i, e] of (spec.effects ?? []).entries()) {
|
|
156
|
+
if (!e?.name || !/^[a-z0-9][a-z0-9-]{0,39}$/.test(e.name)) throw new Error(`effect ${i + 1}: a name of lowercase letters, digits and hyphens`);
|
|
157
|
+
const files = [];
|
|
158
|
+
// Variants keep a sound heard over and over (a jump, a hit) from machine-gunning; a fanfare or a signal
|
|
159
|
+
// heard once a round wants to be the same every time: one take.
|
|
160
|
+
const takes = Math.max(1, Math.min(8, Number(e.variants ?? (ONE_TAKE.has(e.preset ?? e.name) ? 1 : variants))));
|
|
161
|
+
for (let v = 1; v <= takes; v++) {
|
|
162
|
+
// A variant: the same recipe a touch higher or lower and a little longer or shorter, with its own noise.
|
|
163
|
+
const jitter = takes === 1 ? 0 : ((v - 1) / (takes - 1) - 0.5) * 1.2;
|
|
164
|
+
const spec1 = { ...e, style: e.style ?? style, seed: (e.seed ?? 1) + v * 101, pitch: (e.pitch ?? 0) + jitter, length: (e.length ?? 1) * (1 + jitter * 0.05) };
|
|
165
|
+
const { buf, facts } = renderEffect(spec1);
|
|
166
|
+
const file = join(out, `${e.name}-${v}.wav`);
|
|
167
|
+
writeWav(file, buf, { bits: 16, channels: e.stereo ? 2 : 1 });
|
|
168
|
+
// An Ogg (Opus) copy a tenth the size; the WAV stays for browsers that cannot decode Ogg.
|
|
169
|
+
const ogg = encode(file, file.replace(/\.wav$/, '.ogg'), 'ogg');
|
|
170
|
+
files.push({ file: rel(root, file), ogg: rel(root, ogg.file), ...facts });
|
|
171
|
+
if (v === 1) reel.push(buf);
|
|
172
|
+
}
|
|
173
|
+
made.push({ name: e.name, preset: e.preset ?? e.name, about: PRESETS[e.preset ?? e.name].about, files });
|
|
174
|
+
}
|
|
175
|
+
// A reel to listen to: every effect once, half a second apart.
|
|
176
|
+
const gap = 0.5;
|
|
177
|
+
const reelBuf = stereo(reel.reduce((a, b) => a + b.L.length / RATE + gap, 0.2));
|
|
178
|
+
let at = 0.1;
|
|
179
|
+
for (const b of reel) { addInto(reelBuf, b, { at }); at += b.L.length / RATE + gap; }
|
|
180
|
+
const reelWav = writeWav(join(dir, 'work', `${set}-reel.wav`), reelBuf, { bits: 16 });
|
|
181
|
+
encode(reelWav, join(dir, `${set}.mp3`), 'mp3');
|
|
182
|
+
let sheet = null;
|
|
183
|
+
try { sheet = rel(root, sheetPng(reelWav, join(dir, `${set}-reel.png`))); } catch { /* no picture, still fine */ }
|
|
184
|
+
const checks = made.flatMap((m) => m.files.map((f) => ({ name: m.name, ...f }))).map((f) => {
|
|
185
|
+
const m = measure(join(root, f.file), { silenceDb: -60 });
|
|
186
|
+
return { file: f.file, warnings: warnings(m, 'sfx'), startMs: m.startMs, peakDb: m.samplePeakDb, under300: m.under300Share };
|
|
187
|
+
});
|
|
188
|
+
const info = { v: 1, kind: 'sfx', set, style, variants, at: new Date().toISOString(), made: 'synthesized on this computer by the Homie plugin\'s sound skill (no samples, no provider)', ...(flags.has('for-game') ? { for: { game: String(flags.get('for-game')) } } : {}), effects: made, spec: { ...spec, style, variants } };
|
|
189
|
+
writeJson(join(dir, 'sfx.json'), info);
|
|
190
|
+
const warn = checks.filter((c) => c.warnings.length);
|
|
191
|
+
return {
|
|
192
|
+
ok: true, command: 'sfx', set, style, variants, effects: made.map((m) => `${m.name}: ${m.files.length} file(s), ${m.files[0].seconds}s, loudest ${m.files[0].loudestDb} dB (${m.files[0].level})`),
|
|
193
|
+
reel: rel(root, join(dir, `${set}.mp3`)), sheet, spec: rel(root, join(dir, 'sfx.json')),
|
|
194
|
+
warnings: warn.length ? warn.map((c) => `${c.file}: ${c.warnings.join('; ')}`) : 'none: every file starts at once, no clipping, no DC',
|
|
195
|
+
next: `look at ${sheet ?? 'the reel'}; then: node sound.mjs wire ${set} --game <id>`,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/* ---------------------------------------------------------------- score */
|
|
200
|
+
|
|
201
|
+
function score(root) {
|
|
202
|
+
const slug = pos[1];
|
|
203
|
+
const dir = jobDir(root, slug);
|
|
204
|
+
guardDisk(dir);
|
|
205
|
+
const specPath = resolve(String(flags.get('spec') ?? join(dir, 'score.json')));
|
|
206
|
+
const raw = readJson(specPath, null);
|
|
207
|
+
if (!raw) throw new Error(`no score at ${specPath} (write one: references/SCORE.md)`);
|
|
208
|
+
const s = normalise(raw);
|
|
209
|
+
if (specPath !== join(dir, 'score.json')) writeJson(join(dir, 'score.json'), raw);
|
|
210
|
+
const work = join(dir, 'work'); mkdirSync(work, { recursive: true });
|
|
211
|
+
const lufs = Number(flags.get('lufs') ?? s.master?.lufs ?? -14);
|
|
212
|
+
const t0 = Date.now();
|
|
213
|
+
const r = renderScore(s);
|
|
214
|
+
// Master: peaks under the ceiling, then to the loudness target, measured. Stems get the same gain.
|
|
215
|
+
const before = peakOf(r.mix);
|
|
216
|
+
const mix = masterMix(r.mix, { ceilingDb: -1.5 });
|
|
217
|
+
toLoudness(mix, lufs, -1.2, work);
|
|
218
|
+
const gain = peakOf(mix) / Math.max(1e-9, before);
|
|
219
|
+
const masterWav = writeWav(join(dir, `${slug}-master.wav`), mix, { bits: 24 });
|
|
220
|
+
encode(masterWav, join(dir, `${slug}.mp3`), 'mp3');
|
|
221
|
+
const stemsDir = join(dir, 'stems'); rmSync(stemsDir, { recursive: true, force: true }); mkdirSync(stemsDir, { recursive: true });
|
|
222
|
+
const stems = [];
|
|
223
|
+
for (const [name, st] of Object.entries(r.stems)) {
|
|
224
|
+
if (!(peakOf(st) > 1e-5)) continue;
|
|
225
|
+
scale(st, gain);
|
|
226
|
+
writeWav(join(stemsDir, `${name}.wav`), st, { bits: 16 });
|
|
227
|
+
stems.push(name);
|
|
228
|
+
}
|
|
229
|
+
// Seamless loops of the sections asked for (default: the ones the score marks, else the longest).
|
|
230
|
+
const want = flags.has('loops') ? String(flags.get('loops')) : (s.loops ?? []).join(',');
|
|
231
|
+
const names = want === 'all' ? s.sections.map((x) => x.name) : want ? want.split(',').map((x) => x.trim()).filter(Boolean) : [s.sections.reduce((a, b) => (b.bars > a.bars ? b : a)).name];
|
|
232
|
+
const loops = [];
|
|
233
|
+
for (const name of names) {
|
|
234
|
+
// The master's gain and limiter go on inside loopSection, across the three copies it cuts the loop from:
|
|
235
|
+
// limiting the cut loop by itself puts a step of gain at the wrap.
|
|
236
|
+
const lp = loopSection(s, name, { gain, ceilingDb: -1.2 });
|
|
237
|
+
const base = `${slug}-${name}-loop-${lp.bars}bars`;
|
|
238
|
+
const wav = writeWav(join(dir, `${base}.wav`), lp.mix, { bits: 16 });
|
|
239
|
+
const ogg = encode(wav, join(dir, `${base}.ogg`), 'ogg');
|
|
240
|
+
const seam = lp.seam;
|
|
241
|
+
writeJson(join(dir, `${base}.json`), { section: name, bars: lp.bars, seconds: +lp.seconds.toFixed(6), bpm: s.bpm, beatsPerBar: s.beatsPerBar, seam, codec: ogg.codec });
|
|
242
|
+
if (flags.has('loop-stems')) {
|
|
243
|
+
for (const [st, b] of Object.entries(lp.stems)) { if (!(peakOf(b) > 1e-5)) continue; scale(b, gain); writeWav(join(dir, 'loops', name, `${st}.wav`), b, { bits: 16 }); }
|
|
244
|
+
}
|
|
245
|
+
loops.push({ section: name, bars: lp.bars, seconds: +lp.seconds.toFixed(3), seam, wav: rel(root, wav), ogg: rel(root, ogg.file) });
|
|
246
|
+
}
|
|
247
|
+
const m = measure(join(dir, `${slug}.mp3`));
|
|
248
|
+
// A loop over the seam limit is a warning like any other: an empty list must mean the loops were checked and pass.
|
|
249
|
+
const seamWarnings = loops.map((l) => seamWarning(l.section, l.seam)).filter(Boolean);
|
|
250
|
+
const allWarnings = [...warnings(m, 'music'), ...seamWarnings];
|
|
251
|
+
// plan.json: the bar grid, in the shape the video skill reads to cut a trailer on this music's bar lines.
|
|
252
|
+
const plan = {
|
|
253
|
+
title: s.title ?? slug, bpm: s.bpm, key: s.key ?? null, beatsPerBar: s.beatsPerBar, vocals: false, synthesized: true,
|
|
254
|
+
sections: r.grid.sections.map((x) => ({ name: x.name, bars: x.bars, startMs: Math.round(x.at * 1000), durationMs: Math.round(x.bars * r.grid.barSeconds * 1000) })),
|
|
255
|
+
totalMs: Math.round(r.grid.seconds * 1000),
|
|
256
|
+
};
|
|
257
|
+
writeJson(join(dir, 'plan.json'), plan);
|
|
258
|
+
writeJson(join(dir, 'master.json'), { seconds: m.seconds, mp3: { lufs: m.loudness.lufs, truePeak: m.loudness.truePeakDb }, phoneLufs: m.loudness.phoneLufs });
|
|
259
|
+
writeJson(join(dir, 'render.json'), { at: new Date().toISOString(), made: 'synthesized', seconds: m.seconds, grid: r.grid, loudness: m.loudness, stems, loops, warnings: allWarnings, renderMs: Date.now() - t0 });
|
|
260
|
+
let sheet = null;
|
|
261
|
+
try { sheet = rel(root, sheetPng(join(dir, `${slug}.mp3`), join(dir, `${slug}.png`))); } catch { /* */ }
|
|
262
|
+
return {
|
|
263
|
+
ok: true, command: 'score', slug, title: plan.title, seconds: m.seconds, bars: r.grid.bars, bpm: s.bpm,
|
|
264
|
+
loudness: `${m.loudness.lufs} LUFS, true peak ${m.loudness.truePeakDb} dBTP, through a phone speaker ${m.loudness.phoneLufs} LUFS`,
|
|
265
|
+
page: rel(root, join(dir, `${slug}.mp3`)), master: rel(root, masterWav), stems: stems.map((x) => `stems/${x}.wav`),
|
|
266
|
+
loops: loops.map((l) => `${l.section}: ${l.bars} bars, ${l.seconds}s, seam ${l.seam} ${l.seam > 1 ? 'OVER 1: NOT SEAMLESS, see warnings' : '(1 or less is seamless)'} ${l.wav}`),
|
|
267
|
+
loopsSeamless: seamWarnings.length === 0,
|
|
268
|
+
sheet, warnings: allWarnings, renderMs: Date.now() - t0,
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ---------------------------------------------------------------- mix */
|
|
273
|
+
|
|
274
|
+
/** One part of a mix as a stereo buffer: a file, an ffmpeg aevalsrc expression, or an effect preset. */
|
|
275
|
+
function partBuffer(root, p, i) {
|
|
276
|
+
const where = `part ${i + 1}`;
|
|
277
|
+
if (p.effect) return renderEffect(typeof p.effect === 'string' ? { name: p.effect } : p.effect).buf;
|
|
278
|
+
let input;
|
|
279
|
+
if (p.file) {
|
|
280
|
+
const f = resolve(root, String(p.file));
|
|
281
|
+
if (!existsSync(f)) throw new Error(`${where}: no file ${p.file}`);
|
|
282
|
+
input = ['-i', f];
|
|
283
|
+
} else if (p.expr) {
|
|
284
|
+
const exprs = Array.isArray(p.expr) ? p.expr : [p.expr];
|
|
285
|
+
if (!(exprs.length === 1 || exprs.length === 2)) throw new Error(`${where}: expr is one expression (mono) or two (left, right)`);
|
|
286
|
+
for (const e of exprs) {
|
|
287
|
+
if (typeof e !== 'string' || !e.trim()) throw new Error(`${where}: an empty expression`);
|
|
288
|
+
if (/['\\;[\]]/.test(e)) throw new Error(`${where}: an expression may not hold quotes, backslashes, semicolons or brackets (ffmpeg's filter parser takes them)`);
|
|
289
|
+
if (e.length > 8000) throw new Error(`${where}: an expression over 8000 characters; split it into two parts (they sum the same)`);
|
|
290
|
+
if (/[<>]=?/.test(e)) throw new Error(`${where}: aevalsrc has no comparison operators: use gte(a,b), lte(a,b), gt(a,b), lt(a,b)`);
|
|
291
|
+
}
|
|
292
|
+
if (!(Number(p.seconds) > 0 && Number(p.seconds) <= 600)) throw new Error(`${where}: an expression needs "seconds" (0..600)`);
|
|
293
|
+
input = ['-f', 'lavfi', '-i', `aevalsrc=exprs=${exprs.map((e) => `'${e}'`).join('|')}:s=${RATE}:d=${Number(p.seconds)}${exprs.length === 2 ? ':c=stereo' : ''}`];
|
|
294
|
+
} else throw new Error(`${where}: a part is { "file" } or { "expr", "seconds" } or { "effect" }`);
|
|
295
|
+
const chain = [];
|
|
296
|
+
if (p.from || p.seconds) chain.push(`atrim=${p.from ? `start=${Number(p.from)}` : 'start=0'}${p.file && p.seconds ? `:duration=${Number(p.seconds)}` : ''}`, 'asetpts=PTS-STARTPTS');
|
|
297
|
+
if (p.filter) {
|
|
298
|
+
if (/['\\;]/.test(String(p.filter))) throw new Error(`${where}: a filter may not hold quotes, backslashes or semicolons`);
|
|
299
|
+
chain.push(String(p.filter));
|
|
300
|
+
}
|
|
301
|
+
const args = ['-hide_banner', '-loglevel', 'error', ...input, '-vn', '-ac', '2', '-ar', String(RATE)];
|
|
302
|
+
if (chain.length) args.push('-af', chain.join(','));
|
|
303
|
+
args.push('-f', 'f32le', '-');
|
|
304
|
+
const r = run('ffmpeg', args);
|
|
305
|
+
if (r.code !== 0) throw new Error(`${where}: ffmpeg said: ${r.stderr.trim().split('\n').pop()}`);
|
|
306
|
+
const b = r.stdout;
|
|
307
|
+
const all = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength - (b.byteLength % 8)));
|
|
308
|
+
const n = all.length / 2;
|
|
309
|
+
const out = stereo(n / RATE);
|
|
310
|
+
for (let k = 0; k < n; k++) { out.L[k] = all[2 * k]; out.R[k] = all[2 * k + 1]; }
|
|
311
|
+
// An expression that exits 0 can still be NaN or silence (0*inf, a random() slot): say so, never mix it quietly.
|
|
312
|
+
let bad = 0; for (let k = 0; k < n; k += 11) if (!Number.isFinite(out.L[k]) || !Number.isFinite(out.R[k])) bad++;
|
|
313
|
+
if (bad) throw new Error(`${where}: the expression produced NaN samples (0*inf is NaN; write exp(-max(0,t-a)*k))`);
|
|
314
|
+
if (!(peakOf(out) > 1e-6)) throw new Error(`${where}: rendered silence (exit code 0 is not audio: check the expression's envelope and slots)`);
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function mix(root) {
|
|
319
|
+
const slug = pos[1];
|
|
320
|
+
const dir = jobDir(root, slug);
|
|
321
|
+
guardDisk(dir);
|
|
322
|
+
const spec = readJson(resolve(String(flags.get('spec') ?? join(dir, 'mix.json'))), null);
|
|
323
|
+
if (!spec?.parts?.length) throw new Error('a mix spec is { "parts": [ { "file" | "expr"+"seconds" | "effect", "at", "db", "pan", "fadeIn", "fadeOut", "repeat", "every", "filter" } ], "master": { "lufs" } }');
|
|
324
|
+
const placed = [];
|
|
325
|
+
for (const [i, p] of spec.parts.entries()) {
|
|
326
|
+
const buf = partBuffer(root, p, i);
|
|
327
|
+
const repeat = Math.max(1, Math.min(512, Number(p.repeat ?? 1)));
|
|
328
|
+
const every = Number(p.every ?? buf.L.length / RATE);
|
|
329
|
+
const fi = Math.round(Number(p.fadeIn ?? 0) * RATE); const fo = Math.round(Number(p.fadeOut ?? 0) * RATE);
|
|
330
|
+
for (let k = 0; k < fi && k < buf.L.length; k++) { const g = k / fi; buf.L[k] *= g; buf.R[k] *= g; }
|
|
331
|
+
for (let k = 0; k < fo && k < buf.L.length; k++) { const g = k / fo; buf.L[buf.L.length - 1 - k] *= g; buf.R[buf.R.length - 1 - k] *= g; }
|
|
332
|
+
for (let k = 0; k < repeat; k++) placed.push({ buf, at: Number(p.at ?? 0) + k * every, gain: dbToGain(Number(p.db ?? 0)), pan: Number(p.pan ?? 0), i });
|
|
333
|
+
}
|
|
334
|
+
const length = Math.max(...placed.map((p) => p.at + p.buf.L.length / RATE)) + Number(spec.tail ?? 0);
|
|
335
|
+
const out = stereo(length);
|
|
336
|
+
// Summed at the levels written: no automatic normalisation (ffmpeg's amix divides by the number of inputs by default).
|
|
337
|
+
for (const p of placed) {
|
|
338
|
+
if (p.pan) {
|
|
339
|
+
const g = p.pan; const l = Math.cos((g + 1) * Math.PI / 4) * Math.SQRT2; const r = Math.sin((g + 1) * Math.PI / 4) * Math.SQRT2;
|
|
340
|
+
const off = Math.round(p.at * RATE);
|
|
341
|
+
for (let k = 0; k < p.buf.L.length && off + k < out.L.length; k++) { out.L[off + k] += p.buf.L[k] * p.gain * l; out.R[off + k] += p.buf.R[k] * p.gain * r; }
|
|
342
|
+
} else addInto(out, p.buf, { at: p.at, gain: p.gain });
|
|
343
|
+
}
|
|
344
|
+
dcBlock(out);
|
|
345
|
+
const work = join(dir, 'work'); mkdirSync(work, { recursive: true });
|
|
346
|
+
const peakBefore = levelsOf(out).peakDb;
|
|
347
|
+
limit(out, { ceiling: dbToGain(spec.master?.ceilingDb ?? -1.5), releaseMs: 120 });
|
|
348
|
+
if (spec.master?.lufs !== undefined) toLoudness(out, Number(spec.master.lufs), spec.master?.ceilingDb ?? -1.2, work);
|
|
349
|
+
const wav = writeWav(join(dir, `${slug}-master.wav`), out, { bits: 24 });
|
|
350
|
+
encode(wav, join(dir, `${slug}.mp3`), 'mp3');
|
|
351
|
+
const m = measure(wav);
|
|
352
|
+
writeJson(join(dir, 'master.json'), { seconds: m.seconds, mp3: { lufs: m.loudness.lufs, truePeak: m.loudness.truePeakDb } });
|
|
353
|
+
return { ok: true, command: 'mix', slug, parts: spec.parts.length, placed: placed.length, seconds: m.seconds, peakBeforeLimiterDb: peakBefore, loudness: m.loudness, master: rel(root, wav), page: rel(root, join(dir, `${slug}.mp3`)), warnings: warnings(m, 'music') };
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/* ---------------------------------------------------------------- analyze */
|
|
357
|
+
|
|
358
|
+
function analyze() {
|
|
359
|
+
const file = resolve(String(pos[1] ?? ''));
|
|
360
|
+
if (!existsSync(file)) throw new Error('usage: analyze <audio file> [--kind sfx|music|capture] [--sheet out.png]');
|
|
361
|
+
const kind = String(flags.get('kind') ?? (probe(file).duration < 3 ? 'sfx' : 'music'));
|
|
362
|
+
const m = measure(file);
|
|
363
|
+
let sheet = null;
|
|
364
|
+
if (flags.has('sheet')) sheet = sheetPng(file, resolve(String(flags.get('sheet') === true ? file.replace(/\.[a-z0-9]+$/i, '.png') : flags.get('sheet'))));
|
|
365
|
+
return { ok: true, command: 'analyze', kind, ...m, warnings: warnings(m, kind), sheet };
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/* ---------------------------------------------------------------- wire */
|
|
369
|
+
|
|
370
|
+
function gameMode(gameDir) {
|
|
371
|
+
const g = readJson(experienceJson(gameDir), {});
|
|
372
|
+
return g.build?.mode === 'static' ? 'static' : 'bundle';
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function wire(root) {
|
|
376
|
+
const game = String(flags.get('game') ?? '');
|
|
377
|
+
const gameDir = experienceDir(root, game);
|
|
378
|
+
if (!game || !existsSync(experienceJson(gameDir))) throw new Error('--game <id>: a game in this studio (games/<id>/game.json)');
|
|
379
|
+
const slugs = pos.slice(1);
|
|
380
|
+
if (!slugs.length) throw new Error('usage: wire <sfx set or score slug...> --game <id>');
|
|
381
|
+
const mode = gameMode(gameDir);
|
|
382
|
+
const assets = mode === 'static' ? join(gameDir, 'sound') : join(gameDir, 'public', 'sound');
|
|
383
|
+
mkdirSync(assets, { recursive: true });
|
|
384
|
+
const manifest = readJson(join(assets, 'sound.json'), { v: 1, sfx: {}, music: {} });
|
|
385
|
+
let bytes = 0;
|
|
386
|
+
const copied = [];
|
|
387
|
+
const seams = [];
|
|
388
|
+
for (const slug of slugs) {
|
|
389
|
+
const dir = join(root, 'music', slug);
|
|
390
|
+
const sfxInfo = readJson(join(dir, 'sfx.json'), null);
|
|
391
|
+
if (sfxInfo) {
|
|
392
|
+
for (const e of sfxInfo.effects) {
|
|
393
|
+
// Each variant as [ogg, wav]: sound.js plays the first this browser decodes.
|
|
394
|
+
manifest.sfx[e.name] = e.files.map((f) => [f.ogg, f.file].filter((x) => x && existsSync(join(root, x))).map((x) => { const b = basename(x); copyFileSync(join(root, x), join(assets, b)); bytes += statSync(join(assets, b)).size; copied.push(b); return b; }));
|
|
395
|
+
}
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const plan = readJson(join(dir, 'plan.json'), null);
|
|
399
|
+
const loopsFound = existsSync(dir) ? readdirSync(dir).filter((f) => /-loop-\d+bars\.wav$/.test(f)) : [];
|
|
400
|
+
if (plan && loopsFound.length) {
|
|
401
|
+
const loops = {};
|
|
402
|
+
for (const f of loopsFound) {
|
|
403
|
+
const info = readJson(join(dir, f.replace(/\.wav$/, '.json')), {});
|
|
404
|
+
const name = info.section ?? 'main';
|
|
405
|
+
const sw = seamWarning(name, info.seam);
|
|
406
|
+
if (sw) seams.push(`music/${slug}: ${sw}`);
|
|
407
|
+
// Ogg first (a tenth of the bytes), the WAV for a browser that cannot decode Ogg: sound.js takes the first that decodes.
|
|
408
|
+
const list = [];
|
|
409
|
+
for (const g of [f.replace(/\.wav$/, '.ogg'), f]) {
|
|
410
|
+
if (!existsSync(join(dir, g))) continue;
|
|
411
|
+
copyFileSync(join(dir, g), join(assets, g)); bytes += statSync(join(assets, g)).size; copied.push(g); list.push(g);
|
|
412
|
+
}
|
|
413
|
+
loops[name] = list;
|
|
414
|
+
}
|
|
415
|
+
// Order: the plan's section order, so music('<slug>') starts with the first section.
|
|
416
|
+
const ordered = Object.fromEntries(plan.sections.map((x) => x.name).filter((n, i, a) => loops[n] && a.indexOf(n) === i).map((n) => [n, loops[n]]));
|
|
417
|
+
for (const [k, v] of Object.entries(loops)) if (!ordered[k]) ordered[k] = v;
|
|
418
|
+
manifest.music[slug] = { bpm: plan.bpm, beatsPerBar: plan.beatsPerBar ?? 4, barSeconds: (60 / plan.bpm) * (plan.beatsPerBar ?? 4), loops: ordered };
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
throw new Error(`music/${slug}: neither an effect set (sfx.json) nor a score with loops (make loops with: score ${slug} --loops <sections>, or music.mjs loop)`);
|
|
422
|
+
}
|
|
423
|
+
writeJson(join(assets, 'sound.json'), manifest);
|
|
424
|
+
const sizeOf = (f) => (existsSync(join(assets, f)) ? statSync(join(assets, f)).size : 0);
|
|
425
|
+
const downloadBytes = Object.values(manifest.sfx).flat().reduce((a, f) => a + sizeOf(Array.isArray(f) ? f[0] : f), 0)
|
|
426
|
+
+ Object.values(manifest.music).flatMap((t) => Object.values(t.loops)).reduce((a, l) => a + sizeOf(Array.isArray(l) ? l[0] : l), 0);
|
|
427
|
+
const playerSrc = join(HERE, 'player', 'sound.js');
|
|
428
|
+
const playerDest = mode === 'static' ? join(assets, 'sound.js') : join(gameDir, 'src', 'sound.js');
|
|
429
|
+
mkdirSync(dirname(playerDest), { recursive: true });
|
|
430
|
+
copyFileSync(playerSrc, playerDest);
|
|
431
|
+
copyFileSync(join(HERE, 'player', 'sound.d.ts'), playerDest.replace(/\.js$/, '.d.ts'));
|
|
432
|
+
const importLine = mode === 'static'
|
|
433
|
+
? `<script type="module">import { createSound } from './sound/sound.js'; window.sound = createSound({ base: 'sound/' });</script>`
|
|
434
|
+
: `import { createSound } from './sound.js';\nconst sound = createSound({ base: 'sound/' });`;
|
|
435
|
+
return {
|
|
436
|
+
ok: true, command: 'wire', game, mode, files: copied.length, kb: Math.round(bytes / 1024), manifest: rel(root, join(assets, 'sound.json')), player: rel(root, playerDest),
|
|
437
|
+
sfx: Object.keys(manifest.sfx), music: Object.fromEntries(Object.entries(manifest.music).map(([k, v]) => [k, Object.keys(v.loops)])),
|
|
438
|
+
add: importLine,
|
|
439
|
+
then: 'call sound.play(\'<name>\') where the action happens (the host AND every replica: each browser plays its own sound for what it sees), sound.music(\'<slug>\') once at start, sound.section(\'<name>\') when the game\'s intensity changes. Build, then prove it with the playtest skill (its sound row captures what the game really plays).',
|
|
440
|
+
warnings: seams,
|
|
441
|
+
download: `about ${Math.round(downloadBytes / 1024)} KB for a browser that decodes Ogg (the WAV copies are only fetched when it cannot)`,
|
|
442
|
+
...(downloadBytes > 3 * 1024 * 1024 ? { note: `${Math.round(downloadBytes / 1024 / 1024)} MB of sound: phones download it before the first round; fewer variants or shorter loops` } : {}),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/* ---------------------------------------------------------------- add / publish */
|
|
447
|
+
|
|
448
|
+
function add(root) {
|
|
449
|
+
const slug = pos[1];
|
|
450
|
+
const dir = jobDir(root, slug);
|
|
451
|
+
const sfxInfo = readJson(join(dir, 'sfx.json'), null);
|
|
452
|
+
const plan = readJson(join(dir, 'plan.json'), null);
|
|
453
|
+
const m = readJson(join(dir, 'master.json'), null);
|
|
454
|
+
const title = flags.get('title') ?? plan?.title;
|
|
455
|
+
if (!title || title === true) throw new Error('--title "<title>"');
|
|
456
|
+
const files = [];
|
|
457
|
+
const mp3 = join(dir, `${slug}.mp3`);
|
|
458
|
+
if (!existsSync(mp3)) throw new Error(`music/${slug}/${slug}.mp3 is missing: render it first (sfx, score or mix)`);
|
|
459
|
+
files.push({ role: 'audio', path: rel(root, mp3), type: 'audio/mpeg', bytes: statSync(mp3).size });
|
|
460
|
+
if (existsSync(join(dir, `${slug}-master.wav`))) files.push({ role: 'master', path: rel(root, join(dir, `${slug}-master.wav`)), type: 'audio/wav', public: false });
|
|
461
|
+
for (const f of readdirSync(dir)) {
|
|
462
|
+
const lj = /^(.*-loop-(\d+)bars)\.json$/.exec(f);
|
|
463
|
+
if (!lj) continue;
|
|
464
|
+
const info = readJson(join(dir, f), {});
|
|
465
|
+
const wav = join(dir, `${lj[1]}.wav`);
|
|
466
|
+
if (existsSync(wav)) files.push({ role: 'loop', name: `Loop: ${info.section ?? 'section'}, ${lj[2]} bars`, path: rel(root, wav), type: 'audio/wav', bars: Number(lj[2]), bytes: statSync(wav).size, loopSeconds: info.seconds ?? null });
|
|
467
|
+
}
|
|
468
|
+
if (sfxInfo) for (const e of sfxInfo.effects) { const f = e.files[0]; files.push({ role: 'stem', name: `${e.name} (${e.files.length} variant${e.files.length > 1 ? 's' : ''})`, path: f.file, type: 'audio/wav', bytes: statSync(join(root, f.file)).size }); }
|
|
469
|
+
else if (existsSync(join(dir, 'stems'))) for (const f of readdirSync(join(dir, 'stems')).sort()) files.push({ role: 'stem', name: f.replace(/\.wav$/, ''), path: rel(root, join(dir, 'stems', f)), type: 'audio/wav', bytes: statSync(join(dir, 'stems', f)).size });
|
|
470
|
+
const cover = ['cover.jpg', 'cover.png', 'cover.webp'].map((c) => join(dir, c)).find(existsSync);
|
|
471
|
+
if (cover) files.push({ role: 'cover', path: rel(root, cover) });
|
|
472
|
+
const kind = sfxInfo ? 'sfx' : flags.has('for-game') ? 'score' : 'song';
|
|
473
|
+
const forGame = flags.get('for-game') ?? sfxInfo?.for?.game ?? null;
|
|
474
|
+
const entry = {
|
|
475
|
+
slug, kind, title: String(title), blurb: flags.get('blurb') === true ? '' : String(flags.get('blurb') ?? ''),
|
|
476
|
+
published: flags.has('publish'), duration: m?.seconds ?? null, bpm: plan?.bpm ?? null, key: plan?.key ?? null,
|
|
477
|
+
loudness: m?.mp3 ? { lufs: m.mp3.lufs, truePeak: m.mp3.truePeak } : null, files, lyrics: null,
|
|
478
|
+
credits: flags.get('credits') ?? (sfxInfo ? 'Sound effects synthesized from code by the studio: no samples, no AI model.' : 'Synthesized from a written score by the studio: no samples, no AI model.'),
|
|
479
|
+
rights: { provider: 'none', commercial: null, attribution: null },
|
|
480
|
+
...(forGame ? { for: { game: String(forGame) } } : {}),
|
|
481
|
+
made: { provider: 'local', model: 'synthesized (the Homie plugin\'s sound skill)', at: new Date().toISOString(), spec: rel(root, join(dir, sfxInfo ? 'sfx.json' : existsSync(join(dir, 'score.json')) ? 'score.json' : 'mix.json')) },
|
|
482
|
+
};
|
|
483
|
+
const saved = upsertEntry(root, 'music', entry);
|
|
484
|
+
return { ok: true, command: 'add', slug, kind, published: saved.published, files: files.map((f) => `${f.role} ${f.path}${f.public === false ? ' (kept off the site)' : ''}`), manifest: 'music/manifest.json' };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function publish(root) {
|
|
488
|
+
const slug = pos[1];
|
|
489
|
+
const r = spawnSync(process.execPath, [join(HERE, '..', '..', 'music', 'scripts', 'music.mjs'), 'publish', slug, '--json', ...(flags.has('no-deploy') ? ['--no-deploy'] : [])], { cwd: root, encoding: 'utf8', timeout: 20 * 60_000, maxBuffer: 64 * 1024 * 1024 });
|
|
490
|
+
try { return JSON.parse(r.stdout); } catch { return { ok: false, command: 'publish', why: (r.stdout + r.stderr).trim().split('\n').slice(-3).join(' ') }; }
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/* ---------------------------------------------------------------- main */
|
|
494
|
+
|
|
495
|
+
async function main() {
|
|
496
|
+
const cmd = pos[0];
|
|
497
|
+
if (!cmd || cmd === 'help' || flags.has('help')) {
|
|
498
|
+
const head = readFileSync(fileURLToPath(import.meta.url), 'utf8').split('*/')[0];
|
|
499
|
+
return { ok: true, command: 'help', usage: head.replace(/^#!.*\n\/\*\*?/, '').replace(/^ \* ?/gm, '').trim() };
|
|
500
|
+
}
|
|
501
|
+
if (cmd === 'check') return check();
|
|
502
|
+
if (cmd === 'presets') return presets();
|
|
503
|
+
if (cmd === 'analyze') return analyze();
|
|
504
|
+
const root = studioRoot();
|
|
505
|
+
if (cmd === 'sfx') return sfx(root);
|
|
506
|
+
if (cmd === 'score') return score(root);
|
|
507
|
+
if (cmd === 'mix') return mix(root);
|
|
508
|
+
if (cmd === 'wire') return wire(root);
|
|
509
|
+
if (cmd === 'add') return add(root);
|
|
510
|
+
if (cmd === 'publish') return publish(root);
|
|
511
|
+
return { ok: false, command: cmd, why: `unknown command "${cmd}" (sound.mjs help)` };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
try {
|
|
515
|
+
const r = await main();
|
|
516
|
+
if (r) { print(r); if (r.ok === false) process.exitCode = 1; }
|
|
517
|
+
} catch (error) {
|
|
518
|
+
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
|
519
|
+
process.exitCode = 1;
|
|
520
|
+
}
|