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
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AACA,OAAO,EAAC,GAAG,EAAI,MAAM,OAAO,CAAC;AAoE7B;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,IAAI,GAAG,CAAC,GAAG,CAqFnC"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAGA,OAAO,EAAC,GAAG,EAAI,MAAM,OAAO,CAAC;AA+I7B;;;;;;;;;;;GAWG;AACH,wBAAgB,SAAS,IAAI,GAAG,CAAC,GAAG,CAkHnC"}
package/dist/cli.js CHANGED
@@ -1,4 +1,6 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join, resolve } from 'node:path';
2
4
  import { Cli, z } from 'incur';
3
5
  import { registerAudio } from './commands/audio.js';
4
6
  import { registerDoctor } from './commands/doctor.js';
@@ -17,13 +19,79 @@ import { registerVerify } from './commands/verify.js';
17
19
  import { registerVersion } from './commands/version.js';
18
20
  import { DEFAULT_DISPLAY_MODE, DISPLAY_CONFIG_KEY, DISPLAY_ENV_VAR, DISPLAY_MODES, resolveDisplayMode, } from './engine/pico8/display.js';
19
21
  import { VERSION } from './version.js';
20
- /** The commands that use the display mode (they start a `-run` session, or report on it). */
21
- const DISPLAY_COMMANDS = new Set([
22
- 'run',
23
- 'audio record',
24
- 'audio render',
25
- 'doctor',
26
- ]);
22
+ /** The config files picopilot searches (first existing wins), relative to the cwd. */
23
+ const CONFIG_FILES = ['picopilot.json'];
24
+ /**
25
+ * Removes every `--display <v>` / `--display=<v>` from argv and returns the last
26
+ * value. A `--display` with no value (last token, or followed by another flag)
27
+ * stays in argv, so incur rejects it instead of it silently eating the next flag.
28
+ */
29
+ function takeDisplayFlag(argv) {
30
+ const rest = [];
31
+ let display;
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const a = argv[i];
34
+ const next = argv[i + 1];
35
+ if (a === '--display' && next !== undefined && !next.startsWith('-')) {
36
+ display = next;
37
+ i++;
38
+ }
39
+ else if (a.startsWith('--display=') && a.length > '--display='.length) {
40
+ display = a.slice('--display='.length);
41
+ }
42
+ else {
43
+ rest.push(a);
44
+ }
45
+ }
46
+ return { argv: rest, display };
47
+ }
48
+ /** Resolves a config path the way incur does: `~` is the home dir, else relative to the cwd. */
49
+ function resolveConfigPath(path) {
50
+ return path === '~' || path.startsWith('~/')
51
+ ? join(homedir(), path.slice(1))
52
+ : resolve(path);
53
+ }
54
+ /**
55
+ * For `--mcp`: the config file incur would have loaded on the CLI. Mirrors
56
+ * incur's own parsing: `--config <p>` / `--config=<p>` / `--no-config`, the last
57
+ * one wins, a missing value is an error; else the first existing default file.
58
+ */
59
+ function loadMcpConfig(argv) {
60
+ let explicit;
61
+ let disabled = false;
62
+ for (let i = 0; i < argv.length; i++) {
63
+ const a = argv[i];
64
+ let value;
65
+ if (a === '--config')
66
+ value = argv[++i];
67
+ else if (a.startsWith('--config='))
68
+ value = a.slice('--config='.length);
69
+ else if (a === '--no-config') {
70
+ explicit = undefined;
71
+ disabled = true;
72
+ continue;
73
+ }
74
+ else
75
+ continue;
76
+ if (value === undefined || value === '') {
77
+ throw new Error('Missing value for flag: --config');
78
+ }
79
+ explicit = value;
80
+ disabled = false;
81
+ }
82
+ if (disabled)
83
+ return undefined;
84
+ const sink = {};
85
+ if (explicit !== undefined) {
86
+ return loadConfig(resolveConfigPath(explicit), sink);
87
+ }
88
+ for (const file of CONFIG_FILES) {
89
+ const path = resolveConfigPath(file);
90
+ if (existsSync(path))
91
+ return loadConfig(path, sink);
92
+ }
93
+ return undefined;
94
+ }
27
95
  /**
28
96
  * Reads a picopilot.json for incur's config layer AND captures its top-level
29
97
  * {@link DISPLAY_CONFIG_KEY}. incur only maps per-command `options` from the
@@ -82,18 +150,12 @@ export function createCli() {
82
150
  // Per-cart policy config (e.g. `allowMapOverlap`); precedence argv > config > defaults.
83
151
  config: {
84
152
  flag: 'config',
85
- files: ['picopilot.json'],
153
+ files: CONFIG_FILES,
86
154
  loader: async (path) => loadConfig(path, configSink),
87
155
  },
88
- // The display mode (ADR-0017) is global: it applies to every command that
89
- // starts a PICO-8 `-run` session. Precedence: --display > PICOPILOT_DISPLAY
90
- // > picopilot.json `display` > auto.
91
- globals: z.object({
92
- display: z
93
- .enum(DISPLAY_MODES)
94
- .optional()
95
- .describe('How PICO-8 -run sessions (audio record/render, run --record-audio) get a display: auto (default: a private virtual display only when there is none), xvfb (always virtual), native (as-is). Also PICOPILOT_DISPLAY or picopilot.json "display" (under --mcp, only PICOPILOT_DISPLAY applies).'),
96
- }),
156
+ // The display mode (ADR-0017): `--display` (a per-command option on the
157
+ // commands that use it, also accepted anywhere on the command line and on
158
+ // `--mcp`) > PICOPILOT_DISPLAY > picopilot.json `display` > auto.
97
159
  env: z.object({
98
160
  [DISPLAY_ENV_VAR]: z
99
161
  .string()
@@ -102,35 +164,66 @@ export function createCli() {
102
164
  }),
103
165
  vars: z.object({
104
166
  display: z.enum(DISPLAY_MODES).default(DEFAULT_DISPLAY_MODE),
167
+ displayError: z.string().optional(),
168
+ mcpConfig: z.record(z.string(), z.unknown()).optional(),
105
169
  }),
170
+ mcp: {
171
+ // One MCP tool per command (incur's default is progressive discovery
172
+ // behind search/call meta-tools); picopilot's surface is small, and the
173
+ // skills name commands directly.
174
+ tools: { discovery: 'direct' },
175
+ },
106
176
  });
177
+ const invocation = { mcp: false };
107
178
  cli.use(async (c, next) => {
108
- const configured = configSink.display;
179
+ const configured = invocation.mcp
180
+ ? invocation.mcpConfig?.[DISPLAY_CONFIG_KEY]
181
+ : configSink.display;
109
182
  configSink.display = undefined; // never leak into a later invocation
110
183
  const resolved = resolveDisplayMode({
111
- // incur's MCP transport passes no globals (and loads no config file), so
112
- // under `--mcp` only PICOPILOT_DISPLAY applies.
113
- flag: c.globals?.display,
184
+ flag: invocation.display,
114
185
  env: c.env[DISPLAY_ENV_VAR],
115
186
  config: configured,
116
187
  });
117
- if (!resolved.ok) {
118
- // A bad value only fails the commands that use it; the rest run as usual.
119
- if (!DISPLAY_COMMANDS.has(c.command))
120
- return next();
121
- // (An invalid --display never gets here: incur's enum rejects it first.)
122
- const where = resolved.source === 'env'
123
- ? DISPLAY_ENV_VAR
124
- : `picopilot.json "${DISPLAY_CONFIG_KEY}"`;
125
- return c.error({
126
- code: 'invalid-display-mode',
127
- message: `${where} is "${resolved.value}"; expected one of: ${DISPLAY_MODES.join(', ')}.`,
128
- exitCode: 1,
129
- });
188
+ if (resolved.ok) {
189
+ c.set('display', resolved.mode);
190
+ }
191
+ else {
192
+ // Not an error here: only the commands that USE the display mode fail
193
+ // on a bad value (see resolveCommandDisplay); the rest run as usual.
194
+ const where = resolved.source === 'flag'
195
+ ? '--display'
196
+ : resolved.source === 'env'
197
+ ? DISPLAY_ENV_VAR
198
+ : `picopilot.json "${DISPLAY_CONFIG_KEY}"`;
199
+ c.set('displayError', `${where} is "${resolved.value}"; expected one of: ${DISPLAY_MODES.join(', ')}.`);
200
+ }
201
+ if (invocation.mcp && invocation.mcpConfig !== undefined) {
202
+ c.set('mcpConfig', invocation.mcpConfig);
130
203
  }
131
- c.set('display', resolved.mode);
132
204
  await next();
133
205
  });
206
+ // Capture the top-level `--display` (and, for `--mcp`, the config) before
207
+ // incur parses argv; see {@link Invocation}.
208
+ const serve = cli.serve.bind(cli);
209
+ cli.serve = async (argv = process.argv.slice(2), options) => {
210
+ const taken = takeDisplayFlag(argv);
211
+ invocation.display = taken.display;
212
+ invocation.mcp = taken.argv.includes('--mcp');
213
+ invocation.mcpConfig = undefined;
214
+ if (invocation.mcp) {
215
+ try {
216
+ invocation.mcpConfig = loadMcpConfig(taken.argv);
217
+ }
218
+ catch (e) {
219
+ // A broken config must stop the server visibly, not serve without it.
220
+ process.stderr.write(`picopilot --mcp: ${e instanceof Error ? e.message : String(e)}\n`);
221
+ process.exitCode = 1;
222
+ return;
223
+ }
224
+ }
225
+ return serve(taken.argv, options);
226
+ };
134
227
  // The register* helpers take the untyped Cli: the display var is read through
135
228
  // `displayModeOf`, so they do not depend on this CLI's generics.
136
229
  const root = cli;
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
@@ -8,8 +8,29 @@ import { type Pico8AdapterFactory } from './pico8-env.js';
8
8
  */
