Smarter asset loading. Better audio rendering.
Signed-off-by: Hans Kokx <hans.d.kokx@gmail.com>
This commit is contained in:
9
.vscode/launch.json
vendored
9
.vscode/launch.json
vendored
@@ -7,19 +7,22 @@
|
|||||||
{
|
{
|
||||||
"name": "wolf_dart",
|
"name": "wolf_dart",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart"
|
"type": "dart",
|
||||||
|
"program": "lib/main.dart"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "wolf_dart (profile mode)",
|
"name": "wolf_dart (profile mode)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"flutterMode": "profile"
|
"flutterMode": "profile",
|
||||||
|
"program": "lib/main.dart"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "wolf_dart (release mode)",
|
"name": "wolf_dart (release mode)",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"type": "dart",
|
"type": "dart",
|
||||||
"flutterMode": "release"
|
"flutterMode": "release",
|
||||||
|
"program": "lib/main.dart"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,15 @@ class GameSelectScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ByteData?> tryLoad(String path) async {
|
||||||
|
try {
|
||||||
|
return await rootBundle.load(path);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("Asset not found: $path");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<WolfensteinData>> loadData({String? directory}) async {
|
Future<List<WolfensteinData>> loadData({String? directory}) async {
|
||||||
final List<WolfensteinData> loadedGames = [];
|
final List<WolfensteinData> loadedGames = [];
|
||||||
|
|
||||||
@@ -68,40 +77,28 @@ class GameSelectScreen extends StatelessWidget {
|
|||||||
(version: GameVersion.shareware, path: 'shareware'),
|
(version: GameVersion.shareware, path: 'shareware'),
|
||||||
];
|
];
|
||||||
|
|
||||||
for (final config in versionsToTry) {
|
for (final version in versionsToTry) {
|
||||||
try {
|
try {
|
||||||
|
final ext = version.version.fileExtension;
|
||||||
|
final folder = 'assets/${version.path}';
|
||||||
|
|
||||||
final data = WolfensteinLoader.loadFromBytes(
|
final data = WolfensteinLoader.loadFromBytes(
|
||||||
version: config.version,
|
version: version.version,
|
||||||
vswap: await rootBundle.load(
|
vswap: await tryLoad('$folder/VSWAP.$ext'),
|
||||||
'assets/${config.path}/VSWAP.${config.version.fileExtension}',
|
mapHead: await tryLoad('$folder/MAPHEAD.$ext'),
|
||||||
),
|
gameMaps: await tryLoad('$folder/GAMEMAPS.$ext'),
|
||||||
mapHead: await rootBundle.load(
|
vgaDict: await tryLoad('$folder/VGADICT.$ext'),
|
||||||
'assets/${config.path}/MAPHEAD.${config.version.fileExtension}',
|
vgaHead: await tryLoad('$folder/VGAHEAD.$ext'),
|
||||||
),
|
vgaGraph: await tryLoad('$folder/VGAGRAPH.$ext'),
|
||||||
gameMaps: await rootBundle.load(
|
audioHed: await tryLoad('$folder/AUDIOHED.$ext'),
|
||||||
'assets/${config.path}/GAMEMAPS.${config.version.fileExtension}',
|
audioT: await tryLoad('$folder/AUDIOT.$ext'),
|
||||||
),
|
|
||||||
vgaDict: await rootBundle.load(
|
|
||||||
'assets/${config.path}/VGADICT.${config.version.fileExtension}',
|
|
||||||
),
|
|
||||||
vgaHead: await rootBundle.load(
|
|
||||||
'assets/${config.path}/VGAHEAD.${config.version.fileExtension}',
|
|
||||||
),
|
|
||||||
vgaGraph: await rootBundle.load(
|
|
||||||
'assets/${config.path}/VGAGRAPH.${config.version.fileExtension}',
|
|
||||||
),
|
|
||||||
audioHed: await rootBundle.load(
|
|
||||||
'assets/${config.path}/AUDIOHED.${config.version.fileExtension}',
|
|
||||||
),
|
|
||||||
audioT: await rootBundle.load(
|
|
||||||
'assets/${config.path}/AUDIOT.${config.version.fileExtension}',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
loadedGames.add(data);
|
loadedGames.add(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint(
|
// The loader now provides the specific error:
|
||||||
"Bundled ${config.version.name} not found or failed to load.",
|
// "ArgumentError: Cannot load retail: Missing files: VSWAP.WL6, ..."
|
||||||
);
|
debugPrint(e.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,30 +23,53 @@ class WolfensteinLoader {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parses WolfensteinData directly from raw ByteData.
|
/// Parses WolfensteinData from raw ByteData.
|
||||||
/// This is 100% pure Dart and is safe to use on all platforms, including Web.
|
/// Throws an [ArgumentError] if any required file is null.
|
||||||
static WolfensteinData loadFromBytes({
|
static WolfensteinData loadFromBytes({
|
||||||
required GameVersion version,
|
required GameVersion version,
|
||||||
required ByteData vswap,
|
required ByteData? vswap,
|
||||||
required ByteData mapHead,
|
required ByteData? mapHead,
|
||||||
required ByteData gameMaps,
|
required ByteData? gameMaps,
|
||||||
required ByteData vgaDict,
|
required ByteData? vgaDict,
|
||||||
required ByteData vgaHead,
|
required ByteData? vgaHead,
|
||||||
required ByteData vgaGraph,
|
required ByteData? vgaGraph,
|
||||||
required ByteData audioHed,
|
required ByteData? audioHed,
|
||||||
required ByteData audioT,
|
required ByteData? audioT,
|
||||||
}) {
|
}) {
|
||||||
// We just act as a clean pass-through to the core parser
|
// 1. Validation Check
|
||||||
|
final Map<String, ByteData?> files = {
|
||||||
|
'VSWAP': vswap,
|
||||||
|
'MAPHEAD': mapHead,
|
||||||
|
'GAMEMAPS': gameMaps,
|
||||||
|
'VGADICT': vgaDict,
|
||||||
|
'VGAHEAD': vgaHead,
|
||||||
|
'VGAGRAPH': vgaGraph,
|
||||||
|
'AUDIOHED': audioHed,
|
||||||
|
'AUDIOT': audioT,
|
||||||
|
};
|
||||||
|
|
||||||
|
final missing = files.entries
|
||||||
|
.where((e) => e.value == null)
|
||||||
|
.map((e) => "${e.key}.${version.fileExtension}")
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (missing.isNotEmpty) {
|
||||||
|
throw ArgumentError(
|
||||||
|
'Cannot load ${version.name}: Missing files: ${missing.join(", ")}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Pass-through to parser now that we are guaranteed non-null
|
||||||
return WLParser.load(
|
return WLParser.load(
|
||||||
version: version,
|
version: version,
|
||||||
vswap: vswap,
|
vswap: vswap!,
|
||||||
mapHead: mapHead,
|
mapHead: mapHead!,
|
||||||
gameMaps: gameMaps,
|
gameMaps: gameMaps!,
|
||||||
vgaDict: vgaDict,
|
vgaDict: vgaDict!,
|
||||||
vgaHead: vgaHead,
|
vgaHead: vgaHead!,
|
||||||
vgaGraph: vgaGraph,
|
vgaGraph: vgaGraph!,
|
||||||
audioHed: audioHed,
|
audioHed: audioHed!,
|
||||||
audioT: audioT,
|
audioT: audioT!,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,75 +5,89 @@ import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
|
|||||||
import 'opl2_emulator.dart';
|
import 'opl2_emulator.dart';
|
||||||
|
|
||||||
class ImfRenderer {
|
class ImfRenderer {
|
||||||
/// Renders an ImfMusic track into raw 16-bit PCM samples
|
/// Renders music in Stereo with a 10ms Haas widening effect.
|
||||||
static Int16List render(ImfMusic music) {
|
static Int16List render(ImfMusic music) {
|
||||||
final emulator = Opl2Emulator();
|
final emulator = Opl2Emulator();
|
||||||
|
|
||||||
const double samplesPerTick = Opl2Emulator.sampleRate / 700.0;
|
const double samplesPerTick = Opl2Emulator.sampleRate / 700.0;
|
||||||
|
|
||||||
// 1. Pre-calculate the total number of audio samples we need
|
|
||||||
int totalTicks = 0;
|
int totalTicks = 0;
|
||||||
for (final inst in music.instructions) {
|
for (final inst in music.instructions) {
|
||||||
totalTicks += inst.delay;
|
totalTicks += inst.delay;
|
||||||
}
|
}
|
||||||
int totalSamples = (totalTicks * samplesPerTick).round();
|
|
||||||
|
|
||||||
// 2. Pre-allocate a fixed-size buffer (blazing fast!)
|
// Allocate buffer for Stereo (2 channels per sample)
|
||||||
final pcmBuffer = Int16List(totalSamples);
|
int totalMonoSamples = (totalTicks * samplesPerTick).round();
|
||||||
|
final pcmBuffer = Int16List(totalMonoSamples * 2);
|
||||||
int sampleIndex = 0;
|
int sampleIndex = 0;
|
||||||
|
|
||||||
for (final instruction in music.instructions) {
|
// --- Stereo Widening (Haas Effect) ---
|
||||||
// Write the instruction to the chip
|
// 10ms at 44.1kHz = 441 samples.
|
||||||
emulator.writeRegister(instruction.register, instruction.data);
|
// This is tight enough to sound like 'width' rather than 'echo'.
|
||||||
|
const int delaySamples = 441;
|
||||||
|
final delayLine = Float32List(delaySamples);
|
||||||
|
int delayPtr = 0;
|
||||||
|
|
||||||
// Generate the samples for the delay period
|
for (final instruction in music.instructions) {
|
||||||
|
emulator.writeRegister(instruction.register, instruction.data);
|
||||||
int samplesToGenerate = (instruction.delay * samplesPerTick).round();
|
int samplesToGenerate = (instruction.delay * samplesPerTick).round();
|
||||||
|
|
||||||
for (int i = 0; i < samplesToGenerate; i++) {
|
for (int i = 0; i < samplesToGenerate; i++) {
|
||||||
if (sampleIndex >= pcmBuffer.length) break; // Safety bounds
|
if (sampleIndex + 1 >= pcmBuffer.length) break;
|
||||||
|
|
||||||
double sampleFloat = emulator.generateSample();
|
double monoSample = emulator.generateSample();
|
||||||
int sampleInt = (sampleFloat * 32767).toInt().clamp(-32768, 32767);
|
|
||||||
|
|
||||||
pcmBuffer[sampleIndex++] = sampleInt;
|
// 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;
|
return pcmBuffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wraps raw 16-bit PCM data in a standard WAV file header
|
/// Wraps raw PCM data into a standard Stereo WAV file header.
|
||||||
static Uint8List createWavFile(Int16List pcmData) {
|
static Uint8List createWavFile(
|
||||||
int byteRate = Opl2Emulator.sampleRate * 2; // 1 channel * 16-bit (2 bytes)
|
Int16List pcmData, {
|
||||||
int dataSize = pcmData.length * 2;
|
int sampleRate = Opl2Emulator.sampleRate,
|
||||||
int fileSize = 36 + dataSize;
|
}) {
|
||||||
|
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();
|
final bytes = BytesBuilder();
|
||||||
|
|
||||||
// "RIFF" chunk descriptor
|
// RIFF header
|
||||||
bytes.add('RIFF'.codeUnits);
|
bytes.add('RIFF'.codeUnits);
|
||||||
bytes.add(_int32ToBytes(fileSize));
|
bytes.add(_int32ToBytes(fileSize));
|
||||||
bytes.add('WAVE'.codeUnits);
|
bytes.add('WAVE'.codeUnits);
|
||||||
|
|
||||||
// "fmt " sub-chunk
|
// Format chunk
|
||||||
bytes.add('fmt '.codeUnits);
|
bytes.add('fmt '.codeUnits);
|
||||||
bytes.add(_int32ToBytes(16)); // Subchunk1Size (16 for PCM)
|
bytes.add(_int32ToBytes(16));
|
||||||
bytes.add(_int16ToBytes(1)); // AudioFormat (1 = PCM)
|
bytes.add(_int16ToBytes(1));
|
||||||
bytes.add(_int16ToBytes(1)); // NumChannels (1 = Mono)
|
bytes.add(_int16ToBytes(numChannels));
|
||||||
bytes.add(_int32ToBytes(Opl2Emulator.sampleRate)); // SampleRate
|
bytes.add(_int32ToBytes(sampleRate));
|
||||||
bytes.add(_int32ToBytes(byteRate)); // ByteRate
|
bytes.add(_int32ToBytes(byteRate));
|
||||||
bytes.add(_int16ToBytes(2)); // BlockAlign
|
bytes.add(_int16ToBytes(blockAlign));
|
||||||
bytes.add(_int16ToBytes(16)); // BitsPerSample
|
bytes.add(_int16ToBytes(16));
|
||||||
|
|
||||||
// "data" sub-chunk
|
// Data chunk
|
||||||
bytes.add('data'.codeUnits);
|
bytes.add('data'.codeUnits);
|
||||||
bytes.add(_int32ToBytes(dataSize));
|
bytes.add(_int32ToBytes(dataSize));
|
||||||
|
|
||||||
// Append the actual raw audio data (Blazing Fast version)
|
|
||||||
final byteData = ByteData(dataSize);
|
final byteData = ByteData(dataSize);
|
||||||
for (int i = 0; i < pcmData.length; i++) {
|
for (int i = 0; i < pcmData.length; i++) {
|
||||||
// Multiply by 2 because each Int16 takes up 2 bytes.
|
|
||||||
// Endian.little is strictly required for the standard WAV format.
|
|
||||||
byteData.setInt16(i * 2, pcmData[i], Endian.little);
|
byteData.setInt16(i * 2, pcmData[i], Endian.little);
|
||||||
}
|
}
|
||||||
bytes.add(byteData.buffer.asUint8List());
|
bytes.add(byteData.buffer.asUint8List());
|
||||||
@@ -85,7 +99,6 @@ class ImfRenderer {
|
|||||||
value & 0xff,
|
value & 0xff,
|
||||||
(value >> 8) & 0xff,
|
(value >> 8) & 0xff,
|
||||||
];
|
];
|
||||||
|
|
||||||
static List<int> _int32ToBytes(int value) => [
|
static List<int> _int32ToBytes(int value) => [
|
||||||
value & 0xff,
|
value & 0xff,
|
||||||
(value >> 8) & 0xff,
|
(value >> 8) & 0xff,
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
/// More dartdocs go here.
|
/// More dartdocs go here.
|
||||||
library;
|
library;
|
||||||
|
|
||||||
export 'src/imf_renderer.dart' show ImfRenderer;
|
export 'src/imf_renderer.dart' show ImfRenderer, NumChannels;
|
||||||
|
|||||||
Reference in New Issue
Block a user