picopilot 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/audio.d.ts +23 -2
- package/dist/commands/audio.d.ts.map +1 -1
- package/dist/commands/audio.js +228 -23
- package/dist/commands/audio.js.map +1 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +1 -0
- package/dist/commands/doctor.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/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 +1 -1
- package/src/commands/audio.test.ts +170 -1
- package/src/commands/audio.ts +284 -27
- package/src/commands/doctor.test.ts +1 -0
- package/src/commands/doctor.ts +1 -0
- package/src/commands/music.test.ts +119 -0
- package/src/commands/music.ts +251 -1
- 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/skills/picopilot-audio/SKILL.md +74 -184
|
@@ -8,13 +8,20 @@
|
|
|
8
8
|
* adapter. The `sfx from-mml` command is thin wiring over {@link mmlToSfxRow};
|
|
9
9
|
* the `music from-patterns` command over {@link patternsToMusic}.
|
|
10
10
|
*/
|
|
11
|
-
export {
|
|
11
|
+
export {
|
|
12
|
+
clearSfxSlot,
|
|
13
|
+
mergeSfxRow,
|
|
14
|
+
readSfxRows,
|
|
15
|
+
setMusicSection,
|
|
16
|
+
SFX_SLOT_COUNT,
|
|
17
|
+
} from './section.js';
|
|
12
18
|
export {
|
|
13
19
|
AudioMmlError,
|
|
14
20
|
type AudioMmlErrorCode,
|
|
15
21
|
DEFAULT_LENGTH,
|
|
16
22
|
DEFAULT_OCTAVE,
|
|
17
23
|
DEFAULT_SPEED,
|
|
24
|
+
emitSfxRow,
|
|
18
25
|
FILTER_BUZZ,
|
|
19
26
|
FILTER_DAMPEN_LEVEL,
|
|
20
27
|
FILTER_DETUNE_1,
|
|
@@ -22,13 +29,19 @@ export {
|
|
|
22
29
|
FILTER_MAX_LEVEL,
|
|
23
30
|
FILTER_NOIZ,
|
|
24
31
|
FILTER_REVERB,
|
|
32
|
+
type HeldNote,
|
|
33
|
+
mmlToRows,
|
|
34
|
+
type MmlRows,
|
|
25
35
|
mmlToSfxRow,
|
|
26
36
|
PITCH_MAX,
|
|
27
37
|
PITCH_MIN,
|
|
38
|
+
renderHeldNote,
|
|
28
39
|
type RowRoundingWarning,
|
|
29
40
|
SFX_MAX_ROWS,
|
|
41
|
+
type SfxNote,
|
|
30
42
|
SFX_ROW_LENGTH,
|
|
31
43
|
type SfxTranspileResult,
|
|
44
|
+
type VolumeEnvelope,
|
|
32
45
|
} from './sfx.js';
|
|
33
46
|
export {
|
|
34
47
|
CHANNEL_OFF_BIT,
|
|
@@ -45,3 +58,35 @@ export {
|
|
|
45
58
|
patternsToMusic,
|
|
46
59
|
SFX_INDEX_MAX,
|
|
47
60
|
} from './music.js';
|
|
61
|
+
export {
|
|
62
|
+
EMPTY_SFX_ROW,
|
|
63
|
+
formatSlots,
|
|
64
|
+
isFreeSfxRow,
|
|
65
|
+
musicSfxRefs,
|
|
66
|
+
parseSlotList,
|
|
67
|
+
type PlannedSlot,
|
|
68
|
+
type PlannedSong,
|
|
69
|
+
planScore,
|
|
70
|
+
readScore,
|
|
71
|
+
type Score,
|
|
72
|
+
type ScoreCartState,
|
|
73
|
+
type ScoreChannel,
|
|
74
|
+
ScoreError,
|
|
75
|
+
type ScoreErrorCode,
|
|
76
|
+
type ScorePlan,
|
|
77
|
+
type ScorePlanOptions,
|
|
78
|
+
type ScoreSong,
|
|
79
|
+
} from './score.js';
|
|
80
|
+
export {
|
|
81
|
+
type AnalysisWarning,
|
|
82
|
+
analyzeAudio,
|
|
83
|
+
type AnalyzeOptions,
|
|
84
|
+
type AudioAnalysis,
|
|
85
|
+
AudioAnalyzeError,
|
|
86
|
+
decodeWav,
|
|
87
|
+
type DecodedWav,
|
|
88
|
+
encodeWav16,
|
|
89
|
+
FRAME_SIZE,
|
|
90
|
+
SILENCE_RMS,
|
|
91
|
+
THRESHOLDS,
|
|
92
|
+
} from './analyze.js';
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import {describe, expect, it} from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {AudioMmlError, mmlToSfxRow} from './sfx.js';
|
|
4
|
+
import {
|
|
5
|
+
EMPTY_SFX_ROW,
|
|
6
|
+
formatSlots,
|
|
7
|
+
isFreeSfxRow,
|
|
8
|
+
musicSfxRefs,
|
|
9
|
+
parseSlotList,
|
|
10
|
+
planScore,
|
|
11
|
+
readScore,
|
|
12
|
+
type ScoreCartState,
|
|
13
|
+
ScoreError,
|
|
14
|
+
} from './score.js';
|
|
15
|
+
|
|
16
|
+
/** An empty cart: no SFX, no music. */
|
|
17
|
+
const EMPTY: ScoreCartState = {sfxRows: [], musicRefs: new Set()};
|
|
18
|
+
|
|
19
|
+
/** The 4 channel bytes + flag byte of each `__music__` row. */
|
|
20
|
+
function musicRows(section: string) {
|
|
21
|
+
return section
|
|
22
|
+
.trim()
|
|
23
|
+
.split('\n')
|
|
24
|
+
.map((line) => {
|
|
25
|
+
const [flags, ch] = line.split(' ');
|
|
26
|
+
return {
|
|
27
|
+
flags: Number.parseInt(flags!, 16),
|
|
28
|
+
channels: [0, 1, 2, 3].map((c) => {
|
|
29
|
+
const b = Number.parseInt(ch!.slice(c * 2, c * 2 + 2), 16);
|
|
30
|
+
return b & 0x40 ? null : b;
|
|
31
|
+
}),
|
|
32
|
+
};
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Decoded volume of row `r` of an `__sfx__` row. */
|
|
37
|
+
function vol(row: string, r: number): number {
|
|
38
|
+
return Number.parseInt(row[8 + r * 5 + 3]!, 16);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Expects `fn` to throw a ScoreError with `code`. */
|
|
42
|
+
function expectScoreError(fn: () => unknown, code: string) {
|
|
43
|
+
let err: unknown;
|
|
44
|
+
try {
|
|
45
|
+
fn();
|
|
46
|
+
} catch (e) {
|
|
47
|
+
err = e;
|
|
48
|
+
}
|
|
49
|
+
expect(err).toBeInstanceOf(ScoreError);
|
|
50
|
+
expect((err as ScoreError).code).toBe(code);
|
|
51
|
+
return err as ScoreError;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
describe('readScore', () => {
|
|
55
|
+
it('accepts {songs:[...]} and a single song shorthand', () => {
|
|
56
|
+
expect(readScore({songs: [{channels: ['c']}]}).songs).toHaveLength(1);
|
|
57
|
+
expect(readScore({channels: ['c']}).songs).toHaveLength(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('refuses malformed shapes', () => {
|
|
61
|
+
expectScoreError(() => readScore([]), 'audio-score-parse-error');
|
|
62
|
+
expectScoreError(() => readScore({}), 'audio-score-parse-error');
|
|
63
|
+
expectScoreError(() => readScore({songs: []}), 'audio-score-empty');
|
|
64
|
+
expectScoreError(
|
|
65
|
+
() => readScore({songs: [{channels: [3]}]}),
|
|
66
|
+
'audio-score-parse-error',
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('planScore: splitting channels of any length into aligned patterns', () => {
|
|
72
|
+
it('cuts a 64-row melody into 2 SFX and 2 patterns', () => {
|
|
73
|
+
const plan = planScore(
|
|
74
|
+
readScore({channels: ['l8 c d e f g a b > c', 'o1 l32 c c']}),
|
|
75
|
+
EMPTY,
|
|
76
|
+
);
|
|
77
|
+
expect(plan.patterns).toBe(2);
|
|
78
|
+
const rows = musicRows(plan.musicSection);
|
|
79
|
+
expect(rows[0]!.channels.slice(2)).toEqual([null, null]);
|
|
80
|
+
// 3 distinct SFX: two melody halves + one bass bar used twice (deduped).
|
|
81
|
+
expect(plan.slots).toHaveLength(3);
|
|
82
|
+
expect(rows[0]!.channels[1]).toBe(rows[1]!.channels[1]);
|
|
83
|
+
expect(rows[0]!.channels[0]).not.toBe(rows[1]!.channels[0]);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('a channel silent for a whole pattern is null (off), not a silent SFX', () => {
|
|
87
|
+
const plan = planScore(
|
|
88
|
+
readScore({channels: ['c32 r32 d32', 'l96 e']}),
|
|
89
|
+
EMPTY,
|
|
90
|
+
);
|
|
91
|
+
const rows = musicRows(plan.musicSection);
|
|
92
|
+
expect(rows.map((r) => r.channels[0] === null)).toEqual([
|
|
93
|
+
false,
|
|
94
|
+
true,
|
|
95
|
+
false,
|
|
96
|
+
]);
|
|
97
|
+
expect(rows.every((r) => r.channels[1] !== null)).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('a pattern silent on every channel keeps one silent SFX on ch0 (it must last)', () => {
|
|
101
|
+
const plan = planScore(readScore({channels: ['c32 r32 c32']}), EMPTY);
|
|
102
|
+
const rows = musicRows(plan.musicSection);
|
|
103
|
+
expect(rows[1]!.channels[0]).not.toBeNull();
|
|
104
|
+
const rest = plan.slots.find((s) => s.slot === rows[1]!.channels[0])!;
|
|
105
|
+
expect(rest.uses[0]).toContain('rest');
|
|
106
|
+
expect(isFreeSfxRow(rest.row)).toBe(true);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('pads a short channel and a ragged song end, with warnings', () => {
|
|
110
|
+
const plan = planScore(readScore({channels: ['c40', 'd8']}), EMPTY);
|
|
111
|
+
expect(plan.patterns).toBe(2);
|
|
112
|
+
expect(plan.warnings.join('\n')).toMatch(/not a multiple of 32/);
|
|
113
|
+
expect(plan.warnings.join('\n')).toMatch(/channel 1 is 8 rows/);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('every SFX carries the song speed and the channel filters', () => {
|
|
117
|
+
const plan = planScore(
|
|
118
|
+
readScore({
|
|
119
|
+
songs: [
|
|
120
|
+
{
|
|
121
|
+
speed: 20,
|
|
122
|
+
channels: [{mml: 'c32', filters: '!dampen'}, '!reverb d32'],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
}),
|
|
126
|
+
EMPTY,
|
|
127
|
+
);
|
|
128
|
+
const headers = plan.slots.map((s) => s.row.slice(0, 8)).sort();
|
|
129
|
+
expect(headers).toEqual(['20140000', '80140000']);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('patternRows < 32 writes the LEN header on every SFX (e.g. 3/4 bars)', () => {
|
|
133
|
+
const plan = planScore(
|
|
134
|
+
readScore({songs: [{patternRows: 24, channels: ['c48']}]}),
|
|
135
|
+
EMPTY,
|
|
136
|
+
);
|
|
137
|
+
expect(plan.patterns).toBe(2);
|
|
138
|
+
expect(plan.slots[0]!.row.slice(0, 8)).toBe('00101800'); // LEN 24
|
|
139
|
+
expect(plan.songs[0]!.seconds).toBeCloseTo((48 * 16) / 120);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('held-note shaping crosses a pattern boundary (the fade spans both SFX)', () => {
|
|
143
|
+
const plan = planScore(readScore({channels: ['r28 v4 e5 c8']}), EMPTY);
|
|
144
|
+
const [a, b] = musicRows(plan.musicSection).map(
|
|
145
|
+
(r) => plan.slots.find((s) => s.slot === r.channels[0])!.row,
|
|
146
|
+
);
|
|
147
|
+
expect([28, 29, 30, 31].map((r) => vol(a!, r))).toEqual([4, 4, 3, 3]);
|
|
148
|
+
expect([0, 1, 2, 3].map((r) => vol(b!, r))).toEqual([2, 2, 1, 1]);
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe('planScore: songs (sections) and loop flags', () => {
|
|
153
|
+
it('lays songs back to back and reports each start pattern', () => {
|
|
154
|
+
const plan = planScore(
|
|
155
|
+
readScore({
|
|
156
|
+
songs: [
|
|
157
|
+
{name: 'title', channels: ['c64']},
|
|
158
|
+
{name: 'game', channels: ['d96'], loopFrom: 1},
|
|
159
|
+
{name: 'jingle', loop: false, channels: ['l32 e']},
|
|
160
|
+
],
|
|
161
|
+
}),
|
|
162
|
+
EMPTY,
|
|
163
|
+
);
|
|
164
|
+
expect(plan.songs.map((s) => [s.name, s.startPattern, s.patterns])).toEqual(
|
|
165
|
+
[
|
|
166
|
+
['title', 0, 2],
|
|
167
|
+
['game', 2, 3],
|
|
168
|
+
['jingle', 5, 1],
|
|
169
|
+
],
|
|
170
|
+
);
|
|
171
|
+
const flags = musicRows(plan.musicSection).map((r) => r.flags);
|
|
172
|
+
// title: loopStart@0, loopBack@1; game: loopStart@3 (loopFrom 1), loopBack@4;
|
|
173
|
+
// jingle: stop@5.
|
|
174
|
+
expect(flags).toEqual([1, 2, 0, 1, 2, 4]);
|
|
175
|
+
expect(plan.songs[1]!.loopStartPattern).toBe(3);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('refuses a bad loopFrom, channel count, speed or loop marker', () => {
|
|
179
|
+
expectScoreError(
|
|
180
|
+
() => planScore(readScore({channels: ['c32'], loopFrom: 1}), EMPTY),
|
|
181
|
+
'audio-score-bad-loop-from',
|
|
182
|
+
);
|
|
183
|
+
expectScoreError(
|
|
184
|
+
() => planScore(readScore({channels: ['c', 'c', 'c', 'c', 'c']}), EMPTY),
|
|
185
|
+
'audio-score-bad-channel-count',
|
|
186
|
+
);
|
|
187
|
+
expectScoreError(
|
|
188
|
+
() => planScore(readScore({speed: 12, channels: ['s10 c']}), EMPTY),
|
|
189
|
+
'audio-score-speed-mismatch',
|
|
190
|
+
);
|
|
191
|
+
expectScoreError(
|
|
192
|
+
() => planScore(readScore({channels: ['s10 c', 's11 c']}), EMPTY),
|
|
193
|
+
'audio-score-speed-mismatch',
|
|
194
|
+
);
|
|
195
|
+
expectScoreError(
|
|
196
|
+
() => planScore(readScore({channels: ['{c d}']}), EMPTY),
|
|
197
|
+
'audio-score-loop-marker',
|
|
198
|
+
);
|
|
199
|
+
expectScoreError(
|
|
200
|
+
() => planScore(readScore({channels: ['c'], patternRows: 33}), EMPTY),
|
|
201
|
+
'audio-score-bad-pattern-rows',
|
|
202
|
+
);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('a shared s<N> in the channels is the song speed', () => {
|
|
206
|
+
const plan = planScore(readScore({channels: ['s9 c', 's9 d']}), EMPTY);
|
|
207
|
+
expect(plan.songs[0]!.speed).toBe(9);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('an MML error names the song and channel', () => {
|
|
211
|
+
let err: unknown;
|
|
212
|
+
try {
|
|
213
|
+
planScore(
|
|
214
|
+
readScore({songs: [{name: 'title', channels: ['c', 'o5 g']}]}),
|
|
215
|
+
EMPTY,
|
|
216
|
+
);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
err = e;
|
|
219
|
+
}
|
|
220
|
+
expect(err).toBeInstanceOf(AudioMmlError);
|
|
221
|
+
expect((err as AudioMmlError).code).toBe('audio-mml-pitch-out-of-range');
|
|
222
|
+
expect((err as AudioMmlError).message).toMatch(/^song 'title' channel 1:/);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('refuses more than 64 patterns', () => {
|
|
226
|
+
expectScoreError(
|
|
227
|
+
() => planScore(readScore({channels: ['c32 '.repeat(65)]}), EMPTY),
|
|
228
|
+
'audio-score-too-many-patterns',
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
describe('planScore: slot allocation', () => {
|
|
234
|
+
const gameSfx = mmlToSfxRow('s4 @3 v6 c e g').row;
|
|
235
|
+
|
|
236
|
+
it('without a range: free slots only, from 63 down, never touching content', () => {
|
|
237
|
+
const sfxRows: (string | undefined)[] = [];
|
|
238
|
+
sfxRows[63] = gameSfx; // a game SFX up top
|
|
239
|
+
sfxRows[62] = mmlToSfxRow('').row; // empty body = free
|
|
240
|
+
const plan = planScore(readScore({channels: ['c32 d32']}), {
|
|
241
|
+
sfxRows,
|
|
242
|
+
musicRefs: new Set(),
|
|
243
|
+
});
|
|
244
|
+
expect(plan.slots.map((s) => s.slot)).toEqual([61, 62]);
|
|
245
|
+
expect(plan.slots.every((s) => !s.overwrote)).toBe(true);
|
|
246
|
+
expect(plan.cleared).toEqual([]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it('refuses when there are not enough free slots, pointing at --slots', () => {
|
|
250
|
+
const sfxRows = new Array(64).fill(gameSfx);
|
|
251
|
+
sfxRows[10] = undefined;
|
|
252
|
+
const err = expectScoreError(
|
|
253
|
+
() =>
|
|
254
|
+
planScore(readScore({channels: ['c32 d32']}), {
|
|
255
|
+
sfxRows,
|
|
256
|
+
musicRefs: new Set(),
|
|
257
|
+
}),
|
|
258
|
+
'audio-score-no-free-slots',
|
|
259
|
+
);
|
|
260
|
+
expect(err.message).toContain('--slots');
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('with a range: owns it (overwrites, clears unused), never touches outside', () => {
|
|
264
|
+
const sfxRows: (string | undefined)[] = [];
|
|
265
|
+
for (let s = 0; s < 64; s++) sfxRows[s] = gameSfx;
|
|
266
|
+
const plan = planScore(
|
|
267
|
+
readScore({channels: ['c32']}),
|
|
268
|
+
{
|
|
269
|
+
sfxRows,
|
|
270
|
+
musicRefs: new Set(),
|
|
271
|
+
},
|
|
272
|
+
{slots: {from: 40, to: 42}},
|
|
273
|
+
);
|
|
274
|
+
expect(plan.slots.map((s) => [s.slot, s.overwrote])).toEqual([[40, true]]);
|
|
275
|
+
expect(plan.cleared).toEqual([41, 42]);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it('re-running an unchanged score keeps every SFX in its slot', () => {
|
|
279
|
+
const score = readScore({channels: ['c32 d32 l32 e', 'o1 c96']});
|
|
280
|
+
const first = planScore(score, EMPTY, {slots: {from: 20, to: 30}});
|
|
281
|
+
const sfxRows: (string | undefined)[] = [];
|
|
282
|
+
for (const s of first.slots) sfxRows[s.slot] = s.row;
|
|
283
|
+
const second = planScore(
|
|
284
|
+
score,
|
|
285
|
+
{sfxRows, musicRefs: new Set()},
|
|
286
|
+
{slots: {from: 20, to: 30}},
|
|
287
|
+
);
|
|
288
|
+
expect(second.slots).toEqual(
|
|
289
|
+
first.slots.map((s) => ({...s, overwrote: false})),
|
|
290
|
+
);
|
|
291
|
+
expect(second.musicSection).toBe(first.musicSection);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('warns about slots the previous __music__ used that are now orphaned', () => {
|
|
295
|
+
const sfxRows: (string | undefined)[] = [];
|
|
296
|
+
sfxRows[5] = gameSfx;
|
|
297
|
+
const plan = planScore(readScore({channels: ['c32']}), {
|
|
298
|
+
sfxRows,
|
|
299
|
+
musicRefs: new Set([5]),
|
|
300
|
+
});
|
|
301
|
+
expect(plan.warnings.join('\n')).toMatch(
|
|
302
|
+
/SFX 5 were used by the previous __music__/,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('custom instruments (@8-@f) keep the allocator out of slots 0-7', () => {
|
|
307
|
+
const sfxRows = new Array(64).fill(gameSfx);
|
|
308
|
+
for (let s = 0; s < 8; s++) sfxRows[s] = undefined;
|
|
309
|
+
expectScoreError(
|
|
310
|
+
() =>
|
|
311
|
+
planScore(readScore({channels: ['@8 c32']}), {
|
|
312
|
+
sfxRows,
|
|
313
|
+
musicRefs: new Set(),
|
|
314
|
+
}),
|
|
315
|
+
'audio-score-no-free-slots',
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('refuses a bad slot range', () => {
|
|
320
|
+
expectScoreError(
|
|
321
|
+
() =>
|
|
322
|
+
planScore(readScore({channels: ['c']}), EMPTY, {
|
|
323
|
+
slots: {from: 10, to: 64},
|
|
324
|
+
}),
|
|
325
|
+
'audio-score-bad-slot-range',
|
|
326
|
+
);
|
|
327
|
+
});
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
describe('slot helpers', () => {
|
|
331
|
+
it('parseSlotList / formatSlots round-trip', () => {
|
|
332
|
+
expect(parseSlotList('3,5,16-18')).toEqual([3, 5, 16, 17, 18]);
|
|
333
|
+
expect(parseSlotList('64')).toBeUndefined();
|
|
334
|
+
expect(parseSlotList('5-3')).toBeUndefined();
|
|
335
|
+
expect(parseSlotList('x')).toBeUndefined();
|
|
336
|
+
expect(formatSlots([18, 3, 16, 17, 5])).toBe('3,5,16-18');
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('musicSfxRefs reads the referenced slots, skipping off channels', () => {
|
|
340
|
+
expect(
|
|
341
|
+
[...musicSfxRefs('01 10114040\n02 12134041\n')].sort((a, b) => a - b),
|
|
342
|
+
).toEqual([16, 17, 18, 19]);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('EMPTY_SFX_ROW is free', () => {
|
|
346
|
+
expect(isFreeSfxRow(EMPTY_SFX_ROW)).toBe(true);
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
describe('planScore: review fixes', () => {
|
|
351
|
+
it('without a range, re-running an unchanged score reuses its own slots (no leak)', () => {
|
|
352
|
+
const score = readScore({channels: ['c32 d32', 'o1 c64']});
|
|
353
|
+
const first = planScore(score, EMPTY);
|
|
354
|
+
const sfxRows: (string | undefined)[] = [];
|
|
355
|
+
for (const s of first.slots) sfxRows[s.slot] = s.row;
|
|
356
|
+
const musicRefs = musicSfxRefs(first.musicSection);
|
|
357
|
+
const second = planScore(score, {sfxRows, musicRefs});
|
|
358
|
+
expect(second.slots.map((s) => s.slot)).toEqual(
|
|
359
|
+
first.slots.map((s) => s.slot),
|
|
360
|
+
);
|
|
361
|
+
expect(second.musicSection).toBe(first.musicSection);
|
|
362
|
+
expect(second.warnings).toEqual([]);
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
it('a full cart still re-runs an unchanged score (its rows are already there)', () => {
|
|
366
|
+
const score = readScore({channels: ['c32']});
|
|
367
|
+
const first = planScore(score, EMPTY);
|
|
368
|
+
const filler = mmlToSfxRow('s4 @3 v6 c e g').row;
|
|
369
|
+
const sfxRows = new Array(64).fill(filler);
|
|
370
|
+
sfxRows[first.slots[0]!.slot] = first.slots[0]!.row;
|
|
371
|
+
const again = planScore(score, {sfxRows, musicRefs: new Set()});
|
|
372
|
+
expect(again.slots[0]!.slot).toBe(first.slots[0]!.slot);
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it('"filters" only takes ! directives, and "loop" must be a boolean', () => {
|
|
376
|
+
expectScoreError(
|
|
377
|
+
() => readScore({channels: [{mml: 'c', filters: '@3 v1'}]}),
|
|
378
|
+
'audio-score-parse-error',
|
|
379
|
+
);
|
|
380
|
+
expect(
|
|
381
|
+
readScore({channels: [{mml: 'c', filters: '!dampen2 !reverb'}]}).songs,
|
|
382
|
+
).toHaveLength(1);
|
|
383
|
+
expectScoreError(
|
|
384
|
+
() => readScore({channels: ['c'], loop: 'false'}),
|
|
385
|
+
'audio-score-parse-error',
|
|
386
|
+
);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('a range only reports slots with notes as cleared', () => {
|
|
390
|
+
const sfxRows: (string | undefined)[] = [];
|
|
391
|
+
sfxRows[41] = mmlToSfxRow('').row; // header only: already empty
|
|
392
|
+
sfxRows[42] = mmlToSfxRow('s4 c').row;
|
|
393
|
+
const plan = planScore(
|
|
394
|
+
readScore({channels: ['c32']}),
|
|
395
|
+
{sfxRows, musicRefs: new Set()},
|
|
396
|
+
{
|
|
397
|
+
slots: {from: 40, to: 42},
|
|
398
|
+
},
|
|
399
|
+
);
|
|
400
|
+
expect(plan.cleared).toEqual([42]);
|
|
401
|
+
});
|
|
402
|
+
});
|