picopilot 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +239 -26
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +8 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +11 -5
- package/dist/commands/gfx.js.map +1 -1
- package/dist/commands/music.d.ts +2 -1
- package/dist/commands/music.d.ts.map +1 -1
- package/dist/commands/music.js +209 -2
- package/dist/commands/music.js.map +1 -1
- package/dist/commands/pico8-env.d.ts +39 -3
- package/dist/commands/pico8-env.d.ts.map +1 -1
- package/dist/commands/pico8-env.js +51 -4
- package/dist/commands/pico8-env.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +8 -2
- package/dist/commands/run.js.map +1 -1
- package/dist/commands/sfx.d.ts.map +1 -1
- package/dist/commands/sfx.js +99 -11
- package/dist/commands/sfx.js.map +1 -1
- package/dist/engine/audio/analyze.d.ts +164 -0
- package/dist/engine/audio/analyze.d.ts.map +1 -0
- package/dist/engine/audio/analyze.js +512 -0
- package/dist/engine/audio/analyze.js.map +1 -0
- package/dist/engine/audio/index.d.ts +4 -2
- package/dist/engine/audio/index.d.ts.map +1 -1
- package/dist/engine/audio/index.js +4 -2
- package/dist/engine/audio/index.js.map +1 -1
- package/dist/engine/audio/score.d.ts +137 -0
- package/dist/engine/audio/score.d.ts.map +1 -0
- package/dist/engine/audio/score.js +420 -0
- package/dist/engine/audio/score.js.map +1 -0
- package/dist/engine/audio/section.d.ts +7 -0
- package/dist/engine/audio/section.d.ts.map +1 -1
- package/dist/engine/audio/section.js +17 -0
- package/dist/engine/audio/section.js.map +1 -1
- package/dist/engine/audio/sfx.d.ts +114 -12
- package/dist/engine/audio/sfx.d.ts.map +1 -1
- package/dist/engine/audio/sfx.js +236 -33
- package/dist/engine/audio/sfx.js.map +1 -1
- package/dist/engine/pico8/harness.d.ts +11 -5
- package/dist/engine/pico8/harness.d.ts.map +1 -1
- package/dist/engine/pico8/harness.js +24 -10
- package/dist/engine/pico8/harness.js.map +1 -1
- package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
- package/dist/engine/scaffold/scaffold.js +5 -1
- package/dist/engine/scaffold/scaffold.js.map +1 -1
- package/package.json +2 -2
- package/src/cli.test.ts +42 -1
- package/src/cli.ts +143 -37
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +294 -30
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +8 -3
- package/src/commands/gfx.ts +12 -9
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- package/src/commands/pico8-env.ts +71 -3
- package/src/commands/run.ts +8 -2
- package/src/commands/sfx.test.ts +55 -0
- package/src/commands/sfx.ts +118 -11
- package/src/engine/audio/analyze.test.ts +239 -0
- package/src/engine/audio/analyze.ts +664 -0
- package/src/engine/audio/index.ts +46 -1
- package/src/engine/audio/score.test.ts +402 -0
- package/src/engine/audio/score.ts +630 -0
- package/src/engine/audio/section.ts +19 -0
- package/src/engine/audio/sfx.test.ts +219 -4
- package/src/engine/audio/sfx.ts +326 -40
- package/src/engine/pico8/harness.test.ts +19 -0
- package/src/engine/pico8/harness.ts +29 -11
- package/src/engine/scaffold/scaffold.ts +5 -1
- package/src/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +74 -184
- package/src/skills/picopilot-overview/SKILL.md +1 -1
package/dist/cli.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"
|
|
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
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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:
|
|
153
|
+
files: CONFIG_FILES,
|
|
86
154
|
loader: async (path) => loadConfig(path, configSink),
|
|
87
155
|
},
|
|
88
|
-
// The display mode (ADR-0017)
|
|
89
|
-
//
|
|
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 =
|
|
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
|
-
|
|
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 (
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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":"AAAA,OAAO,EAAC,YAAY,EAAC,MAAM,SAAS,CAAC;
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/commands/audio.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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;
|
|
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"}
|
package/dist/commands/audio.js
CHANGED
|
@@ -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,
|
|
8
|
+
import { createShellAdapterFactory, DISPLAY_OPTION, displayUnavailableError, PICO8_ENV, pico8NotFoundError, resolveCommandDisplay, } from './pico8-env.js';
|
|
8
9
|
const defaultAdapterFactory = createShellAdapterFactory();
|
|
9
10
|
/**
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
|
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:
|
|
149
|
-
|
|
150
|
-
|
|
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:
|
|
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:
|
|
322
|
+
backstopMs: backstop.ms,
|
|
204
323
|
});
|
|
205
324
|
if (!result.ok)
|
|
206
325
|
return error(recordFailure('audio record', result));
|
|
207
|
-
const out =
|
|
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:
|
|
249
|
-
|
|
250
|
-
|
|
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:
|
|
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:
|
|
445
|
+
backstopMs: backstop.ms,
|
|
314
446
|
});
|
|
315
447
|
if (!result.ok)
|
|
316
448
|
return error(recordFailure('audio render', result));
|
|
317
|
-
const out =
|
|
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`
|
|
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
|
*/
|