From 4d463e6a761f0fc893c5922e0c6e0ebc9e0b41fb178cb995a55ba9d2bb12480e Mon Sep 17 00:00:00 2001 From: copecone Date: Thu, 9 Jul 2026 23:19:12 +0900 Subject: [PATCH] Add RMS Compressor --- Core/RenderManager.cs | 17 +-- Data/RenderConfig.cs | 2 +- Util/Audio/AudioFilter.cs | 7 -- Util/Audio/Filter/HardClip.cs | 31 +++++ Util/Audio/Filter/IAudioFilter.cs | 14 +++ Util/Audio/Filter/RmsCompressor.cs | 175 +++++++++++++++++++++++++++++ Util/Audio/OfflineAudioMixer.cs | 52 ++++++--- 7 files changed, 262 insertions(+), 36 deletions(-) delete mode 100644 Util/Audio/AudioFilter.cs create mode 100644 Util/Audio/Filter/HardClip.cs create mode 100644 Util/Audio/Filter/IAudioFilter.cs create mode 100644 Util/Audio/Filter/RmsCompressor.cs diff --git a/Core/RenderManager.cs b/Core/RenderManager.cs index 5205c0c..1f9276a 100644 --- a/Core/RenderManager.cs +++ b/Core/RenderManager.cs @@ -620,16 +620,9 @@ namespace ADOFAIRenderer.Core FrameAvailableEvent.WaitOne(50); } - if (IsRenderCancelled) - { - videoFFmpeg.Dispose(); - } - else - { - videoFFmpeg.End(); - videoFFmpeg.Dispose(); - } - + if (!IsRenderCancelled) videoFFmpeg.End(); + videoFFmpeg.Dispose(); + lock (_allVideoFFmpegInstances) { _allVideoFFmpegInstances.Remove(videoFFmpeg); @@ -815,8 +808,8 @@ namespace ADOFAIRenderer.Core { return codec switch { - Codec.AV1 or Codec.VP9 => ".webm", - Codec.VVC => ".mkv", + Codec.VP9 => ".webm", + Codec.AV1 or Codec.VVC => ".mkv", _ => ".mp4" }; } diff --git a/Data/RenderConfig.cs b/Data/RenderConfig.cs index 6c8af72..5eae2eb 100644 --- a/Data/RenderConfig.cs +++ b/Data/RenderConfig.cs @@ -21,7 +21,7 @@ namespace ADOFAIRenderer.Data public bool RenderAudio { get; set; } = true; public int AudioSampleRate { get; set; } = 44100; public int AudioChannels { get; set; } = 2; - public int AudioBitrate { get; set; } = 192000; + public int AudioBitrate { get; set; } = 256000; } public class EncodingConfig diff --git a/Util/Audio/AudioFilter.cs b/Util/Audio/AudioFilter.cs deleted file mode 100644 index 4a8bfed..0000000 --- a/Util/Audio/AudioFilter.cs +++ /dev/null @@ -1,7 +0,0 @@ -namespace ADOFAIRenderer.Util.Audio -{ - public class AudioFilter - { - - } -} \ No newline at end of file diff --git a/Util/Audio/Filter/HardClip.cs b/Util/Audio/Filter/HardClip.cs new file mode 100644 index 0000000..b8ee6f0 --- /dev/null +++ b/Util/Audio/Filter/HardClip.cs @@ -0,0 +1,31 @@ +using System; +using System.Runtime.CompilerServices; + +namespace ADOFAIRenderer.Util.Audio.Filter +{ + public class HardClip : IAudioFilter + { + // ReSharper disable once MemberCanBePrivate.Global + // ReSharper disable once FieldCanBeMadeReadOnly.Global + // ReSharper disable once ConvertToConstant.Global + public float Threshold = 1.0f; + + public void Initialize(int sampleRate, int channels) { } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float ProcessSample(float sample, int channel) + { + if (sample > Threshold) return Threshold; + if (sample < -Threshold) return -Threshold; + return sample; + } + + public void ProcessBlock(Span buffer) + { + for (var i = 0; i < buffer.Length; i++) + buffer[i] = ProcessSample(buffer[i], 0); + } + + public void Reset() { } + } +} \ No newline at end of file diff --git a/Util/Audio/Filter/IAudioFilter.cs b/Util/Audio/Filter/IAudioFilter.cs new file mode 100644 index 0000000..1504d23 --- /dev/null +++ b/Util/Audio/Filter/IAudioFilter.cs @@ -0,0 +1,14 @@ +using System; + +namespace ADOFAIRenderer.Util.Audio.Filter +{ + public interface IAudioFilter + { + void Initialize(int sampleRate, int channels); + + float ProcessSample(float sample, int channel); + void ProcessBlock(Span buffer); + + void Reset(); + } +} \ No newline at end of file diff --git a/Util/Audio/Filter/RmsCompressor.cs b/Util/Audio/Filter/RmsCompressor.cs new file mode 100644 index 0000000..cd33732 --- /dev/null +++ b/Util/Audio/Filter/RmsCompressor.cs @@ -0,0 +1,175 @@ +using System; +using System.Runtime.CompilerServices; + +namespace ADOFAIRenderer.Util.Audio.Filter +{ + public class RmsCompressor : IAudioFilter + { + private float _thresholdDb = -20f; + private float _ratio = 4f; + private float _attackTime = 0.010f; // in seconds + private float _releaseTime = 0.100f; // in seconds + private float _kneeDb = 5f; // in dB + private float _rmsWindowTime = 0.030f; // in seconds + + private int _sampleRate = 44100; + private int _channels = 2; + + private float _attackCoef; + private float _releaseCoef; + private float _rmsCoef; + + // Per-channel state + private float[] _rmsState = Array.Empty(); // running mean-square (MS) state + private float[] _gainState = Array.Empty(); // smoothed gain reduction in dB + + public float ThresholdDb + { + get => _thresholdDb; + set => _thresholdDb = value; + } + + public float Ratio + { + get => _ratio; + set => _ratio = Math.Max(1f, value); + } + + public float AttackTime + { + get => _attackTime; + set + { + _attackTime = Math.Max(0.0001f, value); + UpdateAttackCoef(); + } + } + + public float ReleaseTime + { + get => _releaseTime; + set + { + _releaseTime = Math.Max(0.0001f, value); + UpdateReleaseCoef(); + } + } + + public float KneeDb + { + get => _kneeDb; + set => _kneeDb = Math.Max(0f, value); + } + + public float RmsWindowTime + { + get => _rmsWindowTime; + set + { + _rmsWindowTime = Math.Max(0.0001f, value); + UpdateRmsCoef(); + } + } + + public void Initialize(int sampleRate, int channels) + { + _sampleRate = sampleRate; + _channels = channels; + + UpdateAttackCoef(); + UpdateReleaseCoef(); + UpdateRmsCoef(); + + _rmsState = new float[channels]; + _gainState = new float[channels]; + + Reset(); + } + + private void UpdateAttackCoef() + { + _attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime)); + } + + private void UpdateReleaseCoef() + { + _releaseCoef = (float)Math.Exp(-1.0 / (_sampleRate * _releaseTime)); + } + + private void UpdateRmsCoef() + { + _rmsCoef = (float)Math.Exp(-1.0 / (_sampleRate * _rmsWindowTime)); + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public float ProcessSample(float sample, int channel) + { + if (channel < 0 || channel >= _channels) + { + return sample; + } + + // Calculate running Mean-Square (MS) level + // v_c[n] = alpha * v_c[n-1] + (1 - alpha) * x^2[n] + float sq = sample * sample; + _rmsState[channel] = _rmsCoef * _rmsState[channel] + (1.0f - _rmsCoef) * sq; + + float rmsSq = Math.Max(0f, _rmsState[channel]); + // Convert to dB. 10 * log10(rmsSq) is equivalent to 20 * log10(sqrt(rmsSq)) + float rmsDb = 10.0f * (float)Math.Log10(rmsSq + 1e-12f); + + // Calculate static envelope curve (gain reduction in dB) + float gStatic = 0f; + if (_kneeDb > 0f) + { + if (rmsDb > _thresholdDb + _kneeDb * 0.5f) + { + gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb); + } + else if (rmsDb >= _thresholdDb - _kneeDb * 0.5f) + { + float diff = rmsDb - _thresholdDb + _kneeDb * 0.5f; + gStatic = (1f / _ratio - 1f) * diff * diff / (2f * _kneeDb); + } + } + else + { + if (rmsDb > _thresholdDb) + { + gStatic = (1f / _ratio - 1f) * (rmsDb - _thresholdDb); + } + } + + // Apply ballistics (attack and release) on the dB gain reduction + float lastGain = _gainState[channel]; + float targetGain = gStatic; + + // Note: Since targetGain and lastGain are <= 0 (gain reduction in dB), + // targetGain < lastGain means target gain reduction is larger (more negative), + // so we are in the attack phase. + float coef = targetGain < lastGain ? _attackCoef : _releaseCoef; + float currentGain = coef * lastGain + (1f - coef) * targetGain; + _gainState[channel] = currentGain; + + // Convert back to linear gain multiplier + float linearGain = (float)Math.Pow(10.0, currentGain / 20.0); + + return sample * linearGain; + } + + public void ProcessBlock(Span buffer) + { + for (var i = 0; i < buffer.Length; i++) + { + int channel = i % _channels; + buffer[i] = ProcessSample(buffer[i], channel); + } + } + + public void Reset() + { + Array.Clear(_rmsState, 0, _rmsState.Length); + Array.Clear(_gainState, 0, _gainState.Length); + } + } +} diff --git a/Util/Audio/OfflineAudioMixer.cs b/Util/Audio/OfflineAudioMixer.cs index 1141fd3..29e7a32 100644 --- a/Util/Audio/OfflineAudioMixer.cs +++ b/Util/Audio/OfflineAudioMixer.cs @@ -1,5 +1,8 @@ using System; using System.Collections.Generic; +using System.Runtime.CompilerServices; +using ADOFAIRenderer.Util.Audio.Filter; +using MelonLoader; using UnityEngine; namespace ADOFAIRenderer.Util.Audio @@ -51,7 +54,7 @@ namespace ADOFAIRenderer.Util.Audio } catch (Exception e) { - MelonLoader.MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}"); + MelonLogger.Error($"[AudioClipCache] Failed to cache and resample clip {clipName}: {e}"); return null; } } @@ -78,13 +81,16 @@ namespace ADOFAIRenderer.Util.Audio } private readonly List _activeEvents = new(); - private System.Runtime.CompilerServices.ConditionalWeakTable _sourceMappings = new(); + private ConditionalWeakTable _sourceMappings = new(); private readonly object _lock = new(); // Target Audio Settings private readonly int _targetChannels; private readonly int _targetSampleRate; - private const float MasterVolume = 0.05f; + private const float MasterVolume = 1f; + + // Audio Filter Settings + private readonly LinkedList _filters = new(); // BGM Cached Samples (Pre-resampled) private float[]? _bgmSamples; @@ -97,6 +103,19 @@ namespace ADOFAIRenderer.Util.Audio { _targetChannels = targetChannels; _targetSampleRate = targetSampleRate; + + var rmsCompressor = new RmsCompressor + { + ThresholdDb = -10, Ratio = 2, + AttackTime = 0.1f, ReleaseTime = 0.1f, + KneeDb = 10, RmsWindowTime = 0.02f + }; + rmsCompressor.Initialize(_targetSampleRate, _targetChannels); + _filters.AddLast(rmsCompressor); + + var hardClip = new HardClip(); + hardClip.Initialize(_targetSampleRate, _targetChannels); + _filters.AddLast(hardClip); } public void InitializeBGM(AudioClip? clip) @@ -121,11 +140,11 @@ namespace ADOFAIRenderer.Util.Audio _bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate); _bgmDuration = (double)_bgmSamples.Length / (_targetChannels * _targetSampleRate); - MelonLoader.MelonLogger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}"); + Melon.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}"); } catch (Exception e) { - MelonLoader.MelonLogger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}"); + Melon.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}"); _bgmSamples = null; _bgmDuration = 0; } @@ -189,7 +208,7 @@ namespace ADOFAIRenderer.Util.Audio lock (_lock) { _activeEvents.Clear(); - _sourceMappings = new System.Runtime.CompilerServices.ConditionalWeakTable(); + _sourceMappings = new ConditionalWeakTable(); _bgmSamples = null; _bgmDuration = 0; _clipCache.Clear(); @@ -209,11 +228,12 @@ namespace ADOFAIRenderer.Util.Audio // 2. Mix Hitsounds/Effect Sounds MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime); - // 3. Saturation Guard (Clamping) - for (int i = 0; i < samplesNeeded * _targetChannels; i++) + foreach (var filter in _filters) + filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels)); + + for (var i = 0; i < samplesNeeded * _targetChannels; i++) { - if (mixedBuffer[i] > 1.0f) mixedBuffer[i] = 1.0f; - else if (mixedBuffer[i] < -1.0f) mixedBuffer[i] = -1.0f; + if (Math.Abs(mixedBuffer[i]) > 1) Melon.Logger.Msg("Warning Why Audio Sample Is Larger Than 1"); } } } @@ -226,16 +246,16 @@ namespace ADOFAIRenderer.Util.Audio if (!conductor) return; double pitch = conductor.song.pitch; - double dspTimeSong = conductor.dspTimeSong; - double separateCountdownOffset = conductor.separateCountdownTime - ? (conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks) + var dspTimeSong = conductor.dspTimeSong; + var separateCountdownOffset = conductor.separateCountdownTime + ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0; // BGM Playback start time relative to dspTimeBase - double time1 = dspTimeSong + separateCountdownOffset; - double relativeTime1 = time1 - dspTimeBase; + var time1 = dspTimeSong + separateCountdownOffset; + var relativeTime1 = time1 - dspTimeBase; - int totalSamples = _bgmSamples.Length / _targetChannels; + var totalSamples = _bgmSamples.Length / _targetChannels; for (int s = 0; s < samplesNeeded; s++) {