picopilot 0.2.0 → 0.3.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/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +239 -26
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +8 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +11 -5
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/music.d.ts +2 -1
- package/dist/commands/music.d.ts.map +1 -1
- package/dist/commands/music.js +209 -2
- package/dist/commands/music.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +39 -3
- package/dist/commands/pico8-env.d.ts.map +1 -1
- package/dist/commands/pico8-env.js +51 -4
- package/dist/commands/pico8-env.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +8 -2
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/sfx.d.ts.map +1 -1
- package/dist/commands/sfx.js +99 -11
- package/dist/commands/sfx.js.map +1 -1
- package/dist/engine/audio/analyze.d.ts +164 -0
- package/dist/engine/audio/analyze.d.ts.map +1 -0
- package/dist/engine/audio/analyze.js +512 -0
- package/dist/engine/audio/analyze.js.map +1 -0
- package/dist/engine/audio/index.d.ts +4 -2
- package/dist/engine/audio/index.d.ts.map +1 -1
- package/dist/engine/audio/index.js +4 -2
- package/dist/engine/audio/index.js.map +1 -1
- package/dist/engine/audio/score.d.ts +137 -0
- package/dist/engine/audio/score.d.ts.map +1 -0
- package/dist/engine/audio/score.js +420 -0
- package/dist/engine/audio/score.js.map +1 -0
- package/dist/engine/audio/section.d.ts +7 -0
- package/dist/engine/audio/section.d.ts.map +1 -1
- package/dist/engine/audio/section.js +17 -0
- package/dist/engine/audio/section.js.map +1 -1
- package/dist/engine/audio/sfx.d.ts +114 -12
- package/dist/engine/audio/sfx.d.ts.map +1 -1
- package/dist/engine/audio/sfx.js +236 -33
- package/dist/engine/audio/sfx.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +11 -5
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +24 -10
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
- package/dist/engine/scaffold/scaffold.js +5 -1
- package/dist/engine/scaffold/scaffold.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.test.ts +42 -1
- package/src/cli.ts +143 -37
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +294 -30
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +8 -3
- package/src/commands/gfx.ts +12 -9
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- package/src/commands/pico8-env.ts +71 -3
- package/src/commands/run.ts +8 -2
- package/src/commands/sfx.test.ts +55 -0
- package/src/commands/sfx.ts +118 -11
- package/src/engine/audio/analyze.test.ts +239 -0
- package/src/engine/audio/analyze.ts +664 -0
- package/src/engine/audio/index.ts +46 -1
- package/src/engine/audio/score.test.ts +402 -0
- package/src/engine/audio/score.ts +630 -0
- package/src/engine/audio/section.ts +19 -0
- package/src/engine/audio/sfx.test.ts +219 -4
- package/src/engine/audio/sfx.ts +326 -40
- package/src/engine/pico8/harness.test.ts +19 -0
- package/src/engine/pico8/harness.ts +29 -11
- package/src/engine/scaffold/scaffold.ts +5 -1
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +74 -184
- package/src/skills/picopilot-overview/SKILL.md +1 -1
package/src/commands/sfx.ts
CHANGED
|
@@ -4,9 +4,14 @@ import {Cli, z} from 'incur';
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
AudioMmlError,
|
|
7
|
+
clearSfxSlot,
|
|
8
|
+
formatSlots,
|
|
9
|
+
isFreeSfxRow,
|
|
7
10
|
mergeSfxRow,
|
|
8
11
|
mmlToSfxRow,
|
|
12
|
+
parseSlotList,
|
|
9
13
|
PITCH_MAX,
|
|
14
|
+
readSfxRows,
|
|
10
15
|
SFX_MAX_ROWS,
|
|
11
16
|
SFX_SLOT_COUNT,
|
|
12
17
|
} from '../engine/audio/index.js';
|
|
@@ -58,7 +63,7 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
58
63
|
|
|
59
64
|
sfx.command('from-mml', {
|
|
60
65
|
description:
|
|
61
|
-
'Transpile picopilot-MML text into one __sfx__ slot (0-63) and merge it into the cart (only that slot changes). picopilot-MML is TRACKER-ROW based: a length is a ROW COUNT (l4 = 4 rows), NOT a musical note value, and there is NO tempo/BPM (PICO-8 measures in ticks/row via `s<N>` speed). Notes carry per-note waveform (@0-7, @8-f = custom SFX-instrument), volume (v0-7), and effect (e0-7) that a score notation cannot express. SFX-level FILTER directives reach DESIGNED sounds (explosion/engine/pad): !dampen[2] (low-pass body), !reverb (echo tail), !noiz (@6 white noise), !buzz, !detune1/!detune2. Out-of-range pitch (below C0 / above D#5), >32 rows, and a bad filter level are refused loudly, never silently clamped.',
|
|
66
|
+
'Transpile picopilot-MML text into one __sfx__ slot (0-63) and merge it into the cart (only that slot changes). picopilot-MML is TRACKER-ROW based: a length is a ROW COUNT (l4 = 4 rows), NOT a musical note value, and there is NO tempo/BPM (PICO-8 measures in ticks/row via `s<N>` speed). Notes carry per-note waveform (@0-7, @8-f = custom SFX-instrument), volume (v0-7), and effect (e0-7) that a score notation cannot express. A held note (c8) is ONE note: fade in/out and drop land once per held note, not on every row (ADR-0019); shape its volume with an envelope v[3 2 2 1/0] (one digit per row, last value held, after / = release) and delay vibrato with ~N (vibrato from row N). For melodies over 32 rows use `music from-score`. SFX-level FILTER directives reach DESIGNED sounds (explosion/engine/pad): !dampen[2] (low-pass body), !reverb (echo tail), !noiz (@6 white noise), !buzz, !detune1/!detune2. Out-of-range pitch (below C0 / above D#5), >32 rows, and a bad filter level are refused loudly, never silently clamped.',
|
|
62
67
|
args: z.object({
|
|
63
68
|
slot: z.coerce
|
|
64
69
|
.number()
|
|
@@ -70,7 +75,7 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
70
75
|
.string()
|
|
71
76
|
.optional()
|
|
72
77
|
.describe(
|
|
73
|
-
'The picopilot-MML source (e.g. "s16 @1 v6 c d e f"). Omit and pass --file to read from a file instead. Modal state: @ waveform, v volume, e effect, o octave, l default length (all sticky). Durations are ROWS: c4 =
|
|
78
|
+
'The picopilot-MML source (e.g. "s16 @1 v6 c d e f"). Omit and pass --file to read from a file instead. Modal state: @ waveform, v volume or v[...] envelope, e effect, ~N delayed vibrato, o octave, l default length (all sticky). Durations are ROWS: c4 = one note held 4 rows. Loop with {..}; a lone { marks the pattern LEN. SFX filters (per-SFX, place anywhere): !dampen/!dampen2, !reverb, !noiz, !buzz, !detune1/!detune2 (e.g. "!dampen !reverb @6 v7 o3 e c o1 g" = an explosion).',
|
|
74
79
|
),
|
|
75
80
|
}),
|
|
76
81
|
options: z.object({
|
|
@@ -135,6 +140,11 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
135
140
|
'A DESIGNED explosion: DAMPEN body + REVERB tail on @6 noise',
|
|
136
141
|
args: {slot: 3, mml: 's7 !dampen2 !reverb @6 v7 o3 e c o1 g c'},
|
|
137
142
|
},
|
|
143
|
+
{
|
|
144
|
+
description:
|
|
145
|
+
'A soft lead note: held 8 rows with a decaying envelope and vibrato from row 4',
|
|
146
|
+
args: {slot: 6, mml: 's16 !dampen @0 ~4 v[3 3 2 2 2 1/1 0] o2 a8'},
|
|
147
|
+
},
|
|
138
148
|
{
|
|
139
149
|
description: 'Read a longer source from a file',
|
|
140
150
|
args: {slot: 1},
|
|
@@ -199,28 +209,40 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
199
209
|
}
|
|
200
210
|
: e.code === 'audio-mml-sfx-overflow'
|
|
201
211
|
? {
|
|
202
|
-
description: `One SFX holds at most ${SFX_MAX_ROWS} rows;
|
|
212
|
+
description: `One SFX holds at most ${SFX_MAX_ROWS} rows; for longer melodies:`,
|
|
203
213
|
commands: [
|
|
204
214
|
{
|
|
205
|
-
command: '
|
|
215
|
+
command: 'music from-score --file score.json',
|
|
206
216
|
description:
|
|
207
|
-
'
|
|
217
|
+
'Write each voice at any length; picopilot splits it into SFX + patterns.',
|
|
208
218
|
},
|
|
209
219
|
],
|
|
210
220
|
}
|
|
211
|
-
: e.code === 'audio-mml-
|
|
221
|
+
: e.code === 'audio-mml-tie-state-change'
|
|
212
222
|
? {
|
|
213
|
-
description:
|
|
223
|
+
description:
|
|
224
|
+
'A tie continues one held note; shape it instead:',
|
|
214
225
|
commands: [
|
|
215
226
|
{
|
|
216
|
-
command:
|
|
217
|
-
'sfx from-mml <slot> "!dampen[2] !reverb !noiz !buzz !detune1/!detune2 ..."',
|
|
227
|
+
command: 'sfx from-mml <slot> "v[3 2 2 1] c8"',
|
|
218
228
|
description:
|
|
219
|
-
'
|
|
229
|
+
'A volume envelope: one digit per row, last value held.',
|
|
220
230
|
},
|
|
221
231
|
],
|
|
222
232
|
}
|
|
223
|
-
:
|
|
233
|
+
: e.code === 'audio-mml-filter-level-out-of-range'
|
|
234
|
+
? {
|
|
235
|
+
description: 'The SFX filters + their valid levels:',
|
|
236
|
+
commands: [
|
|
237
|
+
{
|
|
238
|
+
command:
|
|
239
|
+
'sfx from-mml <slot> "!dampen[2] !reverb !noiz !buzz !detune1/!detune2 ..."',
|
|
240
|
+
description:
|
|
241
|
+
'!dampen has 2 levels (!dampen/!dampen2); !reverb/!noiz/!buzz are on/off; !detune needs 1 or 2.',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
: undefined;
|
|
224
246
|
return error({
|
|
225
247
|
code: e.code,
|
|
226
248
|
message: e.message,
|
|
@@ -282,6 +304,91 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
282
304
|
},
|
|
283
305
|
});
|
|
284
306
|
|
|
307
|
+
sfx.command('clear', {
|
|
308
|
+
description:
|
|
309
|
+
'Clear SFX slots back to EMPTY (no notes). Only the named slots change; every other slot and section stays byte-identical. Use it to free slots for `music from-score` or to remove a leftover song.',
|
|
310
|
+
args: z.object({
|
|
311
|
+
slots: z
|
|
312
|
+
.string()
|
|
313
|
+
.describe(
|
|
314
|
+
'The slots to clear: one slot ("5"), a range ("16-63"), or a list ("3,5,16-20").',
|
|
315
|
+
),
|
|
316
|
+
}),
|
|
317
|
+
options: z.object({
|
|
318
|
+
cart: z
|
|
319
|
+
.string()
|
|
320
|
+
.default('main.p8')
|
|
321
|
+
.describe(
|
|
322
|
+
'The .p8 cart to edit. Defaults to main.p8 in the current folder.',
|
|
323
|
+
),
|
|
324
|
+
}),
|
|
325
|
+
output: z.object({
|
|
326
|
+
cart: z
|
|
327
|
+
.string()
|
|
328
|
+
.describe('The absolute path of the cart that was written.'),
|
|
329
|
+
cleared: z
|
|
330
|
+
.array(z.number())
|
|
331
|
+
.describe('The slots that held notes and are now empty.'),
|
|
332
|
+
alreadyEmpty: z
|
|
333
|
+
.array(z.number())
|
|
334
|
+
.describe(
|
|
335
|
+
'The named slots that had no notes already (left byte-identical).',
|
|
336
|
+
),
|
|
337
|
+
}),
|
|
338
|
+
examples: [
|
|
339
|
+
{description: 'Clear slot 11', args: {slots: '11'}},
|
|
340
|
+
{
|
|
341
|
+
description: 'Clear a whole music range of a cart',
|
|
342
|
+
args: {slots: '16-63'},
|
|
343
|
+
options: {cart: 'game.p8'},
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
run({args, options, error, ok}) {
|
|
347
|
+
const slots = parseSlotList(args.slots);
|
|
348
|
+
if (slots === undefined) {
|
|
349
|
+
return error({
|
|
350
|
+
code: 'bad-slot-list',
|
|
351
|
+
message: `"${args.slots}" is not a slot list within 0-${SFX_SLOT_COUNT - 1}: use "5", "16-63" or "3,5,16-20".`,
|
|
352
|
+
exitCode: 1,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const cartPath = resolvePath(options.cart);
|
|
356
|
+
const loaded = loadCart(cartPath);
|
|
357
|
+
if (!loaded.ok) {
|
|
358
|
+
return error({code: loaded.code, message: loaded.message, exitCode: 1});
|
|
359
|
+
}
|
|
360
|
+
const before = readSfxRows(loaded.cart);
|
|
361
|
+
const cleared: number[] = [];
|
|
362
|
+
const alreadyEmpty: number[] = [];
|
|
363
|
+
for (const slot of slots) {
|
|
364
|
+
// A note-less slot is already empty: leave its bytes (header) alone.
|
|
365
|
+
if (isFreeSfxRow(before[slot])) {
|
|
366
|
+
alreadyEmpty.push(slot);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
cleared.push(slot);
|
|
370
|
+
clearSfxSlot(loaded.cart, slot);
|
|
371
|
+
}
|
|
372
|
+
writeFileSync(cartPath, loaded.cart.serialize());
|
|
373
|
+
return ok(
|
|
374
|
+
{cart: cartPath, cleared, alreadyEmpty},
|
|
375
|
+
cleared.length > 0
|
|
376
|
+
? {
|
|
377
|
+
cta: {
|
|
378
|
+
description: `Cleared SFX ${formatSlots(cleared)}. If __music__ still references them, those channels are now silent:`,
|
|
379
|
+
commands: [
|
|
380
|
+
{
|
|
381
|
+
command: 'verify',
|
|
382
|
+
description: 'Static gate: the cart still parses.',
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
: undefined,
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
285
392
|
return sfx;
|
|
286
393
|
}
|
|
287
394
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
analyzeAudio,
|
|
5
|
+
AudioAnalyzeError,
|
|
6
|
+
decodeWav,
|
|
7
|
+
type DecodedWav,
|
|
8
|
+
encodeWav16,
|
|
9
|
+
percentile,
|
|
10
|
+
sparkline,
|
|
11
|
+
} from './analyze.js';
|
|
12
|
+
|
|
13
|
+
const RATE = 22050;
|
|
14
|
+
|
|
15
|
+
/** Builds a mono signal from per-second generator `f(t)`. */
|
|
16
|
+
function signal(seconds: number, f: (t: number) => number): DecodedWav {
|
|
17
|
+
const samples = new Float32Array(Math.round(seconds * RATE));
|
|
18
|
+
for (let i = 0; i < samples.length; i++) samples[i] = f(i / RATE);
|
|
19
|
+
return {sampleRate: RATE, channels: 1, samples};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const sine = (hz: number, amp: number) => (t: number) =>
|
|
23
|
+
amp * Math.sin(2 * Math.PI * hz * t);
|
|
24
|
+
|
|
25
|
+
/** A deterministic "melody": a new pitch every `noteSeconds`, from a seeded sequence. */
|
|
26
|
+
function melody(seed: number, noteSeconds: number) {
|
|
27
|
+
const pitches = [220, 247, 262, 294, 330, 349, 392, 440];
|
|
28
|
+
return (t: number) => {
|
|
29
|
+
const n = Math.floor(t / noteSeconds);
|
|
30
|
+
const x = Math.sin((n + 1) * 12.9898 * seed) * 43758.5453;
|
|
31
|
+
const hz = pitches[Math.floor((x - Math.floor(x)) * pitches.length)]!;
|
|
32
|
+
return 0.1 * Math.sin(2 * Math.PI * hz * t);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('decodeWav / encodeWav16', () => {
|
|
37
|
+
it('round-trips 16-bit mono PCM', () => {
|
|
38
|
+
const wav = signal(0.5, sine(440, 0.5));
|
|
39
|
+
const back = decodeWav(encodeWav16(wav.samples, RATE));
|
|
40
|
+
expect(back.sampleRate).toBe(RATE);
|
|
41
|
+
expect(back.channels).toBe(1);
|
|
42
|
+
expect(back.samples).toHaveLength(wav.samples.length);
|
|
43
|
+
expect(back.samples[100]).toBeCloseTo(wav.samples[100]!, 3);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('downmixes stereo and reads 8-bit', () => {
|
|
47
|
+
// Hand-build a 2-channel 8-bit WAV with 2 frames: (255,1) and (128,128).
|
|
48
|
+
const data = [255, 1, 128, 128];
|
|
49
|
+
const buf = new Uint8Array(44 + data.length);
|
|
50
|
+
const v = new DataView(buf.buffer);
|
|
51
|
+
buf.set(
|
|
52
|
+
[...'RIFF'].map((c) => c.charCodeAt(0)),
|
|
53
|
+
0,
|
|
54
|
+
);
|
|
55
|
+
v.setUint32(4, 36 + data.length, true);
|
|
56
|
+
buf.set(
|
|
57
|
+
[...'WAVEfmt '].map((c) => c.charCodeAt(0)),
|
|
58
|
+
8,
|
|
59
|
+
);
|
|
60
|
+
v.setUint32(16, 16, true);
|
|
61
|
+
v.setUint16(20, 1, true);
|
|
62
|
+
v.setUint16(22, 2, true);
|
|
63
|
+
v.setUint32(24, 8000, true);
|
|
64
|
+
v.setUint32(28, 16000, true);
|
|
65
|
+
v.setUint16(32, 2, true);
|
|
66
|
+
v.setUint16(34, 8, true);
|
|
67
|
+
buf.set(
|
|
68
|
+
[...'data'].map((c) => c.charCodeAt(0)),
|
|
69
|
+
36,
|
|
70
|
+
);
|
|
71
|
+
v.setUint32(40, data.length, true);
|
|
72
|
+
buf.set(data, 44);
|
|
73
|
+
const wav = decodeWav(buf);
|
|
74
|
+
expect(wav.channels).toBe(2);
|
|
75
|
+
expect(wav.samples).toHaveLength(2);
|
|
76
|
+
expect(wav.samples[0]).toBeCloseTo(0, 1); // (+0.99 + -0.99) / 2
|
|
77
|
+
expect(wav.samples[1]).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('refuses something that is not a WAV', () => {
|
|
81
|
+
expect(() =>
|
|
82
|
+
decodeWav(new TextEncoder().encode('hello world, not a wav')),
|
|
83
|
+
).toThrow(AudioAnalyzeError);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
describe('analyzeAudio: loudness, brightness, dynamics', () => {
|
|
88
|
+
it('a steady 440 Hz sine: centroid ~440 Hz, no high energy, flat, and its RMS', () => {
|
|
89
|
+
const a = analyzeAudio(signal(6, sine(440, 0.1)));
|
|
90
|
+
expect(a.skippedSeconds).toBe(1);
|
|
91
|
+
expect(a.analyzedSeconds).toBeCloseTo(5, 1);
|
|
92
|
+
expect(a.loudness.meanRms).toBeCloseTo(0.1 / Math.SQRT2, 2);
|
|
93
|
+
expect(Math.abs(a.brightness.centroidHz - 440)).toBeLessThan(30);
|
|
94
|
+
expect(a.brightness.above2kHzPercent).toBeLessThan(0.1);
|
|
95
|
+
expect(a.dynamicRangeDb).toBeLessThan(0.5);
|
|
96
|
+
const codes = a.warnings.map((w) => w.code);
|
|
97
|
+
expect(codes).toContain('monotonous');
|
|
98
|
+
expect(codes).not.toContain('loud');
|
|
99
|
+
expect(codes).not.toContain('shrill');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('a loud, bright square wave is loud and shrill', () => {
|
|
103
|
+
const a = analyzeAudio(
|
|
104
|
+
signal(4, (t) => (Math.sin(2 * Math.PI * 1500 * t) >= 0 ? 0.3 : -0.3)),
|
|
105
|
+
);
|
|
106
|
+
const codes = a.warnings.map((w) => w.code);
|
|
107
|
+
expect(codes).toContain('loud');
|
|
108
|
+
expect(codes).toContain('shrill');
|
|
109
|
+
expect(a.brightness.above2kHzPercent).toBeGreaterThan(8);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('the first second (PICO-8 startup) is not measured', () => {
|
|
113
|
+
const a = analyzeAudio(
|
|
114
|
+
signal(4, (t) => (t < 1 ? sine(440, 0.9)(t) : sine(440, 0.05)(t))),
|
|
115
|
+
);
|
|
116
|
+
expect(a.loudness.peakFrameRms).toBeLessThan(0.05);
|
|
117
|
+
expect(
|
|
118
|
+
analyzeAudio(signal(4, sine(440, 0.05)), {skipSeconds: 0}).skippedSeconds,
|
|
119
|
+
).toBe(0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('a short burst over a quiet bed is a spike, located in time', () => {
|
|
123
|
+
const a = analyzeAudio(
|
|
124
|
+
signal(6, (t) =>
|
|
125
|
+
t > 3 && t < 3.3 ? sine(330, 0.5)(t) : sine(330, 0.05)(t),
|
|
126
|
+
),
|
|
127
|
+
);
|
|
128
|
+
expect(a.warnings.map((w) => w.code)).toContain('spikes');
|
|
129
|
+
expect(a.loudness.peakFrameAtSeconds).toBeGreaterThan(2.9);
|
|
130
|
+
expect(a.loudness.peakFrameAtSeconds).toBeLessThan(3.3);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('quiet and loud passages give a dynamic range; silence is excluded from it', () => {
|
|
134
|
+
const a = analyzeAudio(
|
|
135
|
+
signal(9, (t) => {
|
|
136
|
+
const phase = Math.floor(t) % 3;
|
|
137
|
+
return phase === 0 ? 0 : sine(330, phase === 1 ? 0.02 : 0.1)(t);
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
expect(a.dynamicRangeDb).toBeGreaterThan(10);
|
|
141
|
+
expect(a.dynamicRangeDb).toBeLessThan(16);
|
|
142
|
+
expect(a.loudness.silentPercent).toBeGreaterThan(20);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('near-silence is reported as such (and nothing else)', () => {
|
|
146
|
+
const a = analyzeAudio(signal(3, () => 0));
|
|
147
|
+
expect(a.warnings.map((w) => w.code)).toEqual(['near-silent']);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('a loudness profile per half second, with a sparkline', () => {
|
|
151
|
+
const a = analyzeAudio(signal(5, (t) => sine(440, t < 3 ? 0.02 : 0.2)(t)));
|
|
152
|
+
expect(a.profile.stepSeconds).toBe(0.5);
|
|
153
|
+
expect(a.profile.rms).toHaveLength(8);
|
|
154
|
+
expect(a.profile.sparkline).toHaveLength(8);
|
|
155
|
+
expect(a.profile.sparkline.at(-1)).toBe('█');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('too short for one frame is a structured error', () => {
|
|
159
|
+
expect(() => analyzeAudio(signal(0.05, sine(440, 0.1)))).toThrow(
|
|
160
|
+
AudioAnalyzeError,
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe('analyzeAudio: repetition (loop) length', () => {
|
|
166
|
+
it('finds a 5 s loop repeated three times, and calls it repetitive', () => {
|
|
167
|
+
const loop = melody(7, 0.25);
|
|
168
|
+
const a = analyzeAudio(
|
|
169
|
+
signal(16, (t) => loop(t % 5)),
|
|
170
|
+
{skipSeconds: 0},
|
|
171
|
+
);
|
|
172
|
+
expect(a.loop.seconds).not.toBeNull();
|
|
173
|
+
expect(Math.abs(a.loop.seconds! - 5)).toBeLessThan(0.15);
|
|
174
|
+
expect(a.warnings.map((w) => w.code)).toContain('repetitive');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('picks the loop, not a multiple of it', () => {
|
|
178
|
+
const loop = melody(3, 0.2);
|
|
179
|
+
const a = analyzeAudio(
|
|
180
|
+
signal(25, (t) => loop(t % 4)),
|
|
181
|
+
{skipSeconds: 0},
|
|
182
|
+
);
|
|
183
|
+
expect(Math.abs(a.loop.seconds! - 4)).toBeLessThan(0.15);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('says why when no repetition is visible', () => {
|
|
187
|
+
const a = analyzeAudio(signal(12, melody(11, 0.25)), {skipSeconds: 0});
|
|
188
|
+
expect(a.loop.seconds).toBeNull();
|
|
189
|
+
expect((a.loop as {reason: string}).reason).toMatch(/at least 2x/);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('analyzeAudio: against a reference SFX', () => {
|
|
194
|
+
it('reports the music level relative to the SFX and warns when it is not under it', () => {
|
|
195
|
+
const sfx = signal(1, sine(600, 0.2));
|
|
196
|
+
const quiet = analyzeAudio(signal(4, sine(330, 0.05)), {against: sfx});
|
|
197
|
+
expect(quiet.against!.musicOverReferenceDb).toBeLessThan(-10);
|
|
198
|
+
expect(quiet.warnings.map((w) => w.code)).not.toContain('music-over-sfx');
|
|
199
|
+
const loud = analyzeAudio(signal(4, sine(330, 0.2)), {against: sfx});
|
|
200
|
+
expect(loud.warnings.map((w) => w.code)).toContain('music-over-sfx');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe('helpers', () => {
|
|
205
|
+
it('percentile interpolates', () => {
|
|
206
|
+
expect(percentile([1, 2, 3, 4, 5], 50)).toBe(3);
|
|
207
|
+
expect(percentile([0, 10], 90)).toBe(9);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('sparkline scales to the loudest value and blanks silence', () => {
|
|
211
|
+
expect(sparkline([0, 0.5, 1])).toBe(' ▅█');
|
|
212
|
+
});
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
describe('analyzeAudio: review fixes', () => {
|
|
216
|
+
it('a silent --against reference is refused, not a false warning', () => {
|
|
217
|
+
expect(() =>
|
|
218
|
+
analyzeAudio(signal(4, sine(330, 0.05)), {against: signal(1, () => 0)}),
|
|
219
|
+
).toThrow(AudioAnalyzeError);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('kind sfx keeps only the checks that apply to a one-shot', () => {
|
|
223
|
+
// A short, loud blip with a silent tail: music rules would flag it.
|
|
224
|
+
const blip = signal(2, (t) => (t < 0.3 ? sine(1500, 0.6)(t) : 0));
|
|
225
|
+
const music = analyzeAudio(blip, {skipSeconds: 0}).warnings.map(
|
|
226
|
+
(w) => w.code,
|
|
227
|
+
);
|
|
228
|
+
expect(music.length).toBeGreaterThan(0);
|
|
229
|
+
const sfx = analyzeAudio(blip, {skipSeconds: 0, kind: 'sfx'}).warnings;
|
|
230
|
+
expect(
|
|
231
|
+
sfx.every((w) => w.code === 'near-silent' || w.code === 'near-clipping'),
|
|
232
|
+
).toBe(true);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('a truncated fmt chunk is a structured bad-wav error', () => {
|
|
236
|
+
const ok = encodeWav16(new Float32Array(10), RATE);
|
|
237
|
+
expect(() => decodeWav(ok.slice(0, 24))).toThrow(AudioAnalyzeError);
|
|
238
|
+
});
|
|
239
|
+
});
|