using axXez.TS3.Protocol; using Concentus.Enums; using Concentus.Structs; using System; using System.Collections.Generic; using System.Diagnostics; using System.Threading; using System.Threading.Tasks; namespace axXez.TS3.Audio { /// Drives a with timed Opus-encoded audio frames. public sealed class AudioPlayer { public const int SampleRate = FFmpeg.SampleRate; public const int Channels = FFmpeg.Channels; public const int FrameSize = 960; // samples/channel = 20 ms public const int BytesPerFrame = FrameSize * Channels * 2; // s16le stereo public const int FrameMs = 20; private readonly VoiceClient _client; private readonly OpusEncoder _encoder; private float _volume = 1f; private CancellationTokenSource _cts; private Task _loopTask; // Buffered-mode state private byte[][] _frames; private int _frameIndex; // Streaming-mode chunk buffer private readonly List _streamChunks = new(); private int _streamHeadOffset; private int _streamChunksSize; private readonly object _streamLock = new(); public bool IsPlaying => _loopTask != null && !_loopTask.IsCompleted && !_isPaused; public bool IsPaused => _isPaused; public bool IsStreaming { get; private set; } private volatile bool _isPaused; private volatile bool _resumeRequested; public TimeSpan Position => _frames != null ? TimeSpan.FromMilliseconds(_frameIndex * FrameMs) : TimeSpan.Zero; public TimeSpan Duration => _frames != null ? TimeSpan.FromMilliseconds(_frames.Length * FrameMs) : TimeSpan.Zero; /// Volume multiplier: 0.0 = silent, 1.0 = original, 2.0 = doubled. public float Volume { get => _volume; set => _volume = Math.Clamp(value, 0f, 2f); } public event Action TrackEnded; public event Action Error; /// /// Throws if any required external tool is missing, /// listing every missing dependency by name. Call this on module startup before creating an instance. /// public static void EnsureDependencies() { var missing = new List(); if (!FFmpeg.IsAvailable()) missing.Add("ffmpeg"); if (!YtDlp.IsAvailable()) missing.Add("yt-dlp"); if (missing.Count > 0) throw new InvalidOperationException( $"Missing required dependencies: {string.Join(", ", missing)}. " + "Make sure they are installed and available on PATH."); } public AudioPlayer(VoiceClient client) { _client = client; _encoder = OpusEncoder.Create(SampleRate, Channels, OpusApplication.OPUS_APPLICATION_AUDIO); _encoder.Bitrate = 96000; _encoder.UseVBR = false; _encoder.SignalType = OpusSignal.OPUS_SIGNAL_MUSIC; } /// Converts via ffmpeg, then begins playback. public async Task PlayFileAsync(string filePath, CancellationToken ct = default) { byte[] pcm = await FFmpeg.ToPcmAsync(filePath, ct); Play(SplitFrames(pcm)); } /// Starts playback of pre-split PCM frames. public void Play(byte[][] frames, int startFrame = 0) { StopInternal(); _frames = frames; _frameIndex = startFrame; _isPaused = false; IsStreaming = false; StartLoop(RunBufferedAsync); } public void Pause() { if (_isPaused || _loopTask == null || _loopTask.IsCompleted) return; _isPaused = true; _client.SendVoiceStop(); } public void Resume() { if (!_isPaused) return; _resumeRequested = true; _isPaused = false; } public void Seek(TimeSpan position) { if (_frames == null || IsStreaming) return; int target = Math.Clamp( (int)(position.TotalMilliseconds / FrameMs), 0, _frames.Length - 1); if (_isPaused) { _frameIndex = target; } else { _cts?.Cancel(); _frameIndex = target; StartLoop(RunBufferedAsync); } } /// Streams PCM from via ffmpeg and plays in real time. public void PlayStream(string url) { StopInternal(); _frames = null; _isPaused = false; IsStreaming = true; StartLoop(ct => RunStreamAsync(url, ct)); } public void Stop() { StopInternal(); _client.SendVoiceStop(); } private void StopInternal() { _cts?.Cancel(); _cts = null; _isPaused = false; IsStreaming = false; lock (_streamLock) { _streamChunks.Clear(); _streamHeadOffset = 0; _streamChunksSize = 0; } } private void StartLoop(Func loop) { _cts = new CancellationTokenSource(); _loopTask = Task.Run(() => loop(_cts.Token)); } private async Task RunBufferedAsync(CancellationToken ct) { var sw = Stopwatch.StartNew(); double next = 0; try { while (!ct.IsCancellationRequested && _frameIndex < _frames.Length) { if (_isPaused) { await Task.Delay(20, ct); continue; } if (_resumeRequested) { _resumeRequested = false; sw.Restart(); next = 0; } double now = sw.Elapsed.TotalMilliseconds; double wait = next - now; if (wait > 2.0) await Task.Delay((int)(wait - 1), ct); while (sw.Elapsed.TotalMilliseconds < next && !ct.IsCancellationRequested) Thread.SpinWait(10); now = sw.Elapsed.TotalMilliseconds; if (now - next >= FrameMs) { int skip = (int)((now - next) / FrameMs); _frameIndex = Math.Min(_frameIndex + skip, _frames.Length); next = now; } if (_frameIndex < _frames.Length && !ct.IsCancellationRequested) { _client.SendVoice(EncodeFrame(_frames[_frameIndex], _volume)); _frameIndex++; } next += FrameMs; if (sw.Elapsed.TotalMilliseconds - next > 5 * FrameMs) next = sw.Elapsed.TotalMilliseconds + FrameMs; } if (!ct.IsCancellationRequested) { _client.SendVoiceStop(); TrackEnded?.Invoke(); } } catch (OperationCanceledException) { } catch (Exception ex) { Error?.Invoke(ex); } } private async Task RunStreamAsync(string url, CancellationToken ct) { var (process, stdout) = FFmpeg.StartPcmStream(url); try { var producer = Task.Run(async () => { var buf = new byte[4096]; try { while (!ct.IsCancellationRequested) { int read = await stdout.ReadAsync(buf, ct); if (read == 0) break; var chunk = new byte[read]; Buffer.BlockCopy(buf, 0, chunk, 0, read); lock (_streamLock) { _streamChunks.Add(chunk); _streamChunksSize += read; } } } catch (OperationCanceledException) { } }, ct); int initialBuffer = BytesPerFrame * 10; while (!ct.IsCancellationRequested) { lock (_streamLock) if (_streamChunksSize >= initialBuffer) break; await Task.Delay(5, ct); } var sw = Stopwatch.StartNew(); double next = 0; while (!ct.IsCancellationRequested) { if (_isPaused) { await Task.Delay(20, ct); continue; } if (_resumeRequested) { _resumeRequested = false; sw.Restart(); next = 0; } double now = sw.Elapsed.TotalMilliseconds; double wait = next - now; if (wait > 2.0) await Task.Delay((int)(wait - 1), ct); while (sw.Elapsed.TotalMilliseconds < next && !ct.IsCancellationRequested) Thread.SpinWait(10); byte[] frame; lock (_streamLock) frame = TakeFromStream(BytesPerFrame); if (frame != null) { _client.SendVoice(EncodeFrame(frame, _volume)); } else if (producer.IsCompleted) { break; } next += FrameMs; if (sw.Elapsed.TotalMilliseconds - next > 5 * FrameMs) next = sw.Elapsed.TotalMilliseconds + FrameMs; } if (!ct.IsCancellationRequested) { _client.SendVoiceStop(); IsStreaming = false; TrackEnded?.Invoke(); } await producer; } catch (OperationCanceledException) { } catch (Exception ex) { Error?.Invoke(ex); } finally { try { process.Kill(); } catch { } process.Dispose(); IsStreaming = false; } } private byte[] EncodeFrame(byte[] pcmFrame, float volume) { var shorts = new short[pcmFrame.Length / 2]; Buffer.BlockCopy(pcmFrame, 0, shorts, 0, pcmFrame.Length); if (MathF.Abs(volume - 1f) > 0.001f) { for (int i = 0; i < shorts.Length; i++) { int v = (int)MathF.Round(shorts[i] * volume); shorts[i] = (short)Math.Clamp(v, short.MinValue, short.MaxValue); } } var outBuf = new byte[4000]; int encoded = _encoder.Encode(shorts, 0, FrameSize, outBuf, 0, outBuf.Length); return outBuf[..encoded]; } private static byte[][] SplitFrames(byte[] pcm) { int count = (pcm.Length + BytesPerFrame - 1) / BytesPerFrame; byte[][] frames = new byte[count][]; for (int i = 0; i < count; i++) { int offset = i * BytesPerFrame; int len = Math.Min(BytesPerFrame, pcm.Length - offset); byte[] frame = new byte[BytesPerFrame]; Buffer.BlockCopy(pcm, offset, frame, 0, len); frames[i] = frame; } return frames; } private byte[] TakeFromStream(int n) { if (_streamChunksSize < n) return null; var result = new byte[n]; int offset = 0; while (offset < n) { var head = _streamChunks[0]; int available = head.Length - _streamHeadOffset; int need = n - offset; if (available <= need) { Buffer.BlockCopy(head, _streamHeadOffset, result, offset, available); offset += available; _streamChunks.RemoveAt(0); _streamHeadOffset = 0; } else { Buffer.BlockCopy(head, _streamHeadOffset, result, offset, need); _streamHeadOffset += need; offset += need; } } _streamChunksSize -= n; return result; } } }