@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,794 @@
1
+ /**
2
+ * WebAudio-based playback for BeatBax (engine package).
3
+ */
4
+ import { playPulse as playPulseImpl, parseEnvelope as pulseParseEnvelope } from '../chips/gameboy/pulse.js';
5
+ import { playWavetable as playWavetableImpl, parseWaveTable } from '../chips/gameboy/wave.js';
6
+ import { playNoise as playNoiseImpl } from '../chips/gameboy/noise.js';
7
+ import { noteNameToMidi, midiToFreq } from '../chips/gameboy/apu.js';
8
+ import { error } from '../util/diag.js';
9
+ import createScheduler from '../scheduler/index.js';
10
+ import BufferedRenderer from './bufferedRenderer.js';
11
+ import { get as getEffect, clearEffectState } from '../effects/index.js';
12
+ export { midiToFreq, noteNameToMidi };
13
+ export { parseWaveTable };
14
+ export const parseEnvelope = pulseParseEnvelope;
15
+ export async function createAudioContext(opts = {}) {
16
+ const backend = opts.backend ?? 'auto';
17
+ // Try browser if requested and available
18
+ if (backend !== 'node-webaudio' && typeof window !== 'undefined' && globalThis.AudioContext) {
19
+ const Ctor = globalThis.AudioContext || globalThis.webkitAudioContext;
20
+ if (opts.offline && opts.duration) {
21
+ const OfflineAudioContextCtor = globalThis.OfflineAudioContext || globalThis.webkitOfflineAudioContext;
22
+ const sampleRate = opts.sampleRate ?? 44100;
23
+ const lengthInSamples = Math.ceil(opts.duration * sampleRate);
24
+ return new OfflineAudioContextCtor(2, lengthInSamples, sampleRate);
25
+ }
26
+ return new Ctor({ sampleRate: opts.sampleRate });
27
+ }
28
+ // Fallback to Node polyfill
29
+ if (backend !== 'browser') {
30
+ try {
31
+ const mod = await import('standardized-audio-context');
32
+ const { AudioContext, OfflineAudioContext } = mod;
33
+ if (opts.offline && opts.duration) {
34
+ const sampleRate = opts.sampleRate ?? 44100;
35
+ const lengthInSamples = Math.ceil(opts.duration * sampleRate);
36
+ return new OfflineAudioContext({ numberOfChannels: 2, length: lengthInSamples, sampleRate });
37
+ }
38
+ return new AudioContext({ sampleRate: opts.sampleRate ?? 44100 });
39
+ }
40
+ catch (error) {
41
+ if (backend === 'node-webaudio') {
42
+ throw new Error(`Failed to load 'standardized-audio-context'. Is it installed? (${error.message})`);
43
+ }
44
+ // If auto, we might just fail later if no context is found
45
+ }
46
+ }
47
+ throw new Error(`No compatible AudioContext found for backend: ${backend}`);
48
+ }
49
+ function playPulse(ctx, freq, duty, start, dur, inst, scheduler, destination) {
50
+ return playPulseImpl(ctx, freq, duty, start, dur, inst, scheduler, destination);
51
+ }
52
+ function playWavetable(ctx, freq, table, start, dur, inst, scheduler, destination) {
53
+ return playWavetableImpl(ctx, freq, table, start, dur, inst, scheduler, destination);
54
+ }
55
+ function playNoise(ctx, start, dur, inst, scheduler, destination) {
56
+ return playNoiseImpl(ctx, start, dur, inst, scheduler, destination);
57
+ }
58
+ export class Player {
59
+ ctx;
60
+ scheduler;
61
+ bpmDefault = 128;
62
+ masterGain = null;
63
+ activeNodes = [];
64
+ muted = new Set();
65
+ solo = null;
66
+ onSchedule;
67
+ _repeatTimer = null;
68
+ constructor(ctx, opts = {}) {
69
+ if (!ctx) {
70
+ const Ctor = (typeof window !== 'undefined' && window.AudioContext) ? window.AudioContext : globalThis.AudioContext;
71
+ if (!Ctor) {
72
+ throw new Error('No AudioContext constructor found. Please provide an AudioContext to the Player constructor or ensure one is available globally.');
73
+ }
74
+ this.ctx = new Ctor();
75
+ }
76
+ else {
77
+ this.ctx = ctx;
78
+ }
79
+ this.scheduler = createScheduler(this.ctx);
80
+ if (opts.buffered) {
81
+ this._buffered = new BufferedRenderer(this.ctx, this.scheduler, { segmentDuration: opts.segmentDuration, lookahead: opts.bufferedLookahead, maxPreRenderSegments: opts.maxPreRenderSegments });
82
+ }
83
+ }
84
+ async playAST(ast) {
85
+ try {
86
+ if (this.ctx && typeof this.ctx.resume === 'function') {
87
+ try {
88
+ const st = this.ctx.state;
89
+ if (st === 'suspended')
90
+ await this.ctx.resume();
91
+ }
92
+ catch (e) { }
93
+ }
94
+ }
95
+ catch (e) { }
96
+ // ensure a clean slate for each playback run
97
+ try {
98
+ this.stop();
99
+ }
100
+ catch (e) { }
101
+ // Create or update master gain node
102
+ // Default to 1.0 (matches hUGETracker behavior - no attenuation)
103
+ const masterVolume = ast.volume !== undefined ? ast.volume : 1.0;
104
+ if (!this.masterGain) {
105
+ this.masterGain = this.ctx.createGain();
106
+ this.masterGain.connect(this.ctx.destination);
107
+ }
108
+ this.masterGain.gain.setValueAtTime(masterVolume, this.ctx.currentTime);
109
+ const chip = ast.chip || 'gameboy';
110
+ if (chip !== 'gameboy') {
111
+ throw new Error(`Unsupported chip: ${chip}. Only 'gameboy' is supported at this time.`);
112
+ }
113
+ // Track estimated playback duration (seconds) across channels for repeat scheduling
114
+ let globalDurationSec = 0;
115
+ // Clone the instrument table to avoid in-place mutations during scheduling/playback
116
+ // Use structuredClone when available for correctness and performance, fallback to JSON clone.
117
+ const rootInsts = ast.insts || {};
118
+ const instsRootClone = (typeof globalThis.structuredClone === 'function')
119
+ ? globalThis.structuredClone(rootInsts)
120
+ : JSON.parse(JSON.stringify(rootInsts));
121
+ // Store chip info in context for effects to access (e.g., for chip-specific frame rates)
122
+ this.ctx._chipType = ast.chip || 'gameboy';
123
+ for (const ch of ast.channels || []) {
124
+ const instsMap = instsRootClone;
125
+ let currentInst = instsMap[ch.inst || ''];
126
+ const tokens = Array.isArray(ch.events) ? ch.events : (Array.isArray(ch.pat) ? ch.pat : ['.']);
127
+ let tempInst = null;
128
+ let tempRemaining = 0;
129
+ let bpm;
130
+ if (typeof ch.speed === 'number' && ast && typeof ast.bpm === 'number')
131
+ bpm = ast.bpm * ch.speed;
132
+ else
133
+ bpm = (ast && typeof ast.bpm === 'number') ? ast.bpm : this.bpmDefault;
134
+ const secondsPerBeat = 60 / bpm;
135
+ const tickSeconds = secondsPerBeat / 4;
136
+ const startTime = this.ctx.currentTime + 0.1;
137
+ // estimate channel duration in seconds from token count and ticks
138
+ let lastEndTimeForThisChannel = 0;
139
+ for (let i = 0; i < tokens.length; i++) {
140
+ const token = tokens[i];
141
+ const t = startTime + i * tickSeconds;
142
+ if (token && typeof token === 'object' && token.type) {
143
+ if (token.type === 'rest' || token.type === 'sustain') {
144
+ // ignore explicit rest/sustain objects here
145
+ }
146
+ else {
147
+ let sustainCount = 0;
148
+ for (let j = i + 1; j < tokens.length; j++) {
149
+ const next = tokens[j];
150
+ if (next && typeof next === 'object' && next.type === 'sustain')
151
+ sustainCount++;
152
+ else if (next === '_' || next === '-')
153
+ sustainCount++;
154
+ else
155
+ break;
156
+ }
157
+ const dur = tickSeconds * (1 + sustainCount);
158
+ if (token.type === 'named') {
159
+ const instProps = token.instProps || instsMap[token.instrument] || null;
160
+ // For noise instruments, always use instrument name for lookup
161
+ // For pulse/wave with defaultNote, use the specified note
162
+ const isNoise = instProps && instProps.type && String(instProps.type).toLowerCase().includes('noise');
163
+ const tokenToPlay = (isNoise || !token.defaultNote)
164
+ ? (token.token || token.instrument)
165
+ : token.defaultNote;
166
+ this.scheduleToken(ch.id, instProps, instsMap, tokenToPlay, t, dur, tickSeconds);
167
+ }
168
+ else if (token.type === 'note') {
169
+ const instProps = token.instProps || (tempRemaining > 0 && tempInst ? tempInst : currentInst);
170
+ // Pass the full token object so scheduleToken can honour inline pan/effects
171
+ this.scheduleToken(ch.id, instProps, instsMap, token, t, dur, tickSeconds);
172
+ if (tempRemaining > 0) {
173
+ tempRemaining -= 1;
174
+ if (tempRemaining <= 0) {
175
+ tempInst = null;
176
+ tempRemaining = 0;
177
+ }
178
+ }
179
+ }
180
+ lastEndTimeForThisChannel = Math.max(lastEndTimeForThisChannel, t + dur);
181
+ }
182
+ continue;
183
+ }
184
+ if (token === '_' || token === '-')
185
+ continue;
186
+ const mInstInline = typeof token === 'string' && token.match(/^inst\(([^,()\s]+)(?:,(\d+))?\)$/i);
187
+ if (mInstInline) {
188
+ const name = mInstInline[1];
189
+ const count = mInstInline[2] ? parseInt(mInstInline[2], 10) : null;
190
+ const resolved = instsMap[name];
191
+ if (count && resolved) {
192
+ tempInst = resolved;
193
+ tempRemaining = count;
194
+ }
195
+ else if (resolved) {
196
+ currentInst = resolved;
197
+ }
198
+ continue;
199
+ }
200
+ const useInst = tempRemaining > 0 && tempInst ? tempInst : currentInst;
201
+ // Calculate duration by looking ahead for sustains
202
+ let sustainCount = 0;
203
+ for (let j = i + 1; j < tokens.length; j++) {
204
+ const next = tokens[j];
205
+ if (next && typeof next === 'object' && next.type === 'sustain')
206
+ sustainCount++;
207
+ else if (next === '_' || next === '-')
208
+ sustainCount++;
209
+ else
210
+ break;
211
+ }
212
+ const dur = tickSeconds * (1 + sustainCount);
213
+ this.scheduleToken(ch.id, useInst, instsMap, token, t, dur, tickSeconds);
214
+ lastEndTimeForThisChannel = Math.max(lastEndTimeForThisChannel, t + dur);
215
+ if (tempRemaining > 0 && token !== '.') {
216
+ tempRemaining -= 1;
217
+ if (tempRemaining <= 0) {
218
+ tempInst = null;
219
+ tempRemaining = 0;
220
+ }
221
+ }
222
+ }
223
+ // convert channel end (absolute) into a channel duration relative to startTime
224
+ const channelDuration = lastEndTimeForThisChannel > 0 ? (lastEndTimeForThisChannel - (this.ctx.currentTime + 0.1)) : (tokens.length * tickSeconds);
225
+ globalDurationSec = Math.max(globalDurationSec, channelDuration);
226
+ }
227
+ // start the scheduler to begin firing scheduled events
228
+ this.scheduler.start();
229
+ // debug: show estimated global duration for repeat scheduling
230
+ try {
231
+ console.debug('[player] estimated globalDurationSec=', globalDurationSec);
232
+ }
233
+ catch (e) { }
234
+ // If AST requests repeat/looping, schedule a restart when playback ends
235
+ try {
236
+ if (ast.play?.repeat) {
237
+ const delayMs = Math.max(10, Math.round(globalDurationSec * 1000) + 50);
238
+ try {
239
+ console.debug('[player] scheduling repeat in ms=', delayMs);
240
+ }
241
+ catch (e) { }
242
+ if (this._repeatTimer)
243
+ clearTimeout(this._repeatTimer);
244
+ this._repeatTimer = setTimeout(() => {
245
+ try {
246
+ try {
247
+ console.debug('[player] repeat timer fired - restarting playback');
248
+ }
249
+ catch (e) { }
250
+ this.stop();
251
+ // replay AST (fire-and-forget)
252
+ this.playAST(ast).catch((e) => { error('player', 'Repeat playback failed: ' + (e && e.message ? e.message : String(e))); });
253
+ }
254
+ catch (e) { }
255
+ }, delayMs);
256
+ }
257
+ }
258
+ catch (e) { }
259
+ }
260
+ scheduleToken(chId, inst, instsMap, token, time, dur, tickSeconds) {
261
+ if (token === '.')
262
+ return;
263
+ if (instsMap && typeof token === 'string' && instsMap[token]) {
264
+ const alt = instsMap[token];
265
+ if (alt.type && String(alt.type).toLowerCase().includes('noise')) {
266
+ try {
267
+ if (typeof this.onSchedule === 'function') {
268
+ this.onSchedule({ chId, inst: alt, token, time, dur });
269
+ }
270
+ }
271
+ catch (e) { }
272
+ this.scheduler.schedule(time, () => {
273
+ if (this.solo !== null && this.solo !== chId)
274
+ return;
275
+ if (this.muted.has(chId))
276
+ return;
277
+ const nodes = playNoise(this.ctx, time, dur, alt, this.scheduler, this.masterGain || undefined);
278
+ for (const n of nodes)
279
+ this.activeNodes.push({ node: n, chId });
280
+ });
281
+ return;
282
+ }
283
+ inst = alt;
284
+ }
285
+ if (!inst)
286
+ return;
287
+ try {
288
+ if (typeof this.onSchedule === 'function') {
289
+ this.onSchedule({ chId, inst, token, time, dur });
290
+ }
291
+ }
292
+ catch (e) { }
293
+ // token may be a string like "C4" or an object with { type: 'note', token: 'C4', pan, effects }
294
+ let tokenStr = typeof token === 'string' ? token : (token && token.token ? token.token : '');
295
+ // compute pan if present: inline token pan takes precedence; inst pan as fallback
296
+ const panVal = (token && token.pan) ? token.pan : (inst && (inst['gb:pan'] || inst['pan']) ? inst['gb:pan'] || inst['pan'] : undefined);
297
+ const m = (typeof tokenStr === 'string' && tokenStr.match(/^([A-G][#B]?)(-?\d+)$/i)) || null;
298
+ if (m) {
299
+ const note = m[1].toUpperCase();
300
+ const octave = parseInt(m[2], 10);
301
+ const midi = noteNameToMidi(note, octave);
302
+ if (midi === null)
303
+ return;
304
+ const freq = midiToFreq(midi);
305
+ if (inst.type && inst.type.toLowerCase().includes('pulse')) {
306
+ const duty = inst.duty ? parseFloat(inst.duty) / 100 : 0.5;
307
+ const buffered = this._buffered;
308
+ if (buffered) {
309
+ // For buffered rendering, attach pan info into queued item for later panning processing
310
+ buffered.enqueuePulse(time, freq, duty, dur, inst, chId, panVal);
311
+ }
312
+ else {
313
+ const capturedInst = inst;
314
+ this.scheduler.schedule(time, () => {
315
+ if (this.solo !== null && this.solo !== chId)
316
+ return;
317
+ if (this.muted.has(chId))
318
+ return;
319
+ const nodes = playPulse(this.ctx, freq, duty, time, dur, capturedInst, this.scheduler, this.masterGain || undefined);
320
+ // apply inline token.effects first (e.g. C4<pan:-1>) then fallback to inline pan/inst pan
321
+ this.tryApplyEffects(this.ctx, nodes, token && token.effects ? token.effects : [], time, dur, chId, tickSeconds, capturedInst);
322
+ // Apply panning first, before echo/retrigger, so panner is inserted before echo routing
323
+ this.tryApplyPan(this.ctx, nodes, panVal);
324
+ this.tryScheduleEcho(nodes);
325
+ this.tryScheduleRetriggers(nodes, freq, capturedInst, chId, token, tickSeconds, panVal);
326
+ for (const n of nodes)
327
+ this.activeNodes.push({ node: n, chId });
328
+ });
329
+ }
330
+ }
331
+ else if (inst.type && inst.type.toLowerCase().includes('wave')) {
332
+ const wav = parseWaveTable(inst.wave);
333
+ const buffered = this._buffered;
334
+ if (buffered) {
335
+ buffered.enqueueWavetable(time, freq, wav, dur, inst, chId, panVal);
336
+ }
337
+ else {
338
+ const capturedInst = inst;
339
+ this.scheduler.schedule(time, () => {
340
+ if (this.solo !== null && this.solo !== chId)
341
+ return;
342
+ if (this.muted.has(chId))
343
+ return;
344
+ const nodes = playWavetable(this.ctx, freq, wav, time, dur, capturedInst, this.scheduler, this.masterGain || undefined);
345
+ this.tryApplyEffects(this.ctx, nodes, token && token.effects ? token.effects : [], time, dur, chId, tickSeconds, capturedInst);
346
+ // Apply panning first, before echo/retrigger, so panner is inserted before echo routing
347
+ this.tryApplyPan(this.ctx, nodes, panVal);
348
+ this.tryScheduleEcho(nodes);
349
+ this.tryScheduleRetriggers(nodes, freq, capturedInst, chId, token, tickSeconds, panVal);
350
+ for (const n of nodes)
351
+ this.activeNodes.push({ node: n, chId });
352
+ });
353
+ }
354
+ }
355
+ else if (inst.type && inst.type.toLowerCase().includes('noise')) {
356
+ const buffered = this._buffered;
357
+ if (buffered) {
358
+ buffered.enqueueNoise(time, dur, inst, chId, panVal);
359
+ }
360
+ else {
361
+ this.scheduler.schedule(time, () => {
362
+ if (this.solo !== null && this.solo !== chId)
363
+ return;
364
+ if (this.muted.has(chId))
365
+ return;
366
+ const nodes = playNoise(this.ctx, time, dur, inst, this.scheduler, this.masterGain || undefined);
367
+ this.tryApplyEffects(this.ctx, nodes, token && token.effects ? token.effects : [], time, dur, chId, tickSeconds);
368
+ // Apply panning first, before echo/retrigger, so panner is inserted before echo routing
369
+ this.tryApplyPan(this.ctx, nodes, panVal);
370
+ this.tryScheduleEcho(nodes);
371
+ this.tryScheduleRetriggers(nodes, 0, inst, chId, token, tickSeconds, panVal);
372
+ for (const n of nodes)
373
+ this.activeNodes.push({ node: n, chId });
374
+ });
375
+ }
376
+ }
377
+ }
378
+ else {
379
+ if (inst.type && inst.type.toLowerCase().includes('noise')) {
380
+ this.scheduler.schedule(time, () => {
381
+ if (this.solo !== null && this.solo !== chId)
382
+ return;
383
+ if (this.muted.has(chId))
384
+ return;
385
+ const nodes = playNoise(this.ctx, time, dur, inst, this.scheduler, this.masterGain || undefined);
386
+ this.tryApplyPan(this.ctx, nodes, panVal);
387
+ for (const n of nodes)
388
+ this.activeNodes.push({ node: n, chId });
389
+ });
390
+ }
391
+ }
392
+ }
393
+ // Apply registered effects for a scheduled note. `effectsArr` may be an array of
394
+ // objects { type, params } produced by the parser (or legacy arrays). This will
395
+ // look up handlers in the effects registry and invoke them.
396
+ tryApplyEffects(ctx, nodes, effectsArr, start, dur, chId, tickSeconds, inst) {
397
+ if (!Array.isArray(effectsArr) || effectsArr.length === 0)
398
+ return;
399
+ for (const fx of effectsArr) {
400
+ try {
401
+ const name = fx && fx.type ? fx.type : fx;
402
+ // Prefer resolver-provided durationSec when available; inject into params[3]
403
+ let params = fx && fx.params ? fx.params : (Array.isArray(fx) ? fx : []);
404
+ if (fx && typeof fx.durationSec === 'number') {
405
+ const pcopy = Array.isArray(params) ? params.slice() : [];
406
+ pcopy[3] = fx.durationSec;
407
+ params = pcopy;
408
+ }
409
+ const handler = getEffect(name);
410
+ if (handler) {
411
+ try {
412
+ handler(ctx, nodes, params, start, dur, chId, tickSeconds, inst);
413
+ }
414
+ catch (e) { }
415
+ }
416
+ }
417
+ catch (e) { }
418
+ }
419
+ }
420
+ // Try to apply per-note panning. `nodes` is the array returned by play* functions
421
+ // which typically is [oscillatorNode, gainNode]. We attempt to insert a StereoPannerNode
422
+ // between the gain and the destination when available. `panSpec` may be:
423
+ // - an object { enum: 'L'|'R'|'C' } or { value: number }
424
+ // - a raw number or string
425
+ // Schedule retriggered notes if retrigger effect was applied.
426
+ // The retrigger effect handler stores metadata on the nodes array that we read here.
427
+ tryScheduleRetriggers(nodes, freq, inst, chId, token, tickSeconds, panVal) {
428
+ const retrigMeta = nodes.__retrigger;
429
+ if (!retrigMeta)
430
+ return;
431
+ const { interval, volumeDelta, tickDuration, start, dur } = retrigMeta;
432
+ const intervalSec = interval * tickDuration;
433
+ // Schedule retriggered notes at each interval
434
+ let retrigTime = start + intervalSec;
435
+ let volMultiplier = 1.0;
436
+ while (retrigTime < start + dur) {
437
+ // Apply volume delta for fadeout/fadein effect
438
+ // volumeDelta is in Game Boy envelope units (-15 to +15, typically -2 to -5 for fadeout)
439
+ // Normalized to 0-1 range by dividing by 15, so -2 = -0.133 per retrigger
440
+ // Example: -2 delta over 8 retrigs = 8 × -0.133 = -1.064 total (full fadeout)
441
+ if (volumeDelta !== 0) {
442
+ volMultiplier = Math.max(0, Math.min(1, volMultiplier + (volumeDelta / 15)));
443
+ }
444
+ // Create modified instrument with adjusted envelope/volume
445
+ const retrigInst = { ...inst };
446
+ if (retrigInst.env) {
447
+ const envParts = String(retrigInst.env).split(',');
448
+ if (envParts.length > 0) {
449
+ const envLevel = Math.max(0, Math.min(15, Math.round(parseFloat(envParts[0]) * volMultiplier)));
450
+ retrigInst.env = `${envLevel},${envParts.slice(1).join(',')}`;
451
+ }
452
+ }
453
+ // Calculate remaining duration for this retrig
454
+ const retrigDur = Math.min(intervalSec, start + dur - retrigTime);
455
+ const capturedTime = retrigTime;
456
+ const capturedInst = retrigInst;
457
+ const capturedToken = token;
458
+ // Schedule the retriggered note
459
+ if (inst.type && inst.type.toLowerCase().includes('pulse')) {
460
+ const duty = inst.duty ? parseFloat(inst.duty) / 100 : 0.5;
461
+ this.scheduler.schedule(capturedTime, () => {
462
+ if (this.solo !== null && this.solo !== chId)
463
+ return;
464
+ if (this.muted.has(chId))
465
+ return;
466
+ const retrigNodes = playPulse(this.ctx, freq, duty, capturedTime, retrigDur, capturedInst, this.scheduler, this.masterGain || undefined);
467
+ // Don't apply retrigger effect recursively, but apply other effects
468
+ const effectsWithoutRetrig = (capturedToken && capturedToken.effects ? capturedToken.effects : []).filter((fx) => {
469
+ const fxType = fx && fx.type ? fx.type : fx;
470
+ return fxType !== 'retrig';
471
+ });
472
+ this.tryApplyEffects(this.ctx, retrigNodes, effectsWithoutRetrig, capturedTime, retrigDur, chId, tickSeconds, capturedInst);
473
+ this.tryApplyPan(this.ctx, retrigNodes, panVal);
474
+ for (const n of retrigNodes)
475
+ this.activeNodes.push({ node: n, chId });
476
+ });
477
+ }
478
+ else if (inst.type && inst.type.toLowerCase().includes('wave')) {
479
+ const wav = parseWaveTable(capturedInst.wave);
480
+ this.scheduler.schedule(capturedTime, () => {
481
+ if (this.solo !== null && this.solo !== chId)
482
+ return;
483
+ if (this.muted.has(chId))
484
+ return;
485
+ const retrigNodes = playWavetable(this.ctx, freq, wav, capturedTime, retrigDur, capturedInst, this.scheduler, this.masterGain || undefined);
486
+ const effectsWithoutRetrig = (capturedToken && capturedToken.effects ? capturedToken.effects : []).filter((fx) => {
487
+ const fxType = fx && fx.type ? fx.type : fx;
488
+ return fxType !== 'retrig';
489
+ });
490
+ this.tryApplyEffects(this.ctx, retrigNodes, effectsWithoutRetrig, capturedTime, retrigDur, chId, tickSeconds, capturedInst);
491
+ this.tryApplyPan(this.ctx, retrigNodes, panVal);
492
+ for (const n of retrigNodes)
493
+ this.activeNodes.push({ node: n, chId });
494
+ });
495
+ }
496
+ else if (inst.type && inst.type.toLowerCase().includes('noise')) {
497
+ this.scheduler.schedule(capturedTime, () => {
498
+ if (this.solo !== null && this.solo !== chId)
499
+ return;
500
+ if (this.muted.has(chId))
501
+ return;
502
+ const retrigNodes = playNoise(this.ctx, capturedTime, retrigDur, capturedInst, this.scheduler, this.masterGain || undefined);
503
+ const effectsWithoutRetrig = (capturedToken && capturedToken.effects ? capturedToken.effects : []).filter((fx) => {
504
+ const fxType = fx && fx.type ? fx.type : fx;
505
+ return fxType !== 'retrig';
506
+ });
507
+ this.tryApplyEffects(this.ctx, retrigNodes, effectsWithoutRetrig, capturedTime, retrigDur, chId, tickSeconds, capturedInst);
508
+ this.tryApplyPan(this.ctx, retrigNodes, panVal);
509
+ for (const n of retrigNodes)
510
+ this.activeNodes.push({ node: n, chId });
511
+ });
512
+ }
513
+ retrigTime += intervalSec;
514
+ }
515
+ }
516
+ // Schedule echo/delay effect if echo metadata was stored on the nodes array.
517
+ // The echo effect handler stores metadata that we use here to create the delay routing.
518
+ tryScheduleEcho(nodes) {
519
+ const echoMeta = nodes.__echo;
520
+ if (!echoMeta)
521
+ return;
522
+ const { delayTime, feedback, mix, start, dur } = echoMeta;
523
+ try {
524
+ // Find the gain node (typically nodes[1])
525
+ const gainNode = nodes.length > 1 ? nodes[1] : nodes[0];
526
+ if (!gainNode || !gainNode.connect)
527
+ return;
528
+ // Create delay effect nodes
529
+ const delayNode = this.ctx.createDelay(Math.max(5.0, delayTime * 4));
530
+ const feedbackGain = this.ctx.createGain();
531
+ const wetGain = this.ctx.createGain();
532
+ const dryGain = this.ctx.createGain();
533
+ // Set parameters
534
+ // mix controls wet/dry balance: mix=0 (all dry), mix=1 (all wet)
535
+ const wetLevel = mix;
536
+ const dryLevel = 1 - mix;
537
+ try {
538
+ delayNode.delayTime.setValueAtTime(delayTime, start);
539
+ feedbackGain.gain.setValueAtTime(feedback, start);
540
+ wetGain.gain.setValueAtTime(wetLevel, start);
541
+ dryGain.gain.setValueAtTime(dryLevel, start);
542
+ }
543
+ catch (_) {
544
+ delayNode.delayTime.value = delayTime;
545
+ feedbackGain.gain.value = feedback;
546
+ wetGain.gain.value = wetLevel;
547
+ dryGain.gain.value = dryLevel;
548
+ }
549
+ // Find the destination (use masterGain if available)
550
+ const destination = this.masterGain || this.ctx.destination;
551
+ // Disconnect gainNode from its current destination to avoid double-routing
552
+ try {
553
+ gainNode.disconnect();
554
+ }
555
+ catch (_) {
556
+ // Already disconnected or no connections
557
+ }
558
+ // Create proper echo routing with separate dry/wet paths:
559
+ // Dry path: gainNode -> dryGain -> destination
560
+ // Wet path: gainNode -> delayNode -> wetGain -> destination
561
+ // Feedback loop: delayNode -> feedbackGain -> delayNode (internal)
562
+ // Connect dry path
563
+ gainNode.connect(dryGain);
564
+ dryGain.connect(destination);
565
+ // Connect to delay input
566
+ gainNode.connect(delayNode);
567
+ // Connect feedback loop: delay -> feedbackGain -> back to delay input
568
+ delayNode.connect(feedbackGain);
569
+ feedbackGain.connect(delayNode);
570
+ // Connect wet signal: delay -> wetGain -> destination
571
+ delayNode.connect(wetGain);
572
+ wetGain.connect(destination);
573
+ // Track all echo nodes for proper cleanup
574
+ this.activeNodes.push({ node: delayNode, chId: -1 });
575
+ this.activeNodes.push({ node: feedbackGain, chId: -1 });
576
+ this.activeNodes.push({ node: wetGain, chId: -1 });
577
+ this.activeNodes.push({ node: dryGain, chId: -1 });
578
+ // Schedule cleanup after the echo tail has died out
579
+ // Use logarithmic decay formula to calculate tail duration:
580
+ // Time for signal to decay to 1/1000 of original level (-60dB)
581
+ // For feedback close to 1.0, this prevents infinite/excessive durations
582
+ let tailDuration;
583
+ if (feedback < 0.001) {
584
+ // Very low feedback - tail dies out quickly (just one repeat)
585
+ tailDuration = delayTime * 2;
586
+ }
587
+ else if (feedback >= 0.999) {
588
+ // Very high feedback - cap to prevent excessive duration
589
+ tailDuration = Math.min(10.0, delayTime * 20);
590
+ }
591
+ else {
592
+ // Calculate decay time using logarithmic formula
593
+ // Math.log(1000) ≈ 6.9, which represents -60dB decay
594
+ const decayTime = (delayTime * Math.log(1000)) / Math.log(1 / feedback);
595
+ // Cap to reasonable maximum (10 seconds) to prevent excessive memory usage
596
+ tailDuration = Math.min(10.0, decayTime);
597
+ }
598
+ const cleanupTime = start + dur + tailDuration;
599
+ // Schedule proper cleanup: ramp gain to zero, then disconnect all nodes
600
+ this.scheduler.schedule(cleanupTime - 0.1, () => {
601
+ try {
602
+ // Ramp feedback to zero over 100ms to avoid clicks
603
+ feedbackGain.gain.setValueAtTime(feedback, this.ctx.currentTime);
604
+ feedbackGain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + 0.1);
605
+ }
606
+ catch (_) {
607
+ // Scheduling failed, proceed to disconnect anyway
608
+ }
609
+ });
610
+ // Schedule node disconnection after fade-out completes
611
+ this.scheduler.schedule(cleanupTime, () => {
612
+ try {
613
+ delayNode.disconnect();
614
+ feedbackGain.disconnect();
615
+ wetGain.disconnect();
616
+ dryGain.disconnect();
617
+ }
618
+ catch (_) {
619
+ // Already disconnected or GC'd
620
+ }
621
+ });
622
+ }
623
+ catch (e) {
624
+ // Echo routing failed, skip silently
625
+ }
626
+ }
627
+ tryApplyPan(ctx, nodes, panSpec) {
628
+ if (!panSpec)
629
+ return;
630
+ let p = undefined;
631
+ if (typeof panSpec === 'number')
632
+ p = Math.max(-1, Math.min(1, panSpec));
633
+ else if (typeof panSpec === 'string') {
634
+ const s = panSpec.toUpperCase();
635
+ if (s === 'L')
636
+ p = -1;
637
+ else if (s === 'R')
638
+ p = 1;
639
+ else if (s === 'C')
640
+ p = 0;
641
+ else {
642
+ const n = Number(panSpec);
643
+ if (!Number.isNaN(n))
644
+ p = Math.max(-1, Math.min(1, n));
645
+ }
646
+ }
647
+ else if (typeof panSpec === 'object') {
648
+ if (panSpec.value !== undefined)
649
+ p = Math.max(-1, Math.min(1, Number(panSpec.value)));
650
+ else if (panSpec.enum) {
651
+ const s = String(panSpec.enum).toUpperCase();
652
+ if (s === 'L')
653
+ p = -1;
654
+ else if (s === 'R')
655
+ p = 1;
656
+ else
657
+ p = 0;
658
+ }
659
+ }
660
+ if (p === undefined)
661
+ return;
662
+ try {
663
+ const gain = nodes && nodes.length >= 2 ? nodes[1] : null;
664
+ if (!gain || typeof gain.connect !== 'function')
665
+ return;
666
+ // Determine the actual destination (masterGain if available, otherwise ctx.destination)
667
+ const dest = this.masterGain || ctx.destination;
668
+ // create StereoPannerNode if available
669
+ const createPanner = ctx.createStereoPanner;
670
+ if (typeof createPanner === 'function') {
671
+ const panner = ctx.createStereoPanner();
672
+ try {
673
+ panner.pan.setValueAtTime(p, ctx.currentTime);
674
+ }
675
+ catch (e) {
676
+ try {
677
+ panner.pan.value = p;
678
+ }
679
+ catch (e2) { }
680
+ }
681
+ // Disconnect from all destinations (handles both masterGain and ctx.destination cases)
682
+ try {
683
+ gain.disconnect();
684
+ }
685
+ catch (e) { }
686
+ gain.connect(panner);
687
+ panner.connect(dest);
688
+ // also track panner node so stop/cleanup will disconnect it
689
+ this.activeNodes.push({ node: panner, chId: -1 });
690
+ }
691
+ else {
692
+ // StereoPanner not available — best-effort: do nothing or optionally implement left/right gains
693
+ // For now, we silently skip (no pan) to avoid complex signal routing.
694
+ }
695
+ }
696
+ catch (e) {
697
+ // swallow errors — panning is best-effort
698
+ }
699
+ }
700
+ stop() {
701
+ if (this._repeatTimer) {
702
+ try {
703
+ clearTimeout(this._repeatTimer);
704
+ }
705
+ catch (e) { }
706
+ this._repeatTimer = null;
707
+ }
708
+ if (this.scheduler) {
709
+ this.scheduler.clear();
710
+ this.scheduler.stop();
711
+ }
712
+ // Clear effect state (e.g., portamento frequency tracking)
713
+ clearEffectState();
714
+ for (const entry of this.activeNodes) {
715
+ try {
716
+ if (entry.node && typeof entry.node.stop === 'function')
717
+ entry.node.stop();
718
+ }
719
+ catch (e) { }
720
+ try {
721
+ if (entry.node && typeof entry.node.disconnect === 'function')
722
+ entry.node.disconnect();
723
+ }
724
+ catch (e) { }
725
+ }
726
+ this.activeNodes = [];
727
+ try {
728
+ const buffered = this._buffered;
729
+ if (buffered && typeof buffered.drainScheduledNodes === 'function') {
730
+ const nodes = buffered.drainScheduledNodes();
731
+ for (const n of nodes) {
732
+ try {
733
+ if (n.src && typeof n.src.stop === 'function')
734
+ n.src.stop();
735
+ }
736
+ catch (_) { }
737
+ try {
738
+ if (n.src && typeof n.src.disconnect === 'function')
739
+ n.src.disconnect();
740
+ }
741
+ catch (_) { }
742
+ try {
743
+ if (n.gain && typeof n.gain.disconnect === 'function')
744
+ n.gain.disconnect();
745
+ }
746
+ catch (_) { }
747
+ }
748
+ }
749
+ }
750
+ catch (e) { }
751
+ }
752
+ toggleChannelMute(chId) {
753
+ if (this.muted.has(chId))
754
+ this.muted.delete(chId);
755
+ else
756
+ this.muted.add(chId);
757
+ }
758
+ toggleChannelSolo(chId) {
759
+ if (this.solo === chId)
760
+ this.solo = null;
761
+ else
762
+ this.solo = chId;
763
+ }
764
+ stopChannel(chId) {
765
+ const keep = [];
766
+ for (const entry of this.activeNodes) {
767
+ if (entry.chId === chId) {
768
+ try {
769
+ if (entry.node && typeof entry.node.stop === 'function')
770
+ entry.node.stop();
771
+ }
772
+ catch (e) { }
773
+ try {
774
+ if (entry.node && typeof entry.node.disconnect === 'function')
775
+ entry.node.disconnect();
776
+ }
777
+ catch (e) { }
778
+ }
779
+ else {
780
+ keep.push(entry);
781
+ }
782
+ }
783
+ this.activeNodes = keep;
784
+ try {
785
+ const buffered = this._buffered;
786
+ if (buffered && typeof buffered.stop === 'function') {
787
+ buffered.stop(chId);
788
+ }
789
+ }
790
+ catch (e) { }
791
+ }
792
+ }
793
+ export default Player;
794
+ //# sourceMappingURL=playback.js.map