Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
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.
26 changes: 24 additions & 2 deletions NOTICE
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
5 changes: 4 additions & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="bodong.Avalonia.PropertyGrid" Version="12.0.4.1" />
<PackageVersion Include="bodong.PropertyModels" Version="12.0.0" />
<PackageVersion Include="Iir1.Net" Version="1.0.0" />
<PackageVersion Include="MVerb.Net" Version="1.0.0" />
<PackageVersion Include="Spectre.Console.Cli" Version="0.55.0" />
<PackageVersion Include="CommunityToolkit.HighPerformance" Version="8.4.2" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
Expand All @@ -38,12 +40,13 @@
<PackageVersion Include="Mt32emu.net" Version="1.0.0" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="NSubstitute.Analyzers.CSharp" Version="1.0.17" />
<PackageVersion Include="NukedOPL3Sharp" Version="1.2.1" />
<PackageVersion Include="NukedOPL3Sharp" Version="1.3.1" />
<PackageVersion Include="PanAndZoom" Version="12.0.0.1" />
<PackageVersion Include="Roslynator.Analyzers" Version="4.15.0" />
<PackageVersion Include="Roslynator.CodeAnalysis.Analyzers" Version="4.15.0" />
<PackageVersion Include="Semi.Avalonia" Version="12.1.0.1" />
<PackageVersion Include="Semi.Avalonia.Dock" Version="12.0.0.2" />
<PackageVersion Include="SpeexResamplerSharp" Version="1.0.0" />
<PackageVersion Include="Spice86.DataGrid" Version="0.1.2" />
<PackageVersion Include="Serilog" Version="4.4.0" />
<PackageVersion Include="Serilog.Extensions.Hosting" Version="10.0.0" />
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Implements the AdLib Gold stereo processor, including tone controls and stereo field shaping.
Expand Down
46 changes: 25 additions & 21 deletions src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,8 +13,6 @@
using System.Runtime.InteropServices;
using System.Threading;

using HighPassFilter = Spice86.Audio.Filters.IirFilters.Filters.Butterworth.HighPass;

/// <summary>
/// Central audio mixer that runs in its own thread and produces final mixed output.
/// </summary>
Expand Down Expand Up @@ -65,12 +67,12 @@

// Reverb state - MVerb professional algorithmic reverb
private readonly bool _doReverb = false;
private readonly MVerb _mverb = new();

Check failure on line 70 in src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs

View workflow job for this annotation

GitHub Actions / build (windows-latest)

'MVerb' is an ambiguous reference between 'Spice86.Audio.Filters.MVerb' and 'MVerb.MVerb'

Check failure on line 70 in src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs

View workflow job for this annotation

GitHub Actions / build (ubuntu-latest)

'MVerb' is an ambiguous reference between 'Spice86.Audio.Filters.MVerb' and 'MVerb.MVerb'

Check failure on line 70 in src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs

View workflow job for this annotation

GitHub Actions / build (macos-latest)

'MVerb' is an ambiguous reference between 'Spice86.Audio.Filters.MVerb' and 'MVerb.MVerb'

Check failure on line 70 in src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

'MVerb' is an ambiguous reference between 'Spice86.Audio.Filters.MVerb' and 'MVerb.MVerb'

Check failure on line 70 in src/Spice86.Core/Emulator/Devices/Sound/SoftwareMixer.cs

View workflow job for this annotation

GitHub Actions / Analyze (csharp)

'MVerb' is an ambiguous reference between 'Spice86.Audio.Filters.MVerb' and 'MVerb.MVerb'
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;
Expand All @@ -82,8 +84,8 @@
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;

Expand All @@ -105,15 +107,15 @@
}

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

Expand Down Expand Up @@ -516,19 +518,21 @@
// results to the master output.
Span<AudioFrame> reverbAux = _reverbAuxBuffer.AsSpan();
Span<AudioFrame> output = _outputBuffer.AsSpan();
int frameCount = reverbAux.Length;
Span<float> reverbLeft = _reverbLeft.AsSpan(0, frameCount);
Span<float> 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]);
}
}

Expand Down Expand Up @@ -608,4 +612,4 @@
device.Channel.AddSilence();
}
}
}
}
37 changes: 17 additions & 20 deletions src/Spice86.Core/Emulator/Devices/Sound/SoundChannel.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -16,8 +16,8 @@ namespace Spice86.Core.Emulator.Devices.Sound;
/// 2022-2025 The DOSBox Staging Team
/// </remarks>
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
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<AudioFrame> 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;
}

Expand Down
3 changes: 3 additions & 0 deletions src/Spice86.Core/Spice86.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@

<ItemGroup>
<PackageReference Include="CommunityToolkit.HighPerformance" />
<PackageReference Include="Iir1.Net" />
<PackageReference Include="JetBrains.Annotations" />
<PackageReference Include="MVerb.Net" />
<PackageReference Include="Serilog" />
<PackageReference Include="Serilog.Sinks.File" />
<PackageReference Include="Spectre.Console.Cli" />
Expand All @@ -27,6 +29,7 @@
</Reference>
<PackageReference Include="MeltySynth" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" />
<PackageReference Include="SpeexResamplerSharp" />
<PackageReference Include="Swashbuckle.AspNetCore" />
<PackageReference Include="NukedOPL3Sharp" />
<PackageReference Include="Spice86.Audio" />
Expand Down
42 changes: 42 additions & 0 deletions tests/Spice86.Tests/Emulator/Devices/Sound/SoundChannelTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
namespace Spice86.Tests.Emulator.Devices.Sound;

using FluentAssertions;

using Spice86.Audio.Filters;
using Spice86.Core.Emulator.Devices.Sound;

using Xunit;

/// <summary>
/// Verifies sound-channel processing contracts independently from a host audio device.
/// </summary>
[Trait("Category", "Sound")]
public sealed class SoundChannelTests {
/// <summary>
/// Variable producer block sizes must not expose retained resampling-buffer capacity as active input.
/// </summary>
[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);
}
}
Loading