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);
+ }
+}