From 4bcb1e76f03e329dea2806d0dc79683a805d86d8 Mon Sep 17 00:00:00 2001 From: Hans Kokx Date: Sun, 15 Mar 2026 12:49:21 +0100 Subject: [PATCH] Added ADSR to sound rendering Signed-off-by: Hans Kokx --- .../wolf_3d_synth/lib/src/opl2_emulator.dart | 162 ++++++++++++++---- 1 file changed, 126 insertions(+), 36 deletions(-) diff --git a/packages/wolf_3d_synth/lib/src/opl2_emulator.dart b/packages/wolf_3d_synth/lib/src/opl2_emulator.dart index b638cf5..164e26b 100644 --- a/packages/wolf_3d_synth/lib/src/opl2_emulator.dart +++ b/packages/wolf_3d_synth/lib/src/opl2_emulator.dart @@ -1,5 +1,7 @@ import 'dart:math' as math; +enum EnvelopeState { off, attack, decay, sustain, release } + class Opl2Channel { int fNum = 0; int block = 0; @@ -8,46 +10,143 @@ class Opl2Channel { double phase = 0.0; double phaseIncrement = 0.0; - void updateFrequency() { - // The magic Yamaha YM3812 frequency formula! - // 49716Hz is the internal sample rate of the original 1980s silicon. - double realFreq = (fNum * math.pow(2, block)) * (49716.0 / 1048576.0); + // ADSR Parameters (0.0 to 1.0 scales or rates per sample) + double attackRate = 0.01; + double decayRate = 0.005; + double sustainLevel = 0.5; + double releaseRate = 0.005; - // Convert that physical frequency into a phase step for our modern 44100Hz output + // Current Envelope State + EnvelopeState envState = EnvelopeState.off; + double envVolume = 0.0; + + void updateFrequency() { + double realFreq = (fNum * math.pow(2, block)) * (49716.0 / 1048576.0); phaseIncrement = (realFreq * 2 * math.pi) / Opl2Emulator.sampleRate; } - double getSample() { - // If the note isn't being pressed, return silence - if (!keyOn) return 0.0; + void triggerOn() { + if (!keyOn) { + keyOn = true; + phase = 0.0; + envState = EnvelopeState.attack; + } + } - // Generate the raw sine wave output + void triggerOff() { + keyOn = false; + envState = EnvelopeState.release; + } + + double getSample() { + // Process the ADSR state machine + switch (envState) { + case EnvelopeState.attack: + envVolume += attackRate; + if (envVolume >= 1.0) { + envVolume = 1.0; + envState = EnvelopeState.decay; + } + break; + case EnvelopeState.decay: + envVolume -= decayRate; + if (envVolume <= sustainLevel) { + envVolume = sustainLevel; + envState = EnvelopeState.sustain; + } + break; + case EnvelopeState.sustain: + // Holds steady at the sustain level + envVolume = sustainLevel; + break; + case EnvelopeState.release: + envVolume -= releaseRate; + if (envVolume <= 0.0) { + envVolume = 0.0; + envState = EnvelopeState.off; + } + break; + case EnvelopeState.off: + envVolume = 0.0; + return 0.0; // Early exit for silence + } + + // Generate the sine wave double out = math.sin(phase); - // Advance the phase for the next frame phase += phaseIncrement; if (phase >= 2 * math.pi) { phase -= 2 * math.pi; } - // Scale the volume down significantly. If 9 channels play at 1.0 volume, - // the audio will severely clip and distort. - return out * 0.1; + // Multiply the raw sine wave by our current envelope volume + return (out * envVolume) * 0.1; } } class Opl2Emulator { static const int sampleRate = 44100; - // The 9 independent audio channels final List channels = List.generate(9, (_) => Opl2Channel()); - void writeRegister(int reg, int data) { - // --- 0xA0 - 0xA8: F-Number (Lower 8 bits) --- - if (reg >= 0xA0 && reg <= 0xA8) { - int channelIdx = reg - 0xA0; + // Real OPL2 chips use 2 operators (oscillators) per channel. + // This maps the IMF register offsets to find the "Carrier" operator for our 9 channels. + static const List _carrierOffsets = [ + 0x03, + 0x04, + 0x05, + 0x0B, + 0x0C, + 0x0D, + 0x13, + 0x14, + 0x15, + ]; - // Keep the upper 2 bits intact, replace the lower 8 bits + int _getChannelIndexForOperator(int regOffset) { + return _carrierOffsets.indexOf(regOffset); + } + + void writeRegister(int reg, int data) { + // --- 0x60 - 0x75: Attack (Upper 4 bits) & Decay (Lower 4 bits) --- + if (reg >= 0x60 && reg <= 0x75) { + int offset = reg - 0x60; + int channelIdx = _getChannelIndexForOperator(offset); + + if (channelIdx != -1) { + // If it's a carrier operator + int attack = (data & 0xF0) >> 4; + int decay = data & 0x0F; + + // Map the 0-15 hardware values to our simple rate scales + // (15 is fastest, 0 is slowest/infinite) + channels[channelIdx].attackRate = attack == 0 + ? 0.0 + : (attack / 15.0) * 0.05; + channels[channelIdx].decayRate = decay == 0 + ? 0.0 + : (decay / 15.0) * 0.005; + } + } + // --- 0x80 - 0x95: Sustain (Upper 4 bits) & Release (Lower 4 bits) --- + else if (reg >= 0x80 && reg <= 0x95) { + int offset = reg - 0x80; + int channelIdx = _getChannelIndexForOperator(offset); + + if (channelIdx != -1) { + int sustain = (data & 0xF0) >> 4; + int release = data & 0x0F; + + // OPL2 sustain is inverted (0 is loudest, 15 is quietest) + channels[channelIdx].sustainLevel = 1.0 - (sustain / 15.0); + channels[channelIdx].releaseRate = release == 0 + ? 0.0 + : (release / 15.0) * 0.005; + } + } + // --- 0xA0 - 0xA8: F-Number (Lower 8 bits) --- + else if (reg >= 0xA0 && reg <= 0xA8) { + int channelIdx = reg - 0xA0; channels[channelIdx].fNum = (channels[channelIdx].fNum & 0x300) | data; channels[channelIdx].updateFrequency(); } @@ -56,34 +155,25 @@ class Opl2Emulator { int channelIdx = reg - 0xB0; Opl2Channel channel = channels[channelIdx]; - // Track the previous key state to prevent constant phase resetting - bool wasKeyOn = channel.keyOn; - - // Extract the bits using bitwise masks - channel.keyOn = (data & 0x20) != 0; // Bit 5 - channel.block = (data & 0x1C) >> 2; // Bits 2-4 - int fNumHigh = (data & 0x03) << 8; // Bits 0-1 - - // Keep the lower 8 bits intact, replace the upper 2 bits - channel.fNum = (channel.fNum & 0xFF) | fNumHigh; + bool newKeyOn = (data & 0x20) != 0; + channel.block = (data & 0x1C) >> 2; + channel.fNum = (channel.fNum & 0xFF) | ((data & 0x03) << 8); channel.updateFrequency(); - // ONLY reset the phase if the note was just struck - if (!wasKeyOn && channel.keyOn) { - channel.phase = 0.0; + // Trigger the ADSR envelope appropriately + if (newKeyOn && !channel.keyOn) { + channel.triggerOn(); + } else if (!newKeyOn && channel.keyOn) { + channel.triggerOff(); } } } - /// Mixes the 9 channels down into a single PCM audio sample double generateSample() { double mixedOutput = 0.0; - for (var channel in channels) { mixedOutput += channel.getSample(); } - - // Hard limiter to prevent audio popping if the mix exceeds 1.0 return mixedOutput.clamp(-1.0, 1.0); } }