Skip to content
Merged
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
74 changes: 62 additions & 12 deletions src/SoundType.Audio/AudioEngine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,35 +9,39 @@ public sealed class AudioEngine : IAsyncDisposable
{
private const int DefaultMaxCachedPacks = 4;
private const int DefaultMaxActiveVoices = 32;
private const int OutputDesiredLatencyMs = 45;
private const int OutputBufferCount = 3;
public const int OutputDesiredLatencyMs = 45;
public const int OutputBufferCount = 3;
private readonly Random _random = new();
private readonly object _packLock = new();
private readonly object _mixerLock = new();
private readonly object _outputLock = new();
private readonly IAudioOutputDeviceFactory _outputFactory;
private readonly Dictionary<string, int> _roundRobin = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, long> _throttleLastPlayedTicks = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, LoadedSoundPack> _packs = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, long> _packLastUsedTicks = new(StringComparer.OrdinalIgnoreCase);
private readonly VoiceLimiter _voiceLimiter = new(DefaultMaxActiveVoices);
private readonly WaveFormat _playbackFormat = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2);
private readonly MixingSampleProvider _mixer;
private readonly WaveOutEvent _output;
private readonly ISampleProvider _outputSource;
private IAudioOutputDevice _output;
private EqSettings _eq = CreateEqSnapshot(new EqSettings());
private PanSettings _pan = CreatePanSnapshot(new PanSettings());
private double _eqOutputTrim = 1.0;
private LoadedSoundPack? _activePack;
private bool _disposed;

public AudioEngine()
: this(new WaveOutAudioOutputDeviceFactory())
{
}

public AudioEngine(IAudioOutputDeviceFactory outputFactory)
{
_outputFactory = outputFactory;
_mixer = new MixingSampleProvider(_playbackFormat) { ReadFully = true };
_output = new WaveOutEvent
{
DesiredLatency = OutputDesiredLatencyMs,
NumberOfBuffers = OutputBufferCount
};
_output.Init(new SoftLimiterSampleProvider(_mixer));
_output.Play();
_outputSource = new SoftLimiterSampleProvider(_mixer);
_output = CreateAndStartOutput();
}

public double MasterVolume { get; set; } = 0.75;
Expand Down Expand Up @@ -147,6 +151,11 @@ public void Preview(string soundGroup = "normal")

private bool PlayNow(PlaybackRequest request)
{
if (!EnsureOutputRunning())
{
return false;
}

if (IsThrottled(request))
{
return false;
Expand Down Expand Up @@ -389,7 +398,9 @@ private void PruneCachedPacks()
{
string? activePackId = _activePack?.Metadata.Id;
string? oldestPackId = _packLastUsedTicks
.Where(entry => !entry.Key.Equals(activePackId, StringComparison.OrdinalIgnoreCase))
.Where(entry =>
!entry.Key.Equals(activePackId, StringComparison.OrdinalIgnoreCase) &&
!IsSystemPack(entry.Key))
.OrderBy(entry => entry.Value)
.Select(entry => entry.Key)
.FirstOrDefault();
Expand All @@ -405,6 +416,10 @@ private void PruneCachedPacks()
}
}

private bool IsSystemPack(string soundPackId) =>
_packs.TryGetValue(soundPackId, out LoadedSoundPack? pack) &&
pack.Metadata.Tags.Any(tag => tag.Equals("system", StringComparison.OrdinalIgnoreCase));

private void RemoveRoundRobinEntries(string soundPackId)
{
string prefix = $"{soundPackId}:";
Expand All @@ -421,10 +436,45 @@ public async ValueTask DisposeAsync()
{
_disposed = true;
await Task.Yield();
lock (_mixerLock)
lock (_outputLock)
{
_output.Stop();
_output.Dispose();
}
}

private IAudioOutputDevice CreateAndStartOutput()
{
IAudioOutputDevice output = _outputFactory.Create();
output.Init(_outputSource);
output.Play();
return output;
}

private bool EnsureOutputRunning()
{
lock (_outputLock)
{
if (_disposed)
{
return false;
}

if (_output.PlaybackState == PlaybackState.Playing)
{
return true;
}

try
{
_output.Dispose();
_output = CreateAndStartOutput();
return _output.PlaybackState == PlaybackState.Playing;
}
catch
{
return false;
}
}
}
}
51 changes: 51 additions & 0 deletions src/SoundType.Audio/IAudioOutputDevice.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
using NAudio.Wave;

namespace SoundType.Audio;

public interface IAudioOutputDevice : IDisposable
{
event EventHandler<StoppedEventArgs>? PlaybackStopped;

PlaybackState PlaybackState { get; }

void Init(ISampleProvider provider);

void Play();

void Stop();
}

public interface IAudioOutputDeviceFactory
{
IAudioOutputDevice Create();
}

public sealed class WaveOutAudioOutputDeviceFactory : IAudioOutputDeviceFactory
{
public IAudioOutputDevice Create() => new WaveOutAudioOutputDevice();
}

public sealed class WaveOutAudioOutputDevice : IAudioOutputDevice
{
private readonly WaveOutEvent output = new()
{
DesiredLatency = AudioEngine.OutputDesiredLatencyMs,
NumberOfBuffers = AudioEngine.OutputBufferCount
};

public event EventHandler<StoppedEventArgs>? PlaybackStopped
{
add => output.PlaybackStopped += value;
remove => output.PlaybackStopped -= value;
}

public PlaybackState PlaybackState => output.PlaybackState;

public void Init(ISampleProvider provider) => output.Init(provider);

public void Play() => output.Play();

public void Stop() => output.Stop();

public void Dispose() => output.Dispose();
}
76 changes: 74 additions & 2 deletions src/SoundType.Tests/AudioProcessingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,43 @@ public async Task AudioEngine_PrunesInactivePacksPastCacheLimit()
await engine.DisposeAsync();
}

