@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/README.md +41 -2
- package/dist/fc.d.ts +15 -0
- package/dist/fc.d.ts.map +1 -0
- package/dist/fc.js +1111 -0
- package/dist/fc.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -0
- package/dist/linearTables.d.ts +4 -0
- package/dist/linearTables.d.ts.map +1 -0
- package/dist/linearTables.js +13 -0
- package/dist/linearTables.js.map +1 -0
- package/dist/synth.d.ts +302 -0
- package/dist/synth.d.ts.map +1 -0
- package/dist/synth.js +1430 -0
- package/dist/synth.js.map +1 -0
- package/package.json +51 -4
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
|