@beatbax/engine 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/.tsbuildinfo +1 -0
- package/dist/audio/bufferedRenderer.d.ts +32 -0
- package/dist/audio/bufferedRenderer.d.ts.map +1 -0
- package/dist/audio/bufferedRenderer.js +309 -0
- package/dist/audio/bufferedRenderer.js.map +1 -0
- package/dist/audio/pcmRenderer.d.ts +25 -0
- package/dist/audio/pcmRenderer.d.ts.map +1 -0
- package/dist/audio/pcmRenderer.js +1288 -0
- package/dist/audio/pcmRenderer.js.map +1 -0
- package/dist/audio/playback.d.ts +57 -0
- package/dist/audio/playback.d.ts.map +1 -0
- package/dist/audio/playback.js +794 -0
- package/dist/audio/playback.js.map +1 -0
- package/dist/chips/gameboy/apu.d.ts +9 -0
- package/dist/chips/gameboy/apu.d.ts.map +1 -0
- package/dist/chips/gameboy/apu.js +27 -0
- package/dist/chips/gameboy/apu.js.map +1 -0
- package/dist/chips/gameboy/noise.d.ts +6 -0
- package/dist/chips/gameboy/noise.d.ts.map +1 -0
- package/dist/chips/gameboy/noise.js +155 -0
- package/dist/chips/gameboy/noise.js.map +1 -0
- package/dist/chips/gameboy/packet.d.ts +3 -0
- package/dist/chips/gameboy/packet.d.ts.map +1 -0
- package/dist/chips/gameboy/packet.js +3 -0
- package/dist/chips/gameboy/packet.js.map +1 -0
- package/dist/chips/gameboy/periodTables.d.ts +16 -0
- package/dist/chips/gameboy/periodTables.d.ts.map +1 -0
- package/dist/chips/gameboy/periodTables.js +29 -0
- package/dist/chips/gameboy/periodTables.js.map +1 -0
- package/dist/chips/gameboy/pulse.d.ts +12 -0
- package/dist/chips/gameboy/pulse.d.ts.map +1 -0
- package/dist/chips/gameboy/pulse.js +275 -0
- package/dist/chips/gameboy/pulse.js.map +1 -0
- package/dist/chips/gameboy/wave.d.ts +8 -0
- package/dist/chips/gameboy/wave.d.ts.map +1 -0
- package/dist/chips/gameboy/wave.js +146 -0
- package/dist/chips/gameboy/wave.js.map +1 -0
- package/dist/effects/index.d.ts +7 -0
- package/dist/effects/index.d.ts.map +1 -0
- package/dist/effects/index.js +1028 -0
- package/dist/effects/index.js.map +1 -0
- package/dist/effects/types.d.ts +8 -0
- package/dist/effects/types.d.ts.map +1 -0
- package/dist/effects/types.js +2 -0
- package/dist/effects/types.js.map +1 -0
- package/dist/expand/refExpander.d.ts +14 -0
- package/dist/expand/refExpander.d.ts.map +1 -0
- package/dist/expand/refExpander.js +130 -0
- package/dist/expand/refExpander.js.map +1 -0
- package/dist/export/index.d.ts +5 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +5 -0
- package/dist/export/index.js.map +1 -0
- package/dist/export/jsonExport.d.ts +9 -0
- package/dist/export/jsonExport.d.ts.map +1 -0
- package/dist/export/jsonExport.js +184 -0
- package/dist/export/jsonExport.js.map +1 -0
- package/dist/export/midiExport.d.ts +9 -0
- package/dist/export/midiExport.d.ts.map +1 -0
- package/dist/export/midiExport.js +390 -0
- package/dist/export/midiExport.js.map +1 -0
- package/dist/export/ugeWriter.d.ts +33 -0
- package/dist/export/ugeWriter.d.ts.map +1 -0
- package/dist/export/ugeWriter.js +1997 -0
- package/dist/export/ugeWriter.js.map +1 -0
- package/dist/export/wavWriter.d.ts +24 -0
- package/dist/export/wavWriter.d.ts.map +1 -0
- package/dist/export/wavWriter.js +126 -0
- package/dist/export/wavWriter.js.map +1 -0
- package/dist/import/index.d.ts +5 -0
- package/dist/import/index.d.ts.map +1 -0
- package/dist/import/index.js +8 -0
- package/dist/import/index.js.map +1 -0
- package/dist/import/remoteCache.d.ts +60 -0
- package/dist/import/remoteCache.d.ts.map +1 -0
- package/dist/import/remoteCache.js +194 -0
- package/dist/import/remoteCache.js.map +1 -0
- package/dist/import/uge/uge.reader.d.ts +27 -0
- package/dist/import/uge/uge.reader.d.ts.map +1 -0
- package/dist/import/uge/uge.reader.js +545 -0
- package/dist/import/uge/uge.reader.js.map +1 -0
- package/dist/import/urlUtils.d.ts +50 -0
- package/dist/import/urlUtils.d.ts.map +1 -0
- package/dist/import/urlUtils.js +87 -0
- package/dist/import/urlUtils.js.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +318 -0
- package/dist/index.js.map +1 -0
- package/dist/instruments/instrumentState.d.ts +15 -0
- package/dist/instruments/instrumentState.d.ts.map +1 -0
- package/dist/instruments/instrumentState.js +24 -0
- package/dist/instruments/instrumentState.js.map +1 -0
- package/dist/parser/ast.d.ts +22 -0
- package/dist/parser/ast.d.ts.map +1 -0
- package/dist/parser/ast.js +5 -0
- package/dist/parser/ast.js.map +1 -0
- package/dist/parser/index.d.ts +14 -0
- package/dist/parser/index.d.ts.map +1 -0
- package/dist/parser/index.js +10 -0
- package/dist/parser/index.js.map +1 -0
- package/dist/parser/peggy/generated/parser.d.ts +8 -0
- package/dist/parser/peggy/generated/parser.d.ts.map +1 -0
- package/dist/parser/peggy/generated/parser.js +6269 -0
- package/dist/parser/peggy/generated/parser.js.map +1 -0
- package/dist/parser/peggy/index.d.ts +3 -0
- package/dist/parser/peggy/index.d.ts.map +1 -0
- package/dist/parser/peggy/index.js +555 -0
- package/dist/parser/peggy/index.js.map +1 -0
- package/dist/parser/structured.d.ts +16 -0
- package/dist/parser/structured.d.ts.map +1 -0
- package/dist/parser/structured.js +232 -0
- package/dist/parser/structured.js.map +1 -0
- package/dist/parser/tokenizer.d.ts +12 -0
- package/dist/parser/tokenizer.d.ts.map +1 -0
- package/dist/parser/tokenizer.js +14 -0
- package/dist/parser/tokenizer.js.map +1 -0
- package/dist/patterns/expand.d.ts +32 -0
- package/dist/patterns/expand.d.ts.map +1 -0
- package/dist/patterns/expand.js +184 -0
- package/dist/patterns/expand.js.map +1 -0
- package/dist/patterns/index.d.ts +2 -0
- package/dist/patterns/index.d.ts.map +1 -0
- package/dist/patterns/index.js +2 -0
- package/dist/patterns/index.js.map +1 -0
- package/dist/scheduler/index.d.ts +6 -0
- package/dist/scheduler/index.d.ts.map +1 -0
- package/dist/scheduler/index.js +9 -0
- package/dist/scheduler/index.js.map +1 -0
- package/dist/scheduler/tickScheduler.d.ts +27 -0
- package/dist/scheduler/tickScheduler.d.ts.map +1 -0
- package/dist/scheduler/tickScheduler.js +74 -0
- package/dist/scheduler/tickScheduler.js.map +1 -0
- package/dist/sequences/expand.d.ts +14 -0
- package/dist/sequences/expand.d.ts.map +1 -0
- package/dist/sequences/expand.js +137 -0
- package/dist/sequences/expand.js.map +1 -0
- package/dist/song/importResolver.browser.d.ts +29 -0
- package/dist/song/importResolver.browser.d.ts.map +1 -0
- package/dist/song/importResolver.browser.js +168 -0
- package/dist/song/importResolver.browser.js.map +1 -0
- package/dist/song/importResolver.d.ts +40 -0
- package/dist/song/importResolver.d.ts.map +1 -0
- package/dist/song/importResolver.js +445 -0
- package/dist/song/importResolver.js.map +1 -0
- package/dist/song/index.browser.d.ts +9 -0
- package/dist/song/index.browser.d.ts.map +1 -0
- package/dist/song/index.browser.js +7 -0
- package/dist/song/index.browser.js.map +1 -0
- package/dist/song/index.d.ts +8 -0
- package/dist/song/index.d.ts.map +1 -0
- package/dist/song/index.js +6 -0
- package/dist/song/index.js.map +1 -0
- package/dist/song/resolver.browser.d.ts +50 -0
- package/dist/song/resolver.browser.d.ts.map +1 -0
- package/dist/song/resolver.browser.js +536 -0
- package/dist/song/resolver.browser.js.map +1 -0
- package/dist/song/resolver.d.ts +20 -0
- package/dist/song/resolver.d.ts.map +1 -0
- package/dist/song/resolver.js +540 -0
- package/dist/song/resolver.js.map +1 -0
- package/dist/song/songModel.d.ts +34 -0
- package/dist/song/songModel.d.ts.map +1 -0
- package/dist/song/songModel.js +2 -0
- package/dist/song/songModel.js.map +1 -0
- package/dist/tests/refExpander.test.d.ts +2 -0
- package/dist/tests/refExpander.test.d.ts.map +1 -0
- package/dist/tests/refExpander.test.js +37 -0
- package/dist/tests/refExpander.test.js.map +1 -0
- package/dist/util/diag.d.ts +16 -0
- package/dist/util/diag.d.ts.map +1 -0
- package/dist/util/diag.js +29 -0
- package/dist/util/diag.js.map +1 -0
- package/package.json +84 -0
|
@@ -0,0 +1,1028 @@
|
|
|
1
|
+
import { warn } from '../util/diag.js';
|
|
2
|
+
import { parseEnvelope } from '../chips/gameboy/pulse.js';
|
|
3
|
+
const registry = new Map();
|
|
4
|
+
export const register = (name, handler) => {
|
|
5
|
+
registry.set(name.toLowerCase(), handler);
|
|
6
|
+
};
|
|
7
|
+
export const get = (name) => registry.get(name.toLowerCase());
|
|
8
|
+
// Built-in pan effect
|
|
9
|
+
register('pan', (ctx, nodes, params, start, dur) => {
|
|
10
|
+
if (!params || params.length === 0)
|
|
11
|
+
return;
|
|
12
|
+
// Accept single numeric value or two numbers [from, to]
|
|
13
|
+
const toNum = (v) => (typeof v === 'number' ? v : (typeof v === 'string' ? Number(v) : NaN));
|
|
14
|
+
const g = nodes && nodes.length >= 2 ? nodes[1] : null;
|
|
15
|
+
if (!g || typeof g.connect !== 'function')
|
|
16
|
+
return;
|
|
17
|
+
const pVal = toNum(params[0]);
|
|
18
|
+
const hasEnd = params.length >= 2 && !Number.isNaN(toNum(params[1]));
|
|
19
|
+
const createPanner = ctx.createStereoPanner;
|
|
20
|
+
if (typeof createPanner === 'function') {
|
|
21
|
+
const panner = ctx.createStereoPanner();
|
|
22
|
+
try {
|
|
23
|
+
panner.pan.setValueAtTime(Number.isFinite(pVal) ? pVal : 0, start);
|
|
24
|
+
}
|
|
25
|
+
catch (e) {
|
|
26
|
+
try {
|
|
27
|
+
panner.pan.value = pVal;
|
|
28
|
+
}
|
|
29
|
+
catch (e2) { }
|
|
30
|
+
}
|
|
31
|
+
// Disconnect from all destinations (handles both masterGain and ctx.destination cases)
|
|
32
|
+
try {
|
|
33
|
+
g.disconnect();
|
|
34
|
+
}
|
|
35
|
+
catch (e) { }
|
|
36
|
+
g.connect(panner);
|
|
37
|
+
panner.connect(ctx.destination);
|
|
38
|
+
if (hasEnd) {
|
|
39
|
+
const endVal = toNum(params[1]);
|
|
40
|
+
try {
|
|
41
|
+
panner.pan.linearRampToValueAtTime(endVal, start + dur);
|
|
42
|
+
}
|
|
43
|
+
catch (e) { }
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
// No StereoPanner support — best-effort: do nothing
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
// Clear all effect state (called when playback stops/resets)
|
|
51
|
+
export const clearEffectState = () => {
|
|
52
|
+
portamentoLastFreq.clear();
|
|
53
|
+
};
|
|
54
|
+
export const registryAPI = {
|
|
55
|
+
register,
|
|
56
|
+
get,
|
|
57
|
+
clearEffectState,
|
|
58
|
+
};
|
|
59
|
+
export default registryAPI;
|
|
60
|
+
// Vibrato effect: create a low-frequency oscillator (LFO) and modulate
|
|
61
|
+
// the primary oscillator's frequency AudioParam. Parameters:
|
|
62
|
+
// - params[0]: depth (BeatBax units, scaled to match Game Boy hardware)
|
|
63
|
+
// - params[1]: rate (Hz, default 4)
|
|
64
|
+
//
|
|
65
|
+
// For Game Boy parity: depth is scaled by VIB_DEPTH_SCALE (4.0) to match
|
|
66
|
+
// the UGE exporter, then converted to Hz deviation matching hUGETracker behavior.
|
|
67
|
+
register('vib', (ctx, nodes, params, start, dur) => {
|
|
68
|
+
if (!nodes || nodes.length === 0)
|
|
69
|
+
return;
|
|
70
|
+
const osc = nodes[0];
|
|
71
|
+
if (!osc || !(osc.frequency && typeof osc.frequency.setValueAtTime === 'function'))
|
|
72
|
+
return;
|
|
73
|
+
const depthRaw = params && params.length > 0 ? Number(params[0]) : 1;
|
|
74
|
+
const rateRaw = params && params.length > 1 ? Number(params[1]) : 4;
|
|
75
|
+
const depth = Number.isFinite(depthRaw) ? depthRaw : 1;
|
|
76
|
+
const rate = Number.isFinite(rateRaw) ? Math.max(0.1, rateRaw) : 4;
|
|
77
|
+
// Determine the base frequency currently assigned to the oscillator at `start`.
|
|
78
|
+
// Fallback to `osc.frequency.value` if AudioParam scheduling isn't available.
|
|
79
|
+
let baseFreq = (osc.frequency && typeof (osc.frequency.value) === 'number') ? osc.frequency.value : NaN;
|
|
80
|
+
try {
|
|
81
|
+
// If there is a scheduled value at `start`, attempt to read it; otherwise use current value.
|
|
82
|
+
if (typeof (osc.frequency.getValueAtTime) === 'function') {
|
|
83
|
+
baseFreq = osc.frequency.getValueAtTime(start);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
catch (e) {
|
|
87
|
+
// Ignore — use fallback value
|
|
88
|
+
}
|
|
89
|
+
if (!Number.isFinite(baseFreq) || baseFreq <= 0)
|
|
90
|
+
baseFreq = osc.frequency.value || 440;
|
|
91
|
+
// Game Boy-accurate vibrato depth calculation:
|
|
92
|
+
// 1. Scale BeatBax depth (e.g., 3) by VIB_DEPTH_SCALE (4.0) to get tracker nibble (12)
|
|
93
|
+
// 2. Convert tracker nibble to Hz deviation matching hUGETracker's register offset behavior
|
|
94
|
+
//
|
|
95
|
+
// hUGETracker applies the depth nibble as a register offset (adds to period register).
|
|
96
|
+
// For WebAudio smooth LFO, we approximate the Hz deviation this creates.
|
|
97
|
+
//
|
|
98
|
+
// Empirical formula (tuned to match hUGETracker output):
|
|
99
|
+
// amplitudeHz ≈ baseFreq * (trackerDepth * 0.012)
|
|
100
|
+
//
|
|
101
|
+
// This gives ~4.6x larger vibrato than the old semitone formula, matching the
|
|
102
|
+
// measurement: hUGETracker = 1272 cents vs old BeatBax = 276 cents.
|
|
103
|
+
const VIB_DEPTH_SCALE = 4.0; // Must match ugeWriter.ts
|
|
104
|
+
const trackerDepth = Math.max(0, Math.min(15, Math.round(depth * VIB_DEPTH_SCALE)));
|
|
105
|
+
const amplitudeHz = Math.abs(baseFreq * trackerDepth * 0.012);
|
|
106
|
+
if (!Number.isFinite(amplitudeHz) || amplitudeHz <= 0)
|
|
107
|
+
return;
|
|
108
|
+
try {
|
|
109
|
+
const lfo = ctx.createOscillator();
|
|
110
|
+
const lfoGain = ctx.createGain();
|
|
111
|
+
lfo.type = 'sine';
|
|
112
|
+
try {
|
|
113
|
+
lfo.frequency.setValueAtTime(rate, start);
|
|
114
|
+
}
|
|
115
|
+
catch (_) {
|
|
116
|
+
lfo.frequency.value = rate;
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
lfoGain.gain.setValueAtTime(amplitudeHz, start);
|
|
120
|
+
}
|
|
121
|
+
catch (_) {
|
|
122
|
+
lfoGain.gain.value = amplitudeHz;
|
|
123
|
+
}
|
|
124
|
+
// Connect LFO -> gain -> oscillator.frequency (AudioParam accepts node input)
|
|
125
|
+
lfo.connect(lfoGain);
|
|
126
|
+
try {
|
|
127
|
+
lfoGain.connect(osc.frequency);
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
// Some implementations require connecting to an AudioParam via .connect(param)
|
|
131
|
+
try {
|
|
132
|
+
lfoGain.connect(osc.frequency);
|
|
133
|
+
}
|
|
134
|
+
catch (e2) { }
|
|
135
|
+
}
|
|
136
|
+
try {
|
|
137
|
+
lfo.start(start);
|
|
138
|
+
}
|
|
139
|
+
catch (e) {
|
|
140
|
+
try {
|
|
141
|
+
lfo.start();
|
|
142
|
+
}
|
|
143
|
+
catch (_) { }
|
|
144
|
+
}
|
|
145
|
+
// If resolver provided a normalized duration in params[3] (seconds), prefer it
|
|
146
|
+
const vibDurSec = (Array.isArray(params) && typeof params[3] === 'number') ? Number(params[3]) : undefined;
|
|
147
|
+
const stopAt = (typeof vibDurSec === 'number' && vibDurSec > 0) ? (start + vibDurSec + 0.05) : (start + dur + 0.05);
|
|
148
|
+
try {
|
|
149
|
+
lfo.stop(stopAt);
|
|
150
|
+
}
|
|
151
|
+
catch (e) { }
|
|
152
|
+
}
|
|
153
|
+
catch (e) {
|
|
154
|
+
// Best-effort only; if the environment doesn't support oscillator-based modulation
|
|
155
|
+
// or connections fail, silently skip vibrato.
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// Portamento effect: smoothly slide the oscillator frequency from the previous
|
|
159
|
+
// note's pitch to the current note's pitch. Parameters:
|
|
160
|
+
// - params[0]: speed (0-255, where higher = faster slide)
|
|
161
|
+
//
|
|
162
|
+
// For Game Boy: speed parameter maps to hUGETracker's 3xx tone portamento,
|
|
163
|
+
// where higher values mean faster pitch transitions.
|
|
164
|
+
//
|
|
165
|
+
// Implementation: We track the last frequency per channel (not per oscillator)
|
|
166
|
+
// so portamento works correctly across rests and pattern boundaries.
|
|
167
|
+
const portamentoLastFreq = new Map();
|
|
168
|
+
register('port', (ctx, nodes, params, start, dur, chId) => {
|
|
169
|
+
if (!nodes || nodes.length === 0)
|
|
170
|
+
return;
|
|
171
|
+
const osc = nodes[0];
|
|
172
|
+
if (!osc || !(osc.frequency && typeof osc.frequency.setValueAtTime === 'function'))
|
|
173
|
+
return;
|
|
174
|
+
const speedRaw = params && params.length > 0 ? Number(params[0]) : 16;
|
|
175
|
+
const speed = Number.isFinite(speedRaw) ? Math.max(1, Math.min(255, speedRaw)) : 16;
|
|
176
|
+
// Get the target frequency (the current note's pitch)
|
|
177
|
+
let targetFreq = osc.frequency.value;
|
|
178
|
+
if (!Number.isFinite(targetFreq) || targetFreq <= 0)
|
|
179
|
+
targetFreq = 440;
|
|
180
|
+
// Get the previous note's frequency for this channel
|
|
181
|
+
// Use channel ID (defaults to 0 if not provided for backward compatibility)
|
|
182
|
+
const channelKey = chId ?? 0;
|
|
183
|
+
const lastFreq = portamentoLastFreq.get(channelKey) || targetFreq;
|
|
184
|
+
// Speed scaling: higher speed = shorter portamento time
|
|
185
|
+
// Map speed [1..255] to portamento duration
|
|
186
|
+
// Lower speed = longer slide, higher speed = shorter slide
|
|
187
|
+
const portDuration = Math.max(0.001, (256 - speed) / 256 * dur * 0.6);
|
|
188
|
+
try {
|
|
189
|
+
// Cancel any existing frequency automation
|
|
190
|
+
osc.frequency.cancelScheduledValues(start);
|
|
191
|
+
// Set starting frequency (previous note or current if first note)
|
|
192
|
+
osc.frequency.setValueAtTime(lastFreq, start);
|
|
193
|
+
if (Math.abs(targetFreq - lastFreq) > 1) {
|
|
194
|
+
// Only apply portamento if there's a significant frequency difference
|
|
195
|
+
const safeTarget = Math.max(20, Math.min(20000, targetFreq));
|
|
196
|
+
try {
|
|
197
|
+
// Exponential ramp sounds more musical for pitch changes
|
|
198
|
+
osc.frequency.exponentialRampToValueAtTime(safeTarget, start + portDuration);
|
|
199
|
+
}
|
|
200
|
+
catch (e) {
|
|
201
|
+
// Fallback to linear if exponential fails (e.g., if lastFreq is too close to 0)
|
|
202
|
+
osc.frequency.linearRampToValueAtTime(safeTarget, start + portDuration);
|
|
203
|
+
}
|
|
204
|
+
// Hold target frequency for remainder of note
|
|
205
|
+
osc.frequency.setValueAtTime(safeTarget, start + portDuration);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
catch (e) {
|
|
209
|
+
// Best effort - skip portamento if automation fails
|
|
210
|
+
}
|
|
211
|
+
// Store this frequency for the next note on this channel
|
|
212
|
+
portamentoLastFreq.set(channelKey, targetFreq);
|
|
213
|
+
});
|
|
214
|
+
// Arpeggio effect: rapidly cycle through pitch offsets to simulate chords.
|
|
215
|
+
// Parameters:
|
|
216
|
+
// - params[0..N]: semitone offsets from the base note (e.g., 0, 4, 7 for major triad)
|
|
217
|
+
//
|
|
218
|
+
// hUGETracker's 0xy effect uses 2 nibbles to encode 2 offsets (x and y).
|
|
219
|
+
// BeatBax supports 3-4 note arpeggios by accepting multiple comma-separated offsets.
|
|
220
|
+
// For UGE export: only the first 2 offsets are exported (3-note arpeggio); if more
|
|
221
|
+
// offsets are provided, a warning is shown.
|
|
222
|
+
//
|
|
223
|
+
// Implementation: Cycle through pitch offsets at 60Hz (Game Boy frame rate).
|
|
224
|
+
// Each arpeggio step lasts exactly 1 frame (~16.667ms), independent of BPM.
|
|
225
|
+
// For a note spanning 7 ticks (Speed=7) with a 3-note arpeggio:
|
|
226
|
+
// Plays 7 notes in ~117ms: Root → +x → +y → Root → +x → +y → Root
|
|
227
|
+
// This rapid cycling creates the illusion of hearing a chord.
|
|
228
|
+
register('arp', (ctx, nodes, params, start, dur, chId, tickSeconds) => {
|
|
229
|
+
if (!nodes || nodes.length === 0)
|
|
230
|
+
return;
|
|
231
|
+
const osc = nodes[0];
|
|
232
|
+
if (!osc || !(osc.frequency && typeof osc.frequency.setValueAtTime === 'function'))
|
|
233
|
+
return;
|
|
234
|
+
// Parse semitone offsets from parameters (filter out non-numeric values)
|
|
235
|
+
const rawOffsets = (params || []).map(p => Number(p));
|
|
236
|
+
const negativeOffsets = rawOffsets.filter(n => Number.isFinite(n) && n < 0);
|
|
237
|
+
if (negativeOffsets.length > 0) {
|
|
238
|
+
warn('effect', `Arpeggio effect contains negative offsets [${negativeOffsets.join(', ')}]. hUGETracker's 0xy format only supports offsets 0-15. Negative offsets will be ignored.`);
|
|
239
|
+
}
|
|
240
|
+
const offsets = rawOffsets.filter(n => Number.isFinite(n) && n >= 0);
|
|
241
|
+
if (offsets.length === 0)
|
|
242
|
+
return;
|
|
243
|
+
// Get the base frequency (the current note's pitch)
|
|
244
|
+
// Use _baseFreq if available (stored by playPulse), otherwise fallback to .value
|
|
245
|
+
let baseFreq = osc._baseFreq || osc.frequency.value;
|
|
246
|
+
if (!Number.isFinite(baseFreq) || baseFreq <= 0)
|
|
247
|
+
baseFreq = 440;
|
|
248
|
+
// Arpeggio timing: advances at the chip's native frame rate.
|
|
249
|
+
// Each tick = 1 frame, independent of BPM or musical tempo.
|
|
250
|
+
// For Speed=7 (7 ticks per row) with 3-note arpeggio:
|
|
251
|
+
// Root → +x → +y → Root → +x → +y → Root (7 notes)
|
|
252
|
+
// This rapid cycling creates the chord illusion.
|
|
253
|
+
// Chip frame rates (Hz) - based on TV standards and hardware specs
|
|
254
|
+
// Note: Defaults reflect the dominant market/scene for each chip:
|
|
255
|
+
// - C64: 50 Hz (PAL) because the European demoscene/SID music community was dominant
|
|
256
|
+
// - Others: 60 Hz (NTSC) due to Japanese/North American market dominance or global standard
|
|
257
|
+
const CHIP_FRAME_RATES = {
|
|
258
|
+
'gameboy': 60, // Game Boy: ~59.73 Hz (global standard, not TV-dependent)
|
|
259
|
+
'nes': 60, // NES: ~60 Hz NTSC (North American market dominant)
|
|
260
|
+
'c64': 50, // C64 SID: 50 Hz PAL (European demoscene/music dominant)
|
|
261
|
+
'genesis': 60, // Sega Genesis: ~60 Hz NTSC (NA/Japan markets)
|
|
262
|
+
'megadrive': 60, // Alias for Genesis
|
|
263
|
+
'pcengine': 60, // PC Engine: ~60 Hz (Japan/NA markets)
|
|
264
|
+
};
|
|
265
|
+
const chipType = (ctx._chipType || 'gameboy').toLowerCase();
|
|
266
|
+
const frameRate = CHIP_FRAME_RATES[chipType] || 60; // Default to 60 Hz
|
|
267
|
+
const stepDuration = 1 / frameRate;
|
|
268
|
+
try {
|
|
269
|
+
// Cancel any existing frequency automation
|
|
270
|
+
osc.frequency.cancelScheduledValues(start);
|
|
271
|
+
// hUGETracker arpeggio always includes the root note first
|
|
272
|
+
// For offsets [3, 7], the cycle is: Root (0) → +3 → +7 → Root → ...
|
|
273
|
+
const allOffsets = [0, ...offsets];
|
|
274
|
+
// Calculate frequencies for each offset
|
|
275
|
+
// Formula: freq = baseFreq * 2^(semitones / 12)
|
|
276
|
+
const frequencies = allOffsets.map(offset => baseFreq * Math.pow(2, offset / 12));
|
|
277
|
+
// Schedule frequency changes at the chip's native frame rate (e.g., 60Hz for Game Boy, 50Hz for C64)
|
|
278
|
+
// Each note in the arpeggio lasts exactly one frame (e.g., ~16.667ms at 60Hz, ~20ms at 50Hz)
|
|
279
|
+
let currentTime = start;
|
|
280
|
+
const endTime = start + dur;
|
|
281
|
+
// Schedule the arpeggio cycle for the entire note duration
|
|
282
|
+
while (currentTime < endTime) {
|
|
283
|
+
for (let i = 0; i < frequencies.length && currentTime < endTime; i++) {
|
|
284
|
+
const freq = frequencies[i];
|
|
285
|
+
const safeFreq = Math.max(20, Math.min(20000, freq));
|
|
286
|
+
// Schedule this frequency to start at currentTime
|
|
287
|
+
osc.frequency.setValueAtTime(safeFreq, currentTime);
|
|
288
|
+
currentTime += stepDuration; // Advance by one chip frame (1/frameRate second)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
// Hold the last frequency until the end of the note
|
|
292
|
+
// (Don't reset to base - let it naturally transition)
|
|
293
|
+
}
|
|
294
|
+
catch (e) {
|
|
295
|
+
// Best effort - skip arpeggio if automation fails
|
|
296
|
+
}
|
|
297
|
+
});
|
|
298
|
+
// Volume Slide effect: smoothly increase or decrease volume over time.
|
|
299
|
+
// Parameters:
|
|
300
|
+
// - params[0]: delta per tick or step (+N = fade in, -N = fade out)
|
|
301
|
+
// - params[1]: (optional) number of steps/ticks for the slide
|
|
302
|
+
//
|
|
303
|
+
// hUGETracker's Axy effect: x = slide up speed (0-15), y = slide down speed (0-15)
|
|
304
|
+
// BeatBax uses signed delta: positive = slide up, negative = slide down
|
|
305
|
+
//
|
|
306
|
+
// Implementation: Apply linear gain ramp from instrument's envelope initial volume
|
|
307
|
+
// to target volume over the note duration. For per-tick slides, apply stepped automation.
|
|
308
|
+
// The baseline is derived from the instrument's envelope initial volume (0-15 on Game Boy).
|
|
309
|
+
//
|
|
310
|
+
// ARCHITECTURAL LIMITATION: This implementation cancels existing gain automation on the
|
|
311
|
+
// same GainNode used for envelope automation, effectively disabling instrument envelopes
|
|
312
|
+
// when volume slide is active. To properly support stacking with envelopes, this should
|
|
313
|
+
// use a separate gain stage (additional GainNode in the audio graph after envelope gain)
|
|
314
|
+
// or apply volume slide via an independent parameter/node without canceling automation.
|
|
315
|
+
register('volSlide', (ctx, nodes, params, start, dur, chId, tickSeconds, inst) => {
|
|
316
|
+
if (!nodes || nodes.length < 2)
|
|
317
|
+
return;
|
|
318
|
+
const gain = nodes[1];
|
|
319
|
+
if (!gain || !gain.gain) {
|
|
320
|
+
warn('effects', `Volume slide: gain node is missing or invalid for channel ${chId || '?'}`);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const gainParam = gain.gain;
|
|
324
|
+
if (!gainParam || typeof gainParam.setValueAtTime !== 'function') {
|
|
325
|
+
warn('effects', `Volume slide: gain.gain AudioParam is invalid for channel ${chId || '?'}`);
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
const deltaRaw = params && params.length > 0 ? Number(params[0]) : 0;
|
|
329
|
+
const stepsRaw = params && params.length > 1 ? Number(params[1]) : undefined;
|
|
330
|
+
const delta = Number.isFinite(deltaRaw) ? deltaRaw : 0;
|
|
331
|
+
const steps = (stepsRaw !== undefined && Number.isFinite(stepsRaw)) ? Math.max(1, Math.round(stepsRaw)) : undefined;
|
|
332
|
+
if (delta === 0)
|
|
333
|
+
return; // No volume change
|
|
334
|
+
// Extract instrument envelope initial volume (0-15 on Game Boy, normalized to 0-1)
|
|
335
|
+
// If no instrument or envelope data available, fall back to 1.0 (full volume)
|
|
336
|
+
let baselineGain = 1.0;
|
|
337
|
+
if (inst && inst.env) {
|
|
338
|
+
try {
|
|
339
|
+
// Parse envelope to get initial volume (handles both string and object formats)
|
|
340
|
+
const env = parseEnvelope(inst.env);
|
|
341
|
+
if (env && env.mode === 'gb' && typeof env.initial === 'number') {
|
|
342
|
+
baselineGain = Math.max(0, Math.min(1, env.initial / 15)); // Normalize to [0, 1]
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch (e) {
|
|
346
|
+
// Fall back to 1.0 if parsing fails
|
|
347
|
+
warn('effects', `Volume slide: envelope parsing failed for channel ${chId || '?'}, using default baseline`);
|
|
348
|
+
baselineGain = 1.0;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
// LIMITATION: Canceling scheduled values wipes envelope automation on this GainNode.
|
|
353
|
+
// Volume slides currently REPLACE envelopes rather than stacking with them.
|
|
354
|
+
// To fix: use a separate gain stage or avoid cancelScheduledValues.
|
|
355
|
+
if (typeof gainParam.cancelScheduledValues === 'function') {
|
|
356
|
+
gainParam.cancelScheduledValues(start);
|
|
357
|
+
}
|
|
358
|
+
gainParam.setValueAtTime(baselineGain, start);
|
|
359
|
+
if (steps !== undefined && tickSeconds !== undefined) {
|
|
360
|
+
// Stepped volume slide: apply delta at each step interval with hard transitions
|
|
361
|
+
// For truly discrete steps in WebAudio, we need to hold each value constant
|
|
362
|
+
// until the next step boundary
|
|
363
|
+
// NOTE: Use larger scaling factor (÷3 instead of ÷5) for stepped slides to make
|
|
364
|
+
// steps more audible in WebAudio which has inherent smoothing
|
|
365
|
+
const stepDuration = dur / steps;
|
|
366
|
+
const scaleFactor = 3; // More aggressive scaling for stepped slides
|
|
367
|
+
// Set initial value and hold it until first step
|
|
368
|
+
gainParam.setValueAtTime(baselineGain, start);
|
|
369
|
+
for (let i = 1; i <= steps; i++) {
|
|
370
|
+
const stepTime = start + (i * stepDuration);
|
|
371
|
+
// Calculate volume for this step: evenly distribute delta across steps
|
|
372
|
+
const stepGain = Math.max(0.001, Math.min(1.5, baselineGain + (delta * i / steps / scaleFactor)));
|
|
373
|
+
// Hold previous value right up to step boundary
|
|
374
|
+
const prevGain = i === 1 ? baselineGain : Math.max(0.001, Math.min(1.5, baselineGain + (delta * (i - 1) / steps / scaleFactor)));
|
|
375
|
+
gainParam.setValueAtTime(prevGain, stepTime - 0.00001);
|
|
376
|
+
// Jump to new value at step boundary
|
|
377
|
+
gainParam.setValueAtTime(stepGain, stepTime);
|
|
378
|
+
}
|
|
379
|
+
// Hold final value until note end
|
|
380
|
+
const finalGain = Math.max(0.001, Math.min(1.5, baselineGain + (delta / scaleFactor)));
|
|
381
|
+
gainParam.setValueAtTime(finalGain, start + dur);
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
// Smooth volume slide: linear ramp over note duration
|
|
385
|
+
// Scale delta to reasonable range: delta ±1 = ±0.2 gain change per note (more audible)
|
|
386
|
+
// Allow volume to increase above baseline up to 1.5x (some headroom for boosts)
|
|
387
|
+
const targetGain = Math.max(0, Math.min(1.5, baselineGain + (delta / 5)));
|
|
388
|
+
gainParam.linearRampToValueAtTime(targetGain, start + dur);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
catch (e) {
|
|
392
|
+
warn('effects', `Volume slide failed for channel ${chId || '?'}: ${e}`);
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
// Tremolo effect: create a low-frequency oscillator (LFO) and modulate the gain
|
|
396
|
+
// (amplitude) to create volume oscillation. Parameters:
|
|
397
|
+
// - params[0]: depth (0-15, where 15 = maximum amplitude modulation)
|
|
398
|
+
// - params[1]: rate (Hz, speed of the tremolo oscillation, default 6)
|
|
399
|
+
// - params[2]: waveform (optional, default 'sine')
|
|
400
|
+
// - params[3]: duration in seconds (normalized from durationRows by resolver)
|
|
401
|
+
//
|
|
402
|
+
// Similar to vibrato but modulates volume instead of pitch. This creates a pulsating
|
|
403
|
+
// or "shimmering" effect commonly used for atmospheric sounds, sustained notes,
|
|
404
|
+
// and adding movement to static tones.
|
|
405
|
+
//
|
|
406
|
+
// MIDI export: Documented via text meta event (MIDI has no native tremolo)
|
|
407
|
+
// UGE export: Can be approximated with volume column automation or effect commands
|
|
408
|
+
register('trem', (ctx, nodes, params, start, dur) => {
|
|
409
|
+
if (!nodes || nodes.length < 2)
|
|
410
|
+
return;
|
|
411
|
+
const gain = nodes[1];
|
|
412
|
+
if (!gain || !gain.gain || typeof gain.gain.setValueAtTime !== 'function')
|
|
413
|
+
return;
|
|
414
|
+
const depthRaw = params && params.length > 0 ? Number(params[0]) : 4;
|
|
415
|
+
const rateRaw = params && params.length > 1 ? Number(params[1]) : 6;
|
|
416
|
+
const waveform = params && params.length > 2 ? String(params[2]).toLowerCase() : 'sine';
|
|
417
|
+
const depth = Number.isFinite(depthRaw) ? Math.max(0, Math.min(15, depthRaw)) : 4;
|
|
418
|
+
const rate = Number.isFinite(rateRaw) ? Math.max(0.1, rateRaw) : 6;
|
|
419
|
+
// Map waveform names to OscillatorNode types
|
|
420
|
+
// Support same waveform aliases as vibrato for consistency
|
|
421
|
+
const waveformMap = {
|
|
422
|
+
'sine': 'sine',
|
|
423
|
+
'triangle': 'triangle',
|
|
424
|
+
'square': 'square',
|
|
425
|
+
'sawtooth': 'sawtooth',
|
|
426
|
+
'saw': 'sawtooth',
|
|
427
|
+
};
|
|
428
|
+
const oscType = waveformMap[waveform] || 'sine';
|
|
429
|
+
// Calculate tremolo amplitude as a fraction of the current gain
|
|
430
|
+
// depth 0 = no effect, depth 15 = ±50% gain modulation (0.5 to 1.5x)
|
|
431
|
+
const modulationDepth = (depth / 15) * 0.5; // 0 to 0.5 (±50% max)
|
|
432
|
+
try {
|
|
433
|
+
// Get the current baseline gain (from envelope or default)
|
|
434
|
+
let baselineGain;
|
|
435
|
+
try {
|
|
436
|
+
if (typeof gain.gain.getValueAtTime === 'function') {
|
|
437
|
+
baselineGain = gain.gain.getValueAtTime(start);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
baselineGain = gain.gain.value || 1.0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch (e) {
|
|
444
|
+
baselineGain = gain.gain.value || 1.0;
|
|
445
|
+
}
|
|
446
|
+
if (!Number.isFinite(baselineGain) || baselineGain <= 0)
|
|
447
|
+
baselineGain = 1.0;
|
|
448
|
+
// Create LFO for tremolo
|
|
449
|
+
const lfo = ctx.createOscillator();
|
|
450
|
+
const lfoGain = ctx.createGain();
|
|
451
|
+
lfo.type = oscType;
|
|
452
|
+
try {
|
|
453
|
+
lfo.frequency.setValueAtTime(rate, start);
|
|
454
|
+
}
|
|
455
|
+
catch (_) {
|
|
456
|
+
lfo.frequency.value = rate;
|
|
457
|
+
}
|
|
458
|
+
// LFO amplitude = baselineGain * modulationDepth
|
|
459
|
+
// This will modulate the gain between (baseline - amplitude) and (baseline + amplitude)
|
|
460
|
+
const amplitude = baselineGain * modulationDepth;
|
|
461
|
+
try {
|
|
462
|
+
lfoGain.gain.setValueAtTime(amplitude, start);
|
|
463
|
+
}
|
|
464
|
+
catch (_) {
|
|
465
|
+
lfoGain.gain.value = amplitude;
|
|
466
|
+
}
|
|
467
|
+
// Connect LFO -> lfoGain -> gain.gain (modulate the volume)
|
|
468
|
+
lfo.connect(lfoGain);
|
|
469
|
+
try {
|
|
470
|
+
lfoGain.connect(gain.gain);
|
|
471
|
+
}
|
|
472
|
+
catch (e) {
|
|
473
|
+
// Some implementations require different connection approach
|
|
474
|
+
try {
|
|
475
|
+
lfoGain.connect(gain.gain);
|
|
476
|
+
}
|
|
477
|
+
catch (e2) { }
|
|
478
|
+
}
|
|
479
|
+
try {
|
|
480
|
+
lfo.start(start);
|
|
481
|
+
}
|
|
482
|
+
catch (e) {
|
|
483
|
+
try {
|
|
484
|
+
lfo.start();
|
|
485
|
+
}
|
|
486
|
+
catch (_) { }
|
|
487
|
+
}
|
|
488
|
+
// Duration handling: use params[3] if provided (normalized seconds), otherwise use note duration
|
|
489
|
+
const tremDurSec = (Array.isArray(params) && typeof params[3] === 'number') ? Number(params[3]) : undefined;
|
|
490
|
+
const stopAt = (typeof tremDurSec === 'number' && tremDurSec > 0) ? (start + tremDurSec + 0.05) : (start + dur + 0.05);
|
|
491
|
+
try {
|
|
492
|
+
lfo.stop(stopAt);
|
|
493
|
+
}
|
|
494
|
+
catch (e) { }
|
|
495
|
+
}
|
|
496
|
+
catch (e) {
|
|
497
|
+
// Best-effort only; if the environment doesn't support oscillator-based modulation
|
|
498
|
+
// or connections fail, silently skip tremolo.
|
|
499
|
+
}
|
|
500
|
+
});
|
|
501
|
+
// Note Cut effect: cuts/gates a note after N ticks
|
|
502
|
+
// Parameters:
|
|
503
|
+
// - params[0]: ticks (required, number of ticks after which to cut the note)
|
|
504
|
+
// - tickSeconds: (optional function argument, injected by caller - seconds per tick)
|
|
505
|
+
//
|
|
506
|
+
// Cuts notes early by ramping gain to zero. Since oscillator.stop() can only be called
|
|
507
|
+
// once and is already scheduled during note creation, we use gain automation to silence
|
|
508
|
+
// the note at the cut time.
|
|
509
|
+
//
|
|
510
|
+
// UGE export: Maps to E0x (cut after x ticks, where x=0-F)
|
|
511
|
+
// MIDI export: Documented via text meta event, or emit Note Off earlier than scheduled
|
|
512
|
+
register('cut', (ctx, nodes, params, start, dur, chId, tickSeconds) => {
|
|
513
|
+
if (!nodes || nodes.length === 0)
|
|
514
|
+
return;
|
|
515
|
+
if (!params || params.length === 0)
|
|
516
|
+
return;
|
|
517
|
+
const ticksRaw = Number(params[0]);
|
|
518
|
+
if (ticksRaw === undefined || !Number.isFinite(ticksRaw) || ticksRaw <= 0)
|
|
519
|
+
return;
|
|
520
|
+
const ticks = Math.max(0, ticksRaw);
|
|
521
|
+
// Use provided tickSeconds if available, otherwise estimate from duration
|
|
522
|
+
// Typical default: 16 ticks per beat at 120 BPM = 0.03125s per tick
|
|
523
|
+
const tickDuration = tickSeconds || 0.03125;
|
|
524
|
+
const cutDelay = ticks * tickDuration;
|
|
525
|
+
// Ensure cut time doesn't exceed note duration
|
|
526
|
+
const cutTime = Math.min(start + cutDelay, start + dur);
|
|
527
|
+
// Cut by ramping gain to zero - this works even though oscillator.stop() was already called
|
|
528
|
+
for (const node of nodes) {
|
|
529
|
+
if (!node)
|
|
530
|
+
continue;
|
|
531
|
+
if (node.gain && typeof node.gain.setValueAtTime === 'function') {
|
|
532
|
+
try {
|
|
533
|
+
// Get current gain value or use default
|
|
534
|
+
let currentGain;
|
|
535
|
+
try {
|
|
536
|
+
if (typeof node.gain.getValueAtTime === 'function') {
|
|
537
|
+
currentGain = node.gain.getValueAtTime(cutTime - 0.001) || 1.0;
|
|
538
|
+
}
|
|
539
|
+
else {
|
|
540
|
+
currentGain = node.gain.value || 1.0;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
catch (e) {
|
|
544
|
+
currentGain = node.gain.value || 1.0;
|
|
545
|
+
}
|
|
546
|
+
// Cancel any scheduled values after cut time and ramp to zero
|
|
547
|
+
node.gain.cancelScheduledValues(cutTime);
|
|
548
|
+
node.gain.setValueAtTime(currentGain, cutTime);
|
|
549
|
+
node.gain.exponentialRampToValueAtTime(0.0001, cutTime + 0.005);
|
|
550
|
+
}
|
|
551
|
+
catch (e) {
|
|
552
|
+
// Fallback: try linear ramp
|
|
553
|
+
try {
|
|
554
|
+
node.gain.linearRampToValueAtTime(0, cutTime + 0.005);
|
|
555
|
+
}
|
|
556
|
+
catch (e2) { }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
});
|
|
561
|
+
// Retrigger effect: retriggering/restarting a note at regular tick intervals
|
|
562
|
+
// Parameters:
|
|
563
|
+
// - params[0]: interval (required, ticks between each retrigger)
|
|
564
|
+
// - params[1]: volumeDelta (optional, volume change per retrigger, e.g., -2 for fadeout)
|
|
565
|
+
// - tickSeconds: (optional function argument, injected by caller - seconds per tick)
|
|
566
|
+
//
|
|
567
|
+
// Creates a rhythmic stuttering effect by scheduling multiple note restarts.
|
|
568
|
+
// Common uses: drum rolls, glitchy effects, volume-decaying retrigs.
|
|
569
|
+
//
|
|
570
|
+
// Note: This effect requires special handling in playback.ts since it needs to
|
|
571
|
+
// schedule additional AudioNodes, not just modify the existing ones.
|
|
572
|
+
// The handler here stores retrigger metadata that playback.ts will read.
|
|
573
|
+
//
|
|
574
|
+
// UGE export: Not supported - hUGETracker has no native retrigger effect
|
|
575
|
+
// MIDI export: Not currently implemented (future enhancement: could emit multiple Note On events)
|
|
576
|
+
register('retrig', (ctx, nodes, params, start, dur, chId, tickSeconds) => {
|
|
577
|
+
if (!params || params.length === 0)
|
|
578
|
+
return;
|
|
579
|
+
const interval = Number(params[0]);
|
|
580
|
+
if (!Number.isFinite(interval) || interval <= 0)
|
|
581
|
+
return;
|
|
582
|
+
const volumeDelta = params.length > 1 ? Number(params[1]) : 0;
|
|
583
|
+
const tickDuration = tickSeconds || 0.03125;
|
|
584
|
+
// Store retrigger metadata on the nodes array for playback.ts to read
|
|
585
|
+
// This is a signal that additional note events need to be scheduled
|
|
586
|
+
nodes.__retrigger = {
|
|
587
|
+
interval,
|
|
588
|
+
volumeDelta,
|
|
589
|
+
tickDuration,
|
|
590
|
+
start,
|
|
591
|
+
dur,
|
|
592
|
+
};
|
|
593
|
+
});
|
|
594
|
+
// Pitch Bend effect: smoothly bend the pitch by a specified number of semitones
|
|
595
|
+
// Parameters:
|
|
596
|
+
// - params[0]: semitones (required, number of semitones to bend - positive = up, negative = down)
|
|
597
|
+
// - params[1]: curve (optional, bend curve shape: 'linear', 'exp', 'log', 'sine'. Default: 'linear')
|
|
598
|
+
// - params[2]: delay (optional, time before bend starts in seconds. Default: 50% of note duration)
|
|
599
|
+
// - params[3]: time (optional, bend duration in seconds. Default: remaining note duration after delay)
|
|
600
|
+
//
|
|
601
|
+
// Bends the pitch smoothly from the base note frequency to the target pitch.
|
|
602
|
+
// Unlike portamento (which slides between discrete notes), pitch bend can hit
|
|
603
|
+
// any frequency including microtonal intervals.
|
|
604
|
+
//
|
|
605
|
+
// Musical behavior: The note plays at base pitch for 'delay' time, then bends to target.
|
|
606
|
+
// This matches traditional guitar/string bending: play note → hold → bend.
|
|
607
|
+
//
|
|
608
|
+
// Common uses:
|
|
609
|
+
// - Guitar-style bends: C4<bend:+2> plays C4, holds, then bends up to D4
|
|
610
|
+
// - Dive bombs and risers (e.g., +12 for octave riser, -12 for dive)
|
|
611
|
+
// - Subtle expression (e.g., +0.5 for slight sharp)
|
|
612
|
+
// - Immediate bends: C4<bend:+2,linear,0> bends from start (delay=0)
|
|
613
|
+
//
|
|
614
|
+
// Curve types:
|
|
615
|
+
// - 'linear' (default): constant rate of pitch change
|
|
616
|
+
// - 'exp'/'exponential': accelerating bend (slow start, fast end)
|
|
617
|
+
// - 'log'/'logarithmic': decelerating bend (fast start, slow end)
|
|
618
|
+
// - 'sine': smooth S-curve (slow-fast-slow)
|
|
619
|
+
//
|
|
620
|
+
// UGE export: Approximated with tone portamento (3xx) or piecewise steps
|
|
621
|
+
// MIDI export: Native pitch wheel events (14-bit resolution, ±2 semitones standard range)
|
|
622
|
+
register('bend', (ctx, nodes, params, start, dur, chId, tickSeconds, inst) => {
|
|
623
|
+
if (!nodes || nodes.length === 0)
|
|
624
|
+
return;
|
|
625
|
+
const node = nodes[0];
|
|
626
|
+
// Detect if this is an oscillator (frequency property) or buffer source (playbackRate property)
|
|
627
|
+
const hasFrequency = node && node.frequency && typeof node.frequency.setValueAtTime === 'function';
|
|
628
|
+
const hasPlaybackRate = node && node.playbackRate && typeof node.playbackRate.setValueAtTime === 'function';
|
|
629
|
+
if (!hasFrequency && !hasPlaybackRate)
|
|
630
|
+
return;
|
|
631
|
+
if (!params || params.length === 0)
|
|
632
|
+
return;
|
|
633
|
+
const semitonesRaw = Number(params[0]);
|
|
634
|
+
if (!Number.isFinite(semitonesRaw))
|
|
635
|
+
return;
|
|
636
|
+
const semitones = semitonesRaw;
|
|
637
|
+
// Parse curve type (default: linear)
|
|
638
|
+
const curveStr = params.length > 1 && typeof params[1] === 'string' ? String(params[1]).toLowerCase() : 'linear';
|
|
639
|
+
const curve = ['linear', 'exp', 'exponential', 'log', 'logarithmic', 'sine', 'sin'].includes(curveStr) ? curveStr : 'linear';
|
|
640
|
+
// Parse delay time (default: 50% of note duration for musical bending)
|
|
641
|
+
const delayRaw = params.length > 2 ? Number(params[2]) : (dur * 0.5);
|
|
642
|
+
const delay = Number.isFinite(delayRaw) && delayRaw >= 0 ? Math.min(delayRaw, dur) : (dur * 0.5);
|
|
643
|
+
// Parse bend time (default: remaining duration after delay)
|
|
644
|
+
const bendTimeRaw = params.length > 3 ? Number(params[3]) : (dur - delay);
|
|
645
|
+
const bendTime = Number.isFinite(bendTimeRaw) && bendTimeRaw > 0 ? Math.min(bendTimeRaw, dur - delay) : (dur - delay);
|
|
646
|
+
// Calculate the pitch bend multiplier: 2^(semitones / 12)
|
|
647
|
+
const bendMultiplier = Math.pow(2, semitones / 12);
|
|
648
|
+
try {
|
|
649
|
+
const bendStart = start + delay;
|
|
650
|
+
const bendEnd = bendStart + bendTime;
|
|
651
|
+
if (hasFrequency) {
|
|
652
|
+
// Oscillator path (pulse1, pulse2, noise)
|
|
653
|
+
const osc = node;
|
|
654
|
+
// Get the base frequency
|
|
655
|
+
let baseFreq = osc._baseFreq || osc.frequency.value;
|
|
656
|
+
if (!Number.isFinite(baseFreq) || baseFreq <= 0)
|
|
657
|
+
baseFreq = 440;
|
|
658
|
+
const targetFreq = baseFreq * bendMultiplier;
|
|
659
|
+
const safeTargetFreq = Math.max(20, Math.min(20000, targetFreq));
|
|
660
|
+
// Cancel any existing frequency automation
|
|
661
|
+
osc.frequency.cancelScheduledValues(start);
|
|
662
|
+
// Set starting frequency (hold at base pitch)
|
|
663
|
+
osc.frequency.setValueAtTime(baseFreq, start);
|
|
664
|
+
// Hold base frequency during delay period
|
|
665
|
+
if (delay > 0) {
|
|
666
|
+
osc.frequency.setValueAtTime(baseFreq, bendStart);
|
|
667
|
+
}
|
|
668
|
+
// Apply pitch bend based on curve type (starts after delay)
|
|
669
|
+
// Use smooth automation curves to avoid audible steps
|
|
670
|
+
if (curve === 'exp' || curve === 'exponential') {
|
|
671
|
+
const safeBase = Math.max(20, baseFreq);
|
|
672
|
+
const safeTarget = Math.max(20, safeTargetFreq);
|
|
673
|
+
osc.frequency.exponentialRampToValueAtTime(safeTarget, bendEnd);
|
|
674
|
+
}
|
|
675
|
+
else if (curve === 'log' || curve === 'logarithmic') {
|
|
676
|
+
// Use setValueCurveAtTime for smooth logarithmic curve
|
|
677
|
+
const samples = 128;
|
|
678
|
+
const curveData = new Float32Array(samples);
|
|
679
|
+
for (let i = 0; i < samples; i++) {
|
|
680
|
+
const t = i / (samples - 1);
|
|
681
|
+
const logT = 1 - Math.pow(1 - t, 2);
|
|
682
|
+
const freq = baseFreq * Math.pow(2, (semitones * logT) / 12);
|
|
683
|
+
curveData[i] = Math.max(20, Math.min(20000, freq));
|
|
684
|
+
}
|
|
685
|
+
try {
|
|
686
|
+
osc.frequency.setValueCurveAtTime(curveData, bendStart, bendTime);
|
|
687
|
+
}
|
|
688
|
+
catch (e) {
|
|
689
|
+
// Fallback: use many small linear ramps
|
|
690
|
+
const steps = 64;
|
|
691
|
+
const stepDur = bendTime / steps;
|
|
692
|
+
for (let i = 1; i <= steps; i++) {
|
|
693
|
+
const t = i / steps;
|
|
694
|
+
const logT = 1 - Math.pow(1 - t, 2);
|
|
695
|
+
const freq = baseFreq * Math.pow(2, (semitones * logT) / 12);
|
|
696
|
+
const safeFreq = Math.max(20, Math.min(20000, freq));
|
|
697
|
+
osc.frequency.linearRampToValueAtTime(safeFreq, bendStart + (i * stepDur));
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
else if (curve === 'sine' || curve === 'sin') {
|
|
702
|
+
// Use setValueCurveAtTime for smooth sine curve
|
|
703
|
+
const samples = 128;
|
|
704
|
+
const curveData = new Float32Array(samples);
|
|
705
|
+
for (let i = 0; i < samples; i++) {
|
|
706
|
+
const t = i / (samples - 1);
|
|
707
|
+
const sineT = (1 - Math.cos(Math.PI * t)) / 2;
|
|
708
|
+
const freq = baseFreq * Math.pow(2, (semitones * sineT) / 12);
|
|
709
|
+
curveData[i] = Math.max(20, Math.min(20000, freq));
|
|
710
|
+
}
|
|
711
|
+
try {
|
|
712
|
+
osc.frequency.setValueCurveAtTime(curveData, bendStart, bendTime);
|
|
713
|
+
}
|
|
714
|
+
catch (e) {
|
|
715
|
+
// Fallback: use many small linear ramps
|
|
716
|
+
const steps = 64;
|
|
717
|
+
const stepDur = bendTime / steps;
|
|
718
|
+
for (let i = 1; i <= steps; i++) {
|
|
719
|
+
const t = i / steps;
|
|
720
|
+
const sineT = (1 - Math.cos(Math.PI * t)) / 2;
|
|
721
|
+
const freq = baseFreq * Math.pow(2, (semitones * sineT) / 12);
|
|
722
|
+
const safeFreq = Math.max(20, Math.min(20000, freq));
|
|
723
|
+
osc.frequency.linearRampToValueAtTime(safeFreq, bendStart + (i * stepDur));
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
// Linear curve (default)
|
|
729
|
+
// Linear curve (default)
|
|
730
|
+
osc.frequency.linearRampToValueAtTime(safeTargetFreq, bendEnd);
|
|
731
|
+
}
|
|
732
|
+
// Hold target frequency for remainder of note
|
|
733
|
+
if (bendEnd < start + dur) {
|
|
734
|
+
osc.frequency.setValueAtTime(safeTargetFreq, bendEnd);
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
else if (hasPlaybackRate) {
|
|
738
|
+
// Buffer source path (wave channel)
|
|
739
|
+
const src = node;
|
|
740
|
+
// Get the base playback rate
|
|
741
|
+
let baseRate = src.playbackRate.value;
|
|
742
|
+
if (!Number.isFinite(baseRate) || baseRate <= 0)
|
|
743
|
+
baseRate = 1;
|
|
744
|
+
const targetRate = baseRate * bendMultiplier;
|
|
745
|
+
const safeTargetRate = Math.max(0.1, Math.min(10, targetRate));
|
|
746
|
+
// Cancel any existing playback rate automation
|
|
747
|
+
try {
|
|
748
|
+
src.playbackRate.cancelScheduledValues(start);
|
|
749
|
+
}
|
|
750
|
+
catch (e) {
|
|
751
|
+
// Some contexts don't support cancelScheduledValues
|
|
752
|
+
}
|
|
753
|
+
// Set starting playback rate (hold at base pitch)
|
|
754
|
+
src.playbackRate.setValueAtTime(baseRate, start);
|
|
755
|
+
// Hold base playback rate during delay period
|
|
756
|
+
if (delay > 0) {
|
|
757
|
+
src.playbackRate.setValueAtTime(baseRate, bendStart);
|
|
758
|
+
}
|
|
759
|
+
// Apply pitch bend based on curve type (starts after delay)
|
|
760
|
+
// For buffer sources, use setValueCurveAtTime for truly smooth automation
|
|
761
|
+
if (curve === 'exp' || curve === 'exponential') {
|
|
762
|
+
// Generate exponential curve samples
|
|
763
|
+
const samples = 128;
|
|
764
|
+
const curveData = new Float32Array(samples);
|
|
765
|
+
for (let i = 0; i < samples; i++) {
|
|
766
|
+
const t = i / (samples - 1);
|
|
767
|
+
const expT = t * t; // Exponential curve
|
|
768
|
+
const rate = baseRate * Math.pow(2, (semitones * expT) / 12);
|
|
769
|
+
curveData[i] = Math.max(0.1, Math.min(10, rate));
|
|
770
|
+
}
|
|
771
|
+
try {
|
|
772
|
+
src.playbackRate.setValueCurveAtTime(curveData, bendStart, bendTime);
|
|
773
|
+
}
|
|
774
|
+
catch (e) {
|
|
775
|
+
// Fallback to exponential ramp
|
|
776
|
+
const safeBase = Math.max(0.1, baseRate);
|
|
777
|
+
const safeTarget = Math.max(0.1, safeTargetRate);
|
|
778
|
+
try {
|
|
779
|
+
src.playbackRate.exponentialRampToValueAtTime(safeTarget, bendEnd);
|
|
780
|
+
}
|
|
781
|
+
catch (e2) {
|
|
782
|
+
// Last resort: linear ramp
|
|
783
|
+
src.playbackRate.linearRampToValueAtTime(safeTargetRate, bendEnd);
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
else if (curve === 'log' || curve === 'logarithmic') {
|
|
788
|
+
// Generate logarithmic curve samples
|
|
789
|
+
const samples = 128;
|
|
790
|
+
const curveData = new Float32Array(samples);
|
|
791
|
+
for (let i = 0; i < samples; i++) {
|
|
792
|
+
const t = i / (samples - 1);
|
|
793
|
+
const logT = 1 - Math.pow(1 - t, 2); // Logarithmic curve
|
|
794
|
+
const rate = baseRate * Math.pow(2, (semitones * logT) / 12);
|
|
795
|
+
curveData[i] = Math.max(0.1, Math.min(10, rate));
|
|
796
|
+
}
|
|
797
|
+
try {
|
|
798
|
+
src.playbackRate.setValueCurveAtTime(curveData, bendStart, bendTime);
|
|
799
|
+
}
|
|
800
|
+
catch (e) {
|
|
801
|
+
// Fallback to linear ramp
|
|
802
|
+
src.playbackRate.linearRampToValueAtTime(safeTargetRate, bendEnd);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
else if (curve === 'sine' || curve === 'sin') {
|
|
806
|
+
// Generate sine curve samples
|
|
807
|
+
const samples = 128;
|
|
808
|
+
const curveData = new Float32Array(samples);
|
|
809
|
+
for (let i = 0; i < samples; i++) {
|
|
810
|
+
const t = i / (samples - 1);
|
|
811
|
+
const sineT = (1 - Math.cos(Math.PI * t)) / 2; // Sine curve
|
|
812
|
+
const rate = baseRate * Math.pow(2, (semitones * sineT) / 12);
|
|
813
|
+
curveData[i] = Math.max(0.1, Math.min(10, rate));
|
|
814
|
+
}
|
|
815
|
+
try {
|
|
816
|
+
src.playbackRate.setValueCurveAtTime(curveData, bendStart, bendTime);
|
|
817
|
+
}
|
|
818
|
+
catch (e) {
|
|
819
|
+
// Fallback to linear ramp
|
|
820
|
+
src.playbackRate.linearRampToValueAtTime(safeTargetRate, bendEnd);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
// Linear curve (default)
|
|
825
|
+
src.playbackRate.linearRampToValueAtTime(safeTargetRate, bendEnd);
|
|
826
|
+
}
|
|
827
|
+
// Hold target playback rate for remainder of note
|
|
828
|
+
if (bendEnd < start + dur) {
|
|
829
|
+
try {
|
|
830
|
+
src.playbackRate.setValueAtTime(safeTargetRate, bendEnd);
|
|
831
|
+
}
|
|
832
|
+
catch (e) {
|
|
833
|
+
// Ignore errors
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
catch (e) {
|
|
839
|
+
// Best effort - skip pitch bend if automation fails
|
|
840
|
+
}
|
|
841
|
+
});
|
|
842
|
+
// Pitch Sweep effect: hardware-accurate Game Boy NR10 frequency sweep
|
|
843
|
+
// Parameters:
|
|
844
|
+
// - params[0]: time (required, sweep step time in 1/128 Hz units, range 0-7)
|
|
845
|
+
// - 0 = sweep disabled, 1-7 = sweep enabled with step time = n/128 Hz
|
|
846
|
+
// - Each step shifts frequency by the amount in params[2]
|
|
847
|
+
// - params[1]: direction (optional, 'up'/'+'/1 or 'down'/'-'/0, default: 'down')
|
|
848
|
+
// - 'up'/'+'/1 = increase frequency (pitch up)
|
|
849
|
+
// - 'down'/'-'/0 = decrease frequency (pitch down, hardware default)
|
|
850
|
+
// - params[2]: shift (required, frequency shift amount, range 0-7)
|
|
851
|
+
// - Number of bits to shift in GB hardware formula
|
|
852
|
+
// - 0 = no change, 1-7 = increasingly dramatic sweeps
|
|
853
|
+
//
|
|
854
|
+
// Hardware behavior (Game Boy NR10):
|
|
855
|
+
// - Only available on Pulse 1 channel (NR10 register)
|
|
856
|
+
// - Formula: f_new = f_old ± f_old / 2^shift
|
|
857
|
+
// - Sweep recalculates every (time/128) seconds
|
|
858
|
+
// - Sweep stops when reaching frequency limits (131 Hz - 131 kHz on GB)
|
|
859
|
+
//
|
|
860
|
+
// WebAudio implementation:
|
|
861
|
+
// - Calculates final frequency using iterative sweep formula
|
|
862
|
+
// - Uses exponentialRampToValueAtTime for smooth hardware-like sweep
|
|
863
|
+
// - Warns if used on non-Pulse1 channels (effect still applies for flexibility)
|
|
864
|
+
//
|
|
865
|
+
// Common uses:
|
|
866
|
+
// - Laser sounds: <sweep:4,down,7> (fast downward sweep)
|
|
867
|
+
// - Sci-fi effects: <sweep:7,up,3> (slow upward sweep)
|
|
868
|
+
// - Classic GB "pew" sound: <sweep:2,down,5>
|
|
869
|
+
// - Pitch risers: <sweep:6,up,4>
|
|
870
|
+
//
|
|
871
|
+
// UGE export: Maps directly to NR10 register (Pulse 1 only)
|
|
872
|
+
// MIDI export: Pitch wheel events or text meta event
|
|
873
|
+
register('sweep', (ctx, nodes, params, start, dur, chId, tickSeconds, inst) => {
|
|
874
|
+
if (!nodes || nodes.length === 0)
|
|
875
|
+
return;
|
|
876
|
+
const osc = nodes[0];
|
|
877
|
+
if (!osc || !(osc.frequency && typeof osc.frequency.setValueAtTime === 'function'))
|
|
878
|
+
return;
|
|
879
|
+
if (!params || params.length < 2)
|
|
880
|
+
return;
|
|
881
|
+
// Parse time parameter (0-7, in 1/128 Hz units)
|
|
882
|
+
const timeRaw = Number(params[0]);
|
|
883
|
+
if (!Number.isFinite(timeRaw) || timeRaw < 0 || timeRaw > 7)
|
|
884
|
+
return;
|
|
885
|
+
const time = Math.round(timeRaw);
|
|
886
|
+
if (time === 0)
|
|
887
|
+
return; // Sweep disabled
|
|
888
|
+
// Parse direction parameter
|
|
889
|
+
const dirRaw = params.length > 1 ? params[1] : 'down';
|
|
890
|
+
let direction = 'down';
|
|
891
|
+
if (typeof dirRaw === 'number') {
|
|
892
|
+
direction = dirRaw > 0 ? 'up' : 'down';
|
|
893
|
+
}
|
|
894
|
+
else if (typeof dirRaw === 'string') {
|
|
895
|
+
const dirStr = String(dirRaw).toLowerCase().trim();
|
|
896
|
+
if (dirStr === 'up' || dirStr === '+' || dirStr === '1') {
|
|
897
|
+
direction = 'up';
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
// Parse shift parameter (0-7, frequency shift amount)
|
|
901
|
+
// Default to 1 if not specified (provides sensible sweep behavior)
|
|
902
|
+
const shiftRaw = params.length > 2 ? Number(params[2]) : 1;
|
|
903
|
+
if (!Number.isFinite(shiftRaw) || shiftRaw < 0 || shiftRaw > 7)
|
|
904
|
+
return;
|
|
905
|
+
const shift = Math.round(shiftRaw);
|
|
906
|
+
if (shift === 0)
|
|
907
|
+
return; // No frequency change
|
|
908
|
+
// Get the base frequency
|
|
909
|
+
let baseFreq = osc._baseFreq || osc.frequency.value;
|
|
910
|
+
if (!Number.isFinite(baseFreq) || baseFreq <= 0)
|
|
911
|
+
baseFreq = 440;
|
|
912
|
+
// Calculate sweep step time in seconds
|
|
913
|
+
// Hardware: each step occurs every (time/128) seconds
|
|
914
|
+
const sweepStepTime = time / 128.0;
|
|
915
|
+
// Calculate final frequency using iterative GB sweep formula
|
|
916
|
+
// f_new = f_old ± f_old / 2^shift
|
|
917
|
+
let freq = baseFreq;
|
|
918
|
+
const maxSteps = Math.floor(dur / sweepStepTime);
|
|
919
|
+
const divisor = Math.pow(2, shift);
|
|
920
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
921
|
+
const delta = freq / divisor;
|
|
922
|
+
if (direction === 'up') {
|
|
923
|
+
freq = freq + delta;
|
|
924
|
+
}
|
|
925
|
+
else {
|
|
926
|
+
freq = freq - delta;
|
|
927
|
+
}
|
|
928
|
+
// Clamp to GB hardware limits (approx 131 Hz - 131 kHz)
|
|
929
|
+
// For WebAudio we use wider range (20 Hz - 20 kHz)
|
|
930
|
+
if (freq < 20)
|
|
931
|
+
freq = 20;
|
|
932
|
+
if (freq > 20000)
|
|
933
|
+
freq = 20000;
|
|
934
|
+
// Stop if frequency change becomes negligible
|
|
935
|
+
if (Math.abs(delta) < 0.1)
|
|
936
|
+
break;
|
|
937
|
+
}
|
|
938
|
+
const targetFreq = freq;
|
|
939
|
+
const safeTargetFreq = Math.max(20, Math.min(20000, targetFreq));
|
|
940
|
+
try {
|
|
941
|
+
// Cancel any existing frequency automation
|
|
942
|
+
osc.frequency.cancelScheduledValues(start);
|
|
943
|
+
// Set starting frequency
|
|
944
|
+
osc.frequency.setValueAtTime(baseFreq, start);
|
|
945
|
+
// Apply exponential sweep (hardware-like behavior)
|
|
946
|
+
const sweepEnd = start + dur;
|
|
947
|
+
// Use exponential ramp for smooth hardware-accurate sweep
|
|
948
|
+
const safeBase = Math.max(20, baseFreq);
|
|
949
|
+
const safeTarget = Math.max(20, safeTargetFreq);
|
|
950
|
+
if (Math.abs(safeTarget - safeBase) > 1) {
|
|
951
|
+
osc.frequency.exponentialRampToValueAtTime(safeTarget, sweepEnd);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
catch (e) {
|
|
955
|
+
// Best effort - skip sweep if automation fails
|
|
956
|
+
}
|
|
957
|
+
});
|
|
958
|
+
// Echo / Delay effect: time-delayed feedback repeats
|
|
959
|
+
// Parameters:
|
|
960
|
+
// - params[0]: delayTime (required, delay time in seconds or as fraction of beat duration)
|
|
961
|
+
// - If < 10.0, treated as fraction of beat (e.g., 0.25 = quarter beat, 1.0 = whole beat, 4.0 = four beats)
|
|
962
|
+
// - If >= 10.0, treated as absolute time in seconds (e.g., 10.0 = 10 seconds)
|
|
963
|
+
// - params[1]: feedback (optional, feedback amount 0-100%, default: 50)
|
|
964
|
+
// - 0 = single repeat (no feedback)
|
|
965
|
+
// - 50 = moderate decay (default)
|
|
966
|
+
// - 90+ = long tail
|
|
967
|
+
// - params[2]: mix (optional, wet/dry mix 0-100%, default: 30)
|
|
968
|
+
// - 0 = dry only (no echo)
|
|
969
|
+
// - 50 = equal mix
|
|
970
|
+
// - 100 = wet only (echo only, no dry signal)
|
|
971
|
+
//
|
|
972
|
+
// Creates ambient/spacey textures, rhythmic echoes, dub-style effects, and adds depth.
|
|
973
|
+
// Uses WebAudio DelayNode with feedback loop for authentic delay behavior.
|
|
974
|
+
//
|
|
975
|
+
// Implementation note: Echo requires access to the audio signal itself, not just parameter
|
|
976
|
+
// modulation. This effect stores echo configuration on the nodes array for the playback
|
|
977
|
+
// system to handle, similar to how retrigger works.
|
|
978
|
+
//
|
|
979
|
+
// Common uses:
|
|
980
|
+
// - Ambient textures: <echo:0.5,30,20> (500ms delay, light feedback, subtle mix)
|
|
981
|
+
// - Slapback delay: <echo:0.125,0,40> (125ms delay, no feedback, moderate mix)
|
|
982
|
+
// - Dub echo: <echo:0.375,70,50> (dotted-8th delay, heavy feedback, equal mix)
|
|
983
|
+
// - Rhythmic echo: <echo:0.25,50,30> (quarter-note delay, moderate feedback)
|
|
984
|
+
//
|
|
985
|
+
// WebAudio implementation using DelayNode + feedback loop
|
|
986
|
+
// PCM renderer: Implemented via manual sample delay and feedback
|
|
987
|
+
// UGE export: Not natively supported - warn and suggest baking or channel duplication
|
|
988
|
+
// MIDI export: Documented via text meta event (MIDI has no native delay)
|
|
989
|
+
register('echo', (ctx, nodes, params, start, dur, chId, tickSeconds, inst) => {
|
|
990
|
+
if (!nodes)
|
|
991
|
+
return; // Don't check nodes.length - metadata can be stored on empty arrays
|
|
992
|
+
if (!params || params.length === 0)
|
|
993
|
+
return;
|
|
994
|
+
// Parse delay time parameter
|
|
995
|
+
const delayTimeRaw = Number(params[0]);
|
|
996
|
+
if (!Number.isFinite(delayTimeRaw) || delayTimeRaw <= 0)
|
|
997
|
+
return;
|
|
998
|
+
// If delay time < 10.0, treat as fraction of beat duration (e.g., 0.25 = quarter beat, 1.0 = whole beat)
|
|
999
|
+
// If delay time >= 10.0, treat as absolute time in seconds (e.g., 10.0 = 10 seconds)
|
|
1000
|
+
let delayTime;
|
|
1001
|
+
if (delayTimeRaw < 10.0) {
|
|
1002
|
+
// Fraction of beat - convert to seconds
|
|
1003
|
+
// tickSeconds = duration of one tick in seconds (if provided)
|
|
1004
|
+
// Convention: 16 ticks per beat, so secondsPerBeat = tickSeconds * 16
|
|
1005
|
+
// Default fallback: 120 BPM = 0.5 seconds per beat (60 / 120 BPM)
|
|
1006
|
+
const secondsPerBeat = tickSeconds ? (tickSeconds * 16) : 0.5;
|
|
1007
|
+
delayTime = delayTimeRaw * secondsPerBeat;
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
delayTime = delayTimeRaw;
|
|
1011
|
+
}
|
|
1012
|
+
// Parse feedback parameter (0-100%, default: 50)
|
|
1013
|
+
const feedbackRaw = params.length > 1 ? Number(params[1]) : 50;
|
|
1014
|
+
const feedback = Number.isFinite(feedbackRaw) ? Math.max(0, Math.min(100, feedbackRaw)) / 100 : 0.5;
|
|
1015
|
+
// Parse mix parameter (0-100%, default: 30)
|
|
1016
|
+
const mixRaw = params.length > 2 ? Number(params[2]) : 30;
|
|
1017
|
+
const mix = Number.isFinite(mixRaw) ? Math.max(0, Math.min(100, mixRaw)) / 100 : 0.3;
|
|
1018
|
+
// Store echo metadata on the nodes array for the playback system to handle
|
|
1019
|
+
// This is similar to how retrigger works - signal that echo post-processing is needed
|
|
1020
|
+
nodes.__echo = {
|
|
1021
|
+
delayTime,
|
|
1022
|
+
feedback,
|
|
1023
|
+
mix,
|
|
1024
|
+
start,
|
|
1025
|
+
dur,
|
|
1026
|
+
};
|
|
1027
|
+
});
|
|
1028
|
+
//# sourceMappingURL=index.js.map
|