Document Audio Pipeline

This commit is contained in:
copecone committed 2026-10-15 16:17:12 +09:00
1 parent 011c749df4
commit 131fabdbe1
22 files changed
+634 -12

No files matched your search

+37 -1
View File
@@ -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);
+33 -1
View File
@@ -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;