picopilot 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/commands/audio.d.ts +23 -2
  2. package/dist/commands/audio.d.ts.map +1 -1
  3. package/dist/commands/audio.js +228 -23
  4. package/dist/commands/audio.js.map +1 -1
  5. package/dist/commands/doctor.d.ts.map +1 -1
  6. package/dist/commands/doctor.js +1 -0
  7. package/dist/commands/doctor.js.map +1 -1
  8. package/dist/commands/music.d.ts +2 -1
  9. package/dist/commands/music.d.ts.map +1 -1
  10. package/dist/commands/music.js +209 -2
  11. package/dist/commands/music.js.map +1 -1
  12. package/dist/commands/sfx.d.ts.map +1 -1
  13. package/dist/commands/sfx.js +99 -11
  14. package/dist/commands/sfx.js.map +1 -1
  15. package/dist/engine/audio/analyze.d.ts +164 -0
  16. package/dist/engine/audio/analyze.d.ts.map +1 -0
  17. package/dist/engine/audio/analyze.js +512 -0
  18. package/dist/engine/audio/analyze.js.map +1 -0
  19. package/dist/engine/audio/index.d.ts +4 -2
  20. package/dist/engine/audio/index.d.ts.map +1 -1
  21. package/dist/engine/audio/index.js +4 -2
  22. package/dist/engine/audio/index.js.map +1 -1
  23. package/dist/engine/audio/score.d.ts +137 -0
  24. package/dist/engine/audio/score.d.ts.map +1 -0
  25. package/dist/engine/audio/score.js +420 -0
  26. package/dist/engine/audio/score.js.map +1 -0
  27. package/dist/engine/audio/section.d.ts +7 -0
  28. package/dist/engine/audio/section.d.ts.map +1 -1
  29. package/dist/engine/audio/section.js +17 -0
  30. package/dist/engine/audio/section.js.map +1 -1
  31. package/dist/engine/audio/sfx.d.ts +114 -12
  32. package/dist/engine/audio/sfx.d.ts.map +1 -1
  33. package/dist/engine/audio/sfx.js +236 -33
  34. package/dist/engine/audio/sfx.js.map +1 -1
  35. package/dist/engine/pico8/harness.d.ts +11 -5
  36. package/dist/engine/pico8/harness.d.ts.map +1 -1
  37. package/dist/engine/pico8/harness.js +24 -10
  38. package/dist/engine/pico8/harness.js.map +1 -1
  39. package/dist/engine/scaffold/scaffold.d.ts.map +1 -1
  40. package/dist/engine/scaffold/scaffold.js +5 -1
  41. package/dist/engine/scaffold/scaffold.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/commands/audio.test.ts +170 -1
  44. package/src/commands/audio.ts +284 -27
  45. package/src/commands/doctor.test.ts +1 -0
  46. package/src/commands/doctor.ts +1 -0
  47. package/src/commands/music.test.ts +119 -0
  48. package/src/commands/music.ts +251 -1
  49. package/src/commands/sfx.test.ts +55 -0
  50. package/src/commands/sfx.ts +118 -11
  51. package/src/engine/audio/analyze.test.ts +239 -0
  52. package/src/engine/audio/analyze.ts +664 -0
  53. package/src/engine/audio/index.ts +46 -1
  54. package/src/engine/audio/score.test.ts +402 -0
  55. package/src/engine/audio/score.ts +630 -0
  56. package/src/engine/audio/section.ts +19 -0
  57. package/src/engine/audio/sfx.test.ts +219 -4
  58. package/src/engine/audio/sfx.ts +326 -40
  59. package/src/engine/pico8/harness.test.ts +19 -0
  60. package/src/engine/pico8/harness.ts +29 -11
  61. package/src/engine/scaffold/scaffold.ts +5 -1
  62. package/src/skills/picopilot-audio/SKILL.md +74 -184
