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 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 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; } }