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

@@ -15,24 +15,9 @@ migration:
- platform: root - platform: root
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: android
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: ios
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: linux
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: macos
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: web - platform: web
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0 base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
- platform: windows
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
# User provided section # User provided section

View File

@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:wolf_3d_data_types/wolf_3d_data_types.dart'; import 'package:wolf_3d_data_types/wolf_3d_data_types.dart';
import 'package:wolf_3d_synth/wolf_3d_synth.dart';
import 'package:wolf_dart/features/difficulty/difficulty.dart'; import 'package:wolf_dart/features/difficulty/difficulty.dart';
import 'package:wolf_dart/features/renderer/renderer.dart'; import 'package:wolf_dart/features/renderer/renderer.dart';
class DifficultyScreen extends StatelessWidget { class DifficultyScreen extends StatefulWidget {
const DifficultyScreen( const DifficultyScreen(
this.data, { this.data, {
super.key, super.key,
@@ -11,7 +13,86 @@ class DifficultyScreen extends StatelessWidget {
final WolfensteinData data; final WolfensteinData data;
bool get isShareware => data.version == GameVersion.shareware; @override
State<DifficultyScreen> createState() => _DifficultyScreenState();
}
class _DifficultyScreenState extends State<DifficultyScreen> {
AudioSource? _menuMusicSource;
SoundHandle? _menuMusicHandle;
bool get isShareware => widget.data.version == GameVersion.shareware;
@override
void initState() {
super.initState();
_playMenuMusic();
}
Future<void> _playMenuMusic() async {
final soloud = SoLoud.instance;
if (!soloud.isInitialized) {
return;
}
// 2. We only want to play music if the IMF data actually exists
if (widget.data.music.isNotEmpty) {
// Get the first track (usually the menu theme "Wondering About My Loved Ones")
final music = widget.data.music.first;
// Render the hardware instructions into PCM and wrap in a WAV header
final pcmSamples = ImfRenderer.render(music);
final wavBytes = ImfRenderer.createWavFile(pcmSamples);
// 3. Load the bytes into SoLoud's memory
// The 'menu_theme.wav' string is just a dummy name to tell SoLoud it's dealing with a WAV format
_menuMusicSource = await soloud.loadMem('menu_theme.wav', wavBytes);
// 4. Play the source and tell it to loop continuously!
_menuMusicHandle = await soloud.play(
_menuMusicSource!,
looping: true,
);
}
}
@override
void dispose() {
_cleanupAudio();
super.dispose();
}
void _cleanupAudio() {
final soloud = SoLoud.instance;
// Stop the playback
if (_menuMusicHandle != null) {
soloud.stop(_menuMusicHandle!);
}
// Free the raw WAV data from C++ memory
if (_menuMusicSource != null) {
soloud.disposeSource(_menuMusicSource!);
}
}
void _startGame(Difficulty difficulty, {bool showGallery = false}) {
// Stop the music and clear memory right before we push the new route
_cleanupAudio();
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => WolfRenderer(
widget.data,
difficulty: difficulty,
isShareware: isShareware,
showSpriteGallery: showGallery,
),
),
);
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -19,18 +100,7 @@ class DifficultyScreen extends StatelessWidget {
backgroundColor: Colors.black, backgroundColor: Colors.black,
floatingActionButton: FloatingActionButton( floatingActionButton: FloatingActionButton(
backgroundColor: Colors.red[900], backgroundColor: Colors.red[900],
onPressed: () { onPressed: () => _startGame(Difficulty.bringEmOn, showGallery: true),
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => WolfRenderer(
data,
difficulty: Difficulty.bringEmOn,
isShareware: isShareware,
showSpriteGallery: true,
),
),
);
},
child: const Icon(Icons.bug_report, color: Colors.white), child: const Icon(Icons.bug_report, color: Colors.white),
), ),
body: Center( body: Center(
@@ -46,6 +116,7 @@ class DifficultyScreen extends StatelessWidget {
fontFamily: 'Courier', fontFamily: 'Courier',
), ),
), ),
const SizedBox(height: 40),
// --- Difficulty Buttons --- // --- Difficulty Buttons ---
ListView.builder( ListView.builder(
@@ -64,17 +135,7 @@ class DifficultyScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
), ),
onPressed: () { onPressed: () => _startGame(difficulty),
Navigator.of(context).pushReplacement(
MaterialPageRoute(
builder: (_) => WolfRenderer(
data,
difficulty: difficulty,
isShareware: isShareware,
),
),
);
},
child: Text( child: Text(
difficulty.title, difficulty.title,
style: const TextStyle(fontSize: 18), style: const TextStyle(fontSize: 18),

View File

@@ -1,7 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_soloud/flutter_soloud.dart';
import 'package:wolf_dart/game_select_screen.dart'; import 'package:wolf_dart/game_select_screen.dart';
void main() { void main() async {
await SoLoud.instance.init(
sampleRate: 44100, // Audio quality
bufferSize: 2048, // Buffer size affects latency
channels: Channels.stereo,
);
runApp( runApp(
const MaterialApp( const MaterialApp(
home: GameSelectScreen(), home: GameSelectScreen(),

View File

@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST
flutter_soloud
) )
set(PLUGIN_BUNDLED_LIBRARIES) set(PLUGIN_BUNDLED_LIBRARIES)

View File

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

View File

@@ -30,13 +30,19 @@ class ImfMusic {
factory ImfMusic.fromBytes(Uint8List bytes) { factory ImfMusic.fromBytes(Uint8List bytes) {
List<ImfInstruction> instructions = []; List<ImfInstruction> instructions = [];
// Read the file in 4-byte chunks // Wolfenstein 3D IMF chunks start with a 16-bit length header (little-endian)
for (int i = 0; i < bytes.length - 3; i += 4) { 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( instructions.add(
ImfInstruction( ImfInstruction(
register: bytes[i], register: bytes[i],
data: bytes[i + 1], 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. /// More dartdocs go here.
library; library;
export 'src/wolf_3d_synth_base.dart'; export 'src/imf_renderer.dart' show ImfRenderer;
// TODO: Export any libraries intended for clients of this package.

View File

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

View File

@@ -9,8 +9,10 @@ environment:
dependencies: dependencies:
wolf_3d_data: any wolf_3d_data: any
wolf_3d_data_types: any wolf_3d_data_types: any
wolf_3d_synth: any
flutter: flutter:
sdk: flutter sdk: flutter
flutter_soloud: ^3.5.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -24,4 +26,6 @@ flutter:
- assets/shareware/ - assets/shareware/
workspace: workspace:
- packages/* - packages/wolf_3d_data/
- packages/wolf_3d_data_types/
- packages/wolf_3d_synth/

BIN
web/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 917 B

BIN
web/icons/Icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
web/icons/Icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

51
web/index.html Normal file
View File

@@ -0,0 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<!--
If you are serving your web app in a path other than the root, change the
href value below to reflect the base path you are serving from.
The path provided below has to start and end with a slash "/" in order for
it to work correctly.
For more details:
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
This is a placeholder for base href that will be replaced by the value of
the `--base-href` argument provided to `flutter build`.
-->
<base href="$FLUTTER_BASE_HREF">
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="wolf_dart">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png" />
<title>wolf_dart</title>
<link rel="manifest" href="manifest.json">
</head>
<body>
<!--
You can customize the "flutter_bootstrap.js" script.
This is useful to provide a custom configuration to the Flutter loader
or to give the user feedback during the initialization process.
For more details:
* https://docs.flutter.dev/platform-integration/web/initialization
-->
<script src="flutter_bootstrap.js" async></script>
<script src="assets/packages/flutter_soloud/web/libflutter_soloud_plugin.js" defer></script>
<script src="assets/packages/flutter_soloud/web/init_module.dart.js" defer></script>
</body>
</html>

35
web/manifest.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "wolf_dart",
"short_name": "wolf_dart",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}