Add Look Ahead to Audio Filter

This commit is contained in:
copecone committed 2026-07-17 14:58:26 +09:00
1 parent 72648b4286
commit 742a9d594c
5 files changed
+129 -12

No files matched your search

+2
View File
@@ -18,5 +18,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter
} }
public void Reset() { } public void Reset() { }
public double Latency => 0.0;
} }
} }
+2
View File
@@ -27,5 +27,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter
} }
public void Reset() { } public void Reset() { }
public double Latency => 0.0;
} }
} }
+2
View File
@@ -10,5 +10,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter
void ProcessBlock(Span<float> buffer); void ProcessBlock(Span<float> buffer);
void Reset(); void Reset();
double Latency { get; }
} }
} }
+53
View File
@@ -12,6 +12,8 @@ namespace ADOFAIRenderer.Util.Audio.Filter
private float _releaseTime = 0.100f; // in seconds private float _releaseTime = 0.100f; // in seconds
private float _kneeDb = 5f; // in dB private float _kneeDb = 5f; // in dB
private float _rmsWindowTime = 0.030f; // in seconds private float _rmsWindowTime = 0.030f; // in seconds
private float _lookAheadTime; // in seconds
private int _lookAheadSamples;
private int _sampleRate = 44100; private int _sampleRate = 44100;
private int _channels = 2; private int _channels = 2;
@@ -23,6 +25,20 @@ namespace ADOFAIRenderer.Util.Audio.Filter
// Per-channel state // Per-channel state
private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state private float[] _rmsState = Array.Empty<float>(); // running mean-square (MS) state
private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB private float[] _gainState = Array.Empty<float>(); // smoothed gain reduction in dB
private float[][] _delayBuffers = Array.Empty<float[]>(); // circular delay buffers
private int[] _delayWriteIndices = Array.Empty<int>();
public float LookAheadTime
{
get => _lookAheadTime;
set
{
_lookAheadTime = Math.Max(0f, Math.Min(1.0f, value));
UpdateLookAheadSettings();
}
}
public double Latency => _lookAheadTime;
public float ThresholdDb public float ThresholdDb
{ {
@@ -80,6 +96,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter
UpdateAttackCoef(); UpdateAttackCoef();
UpdateReleaseCoef(); UpdateReleaseCoef();
UpdateRmsCoef(); UpdateRmsCoef();
UpdateLookAheadSettings();
_rmsState = new float[channels]; _rmsState = new float[channels];
_gainState = new float[channels]; _gainState = new float[channels];
@@ -87,6 +104,26 @@ namespace ADOFAIRenderer.Util.Audio.Filter
Reset(); Reset();
} }
private void UpdateLookAheadSettings()
{
_lookAheadSamples = (int)Math.Round(_sampleRate * _lookAheadTime);
ReallocateDelayBuffers();
}
private void ReallocateDelayBuffers()
{
if (_channels <= 0) return;
_delayBuffers = new float[_channels][];
_delayWriteIndices = new int[_channels];
for (int i = 0; i < _channels; i++)
{
_delayBuffers[i] = _lookAheadSamples > 0 ? new float[_lookAheadSamples] : Array.Empty<float>();
_delayWriteIndices[i] = 0;
}
}
private void UpdateAttackCoef() private void UpdateAttackCoef()
{ {
_attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime)); _attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime));
@@ -155,6 +192,16 @@ namespace ADOFAIRenderer.Util.Audio.Filter
// Convert back to linear gain multiplier // Convert back to linear gain multiplier
float linearGain = (float)Math.Pow(10.0, currentGain / 20.0); float linearGain = (float)Math.Pow(10.0, currentGain / 20.0);
if (_lookAheadSamples > 0 && _delayBuffers.Length > channel && _delayBuffers[channel].Length > 0)
{
var buffer = _delayBuffers[channel];
int writeIdx = _delayWriteIndices[channel];
float delayedSample = buffer[writeIdx];
buffer[writeIdx] = sample;
_delayWriteIndices[channel] = (writeIdx + 1) % _lookAheadSamples;
return delayedSample * linearGain;
}
return sample * linearGain; return sample * linearGain;
} }
@@ -171,6 +218,12 @@ namespace ADOFAIRenderer.Util.Audio.Filter
{ {
Array.Clear(_rmsState, 0, _rmsState.Length); Array.Clear(_rmsState, 0, _rmsState.Length);
Array.Clear(_gainState, 0, _gainState.Length); Array.Clear(_gainState, 0, _gainState.Length);
for (int i = 0; i < _delayBuffers.Length; i++)
{
Array.Clear(_delayBuffers[i], 0, _delayBuffers[i].Length);
}
Array.Clear(_delayWriteIndices, 0, _delayWriteIndices.Length);
} }
} }
} }
+70 -12
View File
@@ -98,6 +98,7 @@ namespace ADOFAIRenderer.Util.Audio
// Clip Cache // Clip Cache
private readonly AudioClipCache _clipCache = new(); private readonly AudioClipCache _clipCache = new();
private bool _isPrimed;
public OfflineAudioMixer(int targetChannels, int targetSampleRate) public OfflineAudioMixer(int targetChannels, int targetSampleRate)
{ {
@@ -113,6 +114,16 @@ namespace ADOFAIRenderer.Util.Audio
rmsCompressor.Initialize(_targetSampleRate, _targetChannels); rmsCompressor.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(rmsCompressor); _filters.AddLast(rmsCompressor);
var limiter = new RMSCompressor
{
ThresholdDb = 0, Ratio = float.PositiveInfinity,
AttackTime = 0f, ReleaseTime = 0.05f,
KneeDb = 10, RmsWindowTime = 0.02f,
LookAheadTime = 0.01f
};
limiter.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(limiter);
var hardClip = new HardClip(); var hardClip = new HardClip();
hardClip.Initialize(_targetSampleRate, _targetChannels); hardClip.Initialize(_targetSampleRate, _targetChannels);
_filters.AddLast(hardClip); _filters.AddLast(hardClip);
@@ -173,11 +184,10 @@ namespace ADOFAIRenderer.Util.Audio
Pitch = pitch Pitch = pitch
}; };
_activeEvents.Add(ev); _activeEvents.Add(ev);
if (source != null)
{ if (!source) return;
_sourceMappings.Remove(source); _sourceMappings.Remove(source);
_sourceMappings.Add(source, ev); _sourceMappings.Add(source, ev);
}
} }
} }
@@ -216,6 +226,38 @@ namespace ADOFAIRenderer.Util.Audio
_bgmSamples = null; _bgmSamples = null;
_bgmDuration = 0; _bgmDuration = 0;
_clipCache.Clear(); _clipCache.Clear();
_isPrimed = false;
foreach (var filter in _filters)
{
filter.Reset();
}
}
}
private void PrimeFilters(double totalLatency, double dspTimeBase)
{
int latencySamples = (int)Math.Round(totalLatency * _targetSampleRate);
if (latencySamples <= 0) return;
// Rent temporary buffer to prime filters with initial audio
float[] tempBuffer = System.Buffers.ArrayPool<float>.Shared.Rent(latencySamples * _targetChannels);
try
{
Array.Clear(tempBuffer, 0, latencySamples * _targetChannels);
// Mix starting from -totalLatency to 0
MixBGM(tempBuffer, latencySamples, -totalLatency, dspTimeBase, totalLatency);
MixEffectSounds(tempBuffer, latencySamples, -totalLatency, totalLatency);
// Feed the buffer into filters to prime circular delay buffers
foreach (var filter in _filters)
{
filter.ProcessBlock(tempBuffer.AsSpan(0, latencySamples * _targetChannels));
}
}
finally
{
System.Buffers.ArrayPool<float>.Shared.Return(tempBuffer);
} }
} }
@@ -226,11 +268,24 @@ namespace ADOFAIRenderer.Util.Audio
lock (_lock) lock (_lock)
{ {
double totalLatency = 0;
foreach (var filter in _filters)
{
totalLatency += filter.Latency;
}
// Prime filters on first frame execution if latency exists
if (!_isPrimed && totalLatency > 0)
{
PrimeFilters(totalLatency, dspTimeBase);
_isPrimed = true;
}
// 1. Mix BGM // 1. Mix BGM
MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase); MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency);
// 2. Mix Hitsounds/Effect Sounds // 2. Mix Hitsounds/Effect Sounds
MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime); MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency);
foreach (var filter in _filters) foreach (var filter in _filters)
filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels)); filter.ProcessBlock(mixedBuffer.AsSpan(0, samplesNeeded * _targetChannels));
@@ -242,7 +297,7 @@ namespace ADOFAIRenderer.Util.Audio
} }
} }
private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase) private void MixBGM(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double dspTimeBase, double totalLatency)
{ {
if (_bgmSamples == null || _bgmSamples.Length == 0) return; if (_bgmSamples == null || _bgmSamples.Length == 0) return;
@@ -258,13 +313,14 @@ namespace ADOFAIRenderer.Util.Audio
// BGM Playback start time relative to dspTimeBase // BGM Playback start time relative to dspTimeBase
var time1 = dspTimeSong + separateCountdownOffset; var time1 = dspTimeSong + separateCountdownOffset;
var relativeTime1 = time1 - dspTimeBase; var relativeTime1 = time1 - dspTimeBase;
var adjustedRelativeTime1 = relativeTime1 - totalLatency;
var totalSamples = _bgmSamples.Length / _targetChannels; var totalSamples = _bgmSamples.Length / _targetChannels;
for (int s = 0; s < samplesNeeded; s++) for (int s = 0; s < samplesNeeded; s++)
{ {
double sampleTime = frameStartTime + (double)s / _targetSampleRate; double sampleTime = frameStartTime + (double)s / _targetSampleRate;
double clipTime = (sampleTime - relativeTime1) * pitch; double clipTime = (sampleTime - adjustedRelativeTime1) * pitch;
if (clipTime >= 0 && clipTime < _bgmDuration) if (clipTime >= 0 && clipTime < _bgmDuration)
{ {
@@ -287,7 +343,7 @@ namespace ADOFAIRenderer.Util.Audio
} }
} }
private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime) private void MixEffectSounds(float[] mixedBuffer, int samplesNeeded, double frameStartTime, double totalLatency)
{ {
for (var i = _activeEvents.Count - 1; i >= 0; i--) for (var i = _activeEvents.Count - 1; i >= 0; i--)
{ {
@@ -301,19 +357,21 @@ namespace ADOFAIRenderer.Util.Audio
var isSoundFinished = false; var isSoundFinished = false;
var totalSamples = clipData.Samples.Length / _targetChannels; var totalSamples = clipData.Samples.Length / _targetChannels;
double adjustedStartTime = ev.StartTime - totalLatency;
double adjustedEndTime = ev.EndTime >= 0 ? ev.EndTime - totalLatency : -1.0;
for (var s = 0; s < samplesNeeded; s++) for (var s = 0; s < samplesNeeded; s++)
{ {
var sampleTime = frameStartTime + (double)s / _targetSampleRate; var sampleTime = frameStartTime + (double)s / _targetSampleRate;
// If it is a looping sound and has reached or exceeded its end time, stop playing. // If it is a looping sound and has reached or exceeded its end time, stop playing.
if (ev.EndTime >= 0 && sampleTime >= ev.EndTime) if (adjustedEndTime >= 0 && sampleTime >= adjustedEndTime)
{ {
isSoundFinished = true; isSoundFinished = true;
break; break;
} }
var elapsed = sampleTime - ev.StartTime; var elapsed = sampleTime - adjustedStartTime;
if (elapsed < 0) continue; // Sound hasn't started yet if (elapsed < 0) continue; // Sound hasn't started yet
var soundTime = elapsed * ev.Pitch; var soundTime = elapsed * ev.Pitch;