9
9
  export type { Pico8AdapterFactory } from './pico8-env.js';
10
10
  /**
11
- * Registers the `audio` command group (`audio record`, `audio render`) on the
12
- * root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
11
+ * The startup margin added to the record window for the default backstop. A
12
+ * recording is real time, so the backstop must cover PICO-8 startup (a virtual
13
+ * display, loading the cart) PLUS the whole record window. A fixed 30 s backstop
14
+ * used to kill any window of ~24 s or more before `audio_end`.
15
+ */
16
+ export declare const RECORD_STARTUP_MARGIN_MS = 30000;
17
+ /** The least startup time an explicit backstop must leave beyond the window. */
18
+ export declare const RECORD_MIN_STARTUP_MS = 5000;
19
+ /**
20
+ * The backstop for a record window: the explicit `--backstop-ms` when it leaves
21
+ * at least {@link RECORD_MIN_STARTUP_MS} for startup, else a refusal; or, when
22
+ * none is given, the window plus {@link RECORD_STARTUP_MARGIN_MS}.
23
+ */
24
+ export declare function resolveRecordBackstop(seconds: number, explicit: number | undefined): {
25
+ ok: true;
26
+ ms: number;
27
+ } | {
28
+ ok: false;
29
+ message: string;
30
+ };
31
+ /**
32
+ * Registers the `audio` command group (`audio record`, `audio render`, `audio
33
+ * analyze`) on the root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
13
34
  * session, never an offline export. `adapterFactory` is injected so tests drive
14
35
  * the orchestration against a fake runner (no paid binary in CI).
15
36
  */
