diff --git a/src/ADLMidi.NET.Tests/ADLMidi.NET.Tests.csproj b/src/ADLMidi.NET.Tests/ADLMidi.NET.Tests.csproj index fe22748..5339cb3 100644 --- a/src/ADLMidi.NET.Tests/ADLMidi.NET.Tests.csproj +++ b/src/ADLMidi.NET.Tests/ADLMidi.NET.Tests.csproj @@ -1,7 +1,7 @@  - net8.0 + net8.0;net10.0 enable enable false diff --git a/src/ADLMidi.NET.Tests/DllImportFixture.cs b/src/ADLMidi.NET.Tests/DllImportFixture.cs index b7311a1..fbe3d42 100644 --- a/src/ADLMidi.NET.Tests/DllImportFixture.cs +++ b/src/ADLMidi.NET.Tests/DllImportFixture.cs @@ -5,13 +5,22 @@ namespace ADLMidi.NET.Tests; public class DllImportFixture : IDisposable { - // Workaround for the terrible support of native dependencies with ProjectReferences + private static readonly object _resolverLock = new(); + private static bool _resolverSet; + + // Workaround for the terrible support of native dependencies with ProjectReferences. + // SetDllImportResolver can only be called once per assembly per process, so we + // guard it across multiple test classes sharing this fixture. public DllImportFixture() { - NativeLibrary.SetDllImportResolver( - typeof(AdlMidi).Assembly, - (name, assembly, path) => - { + lock (_resolverLock) + { + if (_resolverSet) return; + _resolverSet = true; + NativeLibrary.SetDllImportResolver( + typeof(AdlMidi).Assembly, + (name, assembly, path) => + { var root = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!; string filename; @@ -41,6 +50,7 @@ public DllImportFixture() ? NativeLibrary.Load(fullPath) : IntPtr.Zero; }); + } } public void Dispose() { } diff --git a/src/ADLMidi.NET.Tests/OplChipTests.cs b/src/ADLMidi.NET.Tests/OplChipTests.cs new file mode 100644 index 0000000..5fe28ff --- /dev/null +++ b/src/ADLMidi.NET.Tests/OplChipTests.cs @@ -0,0 +1,65 @@ +using System; +using ADLMidi.NET; +using Xunit; + +namespace ADLMidi.NET.Tests; + +public class OplChipTests : IClassFixture +{ + public OplChipTests(DllImportFixture _) { } + + [Fact] + public void Silent_chip_generates_zero_samples() + { + using var chip = OplChip.Create(44100); + var buf = new short[2 * 1024]; + chip.GenerateFrames(buf, 1024); + foreach (var s in buf) Assert.Equal(0, s); + } + + // Drives a minimal OPL2-compatible note-on on channel 0 and asserts the + // chip produces audible output. Not a tone-accuracy check — just a smoke + // test that our Write/Generate sequence reaches the emulator's audio path. + // + // OplChip.Create() leaves the chip in the OPL3-extended state that + // libadlmidi's MIDIplay::applySetup initialises on every new device, so we + // must set the OPL3 panning bits (0xC0 bits 4-5) to enable L/R output — + // in OPL2 mode those bits are don't-cares, but in OPL3 mode (0x105=1) + // they gate the mixer. This matches how real OPL3-aware drivers behave. + [Fact] + public void Note_on_channel_0_generates_nonzero_samples() + { + using var chip = OplChip.Create(44100); + + chip.WriteReg(0x01, 0x20); // enable waveform select (OPL2) + chip.WriteReg(0x20, 0x01); // modulator: MULT=1 + chip.WriteReg(0x23, 0x01); // carrier: MULT=1 + chip.WriteReg(0x40, 0x10); // modulator: TL (attenuate) + chip.WriteReg(0x43, 0x00); // carrier: TL=0 + chip.WriteReg(0x60, 0xF0); // modulator: AR=15 DR=0 + chip.WriteReg(0x63, 0xF0); // carrier: AR=15 DR=0 + chip.WriteReg(0x80, 0x00); // modulator: SL=0 RR=0 + chip.WriteReg(0x83, 0x00); // carrier: SL=0 RR=0 + chip.WriteReg(0xA0, 0x98); // FNum low + chip.WriteReg(0xB0, 0x31); // KeyOn=1, Block=2, FNum-high=1 + chip.WriteReg(0xC0, 0x31); // FB=0, CON=1, OPL3 L+R pan bits enabled + + var buf = new short[2 * 4096]; + chip.GenerateFrames(buf, 4096); + + int nonZero = 0; + foreach (var s in buf) if (s != 0) nonZero++; + Assert.True(nonZero > 100, $"expected audible output, got {nonZero} non-zero samples"); + } + + [Fact] + public void Reset_silences_chip() + { + using var chip = OplChip.Create(44100); + chip.WriteReg(0xB0, 0x31); // key on + chip.Reset(); + var buf = new short[2 * 1024]; + chip.GenerateFrames(buf, 1024); + foreach (var s in buf) Assert.Equal(0, s); + } +} diff --git a/src/ADLMidi.NET/OplChip.cs b/src/ADLMidi.NET/OplChip.cs new file mode 100644 index 0000000..1da3279 --- /dev/null +++ b/src/ADLMidi.NET/OplChip.cs @@ -0,0 +1,188 @@ +using System; +using System.Runtime.InteropServices; + +namespace ADLMidi.NET; + +/// +/// Thin wrapper around a libadlmidi ADL_MIDIPlayer* configured for raw +/// OPL register writes. Intended for use cases that synthesize their own OPL +/// register streams (Ultima Underworld TVFX effects, DRO playback, custom +/// state machines). +/// +/// Backed by two additions to libadlmidi's public API (feat/barechip-wrapper +/// branch on abedegno/libADLMIDI): +/// +/// +/// adl_rt_rawOPL3(device, chipId, reg, val) — writes one +/// OPL3 register on a specific chip, reusing the same internal path +/// that IMF/KLM file playback uses. +/// adl_reserveChipChannels(device, chipId, mask) — marks chip +/// channels as off-limits to the MIDI voice allocator so music +/// playback and raw SFX writes can coexist on the same chip. +/// +/// +/// When constructed for single-chip raw-only use (the common case for UW +/// SFX), this class reserves all 18 melodic channels + 5 rhythm channels so +/// the MIDI driver never touches the chip. To coexist with MIDI on the same +/// chip, use passing only the per-chip channel +/// bitmask you want to own. +/// +public sealed class OplChip : IDisposable +{ + private const int PerChipChannels = 23; // matches libadlmidi NUM_OF_CHANNELS + /// Mask reserving all OPL3 chip-level channels (18 melodic + 5 rhythm). + public const uint AllChannelsMask = (1u << PerChipChannels) - 1u; + + private readonly IntPtr _device; + private readonly int _chipId; + private readonly bool _ownsDevice; + private bool _disposed; + + private OplChip(IntPtr device, int chipId, bool ownsDevice) + { + _device = device; + _chipId = chipId; + _ownsDevice = ownsDevice; + } + + /// + /// Create a stand-alone chip: allocates its own ADL_MIDIPlayer, + /// configures a single OPL3 chip at , and + /// reserves all chip channels so the MIDI driver never allocates voices. + /// The returned instance owns the underlying device and disposes it. + /// + public static OplChip Create(int sampleRateHz) + { + if (sampleRateHz <= 0) throw new ArgumentOutOfRangeException(nameof(sampleRateHz)); + + IntPtr device = AdlMidiImports.adl_init(sampleRateHz); + if (device == IntPtr.Zero) + throw new InvalidOperationException("adl_init returned null"); + + try + { + int rc = AdlMidiImports.adl_setNumChips(device, 1); + if (rc < 0) + throw new InvalidOperationException($"adl_setNumChips(1) failed: {rc}"); + + // Reserve every per-chip channel so the MIDI driver will not + // allocate any voice on this chip. + int rr = Native.adl_reserveChipChannels(device, 0, AllChannelsMask); + if (rr != 0) + throw new InvalidOperationException($"adl_reserveChipChannels failed: {rr}"); + + return new OplChip(device, 0, ownsDevice: true); + } + catch + { + AdlMidiImports.adl_close(device); + throw; + } + } + + /// + /// Wrap an existing ADL_MIDIPlayer* (e.g. one already playing MIDI) + /// so raw writes can target a specific chip in its emulator farm. Reserves + /// the given per-chip channel bitmask from the MIDI allocator. Does not + /// take ownership of the device. + /// + /// Native handle from adl_init. + /// Zero-based chip index. + /// Per-chip channel bitmask to reserve (see + /// ); 0 to not reserve anything. + public static OplChip FromPlayer(IntPtr device, int chipId, uint reserveMask) + { + if (device == IntPtr.Zero) throw new ArgumentNullException(nameof(device)); + if (chipId < 0) throw new ArgumentOutOfRangeException(nameof(chipId)); + + if (reserveMask != 0) + { + int rr = Native.adl_reserveChipChannels(device, chipId, reserveMask); + if (rr != 0) + throw new InvalidOperationException($"adl_reserveChipChannels failed: {rr}"); + } + return new OplChip(device, chipId, ownsDevice: false); + } + + /// Write to OPL register . + public void WriteReg(int addr, byte val) + { + ThrowIfDisposed(); + int rc = Native.adl_rt_rawOPL3(_device, _chipId, (ushort)addr, val); + if (rc == 0) + throw new InvalidOperationException("adl_rt_rawOPL3 returned 0 (invalid chipId?)"); + } + + /// + /// Render stereo frames into + /// (length >= 2 * frames). Mixes + /// output from every chip in the device — which, for a stand-alone + /// instance, is just the one chip. + /// + public unsafe void GenerateFrames(short[] interleavedStereo, int frames) + { + ThrowIfDisposed(); + if (interleavedStereo == null) throw new ArgumentNullException(nameof(interleavedStereo)); + if (frames < 0) throw new ArgumentOutOfRangeException(nameof(frames)); + if (interleavedStereo.Length < frames * 2) + throw new ArgumentException("buffer smaller than 2 * frames", nameof(interleavedStereo)); + if (frames == 0) return; + + fixed (short* ptr = interleavedStereo) + { + // adl_generate takes a total interleaved sample count (2 * frames + // for stereo), not a frame count. + AdlMidiImports.adl_generate(_device, frames * 2, ptr); + } + } + + /// + /// Reset the chip to its power-on state via adl_reset. Only valid + /// for instances created with (owners of the device); + /// otherwise throws, because resetting a shared device would disrupt MIDI + /// playback. + /// + public void Reset() + { + ThrowIfDisposed(); + if (!_ownsDevice) + throw new InvalidOperationException("Reset() only valid on owned-device instances (OplChip.Create). Reset externally on shared devices."); + AdlMidiImports.adl_reset(_device); + // adl_reset re-creates MIDIplay state. Re-apply our channel reservation. + int rr = Native.adl_reserveChipChannels(_device, _chipId, AllChannelsMask); + if (rr != 0) + throw new InvalidOperationException($"adl_reserveChipChannels failed after reset: {rr}"); + } + + /// Release the underlying device when this instance owns it. + public void Dispose() + { + if (_disposed) return; + _disposed = true; + if (_ownsDevice && _device != IntPtr.Zero) + AdlMidiImports.adl_close(_device); + GC.SuppressFinalize(this); + } + + /// Finalizer — releases unmanaged memory if Dispose was not called. + ~OplChip() { Dispose(); } + + private void ThrowIfDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(OplChip)); + } + + private static class Native + { + private const string Lib = "libadlmidi"; + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "adl_rt_rawOPL3")] + public static extern int adl_rt_rawOPL3(IntPtr device, int chipId, ushort reg, byte value); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "adl_reserveChipChannels")] + public static extern int adl_reserveChipChannels(IntPtr device, int chipId, uint channelMask); + + [DllImport(Lib, CallingConvention = CallingConvention.Cdecl, EntryPoint = "adl_getReservedChipChannels")] + public static extern uint adl_getReservedChipChannels(IntPtr device, int chipId); + } +} diff --git a/src/ADLMidi.NET/runtimes/linux-x64/native/libadlmidi.so b/src/ADLMidi.NET/runtimes/linux-x64/native/libadlmidi.so index 26b331d..cebf185 100644 Binary files a/src/ADLMidi.NET/runtimes/linux-x64/native/libadlmidi.so and b/src/ADLMidi.NET/runtimes/linux-x64/native/libadlmidi.so differ diff --git a/src/ADLMidi.NET/runtimes/osx-arm64/native/libadlmidi.dylib b/src/ADLMidi.NET/runtimes/osx-arm64/native/libadlmidi.dylib index 7172074..ab4bebf 100755 Binary files a/src/ADLMidi.NET/runtimes/osx-arm64/native/libadlmidi.dylib and b/src/ADLMidi.NET/runtimes/osx-arm64/native/libadlmidi.dylib differ diff --git a/src/ADLMidi.NET/runtimes/win-x64/native/libadlmidi.dll b/src/ADLMidi.NET/runtimes/win-x64/native/libadlmidi.dll index b1a5012..adb9b94 100644 Binary files a/src/ADLMidi.NET/runtimes/win-x64/native/libadlmidi.dll and b/src/ADLMidi.NET/runtimes/win-x64/native/libadlmidi.dll differ diff --git a/src/ADLMidi.NET/runtimes/win-x86/native/libadlmidi.dll b/src/ADLMidi.NET/runtimes/win-x86/native/libadlmidi.dll index d497f64..4b25904 100644 Binary files a/src/ADLMidi.NET/runtimes/win-x86/native/libadlmidi.dll and b/src/ADLMidi.NET/runtimes/win-x86/native/libadlmidi.dll differ