Added ADSR to sound rendering

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 12:49:21 +01:00
parent a7506b475e
commit 4bcb1e76f0

View File

@@ -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<Opl2Channel> 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<int> _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);
}
}