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
@@ -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
@@ -53,7 +54,7 @@ export declare const DEFAULT_LENGTH = 1;
53
54
  *
54
55
  * Decoded byte-for-byte on PICO-8 v0.2.7 by poke-and-readback + single-bit audio
55
56
  * diff (finding A.7). NOIZ applies only to instrument 6 (`@6`, manual);
56
- * DAMPEN/REVERB have 2 levels each (`!dampen`/`!dampen2`, `!reverb`/`!reverb2`).
57
+ * DAMPEN has 2 levels (`!dampen`/`!dampen2`); REVERB is one switch (ADR-0010).
57
58
  */
58
59
  export declare const FILTER_NOIZ = 2;
59
60
  export declare const FILTER_BUZZ = 4;
@@ -76,6 +77,12 @@ export type AudioMmlErrorCode =
76
77
  | 'audio-mml-sfx-overflow'
77
78
  /** A `!dampen<N>`/`!reverb<N>` level fell outside 1..2 (finding A.7). */
78
79
  | 'audio-mml-filter-level-out-of-range'
80
+ /**
81
+ * A modal setter (`v`, `v[...]`, `e`, `@`, `~`) sat between a note and its `^`
82
+ * tie. A tie continues ONE held note (ADR-0019), so the change would be
83
+ * ignored; it is refused instead of silently dropped.
84
+ */
85
+ | 'audio-mml-tie-state-change'
79
86
  /** The MML text is syntactically malformed (a token we cannot parse). */
80
87
  | 'audio-mml-parse-error';
81
88
  /**
@@ -88,6 +95,17 @@ export declare class AudioMmlError extends Error {
88
95
  readonly code: AudioMmlErrorCode;
89
96
  constructor(code: AudioMmlErrorCode, message: string);
90
97
  }
98
+ /** One decoded tracker note (row): the 5 `__sfx__` nibbles `PP W V E`. */
99
+ export interface SfxNote {
100
+ /** Chromatic pitch index 0..63 (C0..D#5). Ignored when `volume === 0`. */
101
+ pitch: number;
102
+ /** Waveform / instrument nibble 0..15 (0..7 built-in, 8..15 custom SFX-instr). */
103
+ waveform: number;
104
+ /** Volume 0..7; 0 = silent (a rest). */
105
+ volume: number;
106
+ /** Effect 0..7 (finding A.4 table). */
107
+ effect: number;
108
+ }
91
109
  /** A rounding note surfaced to the caller (a dotted length that was not integral). */
