@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,1288 @@
1
+ import { midiToFreq, noteNameToMidi } from '../chips/gameboy/apu.js';
2
+ import { parseSweep, parseEnvelope as parsePulseEnvelope } from '../chips/gameboy/pulse.js';
3
+ import { registerFromFreq, freqFromRegister } from '../chips/gameboy/periodTables.js';
4
+ // How many GB period-register units correspond to one tracker vibrato depth unit (y).
5
+ // hUGE appears to treat the tracker `y` as raw register offset units; tune this
6
+ // multiplier to convert tracker depth (0..15) into GB register steps.
7
+ const RENDER_REG_PER_TRACKER_UNIT = 1;
8
+ // Fraction of base register to scale tracker units by (empirical). This
9
+ // multiplies the tracker `y` value by a portion of the base period register
10
+ // to produce a register offset similar to how hUGEDriver maps depth.
11
+ const RENDER_REG_PER_TRACKER_BASE_FACTOR = 0.04;
12
+ // Exporter-side vibrato depth scaling (used when exporting to UGE).
13
+ // Keep in sync with packages/engine/src/export/ugeWriter.ts VIB_DEPTH_SCALE
14
+ const EXPORTER_VIB_DEPTH_SCALE = 4.0;
15
+ // Apply hUGEDriver-style period modification: add `offset` to the low 8 bits
16
+ // of the GB period register (NR13 low byte), clamp to valid 11-bit range.
17
+ function applyHugeDriverOffset(baseReg, offset) {
18
+ // hUGEDriver adds the low-nibble depth to the 16-bit period value.
19
+ const sum = baseReg + offset;
20
+ // Clamp to 11-bit GB period range (0..2047)
21
+ return Math.max(0, Math.min(2047, Math.round(sum)));
22
+ }
23
+ /**
24
+ * Apply pitch sweep (GB NR10) - hardware-accurate frequency sweep
25
+ * Formula: f_new = f_old ± f_old / 2^shift
26
+ * Each sweep step occurs every (sweepTime/128) seconds
27
+ *
28
+ * @param baseFreq - The base frequency to start the sweep from
29
+ * @param effFreq - The current effective frequency (may already have other modulations applied)
30
+ * @param t - Current time in seconds
31
+ * @param sweepTime - Sweep time parameter (0-7, in 1/128 Hz units)
32
+ * @param sweepShift - Frequency shift amount (0-7, number of bits to shift)
33
+ * @param sweepDirection - Direction of sweep ('up' or 'down')
34
+ * @returns The swept frequency
35
+ */
36
+ function applySweepToFrequency(baseFreq, effFreq, t, sweepTime, sweepShift, sweepDirection) {
37
+ if (sweepTime <= 0 || sweepShift <= 0 || effFreq <= 0) {
38
+ return effFreq;
39
+ }
40
+ const sweepStepTime = sweepTime / 128.0;
41
+ const divisor = Math.pow(2, sweepShift);
42
+ // Calculate which sweep step we're in
43
+ const stepIndex = Math.floor(t / sweepStepTime);
44
+ // Apply iterative sweep formula
45
+ let sweepFreq = baseFreq; // Start from base frequency
46
+ for (let step = 0; step < stepIndex; step++) {
47
+ const delta = sweepFreq / divisor;
48
+ if (sweepDirection === 'up') {
49
+ sweepFreq = sweepFreq + delta;
50
+ }
51
+ else {
52
+ sweepFreq = sweepFreq - delta;
53
+ }
54
+ // Clamp to audible range
55
+ if (sweepFreq < 20)
56
+ sweepFreq = 20;
57
+ if (sweepFreq > 20000)
58
+ sweepFreq = 20000;
59
+ // Stop if change becomes negligible
60
+ if (Math.abs(delta) < 0.1)
61
+ break;
62
+ }
63
+ return sweepFreq;
64
+ }
65
+ /**
66
+ * Per-channel portamento state for PCM rendering.
67
+ * Keyed by channel ID to track the last frequency per channel.
68
+ */
69
+ const channelPortamentoState = new Map();
70
+ /**
71
+ * Per-channel phase accumulator for continuous phase tracking.
72
+ * Prevents phase discontinuities and clicks between notes.
73
+ */
74
+ const channelPhaseState = new Map();
75
+ /**
76
+ * Per-channel vibrato LFO phase for smooth vibrato.
77
+ */
78
+ const channelVibratoPhase = new Map();
79
+ const channelEnvelopeState = new Map();
80
+ /**
81
+ * Clear all PCM render effect state (called before each render).
82
+ */
83
+ function clearPCMEffectState() {
84
+ channelPortamentoState.clear();
85
+ channelPhaseState.clear();
86
+ channelVibratoPhase.clear();
87
+ channelEnvelopeState.clear();
88
+ }
89
+ /**
90
+ * Renders a complete song to a PCM buffer.
91
+ *
92
+ * @param song The song model containing channels and events.
93
+ * @param opts Rendering options (sampleRate, channels, bpm, etc.).
94
+ * @returns a Float32Array containing the interleaved PCM samples.
95
+ */
96
+ export function renderSongToPCM(song, opts = {}) {
97
+ const sampleRate = opts.sampleRate ?? 44100;
98
+ const channels = opts.channels ?? 1;
99
+ const bpm = opts.bpm ?? 128;
100
+ const renderChannels = opts.renderChannels ?? song.channels.map(c => c.id);
101
+ const normalize = opts.normalize ?? false;
102
+ // Clear portamento state before rendering
103
+ clearPCMEffectState();
104
+ // Calculate duration from song events
105
+ const secondsPerBeat = 60 / bpm;
106
+ const tickSeconds = secondsPerBeat / 4;
107
+ let maxTicks = 0;
108
+ for (const ch of song.channels) {
109
+ if (ch.events.length > maxTicks) {
110
+ maxTicks = ch.events.length;
111
+ }
112
+ }
113
+ const duration = opts.duration ?? Math.ceil(maxTicks * tickSeconds) + 1; // Add 1 second buffer
114
+ const totalSamples = Math.floor(duration * sampleRate);
115
+ const buffer = new Float32Array(totalSamples * channels);
116
+ // Render each channel (filter by renderChannels option)
117
+ // Deep-clone instrument table to avoid in-place mutations during rendering
118
+ // (some render paths may temporarily modify instrument objects). Cloning
119
+ // ensures each note render sees a stable, independent instrument object.
120
+ const instsClone = song.insts ? JSON.parse(JSON.stringify(song.insts)) : {};
121
+ const chipType = (song.chip || 'gameboy').toLowerCase();
122
+ const isGameBoy = chipType === 'gameboy';
123
+ const vibDepthScale = typeof opts.vibDepthScale === 'number' ? opts.vibDepthScale : EXPORTER_VIB_DEPTH_SCALE;
124
+ const regPerTrackerBaseFactor = typeof opts.regPerTrackerBaseFactor === 'number' ? opts.regPerTrackerBaseFactor : RENDER_REG_PER_TRACKER_BASE_FACTOR;
125
+ const regPerTrackerUnit = typeof opts.regPerTrackerUnit === 'number' ? opts.regPerTrackerUnit : RENDER_REG_PER_TRACKER_UNIT;
126
+ for (const ch of song.channels) {
127
+ if (renderChannels.includes(ch.id)) {
128
+ renderChannel(ch, instsClone, buffer, sampleRate, channels, tickSeconds, chipType, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit);
129
+ }
130
+ }
131
+ // Apply master volume (default 1.0 matches hUGETracker behavior - no attenuation)
132
+ const masterVolume = song.volume !== undefined ? song.volume : 1.0;
133
+ if (masterVolume !== 1.0) {
134
+ for (let i = 0; i < buffer.length; i++) {
135
+ buffer[i] *= masterVolume;
136
+ }
137
+ }
138
+ // Normalize to prevent clipping or to maximize volume
139
+ if (normalize) {
140
+ normalizeBuffer(buffer, true);
141
+ }
142
+ else {
143
+ normalizeBuffer(buffer, false); // Only scale down if clipping
144
+ }
145
+ return buffer;
146
+ }
147
+ /**
148
+ * Renders a single channel's events into the provided buffer.
149
+ *
150
+ * @param ch The channel object containing events.
151
+ * @param insts The map of available instruments.
152
+ * @param buffer The target PCM buffer.
153
+ * @param sampleRate The sample rate for rendering.
154
+ * @param channels Number of audio channels (1 or 2).
155
+ * @param tickSeconds Duration of a single tick in seconds.
156
+ */
157
+ function renderChannel(ch, insts, buffer, sampleRate, channels, tickSeconds, chipType, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit) {
158
+ let currentInstName = ch.defaultInstrument;
159
+ let tempInstName = undefined;
160
+ let tempRemaining = 0;
161
+ for (let i = 0; i < ch.events.length; i++) {
162
+ const ev = ch.events[i];
163
+ const time = i * tickSeconds;
164
+ if (ev.type === 'rest' || ev.type === 'sustain') {
165
+ // Silence or handled by lookahead
166
+ continue;
167
+ }
168
+ // Calculate duration: prefer explicit duration on the event (e.g., C4:64),
169
+ // otherwise look ahead for sustain tokens (_) to extend the note.
170
+ let dur;
171
+ if (typeof ev.duration === 'number' && ev.duration > 0) {
172
+ dur = tickSeconds * ev.duration;
173
+ }
174
+ else {
175
+ let sustainCount = 0;
176
+ for (let j = i + 1; j < ch.events.length; j++) {
177
+ if (ch.events[j].type === 'sustain') {
178
+ sustainCount++;
179
+ }
180
+ else {
181
+ break;
182
+ }
183
+ }
184
+ dur = tickSeconds * (1 + sustainCount);
185
+ }
186
+ // Resolve instrument
187
+ let instName = ev.instrument || (tempRemaining > 0 ? tempInstName : currentInstName);
188
+ let inst = instName ? insts[instName] : undefined;
189
+ if (!inst)
190
+ continue;
191
+ const startSample = Math.floor(time * sampleRate);
192
+ const durationSamples = Math.floor(dur * sampleRate);
193
+ // Debug: log first note duration for channel 1 to diagnose headless vs browser
194
+ try {
195
+ // diagnostic removed
196
+ }
197
+ catch (e) { }
198
+ if (ev.type === 'note') {
199
+ renderNoteEvent(ev, inst, buffer, startSample, durationSamples, sampleRate, channels, tickSeconds, chipType, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, ch.id);
200
+ if (tempRemaining > 0) {
201
+ tempRemaining--;
202
+ // Skip temp decrement for sustains? Usually temp overrides apply to N *events*
203
+ // but if a note is sustained, it's still one event.
204
+ if (tempRemaining <= 0) {
205
+ tempInstName = undefined;
206
+ }
207
+ }
208
+ }
209
+ else if (ev.type === 'named') {
210
+ // Named instrument token (like drum hits)
211
+ renderNamedEvent(ev, inst, buffer, startSample, durationSamples, sampleRate, channels, tickSeconds, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, ch.id);
212
+ if (tempRemaining > 0) {
213
+ tempRemaining--;
214
+ if (tempRemaining <= 0) {
215
+ tempInstName = undefined;
216
+ }
217
+ }
218
+ }
219
+ }
220
+ }
221
+ /**
222
+ * Renders a specific note event using the appropriate chip-specific renderer.
223
+ *
224
+ * @param ev The note event to render.
225
+ * @param inst The instrument to use for rendering.
226
+ * @param buffer The target PCM buffer.
227
+ * @param startSample The starting sample index in the buffer.
228
+ * @param durationSamples The duration of the note in samples.
229
+ * @param sampleRate The sample rate for rendering.
230
+ * @param channels Number of audio channels.
231
+ */
232
+ function panToGains(panSpec) {
233
+ // panSpec may be: { enum:'L'|'R'|'C' } | { value: number } | string | number
234
+ let p = null;
235
+ if (panSpec === undefined || panSpec === null)
236
+ p = 0;
237
+ else if (typeof panSpec === 'number')
238
+ p = Math.max(-1, Math.min(1, panSpec));
239
+ else if (typeof panSpec === 'string') {
240
+ const up = panSpec.toUpperCase();
241
+ if (up === 'L')
242
+ p = -1;
243
+ else if (up === 'R')
244
+ p = 1;
245
+ else if (up === 'C')
246
+ p = 0;
247
+ else {
248
+ const n = Number(panSpec);
249
+ p = Number.isNaN(n) ? 0 : Math.max(-1, Math.min(1, n));
250
+ }
251
+ }
252
+ else if (typeof panSpec === 'object') {
253
+ if (panSpec.enum) {
254
+ const up = String(panSpec.enum).toUpperCase();
255
+ p = up === 'L' ? -1 : (up === 'R' ? 1 : 0);
256
+ }
257
+ else if (typeof panSpec.value === 'number') {
258
+ p = Math.max(-1, Math.min(1, panSpec.value));
259
+ }
260
+ else
261
+ p = 0;
262
+ }
263
+ // Ensure p is numeric
264
+ if (p === null)
265
+ p = 0;
266
+ // Equal-power panning
267
+ const angle = ((p + 1) / 2) * (Math.PI / 2);
268
+ const left = Math.cos(angle);
269
+ const right = Math.sin(angle);
270
+ return { left, right };
271
+ }
272
+ function resolveEventPan(ev, inst) {
273
+ if (ev && ev.pan)
274
+ return ev.pan;
275
+ if (inst) {
276
+ if (inst['gb:pan'])
277
+ return inst['gb:pan'];
278
+ if (inst['pan'] !== undefined)
279
+ return inst['pan'];
280
+ }
281
+ return undefined;
282
+ }
283
+ function renderNoteEvent(ev, inst, buffer, startSample, durationSamples, sampleRate, channels, tickSeconds, chipType, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId) {
284
+ try {
285
+ // removed debug logging
286
+ }
287
+ catch (e) { }
288
+ const token = ev.token;
289
+ const m = token.match(/^([A-G][#B]?)(-?\d+)$/i);
290
+ if (!m)
291
+ return;
292
+ const note = m[1].toUpperCase();
293
+ const octave = parseInt(m[2], 10);
294
+ const midi = noteNameToMidi(note, octave);
295
+ if (midi === null)
296
+ return;
297
+ const freq = midiToFreq(midi);
298
+ // Align frequency to Game Boy period table like the WebAudio path does
299
+ const alignedFreq = freqFromRegister(registerFromFreq(freq));
300
+ // Determine pan gains for stereo rendering
301
+ let panSpec = resolveEventPan(ev, inst);
302
+ const gains = panToGains(panSpec);
303
+ if (inst.type && inst.type.toLowerCase().includes('pulse')) {
304
+ renderPulse(buffer, startSample, durationSamples, alignedFreq, inst, sampleRate, channels, gains, ev.effects, tickSeconds, chipType, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId, ev.legato || false);
305
+ }
306
+ else if (inst.type && inst.type.toLowerCase().includes('wave')) {
307
+ renderWave(buffer, startSample, durationSamples, alignedFreq, inst, sampleRate, channels, gains, ev.effects, tickSeconds, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId, ev.legato || false);
308
+ }
309
+ else if (inst.type && inst.type.toLowerCase().includes('noise')) {
310
+ renderNoise(buffer, startSample, durationSamples, inst, sampleRate, channels, gains);
311
+ }
312
+ }
313
+ /**
314
+ * Renders a named event (e.g., percussion hits) using the appropriate renderer.
315
+ *
316
+ * @param ev The named event to render.
317
+ * @param inst The instrument to use.
318
+ * @param buffer The target PCM buffer.
319
+ * @param startSample The starting sample index.
320
+ * @param durationSamples The duration in samples.
321
+ * @param sampleRate The sample rate.
322
+ * @param channels Number of audio channels.
323
+ */
324
+ function renderNamedEvent(ev, inst, buffer, startSample, durationSamples, sampleRate, channels, tickSeconds, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId) {
325
+ // For noise instruments, ignore defaultNote and render as noise
326
+ // (noise doesn't use traditional pitch; defaultNote is not applicable)
327
+ if (inst.type && inst.type.toLowerCase().includes('noise')) {
328
+ // Determine pan gains for stereo rendering
329
+ const panSpec = resolveEventPan(ev, inst);
330
+ const gains = panToGains(panSpec);
331
+ renderNoise(buffer, startSample, durationSamples, inst, sampleRate, channels, gains);
332
+ return;
333
+ }
334
+ // For pulse/wave instruments with defaultNote, parse and render at specified pitch
335
+ if (ev.defaultNote) {
336
+ const noteToken = ev.defaultNote;
337
+ const m = noteToken.match(/^([A-G][#B]?)(-?\d+)$/i);
338
+ if (m) {
339
+ const note = m[1].toUpperCase();
340
+ const octave = parseInt(m[2], 10);
341
+ const midi = noteNameToMidi(note, octave);
342
+ if (midi !== null) {
343
+ const freq = midiToFreq(midi);
344
+ // Align frequency to Game Boy period table like the WebAudio path does
345
+ const alignedFreq = freqFromRegister(registerFromFreq(freq));
346
+ // Determine pan gains for stereo rendering
347
+ const panSpec = resolveEventPan(ev, inst);
348
+ const gains = panToGains(panSpec);
349
+ if (inst.type && inst.type.toLowerCase().includes('pulse')) {
350
+ renderPulse(buffer, startSample, durationSamples, alignedFreq, inst, sampleRate, channels, gains, ev.effects, tickSeconds, 'gameboy', isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId, false);
351
+ return;
352
+ }
353
+ else if (inst.type && inst.type.toLowerCase().includes('wave')) {
354
+ renderWave(buffer, startSample, durationSamples, alignedFreq, inst, sampleRate, channels, gains, ev.effects, tickSeconds, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId, false);
355
+ return;
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ /**
362
+ * Renders a Game Boy pulse channel (Pulse 1 or Pulse 2).
363
+ * Supports duty cycle, envelope, and frequency sweep.
364
+ *
365
+ * @param buffer The target PCM buffer.
366
+ * @param start The starting sample index.
367
+ * @param duration The duration in samples.
368
+ * @param freq The base frequency of the note.
369
+ * @param inst The instrument definition.
370
+ * @param sampleRate The sample rate.
371
+ * @param channels Number of audio channels.
372
+ */
373
+ function renderPulse(buffer, start, duration, freq, inst, sampleRate, channels, gains = { left: 1, right: 1 }, effects, tickSeconds, chipType, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId, legato) {
374
+ // removed debug logging
375
+ // Parse duty - handle various formats
376
+ let duty = 0.5;
377
+ if (inst.duty) {
378
+ const dutyStr = String(inst.duty);
379
+ const dutyNum = parseFloat(dutyStr);
380
+ if (!isNaN(dutyNum)) {
381
+ duty = dutyNum > 1 ? dutyNum / 100 : dutyNum; // Handle both 50 and 0.5
382
+ }
383
+ }
384
+ const envelope = parsePulseEnvelope(inst.env);
385
+ const durSec = duration / sampleRate;
386
+ const sweep = parseSweep(inst.sweep);
387
+ let currentFreq = freq;
388
+ let currentReg = registerFromFreq(freq);
389
+ const sweepIntervalSamples = sweep ? (sweep.time / 128) * sampleRate : 0;
390
+ // Generate simple square wave (harmonics were making it sound worse)
391
+ // Vibrato params (depth in register-units, rate in Hz). Support 4th param = duration in rows.
392
+ let vibDepth = 0;
393
+ let vibRate = 0;
394
+ let vibDurationSec = undefined;
395
+ // Tremolo params (depth 0-15, rate in Hz, waveform, duration)
396
+ let tremDepth = 0;
397
+ let tremRate = 0;
398
+ let tremWaveform = undefined;
399
+ let tremDurationSec = undefined;
400
+ // Note cut params (ticks after which to cut the note)
401
+ let cutTicks = 0;
402
+ let cutEnabled = false;
403
+ // Portamento params
404
+ let portSpeed = 0;
405
+ let portDurationSec = undefined;
406
+ // Arpeggio params - semitone offsets
407
+ let arpOffsets = [];
408
+ // Pitch bend params
409
+ let bendSemitones = 0;
410
+ let bendCurve = 'linear';
411
+ let bendDelay = 0;
412
+ let bendTime = 0;
413
+ // Pitch sweep params (GB NR10)
414
+ let sweepTime = 0;
415
+ let sweepDirection = 'down';
416
+ let sweepShift = 0;
417
+ // Volume slide params
418
+ let volDelta = 0;
419
+ let volSteps = undefined;
420
+ if (Array.isArray(effects)) {
421
+ for (const fx of effects) {
422
+ try {
423
+ if (fx && fx.type === 'vib') {
424
+ const p = fx.params || [];
425
+ vibDepth = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
426
+ vibRate = Number(typeof p[1] !== 'undefined' ? p[1] : 0);
427
+ // Prefer resolver-provided durationSec, else fall back to 4th param as rows
428
+ if (typeof fx.durationSec === 'number') {
429
+ vibDurationSec = Number(fx.durationSec);
430
+ }
431
+ else {
432
+ const durRows = typeof p[3] !== 'undefined' ? Number(p[3]) : undefined;
433
+ if (typeof durRows === 'number' && !Number.isNaN(durRows) && typeof tickSeconds === 'number') {
434
+ vibDurationSec = Math.max(0, Math.floor(durRows) * tickSeconds);
435
+ }
436
+ }
437
+ }
438
+ else if (fx && fx.type === 'port') {
439
+ const p = fx.params || [];
440
+ portSpeed = Number(typeof p[0] !== 'undefined' ? p[0] : 16);
441
+ // Prefer resolver-provided durationSec for portamento duration
442
+ if (typeof fx.durationSec === 'number') {
443
+ portDurationSec = Number(fx.durationSec);
444
+ }
445
+ else {
446
+ const durRows = typeof p[1] !== 'undefined' ? Number(p[1]) : undefined;
447
+ if (typeof durRows === 'number' && !Number.isNaN(durRows) && typeof tickSeconds === 'number') {
448
+ portDurationSec = Math.max(0, Math.floor(durRows) * tickSeconds);
449
+ }
450
+ }
451
+ }
452
+ else if (fx && fx.type === 'trem') {
453
+ const p = fx.params || [];
454
+ tremDepth = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
455
+ tremRate = Number(typeof p[1] !== 'undefined' ? p[1] : 6);
456
+ tremWaveform = typeof p[2] !== 'undefined' ? String(p[2]).toLowerCase() : 'sine';
457
+ // Prefer resolver-provided durationSec, else fall back to 4th param as rows
458
+ if (typeof fx.durationSec === 'number') {
459
+ tremDurationSec = Number(fx.durationSec);
460
+ }
461
+ else {
462
+ const durRows = typeof p[3] !== 'undefined' ? Number(p[3]) : undefined;
463
+ if (typeof durRows === 'number' && !Number.isNaN(durRows) && typeof tickSeconds === 'number') {
464
+ tremDurationSec = Math.max(0, Math.floor(durRows) * tickSeconds);
465
+ }
466
+ }
467
+ }
468
+ else if (fx && fx.type === 'arp') {
469
+ const p = fx.params || [];
470
+ // Parse semitone offsets - filter out non-numeric values
471
+ arpOffsets = p
472
+ .map((x) => Number(x))
473
+ .filter((n) => Number.isFinite(n) && n >= 0);
474
+ }
475
+ else if (fx && fx.type === 'bend') {
476
+ const p = fx.params || [];
477
+ bendSemitones = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
478
+ if (Number.isFinite(bendSemitones)) {
479
+ bendCurve = typeof p[1] !== 'undefined' ? String(p[1]).toLowerCase() : 'linear';
480
+ // params[2] is delay (default: 50% of note)
481
+ bendDelay = typeof p[2] !== 'undefined' ? Number(p[2]) : (durSec * 0.5);
482
+ if (!Number.isFinite(bendDelay) || bendDelay < 0)
483
+ bendDelay = durSec * 0.5;
484
+ bendDelay = Math.min(bendDelay, durSec);
485
+ // params[3] is bend time (default: remaining time after delay)
486
+ bendTime = typeof p[3] !== 'undefined' ? Number(p[3]) : (durSec - bendDelay);
487
+ if (!Number.isFinite(bendTime) || bendTime <= 0)
488
+ bendTime = durSec - bendDelay;
489
+ bendTime = Math.min(bendTime, durSec - bendDelay);
490
+ }
491
+ else {
492
+ bendSemitones = 0;
493
+ }
494
+ }
495
+ else if (fx && fx.type === 'sweep') {
496
+ const p = fx.params || [];
497
+ const timeVal = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
498
+ if (Number.isFinite(timeVal) && timeVal >= 0 && timeVal <= 7) {
499
+ sweepTime = Math.round(timeVal);
500
+ // Parse direction
501
+ const dirRaw = typeof p[1] !== 'undefined' ? p[1] : 'down';
502
+ if (typeof dirRaw === 'number') {
503
+ sweepDirection = dirRaw > 0 ? 'up' : 'down';
504
+ }
505
+ else if (typeof dirRaw === 'string') {
506
+ const dirStr = String(dirRaw).toLowerCase().trim();
507
+ sweepDirection = (dirStr === 'up' || dirStr === '+' || dirStr === '1') ? 'up' : 'down';
508
+ }
509
+ // Parse shift
510
+ const shiftVal = Number(typeof p[2] !== 'undefined' ? p[2] : 0);
511
+ if (Number.isFinite(shiftVal) && shiftVal >= 0 && shiftVal <= 7) {
512
+ sweepShift = Math.round(shiftVal);
513
+ }
514
+ }
515
+ }
516
+ else if (fx && fx.type === 'volSlide') {
517
+ const p = fx.params || [];
518
+ volDelta = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
519
+ volSteps = typeof p[1] !== 'undefined' ? Number(p[1]) : undefined;
520
+ if (!Number.isFinite(volDelta))
521
+ volDelta = 0;
522
+ if (volSteps !== undefined && !Number.isFinite(volSteps))
523
+ volSteps = undefined;
524
+ }
525
+ else if (fx && fx.type === 'cut') {
526
+ const p = fx.params || [];
527
+ const ticks = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
528
+ if (Number.isFinite(ticks) && ticks > 0) {
529
+ cutTicks = Math.max(0, ticks);
530
+ cutEnabled = true;
531
+ }
532
+ }
533
+ }
534
+ catch (e) { }
535
+ }
536
+ }
537
+ // Initialize per-channel phase state for continuous phase across notes
538
+ let phase = (typeof channelId === 'number') ? (channelPhaseState.get(channelId) ?? 0) : 0;
539
+ let vibratoPhase = (typeof channelId === 'number') ? (channelVibratoPhase.get(channelId) ?? 0) : 0;
540
+ // For legato notes, retrieve envelope state to sustain at previous level (no decay)
541
+ let envelopeSustainValue = undefined;
542
+ if (legato && typeof channelId === 'number') {
543
+ const envState = channelEnvelopeState.get(channelId);
544
+ if (envState) {
545
+ envelopeSustainValue = envState.lastValue; // Freeze at this level for legato
546
+ }
547
+ }
548
+ for (let i = 0; i < duration; i++) {
549
+ const t = i / sampleRate;
550
+ // normal rendering loop
551
+ // Apply sweep
552
+ const sweepInterval = Math.floor(sweepIntervalSamples);
553
+ if (sweep && sweep.time > 0 && sweepInterval > 0 && i > 0 && i % sweepInterval === 0) {
554
+ const delta = currentReg >> sweep.shift;
555
+ if (sweep.direction === 'up')
556
+ currentReg += delta;
557
+ else
558
+ currentReg -= delta;
559
+ if (currentReg < 0)
560
+ currentReg = 0;
561
+ if (currentReg > 2047) {
562
+ currentFreq = 0; // Silence
563
+ }
564
+ else {
565
+ currentFreq = freqFromRegister(currentReg);
566
+ }
567
+ }
568
+ // Apply vibrato. If rendering for a Game Boy target, emulate hUGEDriver's
569
+ // step-based vibrato: on discrete tracker ticks, add/subtract the depth
570
+ // nibble (converted to register units) to the period register. Otherwise
571
+ // fall back to the smoother, phase-LFO approach used historically.
572
+ let effFreq = currentFreq;
573
+ // Apply portamento if enabled
574
+ if (portSpeed > 0 && typeof channelId === 'number') {
575
+ // Get last frequency for this channel from the per-channel state map
576
+ const lastFreq = channelPortamentoState.get(channelId) ?? currentFreq;
577
+ // Calculate portamento progress: speed maps inversely to duration
578
+ // Higher speed = faster transition = shorter duration
579
+ const portDur = typeof portDurationSec === 'number' && portDurationSec > 0
580
+ ? portDurationSec
581
+ : Math.max(0.001, (256 - Math.min(portSpeed, 255)) / 256 * durSec * 0.6);
582
+ if (portDur > 0 && t <= portDur && Math.abs(currentFreq - lastFreq) > 1) {
583
+ // Smooth cubic ease for natural portamento
584
+ const progress = Math.min(1, t / portDur);
585
+ const easedProgress = progress * progress * (3 - 2 * progress); // smoothstep
586
+ effFreq = lastFreq + (currentFreq - lastFreq) * easedProgress;
587
+ }
588
+ }
589
+ // Save current frequency for next note's portamento (do this for ALL notes, not just portamento notes)
590
+ if (typeof channelId === 'number' && i === duration - 1) {
591
+ channelPortamentoState.set(channelId, currentFreq);
592
+ }
593
+ // Apply vibrato - use per-channel LFO phase for smooth, continuous vibrato
594
+ if (vibDepth !== 0 && vibRate > 0 && effFreq > 0) {
595
+ if (typeof vibDurationSec === 'undefined' || (i / sampleRate) < vibDurationSec) {
596
+ // Game Boy-accurate vibrato: Convert BeatBax depth -> tracker nibble -> Hz deviation
597
+ const trackerDepth = Math.max(0, Math.min(15, Math.round((vibDepth || 0) * (vibDepthScale ?? EXPORTER_VIB_DEPTH_SCALE))));
598
+ // Match WebAudio formula: use 0.012 multiplier for Hz deviation
599
+ const amplitudeHz = effFreq * trackerDepth * 0.012;
600
+ const lfo = Math.sin(vibratoPhase);
601
+ effFreq = effFreq + (lfo * amplitudeHz);
602
+ // Advance vibrato phase
603
+ vibratoPhase += (2 * Math.PI * vibRate) / sampleRate;
604
+ }
605
+ }
606
+ // Apply arpeggio - rapid pitch cycling
607
+ if (arpOffsets.length > 0 && effFreq > 0) {
608
+ // Chip-specific frame rates (Hz) - must match effects/index.ts CHIP_FRAME_RATES
609
+ // C64: 50 Hz (PAL), Game Boy/NES/Genesis: 60 Hz (NTSC or global standard)
610
+ const CHIP_FRAME_RATES = {
611
+ 'gameboy': 60,
612
+ 'nes': 60,
613
+ 'c64': 50,
614
+ 'genesis': 60,
615
+ 'megadrive': 60,
616
+ 'pcengine': 60,
617
+ };
618
+ const frameRate = CHIP_FRAME_RATES[chipType || 'gameboy'] || 60; // Default to 60 Hz
619
+ const cycleDuration = 1 / frameRate; // e.g., ~16.667ms at 60Hz, ~20ms at 50Hz
620
+ // Build arpeggio cycle: [0 (root), ...offsets]
621
+ const allOffsets = [0, ...arpOffsets];
622
+ const offsetIndex = Math.floor((t % (cycleDuration * allOffsets.length)) / cycleDuration);
623
+ const semitoneOffset = allOffsets[offsetIndex % allOffsets.length] || 0;
624
+ // Apply frequency shift: freq * 2^(semitones / 12)
625
+ effFreq = effFreq * Math.pow(2, semitoneOffset / 12);
626
+ }
627
+ // Apply pitch sweep (GB NR10) - hardware-accurate frequency sweep
628
+ effFreq = applySweepToFrequency(freq, effFreq, t, sweepTime, sweepShift, sweepDirection);
629
+ // Apply pitch bend - smooth pitch bend over time with curve shaping
630
+ // Holds base pitch during delay period, then bends to target
631
+ if (bendSemitones !== 0 && bendTime > 0 && effFreq > 0) {
632
+ if (t >= bendDelay && t <= (bendDelay + bendTime)) {
633
+ // Calculate progress within the bend period (0 to 1)
634
+ let bendProgress = Math.min(1, (t - bendDelay) / bendTime);
635
+ // Apply curve shaping
636
+ if (bendCurve === 'exp' || bendCurve === 'exponential') {
637
+ // Exponential: slow start, fast end (y = x^2)
638
+ bendProgress = bendProgress * bendProgress;
639
+ }
640
+ else if (bendCurve === 'log' || bendCurve === 'logarithmic') {
641
+ // Logarithmic: fast start, slow end (y = 1 - (1 - x)^2)
642
+ bendProgress = 1 - Math.pow(1 - bendProgress, 2);
643
+ }
644
+ else if (bendCurve === 'sine' || bendCurve === 'sin') {
645
+ // Sine: smooth S-curve (y = (1 - cos(π * x)) / 2)
646
+ bendProgress = (1 - Math.cos(Math.PI * bendProgress)) / 2;
647
+ }
648
+ // else: linear (default) - no transformation
649
+ // Calculate bend frequency: freq * 2^(semitones * progress / 12)
650
+ const bendMultiplier = Math.pow(2, (bendSemitones * bendProgress) / 12);
651
+ effFreq = effFreq * bendMultiplier;
652
+ }
653
+ }
654
+ // Simple, efficient band-limited pulse wave synthesis using naive square wave
655
+ // with single-pole low-pass filter to reduce aliasing
656
+ let sample = 0;
657
+ if (effFreq > 0 && effFreq < sampleRate / 2) {
658
+ // Advance phase accumulator
659
+ phase += effFreq / sampleRate;
660
+ phase = phase % 1.0; // Keep phase in [0, 1)
661
+ // Generate square wave based on duty cycle
662
+ sample = (phase < duty) ? 1.0 : -1.0;
663
+ }
664
+ // Apply envelope (sustain at previous level for legato notes, otherwise compute normally)
665
+ const envVal = (envelopeSustainValue !== undefined) ? envelopeSustainValue : getEnvelopeValue(t, envelope, durSec);
666
+ sample = sample * envVal;
667
+ // Apply note cut if enabled (fade out quickly after cut time)
668
+ if (cutEnabled && typeof tickSeconds === 'number') {
669
+ const cutTimeSec = cutTicks * tickSeconds;
670
+ if (t >= cutTimeSec) {
671
+ // Apply very fast exponential fade (5ms) to match WebAudio behavior
672
+ const fadeDuration = 0.005; // 5ms fade to match browser
673
+ const fadeTime = t - cutTimeSec;
674
+ if (fadeTime < fadeDuration) {
675
+ // Exponential fade: start at 1.0, ramp to 0.0001 over fadeDuration
676
+ const fadeProgress = fadeTime / fadeDuration;
677
+ const cutGain = Math.pow(0.0001, fadeProgress); // Exponential curve
678
+ sample = sample * cutGain;
679
+ }
680
+ else {
681
+ // After fade completes, full silence
682
+ sample = 0;
683
+ }
684
+ }
685
+ }
686
+ // Apply tremolo if enabled (amplitude modulation)
687
+ if (tremDepth > 0 && tremRate > 0) {
688
+ if (typeof tremDurationSec === 'undefined' || t < tremDurationSec) {
689
+ // Tremolo depth: 0-15 maps to 0-50% amplitude modulation
690
+ const modulationDepth = (tremDepth / 15) * 0.5; // 0 to 0.5 (±50% max)
691
+ // Generate LFO waveform
692
+ let lfo = 0;
693
+ const tremPhase = (t * tremRate * 2 * Math.PI);
694
+ switch (tremWaveform) {
695
+ case 'square':
696
+ lfo = Math.sin(tremPhase) >= 0 ? 1 : -1;
697
+ break;
698
+ case 'sawtooth':
699
+ case 'saw':
700
+ lfo = 2 * ((tremPhase / (2 * Math.PI)) % 1) - 1;
701
+ break;
702
+ case 'triangle':
703
+ const sawLfo = 2 * ((tremPhase / (2 * Math.PI)) % 1) - 1;
704
+ lfo = 2 * Math.abs(sawLfo) - 1;
705
+ break;
706
+ case 'sine':
707
+ default:
708
+ lfo = Math.sin(tremPhase);
709
+ break;
710
+ }
711
+ // Apply tremolo: modulate amplitude around baseline
712
+ // lfo ranges from -1 to +1, modulationDepth is fraction of baseline
713
+ const tremGain = 1.0 + (lfo * modulationDepth);
714
+ sample = sample * tremGain;
715
+ }
716
+ }
717
+ // Apply volume slide if enabled
718
+ if (volDelta !== 0) {
719
+ // Extract baseline from instrument envelope initial volume (0-15 on GB, normalized to 0-1)
720
+ let baseline = 1.0;
721
+ let volSlideGain;
722
+ if (envelope && envelope.mode === 'gb' && typeof envelope.initial === 'number') {
723
+ baseline = Math.max(0, Math.min(1, envelope.initial / 15));
724
+ }
725
+ if (volSteps !== undefined && typeof tickSeconds === 'number') {
726
+ // Stepped volume slide: divide note duration into discrete steps
727
+ const stepDuration = durSec / volSteps;
728
+ const currentStep = Math.min(volSteps, Math.floor(t / stepDuration));
729
+ // Scale delta across steps: ±1 = ±20% gain change per note
730
+ volSlideGain = Math.max(0, Math.min(1.5, baseline + (volDelta * currentStep / volSteps / 5)));
731
+ }
732
+ else {
733
+ // Smooth volume slide: linear ramp over note duration
734
+ const progress = Math.min(1, t / durSec);
735
+ // Scale delta: ±1 = ±20% gain change per note, starting from envelope initial volume
736
+ volSlideGain = Math.max(0, Math.min(1.5, baseline + (volDelta * progress / 5)));
737
+ }
738
+ sample = sample * volSlideGain;
739
+ }
740
+ const bufferIdx = (start + i) * channels;
741
+ if (bufferIdx < buffer.length) {
742
+ if (channels === 2) {
743
+ if (bufferIdx < buffer.length)
744
+ buffer[bufferIdx] += sample * gains.left;
745
+ if (bufferIdx + 1 < buffer.length)
746
+ buffer[bufferIdx + 1] += sample * gains.right;
747
+ }
748
+ else {
749
+ buffer[bufferIdx] += sample; // mono
750
+ }
751
+ }
752
+ }
753
+ // Save phase and vibrato state for next note on this channel
754
+ if (typeof channelId === 'number') {
755
+ channelPhaseState.set(channelId, phase);
756
+ channelVibratoPhase.set(channelId, vibratoPhase);
757
+ // Save envelope state for potential legato continuation
758
+ // Use sustained value if this was a legato note, otherwise compute final value
759
+ const finalEnvVal = (envelopeSustainValue !== undefined) ? envelopeSustainValue : getEnvelopeValue(durSec, envelope, durSec);
760
+ channelEnvelopeState.set(channelId, {
761
+ time: durSec,
762
+ lastValue: finalEnvVal,
763
+ mode: envelope.mode || 'adsr'
764
+ });
765
+ }
766
+ }
767
+ /**
768
+ * Renders a Game Boy wave channel.
769
+ * Uses a 16-sample 4-bit wavetable.
770
+ *
771
+ * @param buffer The target PCM buffer.
772
+ * @param start The starting sample index.
773
+ * @param duration The duration in samples.
774
+ * @param freq The frequency of the note.
775
+ * @param inst The instrument definition containing the wavetable.
776
+ * @param sampleRate The sample rate.
777
+ * @param channels Number of audio channels.
778
+ */
779
+ function renderWave(buffer, start, duration, freq, inst, sampleRate, channels, gains = { left: 1, right: 1 }, effects, tickSeconds, isGameBoy, vibDepthScale, regPerTrackerBaseFactor, regPerTrackerUnit, channelId, legato) {
780
+ const waveTable = inst.wave ? parseWaveTable(inst.wave) : [0, 3, 6, 9, 12, 15, 12, 9, 6, 3, 0, 3, 6, 9, 12, 15];
781
+ // Resolve volume multiplier
782
+ let volRaw = inst.volume !== undefined ? inst.volume : (inst.vol !== undefined ? inst.vol : 100);
783
+ let volNum = 100;
784
+ if (typeof volRaw === 'string') {
785
+ const s = volRaw.trim();
786
+ volNum = s.endsWith('%') ? parseInt(s.slice(0, -1), 10) : parseInt(s, 10);
787
+ }
788
+ else if (typeof volRaw === 'number') {
789
+ volNum = volRaw;
790
+ }
791
+ const volMulMap = { 0: 0, 25: 0.25, 50: 0.5, 100: 1.0 };
792
+ const volMul = volMulMap[volNum] ?? 1.0;
793
+ // Vibrato params
794
+ let vibDepth = 0;
795
+ let vibRate = 0;
796
+ let vibDurationSec = undefined;
797
+ // Tremolo params
798
+ let tremDepth = 0;
799
+ let tremRate = 0;
800
+ let tremWaveform = undefined;
801
+ let tremDurationSec = undefined;
802
+ // Portamento params
803
+ let portSpeed = 0;
804
+ let portDurationSec = undefined;
805
+ // Pitch bend params
806
+ let bendSemitones = 0;
807
+ let bendCurve = 'linear';
808
+ let bendDelay = 0;
809
+ let bendTime = 0;
810
+ // Pitch sweep params (GB NR10)
811
+ let sweepTime = 0;
812
+ let sweepDirection = 'down';
813
+ let sweepShift = 0;
814
+ // Volume slide params
815
+ let volDelta = 0;
816
+ let volSteps = undefined;
817
+ // Calculate duration in seconds (needed for bend time calculation)
818
+ const durSec = duration / sampleRate;
819
+ if (Array.isArray(effects)) {
820
+ for (const fx of effects) {
821
+ try {
822
+ if (fx && fx.type === 'vib') {
823
+ const p = fx.params || [];
824
+ vibDepth = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
825
+ vibRate = Number(typeof p[1] !== 'undefined' ? p[1] : 0);
826
+ if (typeof fx.durationSec === 'number')
827
+ vibDurationSec = Number(fx.durationSec);
828
+ else if (typeof p[3] !== 'undefined' && typeof tickSeconds === 'number') {
829
+ const durRows = Number(p[3]);
830
+ if (!Number.isNaN(durRows))
831
+ vibDurationSec = Math.max(0, Math.floor(durRows) * tickSeconds);
832
+ }
833
+ }
834
+ else if (fx && fx.type === 'port') {
835
+ const p = fx.params || [];
836
+ portSpeed = Number(typeof p[0] !== 'undefined' ? p[0] : 16);
837
+ if (typeof fx.durationSec === 'number') {
838
+ portDurationSec = Number(fx.durationSec);
839
+ }
840
+ else {
841
+ const durRows = typeof p[1] !== 'undefined' ? Number(p[1]) : undefined;
842
+ if (typeof durRows === 'number' && !Number.isNaN(durRows) && typeof tickSeconds === 'number') {
843
+ portDurationSec = Math.max(0, Math.floor(durRows) * tickSeconds);
844
+ }
845
+ }
846
+ }
847
+ else if (fx && fx.type === 'trem') {
848
+ const p = fx.params || [];
849
+ tremDepth = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
850
+ tremRate = Number(typeof p[1] !== 'undefined' ? p[1] : 6);
851
+ tremWaveform = typeof p[2] !== 'undefined' ? String(p[2]).toLowerCase() : 'sine';
852
+ if (typeof fx.durationSec === 'number')
853
+ tremDurationSec = Number(fx.durationSec);
854
+ else if (typeof p[3] !== 'undefined' && typeof tickSeconds === 'number') {
855
+ const durRows = Number(p[3]);
856
+ if (!Number.isNaN(durRows))
857
+ tremDurationSec = Math.max(0, Math.floor(durRows) * tickSeconds);
858
+ }
859
+ }
860
+ else if (fx && fx.type === 'bend') {
861
+ const p = fx.params || [];
862
+ bendSemitones = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
863
+ if (Number.isFinite(bendSemitones)) {
864
+ bendCurve = typeof p[1] !== 'undefined' ? String(p[1]).toLowerCase() : 'linear';
865
+ // params[2] = delay (default 50% of note duration = hold base pitch first)
866
+ // params[3] = time (default remaining duration for bend)
867
+ const defaultDelay = durSec * 0.5;
868
+ bendDelay = typeof p[2] !== 'undefined' ? Number(p[2]) : defaultDelay;
869
+ if (!Number.isFinite(bendDelay) || bendDelay < 0)
870
+ bendDelay = defaultDelay;
871
+ bendDelay = Math.min(bendDelay, durSec);
872
+ const remainingTime = durSec - bendDelay;
873
+ bendTime = typeof p[3] !== 'undefined' ? Number(p[3]) : remainingTime;
874
+ if (!Number.isFinite(bendTime) || bendTime <= 0)
875
+ bendTime = remainingTime;
876
+ bendTime = Math.min(bendTime, durSec - bendDelay); // Cap at remaining duration
877
+ }
878
+ else {
879
+ bendSemitones = 0;
880
+ }
881
+ }
882
+ else if (fx && fx.type === 'sweep') {
883
+ const p = fx.params || [];
884
+ const timeVal = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
885
+ if (Number.isFinite(timeVal) && timeVal >= 0 && timeVal <= 7) {
886
+ sweepTime = Math.round(timeVal);
887
+ // Parse direction
888
+ const dirRaw = typeof p[1] !== 'undefined' ? p[1] : 'down';
889
+ if (typeof dirRaw === 'number') {
890
+ sweepDirection = dirRaw > 0 ? 'up' : 'down';
891
+ }
892
+ else if (typeof dirRaw === 'string') {
893
+ const dirStr = String(dirRaw).toLowerCase().trim();
894
+ sweepDirection = (dirStr === 'up' || dirStr === '+' || dirStr === '1') ? 'up' : 'down';
895
+ }
896
+ // Parse shift
897
+ const shiftVal = Number(typeof p[2] !== 'undefined' ? p[2] : 0);
898
+ if (Number.isFinite(shiftVal) && shiftVal >= 0 && shiftVal <= 7) {
899
+ sweepShift = Math.round(shiftVal);
900
+ }
901
+ }
902
+ }
903
+ else if (fx && fx.type === 'volSlide') {
904
+ const p = fx.params || [];
905
+ volDelta = Number(typeof p[0] !== 'undefined' ? p[0] : 0);
906
+ volSteps = typeof p[1] !== 'undefined' ? Number(p[1]) : undefined;
907
+ if (!Number.isFinite(volDelta))
908
+ volDelta = 0;
909
+ if (volSteps !== undefined && !Number.isFinite(volSteps))
910
+ volSteps = undefined;
911
+ }
912
+ }
913
+ catch (e) { }
914
+ }
915
+ }
916
+ // Get or initialize phase accumulator for this channel
917
+ let phase = (typeof channelId === 'number') ? (channelPhaseState.get(channelId) ?? 0) : 0;
918
+ // Get or initialize vibrato LFO phase for this channel
919
+ let vibratoPhase = (typeof channelId === 'number') ? (channelVibratoPhase.get(channelId) ?? 0) : 0;
920
+ // For legato notes, retrieve envelope state to sustain at previous level
921
+ // (Wave channel uses fixed volume, but we track for consistency)
922
+ let volumeSustainValue = undefined;
923
+ if (legato && typeof channelId === 'number') {
924
+ const envState = channelEnvelopeState.get(channelId);
925
+ if (envState) {
926
+ volumeSustainValue = envState.lastValue;
927
+ }
928
+ }
929
+ for (let i = 0; i < duration; i++) {
930
+ const t = i / sampleRate;
931
+ let effFreq = freq;
932
+ // Apply portamento if enabled
933
+ if (portSpeed > 0 && typeof channelId === 'number') {
934
+ const lastFreq = channelPortamentoState.get(channelId) ?? freq;
935
+ const portDur = typeof portDurationSec === 'number' && portDurationSec > 0
936
+ ? portDurationSec
937
+ : Math.max(0.001, (256 - Math.min(portSpeed, 255)) / 256 * durSec * 0.6);
938
+ if (portDur > 0 && t <= portDur && Math.abs(freq - lastFreq) > 1) {
939
+ const progress = Math.min(1, t / portDur);
940
+ const easedProgress = progress * progress * (3 - 2 * progress); // smoothstep
941
+ effFreq = lastFreq + (freq - lastFreq) * easedProgress;
942
+ }
943
+ }
944
+ // Save current frequency for next note's portamento (do this for ALL notes, not just portamento notes)
945
+ if (typeof channelId === 'number' && i === duration - 1) {
946
+ channelPortamentoState.set(channelId, freq);
947
+ }
948
+ // Apply pitch sweep (GB NR10) - hardware-accurate frequency sweep
949
+ effFreq = applySweepToFrequency(freq, effFreq, t, sweepTime, sweepShift, sweepDirection);
950
+ // Apply pitch bend - smooth pitch bend over time with curve shaping (after delay)
951
+ if (bendSemitones !== 0 && bendTime > 0 && t >= bendDelay && t <= (bendDelay + bendTime) && effFreq > 0) {
952
+ const bendT = t - bendDelay;
953
+ let bendProgress = Math.min(1, bendT / bendTime);
954
+ // Apply curve shaping
955
+ if (bendCurve === 'exp' || bendCurve === 'exponential') {
956
+ // Exponential: slow start, fast end (y = x^2)
957
+ bendProgress = bendProgress * bendProgress;
958
+ }
959
+ else if (bendCurve === 'log' || bendCurve === 'logarithmic') {
960
+ // Logarithmic: fast start, slow end (y = 1 - (1 - x)^2)
961
+ bendProgress = 1 - Math.pow(1 - bendProgress, 2);
962
+ }
963
+ else if (bendCurve === 'sine' || bendCurve === 'sin') {
964
+ // Sine: smooth S-curve (y = (1 - cos(π * x)) / 2)
965
+ bendProgress = (1 - Math.cos(Math.PI * bendProgress)) / 2;
966
+ }
967
+ // else: linear (default) - no transformation
968
+ // Calculate bend frequency: freq * 2^(semitones * progress / 12)
969
+ const bendMultiplier = Math.pow(2, (bendSemitones * bendProgress) / 12);
970
+ effFreq = effFreq * bendMultiplier;
971
+ }
972
+ if (vibDepth !== 0 && vibRate > 0 && freq > 0) {
973
+ if (renderWave.__vibState === undefined)
974
+ renderWave.__vibState = {};
975
+ const noteKey = String(start);
976
+ let state = renderWave.__vibState[noteKey];
977
+ if (!state) {
978
+ state = { counter: 0, lastTick: -1, currentOffset: 0 };
979
+ renderWave.__vibState[noteKey] = state;
980
+ }
981
+ const globalTime = (start + i) / sampleRate;
982
+ const tickSec = typeof tickSeconds === 'number' ? tickSeconds : (60 / 128) / 4;
983
+ const tickIndex = Math.floor(globalTime / tickSec);
984
+ if (typeof vibDurationSec === 'undefined' || t < vibDurationSec) {
985
+ if (tickIndex !== state.lastTick) {
986
+ state.lastTick = tickIndex;
987
+ state.counter++;
988
+ if (isGameBoy) {
989
+ const speedNibble = Math.max(0, Math.min(15, Math.round(vibRate || 0)));
990
+ const depthNibble = Math.max(0, Math.min(15, Math.round((vibDepth || 0) * (vibDepthScale ?? EXPORTER_VIB_DEPTH_SCALE))));
991
+ const mask = speedNibble & 0x0f;
992
+ if (mask === 0 || (state.counter & mask) === 0)
993
+ state.currentOffset = depthNibble;
994
+ else
995
+ state.currentOffset = 0;
996
+ const baseReg = registerFromFreq(freq);
997
+ const effReg = applyHugeDriverOffset(baseReg, state.currentOffset || 0);
998
+ effFreq = freqFromRegister(effReg);
999
+ }
1000
+ else {
1001
+ if (state.phase === undefined)
1002
+ state.phase = 0;
1003
+ state.phase += vibRate;
1004
+ const lfo = Math.sin(state.phase || 0);
1005
+ const baseReg = registerFromFreq(freq);
1006
+ const trackerDepth = Math.max(0, Math.min(15, Math.round(vibDepth * (vibDepthScale ?? EXPORTER_VIB_DEPTH_SCALE))));
1007
+ const regScale = Math.max(1, Math.round(baseReg * (regPerTrackerBaseFactor ?? RENDER_REG_PER_TRACKER_BASE_FACTOR)));
1008
+ const unit = regPerTrackerUnit ?? RENDER_REG_PER_TRACKER_UNIT;
1009
+ const effReg = Math.max(0, baseReg + lfo * trackerDepth * unit * regScale);
1010
+ effFreq = freqFromRegister(Math.max(0, Math.round(effReg)));
1011
+ }
1012
+ }
1013
+ else {
1014
+ if (isGameBoy) {
1015
+ const baseReg = registerFromFreq(freq);
1016
+ const effReg = applyHugeDriverOffset(baseReg, state.currentOffset || 0);
1017
+ effFreq = freqFromRegister(effReg);
1018
+ }
1019
+ else {
1020
+ if (state.phase === undefined)
1021
+ state.phase = 0;
1022
+ const lfo = Math.sin(state.phase || 0);
1023
+ const baseReg = registerFromFreq(freq);
1024
+ const trackerDepth = Math.max(0, Math.min(15, Math.round(vibDepth * (vibDepthScale ?? EXPORTER_VIB_DEPTH_SCALE))));
1025
+ const regScale = Math.max(1, Math.round(baseReg * (regPerTrackerBaseFactor ?? RENDER_REG_PER_TRACKER_BASE_FACTOR)));
1026
+ const unit = regPerTrackerUnit ?? RENDER_REG_PER_TRACKER_UNIT;
1027
+ const effReg = Math.max(0, baseReg + lfo * trackerDepth * unit * regScale);
1028
+ effFreq = freqFromRegister(Math.max(0, Math.round(effReg)));
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ const phase = (t * effFreq) % 1.0;
1034
+ const tablePos = phase * waveTable.length;
1035
+ const i0 = Math.floor(tablePos) % waveTable.length;
1036
+ const i1 = (i0 + 1) % waveTable.length;
1037
+ const frac = tablePos - Math.floor(tablePos);
1038
+ const v0 = (waveTable[i0] / 15.0) * 2.0 - 1.0;
1039
+ const v1 = (waveTable[i1] / 15.0) * 2.0 - 1.0;
1040
+ // Use sustained volume for legato notes, otherwise use instrument volume
1041
+ const effectiveVolMul = (volumeSustainValue !== undefined) ? volumeSustainValue : volMul;
1042
+ let sample = ((v0 * (1 - frac) + v1 * frac) * effectiveVolMul);
1043
+ // Apply tremolo if enabled (amplitude modulation)
1044
+ if (tremDepth > 0 && tremRate > 0) {
1045
+ if (typeof tremDurationSec === 'undefined' || t < tremDurationSec) {
1046
+ const modulationDepth = (tremDepth / 15) * 0.5;
1047
+ let lfo = 0;
1048
+ const tremPhase = (t * tremRate * 2 * Math.PI);
1049
+ switch (tremWaveform) {
1050
+ case 'square':
1051
+ lfo = Math.sin(tremPhase) >= 0 ? 1 : -1;
1052
+ break;
1053
+ case 'sawtooth':
1054
+ case 'saw':
1055
+ lfo = 2 * ((tremPhase / (2 * Math.PI)) % 1) - 1;
1056
+ break;
1057
+ case 'triangle':
1058
+ const sawLfo = 2 * ((tremPhase / (2 * Math.PI)) % 1) - 1;
1059
+ lfo = 2 * Math.abs(sawLfo) - 1;
1060
+ break;
1061
+ case 'sine':
1062
+ default:
1063
+ lfo = Math.sin(tremPhase);
1064
+ break;
1065
+ }
1066
+ const tremGain = 1.0 + (lfo * modulationDepth);
1067
+ sample = sample * tremGain;
1068
+ }
1069
+ }
1070
+ // Apply volume slide if enabled
1071
+ if (volDelta !== 0) {
1072
+ // Use wave instrument volume as baseline (already normalized to 0-1 in volMul)
1073
+ const baseline = effectiveVolMul;
1074
+ let volSlideGain;
1075
+ if (volSteps !== undefined && typeof tickSeconds === 'number') {
1076
+ // Stepped volume slide: divide note duration into discrete steps
1077
+ const stepDuration = durSec / volSteps;
1078
+ const currentStep = Math.min(volSteps, Math.floor(t / stepDuration));
1079
+ // Scale delta across steps: ±1 = ±20% gain change per note
1080
+ volSlideGain = Math.max(0, Math.min(1.5, baseline + (volDelta * currentStep / volSteps / 5)));
1081
+ }
1082
+ else {
1083
+ // Smooth volume slide
1084
+ const progress = Math.min(1, t / durSec);
1085
+ // Scale delta: ±1 = ±20% gain change per note, starting from wave instrument volume
1086
+ volSlideGain = Math.max(0, Math.min(1.5, baseline + (volDelta * progress / 5)));
1087
+ }
1088
+ sample = sample * volSlideGain;
1089
+ }
1090
+ const bufferIdx = (start + i) * channels;
1091
+ if (bufferIdx < buffer.length) {
1092
+ if (channels === 2) {
1093
+ if (bufferIdx < buffer.length)
1094
+ buffer[bufferIdx] += sample * gains.left;
1095
+ if (bufferIdx + 1 < buffer.length)
1096
+ buffer[bufferIdx + 1] += sample * gains.right;
1097
+ }
1098
+ else {
1099
+ buffer[bufferIdx] += sample; // mono
1100
+ }
1101
+ }
1102
+ }
1103
+ // Save phase and vibrato state for next note on this channel
1104
+ if (typeof channelId === 'number') {
1105
+ channelPhaseState.set(channelId, phase);
1106
+ channelVibratoPhase.set(channelId, vibratoPhase);
1107
+ // Save volume state (use sustained value if legato, otherwise current volMul)
1108
+ const finalVol = (volumeSustainValue !== undefined) ? volumeSustainValue : volMul;
1109
+ channelEnvelopeState.set(channelId, {
1110
+ time: durSec,
1111
+ lastValue: finalVol,
1112
+ mode: 'fixed'
1113
+ });
1114
+ }
1115
+ }
1116
+ /**
1117
+ * Renders a Game Boy noise channel.
1118
+ * Uses an LFSR (Linear Feedback Shift Register) to generate noise.
1119
+ *
1120
+ * @param buffer The target PCM buffer.
1121
+ * @param start The starting sample index.
1122
+ * @param duration The duration in samples.
1123
+ * @param inst The instrument definition containing noise parameters.
1124
+ * @param sampleRate The sample rate.
1125
+ * @param channels Number of audio channels.
1126
+ */
1127
+ function renderNoise(buffer, start, duration, inst, sampleRate, channels, gains = { left: 1, right: 1 }) {
1128
+ const envelope = parsePulseEnvelope(inst.env);
1129
+ const durSec = duration / sampleRate;
1130
+ // Game Boy noise parameters - support both plain and gb: prefixed properties
1131
+ const width = inst.width ? Number(inst.width) : (inst['gb:width'] ? Number(inst['gb:width']) : 15);
1132
+ const divisor = inst.divisor ? Number(inst.divisor) : (inst['gb:divisor'] ? Number(inst['gb:divisor']) : 3);
1133
+ const shift = inst.shift ? Number(inst.shift) : (inst['gb:shift'] ? Number(inst['gb:shift']) : 4);
1134
+ const GB_CLOCK = 4194304;
1135
+ // Calculate LFSR frequency (matches browser implementation)
1136
+ const div = Math.max(1, Number.isFinite(divisor) ? divisor : 3);
1137
+ const lfsrHz = GB_CLOCK / (div * Math.pow(2, (shift || 0) + 1));
1138
+ let phase = 0;
1139
+ let lfsr = 1;
1140
+ const is7bit = width === 7;
1141
+ // LFSR step function (matches browser)
1142
+ function stepLFSR(state) {
1143
+ const bit = ((state >> 0) ^ (state >> 1)) & 1;
1144
+ state = (state >> 1) | (bit << 14);
1145
+ if (is7bit) {
1146
+ const low7 = ((state >> 8) & 0x7F) >>> 0;
1147
+ const newLow7 = ((low7 >> 1) | ((low7 & 1) << 6)) & 0x7F;
1148
+ state = (state & ~(0x7F << 8)) | (newLow7 << 8);
1149
+ }
1150
+ return state >>> 0;
1151
+ }
1152
+ for (let i = 0; i < duration; i++) {
1153
+ const t = i / sampleRate;
1154
+ // Update LFSR at proper frequency
1155
+ phase += lfsrHz / sampleRate;
1156
+ const ticks = Math.floor(phase);
1157
+ if (ticks > 0) {
1158
+ for (let tick = 0; tick < ticks; tick++) {
1159
+ lfsr = stepLFSR(lfsr);
1160
+ }
1161
+ phase -= ticks;
1162
+ }
1163
+ const noise = (lfsr & 1) ? 1.0 : -1.0;
1164
+ const envVal = getEnvelopeValue(t, envelope, durSec);
1165
+ const sample = noise * envVal * 0.85; // slightly higher base to better match WebAudio output
1166
+ const bufferIdx = (start + i) * channels;
1167
+ if (bufferIdx < buffer.length) {
1168
+ if (channels === 2) {
1169
+ if (bufferIdx < buffer.length)
1170
+ buffer[bufferIdx] += sample * gains.left;
1171
+ if (bufferIdx + 1 < buffer.length)
1172
+ buffer[bufferIdx + 1] += sample * gains.right;
1173
+ }
1174
+ else {
1175
+ buffer[bufferIdx] += sample; // mono
1176
+ }
1177
+ }
1178
+ }
1179
+ }
1180
+ /**
1181
+ * Parses a wavetable definition into an array of 16 4-bit values (0-15).
1182
+ *
1183
+ * @param wave The wavetable definition (string or array).
1184
+ * @returns An array of 16 numbers representing the wavetable.
1185
+ */
1186
+ function parseWaveTable(wave) {
1187
+ if (typeof wave === 'string') {
1188
+ try {
1189
+ // Parse array string like "[0,3,6,9,12,9,6,3,0,3,6,9,12,9,6,3]"
1190
+ const parsed = JSON.parse(wave);
1191
+ if (Array.isArray(parsed)) {
1192
+ return parsed.map(v => Math.max(0, Math.min(15, v)));
1193
+ }
1194
+ }
1195
+ catch (e) {
1196
+ // Fall through to default
1197
+ }
1198
+ }
1199
+ else if (Array.isArray(wave)) {
1200
+ return wave.map(v => Math.max(0, Math.min(15, v)));
1201
+ }
1202
+ // Default sine-like wave
1203
+ return [0, 3, 6, 9, 12, 15, 12, 9, 6, 3, 0, 3, 6, 9, 12, 15];
1204
+ }
1205
+ /**
1206
+ * Parses an envelope definition into its components.
1207
+ * Supports "gb:initial,direction,period" and "initial,direction,period" formats.
1208
+ *
1209
+ * @param env The envelope definition.
1210
+ * @returns An object containing initial volume, direction, and period.
1211
+ */
1212
+ // Use the same envelope parsing as the WebAudio path (pulse.parseEnvelope)
1213
+ // and approximate ADSR/GB behavior in sample domain for parity.
1214
+ function getEnvelopeValue(t, envObj, dur) {
1215
+ if (!envObj)
1216
+ return 1;
1217
+ // GB-style envelope
1218
+ if (envObj.mode === 'gb' || typeof envObj.initial !== 'undefined' && typeof envObj.period !== 'undefined') {
1219
+ const initial = envObj.initial ?? envObj.level ?? 15;
1220
+ const period = envObj.period ?? envObj.step ?? 1;
1221
+ if (period === 0)
1222
+ return Math.max(0, Math.min(1, (initial / 15)));
1223
+ const stepDuration = period * (1 / 64); // same as WebAudio path
1224
+ const currentStep = Math.floor(t / stepDuration);
1225
+ let volume = envObj.direction === 'up' ? Math.min(15, (initial + currentStep)) : Math.max(0, (initial - currentStep));
1226
+ return Math.max(0, Math.min(1, volume / 15));
1227
+ }
1228
+ // ADSR-like envelope (mode 'adsr' or parsed ADSR object)
1229
+ const env = envObj;
1230
+ const attack = Math.max(0, env.attack ?? 0.001);
1231
+ const decay = Math.max(0.001, env.decay ?? 0.05);
1232
+ const sustain = Math.max(0, Math.min(1, env.sustainLevel ?? env.sustain ?? 0.5));
1233
+ const release = Math.max(0.001, env.release ?? 0.02);
1234
+ const attackLevel = env.attackLevel ?? 1.0;
1235
+ if (t < 0)
1236
+ return 0.0001;
1237
+ if (t < attack) {
1238
+ // exponential-like attack to better match WebAudio gain scheduling
1239
+ const tau = Math.max(attack, 1e-6) / 5;
1240
+ const x = 1 - Math.exp(-t / tau);
1241
+ return 0.0001 + (attackLevel - 0.0001) * x;
1242
+ }
1243
+ // value at attack
1244
+ const vAtAttack = attackLevel;
1245
+ const tAfterAttack = t - attack;
1246
+ // decay phase: exponential approach from vAtAttack to sustain with time constant = decay
1247
+ if (tAfterAttack < decay) {
1248
+ const x = tAfterAttack;
1249
+ return sustain + (vAtAttack - sustain) * Math.exp(-x / decay);
1250
+ }
1251
+ // sustain phase
1252
+ let current = sustain;
1253
+ // handle release if duration provided
1254
+ if (typeof dur === 'number') {
1255
+ const relStart = Math.max(0, dur - release);
1256
+ if (t >= relStart) {
1257
+ const vAtRelStart = (relStart <= attack) ? (relStart <= 0 ? 0.0001 : (0.0001 + (attackLevel - 0.0001) * (relStart / attack))) :
1258
+ (sustain + (vAtAttack - sustain) * Math.exp(-(relStart - attack) / decay));
1259
+ const relT = t - relStart;
1260
+ return Math.max(0.0001, 0.0001 + (vAtRelStart - 0.0001) * Math.exp(-relT / release));
1261
+ }
1262
+ }
1263
+ return current;
1264
+ }
1265
+ /**
1266
+ * Normalizes the audio buffer to a peak of 0.95.
1267
+ *
1268
+ * @param buffer The audio buffer to normalize.
1269
+ * @param force If true, always normalizes the buffer regardless of current peak level.
1270
+ * If false, only normalizes if the peak exceeds 0.95 (to prevent clipping).
1271
+ */
1272
+ function normalizeBuffer(buffer, force) {
1273
+ let max = 0;
1274
+ for (let i = 0; i < buffer.length; i++) {
1275
+ const abs = Math.abs(buffer[i]);
1276
+ if (abs > max)
1277
+ max = abs;
1278
+ }
1279
+ if (max > 0) {
1280
+ if (force || max > 0.95) {
1281
+ const scale = 0.95 / max;
1282
+ for (let i = 0; i < buffer.length; i++) {
1283
+ buffer[i] *= scale;
1284
+ }
1285
+ }
1286
+ }
1287
+ }
1288
+ //# sourceMappingURL=pcmRenderer.js.map