@@ -1 +1 @@
1
- {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../../src/commands/audio.ts"],"names":[],"mappings":"AASA,OAAO,EAAC,GAAG,EAAI,MAAM,OAAO,CAAC;AAW7B,OAAO,EAKN,KAAK,mBAAmB,EAExB,MAAM,gBAAgB,CAAC;AAExB;;;;;GAKG;AACH,YAAY,EAAC,mBAAmB,EAAC,MAAM,gBAAgB,CAAC;AAiYxD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC5B,GAAG,EAAE,GAAG,CAAC,GAAG,EACZ,cAAc,GAAE,mBAA2C,GACzD,IAAI,CAEN"}
1
+ {"version":3,"file":"audio.d.ts","sourceRoot":"","sources":["../../src/commands/audio.ts"],"names":[],"mappings":"AASA,OAAO,EAAC,GAAG,EAAI,MAAM,OAAO,CAAC;AAkB7B,OAAO,EAKN,KAAK,mBAAmB,EAGxB,MAAM,gBAAgB,CAAC;AAExB;;;;;GAKG;AACH,YAAY,EAAC,mBAAmB,EAAC,MAAM,gBAAgB,CAAC;AAIxD;;;;;GAKG;AACH,eAAO,MAAM,wBAAwB,QAAS,CAAC;AAE/C,gFAAgF;AAChF,eAAO,MAAM,qBAAqB,OAAQ,CAAC;AAE3C;;;;GAIG;AACH,wBAAgB,qBAAqB,CACpC,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GAAG,SAAS,GAC1B;IAAC,EAAE,EAAE,IAAI,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAC,GAAG;IAAC,EAAE,EAAE,KAAK,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAC,CAavD;AA6lBD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC5B,GAAG,EAAE,GAAG,CAAC,GAAG,EACZ,cAAc,GAAE,mBAA2C,GACzD,IAAI,CAEN"}
@@ -2,16 +2,46 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync, } from
2
2
  import { tmpdir } from 'node:os';
