Added FM synth

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

View File

@@ -2,44 +2,39 @@ import 'dart:math' as math;
enum EnvelopeState { off, attack, decay, sustain, release } enum EnvelopeState { off, attack, decay, sustain, release }
class Opl2Channel { class Opl2Operator {
int fNum = 0;
int block = 0;
bool keyOn = false;
double phase = 0.0; double phase = 0.0;
double phaseIncrement = 0.0; double phaseIncrement = 0.0;
// ADSR Parameters (0.0 to 1.0 scales or rates per sample) // ADSR
double attackRate = 0.01; double attackRate = 0.01;
double decayRate = 0.005; double decayRate = 0.005;
double sustainLevel = 0.5; double sustainLevel = 0.5;
double releaseRate = 0.005; double releaseRate = 0.005;
// Current Envelope State
EnvelopeState envState = EnvelopeState.off; EnvelopeState envState = EnvelopeState.off;
double envVolume = 0.0; double envVolume = 0.0;
void updateFrequency() { // FM specific parameters
double realFreq = (fNum * math.pow(2, block)) * (49716.0 / 1048576.0); double multiplier = 1.0;
phaseIncrement = (realFreq * 2 * math.pi) / Opl2Emulator.sampleRate; double volume =
1.0; // Acts as volume for Carrier, but "Modulation Strength" for Modulator
void updateFrequency(double baseFreq) {
phaseIncrement =
(baseFreq * multiplier * 2 * math.pi) / Opl2Emulator.sampleRate;
} }
void triggerOn() { void triggerOn() {
if (!keyOn) { phase = 0.0;
keyOn = true; envState = EnvelopeState.attack;
phase = 0.0;
envState = EnvelopeState.attack;
}
} }
void triggerOff() { void triggerOff() {
keyOn = false;
envState = EnvelopeState.release; envState = EnvelopeState.release;
} }
double getSample() { // phaseOffset is where the Modulator feeds into the Carrier
// Process the ADSR state machine double getSample(double phaseOffset) {
switch (envState) { switch (envState) {
case EnvelopeState.attack: case EnvelopeState.attack:
envVolume += attackRate; envVolume += attackRate;
@@ -56,7 +51,6 @@ class Opl2Channel {
} }
break; break;
case EnvelopeState.sustain: case EnvelopeState.sustain:
// Holds steady at the sustain level
envVolume = sustainLevel; envVolume = sustainLevel;
break; break;
case EnvelopeState.release: case EnvelopeState.release:
@@ -68,19 +62,48 @@ class Opl2Channel {
break; break;
case EnvelopeState.off: case EnvelopeState.off:
envVolume = 0.0; envVolume = 0.0;
return 0.0; // Early exit for silence return 0.0;
} }
// Generate the sine wave // Add the Modulator's output to our current phase!
double out = math.sin(phase); double out = math.sin(phase + phaseOffset);
phase += phaseIncrement; phase += phaseIncrement;
if (phase >= 2 * math.pi) { if (phase >= 2 * math.pi) phase -= 2 * math.pi;
phase -= 2 * math.pi;
return out * envVolume * volume;
}
}
class Opl2Channel {
Opl2Operator modulator = Opl2Operator();
Opl2Operator carrier = Opl2Operator();
int fNum = 0;
int block = 0;
bool keyOn = false;
void updateFrequency() {
double baseFreq = (fNum * math.pow(2, block)) * (49716.0 / 1048576.0);
modulator.updateFrequency(baseFreq);
carrier.updateFrequency(baseFreq);
}
double getSample() {
if (!keyOn &&
carrier.envState == EnvelopeState.off &&
modulator.envState == EnvelopeState.off) {
return 0.0;
} }
// Multiply the raw sine wave by our current envelope volume // 1. Get the modulator's output (it takes no phase offset itself)
return (out * envVolume) * 0.1; // We multiply it by an arbitrary index (e.g. 2.0) to give it enough strength to warp the carrier
double modOutput = modulator.getSample(0.0) * 2.0;
// 2. Feed the modulator's output directly into the carrier's phase!
double carOutput = carrier.getSample(modOutput);
return carOutput * 0.1; // Master channel volume reduction
} }
} }
@@ -89,59 +112,68 @@ class Opl2Emulator {
final List<Opl2Channel> channels = List.generate(9, (_) => Opl2Channel()); final List<Opl2Channel> channels = List.generate(9, (_) => Opl2Channel());
// Real OPL2 chips use 2 operators (oscillators) per channel. // The OPL2 maps 18 operators to 9 channels in a very fragmented memory layout.
// This maps the IMF register offsets to find the "Carrier" operator for our 9 channels. // This helper array maps a register offset (0x00 to 0x15) to a specific channel and operator type.
static const List<int> _carrierOffsets = [ static const List<int> _operatorMap = [
0x03, 0, 1, 2, 0, 1, 2, // Offsets 0x00-0x05 (Ch 0-2 Modulators, then Carriers)
0x04, -1, -1, // Offsets 0x06-0x07 (Unused)
0x05, 3, 4, 5, 3, 4, 5, // Offsets 0x08-0x0D (Ch 3-5 Modulators, then Carriers)
0x0B, -1, -1, // Offsets 0x0E-0x0F (Unused)
0x0C, 6, 7, 8, 6, 7, 8, // Offsets 0x10-0x15 (Ch 6-8 Modulators, then Carriers)
0x0D,
0x13,
0x14,
0x15,
]; ];
int _getChannelIndexForOperator(int regOffset) { Opl2Operator? _getOperator(int offset) {
return _carrierOffsets.indexOf(regOffset); if (offset < 0 ||
offset >= _operatorMap.length ||
_operatorMap[offset] == -1) {
return null;
}
int channelIdx = _operatorMap[offset];
// In our map, if the offset relative to the block is < 3, it's a Modulator.
// Otherwise, it's a Carrier.
bool isCarrier = (offset % 8) >= 3;
return isCarrier
? channels[channelIdx].carrier
: channels[channelIdx].modulator;
} }
void writeRegister(int reg, int data) { void writeRegister(int reg, int data) {
// --- 0x60 - 0x75: Attack (Upper 4 bits) & Decay (Lower 4 bits) --- // --- 0x20 - 0x35: Multiplier ---
if (reg >= 0x60 && reg <= 0x75) { if (reg >= 0x20 && reg <= 0x35) {
int offset = reg - 0x60; var op = _getOperator(reg - 0x20);
int channelIdx = _getChannelIndexForOperator(offset); if (op != null) {
int mult = data & 0x0F;
if (channelIdx != -1) { op.multiplier = mult == 0 ? 0.5 : mult.toDouble();
// 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) --- // --- 0x40 - 0x55: Total Level (Volume) ---
else if (reg >= 0x40 && reg <= 0x55) {
var op = _getOperator(reg - 0x40);
if (op != null) {
int level = data & 0x3F; // 0 is loudest, 63 is quietest
op.volume = 1.0 - (level / 63.0);
}
}
// --- 0x60 - 0x75: Attack & Decay ---
else if (reg >= 0x60 && reg <= 0x75) {
var op = _getOperator(reg - 0x60);
if (op != null) {
int attack = (data & 0xF0) >> 4;
int decay = data & 0x0F;
op.attackRate = attack == 0 ? 0.0 : (attack / 15.0) * 0.05;
op.decayRate = decay == 0 ? 0.0 : (decay / 15.0) * 0.005;
}
}
// --- 0x80 - 0x95: Sustain & Release ---
else if (reg >= 0x80 && reg <= 0x95) { else if (reg >= 0x80 && reg <= 0x95) {
int offset = reg - 0x80; var op = _getOperator(reg - 0x80);
int channelIdx = _getChannelIndexForOperator(offset); if (op != null) {
if (channelIdx != -1) {
int sustain = (data & 0xF0) >> 4; int sustain = (data & 0xF0) >> 4;
int release = data & 0x0F; int release = data & 0x0F;
op.sustainLevel = 1.0 - (sustain / 15.0);
// OPL2 sustain is inverted (0 is loudest, 15 is quietest) op.releaseRate = release == 0 ? 0.0 : (release / 15.0) * 0.005;
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) --- // --- 0xA0 - 0xA8: F-Number (Lower 8 bits) ---
@@ -160,11 +192,14 @@ class Opl2Emulator {
channel.fNum = (channel.fNum & 0xFF) | ((data & 0x03) << 8); channel.fNum = (channel.fNum & 0xFF) | ((data & 0x03) << 8);
channel.updateFrequency(); channel.updateFrequency();
// Trigger the ADSR envelope appropriately
if (newKeyOn && !channel.keyOn) { if (newKeyOn && !channel.keyOn) {
channel.triggerOn(); channel.keyOn = true;
channel.modulator.triggerOn();
channel.carrier.triggerOn();
} else if (!newKeyOn && channel.keyOn) { } else if (!newKeyOn && channel.keyOn) {
channel.triggerOff(); channel.keyOn = false;
channel.modulator.triggerOff();
channel.carrier.triggerOff();
} }
} }
} }