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
|
@@ -10,6 +10,7 @@ import {homedir, tmpdir} from 'node:os';
|
|
|
10
10
|
import {join} from 'node:path';
|
|
11
11
|
import {Cli} from 'incur';
|
|
12
12
|
import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
13
|
+
import {encodeWav16} from '../engine/audio/analyze.js';
|
|
13
14
|
import {mmlToSfxRow} from '../engine/audio/sfx.js';
|
|
14
15
|
import {mergeSfxRow} from '../engine/audio/section.js';
|
|
15
16
|
import {Cart} from '../engine/cart/index.js';
|
|
@@ -20,7 +21,12 @@ import type {
|
|
|
20
21
|
RunOptions,
|
|
21
22
|
} from '../engine/pico8/index.js';
|
|
22
23
|
import {displayUnavailable, pico8NotFound} from '../engine/pico8/index.js';
|
|
23
|
-
import {
|
|
24
|
+
import {
|
|
25
|
+
type Pico8AdapterFactory,
|
|
26
|
+
RECORD_STARTUP_MARGIN_MS,
|
|
27
|
+
registerAudio,
|
|
28
|
+
resolveRecordBackstop,
|
|
29
|
+
} from './audio.js';
|
|
24
30
|
|
|
25
31
|
const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
|
|
26
32
|
|
|
@@ -353,3 +359,166 @@ describe('audio record/render: headless (ADR-0017)', () => {
|
|
|
353
359
|
expect(out.pico8LogTail).toContain('main.lua');
|
|
354
360
|
});
|
|
355
361
|
});
|
|
362
|
+
|
|
363
|
+
describe('audio record/render: the backstop follows the record window', () => {
|
|
364
|
+
it('defaults to the window plus a startup margin (a 24 s window is not killed at 30 s)', async () => {
|
|
365
|
+
for (const argv of [
|
|
366
|
+
[
|
|
367
|
+
'audio',
|
|
368
|
+
'render',
|
|
369
|
+
cartPath,
|
|
370
|
+
'--pattern',
|
|
371
|
+
'0',
|
|
372
|
+
'--seconds',
|
|
373
|
+
'24',
|
|
374
|
+
'--json',
|
|
375
|
+
],
|
|
376
|
+
['audio', 'record', cartPath, '--seconds', '24', '--json'],
|
|
377
|
+
]) {
|
|
378
|
+
const seen: {record?: RecordOptions} = {};
|
|
379
|
+
const {exitCode} = await runAudio(
|
|
380
|
+
() => stubAdapter(recorded(undefined), seen),
|
|
381
|
+
argv,
|
|
382
|
+
);
|
|
383
|
+
expect(exitCode).toBe(0);
|
|
384
|
+
expect(seen.record?.backstopMs).toBe(24_000 + RECORD_STARTUP_MARGIN_MS);
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('an explicit backstop that cannot fit the window is refused up front', async () => {
|
|
389
|
+
const seen: {record?: RecordOptions} = {};
|
|
390
|
+
const {stdout, exitCode} = await runAudio(
|
|
391
|
+
() => stubAdapter(recorded(undefined), seen),
|
|
392
|
+
[
|
|
393
|
+
'audio',
|
|
394
|
+
'render',
|
|
395
|
+
cartPath,
|
|
396
|
+
'--pattern',
|
|
397
|
+
'0',
|
|
398
|
+
'--seconds',
|
|
399
|
+
'28',
|
|
400
|
+
'--backstop-ms',
|
|
401
|
+
'30000',
|
|
402
|
+
'--json',
|
|
403
|
+
],
|
|
404
|
+
);
|
|
405
|
+
expect(exitCode).not.toBe(0);
|
|
406
|
+
expect(stdout).toContain('audio-backstop-too-small');
|
|
407
|
+
expect(stdout).toContain('33000');
|
|
408
|
+
expect(seen.record).toBeUndefined(); // PICO-8 was never launched
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it('an explicit backstop with room for startup is honoured', async () => {
|
|
412
|
+
const seen: {record?: RecordOptions} = {};
|
|
413
|
+
await runAudio(
|
|
414
|
+
() => stubAdapter(recorded(undefined), seen),
|
|
415
|
+
[
|
|
416
|
+
'audio',
|
|
417
|
+
'record',
|
|
418
|
+
cartPath,
|
|
419
|
+
'--seconds',
|
|
420
|
+
'4',
|
|
421
|
+
'--backstop-ms',
|
|
422
|
+
'12000',
|
|
423
|
+
'--json',
|
|
424
|
+
],
|
|
425
|
+
);
|
|
426
|
+
expect(seen.record?.backstopMs).toBe(12_000);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it('resolveRecordBackstop', () => {
|
|
430
|
+
expect(resolveRecordBackstop(8, undefined)).toEqual({ok: true, ms: 38_000});
|
|
431
|
+
expect(resolveRecordBackstop(8, 13_000)).toEqual({ok: true, ms: 13_000});
|
|
432
|
+
expect(resolveRecordBackstop(8, 12_999).ok).toBe(false);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
/** Writes a WAV of `seconds` of a sine at `amp` to `path`. */
|
|
437
|
+
function writeSineWav(path: string, seconds: number, hz: number, amp: number) {
|
|
438
|
+
const samples = new Float32Array(Math.round(seconds * 22050));
|
|
439
|
+
for (let i = 0; i < samples.length; i++) {
|
|
440
|
+
samples[i] = amp * Math.sin((2 * Math.PI * hz * i) / 22050);
|
|
441
|
+
}
|
|
442
|
+
writeFileSync(path, encodeWav16(samples, 22050));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
describe('audio analyze (no PICO-8 needed)', () => {
|
|
446
|
+
it('reports loudness/brightness/dynamics and plain-language warnings', async () => {
|
|
447
|
+
const wav = join(dir, 'm.wav');
|
|
448
|
+
writeSineWav(wav, 4, 440, 0.3);
|
|
449
|
+
const {stdout, exitCode} = await runAudio(
|
|
450
|
+
() => stubAdapter(pico8NotFound()),
|
|
451
|
+
['audio', 'analyze', wav, '--json'],
|
|
452
|
+
);
|
|
453
|
+
expect(exitCode).toBe(0);
|
|
454
|
+
const out = JSON.parse(stdout);
|
|
455
|
+
expect(out.skippedSeconds).toBe(1);
|
|
456
|
+
expect(out.loudness.meanRms).toBeCloseTo(0.212, 2);
|
|
457
|
+
expect(Math.abs(out.brightness.centroidHz - 440)).toBeLessThan(30);
|
|
458
|
+
const codes = out.warnings.map((w: {code: string}) => w.code);
|
|
459
|
+
expect(codes).toContain('loud');
|
|
460
|
+
expect(codes).toContain('monotonous');
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
it('--against compares the music with a reference SFX', async () => {
|
|
464
|
+
const music = join(dir, 'm.wav');
|
|
465
|
+
const sfx = join(dir, 's.wav');
|
|
466
|
+
writeSineWav(music, 4, 330, 0.05);
|
|
467
|
+
writeSineWav(sfx, 1, 660, 0.3);
|
|
468
|
+
const {stdout} = await runAudio(
|
|
469
|
+
() => stubAdapter(pico8NotFound()),
|
|
470
|
+
['audio', 'analyze', music, '--against', sfx, '--json'],
|
|
471
|
+
);
|
|
472
|
+
expect(JSON.parse(stdout).against.musicOverReferenceDb).toBeLessThan(-10);
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it('a missing or bad WAV is a structured error', async () => {
|
|
476
|
+
const bad = join(dir, 'bad.wav');
|
|
477
|
+
writeFileSync(bad, 'not a wav');
|
|
478
|
+
for (const p of [bad, join(dir, 'missing.wav')]) {
|
|
479
|
+
const {stdout, exitCode} = await runAudio(
|
|
480
|
+
() => stubAdapter(pico8NotFound()),
|
|
481
|
+
['audio', 'analyze', p, '--json'],
|
|
482
|
+
);
|
|
483
|
+
expect(exitCode).not.toBe(0);
|
|
484
|
+
expect(stdout).toContain('audio-analyze-bad-wav');
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('render --analyze attaches the analysis of the captured WAV', async () => {
|
|
489
|
+
const wav = join(dir, 'cap.wav');
|
|
490
|
+
writeSineWav(wav, 3, 440, 0.05);
|
|
491
|
+
const {stdout, exitCode} = await runAudio(
|
|
492
|
+
() => stubAdapter(recorded(wav)),
|
|
493
|
+
['audio', 'render', cartPath, '--sfx', '0', '--analyze', '--json'],
|
|
494
|
+
);
|
|
495
|
+
expect(exitCode).toBe(0);
|
|
496
|
+
const out = JSON.parse(stdout);
|
|
497
|
+
expect(out.captured).toBe(true);
|
|
498
|
+
expect(out.analysis.loudness.meanRms).toBeCloseTo(0.035, 2);
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('without --analyze there is no analysis', async () => {
|
|
502
|
+
const wav = join(dir, 'cap.wav');
|
|
503
|
+
writeSineWav(wav, 3, 440, 0.05);
|
|
504
|
+
const {stdout} = await runAudio(
|
|
505
|
+
() => stubAdapter(recorded(wav)),
|
|
506
|
+
['audio', 'record', cartPath, '--json'],
|
|
507
|
+
);
|
|
508
|
+
expect(JSON.parse(stdout).analysis).toBeUndefined();
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
describe('render --analyze on an SFX target', () => {
|
|
513
|
+
it('analyzes the whole capture (no 1 s skip) with sfx rules', async () => {
|
|
514
|
+
const wav = join(dir, 'blip.wav');
|
|
515
|
+
writeSineWav(wav, 1.5, 440, 0.3);
|
|
516
|
+
const {stdout} = await runAudio(
|
|
517
|
+
() => stubAdapter(recorded(wav)),
|
|
518
|
+
['audio', 'render', cartPath, '--sfx', '0', '--analyze', '--json'],
|
|
519
|
+
);
|
|
520
|
+
const out = JSON.parse(stdout);
|
|
521
|
+
expect(out.analysis.skippedSeconds).toBe(0);
|
|
522
|
+
expect(out.analysis.warnings).toEqual([]);
|
|
523
|
+
});
|
|
524
|
+
});
|
package/src/commands/audio.ts
CHANGED
|
@@ -9,6 +9,13 @@ import {tmpdir} from 'node:os';
|
|
|
9
9
|
import {isAbsolute, join, resolve} from 'node:path';
|
|
10
10
|
import {Cli, z} from 'incur';
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
analyzeAudio,
|
|
14
|
+
type AudioAnalysis,
|
|
15
|
+
AudioAnalyzeError,
|
|
16
|
+
decodeWav,
|
|
17
|
+
THRESHOLDS,
|
|
18
|
+
} from '../engine/audio/index.js';
|
|
12
19
|
import {
|
|
13
20
|
buildRecordHarness,
|
|
14
21
|
buildRenderHarness,
|
|
@@ -20,11 +27,12 @@ import {
|
|
|
20
27
|
import {makeReadInclude} from './cart-includes.js';
|
|
21
28
|
import {
|
|
22
29
|
createShellAdapterFactory,
|
|
23
|
-
|
|
30
|
+
DISPLAY_OPTION,
|
|
24
31
|
displayUnavailableError,
|
|
25
32
|
PICO8_ENV,
|
|
26
33
|
type Pico8AdapterFactory,
|
|
27
34
|
pico8NotFoundError,
|
|
35
|
+
resolveCommandDisplay,
|
|
28
36
|
} from './pico8-env.js';
|
|
29
37
|
|
|
30
38
|
/**
|
|
@@ -38,11 +46,48 @@ export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
|
38
46
|
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
39
47
|
|
|
40
48
|
/**
|
|
41
|
-
* The
|
|
42
|
-
*
|
|
43
|
-
*
|
|
49
|
+
* The startup margin added to the record window for the default backstop. A
|
|
50
|
+
* recording is real time, so the backstop must cover PICO-8 startup (a virtual
|
|
51
|
+
* display, loading the cart) PLUS the whole record window. A fixed 30 s backstop
|
|
52
|
+
* used to kill any window of ~24 s or more before `audio_end`.
|
|
53
|
+
*/
|
|
54
|
+
export const RECORD_STARTUP_MARGIN_MS = 30_000;
|
|
55
|
+
|
|
56
|
+
/** The least startup time an explicit backstop must leave beyond the window. */
|
|
57
|
+
export const RECORD_MIN_STARTUP_MS = 5_000;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The backstop for a record window: the explicit `--backstop-ms` when it leaves
|
|
61
|
+
* at least {@link RECORD_MIN_STARTUP_MS} for startup, else a refusal; or, when
|
|
62
|
+
* none is given, the window plus {@link RECORD_STARTUP_MARGIN_MS}.
|
|
44
63
|
*/
|
|
45
|
-
|
|
64
|
+
export function resolveRecordBackstop(
|
|
65
|
+
seconds: number,
|
|
66
|
+
explicit: number | undefined,
|
|
67
|
+
): {ok: true; ms: number} | {ok: false; message: string} {
|
|
68
|
+
const windowMs = Math.ceil(seconds * 1000);
|
|
69
|
+
if (explicit === undefined) {
|
|
70
|
+
return {ok: true, ms: windowMs + RECORD_STARTUP_MARGIN_MS};
|
|
71
|
+
}
|
|
72
|
+
const least = windowMs + RECORD_MIN_STARTUP_MS;
|
|
73
|
+
if (explicit < least) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
message: `--backstop-ms ${explicit} is too small for a ${seconds} s record window: PICO-8 would be killed before the recording ends (it also needs a few seconds to start). Use at least ${least}, or omit --backstop-ms (the default is the window + ${RECORD_STARTUP_MARGIN_MS / 1000} s).`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {ok: true, ms: explicit};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The `--backstop-ms` option both audio subcommands share. */
|
|
83
|
+
const BACKSTOP_OPTION = z
|
|
84
|
+
.number()
|
|
85
|
+
.int()
|
|
86
|
+
.positive()
|
|
87
|
+
.optional()
|
|
88
|
+
.describe(
|
|
89
|
+
`Safety backstop: kill PICO-8 after this many ms if it never signals. Default: the record window + ${RECORD_STARTUP_MARGIN_MS / 1000} s of startup margin. An explicit value smaller than the window + ${RECORD_MIN_STARTUP_MS / 1000} s is refused.`,
|
|
90
|
+
);
|
|
46
91
|
|
|
47
92
|
/** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
|
|
48
93
|
const HONEST_CAVEAT =
|
|
@@ -74,6 +119,98 @@ function chooseWavDir(given: string | undefined): string {
|
|
|
74
119
|
/** The env schema both audio subcommands share (locates the binary). */
|
|
75
120
|
const AUDIO_ENV = PICO8_ENV;
|
|
76
121
|
|
|
122
|
+
/** The structured analysis of a recording (`audio analyze`, `--analyze`). */
|
|
123
|
+
const ANALYSIS_OUTPUT = z.object({
|
|
124
|
+
sampleRate: z.number(),
|
|
125
|
+
durationSeconds: z.number(),
|
|
126
|
+
skippedSeconds: z
|
|
127
|
+
.number()
|
|
128
|
+
.describe('Seconds skipped at the start (PICO-8 startup).'),
|
|
129
|
+
analyzedSeconds: z.number(),
|
|
130
|
+
loudness: z.object({
|
|
131
|
+
meanRms: z
|
|
132
|
+
.number()
|
|
133
|
+
.describe(
|
|
134
|
+
`Mean RMS of 2048-sample frames (0-1). Accepted game music: 0.07-0.10; over ${THRESHOLDS.loudMeanRms} warns.`,
|
|
135
|
+
),
|
|
136
|
+
medianRms: z.number(),
|
|
137
|
+
peakFrameRms: z.number().describe('The loudest frame.'),
|
|
138
|
+
peakFrameAtSeconds: z
|
|
139
|
+
.number()
|
|
140
|
+
.describe('When the loudest frame starts (s from file start).'),
|
|
141
|
+
peakSample: z.number().describe('99.9th-percentile absolute sample.'),
|
|
142
|
+
silentPercent: z.number().describe('Share of silent frames (%).'),
|
|
143
|
+
}),
|
|
144
|
+
brightness: z.object({
|
|
145
|
+
centroidHz: z
|
|
146
|
+
.number()
|
|
147
|
+
.describe(
|
|
148
|
+
`Median spectral centroid (Hz). Accepted: under 500; over ${THRESHOLDS.shrillCentroidHz} is shrill.`,
|
|
149
|
+
),
|
|
150
|
+
above2kHzPercent: z
|
|
151
|
+
.number()
|
|
152
|
+
.describe(
|
|
153
|
+
`Share of energy above 2 kHz (%). Accepted: about 0.2; over ${THRESHOLDS.shrillAbove2kHzPercent} is shrill.`,
|
|
154
|
+
),
|
|
155
|
+
}),
|
|
156
|
+
dynamicRangeDb: z
|
|
157
|
+
.number()
|
|
158
|
+
.describe(
|
|
159
|
+
`p90 - p10 of frame level, silence excluded (dB). Accepted: 7-8; under ${THRESHOLDS.flatRangeDb} is monotonous.`,
|
|
160
|
+
),
|
|
161
|
+
profile: z.object({
|
|
162
|
+
stepSeconds: z.number(),
|
|
163
|
+
rms: z.array(z.number()),
|
|
164
|
+
sparkline: z.string().describe('The loudness profile as a bar chart.'),
|
|
165
|
+
}),
|
|
166
|
+
loop: z
|
|
167
|
+
.object({
|
|
168
|
+
seconds: z.number().nullable(),
|
|
169
|
+
confidence: z.number().optional(),
|
|
170
|
+
reason: z.string().optional(),
|
|
171
|
+
})
|
|
172
|
+
.describe(
|
|
173
|
+
'The repetition length (needs the loop to appear twice in the recording).',
|
|
174
|
+
),
|
|
175
|
+
against: z
|
|
176
|
+
.object({referenceRms: z.number(), musicOverReferenceDb: z.number()})
|
|
177
|
+
.optional(),
|
|
178
|
+
warnings: z
|
|
179
|
+
.array(z.object({code: z.string(), message: z.string()}))
|
|
180
|
+
.describe('Plain-language findings; empty = nothing flagged.'),
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
/** The `--analyze` option of record/render. */
|
|
184
|
+
const ANALYZE_OPTION = z
|
|
185
|
+
.boolean()
|
|
186
|
+
.default(false)
|
|
187
|
+
.describe(
|
|
188
|
+
'Also analyze the captured WAV (loudness, brightness, dynamic range, profile, loop length, warnings), as `audio analyze` does.',
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Analyzes a captured WAV for `--analyze`, or explains why it could not. Never
|
|
193
|
+
* fails the recording itself.
|
|
194
|
+
*/
|
|
195
|
+
function analyzeCapture(
|
|
196
|
+
wavPath: string | undefined,
|
|
197
|
+
kind: 'music' | 'sfx',
|
|
198
|
+
): {analysis: AudioAnalysis} | {analysisError: string} | undefined {
|
|
199
|
+
if (wavPath === undefined) return undefined;
|
|
200
|
+
try {
|
|
201
|
+
// An SFX render starts recording on the frame the SFX starts: skip
|
|
202
|
+
// nothing, or a short SFX would be measured on its silent tail only.
|
|
203
|
+
return {
|
|
204
|
+
analysis: analyzeAudio(decodeWav(readFileSync(wavPath)), {
|
|
205
|
+
kind,
|
|
206
|
+
skipSeconds: kind === 'sfx' ? 0 : 1,
|
|
207
|
+
}),
|
|
208
|
+
};
|
|
209
|
+
} catch (e) {
|
|
210
|
+
return {analysisError: e instanceof Error ? e.message : String(e)};
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
77
214
|
/** The structured output shape shared by `record` and `render`. */
|
|
78
215
|
const AUDIO_OUTPUT = z.object({
|
|
79
216
|
wav: z
|
|
@@ -117,6 +254,13 @@ const AUDIO_OUTPUT = z.object({
|
|
|
117
254
|
.describe(
|
|
118
255
|
"When no WAV was captured: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
|
|
119
256
|
),
|
|
257
|
+
analysis: ANALYSIS_OUTPUT.optional().describe(
|
|
258
|
+
'With --analyze: the analysis of the captured WAV.',
|
|
259
|
+
),
|
|
260
|
+
analysisError: z
|
|
261
|
+
.string()
|
|
262
|
+
.optional()
|
|
263
|
+
.describe('With --analyze: why the WAV could not be analyzed.'),
|
|
120
264
|
});
|
|
121
265
|
|
|
122
266
|
/**
|
|
@@ -181,7 +325,7 @@ function recordOutput(
|
|
|
181
325
|
function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
182
326
|
const audio = Cli.create('audio', {
|
|
183
327
|
description:
|
|
184
|
-
'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so
|
|
328
|
+
'Get a WAV out of a cart by RECORDING a running session (ADR-0009), and measure it. PICO-8 has no working offline WAV export, so `record` and `render` are REAL-TIME recordings of a -run session, NOT deterministic offline exports; they work headless (with no display, picopilot runs the session on a private virtual display, ADR-0017) and require PICO-8. `analyze` measures a WAV (loudness, brightness, dynamics, loop length) and needs nothing.',
|
|
185
329
|
});
|
|
186
330
|
|
|
187
331
|
// --- audio record <cart> ---------------------------------------------------
|
|
@@ -211,14 +355,9 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
211
355
|
.string()
|
|
212
356
|
.default(DONE_SENTINEL)
|
|
213
357
|
.describe('The stdout line that ends the recording.'),
|
|
214
|
-
backstopMs:
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
.positive()
|
|
218
|
-
.default(DEFAULT_BACKSTOP_MS)
|
|
219
|
-
.describe(
|
|
220
|
-
'Safety backstop: kill PICO-8 after this many ms if it never signals.',
|
|
221
|
-
),
|
|
358
|
+
backstopMs: BACKSTOP_OPTION,
|
|
359
|
+
analyze: ANALYZE_OPTION,
|
|
360
|
+
display: DISPLAY_OPTION,
|
|
222
361
|
}),
|
|
223
362
|
env: AUDIO_ENV,
|
|
224
363
|
output: AUDIO_OUTPUT,
|
|
@@ -256,26 +395,45 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
256
395
|
throw e;
|
|
257
396
|
}
|
|
258
397
|
|
|
398
|
+
const backstop = resolveRecordBackstop(
|
|
399
|
+
harness.seconds,
|
|
400
|
+
options.backstopMs,
|
|
401
|
+
);
|
|
402
|
+
if (!backstop.ok) {
|
|
403
|
+
return error({
|
|
404
|
+
code: 'audio-backstop-too-small',
|
|
405
|
+
message: backstop.message,
|
|
406
|
+
exitCode: 1,
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
259
410
|
// The throwaway harness cart + the WAV both live in the isolated dir, so a
|
|
260
411
|
// record run never mutates the user's cart or writes outside the temp dir.
|
|
261
412
|
const wavDir = chooseWavDir(options.wavDir);
|
|
262
413
|
const harnessCart = join(wavDir, 'record-harness.p8');
|
|
263
414
|
writeFileSync(harnessCart, harness.cartText);
|
|
264
415
|
|
|
416
|
+
const display = resolveCommandDisplay(options.display, vars);
|
|
417
|
+
if (!display.ok) return error(display.error);
|
|
265
418
|
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
266
|
-
display:
|
|
419
|
+
display: display.mode,
|
|
267
420
|
});
|
|
268
421
|
const result = await adapter.record({
|
|
269
422
|
cartPath: harnessCart,
|
|
270
423
|
wavDir,
|
|
271
424
|
wavBasename: harness.wavBasename,
|
|
272
425
|
sentinel: options.sentinel,
|
|
273
|
-
backstopMs:
|
|
426
|
+
backstopMs: backstop.ms,
|
|
274
427
|
});
|
|
275
428
|
|
|
276
429
|
if (!result.ok) return error(recordFailure('audio record', result));
|
|
277
430
|
|
|
278
|
-
const out =
|
|
431
|
+
const out = {
|
|
432
|
+
...recordOutput(result.value, harness),
|
|
433
|
+
...(options.analyze
|
|
434
|
+
? analyzeCapture(result.value.wavPath, 'music')
|
|
435
|
+
: {}),
|
|
436
|
+
};
|
|
279
437
|
return ok(
|
|
280
438
|
out,
|
|
281
439
|
out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
|
|
@@ -330,14 +488,9 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
330
488
|
.string()
|
|
331
489
|
.default(DONE_SENTINEL)
|
|
332
490
|
.describe('The stdout line that ends the recording.'),
|
|
333
|
-
backstopMs:
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.positive()
|
|
337
|
-
.default(DEFAULT_BACKSTOP_MS)
|
|
338
|
-
.describe(
|
|
339
|
-
'Safety backstop: kill PICO-8 after this many ms if it never signals.',
|
|
340
|
-
),
|
|
491
|
+
backstopMs: BACKSTOP_OPTION,
|
|
492
|
+
analyze: ANALYZE_OPTION,
|
|
493
|
+
display: DISPLAY_OPTION,
|
|
341
494
|
}),
|
|
342
495
|
env: AUDIO_ENV,
|
|
343
496
|
output: AUDIO_OUTPUT,
|
|
@@ -390,24 +543,46 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
390
543
|
throw e;
|
|
391
544
|
}
|
|
392
545
|
|
|
546
|
+
const backstop = resolveRecordBackstop(
|
|
547
|
+
harness.seconds,
|
|
548
|
+
options.backstopMs,
|
|
549
|
+
);
|
|
550
|
+
if (!backstop.ok) {
|
|
551
|
+
return error({
|
|
552
|
+
code: 'audio-backstop-too-small',
|
|
553
|
+
message: backstop.message,
|
|
554
|
+
exitCode: 1,
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
|
|
393
558
|
const wavDir = chooseWavDir(options.wavDir);
|
|
394
559
|
const harnessCart = join(wavDir, 'render-harness.p8');
|
|
395
560
|
writeFileSync(harnessCart, harness.cartText);
|
|
396
561
|
|
|
562
|
+
const display = resolveCommandDisplay(options.display, vars);
|
|
563
|
+
if (!display.ok) return error(display.error);
|
|
397
564
|
const adapter = adapterFactory(env as NodeJS.ProcessEnv, {
|
|
398
|
-
display:
|
|
565
|
+
display: display.mode,
|
|
399
566
|
});
|
|
400
567
|
const result = await adapter.record({
|
|
401
568
|
cartPath: harnessCart,
|
|
402
569
|
wavDir,
|
|
403
570
|
wavBasename: harness.wavBasename,
|
|
404
571
|
sentinel: options.sentinel,
|
|
405
|
-
backstopMs:
|
|
572
|
+
backstopMs: backstop.ms,
|
|
406
573
|
});
|
|
407
574
|
|
|
408
575
|
if (!result.ok) return error(recordFailure('audio render', result));
|
|
409
576
|
|
|
410
|
-
const out =
|
|
577
|
+
const out = {
|
|
578
|
+
...recordOutput(result.value, harness),
|
|
579
|
+
...(options.analyze
|
|
580
|
+
? analyzeCapture(
|
|
581
|
+
result.value.wavPath,
|
|
582
|
+
target.kind === 'sfx' ? 'sfx' : 'music',
|
|
583
|
+
)
|
|
584
|
+
: {}),
|
|
585
|
+
};
|
|
411
586
|
return ok(
|
|
412
587
|
out,
|
|
413
588
|
out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
|
|
@@ -415,12 +590,101 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
415
590
|
},
|
|
416
591
|
});
|
|
417
592
|
|
|
593
|
+
// --- audio analyze <wav> ----------------------------------------------------
|
|
594
|
+
audio.command('analyze', {
|
|
595
|
+
description: `Measure a WAV (from \`audio render\` / \`audio record\`) so a model that cannot hear can judge it: loudness (mean/peak RMS, loudest moment), brightness (spectral centroid, energy above 2 kHz), dynamic range, a loudness profile per half second, and the loop length, plus plain-language warnings (loud, shrill, monotonous, spikes, repetitive). Skips the first second by default (as the calibration did); thresholds are for music, \`--kind sfx\` keeps only the checks that apply to a one-shot sound. --against <sfx.wav> compares the music's loudness with a rendered SFX, so the music sits under the SFX. Pure TypeScript: needs neither PICO-8 nor Python.`,
|
|
596
|
+
args: z.object({
|
|
597
|
+
wav: z.string().describe('The WAV file to analyze.'),
|
|
598
|
+
}),
|
|
599
|
+
options: z.object({
|
|
600
|
+
skip: z.coerce
|
|
601
|
+
.number()
|
|
602
|
+
.min(0)
|
|
603
|
+
.default(1)
|
|
604
|
+
.describe(
|
|
605
|
+
'Seconds to skip at the start (default 1, as in the calibration; use 0 for an SFX render). Ignored when the WAV is too short for it.',
|
|
606
|
+
),
|
|
607
|
+
kind: z
|
|
608
|
+
.enum(['music', 'sfx'])
|
|
609
|
+
.default('music')
|
|
610
|
+
.describe(
|
|
611
|
+
'music: every warning; sfx: a one-shot sound, only near-silent / near-clipping (the music thresholds do not apply).',
|
|
612
|
+
),
|
|
613
|
+
against: z
|
|
614
|
+
.string()
|
|
615
|
+
.optional()
|
|
616
|
+
.describe(
|
|
617
|
+
'A reference SFX WAV (e.g. from `audio render --sfx 7`): report the music level relative to it and warn when the music is not quieter.',
|
|
618
|
+
),
|
|
619
|
+
}),
|
|
620
|
+
output: ANALYSIS_OUTPUT,
|
|
621
|
+
examples: [
|
|
622
|
+
{
|
|
623
|
+
description: 'Analyze a rendered song',
|
|
624
|
+
args: {wav: '/tmp/picopilot-audio-x/picopilot-audio.wav'},
|
|
625
|
+
},
|
|
626
|
+
{
|
|
627
|
+
description: 'Check the music sits under an SFX',
|
|
628
|
+
args: {wav: 'music.wav'},
|
|
629
|
+
options: {against: 'explosion.wav'},
|
|
630
|
+
},
|
|
631
|
+
],
|
|
632
|
+
run({args, options, error, ok}) {
|
|
633
|
+
const read = (p: string) => {
|
|
634
|
+
const abs = resolvePath(p);
|
|
635
|
+
if (!existsSync(abs)) {
|
|
636
|
+
throw new AudioAnalyzeError(
|
|
637
|
+
'audio-analyze-bad-wav',
|
|
638
|
+
`no WAV at ${abs}`,
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
return decodeWav(readFileSync(abs));
|
|
642
|
+
};
|
|
643
|
+
let analysis: AudioAnalysis;
|
|
644
|
+
try {
|
|
645
|
+
analysis = analyzeAudio(read(args.wav), {
|
|
646
|
+
skipSeconds: options.skip,
|
|
647
|
+
kind: options.kind,
|
|
648
|
+
...(options.against !== undefined
|
|
649
|
+
? {against: read(options.against)}
|
|
650
|
+
: {}),
|
|
651
|
+
});
|
|
652
|
+
} catch (e) {
|
|
653
|
+
if (e instanceof AudioAnalyzeError) {
|
|
654
|
+
return error({code: e.code, message: e.message, exitCode: 1});
|
|
655
|
+
}
|
|
656
|
+
throw e;
|
|
657
|
+
}
|
|
658
|
+
return ok(
|
|
659
|
+
analysis,
|
|
660
|
+
analysis.warnings.length > 0
|
|
661
|
+
? {
|
|
662
|
+
cta: {
|
|
663
|
+
description:
|
|
664
|
+
'Fix the flagged issues in the score, then measure again:',
|
|
665
|
+
commands: [
|
|
666
|
+
{
|
|
667
|
+
command: 'music from-score --file <score.json>',
|
|
668
|
+
description: 'Rewrite the music.',
|
|
669
|
+
},
|
|
670
|
+
{
|
|
671
|
+
command: 'audio render --pattern <n> --analyze',
|
|
672
|
+
description: 'Record and analyze it again.',
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
},
|
|
676
|
+
}
|
|
677
|
+
: undefined,
|
|
678
|
+
);
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
|
|
418
682
|
return audio;
|
|
419
683
|
}
|
|
420
684
|
|
|
421
685
|
/**
|
|
422
|
-
* Registers the `audio` command group (`audio record`, `audio render`
|
|
423
|
-
* root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
|
|
686
|
+
* Registers the `audio` command group (`audio record`, `audio render`, `audio
|
|
687
|
+
* analyze`) on the root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
|
|
424
688
|
* session, never an offline export. `adapterFactory` is injected so tests drive
|
|
425
689
|
* the orchestration against a fake runner (no paid binary in CI).
|
|
426
690
|
*/
|
package/src/commands/doctor.ts
CHANGED
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
} from '../engine/pico8/index.js';
|
|
16
16
|
import {
|
|
17
17
|
createShellAdapterFactory,
|
|
18
|
-
|
|
18
|
+
DISPLAY_OPTION,
|
|
19
19
|
PICO8_ENV,
|
|
20
|
+
resolveCommandDisplay,
|
|
20
21
|
type Pico8AdapterFactory,
|
|
21
22
|
} from './pico8-env.js';
|
|
22
23
|
import {
|
|
@@ -72,6 +73,7 @@ const COMMAND_NEEDS: readonly {command: string; needs: readonly Need[]}[] = [
|
|
|
72
73
|
{command: 'run --record-audio', needs: ['pico8', 'display']},
|
|
73
74
|
{command: 'audio record', needs: ['pico8', 'display']},
|
|
74
75
|
{command: 'audio render', needs: ['pico8', 'display']},
|
|
76
|
+
{command: 'audio analyze', needs: []},
|
|
75
77
|
];
|
|
76
78
|
|
|
77
79
|
/** The PICO-8 version from its install path (e.g. `.../pico-8-0.2.7/...`), if it names one. */
|
|
@@ -112,6 +114,7 @@ export function registerDoctor(cli: Cli.Cli, deps: DoctorDeps = {}): void {
|
|
|
112
114
|
description:
|
|
113
115
|
'Report what this machine can do: PICO-8 (found, runs headless, version), shrinko8, the display a PICO-8 -run session would use (native / virtual Xvfb / none), and which picopilot commands are therefore available. Call this first on a new machine.',
|
|
114
116
|
env: PICO8_ENV,
|
|
117
|
+
options: z.object({display: DISPLAY_OPTION}),
|
|
115
118
|
output: z.object({
|
|
116
119
|
pico8: z.object({
|
|
117
120
|
found: z.boolean().describe('Whether the PICO-8 binary was found.'),
|
|
@@ -162,8 +165,10 @@ export function registerDoctor(cli: Cli.Cli, deps: DoctorDeps = {}): void {
|
|
|
162
165
|
)
|
|
163
166
|
.describe('Commands that do not, and what they are missing.'),
|
|
164
167
|
}),
|
|
165
|
-
async run({env, ok, var: vars}) {
|
|
166
|
-
const
|
|
168
|
+
async run({env, ok, error, options, var: vars}) {
|
|
169
|
+
const resolved = resolveCommandDisplay(options.display, vars);
|
|
170
|
+
if (!resolved.ok) return error(resolved.error);
|
|
171
|
+
const display: DisplayMode = resolved.mode;
|
|
167
172
|
const childEnv = pico8ChildEnv(
|
|
168
173
|
env as Record<string, string | undefined>,
|
|
169
174
|
deps.parentEnv ?? process.env,
|