@@ -0,0 +1,630 @@
1
+ /**
2
+ * `engine/audio`: the score to `__sfx__` + `__music__` planner (ADR-0020).
3
+ *
4
+ * A SCORE is one or more SONGS (sections of `__music__`, e.g. a title song and
5
+ * an in-game song), each up to 4 channels of picopilot-MML of ANY length. The
6
+ * planner does the bookkeeping an author used to do by hand (the conquest
7
+ * dogfood needed a 250-line script for it):
8
+ *
9
+ * 1. parse every channel into its tracker-row stream ({@link mmlToRows});
10
+ * 2. cut all channels of a song at the SAME row boundaries into patterns of
11
+ * `patternRows` rows (default 32), so channels stay aligned;
12
+ * 3. a channel that is silent for a whole pattern becomes `null` (channel OFF),
13
+ * not a silent SFX; a pattern silent on EVERY channel keeps one silent SFX on
14
+ * channel 0 so it still lasts its length;
15
+ * 4. de-duplicate identical SFX rows across the whole score;
16
+ * 5. allocate slots: free (note-less) slots only, from 63 downwards, unless the
17
+ * caller names an explicit slot RANGE, which the score then OWNS (it may
18
+ * overwrite any slot in it and clears the slots in it it does not use);
19
+ * 6. lay songs out back to back in `__music__`, with loop / stop flags.
20
+ *
21
+ * Pure: cart-free inputs in, a plan out. The `music from-score` command applies
22
+ * the plan through the cart model (only owned `__sfx__` slots + `__music__`
23
+ * change).
24
+ */
25
+
26
+ import {
27
+ CHANNEL_OFF_BIT,
28
+ type ChannelRef,
29
+ type Pattern,
30
+ patternsToMusic,
31
+ MUSIC_PATTERN_MAX,
32
+ } from './music.js';
33
+ import {
34
+ AudioMmlError,
35
+ DEFAULT_SPEED,
36
+ emitSfxRow,
37
+ mmlToRows,
38
+ SFX_MAX_ROWS,
39
+ SFX_ROW_LENGTH,
40
+ type SfxNote,
41
+ } from './sfx.js';
42
+
43
+ /** The number of SFX slots (0..63). */
44
+ const SLOT_COUNT = 64;
45
+
46
+ /** PICO-8 SFX ticks per second (speed 1 = 1/120 s per row). */
47
+ const TICKS_PER_SECOND = 120;
48
+
49
+ /** One score channel: MML text, or `{mml, filters}` where `filters` are `!` directives. */
50
+ export type ScoreChannel = string | {mml: string; filters?: string} | null;
51
+
52
+ /** One song (a contiguous section of `__music__`). */
53
+ export interface ScoreSong {
54
+ /** A label for the report (default `song<N>`). */
55
+ name?: string;
56
+ /** SFX speed (ticks/row) for every channel. Default: the channels' shared `s<N>`, else 16. */
57
+ speed?: number;
58
+ /** Rows per pattern, 1..32 (default 32). Under 32, each SFX carries the LEN header. */
59
+ patternRows?: number;
60
+ /** Loop the song (default true). false = the last pattern stops the music. */
61
+ loop?: boolean;
62
+ /** The pattern (0-based, within the song) the loop returns to (default 0). */
63
+ loopFrom?: number;
64
+ /** Up to 4 channels (ch0..ch3); `null` or a missing entry = no channel. */
65
+ channels: ScoreChannel[];
66
+ }
67
+
68
+ /** A whole score: the songs, laid out in this order. */
69
+ export interface Score {
70
+ songs: ScoreSong[];
71
+ }
72
+
73
+ /** Machine-readable reason a score could not be planned. */
74
+ export type ScoreErrorCode =
75
+ | 'audio-score-parse-error'
76
+ | 'audio-score-empty'
77
+ | 'audio-score-bad-channel-count'
78
+ | 'audio-score-bad-speed'
79
+ | 'audio-score-speed-mismatch'
80
+ | 'audio-score-bad-pattern-rows'
81
+ | 'audio-score-bad-loop-from'
82
+ | 'audio-score-loop-marker'
83
+ | 'audio-score-too-many-patterns'
84
+ | 'audio-score-no-free-slots'
85
+ | 'audio-score-bad-slot-range';
86
+
87
+ /** A structured score refusal (mirrors {@link AudioMmlError}). */
88
+ export class ScoreError extends Error {
89
+ readonly code: ScoreErrorCode;
90
+ constructor(code: ScoreErrorCode, message: string) {
91
+ super(message);
92
+ this.name = 'ScoreError';
93
+ this.code = code;
94
+ }
95
+ }
96
+
97
+ /** What the planner needs to know about the cart it plans for. */
98
+ export interface ScoreCartState {
99
+ /** The current `__sfx__` rows, indexed by slot (missing = empty). */
100
+ sfxRows: readonly (string | undefined)[];
101
+ /** The SFX slots the cart's CURRENT `__music__` references (the song being replaced). */
102
+ musicRefs: ReadonlySet<number>;
103
+ }
104
+
105
+ /** Planner options. */
106
+ export interface ScorePlanOptions {
107
+ /** An explicit inclusive slot range the score OWNS (overwrite + clear unused). */
108
+ slots?: {from: number; to: number};
109
+ }
110
+
111
+ /** One song as laid out in `__music__`. */
112
+ export interface PlannedSong {
113
+ name: string;
114
+ /** The first pattern index: play it with `music(startPattern)`. */
115
+ startPattern: number;
116
+ /** The last pattern index of the song. */
117
+ endPattern: number;
118
+ patterns: number;
119
+ speed: number;
120
+ patternRows: number;
121
+ loop: boolean;
122
+ /** The absolute pattern index the loop returns to (when `loop`). */
123
+ loopStartPattern: number | undefined;
124
+ /** Play length of one pass through the song, in seconds. */
125
+ seconds: number;
126
+ }
127
+
128
+ /** One SFX slot the plan writes. */
129
+ export interface PlannedSlot {
130
+ slot: number;
131
+ row: string;
132
+ /** Where the SFX is used, e.g. `title p0 ch1` (pattern index is within the song). */
133
+ uses: string[];
134
+ /** Whether the slot held DIFFERENT, non-empty content before (an overwrite). */
135
+ overwrote: boolean;
136
+ }
137
+
138
+ /** The plan: what to write into the cart, and the report. */
139
+ export interface ScorePlan {
140
+ songs: PlannedSong[];
141
+ slots: PlannedSlot[];
142
+ /** Owned slots the score does not use, cleared to empty (only with a range). */
143
+ cleared: number[];
144
+ /** The `__music__` section body. */
145
+ musicSection: string;
146
+ /** Total patterns written. */
147
+ patterns: number;
148
+ /** Advisory warnings. */
149
+ warnings: string[];
150
+ }
151
+
152
+ /** An all-zero `__sfx__` row: the canonical empty slot. */
153
+ export const EMPTY_SFX_ROW = '0'.repeat(SFX_ROW_LENGTH);
154
+
155
+ /** Whether an `__sfx__` row has no notes (its 32 note slots are all zero). */
156
+ export function isFreeSfxRow(row: string | undefined): boolean {
157
+ return row === undefined || /^0*$/.test(row.slice(8));
158
+ }
159
+
160
+ /**
161
+ * Parses + validates a score from JSON-ish input. Accepts `{songs:[...]}` or a
162
+ * single song object (`{channels:[...]}`) as shorthand.
163
+ *
164
+ * @throws {ScoreError} `audio-score-parse-error` for a shape it cannot read.
165
+ */
166
+ export function readScore(raw: unknown): Score {
167
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
168
+ throw new ScoreError(
169
+ 'audio-score-parse-error',
170
+ 'a score is a JSON object: {"songs":[{"name":"title","speed":16,"channels":["<mml>","<mml>",null,null]}]} (or one song object with "channels").',
171
+ );
172
+ }
173
+ const obj = raw as Record<string, unknown>;
174
+ const songs = Array.isArray(obj.songs)
175
+ ? obj.songs
176
+ : Array.isArray(obj.channels)
177
+ ? [obj]
178
+ : undefined;
179
+ if (songs === undefined) {
180
+ throw new ScoreError(
181
+ 'audio-score-parse-error',
182
+ 'a score needs "songs" (an array of songs) or, for one song, "channels".',
183
+ );
184
+ }
185
+ if (songs.length === 0) {
186
+ throw new ScoreError('audio-score-empty', 'the score has no songs.');
187
+ }
188
+ return {
189
+ songs: songs.map((s, i) => {
190
+ if (
191
+ s === null ||
192
+ typeof s !== 'object' ||
193
+ !Array.isArray((s as ScoreSong).channels)
194
+ ) {
195
+ throw new ScoreError(
196
+ 'audio-score-parse-error',
197
+ `song ${i} must be an object with a "channels" array (up to 4 MML strings, {"mml","filters"} objects, or null).`,
198
+ );
199
+ }
200
+ const song = s as ScoreSong;
201
+ if (song.loop !== undefined && typeof song.loop !== 'boolean') {
202
+ throw new ScoreError(
203
+ 'audio-score-parse-error',
204
+ `song ${i} "loop" must be true or false, got ${JSON.stringify(song.loop)}.`,
205
+ );
206
+ }
207
+ for (const [c, ch] of song.channels.entries()) {
208
+ const ok =
209
+ ch === null ||
210
+ typeof ch === 'string' ||
211
+ (typeof ch === 'object' &&
212
+ typeof (ch as {mml?: unknown}).mml === 'string' &&
213
+ ((ch as {filters?: unknown}).filters === undefined ||
214
+ typeof (ch as {filters?: unknown}).filters === 'string'));
215
+ const filters =
216
+ ch !== null && typeof ch === 'object'
217
+ ? (ch as {filters?: string}).filters
218
+ : undefined;
219
+ if (
220
+ ok &&
221
+ filters !== undefined &&
222
+ !/^\s*(![a-z]+\d?\s*)*$/i.test(filters)
223
+ ) {
224
+ throw new ScoreError(
225
+ 'audio-score-parse-error',
226
+ `song ${i} channel ${c} "filters" may only hold ! filter directives (e.g. "!dampen !reverb"), got "${filters}". Put notes and modal settings in "mml".`,
227
+ );
228
+ }
229
+ if (!ok) {
230
+ throw new ScoreError(
231
+ 'audio-score-parse-error',
232
+ `song ${i} channel ${c} must be an MML string, {"mml": "...", "filters": "!dampen"}, or null.`,
233
+ );
234
+ }
235
+ }
236
+ return song;
237
+ }),
238
+ };
239
+ }
240
+
241
+ /** A parsed channel of one song. */
242
+ interface ParsedChannel {
243
+ notes: SfxNote[];
244
+ filters: number;
245
+ }
246
+
247
+ /** Whether a run of rows is silent (every row at volume 0). */
248
+ function isSilent(notes: readonly SfxNote[]): boolean {
249
+ return notes.every((n) => n.volume === 0);
250
+ }
251
+
252
+ /** A silent row (the padding past a channel's end). */
253
+ const SILENT: SfxNote = {pitch: 0, waveform: 0, volume: 0, effect: 0};
254
+
255
+ /**
256
+ * Plans a score against a cart's current audio (ADR-0020). Pure.
257
+ *
258
+ * @throws {ScoreError} a malformed score, a speed/loop conflict, more than 64
259
+ * patterns, or not enough slots.
260
+ * @throws {AudioMmlError} a channel's MML is invalid (message names song+channel).
261
+ */
262
+ export function planScore(
263
+ score: Score,
264
+ cart: ScoreCartState,
265
+ options: ScorePlanOptions = {},
266
+ ): ScorePlan {
267
+ const warnings: string[] = [];
268
+ const allPatterns: Pattern[] = [];
269
+ const songs: PlannedSong[] = [];
270
+ /** Unique SFX rows, in first-use order, with their uses. */
271
+ const unique = new Map<string, string[]>();
272
+ /** Per pattern+channel, the unique row it plays (resolved to a slot later). */
273
+ const channelRows: (string | null)[][] = [];
274
+ let usesCustomInstrument = false;
275
+
276
+ for (const [si, song] of score.songs.entries()) {
277
+ const name = song.name ?? `song${si}`;
278
+ if (song.channels.length === 0 || song.channels.length > 4) {
279
+ throw new ScoreError(
280
+ 'audio-score-bad-channel-count',
281
+ `song '${name}' has ${song.channels.length} channels; PICO-8 music has 1 to 4 (ch0..ch3).`,
282
+ );
283
+ }
284
+ const patternRows = song.patternRows ?? SFX_MAX_ROWS;
285
+ if (
286
+ !Number.isInteger(patternRows) ||
287
+ patternRows < 1 ||
288
+ patternRows > SFX_MAX_ROWS
289
+ ) {
290
+ throw new ScoreError(
291
+ 'audio-score-bad-pattern-rows',
292
+ `song '${name}' patternRows ${patternRows} is not a whole number in 1..${SFX_MAX_ROWS} (one SFX holds 32 rows).`,
293
+ );
294
+ }
295
+
296
+ // Parse every channel (no 32-row cap: the planner cuts them).
297
+ const parsed: (ParsedChannel | null)[] = [];
298
+ const speeds = new Set<number>();
299
+ for (const [c, ch] of song.channels.entries()) {
300
+ if (ch === null || ch === undefined) {
301
+ parsed.push(null);
302
+ continue;
303
+ }
304
+ const text =
305
+ typeof ch === 'string' ? ch : `${ch.filters ?? ''} ${ch.mml}`;
306
+ let rows;
307
+ try {
308
+ rows = mmlToRows(text, {maxRows: Number.POSITIVE_INFINITY});
309
+ } catch (e) {
310
+ if (e instanceof AudioMmlError) {
311
+ throw new AudioMmlError(
312
+ e.code,
313
+ `song '${name}' channel ${c}: ${e.message}`,
314
+ );
315
+ }
316
+ throw e;
317
+ }
318
+ if (rows.loopStartRow !== undefined || rows.loopEndRow !== undefined) {
319
+ throw new ScoreError(
320
+ 'audio-score-loop-marker',
321
+ `song '${name}' channel ${c} uses a '{'/'}' loop marker. In a score, looping is per SONG ("loop", "loopFrom"), not per SFX; remove the markers.`,
322
+ );
323
+ }
324
+ if (rows.speedSet) speeds.add(rows.speed);
325
+ if (rows.notes.some((n) => n.waveform >= 8 && n.volume > 0)) {
326
+ usesCustomInstrument = true;
327
+ }
328
+ parsed.push({notes: rows.notes, filters: rows.filters});
329
+ }
330
+
331
+ let speed: number;
332
+ if (song.speed !== undefined) {
333
+ if (!Number.isInteger(song.speed) || song.speed < 1 || song.speed > 255) {
334
+ throw new ScoreError(
335
+ 'audio-score-bad-speed',
336
+ `song '${name}' speed ${song.speed} is not a whole number in 1..255 (ticks per row).`,
337
+ );
338
+ }
339
+ speed = song.speed;
340
+ const other = [...speeds].find((s) => s !== speed);
341
+ if (other !== undefined) {
342
+ throw new ScoreError(
343
+ 'audio-score-speed-mismatch',
344
+ `song '${name}' has speed ${speed} but a channel sets s${other}. Every channel of a song shares ONE speed (patterns must stay aligned); drop the s<N> from the channel MML.`,
345
+ );
346
+ }
347
+ } else if (speeds.size > 1) {
348
+ throw new ScoreError(
349
+ 'audio-score-speed-mismatch',
350
+ `song '${name}' channels set different speeds (${[...speeds].map((s) => `s${s}`).join(', ')}). Every channel of a song shares ONE speed; set "speed" on the song instead.`,
351
+ );
352
+ } else {
353
+ speed = speeds.size === 1 ? [...speeds][0]! : DEFAULT_SPEED;
354
+ }
355
+
356
+ const totalRows = Math.max(0, ...parsed.map((p) => p?.notes.length ?? 0));
357
+ if (totalRows === 0) {
358
+ throw new ScoreError(
359
+ 'audio-score-empty',
360
+ `song '${name}' has no rows on any channel.`,
361
+ );
362
+ }
363
+ const count = Math.ceil(totalRows / patternRows);
364
+ for (const [c, p] of parsed.entries()) {
365
+ if (p !== null && p.notes.length < totalRows) {
366
+ warnings.push(
367
+ `song '${name}' channel ${c} is ${p.notes.length} rows, shorter than the song (${totalRows}); it is padded with silence.`,
368
+ );
369
+ }
370
+ }
371
+ if (totalRows % patternRows !== 0) {
372
+ warnings.push(
373
+ `song '${name}' is ${totalRows} rows, not a multiple of ${patternRows}; the last pattern is padded with ${count * patternRows - totalRows} silent rows.`,
374
+ );
375
+ }
376
+ const loop = song.loop ?? true;
377
+ const loopFrom = song.loopFrom ?? 0;
378
+ if (!Number.isInteger(loopFrom) || loopFrom < 0 || loopFrom >= count) {
379
+ throw new ScoreError(
380
+ 'audio-score-bad-loop-from',
381
+ `song '${name}' loopFrom ${loopFrom} is not a pattern of the song (0..${count - 1}).`,
382
+ );
383
+ }
384
+
385
+ const start = allPatterns.length;
386
+ const lenHeader = patternRows < SFX_MAX_ROWS ? patternRows : 0;
387
+ for (let p = 0; p < count; p++) {
388
+ const refs: (string | null)[] = [];
389
+ for (let c = 0; c < 4; c++) {
390
+ const ch = parsed[c];
391
+ if (ch === null || ch === undefined) {
392
+ refs.push(null);
393
+ continue;
394
+ }
395
+ const chunk = ch.notes.slice(p * patternRows, (p + 1) * patternRows);
396
+ if (isSilent(chunk)) {
397
+ refs.push(null);
398
+ continue;
399
+ }
400
+ while (chunk.length < patternRows) chunk.push(SILENT);
401
+ const row = emitSfxRow({
402
+ filters: ch.filters,
403
+ speed,
404
+ loopStart: lenHeader,
405
+ loopEnd: 0,
406
+ notes: chunk,
407
+ });
408
+ refs.push(row);
409
+ const uses = unique.get(row) ?? [];
410
+ uses.push(`${name} p${p} ch${c}`);
411
+ unique.set(row, uses);
412
+ }
413
+ if (!refs.some((r) => r !== null)) {
414
+ // A pattern silent on every channel still has to LAST its length:
415
+ // keep one silent SFX on channel 0 (PICO-8 times a pattern by its
416
+ // left-most playing channel).
417
+ const rest = emitSfxRow({
418
+ speed,
419
+ filters: 0,
420
+ loopStart: lenHeader,
421
+ loopEnd: 0,
422
+ notes: [],
423
+ });
424
+ refs[0] = rest;
425
+ const uses = unique.get(rest) ?? [];
426
+ uses.push(`${name} p${p} ch0 (rest)`);
427
+ unique.set(rest, uses);
428
+ }
429
+ channelRows.push(refs);
430
+ allPatterns.push({
431
+ channels: [null, null, null, null],
432
+ ...(loop && p === loopFrom ? {loopStart: true} : {}),
433
+ ...(loop && p === count - 1 ? {loopBack: true} : {}),
434
+ ...(!loop && p === count - 1 ? {stop: true} : {}),
435
+ });
436
+ }
437
+ songs.push({
438
+ name,
439
+ startPattern: start,
440
+ endPattern: start + count - 1,
441
+ patterns: count,
442
+ speed,
443
+ patternRows,
444
+ loop,
445
+ loopStartPattern: loop ? start + loopFrom : undefined,
446
+ seconds: round2((count * patternRows * speed) / TICKS_PER_SECOND),
447
+ });
448
+ }
449
+
450
+ if (allPatterns.length > MUSIC_PATTERN_MAX) {
451
+ throw new ScoreError(
452
+ 'audio-score-too-many-patterns',
453
+ `the score needs ${allPatterns.length} patterns; __music__ holds ${MUSIC_PATTERN_MAX}. Shorten a song, or reuse material (identical patterns still take a pattern each).`,
454
+ );
455
+ }
456
+
457
+ // --- slot allocation --------------------------------------------------------
458
+ const range = options.slots;
459
+ if (range !== undefined) {
460
+ if (
461
+ !Number.isInteger(range.from) ||
462
+ !Number.isInteger(range.to) ||
463
+ range.from < 0 ||
464
+ range.to > SLOT_COUNT - 1 ||
465
+ range.from > range.to
466
+ ) {
467
+ throw new ScoreError(
468
+ 'audio-score-bad-slot-range',
469
+ `slot range ${range.from}-${range.to} is not within 0-63 (from <= to).`,
470
+ );
471
+ }
472
+ }
473
+ // Stable allocation: an SFX whose exact row already sits in a usable slot
474
+ // keeps that slot, so re-running an unchanged score is a byte-identical
475
+ // no-op. With a range, "usable" is the range; without one, it is ANY slot
476
+ // (reusing an identical row overwrites nothing), so default-mode re-runs do
477
+ // not leak copies of the previous song into fresh slots.
478
+ const usable = (s: number) =>
479
+ range !== undefined
480
+ ? s >= range.from && s <= range.to
481
+ : !(usesCustomInstrument && s < 8); // @8-@f read SFX 0..7
482
+ const slotOf = new Map<string, number>();
483
+ const taken = new Set<number>();
484
+ for (const row of unique.keys()) {
485
+ for (let s = SLOT_COUNT - 1; s >= 0; s--) {
486
+ if (!taken.has(s) && usable(s) && cart.sfxRows[s] === row) {
487
+ slotOf.set(row, s);
488
+ taken.add(s);
489
+ break;
490
+ }
491
+ }
492
+ }
493
+
494
+ const candidates: number[] = [];
495
+ if (range !== undefined) {
496
+ for (let s = range.from; s <= range.to; s++) candidates.push(s);
497
+ } else {
498
+ // Free slots only, from the top down: game SFX are usually authored from
499
+ // slot 0 up, so music fills from 63 down and the two meet in the middle.
500
+ for (let s = SLOT_COUNT - 1; s >= 0; s--) {
501
+ if (usable(s) && isFreeSfxRow(cart.sfxRows[s])) candidates.push(s);
502
+ }
503
+ }
504
+ const open = candidates.filter((c) => !taken.has(c));
505
+ const needed = unique.size - slotOf.size;
506
+ if (needed > open.length) {
507
+ throw new ScoreError(
508
+ 'audio-score-no-free-slots',
509
+ range !== undefined
510
+ ? `the score needs ${unique.size} distinct SFX but the slot range ${range.from}-${range.to} has ${candidates.length}. Widen the range or shorten/simplify the score.`
511
+ : `the score needs ${needed} more SFX slots but the cart has ${open.length} free. Pass --slots <from-to> to give the music a range it owns (it overwrites that range), clear old slots with \`sfx clear\`, or shorten the score.`,
512
+ );
513
+ }
514
+ if (range !== undefined && usesCustomInstrument && range.from < 8) {
515
+ warnings.push(
516
+ `the score uses custom instruments (@8-@f = SFX 0-7) and the slot range ${range.from}-${range.to} overlaps slots 0-7: music written there would replace those instruments.`,
517
+ );
518
+ }
519
+ for (const row of unique.keys()) {
520
+ if (slotOf.has(row)) continue;
521
+ const s = open.shift()!;
522
+ slotOf.set(row, s);
523
+ taken.add(s);
524
+ }
525
+
526
+ const slots: PlannedSlot[] = [...unique.entries()]
527
+ .map(([row, uses]) => {
528
+ const slot = slotOf.get(row)!;
529
+ const before = cart.sfxRows[slot];
530
+ return {
531
+ slot,
532
+ row,
533
+ uses,
534
+ overwrote: !isFreeSfxRow(before) && before !== row,
535
+ };
536
+ })
537
+ .sort((a, b) => a.slot - b.slot);
538
+
539
+ const cleared =
540
+ range === undefined
541
+ ? []
542
+ : candidates.filter(
543
+ (s) => !taken.has(s) && !isFreeSfxRow(cart.sfxRows[s]),
544
+ );
545
+
546
+ for (const [p, refs] of channelRows.entries()) {
547
+ allPatterns[p]!.channels = refs.map((r) =>
548
+ r === null ? null : slotOf.get(r)!,
549
+ ) as [ChannelRef, ChannelRef, ChannelRef, ChannelRef];
550
+ }
551
+
552
+ // Slots the previous __music__ used that nothing references any more, and
553
+ // that we neither rewrote nor cleared: leftovers of the replaced song.
554
+ const nowUsed = new Set(slots.map((s) => s.slot));
555
+ const orphans = [...cart.musicRefs]
556
+ .filter(
557
+ (s) =>
558
+ !nowUsed.has(s) &&
559
+ !cleared.includes(s) &&
560
+ !isFreeSfxRow(cart.sfxRows[s]),
561
+ )
562
+ .sort((a, b) => a - b);
563
+ if (orphans.length > 0) {
564
+ warnings.push(
565
+ `SFX ${formatSlots(orphans)} were used by the previous __music__ and are no longer referenced; they still hold the old song's notes. Clear them with \`sfx clear ${formatSlots(orphans)}\`, or re-run with --slots <from-to> so the score owns (and clears) its range.`,
566
+ );
567
+ }
568
+
569
+ const music = patternsToMusic(allPatterns);
570
+ warnings.push(...music.warnings.map((w) => w.message));
571
+
572
+ return {
573
+ songs,
574
+ slots,
575
+ cleared,
576
+ musicSection: music.section,
577
+ patterns: allPatterns.length,
578
+ warnings,
579
+ };
580
+ }
581
+
582
+ /** Rounds to 2 decimals. */
583
+ function round2(n: number): number {
584
+ return Math.round(n * 100) / 100;
585
+ }
586
+
587
+ /** Formats slot numbers compactly: `16-20,25`. */
588
+ export function formatSlots(slots: readonly number[]): string {
589
+ const sorted = [...slots].sort((a, b) => a - b);
590
+ const parts: string[] = [];
591
+ for (let i = 0; i < sorted.length;) {
592
+ let j = i;
593
+ while (j + 1 < sorted.length && sorted[j + 1] === sorted[j]! + 1) j++;
594
+ parts.push(i === j ? `${sorted[i]}` : `${sorted[i]}-${sorted[j]}`);
595
+ i = j + 1;
596
+ }
597
+ return parts.join(',');
598
+ }
599
+
600
+ /**
601
+ * Parses a slot list like `5`, `16-63` or `3,5,16-20` into sorted unique slots.
602
+ * Returns `undefined` when the text is not a valid list within 0..63.
603
+ */
604
+ export function parseSlotList(text: string): number[] | undefined {
605
+ const out = new Set<number>();
606
+ for (const part of text.split(',')) {
607
+ const m = /^\s*(\d+)\s*(?:-\s*(\d+)\s*)?$/.exec(part);
608
+ if (m === null) return undefined;
609
+ const from = Number(m[1]);
610
+ const to = m[2] === undefined ? from : Number(m[2]);
611
+ if (from > to || to > SLOT_COUNT - 1) return undefined;
612
+ for (let s = from; s <= to; s++) out.add(s);
613
+ }
614
+ return [...out].sort((a, b) => a - b);
615
+ }
616
+
617
+ /** The SFX slots a `__music__` body references (on channels that are not off). */
618
+ export function musicSfxRefs(body: string | undefined): Set<number> {
619
+ const refs = new Set<number>();
620
+ if (body === undefined) return refs;
621
+ for (const line of body.split(/\r?\n/)) {
622
+ const m = /^[0-9a-f]{2} ([0-9a-f]{8})$/i.exec(line.trim());
623
+ if (m === null) continue;
624
+ for (let c = 0; c < 4; c++) {
625
+ const b = Number.parseInt(m[1]!.slice(c * 2, c * 2 + 2), 16);
626
+ if ((b & CHANNEL_OFF_BIT) === 0) refs.add(b & 0x3f);
627
+ }
628
+ }
629
+ return refs;
630
+ }
@@ -77,3 +77,22 @@ export function setMusicSection(cart: Cart, body: string): void {
77
77
  const normalised = crlf ? body.replace(/\n/g, '\r\n') : body;
78
78
  cart.setSection('music', normalised);
79
79
  }
80
+
81
+ /**
82
+ * Reads a cart's `__sfx__` section into one entry per slot (0..63); a slot past
83
+ * the end of the (trimmed) section is `undefined` (empty).
84
+ */
85
+ export function readSfxRows(cart: Cart): (string | undefined)[] {
86
+ const body = cart.getSection('sfx');
87
+ const rows: (string | undefined)[] =
88
+ body === undefined
89
+ ? []
90
+ : body.split(/\r?\n/).filter((line) => line.length > 0);
91
+ while (rows.length < SFX_SLOT_COUNT) rows.push(undefined);
92
+ return rows.slice(0, SFX_SLOT_COUNT);
93
+ }
94
+
95
+ /** Clears SFX slot `slot` back to empty (the all-zero row), via {@link mergeSfxRow}. */
96
+ export function clearSfxSlot(cart: Cart, slot: number): void {
97
+ mergeSfxRow(cart, slot, EMPTY_ROW);
98
+ }