picopilot 0.2.1 → 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/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +228 -23
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/doctor.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/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 +1 -1
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +284 -27
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +1 -0
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- 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/skills/picopilot-audio/SKILL.md +74 -184
package/src/commands/audio.ts
CHANGED
|
@@ -9,6 +9,13 @@ import {tmpdir} from 'node:os';
|
|
|
9
9
|
import {isAbsolute, join, resolve} from 'node:path';
|
|
10
10
|
import {Cli, z} from 'incur';
|
|
11
11
|
|
|
12
|
+
import {
|
|
13
|
+
analyzeAudio,
|
|
14
|
+
type AudioAnalysis,
|
|
15
|
+
AudioAnalyzeError,
|
|
16
|
+
decodeWav,
|
|
17
|
+
THRESHOLDS,
|
|
18
|
+
} from '../engine/audio/index.js';
|
|
12
19
|
import {
|
|
13
20
|
buildRecordHarness,
|
|
14
21
|
buildRenderHarness,
|
|
@@ -39,11 +46,48 @@ export type {Pico8AdapterFactory} from './pico8-env.js';
|
|
|
39
46
|
const defaultAdapterFactory: Pico8AdapterFactory = createShellAdapterFactory();
|
|
40
47
|
|
|
41
48
|
/**
|
|
42
|
-
* The
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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}.
|
|
45
63
|
*/
|
|
46
|
-
|
|
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
|
+
);
|
|
47
91
|
|
|
48
92
|
/** The honest one-liner both `record` and `render` carry (ADR-0009): not offline. */
|
|
49
93
|
const HONEST_CAVEAT =
|
|
@@ -75,6 +119,98 @@ function chooseWavDir(given: string | undefined): string {
|
|
|
75
119
|
/** The env schema both audio subcommands share (locates the binary). */
|
|
76
120
|
const AUDIO_ENV = PICO8_ENV;
|
|
77
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
|
+
|
|
78
214
|
/** The structured output shape shared by `record` and `render`. */
|
|
79
215
|
const AUDIO_OUTPUT = z.object({
|
|
80
216
|
wav: z
|
|
@@ -118,6 +254,13 @@ const AUDIO_OUTPUT = z.object({
|
|
|
118
254
|
.describe(
|
|
119
255
|
"When no WAV was captured: the tail of PICO-8's own log.txt (load errors, SDL init failures).",
|
|
120
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.'),
|
|
121
264
|
});
|
|
122
265
|
|
|
123
266
|
/**
|
|
@@ -182,7 +325,7 @@ function recordOutput(
|
|
|
182
325
|
function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
183
326
|
const audio = Cli.create('audio', {
|
|
184
327
|
description:
|
|
185
|
-
'Get a WAV out of a cart by RECORDING a running session (ADR-0009). PICO-8 has no working offline WAV export, so
|
|
328
|
+
'Get a WAV out of a cart by RECORDING a running session (ADR-0009), and measure it. PICO-8 has no working offline WAV export, so `record` and `render` are REAL-TIME recordings of a -run session, NOT deterministic offline exports; they work headless (with no display, picopilot runs the session on a private virtual display, ADR-0017) and require PICO-8. `analyze` measures a WAV (loudness, brightness, dynamics, loop length) and needs nothing.',
|
|
186
329
|
});
|
|
187
330
|
|
|
188
331
|
// --- audio record <cart> ---------------------------------------------------
|
|
@@ -212,14 +355,8 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
212
355
|
.string()
|
|
213
356
|
.default(DONE_SENTINEL)
|
|
214
357
|
.describe('The stdout line that ends the recording.'),
|
|
215
|
-
backstopMs:
|
|
216
|
-
|
|
217
|
-
.int()
|
|
218
|
-
.positive()
|
|
219
|
-
.default(DEFAULT_BACKSTOP_MS)
|
|
220
|
-
.describe(
|
|
221
|
-
'Safety backstop: kill PICO-8 after this many ms if it never signals.',
|
|
222
|
-
),
|
|
358
|
+
backstopMs: BACKSTOP_OPTION,
|
|
359
|
+
analyze: ANALYZE_OPTION,
|
|
223
360
|
display: DISPLAY_OPTION,
|
|
224
361
|
}),
|
|
225
362
|
env: AUDIO_ENV,
|
|
@@ -258,6 +395,18 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
258
395
|
throw e;
|
|
259
396
|
}
|
|
260
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
|
+
|
|
261
410
|
// The throwaway harness cart + the WAV both live in the isolated dir, so a
|
|
262
411
|
// record run never mutates the user's cart or writes outside the temp dir.
|
|
263
412
|
const wavDir = chooseWavDir(options.wavDir);
|
|
@@ -274,12 +423,17 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
274
423
|
wavDir,
|
|
275
424
|
wavBasename: harness.wavBasename,
|
|
276
425
|
sentinel: options.sentinel,
|
|
277
|
-
backstopMs:
|
|
426
|
+
backstopMs: backstop.ms,
|
|
278
427
|
});
|
|
279
428
|
|
|
280
429
|
if (!result.ok) return error(recordFailure('audio record', result));
|
|
281
430
|
|
|
282
|
-
const out =
|
|
431
|
+
const out = {
|
|
432
|
+
...recordOutput(result.value, harness),
|
|
433
|
+
...(options.analyze
|
|
434
|
+
? analyzeCapture(result.value.wavPath, 'music')
|
|
435
|
+
: {}),
|
|
436
|
+
};
|
|
283
437
|
return ok(
|
|
284
438
|
out,
|
|
285
439
|
out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
|
|
@@ -334,14 +488,8 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
334
488
|
.string()
|
|
335
489
|
.default(DONE_SENTINEL)
|
|
336
490
|
.describe('The stdout line that ends the recording.'),
|
|
337
|
-
backstopMs:
|
|
338
|
-
|
|
339
|
-
.int()
|
|
340
|
-
.positive()
|
|
341
|
-
.default(DEFAULT_BACKSTOP_MS)
|
|
342
|
-
.describe(
|
|
343
|
-
'Safety backstop: kill PICO-8 after this many ms if it never signals.',
|
|
344
|
-
),
|
|
491
|
+
backstopMs: BACKSTOP_OPTION,
|
|
492
|
+
analyze: ANALYZE_OPTION,
|
|
345
493
|
display: DISPLAY_OPTION,
|
|
346
494
|
}),
|
|
347
495
|
env: AUDIO_ENV,
|
|
@@ -395,6 +543,18 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
395
543
|
throw e;
|
|
396
544
|
}
|
|
397
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
|
+
|
|
398
558
|
const wavDir = chooseWavDir(options.wavDir);
|
|
399
559
|
const harnessCart = join(wavDir, 'render-harness.p8');
|
|
400
560
|
writeFileSync(harnessCart, harness.cartText);
|
|
@@ -409,12 +569,20 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
409
569
|
wavDir,
|
|
410
570
|
wavBasename: harness.wavBasename,
|
|
411
571
|
sentinel: options.sentinel,
|
|
412
|
-
backstopMs:
|
|
572
|
+
backstopMs: backstop.ms,
|
|
413
573
|
});
|
|
414
574
|
|
|
415
575
|
if (!result.ok) return error(recordFailure('audio render', result));
|
|
416
576
|
|
|
417
|
-
const out =
|
|
577
|
+
const out = {
|
|
578
|
+
...recordOutput(result.value, harness),
|
|
579
|
+
...(options.analyze
|
|
580
|
+
? analyzeCapture(
|
|
581
|
+
result.value.wavPath,
|
|
582
|
+
target.kind === 'sfx' ? 'sfx' : 'music',
|
|
583
|
+
)
|
|
584
|
+
: {}),
|
|
585
|
+
};
|
|
418
586
|
return ok(
|
|
419
587
|
out,
|
|
420
588
|
out.captured ? undefined : {cta: noAudioCta(out.noAudioReason)},
|
|
@@ -422,12 +590,101 @@ function buildAudioGroup(adapterFactory: Pico8AdapterFactory): Cli.Cli {
|
|
|
422
590
|
},
|
|
423
591
|
});
|
|
424
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
|
+
|
|
425
682
|
return audio;
|
|
426
683
|
}
|
|
427
684
|
|
|
428
685
|
/**
|
|
429
|
-
* Registers the `audio` command group (`audio record`, `audio render`
|
|
430
|
-
* 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
|
|
431
688
|
* session, never an offline export. `adapterFactory` is injected so tests drive
|
|
432
689
|
* the orchestration against a fake runner (no paid binary in CI).
|
|
433
690
|
*/
|
package/src/commands/doctor.ts
CHANGED
|
@@ -73,6 +73,7 @@ const COMMAND_NEEDS: readonly {command: string; needs: readonly Need[]}[] = [
|
|
|
73
73
|
{command: 'run --record-audio', needs: ['pico8', 'display']},
|
|
74
74
|
{command: 'audio record', needs: ['pico8', 'display']},
|
|
75
75
|
{command: 'audio render', needs: ['pico8', 'display']},
|
|
76
|
+
{command: 'audio analyze', needs: []},
|
|
76
77
|
];
|
|
77
78
|
|
|
78
79
|
/** The PICO-8 version from its install path (e.g. `.../pico-8-0.2.7/...`), if it names one. */
|
|
@@ -270,3 +270,122 @@ describe('picopilot music from-patterns: input surface (arg vs --file)', () => {
|
|
|
270
270
|
expect(JSON.parse(stdout).code).toBe('cart-not-found');
|
|
271
271
|
});
|
|
272
272
|
});
|
|
273
|
+
|
|
274
|
+
/** The __sfx__ body lines of a cart file. */
|
|
275
|
+
function sfxLines(text: string): string[] {
|
|
276
|
+
const m = text.match(/__sfx__\n([\s\S]*?)(?:\n__|$)/);
|
|
277
|
+
return (m?.[1] ?? '').split('\n').filter((l) => l.length > 0);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
describe('picopilot music from-score (ADR-0020)', () => {
|
|
281
|
+
const gameSfx = mmlToSfxRow('s4 @3 v6 c e g').row;
|
|
282
|
+
|
|
283
|
+
it('writes two songs, reports start patterns, and leaves other content alone', async () => {
|
|
284
|
+
writeFileSync(cartPath, cartText({sfx: `${gameSfx}\n`}));
|
|
285
|
+
const score = {
|
|
286
|
+
songs: [
|
|
287
|
+
{name: 'title', speed: 16, channels: ['l32 c d', 'o1 l64 c', null]},
|
|
288
|
+
{name: 'game', speed: 20, channels: [null, 'o1 l16 g d g d']},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
const scoreFile = join(dir, 'score.json');
|
|
292
|
+
writeFileSync(scoreFile, JSON.stringify(score));
|
|
293
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
294
|
+
const {stdout, exitCode} = await runMusic([
|
|
295
|
+
'music',
|
|
296
|
+
'from-score',
|
|
297
|
+
'--file',
|
|
298
|
+
scoreFile,
|
|
299
|
+
'--cart',
|
|
300
|
+
cartPath,
|
|
301
|
+
'--json',
|
|
302
|
+
]);
|
|
303
|
+
expect(exitCode).toBe(0);
|
|
304
|
+
const out = JSON.parse(stdout);
|
|
305
|
+
expect(
|
|
306
|
+
out.songs.map((s: {name: string; startPattern: number}) => [
|
|
307
|
+
s.name,
|
|
308
|
+
s.startPattern,
|
|
309
|
+
]),
|
|
310
|
+
).toEqual([
|
|
311
|
+
['title', 0],
|
|
312
|
+
['game', 2],
|
|
313
|
+
]);
|
|
314
|
+
expect(out.patterns).toBe(4);
|
|
315
|
+
const after = readFileSync(cartPath, 'utf8');
|
|
316
|
+
// Slot 0 (the game SFX), __lua__ and __gfx__ are byte-identical.
|
|
317
|
+
expect(sfxLines(after)[0]).toBe(gameSfx);
|
|
318
|
+
expect(after.split('__sfx__')[0]).toBe(before.split('__sfx__')[0]);
|
|
319
|
+
// Free slots are used from 63 down; slot 0 is never touched.
|
|
320
|
+
expect(out.slots.every((s: {slot: number}) => s.slot > 0)).toBe(true);
|
|
321
|
+
expect(out.slots.every((s: {overwrote: boolean}) => !s.overwrote)).toBe(
|
|
322
|
+
true,
|
|
323
|
+
);
|
|
324
|
+
// The game song's first channel is off in every pattern.
|
|
325
|
+
const music = musicLines(after);
|
|
326
|
+
expect(music).toHaveLength(4);
|
|
327
|
+
for (const line of music.slice(2)) expect(line.slice(3, 5)).toBe('40');
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it('--slots owns a range: re-running is idempotent (byte-identical cart)', async () => {
|
|
331
|
+
writeFileSync(cartPath, cartText({sfx: `${gameSfx}\n`}));
|
|
332
|
+
const score = JSON.stringify({channels: ['l32 c d', 'o1 l64 c']});
|
|
333
|
+
const argv = [
|
|
334
|
+
'music',
|
|
335
|
+
'from-score',
|
|
336
|
+
score,
|
|
337
|
+
'--cart',
|
|
338
|
+
cartPath,
|
|
339
|
+
'--slots',
|
|
340
|
+
'40-50',
|
|
341
|
+
'--json',
|
|
342
|
+
];
|
|
343
|
+
const first = await runMusic(argv);
|
|
344
|
+
expect(first.exitCode).toBe(0);
|
|
345
|
+
const once = readFileSync(cartPath, 'utf8');
|
|
346
|
+
await runMusic(argv);
|
|
347
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(once);
|
|
348
|
+
const slots = JSON.parse(first.stdout).slots.map(
|
|
349
|
+
(s: {slot: number}) => s.slot,
|
|
350
|
+
);
|
|
351
|
+
expect(slots.every((s: number) => s >= 40 && s <= 50)).toBe(true);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('a bad score never touches the cart', async () => {
|
|
355
|
+
writeFileSync(cartPath, cartText({sfx: `${gameSfx}\n`}));
|
|
356
|
+
const before = readFileSync(cartPath, 'utf8');
|
|
357
|
+
for (const score of [
|
|
358
|
+
'{',
|
|
359
|
+
'{"channels":["o5 g"]}',
|
|
360
|
+
'{"channels":["{c}"]}',
|
|
361
|
+
]) {
|
|
362
|
+
const {stdout, exitCode} = await runMusic([
|
|
363
|
+
'music',
|
|
364
|
+
'from-score',
|
|
365
|
+
score,
|
|
366
|
+
'--cart',
|
|
367
|
+
cartPath,
|
|
368
|
+
'--json',
|
|
369
|
+
]);
|
|
370
|
+
expect(exitCode, score).not.toBe(0);
|
|
371
|
+
expect(stdout).toMatch(/audio-(score|mml)-/);
|
|
372
|
+
}
|
|
373
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(before);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('refuses a non-contiguous --slots', async () => {
|
|
377
|
+
writeFileSync(cartPath, cartText());
|
|
378
|
+
const {stdout, exitCode} = await runMusic([
|
|
379
|
+
'music',
|
|
380
|
+
'from-score',
|
|
381
|
+
'{"channels":["c"]}',
|
|
382
|
+
'--cart',
|
|
383
|
+
cartPath,
|
|
384
|
+
'--slots',
|
|
385
|
+
'3,9',
|
|
386
|
+
'--json',
|
|
387
|
+
]);
|
|
388
|
+
expect(exitCode).not.toBe(0);
|
|
389
|
+
expect(stdout).toContain('audio-score-bad-slot-range');
|
|
390
|
+
});
|
|
391
|
+
});
|