@modplayjs/fmt-fc 0.0.0-stage → 0.2.2

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/fc.js ADDED
@@ -0,0 +1,1111 @@
1
+ // SPDX-License-Identifier: BSD-3-Clause
2
+ // Copyright (c) 2026 Bitti09 — modplayjs contributors
3
+ // Ported from: OpenMPT soundlib/Load_fc.cpp (BSD-3-Clause) — Future
4
+ // Composer 1.0-1.3 (SMOD) and 1.4 (FC14) module loader, with the synth
5
+ // scripts from soundlib/InstrumentSynth.cpp driving playback.
6
+ import { C4_PAL_RATE, EMPTY_EVENT, FX_BREAK, FX_SPEED, FX_FC_AUTO_PORTA, FX_FC_TONEPORTA_DURATION, FX_FC_MED_SYNTH_JUMP, ParseError, PeriodType, Quirk, ReadEventType, SampleFlags, } from '@modplayjs/core';
7
+ import { setSiblingStatesAccessor, SynthEventType, SynthState, synthStateNextTick, evDelay, evFcPitchSlide, evFcSetPitch, evFcSetVibrato, evFcSetWaveform, evFcVolumeSlide, evJump, evJumpMarker, evMedJumpScript, evMedSetVolume, evSetStepSpeed, evStopScript, fixupJumpTarget, } from './synth.js';
8
+ const FC_MAX_SIZE = 0x80000;
9
+ function sequenceSizeOf(h) {
10
+ // SequenceSize (Load_fc.cpp:124-134): a broken copy of cult.smod has a
11
+ // sequence size of 0 but the sequence data is actually in the place
12
+ // where it's supposed to be.
13
+ if (h.sequenceSize > 0)
14
+ return h.sequenceSize;
15
+ const headerSize = h.isFc14 ? 180 : 100;
16
+ if (h.patternsOffset > headerSize)
17
+ return h.patternsOffset - headerSize;
18
+ return 0;
19
+ }
20
+ function headerIsValid(h) {
21
+ if (h.magic !== 'SMOD' && h.magic !== 'FC14')
22
+ return false;
23
+ const seqSize = sequenceSizeOf(h);
24
+ if (seqSize % 13 > 1 || // Some files have a mysterious extra byte
25
+ seqSize < 13 ||
26
+ seqSize > 256 * 13 ||
27
+ h.patternsSize % 64 !== 0 ||
28
+ h.patternsSize === 0 ||
29
+ h.patternsSize > 64 * 256 ||
30
+ h.patternsOffset > FC_MAX_SIZE ||
31
+ h.freqSequenceSize % 64 !== 0 ||
32
+ h.freqSequenceSize === 0 ||
33
+ h.freqSequenceSize > 64 * 256 ||
34
+ h.freqSequenceOffset > FC_MAX_SIZE ||
35
+ h.volSequenceSize % 64 !== 0 ||
36
+ h.volSequenceSize === 0 ||
37
+ h.volSequenceSize > 64 * 256 ||
38
+ h.volSequenceOffset > FC_MAX_SIZE ||
39
+ h.sampleDataOffset > FC_MAX_SIZE ||
40
+ h.waveTableOffset > FC_MAX_SIZE) {
41
+ return false;
42
+ }
43
+ return true;
44
+ }
45
+ function readHeader(b) {
46
+ if (b.length < 100)
47
+ return null;
48
+ const dv = new DataView(b.buffer, b.byteOffset, b.byteLength);
49
+ const magic = String.fromCharCode(b[0], b[1], b[2], b[3]);
50
+ if (magic !== 'SMOD' && magic !== 'FC14')
51
+ return null;
52
+ const h = {
53
+ magic,
54
+ sequenceSize: dv.getUint32(4, false),
55
+ patternsOffset: dv.getUint32(8, false),
56
+ patternsSize: dv.getUint32(12, false),
57
+ freqSequenceOffset: dv.getUint32(16, false),
58
+ freqSequenceSize: dv.getUint32(20, false),
59
+ volSequenceOffset: dv.getUint32(24, false),
60
+ volSequenceSize: dv.getUint32(28, false),
61
+ sampleDataOffset: dv.getUint32(32, false),
62
+ waveTableOffset: dv.getUint32(36, false),
63
+ sampleInfo: [],
64
+ isFc14: magic === 'FC14',
65
+ };
66
+ for (let i = 0; i < 10; i++) {
67
+ const off = 40 + i * 6;
68
+ h.sampleInfo.push({
69
+ length: dv.getUint16(off, false),
70
+ loopStart: dv.getUint16(off + 2, false),
71
+ loopLength: dv.getUint16(off + 4, false),
72
+ });
73
+ }
74
+ if (!headerIsValid(h))
75
+ return null;
76
+ return h;
77
+ }
78
+ function translateFcScript(events, script, isFc14, startSequence = 0xffff) {
79
+ const isVolume = startSequence > 255;
80
+ const volScriptSpeed = script[0] > 0 ? script[0] : 255;
81
+ if (isVolume) {
82
+ events.events.push(evSetStepSpeed(volScriptSpeed, true));
83
+ }
84
+ const sequencesToParse = [isVolume ? 0 : startSequence & 0xff];
85
+ const parsedSequences = new Set(sequencesToParse);
86
+ const entryFromByte = new Map();
87
+ events.events.push(evJumpMarker(0));
88
+ while (sequencesToParse.length > 0) {
89
+ const sequence = sequencesToParse.pop();
90
+ const currentSequenceOffset = sequence * 64;
91
+ if (isVolume) {
92
+ // volume scripts skip the 5-byte header
93
+ }
94
+ const maxScriptJumpPos = (currentSequenceOffset + (isVolume ? 260 : 63)) & 0xffff;
95
+ let maxJump = 0;
96
+ let nextByteIsPitch = false;
97
+ let pos = currentSequenceOffset;
98
+ if (isVolume)
99
+ pos += 5;
100
+ while (pos < script.length) {
101
+ const scriptPos = pos & 0xffff;
102
+ if (scriptPos <= maxScriptJumpPos) {
103
+ entryFromByte.set(scriptPos, events.events.length);
104
+ }
105
+ const b = script[pos++];
106
+ // After several pitch Ex commands, the next byte is always
107
+ // interpreted as Set Pitch.
108
+ if (nextByteIsPitch) {
109
+ events.events.push(evFcSetPitch((b << 24) >> 24));
110
+ nextByteIsPitch = false;
111
+ continue;
112
+ }
113
+ const volumeCommand = evMedSetVolume(Math.min(b & 0x7f, 64));
114
+ let handled = true;
115
+ switch (b) {
116
+ case 0xe0: {
117
+ // Loop (position)
118
+ let target = script[pos++] & 0x3f;
119
+ if (isVolume && target < 5) {
120
+ // Volume sequence offset is relative to the first volume
121
+ // command at offset 5, so FC subtracts 5 from the jump target
122
+ // and happily underflows the byte value.
123
+ target += 256;
124
+ }
125
+ target += currentSequenceOffset;
126
+ if (!isVolume && target < script.length && (script[target] === 0xe0 || script[target] === 0xe1)) {
127
+ // The first byte that is executed after an E0 jump is never
128
+ // interpreted as command E0 or E1.
129
+ events.events.push(evFcSetPitch((script[target] << 24) >> 24));
130
+ target++;
131
+ if (target < script.length && script[target - 1] === 0xe0) {
132
+ events.events.push(evFcSetPitch((script[target] << 24) >> 24));
133
+ target++;
134
+ }
135
+ }
136
+ maxJump = Math.max(maxJump, target);
137
+ events.events.push(evJump(target & 0xffff));
138
+ break;
139
+ }
140
+ case 0xe1: // End (none)
141
+ events.events.push(evStopScript());
142
+ break;
143
+ case 0xe2: // Set waveform (waveform)
144
+ // C: if(!isVolume) push(MED_JumpScript(1, 0)) then FALLS
145
+ // THROUGH to the E4 handler — the jump re-syncs the volume
146
+ // script's state before the waveform switch. Our port missed
147
+ // the MED_JumpScript, which desynced the volume script (the
148
+ // DEViANCE - Blood Rayne intro.fc14 synth sounded wrong).
149
+ if (!isVolume) {
150
+ events.events.push(evMedJumpScript(1, 0));
151
+ pos++; // waveform byte consumed (FC_SetWaveform)
152
+ events.events.push(evFcSetWaveform(b, script[pos - 1], 0));
153
+ }
154
+ else {
155
+ handled = false;
156
+ }
157
+ break;
158
+ case 0xe3: // Set new vibrato (speed, amplitude)
159
+ if (isVolume) {
160
+ handled = false;
161
+ }
162
+ else {
163
+ const speed = script[pos++];
164
+ const depth = script[pos++];
165
+ events.events.push(evFcSetVibrato(speed, depth, 0));
166
+ }
167
+ break;
168
+ case 0xe4: // Change waveform (waveform)
169
+ if (isVolume) {
170
+ handled = false;
171
+ }
172
+ else {
173
+ const waveform = script[pos++];
174
+ events.events.push(evFcSetWaveform(b, waveform, 0));
175
+ }
176
+ break;
177
+ case 0xe7: {
178
+ // Jump to freq. sequence (sequence)
179
+ if (isVolume) {
180
+ handled = false;
181
+ }
182
+ else {
183
+ const sequence2 = script[pos++];
184
+ events.events.push(evJump((sequence2 * 64) & 0xffff));
185
+ if (!parsedSequences.has(sequence2)) {
186
+ parsedSequences.add(sequence2);
187
+ sequencesToParse.push(sequence2);
188
+ }
189
+ }
190
+ break;
191
+ }
192
+ case 0xe8: {
193
+ // Sustain (time)
194
+ const delay = script[pos++];
195
+ if (isVolume && volScriptSpeed > 1) {
196
+ events.events.push(evSetStepSpeed(1, true));
197
+ events.events.push(evDelay(delay + volScriptSpeed - 2));
198
+ events.events.push(evSetStepSpeed(volScriptSpeed, true));
199
+ }
200
+ else if (delay !== 0) {
201
+ events.events.push(evDelay(delay - 1));
202
+ }
203
+ break;
204
+ }
205
+ case 0xe9: {
206
+ // Set waveform (waveform, number)
207
+ if (isVolume) {
208
+ handled = false;
209
+ }
210
+ else if (isFc14) {
211
+ const waveform = script[pos++];
212
+ const subSample = script[pos++];
213
+ events.events.push(evFcSetWaveform(b, subSample, waveform));
214
+ }
215
+ else {
216
+ events.events.push(evFcSetPitch((b << 24) >> 24));
217
+ }
218
+ break;
219
+ }
220
+ case 0xea: {
221
+ // Volume slide / Pitch bend (step, time)
222
+ if (isFc14) {
223
+ const speed = script[pos++];
224
+ const time = script[pos++];
225
+ if (isVolume) {
226
+ events.events.push(evFcVolumeSlide(speed, time));
227
+ }
228
+ else {
229
+ events.events.push(evFcPitchSlide(speed, time));
230
+ }
231
+ break;
232
+ }
233
+ handled = false;
234
+ break;
235
+ }
236
+ default:
237
+ handled = false;
238
+ break;
239
+ }
240
+ if (!handled) {
241
+ if (isVolume) {
242
+ events.events.push(volumeCommand);
243
+ }
244
+ else {
245
+ events.events.push(evFcSetPitch((b << 24) >> 24));
246
+ }
247
+ }
248
+ if (!isVolume) {
249
+ nextByteIsPitch = (b >= 0xe2 && b <= 0xe4) || (isFc14 && (b === 0xe9 || b === 0xea));
250
+ }
251
+ // If a sequence doesn't end with an E0/E1/E7 command, execution will
252
+ // continue in the next sequence. E0 commands cannot jump beyond the
253
+ // end of the initial sequence, so we can stop translating additional
254
+ // sequences when we hit command E0/E1 there.
255
+ if ((b === 0xe0 || b === 0xe1 || (b === 0xe7 && !isVolume)) &&
256
+ (maxJump <= scriptPos || scriptPos >= maxScriptJumpPos)) {
257
+ break;
258
+ }
259
+ }
260
+ }
261
+ for (const e of events.events) {
262
+ fixupJumpTarget(e, entryFromByte);
263
+ }
264
+ }
265
+ // ---------------------------------------------------------------------------
266
+ // Built-in waveforms (Load_fc.cpp:496-552)
267
+ // ---------------------------------------------------------------------------
268
+ const FC_SAMPLE_LENGTHS = [
269
+ 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
270
+ 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16, 16,
271
+ 8, 8, 8, 8, 8, 8, 8, 8, 16, 8, 16, 16, 8, 8, 24, // FC 1.4 imports the last sample with a length of 32 instead of 48
272
+ ];
273
+ const FC_SAMPLE_DATA = (() => {
274
+ // (Load_fc.cpp:499-552) — 47 rows; the first 16 rows are 32-byte saw
275
+ // ramps, then flat/noise rows.
276
+ const rows = [
277
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 3F 37 2F 27 1F 17 0F 07 FF 07 0F 17 1F 27 2F 37',
278
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 37 2F 27 1F 17 0F 07 FF 07 0F 17 1F 27 2F 37',
279
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 2F 27 1F 17 0F 07 FF 07 0F 17 1F 27 2F 37',
280
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 27 1F 17 0F 07 FF 07 0F 17 1F 27 2F 37',
281
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 1F 17 0F 07 FF 07 0F 17 1F 27 2F 37',
282
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 17 0F 07 FF 07 0F 17 1F 27 2F 37',
283
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 0F 07 FF 07 0F 17 1F 27 2F 37',
284
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 07 FF 07 0F 17 1F 27 2F 37',
285
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 FF 07 0F 17 1F 27 2F 37',
286
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 07 0F 17 1F 27 2F 37',
287
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 88 0F 17 1F 27 2F 37',
288
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 88 90 17 1F 27 2F 37',
289
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 88 90 98 1F 27 2F 37',
290
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 88 90 98 A0 27 2F 37',
291
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 88 90 98 A0 A8 2F 37',
292
+ 'C0 C0 D0 D8 E0 E8 F0 F8 00 F8 F0 E8 E0 D8 D0 C8 C0 B8 B0 A8 A0 98 90 88 80 88 90 98 A0 A8 B0 37',
293
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
294
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
295
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
296
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
297
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
298
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
299
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
300
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F 7F',
301
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F 7F',
302
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F 7F',
303
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F 7F',
304
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F 7F',
305
+ '81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 81 7F 7F 7F 7F',
306
+ '80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 7F 7F',
307
+ '80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 80 7F',
308
+ '80 80 80 80 80 80 80 80 7F 7F 7F 7F 7F 7F 7F 7F',
309
+ '80 80 80 80 80 80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
310
+ '80 80 80 80 80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
311
+ '80 80 80 80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
312
+ '80 80 80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
313
+ '80 80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
314
+ '80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
315
+ '80 80 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F 7F',
316
+ '80 80 90 98 A0 A8 B0 B8 C0 C8 D0 D8 E0 E8 F0 F8 00 08 10 18 20 28 30 38 40 48 50 58 60 68 70 7F',
317
+ '80 80 A0 B0 C0 D0 E0 F0 00 10 20 30 40 50 60 70',
318
+ '45 45 79 7D 7A 77 70 66 61 58 53 4D 2C 20 18 12 04 DB D3 CD C6 BC B5 AE A8 A3 9D 99 93 8E 8B 8A',
319
+ '45 45 79 7D 7A 77 70 66 5B 4B 43 37 2C 20 18 12 04 F8 E8 DB CF C6 BE B0 A8 A4 9E 9A 95 94 8D 83',
320
+ '00 00 40 60 7F 60 40 20 00 E0 C0 A0 80 A0 C0 E0',
321
+ '00 00 40 60 7F 60 40 20 00 E0 C0 A0 80 A0 C0 E0',
322
+ '80 80 90 98 A0 A8 B0 B8 C0 C8 D0 D8 E0 E8 F0 F8 00 08 10 18 20 28 30 38 40 48 50 58 60 68 70 7F 80 80 A0 B0 C0 D0 E0 F0 00 10 20 30 40 50 60 70',
323
+ ];
324
+ const out = [];
325
+ for (const row of rows) {
326
+ for (const tok of row.split(' ')) {
327
+ out.push(parseInt(tok, 16));
328
+ }
329
+ }
330
+ return out;
331
+ })();
332
+ /** 8-bit signed sample from the built-in waveform table. */
333
+ function sampleFromBuiltin(offset, length) {
334
+ const data = new Uint8Array(length);
335
+ for (let i = 0; i < length; i++) {
336
+ const v = FC_SAMPLE_DATA[offset + i] ?? 0x80;
337
+ data[i] = v;
338
+ }
339
+ return data;
340
+ }
341
+ // ---------------------------------------------------------------------------
342
+ // fc_test / fc_load (Load_fc.cpp:331-562)
343
+ // ---------------------------------------------------------------------------
344
+ export function fcTest(bytes) {
345
+ return readHeader(bytes) !== null;
346
+ }
347
+ /** OpenMPT NOTE_MIDDLEC = 61, NOTE_MIN = 1. Our note convention: the same
348
+ * OpenMPT note values (1..128) + keyoff/cut/fade markers ≥ 0x79, matching
349
+ * fmt-xm's event storage. */
350
+ const NOTE_MIDDLEC = 61;
351
+ /** FCSampleInfo::ConvertToMPT (Load_fc.cpp:42-58). */
352
+ function convertSample(info, bytes, filePos, isFc14, raw) {
353
+ let length = info.length * 2;
354
+ if (isFc14 && length !== 0)
355
+ length += 2;
356
+ const loopStart = info.loopStart;
357
+ const loopEnd = loopStart + info.loopLength * 2;
358
+ const looped = info.loopLength > 1;
359
+ // Fix for axel foley remix.smod (loop extends into next sample)
360
+ const nextSampleStart = filePos.v + length;
361
+ if (looped && loopEnd > length) {
362
+ length = loopEnd;
363
+ }
364
+ raw.length = length;
365
+ raw.loopStart = loopStart;
366
+ raw.loopEnd = loopEnd;
367
+ raw.flags = looped ? SampleFlags.LOOP : 0;
368
+ raw.finetune = 0;
369
+ raw.volume = 64;
370
+ raw.c5spd = 8363; // MOD c5speed (Amiga)
371
+ // SampleIO{8bit, mono, bigEndian, signedPCM}.ReadSample
372
+ const take = Math.min(length, Math.max(0, bytes.length - filePos.v));
373
+ const data = new Uint8Array(length);
374
+ for (let i = 0; i < take; i++) {
375
+ data[i] = bytes[filePos.v + i];
376
+ }
377
+ // Big-endian signed 8-bit → our signed 8-bit (same bytes; the store's
378
+ // signedByte() reinterprets).
379
+ raw.data = data;
380
+ filePos.v = nextSampleStart;
381
+ }
382
+ export function fcLoad(bytes, ctx) {
383
+ const header = readHeader(bytes);
384
+ if (!header)
385
+ throw new ParseError('FC: invalid header');
386
+ const isFc14 = header.isFc14;
387
+ const dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
388
+ // InitializeGlobals(MOD_TYPE_MOD, 4) + SetupMODPanning(true).
389
+ const chnCount = 4;
390
+ const numInstruments = Math.min(Math.trunc(header.volSequenceSize / 64) + 1, 255);
391
+ // std::array<uint8, 80> waveTableLengths (only for FC 1.4).
392
+ const waveTableLengths = new Uint8Array(80);
393
+ let pos = 100;
394
+ if (isFc14) {
395
+ waveTableLengths.set(bytes.subarray(pos, pos + 80));
396
+ pos += 80;
397
+ }
398
+ const numOrders = Math.trunc(sequenceSizeOf(header) / 13);
399
+ const orderData = [];
400
+ for (let ord = 0; ord < numOrders; ord++) {
401
+ const off = pos + ord * 13;
402
+ const entry = {
403
+ pattern: [0, 0, 0, 0],
404
+ noteTranspose: [0, 0, 0, 0],
405
+ instrTranspose: [0, 0, 0, 0],
406
+ speed: 0,
407
+ };
408
+ for (let c = 0; c < 4; c++) {
409
+ entry.pattern[c] = bytes[off + c * 3];
410
+ entry.noteTranspose[c] = (bytes[off + c * 3 + 1] << 24) >> 24;
411
+ entry.instrTranspose[c] = (bytes[off + c * 3 + 2] << 24) >> 24;
412
+ }
413
+ entry.speed = bytes[off + 12];
414
+ orderData.push(entry);
415
+ }
416
+ pos += numOrders * 13;
417
+ // Pattern data (patternsSize / 2 u16 pairs, big-endian).
418
+ const patternData = [];
419
+ {
420
+ let p = header.patternsOffset;
421
+ const count = header.patternsSize / 2;
422
+ for (let i = 0; i < count; i++) {
423
+ patternData.push([dv.getUint16(p + i * 2, false) >> 8, dv.getUint16(p + i * 2, false) & 0xff]);
424
+ }
425
+ }
426
+ // Patterns: one per order, 32 rows (Load_fc.cpp:382-439).
427
+ const patterns = [];
428
+ const prevNote = [0, 0, 0, 0];
429
+ for (let ord = 0; ord < numOrders; ord++) {
430
+ const rows = 32;
431
+ const tracks = [];
432
+ for (let c = 0; c < chnCount; c++) {
433
+ const ev = [];
434
+ for (let r = 0; r < rows; r++)
435
+ ev.push({ ...EMPTY_EVENT });
436
+ tracks.push({ rows, event: ev });
437
+ }
438
+ patterns.push({ rows, tracks });
439
+ let lastRow = rows;
440
+ const order = orderData[ord];
441
+ for (let c = 0; c < chnCount; c++) {
442
+ const chnInfo = order;
443
+ const patternOffset = chnInfo.pattern[c] * 32;
444
+ if (patternOffset >= patternData.length)
445
+ continue;
446
+ const track = patterns[ord].tracks[c];
447
+ for (let row = 0; row < rows; row++) {
448
+ const p = patternData[patternOffset + row] ?? [0, 0];
449
+ const m = track.event[row];
450
+ if (p[0] === 0x49) {
451
+ lastRow = Math.min(lastRow, row);
452
+ }
453
+ if (p[0] > 0 && p[0] !== 0x49) {
454
+ prevNote[c] = p[0];
455
+ // m->note = NOTE_MIN + ((noteTranspose + p[0]) & 0x7F) — an
456
+ // OpenMPT note value; our events store OpenMPT values too.
457
+ m.note = 1 + ((chnInfo.noteTranspose[c] + p[0]) & 0x7f);
458
+ const instr = (p[1] & 0x3f) + chnInfo.instrTranspose[c] + 1;
459
+ m.ins = instr >= 1 && instr <= numInstruments ? instr : numInstruments;
460
+ }
461
+ else if (row === 0 &&
462
+ ord > 0 &&
463
+ orderData[ord - 1].noteTranspose[c] !== chnInfo.noteTranspose[c] &&
464
+ prevNote[c] > 0) {
465
+ m.note = 1 + ((chnInfo.noteTranspose[c] + prevNote[c]) & 0x7f);
466
+ if ((p[1] & 0xc0) !== 0) {
467
+ // VOLCMD_TONEPORTAMENTO, param 9
468
+ m.f2t = 0x03; // FX_TONEPORTA via volume column path
469
+ m.f2p = 9;
470
+ }
471
+ else {
472
+ m.fxt = FX_FC_TONEPORTA_DURATION;
473
+ m.fxp = 0;
474
+ }
475
+ }
476
+ if ((p[1] & 0xc0) !== 0) {
477
+ // CMD_AUTO_PORTAMENTO_FC
478
+ m.f2t = FX_FC_AUTO_PORTA;
479
+ m.f2p = 0;
480
+ }
481
+ if ((p[1] & 0x80) !== 0) {
482
+ const data = row + 1 < 32 ? (patternData[patternOffset + row + 1] ?? [0, 0])[1] : 0;
483
+ let param = (data & 0x1f);
484
+ if (!isFc14)
485
+ param *= 2;
486
+ if (data > 0x1f)
487
+ param = -param;
488
+ m.fxp = param & 0xff;
489
+ }
490
+ }
491
+ }
492
+ if (order.speed !== 0) {
493
+ // WriteEffect(CMD_SPEED, speed).RetryNextRow() — the speed lands on
494
+ // row 0 and is retried until it takes effect; we can write it
495
+ // directly into row 0 of channel 0 (Fxc speed semantics).
496
+ const m = patterns[ord].tracks[0].event[0];
497
+ if (m.fxt === 0) {
498
+ m.fxt = FX_SPEED;
499
+ m.fxp = order.speed;
500
+ }
501
+ }
502
+ if (lastRow < rows) {
503
+ const row = Math.max(lastRow, 1) - 1;
504
+ // WriteEffect(CMD_PATTERNBREAK, 0).Row(row)
505
+ const m = patterns[ord].tracks[3].event[row];
506
+ if (m.fxt === 0) {
507
+ m.fxt = FX_BREAK;
508
+ m.fxp = 0;
509
+ }
510
+ }
511
+ }
512
+ // Sequences (Load_fc.cpp:443-452).
513
+ const freqSequences = new Uint8Array(64 * 256);
514
+ {
515
+ let p = header.freqSequenceOffset;
516
+ const len = header.freqSequenceSize;
517
+ for (let i = 0; i < len; i++) {
518
+ freqSequences[i] = bytes[p + i] ?? 0;
519
+ }
520
+ }
521
+ const volSequences = new Uint8Array(header.volSequenceSize + 8);
522
+ {
523
+ let p = header.volSequenceOffset;
524
+ const len = header.volSequenceSize;
525
+ for (let i = 0; i < len; i++) {
526
+ volSequences[i] = bytes[p + i] ?? 0;
527
+ }
528
+ }
529
+ // Empty instrument for out-of-range instrument numbers
530
+ // (Load_fc.cpp:455-458).
531
+ const emptyInstr = [0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe1];
532
+ for (let i = 0; i < 8; i++) {
533
+ volSequences[header.volSequenceSize + i] = emptyInstr[i];
534
+ }
535
+ // Instruments (Load_fc.cpp:460-487).
536
+ const instruments = [];
537
+ // NoteMap is instrument-independent for FC (same formula every
538
+ // instrument — Load_fc.cpp:471-479): keep one copy for the synth's
539
+ // pitch block, which C reads as instr->NoteMap[].
540
+ const fcNoteMap = new Array(128).fill(0);
541
+ for (let ins = 1; ins <= numInstruments; ins++) {
542
+ const volSeqOff = (ins - 1) * 64;
543
+ const volSeq = volSequences.subarray(volSeqOff, volSeqOff + 64);
544
+ const freqSeqOff = (volSeq[1] ?? 0) * 64;
545
+ const freqSeq = freqSequences.subarray(freqSeqOff, freqSeqOff + 64);
546
+ let defaultSample = freqSeq[1] ?? 0;
547
+ if (freqSeq[0] === 0xe9) {
548
+ defaultSample = 90 + defaultSample * 10 + (freqSeq[2] ?? 0);
549
+ }
550
+ // NoteMap (Load_fc.cpp:471-479):
551
+ // note < 48: note + NOTE_MIDDLEC - 24
552
+ // note < 60 or >= 120: NOTE_MIDDLEC + 23
553
+ // else: note + NOTE_MIDDLEC - 36 - 60
554
+ const map = new Array(121).fill(0xff);
555
+ const mapXpo = new Array(121).fill(0);
556
+ // C: NoteMap does NOT remap the played note — the pattern note plays
557
+ // at its raw pitch; NoteMap only enters the synth's FC pitch/vibrato
558
+ // offset block (InstrumentSynth.cpp:505-535, stored in mod.extras).
559
+ // map[key] = 0 (the single sub), mapXpo = 0 everywhere.
560
+ for (let note = 0; note <= 127; note++) {
561
+ let mapped;
562
+ if (note < 48)
563
+ mapped = note + NOTE_MIDDLEC - 24;
564
+ else if (note < 60 || note >= 120)
565
+ mapped = NOTE_MIDDLEC + 23;
566
+ else
567
+ mapped = note + NOTE_MIDDLEC - 36 - 60;
568
+ fcNoteMap[note] = mapped;
569
+ const key = note + 1; // our events store OpenMPT note values
570
+ if (key >= 1 && key <= 120) {
571
+ map[key - 1] = 0; // sub index 0
572
+ mapXpo[key - 1] = 0;
573
+ }
574
+ }
575
+ // TranslateFCScript for the freq + volume scripts.
576
+ const freqEvents = { events: [] };
577
+ const volEvents = { events: [] };
578
+ freqEvents.events.push(evFcSetVibrato(volSeq[2] ?? 0, volSeq[3] ?? 0, volSeq[4] ?? 0));
579
+ translateFcScript(freqEvents, freqSequences, isFc14, volSeq[1] ?? 0);
580
+ translateFcScript(volEvents, volSequences, isFc14);
581
+ const sub = {
582
+ vol: 64,
583
+ gvl: 0x40,
584
+ pan: -1,
585
+ xpo: 0,
586
+ fin: 0,
587
+ vwf: 0,
588
+ vde: 0,
589
+ vra: 0,
590
+ vsw: 0,
591
+ sid: defaultSample, // 0-based sample id (C: defaultSample + 1 - 1)
592
+ rvv: 0,
593
+ nna: 0,
594
+ dct: 0,
595
+ dca: 0,
596
+ ifc: 0,
597
+ ifr: 0,
598
+ };
599
+ const instrument = {
600
+ name: '',
601
+ volume: 64,
602
+ nsm: 1,
603
+ rls: 0,
604
+ map,
605
+ mapXpo,
606
+ sub: [sub],
607
+ aei: zeroEnvelope(),
608
+ pei: zeroEnvelope(),
609
+ fei: zeroEnvelope(),
610
+ };
611
+ instruments.push(instrument);
612
+ fcScripts.set(instrument, [freqEvents.events, volEvents.events]);
613
+ }
614
+ // Sample data (Load_fc.cpp:489-524). Dense 0..189 slot list (C keeps
615
+ // MAX_SAMPLES slots; FC 1.4 sub-samples live at 91+).
616
+ const rawSamples = [];
617
+ for (let i = 0; i < 190; i++)
618
+ rawSamples.push(blankSample());
619
+ const sampleFilePos = { v: header.sampleDataOffset };
620
+ for (let smp = 0; smp < 10; smp++) {
621
+ const info = header.sampleInfo[smp];
622
+ if (info.length === 0)
623
+ continue;
624
+ if (isFc14 && bytes[sampleFilePos.v] === 0x53 /* S */ && bytes[sampleFilePos.v + 1] === 0x53 && bytes[sampleFilePos.v + 2] === 0x4d && bytes[sampleFilePos.v + 3] === 0x50) {
625
+ // "SSMP" — FC 1.4 sub-samples
626
+ sampleFilePos.v += 4;
627
+ const sampleHeaders = bytes.subarray(sampleFilePos.v, sampleFilePos.v + 160);
628
+ sampleFilePos.v += 160;
629
+ for (let subSmp = 0; subSmp < 10; subSmp++) {
630
+ const hOff = subSmp * 16;
631
+ const subInfo = {
632
+ length: (sampleHeaders[hOff + 4] << 8) | sampleHeaders[hOff + 5],
633
+ loopStart: (sampleHeaders[hOff + 6] << 8) | sampleHeaders[hOff + 7],
634
+ loopLength: (sampleHeaders[hOff + 8] << 8) | sampleHeaders[hOff + 9],
635
+ };
636
+ const raw = blankSample();
637
+ convertSample(subInfo, bytes, sampleFilePos, true, raw);
638
+ ctx.addSample(raw);
639
+ rawSamples[91 + smp * 10 + subSmp - 1] = raw;
640
+ }
641
+ }
642
+ else {
643
+ const raw = rawSamples[smp];
644
+ convertSample(info, bytes, sampleFilePos, isFc14, raw);
645
+ }
646
+ }
647
+ // Built-in waveform samples (Load_fc.cpp:526-556): samples 11..(10+n).
648
+ const sampleLengths = isFc14
649
+ ? Array.from(waveTableLengths)
650
+ : FC_SAMPLE_LENGTHS;
651
+ let builtinOffset = 0;
652
+ for (let smp = 0; smp < sampleLengths.length; smp++) {
653
+ const len = sampleLengths[smp] * 2;
654
+ const raw = rawSamples[10 + smp];
655
+ raw.length = len;
656
+ raw.loopStart = 0;
657
+ raw.loopEnd = len;
658
+ raw.flags = SampleFlags.LOOP;
659
+ raw.data = isFc14
660
+ ? bytes.subarray(header.waveTableOffset + builtinOffset, header.waveTableOffset + builtinOffset + len)
661
+ : sampleFromBuiltin(builtinOffset, len);
662
+ if (isFc14)
663
+ builtinOffset += len;
664
+ else
665
+ builtinOffset += len;
666
+ }
667
+ // Register every slot in order so store ids align with slot indices.
668
+ for (const raw of rawSamples)
669
+ ctx.addSample(raw);
670
+ // Assemble the module (Load_fc.cpp:337-380 semantics).
671
+ const mod = {
672
+ title: '',
673
+ format: 'mod',
674
+ comment: '',
675
+ chn: chnCount,
676
+ pat: numOrders,
677
+ ins: numInstruments,
678
+ len: numOrders,
679
+ restart: 0,
680
+ xxo: Array.from({ length: numOrders }, (_, i) => i),
681
+ channels: [],
682
+ patterns,
683
+ instruments,
684
+ samples: rawSamples,
685
+ num_sequences: 1,
686
+ sequences: [
687
+ {
688
+ ord: 0,
689
+ entry_point: 0,
690
+ duration: 0,
691
+ time: 0,
692
+ speed: 3,
693
+ bpm: 125,
694
+ gvl: -1,
695
+ start_row: 0,
696
+ },
697
+ ],
698
+ speed: 3, // Order().SetDefaultSpeed(3)
699
+ bpm: 125,
700
+ volbase: 0x40,
701
+ gvolbase: 0x40,
702
+ gvol: 0x40,
703
+ quirks: Quirk.PROTRACK,
704
+ flowMode: 0,
705
+ readEventType: ReadEventType.MOD,
706
+ periodType: PeriodType.AMIGA,
707
+ defpan: 0x80,
708
+ time_factor: 10,
709
+ rrate: 250,
710
+ c4rate: C4_PAL_RATE,
711
+ compare_vblank: false,
712
+ tracker: isFc14 ? 'Future Composer 1.4' : 'Future Composer 1.0 - 1.3',
713
+ extras: { kind: 'fc', noteMap: fcNoteMap },
714
+ };
715
+ // SetupMODPanning(true) (Sndfile.cpp) — LRLR Amiga panning.
716
+ for (let i = 0; i < chnCount; i++) {
717
+ const pan = Math.floor((i + 1) / 2) % 2 === 0 ? 0x20 : 0xe0; // 64/224 of 255
718
+ mod.channels.push({
719
+ pan: i % 2 === 0 ? 0x20 : 0xe0,
720
+ vol: 0x40,
721
+ flg: 0,
722
+ });
723
+ void pan;
724
+ }
725
+ void dv;
726
+ return mod;
727
+ }
728
+ function zeroEnvelope() {
729
+ return { flags: 0, npt: 0, scl: 0, sus: 0, sue: 0, lps: 0, lpe: 0, x: [], y: [] };
730
+ }
731
+ function blankSample() {
732
+ return {
733
+ name: '',
734
+ data: new Uint8Array(0),
735
+ length: 0,
736
+ loopStart: 0,
737
+ loopEnd: 0,
738
+ sustainStart: 0,
739
+ sustainEnd: 0,
740
+ finetune: 0,
741
+ volume: 64,
742
+ flags: 0,
743
+ c5spd: 8363,
744
+ };
745
+ }
746
+ // ---------------------------------------------------------------------------
747
+ // Script registry + player hooks
748
+ // ---------------------------------------------------------------------------
749
+ /** Per-instrument synth scripts (C: instr->synth.m_scripts). Keyed by the
750
+ * Instrument object identity. */
751
+ const fcScripts = new WeakMap();
752
+ const fcChannelState = [];
753
+ function getChannelExtras(core, chn) {
754
+ void core;
755
+ let st = fcChannelState[chn];
756
+ if (!st) {
757
+ st = {
758
+ states: [],
759
+ scripts: null,
760
+ swapSampleIndex: 0,
761
+ fcPortaTick: false,
762
+ fcPortaActive: false,
763
+ fcPortaParam: 0,
764
+ portaDurationActive: false,
765
+ portamentoSlide: 0,
766
+ portaDestPeriod: 0,
767
+ };
768
+ fcChannelState[chn] = st;
769
+ }
770
+ return st;
771
+ }
772
+ // Wire the sibling-state accessor so FTM detune / MED_JumpScript /
773
+ // FTM_CloneTrack can reach sibling script states (synth.ts).
774
+ setSiblingStatesAccessor(0, (channel) => fcChannelState[channel] ?? null);
775
+ setSiblingStatesAccessor(1, (channel) => fcChannelState[channel] ?? null);
776
+ setSiblingStatesAccessor(2, (channel) => fcChannelState[channel] ?? null);
777
+ setSiblingStatesAccessor(3, (channel) => fcChannelState[channel] ?? null);
778
+ // ---------------------------------------------------------------------------
779
+ // Effect plugin (drives the synth engine per tick)
780
+ // ---------------------------------------------------------------------------
781
+ /** OpenMPT Sndmix.cpp:2197 (NextTick before volume) + :2371
782
+ * (ApplyChannelState after volume) — the plugin's onTick/onTickPost run at
783
+ * exactly those points in our per-channel loop. */
784
+ export const fcEffect = {
785
+ name: 'fc-synth',
786
+ onRow(core, chn, ev) {
787
+ void core;
788
+ const mod = core.module;
789
+ if (mod.extras?.kind !== 'fc')
790
+ return;
791
+ const st = getChannelExtras(core, chn);
792
+ // AutoSlide activation (Snd_fx.cpp:3444-3447):
793
+ // CMD_AUTO_PORTAMENTO_FC → fcPortaActive = param != 0.
794
+ if (ev.fxt === FX_FC_AUTO_PORTA) {
795
+ st.fcPortaActive = ev.fxp !== 0;
796
+ st.fcPortaParam = ev.fxp;
797
+ }
798
+ // Instrument change → rebind scripts (C: chn.pModInstrument changes).
799
+ if (ev.ins > 0 && ev.ins <= mod.instruments.length) {
800
+ const instr = mod.instruments[ev.ins - 1];
801
+ const scripts = fcScripts.get(instr);
802
+ if (scripts) {
803
+ st.scripts = scripts;
804
+ st.states = scripts.map(() => new SynthState());
805
+ }
806
+ }
807
+ // New note → reset synth state (C: chn.triggerNote → reconstruct).
808
+ if (ev.note > 0 && ev.note < 0x78) {
809
+ for (const s of st.states)
810
+ s.reconstruct();
811
+ }
812
+ // C autoSlide(0) per-tick semantics (Snd_fx.cpp:201 → :4687-4696): a
813
+ // new note on a row WITHOUT the duration effect snaps the period to
814
+ // the old destination and deactivates the slide.
815
+ if (st.portaDurationActive && ev.note > 0 && ev.fxt !== FX_FC_TONEPORTA_DURATION) {
816
+ const xc0 = core.ctx.channelStates[chn];
817
+ if (st.portaDestPeriod !== 0)
818
+ xc0.period = st.portaDestPeriod;
819
+ st.portaDurationActive = false;
820
+ st.portaDestPeriod = 0;
821
+ }
822
+ // CMD_TONEPORTA_DURATION (Snd_fx.cpp:1284-1287 → :4686-4725): prepare
823
+ // the auto-slide toward the new note's period over `param` rows.
824
+ if (ev.fxt === FX_FC_TONEPORTA_DURATION && ev.note > 0) {
825
+ const xc = core.ctx.channelStates[chn];
826
+ st.portaDurationActive = ev.fxp !== 0;
827
+ if (ev.fxp === 0) {
828
+ // chn.nPeriod = chn.nPortamentoDest (the new note's period)
829
+ xc.period = notePeriod(core, ev.note);
830
+ st.portamentoSlide = 0;
831
+ }
832
+ else {
833
+ const speed = core.ctx.p.speed;
834
+ const sourceNote = noteFromPeriod(core, xc.period);
835
+ // Util::muldivr_unsigned(|rowNote - sourceNote|, 64, speed * param)
836
+ const num = Math.abs(ev.note - sourceNote) * 64;
837
+ const den = speed * ev.fxp;
838
+ st.portamentoSlide = den > 0 ? Math.trunc((num + den / 2) / den) : 0;
839
+ st.portaDestPeriod = notePeriod(core, ev.note);
840
+ }
841
+ }
842
+ // CMD_MED_SYNTH_JUMP: jump the volume script (C: GlobalScriptState +
843
+ // chn.synthState special case at InstrumentSynth.cpp:214-224).
844
+ if (ev.fxt === FX_FC_MED_SYNTH_JUMP) {
845
+ if (st.scripts && st.scripts[1]) {
846
+ const script = st.scripts[1];
847
+ if (st.states.length < 2)
848
+ st.states.push(new SynthState());
849
+ const state = st.states[1];
850
+ if (state.nextRow === 0 && script.length > 0 && script[0].type === SynthEventType.SetStepSpeed) {
851
+ state.nextRow = 0;
852
+ }
853
+ state.jumpToPosition(script, ev.fxp);
854
+ }
855
+ }
856
+ },
857
+ onTick(core, chn) {
858
+ const mod = core.module;
859
+ if (mod.extras?.kind !== 'fc')
860
+ return;
861
+ const st = getChannelExtras(core, chn);
862
+ if (!st.scripts)
863
+ return;
864
+ // InstrumentSynth::States::NextTick (Sndmix.cpp:2197) — before volume.
865
+ for (let i = 0; i < st.scripts.length; i++) {
866
+ const script = st.scripts[i];
867
+ let state = st.states[i];
868
+ if (!state) {
869
+ state = new SynthState();
870
+ st.states[i] = state;
871
+ }
872
+ const xc = core.ctx.channelStates[chn];
873
+ if (core.ctx.p.frame === 0 &&
874
+ xc.note !== 0 &&
875
+ (xc.note_flags & (1 << 6)) !== 0 // NoteFlag.SET — trigger this row
876
+ ) {
877
+ // C: chn.triggerNote → reconstruct (new note this row,
878
+ // InstrumentSynth.cpp:210 — first tick only)
879
+ state.reconstruct();
880
+ }
881
+ synthStateNextTick(core, chn, script, state);
882
+ }
883
+ },
884
+ onTickPost(core, chn) {
885
+ const mod = core.module;
886
+ if (mod.extras?.kind !== 'fc')
887
+ return;
888
+ const st = getChannelExtras(core, chn);
889
+ if (!st.scripts)
890
+ return;
891
+ const xc = core.ctx.channelStates[chn];
892
+ const instr = xc.ins >= 0 ? mod.instruments[xc.ins] : null;
893
+ if (!instr)
894
+ return;
895
+ const vi = core.virt.mapChannel(chn);
896
+ const v2 = vi >= 0 ? core.virt.voices[vi] ?? null : null;
897
+ // TonePortamentoWithDuration run phase (Snd_fx.cpp:200-202 →
898
+ // :4704-4723): slide the period toward nPortamentoDest each tick.
899
+ if (st.portaDurationActive && st.portaDestPeriod !== 0 && v2 !== null) {
900
+ const dest = st.portaDestPeriod;
901
+ const delta = st.portamentoSlide;
902
+ if (v2.period < dest) {
903
+ v2.period += delta;
904
+ if (v2.period >= dest) {
905
+ v2.period = dest;
906
+ st.portaDestPeriod = 0;
907
+ }
908
+ }
909
+ else if (v2.period > dest) {
910
+ v2.period -= delta;
911
+ if (v2.period <= dest) {
912
+ v2.period = dest;
913
+ st.portaDestPeriod = 0;
914
+ }
915
+ }
916
+ }
917
+ // PortamentoFC (Sndmix.cpp:4017-4019 → Snd_fx.cpp:4151-4159):
918
+ if (st.fcPortaActive) {
919
+ st.fcPortaTick = !st.fcPortaTick;
920
+ if (st.fcPortaTick) {
921
+ const vi = core.virt.mapChannel(chn);
922
+ const v = vi >= 0 ? (core.virt.voices[vi] ?? null) : null;
923
+ if (v !== null && v.period > 0) {
924
+ const delta = (st.fcPortaParam << 24) >> 24; // int8 param
925
+ v.period -= delta * 4;
926
+ }
927
+ }
928
+ }
929
+ // ApplyChannelState (Sndmix.cpp:2371) per script state.
930
+ for (let i = 0; i < st.scripts.length; i++) {
931
+ const script = st.scripts[i];
932
+ const state = st.states[i];
933
+ if (!state)
934
+ continue;
935
+ const applied = applyState(core, chn, xc, instr, state);
936
+ if (applied && i === 0) {
937
+ // The frequency script drives pitch; the volume script drives
938
+ // volume (C: both ApplyChannelState in order).
939
+ }
940
+ void script;
941
+ }
942
+ // kMODSampleSwap: the queued swap is applied by the mixer at loop
943
+ // end (queuePatch). If the voice stopped before the loop ended, bind
944
+ // directly like C (ChannelSetSample non-deferred path).
945
+ if (st.swapSampleIndex > 0) {
946
+ const vi = core.virt.mapChannel(chn);
947
+ const v = vi >= 0 ? (core.virt.voices[vi] ?? null) : null;
948
+ const stillActive = v !== null && v.smp >= 0 && voiceIsActive(v);
949
+ const queued = v !== null && (v.flags & 0x20) !== 0; // VoiceFlag.SAMPLE_QUEUED
950
+ if (!stillActive && !queued) {
951
+ const smp0 = st.swapSampleIndex - 1;
952
+ if (smp0 >= 0 && smp0 < mod.samples.length) {
953
+ core.virt.setPatchSmp(chn, xc.ins, smp0, xc.note);
954
+ }
955
+ st.swapSampleIndex = 0;
956
+ }
957
+ else if (stillActive && v !== null && v.smp === st.swapSampleIndex - 1) {
958
+ st.swapSampleIndex = 0; // swap already applied
959
+ }
960
+ }
961
+ },
962
+ };
963
+ /** ApplyChannelState (InstrumentSynth.cpp:468-554) — write the synth's
964
+ * volume/pan/period results into the channel/voice. */
965
+ function applyState(core, chn, xc, instr, state) {
966
+ void instr;
967
+ const vi = core.virt.mapChannel(chn);
968
+ const v = vi >= 0 ? (core.virt.voices[vi] ?? null) : null;
969
+ // Volume (C: chn.nRealVolume scaling).
970
+ if (v !== null && v.smp >= 0) {
971
+ if (state.volumeFactor !== 16384) {
972
+ v.vol = Math.trunc((v.vol * state.volumeFactor) / 16384);
973
+ }
974
+ if (state.volumeAdd !== -32768) {
975
+ v.vol = clampVol(v.vol + state.volumeAdd / 4);
976
+ }
977
+ if ((state.flags & (1 << 1) /* kGTKTremorEnabled */) !== 0 &&
978
+ (state.flags & (1 << 2) /* kGTKTremorMute */) !== 0) {
979
+ v.vol = 0;
980
+ }
981
+ }
982
+ // Panning (C: chn.nRealPan adjustments) — FC doesn't change panning.
983
+ void state.panning;
984
+ // Period (C: period += m_periodAdd etc. before the final clamp).
985
+ if (v !== null && v.period > 0) {
986
+ let period = v.period;
987
+ if (state.periodFreqSlide !== 0) {
988
+ // DoFreqSlide with MOD semantics: period -= amount
989
+ period -= state.periodFreqSlide;
990
+ }
991
+ period += state.periodAdd;
992
+ if (state.linearPitchFactor !== 0) {
993
+ // Linear-pitch formats only; FC uses Amiga periods.
994
+ }
995
+ if (state.medVibratoDepth !== 0) {
996
+ period += Math.trunc((state.medVibratoValue * state.medVibratoDepth) / 64);
997
+ }
998
+ // FC pitch + vibrato (InstrumentSynth.cpp:505-535). C reads
999
+ // instr->NoteMap[fcNote] via GetPeriodFromNote(..., chn.nC5Speed);
1000
+ // nLastNote is the played OpenMPT note.
1001
+ const noteMap = core.module.extras?.kind === 'fc' ? core.module.extras.noteMap : null;
1002
+ const vibratoFc = state.fcVibratoValue - state.fcVibratoDepth;
1003
+ const doVibratoFc = vibratoFc !== 0 && state.fcVibratoDelay < 1;
1004
+ if ((state.fcPitch !== 0 || doVibratoFc) && noteMap) {
1005
+ // xc.note is the 0-based played note; C nLastNote = xc.note + 1.
1006
+ // fcNote = (fcPitch + nLastNote - NOTE_MIN) & 0x7f = fcPitch + xc.note.
1007
+ const lastNote = xc.note;
1008
+ let fcNote;
1009
+ if (state.fcPitch >= 0) {
1010
+ fcNote = (state.fcPitch + lastNote) & 0x7f;
1011
+ }
1012
+ else {
1013
+ fcNote = state.fcPitch & 0x7f;
1014
+ }
1015
+ if (state.fcPitch !== 0 && lastNote >= 0 && lastNote <= 127) {
1016
+ const p1 = notePeriod(core, noteMap[fcNote] ?? 0);
1017
+ const p2 = notePeriod(core, noteMap[lastNote] ?? 0);
1018
+ period += p1 - p2;
1019
+ }
1020
+ if (doVibratoFc) {
1021
+ let note = fcNote * 2 + 160;
1022
+ let vf = vibratoFc;
1023
+ while (note < 256) {
1024
+ vf *= 2;
1025
+ note += 24;
1026
+ }
1027
+ period += vf * 4;
1028
+ }
1029
+ }
1030
+ // Period limits (Load_fc.cpp:343-344: 113*4 .. 3424*4).
1031
+ if (period < 113 * 4)
1032
+ period = 113 * 4;
1033
+ else if (period > 3424 * 4)
1034
+ period = 3424 * 4;
1035
+ if (period < 1)
1036
+ period = 1;
1037
+ v.period = period;
1038
+ }
1039
+ return true;
1040
+ }
1041
+ /** C GetNoteFromPeriod for MOD: binary search the note whose
1042
+ * GetPeriodFromNote equals `period` (Snd_fx.cpp:6429-6460). */
1043
+ function noteFromPeriod(core, period) {
1044
+ if (!period)
1045
+ return 0;
1046
+ let minNote = 1, maxNote = 120;
1047
+ while (minNote <= maxNote) {
1048
+ const mid = (minNote + maxNote) >> 1;
1049
+ const p = notePeriod(core, mid);
1050
+ if (p > period)
1051
+ minNote = mid + 1;
1052
+ else if (p < period)
1053
+ maxNote = mid - 1;
1054
+ else
1055
+ return mid;
1056
+ }
1057
+ return (minNote + maxNote) >> 1;
1058
+ }
1059
+ function notePeriod(core, openmptNote) {
1060
+ void core;
1061
+ // GetPeriodFromNote for MOD: 8363 * FreqS3MTable[n%12] << 5 / (8363 << n/12)
1062
+ const FREQ = [1712, 1616, 1524, 1440, 1356, 1280, 1208, 1140, 1076, 1016, 960, 907];
1063
+ const n = openmptNote - 1; // NOTE_MIN = 1
1064
+ if (n < 0)
1065
+ return 0;
1066
+ return Math.trunc((8363 * (FREQ[n % 12] << 5)) / (8363 << Math.floor(n / 12)));
1067
+ }
1068
+ function clampVol(v) {
1069
+ return Math.max(0, Math.min(64, v));
1070
+ }
1071
+ /** The plugin the demo/core registers. Also wires the channel extras reset
1072
+ * on module start (C: new_channel_extras). */
1073
+ export const plugin = {
1074
+ name: 'fc',
1075
+ test: fcTest,
1076
+ load: fcLoad,
1077
+ readEvent(core, chn, row) {
1078
+ // FC modules use the MOD event reader (MOD_TYPE_MOD) — the FC-specific
1079
+ // commands are interpreted in the effect hooks above.
1080
+ const mod = core.module;
1081
+ void mod;
1082
+ void chn;
1083
+ void row;
1084
+ // Defer to fmt-mod's MOD reader via re-registration ordering: the core
1085
+ // dispatches on mod.format ('fc') — we replicate read_event_mod here.
1086
+ const e = core.readEventScratch(chn) ?? core.readEventAt(mod.xxo[core.ctx.p.ord] ?? 0, chn, row);
1087
+ if (e.ins !== 0) {
1088
+ const xc = core.ctx.channelStates[chn];
1089
+ xc.old_ins = e.ins;
1090
+ }
1091
+ // Use the shared MOD reader through the registered fmt-mod plugin so
1092
+ // note/volume handling is identical; our effect hooks layer the FC
1093
+ // behaviour on top.
1094
+ modReadEvent(core, chn, row);
1095
+ },
1096
+ };
1097
+ // Injected by the demo/registry wiring (avoids a package cycle).
1098
+ let modReadEvent = () => {
1099
+ throw new ParseError('FC: fmt-mod reader not wired');
1100
+ };
1101
+ /** Wire the shared MOD event reader (called by the host after registering
1102
+ * both plugins — C: MOD_TYPE_MOD shares read_event_mod). */
1103
+ export function setModEventReader(fn) {
1104
+ modReadEvent = fn;
1105
+ }
1106
+ /** A voice is actively producing audio (current sample bound and not
1107
+ * paused/exhausted). */
1108
+ function voiceIsActive(v) {
1109
+ return (v.flags & 0x40) === 0; // ~VoiceFlag.SAMPLE_PAUSED
1110
+ }
1111
+ //# sourceMappingURL=fc.js.map