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:
108
packages/wolf_3d_dart/lib/src/synth/imf_renderer.dart
Normal file
108
packages/wolf_3d_dart/lib/src/synth/imf_renderer.dart
Normal 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,
|
||||
];
|
||||
}
|
||||
385
packages/wolf_3d_dart/lib/src/synth/opl2_emulator.dart
Normal file
385
packages/wolf_3d_dart/lib/src/synth/opl2_emulator.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
174
packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart
Normal file
174
packages/wolf_3d_dart/lib/src/synth/wolf_3d_audio.dart
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user