Migrate all Dart packages to a single wolf_3d_dart package

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-17 10:55:10 +01:00
parent eec1f8f495
commit 0dc75ded62
120 changed files with 364 additions and 763 deletions

View File

@@ -0,0 +1,108 @@
import 'dart:typed_data';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'opl2_emulator.dart';
class ImfRenderer {
/// Renders music in Stereo with a 10ms Haas widening effect.
static Int16List render(ImfMusic music) {
final emulator = Opl2Emulator();
const double samplesPerTick = Opl2Emulator.sampleRate / 700.0;
int totalTicks = 0;
for (final inst in music.instructions) {
totalTicks += inst.delay;
}
// Allocate buffer for Stereo (2 channels per sample)
int totalMonoSamples = (totalTicks * samplesPerTick).round();
final pcmBuffer = Int16List(totalMonoSamples * 2);
int sampleIndex = 0;
// --- Stereo Widening (Haas Effect) ---
// 10ms at 44.1kHz = 441 samples.
// This is tight enough to sound like 'width' rather than 'echo'.
const int delaySamples = 441;
final delayLine = Float32List(delaySamples);
int delayPtr = 0;
for (final instruction in music.instructions) {
emulator.writeRegister(instruction.register, instruction.data);
int samplesToGenerate = (instruction.delay * samplesPerTick).round();
for (int i = 0; i < samplesToGenerate; i++) {
if (sampleIndex + 1 >= pcmBuffer.length) break;
double monoSample = emulator.generateSample();
// Left channel: Immediate
double left = monoSample;
// Right channel: Delayed
double right = delayLine[delayPtr];
delayLine[delayPtr] = monoSample;
delayPtr = (delayPtr + 1) % delaySamples;
// Interleave L and R
pcmBuffer[sampleIndex++] = (left * 32767).toInt().clamp(-32768, 32767);
pcmBuffer[sampleIndex++] = (right * 32767).toInt().clamp(-32768, 32767);
}
}
return pcmBuffer;
}
/// Wraps raw PCM data into a standard Stereo WAV file header.
static Uint8List createWavFile(
Int16List pcmData, {
int sampleRate = Opl2Emulator.sampleRate,
}) {
const int numChannels = 2;
const int bytesPerSample = 2;
const int blockAlign = numChannels * bytesPerSample;
final int dataSize = pcmData.length * bytesPerSample;
final int byteRate = sampleRate * blockAlign;
final int fileSize = 36 + dataSize;
final bytes = BytesBuilder();
// RIFF header
bytes.add('RIFF'.codeUnits);
bytes.add(_int32ToBytes(fileSize));
bytes.add('WAVE'.codeUnits);
// Format chunk
bytes.add('fmt '.codeUnits);
bytes.add(_int32ToBytes(16));
bytes.add(_int16ToBytes(1));
bytes.add(_int16ToBytes(numChannels));
bytes.add(_int32ToBytes(sampleRate));
bytes.add(_int32ToBytes(byteRate));
bytes.add(_int16ToBytes(blockAlign));
bytes.add(_int16ToBytes(16));
// Data chunk
bytes.add('data'.codeUnits);
bytes.add(_int32ToBytes(dataSize));
final byteData = ByteData(dataSize);
for (int i = 0; i < pcmData.length; i++) {
byteData.setInt16(i * 2, pcmData[i], Endian.little);
}
bytes.add(byteData.buffer.asUint8List());
return bytes.toBytes();
}
static List<int> _int16ToBytes(int value) => [
value & 0xff,
(value >> 8) & 0xff,
];
static List<int> _int32ToBytes(int value) => [
value & 0xff,
(value >> 8) & 0xff,
(value >> 16) & 0xff,
(value >> 24) & 0xff,
];
}

View File

