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
@@ -1,6 +1,6 @@
1
1
  import {describe, expect, it} from 'vitest';
2
2
 
3
- import {AudioMmlError, mmlToSfxRow, SFX_ROW_LENGTH} from './sfx.js';
3
+ import {AudioMmlError, mmlToRows, mmlToSfxRow, SFX_ROW_LENGTH} from './sfx.js';
4
4
 
5
5
  /** The 8-char header of a row (mode/speed/loopstart/loopend). */
6
6
  function header(row: string): string {
@@ -166,10 +166,10 @@ describe('mmlToSfxRow: full combined-field probes (finding A.2)', () => {
166
166
  });
167
167
 
168
168
  describe('mmlToSfxRow: durations are TRACKER ROWS (finding B.4)', () => {
169
- it('a bare note is one row; cN holds it for N rows via tie rows', () => {
169
+ it('a bare note is one row; cN holds it for N rows (one held note)', () => {
170
170
  const {row, rows} = mmlToSfxRow('c4');
171
171
  expect(rows).toBe(4);
172
- // Same pitch/wave/vol/eff re-emitted in rows 0..3.
172
+ // Same pitch/wave/vol/eff on rows 0..3 (PICO-8 plays them as one tone).
173
173
  for (let r = 0; r < 4; r++) expect(note(row, r)).toBe('18070');
174
174
  expect(note(row, 4)).toBe('00000'); // row 5 empty
175
175
  });
@@ -192,7 +192,7 @@ describe('mmlToSfxRow: durations are TRACKER ROWS (finding B.4)', () => {
192
192
 
193
193
  it('^ (tie) extends the previous note by its length in rows', () => {
194
194
  const {row, rows} = mmlToSfxRow('c ^3');
195
- expect(rows).toBe(4); // 1 + 3 tie rows
195
+ expect(rows).toBe(4); // one note held 1 + 3 rows
196
196
  for (let r = 0; r < 4; r++) expect(note(row, r)).toBe('18070');
197
197
  });
198
198
 
@@ -408,3 +408,218 @@ describe('mmlToSfxRow: parse errors (tempo-style inputs rejected loudly)', () =>
408
408
  expect(mmlToSfxRow('c d e').row).toBe(mmlToSfxRow('c|d|e').row);
409
409
  });
410
410
  });
411
+
412
+ /** Decodes row `r` of an emitted `__sfx__` row into its 4 note fields. */
413
+ function decode(row: string, r: number) {
414
+ const n = note(row, r);
415
+ return {
416
+ pitch: Number.parseInt(n.slice(0, 2), 16),
417
+ wave: Number.parseInt(n[2]!, 16),
418
+ vol: Number.parseInt(n[3]!, 16),
419
+ fx: Number.parseInt(n[4]!, 16),
420
+ };
421
+ }
422
+
423
+ /** The decoded [pitch, wave, vol, fx] of rows 0..n-1 (compact for toEqual). */
424
+ function rowsOf(mml: string, n?: number): number[][] {
425
+ const res = mmlToSfxRow(mml);
426
+ const count = n ?? res.rows;
427
+ const out: number[][] = [];
428
+ for (let r = 0; r < count; r++) {
429
+ const d = decode(res.row, r);
430
+ out.push([d.pitch, d.wave, d.vol, d.fx]);
431
+ }
432
+ return out;
433
+ }
434
+
435
+ describe('held notes are ONE shaped note, not a stutter (ADR-0019)', () => {
436
+ it('fade out (e5) on a held note is a volume ramp with e5 on the LAST row only', () => {
437
+ // v5 held 4 rows: ceil(5*(1-k/4)) = 5,4,3,2 then the last row fades to 0.
438
+ expect(rowsOf('@0 v5 e5 c4')).toEqual([
439
+ [24, 0, 5, 0],
440
+ [24, 0, 4, 0],
441
+ [24, 0, 3, 0],
442
+ [24, 0, 2, 5],
443
+ ]);
444
+ });
445
+
446
+ it('the ramp never reaches a silent row before the note ends', () => {
447
+ const vols = rowsOf('v2 e5 c8').map((r) => r[2]);
448
+ expect(vols).toEqual([2, 2, 2, 2, 1, 1, 1, 1]);
449
+ expect(rowsOf('v2 e5 c8')[7]![3]).toBe(5);
450
+ });
451
+
452
+ it('`l4 e5 c` is one decaying note per held note (the conquest stutter is gone)', () => {
453
+ const fx = rowsOf('l4 v4 e5 c d').map((r) => r[3]);
454
+ expect(fx).toEqual([0, 0, 0, 5, 0, 0, 0, 5]);
455
+ });
456
+
457
+ it('fade in (e4) is the attack: first row only, the rest hold at full volume', () => {
458
+ expect(rowsOf('@3 v6 e4 g3')).toEqual([
459
+ [31, 3, 6, 4],
460
+ [31, 3, 6, 0],
461
+ [31, 3, 6, 0],
462
+ ]);
463
+ });
464
+
465
+ it('drop (e3) is a one-row hit; the rest of the held note is silent', () => {
466
+ expect(rowsOf('@0 v6 e3 o1 c4')).toEqual([
467
+ [12, 0, 6, 3],
468
+ [0, 0, 0, 0],
469
+ [0, 0, 0, 0],
470
+ [0, 0, 0, 0],
471
+ ]);
472
+ });
473
+
474
+ it('vibrato, slide and arpeggio continue on every held row', () => {
475
+ for (const fx of [1, 2, 6, 7]) {
476
+ const got = rowsOf(`v3 e${fx} c3`).map((r) => r[3]);
477
+ expect(got).toEqual([fx, fx, fx]);
478
+ }
479
+ });
480
+
481
+ it('a one-row note keeps its effect exactly (separate notes still re-trigger)', () => {
482
+ // Four 1-row plucks: the explicit way to get repeated fades.
483
+ expect(rowsOf('v4 e5 c c c c').map((r) => r[3])).toEqual([5, 5, 5, 5]);
484
+ expect(rowsOf('v4 e5 c c c c').map((r) => r[2])).toEqual([4, 4, 4, 4]);
485
+ });
486
+
487
+ it('a ^ tie re-shapes the note as one longer note', () => {
488
+ // `c2 ^2` == `c4`: the fade lands once, at the end of the whole hold.
489
+ expect(rowsOf('v5 e5 c2 ^2')).toEqual(rowsOf('v5 e5 c4'));
490
+ expect(rowsOf('v6 e3 c ^3')).toEqual(rowsOf('v6 e3 c4'));
491
+ });
492
+
493
+ it('a modal change between a note and its tie is refused (audio-mml-tie-state-change)', () => {
494
+ for (const mml of [
495
+ 'c4 v3 ^4',
496
+ 'c4 e5 ^2',
497
+ 'c2 @3 ^2',
498
+ 'c2 v[32] ^2',
499
+ 'c2 ~1 ^2',
500
+ ]) {
501
+ let err: unknown;
502
+ try {
503
+ mmlToSfxRow(mml);
504
+ } catch (e) {
505
+ err = e;
506
+ }
507
+ expect(err, mml).toBeInstanceOf(AudioMmlError);
508
+ expect((err as AudioMmlError).code).toBe('audio-mml-tie-state-change');
509
+ expect((err as AudioMmlError).message).toContain('v[');
510
+ }
511
+ });
512
+
513
+ it('octave and default-length changes between a note and its tie are fine', () => {
514
+ expect(rowsOf('c o4 ^3')).toEqual(rowsOf('c4'));
515
+ expect(rowsOf('c l3 ^')).toEqual(rowsOf('c4'));
516
+ });
517
+
518
+ it('a tie that would overflow 32 rows is still audio-mml-sfx-overflow', () => {
519
+ expect(() => mmlToSfxRow('c16 ^17')).toThrow(/32/);
520
+ });
521
+ });
522
+
523
+ describe('v[...] per-note volume envelope (ADR-0019)', () => {
524
+ it('one digit per row; the last value is held', () => {
525
+ expect(rowsOf('@0 v[3 3 2 2 2 1 1 0] o2 a8')).toEqual([
526
+ [33, 0, 3, 0],
527
+ [33, 0, 3, 0],
528
+ [33, 0, 2, 0],
529
+ [33, 0, 2, 0],
530
+ [33, 0, 2, 0],
531
+ [33, 0, 1, 0],
532
+ [33, 0, 1, 0],
533
+ [33, 0, 0, 0],
534
+ ]);
535
+ expect(rowsOf('v[321] c6').map((r) => r[2])).toEqual([3, 2, 1, 1, 1, 1]);
536
+ });
537
+
538
+ it('spaces are optional and the envelope is modal (every later note uses it)', () => {
539
+ expect(rowsOf('v[3 2 1] c3 d3')).toEqual(rowsOf('v[321] c3 d3'));
540
+ expect(rowsOf('v[321] c3 d3').map((r) => r[2])).toEqual([3, 2, 1, 3, 2, 1]);
541
+ });
542
+
543
+ it('a tail after / is the release, placed on the last rows of each note', () => {
544
+ expect(rowsOf('v[3 2/1 0] c6').map((r) => r[2])).toEqual([
545
+ 3, 2, 2, 2, 1, 0,
546
+ ]);
547
+ // A note no longer than the tail gets the head only (it must sound).
548
+ expect(rowsOf('v[3 2/1 0] c2').map((r) => r[2])).toEqual([3, 2]);
549
+ expect(rowsOf('v[3 2/1 0] c').map((r) => r[2])).toEqual([3]);
550
+ expect(rowsOf('v[3/1] c3').map((r) => r[2])).toEqual([3, 3, 1]);
551
+ });
552
+
553
+ it('a flat v<N> clears the envelope', () => {
554
+ expect(rowsOf('v[31] c2 v5 c2').map((r) => r[2])).toEqual([3, 1, 5, 5]);
555
+ });
556
+
557
+ it('with an envelope, e5 still fades only the last row', () => {
558
+ expect(rowsOf('v[3 2 2 1] e5 c4')).toEqual([
559
+ [24, 0, 3, 0],
560
+ [24, 0, 2, 0],
561
+ [24, 0, 2, 0],
562
+ [24, 0, 1, 5],
563
+ ]);
564
+ });
565
+
566
+ it('a silent envelope row carries no effect', () => {
567
+ expect(rowsOf('v[2 0] e2 c2')).toEqual([
568
+ [24, 0, 2, 2],
569
+ [24, 0, 0, 0],
570
+ ]);
571
+ });
572
+
573
+ it('malformed envelopes are parse errors', () => {
574
+ for (const mml of [
575
+ 'v[ c',
576
+ 'v[8] c',
577
+ 'v[] c',
578
+ 'v[/1] c',
579
+ 'v[1/2/3] c',
580
+ 'v[a] c',
581
+ ]) {
582
+ let err: unknown;
583
+ try {
584
+ mmlToSfxRow(mml);
585
+ } catch (e) {
586
+ err = e;
587
+ }
588
+ expect(err, mml).toBeInstanceOf(AudioMmlError);
589
+ expect((err as AudioMmlError).code).toBe('audio-mml-parse-error');
590
+ }
591
+ });
592
+ });
593
+
594
+ describe('~N delayed vibrato (ADR-0019)', () => {
595
+ it('a held note switches to e2 from row N', () => {
596
+ expect(rowsOf('v3 ~3 c6').map((r) => r[3])).toEqual([0, 0, 0, 2, 2, 2]);
597
+ });
598
+
599
+ it('notes shorter than the delay never get vibrato', () => {
600
+ expect(rowsOf('v3 ~3 c2 d3').map((r) => r[3])).toEqual([0, 0, 0, 0, 0]);
601
+ });
602
+
603
+ it('it does not override an explicit effect (the fade-out row stays e5)', () => {
604
+ expect(rowsOf('v3 ~2 e5 c4').map((r) => r[3])).toEqual([0, 0, 2, 5]);
605
+ });
606
+
607
+ it('~0 turns it off; a delay past 31 is a parse error', () => {
608
+ expect(rowsOf('~2 ~0 c4').map((r) => r[3])).toEqual([0, 0, 0, 0]);
609
+ expect(() => mmlToSfxRow('~32 c')).toThrow(AudioMmlError);
610
+ expect(() => mmlToSfxRow('~ c')).toThrow(AudioMmlError);
611
+ });
612
+ });
613
+
614
+ describe('mmlToRows: the uncapped row stream (music from-score)', () => {
615
+ it('has no 32-row cap when asked, and reports whether s<N> was set', () => {
616
+ const long = mmlToRows('c64', {maxRows: Number.POSITIVE_INFINITY});
617
+ expect(long.notes).toHaveLength(64);
618
+ expect(long.speedSet).toBe(false);
619
+ expect(mmlToRows('s9 c').speedSet).toBe(true);
620
+ });
621
+
622
+ it('caps at 32 by default', () => {
623
+ expect(() => mmlToRows('c33')).toThrow(AudioMmlError);
624
+ });
625
+ });