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.
Files changed (62) hide show
  1. package/dist/commands/audio.d.ts +23 -2
  2. package/dist/commands/audio.d.ts.map +1 -1
  3. package/dist/commands/audio.js +228 -23
  4. package/dist/commands/audio.js.map +1 -1
  5. package/dist/commands/doctor.d.ts.map +1 -1
  6. package/dist/commands/doctor.js +1 -0
  7. package/dist/commands/doctor.js.map +1 -1
  8. package/dist/commands/music.d.ts +2 -1
  9. package/dist/commands/music.d.ts.map +1 -1
  10. package/dist/commands/music.js +209 -2
  11. package/dist/commands/music.js.map +1 -1
  12. package/dist/commands/sfx.d.ts.map +1 -1
  13. package/dist/commands/sfx.js +99 -11
  14. package/dist/commands/sfx.js.map +1 -1
  15. package/dist/engine/audio/analyze.d.ts +164 -0
  16. package/dist/engine/audio/analyze.d.ts.map +1 -0
  17. package/dist/engine/audio/analyze.js +512 -0
  18. package/dist/engine/audio/analyze.js.map +1 -0
  19. package/dist/engine/audio/index.d.ts +4 -2
  20. package/dist/engine/audio/index.d.ts.map +1 -1
  21. package/dist/engine/audio/index.js +4 -2
  22. package/dist/engine/audio/index.js.map +1 -1
  23. package/dist/engine/audio/score.d.ts +137 -0
  24. package/dist/engine/audio/score.d.ts.map +1 -0
  25. package/dist/engine/audio/score.js +420 -0
  26. package/dist/engine/audio/score.js.map +1 -0
  27. package/dist/engine/audio/section.d.ts +7 -0
  28. package/dist/engine/audio/section.d.ts.map +1 -1
  29. package/dist/engine/audio/section.js +17 -0
  30. package/dist/engine/audio/section.js.map +1 -1
  31. package/dist/engine/audio/sfx.d.ts +114 -12
  32. package/dist/engine/audio/sfx.d.ts.map +1 -1
  33. package/dist/engine/audio/sfx.js +236 -33
  34. package/dist/engine/audio/sfx.js.map +1 -1
  35. package/dist/engine/pico8/harness.d.ts +11 -5
  36. package/dist/engine/pico8/harness.d.ts.map +1 -1
  37. package/dist/engine/pico8/harness.js +24 -10
  38. package/dist/engine/pico8/harness.js.map +1 -1
  39. package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
  40. package/dist/engine/scaffold/scaffold.js +5 -1
  41. package/dist/engine/scaffold/scaffold.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/commands/audio.test.ts +170 -1
  44. package/src/commands/audio.ts +284 -27
  45. package/src/commands/doctor.test.ts +1 -0
  46. package/src/commands/doctor.ts +1 -0
  47. package/src/commands/music.test.ts +119 -0
  48. package/src/commands/music.ts +251 -1
  49. package/src/commands/sfx.test.ts +55 -0
  50. package/src/commands/sfx.ts +118 -11
  51. package/src/engine/audio/analyze.test.ts +239 -0
  52. package/src/engine/audio/analyze.ts +664 -0
  53. package/src/engine/audio/index.ts +46 -1
  54. package/src/engine/audio/score.test.ts +402 -0
  55. package/src/engine/audio/score.ts +630 -0
  56. package/src/engine/audio/section.ts +19 -0
  57. package/src/engine/audio/sfx.test.ts +219 -4
  58. package/src/engine/audio/sfx.ts +326 -40
  59. package/src/engine/pico8/harness.test.ts +19 -0
  60. package/src/engine/pico8/harness.ts +29 -11
  61. package/src/engine/scaffold/scaffold.ts +5 -1
  62. package/src/skills/picopilot-audio/SKILL.md +74 -184
@@ -15,15 +15,16 @@
15
15
  * tempo. Every note occupies exactly one tracker ROW; all rows share ONE
16
16
  * SFX-level `speed`. A picopilot-MML length is therefore a ROW COUNT, not a
17
17
  * musical fraction, and there is no tempo/BPM token. Holding a note for M rows
18
- * re-emits the same note in the following (M-1) rows (PICO-8 has no cross-row
19
- * sustain bit).
18
+ * emits the same pitch on M rows; PICO-8 plays consecutive same-pitch rows as
19
+ * one continuous tone, so a held note is shaped by stepping its VOLUME across
20
+ * those rows (fade/drop effects land once per held note, ADR-0019).
20
21
  *
21
22
  * Out-of-range pitch and >32 rows are STRUCTURED ERRORS ({@link AudioMmlError}),
22
23
  * never silent clamps: a silent clamp produces wrong-sounding music the author
