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.
Files changed (82) hide show
  1. package/dist/cli.d.ts.map +1 -1
  2. package/dist/cli.js +129 -36
  3. package/dist/cli.js.map +1 -1
  4. package/dist/commands/audio.d.ts +23 -2
  5. package/dist/commands/audio.d.ts.map +1 -1
  6. package/dist/commands/audio.js +239 -26
  7. package/dist/commands/audio.js.map +1 -1
  8. package/dist/commands/doctor.d.ts.map +1 -1
  9. package/dist/commands/doctor.js +8 -3
  10. package/dist/commands/doctor.js.map +1 -1
  11. package/dist/commands/gfx.d.ts.map +1 -1
  12. package/dist/commands/gfx.js +11 -5
  13. package/dist/commands/gfx.js.map +1 -1
  14. package/dist/commands/music.d.ts +2 -1
  15. package/dist/commands/music.d.ts.map +1 -1
  16. package/dist/commands/music.js +209 -2
  17. package/dist/commands/music.js.map +1 -1
  18. package/dist/commands/pico8-env.d.ts +39 -3
  19. package/dist/commands/pico8-env.d.ts.map +1 -1
  20. package/dist/commands/pico8-env.js +51 -4
  21. package/dist/commands/pico8-env.js.map +1 -1
  22. package/dist/commands/run.d.ts.map +1 -1
  23. package/dist/commands/run.js +8 -2
  24. package/dist/commands/run.js.map +1 -1
  25. package/dist/commands/sfx.d.ts.map +1 -1
  26. package/dist/commands/sfx.js +99 -11
  27. package/dist/commands/sfx.js.map +1 -1
  28. package/dist/engine/audio/analyze.d.ts +164 -0
  29. package/dist/engine/audio/analyze.d.ts.map +1 -0
  30. package/dist/engine/audio/analyze.js +512 -0
  31. package/dist/engine/audio/analyze.js.map +1 -0
  32. package/dist/engine/audio/index.d.ts +4 -2
  33. package/dist/engine/audio/index.d.ts.map +1 -1
  34. package/dist/engine/audio/index.js +4 -2
  35. package/dist/engine/audio/index.js.map +1 -1
  36. package/dist/engine/audio/score.d.ts +137 -0
  37. package/dist/engine/audio/score.d.ts.map +1 -0
  38. package/dist/engine/audio/score.js +420 -0
  39. package/dist/engine/audio/score.js.map +1 -0
  40. package/dist/engine/audio/section.d.ts +7 -0
  41. package/dist/engine/audio/section.d.ts.map +1 -1
  42. package/dist/engine/audio/section.js +17 -0
  43. package/dist/engine/audio/section.js.map +1 -1
  44. package/dist/engine/audio/sfx.d.ts +114 -12
  45. package/dist/engine/audio/sfx.d.ts.map +1 -1
  46. package/dist/engine/audio/sfx.js +236 -33
  47. package/dist/engine/audio/sfx.js.map +1 -1
  48. package/dist/engine/pico8/harness.d.ts +11 -5
  49. package/dist/engine/pico8/harness.d.ts.map +1 -1
  50. package/dist/engine/pico8/harness.js +24 -10
  51. package/dist/engine/pico8/harness.js.map +1 -1
  52. package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
  53. package/dist/engine/scaffold/scaffold.js +5 -1
  54. package/dist/engine/scaffold/scaffold.js.map +1 -1
  55. package/package.json +2 -2
  56. package/src/cli.test.ts +42 -1
  57. package/src/cli.ts +143 -37
  58. package/src/commands/audio.test.ts +170 -1
  59. package/src/commands/audio.ts +294 -30
  60. package/src/commands/doctor.test.ts +1 -0
  61. package/src/commands/doctor.ts +8 -3
  62. package/src/commands/gfx.ts +12 -9
  63. package/src/commands/music.test.ts +119 -0
  64. package/src/commands/music.ts +251 -1
  65. package/src/commands/pico8-env.ts +71 -3
  66. package/src/commands/run.ts +8 -2
  67. package/src/commands/sfx.test.ts +55 -0
  68. package/src/commands/sfx.ts +118 -11
  69. package/src/engine/audio/analyze.test.ts +239 -0
  70. package/src/engine/audio/analyze.ts +664 -0
  71. package/src/engine/audio/index.ts +46 -1
  72. package/src/engine/audio/score.test.ts +402 -0
  73. package/src/engine/audio/score.ts +630 -0
  74. package/src/engine/audio/section.ts +19 -0
  75. package/src/engine/audio/sfx.test.ts +219 -4
  76. package/src/engine/audio/sfx.ts +326 -40
  77. package/src/engine/pico8/harness.test.ts +19 -0
  78. package/src/engine/pico8/harness.ts +29 -11
  79. package/src/engine/scaffold/scaffold.ts +5 -1
  80. package/src/mcp.process.test.ts +219 -0
  81. package/src/skills/picopilot-audio/SKILL.md +74 -184
  82. 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 {type Pico8AdapterFactory, registerAudio} from './audio.js';
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
+ });
@@ -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
- displayModeOf,
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 default record backstop: kill a capture that never signals after this many
42
- * ms. Larger than `run`'s default because a record window is a real-time playback
43
- * (up to several seconds) plus PICO-8 startup, not a quick screenshot pass.
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
- const DEFAULT_BACKSTOP_MS = 30_000;
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 both commands 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). Requires PICO-8.',
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: z
215
- .number()
216
- .int()
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: displayModeOf(vars),
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: options.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 = recordOutput(result.value, harness);
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: z
334
- .number()
335
- .int()
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: displayModeOf(vars),
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: options.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 = recordOutput(result.value, harness);
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`) on the
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
  */
@@ -144,6 +144,7 @@ describe('picopilot doctor', () => {
144
144
  'gfx show / set / render',
145
145
  'sfx',
146
146
  'music',
147
+ 'audio analyze',
147
148
  ]);
148
149
  });
149
150
  });
@@ -15,8 +15,9 @@ import {
15
15
  } from '../engine/pico8/index.js';
16
16
  import {
17
17
  createShellAdapterFactory,
18
- displayModeOf,
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 display: DisplayMode = displayModeOf(vars);
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,