From c5e4d7e892e85a26b64cd04e778d0001f6e318f3 Mon Sep 17 00:00:00 2001 From: Mercury Date: Fri, 22 May 2026 17:22:55 -0400 Subject: [PATCH] Harden audio hot path and output recovery --- src/SoundType.Audio/AudioEngine.cs | 74 ++++++++++++++++---- src/SoundType.Audio/IAudioOutputDevice.cs | 51 ++++++++++++++ src/SoundType.Tests/AudioProcessingTests.cs | 76 ++++++++++++++++++++- 3 files changed, 187 insertions(+), 14 deletions(-) create mode 100644 src/SoundType.Audio/IAudioOutputDevice.cs diff --git a/src/SoundType.Audio/AudioEngine.cs b/src/SoundType.Audio/AudioEngine.cs index b143db9..2dffb3b 100644 --- a/src/SoundType.Audio/AudioEngine.cs +++ b/src/SoundType.Audio/AudioEngine.cs @@ -9,11 +9,13 @@ 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 _roundRobin = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _throttleLastPlayedTicks = new(StringComparer.OrdinalIgnoreCase); private readonly Dictionary _packs = new(StringComparer.OrdinalIgnoreCase); @@ -21,7 +23,8 @@ public sealed class AudioEngine : IAsyncDisposable 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; @@ -29,15 +32,16 @@ public sealed class AudioEngine : IAsyncDisposable 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; @@ -147,6 +151,11 @@ public void Preview(string soundGroup = "normal") private bool PlayNow(PlaybackRequest request) { + if (!EnsureOutputRunning()) + { + return false; + } + if (IsThrottled(request)) { return false; @@ -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(); @@ -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}:"; @@ -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; + } + } + } } diff --git a/src/SoundType.Audio/IAudioOutputDevice.cs b/src/SoundType.Audio/IAudioOutputDevice.cs new file mode 100644 index 0000000..fd5790d --- /dev/null +++ b/src/SoundType.Audio/IAudioOutputDevice.cs @@ -0,0 +1,51 @@ +using NAudio.Wave; + +namespace SoundType.Audio; + +public interface IAudioOutputDevice : IDisposable +{ + event EventHandler? 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? 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(); +} diff --git a/src/SoundType.Tests/AudioProcessingTests.cs b/src/SoundType.Tests/AudioProcessingTests.cs index 0f12f56..9a7318a 100644 --- a/src/SoundType.Tests/AudioProcessingTests.cs +++ b/src/SoundType.Tests/AudioProcessingTests.cs @@ -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() { @@ -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? tags = null) { WaveFormat format = WaveFormat.CreateIeeeFloatWaveFormat(44100, 2); LoadedSoundSample sample = new( @@ -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>(StringComparer.OrdinalIgnoreCase) { ["normal"] = [sample] }); } + private sealed class FakeAudioOutputDeviceFactory : IAudioOutputDeviceFactory + { + public List CreatedDevices { get; } = []; + + public IAudioOutputDevice Create() + { + FakeAudioOutputDevice device = new(); + CreatedDevices.Add(device); + return device; + } + } + + private sealed class FakeAudioOutputDevice : IAudioOutputDevice + { + public event EventHandler? 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;