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.
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +129 -36
- package/dist/cli.js.map +1 -1
- package/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +239 -26
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +8 -3
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/gfx.d.ts.map +1 -1
- package/dist/commands/gfx.js +11 -5
- package/dist/commands/gfx.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/pico8-env.d.ts +39 -3
- package/dist/commands/pico8-env.d.ts.map +1 -1
- package/dist/commands/pico8-env.js +51 -4
- package/dist/commands/pico8-env.js.map +1 -1
- package/dist/commands/run.d.ts.map +1 -1
- package/dist/commands/run.js +8 -2
- package/dist/commands/run.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 +2 -2
- package/src/cli.test.ts +42 -1
- package/src/cli.ts +143 -37
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +294 -30
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +8 -3
- package/src/commands/gfx.ts +12 -9
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- package/src/commands/pico8-env.ts +71 -3
- package/src/commands/run.ts +8 -2
- 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/mcp.process.test.ts +219 -0
- package/src/skills/picopilot-audio/SKILL.md +74 -184
- package/src/skills/picopilot-overview/SKILL.md +1 -1
package/src/engine/audio/sfx.ts
CHANGED
|
@@ -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
|
-
*
|
|
19
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
* `
|
|
189
|
-
*
|
|
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` =
|
|
195
|
-
*
|
|
196
|
-
* to the nearest whole row, warned); `r` = silent rows; `^` extends the
|
|
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),
|
|
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:
|
|
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
|
-
|
|
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
|
|
226
|
-
if (notes.length +
|
|
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 ${
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
580
|
+
silent.push({pitch: 0, waveform: state.waveform, volume: 0, effect: 0});
|
|
374
581
|
}
|
|
582
|
+
pushRows(silent);
|
|
375
583
|
collectWarning(dur, warnings);
|
|
376
|
-
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
644
|
+
const start = notes.length;
|
|
645
|
+
pushRows(renderHeldNote(note, dur.rows));
|
|
421
646
|
collectWarning(dur, warnings);
|
|
422
|
-
|
|
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
|
-
|
|
438
|
-
rows: notes.length,
|
|
656
|
+
notes,
|
|
439
657
|
speed,
|
|
440
|
-
|
|
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
|
|
938
|
+
export function emitSfxRow(sfx: {
|
|
653
939
|
filters: number;
|
|
654
940
|
speed: number;
|
|
655
941
|
loopStart: number;
|
|
656
942
|
loopEnd: number;
|
|
657
|
-
notes:
|
|
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
|
-
/**
|
|
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
|
|
301
|
-
*
|
|
302
|
-
*
|
|
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
|
|
343
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
355
|
-
` if __pp_rec_t
|
|
356
|
-
'
|
|
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)\`.
|