From 17565603bcc7ef82e81debd6ac5fa141f5077521 Mon Sep 17 00:00:00 2001 From: codengine <498798+codengine@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:26:13 +0200 Subject: [PATCH] update NukedOPL3Sharp, add direct dependencies to Iir, MVerb and Speex --- LICENSE | 17 +++++-- NOTICE | 26 ++++++++++- src/Directory.Packages.props | 5 +- .../Sound/AdlibGoldOpl/StereoProcessor.cs | 3 +- .../Emulator/Devices/Sound/SoftwareMixer.cs | 46 ++++++++++--------- .../Emulator/Devices/Sound/SoundChannel.cs | 37 +++++++-------- src/Spice86.Core/Spice86.Core.csproj | 3 ++ .../Devices/Sound/SoundChannelTests.cs | 42 +++++++++++++++++ 8 files changed, 129 insertions(+), 50 deletions(-) create mode 100644 tests/Spice86.Tests/Emulator/Devices/Sound/SoundChannelTests.cs diff --git a/LICENSE b/LICENSE index dce2f3ca4b..e92e31bbd1 100644 --- a/LICENSE +++ b/LICENSE @@ -202,8 +202,15 @@ limitations under the License. - Third-party libraries are licensed as followed: - - Adlib Gold: Personal permission granted by John Novak from DosBox Staging - - Nuked-OPL3: LGPL-2.1 - - iir1: MIT - - YM7128B_emu: BSD 2-Clause \ No newline at end of file + Third-party audio components retain their respective licenses: + - AdLib Gold: personal porting permission granted by John Novak from + DOSBox Staging. + - NukedOPL3Sharp: LGPL-2.1-only; upstream Nuked OPL3 source is + LGPL-2.1-or-later. + - Iir1.Net and upstream iir1: MIT. + - MVerb.Net and upstream MVerb: GPL-3.0-or-later. + - SpeexResamplerSharp and upstream SpeexDSP: BSD-3-Clause. + - YM7128B_emu: BSD-2-Clause. + + See NOTICE for copyright attribution and the corresponding dependency + packages and source directories for their license texts. diff --git a/NOTICE b/NOTICE index f361e215d2..2fad0a55f3 100644 --- a/NOTICE +++ b/NOTICE @@ -17,11 +17,33 @@ Authors: - Stefan Hueg - Thomas Kaiser -Third party authors: +Third-party audio components: - Greg Divis (Aeon project: https://github.com/gregdivis/Aeon) +- AdLib Gold audio code is ported from DOSBox Staging with personal permission + from project lead and original component author John Novak. +- NukedOPL3Sharp is Stefan Hueg's C# port of Nuked OPL3 by Nuke.YKT, + copyright (c) 2013-2020 Nuke.YKT, licensed under LGPL-2.1-only. +- YM7128B is ported from Andrea Zoppi's YM7128B_emu, + copyright (c) 2020-2023 Andrea Zoppi, licensed under BSD-2-Clause. +- Iir1.Net is copyright (c) 2026 Stefan Hueg and is a managed derivative of + iir1, copyright (c) 2009 Vinnie Falco and copyright (c) 2012 Bernd Porr. + Both are licensed under MIT. +- MVerb.Net is a managed derivative of MVerb, copyright (c) 2010 Martin + Eastwood, with the managed port copyright (c) 2026 Stefan Hueg. It is + licensed under GPL-3.0-or-later. +- SpeexResamplerSharp is copyright (c) 2026 Stefan Hueg and the SpeexDSP + contributors. It is a managed port of the SpeexDSP resampler by Jean-Marc + Valin, Thorvald Natvig, and the Xiph.Org contributors, licensed under + BSD-3-Clause. +The NuGet packages declare their applicable licenses. Iir1.Net, MVerb.Net, +and SpeexResamplerSharp also include their upstream third-party notices. The +YM7128B license is preserved beside its source under +src/Spice86.Core/Emulator/Devices/Sound/YM7128B/LICENSE. -Licensed under the Apache License, Version 2.0 (the "License"); + +Spice86 project-authored code is licensed under the Apache License, Version +2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index f1b9f9e3d6..6c3f72eafb 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -14,6 +14,8 @@ + + @@ -38,12 +40,13 @@ - + + diff --git a/src/Spice86.Core/Emulator/Devices/Sound/AdlibGoldOpl/StereoProcessor.cs b/src/Spice86.Core/Emulator/Devices/Sound/AdlibGoldOpl/StereoProcessor.cs index 1dbe56c46c..bbe58929fd 100644 --- a/src/Spice86.Core/Emulator/Devices/Sound/AdlibGoldOpl/StereoProcessor.cs +++ b/src/Spice86.Core/Emulator/Devices/Sound/AdlibGoldOpl/StereoProcessor.cs @@ -1,7 +1,8 @@ namespace Spice86.Core.Emulator.Devices.Sound.AdlibGoldOpl; +using Iir.RBJ; + using Spice86.Audio.Common; -using Spice86.Audio.Filters.IirFilters.Filters.RBJ; /// /// Implements the AdLib Gold stereo processor, including tone controls and stereo field shaping. diff --git a/src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs b/src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs index d87be54aa7..49e58c28e7 100644 --- a/src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs +++ b/src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs @@ -1,5 +1,9 @@ namespace Spice86.Core.Emulator.Devices.Sound; +using Iir.Butterworth; + +using MVerb; + using Spice86.Audio.Backend.Audio; using Spice86.Audio.Common; using Spice86.Audio.Filters; @@ -9,8 +13,6 @@ namespace Spice86.Core.Emulator.Devices.Sound; using System.Runtime.InteropServices; using System.Threading; -using HighPassFilter = Spice86.Audio.Filters.IirFilters.Filters.Butterworth.HighPass; - /// /// Central audio mixer that runs in its own thread and produces final mixed output. /// @@ -69,8 +71,8 @@ public sealed class SoftwareMixer : ISoundChannelCreator, IDisposable { private readonly float _reverbSynthSendLevel = 0.0f; private readonly float _reverbDigitalSendLevel = 0.0f; - private float _reverbLeftIn; - private float _reverbRightIn; + private readonly float[] _reverbLeft = new float[DefaultBlocksize]; + private readonly float[] _reverbRight = new float[DefaultBlocksize]; private readonly bool _doChorus = false; private readonly ChorusEngine _chorusEngine; @@ -82,8 +84,8 @@ public sealed class SoftwareMixer : ISoundChannelCreator, IDisposable { private readonly float _crossfeedGlobalStrength = 0.0f; // Varies by preset: Light=0.20f, Normal=0.40f, Strong=0.60f // Used on reverb input and master output - private readonly HighPassFilter[] _reverbHighPassFilter; - private readonly HighPassFilter[] _masterHighPassFilter; + private readonly HighPass[] _reverbHighPassFilter; + private readonly HighPass[] _masterHighPassFilter; private const int HighPassFilterOrder = 2; // 2nd-order Butterworth private const float MasterHighPassCutoffHz = 20.0f; @@ -105,15 +107,15 @@ public SoftwareMixer(AudioEngine audioEngine, IPauseHandler pauseHandler) { } // Initialize high-pass filters (2 channels - left and right) - _reverbHighPassFilter = new HighPassFilter[2]; - _masterHighPassFilter = new HighPassFilter[2]; + _reverbHighPassFilter = new HighPass[2]; + _masterHighPassFilter = new HighPass[2]; const float DefaultReverbHighPassHz = 200.0f; for (int i = 0; i < 2; i++) { - _reverbHighPassFilter[i] = new HighPassFilter(HighPassFilterOrder); + _reverbHighPassFilter[i] = new HighPass(HighPassFilterOrder); _reverbHighPassFilter[i].Setup(HighPassFilterOrder, _sampleRateHz, DefaultReverbHighPassHz); - _masterHighPassFilter[i] = new HighPassFilter(HighPassFilterOrder); + _masterHighPassFilter[i] = new HighPass(HighPassFilterOrder); _masterHighPassFilter[i].Setup(HighPassFilterOrder, _sampleRateHz, MasterHighPassCutoffHz); } @@ -516,19 +518,21 @@ private void ApplyReverb() { // results to the master output. Span reverbAux = _reverbAuxBuffer.AsSpan(); Span output = _outputBuffer.AsSpan(); + int frameCount = reverbAux.Length; + Span reverbLeft = _reverbLeft.AsSpan(0, frameCount); + Span reverbRight = _reverbRight.AsSpan(0, frameCount); - for (int i = 0; i < reverbAux.Length; i++) { + for (int i = 0; i < frameCount; i++) { // High-pass filter the reverb input AudioFrame inFrame = reverbAux[i]; - inFrame = new AudioFrame( - _reverbHighPassFilter[0].Filter(inFrame.Left), - _reverbHighPassFilter[1].Filter(inFrame.Right) - ); - // MVerb operates on two non-interleaved sample streams - _reverbLeftIn = inFrame.Left; - _reverbRightIn = inFrame.Right; - _mverb.Process(ref _reverbLeftIn, ref _reverbRightIn); - output[i] += new AudioFrame(_reverbLeftIn, _reverbRightIn); + reverbLeft[i] = _reverbHighPassFilter[0].Filter(inFrame.Left); + reverbRight[i] = _reverbHighPassFilter[1].Filter(inFrame.Right); + } + + _mverb.Process(reverbLeft, reverbRight, reverbLeft, reverbRight, frameCount); + + for (int i = 0; i < frameCount; i++) { + output[i] += new AudioFrame(reverbLeft[i], reverbRight[i]); } } @@ -608,4 +612,4 @@ internal static void PullFromQueueCallback(int framesRequested, device.Channel.AddSilence(); } } -} \ No newline at end of file +} diff --git a/src/Spice86.Core/Emulator/Devices/Sound/SoundChannel.cs b/src/Spice86.Core/Emulator/Devices/Sound/SoundChannel.cs index 75e169ca85..a89ce7d6cd 100644 --- a/src/Spice86.Core/Emulator/Devices/Sound/SoundChannel.cs +++ b/src/Spice86.Core/Emulator/Devices/Sound/SoundChannel.cs @@ -1,11 +1,11 @@ namespace Spice86.Core.Emulator.Devices.Sound; +using Iir.Butterworth; + +using SpeexResamplerSharp; + using Spice86.Audio.Common; using Spice86.Audio.Filters; -using Spice86.Audio.Filters.Speex; - -using HighPass = Audio.Filters.IirFilters.Filters.Butterworth.HighPass; -using LowPass = Audio.Filters.IirFilters.Filters.Butterworth.LowPass; using System.Threading; @@ -16,8 +16,8 @@ namespace Spice86.Core.Emulator.Devices.Sound; /// 2022-2025 The DOSBox Staging Team /// public sealed class SoundChannel { - private const uint SpeexChannels = 2; // Always use stereo for processing - private const int SpeexQuality = 5; // Medium quality - good balance between CPU and quality + private const int SpeexChannels = 2; // Always use stereo for processing + private const ResamplerQuality SpeexQuality = ResamplerQuality.Desktop; private const byte EnvelopeMaxExpansionOverMs = 15; // Envelope expands over 15ms private const byte EnvelopeExpiresAfterSeconds = 10; // Envelope expires after 10s @@ -48,7 +48,7 @@ public sealed class SoundChannel { private int _zohTargetRateHz; // Initialized ONCE when first needed (see ConfigureResampler) - private SpeexResamplerCSharp? _speexResampler; + private SpeexResampler? _speexResampler; // Pre-allocated resample buffers (avoids per-tick GC allocations) private float[] _resampleInputBuffer = []; @@ -341,7 +341,7 @@ void ConfigureSpeexResampler(int inRateHz) { uint speexInRate = (uint)inRateHz; uint speexOutRate = (uint)mixerRateHz; - _speexResampler ??= new SpeexResamplerCSharp( + _speexResampler ??= new SpeexResampler( SpeexChannels, speexInRate, speexOutRate, @@ -986,7 +986,7 @@ private static float Lerp(float a, float b, float t) { } private void ApplySpeexResampling(int audioFramesStartingSize) { - if (_speexResampler is null or { IsInitialized: false }) { + if (_speexResampler is null) { throw new InvalidOperationException("Speex Resampler was null or not initialized before audio resampling"); } int inFrames = _convertBuffer.Count; @@ -1018,35 +1018,32 @@ private void ApplySpeexResampling(int audioFramesStartingSize) { } // Process through Speex resampler (interleaved stereo) - _speexResampler.ProcessInterleavedFloat( + ResamplerProcessResult framesGenerated = _speexResampler.ProcessInterleaved( _resampleInputBuffer.AsSpan(0, inputSize), - _resampleOutputBuffer.AsSpan(0, outputSize), - out uint _, - out uint outFramesGenerated); + _resampleOutputBuffer.AsSpan(0, outputSize)); // Copy resampled frames back to audio_frames Span audioSpan = AudioFrames.AsSpan(); - for (int i = 0; i < (int)outFramesGenerated; i++) { + for (int i = 0; i < framesGenerated.OutputProduced; i++) { audioSpan[audioFramesStartingSize + i] = new AudioFrame( _resampleOutputBuffer[i * 2], _resampleOutputBuffer[i * 2 + 1]); } // Trim audio_frames to actual size - int actualSize = audioFramesStartingSize + (int)outFramesGenerated; + int actualSize = audioFramesStartingSize + framesGenerated.OutputProduced; if (AudioFrames.Count > actualSize) { AudioFrames.RemoveRange(actualSize, AudioFrames.Count - actualSize); } } - private static int EstimateMaxOutFrames(SpeexResamplerCSharp resampler, int inFrames) { - resampler.GetRatio(out uint ratioNum, out uint ratioDen); - if (ratioNum == 0 || ratioDen == 0 || inFrames <= 0) { + private static int EstimateMaxOutFrames(SpeexResampler resampler, int inFrames) { + if (resampler.RatioNumerator == 0 || resampler.RatioDenominator == 0 || inFrames <= 0) { return inFrames; } - double numerator = (double)inFrames * ratioDen; - int estimated = (int)Math.Ceiling(numerator / ratioNum); + double numerator = (double)inFrames * resampler.RatioDenominator; + int estimated = (int)Math.Ceiling(numerator / resampler.RatioNumerator); return estimated <= 0 ? inFrames : estimated; } diff --git a/src/Spice86.Core/Spice86.Core.csproj b/src/Spice86.Core/Spice86.Core.csproj index 8e5ba252e9..aa86fa07fa 100644 --- a/src/Spice86.Core/Spice86.Core.csproj +++ b/src/Spice86.Core/Spice86.Core.csproj @@ -17,7 +17,9 @@ + + @@ -27,6 +29,7 @@ + diff --git a/tests/Spice86.Tests/Emulator/Devices/Sound/SoundChannelTests.cs b/tests/Spice86.Tests/Emulator/Devices/Sound/SoundChannelTests.cs new file mode 100644 index 0000000000..ba8cd44bc6 --- /dev/null +++ b/tests/Spice86.Tests/Emulator/Devices/Sound/SoundChannelTests.cs @@ -0,0 +1,42 @@ +namespace Spice86.Tests.Emulator.Devices.Sound; + +using FluentAssertions; + +using Spice86.Audio.Filters; +using Spice86.Core.Emulator.Devices.Sound; + +using Xunit; + +/// +/// Verifies sound-channel processing contracts independently from a host audio device. +/// +[Trait("Category", "Sound")] +public sealed class SoundChannelTests { + /// + /// Variable producer block sizes must not expose retained resampling-buffer capacity as active input. + /// + [Fact] + public void AddSamplesFloat_AfterLargerBlock_ProcessesOnlyCurrentFrames() { + // Arrange + const int MixerRate = 48000; + const int ChannelRate = 96000; + const int StereoChannels = 2; + const int LargeFrameCount = 64; + const int SmallFrameCount = 1; + SoundChannel channel = new(_ => { }, nameof(SoundChannelTests), []); + channel.SetMixerSampleRate(MixerRate); + channel.SampleRate = ChannelRate; + channel.SetResampleMethod(ResampleMethod.Resample); + float[] largeBlock = new float[LargeFrameCount * StereoChannels]; + float[] smallBlock = new float[SmallFrameCount * StereoChannels]; + channel.AddSamplesFloat(LargeFrameCount, largeBlock); + int framesAfterLargeBlock = channel.AudioFrames.Count; + + // Act + Action act = () => channel.AddSamplesFloat(SmallFrameCount, smallBlock); + + // Assert + act.Should().NotThrow(); + channel.AudioFrames.Count.Should().BeInRange(framesAfterLargeBlock, framesAfterLargeBlock + SmallFrameCount); + } +}