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
|
@@ -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
|
|
@@ -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
|
|
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
|
-
* `
|
|
126
|
-
*
|
|
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` =
|
|
132
|
-
*
|
|
133
|
-
* to the nearest whole row, warned); `r` = silent rows; `^` extends the
|
|
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),
|
|
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
|
|
1
|
+
{"version":3,"file":"sfx.d.ts","sourceRoot":"","sources":["../../../src/engine/audio/sfx.ts"],"names":[],"mappings":"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"}
|
package/dist/engine/audio/sfx.js
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
|
|
@@ -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
|
|
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
|
-
* `
|
|
104
|
-
*
|
|
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` =
|
|
110
|
-
*
|
|
111
|
-
* to the nearest whole row, warned); `r` = silent rows; `^` extends the
|
|
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),
|
|
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
|
-
|
|
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
|
|
138
|
-
if (notes.length +
|
|
139
|
-
throw new AudioMmlError('audio-mml-sfx-overflow', `picopilot-MML produced more than ${
|
|
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(
|
|
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
|
|
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
|
-
|
|
397
|
+
silent.push({ pitch: 0, waveform: state.waveform, volume: 0, effect: 0 });
|
|
261
398
|
}
|
|
399
|
+
pushRows(silent);
|
|
262
400
|
collectWarning(dur, warnings);
|
|
263
|
-
|
|
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 (
|
|
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
|
-
|
|
274
|
-
|
|
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
|
-
|
|
305
|
-
|
|
454
|
+
const start = notes.length;
|
|
455
|
+
pushRows(renderHeldNote(note, dur.rows));
|
|
306
456
|
collectWarning(dur, warnings);
|
|
307
|
-
|
|
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
|
-
|
|
316
|
-
rows: notes.length,
|
|
464
|
+
notes,
|
|
317
465
|
speed,
|
|
318
|
-
|
|
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
|
|
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
|