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/music.ts
CHANGED
|
@@ -3,9 +3,19 @@ import {isAbsolute, resolve} from 'node:path';
|
|
|
3
3
|
import {Cli, z} from 'incur';
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
|
+
AudioMmlError,
|
|
7
|
+
clearSfxSlot,
|
|
8
|
+
formatSlots,
|
|
9
|
+
mergeSfxRow,
|
|
6
10
|
MusicError,
|
|
11
|
+
musicSfxRefs,
|
|
12
|
+
parseSlotList,
|
|
7
13
|
patternsToMusic,
|
|
8
14
|
type Pattern,
|
|
15
|
+
planScore,
|
|
16
|
+
readScore,
|
|
17
|
+
readSfxRows,
|
|
18
|
+
ScoreError,
|
|
9
19
|
SFX_INDEX_MAX,
|
|
10
20
|
setMusicSection,
|
|
11
21
|
} from '../engine/audio/index.js';
|
|
@@ -270,11 +280,251 @@ function buildMusicGroup(): Cli.Cli {
|
|
|
270
280
|
},
|
|
271
281
|
});
|
|
272
282
|
|
|
283
|
+
music.command('from-score', {
|
|
284
|
+
description:
|
|
285
|
+
"Write whole SONGS from a score: up to 4 channels per song, each a picopilot-MML string of ANY length (ADR-0020). picopilot cuts every channel into 32-row SFX aligned across channels, makes a channel that is silent for a whole pattern OFF (null), de-duplicates identical SFX, allocates slots, and writes __sfx__ + __music__ (only the slots it writes or clears and __music__ change). Several songs (e.g. title + in-game) are laid out back to back; the report gives each song's start pattern for `music(n)`. Slots: by default only FREE (note-less) slots, filled from 63 down, never touching other content; --slots <from-to> gives the score a range it OWNS (it overwrites and clears that range; recommended when iterating, so re-runs are idempotent).",
|
|
286
|
+
args: z.object({
|
|
287
|
+
score: z
|
|
288
|
+
.string()
|
|
289
|
+
.optional()
|
|
290
|
+
.describe(
|
|
291
|
+
'The score as JSON: {"songs":[{"name":"title","speed":16,"loop":true,"channels":["<mml ch0>", {"mml":"<mml ch1>","filters":"!dampen"}, null, null]}]}. Per song: speed (ticks/row, default the channels\' shared s<N> or 16), patternRows (1-32, default 32), loop (default true; false = stop at the end), loopFrom (pattern within the song the loop returns to, default 0). One song may be given directly as {"channels":[...]}. Omit and pass --file instead.',
|
|
292
|
+
),
|
|
293
|
+
}),
|
|
294
|
+
options: z.object({
|
|
295
|
+
cart: z
|
|
296
|
+
.string()
|
|
297
|
+
.default('main.p8')
|
|
298
|
+
.describe(
|
|
299
|
+
'The .p8 cart to write. Defaults to main.p8 in the current folder.',
|
|
300
|
+
),
|
|
301
|
+
file: z
|
|
302
|
+
.string()
|
|
303
|
+
.optional()
|
|
304
|
+
.describe(
|
|
305
|
+
'Read the JSON score from this file instead of the `score` argument.',
|
|
306
|
+
),
|
|
307
|
+
slots: z
|
|
308
|
+
.string()
|
|
309
|
+
.optional()
|
|
310
|
+
.describe(
|
|
311
|
+
'A slot range the score OWNS, e.g. "16-63": any slot in it may be overwritten, and the ones the score does not use are cleared. Without it, only free (note-less) slots are used.',
|
|
312
|
+
),
|
|
313
|
+
}),
|
|
314
|
+
output: z.object({
|
|
315
|
+
cart: z
|
|
316
|
+
.string()
|
|
317
|
+
.describe('The absolute path of the cart that was written.'),
|
|
318
|
+
songs: z
|
|
319
|
+
.array(
|
|
320
|
+
z.object({
|
|
321
|
+
name: z.string(),
|
|
322
|
+
startPattern: z
|
|
323
|
+
.number()
|
|
324
|
+
.describe('Play this song with music(startPattern).'),
|
|
325
|
+
endPattern: z.number(),
|
|
326
|
+
patterns: z.number(),
|
|
327
|
+
speed: z.number(),
|
|
328
|
+
patternRows: z.number(),
|
|
329
|
+
loop: z.boolean(),
|
|
330
|
+
loopStartPattern: z.number().optional(),
|
|
331
|
+
seconds: z.number().describe('Length of one pass, in seconds.'),
|
|
332
|
+
}),
|
|
333
|
+
)
|
|
334
|
+
.describe('Each song as laid out in __music__.'),
|
|
335
|
+
patterns: z.number().describe('Total patterns written to __music__.'),
|
|
336
|
+
slots: z
|
|
337
|
+
.array(
|
|
338
|
+
z.object({
|
|
339
|
+
slot: z.number(),
|
|
340
|
+
uses: z.array(z.string()),
|
|
341
|
+
overwrote: z
|
|
342
|
+
.boolean()
|
|
343
|
+
.describe('The slot held different, non-empty content before.'),
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
.describe('Every SFX slot the score wrote, and where it plays.'),
|
|
347
|
+
slotRange: z
|
|
348
|
+
.string()
|
|
349
|
+
.describe('The slots used, compactly (e.g. "40-63").'),
|
|
350
|
+
cleared: z
|
|
351
|
+
.array(z.number())
|
|
352
|
+
.describe(
|
|
353
|
+
'Owned slots (--slots) the score does not use, cleared to empty.',
|
|
354
|
+
),
|
|
355
|
+
warnings: z.array(z.string()).describe('Advisory warnings.'),
|
|
356
|
+
}),
|
|
357
|
+
examples: [
|
|
358
|
+
{
|
|
359
|
+
description:
|
|
360
|
+
'Two songs (title + in-game) from a file, owning slots 16-63',
|
|
361
|
+
args: {},
|
|
362
|
+
options: {file: 'score.json', slots: '16-63'},
|
|
363
|
+
},
|
|
364
|
+
{
|
|
365
|
+
description: 'One short looping song inline',
|
|
366
|
+
args: {
|
|
367
|
+
score:
|
|
368
|
+
'{"speed":16,"channels":["!dampen @0 v[3 2 2 1/0] o2 l8 a d+ f e","!dampen @0 v[2 2 1] o1 l16 d a-"]}',
|
|
369
|
+
},
|
|
370
|
+
},
|
|
371
|
+
],
|
|
372
|
+
run({args, options, error, ok}) {
|
|
373
|
+
let text: string;
|
|
374
|
+
if (options.file !== undefined) {
|
|
375
|
+
if (args.score !== undefined) {
|
|
376
|
+
return error({
|
|
377
|
+
code: 'ambiguous-input',
|
|
378
|
+
message:
|
|
379
|
+
'pass EITHER the score argument OR --file, not both (they are two ways to give the same source).',
|
|
380
|
+
exitCode: 1,
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
const filePath = resolvePath(options.file);
|
|
384
|
+
if (!existsSync(filePath)) {
|
|
385
|
+
return error({
|
|
386
|
+
code: 'score-file-not-found',
|
|
387
|
+
message: `no score file at ${filePath}`,
|
|
388
|
+
exitCode: 1,
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
text = readFileSync(filePath, 'utf8');
|
|
392
|
+
} else if (args.score !== undefined) {
|
|
393
|
+
text = args.score;
|
|
394
|
+
} else {
|
|
395
|
+
return error({
|
|
396
|
+
code: 'no-score',
|
|
397
|
+
message:
|
|
398
|
+
'no score: pass it as the `score` argument (JSON) or via --file.',
|
|
399
|
+
exitCode: 1,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
let range: {from: number; to: number} | undefined;
|
|
404
|
+
if (options.slots !== undefined) {
|
|
405
|
+
const list = parseSlotList(options.slots);
|
|
406
|
+
const ok =
|
|
407
|
+
list !== undefined &&
|
|
408
|
+
list.length > 0 &&
|
|
409
|
+
list[list.length - 1]! - list[0]! + 1 === list.length;
|
|
410
|
+
if (!ok) {
|
|
411
|
+
return error({
|
|
412
|
+
code: 'audio-score-bad-slot-range',
|
|
413
|
+
message: `--slots "${options.slots}" is not one contiguous range within 0-${SFX_INDEX_MAX}, e.g. "16-63".`,
|
|
414
|
+
exitCode: 1,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
range = {from: list![0]!, to: list![list!.length - 1]!};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const cartPath = resolvePath(options.cart);
|
|
421
|
+
const loaded = loadCart(cartPath);
|
|
422
|
+
if (!loaded.ok) {
|
|
423
|
+
return error({code: loaded.code, message: loaded.message, exitCode: 1});
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Plan FIRST (pure): a bad score never touches the cart.
|
|
427
|
+
let plan: ReturnType<typeof planScore>;
|
|
428
|
+
try {
|
|
429
|
+
let raw: unknown;
|
|
430
|
+
try {
|
|
431
|
+
raw = JSON.parse(text);
|
|
432
|
+
} catch (e) {
|
|
433
|
+
throw new ScoreError(
|
|
434
|
+
'audio-score-parse-error',
|
|
435
|
+
`score is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
plan = planScore(
|
|
439
|
+
readScore(raw),
|
|
440
|
+
{
|
|
441
|
+
sfxRows: readSfxRows(loaded.cart),
|
|
442
|
+
musicRefs: musicSfxRefs(loaded.cart.getSection('music')),
|
|
443
|
+
},
|
|
444
|
+
range !== undefined ? {slots: range} : {},
|
|
445
|
+
);
|
|
446
|
+
} catch (e) {
|
|
447
|
+
if (e instanceof ScoreError || e instanceof AudioMmlError) {
|
|
448
|
+
return error({
|
|
449
|
+
code: e.code,
|
|
450
|
+
message: e.message,
|
|
451
|
+
exitCode: 1,
|
|
452
|
+
...(e.code === 'audio-score-no-free-slots'
|
|
453
|
+
? {
|
|
454
|
+
cta: {
|
|
455
|
+
description: 'Give the music a slot range it owns:',
|
|
456
|
+
commands: [
|
|
457
|
+
{
|
|
458
|
+
command:
|
|
459
|
+
'music from-score --file <score.json> --slots 16-63',
|
|
460
|
+
description:
|
|
461
|
+
'The score overwrites and clears that range (and nothing else).',
|
|
462
|
+
},
|
|
463
|
+
],
|
|
464
|
+
},
|
|
465
|
+
}
|
|
466
|
+
: {}),
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
throw e;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
for (const s of plan.slots) mergeSfxRow(loaded.cart, s.slot, s.row);
|
|
473
|
+
for (const slot of plan.cleared) clearSfxSlot(loaded.cart, slot);
|
|
474
|
+
setMusicSection(loaded.cart, plan.musicSection);
|
|
475
|
+
writeFileSync(cartPath, loaded.cart.serialize());
|
|
476
|
+
|
|
477
|
+
const lua = plan.songs
|
|
478
|
+
.map((s) => `${s.name}: music(${s.startPattern})`)
|
|
479
|
+
.join(', ');
|
|
480
|
+
return ok(
|
|
481
|
+
{
|
|
482
|
+
cart: cartPath,
|
|
483
|
+
songs: plan.songs.map((s) => ({
|
|
484
|
+
name: s.name,
|
|
485
|
+
startPattern: s.startPattern,
|
|
486
|
+
endPattern: s.endPattern,
|
|
487
|
+
patterns: s.patterns,
|
|
488
|
+
speed: s.speed,
|
|
489
|
+
patternRows: s.patternRows,
|
|
490
|
+
loop: s.loop,
|
|
491
|
+
...(s.loopStartPattern !== undefined
|
|
492
|
+
? {loopStartPattern: s.loopStartPattern}
|
|
493
|
+
: {}),
|
|
494
|
+
seconds: s.seconds,
|
|
495
|
+
})),
|
|
496
|
+
patterns: plan.patterns,
|
|
497
|
+
slots: plan.slots.map((s) => ({
|
|
498
|
+
slot: s.slot,
|
|
499
|
+
uses: s.uses,
|
|
500
|
+
overwrote: s.overwrote,
|
|
501
|
+
})),
|
|
502
|
+
slotRange: formatSlots(plan.slots.map((s) => s.slot)),
|
|
503
|
+
cleared: plan.cleared,
|
|
504
|
+
warnings: plan.warnings,
|
|
505
|
+
},
|
|
506
|
+
{
|
|
507
|
+
cta: {
|
|
508
|
+
description: `Written. Play from Lua with ${lua} (add a channel mask, e.g. music(n,0,7), to leave channel 3 for SFX). Then hear and measure it:`,
|
|
509
|
+
commands: [
|
|
510
|
+
{
|
|
511
|
+
command: `audio render --pattern ${plan.songs[0]!.startPattern} --seconds ${Math.ceil(2 * plan.songs[0]!.seconds + 2)} --analyze`,
|
|
512
|
+
description:
|
|
513
|
+
'Record the first song twice over (the loop length needs two passes) and get loudness/brightness/repetition numbers.',
|
|
514
|
+
},
|
|
515
|
+
],
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
);
|
|
519
|
+
},
|
|
520
|
+
});
|
|
521
|
+
|
|
273
522
|
return music;
|
|
274
523
|
}
|
|
275
524
|
|
|
276
525
|
/**
|
|
277
|
-
* Registers the `music` command group (`music from-patterns`
|
|
526
|
+
* Registers the `music` command group (`music from-patterns`, `music
|
|
527
|
+
* from-score`) on the root CLI.
|
|
278
528
|
*
|
|
279
529
|
* `music` is a shrinko-FREE and PICO-8-FREE command group: it assembles a
|
|
280
530
|
* structural pattern list into `__music__` and merges it through the cart model
|
package/src/commands/sfx.test.ts
CHANGED
|
@@ -7,6 +7,9 @@ import {afterEach, beforeEach, describe, expect, it} from 'vitest';
|
|
|
7
7
|
import {mmlToSfxRow, SFX_ROW_LENGTH} from '../engine/audio/index.js';
|
|
8
8
|
import {createCli} from '../cli.js';
|
|
9
9
|
|
|
10
|
+
/** The .p8 header lines. */
|
|
11
|
+
const HEADER = 'pico-8 cartridge // http://www.pico-8.com\nversion 42\n';
|
|
12
|
+
|
|
10
13
|
/** A minimal valid cart with a lua + gfx section (to prove they stay untouched). */
|
|
11
14
|
function cartText(sfxBody?: string): string {
|
|
12
15
|
let text =
|
|
@@ -347,3 +350,55 @@ describe('picopilot sfx from-mml: dotted-length rounding is reported', () => {
|
|
|
347
350
|
expect(out.warnings[0]).toContain('rounded');
|
|
348
351
|
});
|
|
349
352
|
});
|
|
353
|
+
|
|
354
|
+
describe('picopilot sfx clear', () => {
|
|
355
|
+
it('clears the named slots and nothing else', async () => {
|
|
356
|
+
const a = mmlToSfxRow('s4 c').row;
|
|
357
|
+
const b = mmlToSfxRow('s5 d').row;
|
|
358
|
+
const c = mmlToSfxRow('s6 e0 c').row;
|
|
359
|
+
writeFileSync(
|
|
360
|
+
cartPath,
|
|
361
|
+
`${HEADER}__lua__\nprint(1)\n__sfx__\n${a}\n${b}\n${c}\n`,
|
|
362
|
+
);
|
|
363
|
+
const {stdout, exitCode} = await runSfx([
|
|
364
|
+
'sfx',
|
|
365
|
+
'clear',
|
|
366
|
+
'1,5',
|
|
367
|
+
'--cart',
|
|
368
|
+
cartPath,
|
|
369
|
+
'--json',
|
|
370
|
+
]);
|
|
371
|
+
expect(exitCode).toBe(0);
|
|
372
|
+
const out = JSON.parse(stdout);
|
|
373
|
+
expect(out.cleared).toEqual([1]);
|
|
374
|
+
expect(out.alreadyEmpty).toEqual([5]);
|
|
375
|
+
const text = readFileSync(cartPath, 'utf8');
|
|
376
|
+
expect(text).toContain(`${a}\n${'0'.repeat(168)}\n${c}\n`);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('clearing the last slots trims them (what PICO-8 writes)', async () => {
|
|
380
|
+
const a = mmlToSfxRow('s4 c').row;
|
|
381
|
+
writeFileSync(
|
|
382
|
+
cartPath,
|
|
383
|
+
`${HEADER}__lua__\nprint(1)\n__sfx__\n${a}\n${a}\n`,
|
|
384
|
+
);
|
|
385
|
+
await runSfx(['sfx', 'clear', '1-63', '--cart', cartPath, '--json']);
|
|
386
|
+
expect(readFileSync(cartPath, 'utf8')).toBe(
|
|
387
|
+
`${HEADER}__lua__\nprint(1)\n__sfx__\n${a}\n`,
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it('refuses a bad slot list', async () => {
|
|
392
|
+
writeFileSync(cartPath, `${HEADER}__lua__\nprint(1)\n`);
|
|
393
|
+
const {stdout, exitCode} = await runSfx([
|
|
394
|
+
'sfx',
|
|
395
|
+
'clear',
|
|
396
|
+
'70',
|
|
397
|
+
'--cart',
|
|
398
|
+
cartPath,
|
|
399
|
+
'--json',
|
|
400
|
+
]);
|
|
401
|
+
expect(exitCode).not.toBe(0);
|
|
402
|
+
expect(stdout).toContain('bad-slot-list');
|
|
403
|
+
});
|
|
404
|
+
});
|
package/src/commands/sfx.ts
CHANGED
|
@@ -4,9 +4,14 @@ import {Cli, z} from 'incur';
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
AudioMmlError,
|
|
7
|
+
clearSfxSlot,
|
|
8
|
+
formatSlots,
|
|
9
|
+
isFreeSfxRow,
|
|
7
10
|
mergeSfxRow,
|
|
8
11
|
mmlToSfxRow,
|
|
12
|
+
parseSlotList,
|
|
9
13
|
PITCH_MAX,
|
|
14
|
+
readSfxRows,
|
|
10
15
|
SFX_MAX_ROWS,
|
|
11
16
|
SFX_SLOT_COUNT,
|
|
12
17
|
} from '../engine/audio/index.js';
|
|
@@ -58,7 +63,7 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
58
63
|
|
|
59
64
|
sfx.command('from-mml', {
|
|
60
65
|
description:
|
|
61
|
-
'Transpile picopilot-MML text into one __sfx__ slot (0-63) and merge it into the cart (only that slot changes). picopilot-MML is TRACKER-ROW based: a length is a ROW COUNT (l4 = 4 rows), NOT a musical note value, and there is NO tempo/BPM (PICO-8 measures in ticks/row via `s<N>` speed). Notes carry per-note waveform (@0-7, @8-f = custom SFX-instrument), volume (v0-7), and effect (e0-7) that a score notation cannot express. SFX-level FILTER directives reach DESIGNED sounds (explosion/engine/pad): !dampen[2] (low-pass body), !reverb (echo tail), !noiz (@6 white noise), !buzz, !detune1/!detune2. Out-of-range pitch (below C0 / above D#5), >32 rows, and a bad filter level are refused loudly, never silently clamped.',
|
|
66
|
+
'Transpile picopilot-MML text into one __sfx__ slot (0-63) and merge it into the cart (only that slot changes). picopilot-MML is TRACKER-ROW based: a length is a ROW COUNT (l4 = 4 rows), NOT a musical note value, and there is NO tempo/BPM (PICO-8 measures in ticks/row via `s<N>` speed). Notes carry per-note waveform (@0-7, @8-f = custom SFX-instrument), volume (v0-7), and effect (e0-7) that a score notation cannot express. A held note (c8) is ONE note: fade in/out and drop land once per held note, not on every row (ADR-0019); shape its volume with an envelope v[3 2 2 1/0] (one digit per row, last value held, after / = release) and delay vibrato with ~N (vibrato from row N). For melodies over 32 rows use `music from-score`. SFX-level FILTER directives reach DESIGNED sounds (explosion/engine/pad): !dampen[2] (low-pass body), !reverb (echo tail), !noiz (@6 white noise), !buzz, !detune1/!detune2. Out-of-range pitch (below C0 / above D#5), >32 rows, and a bad filter level are refused loudly, never silently clamped.',
|
|
62
67
|
args: z.object({
|
|
63
68
|
slot: z.coerce
|
|
64
69
|
.number()
|
|
@@ -70,7 +75,7 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
70
75
|
.string()
|
|
71
76
|
.optional()
|
|
72
77
|
.describe(
|
|
73
|
-
'The picopilot-MML source (e.g. "s16 @1 v6 c d e f"). Omit and pass --file to read from a file instead. Modal state: @ waveform, v volume, e effect, o octave, l default length (all sticky). Durations are ROWS: c4 =
|
|
78
|
+
'The picopilot-MML source (e.g. "s16 @1 v6 c d e f"). Omit and pass --file to read from a file instead. Modal state: @ waveform, v volume or v[...] envelope, e effect, ~N delayed vibrato, o octave, l default length (all sticky). Durations are ROWS: c4 = one note held 4 rows. Loop with {..}; a lone { marks the pattern LEN. SFX filters (per-SFX, place anywhere): !dampen/!dampen2, !reverb, !noiz, !buzz, !detune1/!detune2 (e.g. "!dampen !reverb @6 v7 o3 e c o1 g" = an explosion).',
|
|
74
79
|
),
|
|
75
80
|
}),
|
|
76
81
|
options: z.object({
|
|
@@ -135,6 +140,11 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
135
140
|
'A DESIGNED explosion: DAMPEN body + REVERB tail on @6 noise',
|
|
136
141
|
args: {slot: 3, mml: 's7 !dampen2 !reverb @6 v7 o3 e c o1 g c'},
|
|
137
142
|
},
|
|
143
|
+
{
|
|
144
|
+
description:
|
|
145
|
+
'A soft lead note: held 8 rows with a decaying envelope and vibrato from row 4',
|
|
146
|
+
args: {slot: 6, mml: 's16 !dampen @0 ~4 v[3 3 2 2 2 1/1 0] o2 a8'},
|
|
147
|
+
},
|
|
138
148
|
{
|
|
139
149
|
description: 'Read a longer source from a file',
|
|
140
150
|
args: {slot: 1},
|
|
@@ -199,28 +209,40 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
199
209
|
}
|
|
200
210
|
: e.code === 'audio-mml-sfx-overflow'
|
|
201
211
|
? {
|
|
202
|
-
description: `One SFX holds at most ${SFX_MAX_ROWS} rows;
|
|
212
|
+
description: `One SFX holds at most ${SFX_MAX_ROWS} rows; for longer melodies:`,
|
|
203
213
|
commands: [
|
|
204
214
|
{
|
|
205
|
-
command: '
|
|
215
|
+
command: 'music from-score --file score.json',
|
|
206
216
|
description:
|
|
207
|
-
'
|
|
217
|
+
'Write each voice at any length; picopilot splits it into SFX + patterns.',
|
|
208
218
|
},
|
|
209
219
|
],
|
|
210
220
|
}
|
|
211
|
-
: e.code === 'audio-mml-
|
|
221
|
+
: e.code === 'audio-mml-tie-state-change'
|
|
212
222
|
? {
|
|
213
|
-
description:
|
|
223
|
+
description:
|
|
224
|
+
'A tie continues one held note; shape it instead:',
|
|
214
225
|
commands: [
|
|
215
226
|
{
|
|
216
|
-
command:
|
|
217
|
-
'sfx from-mml <slot> "!dampen[2] !reverb !noiz !buzz !detune1/!detune2 ..."',
|
|
227
|
+
command: 'sfx from-mml <slot> "v[3 2 2 1] c8"',
|
|
218
228
|
description:
|
|
219
|
-
'
|
|
229
|
+
'A volume envelope: one digit per row, last value held.',
|
|
220
230
|
},
|
|
221
231
|
],
|
|
222
232
|
}
|
|
223
|
-
:
|
|
233
|
+
: e.code === 'audio-mml-filter-level-out-of-range'
|
|
234
|
+
? {
|
|
235
|
+
description: 'The SFX filters + their valid levels:',
|
|
236
|
+
commands: [
|
|
237
|
+
{
|
|
238
|
+
command:
|
|
239
|
+
'sfx from-mml <slot> "!dampen[2] !reverb !noiz !buzz !detune1/!detune2 ..."',
|
|
240
|
+
description:
|
|
241
|
+
'!dampen has 2 levels (!dampen/!dampen2); !reverb/!noiz/!buzz are on/off; !detune needs 1 or 2.',
|
|
242
|
+
},
|
|
243
|
+
],
|
|
244
|
+
}
|
|
245
|
+
: undefined;
|
|
224
246
|
return error({
|
|
225
247
|
code: e.code,
|
|
226
248
|
message: e.message,
|
|
@@ -282,6 +304,91 @@ function buildSfxGroup(): Cli.Cli {
|
|
|
282
304
|
},
|
|
283
305
|
});
|
|
284
306
|
|
|
307
|
+
sfx.command('clear', {
|
|
308
|
+
description:
|
|
309
|
+
'Clear SFX slots back to EMPTY (no notes). Only the named slots change; every other slot and section stays byte-identical. Use it to free slots for `music from-score` or to remove a leftover song.',
|
|
310
|
+
args: z.object({
|
|
311
|
+
slots: z
|
|
312
|
+
.string()
|
|
313
|
+
.describe(
|
|
314
|
+
'The slots to clear: one slot ("5"), a range ("16-63"), or a list ("3,5,16-20").',
|
|
315
|
+
),
|
|
316
|
+
}),
|
|
317
|
+
options: z.object({
|
|
318
|
+
cart: z
|
|
319
|
+
.string()
|
|
320
|
+
.default('main.p8')
|
|
321
|
+
.describe(
|
|
322
|
+
'The .p8 cart to edit. Defaults to main.p8 in the current folder.',
|
|
323
|
+
),
|
|
324
|
+
}),
|
|
325
|
+
output: z.object({
|
|
326
|
+
cart: z
|
|
327
|
+
.string()
|
|
328
|
+
.describe('The absolute path of the cart that was written.'),
|
|
329
|
+
cleared: z
|
|
330
|
+
.array(z.number())
|
|
331
|
+
.describe('The slots that held notes and are now empty.'),
|
|
332
|
+
alreadyEmpty: z
|
|
333
|
+
.array(z.number())
|
|
334
|
+
.describe(
|
|
335
|
+
'The named slots that had no notes already (left byte-identical).',
|
|
336
|
+
),
|
|
337
|
+
}),
|
|
338
|
+
examples: [
|
|
339
|
+
{description: 'Clear slot 11', args: {slots: '11'}},
|
|
340
|
+
{
|
|
341
|
+
description: 'Clear a whole music range of a cart',
|
|
342
|
+
args: {slots: '16-63'},
|
|
343
|
+
options: {cart: 'game.p8'},
|
|
344
|
+
},
|
|
345
|
+
],
|
|
346
|
+
run({args, options, error, ok}) {
|
|
347
|
+
const slots = parseSlotList(args.slots);
|
|
348
|
+
if (slots === undefined) {
|
|
349
|
+
return error({
|
|
350
|
+
code: 'bad-slot-list',
|
|
351
|
+
message: `"${args.slots}" is not a slot list within 0-${SFX_SLOT_COUNT - 1}: use "5", "16-63" or "3,5,16-20".`,
|
|
352
|
+
exitCode: 1,
|
|
353
|
+
});
|
|
354
|
+
}
|
|
355
|
+
const cartPath = resolvePath(options.cart);
|
|
356
|
+
const loaded = loadCart(cartPath);
|
|
357
|
+
if (!loaded.ok) {
|
|
358
|
+
return error({code: loaded.code, message: loaded.message, exitCode: 1});
|
|
359
|
+
}
|
|
360
|
+
const before = readSfxRows(loaded.cart);
|
|
361
|
+
const cleared: number[] = [];
|
|
362
|
+
const alreadyEmpty: number[] = [];
|
|
363
|
+
for (const slot of slots) {
|
|
364
|
+
// A note-less slot is already empty: leave its bytes (header) alone.
|
|
365
|
+
if (isFreeSfxRow(before[slot])) {
|
|
366
|
+
alreadyEmpty.push(slot);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
cleared.push(slot);
|
|
370
|
+
clearSfxSlot(loaded.cart, slot);
|
|
371
|
+
}
|
|
372
|
+
writeFileSync(cartPath, loaded.cart.serialize());
|
|
373
|
+
return ok(
|
|
374
|
+
{cart: cartPath, cleared, alreadyEmpty},
|
|
375
|
+
cleared.length > 0
|
|
376
|
+
? {
|
|
377
|
+
cta: {
|
|
378
|
+
description: `Cleared SFX ${formatSlots(cleared)}. If __music__ still references them, those channels are now silent:`,
|
|
379
|
+
commands: [
|
|
380
|
+
{
|
|
381
|
+
command: 'verify',
|
|
382
|
+
description: 'Static gate: the cart still parses.',
|
|
383
|
+
},
|
|
384
|
+
],
|
|
385
|
+
},
|
|
386
|
+
}
|
|
387
|
+
: undefined,
|
|
388
|
+
);
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
|
|
285
392
|
return sfx;
|
|
286
393
|
}
|
|
287
394
|
|