Fix errors when parsing audio

Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
2026-03-15 12:26:35 +01:00
parent b0f75d9f10
commit 431126f893
19 changed files with 385 additions and 62 deletions

View File

@@ -62,7 +62,7 @@ abstract class WLParser {
}) {
final isShareware = version == GameVersion.shareware;
final audio = parseAudio(audioHed, audioT);
final audio = parseAudio(audioHed, audioT, version);
return WolfensteinData(
version: version,
@@ -295,9 +295,9 @@ abstract class WLParser {
static ({List<AdLibSound> adLib, List<ImfMusic> music}) parseAudio(
ByteData audioHed,
ByteData audioT,
GameVersion version,
) {
List<int> offsets = [];
// AUDIOHED is a series of 32-bit unsigned integers
for (int i = 0; i < audioHed.lengthInBytes ~/ 4; i++) {
offsets.add(audioHed.getUint32(i * 4, Endian.little));
}
@@ -308,7 +308,6 @@ abstract class WLParser {
int start = offsets[i];
int next = offsets[i + 1];
// 0xFFFFFFFF (or 4294967295) marks an empty slot
if (start == 0xFFFFFFFF || start >= audioT.lengthInBytes) {
allAudioChunks.add(Uint8List(0));
continue;
@@ -324,16 +323,18 @@ abstract class WLParser {
}
}
// Wolfenstein 3D split:
// Chunks 0-299: AdLib Sounds
// Chunks 300+: IMF Music
// --- THE FIX: Accurate Historical Chunk Indices ---
// WL1 (Shareware) has exactly 234 sound effects before the music.
// WL6 (Retail) has exactly 261 sound effects before the music.
int musicStartIndex = (version == GameVersion.shareware) ? 234 : 261;
List<AdLibSound> adLib = allAudioChunks
.take(300)
.take(musicStartIndex)
.map((bytes) => AdLibSound(bytes))
.toList();
List<ImfMusic> music = allAudioChunks
.skip(300)
.skip(musicStartIndex)
.where((chunk) => chunk.isNotEmpty)
.map((bytes) => ImfMusic.fromBytes(bytes))
.toList();

View File

@@ -30,13 +30,19 @@ class ImfMusic {
factory ImfMusic.fromBytes(Uint8List bytes) {
List<ImfInstruction> instructions = [];
// Read the file in 4-byte chunks
for (int i = 0; i < bytes.length - 3; i += 4) {
// Wolfenstein 3D IMF chunks start with a 16-bit length header (little-endian)
int actualSize = bytes[0] | (bytes[1] << 8);
// Start parsing at index 2 to skip the size header
int limit = 2 + actualSize;
if (limit > bytes.length) limit = bytes.length; // Safety bounds
for (int i = 2; i < limit - 3; i += 4) {
instructions.add(
ImfInstruction(
register: bytes[i],
data: bytes[i + 1],
delay: bytes[i + 2] | (bytes[i + 3] << 8), // 16-bit little-endian
delay: bytes[i + 2] | (bytes[i + 3] << 8),
),
);
}

View File

@@ -0,0 +1,91 @@
import 'dart:typed_data';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'opl2_emulator.dart';
class ImfRenderer {
/// Renders an ImfMusic track into raw 16-bit PCM samples
static Int16List render(ImfMusic music) {
final emulator = Opl2Emulator();
const double samplesPerTick = Opl2Emulator.sampleRate / 700.0;
// 1. Pre-calculate the total number of audio samples we need
int totalTicks = 0;
for (final inst in music.instructions) {
totalTicks += inst.delay;
}
int totalSamples = (totalTicks * samplesPerTick).round();
// 2. Pre-allocate a fixed-size buffer (blazing fast!)
final pcmBuffer = Int16List(totalSamples);
int sampleIndex = 0;
for (final instruction in music.instructions) {
// Write the instruction to the chip
emulator.writeRegister(instruction.register, instruction.data);
// Generate the samples for the delay period
int samplesToGenerate = (instruction.delay * samplesPerTick).round();
for (int i = 0; i < samplesToGenerate; i++) {
if (sampleIndex >= pcmBuffer.length) break; // Safety bounds
double sampleFloat = emulator.generateSample();
int sampleInt = (sampleFloat * 32767).toInt().clamp(-32768, 32767);
pcmBuffer[sampleIndex++] = sampleInt;
}
}
return pcmBuffer;
}
/// Wraps raw 16-bit PCM data in a standard WAV file header
static Uint8List createWavFile(Int16List pcmData) {
int byteRate = Opl2Emulator.sampleRate * 2; // 1 channel * 16-bit (2 bytes)
int dataSize = pcmData.length * 2;
int fileSize = 36 + dataSize;
final bytes = BytesBuilder();
// "RIFF" chunk descriptor
bytes.add('RIFF'.codeUnits);
bytes.add(_int32ToBytes(fileSize));
bytes.add('WAVE'.codeUnits);
// "fmt " sub-chunk
bytes.add('fmt '.codeUnits);
bytes.add(_int32ToBytes(16)); // Subchunk1Size (16 for PCM)
bytes.add(_int16ToBytes(1)); // AudioFormat (1 = PCM)
bytes.add(_int16ToBytes(1)); // NumChannels (1 = Mono)
bytes.add(_int32ToBytes(Opl2Emulator.sampleRate)); // SampleRate
bytes.add(_int32ToBytes(byteRate)); // ByteRate
bytes.add(_int16ToBytes(2)); // BlockAlign
bytes.add(_int16ToBytes(16)); // BitsPerSample
// "data" sub-chunk
bytes.add('data'.codeUnits);
bytes.add(_int32ToBytes(dataSize));
// Append the actual raw audio data
for (int sample in pcmData) {
bytes.add(_int16ToBytes(sample));
}
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,86 @@
import 'dart:math' as math;
class Opl2Channel {
int fNum = 0;
int block = 0;
bool keyOn = false;
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);
// Convert that physical frequency into a phase step for our modern 44100Hz output
phaseIncrement = (realFreq * 2 * math.pi) / Opl2Emulator.sampleRate;
}
double getSample() {
// If the note isn't being pressed, return silence
if (!keyOn) return 0.0;
// Generate the raw sine wave output
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;
}
}
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;
// Keep the upper 2 bits intact, replace the lower 8 bits
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];
// 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;
channel.updateFrequency();
// When a new note is struck, we reset the phase counter to 0
if (channel.keyOn) {
channel.phase = 0.0;
}
}
}
/// 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);
}
}

View File

@@ -1,6 +0,0 @@
// TODO: Put public facing types in this file.
/// Checks if you are awesome. Spoiler: you are.
class Awesome {
bool get isAwesome => true;
}

View File

@@ -3,6 +3,4 @@
/// More dartdocs go here.
library;
export 'src/wolf_3d_synth_base.dart';
// TODO: Export any libraries intended for clients of this package.
export 'src/imf_renderer.dart' show ImfRenderer;

View File

@@ -8,6 +8,9 @@ environment:
resolution: workspace
dependencies:
wolf_3d_data_types:
dev_dependencies:
lints: ^6.0.0
test: ^1.25.6