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 effect sound), 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 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 ( * output rate, fractional). /// Not looping: the voice ends when the position reaches . /// 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 ── /// 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; internal long AnchorSample; internal double AnchorPosition; internal double Pitch; internal double Volume; internal bool Loop; 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; internal AudioCursor? Cursor; 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) { 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; } }