using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
///
/// Mixes every 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 and the Voice Set methods). A time t maps to sample floor(t * OutputRate) ().
///
/// Threads:
/// - Main thread: and the Voice Set methods queue commands under a lock. Commands must be queued in
/// non-decreasing time order per voice.
/// - Render thread: takes the queued commands and mixes a block of samples. A command takes effect at its sample;
/// a command whose sample is before the block being mixed takes effect at the start of that block.
/// Chunks are read through , which waits for the decoder thread.
///
/// Sample reading: clip positions are in frames of the source at the output rate (sources are resampled when loaded).
/// An integer position copies the stored sample; a fractional position interpolates linearly between the frame and the next one
/// (the next frame of a looping voice after the last frame is frame 0; after the end of a non-looping clip it is 0).
/// Channel mapping follows .
///
internal sealed class VoiceMixer : IDisposable
{
/// Kind of a queued main-thread command.
private enum CommandKind
{
/// Adds a new voice to the mixed set (created by ).
Play,
/// Appends a to the voice's pending queue.
Change
}
/// Command queued by the main Unity thread and taken by the render thread at the start of .
private readonly struct Command
{
public readonly CommandKind Kind;
public readonly Voice Voice;
/// The change for ; default for .
public readonly Voice.Change Change;
public Command(CommandKind kind, Voice voice, Voice.Change change)
{
Kind = kind;
Voice = voice;
Change = change;
}
}
private readonly AudioClipStore _store;
private readonly CancellationToken _cancellationToken;
// Double-buffered command lists: the main Unity 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 _commands = new();
private List _takenCommands = new();
// Render thread only.
private readonly List _voices = new();
private bool _disposed;
/// Store the voices' sources belong to; its OutputRate is the mixer's sample rate.
/// Interleaved channel count of the mixed output.
/// Cancels waits for chunks in .
public VoiceMixer(AudioClipStore store, int outputChannels, CancellationToken cancellationToken = default)
{
if (outputChannels < 1) throw new ArgumentOutOfRangeException(nameof(outputChannels));
_store = store;
OutputRate = store.OutputRate;
OutputChannels = outputChannels;
_cancellationToken = cancellationToken;
}
/// Output sample rate in Hz (the store's ).
public int OutputRate { get; }
/// Interleaved channel count of the mixed output (1 or more).
public int OutputChannels { get; }
/// Voices currently mixed (started or waiting to start). Render thread only.
public int ActiveVoiceCount => _voices.Count;
///
/// Output sample containing (seconds × rate rounded down), the sample at which
/// AudioSource.PlayScheduled starts a sound scheduled between two output samples.
///
public static long ToSample(double seconds, int rate) => (long)Math.Floor(seconds * rate);
// ── Main thread ──
///
/// Schedules to start at .
/// A start time before (the current time) starts the voice at .
///
/// Clip to play; null plays silence.
/// Name for logs.
/// Start of playback, seconds relative to the render start.
/// Linear gain.
/// Playback rate (1 = original speed); negative values are treated as 0.
/// Repeats the clip until the voice is stopped.
/// Current time, seconds relative to the render start.
public Voice Play(AudioClipSource? source, string name, double startTime, double volume, double pitch, bool loop, double time)
{
var voice = new Voice(this, source, name)
{
StartSample = Math.Max(ToSample(startTime, OutputRate), ToSample(time, OutputRate)),
Volume = volume,
Pitch = Math.Max(0.0, pitch),
Loop = loop,
Period = source != null ? source.Duration * OutputRate : 0.0
};
voice.AnchorSample = voice.StartSample;
lock (_commandLock)
{
_commands.Add(new Command(CommandKind.Play, voice, default));
}
return voice;
}
///
/// Queues a change of that takes effect at sample ().
/// Called by the Voice Set methods on the main Unity thread.
///
internal void Enqueue(Voice voice, Voice.ChangeKind kind, double time, double value)
{
var change = new Voice.Change(kind, ToSample(time, OutputRate), value);
lock (_commandLock)
{
_commands.Add(new Command(CommandKind.Change, voice, change));
}
}
// ── Render thread ──
///
/// Adds the mix of output samples [, firstSample + frames) to
/// (interleaved, frames = destination.Length / OutputChannels). Blocks are expected in increasing sample order.
///
/// Chunk loading failed fatally (see ).
/// The cancellation token was canceled while waiting for a chunk.
public void Mix(long firstSample, Span destination)
{
if (_disposed) throw new ObjectDisposedException(nameof(VoiceMixer));
var frames = destination.Length / OutputChannels;
var endSample = firstSample + frames;
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;
if (voice.Pending.Count > 0)
{
var next = voice.Pending.Peek();
if (next.Sample <= sample)
{
voice.Apply(voice.Pending.Dequeue(), sample, OutputRate);
continue;
}
segmentEnd = Math.Min(segmentEnd, next.Sample);
}
if (segmentEnd > sample)
MixSegment(voice, sample, segmentEnd, destination.Slice((int)(sample - firstSample) * OutputChannels));
sample = segmentEnd;
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)
{
ReleaseVoice(voice);
_voices.RemoveAt(i);
}
}
}
///
/// 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.
///
private void TakeCommands()
{
lock (_commandLock)
{
(_commands, _takenCommands) = (_takenCommands, _commands);
}
foreach (var command in _takenCommands)
{
var voice = command.Voice;
if (command.Kind == CommandKind.Play)
{
if (voice.Source is { IsFailed: false } source)
voice.Cursor = source.AddCursor(0, voice.Loop);
_voices.Add(voice);
}
else if (!voice.Finished)
{
voice.Pending.Enqueue(command.Change);
}
}
_takenCommands.Clear();
}
/// Mixes over samples [from, to) with its current state into (starting at sample from).
private void MixSegment(Voice voice, long from, long to, Span destination)
{
var start = Math.Max(from, voice.StartSample);
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)
{
voice.Finished = true;
return;
}
var totalFrames = source.TotalFrames;
if (voice.Volume == 0.0)
{
// Nothing to add; a non-looping voice still ends at the end of the clip.
if (!voice.Loop && voice.PositionAt(end - 1) >= totalFrames) voice.Finished = true;
return;
}
var channels = OutputChannels;
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)
{
var position = voice.WrappedPositionAt(n);
if (position >= totalFrames)
{
if (!voice.Loop)
{
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;
}
var index = (long)position;
var fraction = position - index;
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++)
destination[offset + c] += view.GetSample(index, c, channels) * volume;
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++)
{
double low = view.GetSample(index, c, channels);
double high = nextInView ? view.GetSample(nextIndex, c, channels) : ReadOutside(voice, nextIndex, c);
destination[offset + c] += (low + fraction * (high - low)) * volume;
}
}
}
/// Sample of frame outside the voice's current view (next chunk, loop start, or past the end).
private double ReadOutside(Voice voice, long frame, int channel)
{
var source = voice.Source!;
if (frame >= source.TotalFrames)
{
if (!voice.Loop) return 0.0;
frame = 0;
}
var view = _store.Acquire(source, frame, _cancellationToken);
if (!view.IsValid) return 0.0;
try
{
return view.GetSample(frame, channel, OutputChannels);
}
finally
{
source.Release(view);
}
}
/// Makes voice.View the chunk containing . False when the source cannot provide it (voice finished).
private bool EnsureView(Voice voice, long frame)
{
if (voice.View.IsValid && voice.View.Contains(frame)) return true;
var source = voice.Source!;
if (voice.View.IsValid)
{
source.Release(voice.View);
voice.View = default;
}
UpdateCursor(voice, frame);
voice.View = _store.Acquire(source, frame, _cancellationToken);
if (voice.View.IsValid) return true;
voice.Finished = true;
return false;
}
/// Moves the voice's cursor to and copies the voice's loop flag (writes only on change).
private static void UpdateCursor(Voice voice, long frame)
{
var cursor = voice.Cursor;
if (cursor == null) return;
if (cursor.Loop != voice.Loop) cursor.Loop = voice.Loop;
if (cursor.Position != frame) cursor.Position = frame;
}
/// Releases the voice's held chunk and cursor and drops its pending changes. Render thread only.
private static void ReleaseVoice(Voice voice)
{
var source = voice.Source;
if (source == null) return;
if (voice.View.IsValid)
{
source.Release(voice.View);
voice.View = default;
}
if (voice.Cursor != null)
{
source.RemoveCursor(voice.Cursor);
voice.Cursor = null;
}
voice.Pending.Clear();
}
/// Releases every voice's chunk and cursor. Render thread (or after the render thread stopped).
public void Dispose()
{
if (_disposed) return;
_disposed = true;
foreach (var voice in _voices)
ReleaseVoice(voice);
_voices.Clear();
lock (_commandLock)
{
_commands.Clear();
}
}
}