23
24
  * cannot see (finding B.2 / B.4, matching picopilot's smart-refuse philosophy).
24
25
  *
25
26
  * SFX-level FILTER directives (finding A.7): `!noiz !buzz !detune1/!detune2
26
- * !reverb[1|2] !dampen[1|2]` set PICO-8's 5 per-SFX filters (the first header
27
+ * !reverb !dampen[1|2]` set PICO-8's 5 per-SFX filters (the first header
27
28
  * byte), reaching the class of DESIGNED sounds (explosion = DAMPEN body + REVERB
28
29
  * tail, engine hum, pad) that no per-note pitch/waveform/volume MML can
29
30
  * synthesize. `!`-prefixed keywords never collide with the note grammar (unlike
@@ -61,7 +62,7 @@ export const DEFAULT_LENGTH = 1;
61
62
  *
62
63
  * Decoded byte-for-byte on PICO-8 v0.2.7 by poke-and-readback + single-bit audio
63
64
  * diff (finding A.7). NOIZ applies only to instrument 6 (`@6`, manual);
64
- * DAMPEN/REVERB have 2 levels each (`!dampen`/`!dampen2`, `!reverb`/`!reverb2`).
65
+ * DAMPEN has 2 levels (`!dampen`/`!dampen2`); REVERB is one switch (ADR-0010).
65
66
  */
66
67
  export const FILTER_NOIZ = 0x02;
67
68
  export const FILTER_BUZZ = 0x04;
@@ -109,6 +110,12 @@ export type AudioMmlErrorCode =
109
110
  | 'audio-mml-sfx-overflow'
110
111
  /** A `!dampen<N>`/`!reverb<N>` level fell outside 1..2 (finding A.7). */
111
112
  | 'audio-mml-filter-level-out-of-range'
113
+ /**
114
+ * A modal setter (`v`, `v[...]`, `e`, `@`, `~`) sat between a note and its `^`
115
+ * tie. A tie continues ONE held note (ADR-0019), so the change would be
116
+ * ignored; it is refused instead of silently dropped.
117
+ */
118
+ | 'audio-mml-tie-state-change'
112
119
  /** The MML text is syntactically malformed (a token we cannot parse). */
113
120
  | 'audio-mml-parse-error';
114
121
 
@@ -129,7 +136,7 @@ export class AudioMmlError extends Error {
129
136
  }
130
137
 
131
138
  /** One decoded tracker note (row): the 5 `__sfx__` nibbles `PP W V E`. */
132
- interface Note {
139
+ export interface SfxNote {
133
140
  /** Chromatic pitch index 0..63 (C0..D#5). Ignored when `volume === 0`. */
134
141
  pitch: number;
135
142
  /** Waveform / instrument nibble 0..15 (0..7 built-in, 8..15 custom SFX-instr). */
@@ -171,65 +178,235 @@ export interface SfxTranspileResult {
171
178
  roundingWarnings: RowRoundingWarning[];
172
179
  }
173
180
 
181
+ /**
182
+ * A per-note VOLUME ENVELOPE (`v[...]`, ADR-0019). `head` is applied from the
183
+ * note's first row, its last value held for as long as the note lasts; the
184
+ * optional `tail` (after `/`) replaces the note's LAST rows, so a release lands
185
+ * at the end of every note long enough to carry it (more rows than the tail).
186
+ */
187
+ export interface VolumeEnvelope {
188
+ readonly head: readonly number[];
189
+ readonly tail: readonly number[];
190
+ }
191
+
174
192
  /** Modal parser state (finding B.1: `@`/`v`/`e`/`o`/`l` are sticky). */
175
193
  interface State {
176
194
  octave: number;
177
195
  waveform: number;
178
196
  volume: number;
197
+ /** `v[...]`: when set, it shapes every note's volume instead of `volume`. */
198
+ envelope: VolumeEnvelope | undefined;
179
199
  effect: number;
200
+ /** `~N`: switch held notes to vibrato (e2) from their row N on; 0 = off. */
201
+ vibratoDelay: number;
180
202
  length: number;
181
203
  }
182
204
 
205
+ /** The modal values a held note was struck with (a `^` tie must not change them). */
206
+ export interface HeldNote {
207
+ pitch: number;
208
+ waveform: number;
209
+ volume: number;
210
+ envelope: VolumeEnvelope | undefined;
211
+ effect: number;
212
+ vibratoDelay: number;
213
+ }
214
+
215
+ /** The effect numbers whose behaviour is per ROW, not per held note (finding A.4). */
216
+ const EFFECT_DROP = 3;
217
+ const EFFECT_FADE_IN = 4;
218
+ const EFFECT_FADE_OUT = 5;
219
+ const EFFECT_VIBRATO = 2;
220
+
221
+ /**
222
+ * The parsed row stream of a picopilot-MML string, before it is cut into one
223
+ * `__sfx__` row. `music from-score` consumes this directly (a channel of ANY
224
+ * length), `sfx from-mml` caps it at 32 rows and emits one SFX.
225
+ */
226
+ export interface MmlRows {
227
+ /** Every tracker row, in order. */
228
+ notes: SfxNote[];
229
+ /** The `s<N>` speed, or {@link DEFAULT_SPEED} when no `s<N>` was given. */
230
+ speed: number;
231
+ /** Whether an explicit `s<N>` appeared (a score refuses a mismatching one). */
232
+ speedSet: boolean;
233
+ /** The OR of the `!` filter directives. */
234
+ filters: number;
235
+ /** The row index at `{`, if any. */
236
+ loopStartRow: number | undefined;
237
+ /** The row index at `}`, if any. */
238
+ loopEndRow: number | undefined;
239
+ /** Dotted lengths that were rounded to whole rows. */
240
+ roundingWarnings: RowRoundingWarning[];
241
+ }
242
+
243
+ /**
244
+ * Renders ONE held note (struck once, held for `rows` rows) into tracker rows
245
+ * (ADR-0019). PICO-8 plays consecutive same-pitch rows as one continuous tone,
246
+ * so a held note is shaped by stepping VOLUME across its rows, never by
247
+ * repeating a one-row effect on each of them:
248
+ *
249
+ * - volume: the `v[...]` envelope when one is set; otherwise, under fade-out
250
+ * (`e5`), a linear ramp from `v` towards 0 (`ceil`, so no row goes silent
251
+ * before the end); otherwise the flat `v`.
252
+ * - `e4` fade in: the FIRST row only (the attack), the rest hold.
253
+ * - `e5` fade out: the LAST row only (it fades the ramp's final step to 0).
254
+ * - `e3` drop: the FIRST row only; the remaining rows are SILENT. A drop ends far
255
+ * below the note's pitch, so a held drop is a one-row hit followed by rests
256
+ * (re-sounding the pitch would be a second, un-dropped note).
257
+ * - `e1` slide, `e2` vibrato, `e6`/`e7` arpeggio: EVERY row. They are
258
+ * continuous (vibrato), a no-op between equal pitches (slide), or defined over
259
+ * the 4-row group a row sits in (arpeggio), so carrying them on is what keeps
260
+ * their meaning.
261
+ * - `~N` delayed vibrato: rows from index N on that would carry no effect get
262
+ * `e2`, so a long note starts plain and blooms into vibrato.
263
+ *
264
+ * A one-row note is emitted exactly as written (its effect included).
265
+ */
266
+ export function renderHeldNote(note: HeldNote, rows: number): SfxNote[] {
267
+ const out: SfxNote[] = [];
268
+ for (let k = 0; k < rows; k++) {
269
+ let volume: number;
270
+ if (note.envelope !== undefined) {
271
+ volume = envelopeVolume(note.envelope, k, rows);
272
+ } else if (note.effect === EFFECT_FADE_OUT && rows > 1) {
273
+ volume = Math.ceil(note.volume * (1 - k / rows));
274
+ } else {
275
+ volume = note.volume;
276
+ }
277
+
278
+ let effect: number;
279
+ switch (note.effect) {
280
+ case EFFECT_FADE_IN:
281
+ effect = k === 0 ? EFFECT_FADE_IN : 0;
282
+ break;
283
+ case EFFECT_FADE_OUT:
284
+ effect = k === rows - 1 ? EFFECT_FADE_OUT : 0;
285
+ break;
286
+ case EFFECT_DROP:
287
+ if (k > 0) {
288
+ out.push({pitch: 0, waveform: note.waveform, volume: 0, effect: 0});
289
+ continue;
290
+ }
291
+ effect = EFFECT_DROP;
292
+ break;
293
+ default:
294
+ effect = note.effect;
295
+ }
296
+ if (effect === 0 && note.vibratoDelay > 0 && k >= note.vibratoDelay) {
297
+ effect = EFFECT_VIBRATO;
298
+ }
299
+ // A silent row inside a note carries no effect (nothing to shape).
300
+ if (volume === 0) effect = 0;
301
+ out.push({pitch: note.pitch, waveform: note.waveform, volume, effect});
302
+ }
303
+ return out;
304
+ }
305
+
306
+ /**
307
+ * The volume a `v[...]` envelope gives row `k` of a `rows`-row note: the tail
308
+ * owns the note's last `tail.length` rows when the note is LONGER than the tail
309
+ * (so the head always sounds), the head owns the rest with its last value held.
310
+ */
311
+ function envelopeVolume(env: VolumeEnvelope, k: number, rows: number): number {
312
+ const tailLen = env.tail.length;
313
+ if (tailLen > 0 && rows > tailLen && k >= rows - tailLen) {
314
+ return env.tail[k - (rows - tailLen)]!;
315
+ }
316
+ return env.head[Math.min(k, env.head.length - 1)]!;
317
+ }
318
+
183
319
  /**
184
320
  * Transpiles a picopilot-MML string into ONE `__sfx__` text row (finding Part
185
321
  * A/B). Emits the friendly text nibbles directly.
186
322
  *
187
- * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume,
188
- * `e<0-7>`/mnemonic effect, `o<0-5>` octave, `l<N>` default length, `>`/`<`
189
- * octave shift, all sticky. A bare note emits one row using the current state.
323
+ * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume (or
324
+ * `v[...]` a per-note volume envelope, ADR-0019), `e<0-7>` effect, `~<N>`
325
+ * delayed vibrato, `o<0-5>` octave, `l<N>` default length, `>`/`<` octave
326
+ * shift, all sticky. A bare note emits one row using the current state.
190
327
  *
191
328
  * SFX-level directives (finding B.5): `s<N>` speed (default 16), `{`/`}` loop
192
329
  * markers (single `{` = the LEN special case).
193
330
  *
194
- * Durations are TRACKER ROWS (finding B.4): a length `N` = N rows, emitted as
195
- * the note then `(N-1)` tie rows re-striking it; `.` multiplies by 1.5 (rounded
196
- * to the nearest whole row, warned); `r` = silent rows; `^` extends the previous
197
- * note.
331
+ * Durations are TRACKER ROWS (finding B.4): a length `N` = one note HELD for N
332
+ * rows, shaped by {@link renderHeldNote} (ADR-0019); `.` multiplies by 1.5
333
+ * (rounded to the nearest whole row, warned); `r` = silent rows; `^` extends the
334
+ * previous note (re-shaping it as one longer note).
198
335
  *
199
336
  * @throws {AudioMmlError} `audio-mml-pitch-out-of-range` (a note below C0 / above
200
- * D#5), `audio-mml-sfx-overflow` (>32 rows), or `audio-mml-parse-error` (a
337
+ * D#5), `audio-mml-sfx-overflow` (>32 rows), `audio-mml-tie-state-change` (a
338
+ * modal change between a note and its `^`), or `audio-mml-parse-error` (a
201
339
  * token that cannot be parsed, incl. a tempo-style input).
202
340
  */
203
341
  export function mmlToSfxRow(mml: string): SfxTranspileResult {
342
+ const parsed = mmlToRows(mml, {maxRows: SFX_MAX_ROWS});
343
+ const {loopStart, loopEnd} = resolveLoop(
344
+ parsed.loopStartRow,
345
+ parsed.loopEndRow,
346
+ parsed.notes.length,
347
+ );
348
+ return {
349
+ row: emitSfxRow({
350
+ filters: parsed.filters,
351
+ speed: parsed.speed,
352
+ loopStart,
353
+ loopEnd,
354
+ notes: parsed.notes,
355
+ }),
356
+ rows: parsed.notes.length,
357
+ speed: parsed.speed,
358
+ loopStart,
359
+ loopEnd,
360
+ filters: parsed.filters,
361
+ roundingWarnings: parsed.roundingWarnings,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Parses picopilot-MML into its tracker-row stream (the core of
367
+ * {@link mmlToSfxRow}). `maxRows` is the overflow cap: 32 for one SFX,
368
+ * `Infinity` for a `music from-score` channel, which is cut into SFX later.
369
+ *
370
+ * @throws {AudioMmlError} as {@link mmlToSfxRow}.
371
+ */
372
+ export function mmlToRows(
373
+ mml: string,
374
+ options: {maxRows?: number} = {},
375
+ ): MmlRows {
376
+ const maxRows = options.maxRows ?? SFX_MAX_ROWS;
204
377
  const state: State = {
205
378
  octave: DEFAULT_OCTAVE,
206
379
  waveform: 0,
207
380
  volume: 7,
381
+ envelope: undefined,
208
382
  effect: 0,
383
+ vibratoDelay: 0,
209
384
  length: DEFAULT_LENGTH,
210
385
  };
211
386
 
212
- const notes: Note[] = [];
387
+ const notes: SfxNote[] = [];
213
388
  const warnings: RowRoundingWarning[] = [];
214
389
  let speed = DEFAULT_SPEED;
390
+ let speedSet = false;
215
391
  let filters = 0;
216
392
  let loopStartRow: number | undefined;
217
393
  let loopEndRow: number | undefined;
218
- let lastNote: Note | undefined;
394
+ /** The last held note, where its rows start, and how many rows it holds. */
395
+ let last: {note: HeldNote; start: number; rows: number} | undefined;
219
396
 
220
397
  const src = mml;
221
398
  let i = 0;
222
399
  const len = src.length;
223
400
 
224
401
  /** The overflow check, applied every time rows are appended (finding B.4). */
225
- const pushNote = (note: Note): void => {
226
- if (notes.length + 1 > SFX_MAX_ROWS) {
402
+ const pushRows = (rows: SfxNote[]): void => {
403
+ if (notes.length + rows.length > maxRows) {
227
404
  throw new AudioMmlError(
228
405
  'audio-mml-sfx-overflow',
229
- `picopilot-MML produced more than ${SFX_MAX_ROWS} tracker rows (an SFX holds at most ${SFX_MAX_ROWS} rows). Split the melody across multiple SFX + a music pattern, or shorten it. (Remember: a length is a ROW COUNT, not a musical note value.)`,
406
+ `picopilot-MML produced more than ${maxRows} tracker rows (an SFX holds at most ${maxRows} rows). Write a longer melody with \`music from-score\` (it splits any length into SFX + patterns), or shorten it. (Remember: a length is a ROW COUNT, not a musical note value.)`,
230
407
  );
231
408
  }
