@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/app-starters/gathering/README.md +4 -0
  3. package/app-starters/gathering/app.json +162 -0
  4. package/app-starters/gathering/index.html +3 -0
  5. package/app-starters/gathering/map/main.json +1 -0
  6. package/app-starters/gathering/src/move.ts +3 -0
  7. package/app-starters/gathering/src/rules.ts +21 -0
  8. package/app-starters/gathering/src/view.ts +74 -0
  9. package/app-starters/giving/README.md +5 -0
  10. package/app-starters/giving/app.json +162 -0
  11. package/app-starters/giving/index.html +3 -0
  12. package/app-starters/giving/map/main.json +1 -0
  13. package/app-starters/giving/src/move.ts +3 -0
  14. package/app-starters/giving/src/rules.ts +21 -0
  15. package/app-starters/giving/src/view.ts +74 -0
  16. package/app-starters/jobs/README.md +4 -0
  17. package/app-starters/jobs/app.json +162 -0
  18. package/app-starters/jobs/index.html +3 -0
  19. package/app-starters/jobs/map/main.json +1 -0
  20. package/app-starters/jobs/src/move.ts +3 -0
  21. package/app-starters/jobs/src/rules.ts +21 -0
  22. package/app-starters/jobs/src/view.ts +74 -0
  23. package/app-starters/quiz/README.md +4 -0
  24. package/app-starters/quiz/app.json +162 -0
  25. package/app-starters/quiz/index.html +3 -0
  26. package/app-starters/quiz/map/main.json +1 -0
  27. package/app-starters/quiz/src/move.ts +3 -0
  28. package/app-starters/quiz/src/rules.ts +21 -0
  29. package/app-starters/quiz/src/view.ts +74 -0
  30. package/app-starters/welcome/app.json +25 -2
  31. package/app-starters/welcome/index.html +1 -1
  32. package/app-starters/welcome/src/view.ts +20 -8
  33. package/bin/homie-studio.mjs +20 -16
  34. package/functions/FUNCTIONS.md +1 -1
  35. package/guides/app/SKILL.md +178 -0
  36. package/guides/art/SKILL.md +154 -0
  37. package/guides/art/references/RIGHTS.md +20 -0
  38. package/guides/art/scripts/art.mjs +308 -0
  39. package/guides/models/SKILL.md +198 -0
  40. package/guides/models/references/RIGHTS.md +46 -0
  41. package/guides/models/references/models.json +175 -0
  42. package/guides/models/scripts/models.mjs +483 -0
  43. package/guides/music/SKILL.md +197 -0
  44. package/guides/music/references/COMPOSITION.md +59 -0
  45. package/guides/music/references/GAME-AUDIO.md +50 -0
  46. package/guides/music/references/RIGHTS.md +43 -0
  47. package/guides/music/scripts/lib/audio.mjs +258 -0
  48. package/guides/music/scripts/lib/eleven.mjs +260 -0
  49. package/guides/music/scripts/lib/studio.mjs +147 -0
  50. package/guides/music/scripts/music.mjs +644 -0
  51. package/guides/parts/SKILL.md +158 -0
  52. package/guides/plan/SKILL.md +126 -0
  53. package/guides/plan/references/CODEX.md +105 -0
  54. package/guides/plan/references/INTERVIEW.md +86 -0
  55. package/guides/playtest/SKILL.md +205 -0
  56. package/guides/playtest/references/METHOD.md +124 -0
  57. package/guides/playtest/references/REVIEWER.md +78 -0
  58. package/guides/playtest/scripts/lib/exit.mjs +31 -0
  59. package/guides/playtest/scripts/lib/judge.mjs +502 -0
  60. package/guides/playtest/scripts/lib/pixels.mjs +128 -0
  61. package/guides/playtest/scripts/lib/preflight.mjs +69 -0
  62. package/guides/playtest/scripts/playtest.mjs +870 -0
  63. package/guides/shop/SKILL.md +153 -0
  64. package/guides/sound/SKILL.md +156 -0
  65. package/guides/sound/references/GAME-SOUND.md +100 -0
  66. package/guides/sound/references/SCORE.md +131 -0
  67. package/guides/sound/references/TRAPS.md +93 -0
  68. package/guides/sound/scripts/lib/measure.mjs +252 -0
  69. package/guides/sound/scripts/lib/score.mjs +415 -0
  70. package/guides/sound/scripts/lib/sfx.mjs +222 -0
  71. package/guides/sound/scripts/lib/synth.mjs +509 -0
  72. package/guides/sound/scripts/sound.mjs +520 -0
  73. package/guides/studio-setup/SKILL.md +419 -0
  74. package/guides/style/SKILL.md +105 -0
  75. package/lib/app-needs.mjs +63 -0
  76. package/lib/authoring-guides.mjs +5 -4
  77. package/lib/build.mjs +5 -4
  78. package/lib/check-site.mjs +6 -6
  79. package/lib/check.mjs +18 -5
  80. package/lib/codex.mjs +95 -18
  81. package/lib/decisions.mjs +9 -7
  82. package/lib/dev.mjs +4 -2
  83. package/lib/doctor.mjs +1 -1
  84. package/lib/handoff.mjs +13 -8
  85. package/lib/mcp-tools.mjs +19 -17
  86. package/lib/mcp.mjs +1 -1
  87. package/lib/parts.mjs +6 -6
  88. package/lib/scaffold.mjs +45 -11
  89. package/lib/shop-links.mjs +2 -2
  90. package/lib/shop.mjs +5 -3
  91. package/lib/studio.mjs +7 -6
  92. package/lib/template-history.json +84 -0
  93. package/package.json +4 -4
  94. package/shop/SHOP.md +4 -0
  95. package/shop/shop.ts +4 -4
  96. package/worker/app-format.mjs +1 -1
  97. package/worker/build-id.mjs +1 -1
  98. package/worker/functions-schema.mjs +15 -0
  99. package/worker/index.mjs +2 -1
  100. package/worker/parts.mjs +4 -4
  101. package/worker/shop-page.mjs +22 -16
  102. package/worker/shop-rules.mjs +4 -3
  103. package/worker/shop.mjs +6 -2
  104. package/worker/site.mjs +4 -2
  105. package/worker/version.mjs +1 -1
