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