[Fact]
public async Task AudioEngine_PruneKeepsSystemPacksLoadedForHotPath()
{
AudioEngine engine = new() { MaxCachedPacks = 2 };

engine.LoadPack(CreateLoadedPack("system-ding", tags: ["system"]), makeActive: false);
engine.LoadPack(CreateLoadedPack("one"), makeActive: true);
engine.LoadPack(CreateLoadedPack("two"), makeActive: false);
engine.LoadPack(CreateLoadedPack("three"), makeActive: false);

Assert.True(engine.TryGetLoadedPack("system-ding", out _));
Assert.Equal(2, engine.LoadedPackCount);
await engine.DisposeAsync();
}

[Fact]
public async Task AudioEngine_TryPlay_RecreatesOutputAfterUnexpectedStop()
{
FakeAudioOutputDeviceFactory factory = new();
AudioEngine engine = new(factory);
engine.LoadPack(CreateLoadedPack("device-test"));
FakeAudioOutputDevice firstOutput = factory.CreatedDevices.Single();
firstOutput.MarkStopped();

bool played = engine.TryPlay(new PlaybackRequest
{
Key = new KeyIdentity("A", "A", KeyCategory.Character),
SoundGroup = "normal",
SoundPackId = "device-test"
});

Assert.True(played);
Assert.Equal(2, factory.CreatedDevices.Count);
Assert.Equal(PlaybackState.Playing, factory.CreatedDevices[^1].PlaybackState);
await engine.DisposeAsync();
}

[Fact]
public void LimiterSampleProvider_ClampsSamplesToThreshold()
{
Expand Down Expand Up @@ -323,7 +360,7 @@ private static float[] CreateRamp(int count)
return samples;
}

private static LoadedSoundPack CreateLoadedPack(string id)
private static LoadedSoundPack CreateLoadedPack(string id, IReadOnlyList<string>? tags = null)
{
WaveFormat format = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2);
LoadedSoundSample sample = new(
Expand All @@ -334,13 +371,48 @@ private static LoadedSoundPack CreateLoadedPack(string id)
format);

return new LoadedSoundPack(
new SoundPackMetadata { Id = id, Name = id },
new SoundPackMetadata { Id = id, Name = id, Tags = tags?.ToList() ?? [] },
new Dictionary<string, IReadOnlyList<LoadedSoundSample>>(StringComparer.OrdinalIgnoreCase)
{
["normal"] = [sample]
});
}

private sealed class FakeAudioOutputDeviceFactory : IAudioOutputDeviceFactory
{
public List<FakeAudioOutputDevice> CreatedDevices { get; } = [];

public IAudioOutputDevice Create()
{
FakeAudioOutputDevice device = new();
CreatedDevices.Add(device);
return device;
}
}

private sealed class FakeAudioOutputDevice : IAudioOutputDevice
{
public event EventHandler<StoppedEventArgs>? PlaybackStopped;

public PlaybackState PlaybackState { get; private set; } = PlaybackState.Stopped;

public void Init(ISampleProvider provider)
{
}

public void Play() => PlaybackState = PlaybackState.Playing;

public void Stop()
{
PlaybackState = PlaybackState.Stopped;
PlaybackStopped?.Invoke(this, new StoppedEventArgs());
}

public void MarkStopped() => PlaybackState = PlaybackState.Stopped;

public void Dispose() => PlaybackState = PlaybackState.Stopped;
}

private sealed class ArraySampleProvider : ISampleProvider
{
private readonly float[] _samples;
Expand Down
Loading