3
3
  import { isAbsolute, join, resolve } from 'node:path';
4
4
  import { Cli, z } from 'incur';
5
+ import { analyzeAudio, AudioAnalyzeError, decodeWav, THRESHOLDS, } from '../engine/audio/index.js';
5
6
  import { buildRecordHarness, buildRenderHarness, DONE_SENTINEL, HarnessError, } from '../engine/pico8/index.js';
6
7
  import { makeReadInclude } from './cart-includes.js';
7
- import { createShellAdapterFactory, displayModeOf, displayUnavailableError, PICO8_ENV, pico8NotFoundError, } from './pico8-env.js';
8
+ import { createShellAdapterFactory, DISPLAY_OPTION, displayUnavailableError, PICO8_ENV, pico8NotFoundError, resolveCommandDisplay, } from './pico8-env.js';
8
9
  const defaultAdapterFactory = createShellAdapterFactory();
9
10
  /**
10
- * The default record backstop: kill a capture that never signals after this many
11
- * ms. Larger than `run`'s default because a record window is a real-time playback
12
- * (up to several seconds) plus PICO-8 startup, not a quick screenshot pass.
11
+ * The startup margin added to the record window for the default backstop. A
12
+ * recording is real time, so the backstop must cover PICO-8 startup (a virtual
13
+ * display, loading the cart) PLUS the whole record window. A fixed 30 s backstop
14
+ * used to kill any window of ~24 s or more before `audio_end`.
13
15
  */
