diff --git a/Util/Audio/Filter/Gain.cs b/Util/Audio/Filter/Gain.cs index 645978c..23ab42a 100644 --- a/Util/Audio/Filter/Gain.cs +++ b/Util/Audio/Filter/Gain.cs @@ -18,5 +18,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter } public void Reset() { } + + public double Latency => 0.0; } } \ No newline at end of file diff --git a/Util/Audio/Filter/HardClip.cs b/Util/Audio/Filter/HardClip.cs index b8ee6f0..eaaeff1 100644 --- a/Util/Audio/Filter/HardClip.cs +++ b/Util/Audio/Filter/HardClip.cs @@ -27,5 +27,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter } public void Reset() { } + + public double Latency => 0.0; } } \ No newline at end of file diff --git a/Util/Audio/Filter/IAudioFilter.cs b/Util/Audio/Filter/IAudioFilter.cs index 1504d23..069cf1e 100644 --- a/Util/Audio/Filter/IAudioFilter.cs +++ b/Util/Audio/Filter/IAudioFilter.cs @@ -10,5 +10,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter void ProcessBlock(Span buffer); void Reset(); + + double Latency { get; } } } \ No newline at end of file diff --git a/Util/Audio/Filter/RMSCompressor.cs b/Util/Audio/Filter/RMSCompressor.cs index 1cbd6c3..107834d 100644 --- a/Util/Audio/Filter/RMSCompressor.cs +++ b/Util/Audio/Filter/RMSCompressor.cs @@ -12,6 +12,8 @@ namespace ADOFAIRenderer.Util.Audio.Filter private float _releaseTime = 0.100f; // in seconds private float _kneeDb = 5f; // in dB private float _rmsWindowTime = 0.030f; // in seconds + private float _lookAheadTime; // in seconds + private int _lookAheadSamples; private int _sampleRate = 44100; private int _channels = 2; @@ -23,6 +25,20 @@ namespace ADOFAIRenderer.Util.Audio.Filter // Per-channel state private float[] _rmsState = Array.Empty(); // running mean-square (MS) state private float[] _gainState = Array.Empty(); // smoothed gain reduction in dB + private float[][] _delayBuffers = Array.Empty(); // circular delay buffers + private int[] _delayWriteIndices = Array.Empty(); + + public float LookAheadTime + { + get => _lookAheadTime; + set + { + _lookAheadTime = Math.Max(0f, Math.Min(1.0f, value)); + UpdateLookAheadSettings(); + } + } + + public double Latency => _lookAheadTime; public float ThresholdDb { @@ -80,6 +96,7 @@ namespace ADOFAIRenderer.Util.Audio.Filter UpdateAttackCoef(); UpdateReleaseCoef(); UpdateRmsCoef(); + UpdateLookAheadSettings(); _rmsState = new float[channels]; _gainState = new float[channels]; @@ -87,6 +104,26 @@ namespace ADOFAIRenderer.Util.Audio.Filter 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(); + _delayWriteIndices[i] = 0; + } + } + private void UpdateAttackCoef() { _attackCoef = (float)Math.Exp(-1.0 / (_sampleRate * _attackTime)); @@ -155,6 +192,16 @@ namespace ADOFAIRenderer.Util.Audio.Filter // Convert back to linear gain multiplier 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; } @@ -171,6 +218,12 @@ namespace ADOFAIRenderer.Util.Audio.Filter { Array.Clear(_rmsState, 0, _rmsState.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); } } } diff --git a/Util/Audio/OfflineAudioMixer.cs b/Util/Audio/OfflineAudioMixer.cs index 0912d60..287da5b 100644 --- a/Util/Audio/OfflineAudioMixer.cs +++ b/Util/Audio/OfflineAudioMixer.cs @@ -98,6 +98,7 @@ namespace ADOFAIRenderer.Util.Audio // Clip Cache private readonly AudioClipCache _clipCache = new(); + private bool _isPrimed; public OfflineAudioMixer(int targetChannels, int targetSampleRate) { @@ -113,6 +114,16 @@ namespace ADOFAIRenderer.Util.Audio rmsCompressor.Initialize(_targetSampleRate, _targetChannels); _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(); hardClip.Initialize(_targetSampleRate, _targetChannels); _filters.AddLast(hardClip); @@ -173,11 +184,10 @@ namespace ADOFAIRenderer.Util.Audio Pitch = pitch }; _activeEvents.Add(ev); - if (source != null) - { - _sourceMappings.Remove(source); - _sourceMappings.Add(source, ev); - } + + if (!source) return; + _sourceMappings.Remove(source); + _sourceMappings.Add(source, ev); } } @@ -216,6 +226,38 @@ namespace ADOFAIRenderer.Util.Audio _bgmSamples = null; _bgmDuration = 0; _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.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.Shared.Return(tempBuffer); } } @@ -226,11 +268,24 @@ namespace ADOFAIRenderer.Util.Audio 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 - MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase); + MixBGM(mixedBuffer, samplesNeeded, frameStartTime, dspTimeBase, totalLatency); // 2. Mix Hitsounds/Effect Sounds - MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime); + MixEffectSounds(mixedBuffer, samplesNeeded, frameStartTime, totalLatency); foreach (var filter in _filters) 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; @@ -258,13 +313,14 @@ namespace ADOFAIRenderer.Util.Audio // BGM Playback start time relative to dspTimeBase var time1 = dspTimeSong + separateCountdownOffset; var relativeTime1 = time1 - dspTimeBase; + var adjustedRelativeTime1 = relativeTime1 - totalLatency; var totalSamples = _bgmSamples.Length / _targetChannels; for (int s = 0; s < samplesNeeded; s++) { double sampleTime = frameStartTime + (double)s / _targetSampleRate; - double clipTime = (sampleTime - relativeTime1) * pitch; + double clipTime = (sampleTime - adjustedRelativeTime1) * pitch; 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--) { @@ -301,19 +357,21 @@ namespace ADOFAIRenderer.Util.Audio var isSoundFinished = false; 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++) { var sampleTime = frameStartTime + (double)s / _targetSampleRate; // 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; break; } - var elapsed = sampleTime - ev.StartTime; + var elapsed = sampleTime - adjustedStartTime; if (elapsed < 0) continue; // Sound hasn't started yet var soundTime = elapsed * ev.Pitch;