@@ -0,0 +1,252 @@
1
+ /**
2
+ * What a sound file actually is, measured, so "it sounds fine" is never the report:
3
+ * loudness (EBU R128, and again through a phone-speaker curve), true peak, clipping, DC, how late
4
+ * the sound starts, silences and gaps, where the energy sits (five bands and the share below
5
+ * 300 Hz that phone speakers throw away), stereo correlation and what folding to mono costs,
6
+ * onset density, narrow tones with their level and how much of the time they are there (a steady one
7
+ * is a hum), and a loudness arc over time.
8
+ *
9
+ * ffmpeg decodes and does R128; everything else is plain JavaScript over the decoded samples.
10
+ */
11
+ import { existsSync } from 'node:fs';
12
+ import { fft, run } from '../../../music/scripts/lib/audio.mjs';
13
+ import { svf } from './synth.mjs';
14
+
15
+ export const BANDS = [
16
+ { id: 'sub', from: 20, to: 120 },
17
+ { id: 'low', from: 120, to: 500 },
18
+ { id: 'mid', from: 500, to: 2000 },
19
+ { id: 'high', from: 2000, to: 8000 },
20
+ { id: 'air', from: 8000, to: 20000 },
21
+ ];
22
+ /** A small phone speaker: nothing much under ~350 Hz, little over 12 kHz (4th-order high-pass, 2nd-order low-pass). */
23
+ export const PHONE_CURVE = 'highpass=f=350:poles=2,highpass=f=350:poles=2,lowpass=f=12000:poles=2';
24
+ /** A television's speakers: a little more bass. */
25
+ export const TV_CURVE = 'highpass=f=110:poles=2,lowpass=f=15000:poles=2';
26
+
27
+ const db = (x) => (x > 0 ? 20 * Math.log10(x) : -Infinity);
28
+ const r = (x, p = 2) => (Number.isFinite(x) ? +x.toFixed(p) : null);
29
+
30
+ /** Decode to interleaved float stereo at `rate` (mono files come back with L = R). */
31
+ export function decodeStereo(file, { rate = 48000, filter = null, start = null, duration = null } = {}) {
32
+ if (!existsSync(file)) throw new Error(`no such file: ${file}`);
33
+ const args = ['-hide_banner', '-loglevel', 'error'];
34
+ if (start !== null) args.push('-ss', String(start));
35
+ if (duration !== null) args.push('-t', String(duration));
36
+ args.push('-i', file, '-vn', '-ac', '2', '-ar', String(rate));
37
+ if (filter) args.push('-af', filter);
38
+ args.push('-f', 'f32le', '-');
39
+ const out = run('ffmpeg', args);
40
+ if (out.code !== 0) throw new Error(`ffmpeg could not decode ${file}: ${out.stderr.trim().split('\n').pop()}`);
41
+ const b = out.stdout;
42
+ const all = new Float32Array(b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength - (b.byteLength % 8)));
43
+ const n = all.length / 2;
44
+ const L = new Float32Array(n); const R = new Float32Array(n);
45
+ for (let i = 0; i < n; i++) { L[i] = all[2 * i]; R[i] = all[2 * i + 1]; }
46
+ return { L, R, rate };
47
+ }
48
+
49
+ /** EBU R128 through an optional filter chain: integrated LUFS, range, true peak. Needs 0.4 s or more of audio. */
50
+ export function r128(file, filter = null) {
51
+ const chain = `${filter ? `${filter},` : ''}ebur128=peak=true`;
52
+ const out = run('ffmpeg', ['-hide_banner', '-nostats', '-i', file, '-af', chain, '-f', 'null', '-']);
53
+ const text = out.stderr;
54
+ const sum = text.slice(text.lastIndexOf('Summary:'));
55
+ const num = (re) => { const m = re.exec(sum); return m ? (m[1] === '-inf' ? -Infinity : Number(m[1])) : null; };
56
+ return { lufs: num(/I:\s+(-?[\d.]+|-inf) LUFS/), lra: num(/LRA:\s+(-?[\d.]+) LU/), truePeak: num(/Peak:\s+(-?[\d.]+|-inf) dBFS/) };
57
+ }
58
+
59
+ /**
60
+ * Everything about one file. `sections` [{ id, from, to }] (seconds) adds per-section numbers.
61
+ * `silenceDb` is the floor for silence and gaps (default -50 dBFS over 100 ms windows).
62
+ */
63
+ export function measure(file, { rate = 48000, silenceDb = -50, gapSeconds = 0.4, arcSeconds = 10 } = {}) {
64
+ const { L, R } = decodeStereo(file, { rate });
65
+ const n = L.length;
66
+ const seconds = n / rate;
67
+ let peak = 0; let sum = 0; let clipped = 0; let dc = 0; let lr = 0; let ll = 0; let rr = 0; let monoSum = 0;
68
+ for (let i = 0; i < n; i++) {
69
+ const a = L[i]; const b = R[i];
70
+ const pa = Math.abs(a); const pb = Math.abs(b);
71
+ if (pa > peak) peak = pa; if (pb > peak) peak = pb;
72
+ if (pa >= 0.9999) clipped++; if (pb >= 0.9999) clipped++;
73
+ const m = (a + b) * 0.5;
74
+ sum += (a * a + b * b) * 0.5; dc += m; lr += a * b; ll += a * a; rr += b * b; monoSum += m * m;
75
+ }
76
+ const rms = Math.sqrt(sum / Math.max(1, n));
77
+ const floor = 10 ** (silenceDb / 20);
78
+ // 100 ms windows: silence share, gaps, when the sound starts, the loudness arc.
79
+ const w = Math.max(1, Math.round(rate * 0.1));
80
+ const wins = [];
81
+ for (let i = 0; i < n; i += w) {
82
+ let s = 0; const e = Math.min(n, i + w);
83
+ for (let j = i; j < e; j++) { const m = (L[j] + R[j]) * 0.5; s += m * m; }
84
+ wins.push(Math.sqrt(s / Math.max(1, e - i)));
85
+ }
86
+ const silent = wins.filter((v) => v < floor).length;
87
+ const gaps = [];
88
+ let run0 = -1;
89
+ for (let k = 0; k <= wins.length; k++) {
90
+ const quiet = k < wins.length && wins[k] < floor;
91
+ if (quiet && run0 < 0) run0 = k;
92
+ if (!quiet && run0 >= 0) {
93
+ const len = (k - run0) * 0.1;
94
+ // Only gaps inside the sound count: leading and trailing silence is reported as start/tail.
95
+ if (len >= gapSeconds && run0 > 0 && k < wins.length) gaps.push({ at: r(run0 * 0.1, 1), seconds: r(len, 1) });
96
+ run0 = -1;
97
+ }
98
+ }
99
+ let start = null;
100
+ const startFloor = Math.max(floor, peak * 10 ** (-40 / 20));
101
+ for (let i = 0; i < n; i++) if (Math.abs(L[i]) > startFloor || Math.abs(R[i]) > startFloor) { start = i / rate; break; }
102
+ const arc = [];
103
+ const per = Math.max(1, Math.round(arcSeconds / 0.1));
104
+ for (let k = 0; k < wins.length; k += per) {
105
+ const slice = wins.slice(k, k + per);
106
+ const e = Math.sqrt(slice.reduce((a, v) => a + v * v, 0) / slice.length);
107
+ arc.push({ at: r(k * 0.1, 1), rmsDb: r(db(e), 1) });
108
+ }
109
+ // Spectrum: Hann frames of 4096, at most ~800 of them spread over the file.
110
+ const N = 4096;
111
+ const hop = Math.max(1024, Math.floor((n - N) / 800));
112
+ const hann = new Float32Array(N); for (let i = 0; i < N; i++) hann[i] = 0.5 - 0.5 * Math.cos((2 * Math.PI * i) / (N - 1));
113
+ const bandE = new Float64Array(BANDS.length); const monoBandE = new Float64Array(BANDS.length); const lrBandE = new Float64Array(BANDS.length);
114
+ let under300 = 0; let total = 0; let cTop = 0; let cBot = 0;
115
+ const mean = new Float64Array(N / 2);
116
+ const flux = [];
117
+ let prev = null; let frames = 0;
118
+ const spectra = []; // every frame's power spectrum, kept so a narrow tone can be asked how OFTEN it is there
119
+ const reL = new Float64Array(N); const imL = new Float64Array(N); const reR = new Float64Array(N); const imR = new Float64Array(N);
120
+ for (let s = 0; s + N <= n; s += hop) {
121
+ for (let i = 0; i < N; i++) { reL[i] = L[s + i] * hann[i]; imL[i] = 0; reR[i] = R[s + i] * hann[i]; imR[i] = 0; }
122
+ fft(reL, imL); fft(reR, imR);
123
+ const mag = new Float64Array(N / 2);
124
+ for (let k = 1; k < N / 2; k++) {
125
+ const hz = (k * rate) / N;
126
+ const pl = reL[k] * reL[k] + imL[k] * imL[k]; const pr = reR[k] * reR[k] + imR[k] * imR[k];
127
+ const mr = (reL[k] + reR[k]) * 0.5; const mi = (imL[k] + imR[k]) * 0.5; const pm = mr * mr + mi * mi;
128
+ const p = (pl + pr) * 0.5;
129
+ mag[k] = Math.sqrt(p); mean[k] += p;
130
+ if (hz < 20 || hz > 20000) continue;
131
+ total += p; if (hz < 300) under300 += p;
132
+ cTop += hz * p; cBot += p;
133
+ const bi = BANDS.findIndex((b) => hz >= b.from && hz < b.to);
134
+ if (bi >= 0) { bandE[bi] += p; monoBandE[bi] += pm; lrBandE[bi] += p; }
135
+ }
136
+ if (prev) { let f = 0; for (let k = 1; k < N / 2; k++) { const d = mag[k] - prev[k]; if (d > 0) f += d; } flux.push(f); }
137
+ prev = mag; frames++;
138
+ const pw = new Float32Array(N / 2); for (let k = 1; k < N / 2; k++) pw[k] = mag[k] * mag[k]; spectra.push(pw);
139
+ }
140
+ const bands = Object.fromEntries(BANDS.map((b, i) => [b.id, r(bandE[i] / (total || 1), 3)]));
141
+ const monoLossDb = Object.fromEntries(BANDS.map((b, i) => [b.id, lrBandE[i] > 0 ? r(10 * Math.log10(Math.max(1e-20, monoBandE[i]) / lrBandE[i]), 1) : null]));
142
+ const med = (xs) => { if (!xs.length) return 0; const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
143
+ const fm = med(flux); const mad = med(flux.map((v) => Math.abs(v - fm)));
144
+ const onsets = flux.filter((v, i) => v > fm + 3 * mad && v > (flux[i - 1] ?? 0) && v >= (flux[i + 1] ?? 0)).length;
145
+ const hopSeconds = hop / rate;
146
+ // Narrow tones: a bin 10 dB over its neighbourhood in the AVERAGE spectrum. That alone is only "narrow": a melody's
147
+ // high partial that sounds for two seconds of sixteen is narrow too, and so is any single note. Three more numbers
148
+ // say whether it is a hum (a whine, a stuck voice), which is steady and audible:
149
+ // levelDb how loud the tone is, in dBFS (its RMS as a sine, averaged over the file): prominence is
150
+ // relative, and a filter that lowers a tone lowers its neighbours with it and leaves it unchanged
151
+ // energyShare the tone's share of all the energy between 20 Hz and 20 kHz
152
+ // presentShare the share of the file's frames in which the tone stands 10 dB over its neighbours and is
153
+ // above -70 dBFS: a hum is there nearly all the time, a note is not
154
+ // A Hann-windowed sine of amplitude A puts A² · N²/16 · 1.5 of power across its main lobe (1.5 = the window's
155
+ // noise bandwidth in bins), so three bins summed and divided by that give A² back.
156
+ const lobe = (N * N / 16) * 1.5;
157
+ const toneFloor = 10 ** (-70 / 10) * 2; // A² of a sine at -70 dBFS RMS
158
+ const lines = [];
159
+ for (let k = 4; k < N / 2 - 4; k++) {
160
+ const hz = (k * rate) / N;
161
+ if (hz < 40 || hz > 16000) continue;
162
+ const local = (mean[k - 4] + mean[k - 3] + mean[k + 3] + mean[k + 4]) / 4;
163
+ if (!(local > 0 && mean[k] > local * 10 && mean[k] >= mean[k - 1] && mean[k] >= mean[k + 1])) continue;
164
+ let present = 0;
165
+ for (const pw of spectra) {
166
+ const near = (pw[k - 4] + pw[k - 3] + pw[k + 3] + pw[k + 4]) / 4;
167
+ const a2 = (pw[k - 1] + pw[k] + pw[k + 1]) / lobe;
168
+ if (a2 > toneFloor && pw[k] > near * 10) present++;
169
+ }
170
+ const three = mean[k - 1] + mean[k] + mean[k + 1];
171
+ const a2mean = three / Math.max(1, frames) / lobe;
172
+ lines.push({
173
+ hz: r(hz, 1), prominenceDb: r(10 * Math.log10(mean[k] / local), 1),
174
+ levelDb: r(10 * Math.log10(Math.max(1e-20, a2mean / 2)), 1),
175
+ energyShare: r(three / (total || 1), 4),
176
+ presentShare: r(present / Math.max(1, frames), 3),
177
+ });
178
+ }
179
+ lines.sort((a, b) => b.prominenceDb - a.prominenceDb);
180
+ const corr = ll > 0 && rr > 0 ? lr / Math.sqrt(ll * rr) : null;
181
+ const stereoDiff = (() => { let d = 0; for (let i = 0; i < n; i += 3) d = Math.max(d, Math.abs(L[i] - R[i])); return d; })();
182
+ const long = seconds >= 0.45;
183
+ const full = long ? r128(file) : { lufs: null, lra: null, truePeak: null };
184
+ const phone = long ? r128(file, PHONE_CURVE) : { lufs: null };
185
+ // A short effect is too short for R128: the same phone curve in JavaScript, RMS of the loudest 50 ms before and after.
186
+ const phoneRmsLoss = (() => {
187
+ if (rate !== 48000) return null;
188
+ const a = svf(); const b = svf(); const c = svf();
189
+ const w50 = Math.round(rate * 0.05);
190
+ let best = 0; let bestF = 0; let win = 0; let winF = 0;
191
+ const hist = new Float32Array(n); const histF = new Float32Array(n);
192
+ for (let i = 0; i < n; i++) {
193
+ const m = (L[i] + R[i]) * 0.5;
194
+ const f = c(b(a(m, 350, 0.707, 'highpass'), 350, 0.707, 'highpass'), 12000, 0.707, 'lowpass');
195
+ hist[i] = m * m; histF[i] = f * f; win += hist[i]; winF += histF[i];
196
+ if (i >= w50) { win -= hist[i - w50]; winF -= histF[i - w50]; }
197
+ if (win > best) best = win; if (winF > bestF) bestF = winF;
198
+ }
199
+ return best > 0 ? r(10 * Math.log10(best / Math.max(1e-20, bestF)), 1) : null;
200
+ })();
201
+ return {
202
+ file, seconds: r(seconds, 3), rate,
203
+ loudness: { lufs: r(full.lufs, 1), lra: r(full.lra, 1), truePeakDb: r(full.truePeak, 1), phoneLufs: r(phone.lufs, 1), phoneLossDb: full.lufs !== null && phone.lufs !== null ? r(full.lufs - phone.lufs, 1) : phoneRmsLoss },
204
+ samplePeakDb: r(db(peak), 2), rmsDb: r(db(rms), 2), crestDb: rms > 0 ? r(db(peak / rms), 1) : null,
205
+ clippedSamples: clipped, dcOffset: r(dc / Math.max(1, n), 5),
206
+ startMs: start === null ? null : Math.round(start * 1000), silentShare: r(silent / Math.max(1, wins.length), 3), gaps: gaps.slice(0, 20),
207
+ bands, under300Share: r(under300 / (total || 1), 3), centroidHz: cBot > 0 ? Math.round(cTop / cBot) : null,
208
+ stereo: { correlation: r(corr, 3), wide: stereoDiff > 1e-4, monoLossDb },
209
+ onsetsPerSecond: frames > 1 ? r(onsets / (frames * hopSeconds), 2) : 0,
210
+ narrowTones: lines.slice(0, 5),
211
+ arc,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * What makes a narrow tone a steady one, a hum rather than a note: it stands well over its neighbours, it is
217
+ * loud enough to hear, and it is there most of the time. A melody's brief high partial passes the first test
218
+ * and often the second, never the third; a tone under the level floor is prominent only because nothing else
219
+ * is near it.
220
+ */
221
+ export const STEADY = { prominenceDb: 25, levelDb: -60, presentShare: 0.6 };
222
+ /** The most prominent narrow tone that is steady by STEADY's three tests, or null. Older measurements without the two new numbers are never called steady. */
223
+ export function steadyTone(m) {
224
+ return (m.narrowTones ?? []).find((t) => t.prominenceDb > STEADY.prominenceDb && (t.levelDb ?? -Infinity) > STEADY.levelDb && (t.presentShare ?? 0) >= STEADY.presentShare) ?? null;
225
+ }
226
+
227
+ /** Plain-words warnings from a measurement: what a person will hear wrong. `kind` sfx | music | capture. */
228
+ export function warnings(m, kind = 'music') {
229
+ const w = [];
230
+ if (m.clippedSamples > 0) w.push(`${m.clippedSamples} clipped samples: it distorts; lower the gain before the limiter`);
231
+ if (m.samplePeakDb !== null && m.samplePeakDb < -40) w.push(`the loudest sample is ${m.samplePeakDb} dBFS: this is close to silence`);
232
+ if (Math.abs(m.dcOffset ?? 0) > 0.01) w.push(`DC offset ${m.dcOffset}: a click at every start and stop; high-pass it at 20 Hz`);
233
+ if (m.loudness.truePeakDb !== null && m.loudness.truePeakDb > -1) w.push(`true peak ${m.loudness.truePeakDb} dBTP: over -1 dBTP it can clip after mp3/aac encoding`);
234
+ if ((m.loudness.phoneLossDb ?? 0) > 10) w.push(`through a phone speaker it is ${m.loudness.phoneLossDb} dB quieter (${Math.round(m.under300Share * 100)}% of its energy is under 300 Hz, which phones barely play): give it something between 500 Hz and 4 kHz, a click, a knock, an upper harmonic`);
235
+ if (m.stereo.wide && m.stereo.correlation !== null && m.stereo.correlation < 0) w.push(`stereo correlation ${m.stereo.correlation}: the channels fight; folded to mono (one phone speaker) parts cancel`);
236
+ const monoWorst = Object.entries(m.stereo.monoLossDb).filter(([, v]) => v !== null && v < -3);
237
+ if (m.stereo.wide && monoWorst.length) w.push(`folding to mono loses ${monoWorst.map(([b, v]) => `${-v} dB of ${b}`).join(', ')}`);
238
+ if (kind === 'sfx' && m.startMs !== null && m.startMs > 15) w.push(`the sound starts ${m.startMs} ms late: a hit feels laggy; trim the head`);
239
+ if (kind !== 'sfx' && m.gaps.length) w.push(`${m.gaps.length} gap(s) of silence inside it (first at ${m.gaps[0].at} s, ${m.gaps[0].seconds} s long)`);
240
+ if (kind === 'music' && m.loudness.lufs !== null && (m.loudness.lufs > -9 || m.loudness.lufs < -24)) w.push(`integrated loudness ${m.loudness.lufs} LUFS is outside -24..-9: a web page or a game bed usually sits at -14 to -20`);
241
+ const hum = steadyTone(m);
242
+ if (hum && kind === 'capture') w.push(`a steady tone at ${hum.hz} Hz: there ${Math.round(hum.presentShare * 100)}% of the time at ${hum.levelDb} dBFS (${(hum.energyShare * 100).toFixed(1)}% of the energy), ${hum.prominenceDb} dB over its neighbours: a hum or a stuck voice?`);
243
+ return w;
244
+ }
245
+
246
+ /** A spectrogram over a waveform, one PNG, for looking at a sound (width x 2 x height). */
247
+ export function sheetPng(file, out, { width = 1200, height = 240 } = {}) {
248
+ const graph = `[0:a]aformat=channel_layouts=mono,showspectrumpic=s=${width}x${height}:legend=0:scale=log:fscale=log:color=intensity[s];[0:a]aformat=channel_layouts=mono,showwavespic=s=${width}x${Math.round(height / 2)}:colors=0xffcf5a[w];[s][w]vstack=inputs=2`;
249
+ const res = run('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-y', '-i', file, '-filter_complex', graph, '-frames:v', '1', out]);
250
+ if (res.code !== 0) throw new Error(`ffmpeg could not draw ${out}: ${res.stderr.trim().split('\n').pop()}`);
251
+ return out;
252
+ }