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