@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.
Files changed (174) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/audio/bufferedRenderer.d.ts +32 -0
  3. package/dist/audio/bufferedRenderer.d.ts.map +1 -0
  4. package/dist/audio/bufferedRenderer.js +309 -0
  5. package/dist/audio/bufferedRenderer.js.map +1 -0
  6. package/dist/audio/pcmRenderer.d.ts +25 -0
  7. package/dist/audio/pcmRenderer.d.ts.map +1 -0
  8. package/dist/audio/pcmRenderer.js +1288 -0
  9. package/dist/audio/pcmRenderer.js.map +1 -0
  10. package/dist/audio/playback.d.ts +57 -0
  11. package/dist/audio/playback.d.ts.map +1 -0
  12. package/dist/audio/playback.js +794 -0
  13. package/dist/audio/playback.js.map +1 -0
  14. package/dist/chips/gameboy/apu.d.ts +9 -0
  15. package/dist/chips/gameboy/apu.d.ts.map +1 -0
  16. package/dist/chips/gameboy/apu.js +27 -0
  17. package/dist/chips/gameboy/apu.js.map +1 -0
  18. package/dist/chips/gameboy/noise.d.ts +6 -0
  19. package/dist/chips/gameboy/noise.d.ts.map +1 -0
  20. package/dist/chips/gameboy/noise.js +155 -0
  21. package/dist/chips/gameboy/noise.js.map +1 -0
  22. package/dist/chips/gameboy/packet.d.ts +3 -0
  23. package/dist/chips/gameboy/packet.d.ts.map +1 -0
  24. package/dist/chips/gameboy/packet.js +3 -0
  25. package/dist/chips/gameboy/packet.js.map +1 -0
  26. package/dist/chips/gameboy/periodTables.d.ts +16 -0
  27. package/dist/chips/gameboy/periodTables.d.ts.map +1 -0
  28. package/dist/chips/gameboy/periodTables.js +29 -0
  29. package/dist/chips/gameboy/periodTables.js.map +1 -0
  30. package/dist/chips/gameboy/pulse.d.ts +12 -0
  31. package/dist/chips/gameboy/pulse.d.ts.map +1 -0
  32. package/dist/chips/gameboy/pulse.js +275 -0
  33. package/dist/chips/gameboy/pulse.js.map +1 -0
  34. package/dist/chips/gameboy/wave.d.ts +8 -0
  35. package/dist/chips/gameboy/wave.d.ts.map +1 -0
  36. package/dist/chips/gameboy/wave.js +146 -0
  37. package/dist/chips/gameboy/wave.js.map +1 -0
  38. package/dist/effects/index.d.ts +7 -0
  39. package/dist/effects/index.d.ts.map +1 -0
  40. package/dist/effects/index.js +1028 -0
  41. package/dist/effects/index.js.map +1 -0
  42. package/dist/effects/types.d.ts +8 -0
  43. package/dist/effects/types.d.ts.map +1 -0
  44. package/dist/effects/types.js +2 -0
  45. package/dist/effects/types.js.map +1 -0
  46. package/dist/expand/refExpander.d.ts +14 -0
  47. package/dist/expand/refExpander.d.ts.map +1 -0
  48. package/dist/expand/refExpander.js +130 -0
  49. package/dist/expand/refExpander.js.map +1 -0
  50. package/dist/export/index.d.ts +5 -0
  51. package/dist/export/index.d.ts.map +1 -0
  52. package/dist/export/index.js +5 -0
  53. package/dist/export/index.js.map +1 -0
  54. package/dist/export/jsonExport.d.ts +9 -0
  55. package/dist/export/jsonExport.d.ts.map +1 -0
  56. package/dist/export/jsonExport.js +184 -0
  57. package/dist/export/jsonExport.js.map +1 -0
  58. package/dist/export/midiExport.d.ts +9 -0
  59. package/dist/export/midiExport.d.ts.map +1 -0
  60. package/dist/export/midiExport.js +390 -0
  61. package/dist/export/midiExport.js.map +1 -0
  62. package/dist/export/ugeWriter.d.ts +33 -0
  63. package/dist/export/ugeWriter.d.ts.map +1 -0
  64. package/dist/export/ugeWriter.js +1997 -0
  65. package/dist/export/ugeWriter.js.map +1 -0
  66. package/dist/export/wavWriter.d.ts +24 -0
  67. package/dist/export/wavWriter.d.ts.map +1 -0
  68. package/dist/export/wavWriter.js +126 -0
  69. package/dist/export/wavWriter.js.map +1 -0
  70. package/dist/import/index.d.ts +5 -0
  71. package/dist/import/index.d.ts.map +1 -0
  72. package/dist/import/index.js +8 -0
  73. package/dist/import/index.js.map +1 -0
  74. package/dist/import/remoteCache.d.ts +60 -0
  75. package/dist/import/remoteCache.d.ts.map +1 -0
  76. package/dist/import/remoteCache.js +194 -0
  77. package/dist/import/remoteCache.js.map +1 -0
  78. package/dist/import/uge/uge.reader.d.ts +27 -0
  79. package/dist/import/uge/uge.reader.d.ts.map +1 -0
  80. package/dist/import/uge/uge.reader.js +545 -0
  81. package/dist/import/uge/uge.reader.js.map +1 -0
  82. package/dist/import/urlUtils.d.ts +50 -0
  83. package/dist/import/urlUtils.d.ts.map +1 -0
  84. package/dist/import/urlUtils.js +87 -0
  85. package/dist/import/urlUtils.js.map +1 -0
  86. package/dist/index.d.ts +18 -0
  87. package/dist/index.d.ts.map +1 -0
  88. package/dist/index.js +318 -0
  89. package/dist/index.js.map +1 -0
  90. package/dist/instruments/instrumentState.d.ts +15 -0
  91. package/dist/instruments/instrumentState.d.ts.map +1 -0
  92. package/dist/instruments/instrumentState.js +24 -0
  93. package/dist/instruments/instrumentState.js.map +1 -0
  94. package/dist/parser/ast.d.ts +22 -0
  95. package/dist/parser/ast.d.ts.map +1 -0
  96. package/dist/parser/ast.js +5 -0
  97. package/dist/parser/ast.js.map +1 -0
  98. package/dist/parser/index.d.ts +14 -0
  99. package/dist/parser/index.d.ts.map +1 -0
  100. package/dist/parser/index.js +10 -0
  101. package/dist/parser/index.js.map +1 -0
  102. package/dist/parser/peggy/generated/parser.d.ts +8 -0
  103. package/dist/parser/peggy/generated/parser.d.ts.map +1 -0
  104. package/dist/parser/peggy/generated/parser.js +6269 -0
  105. package/dist/parser/peggy/generated/parser.js.map +1 -0
  106. package/dist/parser/peggy/index.d.ts +3 -0
  107. package/dist/parser/peggy/index.d.ts.map +1 -0
  108. package/dist/parser/peggy/index.js +555 -0
  109. package/dist/parser/peggy/index.js.map +1 -0
  110. package/dist/parser/structured.d.ts +16 -0
  111. package/dist/parser/structured.d.ts.map +1 -0
  112. package/dist/parser/structured.js +232 -0
  113. package/dist/parser/structured.js.map +1 -0
  114. package/dist/parser/tokenizer.d.ts +12 -0
  115. package/dist/parser/tokenizer.d.ts.map +1 -0
  116. package/dist/parser/tokenizer.js +14 -0
  117. package/dist/parser/tokenizer.js.map +1 -0
  118. package/dist/patterns/expand.d.ts +32 -0
  119. package/dist/patterns/expand.d.ts.map +1 -0
  120. package/dist/patterns/expand.js +184 -0
  121. package/dist/patterns/expand.js.map +1 -0
  122. package/dist/patterns/index.d.ts +2 -0
  123. package/dist/patterns/index.d.ts.map +1 -0
  124. package/dist/patterns/index.js +2 -0
  125. package/dist/patterns/index.js.map +1 -0
  126. package/dist/scheduler/index.d.ts +6 -0
  127. package/dist/scheduler/index.d.ts.map +1 -0
  128. package/dist/scheduler/index.js +9 -0
  129. package/dist/scheduler/index.js.map +1 -0
  130. package/dist/scheduler/tickScheduler.d.ts +27 -0
  131. package/dist/scheduler/tickScheduler.d.ts.map +1 -0
  132. package/dist/scheduler/tickScheduler.js +74 -0
  133. package/dist/scheduler/tickScheduler.js.map +1 -0
  134. package/dist/sequences/expand.d.ts +14 -0
  135. package/dist/sequences/expand.d.ts.map +1 -0
  136. package/dist/sequences/expand.js +137 -0
  137. package/dist/sequences/expand.js.map +1 -0
  138. package/dist/song/importResolver.browser.d.ts +29 -0
  139. package/dist/song/importResolver.browser.d.ts.map +1 -0
  140. package/dist/song/importResolver.browser.js +168 -0
  141. package/dist/song/importResolver.browser.js.map +1 -0
  142. package/dist/song/importResolver.d.ts +40 -0
  143. package/dist/song/importResolver.d.ts.map +1 -0
  144. package/dist/song/importResolver.js +445 -0
  145. package/dist/song/importResolver.js.map +1 -0
  146. package/dist/song/index.browser.d.ts +9 -0
  147. package/dist/song/index.browser.d.ts.map +1 -0
  148. package/dist/song/index.browser.js +7 -0
  149. package/dist/song/index.browser.js.map +1 -0
  150. package/dist/song/index.d.ts +8 -0
  151. package/dist/song/index.d.ts.map +1 -0
  152. package/dist/song/index.js +6 -0
  153. package/dist/song/index.js.map +1 -0
  154. package/dist/song/resolver.browser.d.ts +50 -0
  155. package/dist/song/resolver.browser.d.ts.map +1 -0
  156. package/dist/song/resolver.browser.js +536 -0
  157. package/dist/song/resolver.browser.js.map +1 -0
  158. package/dist/song/resolver.d.ts +20 -0
  159. package/dist/song/resolver.d.ts.map +1 -0
  160. package/dist/song/resolver.js +540 -0
  161. package/dist/song/resolver.js.map +1 -0
  162. package/dist/song/songModel.d.ts +34 -0
  163. package/dist/song/songModel.d.ts.map +1 -0
  164. package/dist/song/songModel.js +2 -0
  165. package/dist/song/songModel.js.map +1 -0
  166. package/dist/tests/refExpander.test.d.ts +2 -0
  167. package/dist/tests/refExpander.test.d.ts.map +1 -0
  168. package/dist/tests/refExpander.test.js +37 -0
  169. package/dist/tests/refExpander.test.js.map +1 -0
  170. package/dist/util/diag.d.ts +16 -0
  171. package/dist/util/diag.d.ts.map +1 -0
  172. package/dist/util/diag.js +29 -0
  173. package/dist/util/diag.js.map +1 -0
  174. 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