SHA256
Document Audio Pipeline
This commit is contained in:
1 parent
7bb47e4974
commit
19d0e258ac
22 files changed
+634
-12
No files matched your search
@@ -26,7 +26,7 @@ namespace ADOFAIRenderer.Util.Audio;
|
||||
/// </summary>
|
||||
internal sealed class OfflineAudioMixer : IDisposable
|
||||
{
|
||||
/// <summary>Gain applied to every clip.</summary>
|
||||
/// <summary>Linear gain applied to every clip (song and effects), before the filter chain (0.5 ≈ -6 dB headroom).</summary>
|
||||
private const double ClipVolume = 0.5;
|
||||
|
||||
/// <summary>Additional gain of effect sounds (registered with <see cref="RegisterSoundEvent"/>).</summary>
|
||||
@@ -41,21 +41,48 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
/// <summary>Longest wait for a commit before checking for cancellation again, in milliseconds.</summary>
|
||||
private const int CommitWaitMs = 50;
|
||||
|
||||
/// <summary>Output channel count (interleaved).</summary>
|
||||
private readonly int _channels;
|
||||
|
||||
/// <summary>Output sample rate in Hz.</summary>
|
||||
private readonly int _sampleRate;
|
||||
|
||||
private readonly AudioClipStore _store;
|
||||
private readonly VoiceMixer _mixer;
|
||||
|
||||
/// <summary>Canceled by <see cref="Cancel"/>/<see cref="Dispose"/>; ends commit waits and chunk waits on the render thread.</summary>
|
||||
private readonly CancellationTokenSource _cancellation = new();
|
||||
|
||||
/// <summary>Filter chain applied in list order to every mixed block.</summary>
|
||||
private readonly List<IAudioFilter> _filters = new();
|
||||
|
||||
/// <summary>Sum of the filter latencies in output samples: round(sum of <see cref="IAudioFilter.Latency"/> * sample rate).</summary>
|
||||
private readonly int _latencySamples;
|
||||
|
||||
// Main thread.
|
||||
|
||||
/// <summary>
|
||||
/// Mapping of game AudioSources to the state of the effect sound last started on them. Weak keys: a destroyed source
|
||||
/// does not keep its state alive. Replaced by a new table to drop every mapping at once.
|
||||
/// </summary>
|
||||
private ConditionalWeakTable<AudioSource, SourceState> _sourceStates = new();
|
||||
|
||||
/// <summary>Effect voices started since the last <see cref="StopAllEffects"/> (finished ones are pruned periodically).</summary>
|
||||
private readonly List<Voice> _effectVoices = new();
|
||||
|
||||
/// <summary>Count of <see cref="_effectVoices"/> at which the next prune runs.</summary>
|
||||
private int _effectVoicePruneAt = EffectVoicePruneCount;
|
||||
|
||||
/// <summary>Song source, or null when there is no song or it could not be loaded.</summary>
|
||||
private AudioClipSource? _bgmSource;
|
||||
|
||||
/// <summary>audioLib key (AudioClip.name) of the song.</summary>
|
||||
private string _bgmKey = "";
|
||||
|
||||
/// <summary>Current song voice, created by the first <see cref="UpdateBGM"/> and replaced when the start time changes.</summary>
|
||||
private Voice? _bgmVoice;
|
||||
|
||||
// Values last sent to the song voice (start in seconds relative to the render start); NaN before the first update.
|
||||
private double _bgmStart = double.NaN;
|
||||
private double _bgmPitch = double.NaN;
|
||||
private double _bgmVolume = double.NaN;
|
||||
@@ -66,11 +93,26 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
|
||||
// Render thread (and Dispose, under _renderLock).
|
||||
private readonly object _renderLock = new();
|
||||
|
||||
/// <summary>Index of the next output sample <see cref="GenerateAudioForFrame"/> writes (frames per channel since the render start).</summary>
|
||||
private long _nextOutputSample;
|
||||
|
||||
/// <summary>True once the filters have been fed samples [0, latency).</summary>
|
||||
private bool _primed;
|
||||
|
||||
/// <summary>Reused interleaved mix buffer, grown to the largest block requested.</summary>
|
||||
private double[] _mixBuffer = Array.Empty<double>();
|
||||
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Creates the clip store (and starts its decoder thread), the voice mixer and the filter chain.
|
||||
/// Filter settings: RMS compressor (threshold -10 dB, ratio 2, attack/release 0.1 s, knee 10 dB, RMS window 0.02 s),
|
||||
/// limiter (RMS compressor with threshold 0 dB, infinite ratio, attack 0 raised to the 0.0001 s minimum, release 0.05 s,
|
||||
/// hard knee, RMS window 0.02 s, look-ahead 0.01 s), hard clip at ±1.0, then -1 dB gain.
|
||||
/// </summary>
|
||||
/// <param name="targetChannels">Output channel count (interleaved).</param>
|
||||
/// <param name="targetSampleRate">Output sample rate in Hz; every clip is resampled to it.</param>
|
||||
public OfflineAudioMixer(int targetChannels, int targetSampleRate)
|
||||
{
|
||||
_channels = targetChannels;
|
||||
@@ -96,6 +138,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
_filters.Add(new HardClip());
|
||||
_filters.Add(new Gain { Level = -1 });
|
||||
|
||||
// Total delay of the chain; only the limiter's look-ahead contributes (0.01 s).
|
||||
double latency = 0;
|
||||
foreach (var filter in _filters)
|
||||
{
|
||||
@@ -148,6 +191,8 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
double pitch = conductor.song.pitch;
|
||||
var countdown = conductor.separateCountdownTime ? conductor.crotchetAtStart / pitch * conductor.adjustedCountdownTicks : 0.0;
|
||||
var start = conductor.dspTimeSong + countdown - dspTimeBase;
|
||||
// levelData.volume is a percentage (100 = unchanged; scrConductor sets song.volume = volume * 0.01 the same way).
|
||||
// Without a scnGame instance the level volume is 1.0.
|
||||
var levelVolume = scnGame.instance ? scnGame.instance.levelData.volume * 0.01 : 1.0;
|
||||
var volume = levelVolume * ClipVolume;
|
||||
|
||||
@@ -181,6 +226,11 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
GetOrAddSource(clipName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Source of the game clip <paramref name="clipName"/> (audioLib key): an existing source, or one opened through
|
||||
/// <see cref="AudioClipResolver"/> with the clip from AudioManager.FindOrLoadAudioClip. Null for an empty name or an
|
||||
/// unreadable clip (the key is then remembered as missing by the store). Main thread only.
|
||||
/// </summary>
|
||||
private AudioClipSource? GetOrAddSource(string clipName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(clipName)) return null;
|
||||
@@ -193,6 +243,14 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
/// Starts an effect sound at <paramref name="startTime"/>. An <paramref name="endTime"/> of 0 or more makes it loop until then.
|
||||
/// <paramref name="source"/> (when given) is mapped to the returned state, which reports and applies later changes through the AudioSource.
|
||||
/// </summary>
|
||||
/// <param name="source">Game AudioSource playing the sound, or null to map it later with <see cref="MapSource"/>.</param>
|
||||
/// <param name="clipName">audioLib key of the clip. A clip that cannot be loaded gives a silent voice.</param>
|
||||
/// <param name="startTime">Scheduled start, seconds relative to the render start (a start before <paramref name="now"/> starts at now).</param>
|
||||
/// <param name="endTime">Loop end, seconds relative to the render start; negative for a sound that plays once.</param>
|
||||
/// <param name="volume">Game volume (linear); multiplied by the clip and effect gains.</param>
|
||||
/// <param name="pitch">Playback rate at the start.</param>
|
||||
/// <param name="now">Current render time in seconds.</param>
|
||||
/// <returns>State of the new sound, also used by the AudioSource patches.</returns>
|
||||
public SourceState RegisterSoundEvent(AudioSource? source, string clipName, double startTime, double endTime, float volume, float pitch, double now)
|
||||
{
|
||||
var clipSource = GetOrAddSource(clipName);
|
||||
@@ -200,6 +258,7 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
var voice = _mixer.Play(clipSource, clipName, startTime, volume * ClipVolume * EffectVolume, pitch, loop, now);
|
||||
if (loop) voice.SetEndTime(endTime, now);
|
||||
|
||||
// Amortized pruning: the list is scanned only when it has doubled since the last prune.
|
||||
if (_effectVoices.Count >= _effectVoicePruneAt)
|
||||
{
|
||||
_effectVoices.RemoveAll(v => v.IsFinished);
|
||||
@@ -269,7 +328,10 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.</summary>
|
||||
/// <summary>
|
||||
/// Stops waiting for commits and chunks; the render thread's current or next mix throws OperationCanceledException.
|
||||
/// May be called from any thread, more than once.
|
||||
/// </summary>
|
||||
public void Cancel()
|
||||
{
|
||||
_cancellation.Cancel();
|
||||
@@ -280,7 +342,10 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
|
||||
/// <summary>
|
||||
/// Writes the next <paramref name="samplesNeeded"/> output frames (interleaved float, continuing from the previous call).
|
||||
/// Blocks until the main thread has committed the voice samples it needs (<see cref="Commit"/>, <see cref="Complete"/>).
|
||||
/// </summary>
|
||||
/// <param name="mixedBuffer">Destination; must hold at least samplesNeeded * channels floats. Values beyond that are untouched.</param>
|
||||
/// <param name="samplesNeeded">Frames (samples per channel) to write.</param>
|
||||
/// <exception cref="InvalidOperationException">Chunk loading failed fatally (out of memory or the decoder thread stopped).</exception>
|
||||
/// <exception cref="OperationCanceledException">The render was canceled (<see cref="Cancel"/>).</exception>
|
||||
public void GenerateAudioForFrame(float[] mixedBuffer, int samplesNeeded)
|
||||
@@ -289,6 +354,8 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(OfflineAudioMixer));
|
||||
|
||||
// First call: run voice samples [0, latency) through the filters and discard the output, which is the
|
||||
// zero-filled delay lines. Afterwards output sample n is the filtered voice sample n.
|
||||
if (!_primed)
|
||||
{
|
||||
_primed = true;
|
||||
@@ -323,6 +390,11 @@ internal sealed class OfflineAudioMixer : IDisposable
|
||||
return block;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the committed sample reaches <paramref name="endSample"/> (exclusive end of the block to mix), checking for
|
||||
/// cancellation every <see cref="CommitWaitMs"/> ms.
|
||||
/// </summary>
|
||||
/// <exception cref="OperationCanceledException">The render was canceled.</exception>
|
||||
private void WaitForCommit(long endSample)
|
||||
{
|
||||
lock (_commitLock)
|
||||
|
||||
Reference in new issue
Block a user