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 round(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 { private enum CommandKind { Play, Change } private readonly struct Command { public readonly CommandKind Kind; public readonly Voice Voice; 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; 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; } public int OutputRate { get; } 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; } 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(); for (var i = _voices.Count - 1; i >= 0; i--) { var voice = _voices[i]; var sample = firstSample; 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; } if (!voice.Finished && endSample >= voice.EndSample) voice.Finished = true; if (voice.Finished) { ReleaseVoice(voice); _voices.RemoveAt(i); } } } 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; 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; 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; } position = totalFrames - 1; } var index = (long)position; var fraction = position - index; if (!EnsureView(voice, index)) return; var view = voice.View; if (fraction == 0.0) { for (var c = 0; c < channels; c++) destination[offset + c] += view.GetSample(index, c, channels) * volume; continue; } 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; } 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; } 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(); } } }