@beatbax/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -0
- package/dist/audio/bufferedRenderer.d.ts +32 -0
- package/dist/audio/bufferedRenderer.d.ts.map +1 -0
- package/dist/audio/bufferedRenderer.js +309 -0
- package/dist/audio/bufferedRenderer.js.map +1 -0
- package/dist/audio/pcmRenderer.d.ts +25 -0
- package/dist/audio/pcmRenderer.d.ts.map +1 -0
- package/dist/audio/pcmRenderer.js +1288 -0
- package/dist/audio/pcmRenderer.js.map +1 -0
- package/dist/audio/playback.d.ts +57 -0
- package/dist/audio/playback.d.ts.map +1 -0
- package/dist/audio/playback.js +794 -0
- package/dist/audio/playback.js.map +1 -0
- package/dist/chips/gameboy/apu.d.ts +9 -0
- package/dist/chips/gameboy/apu.d.ts.map +1 -0
- package/dist/chips/gameboy/apu.js +27 -0
- package/dist/chips/gameboy/apu.js.map +1 -0
- package/dist/chips/gameboy/noise.d.ts +6 -0
- package/dist/chips/gameboy/noise.d.ts.map +1 -0
- package/dist/chips/gameboy/noise.js +155 -0
- package/dist/chips/gameboy/noise.js.map +1 -0
- package/dist/chips/gameboy/packet.d.ts +3 -0
- package/dist/chips/gameboy/packet.d.ts.map +1 -0
- package/dist/chips/gameboy/packet.js +3 -0
- package/dist/chips/gameboy/packet.js.map +1 -0
- package/dist/chips/gameboy/periodTables.d.ts +16 -0
- package/dist/chips/gameboy/periodTables.d.ts.map +1 -0
- package/dist/chips/gameboy/periodTables.js +29 -0
- package/dist/chips/gameboy/periodTables.js.map +1 -0
- package/dist/chips/gameboy/pulse.d.ts +12 -0
- package/dist/chips/gameboy/pulse.d.ts.map +1 -0
- package/dist/chips/gameboy/pulse.js +275 -0
- package/dist/chips/gameboy/pulse.js.map +1 -0
- package/dist/chips/gameboy/wave.d.ts +8 -0
- package/dist/chips/gameboy/wave.d.ts.map +1 -0
- package/dist/chips/gameboy/wave.js +146 -0
- package/dist/chips/gameboy/wave.js.map +1 -0
- package/dist/effects/index.d.ts +7 -0
- package/dist/effects/index.d.ts.map +1 -0
- package/dist/effects/index.js +1028 -0
- package/dist/effects/index.js.map +1 -0
- package/dist/effects/types.d.ts +8 -0
- package/dist/effects/types.d.ts.map +1 -0
- package/dist/effects/types.js +2 -0
- package/dist/effects/types.js.map +1 -0
- package/dist/expand/refExpander.d.ts +14 -0
- package/dist/expand/refExpander.d.ts.map +1 -0
- package/dist/expand/refExpander.js +130 -0
- package/dist/expand/refExpander.js.map +1 -0
- package/dist/export/index.d.ts +5 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +5 -0
- package/dist/export/index.js.map +1 -0
- package/dist/export/jsonExport.d.ts +9 -0
- package/dist/export/jsonExport.d.ts.map +1 -0
- package/dist/export/jsonExport.js +184 -0
- package/dist/export/jsonExport.js.map +1 -0
- package/dist/export/midiExport.d.ts +9 -0
- package/dist/export/midiExport.d.ts.map +1 -0
- package/dist/export/midiExport.js +390 -0
- package/dist/export/midiExport.js.map +1 -0
- package/dist/export/ugeWriter.d.ts +33 -0
- package/dist/export/ugeWriter.d.ts.map +1 -0
- package/dist/export/ugeWriter.js +1997 -0
- package/dist/export/ugeWriter.js.map +1 -0
- package/dist/export/wavWriter.d.ts +24 -0
- package/dist/export/wavWriter.d.ts.map +1 -0
- package/dist/export/wavWriter.js +126 -0
- package/dist/export/wavWriter.js.map +1 -0
- package/dist/import/index.d.ts +5 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +8 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/remoteCache.d.ts +60 -0
- package/dist/import/remoteCache.d.ts.map +1 -0
- package/dist/import/remoteCache.js +194 -0
- package/dist/import/remoteCache.js.map +1 -0
- package/dist/import/uge/uge.reader.d.ts +27 -0
- package/dist/import/uge/uge.reader.d.ts.map +1 -0
- package/dist/import/uge/uge.reader.js +545 -0
- package/dist/import/uge/uge.reader.js.map +1 -0
- package/dist/import/urlUtils.d.ts +50 -0
- package/dist/import/urlUtils.d.ts.map +1 -0
- package/dist/import/urlUtils.js +87 -0
- package/dist/import/urlUtils.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +318 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments/instrumentState.d.ts +15 -0
- package/dist/instruments/instrumentState.d.ts.map +1 -0
- package/dist/instruments/instrumentState.js +24 -0
- package/dist/instruments/instrumentState.js.map +1 -0
- package/dist/parser/ast.d.ts +22 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/ast.js +5 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/index.d.ts +14 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +10 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/peggy/generated/parser.d.ts +8 -0
- package/dist/parser/peggy/generated/parser.d.ts.map +1 -0
- package/dist/parser/peggy/generated/parser.js +6269 -0
- package/dist/parser/peggy/generated/parser.js.map +1 -0
- package/dist/parser/peggy/index.d.ts +3 -0
- package/dist/parser/peggy/index.d.ts.map +1 -0
- package/dist/parser/peggy/index.js +555 -0
- package/dist/parser/peggy/index.js.map +1 -0
- package/dist/parser/structured.d.ts +16 -0
- package/dist/parser/structured.d.ts.map +1 -0
- package/dist/parser/structured.js +232 -0
- package/dist/parser/structured.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +12 -0
- package/dist/parser/tokenizer.d.ts.map +1 -0
- package/dist/parser/tokenizer.js +14 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/patterns/expand.d.ts +32 -0
- package/dist/patterns/expand.d.ts.map +1 -0
- package/dist/patterns/expand.js +184 -0
- package/dist/patterns/expand.js.map +1 -0
- package/dist/patterns/index.d.ts +2 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +2 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/tickScheduler.d.ts +27 -0
- package/dist/scheduler/tickScheduler.d.ts.map +1 -0
- package/dist/scheduler/tickScheduler.js +74 -0
- package/dist/scheduler/tickScheduler.js.map +1 -0
- package/dist/sequences/expand.d.ts +14 -0
- package/dist/sequences/expand.d.ts.map +1 -0
- package/dist/sequences/expand.js +137 -0
- package/dist/sequences/expand.js.map +1 -0
- package/dist/song/importResolver.browser.d.ts +29 -0
- package/dist/song/importResolver.browser.d.ts.map +1 -0
- package/dist/song/importResolver.browser.js +168 -0
- package/dist/song/importResolver.browser.js.map +1 -0
- package/dist/song/importResolver.d.ts +40 -0
- package/dist/song/importResolver.d.ts.map +1 -0
- package/dist/song/importResolver.js +445 -0
- package/dist/song/importResolver.js.map +1 -0
- package/dist/song/index.browser.d.ts +9 -0
- package/dist/song/index.browser.d.ts.map +1 -0
- package/dist/song/index.browser.js +7 -0
- package/dist/song/index.browser.js.map +1 -0
- package/dist/song/index.d.ts +8 -0
- package/dist/song/index.d.ts.map +1 -0
- package/dist/song/index.js +6 -0
- package/dist/song/index.js.map +1 -0
- package/dist/song/resolver.browser.d.ts +50 -0
- package/dist/song/resolver.browser.d.ts.map +1 -0
- package/dist/song/resolver.browser.js +536 -0
- package/dist/song/resolver.browser.js.map +1 -0
- package/dist/song/resolver.d.ts +20 -0
- package/dist/song/resolver.d.ts.map +1 -0
- package/dist/song/resolver.js +540 -0
- package/dist/song/resolver.js.map +1 -0
- package/dist/song/songModel.d.ts +34 -0
- package/dist/song/songModel.d.ts.map +1 -0
- package/dist/song/songModel.js +2 -0
- package/dist/song/songModel.js.map +1 -0
- package/dist/tests/refExpander.test.d.ts +2 -0
- package/dist/tests/refExpander.test.d.ts.map +1 -0
- package/dist/tests/refExpander.test.js +37 -0
- package/dist/tests/refExpander.test.js.map +1 -0
- package/dist/util/diag.d.ts +16 -0
- package/dist/util/diag.d.ts.map +1 -0
- package/dist/util/diag.js +29 -0
- package/dist/util/diag.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,1997 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UGE v6 binary file writer for hUGETracker.
|
|
3
|
+
*
|
|
4
|
+
* This writer exports a beatbax SongModel to a valid UGE v6 file that can be
|
|
5
|
+
* opened in hUGETracker and processed by uge2source.exe.
|
|
6
|
+
*
|
|
7
|
+
* Format spec: Based on hUGETracker source (song.pas, HugeDatatypes.pas)
|
|
8
|
+
* Reference implementation: generate_minimal_uge.py (validated with uge2source.exe)
|
|
9
|
+
*
|
|
10
|
+
* Key discoveries:
|
|
11
|
+
* - TInstrumentV3 is a packed record with embedded TPattern (64 cells × 17 bytes)
|
|
12
|
+
* - SubpatternEnabled is a semantic flag; bytes are ALWAYS written (1381 bytes per instrument)
|
|
13
|
+
* - Pascal AnsiString format: u32 length + bytes (length does NOT include null terminator)
|
|
14
|
+
* - Pattern cell v6 (TCellV2): 17 bytes = Note(u32) + Instrument(u32) + Volume(u32) + EffectCode(u32) + EffectParams(u8)
|
|
15
|
+
* - Volume field: 0x00005A00 (23040) means "no volume change"
|
|
16
|
+
*/
|
|
17
|
+
import { writeFileSync } from 'fs';
|
|
18
|
+
import { parseEnvelope, parseSweep } from '../chips/gameboy/pulse.js';
|
|
19
|
+
import { warn } from '../util/diag.js';
|
|
20
|
+
// Constants from UGE v6 spec
|
|
21
|
+
const UGE_VERSION = 6;
|
|
22
|
+
const NUM_DUTY_INSTRUMENTS = 15;
|
|
23
|
+
const NUM_WAVE_INSTRUMENTS = 15;
|
|
24
|
+
const NUM_NOISE_INSTRUMENTS = 15;
|
|
25
|
+
const NUM_WAVETABLES = 16;
|
|
26
|
+
const WAVETABLE_SIZE = 32; // 32 nibbles (4-bit values)
|
|
27
|
+
const PATTERN_ROWS = 64;
|
|
28
|
+
const NUM_CHANNELS = 4;
|
|
29
|
+
const NUM_ROUTINES = 16;
|
|
30
|
+
const EMPTY_NOTE = 90; // Note value for empty/rest cells
|
|
31
|
+
// Game Boy channel mapping
|
|
32
|
+
var GBChannel;
|
|
33
|
+
(function (GBChannel) {
|
|
34
|
+
GBChannel[GBChannel["PULSE1"] = 0] = "PULSE1";
|
|
35
|
+
GBChannel[GBChannel["PULSE2"] = 1] = "PULSE2";
|
|
36
|
+
GBChannel[GBChannel["WAVE"] = 2] = "WAVE";
|
|
37
|
+
GBChannel[GBChannel["NOISE"] = 3] = "NOISE";
|
|
38
|
+
})(GBChannel || (GBChannel = {}));
|
|
39
|
+
// Instrument types
|
|
40
|
+
var InstrumentType;
|
|
41
|
+
(function (InstrumentType) {
|
|
42
|
+
InstrumentType[InstrumentType["DUTY"] = 0] = "DUTY";
|
|
43
|
+
InstrumentType[InstrumentType["WAVE"] = 1] = "WAVE";
|
|
44
|
+
InstrumentType[InstrumentType["NOISE"] = 2] = "NOISE";
|
|
45
|
+
})(InstrumentType || (InstrumentType = {}));
|
|
46
|
+
// Vibrato depth scaling applied when encoding 4xy for UGE export.
|
|
47
|
+
// Some trackers and synths use different depth units; tune this to match hUGE.
|
|
48
|
+
const VIB_DEPTH_SCALE = 4.0;
|
|
49
|
+
// Map waveform names to hUGETracker waveform selector values (0-15)
|
|
50
|
+
// Official hUGETracker vibrato waveform names:
|
|
51
|
+
// 0=none, 1=square, 2=triangle, 3=sawUp, 4=sawDown, 5=stepped, 6=gated, 7=gatedSlow,
|
|
52
|
+
// 8=pulsedExtreme, 9=hybridTrillStep, A=hybridTriangleStep, B=hybridSawUpStep,
|
|
53
|
+
// C=longStepSawDown, D=hybridStepLongPause, E=slowPulse, F=subtlePulse
|
|
54
|
+
function mapWaveformName(value) {
|
|
55
|
+
if (typeof value === 'number')
|
|
56
|
+
return value;
|
|
57
|
+
const name = String(value).toLowerCase().trim();
|
|
58
|
+
const waveformMap = {
|
|
59
|
+
// Official hUGETracker waveform names (0-F)
|
|
60
|
+
'none': 0,
|
|
61
|
+
'square': 1,
|
|
62
|
+
'triangle': 2,
|
|
63
|
+
'sawup': 3,
|
|
64
|
+
'sawdown': 4,
|
|
65
|
+
'stepped': 5,
|
|
66
|
+
'gated': 6,
|
|
67
|
+
'gatedslow': 7,
|
|
68
|
+
'pulsedextreme': 8,
|
|
69
|
+
'hybridtrillstep': 9,
|
|
70
|
+
'hybridtrianglestep': 10,
|
|
71
|
+
'hybridsawupstep': 11,
|
|
72
|
+
'longstepsawdown': 12,
|
|
73
|
+
'hybridsteplongpause': 13,
|
|
74
|
+
'slowpulse': 14,
|
|
75
|
+
'subtlepulse': 15,
|
|
76
|
+
// Common aliases for backward compatibility
|
|
77
|
+
'sine': 2, // Maps to triangle (closest smooth waveform to sine)
|
|
78
|
+
'sin': 2,
|
|
79
|
+
'tri': 2, // Short for triangle
|
|
80
|
+
'sqr': 1, // Short for square
|
|
81
|
+
'pulse': 1, // Alias for square
|
|
82
|
+
'saw': 3, // Default to sawUp
|
|
83
|
+
'sawtooth': 3,
|
|
84
|
+
'ramp': 4, // Ramp down (sawDown)
|
|
85
|
+
'noise': 5, // Maps to stepped (choppy)
|
|
86
|
+
'random': 5,
|
|
87
|
+
};
|
|
88
|
+
return waveformMap[name] ?? 0; // Default to none (0) if unknown
|
|
89
|
+
}
|
|
90
|
+
function encodeVibParam(waveform, depth) {
|
|
91
|
+
const d = Math.max(0, Math.min(15, Math.round(depth * VIB_DEPTH_SCALE)));
|
|
92
|
+
const w = Math.max(0, Math.min(15, Math.round(waveform)));
|
|
93
|
+
return ((w & 0xf) << 4) | (d & 0xf);
|
|
94
|
+
}
|
|
95
|
+
// Vibrato effect handler (4xy)
|
|
96
|
+
const VibratoHandler = {
|
|
97
|
+
type: 'vib',
|
|
98
|
+
priority: 10,
|
|
99
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
100
|
+
const name = fx.type || fx;
|
|
101
|
+
if (String(name).toLowerCase() !== 'vib')
|
|
102
|
+
return null;
|
|
103
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
104
|
+
const depthRaw = params.length > 0 ? Number(params[0]) : 0;
|
|
105
|
+
// Default to triangle (2) if waveform is missing, empty, or falsy
|
|
106
|
+
const waveformParam = (params.length > 2 && params[2]) ? params[2] : 2;
|
|
107
|
+
const waveformRaw = mapWaveformName(waveformParam); // 3rd param: waveform name or number
|
|
108
|
+
// Parse duration from 4th param or durationSec
|
|
109
|
+
let durationRows = sustainCount + 1; // Default: full note length
|
|
110
|
+
if (params && params.length > 3 && Number.isFinite(Number(params[3]))) {
|
|
111
|
+
durationRows = Math.max(1, Math.round(Number(params[3])));
|
|
112
|
+
}
|
|
113
|
+
else if (fx.paramsStr && typeof fx.paramsStr === 'string') {
|
|
114
|
+
const rawParts = fx.paramsStr.split(',').map((s) => s.trim());
|
|
115
|
+
if (rawParts.length > 3) {
|
|
116
|
+
const p3 = Number(rawParts[3]);
|
|
117
|
+
if (Number.isFinite(p3))
|
|
118
|
+
durationRows = Math.max(1, Math.round(p3));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
else if (fx.durationSec && Number.isFinite(fx.durationSec)) {
|
|
122
|
+
durationRows = Math.max(1, Math.round((fx.durationSec) / tickSeconds));
|
|
123
|
+
}
|
|
124
|
+
const depth = Math.max(0, Math.min(15, Math.round(depthRaw)));
|
|
125
|
+
const waveform = Math.max(0, Math.min(15, Math.round(waveformRaw)));
|
|
126
|
+
const param = encodeVibParam(waveform, depth);
|
|
127
|
+
return {
|
|
128
|
+
type: 'vib',
|
|
129
|
+
code: 4,
|
|
130
|
+
param: param & 0xff,
|
|
131
|
+
duration: Math.min(durationRows, sustainCount + 1), // Clamp to note length
|
|
132
|
+
priority: this.priority,
|
|
133
|
+
isGlobal: false,
|
|
134
|
+
};
|
|
135
|
+
},
|
|
136
|
+
canCoexist(other) {
|
|
137
|
+
// Vibrato can be delayed to sustain rows if panning takes priority on note row
|
|
138
|
+
return other.type === 'pan' && other.isGlobal;
|
|
139
|
+
},
|
|
140
|
+
apply(cell, request) {
|
|
141
|
+
cell.effectCode = request.code;
|
|
142
|
+
cell.effectParam = request.param;
|
|
143
|
+
return true;
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
// Note Cut effect handler (ECx)
|
|
147
|
+
const NoteCutHandler = {
|
|
148
|
+
type: 'cut',
|
|
149
|
+
priority: 20, // Highest priority - always applied
|
|
150
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
151
|
+
const name = (fx.type || fx).toString().toLowerCase();
|
|
152
|
+
if (name !== 'cut')
|
|
153
|
+
return null;
|
|
154
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
155
|
+
const p0 = params.length > 0 ? Number(params[0]) : 0;
|
|
156
|
+
const cutParam = Number.isFinite(p0) ? Math.max(0, Math.min(15, Math.round(p0))) : 0;
|
|
157
|
+
return {
|
|
158
|
+
type: 'cut',
|
|
159
|
+
code: 0xE,
|
|
160
|
+
param: ((0 & 0xF) << 4) | (cutParam & 0xF), // Extended effect E0x
|
|
161
|
+
duration: 1, // Applied only on last sustain row
|
|
162
|
+
priority: this.priority,
|
|
163
|
+
isGlobal: false,
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
canCoexist(other) {
|
|
167
|
+
return false; // Note cut always wins, applied at end of note
|
|
168
|
+
},
|
|
169
|
+
apply(cell, request) {
|
|
170
|
+
cell.effectCode = request.code;
|
|
171
|
+
cell.effectParam = request.param;
|
|
172
|
+
if (cell.volume === undefined)
|
|
173
|
+
cell.volume = 0;
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
// Portamento effect handler (3xx - Tone portamento)
|
|
178
|
+
const PortamentoHandler = {
|
|
179
|
+
type: 'port',
|
|
180
|
+
priority: 12,
|
|
181
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
182
|
+
const name = fx.type || fx;
|
|
183
|
+
if (String(name).toLowerCase() !== 'port')
|
|
184
|
+
return null;
|
|
185
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
186
|
+
// port: speed parameter (0-255, determines how fast the pitch slides)
|
|
187
|
+
const speedRaw = params.length > 0 ? Number(params[0]) : 16;
|
|
188
|
+
const speed = Math.max(0, Math.min(255, Math.round(speedRaw)));
|
|
189
|
+
// Parse duration if specified
|
|
190
|
+
let durationRows = sustainCount + 1; // Default: full note length
|
|
191
|
+
if (params && params.length > 1 && Number.isFinite(Number(params[1]))) {
|
|
192
|
+
durationRows = Math.max(1, Math.round(Number(params[1])));
|
|
193
|
+
}
|
|
194
|
+
else if (fx.durationSec && Number.isFinite(fx.durationSec)) {
|
|
195
|
+
durationRows = Math.max(1, Math.round((fx.durationSec) / tickSeconds));
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
type: 'port',
|
|
199
|
+
code: 3,
|
|
200
|
+
param: speed & 0xff,
|
|
201
|
+
duration: Math.min(durationRows, sustainCount + 1),
|
|
202
|
+
priority: this.priority,
|
|
203
|
+
isGlobal: false,
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
canCoexist(other) {
|
|
207
|
+
// Portamento can be delayed to sustain rows if panning takes priority
|
|
208
|
+
return other.type === 'pan' && other.isGlobal;
|
|
209
|
+
},
|
|
210
|
+
apply(cell, request) {
|
|
211
|
+
cell.effectCode = request.code;
|
|
212
|
+
cell.effectParam = request.param;
|
|
213
|
+
return true;
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
// Pitch Bend effect handler - approximated with tone portamento (3xx)
|
|
217
|
+
// Note: This is a lossy approximation. Delay parameter and non-linear curves are not supported.
|
|
218
|
+
const PitchBendHandler = {
|
|
219
|
+
type: 'bend',
|
|
220
|
+
priority: 11, // Just below portamento priority
|
|
221
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
222
|
+
const name = fx.type || fx;
|
|
223
|
+
if (String(name).toLowerCase() !== 'bend')
|
|
224
|
+
return null;
|
|
225
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
226
|
+
// Parse bend parameters: [semitones, curve, delay, time]
|
|
227
|
+
const semitones = params.length > 0 ? Number(params[0]) : 0;
|
|
228
|
+
if (!Number.isFinite(semitones) || semitones === 0)
|
|
229
|
+
return null;
|
|
230
|
+
const curve = params.length > 1 ? String(params[1]).toLowerCase() : 'linear';
|
|
231
|
+
const delay = params.length > 2 ? Number(params[2]) : 0.5;
|
|
232
|
+
const bendTime = params.length > 3 ? Number(params[3]) : undefined;
|
|
233
|
+
// Warn about unsupported features
|
|
234
|
+
if (curve !== 'linear') {
|
|
235
|
+
warn('export', `Pitch bend with curve '${curve}' detected. hUGETracker only supports linear pitch slides (3xx). Curve will be approximated as linear.`);
|
|
236
|
+
}
|
|
237
|
+
if (delay > 0) {
|
|
238
|
+
warn('export', `Pitch bend with delay=${delay} detected. hUGETracker portamento (3xx) cannot delay bend start. The bend will apply across the full note duration.`);
|
|
239
|
+
}
|
|
240
|
+
// Calculate portamento speed based on semitone distance
|
|
241
|
+
// Portamento formula: portDur = (256 - speed) / 256 * noteDuration * 0.6
|
|
242
|
+
// For pitch bend, we want the slide to complete across the full note duration
|
|
243
|
+
// Rearranging: speed = 256 - (portDur / (noteDuration * 0.6)) * 256
|
|
244
|
+
// For full note bend: portDur ≈ noteDuration, so speed ≈ 256 - (1/0.6)*256 ≈ -171 (invalid)
|
|
245
|
+
// This means we need to account for the note duration relationship differently
|
|
246
|
+
// Simpler approach: Use a fixed speed that produces reasonable slides
|
|
247
|
+
// Testing shows: speed ~16-64 works well for typical bends
|
|
248
|
+
// Map larger semitone distances to higher speeds (faster slides needed)
|
|
249
|
+
const absSemitones = Math.abs(semitones);
|
|
250
|
+
let speed;
|
|
251
|
+
if (absSemitones <= 1) {
|
|
252
|
+
speed = 32; // Subtle bends (quarter/half-tone)
|
|
253
|
+
}
|
|
254
|
+
else if (absSemitones <= 2) {
|
|
255
|
+
speed = 48; // Whole-tone bends (guitar-style)
|
|
256
|
+
}
|
|
257
|
+
else if (absSemitones <= 5) {
|
|
258
|
+
speed = 64; // Medium bends (perfect fourth)
|
|
259
|
+
}
|
|
260
|
+
else if (absSemitones <= 7) {
|
|
261
|
+
speed = 96; // Large bends (perfect fifth)
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
speed = 128; // Extreme bends (octave+)
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
type: 'bend',
|
|
268
|
+
code: 3, // Tone portamento
|
|
269
|
+
param: speed & 0xff,
|
|
270
|
+
duration: sustainCount + 1, // Apply across full note
|
|
271
|
+
priority: this.priority,
|
|
272
|
+
isGlobal: false,
|
|
273
|
+
};
|
|
274
|
+
},
|
|
275
|
+
canCoexist(other) {
|
|
276
|
+
// Pitch bend cannot coexist with portamento (same effect code)
|
|
277
|
+
if (other.type === 'port')
|
|
278
|
+
return false;
|
|
279
|
+
// Can be delayed for panning
|
|
280
|
+
return other.type === 'pan' && other.isGlobal;
|
|
281
|
+
},
|
|
282
|
+
apply(cell, request) {
|
|
283
|
+
cell.effectCode = request.code;
|
|
284
|
+
cell.effectParam = request.param;
|
|
285
|
+
return true;
|
|
286
|
+
},
|
|
287
|
+
};
|
|
288
|
+
// Pitch Sweep effect handler - hardware-native GB NR10 frequency sweep
|
|
289
|
+
// Maps directly to Game Boy Pulse 1 channel sweep register
|
|
290
|
+
// Note: Sweep is ONLY available on Pulse 1 (channel 0) in hardware
|
|
291
|
+
const SweepHandler = {
|
|
292
|
+
type: 'sweep',
|
|
293
|
+
priority: 13, // Between portamento and arpeggio
|
|
294
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
295
|
+
const name = fx.type || fx;
|
|
296
|
+
if (String(name).toLowerCase() !== 'sweep')
|
|
297
|
+
return null;
|
|
298
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
299
|
+
// Parse sweep parameters: [time, direction, shift]
|
|
300
|
+
const time = params.length > 0 ? Number(params[0]) : 0;
|
|
301
|
+
if (!Number.isFinite(time) || time < 0 || time > 7)
|
|
302
|
+
return null;
|
|
303
|
+
if (time === 0)
|
|
304
|
+
return null; // Sweep disabled
|
|
305
|
+
// GB sweep is set in the instrument definition, not per-note
|
|
306
|
+
// Validate that other parameters exist but don't need to parse them in detail
|
|
307
|
+
const shift = params.length > 2 ? Number(params[2]) : 0;
|
|
308
|
+
if (!Number.isFinite(shift) || shift < 0 || shift > 7)
|
|
309
|
+
return null;
|
|
310
|
+
if (shift === 0)
|
|
311
|
+
return null; // No frequency change
|
|
312
|
+
// Warn and ignore - sweep is instrument-level in GB/UGE
|
|
313
|
+
warn('export', `Sweep effect detected on note. Game Boy sweep (NR10) is configured per-instrument, not per-note. Set sweep parameters in the instrument definition instead. Effect will be ignored in UGE export.`);
|
|
314
|
+
return null; // Sweep is instrument-level, not note-level
|
|
315
|
+
},
|
|
316
|
+
canCoexist(other) {
|
|
317
|
+
return true; // Never applied to cells anyway
|
|
318
|
+
},
|
|
319
|
+
apply(cell, request) {
|
|
320
|
+
// Sweep is instrument-level, cannot be applied to individual cells
|
|
321
|
+
return false;
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
// Arpeggio effect handler (0xy - Arpeggio)
|
|
325
|
+
const ArpeggioHandler = {
|
|
326
|
+
type: 'arp',
|
|
327
|
+
priority: 15, // Higher priority than vibrato, lower than note cut
|
|
328
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
329
|
+
const name = fx.type || fx;
|
|
330
|
+
if (String(name).toLowerCase() !== 'arp')
|
|
331
|
+
return null;
|
|
332
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
333
|
+
// Parse semitone offsets - filter out non-numeric values
|
|
334
|
+
const offsets = params
|
|
335
|
+
.map((p) => Number(p))
|
|
336
|
+
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 15);
|
|
337
|
+
if (offsets.length === 0)
|
|
338
|
+
return null;
|
|
339
|
+
// hUGETracker 0xy supports only 2 offsets (x and y nibbles, both 0-15)
|
|
340
|
+
// If user provides more than 2 offsets, we'll take the first 2 and warn
|
|
341
|
+
const offset1 = offsets.length > 0 ? offsets[0] : 0;
|
|
342
|
+
const offset2 = offsets.length > 1 ? offsets[1] : 0;
|
|
343
|
+
// Encode as 0xy where x=first offset, y=second offset
|
|
344
|
+
const param = ((offset1 & 0xF) << 4) | (offset2 & 0xF);
|
|
345
|
+
// Store metadata for warning if >2 offsets provided
|
|
346
|
+
const hasExtraOffsets = offsets.length > 2;
|
|
347
|
+
return {
|
|
348
|
+
type: 'arp',
|
|
349
|
+
code: 0,
|
|
350
|
+
param: param & 0xff,
|
|
351
|
+
duration: sustainCount + 1, // Full note duration
|
|
352
|
+
priority: this.priority,
|
|
353
|
+
isGlobal: false,
|
|
354
|
+
...(hasExtraOffsets && { _extraOffsets: offsets.slice(2) }), // Metadata for warning
|
|
355
|
+
};
|
|
356
|
+
},
|
|
357
|
+
canCoexist(other) {
|
|
358
|
+
// Arpeggio cannot coexist with other note-level effects
|
|
359
|
+
return false;
|
|
360
|
+
},
|
|
361
|
+
apply(cell, request) {
|
|
362
|
+
cell.effectCode = request.code;
|
|
363
|
+
cell.effectParam = request.param;
|
|
364
|
+
return true;
|
|
365
|
+
},
|
|
366
|
+
};
|
|
367
|
+
// Volume Slide effect handler (Dxy - Volume Slide)
|
|
368
|
+
const VolumeSlideHandler = {
|
|
369
|
+
type: 'volSlide',
|
|
370
|
+
priority: 8,
|
|
371
|
+
parse(fx, noteEvent, sustainCount, tickSeconds) {
|
|
372
|
+
const name = fx.type || fx;
|
|
373
|
+
const nameStr = String(name).toLowerCase();
|
|
374
|
+
if (nameStr !== 'volslide')
|
|
375
|
+
return null;
|
|
376
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
377
|
+
// volSlide: delta parameter (signed: +N = slide up, -N = slide down)
|
|
378
|
+
const deltaRaw = params.length > 0 ? Number(params[0]) : 0;
|
|
379
|
+
const delta = Number.isFinite(deltaRaw) ? deltaRaw : 0;
|
|
380
|
+
if (delta === 0)
|
|
381
|
+
return null; // No volume change
|
|
382
|
+
// hUGETracker Axy operates per-frame (~60Hz), so volume changes happen very fast.
|
|
383
|
+
// Scale BeatBax delta values down for smoother, more audible fades:
|
|
384
|
+
// - Divide by 4 to convert "musical" slide amounts to frame-rate slide speeds
|
|
385
|
+
// - Ensure minimum of 1 for any non-zero delta (to avoid no-op)
|
|
386
|
+
// - Clamp to hardware range 0-15
|
|
387
|
+
// Example: volSlide:+8 → A20 (2 per frame = ~0.1s for 15→0 fade)
|
|
388
|
+
// volSlide:+4 → A10 (1 per frame = ~0.25s fade)
|
|
389
|
+
let slideUp = 0;
|
|
390
|
+
let slideDown = 0;
|
|
391
|
+
if (delta > 0) {
|
|
392
|
+
// Positive delta = slide up
|
|
393
|
+
const scaledDelta = Math.max(1, Math.round(Math.abs(delta) / 4));
|
|
394
|
+
slideUp = Math.min(15, scaledDelta);
|
|
395
|
+
}
|
|
396
|
+
else if (delta < 0) {
|
|
397
|
+
// Negative delta = slide down
|
|
398
|
+
const scaledDelta = Math.max(1, Math.round(Math.abs(delta) / 4));
|
|
399
|
+
slideDown = Math.min(15, scaledDelta);
|
|
400
|
+
}
|
|
401
|
+
// Encode as Axy where x=slide up, y=slide down
|
|
402
|
+
const param = ((slideUp & 0xF) << 4) | (slideDown & 0xF);
|
|
403
|
+
// Parse duration if specified
|
|
404
|
+
let durationRows = sustainCount + 1; // Default: full note length
|
|
405
|
+
if (params && params.length > 1 && Number.isFinite(Number(params[1]))) {
|
|
406
|
+
durationRows = Math.max(1, Math.round(Number(params[1])));
|
|
407
|
+
}
|
|
408
|
+
else if (fx.durationSec && Number.isFinite(fx.durationSec)) {
|
|
409
|
+
durationRows = Math.max(1, Math.round((fx.durationSec) / tickSeconds));
|
|
410
|
+
}
|
|
411
|
+
return {
|
|
412
|
+
type: 'volSlide', // Must match handler type
|
|
413
|
+
code: 0xA, // Axy = Volume Slide (not 0xD which is pattern break)
|
|
414
|
+
param: param & 0xff,
|
|
415
|
+
duration: Math.min(durationRows, sustainCount + 1),
|
|
416
|
+
priority: this.priority,
|
|
417
|
+
isGlobal: false,
|
|
418
|
+
};
|
|
419
|
+
},
|
|
420
|
+
canCoexist(other) {
|
|
421
|
+
// Volume slide can be delayed if higher priority effects take note row
|
|
422
|
+
return other.type === 'pan' && other.isGlobal;
|
|
423
|
+
},
|
|
424
|
+
apply(cell, request) {
|
|
425
|
+
cell.effectCode = request.code;
|
|
426
|
+
cell.effectParam = request.param;
|
|
427
|
+
return true;
|
|
428
|
+
},
|
|
429
|
+
};
|
|
430
|
+
// Effect handler registry - add new handlers here as effects are implemented
|
|
431
|
+
// Note: Retrigger is NOT included - hUGETracker has no native retrigger effect
|
|
432
|
+
// Retrigger is WebAudio-only and cannot be exported to UGE
|
|
433
|
+
// Note: Sweep is registered but warns - GB sweep is instrument-level (NR10), not per-note
|
|
434
|
+
const EFFECT_HANDLERS = [
|
|
435
|
+
NoteCutHandler, // Priority 20 - always wins
|
|
436
|
+
ArpeggioHandler, // Priority 15
|
|
437
|
+
SweepHandler, // Priority 13 - warns (instrument-level only)
|
|
438
|
+
PortamentoHandler, // Priority 12
|
|
439
|
+
PitchBendHandler, // Priority 11 - approximated with portamento
|
|
440
|
+
VibratoHandler, // Priority 10
|
|
441
|
+
VolumeSlideHandler, // Priority 8
|
|
442
|
+
];
|
|
443
|
+
/**
|
|
444
|
+
* Resolve effect conflicts for a single row. Returns the highest-priority
|
|
445
|
+
* effect that should be applied, or null if no effects apply.
|
|
446
|
+
*/
|
|
447
|
+
function resolveEffectConflict(requests) {
|
|
448
|
+
if (requests.length === 0)
|
|
449
|
+
return null;
|
|
450
|
+
if (requests.length === 1)
|
|
451
|
+
return requests[0];
|
|
452
|
+
// Sort by priority (descending)
|
|
453
|
+
const sorted = [...requests].sort((a, b) => b.priority - a.priority);
|
|
454
|
+
// Return highest priority effect
|
|
455
|
+
// Future enhancement: check handler.canCoexist() for multi-effect support
|
|
456
|
+
return sorted[0];
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Binary buffer writer with helper methods for UGE format.
|
|
460
|
+
*/
|
|
461
|
+
class UGEWriter {
|
|
462
|
+
buffer = [];
|
|
463
|
+
writeU8(val) {
|
|
464
|
+
this.buffer.push(val & 0xff);
|
|
465
|
+
}
|
|
466
|
+
writeU32(val) {
|
|
467
|
+
this.buffer.push(val & 0xff);
|
|
468
|
+
this.buffer.push((val >> 8) & 0xff);
|
|
469
|
+
this.buffer.push((val >> 16) & 0xff);
|
|
470
|
+
this.buffer.push((val >> 24) & 0xff);
|
|
471
|
+
}
|
|
472
|
+
writeBool(val) {
|
|
473
|
+
this.writeU8(val ? 1 : 0);
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Write shortstring: 1 byte length + 255 bytes (padded with zeros)
|
|
477
|
+
*/
|
|
478
|
+
writeShortString(s) {
|
|
479
|
+
const bytes = Buffer.from(s.substring(0, 255), 'utf-8');
|
|
480
|
+
this.writeU8(bytes.length);
|
|
481
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
482
|
+
this.buffer.push(bytes[i]);
|
|
483
|
+
}
|
|
484
|
+
// Pad to 255 bytes
|
|
485
|
+
for (let i = bytes.length; i < 255; i++) {
|
|
486
|
+
this.buffer.push(0);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/**
|
|
490
|
+
* Write string: u32 length + bytes (Pascal AnsiString format)
|
|
491
|
+
* Length does NOT include null terminator.
|
|
492
|
+
*/
|
|
493
|
+
writeString(s) {
|
|
494
|
+
const bytes = Buffer.from(s, 'utf-8');
|
|
495
|
+
this.writeU32(bytes.length);
|
|
496
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
497
|
+
this.buffer.push(bytes[i]);
|
|
498
|
+
}
|
|
499
|
+
// Add null terminator as per UGE spec
|
|
500
|
+
this.buffer.push(0);
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Write a pattern cell (TCellV2): 17 bytes
|
|
504
|
+
* Note(u32) + Instrument(u32) + Volume(u32) + EffectCode(u32) + EffectParams(u8)
|
|
505
|
+
*/
|
|
506
|
+
writePatternCell(note, instrument, effectCode, effectParam, volume = 0x00005A00) {
|
|
507
|
+
this.writeU32(note);
|
|
508
|
+
this.writeU32(instrument);
|
|
509
|
+
this.writeU32(volume); // Volume field: 0x00005A00 = "no volume change" marker
|
|
510
|
+
this.writeU32(effectCode);
|
|
511
|
+
this.writeU8(effectParam);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Write instrument subpattern cell: 17 bytes
|
|
515
|
+
* Note(u32) + Instrument(u32) + Volume(u32) + EffectCode(u32) + EffectParams(u8)
|
|
516
|
+
* (Same as TCellV2)
|
|
517
|
+
*/
|
|
518
|
+
writeInstrumentSubpatternCell(note, instrument, volume, effectCode, effectParam) {
|
|
519
|
+
this.writePatternCell(note, instrument, effectCode, effectParam, volume);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Write empty pattern cell (rest)
|
|
523
|
+
*/
|
|
524
|
+
writeEmptyCell() {
|
|
525
|
+
this.writePatternCell(EMPTY_NOTE, 0, 0, 0);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Write empty instrument subpattern cell
|
|
529
|
+
*/
|
|
530
|
+
writeEmptyInstrumentCell() {
|
|
531
|
+
this.writeInstrumentSubpatternCell(EMPTY_NOTE, 0, 0, 0, 0);
|
|
532
|
+
}
|
|
533
|
+
toBuffer() {
|
|
534
|
+
return Buffer.from(this.buffer);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Write a minimal duty instrument (TInstrumentV3 with type=0)
|
|
539
|
+
* Total size: 1381 bytes = 293 base + 1088 subpattern
|
|
540
|
+
*/
|
|
541
|
+
function writeDutyInstrument(w, name, duty = 2, // 0=12.5%, 1=25%, 2=50%, 3=75%
|
|
542
|
+
initialVolume = 15, sweepDir = 1, // 0=increase, 1=decrease
|
|
543
|
+
sweepChange = 0, // 0-7
|
|
544
|
+
lengthEnabled = false, length = 0, freqSweepTime = 0, freqSweepDir = 0, // 0=up, 1=down
|
|
545
|
+
freqSweepShift = 0) {
|
|
546
|
+
w.writeU32(InstrumentType.DUTY);
|
|
547
|
+
w.writeShortString(name);
|
|
548
|
+
w.writeU32(length);
|
|
549
|
+
w.writeBool(lengthEnabled);
|
|
550
|
+
w.writeU8(initialVolume);
|
|
551
|
+
w.writeU32(sweepDir); // volume_sweep_dir (0=Increase, 1=Decrease)
|
|
552
|
+
w.writeU8(sweepChange); // volume_sweep_change
|
|
553
|
+
w.writeU32(freqSweepTime); // freq_sweep_time
|
|
554
|
+
w.writeU32(freqSweepDir); // freq_sweep_direction (0=up, 1=down)
|
|
555
|
+
w.writeU32(freqSweepShift); // freq_sweep_shift
|
|
556
|
+
w.writeU8(duty); // duty_cycle
|
|
557
|
+
w.writeU32(0); // unused_a
|
|
558
|
+
w.writeU32(0); // unused_b
|
|
559
|
+
w.writeU32(0); // counter_step (TStepWidth) - MISSING in previous version
|
|
560
|
+
// Subpattern: ALWAYS write 64 rows (part of TInstrumentV3 structure)
|
|
561
|
+
w.writeBool(false); // subpattern_enabled (set to false by default)
|
|
562
|
+
for (let row = 0; row < PATTERN_ROWS; row++) {
|
|
563
|
+
w.writeEmptyInstrumentCell();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Write a minimal wave instrument (TInstrumentV3 with type=1)
|
|
568
|
+
* Total size: 1381 bytes
|
|
569
|
+
*/
|
|
570
|
+
export function mapWaveVolumeToUGE(vol) {
|
|
571
|
+
// Accept numbers or percent strings (e.g. '50%'). Default is 100 => maps to UGE value 1
|
|
572
|
+
let vNum = 100;
|
|
573
|
+
if (vol !== undefined && vol !== null) {
|
|
574
|
+
if (typeof vol === 'string') {
|
|
575
|
+
const s = vol.trim();
|
|
576
|
+
vNum = s.endsWith('%') ? parseInt(s.slice(0, -1), 10) : parseInt(s, 10);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
vNum = Number(vol);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
if (![0, 25, 50, 100].includes(vNum))
|
|
583
|
+
vNum = 100;
|
|
584
|
+
// UGE mapping: 0=mute, 1=100%, 2=50%, 3=25%
|
|
585
|
+
const map = { 0: 0, 100: 1, 50: 2, 25: 3 };
|
|
586
|
+
return map[vNum];
|
|
587
|
+
}
|
|
588
|
+
function writeWaveInstrument(w, name, waveIndex = 0, volume = 3, // 0=mute, 1=100%, 2=50%, 3=25%
|
|
589
|
+
lengthEnabled = false, length = 0) {
|
|
590
|
+
w.writeU32(InstrumentType.WAVE);
|
|
591
|
+
w.writeShortString(name);
|
|
592
|
+
w.writeU32(length);
|
|
593
|
+
w.writeBool(lengthEnabled);
|
|
594
|
+
w.writeU8(0); // unused1_u8
|
|
595
|
+
w.writeU32(0); // unused2_u32
|
|
596
|
+
w.writeU8(0); // unused3_u8
|
|
597
|
+
w.writeU32(0); // unused4_u32
|
|
598
|
+
w.writeU32(0); // unused5_u32
|
|
599
|
+
w.writeU32(0); // unused6_u32
|
|
600
|
+
w.writeU8(0); // unused7_u8
|
|
601
|
+
// output_level: per hUGE v6 spec this is a raw selector value (0..3) not a full envelope or percent.
|
|
602
|
+
// Values: 0=mute, 1=100%, 2=50%, 3=25%. hUGEDriver expands this to NR32 by doing (output_level << 5) when writing to hardware.
|
|
603
|
+
// Store as u32 for struct alignment in TInstrumentV3.
|
|
604
|
+
w.writeU32(volume); // output_level (raw 0..3 per hUGE spec)
|
|
605
|
+
w.writeU32(waveIndex); // wave_index
|
|
606
|
+
w.writeU32(0); // counter_step (TStepWidth) - MISSING in previous version
|
|
607
|
+
// Subpattern: ALWAYS write 64 rows
|
|
608
|
+
w.writeBool(false); // subpattern_enabled (set to false by default)
|
|
609
|
+
for (let row = 0; row < PATTERN_ROWS; row++) {
|
|
610
|
+
w.writeEmptyInstrumentCell();
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Write a minimal noise instrument (TInstrumentV3 with type=2)
|
|
615
|
+
* Total size: 1381 bytes
|
|
616
|
+
*/
|
|
617
|
+
function writeNoiseInstrument(w, name, initialVolume = 15, sweepDir = 1, // 0=increase, 1=decrease
|
|
618
|
+
sweepChange = 0, // 0-7, envelope period
|
|
619
|
+
noiseMode = 0, // 0=15-bit, 1=7-bit
|
|
620
|
+
lengthEnabled = true, length = 8) {
|
|
621
|
+
w.writeU32(InstrumentType.NOISE);
|
|
622
|
+
w.writeShortString(name);
|
|
623
|
+
w.writeU32(length);
|
|
624
|
+
w.writeBool(lengthEnabled);
|
|
625
|
+
w.writeU8(initialVolume);
|
|
626
|
+
w.writeU32(sweepDir); // volume_sweep_dir (0=Increase, 1=Decrease)
|
|
627
|
+
w.writeU8(sweepChange); // volume_sweep_change
|
|
628
|
+
w.writeU32(0); // unused_a
|
|
629
|
+
w.writeU32(0); // unused_b
|
|
630
|
+
w.writeU32(0); // unused_c
|
|
631
|
+
w.writeU8(0); // unused_d
|
|
632
|
+
w.writeU32(0); // unused_e
|
|
633
|
+
w.writeU32(0); // unused_f
|
|
634
|
+
w.writeU32(noiseMode); // noise_mode: 0=15-bit, 1=7-bit
|
|
635
|
+
// Subpattern: ALWAYS write 64 rows
|
|
636
|
+
w.writeBool(false); // subpattern_enabled (set to false by default)
|
|
637
|
+
for (let row = 0; row < PATTERN_ROWS; row++) {
|
|
638
|
+
w.writeEmptyInstrumentCell();
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Convert note name (e.g. "C4") to MIDI note number
|
|
643
|
+
*/
|
|
644
|
+
/**
|
|
645
|
+
* Convert note name (e.g., "C5") to hUGETracker note index.
|
|
646
|
+
* hUGETracker uses indices 0-72 where 0 = C-3, 12 = C-4, 24 = C-5, etc.
|
|
647
|
+
* This is MIDI note number minus 36 (3 octaves offset).
|
|
648
|
+
* Notes below C-3 are transposed up by octaves to fit in range.
|
|
649
|
+
*
|
|
650
|
+
* @param noteName - Note name like "C4", "D#5"
|
|
651
|
+
* @param ugeTranspose - Optional transpose in semitones for UGE export only (e.g., +12 = up one octave)
|
|
652
|
+
*/
|
|
653
|
+
function noteNameToMidiNote(noteName, ugeTranspose = 0) {
|
|
654
|
+
const match = noteName.match(/^([A-G]#?)(-?\d+)$/i);
|
|
655
|
+
if (!match)
|
|
656
|
+
return EMPTY_NOTE;
|
|
657
|
+
const noteNames = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
|
658
|
+
const [, pitch, octaveStr] = match;
|
|
659
|
+
const octave = parseInt(octaveStr, 10);
|
|
660
|
+
const noteIndex = noteNames.indexOf(pitch.toUpperCase());
|
|
661
|
+
if (noteIndex === -1)
|
|
662
|
+
return EMPTY_NOTE;
|
|
663
|
+
// Calculate MIDI note number and apply UGE-specific transpose
|
|
664
|
+
let midiNote = (octave + 1) * 12 + noteIndex + ugeTranspose;
|
|
665
|
+
// Convert to hUGETracker index
|
|
666
|
+
// hUGETracker's "C3" (index 0) corresponds to MIDI note 36,
|
|
667
|
+
// which is the note C2 at approximately 65.4 Hz in standard MIDI tuning.
|
|
668
|
+
let ugeIndex = midiNote - 36;
|
|
669
|
+
// hUGETracker minimum note is index 0 (displayed as C3)
|
|
670
|
+
const originalIndex = ugeIndex;
|
|
671
|
+
// If below range, transpose up by octaves until in range
|
|
672
|
+
while (ugeIndex < 0 && ugeIndex + 12 <= 72) {
|
|
673
|
+
ugeIndex += 12;
|
|
674
|
+
}
|
|
675
|
+
// Warn if note was transposed (below C3)
|
|
676
|
+
if (originalIndex < 0 && ugeIndex >= 0) {
|
|
677
|
+
const octavesShifted = Math.ceil(Math.abs(originalIndex) / 12);
|
|
678
|
+
warn('export', `Note ${noteName} is below hUGETracker minimum (C3). Transposed up ${octavesShifted} octave(s).`);
|
|
679
|
+
}
|
|
680
|
+
// If above range, clamp to maximum value and warn
|
|
681
|
+
if (ugeIndex > 72) {
|
|
682
|
+
warn('export', `Note ${noteName} (index ${ugeIndex}) is above hUGETracker maximum (72). Clamped to C-9.`);
|
|
683
|
+
ugeIndex = 72;
|
|
684
|
+
}
|
|
685
|
+
// Valid range is 0-72 (C-3 to C-9 in hUGETracker)
|
|
686
|
+
if (ugeIndex < 0)
|
|
687
|
+
return EMPTY_NOTE;
|
|
688
|
+
return ugeIndex;
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* Map beatbax instrument to Game Boy instrument index
|
|
692
|
+
*/
|
|
693
|
+
function resolveInstrumentIndex(instName, instProps, instruments, channelType, dutyInsts, waveInsts, noiseInsts) {
|
|
694
|
+
// If no instrument specified, return 0 (default)
|
|
695
|
+
if (!instName)
|
|
696
|
+
return 0;
|
|
697
|
+
// Look up instrument in song model
|
|
698
|
+
const inst = instruments[instName] || instProps;
|
|
699
|
+
if (!inst)
|
|
700
|
+
return 0;
|
|
701
|
+
const type = inst.type?.toLowerCase();
|
|
702
|
+
// Map to appropriate instrument index based on type and channel
|
|
703
|
+
// UGE uses 0-based instrument indexing:
|
|
704
|
+
// 0-14 = duty instruments (array indices 0-14)
|
|
705
|
+
// 15-29 = wave instruments (array indices 0-14)
|
|
706
|
+
// 30-44 = noise instruments (array indices 0-14)
|
|
707
|
+
if (type === 'pulse1' || type === 'pulse2' || type === 'duty') {
|
|
708
|
+
const idx = dutyInsts.indexOf(instName);
|
|
709
|
+
return idx !== -1 ? idx : 0;
|
|
710
|
+
}
|
|
711
|
+
else if (type === 'wave') {
|
|
712
|
+
const idx = waveInsts.indexOf(instName);
|
|
713
|
+
return idx !== -1 ? idx + NUM_DUTY_INSTRUMENTS : NUM_DUTY_INSTRUMENTS;
|
|
714
|
+
}
|
|
715
|
+
else if (type === 'noise') {
|
|
716
|
+
const idx = noiseInsts.indexOf(instName);
|
|
717
|
+
return idx !== -1 ? idx + NUM_DUTY_INSTRUMENTS + NUM_WAVE_INSTRUMENTS : NUM_DUTY_INSTRUMENTS + NUM_WAVE_INSTRUMENTS;
|
|
718
|
+
}
|
|
719
|
+
return 0;
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* Convert beatbax channel events to UGE pattern cells
|
|
723
|
+
* Returns patterns (64-row chunks) for a single channel
|
|
724
|
+
*/
|
|
725
|
+
function eventsToPatterns(events, instruments, channelType, dutyInsts, waveInsts, noiseInsts, strictGb = false, songBpm, desiredVibMap) {
|
|
726
|
+
const patterns = [];
|
|
727
|
+
// Split events into 64-row patterns
|
|
728
|
+
let currentPattern = [];
|
|
729
|
+
// Compute engine tick length (seconds per pattern row) from song BPM so
|
|
730
|
+
// we can convert any `durationSec` normalized by the resolver back into
|
|
731
|
+
// a row count when exporting to tracker rows. Prefer an explicit `songBpm`
|
|
732
|
+
// passed from the exporter.
|
|
733
|
+
const bpmForTicks = (typeof songBpm === 'number' && Number.isFinite(songBpm)) ? songBpm : 128;
|
|
734
|
+
const tickSeconds = (60 / bpmForTicks) / 4; // same semantics used by resolver
|
|
735
|
+
// Track the last active pan for this channel so sustain rows inherit it
|
|
736
|
+
let currentPan = 'C';
|
|
737
|
+
// Rows where we should force a note cutoff by setting volume=0 on the empty cell
|
|
738
|
+
const endCutRows = new Set();
|
|
739
|
+
// Map of target global row -> cut parameter (ticks) to write as ECx
|
|
740
|
+
const cutParamMap = new Map();
|
|
741
|
+
// Track active per-channel vibrato so we can repeat it on sustain rows.
|
|
742
|
+
// `remainingRows` is optional; if present it counts sustain rows left AFTER the note row.
|
|
743
|
+
let activeVib = null;
|
|
744
|
+
// Track active per-channel arpeggio so we can repeat it on sustain rows.
|
|
745
|
+
let activeArp = null;
|
|
746
|
+
// Map of note globalRow -> desired durationRows (including the note row)
|
|
747
|
+
if (!desiredVibMap)
|
|
748
|
+
desiredVibMap = new Map();
|
|
749
|
+
// Track if any retrigger or echo effects are encountered (for warning)
|
|
750
|
+
let hasRetrigEffects = false;
|
|
751
|
+
let hasEchoEffects = false;
|
|
752
|
+
let prevEventType = null;
|
|
753
|
+
// Track if we've seen the first note yet (to skip portamento on first note)
|
|
754
|
+
let hasSeenNote = false;
|
|
755
|
+
for (let i = 0; i < events.length; i++) {
|
|
756
|
+
const event = events[i];
|
|
757
|
+
let cell;
|
|
758
|
+
if (event.type === 'rest') {
|
|
759
|
+
// Rest = empty cell (no note trigger, no effect) but inherit current pan
|
|
760
|
+
// If this rest immediately follows a note or sustain, emit an explicit
|
|
761
|
+
// Note Cut effect so trackers show the termination.
|
|
762
|
+
let effCode = 0;
|
|
763
|
+
let effParam = 0;
|
|
764
|
+
if (prevEventType === 'note' || prevEventType === 'sustain') {
|
|
765
|
+
// Emit as extended effect group E0x (note cut). Sub-effect=0, param=0
|
|
766
|
+
effCode = 0xE;
|
|
767
|
+
effParam = (0 << 4) | (0 & 0xF);
|
|
768
|
+
}
|
|
769
|
+
cell = {
|
|
770
|
+
note: EMPTY_NOTE,
|
|
771
|
+
instrument: -1, // No instrument change on rest cells
|
|
772
|
+
effectCode: effCode,
|
|
773
|
+
effectParam: effParam,
|
|
774
|
+
pan: currentPan,
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
else if (event.type === 'sustain') {
|
|
778
|
+
// Sustain = ongoing note; retain currentPan. If a vibrato or arpeggio was active on the
|
|
779
|
+
// previous note row, repeat that effect on this sustain row until the note ends
|
|
780
|
+
// or until an explicit duration has expired.
|
|
781
|
+
let effCode = 0;
|
|
782
|
+
let effParam = 0;
|
|
783
|
+
if (activeVib) {
|
|
784
|
+
// If remainingRows is undefined, vib continues for full note (until note ends).
|
|
785
|
+
if (typeof activeVib.remainingRows === 'undefined' || activeVib.remainingRows > 0) {
|
|
786
|
+
effCode = activeVib.code;
|
|
787
|
+
effParam = activeVib.param;
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
else if (activeArp) {
|
|
791
|
+
// If remainingRows is undefined, arp continues for full note (until note ends).
|
|
792
|
+
if (typeof activeArp.remainingRows === 'undefined' || activeArp.remainingRows > 0) {
|
|
793
|
+
effCode = activeArp.code;
|
|
794
|
+
effParam = activeArp.param;
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
cell = {
|
|
798
|
+
note: EMPTY_NOTE,
|
|
799
|
+
instrument: -1, // No instrument change on sustain cells
|
|
800
|
+
effectCode: effCode,
|
|
801
|
+
effectParam: effParam,
|
|
802
|
+
pan: currentPan,
|
|
803
|
+
};
|
|
804
|
+
// Decrement remainingRows if present
|
|
805
|
+
if (activeVib && typeof activeVib.remainingRows === 'number') {
|
|
806
|
+
activeVib.remainingRows = Math.max(0, activeVib.remainingRows - 1);
|
|
807
|
+
if (activeVib.remainingRows === 0) {
|
|
808
|
+
// Once expired, clear activeVib so further sustains don't repeat it
|
|
809
|
+
activeVib = null;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
if (activeArp && typeof activeArp.remainingRows === 'number') {
|
|
813
|
+
activeArp.remainingRows = Math.max(0, activeArp.remainingRows - 1);
|
|
814
|
+
if (activeArp.remainingRows === 0) {
|
|
815
|
+
// Once expired, clear activeArp so further sustains don't repeat it
|
|
816
|
+
activeArp = null;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
else if (event.type === 'note') {
|
|
821
|
+
const noteEvent = event;
|
|
822
|
+
// Compute sustain length (count following sustain events)
|
|
823
|
+
let sustainCount = 0;
|
|
824
|
+
try {
|
|
825
|
+
for (let k = i + 1; k < events.length; k++) {
|
|
826
|
+
const ne = events[k];
|
|
827
|
+
if (ne && ne.type === 'sustain')
|
|
828
|
+
sustainCount++;
|
|
829
|
+
else
|
|
830
|
+
break;
|
|
831
|
+
}
|
|
832
|
+
// Mark cut rows if:
|
|
833
|
+
// 1. The note has an explicit cut effect, OR
|
|
834
|
+
// 2. The note is followed only by rests/empty cells until pattern boundary
|
|
835
|
+
// (to prevent bleeding into next pattern)
|
|
836
|
+
let hasExplicitCut = false;
|
|
837
|
+
let cutParam = undefined;
|
|
838
|
+
if (Array.isArray(noteEvent.effects) && noteEvent.effects.length > 0) {
|
|
839
|
+
for (const fx of noteEvent.effects) {
|
|
840
|
+
if (!fx)
|
|
841
|
+
continue;
|
|
842
|
+
const name = (fx.type || fx).toString().toLowerCase();
|
|
843
|
+
const params = fx.params || (Array.isArray(fx) ? fx : []);
|
|
844
|
+
if (name === 'cut') {
|
|
845
|
+
hasExplicitCut = true;
|
|
846
|
+
const p0 = params.length > 0 ? Number(params[0]) : NaN;
|
|
847
|
+
if (Number.isFinite(p0)) {
|
|
848
|
+
cutParam = Math.max(0, Math.min(255, Math.round(p0)));
|
|
849
|
+
break;
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
if (cutParam === undefined && params && params.length > 3) {
|
|
853
|
+
const p3 = Number(params[3]);
|
|
854
|
+
if (Number.isFinite(p3))
|
|
855
|
+
cutParam = Math.max(0, Math.min(255, Math.round(p3)));
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
const targetRow = i + sustainCount; // last sustain or same note row
|
|
860
|
+
// Check if this note is followed only by rests until the next pattern boundary
|
|
861
|
+
// (to prevent note bleed across pattern loops)
|
|
862
|
+
let needsAutoCut = false;
|
|
863
|
+
if (!hasExplicitCut) {
|
|
864
|
+
const patternBoundary = Math.ceil((targetRow + 1) / PATTERN_ROWS) * PATTERN_ROWS;
|
|
865
|
+
let hasNonRestAfter = false;
|
|
866
|
+
for (let j = targetRow + 1; j < patternBoundary && j < events.length; j++) {
|
|
867
|
+
const laterEvent = events[j];
|
|
868
|
+
if (laterEvent && laterEvent.type !== 'rest') {
|
|
869
|
+
hasNonRestAfter = true;
|
|
870
|
+
break;
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// If no non-rest events until pattern boundary OR end of channel, add auto-cut to prevent bleed
|
|
874
|
+
// Also add auto-cut if this is the very last event in the channel
|
|
875
|
+
if (!hasNonRestAfter) {
|
|
876
|
+
needsAutoCut = true;
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
// Add to endCutRows/cutParamMap if explicit cut or auto-cut needed
|
|
880
|
+
if (hasExplicitCut || needsAutoCut) {
|
|
881
|
+
endCutRows.add(targetRow);
|
|
882
|
+
if (typeof cutParam === 'undefined' || cutParam === null)
|
|
883
|
+
cutParam = 0;
|
|
884
|
+
cutParamMap.set(targetRow, cutParam);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
catch (e) { }
|
|
888
|
+
// Check for uge_transpose in instrument properties
|
|
889
|
+
const inst = noteEvent.instrument ? instruments[noteEvent.instrument] : undefined;
|
|
890
|
+
let ugeTranspose = inst?.uge_transpose ? parseInt(inst.uge_transpose, 10) : 0;
|
|
891
|
+
// For noise channels, NO automatic transpose by default
|
|
892
|
+
// User should write notes in the actual range they want (C2-C8)
|
|
893
|
+
// which will map to hUGETracker indices 0-72 (C-3 to C-9 in tracker notation)
|
|
894
|
+
// If a custom transpose is needed, use uge_transpose in the instrument
|
|
895
|
+
if (channelType === GBChannel.NOISE && !inst?.uge_transpose) {
|
|
896
|
+
ugeTranspose = 0; // No automatic transpose for noise
|
|
897
|
+
}
|
|
898
|
+
const midiNote = noteNameToMidiNote(noteEvent.token, ugeTranspose);
|
|
899
|
+
const instIndex = resolveInstrumentIndex(noteEvent.instrument, noteEvent.instProps, instruments, channelType, dutyInsts, waveInsts, noiseInsts);
|
|
900
|
+
// Determine per-note pan enum (L/C/R)
|
|
901
|
+
let panEnum = currentPan;
|
|
902
|
+
// Inline note pan -> instrument GB pan -> instrument pan
|
|
903
|
+
const notePan = convertPanToEnum(noteEvent.pan, strictGb, 'inline');
|
|
904
|
+
if (notePan) {
|
|
905
|
+
panEnum = notePan;
|
|
906
|
+
}
|
|
907
|
+
else if (inst) {
|
|
908
|
+
const gbP = convertPanToEnum(inst['gb:pan'], strictGb, 'instrument');
|
|
909
|
+
if (gbP)
|
|
910
|
+
panEnum = gbP;
|
|
911
|
+
else {
|
|
912
|
+
const instP = convertPanToEnum(inst['pan'], strictGb, 'instrument');
|
|
913
|
+
if (instP)
|
|
914
|
+
panEnum = instP;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
// Update currentPan for subsequent sustain/rest rows
|
|
918
|
+
currentPan = panEnum;
|
|
919
|
+
// Base cell
|
|
920
|
+
cell = {
|
|
921
|
+
note: midiNote,
|
|
922
|
+
instrument: instIndex, // UGE instruments are 0-based
|
|
923
|
+
effectCode: 0,
|
|
924
|
+
effectParam: 0,
|
|
925
|
+
pan: panEnum,
|
|
926
|
+
};
|
|
927
|
+
// Parse all effects using the extensible handler system
|
|
928
|
+
activeVib = null;
|
|
929
|
+
activeArp = null;
|
|
930
|
+
const effectRequests = [];
|
|
931
|
+
if (Array.isArray(noteEvent.effects) && noteEvent.effects.length > 0) {
|
|
932
|
+
for (const fx of noteEvent.effects) {
|
|
933
|
+
if (!fx)
|
|
934
|
+
continue;
|
|
935
|
+
// Track retrigger effects for warning
|
|
936
|
+
const fxName = (fx.type || fx).toString().toLowerCase();
|
|
937
|
+
if (fxName === 'retrig') {
|
|
938
|
+
hasRetrigEffects = true;
|
|
939
|
+
continue; // Skip retrigger - not supported in UGE
|
|
940
|
+
}
|
|
941
|
+
if (fxName === 'echo') {
|
|
942
|
+
hasEchoEffects = true;
|
|
943
|
+
continue; // Skip echo - not supported in UGE
|
|
944
|
+
}
|
|
945
|
+
// Skip portamento on the first note (nothing to slide from)
|
|
946
|
+
if (fxName === 'port' && !hasSeenNote) {
|
|
947
|
+
continue; // Don't add portamento effect to first note
|
|
948
|
+
}
|
|
949
|
+
// Try each handler to parse this effect
|
|
950
|
+
for (const handler of EFFECT_HANDLERS) {
|
|
951
|
+
const request = handler.parse(fx, noteEvent, sustainCount, tickSeconds);
|
|
952
|
+
if (request) {
|
|
953
|
+
// Warn if arpeggio has more than 2 offsets (UGE 0xy only supports 2)
|
|
954
|
+
if (request.type === 'arp' && request._extraOffsets) {
|
|
955
|
+
const extraOffsets = request._extraOffsets;
|
|
956
|
+
const totalOffsets = 2 + extraOffsets.length; // 2 encoded + extras
|
|
957
|
+
warn('export', `Arpeggio with ${totalOffsets} offsets detected. hUGETracker 0xy only supports 2 offsets. Extra offsets [${extraOffsets.join(', ')}] will be ignored in UGE export.`);
|
|
958
|
+
}
|
|
959
|
+
effectRequests.push(request);
|
|
960
|
+
break; // Each effect handled by only one handler
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
// Mark that we've seen a note
|
|
966
|
+
hasSeenNote = true;
|
|
967
|
+
// Resolve conflicts and apply the winning effect to note row
|
|
968
|
+
const winningEffect = resolveEffectConflict(effectRequests);
|
|
969
|
+
if (winningEffect && winningEffect.type !== 'cut') {
|
|
970
|
+
// For vibrato, apply to BOTH note row AND the next sustain row
|
|
971
|
+
if (winningEffect.type === 'vib') {
|
|
972
|
+
// Apply to note row
|
|
973
|
+
const handler = EFFECT_HANDLERS.find(h => h.type === winningEffect.type);
|
|
974
|
+
if (handler) {
|
|
975
|
+
handler.apply(cell, winningEffect);
|
|
976
|
+
}
|
|
977
|
+
// Also keep it active for the next sustain row
|
|
978
|
+
// remainingRows=1 means it will be applied to exactly one more row
|
|
979
|
+
activeVib = {
|
|
980
|
+
code: winningEffect.code,
|
|
981
|
+
param: winningEffect.param,
|
|
982
|
+
remainingRows: 1
|
|
983
|
+
};
|
|
984
|
+
desiredVibMap.set(i, 2); // vibrato appears on 2 rows total
|
|
985
|
+
}
|
|
986
|
+
else if (winningEffect.type === 'arp') {
|
|
987
|
+
// Apply arpeggio to note row AND all sustain rows (arpeggio lasts for full note duration)
|
|
988
|
+
const handler = EFFECT_HANDLERS.find(h => h.type === winningEffect.type);
|
|
989
|
+
if (handler) {
|
|
990
|
+
handler.apply(cell, winningEffect);
|
|
991
|
+
}
|
|
992
|
+
// Set activeArp to continue on all sustain rows
|
|
993
|
+
// sustainCount is the number of sustain events following this note
|
|
994
|
+
activeArp = {
|
|
995
|
+
code: winningEffect.code,
|
|
996
|
+
param: winningEffect.param,
|
|
997
|
+
remainingRows: sustainCount // Apply to ALL sustain rows
|
|
998
|
+
};
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
// Apply non-vibrato, non-arpeggio effects to note row (portamento, volslide, etc.)
|
|
1002
|
+
const handler = EFFECT_HANDLERS.find(h => h.type === winningEffect.type);
|
|
1003
|
+
if (handler) {
|
|
1004
|
+
handler.apply(cell, winningEffect);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
else if (event.type === 'named') {
|
|
1010
|
+
// Named instrument (e.g., percussion) - the token IS the instrument name
|
|
1011
|
+
const namedEvent = event; // NamedInstrumentEvent
|
|
1012
|
+
const instIndex = resolveInstrumentIndex(namedEvent.token, // Use token as the instrument name
|
|
1013
|
+
namedEvent.instProps, instruments, channelType, dutyInsts, waveInsts, noiseInsts);
|
|
1014
|
+
// For named events, derive pan from instrument defaults if present
|
|
1015
|
+
let namedPan = currentPan;
|
|
1016
|
+
const namedInst = namedEvent.token ? instruments[namedEvent.token] : undefined;
|
|
1017
|
+
if (namedInst) {
|
|
1018
|
+
const gbP = convertPanToEnum(namedInst['gb:pan'], strictGb, 'instrument');
|
|
1019
|
+
if (gbP)
|
|
1020
|
+
namedPan = gbP;
|
|
1021
|
+
else {
|
|
1022
|
+
const nP = convertPanToEnum(namedInst['pan'], strictGb, 'instrument');
|
|
1023
|
+
if (nP)
|
|
1024
|
+
namedPan = nP;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
currentPan = namedPan;
|
|
1028
|
+
// Use instrument's default note if specified, otherwise default to C5 (index 24)
|
|
1029
|
+
let noteValue = 24; // Default: hUGETracker index 24 (MIDI 60 - 36 = C5)
|
|
1030
|
+
if (namedEvent.defaultNote) {
|
|
1031
|
+
const parsedNote = noteNameToMidiNote(namedEvent.defaultNote, 0);
|
|
1032
|
+
if (parsedNote !== EMPTY_NOTE) {
|
|
1033
|
+
noteValue = parsedNote;
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
cell = {
|
|
1037
|
+
note: noteValue,
|
|
1038
|
+
instrument: instIndex || 0,
|
|
1039
|
+
effectCode: 0,
|
|
1040
|
+
effectParam: 0,
|
|
1041
|
+
pan: namedPan,
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
else {
|
|
1045
|
+
// Unknown event type - treat as sustain
|
|
1046
|
+
cell = {
|
|
1047
|
+
note: EMPTY_NOTE,
|
|
1048
|
+
instrument: 0,
|
|
1049
|
+
effectCode: 0,
|
|
1050
|
+
effectParam: 0,
|
|
1051
|
+
pan: currentPan,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
// If exporter computed a cutParam for this exact global row, write
|
|
1055
|
+
// an explicit Note Cut effect (ECx) on this cell so trackers display it.
|
|
1056
|
+
if (cutParamMap.has(i)) {
|
|
1057
|
+
const cp = cutParamMap.get(i) || 0;
|
|
1058
|
+
const nib = Math.max(0, Math.min(15, cp & 0xff));
|
|
1059
|
+
cell.effectCode = 0xE; // Extended effect group
|
|
1060
|
+
cell.effectParam = ((0 & 0xF) << 4) | (nib & 0xF); // E0x -> sub=0 (cut), param=nib
|
|
1061
|
+
cell.volume = 0; // also set explicit zero volume as a fallback
|
|
1062
|
+
}
|
|
1063
|
+
// If this row was marked as an explicit cut, and the cell is empty,
|
|
1064
|
+
// emit an explicit Note Cut effect (0xC) so trackers show the cut in
|
|
1065
|
+
// the effect column. Also keep volume=0 as a fallback for players
|
|
1066
|
+
// that prefer the volume field.
|
|
1067
|
+
if (endCutRows.has(i) && cell.note === EMPTY_NOTE) {
|
|
1068
|
+
cell.effectCode = 0xE; // Extended effect group
|
|
1069
|
+
cell.effectParam = ((0 & 0xF) << 4) | (0 & 0xF); // E00 = immediate cut
|
|
1070
|
+
cell.volume = 0; // fallback: explicit zero volume
|
|
1071
|
+
}
|
|
1072
|
+
// Update prevEventType for next iteration
|
|
1073
|
+
prevEventType = event && event.type ? event.type : null;
|
|
1074
|
+
currentPattern.push(cell);
|
|
1075
|
+
// When pattern reaches 64 rows, start a new one
|
|
1076
|
+
if (currentPattern.length >= PATTERN_ROWS) {
|
|
1077
|
+
patterns.push(currentPattern);
|
|
1078
|
+
currentPattern = [];
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
// Add final pattern if it has any rows
|
|
1082
|
+
if (currentPattern.length > 0) {
|
|
1083
|
+
// Pad to 64 rows. If padding extends past the last event, the global
|
|
1084
|
+
// event index for padded rows starts at `events.length` — honor
|
|
1085
|
+
// `endCutRows` for these padded rows so explicit cuts at song end are
|
|
1086
|
+
// emitted.
|
|
1087
|
+
const existing = currentPattern.length;
|
|
1088
|
+
const padCount = PATTERN_ROWS - existing;
|
|
1089
|
+
for (let j = 0; j < padCount; j++) {
|
|
1090
|
+
const globalRow = events.length + j; // global event index for this padded row
|
|
1091
|
+
const isCut = endCutRows.has(globalRow);
|
|
1092
|
+
const cell = {
|
|
1093
|
+
note: EMPTY_NOTE,
|
|
1094
|
+
instrument: -1, // No instrument on padding rows
|
|
1095
|
+
effectCode: 0,
|
|
1096
|
+
effectParam: 0,
|
|
1097
|
+
pan: 'C',
|
|
1098
|
+
};
|
|
1099
|
+
if (isCut) {
|
|
1100
|
+
cell.effectCode = 0xC;
|
|
1101
|
+
cell.effectParam = 0x00;
|
|
1102
|
+
cell.volume = 0;
|
|
1103
|
+
}
|
|
1104
|
+
currentPattern.push(cell);
|
|
1105
|
+
}
|
|
1106
|
+
patterns.push(currentPattern);
|
|
1107
|
+
}
|
|
1108
|
+
// If no patterns, create one empty pattern
|
|
1109
|
+
if (patterns.length === 0) {
|
|
1110
|
+
const emptyPattern = [];
|
|
1111
|
+
for (let i = 0; i < PATTERN_ROWS; i++) {
|
|
1112
|
+
emptyPattern.push({
|
|
1113
|
+
note: EMPTY_NOTE,
|
|
1114
|
+
instrument: -1, // No instrument on empty pattern rows
|
|
1115
|
+
effectCode: 0,
|
|
1116
|
+
effectParam: 0,
|
|
1117
|
+
pan: 'C',
|
|
1118
|
+
});
|
|
1119
|
+
}
|
|
1120
|
+
patterns.push(emptyPattern);
|
|
1121
|
+
}
|
|
1122
|
+
// Store retrigger and echo warning flags on the patterns array for caller to check
|
|
1123
|
+
patterns.__hasRetrigEffects = hasRetrigEffects;
|
|
1124
|
+
patterns.__hasEchoEffects = hasEchoEffects;
|
|
1125
|
+
return patterns;
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Helper: snap numeric pan value to GB enum
|
|
1129
|
+
*/
|
|
1130
|
+
function snapToGB(value) {
|
|
1131
|
+
if (value < -0.33)
|
|
1132
|
+
return 'L';
|
|
1133
|
+
if (value > 0.33)
|
|
1134
|
+
return 'R';
|
|
1135
|
+
return 'C';
|
|
1136
|
+
}
|
|
1137
|
+
function enumToNR51Bits(p, chIndex) {
|
|
1138
|
+
// Hardware-accurate NR51 layout (hUGETracker / Game Boy):
|
|
1139
|
+
// Pulse1 (ch 0): left=0x01, right=0x10
|
|
1140
|
+
// Pulse2 (ch 1): left=0x02, right=0x20
|
|
1141
|
+
// Wave (ch 2): left=0x04, right=0x40
|
|
1142
|
+
// Noise (ch 3): left=0x08, right=0x80
|
|
1143
|
+
const LEFT_BITS = [0x01, 0x02, 0x04, 0x08];
|
|
1144
|
+
const RIGHT_BITS = [0x10, 0x20, 0x40, 0x80];
|
|
1145
|
+
const leftBit = LEFT_BITS[chIndex] || 0;
|
|
1146
|
+
const rightBit = RIGHT_BITS[chIndex] || 0;
|
|
1147
|
+
if (p === 'L')
|
|
1148
|
+
return leftBit;
|
|
1149
|
+
if (p === 'R')
|
|
1150
|
+
return rightBit;
|
|
1151
|
+
return leftBit | rightBit;
|
|
1152
|
+
}
|
|
1153
|
+
export function convertPanToEnum(pan, strictGb, context = 'inline') {
|
|
1154
|
+
if (pan === undefined || pan === null)
|
|
1155
|
+
return undefined;
|
|
1156
|
+
if (typeof pan === 'object') {
|
|
1157
|
+
if (pan.enum) {
|
|
1158
|
+
const up = String(pan.enum).toUpperCase();
|
|
1159
|
+
if (up === 'L' || up === 'R' || up === 'C')
|
|
1160
|
+
return up;
|
|
1161
|
+
}
|
|
1162
|
+
if (typeof pan.value === 'number') {
|
|
1163
|
+
if (strictGb)
|
|
1164
|
+
throw new Error(`Numeric ${context === 'instrument' ? 'instrument' : 'inline'} pan not allowed in strict GB export`);
|
|
1165
|
+
return snapToGB(pan.value);
|
|
1166
|
+
}
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
if (typeof pan === 'number') {
|
|
1170
|
+
if (strictGb)
|
|
1171
|
+
throw new Error(`Numeric ${context === 'instrument' ? 'instrument' : 'inline'} pan not allowed in strict GB export`);
|
|
1172
|
+
return snapToGB(pan);
|
|
1173
|
+
}
|
|
1174
|
+
const s = String(pan);
|
|
1175
|
+
const up = s.toUpperCase();
|
|
1176
|
+
if (up === 'L' || up === 'R' || up === 'C')
|
|
1177
|
+
return up;
|
|
1178
|
+
const n = Number(s);
|
|
1179
|
+
if (!Number.isNaN(n)) {
|
|
1180
|
+
if (strictGb)
|
|
1181
|
+
throw new Error(`Numeric ${context === 'instrument' ? 'instrument' : 'inline'} pan not allowed in strict GB export`);
|
|
1182
|
+
return snapToGB(n);
|
|
1183
|
+
}
|
|
1184
|
+
return undefined;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Export a beatbax SongModel to UGE v6 binary format.
|
|
1188
|
+
*/
|
|
1189
|
+
export async function exportUGE(song, outputPath, opts = {}) {
|
|
1190
|
+
const w = new UGEWriter();
|
|
1191
|
+
const strictGb = opts && opts.strictGb === true;
|
|
1192
|
+
const verbose = opts && opts.verbose === true;
|
|
1193
|
+
if (verbose) {
|
|
1194
|
+
console.log(`Exporting to UGE v6 format: ${outputPath}`);
|
|
1195
|
+
}
|
|
1196
|
+
// ====== Header & NR51 metadata ======
|
|
1197
|
+
// Compute NR51 register from channel/instrument pans and encode into comment for compatibility
|
|
1198
|
+
function resolveChannelPan(chModel, insts) {
|
|
1199
|
+
if (chModel && chModel.defaultInstrument) {
|
|
1200
|
+
const inst = insts && insts[chModel.defaultInstrument];
|
|
1201
|
+
if (inst) {
|
|
1202
|
+
const gbPan = convertPanToEnum(inst['gb:pan'], strictGb, 'instrument');
|
|
1203
|
+
if (gbPan)
|
|
1204
|
+
return gbPan;
|
|
1205
|
+
const instPan = convertPanToEnum(inst['pan'], strictGb, 'instrument');
|
|
1206
|
+
if (instPan)
|
|
1207
|
+
return instPan;
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
const events = chModel && chModel.events ? chModel.events : [];
|
|
1211
|
+
for (const ev of events) {
|
|
1212
|
+
if (ev && ev.pan) {
|
|
1213
|
+
const evPan = convertPanToEnum(ev.pan, strictGb, 'inline');
|
|
1214
|
+
if (evPan)
|
|
1215
|
+
return evPan;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
return 'C';
|
|
1219
|
+
}
|
|
1220
|
+
// Enforce strict GB export rules early (throws if numeric pan is present and strict mode enabled)
|
|
1221
|
+
if (strictGb) {
|
|
1222
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1223
|
+
const chModel = song.channels && song.channels.find((c) => c.id === ch + 1);
|
|
1224
|
+
// resolveChannelPan will throw if numeric pan is present and strictGb=true
|
|
1225
|
+
resolveChannelPan(chModel, song.insts || {});
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
// Header write
|
|
1229
|
+
w.writeU32(UGE_VERSION);
|
|
1230
|
+
const title = song.metadata && song.metadata.name ? song.metadata.name : (song.pats ? 'BeatBax Song' : 'Untitled');
|
|
1231
|
+
const author = song.metadata && song.metadata.artist ? song.metadata.artist : 'BeatBax';
|
|
1232
|
+
const comment = song.metadata && song.metadata.description ? song.metadata.description : 'Exported from BeatBax live-coding engine';
|
|
1233
|
+
// Do not append NR51 debug metadata to the comment — remove NR51 metadata from UGE export
|
|
1234
|
+
w.writeShortString(title);
|
|
1235
|
+
w.writeShortString(author);
|
|
1236
|
+
w.writeShortString(comment);
|
|
1237
|
+
// ====== Build instrument lists ======
|
|
1238
|
+
const dutyInsts = [];
|
|
1239
|
+
const waveInsts = [];
|
|
1240
|
+
const noiseInsts = [];
|
|
1241
|
+
if (song.insts) {
|
|
1242
|
+
for (const [name, inst] of Object.entries(song.insts)) {
|
|
1243
|
+
const type = inst.type?.toLowerCase();
|
|
1244
|
+
if (type === 'pulse1' || type === 'pulse2' || type === 'duty') {
|
|
1245
|
+
if (dutyInsts.length < NUM_DUTY_INSTRUMENTS)
|
|
1246
|
+
dutyInsts.push(name);
|
|
1247
|
+
}
|
|
1248
|
+
else if (type === 'wave') {
|
|
1249
|
+
if (waveInsts.length < NUM_WAVE_INSTRUMENTS)
|
|
1250
|
+
waveInsts.push(name);
|
|
1251
|
+
}
|
|
1252
|
+
else if (type === 'noise') {
|
|
1253
|
+
if (noiseInsts.length < NUM_NOISE_INSTRUMENTS)
|
|
1254
|
+
noiseInsts.push(name);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
if (opts && opts.debug)
|
|
1259
|
+
console.log(`[DEBUG] Discovered instruments: duty=${dutyInsts.length} wave=${waveInsts.length} noise=${noiseInsts.length}`);
|
|
1260
|
+
if (opts && opts.debug)
|
|
1261
|
+
console.log(`[DEBUG] Wave instrument names: ${JSON.stringify(waveInsts)}`);
|
|
1262
|
+
if (verbose) {
|
|
1263
|
+
console.log('Processing instruments...');
|
|
1264
|
+
if (dutyInsts.length > 0 || waveInsts.length > 0 || noiseInsts.length > 0) {
|
|
1265
|
+
console.log(` Instruments exported:`);
|
|
1266
|
+
if (dutyInsts.length > 0)
|
|
1267
|
+
console.log(` - Duty: ${dutyInsts.length}/15 slots (${dutyInsts.join(', ')})`);
|
|
1268
|
+
if (waveInsts.length > 0)
|
|
1269
|
+
console.log(` - Wave: ${waveInsts.length}/15 slots (${waveInsts.join(', ')})`);
|
|
1270
|
+
if (noiseInsts.length > 0)
|
|
1271
|
+
console.log(` - Noise: ${noiseInsts.length}/15 slots (${noiseInsts.join(', ')})`);
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
// ====== Instruments Section ======
|
|
1275
|
+
// Write Duty instruments (15 slots)
|
|
1276
|
+
for (let i = 0; i < NUM_DUTY_INSTRUMENTS; i++) {
|
|
1277
|
+
if (i < dutyInsts.length) {
|
|
1278
|
+
const name = dutyInsts[i];
|
|
1279
|
+
const inst = song.insts[name];
|
|
1280
|
+
const dutyVal = parseFloat(inst.duty || '50');
|
|
1281
|
+
let dutyCycle = 2; // 50%
|
|
1282
|
+
if (dutyVal <= 12.5)
|
|
1283
|
+
dutyCycle = 0;
|
|
1284
|
+
else if (dutyVal <= 25)
|
|
1285
|
+
dutyCycle = 1;
|
|
1286
|
+
else if (dutyVal <= 50)
|
|
1287
|
+
dutyCycle = 2;
|
|
1288
|
+
else
|
|
1289
|
+
dutyCycle = 3;
|
|
1290
|
+
const env = parseEnvelope(inst.env);
|
|
1291
|
+
const initialVol = env.mode === 'gb' ? (env.initial ?? 15) : 15;
|
|
1292
|
+
// Map direction: 'flat' means no sweep (period=0), up=0, down=1
|
|
1293
|
+
let sweepDir = 1; // default to down
|
|
1294
|
+
let sweepChange = 0;
|
|
1295
|
+
if (env.mode === 'gb') {
|
|
1296
|
+
if (env.direction === 'flat') {
|
|
1297
|
+
sweepChange = 0; // No sweep change for flat
|
|
1298
|
+
}
|
|
1299
|
+
else {
|
|
1300
|
+
sweepDir = env.direction === 'up' ? 0 : 1;
|
|
1301
|
+
sweepChange = env.period ?? 0;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
const length = inst.length ? Number(inst.length) : 0;
|
|
1305
|
+
const lengthEnabled = inst.length ? true : false;
|
|
1306
|
+
const sweep = parseSweep(inst.sweep);
|
|
1307
|
+
const freqSweepTime = sweep ? sweep.time : 0;
|
|
1308
|
+
const freqSweepDir = sweep ? (sweep.direction === 'up' ? 0 : 1) : 0;
|
|
1309
|
+
const freqSweepShift = sweep ? sweep.shift : 0;
|
|
1310
|
+
writeDutyInstrument(w, name, dutyCycle, initialVol, sweepDir, sweepChange, lengthEnabled, length, freqSweepTime, freqSweepDir, freqSweepShift);
|
|
1311
|
+
}
|
|
1312
|
+
else {
|
|
1313
|
+
writeDutyInstrument(w, `DUTY_${i}`, 2, 15, 1, 0);
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
// Write Wave instruments (15 slots)
|
|
1317
|
+
for (let i = 0; i < NUM_WAVE_INSTRUMENTS; i++) {
|
|
1318
|
+
if (i < waveInsts.length) {
|
|
1319
|
+
const name = waveInsts[i];
|
|
1320
|
+
const inst = song.insts[name];
|
|
1321
|
+
// Wave index mapping: use the slot index i
|
|
1322
|
+
const length = inst.length ? Number(inst.length) : 0;
|
|
1323
|
+
const lengthEnabled = inst.length ? true : false;
|
|
1324
|
+
const ugeVolume = mapWaveVolumeToUGE(inst.volume ?? inst.vol ?? 100);
|
|
1325
|
+
if (opts && opts.debug)
|
|
1326
|
+
console.log(`[DEBUG] Wave instrument '${name}' -> volume (beatbax)=${inst.volume ?? inst.vol ?? 'undefined'} ugeValue=${ugeVolume}`);
|
|
1327
|
+
writeWaveInstrument(w, name, i, ugeVolume, lengthEnabled, length);
|
|
1328
|
+
}
|
|
1329
|
+
else {
|
|
1330
|
+
// Default placeholder: use default 100% mapping
|
|
1331
|
+
writeWaveInstrument(w, `WAVE_${i}`, 0, mapWaveVolumeToUGE(100));
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
// Write Noise instruments (15 slots)
|
|
1335
|
+
for (let i = 0; i < NUM_NOISE_INSTRUMENTS; i++) {
|
|
1336
|
+
if (i < noiseInsts.length) {
|
|
1337
|
+
const name = noiseInsts[i];
|
|
1338
|
+
const inst = song.insts[name];
|
|
1339
|
+
const env = parseEnvelope(inst.env);
|
|
1340
|
+
const initialVol = env.mode === 'gb' ? (env.initial ?? 15) : 15;
|
|
1341
|
+
// Map direction: 'flat' means no sweep (period=0), up=0, down=1
|
|
1342
|
+
let sweepDir = 1; // default to down
|
|
1343
|
+
let sweepChange = 0;
|
|
1344
|
+
if (env.mode === 'gb') {
|
|
1345
|
+
if (env.direction === 'flat') {
|
|
1346
|
+
sweepChange = 0; // No sweep change for flat
|
|
1347
|
+
}
|
|
1348
|
+
else {
|
|
1349
|
+
sweepDir = env.direction === 'up' ? 0 : 1;
|
|
1350
|
+
sweepChange = env.period ?? 0;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
// width parameter: 7=7-bit mode, 15=15-bit mode (default)
|
|
1354
|
+
const width = inst.width ? Number(inst.width) : 15;
|
|
1355
|
+
const noiseMode = width === 7 ? 1 : 0; // 0=15-bit, 1=7-bit
|
|
1356
|
+
const length = inst.length ? Number(inst.length) : 0;
|
|
1357
|
+
const lengthEnabled = inst.length ? true : false;
|
|
1358
|
+
writeNoiseInstrument(w, name, initialVol, sweepDir, sweepChange, noiseMode, lengthEnabled, length);
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
writeNoiseInstrument(w, `NOISE_${i}`);
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
// ====== Wavetables Section ======
|
|
1365
|
+
const wavetables = [];
|
|
1366
|
+
for (let i = 0; i < NUM_WAVETABLES; i++) {
|
|
1367
|
+
const table = new Array(WAVETABLE_SIZE).fill(0);
|
|
1368
|
+
if (i < waveInsts.length) {
|
|
1369
|
+
const name = waveInsts[i];
|
|
1370
|
+
const inst = song.insts[name];
|
|
1371
|
+
// Parse wave data (can be string or array)
|
|
1372
|
+
let waveData;
|
|
1373
|
+
if (inst.wave) {
|
|
1374
|
+
if (Array.isArray(inst.wave)) {
|
|
1375
|
+
waveData = inst.wave;
|
|
1376
|
+
}
|
|
1377
|
+
else if (typeof inst.wave === 'string') {
|
|
1378
|
+
// Parse string like "[0,3,6,9,12,9,6,3,0,3,6,9,12,9,6,3]"
|
|
1379
|
+
try {
|
|
1380
|
+
waveData = JSON.parse(inst.wave);
|
|
1381
|
+
}
|
|
1382
|
+
catch (e) {
|
|
1383
|
+
warn('export', `Failed to parse wave data for ${name}: ${inst.wave}`);
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
if (waveData && Array.isArray(waveData)) {
|
|
1388
|
+
for (let n = 0; n < Math.min(WAVETABLE_SIZE, waveData.length); n++) {
|
|
1389
|
+
table[n] = Math.max(0, Math.min(15, waveData[n]));
|
|
1390
|
+
}
|
|
1391
|
+
// If 16 entries, repeat to fill 32
|
|
1392
|
+
if (waveData.length === 16) {
|
|
1393
|
+
for (let n = 0; n < 16; n++) {
|
|
1394
|
+
table[n + 16] = table[n];
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
1399
|
+
wavetables.push(table);
|
|
1400
|
+
}
|
|
1401
|
+
// Write wavetable data (16 tables × 32 nibbles)
|
|
1402
|
+
for (let t = 0; t < NUM_WAVETABLES; t++) {
|
|
1403
|
+
for (let n = 0; n < WAVETABLE_SIZE; n++) {
|
|
1404
|
+
w.writeU8(wavetables[t][n]);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
// ====== Build patterns per channel ======
|
|
1408
|
+
const channelPatterns = [];
|
|
1409
|
+
if (verbose) {
|
|
1410
|
+
console.log('Building patterns for 4 channels...');
|
|
1411
|
+
}
|
|
1412
|
+
// Shared map of desired vibrato durations (globalRow -> rows)
|
|
1413
|
+
const desiredVibMap = new Map();
|
|
1414
|
+
let hasRetrigEffectsInSong = false; // Track if any channel has retrigger effects
|
|
1415
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1416
|
+
// Find channel by ID (1-4)
|
|
1417
|
+
const chModel = song.channels && song.channels.find(c => c.id === ch + 1);
|
|
1418
|
+
const chEvents = (chModel && chModel.events) || [];
|
|
1419
|
+
if (opts && opts.debug)
|
|
1420
|
+
console.log(`[DEBUG] Channel ${ch + 1} has ${chEvents.length} events`);
|
|
1421
|
+
// share `desiredVibMap` across channels so later passes can inspect desired vib rows
|
|
1422
|
+
const patterns = eventsToPatterns(chEvents, song.insts || {}, ch, dutyInsts, waveInsts, noiseInsts, strictGb, song.bpm, desiredVibMap);
|
|
1423
|
+
channelPatterns.push(patterns);
|
|
1424
|
+
// Check if this channel has retrigger effects
|
|
1425
|
+
if (patterns.__hasRetrigEffects) {
|
|
1426
|
+
hasRetrigEffectsInSong = true;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
// Check for echo effects
|
|
1430
|
+
let hasEchoEffectsInSong = false;
|
|
1431
|
+
for (const patterns of channelPatterns) {
|
|
1432
|
+
if (patterns.__hasEchoEffects) {
|
|
1433
|
+
hasEchoEffectsInSong = true;
|
|
1434
|
+
break;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
// Emit warnings if retrigger or echo effects were found
|
|
1438
|
+
if (hasRetrigEffectsInSong) {
|
|
1439
|
+
warn('export', 'Retrigger effects detected in song but cannot be exported to UGE (hUGETracker has no native retrigger effect). Retrigger effects will be lost. Use --browser flag for retrigger support.');
|
|
1440
|
+
}
|
|
1441
|
+
if (hasEchoEffectsInSong) {
|
|
1442
|
+
warn('export', 'Echo/delay effects detected in song but cannot be exported to UGE (hUGETracker has no native echo effect). Echo effects will be lost. Use --browser flag for echo support.');
|
|
1443
|
+
}
|
|
1444
|
+
// ====== Unified Post-Processing Pass ======
|
|
1445
|
+
// Single pass to handle: explicit note cuts, vibrato duration enforcement, and effect conflicts
|
|
1446
|
+
// Track statistics for verbose output
|
|
1447
|
+
let cutCount = 0;
|
|
1448
|
+
let vibCount = 0;
|
|
1449
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1450
|
+
const chModel = song.channels && song.channels.find(c => c.id === ch + 1);
|
|
1451
|
+
const chEvents = (chModel && chModel.events) || [];
|
|
1452
|
+
const patterns = channelPatterns[ch] || [];
|
|
1453
|
+
for (let i = 0; i < chEvents.length; i++) {
|
|
1454
|
+
const ev = chEvents[i];
|
|
1455
|
+
if (!ev || ev.type !== 'note')
|
|
1456
|
+
continue;
|
|
1457
|
+
const noteEvent = ev;
|
|
1458
|
+
// Count sustain rows following this note
|
|
1459
|
+
let sustainCount = 0;
|
|
1460
|
+
for (let k = i + 1; k < chEvents.length; k++) {
|
|
1461
|
+
const ne = chEvents[k];
|
|
1462
|
+
if (ne && ne.type === 'sustain')
|
|
1463
|
+
sustainCount++;
|
|
1464
|
+
else
|
|
1465
|
+
break;
|
|
1466
|
+
}
|
|
1467
|
+
const lastSustainRow = i + sustainCount;
|
|
1468
|
+
// 1. Apply explicit note cut effects ONLY if the note has a cut effect
|
|
1469
|
+
// (Implicit cuts are handled during pattern building when a rest follows a note)
|
|
1470
|
+
const cutEffect = noteEvent.effects?.find((fx) => {
|
|
1471
|
+
const name = (fx.type || fx).toString().toLowerCase();
|
|
1472
|
+
return name === 'cut';
|
|
1473
|
+
});
|
|
1474
|
+
if (cutEffect) {
|
|
1475
|
+
const request = NoteCutHandler.parse(cutEffect, noteEvent, sustainCount, 0);
|
|
1476
|
+
if (request) {
|
|
1477
|
+
const patIdx = Math.floor(lastSustainRow / PATTERN_ROWS);
|
|
1478
|
+
const rowIdx = lastSustainRow % PATTERN_ROWS;
|
|
1479
|
+
if (patterns[patIdx] && patterns[patIdx][rowIdx]) {
|
|
1480
|
+
NoteCutHandler.apply(patterns[patIdx][rowIdx], request);
|
|
1481
|
+
cutCount++;
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
// 2. Enforce vibrato duration (trim any effects beyond requested duration)
|
|
1486
|
+
const desiredDuration = desiredVibMap.get(i);
|
|
1487
|
+
if (typeof desiredDuration === 'number' && desiredDuration > 0) {
|
|
1488
|
+
vibCount++;
|
|
1489
|
+
// Last row that should have vibrato is i + desiredDuration - 1
|
|
1490
|
+
// Clear any vibrato effects beyond the desired duration
|
|
1491
|
+
for (let rowOffset = desiredDuration; rowOffset <= sustainCount; rowOffset++) {
|
|
1492
|
+
const globalRow = i + rowOffset;
|
|
1493
|
+
const patIdx = Math.floor(globalRow / PATTERN_ROWS);
|
|
1494
|
+
const rowIdx = globalRow % PATTERN_ROWS;
|
|
1495
|
+
if (patterns[patIdx] && patterns[patIdx][rowIdx] && patterns[patIdx][rowIdx].effectCode === 4) {
|
|
1496
|
+
patterns[patIdx][rowIdx].effectCode = 0;
|
|
1497
|
+
patterns[patIdx][rowIdx].effectParam = 0;
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
if (verbose) {
|
|
1504
|
+
console.log('Applying effects and post-processing...');
|
|
1505
|
+
// Pattern statistics
|
|
1506
|
+
let totalRows = 0;
|
|
1507
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1508
|
+
totalRows += channelPatterns[ch].reduce((sum, pat) => sum + pat.length, 0);
|
|
1509
|
+
}
|
|
1510
|
+
console.log(' Pattern structure:');
|
|
1511
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1512
|
+
const patterns = channelPatterns[ch];
|
|
1513
|
+
const rowCount = patterns.reduce((sum, pat) => sum + pat.length, 0);
|
|
1514
|
+
console.log(` - Channel ${ch + 1}: ${patterns.length} pattern${patterns.length !== 1 ? 's' : ''} (${rowCount} rows total)`);
|
|
1515
|
+
}
|
|
1516
|
+
// Effect statistics
|
|
1517
|
+
if (vibCount > 0 || cutCount > 0) {
|
|
1518
|
+
console.log(' Effects applied:');
|
|
1519
|
+
if (vibCount > 0)
|
|
1520
|
+
console.log(` - Vibrato: ${vibCount} note${vibCount !== 1 ? 's' : ''}`);
|
|
1521
|
+
if (cutCount > 0)
|
|
1522
|
+
console.log(` - Note cuts: ${cutCount} occurrence${cutCount !== 1 ? 's' : ''}`);
|
|
1523
|
+
}
|
|
1524
|
+
}
|
|
1525
|
+
// Inject global per-row NR51 panning effects (write a single 8xx on channel 1 when value changes)
|
|
1526
|
+
// Create a blank pattern for missing channels/patterns
|
|
1527
|
+
const blankPatternWithPan = [];
|
|
1528
|
+
for (let i = 0; i < PATTERN_ROWS; i++) {
|
|
1529
|
+
blankPatternWithPan.push({ note: EMPTY_NOTE, instrument: 0, effectCode: 0, effectParam: 0, pan: 'C' });
|
|
1530
|
+
}
|
|
1531
|
+
// Compute max order length across channels (local variable)
|
|
1532
|
+
const orderLen = Math.max(1, Math.max(...channelPatterns.map(p => p.length)));
|
|
1533
|
+
// Keep NR51 state across orders so we don't re-emit the same mix repeatedly
|
|
1534
|
+
let lastNr51 = null;
|
|
1535
|
+
// Track which global rows we wrote NR51 to and whether the pan came from
|
|
1536
|
+
// an explicit source in the `.bax` (instrument or inline). Key = globalRow
|
|
1537
|
+
// (orderIdx*PATTERN_ROWS + row)
|
|
1538
|
+
const nr51Writes = new Map();
|
|
1539
|
+
for (let orderIdx = 0; orderIdx < orderLen; orderIdx++) {
|
|
1540
|
+
for (let row = 0; row < PATTERN_ROWS; row++) {
|
|
1541
|
+
let nr51Value = 0;
|
|
1542
|
+
let hasNoteOn = false;
|
|
1543
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1544
|
+
const patterns = channelPatterns[ch];
|
|
1545
|
+
const pat = (orderIdx < patterns.length) ? patterns[orderIdx] : blankPatternWithPan;
|
|
1546
|
+
const cell = pat[row];
|
|
1547
|
+
const p = (cell && cell.pan) ? cell.pan : 'C';
|
|
1548
|
+
nr51Value |= enumToNR51Bits(p, ch);
|
|
1549
|
+
// Note-on detection: a cell with a note != EMPTY_NOTE indicates a note trigger
|
|
1550
|
+
if (cell && typeof cell.note === 'number' && cell.note !== EMPTY_NOTE) {
|
|
1551
|
+
hasNoteOn = true;
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
// Only set panning when NR51 changes AND we have a note-on at this row (avoids forcing mix changes on rows with no note start).
|
|
1555
|
+
if (nr51Value !== lastNr51 && hasNoteOn) {
|
|
1556
|
+
// Skip emitting the default NR51 mix (0xFF) as a tracker write; it
|
|
1557
|
+
// often collides with per-note effects and is unnecessary to emit.
|
|
1558
|
+
if ((nr51Value & 0xFF) === 0xFF) {
|
|
1559
|
+
continue; // Skip default NR51 mix
|
|
1560
|
+
}
|
|
1561
|
+
// Attempt to write 8xx effect on channel 1 (index 0) for this row.
|
|
1562
|
+
// If the target cell already contains a non-zero effect (e.g. vib=4xy),
|
|
1563
|
+
// do not overwrite it — prefer preserving per-note effects. Only
|
|
1564
|
+
// update `lastNr51` when we actually write the 8xx effect.
|
|
1565
|
+
// Skip writing NR51 if any channel already has a vib (4xy) on this row
|
|
1566
|
+
let anyVibOnRow = false;
|
|
1567
|
+
for (let chCheck = 0; chCheck < NUM_CHANNELS; chCheck++) {
|
|
1568
|
+
const pats = channelPatterns[chCheck] || [];
|
|
1569
|
+
const p = (orderIdx < pats.length) ? pats[orderIdx] : blankPatternWithPan;
|
|
1570
|
+
const c = p[row];
|
|
1571
|
+
if (c && c.effectCode === 4) {
|
|
1572
|
+
anyVibOnRow = true;
|
|
1573
|
+
break;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
if (anyVibOnRow) {
|
|
1577
|
+
continue; // Skip NR51 when vibrato already present
|
|
1578
|
+
}
|
|
1579
|
+
if (orderIdx >= channelPatterns[0].length) {
|
|
1580
|
+
// Ensure pattern exists for channel 1 up to orderIdx
|
|
1581
|
+
while (channelPatterns[0].length <= orderIdx) {
|
|
1582
|
+
const newPat = blankPatternWithPan.map(c => ({ ...c }));
|
|
1583
|
+
channelPatterns[0].push(newPat);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
const targetCell = channelPatterns[0][orderIdx][row];
|
|
1587
|
+
// Determine whether this NR51 change is driven by any explicit pan
|
|
1588
|
+
// present in the source `.bax` for the channels at this global row.
|
|
1589
|
+
const globalRow = orderIdx * PATTERN_ROWS + row;
|
|
1590
|
+
let anyExplicit = false;
|
|
1591
|
+
for (let ch2 = 0; ch2 < NUM_CHANNELS; ch2++) {
|
|
1592
|
+
const chModel = song.channels && song.channels.find((c) => c.id === ch2 + 1);
|
|
1593
|
+
if (!chModel)
|
|
1594
|
+
continue;
|
|
1595
|
+
const ev = chModel.events && chModel.events[globalRow];
|
|
1596
|
+
if (ev && ev.pan) {
|
|
1597
|
+
anyExplicit = true;
|
|
1598
|
+
break;
|
|
1599
|
+
}
|
|
1600
|
+
// instrument-level pan defaults are explicit if present on the instrument
|
|
1601
|
+
if (chModel.defaultInstrument) {
|
|
1602
|
+
const inst = song.insts && song.insts[chModel.defaultInstrument];
|
|
1603
|
+
if (inst && (inst['gb:pan'] || inst['pan'])) {
|
|
1604
|
+
anyExplicit = true;
|
|
1605
|
+
break;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
if (targetCell && (!targetCell.effectCode || targetCell.effectCode === 0)) {
|
|
1610
|
+
targetCell.effectCode = 8;
|
|
1611
|
+
targetCell.effectParam = nr51Value & 0xFF;
|
|
1612
|
+
lastNr51 = nr51Value;
|
|
1613
|
+
nr51Writes.set(globalRow, { value: nr51Value & 0xFF, explicit: anyExplicit });
|
|
1614
|
+
}
|
|
1615
|
+
else {
|
|
1616
|
+
if (opts && opts.debug) {
|
|
1617
|
+
try {
|
|
1618
|
+
console.log('[DEBUG] Skipping NR51 write due to existing effect on ch1 row', { orderIdx, row, existingEffect: targetCell && targetCell.effectCode });
|
|
1619
|
+
}
|
|
1620
|
+
catch (e) { }
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
// Debug: dump NR51 writes map so we can inspect which rows were written
|
|
1627
|
+
if (opts && opts.debug) {
|
|
1628
|
+
try {
|
|
1629
|
+
const rows = Array.from(nr51Writes.entries()).map(([k, v]) => ({ globalRow: k, value: v.value, explicit: v.explicit }));
|
|
1630
|
+
console.log('[DEBUG] NR51 writes:', JSON.stringify(rows, null, 2));
|
|
1631
|
+
}
|
|
1632
|
+
catch (e) { }
|
|
1633
|
+
}
|
|
1634
|
+
// ====== Song Patterns Section ======
|
|
1635
|
+
// Calculate ticks per row from BPM
|
|
1636
|
+
// hUGETracker uses a Game Boy timer-based system where lower ticks = faster tempo
|
|
1637
|
+
// Formula: BPM = 896 / ticksPerRow (derived from hUGETracker behavior)
|
|
1638
|
+
// Examples: 4 ticks/row ≈ 224 BPM, 7 ticks/row ≈ 128 BPM, 8 ticks/row ≈ 112 BPM
|
|
1639
|
+
// Note: Due to integer tick constraints, exact BPM matching is not always possible
|
|
1640
|
+
// Default: 128 BPM (7 ticks/row) provides exact timing alignment
|
|
1641
|
+
const bpm = (song && typeof song.bpm === 'number') ? song.bpm : 128;
|
|
1642
|
+
const ticksPerRow = Math.max(1, Math.round(896 / bpm));
|
|
1643
|
+
if (verbose) {
|
|
1644
|
+
const actualBpm = Math.round(896 / ticksPerRow);
|
|
1645
|
+
console.log(` Tempo: ${bpm} BPM (${ticksPerRow} ticks/row in UGE${actualBpm !== bpm ? `, actual: ~${actualBpm} BPM` : ''})`);
|
|
1646
|
+
}
|
|
1647
|
+
w.writeU32(ticksPerRow); // Initial ticks per row
|
|
1648
|
+
w.writeBool(false); // Timer based tempo enabled (v6)
|
|
1649
|
+
w.writeU32(0); // Timer based tempo divider (v6)
|
|
1650
|
+
// Count total patterns across all channels
|
|
1651
|
+
const allPatterns = [];
|
|
1652
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1653
|
+
const patterns = channelPatterns[ch];
|
|
1654
|
+
for (let pi = 0; pi < patterns.length; pi++) {
|
|
1655
|
+
allPatterns.push({
|
|
1656
|
+
channelIndex: ch,
|
|
1657
|
+
patternIndex: pi,
|
|
1658
|
+
cells: patterns[pi],
|
|
1659
|
+
});
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// (debug forced E08 removed)
|
|
1663
|
+
// Add a blank pattern for padding shorter channels
|
|
1664
|
+
const blankPatternCells = [];
|
|
1665
|
+
for (let i = 0; i < PATTERN_ROWS; i++) {
|
|
1666
|
+
blankPatternCells.push({ note: EMPTY_NOTE, instrument: 0, effectCode: 0, effectParam: 0 });
|
|
1667
|
+
}
|
|
1668
|
+
const blankPatternIndex = allPatterns.length;
|
|
1669
|
+
allPatterns.push({ channelIndex: -1, patternIndex: -1, cells: blankPatternCells });
|
|
1670
|
+
// FINAL ENFORCEMENT PASS: ensure vibrato (4xy) appears for exactly the
|
|
1671
|
+
// requested number of rows (note + dr - 1) per-note. This operates on
|
|
1672
|
+
// `allPatterns` which are the final pattern buffers to be serialized.
|
|
1673
|
+
try {
|
|
1674
|
+
if (opts && opts.debug) {
|
|
1675
|
+
try {
|
|
1676
|
+
console.log('[DEBUG] finalEnforcement start', { nr51Writes: Array.from(nr51Writes.entries()), desiredVibMap: Array.from(desiredVibMap.entries()) });
|
|
1677
|
+
}
|
|
1678
|
+
catch (e) { }
|
|
1679
|
+
}
|
|
1680
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1681
|
+
const chModel = song.channels && song.channels.find(c => c.id === ch + 1);
|
|
1682
|
+
const chEvents = (chModel && chModel.events) || [];
|
|
1683
|
+
for (let i = 0; i < chEvents.length; i++) {
|
|
1684
|
+
const ev = chEvents[i];
|
|
1685
|
+
if (!ev || ev.type !== 'note')
|
|
1686
|
+
continue;
|
|
1687
|
+
const noteEvent = ev;
|
|
1688
|
+
// find vib effect on this note
|
|
1689
|
+
let vibFx = null;
|
|
1690
|
+
if (Array.isArray(noteEvent.effects)) {
|
|
1691
|
+
for (const fx of noteEvent.effects) {
|
|
1692
|
+
if (!fx)
|
|
1693
|
+
continue;
|
|
1694
|
+
if (String(fx.type || fx).toLowerCase() === 'vib') {
|
|
1695
|
+
vibFx = fx;
|
|
1696
|
+
break;
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
if (!vibFx)
|
|
1701
|
+
continue; // parse requested duration (rows) from positional param, paramsStr, or durationSec
|
|
1702
|
+
let dr = undefined;
|
|
1703
|
+
const params = vibFx.params || [];
|
|
1704
|
+
if (params && params.length > 3 && Number.isFinite(Number(params[3]))) {
|
|
1705
|
+
dr = Number(params[3]);
|
|
1706
|
+
}
|
|
1707
|
+
else if (vibFx.paramsStr && typeof vibFx.paramsStr === 'string') {
|
|
1708
|
+
const parts = vibFx.paramsStr.split(',').map((s) => s.trim());
|
|
1709
|
+
if (parts.length > 3) {
|
|
1710
|
+
const p3 = Number(parts[3]);
|
|
1711
|
+
if (Number.isFinite(p3))
|
|
1712
|
+
dr = p3;
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
else if (vibFx.durationSec && Number.isFinite(vibFx.durationSec)) {
|
|
1716
|
+
const bpmForTicks = (typeof song.bpm === 'number' && Number.isFinite(song.bpm)) ? song.bpm : 128;
|
|
1717
|
+
const tickSeconds = (60 / bpmForTicks) / 4;
|
|
1718
|
+
dr = Math.max(0, Math.round(vibFx.durationSec / tickSeconds));
|
|
1719
|
+
}
|
|
1720
|
+
if (typeof dr === 'undefined' || dr <= 0)
|
|
1721
|
+
continue;
|
|
1722
|
+
// compute sustainCount
|
|
1723
|
+
let sustainCount = 0;
|
|
1724
|
+
for (let k = i + 1; k < chEvents.length; k++) {
|
|
1725
|
+
const ne = chEvents[k];
|
|
1726
|
+
if (ne && ne.type === 'sustain')
|
|
1727
|
+
sustainCount++;
|
|
1728
|
+
else
|
|
1729
|
+
break;
|
|
1730
|
+
}
|
|
1731
|
+
// determine vib param
|
|
1732
|
+
const depthRaw = params.length > 0 ? Number(params[0]) : 0;
|
|
1733
|
+
// Default to triangle (2) if waveform is missing, empty, or falsy
|
|
1734
|
+
const waveformParam = (params.length > 2 && params[2]) ? params[2] : 2;
|
|
1735
|
+
const waveformRaw = mapWaveformName(waveformParam); // 3rd param: waveform name or number
|
|
1736
|
+
const depth = Number.isFinite(depthRaw) ? Math.max(0, Math.min(15, Math.round(depthRaw))) : 0;
|
|
1737
|
+
const waveform = Number.isFinite(waveformRaw) ? Math.max(0, Math.min(15, Math.round(waveformRaw))) : 0;
|
|
1738
|
+
const param = encodeVibParam(waveform, depth);
|
|
1739
|
+
const globalStart = i;
|
|
1740
|
+
const allowedEnd = globalStart + dr - 1; // inclusive
|
|
1741
|
+
const actualEnd = globalStart + sustainCount; // inclusive
|
|
1742
|
+
// if NR51 explicit and non-default on note row, we will preserve it
|
|
1743
|
+
const notePatIdx = Math.floor(globalStart / PATTERN_ROWS);
|
|
1744
|
+
const noteRowIdx = globalStart % PATTERN_ROWS;
|
|
1745
|
+
const noteCell = (allPatterns.find(p => p.channelIndex === ch && p.patternIndex === notePatIdx) || { cells: [] }).cells[noteRowIdx];
|
|
1746
|
+
const nrInfo = nr51Writes.get(globalStart);
|
|
1747
|
+
const nrWasExplicit = nrInfo ? nrInfo.explicit : false;
|
|
1748
|
+
const nrValue = nrInfo ? nrInfo.value : null;
|
|
1749
|
+
const nrIsDefault = (nrValue === 0xFF);
|
|
1750
|
+
const preserveNoteNR51 = !!noteCell && noteCell.effectCode === 8 && nrWasExplicit && !nrIsDefault;
|
|
1751
|
+
if (opts && opts.debug) {
|
|
1752
|
+
try {
|
|
1753
|
+
console.log('[DEBUG] finalEnforce note check', { ch, globalStart, nrInfo, noteCellEffect: noteCell && noteCell.effectCode, preserveNoteNR51, allPatternsNoteCell: (allPatterns.find(p => p.channelIndex === ch && p.patternIndex === Math.floor(globalStart / PATTERN_ROWS)) || { cells: [] }).cells[globalStart % PATTERN_ROWS] });
|
|
1754
|
+
}
|
|
1755
|
+
catch (e) { }
|
|
1756
|
+
}
|
|
1757
|
+
// Updated behavior: vibrato appears on BOTH note row AND first sustain row
|
|
1758
|
+
// This provides immediate vibrato effect starting from the note trigger
|
|
1759
|
+
// No need to clear vibrato from note row anymore
|
|
1760
|
+
// enforce: for g in [globalStart .. min(allowedEnd, actualEnd)] set 4xy=param
|
|
1761
|
+
// Note: starting from globalStart (note row) instead of globalStart+1
|
|
1762
|
+
for (let g = globalStart; g <= Math.min(allowedEnd, actualEnd); g++) {
|
|
1763
|
+
const patIdx = Math.floor(g / PATTERN_ROWS);
|
|
1764
|
+
const rowIdx = g % PATTERN_ROWS;
|
|
1765
|
+
const patObj = allPatterns.find(p => p.channelIndex === ch && p.patternIndex === patIdx);
|
|
1766
|
+
if (!patObj)
|
|
1767
|
+
continue;
|
|
1768
|
+
const cell = patObj.cells[rowIdx];
|
|
1769
|
+
if (!cell)
|
|
1770
|
+
continue;
|
|
1771
|
+
// Only apply if no conflicting effect already set (e.g., panning on note row)
|
|
1772
|
+
if (cell.effectCode === 0 || cell.effectCode === 4) {
|
|
1773
|
+
cell.effectCode = 4;
|
|
1774
|
+
cell.effectParam = param & 0xff;
|
|
1775
|
+
}
|
|
1776
|
+
}
|
|
1777
|
+
// clear any 4xy beyond allowedEnd up to actualEnd
|
|
1778
|
+
for (let g = Math.max(allowedEnd + 1, globalStart + 1); g <= actualEnd; g++) {
|
|
1779
|
+
const patIdx = Math.floor(g / PATTERN_ROWS);
|
|
1780
|
+
const rowIdx = g % PATTERN_ROWS;
|
|
1781
|
+
const patObj = allPatterns.find(p => p.channelIndex === ch && p.patternIndex === patIdx);
|
|
1782
|
+
if (!patObj)
|
|
1783
|
+
continue;
|
|
1784
|
+
const cell = patObj.cells[rowIdx];
|
|
1785
|
+
if (!cell)
|
|
1786
|
+
continue;
|
|
1787
|
+
if (cell.effectCode === 4) {
|
|
1788
|
+
cell.effectCode = 0;
|
|
1789
|
+
cell.effectParam = 0;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
catch (e) {
|
|
1796
|
+
if (opts && opts.debug) {
|
|
1797
|
+
console.log('[DEBUG] final vib enforcement failed', e && e.stack ? e.stack : e);
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
// NOTE: vib->cut heuristic removed. We rely on per-note post-process above
|
|
1801
|
+
// that injects a single extended `E0x` at the computed end-of-note row
|
|
1802
|
+
// (patterns[patIdx][rowIdx]) so cuts are deterministic and occur only once.
|
|
1803
|
+
// Write number of patterns
|
|
1804
|
+
// Debug: inspect patterns before serialization
|
|
1805
|
+
if (opts && opts.debug) {
|
|
1806
|
+
try {
|
|
1807
|
+
console.log('[DEBUG] Dumping first 3 allPatterns entries (showing up to 16 rows each)');
|
|
1808
|
+
for (let pi = 0; pi < Math.min(3, allPatterns.length); pi++) {
|
|
1809
|
+
const p = allPatterns[pi];
|
|
1810
|
+
console.log(`[DEBUG] allPatterns[${pi}] -> channel=${p.channelIndex} pattern=${p.patternIndex} rows=${p.cells.length}`);
|
|
1811
|
+
for (let r = 0; r < Math.min(16, p.cells.length); r++) {
|
|
1812
|
+
const c = p.cells[r];
|
|
1813
|
+
console.log(` [DEBUG] pat${pi} row${r}: note=${c.note} inst=${c.instrument} vol=${typeof c.volume === 'number' ? c.volume : 'undef'} eff=0x${(c.effectCode || 0).toString(16)} effp=0x${(c.effectParam || 0).toString(16)}`);
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
catch (e) {
|
|
1818
|
+
console.log('[DEBUG] Pattern dump failed', e && e.stack ? e.stack : e);
|
|
1819
|
+
}
|
|
1820
|
+
// Debug: count EC (note-cut) occurrences before writing
|
|
1821
|
+
let ecCount = 0;
|
|
1822
|
+
for (const p of allPatterns) {
|
|
1823
|
+
for (let r = 0; r < p.cells.length; r++) {
|
|
1824
|
+
const c = p.cells[r];
|
|
1825
|
+
if (c && c.effectCode === 0xC)
|
|
1826
|
+
ecCount++;
|
|
1827
|
+
}
|
|
1828
|
+
}
|
|
1829
|
+
console.log('[DEBUG] Pre-serialize EC count:', ecCount);
|
|
1830
|
+
}
|
|
1831
|
+
w.writeU32(allPatterns.length);
|
|
1832
|
+
if (opts && opts.debug)
|
|
1833
|
+
console.log(`[DEBUG] Total patterns: ${allPatterns.length}`);
|
|
1834
|
+
if (opts && opts.debug)
|
|
1835
|
+
console.log(`[DEBUG] Pattern breakdown: Ch1=${channelPatterns[0].length}, Ch2=${channelPatterns[1].length}, Ch3=${channelPatterns[2].length}, Ch4=${channelPatterns[3].length}`);
|
|
1836
|
+
// Focused debug: show effect codes for first 16 rows of each channel for quick verification
|
|
1837
|
+
if (opts && opts.debug) {
|
|
1838
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1839
|
+
const patterns = channelPatterns[ch] || [];
|
|
1840
|
+
const pat = patterns[0] || new Array(PATTERN_ROWS).fill({ note: EMPTY_NOTE, instrument: 0, effectCode: 0, effectParam: 0 });
|
|
1841
|
+
const rowsToShow = Math.min(16, pat.length);
|
|
1842
|
+
const entries = [];
|
|
1843
|
+
for (let r = 0; r < rowsToShow; r++) {
|
|
1844
|
+
const c = pat[r];
|
|
1845
|
+
entries.push({ row: r, note: c.note, eff: `0x${(c.effectCode || 0).toString(16)}`, effp: `0x${(c.effectParam || 0).toString(16)}` });
|
|
1846
|
+
}
|
|
1847
|
+
console.log(`[DEBUG] Channel ${ch + 1} first ${rowsToShow} rows:`, JSON.stringify(entries));
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
// Write pattern data
|
|
1851
|
+
for (let i = 0; i < allPatterns.length; i++) {
|
|
1852
|
+
w.writeU32(i); // Pattern index
|
|
1853
|
+
const pattern = allPatterns[i];
|
|
1854
|
+
const ch = pattern.channelIndex;
|
|
1855
|
+
// Debug all channel patterns
|
|
1856
|
+
if (ch >= 0 && ch < NUM_CHANNELS) {
|
|
1857
|
+
const nonEmpty = pattern.cells.filter((c, idx) => c.note !== EMPTY_NOTE || c.instrument !== 0);
|
|
1858
|
+
if (opts && opts.debug)
|
|
1859
|
+
console.log(`[DEBUG] Pattern ${i} for channel ${ch + 1}: ${nonEmpty.length} non-empty cells out of ${pattern.cells.length} total rows`);
|
|
1860
|
+
if (nonEmpty.length <= 20) {
|
|
1861
|
+
if (opts && opts.debug)
|
|
1862
|
+
console.log(`[DEBUG] Non-empty cells:`, nonEmpty.map((c) => {
|
|
1863
|
+
const rowIdx = pattern.cells.indexOf(c);
|
|
1864
|
+
return `row${rowIdx}:note=${c.note},inst=${c.instrument}`;
|
|
1865
|
+
}).join('; '));
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
// Write cells with instrument index conversion
|
|
1869
|
+
for (let rowIdx = 0; rowIdx < pattern.cells.length; rowIdx++) {
|
|
1870
|
+
const cell = pattern.cells[rowIdx];
|
|
1871
|
+
if (opts && opts.debug && cell && cell.effectCode === 0xC) {
|
|
1872
|
+
console.log(`[DEBUG] Writing Note Cut in pattern ${i} ch ${ch} row ${rowIdx}`);
|
|
1873
|
+
}
|
|
1874
|
+
// Convert absolute instrument index to relative index based on channel type
|
|
1875
|
+
// UGE pattern cells use 1-based indices (1-15) within each instrument type
|
|
1876
|
+
// 0 means "no instrument" (use previous/default)
|
|
1877
|
+
let relativeInstrument = cell.instrument;
|
|
1878
|
+
// HUGETracker convention: when portamento or similar effects are present on a note,
|
|
1879
|
+
// the instrument field should be blank (0) to prevent re-triggering.
|
|
1880
|
+
// However, we DO want to set instruments on:
|
|
1881
|
+
// - Rows with effects but NO note (sustain rows with vibrato, etc.)
|
|
1882
|
+
// - First note of a song (needs instrument to trigger)
|
|
1883
|
+
// So only clear instrument when there's BOTH a note AND an effect code.
|
|
1884
|
+
if (cell.effectCode && cell.effectCode !== 0 && cell.note !== EMPTY_NOTE) {
|
|
1885
|
+
// Only clear instrument for specific effects that should not retrigger instruments
|
|
1886
|
+
// For now, only portamento (3) should clear the instrument on notes
|
|
1887
|
+
if (cell.effectCode === 3) {
|
|
1888
|
+
relativeInstrument = 0;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
if (ch >= 0 && ch < NUM_CHANNELS) {
|
|
1892
|
+
// If instrument is -1 (rest/sustain cell), use 0 to indicate no instrument change
|
|
1893
|
+
if (cell.instrument === -1) {
|
|
1894
|
+
relativeInstrument = 0;
|
|
1895
|
+
}
|
|
1896
|
+
else if (ch === 0 || ch === 1) {
|
|
1897
|
+
// Duty channels: absolute index 0-14 → relative 1-15
|
|
1898
|
+
if (cell.instrument >= 0 && cell.instrument < NUM_DUTY_INSTRUMENTS) {
|
|
1899
|
+
relativeInstrument = cell.instrument + 1;
|
|
1900
|
+
}
|
|
1901
|
+
else {
|
|
1902
|
+
relativeInstrument = 0; // No instrument
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
else if (ch === 2) {
|
|
1906
|
+
// Wave channel: absolute index 15-29 → relative 1-15
|
|
1907
|
+
if (cell.instrument >= NUM_DUTY_INSTRUMENTS && cell.instrument < NUM_DUTY_INSTRUMENTS + NUM_WAVE_INSTRUMENTS) {
|
|
1908
|
+
relativeInstrument = (cell.instrument - NUM_DUTY_INSTRUMENTS) + 1;
|
|
1909
|
+
}
|
|
1910
|
+
else {
|
|
1911
|
+
relativeInstrument = 0; // No instrument
|
|
1912
|
+
}
|
|
1913
|
+
}
|
|
1914
|
+
else if (ch === 3) {
|
|
1915
|
+
// Noise channel: absolute index 30-44 → relative 1-15
|
|
1916
|
+
if (cell.instrument >= NUM_DUTY_INSTRUMENTS + NUM_WAVE_INSTRUMENTS) {
|
|
1917
|
+
relativeInstrument = (cell.instrument - NUM_DUTY_INSTRUMENTS - NUM_WAVE_INSTRUMENTS) + 1;
|
|
1918
|
+
}
|
|
1919
|
+
else {
|
|
1920
|
+
relativeInstrument = 0; // No instrument
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
let volume = (typeof cell.volume === 'number') ? cell.volume : undefined;
|
|
1925
|
+
// If exporter previously set volume=0 to force a cut, ensure an explicit
|
|
1926
|
+
// Note Cut effect is written too so tracker UIs show the cut in the
|
|
1927
|
+
// effect column. Prefer explicit effect when volume==0.
|
|
1928
|
+
if (typeof volume === 'number' && volume === 0 && (!cell.effectCode || cell.effectCode === 0)) {
|
|
1929
|
+
// write as extended effect group E00 (immediate cut) when volume explicitly zero
|
|
1930
|
+
cell.effectCode = 0xE;
|
|
1931
|
+
cell.effectParam = ((0 & 0xF) << 4) | (0 & 0xF);
|
|
1932
|
+
}
|
|
1933
|
+
if (typeof volume === 'number') {
|
|
1934
|
+
w.writePatternCell(cell.note, relativeInstrument, cell.effectCode, cell.effectParam, volume);
|
|
1935
|
+
}
|
|
1936
|
+
else {
|
|
1937
|
+
w.writePatternCell(cell.note, relativeInstrument, cell.effectCode, cell.effectParam);
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
// ====== Song Orders Section ======
|
|
1942
|
+
// Find max order length across all channels
|
|
1943
|
+
let maxOrderLength = 0;
|
|
1944
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1945
|
+
maxOrderLength = Math.max(maxOrderLength, channelPatterns[ch].length);
|
|
1946
|
+
}
|
|
1947
|
+
// Ensure at least one order row exists
|
|
1948
|
+
maxOrderLength = Math.max(1, maxOrderLength);
|
|
1949
|
+
// Write order lists for 4 channels (Duty1, Duty2, Wave, Noise)
|
|
1950
|
+
for (let ch = 0; ch < NUM_CHANNELS; ch++) {
|
|
1951
|
+
const patterns = channelPatterns[ch];
|
|
1952
|
+
// Write order length + 1 (off-by-one per UGE spec)
|
|
1953
|
+
w.writeU32(maxOrderLength + 1);
|
|
1954
|
+
// Write order indices
|
|
1955
|
+
let patternIndexOffset = 0;
|
|
1956
|
+
for (let prevCh = 0; prevCh < ch; prevCh++) {
|
|
1957
|
+
patternIndexOffset += channelPatterns[prevCh].length;
|
|
1958
|
+
}
|
|
1959
|
+
//if (ch === 3) {
|
|
1960
|
+
// if (opts && opts.debug) console.log(`[DEBUG] Channel 4 order list: length=${maxOrderLength}, patternIndexOffset=${patternIndexOffset}`);
|
|
1961
|
+
//}
|
|
1962
|
+
for (let i = 0; i < maxOrderLength; i++) {
|
|
1963
|
+
if (i < patterns.length) {
|
|
1964
|
+
const patIdx = patternIndexOffset + i;
|
|
1965
|
+
//if (ch === 3) {
|
|
1966
|
+
// if (opts && opts.debug) console.log(`[DEBUG] Channel 4 order[${i}] = pattern ${patIdx}`);
|
|
1967
|
+
//}
|
|
1968
|
+
w.writeU32(patIdx);
|
|
1969
|
+
}
|
|
1970
|
+
else {
|
|
1971
|
+
// Pad with the blank pattern
|
|
1972
|
+
w.writeU32(blankPatternIndex);
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
// Write off-by-one filler
|
|
1976
|
+
w.writeU32(0);
|
|
1977
|
+
}
|
|
1978
|
+
// ====== Routines Section ======
|
|
1979
|
+
// Write 16 empty routine strings
|
|
1980
|
+
for (let i = 0; i < NUM_ROUTINES; i++) {
|
|
1981
|
+
w.writeString('');
|
|
1982
|
+
}
|
|
1983
|
+
// Write final binary
|
|
1984
|
+
const out = w.toBuffer();
|
|
1985
|
+
if (opts && opts.debug) {
|
|
1986
|
+
console.log(`[DEBUG] UGE: ${out.length} bytes written to ${outputPath}`);
|
|
1987
|
+
}
|
|
1988
|
+
writeFileSync(outputPath, out);
|
|
1989
|
+
if (verbose) {
|
|
1990
|
+
console.log('Writing binary output...');
|
|
1991
|
+
const sizeKB = (out.length / 1024).toFixed(2);
|
|
1992
|
+
console.log(`Export complete: ${out.length.toLocaleString()} bytes (${sizeKB} KB) written`);
|
|
1993
|
+
console.log(`File ready for hUGETracker v6`);
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
export default exportUGE;
|
|
1997
|
+
//# sourceMappingURL=ugeWriter.js.map
|