92
110
  export interface RowRoundingWarning {
93
111
  /** The picopilot-MML token whose row count was rounded, e.g. `c4.`. */
@@ -117,25 +135,109 @@ export interface SfxTranspileResult {
117
135
  /** Any dotted-length rows that had to be rounded to a whole row. */
118
136
  roundingWarnings: RowRoundingWarning[];
119
137
  }
138
+ /**
139
+ * A per-note VOLUME ENVELOPE (`v[...]`, ADR-0019). `head` is applied from the
140
+ * note's first row, its last value held for as long as the note lasts; the
141
+ * optional `tail` (after `/`) replaces the note's LAST rows, so a release lands
142
+ * at the end of every note long enough to carry it (more rows than the tail).
143
+ */
144
+ export interface VolumeEnvelope {
145
+ readonly head: readonly number[];
146
+ readonly tail: readonly number[];
147
+ }
148
+ /** The modal values a held note was struck with (a `^` tie must not change them). */
149
+ export interface HeldNote {
150
+ pitch: number;
151
+ waveform: number;
152
+ volume: number;
153
+ envelope: VolumeEnvelope | undefined;
154
+ effect: number;
155
+ vibratoDelay: number;
156
+ }
157
+ /**
158
+ * The parsed row stream of a picopilot-MML string, before it is cut into one
159
+ * `__sfx__` row. `music from-score` consumes this directly (a channel of ANY
160
+ * length), `sfx from-mml` caps it at 32 rows and emits one SFX.
161
+ */
162
+ export interface MmlRows {
163
+ /** Every tracker row, in order. */
164
+ notes: SfxNote[];
165
+ /** The `s<N>` speed, or {@link DEFAULT_SPEED} when no `s<N>` was given. */
166
+ speed: number;
167
+ /** Whether an explicit `s<N>` appeared (a score refuses a mismatching one). */
168
+ speedSet: boolean;
169
+ /** The OR of the `!` filter directives. */
170
+ filters: number;
171
+ /** The row index at `{`, if any. */
172
+ loopStartRow: number | undefined;
173
+ /** The row index at `}`, if any. */
174
+ loopEndRow: number | undefined;
175
+ /** Dotted lengths that were rounded to whole rows. */
176
+ roundingWarnings: RowRoundingWarning[];
177
+ }
178
+ /**
179
+ * Renders ONE held note (struck once, held for `rows` rows) into tracker rows
180
+ * (ADR-0019). PICO-8 plays consecutive same-pitch rows as one continuous tone,
181
+ * so a held note is shaped by stepping VOLUME across its rows, never by
182
+ * repeating a one-row effect on each of them:
183
+ *
184
+ * - volume: the `v[...]` envelope when one is set; otherwise, under fade-out
185
+ * (`e5`), a linear ramp from `v` towards 0 (`ceil`, so no row goes silent
186
+ * before the end); otherwise the flat `v`.
187
+ * - `e4` fade in: the FIRST row only (the attack), the rest hold.
188
+ * - `e5` fade out: the LAST row only (it fades the ramp's final step to 0).
189
+ * - `e3` drop: the FIRST row only; the remaining rows are SILENT. A drop ends far
190
+ * below the note's pitch, so a held drop is a one-row hit followed by rests
191
+ * (re-sounding the pitch would be a second, un-dropped note).
192
+ * - `e1` slide, `e2` vibrato, `e6`/`e7` arpeggio: EVERY row. They are
193
+ * continuous (vibrato), a no-op between equal pitches (slide), or defined over
194
+ * the 4-row group a row sits in (arpeggio), so carrying them on is what keeps
195
+ * their meaning.
196
+ * - `~N` delayed vibrato: rows from index N on that would carry no effect get
197
+ * `e2`, so a long note starts plain and blooms into vibrato.
198
+ *
199
+ * A one-row note is emitted exactly as written (its effect included).
200
+ */
201
+ export declare function renderHeldNote(note: HeldNote, rows: number): SfxNote[];
120
202
  /**
121
203
  * Transpiles a picopilot-MML string into ONE `__sfx__` text row (finding Part
122
204
  * A/B). Emits the friendly text nibbles directly.
123
205
  *
124
- * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume,
125
- * `e<0-7>`/mnemonic effect, `o<0-5>` octave, `l<N>` default length, `>`/`<`
126
- * octave shift, all sticky. A bare note emits one row using the current state.
206
+ * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume (or
207
+ * `v[...]` a per-note volume envelope, ADR-0019), `e<0-7>` effect, `~<N>`
208
+ * delayed vibrato, `o<0-5>` octave, `l<N>` default length, `>`/`<` octave
209
+ * shift, all sticky. A bare note emits one row using the current state.
127
210
  *
128
211
  * SFX-level directives (finding B.5): `s<N>` speed (default 16), `{`/`}` loop
129
212
  * markers (single `{` = the LEN special case).
130
213
  *
131
- * Durations are TRACKER ROWS (finding B.4): a length `N` = N rows, emitted as
132
- * the note then `(N-1)` tie rows re-striking it; `.` multiplies by 1.5 (rounded
133
- * to the nearest whole row, warned); `r` = silent rows; `^` extends the previous
134
- * note.
214
+ * Durations are TRACKER ROWS (finding B.4): a length `N` = one note HELD for N
215
+ * rows, shaped by {@link renderHeldNote} (ADR-0019); `.` multiplies by 1.5
216
+ * (rounded to the nearest whole row, warned); `r` = silent rows; `^` extends the
217
+ * previous note (re-shaping it as one longer note).
135
218
  *
136
219
  * @throws {AudioMmlError} `audio-mml-pitch-out-of-range` (a note below C0 / above
137
- * D#5), `audio-mml-sfx-overflow` (>32 rows), or `audio-mml-parse-error` (a
220
+ * D#5), `audio-mml-sfx-overflow` (>32 rows), `audio-mml-tie-state-change` (a
221
+ * modal change between a note and its `^`), or `audio-mml-parse-error` (a
138
222
  * token that cannot be parsed, incl. a tempo-style input).
139
223
  */
140
224
  export declare function mmlToSfxRow(mml: string): SfxTranspileResult;
225
+ /**
226
+ * Parses picopilot-MML into its tracker-row stream (the core of
227
+ * {@link mmlToSfxRow}). `maxRows` is the overflow cap: 32 for one SFX,
228
+ * `Infinity` for a `music from-score` channel, which is cut into SFX later.
229
+ *
230
+ * @throws {AudioMmlError} as {@link mmlToSfxRow}.
231
+ */
232
+ export declare function mmlToRows(mml: string, options?: {
233
+ maxRows?: number;
234
+ }): MmlRows;
235
+ /** Serializes the header + notes into the 168-char `__sfx__` text row (finding A). */
236
+ export declare function emitSfxRow(sfx: {
237
+ filters: number;
238
+ speed: number;
239
+ loopStart: number;
240
+ loopEnd: number;
241
+ notes: readonly SfxNote[];
242
+ }): string;
141
243
  //# sourceMappingURL=sfx.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"sfx.d.ts","sourceRoot":"","sources":["../../../src/engine/audio/sfx.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,+EAA+E;AAC/E,eAAO,MAAM,cAAc,MAAM,CAAC;AAElC,0EAA0E;AAC1E,eAAO,MAAM,YAAY,KAAK,CAAC;AAE/B,kEAAkE;AAClE,eAAO,MAAM,SAAS,IAAI,CAAC;AAE3B,qEAAqE;AACrE,eAAO,MAAM,SAAS,KAAK,CAAC;AAE5B,8EAA8E;AAC9E,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC,8EAA8E;AAC9E,eAAO,MAAM,aAAa,KAAK,CAAC;AAEhC,sEAAsE;AACtE,eAAO,MAAM,cAAc,IAAI,CAAC;AAEhC;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,IAAO,CAAC;AAChC,eAAO,MAAM,WAAW,IAAO,CAAC;AAChC,eAAO,MAAM,eAAe,IAAO,CAAC;AACpC,eAAO,MAAM,eAAe,KAAO,CAAC;AACpC,6EAA6E;AAC7E,eAAO,MAAM,aAAa,KAAO,CAAC;AAClC;;;GAGG;AACH,eAAO,MAAM,mBAAmB,wBAA8B,CAAC;AAE/D,qEAAqE;AACrE,eAAO,MAAM,gBAAgB,IAAI,CAAC;AAyBlC,8EAA8E;AAC9E,MAAM,MAAM,iBAAiB;AAC5B,0DAA0D;AACxD,8BAA8B;AAChC,8EAA8E;GAC5E,wBAAwB;AAC1B,yEAAyE;GACvE,qCAAqC;AACvC,yEAAyE;GACvE,uBAAuB,CAAC;AAE3B;;;;;GAKG;AACH,qBAAa,aAAc,SAAQ,KAAK;IACvC,QAAQ,CAAC,IAAI,EAAE,iBAAiB,CAAC;gBAErB,IAAI,EAAE,iBAAiB,EAAE,OAAO,EAAE,MAAM;CAKpD;AAcD,sFAAsF;AACtF,MAAM,WAAW,kBAAkB;IAClC,uEAAuE;IACvE,KAAK,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,SAAS,EAAE,MAAM,CAAC;IAClB,+DAA+D;IAC/D,WAAW,EAAE,MAAM,CAAC;CACpB;AAED,+EAA+E;AAC/E,MAAM,WAAW,kBAAkB;IAClC,wEAAwE;IACxE,GAAG,EAAE,MAAM,CAAC;IACZ,2DAA2D;IAC3D,IAAI,EAAE,MAAM,CAAC;IACb,kEAAkE;IAClE,KAAK,EAAE,MAAM,CAAC;IACd,4EAA4E;IAC5E,SAAS,EAAE,MAAM,CAAC;IAClB,yEAAyE;IACzE,OAAO,EAAE,MAAM,CAAC;IAChB;;;OAGG;IACH,OAAO,EAAE,MAAM,CAAC;IAChB,oEAAoE;IACpE,gBAAgB,EAAE,kBAAkB,EAAE,CAAC;CACvC;AAWD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,GAAG,kBAAkB,CAkP3D"}
1
+ {"version":3,"file":"sfx.d.ts","sourceRoot":"","sources":["../../../src/engine/audio/sfx.ts"],"names":[],"mappings":"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"}
@@ -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
@@ -53,7 +54,7 @@ export const DEFAULT_LENGTH = 1;
53
54
  *
54
55
  * Decoded byte-for-byte on PICO-8 v0.2.7 by poke-and-readback + single-bit audio
55
56
  * diff (finding A.7). NOIZ applies only to instrument 6 (`@6`, manual);
56
- * DAMPEN/REVERB have 2 levels each (`!dampen`/`!dampen2`, `!reverb`/`!reverb2`).
57
+ * DAMPEN has 2 levels (`!dampen`/`!dampen2`); REVERB is one switch (ADR-0010).
57
58
  */
58
59
  export const FILTER_NOIZ = 0x02;
59
60
  export const FILTER_BUZZ = 0x04;
@@ -95,50 +96,164 @@ export class AudioMmlError extends Error {
95
96
  this.code = code;
96
97
  }
97
98
  }