14
- const DEFAULT_BACKSTOP_MS = 30_000;
16
+ export const RECORD_STARTUP_MARGIN_MS = 30_000;
17
+ /** The least startup time an explicit backstop must leave beyond the window. */
18
+ export const RECORD_MIN_STARTUP_MS = 5_000;
19
+ /**
20
+ * The backstop for a record window: the explicit `--backstop-ms` when it leaves
21
+ * at least {@link RECORD_MIN_STARTUP_MS} for startup, else a refusal; or, when
22
+ * none is given, the window plus {@link RECORD_STARTUP_MARGIN_MS}.
23
+ */
24
+ export function resolveRecordBackstop(seconds, explicit) {
25
+ const windowMs = Math.ceil(seconds * 1000);
26
+ if (explicit === undefined) {
27
+ return { ok: true, ms: windowMs + RECORD_STARTUP_MARGIN_MS };
28
+ }
29
+ const least = windowMs + RECORD_MIN_STARTUP_MS;
30
+ if (explicit < least) {
31
+ return {
32
+ ok: false,
33
+ 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).`,
34
+ };
35
+ }
36
+ return { ok: true, ms: explicit };
37
+ }
38
+ /** The `--backstop-ms` option both audio subcommands share. */
39
+ const BACKSTOP_OPTION = z
40
+ .number()
41
+ .int()
42
+ .positive()
43
+ .optional()
44
+ .describe(`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.`);
15
45
  /** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
16
46
  const HONEST_CAVEAT = 'This is a REAL-TIME recording of playback in a -run session, NOT a deterministic offline export. Works headless: with no display, picopilot runs the session on a private virtual display (ADR-0017, --display).';
17
47
  /** Resolves a path argument to an absolute path (relative to cwd). */
@@ -37,6 +67,82 @@ function chooseWavDir(given) {
37
67
  }
38
68
  /** The env schema both audio subcommands share (locates the binary). */
39
69
  const AUDIO_ENV = PICO8_ENV;
70
+ /** The structured analysis of a recording (`audio analyze`, `--analyze`). */
71
+ const ANALYSIS_OUTPUT = z.object({
72
+ sampleRate: z.number(),
73
+ durationSeconds: z.number(),
74
+ skippedSeconds: z
75
+ .number()
76
+ .describe('Seconds skipped at the start (PICO-8 startup).'),
77
+ analyzedSeconds: z.number(),
78
+ loudness: z.object({
79
+ meanRms: z
80
+ .number()
81
+ .describe(`Mean RMS of 2048-sample frames (0-1). Accepted game music: 0.07-0.10; over ${THRESHOLDS.loudMeanRms} warns.`),
82
+ medianRms: z.number(),
83
+ peakFrameRms: z.number().describe('The loudest frame.'),
84
+ peakFrameAtSeconds: z
85
+ .number()
86
+ .describe('When the loudest frame starts (s from file start).'),
87
+ peakSample: z.number().describe('99.9th-percentile absolute sample.'),
88
+ silentPercent: z.number().describe('Share of silent frames (%).'),
89
+ }),
90
+ brightness: z.object({
91
+ centroidHz: z
92
+ .number()
93
+ .describe(`Median spectral centroid (Hz). Accepted: under 500; over ${THRESHOLDS.shrillCentroidHz} is shrill.`),
94
+ above2kHzPercent: z
95
+ .number()
96
+ .describe(`Share of energy above 2 kHz (%). Accepted: about 0.2; over ${THRESHOLDS.shrillAbove2kHzPercent} is shrill.`),
97
+ }),
98
+ dynamicRangeDb: z
99
+ .number()
100
+ .describe(`p90 - p10 of frame level, silence excluded (dB). Accepted: 7-8; under ${THRESHOLDS.flatRangeDb} is monotonous.`),
101
+ profile: z.object({
102
+ stepSeconds: z.number(),
103
+ rms: z.array(z.number()),
104
+ sparkline: z.string().describe('The loudness profile as a bar chart.'),
105
+ }),
106
+ loop: z
107
+ .object({
108
+ seconds: z.number().nullable(),
109
+ confidence: z.number().optional(),
110
+ reason: z.string().optional(),
111
+ })
112
+ .describe('The repetition length (needs the loop to appear twice in the recording).'),
113
+ against: z
114
+ .object({ referenceRms: z.number(), musicOverReferenceDb: z.number() })
115
+ .optional(),
116
+ warnings: z
117
+ .array(z.object({ code: z.string(), message: z.string() }))
118
+ .describe('Plain-language findings; empty = nothing flagged.'),
119
+ });
120
+ /** The `--analyze` option of record/render. */
121
+ const ANALYZE_OPTION = z
122
+ .boolean()
123
+ .default(false)
124
+ .describe('Also analyze the captured WAV (loudness, brightness, dynamic range, profile, loop length, warnings), as `audio analyze` does.');
125
+ /**
126
+ * Analyzes a captured WAV for `--analyze`, or explains why it could not. Never
127
+ * fails the recording itself.
128
+ */
129
+ function analyzeCapture(wavPath, kind) {
130
+ if (wavPath === undefined)
131
+ return undefined;
132
+ try {
133
+ // An SFX render starts recording on the frame the SFX starts: skip
134
+ // nothing, or a short SFX would be measured on its silent tail only.
135
+ return {
136
+ analysis: analyzeAudio(decodeWav(readFileSync(wavPath)), {
137
+ kind,
138
+ skipSeconds: kind === 'sfx' ? 0 : 1,
139
+ }),
140
+ };
141
+ }
142
+ catch (e) {
143
+ return { analysisError: e instanceof Error ? e.message : String(e) };
144
+ }
145
+ }
40
146
  /** The structured output shape shared by `record` and `render`. */
41
147
  const AUDIO_OUTPUT = z.object({
42
148
  wav: z
@@ -68,6 +174,11 @@ const AUDIO_OUTPUT = z.object({
68
174
  .string()
69
175
  .optional()
70
176
  .describe("When no WAV was captured: the tail of PICO-8's own log.txt (load errors, SDL init failures)."),
177
+ analysis: ANALYSIS_OUTPUT.optional().describe('With --analyze: the analysis of the captured WAV.'),
178
+ analysisError: z
179
+ .string()
180
+ .optional()
181
+ .describe('With --analyze: why the WAV could not be analyzed.'),
71
182
  });
72
183
  /**
73
184
  * The advisory CTA when a record/render run captured NO audio. The report's
@@ -120,7 +231,7 @@ function recordOutput(report, harness) {
120
231
  */
121
232
  function buildAudioGroup(adapterFactory) {
122
233
  const audio = Cli.create('audio', {
123
- description: '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.',
234
+ description: '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.',
124
235
  });
125
236
  // --- audio record <cart> ---------------------------------------------------
126
237
  audio.command('record', {
@@ -145,12 +256,9 @@ function buildAudioGroup(adapterFactory) {
145
256
  .string()
146
257
  .default(DONE_SENTINEL)
147
258
  .describe('The stdout line that ends the recording.'),
148
- backstopMs: z
149
- .number()
150
- .int()
151
- .positive()
152
- .default(DEFAULT_BACKSTOP_MS)
153
- .describe('Safety backstop: kill PICO-8 after this many ms if it never signals.'),
259
+ backstopMs: BACKSTOP_OPTION,
260
+ analyze: ANALYZE_OPTION,
261
+ display: DISPLAY_OPTION,
154
262
  }),
155
263
  env: AUDIO_ENV,
156
264
  output: AUDIO_OUTPUT,
@@ -187,24 +295,40 @@ function buildAudioGroup(adapterFactory) {
187
295
  }
188
296
  throw e;
189
297
  }
298
+ const backstop = resolveRecordBackstop(harness.seconds, options.backstopMs);
299
+ if (!backstop.ok) {
300
+ return error({
301
+ code: 'audio-backstop-too-small',
302
+ message: backstop.message,
303
+ exitCode: 1,
304
+ });
305
+ }
190
306
  // The throwaway harness cart + the WAV both live in the isolated dir, so a
191
307
  // record run never mutates the user's cart or writes outside the temp dir.
192
308
  const wavDir = chooseWavDir(options.wavDir);
193
309
  const harnessCart = join(wavDir, 'record-harness.p8');
194
310
  writeFileSync(harnessCart, harness.cartText);
311
+ const display = resolveCommandDisplay(options.display, vars);
312
+ if (!display.ok)
313
+ return error(display.error);
195
314
  const adapter = adapterFactory(env, {
196
- display: displayModeOf(vars),
315
+ display: display.mode,
197
316
  });
198
317
  const result = await adapter.record({
199
318
  cartPath: harnessCart,
200
319
  wavDir,
201
320
  wavBasename: harness.wavBasename,
202
321
  sentinel: options.sentinel,
203
- backstopMs: options.backstopMs,
322
+ backstopMs: backstop.ms,
204
323
  });
205
324
  if (!result.ok)
206
325
  return error(recordFailure('audio record', result));
207
- const out = recordOutput(result.value, harness);
326
+ const out = {
327
+ ...recordOutput(result.value, harness),
328
+ ...(options.analyze
329
+ ? analyzeCapture(result.value.wavPath, 'music')
330
+ : {}),
331
+ };
208
332
  return ok(out, out.captured ? undefined : { cta: noAudioCta(out.noAudioReason) });
209
333
  },
210
334
  });
@@ -245,12 +369,9 @@ function buildAudioGroup(adapterFactory) {
245
369
  .string()
246
370
  .default(DONE_SENTINEL)
247
371
  .describe('The stdout line that ends the recording.'),
248
- backstopMs: z
249
- .number()
250
- .int()
251
- .positive()
252
- .default(DEFAULT_BACKSTOP_MS)
253
- .describe('Safety backstop: kill PICO-8 after this many ms if it never signals.'),
372
+ backstopMs: BACKSTOP_OPTION,
373
+ analyze: ANALYZE_OPTION,
374
+ display: DISPLAY_OPTION,
254
375
  }),
255
376
  env: AUDIO_ENV,
256
377
  output: AUDIO_OUTPUT,
@@ -299,30 +420,122 @@ function buildAudioGroup(adapterFactory) {
299
420
  }
300
421
  throw e;
301
422
  }
423
+ const backstop = resolveRecordBackstop(harness.seconds, options.backstopMs);
424
+ if (!backstop.ok) {
425
+ return error({
426
+ code: 'audio-backstop-too-small',
427
+ message: backstop.message,
428
+ exitCode: 1,
429
+ });
430
+ }
302
431
  const wavDir = chooseWavDir(options.wavDir);
303
432
  const harnessCart = join(wavDir, 'render-harness.p8');
304
433
  writeFileSync(harnessCart, harness.cartText);
434
+ const display = resolveCommandDisplay(options.display, vars);
435
+ if (!display.ok)
436
+ return error(display.error);
305
437
  const adapter = adapterFactory(env, {
306
- display: displayModeOf(vars),
438
+ display: display.mode,
307
439
  });
308
440
  const result = await adapter.record({
309
441
  cartPath: harnessCart,
310
442
  wavDir,
311
443
  wavBasename: harness.wavBasename,
312
444
  sentinel: options.sentinel,
313
- backstopMs: options.backstopMs,
445
+ backstopMs: backstop.ms,
314
446
  });
315
447
  if (!result.ok)
316
448
  return error(recordFailure('audio render', result));
317
- const out = recordOutput(result.value, harness);
449
+ const out = {
450
+ ...recordOutput(result.value, harness),
451
+ ...(options.analyze
452
+ ? analyzeCapture(result.value.wavPath, target.kind === 'sfx' ? 'sfx' : 'music')
453
+ : {}),
454
+ };
318
455
  return ok(out, out.captured ? undefined : { cta: noAudioCta(out.noAudioReason) });
319
456
  },
320
457
  });
458
+ // --- audio analyze <wav> ----------------------------------------------------
459
+ audio.command('analyze', {
460
+ 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.`,
461
+ args: z.object({
462
+ wav: z.string().describe('The WAV file to analyze.'),
463
+ }),
464
+ options: z.object({
465
+ skip: z.coerce
466
+ .number()
467
+ .min(0)
468
+ .default(1)
469
+ .describe('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.'),
470
+ kind: z
471
+ .enum(['music', 'sfx'])
472
+ .default('music')
473
+ .describe('music: every warning; sfx: a one-shot sound, only near-silent / near-clipping (the music thresholds do not apply).'),
474
+ against: z
475
+ .string()
476
+ .optional()
477
+ .describe('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.'),
478
+ }),
479
+ output: ANALYSIS_OUTPUT,
480
+ examples: [
481
+ {
482
+ description: 'Analyze a rendered song',
483
+ args: { wav: '/tmp/picopilot-audio-x/picopilot-audio.wav' },
484
+ },
485
+ {
486
+ description: 'Check the music sits under an SFX',
487
+ args: { wav: 'music.wav' },
488
+ options: { against: 'explosion.wav' },
489
+ },
490
+ ],
491
+ run({ args, options, error, ok }) {
492
+ const read = (p) => {
493
+ const abs = resolvePath(p);
494
+ if (!existsSync(abs)) {
495
+ throw new AudioAnalyzeError('audio-analyze-bad-wav', `no WAV at ${abs}`);
496
+ }
497
+ return decodeWav(readFileSync(abs));
498
+ };
499
+ let analysis;
500
+ try {
501
+ analysis = analyzeAudio(read(args.wav), {
502
+ skipSeconds: options.skip,
503
+ kind: options.kind,
504
+ ...(options.against !== undefined
505
+ ? { against: read(options.against) }
506
+ : {}),
507
+ });
508
+ }
509
+ catch (e) {
510
+ if (e instanceof AudioAnalyzeError) {
511
+ return error({ code: e.code, message: e.message, exitCode: 1 });
512
+ }
513
+ throw e;
514
+ }
515
+ return ok(analysis, analysis.warnings.length > 0
516
+ ? {
517
+ cta: {
518
+ description: 'Fix the flagged issues in the score, then measure again:',
519
+ commands: [
520
+ {
521
+ command: 'music from-score --file <score.json>',
522
+ description: 'Rewrite the music.',
523
+ },
524
+ {
525
+ command: 'audio render --pattern <n> --analyze',
526
+ description: 'Record and analyze it again.',
527
+ },
528
+ ],
529
+ },
530
+ }
531
+ : undefined);
532
+ },
533
+ });
321
534
  return audio;
322
535
  }
323
536
  /**
324
- * Registers the `audio` command group (`audio record`, `audio render`) on the
325
- * root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
537
+ * Registers the `audio` command group (`audio record`, `audio render`, `audio
538
+ * analyze`) on the root CLI. PICO-8-gated (ADR-0009): audio-to-WAV is a record of a running
326
539
  * session, never an offline export. `adapterFactory` is injected so tests drive
327
540
  * the orchestration against a fake runner (no paid binary in CI).
328
541
  */