SHA256
forked from ADOFAIEditorForum/ADOFAIRenderer
Add RMS Compressor
This commit is contained in:
7 files changed
+262
-36
No files matched your search
+5
-12
@@ -620,16 +620,9 @@ namespace ADOFAIRenderer.Core
|
|||||||
FrameAvailableEvent.WaitOne(50);
|
FrameAvailableEvent.WaitOne(50);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (IsRenderCancelled)
|
if (!IsRenderCancelled) videoFFmpeg.End();
|
||||||
{
|
videoFFmpeg.Dispose();
|
||||||
videoFFmpeg.Dispose();
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
videoFFmpeg.End();
|
|
||||||
videoFFmpeg.Dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_allVideoFFmpegInstances)
|
lock (_allVideoFFmpegInstances)
|
||||||
{
|
{
|
||||||
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
_allVideoFFmpegInstances.Remove(videoFFmpeg);
|
||||||
@@ -815,8 +808,8 @@ namespace ADOFAIRenderer.Core
|
|||||||
{
|
{
|
||||||
return codec switch
|
return codec switch
|
||||||
{
|
{
|
||||||
Codec.AV1 or Codec.VP9 => ".webm",
|
Codec.VP9 => ".webm",
|
||||||
Codec.VVC => ".mkv",
|
Codec.AV1 or Codec.VVC => ".mkv",
|
||||||
_ => ".mp4"
|
_ => ".mp4"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ namespace ADOFAIRenderer.Data
|
|||||||
public bool RenderAudio { get; set; } = true;
|
public bool RenderAudio { get; set; } = true;
|
||||||
public int AudioSampleRate { get; set; } = 44100;
|
public int AudioSampleRate { get; set; } = 44100;
|
||||||
public int AudioChannels { get; set; } = 2;
|
public int AudioChannels { get; set; } = 2;
|
||||||
public int AudioBitrate { get; set; } = 192000;
|
public int AudioBitrate { get; set; } = 256000;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class EncodingConfig
|
public class EncodingConfig
|
||||||
|
|||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace ADOFAIRenderer.Util.Audio
|
|
||||||
{
|
|
||||||
public class AudioFilter
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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<float> buffer)
|
||||||
|
{
|
||||||
|
for (var i = 0; i < buffer.Length; i++)
|
||||||
|
buffer[i] = ProcessSample(buffer[i], 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Reset() { }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<float> buffer);
|
||||||
|
|
||||||
|
void Reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<float>(); // running mean-square (MS) state
|
||||||
|
private float[] _gainState = Array.Empty<float>(); // 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<float> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Runtime.CompilerServices;
|
||||||
|
using ADOFAIRenderer.Util.Audio.Filter;
|
||||||
|
using MelonLoader;
|
||||||
using UnityEngine;
|
using UnityEngine;
|
||||||
|
|
||||||
namespace ADOFAIRenderer.Util.Audio
|
namespace ADOFAIRenderer.Util.Audio
|
||||||
@@ -51,7 +54,7 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
}
|
}
|
||||||
catch (Exception e)
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,13 +81,16 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
}
|
}
|
||||||
|
|
||||||
private readonly List<SoundEvent> _activeEvents = new();
|
private readonly List<SoundEvent> _activeEvents = new();
|
||||||
private System.Runtime.CompilerServices.ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
|
private ConditionalWeakTable<AudioSource, SoundEvent> _sourceMappings = new();
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
// Target Audio Settings
|
// Target Audio Settings
|
||||||
private readonly int _targetChannels;
|
private readonly int _targetChannels;
|
||||||
private readonly int _targetSampleRate;
|
private readonly int _targetSampleRate;
|
||||||
private const float MasterVolume = 0.05f;
|
private const float MasterVolume = 1f;
|
||||||
|
|
||||||
|
// Audio Filter Settings
|
||||||
|
private readonly LinkedList<IAudioFilter> _filters = new();
|
||||||
|
|
||||||
// BGM Cached Samples (Pre-resampled)
|
// BGM Cached Samples (Pre-resampled)
|
||||||
private float[]? _bgmSamples;
|
private float[]? _bgmSamples;
|
||||||
@@ -97,6 +103,19 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
{
|
{
|
||||||
_targetChannels = targetChannels;
|
_targetChannels = targetChannels;
|
||||||
_targetSampleRate = targetSampleRate;
|
_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)
|
public void InitializeBGM(AudioClip? clip)
|
||||||
@@ -121,11 +140,11 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
|
_bgmSamples = AudioResampler.Resample(rawSamples, originalChannels, clip.frequency, _targetChannels, _targetSampleRate);
|
||||||
_bgmDuration = (double)_bgmSamples.Length / (_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<RenderMod>.Logger.Msg($"[OfflineAudioMixer] Initialized resampled BGM: {clip.name}, Samples: {_bgmSamples.Length / _targetChannels}, TargetFreq: {_targetSampleRate}Hz, TargetChannels: {_targetChannels}");
|
||||||
}
|
}
|
||||||
catch (Exception e)
|
catch (Exception e)
|
||||||
{
|
{
|
||||||
MelonLoader.MelonLogger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
|
Melon<RenderMod>.Logger.Error($"[OfflineAudioMixer] Failed to initialize and resample BGM: {e}");
|
||||||
_bgmSamples = null;
|
_bgmSamples = null;
|
||||||
_bgmDuration = 0;
|
_bgmDuration = 0;
|
||||||
}
|
}
|
||||||
@@ -189,7 +208,7 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
_activeEvents.Clear();
|
_activeEvents.Clear();
|
||||||
_sourceMappings = new System.Runtime.CompilerServices.ConditionalWeakTable<AudioSource, SoundEvent>();
|
_sourceMappings = new ConditionalWeakTable<AudioSource, SoundEvent>();
|
||||||
_bgmSamples = null;
|
_bgmSamples = null;
|
||||||
_bgmDuration = 0;
|
_bgmDuration = 0;
|
||||||
_clipCache.Clear();
|
_clipCache.Clear();
|
||||||
@@ -209,11 +228,12 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
// 2. Mix Hitsounds/Effect Sounds
|
// 2. Mix Hitsounds/Effect Sounds
|
||||||
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime);
|
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime);
|
||||||
|
|
||||||
// 3. Saturation Guard (Clamping)
|
foreach (var filter in _filters)
|
||||||
for (int i = 0; i < samplesNeeded * _targetChannels; i++)
|
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
|
||||||
|
|
||||||
|
for (var i = 0; i < samplesNeeded * _targetChannels; i++)
|
||||||
{
|
{
|
||||||
if (mixedBuffer[i] > 1.0f) mixedBuffer[i] = 1.0f;
|
if (Math.Abs(mixedBuffer[i]) > 1) Melon<RenderMod>.Logger.Msg("Warning Why Audio Sample Is Larger Than 1");
|
||||||
else if (mixedBuffer[i] < -1.0f) mixedBuffer[i] = -1.0f;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,16 +246,16 @@ namespace ADOFAIRenderer.Util.Audio
|
|||||||
if (!conductor) return;
|
if (!conductor) return;
|
||||||
|
|
||||||
double pitch = conductor.song.pitch;
|
double pitch = conductor.song.pitch;
|
||||||
double dspTimeSong = conductor.dspTimeSong;
|
var dspTimeSong = conductor.dspTimeSong;
|
||||||
double separateCountdownOffset = conductor.separateCountdownTime
|
var separateCountdownOffset = conductor.separateCountdownTime
|
||||||
? (conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks)
|
? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks
|
||||||
: 0.0;
|
: 0.0;
|
||||||
|
|
||||||
// BGM Playback start time relative to dspTimeBase
|
// BGM Playback start time relative to dspTimeBase
|
||||||
double time1 = dspTimeSong + separateCountdownOffset;
|
var time1 = dspTimeSong + separateCountdownOffset;
|
||||||
double relativeTime1 = time1 - dspTimeBase;
|
var relativeTime1 = time1 - dspTimeBase;
|
||||||
|
|
||||||
int totalSamples = _bgmSamples.Length / _targetChannels;
|
var totalSamples = _bgmSamples.Length / _targetChannels;
|
||||||
|
|
||||||
for (int s = 0; s < samplesNeeded; s++)
|
for (int s = 0; s < samplesNeeded; s++)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user