99
+ /** The effect numbers whose behaviour is per ROW, not per held note (finding A.4). */
100
+ const EFFECT_DROP = 3;
101
+ const EFFECT_FADE_IN = 4;
102
+ const EFFECT_FADE_OUT = 5;
103
+ const EFFECT_VIBRATO = 2;
104
+ /**
105
+ * Renders ONE held note (struck once, held for `rows` rows) into tracker rows
106
+ * (ADR-0019). PICO-8 plays consecutive same-pitch rows as one continuous tone,
107
+ * so a held note is shaped by stepping VOLUME across its rows, never by
108
+ * repeating a one-row effect on each of them:
109
+ *
110
+ * - volume: the `v[...]` envelope when one is set; otherwise, under fade-out
111
+ * (`e5`), a linear ramp from `v` towards 0 (`ceil`, so no row goes silent
112
+ * before the end); otherwise the flat `v`.
113
+ * - `e4` fade in: the FIRST row only (the attack), the rest hold.
114
+ * - `e5` fade out: the LAST row only (it fades the ramp's final step to 0).
115
+ * - `e3` drop: the FIRST row only; the remaining rows are SILENT. A drop ends far
116
+ * below the note's pitch, so a held drop is a one-row hit followed by rests
117
+ * (re-sounding the pitch would be a second, un-dropped note).
118
+ * - `e1` slide, `e2` vibrato, `e6`/`e7` arpeggio: EVERY row. They are
119
+ * continuous (vibrato), a no-op between equal pitches (slide), or defined over
120
+ * the 4-row group a row sits in (arpeggio), so carrying them on is what keeps
121
+ * their meaning.
122
+ * - `~N` delayed vibrato: rows from index N on that would carry no effect get
123
+ * `e2`, so a long note starts plain and blooms into vibrato.
124
+ *
125
+ * A one-row note is emitted exactly as written (its effect included).
126
+ */
127
+ export function renderHeldNote(note, rows) {
128
+ const out = [];
129
+ for (let k = 0; k < rows; k++) {
130
+ let volume;
131
+ if (note.envelope !== undefined) {
132
+ volume = envelopeVolume(note.envelope, k, rows);
133
+ }
134
+ else if (note.effect === EFFECT_FADE_OUT && rows > 1) {
135
+ volume = Math.ceil(note.volume * (1 - k / rows));
136
+ }
137
+ else {
138
+ volume = note.volume;
139
+ }
140
+ let effect;
141
+ switch (note.effect) {
142
+ case EFFECT_FADE_IN:
143
+ effect = k === 0 ? EFFECT_FADE_IN : 0;
144
+ break;
145
+ case EFFECT_FADE_OUT:
146
+ effect = k === rows - 1 ? EFFECT_FADE_OUT : 0;
147
+ break;
148
+ case EFFECT_DROP:
149
+ if (k > 0) {
150
+ out.push({ pitch: 0, waveform: note.waveform, volume: 0, effect: 0 });
151
+ continue;
152
+ }
153
+ effect = EFFECT_DROP;
154
+ break;
155
+ default:
156
+ effect = note.effect;
157
+ }
158
+ if (effect === 0 && note.vibratoDelay > 0 && k >= note.vibratoDelay) {
159
+ effect = EFFECT_VIBRATO;
160
+ }
161
+ // A silent row inside a note carries no effect (nothing to shape).
162
+ if (volume === 0)
163
+ effect = 0;
164
+ out.push({ pitch: note.pitch, waveform: note.waveform, volume, effect });
165
+ }
166
+ return out;
167
+ }
168
+ /**
169
+ * The volume a `v[...]` envelope gives row `k` of a `rows`-row note: the tail
170
+ * owns the note's last `tail.length` rows when the note is LONGER than the tail
171
+ * (so the head always sounds), the head owns the rest with its last value held.
172
+ */
173
+ function envelopeVolume(env, k, rows) {
174
+ const tailLen = env.tail.length;
175
+ if (tailLen > 0 && rows > tailLen && k >= rows - tailLen) {
176
+ return env.tail[k - (rows - tailLen)];
177
+ }
178
+ return env.head[Math.min(k, env.head.length - 1)];
179
+ }
98
180
  /**
99
181
  * Transpiles a picopilot-MML string into ONE `__sfx__` text row (finding Part
100
182
  * A/B). Emits the friendly text nibbles directly.
101
183
  *
102
- * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume,
103
- * `e<0-7>`/mnemonic effect, `o<0-5>` octave, `l<N>` default length, `>`/`<`
104
- * octave shift, all sticky. A bare note emits one row using the current state.
184
+ * Modal state model (finding B.1): `@<hex>` waveform, `v<0-7>` volume (or
185
+ * `v[...]` a per-note volume envelope, ADR-0019), `e<0-7>` effect, `~<N>`
186
+ * delayed vibrato, `o<0-5>` octave, `l<N>` default length, `>`/`<` octave
187
+ * shift, all sticky. A bare note emits one row using the current state.
105
188
  *
106
189
  * SFX-level directives (finding B.5): `s<N>` speed (default 16), `{`/`}` loop
107
190
  * markers (single `{` = the LEN special case).
108
191
  *
109
- * Durations are TRACKER ROWS (finding B.4): a length `N` = N rows, emitted as
110
- * the note then `(N-1)` tie rows re-striking it; `.` multiplies by 1.5 (rounded
111
- * to the nearest whole row, warned); `r` = silent rows; `^` extends the previous
112
- * note.
192
+ * Durations are TRACKER ROWS (finding B.4): a length `N` = one note HELD for N
193
+ * rows, shaped by {@link renderHeldNote} (ADR-0019); `.` multiplies by 1.5
194
+ * (rounded to the nearest whole row, warned); `r` = silent rows; `^` extends the
195
+ * previous note (re-shaping it as one longer note).
113
196
  *
114
197
  * @throws {AudioMmlError} `audio-mml-pitch-out-of-range` (a note below C0 / above
115
- * D#5), `audio-mml-sfx-overflow` (>32 rows), or `audio-mml-parse-error` (a
198
+ * D#5), `audio-mml-sfx-overflow` (>32 rows), `audio-mml-tie-state-change` (a
199
+ * modal change between a note and its `^`), or `audio-mml-parse-error` (a
116
200
  * token that cannot be parsed, incl. a tempo-style input).
117
201
  */
