SHA256
Document Audio Pipeline
This commit is contained in:
1 parent
7bb47e4974
commit
19d0e258ac
22 files changed
+634
-12
No files matched your search
@@ -10,7 +10,7 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||
///
|
||||
/// Main thread: the Set methods queue a change that takes effect at a given time (seconds relative to the render start,
|
||||
/// the time at which the game made the change). Changes of one voice must be queued in non-decreasing time order.
|
||||
/// Render thread: <see cref="VoiceMixer.Mix"/> applies each change at sample round(time * OutputRate).
|
||||
/// Render thread: <see cref="VoiceMixer.Mix"/> applies each change at sample floor(time * OutputRate) (<see cref="VoiceMixer.ToSample"/>).
|
||||
///
|
||||
/// Playback model (output samples n, clip positions in frames of the source at the output sample rate):
|
||||
/// - The voice is silent before its start sample and from its end sample on.
|
||||
@@ -21,20 +21,37 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||
/// </summary>
|
||||
internal sealed class Voice
|
||||
{
|
||||
/// <summary>Kind of a queued <see cref="Change"/>; the meaning of <see cref="Change.Value"/> depends on it.</summary>
|
||||
internal enum ChangeKind
|
||||
{
|
||||
/// <summary>Value: playback rate, at least 0.</summary>
|
||||
Pitch,
|
||||
|
||||
/// <summary>Value: linear gain.</summary>
|
||||
Volume,
|
||||
|
||||
/// <summary>Value: 1 = loop on, 0 = loop off.</summary>
|
||||
Loop,
|
||||
|
||||
/// <summary>Value: clip position in seconds, at least 0.</summary>
|
||||
Position,
|
||||
|
||||
/// <summary>Value: end time in seconds relative to the render start.</summary>
|
||||
EndTime,
|
||||
|
||||
/// <summary>Value: unused (0). Ends playback at the sample the change is applied at.</summary>
|
||||
Stop
|
||||
}
|
||||
|
||||
/// <summary>One timed change of a voice, queued by the main thread and applied by the render thread.</summary>
|
||||
internal readonly struct Change
|
||||
{
|
||||
public readonly ChangeKind Kind;
|
||||
|
||||
/// <summary>Output sample at which the change takes effect (<see cref="VoiceMixer.ToSample"/> of the change time).</summary>
|
||||
public readonly long Sample;
|
||||
|
||||
/// <summary>Argument of the change (see <see cref="ChangeKind"/>).</summary>
|
||||
public readonly double Value;
|
||||
|
||||
public Change(ChangeKind kind, long sample, double value)
|
||||
@@ -45,6 +62,7 @@ internal sealed class Voice
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Mixer that owns this voice; the Set methods queue their changes on it.</summary>
|
||||
private readonly VoiceMixer _mixer;
|
||||
|
||||
// ── Render thread state ──
|
||||
@@ -58,11 +76,22 @@ internal sealed class Voice
|
||||
/// <summary>Output sample at which playback stops (exclusive); long.MaxValue when not set.</summary>
|
||||
internal long EndSample = long.MaxValue;
|
||||
|
||||
/// <summary>Output sample of the current anchor (see the class summary).</summary>
|
||||
internal long AnchorSample;
|
||||
|
||||
/// <summary>Clip position at <see cref="AnchorSample"/>, in frames at the output sample rate (may be fractional).</summary>
|
||||
internal double AnchorPosition;
|
||||
|
||||
/// <summary>Clip frames advanced per output sample (1 = original speed), at least 0.</summary>
|
||||
internal double Pitch;
|
||||
|
||||
/// <summary>Linear gain applied to the clip samples.</summary>
|
||||
internal double Volume;
|
||||
|
||||
/// <summary>True while the clip repeats at <see cref="Period"/>.</summary>
|
||||
internal bool Loop;
|
||||
|
||||
/// <summary>Set by the render thread when playback has ended; the voice is then released and removed from the mixer.</summary>
|
||||
internal bool Finished;
|
||||
|
||||
/// <summary>
|
||||
@@ -74,7 +103,10 @@ internal sealed class Voice
|
||||
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
|
||||
internal double Period;
|
||||
|
||||
/// <summary>Read position registered on <see cref="Source"/> so the decoder thread keeps the chunks ahead loaded; null without a usable source.</summary>
|
||||
internal AudioCursor? Cursor;
|
||||
|
||||
/// <summary>Chunk of <see cref="Source"/> currently held for reading (invalid when none). Render thread only.</summary>
|
||||
internal ChunkView View;
|
||||
|
||||
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
|
||||
@@ -128,6 +160,9 @@ internal sealed class Voice
|
||||
/// </summary>
|
||||
internal void Apply(in Change change, long sample, int outputRate)
|
||||
{
|
||||
// Anchor changes made before the start take effect at the start sample (playback starts from the new state).
|
||||
// EndTime uses the change's own value; Stop ends at the sample it is applied at, so a Stop before the start
|
||||
// ends the voice before it plays.
|
||||
var at = Math.Max(sample, StartSample);
|
||||
switch (change.Kind)
|
||||
{
|
||||
@@ -155,6 +190,7 @@ internal sealed class Voice
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Moves the anchor to <paramref name="sample"/>, keeping the (wrapped) position there, before a pitch or loop change.</summary>
|
||||
private void Reanchor(long sample)
|
||||
{
|
||||
AnchorPosition = WrappedPositionAt(sample);
|
||||
|
||||
@@ -9,7 +9,7 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||
/// Mixes every <see cref="Voice"/> into interleaved double samples at the output sample rate and channel count.
|
||||
///
|
||||
/// Time: output sample n is at n / OutputRate seconds relative to the render start (the same time base as the times passed
|
||||
/// to <see cref="Play"/> and the Voice Set methods). A time t maps to sample round(t * OutputRate).
|
||||
/// to <see cref="Play"/> and the Voice Set methods). A time t maps to sample floor(t * OutputRate) (<see cref="ToSample"/>).
|
||||
///
|
||||
/// Threads:
|
||||
/// - Main thread: <see cref="Play"/> and the Voice Set methods queue commands under a lock. Commands must be queued in
|
||||
@@ -25,16 +25,23 @@ namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||
/// </summary>
|
||||
internal sealed class VoiceMixer : IDisposable
|
||||
{
|
||||
/// <summary>Kind of a queued main-thread command.</summary>
|
||||
private enum CommandKind
|
||||
{
|
||||
/// <summary>Adds a new voice to the mixed set (created by <see cref="VoiceMixer.Play"/>).</summary>
|
||||
Play,
|
||||
|
||||
/// <summary>Appends a <see cref="Voice.Change"/> to the voice's pending queue.</summary>
|
||||
Change
|
||||
}
|
||||
|
||||
/// <summary>Command queued by the main thread and taken by the render thread at the start of <see cref="Mix"/>.</summary>
|
||||
private readonly struct Command
|
||||
{
|
||||
public readonly CommandKind Kind;
|
||||
public readonly Voice Voice;
|
||||
|
||||
/// <summary>The change for <see cref="CommandKind.Change"/>; default for <see cref="CommandKind.Play"/>.</summary>
|
||||
public readonly Voice.Change Change;
|
||||
|
||||
public Command(CommandKind kind, Voice voice, Voice.Change change)
|
||||
@@ -48,6 +55,8 @@ internal sealed class VoiceMixer : IDisposable
|
||||
private readonly AudioClipStore _store;
|
||||
private readonly CancellationToken _cancellationToken;
|
||||
|
||||
// Double-buffered command lists: the main thread appends to _commands under _commandLock; the render thread swaps the
|
||||
// two lists under the lock and processes _takenCommands outside it, then clears it for the next swap.
|
||||
private readonly object _commandLock = new();
|
||||
private List<Command> _commands = new();
|
||||
private List<Command> _takenCommands = new();
|
||||
@@ -68,8 +77,10 @@ internal sealed class VoiceMixer : IDisposable
|
||||
_cancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
/// <summary>Output sample rate in Hz (the store's <see cref="AudioClipStore.OutputRate"/>).</summary>
|
||||
public int OutputRate { get; }
|
||||
|
||||
/// <summary>Interleaved channel count of the mixed output (1 or more).</summary>
|
||||
public int OutputChannels { get; }
|
||||
|
||||
/// <summary>Voices currently mixed (started or waiting to start). Render thread only.</summary>
|
||||
@@ -113,6 +124,10 @@ internal sealed class VoiceMixer : IDisposable
|
||||
return voice;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Queues a change of <paramref name="voice"/> that takes effect at sample <see cref="ToSample"/>(<paramref name="time"/>).
|
||||
/// Called by the Voice Set methods on the main thread.
|
||||
/// </summary>
|
||||
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
|
||||
{
|
||||
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
|
||||
@@ -138,10 +153,13 @@ internal sealed class VoiceMixer : IDisposable
|
||||
|
||||
TakeCommands();
|
||||
|
||||
// Iterated backwards so finished voices can be removed in place.
|
||||
for (var i = _voices.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var voice = _voices[i];
|
||||
var sample = firstSample;
|
||||
// Split the block into segments at the samples of pending changes: each segment is mixed with constant
|
||||
// voice state, and a change is applied when the segment reaches its sample (or at once when its sample is past).
|
||||
while (!voice.Finished)
|
||||
{
|
||||
var segmentEnd = endSample;
|
||||
@@ -162,6 +180,8 @@ internal sealed class VoiceMixer : IDisposable
|
||||
if (sample >= endSample) break;
|
||||
}
|
||||
|
||||
// The end sample is inside or before this block: nothing more will be mixed. Changes still pending for this voice
|
||||
// (after the end) are dropped with it.
|
||||
if (!voice.Finished && endSample >= voice.EndSample)
|
||||
voice.Finished = true;
|
||||
if (voice.Finished)
|
||||
@@ -172,6 +192,10 @@ internal sealed class VoiceMixer : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Takes every queued command: Play adds the voice (with a cursor at frame 0 when its source is usable), Change appends to
|
||||
/// the voice's pending queue (dropped for finished voices). Render thread only.
|
||||
/// </summary>
|
||||
private void TakeCommands()
|
||||
{
|
||||
lock (_commandLock)
|
||||
@@ -203,6 +227,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
var end = Math.Min(to, voice.EndSample);
|
||||
if (start >= end) return;
|
||||
|
||||
// A voice whose source is missing or failed is silent; it finishes at its first segment inside [start, end).
|
||||
var source = voice.Source;
|
||||
if (source == null || source.IsFailed)
|
||||
{
|
||||
@@ -222,6 +247,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
var volume = voice.Volume;
|
||||
var offset = (int)(start - from) * channels;
|
||||
|
||||
// Move the cursor to the segment start so the decoder thread keeps the chunks ahead of it loaded.
|
||||
UpdateCursor(voice, (long)voice.WrappedPositionAt(start));
|
||||
|
||||
for (var n = start; n < end; n++, offset += channels)
|
||||
@@ -234,6 +260,8 @@ internal sealed class VoiceMixer : IDisposable
|
||||
voice.Finished = true;
|
||||
return;
|
||||
}
|
||||
// Looping: a wrapped position is below Period, and Period (Duration * rate) is at most TotalFrames
|
||||
// (TotalFrames rounds up), so this is a guard that reads the last frame.
|
||||
position = totalFrames - 1;
|
||||
}
|
||||
|
||||
@@ -243,6 +271,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
if (!EnsureView(voice, index)) return;
|
||||
var view = voice.View;
|
||||
|
||||
// Integer position (pitch 1 with an integer anchor): copy the stored sample.
|
||||
if (fraction == 0.0)
|
||||
{
|
||||
for (var c = 0; c < channels; c++)
|
||||
@@ -250,6 +279,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
// Linear interpolation; the next frame may lie in the following chunk, at the loop start or past the end.
|
||||
var nextIndex = index + 1;
|
||||
var nextInView = view.Contains(nextIndex);
|
||||
for (var c = 0; c < channels; c++)
|
||||
@@ -303,6 +333,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Moves the voice's cursor to <paramref name="frame"/> and copies the voice's loop flag (writes only on change).</summary>
|
||||
private static void UpdateCursor(Voice voice, long frame)
|
||||
{
|
||||
var cursor = voice.Cursor;
|
||||
@@ -311,6 +342,7 @@ internal sealed class VoiceMixer : IDisposable
|
||||
if (cursor.Position != frame) cursor.Position = frame;
|
||||
}
|
||||
|
||||
/// <summary>Releases the voice's held chunk and cursor and drops its pending changes. Render thread only.</summary>
|
||||
private static void ReleaseVoice(Voice voice)
|
||||
{
|
||||
var source = voice.Source;
|
||||
|
||||
Reference in new issue
Block a user