@@ -0,0 +1,385 @@
import 'dart:math' as math;
enum EnvelopeState { off, attack, decay, sustain, release }
class Opl2Operator {
double phase = 0.0;
double phaseIncrement = 0.0;
double attackRate = 0.01;
double decayRate = 0.005;
double sustainLevel = 0.5;
double releaseRate = 0.005;
EnvelopeState envState = EnvelopeState.off;
double envVolume = 0.0;
double multiplier = 1.0;
double volume = 1.0;
// Waveform Selection (0-3)
int waveform = 0;
void updateFrequency(double baseFreq) {
phaseIncrement =
(baseFreq * multiplier * 2 * math.pi) / Opl2Emulator.sampleRate;
}
void triggerOn() {
phase = 0.0;
envState = EnvelopeState.attack;
}
void triggerOff() {
envState = EnvelopeState.release;
}
// Applies the OPL2 hardware waveform math
double _getOscillatorOutput(double currentPhase) {
// Normalize phase between 0 and 2*pi
double p = currentPhase % (2 * math.pi);
if (p < 0) p += 2 * math.pi;
switch (waveform) {
case 1: // Half-Sine (silence for the second half)
return p < math.pi ? math.sin(p) : 0.0;
case 2: // Absolute-Sine (fully rectified)
return math.sin(p).abs();
case 3: // Quarter-Sine / Pulse (raspy pulse)
return (p % math.pi) < (math.pi / 2.0) ? math.sin(p) : 0.0;
case 0:
default: // Standard Sine
return math.sin(p);
}
}
double getSample(double phaseOffset) {
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:
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;
}
// Pass the phase + modulation offset into our waveform generator!
double out = _getOscillatorOutput(phase + phaseOffset);
phase += phaseIncrement;
if (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;
bool isAdditive = false;
int feedbackStrength = 0;
double _prevModOutput1 = 0.0;
double _prevModOutput2 = 0.0;
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;
}
double feedbackPhase = 0.0;
if (feedbackStrength > 0) {
double averageMod = (_prevModOutput1 + _prevModOutput2) / 2.0;
double feedbackFactor =
math.pow(2, feedbackStrength - 1) * (math.pi / 16.0);
feedbackPhase = averageMod * feedbackFactor;
}
double modOutput = modulator.getSample(feedbackPhase);
_prevModOutput2 = _prevModOutput1;
_prevModOutput1 = modOutput;
double channelOutput = 0.0;
if (isAdditive) {
double carOutput = carrier.getSample(0.0);
channelOutput = modOutput + carOutput;
} else {
double carOutput = carrier.getSample(modOutput * 2.0);
channelOutput = carOutput;
}
return channelOutput * 0.1;
}
}
class Opl2Noise {
int _seed = 0xFFFF;
// Simple 16-bit LFSR to match OPL2 noise characteristics
double next() {
int bit = ((_seed >> 0) ^ (_seed >> 2) ^ (_seed >> 3) ^ (_seed >> 5)) & 1;
_seed = (_seed >> 1) | (bit << 15);
return ((_seed & 1) == 1) ? 1.0 : -1.0;
}
}
class Opl2Emulator {
static const int sampleRate = 44100;
bool rhythmMode = false;
// Key states for the 5 drums
bool bassDrumKey = false;
bool snareDrumKey = false;
bool tomTomKey = false;
bool topCymbalKey = false;
bool hiHatKey = false;
final Opl2Noise _noise = Opl2Noise();
final List<Opl2Channel> channels = List.generate(9, (_) => Opl2Channel());
// The master lock for waveforms
bool _waveformSelectionEnabled = false;
static const List<int> _operatorMap = [
0,
1,
2,
0,
1,
2,
-1,
-1,
3,
4,
5,
3,
4,
5,
-1,
-1,
6,
7,
8,
6,
7,
8,
];
Opl2Operator? _getOperator(int offset) {
if (offset < 0 ||
offset >= _operatorMap.length ||
_operatorMap[offset] == -1) {
return null;
}
int channelIdx = _operatorMap[offset];
bool isCarrier = (offset % 8) >= 3;
return isCarrier
? channels[channelIdx].carrier
: channels[channelIdx].modulator;
}
void writeRegister(int reg, int data) {
// --- 0x01: Test / Waveform Enable ---
if (reg == 0x01) {
// Bit 5 must be set to 1 to allow waveform changes
_waveformSelectionEnabled = (data & 0x20) != 0;
}
// --- 0x20 - 0x35: Multiplier ---
else if (reg >= 0x20 && reg <= 0x35) {
var op = _getOperator(reg - 0x20);
if (op != null) {
int mult = data & 0x0F;
op.multiplier = mult == 0 ? 0.5 : mult.toDouble();
}
}
// --- 0x40 - 0x55: Total Level (Volume) ---
else if (reg >= 0x40 && reg <= 0x55) {
var op = _getOperator(reg - 0x40);
if (op != null) {
op.volume = 1.0 - ((data & 0x3F) / 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) {
var op = _getOperator(reg - 0x80);
if (op != null) {
int sustain = (data & 0xF0) >> 4;
int release = data & 0x0F;
op.sustainLevel = 1.0 - (sustain / 15.0);
op.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();
}
// --- 0xB0 - 0xB8: Key-On, Block, F-Number (Upper 2 bits) ---
else if (reg >= 0xB0 && reg <= 0xB8) {
int channelIdx = reg - 0xB0;
Opl2Channel channel = channels[channelIdx];
bool newKeyOn = (data & 0x20) != 0;
channel.block = (data & 0x1C) >> 2;
channel.fNum = (channel.fNum & 0xFF) | ((data & 0x03) << 8);
channel.updateFrequency();
if (newKeyOn && !channel.keyOn) {
channel.keyOn = true;
channel.modulator.triggerOn();
channel.carrier.triggerOn();
} else if (!newKeyOn && channel.keyOn) {
channel.keyOn = false;
channel.modulator.triggerOff();
channel.carrier.triggerOff();
}
}
// --- 0xC0 - 0xC8: Feedback & Connection ---
else if (reg >= 0xC0 && reg <= 0xC8) {
int channelIdx = reg - 0xC0;
channels[channelIdx].isAdditive = (data & 0x01) != 0;
channels[channelIdx].feedbackStrength = (data & 0x0E) >> 1;
}
// --- 0xE0 - 0xF5: Waveform Selection ---
else if (reg >= 0xE0 && reg <= 0xF5) {
// The chip ignores this register if the master enable bit wasn't set in 0x01
if (_waveformSelectionEnabled) {
var op = _getOperator(reg - 0xE0);
if (op != null) {
// Bottom 2 bits determine the waveform (0-3)
op.waveform = data & 0x03;
}
}
} else if (reg == 0xBD) {
rhythmMode = (data & 0x20) != 0; // Bit 5: Rhythm Enable
// Bits 0-4: Key-On for individual drums
bassDrumKey = (data & 0x10) != 0;
snareDrumKey = (data & 0x08) != 0;
tomTomKey = (data & 0x04) != 0;
topCymbalKey = (data & 0x02) != 0;
hiHatKey = (data & 0x01) != 0;
}
}
double generateSample() {
double mixedOutput = 0.0;
// Channels 0-5 always act as normal melodic FM channels
for (int i = 0; i < 6; i++) {
mixedOutput += channels[i].getSample();
}
if (!rhythmMode) {
// Standard mode: play channels 6, 7, and 8 normally
for (int i = 6; i < 9; i++) {
mixedOutput += channels[i].getSample();
}
} else {
// RHYTHM MODE: The last 3 channels are re-routed
mixedOutput += _generateBassDrum();
mixedOutput += _generateSnareAndHiHat();
mixedOutput += _generateTomAndCymbal();
}
return mixedOutput.clamp(-1.0, 1.0);
}
// Example of Bass Drum logic (Channel 6)
double _generateBassDrum() {
if (!bassDrumKey) return 0.0;
// Bass drum uses standard FM (Mod -> Car) but usually with very low frequency
return channels[6].getSample();
}
double _generateSnareAndHiHat() {
double snareOut = 0.0;
double hiHatOut = 0.0;
// Snare uses Channel 7 Modulator
if (snareDrumKey) {
// The Snare is a mix of a periodic tone and white noise
double noise = _noise.next();
snareOut = channels[7].modulator.getSample(0.0) * noise;
}
// Hi-Hat uses Channel 7 Carrier
if (hiHatKey) {
// Hi-Hats are almost pure high-frequency noise
hiHatOut =
_noise.next() *
channels[7].carrier.envVolume *
channels[7].carrier.volume;
}
return (snareOut + hiHatOut) * 0.1;
}
double _generateTomAndCymbal() {
double tomOut = 0.0;
double cymbalOut = 0.0;
// Tom-tom uses Channel 8 Modulator
if (tomTomKey) {
// Toms are basically just a melodic sine wave with a fast decay
tomOut = channels[8].modulator.getSample(0.0);
}
// Top Cymbal uses Channel 8 Carrier
if (topCymbalKey) {
// Cymbals use the carrier but are usually phase-modulated by noise
double noise = _noise.next();
cymbalOut = channels[8].carrier.getSample(noise * 2.0);
}
return (tomOut + cymbalOut) * 0.1;
}
}

View File

@@ -0,0 +1,174 @@
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:wolf_3d_dart/wolf_3d_data_types.dart';
import 'package:wolf_3d_dart/wolf_3d_engine.dart';
import 'package:wolf_3d_dart/src/synth/imf_renderer.dart';
class WolfAudio implements EngineAudio {
@override
Future<void> debugSoundTest() async {
// Play the first 50 sounds with a 2-second gap to identify them
for (int i = 0; i < 50; i++) {
Future.delayed(Duration(seconds: i * 2), () {
print("Testing Sound ID: $i");
playSoundEffect(i);
});
}
}
bool _isInitialized = false;
// --- Music State ---
final AudioPlayer _musicPlayer = AudioPlayer();
// --- SFX State ---
// A pool of players to allow overlapping sound effects.
static const int _maxSfxChannels = 8;
final List<AudioPlayer> _sfxPlayers = [];
int _currentSfxIndex = 0;
@override
WolfensteinData? activeGame;
/// Initializes the audio engine and pre-allocates the SFX pool.
@override
Future<void> init() async {
if (_isInitialized) return;
try {
// Set music player mode
await _musicPlayer.setPlayerMode(PlayerMode.mediaPlayer);
// Initialize the SFX pool
for (int i = 0; i < _maxSfxChannels; i++) {
final player = AudioPlayer();
// lowLatency mode is highly recommended for short game sounds
await player.setPlayerMode(PlayerMode.lowLatency);
await player.setReleaseMode(ReleaseMode.stop);
_sfxPlayers.add(player);
}
_isInitialized = true;
print(
"WolfAudio: AudioPlayers initialized successfully with $_maxSfxChannels SFX channels.",
);
} catch (e) {
print("WolfAudio: Failed to initialize AudioPlayers - $e");
}
}
/// Disposes of the audio engine and frees resources.
@override
void dispose() {
stopMusic();
_musicPlayer.dispose();
for (final player in _sfxPlayers) {
player.stop();
player.dispose();
}
_sfxPlayers.clear();
_isInitialized = false;
}
// ==========================================
// MUSIC MANAGEMENT
// ==========================================
Future<void> playMusic(ImfMusic track, {bool looping = true}) async {
if (!_isInitialized) return;
await stopMusic();
try {
final pcmSamples = ImfRenderer.render(track);
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
await _musicPlayer.setReleaseMode(
looping ? ReleaseMode.loop : ReleaseMode.stop,
);
await _musicPlayer.play(BytesSource(wavBytes));
} catch (e) {
print("WolfAudio: Error playing music track - $e");
}
}
@override
Future<void> stopMusic() async {
if (!_isInitialized) return;
await _musicPlayer.stop();
}
Future<void> pauseMusic() async {
if (_isInitialized) await _musicPlayer.pause();
}
Future<void> resumeMusic() async {
if (_isInitialized) await _musicPlayer.resume();
}
@override
Future<void> playMenuMusic() async {
final data = activeGame;
if (data == null || data.music.length <= 1) return;
await playMusic(data.music[1]);
}
@override
Future<void> playLevelMusic(WolfLevel level) async {
final data = activeGame;
if (data == null || data.music.isEmpty) return;
final index = level.musicIndex;
if (index < data.music.length) {
await playMusic(data.music[index]);
} else {
print("WolfAudio: Warning - Track index $index out of bounds.");
}
}
// ==========================================
// SFX MANAGEMENT
// ==========================================
@override
Future<void> playSoundEffect(int sfxId) async {
print("Playing sfx id $sfxId");
// The original engine uses a specific starting chunk for digitized sounds.
// In many loaders, the 'sounds' list is already just the digitized ones.
// If your list contains EVERYTHING, you need to add the offset (174).
// If it's JUST digitized sounds, sfxId should work directly.
final soundsList = activeGame!.sounds;
if (sfxId < 0 || sfxId >= soundsList.length) return;
final raw8bitBytes = soundsList[sfxId].bytes;
if (raw8bitBytes.isEmpty) return;
// 2. Convert 8-bit Unsigned PCM -> 16-bit Signed PCM
// Wolf3D raw sounds are biased at 128.
final Int16List converted16bit = Int16List(raw8bitBytes.length);
for (int i = 0; i < raw8bitBytes.length; i++) {
// (sample - 128) shifts it to signed 8-bit range (-128 to 127)
// Multiplying by 256 scales it to 16-bit range (-32768 to 32512)
converted16bit[i] = (raw8bitBytes[i] - 128) * 256;
}
// 3. Wrap in a WAV header (Wolf3D digitized sounds are 7000Hz Mono)
final wavBytes = ImfRenderer.createWavFile(
converted16bit,
sampleRate: 7000,
);
try {
final player = _sfxPlayers[_currentSfxIndex];
_currentSfxIndex = (_currentSfxIndex + 1) % _maxSfxChannels;
// Note: We use BytesSource because createWavFile returns Uint8List (the file bytes)
await player.play(BytesSource(wavBytes));
} catch (e) {
print("WolfAudio: SFX Error - $e");
}
}
}