118
202
  export function mmlToSfxRow(mml) {
203
+ const parsed = mmlToRows(mml, { maxRows: SFX_MAX_ROWS });
204
+ const { loopStart, loopEnd } = resolveLoop(parsed.loopStartRow, parsed.loopEndRow, parsed.notes.length);
205
+ return {
206
+ row: emitSfxRow({
207
+ filters: parsed.filters,
208
+ speed: parsed.speed,
209
+ loopStart,
210
+ loopEnd,
211
+ notes: parsed.notes,
212
+ }),
213
+ rows: parsed.notes.length,
214
+ speed: parsed.speed,
215
+ loopStart,
216
+ loopEnd,
217
+ filters: parsed.filters,
218
+ roundingWarnings: parsed.roundingWarnings,
219
+ };
220
+ }
221
+ /**
222
+ * Parses picopilot-MML into its tracker-row stream (the core of
223
+ * {@link mmlToSfxRow}). `maxRows` is the overflow cap: 32 for one SFX,
224
+ * `Infinity` for a `music from-score` channel, which is cut into SFX later.
225
+ *
226
+ * @throws {AudioMmlError} as {@link mmlToSfxRow}.
227
+ */
228
+ export function mmlToRows(mml, options = {}) {
229
+ const maxRows = options.maxRows ?? SFX_MAX_ROWS;
119
230
  const state = {
120
231
  octave: DEFAULT_OCTAVE,
121
232
  waveform: 0,
122
233
  volume: 7,
234
+ envelope: undefined,
123
235
  effect: 0,
236
+ vibratoDelay: 0,
124
237
  length: DEFAULT_LENGTH,
125
238
  };
126
239
  const notes = [];
127
240
  const warnings = [];
128
241
  let speed = DEFAULT_SPEED;
242
+ let speedSet = false;
129
243
  let filters = 0;
130
244
  let loopStartRow;
131
245
  let loopEndRow;
132
- let lastNote;
246
+ /** The last held note, where its rows start, and how many rows it holds. */
247
+ let last;
133
248
  const src = mml;
134
249
  let i = 0;
135
250
  const len = src.length;
136
251
  /** The overflow check, applied every time rows are appended (finding B.4). */
137
- const pushNote = (note) => {
138
- if (notes.length + 1 > SFX_MAX_ROWS) {
139
- throw new AudioMmlError('audio-mml-sfx-overflow', `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.)`);
252
+ const pushRows = (rows) => {
253
+ if (notes.length + rows.length > maxRows) {
254
+ throw new AudioMmlError('audio-mml-sfx-overflow', `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.)`);
140
255
  }
141
- notes.push(note);
256
+ notes.push(...rows);
142
257
  };
143
258
  while (i < len) {
144
259
  const ch = src[i];
@@ -158,13 +273,14 @@ export function mmlToSfxRow(mml) {
158
273
  throw parseError(src, i, `SFX speed 's${value}' out of range 1..255 (ticks per row)`);
159
274
  }
160
275
  speed = value;
276
+ speedSet = true;
161
277
  i = next;
162
278
  continue;
163
279
  }
164
280
  // `!<name>[<level>]`: an SFX-level FILTER directive (finding A.7). A `!` head
165
281
  // never collides with the note grammar (unlike the rejected `sl vb dr`
166
282
  // mnemonics, which WERE bare note-letter sequences, ADR-0008). They set the
167
- // first header byte; DAMPEN/REVERB take an optional 1..2 level (default 1).
283
+ // first header byte; DAMPEN takes an optional 1..2 level (default 1).
168
284
  if (ch === '!') {
169
285
  const parsed = readFilter(src, i);
170
286
  filters |= parsed.bits;
@@ -194,11 +310,31 @@ export function mmlToSfxRow(mml) {
194
310
  continue;
195
311
  }
196
312
  if (lower === 'v') {
313
+ // `v[...]`: a per-note volume ENVELOPE (ADR-0019). `[` after `v` was a
314
+ // parse error before, so no previously valid MML changes meaning.
315
+ if (src[i + 1] === '[') {
316
+ const env = readEnvelope(src, i);
317
+ state.envelope = env.envelope;
318
+ i = env.next;
319
+ continue;
320
+ }
197
321
  const { value, next } = readInt(src, i + 1);
198
322
  if (value === undefined || value > 7) {
199
- throw parseError(src, i, "expected a digit 0-7 after 'v' (volume)");
323
+ throw parseError(src, i, "expected a digit 0-7 after 'v' (volume), or 'v[...]' (a volume envelope, e.g. v[3 2 2 1/0])");
200
324
  }
201
325
  state.volume = value;
326
+ state.envelope = undefined; // a flat volume replaces an envelope
327
+ i = next;
328
+ continue;
329
+ }
330
+ // `~<N>`: delayed vibrato (ADR-0019). Held notes switch to e2 from their
331
+ // row N; `~0` turns it off. `~` was an unexpected token before.
332
+ if (ch === '~') {
333
+ const { value, next } = readInt(src, i + 1);
334
+ if (value === undefined || value > SFX_MAX_ROWS - 1) {
335
+ throw parseError(src, i, `expected a row count 0-${SFX_MAX_ROWS - 1} after '~' (delayed vibrato: ~4 = vibrato from a held note's 5th row, ~0 = off)`);
336
+ }
337
+ state.vibratoDelay = value;
202
338
  i = next;
203
339
  continue;
204
340
  }
@@ -256,22 +392,34 @@ export function mmlToSfxRow(mml) {
256
392
  // --- Rest -----------------------------------------------------------------
257
393
  if (lower === 'r') {
258
394
  const dur = readDuration(src, i, i + 1, state.length);
395
+ const silent = [];
259
396
  for (let k = 0; k < dur.rows; k++) {
260
- pushNote({ pitch: 0, waveform: state.waveform, volume: 0, effect: 0 });
397
+ silent.push({ pitch: 0, waveform: state.waveform, volume: 0, effect: 0 });
261
398
  }
399
+ pushRows(silent);
262
400
  collectWarning(dur, warnings);
263
- lastNote = undefined;
401
+ last = undefined;
264
402
  i = dur.next;
265
403
  continue;
266
404
  }
267
405
  // --- Tie: extend the previous note ---------------------------------------
268
406
  if (ch === '^') {
269
- if (lastNote === undefined) {
407
+ if (last === undefined) {
270
408
  throw parseError(src, i, "'^' (tie) with no preceding note to extend");
271
409
  }
410
+ // A tie extends the SAME held note, so it is re-shaped as one longer
411
+ // note. A modal change in between (`c4 v3 ^4`, `c4 e5 ^2`) used to be
412
+ // silently ignored; it is now refused, because a held note has one
413
+ // shape (ADR-0019): use a `v[...]` envelope or a new note instead.
414
+ const changed = tieStateChange(last.note, state);
415
+ if (changed !== undefined) {
416
+ throw new AudioMmlError('audio-mml-tie-state-change', `'^' (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.`);
417
+ }
272
418
  const dur = readDuration(src, i, i + 1, state.length);
273
- for (let k = 0; k < dur.rows; k++)
274
- pushNote({ ...lastNote });
419
+ const total = last.rows + dur.rows;
420
+ notes.splice(last.start);
421
+ pushRows(renderHeldNote(last.note, total));
422
+ last.rows = total;
275
423
  collectWarning(dur, warnings);
276
424
  i = dur.next;
277
425
  continue;
@@ -299,28 +447,83 @@ export function mmlToSfxRow(mml) {
299
447
  pitch,
300
448
  waveform: state.waveform,
301
449
  volume: state.volume,
450
+ envelope: state.envelope,
302
451
  effect: state.effect,
452
+ vibratoDelay: state.vibratoDelay,
303
453
  };
304
- for (let k = 0; k < dur.rows; k++)
305
- pushNote({ ...note });
454
+ const start = notes.length;
455
+ pushRows(renderHeldNote(note, dur.rows));
306
456
  collectWarning(dur, warnings);
307
- lastNote = note;
457
+ last = { note, start, rows: dur.rows };
308
458
  i = dur.next;
309
459
  continue;
310
460
  }
311
461
  throw parseError(src, i, `unexpected token '${ch}'`);
312
462
  }
313
- const { loopStart, loopEnd } = resolveLoop(loopStartRow, loopEndRow, notes.length);
314
463
  return {
315
- row: emitRow({ filters, speed, loopStart, loopEnd, notes }),
316
- rows: notes.length,
464
+ notes,
317
465
  speed,
318
- loopStart,
319
- loopEnd,
466
+ speedSet,
320
467
  filters,
468
+ loopStartRow,
469
+ loopEndRow,
321
470
  roundingWarnings: warnings,
322
471
  };
323
472
  }
473
+ /**
474
+ * Names the modal value that changed between a held note and a `^` tie, or
475
+ * `undefined` when the tie continues the note's own state. Octave and default
476
+ * length are free to change (a tie keeps its pitch, and `^` reads the length).
477
+ */
478
+ function tieStateChange(note, state) {
479
+ if (note.waveform !== state.waveform)
480
+ return 'waveform (@)';
481
+ if (note.effect !== state.effect)
482
+ return 'effect (e)';
483
+ if (note.vibratoDelay !== state.vibratoDelay)
484
+ return 'delayed vibrato (~)';
485
+ if (JSON.stringify(note.envelope) !== JSON.stringify(state.envelope)) {
486
+ return 'volume envelope (v[...])';
487
+ }
488
+ if (note.envelope === undefined && note.volume !== state.volume) {
489
+ return 'volume (v)';
490
+ }
491
+ return undefined;
492
+ }
493
+ /**
494
+ * Parses a `v[...]` volume envelope at `at` (the `v`): digits 0-7 (one digit =
495
+ * one row's volume, whitespace optional), and at most one `/` separating the
496
+ * head from the release tail (ADR-0019).
497
+ */
498
+ function readEnvelope(src, at) {
499
+ const close = src.indexOf(']', at + 2);
500
+ if (close < 0) {
501
+ throw parseError(src, at, "unclosed volume envelope 'v[': missing ']'");
502
+ }
503
+ const body = src.slice(at + 2, close);
504
+ const parts = body.split('/');
505
+ if (parts.length > 2) {
506
+ throw parseError(src, at, "a volume envelope takes at most one '/' (head/release), e.g. v[3 2 2/1 0]");
507
+ }
508
+ const digits = (part) => {
509
+ const out = [];
510
+ for (const c of part) {
511
+ if (/\s/.test(c))
512
+ continue;
513
+ if (c < '0' || c > '7') {
514
+ throw parseError(src, at, `volume envelope values are digits 0-7 (one per row), got '${c}'`);
515
+ }
516
+ out.push(c.charCodeAt(0) - 48);
517
+ }
518
+ return out;
519
+ };
520
+ const head = digits(parts[0]);
521
+ const tail = parts.length === 2 ? digits(parts[1]) : [];
522
+ if (head.length === 0) {
523
+ throw parseError(src, at, 'a volume envelope needs at least one value before any /, e.g. v[3 2 2 1]');
524
+ }
525
+ return { envelope: { head, tail }, next: close + 1 };
526
+ }
324
527
  /**
325
528
  * Reads an optional `[length][dots]` after a note/rest/tie head at `from`. If no
326
529
  * explicit length digit is present the current default `length` (already in
@@ -470,7 +673,7 @@ function resolveLoop(loopStartRow, loopEndRow, rowCount) {
470
673
  return { loopStart: loopStartRow ?? 0, loopEnd: loopEndRow ?? 0 };
471
674
  }
472
675
  /** Serializes the header + notes into the 168-char `__sfx__` text row (finding A). */
473
- function emitRow(sfx) {
676
+ export function emitSfxRow(sfx) {
474
677
  // Header: filter-byte(2) speed(2) loopstart(2) loopend(2). The first byte is the
475
678
  // per-SFX FILTER bitfield (finding A.7); its low bit is the cosmetic pitch/
476
679
  // tracker mode flag, which we leave 0 (pitch mode), so the byte is exactly the