SHA256
Add Voice Mixer With Timed Changes
This commit is contained in:
1 parent
7ea2d8fe3f
commit
a21d86d3a6
2 files changed
+497
No files matched your search
@@ -0,0 +1,156 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ADOFAIRenderer.Util.Audio.Clip;
|
||||
|
||||
namespace ADOFAIRenderer.Util.Audio.Mixer;
|
||||
|
||||
/// <summary>
|
||||
/// One playing sound of a <see cref="VoiceMixer"/> (the song, or one effect sound), 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 round(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 (<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
|
||||
{
|
||||
internal enum ChangeKind
|
||||
{
|
||||
Pitch,
|
||||
Volume,
|
||||
Loop,
|
||||
Position,
|
||||
EndTime,
|
||||
Stop
|
||||
}
|
||||
|
||||
internal readonly struct Change
|
||||
{
|
||||
public readonly ChangeKind Kind;
|
||||
public readonly long Sample;
|
||||
public readonly double Value;
|
||||
|
||||
public Change(ChangeKind kind, long sample, double value)
|
||||
{
|
||||
Kind = kind;
|
||||
Sample = sample;
|
||||
Value = value;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
internal long AnchorSample;
|
||||
internal double AnchorPosition;
|
||||
internal double Pitch;
|
||||
internal double Volume;
|
||||
internal bool Loop;
|
||||
internal bool Finished;
|
||||
|
||||
/// <summary>Loop length in frames at the output sample rate (source duration * output rate).</summary>
|
||||
internal double Period;
|
||||
|
||||
internal AudioCursor? Cursor;
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
private void Reanchor(long sample)
|
||||
{
|
||||
AnchorPosition = WrappedPositionAt(sample);
|
||||
AnchorSample = sample;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user