SHA256
Add Look Ahead to Audio Filter
This commit is contained in:
5 files changed
+129
-12
No files matched your search
@@ -18,5 +18,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Reset() { }
|
public void Reset() { }
|
||||||
|
|
||||||
|
public double Latency => 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,5 +27,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter
|
|||||||
}
|
}
|
||||||
|
|
||||||
public void Reset() { }
|
public void Reset() { }
|
||||||
|
|
||||||
|
public double Latency => 0.0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user