232
- notes.push(note);
409
+ notes.push(...rows);
233
410
  };
234
411
 
235
412
  while (i < len) {
@@ -257,13 +434,14 @@ export function mmlToSfxRow(mml: string): SfxTranspileResult {
257
434
  );
258
435
  }
259
436
  speed = value;
437
+ speedSet = true;
260
438
  i = next;
261
439
  continue;
262
440
  }
263
441
  // `!<name>[<level>]`: an SFX-level FILTER directive (finding A.7). A `!` head
264
442
  // never collides with the note grammar (unlike the rejected `sl vb dr`
265
443
  // mnemonics, which WERE bare note-letter sequences, ADR-0008). They set the
266
- // first header byte; DAMPEN/REVERB take an optional 1..2 level (default 1).
444
+ // first header byte; DAMPEN takes an optional 1..2 level (default 1).
267
445
  if (ch === '!') {
268
446
  const parsed = readFilter(src, i);
269
447
  filters |= parsed.bits;
@@ -298,11 +476,39 @@ export function mmlToSfxRow(mml: string): SfxTranspileResult {
298
476
  continue;
299
477
  }
300
478
  if (lower === 'v') {
479
+ // `v[...]`: a per-note volume ENVELOPE (ADR-0019). `[` after `v` was a
480
+ // parse error before, so no previously valid MML changes meaning.
481
+ if (src[i + 1] === '[') {
482
+ const env = readEnvelope(src, i);
483
+ state.envelope = env.envelope;
484
+ i = env.next;
485
+ continue;
486
+ }
301
487
  const {value, next} = readInt(src, i + 1);
302
488
  if (value === undefined || value > 7) {
303
- throw parseError(src, i, "expected a digit 0-7 after 'v' (volume)");
489
+ throw parseError(
490
+ src,
491
+ i,
492
+ "expected a digit 0-7 after 'v' (volume), or 'v[...]' (a volume envelope, e.g. v[3 2 2 1/0])",
493
+ );
304
494
  }
305
495
  state.volume = value;
496
+ state.envelope = undefined; // a flat volume replaces an envelope
497
+ i = next;
498
+ continue;
499
+ }
500
+ // `~<N>`: delayed vibrato (ADR-0019). Held notes switch to e2 from their
501
+ // row N; `~0` turns it off. `~` was an unexpected token before.
502
+ if (ch === '~') {
503
+ const {value, next} = readInt(src, i + 1);
504
+ if (value === undefined || value > SFX_MAX_ROWS - 1) {
505
+ throw parseError(
506
+ src,
507
+ i,
508
+ `expected a row count 0-${SFX_MAX_ROWS - 1} after '~' (delayed vibrato: ~4 = vibrato from a held note's 5th row, ~0 = off)`,
509
+ );
510
+ }
511
+ state.vibratoDelay = value;
306
512
  i = next;
307
513
  continue;
308
514
  }
@@ -369,22 +575,38 @@ export function mmlToSfxRow(mml: string): SfxTranspileResult {
369
575
  // --- Rest -----------------------------------------------------------------
370
576
  if (lower === 'r') {
371
577
  const dur = readDuration(src, i, i + 1, state.length);
578
+ const silent: SfxNote[] = [];
372
579
  for (let k = 0; k < dur.rows; k++) {
373
- pushNote({pitch: 0, waveform: state.waveform, volume: 0, effect: 0});
580
+ silent.push({pitch: 0, waveform: state.waveform, volume: 0, effect: 0});
374
581
  }
582
+ pushRows(silent);
375
583
  collectWarning(dur, warnings);
376
- lastNote = undefined;
584
+ last = undefined;
377
585
  i = dur.next;
378
586
  continue;
379
587
  }
380
588
 
381
589
  // --- Tie: extend the previous note ---------------------------------------
382
590
  if (ch === '^') {
383
- if (lastNote === undefined) {
591
+ if (last === undefined) {
384
592
  throw parseError(src, i, "'^' (tie) with no preceding note to extend");
385
593
  }
594
+ // A tie extends the SAME held note, so it is re-shaped as one longer
595
+ // note. A modal change in between (`c4 v3 ^4`, `c4 e5 ^2`) used to be
596
+ // silently ignored; it is now refused, because a held note has one
597
+ // shape (ADR-0019): use a `v[...]` envelope or a new note instead.
598
+ const changed = tieStateChange(last.note, state);
599
+ if (changed !== undefined) {
600
+ throw new AudioMmlError(
601
+ 'audio-mml-tie-state-change',
602
+ `'^' (tie) at column ${i + 1} extends a note struck with a different ${changed}: a tie continues the SAME held note, so the ${changed} set between the note and its '^' would be ignored. Shape a held note's volume with an envelope (e.g. "v[3 2 2 1] c8"), or start a new note instead of a tie.`,
603
+ );
604
+ }
386
605
  const dur = readDuration(src, i, i + 1, state.length);
387
- for (let k = 0; k < dur.rows; k++) pushNote({...lastNote});
606
+ const total = last.rows + dur.rows;
607
+ notes.splice(last.start);
608
+ pushRows(renderHeldNote(last.note, total));
609
+ last.rows = total;
388
610
  collectWarning(dur, warnings);
389
611
  i = dur.next;
390
612
  continue;
@@ -411,15 +633,18 @@ export function mmlToSfxRow(mml: string): SfxTranspileResult {
411
633
  );
412
634
  }
413
635
  const dur = readDuration(src, i, j, state.length);
414
- const note: Note = {
636
+ const note: HeldNote = {
415
637
  pitch,
416
638
  waveform: state.waveform,
417
639
  volume: state.volume,
640
+ envelope: state.envelope,
418
641
  effect: state.effect,
642
+ vibratoDelay: state.vibratoDelay,
419
643
  };
420
- for (let k = 0; k < dur.rows; k++) pushNote({...note});
644
+ const start = notes.length;
645
+ pushRows(renderHeldNote(note, dur.rows));
421
646
  collectWarning(dur, warnings);
422
- lastNote = note;
647
+ last = {note, start, rows: dur.rows};
423
648
  i = dur.next;
424
649
  continue;
425
650
  }
@@ -427,23 +652,84 @@ export function mmlToSfxRow(mml: string): SfxTranspileResult {
427
652
  throw parseError(src, i, `unexpected token '${ch}'`);
428
653
  }
429
654
 
430
- const {loopStart, loopEnd} = resolveLoop(
431
- loopStartRow,
432
- loopEndRow,
433
- notes.length,
434
- );
435
-
436
655
  return {
437
- row: emitRow({filters, speed, loopStart, loopEnd, notes}),
438
- rows: notes.length,
656
+ notes,
439
657
  speed,
440
- loopStart,
441
- loopEnd,
658
+ speedSet,
442
659
  filters,
660
+ loopStartRow,
661
+ loopEndRow,
443
662
  roundingWarnings: warnings,
444
663
  };
445
664
  }
446
665
 
666
+ /**
667
+ * Names the modal value that changed between a held note and a `^` tie, or
668
+ * `undefined` when the tie continues the note's own state. Octave and default
669
+ * length are free to change (a tie keeps its pitch, and `^` reads the length).
670
+ */
671
+ function tieStateChange(note: HeldNote, state: State): string | undefined {
672
+ if (note.waveform !== state.waveform) return 'waveform (@)';
673
+ if (note.effect !== state.effect) return 'effect (e)';
674
+ if (note.vibratoDelay !== state.vibratoDelay) return 'delayed vibrato (~)';
675
+ if (JSON.stringify(note.envelope) !== JSON.stringify(state.envelope)) {
676
+ return 'volume envelope (v[...])';
677
+ }
678
+ if (note.envelope === undefined && note.volume !== state.volume) {
679
+ return 'volume (v)';
680
+ }
681
+ return undefined;
682
+ }
683
+
684
+ /**
685
+ * Parses a `v[...]` volume envelope at `at` (the `v`): digits 0-7 (one digit =
686
+ * one row's volume, whitespace optional), and at most one `/` separating the
687
+ * head from the release tail (ADR-0019).
688
+ */
689
+ function readEnvelope(
690
+ src: string,
691
+ at: number,
692
+ ): {envelope: VolumeEnvelope; next: number} {
693
+ const close = src.indexOf(']', at + 2);
694
+ if (close < 0) {
695
+ throw parseError(src, at, "unclosed volume envelope 'v[': missing ']'");
696
+ }
697
+ const body = src.slice(at + 2, close);
698
+ const parts = body.split('/');
699
+ if (parts.length > 2) {
700
+ throw parseError(
701
+ src,
702
+ at,
703
+ "a volume envelope takes at most one '/' (head/release), e.g. v[3 2 2/1 0]",
704
+ );
705
+ }
706
+ const digits = (part: string): number[] => {
707
+ const out: number[] = [];
708
+ for (const c of part) {
709
+ if (/\s/.test(c)) continue;
710
+ if (c < '0' || c > '7') {
711
+ throw parseError(
712
+ src,
713
+ at,
714
+ `volume envelope values are digits 0-7 (one per row), got '${c}'`,
715
+ );
716
+ }
717
+ out.push(c.charCodeAt(0) - 48);
718
+ }
719
+ return out;
720
+ };
721
+ const head = digits(parts[0]!);
722
+ const tail = parts.length === 2 ? digits(parts[1]!) : [];
723
+ if (head.length === 0) {
724
+ throw parseError(
725
+ src,
726
+ at,
727
+ 'a volume envelope needs at least one value before any /, e.g. v[3 2 2 1]',
728
+ );
729
+ }
730
+ return {envelope: {head, tail}, next: close + 1};
731
+ }
732
+
447
733
  /** A parsed duration: how many rows, whether it rounded, and where parsing resumed. */
448
734
  interface Duration {
449
735
  rows: number;
@@ -649,12 +935,12 @@ function resolveLoop(
649
935
  }
650
936
 
651
937
  /** Serializes the header + notes into the 168-char `__sfx__` text row (finding A). */
652
- function emitRow(sfx: {
938
+ export function emitSfxRow(sfx: {
653
939
  filters: number;
654
940
  speed: number;
655
941
  loopStart: number;
656
942
  loopEnd: number;
657
- notes: Note[];
943
+ notes: readonly SfxNote[];
658
944
  }): string {
659
945
  // Header: filter-byte(2) speed(2) loopstart(2) loopend(2). The first byte is the
660
946
  // per-SFX FILTER bitfield (finding A.7); its low bit is the cosmetic pitch/
@@ -165,6 +165,25 @@ describe('buildRecordHarness', () => {
165
165
  expect(h.description).toBe('the running cart');
166
166
  });
167
167
 
168
+ it('hooks _update60 when the cart uses it, counting frames at 60fps', () => {
169
+ const h = buildRecordHarness(
170
+ `${HEADER}__lua__\nfunction _update60() foo() end\n`,
171
+ {seconds: 2},
172
+ );
173
+ const lua = Cart.parse(h.cartText).getSection('lua') ?? '';
174
+ // PICO-8 calls _update60 INSTEAD of _update: wrapping only _update never ticked.
175
+ expect(lua).toContain('__pp_rec_update60=_update60');
176
+ expect(lua).toContain(
177
+ 'function _update60() __pp_rec_tick() __pp_rec_update60() end',
178
+ );
179
+ // 2 s = 60 frames at 30fps, 120 at 60fps; the Lua picks by the loop in use.
180
+ expect(lua).toContain('__pp_rec_n=60');
181
+ expect(lua).toContain('if __pp_rec_update60 then __pp_rec_n=120 end');
182
+ expect(lua).toContain(
183
+ 'if __pp_rec_t==__pp_rec_n then extcmd("audio_end",1) end',
184
+ );
185
+ });
186
+
168
187
  it('uses the default record window when none is given', () => {
169
188
  const h = buildRecordHarness(`${HEADER}__lua__\n`);
170
189
  expect(h.seconds).toBe(DEFAULT_RECORD_SECONDS);
@@ -185,7 +185,11 @@ export interface RenderHarness {
185
185
  readonly cartText: string;
186
186
  /** The record window in seconds the adapter should let the capture run for. */
187
187
  readonly seconds: number;
188
- /** The number of `_update` frames the harness records for (= seconds * 30fps). */
188
+ /**
189
+ * The number of `_update` frames the harness records for (= seconds * 30fps).
190
+ * For `buildRecordHarness` on a cart that uses `_update60`, the recorder
191
+ * counts seconds * 60 frames instead, chosen at run time.
192
+ */
189
193
  readonly frames: number;
190
194
  /** The base filename the harness passes to `extcmd("set_filename")`. */
191
195
  readonly wavBasename: string;
@@ -295,11 +299,13 @@ export function buildRenderHarness(
295
299
  * cooperative recorder tab that starts recording immediately and stops after the
296
300
  * window. The recorder runs as an extra code tab so it does not disturb the
297
301
  * cart's own `_update`/`_draw` (PICO-8 runs all tabs; the appended top-level code
298
- * arms the recorder and a hooked `_update` advances the timer).
302
+ * arms the recorder and a hooked `_update`/`_update60` advances the timer).
299
303
  *
300
- * Because a general cart may define its own `_update`, the recorder wraps it: it
301
- * saves the cart's `_update` and installs one that ticks the record timer then
302
- * calls the original. This is the record analogue of the debug skill's "add
304
+ * Because a general cart may define its own `_update` or `_update60` (PICO-8
305
+ * calls `_update60` instead of `_update` when it exists), the recorder wraps
306
+ * whichever the cart uses: it saves the cart's callback and installs one that
307
+ * ticks the record timer then calls the original, counting frames at that
308
+ * loop's rate (30 or 60 fps). This is the record analogue of the debug skill's "add
303
309
  * capture lines, run, revert" recipe, done on a THROWAWAY copy.
304
310
  *
305
311
  * The harness runs from a different folder than the source cart (an isolated
@@ -339,21 +345,33 @@ export function buildRecordHarness(
339
345
  cart.getSection('lua') ?? '',
340
346
  options.readInclude,
341
347
  );
342
- // Append the recorder AFTER the cart's own code. It wraps _update so it works
343
- // whether or not the cart defines one, and never edits the cart's own logic.
348
+ // Append the recorder AFTER the cart's own code, so the cart's callbacks
349
+ // exist when it runs. It hooks whichever update loop the cart uses: PICO-8
350
+ // calls _update60 INSTEAD of _update when a cart defines it (60fps), so a
351
+ // recorder that only wrapped _update never ticked on such a cart and the
352
+ // run hit the backstop without ever starting audio_rec. The frame count
353
+ // follows the loop's rate so the window stays `seconds` long either way.
354
+ const frames60 = Math.max(1, Math.round(seconds * 60));
344
355
  const recorder = [
345
356
  '',
346
357
  '-->8',
347
358
  '-- picopilot audio record harness (throwaway; ADR-0009).',
348
359
  "-- Records the cart's own audio; real-time capture, NOT an offline export.",
349
360
  '__pp_rec_update=_update',
361
+ '__pp_rec_update60=_update60',
350
362
  '__pp_rec_t=0',
351
- 'function _update()',
363
+ `__pp_rec_n=${frames}`,
364
+ `if __pp_rec_update60 then __pp_rec_n=${frames60} end`,
365
+ 'function __pp_rec_tick()',
352
366
  ' __pp_rec_t+=1',
353
367
  ` if __pp_rec_t==1 then extcmd("set_filename","${wavBasename}") extcmd("audio_rec") end`,
354
- ` if __pp_rec_t==${frames} then extcmd("audio_end",1) end`,
355
- ` if __pp_rec_t==${frames + 1} then printh("${sentinel}") end`,
356
- ' if __pp_rec_update then __pp_rec_update() end',
368
+ ' if __pp_rec_t==__pp_rec_n then extcmd("audio_end",1) end',
369
+ ` if __pp_rec_t==__pp_rec_n+1 then printh("${sentinel}") end`,
370
+ 'end',
371
+ 'if __pp_rec_update60 then',
372
+ ' function _update60() __pp_rec_tick() __pp_rec_update60() end',
373
+ 'else',
374
+ ' function _update() __pp_rec_tick() if __pp_rec_update then __pp_rec_update() end end',
357
375
  'end',
358
376
  '',
359
377
  ].join('\n');
@@ -94,6 +94,9 @@ curated from PICO-8's manual for the things models get wrong without a reminder.
94
94
  - Sprites: 256 8x8 sprites in a base bank (0..127) plus a shared bank (128..255).
95
95
  - Map: 128x32 tiles + a shared half that ALIASES sprites 128..255 (see below).
96
96
  - Sound: 4 channels, 64 SFX slots; music is patterns of up to 4 SFX references.
97
+ Compose it as text (\`picopilot sfx from-mml\`, \`music from-score\`), then
98
+ \`audio render --analyze\` to judge it by numbers; the \`picopilot-audio\` skill
99
+ carries the loop and the taste rules (soft, low, varied, under the SFX).
97
100
  - Numbers: 16.16 fixed point, range \`-32768.0 .. 32767.99\`.
98
101
 
99
102
  ## The execution loop (every cart)
@@ -239,7 +242,8 @@ in \`picopilot.json\`), so you never silently destroy map data you cannot see.
239
242
  \`color(col)\`, \`fillp(mask)\`, \`tline(...)\`, \`flip()\`.
240
243
  - **Map / flags:** \`mget(x,y)\`, \`mset(x,y,v)\`, \`fget(n,[f])\`, \`fset(n,[f],v)\`.
241
244
  - **Input:** \`btn([i,[p]])\`, \`btnp([i,[p]])\` (buttons 0..5 = left,right,up,down,O/Z,X).
242
- - **Audio:** \`sfx(n,[channel,[offset]])\`, \`music([n,[fade,[mask]]])\`.
245
+ - **Audio:** \`sfx(n,[channel,[offset]])\`, \`music([n,[fade,[mask]]])\` (mask 7 =
246
+ channels 0-2, so channel 3 stays free for \`sfx\`).
243
247
  - **Math:** \`abs\`, \`flr\` (\`-flr(-x)\` = ceil), \`min\`, \`max\`, \`mid\`, \`rnd(x)\`,
244
248
  \`srand(x)\`, \`sgn\`, \`sqrt\`, \`sin(x)\`/\`cos(x)\` (range 0..1, sin inverted),
245
249
  \`atan2(x,y)\`.