using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
///
/// One playing sound of a (the song, or one sound effect), created by .
///
/// 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: applies each change at sample floor(time * OutputRate) ().
///
/// 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.
/// - Clip position at sample n = anchor position + (n - anchor sample) * pitch. A position or pitch change sets a new anchor
/// at the sample where it takes effect, so a pitch change keeps the position continuous.
/// - Looping: the position wraps at the clip length ( * output rate, fractional).
/// Not looping: the voice ends when the position reaches .
///
internal sealed class Voice
{
/// Kind of a queued ; the meaning of depends on it.
internal enum ChangeKind
{
/// Value: playback rate, at least 0.
Pitch,
/// Value: linear gain.
Volume,
/// Value: 1 = loop on, 0 = loop off.
Loop,
/// Value: clip position in seconds, at least 0.
Position,
/// Value: end time in seconds relative to the render start.
EndTime,
/// Value: unused (0). Ends playback at the sample the change is applied at.
Stop
}
/// One timed change of a voice, queued by the main Unity thread and applied by the render thread.
internal readonly struct Change
{
public readonly ChangeKind Kind;
/// Output sample at which the change takes effect ( of the change time).
public readonly long Sample;
/// Argument of the change (see ).
public readonly double Value;
public Change(ChangeKind kind, long sample, double value)
{
Kind = kind;
Sample = sample;
Value = value;
}
}
/// Mixer that owns this voice; the Set methods queue their changes on it.
private readonly VoiceMixer _mixer;
// ── Render thread state ──
/// Changes not yet applied, in time order. Render thread only.
internal readonly Queue Pending = new();
/// First output sample of playback.
internal long StartSample;
/// Output sample at which playback stops (exclusive); long.MaxValue when not set.
internal long EndSample = long.MaxValue;
/// Output sample of the current anchor (see the class summary).
internal long AnchorSample;
/// Clip position at , in frames at the output sample rate (may be fractional).
internal double AnchorPosition;
/// Clip frames advanced per output sample (1 = original speed), at least 0.
internal double Pitch;
/// Linear gain applied to the clip samples.
internal double Volume;
/// True while the clip repeats at .
internal bool Loop;
/// Set by the render thread when playback has ended; the voice is then released and removed from the mixer.
internal bool Finished;
///
/// Playback has ended and the voice was released by the render thread. Readable from any thread;
/// the value can lag behind the render thread.
///
public bool IsFinished => Volatile.Read(ref Finished);
/// Loop length in frames at the output sample rate (source duration * output rate).
internal double Period;
/// Read position registered on so the decoder thread keeps the chunks ahead loaded; null without a usable source.
internal AudioCursor? Cursor;
/// Chunk of currently held for reading (invalid when none). Render thread only.
internal ChunkView View;
internal Voice(VoiceMixer mixer, AudioClipSource? source, string name)
{
_mixer = mixer;
Source = source;
Name = name;
}
/// Clip played by this voice; null plays silence (the clip could not be loaded).
public AudioClipSource? Source { get; }
/// Name for logs (the game's clip key).
public string Name { get; }
/// Sets the playback rate (1 = original speed). Negative values are treated as 0.
public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch));
/// Sets the linear gain applied to the clip samples.
public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume);
/// Turns looping on or off. Turning it off lets the current repetition play to the end of the clip.
public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0);
///
/// Moves playback to seconds into the clip.
/// Before the voice starts, this sets the position playback starts from.
///
public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds));
/// Stops playback at (seconds relative to the render start).
public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime);
/// Stops playback at .
public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0);
/// Unwrapped clip position at output sample (sample at or after the anchor).
internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch;
/// Clip position at , wrapped into the loop when looping.
internal double WrappedPositionAt(long sample)
{
var position = PositionAt(sample);
if (Loop && Period > 0 && position >= Period) position %= Period;
return position;
}
///
/// Applies at output sample (its own sample, or a later one when the change
/// arrived after its sample was mixed). Before the start sample, changes replace the starting state. Render thread only.
///
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)
{
case ChangeKind.Pitch:
Reanchor(at);
Pitch = change.Value;
break;
case ChangeKind.Volume:
Volume = change.Value;
break;
case ChangeKind.Loop:
Reanchor(at);
Loop = change.Value != 0.0;
break;
case ChangeKind.Position:
AnchorSample = at;
AnchorPosition = change.Value * outputRate;
break;
case ChangeKind.EndTime:
EndSample = VoiceMixer.ToSample(change.Value, outputRate);
break;
case ChangeKind.Stop:
EndSample = Math.Min(EndSample, sample);
break;
}
}
/// Moves the anchor to , keeping the (wrapped) position there, before a pitch or loop change.
private void Reanchor(long sample)
{
AnchorPosition = WrappedPositionAt(sample);
AnchorSample = sample;
}
}