Files
ADOFAIRenderer/Util/Audio/Mixer/Voice.cs
T
2026-10-15 18:29:22 +09:00

200 lines
8.7 KiB
C#

using System;
using System.Collections.Generic;
using System.Threading;
using ADOFAIRenderer.Util.Audio.Clip;
namespace ADOFAIRenderer.Util.Audio.Mixer;
/// <summary>
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one sound effect), created by <see cref="VoiceMixer.Play"/>.
///
/// 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 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.
/// - 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 (<see cref="AudioClipSource.Duration"/> * output rate, fractional).
/// Not looping: the voice ends when the position reaches <see cref="AudioClipSource.TotalFrames"/>.
/// </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 Unity 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)
{
Kind = kind;
Sample = sample;
Value = value;
}
}
/// <summary>Mixer that owns this voice; the Set methods queue their changes on it.</summary>
private readonly VoiceMixer _mixer;
// ── Render thread state ──
/// <summary>Changes not yet applied, in time order. Render thread only.</summary>
internal readonly Queue<Change> Pending = new();
/// <summary>First output sample of playback.</summary>
internal long StartSample;
/// <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>
/// Playback has ended and the voice was released by the render thread. Readable from any thread;
/// the value can lag behind the render thread.
/// </summary>
public bool IsFinished => Volatile.Read(ref Finished);
/// <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)
{
_mixer = mixer;
Source = source;
Name = name;
}
/// <summary>Clip played by this voice; null plays silence (the clip could not be loaded).</summary>
public AudioClipSource? Source { get; }
/// <summary>Name for logs (the game's clip key).</summary>
public string Name { get; }
/// <summary>Sets the playback rate (1 = original speed). Negative values are treated as 0.</summary>
public void SetPitch(double pitch, double time) => _mixer.Enqueue(this, ChangeKind.Pitch, time, Math.Max(0.0, pitch));
/// <summary>Sets the linear gain applied to the clip samples.</summary>
public void SetVolume(double volume, double time) => _mixer.Enqueue(this, ChangeKind.Volume, time, volume);
/// <summary>Turns looping on or off. Turning it off lets the current repetition play to the end of the clip.</summary>
public void SetLoop(bool loop, double time) => _mixer.Enqueue(this, ChangeKind.Loop, time, loop ? 1.0 : 0.0);
/// <summary>
/// Moves playback to <paramref name="clipSeconds"/> seconds into the clip.
/// Before the voice starts, this sets the position playback starts from.
/// </summary>
public void SetPosition(double clipSeconds, double time) => _mixer.Enqueue(this, ChangeKind.Position, time, Math.Max(0.0, clipSeconds));
/// <summary>Stops playback at <paramref name="endTime"/> (seconds relative to the render start).</summary>
public void SetEndTime(double endTime, double time) => _mixer.Enqueue(this, ChangeKind.EndTime, time, endTime);
/// <summary>Stops playback at <paramref name="time"/>.</summary>
public void Stop(double time) => _mixer.Enqueue(this, ChangeKind.Stop, time, 0.0);
/// <summary>Unwrapped clip position at output sample <paramref name="sample"/> (sample at or after the anchor).</summary>
internal double PositionAt(long sample) => AnchorPosition + (sample - AnchorSample) * Pitch;
/// <summary>Clip position at <paramref name="sample"/>, wrapped into the loop when looping.</summary>
internal double WrappedPositionAt(long sample)
{
var position = PositionAt(sample);
if (Loop && Period > 0 && position >= Period) position %= Period;
return position;
}
/// <summary>
/// Applies <paramref name="change"/> at output sample <paramref name="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.
/// </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)
{
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;
}
}
/// <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);
AnchorSample = sample;
}
}