#nullable enable using System; using System.Collections.Generic; using System.ComponentModel; using System.Diagnostics; using System.Linq; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Threading; using System.Threading.Tasks; using Ayu.Discord.Voice; using NadekoBot.Common; using NadekoBot.Core.Modules.Music.Common; using NadekoBot.Core.Services.Database.Models; using NadekoBot.Extensions; using NadekoBot.Modules.Music; using Serilog; namespace NadekoBot.Core.Modules.Music { public sealed class MusicPlayer : IMusicPlayer { private delegate void AdjustVolumeDelegate(Span data, float volume); private AdjustVolumeDelegate AdjustVolume; private readonly VoiceClient _vc; public bool IsKilled { get; private set; } public bool IsStopped { get; private set; } public bool IsPaused { get; private set; } public PlayerRepeatType Repeat { get; private set; } public int CurrentIndex => _queue.Index; public float Volume => _volume; private float _volume = 1.0f; private readonly IMusicQueue _queue; private readonly ITrackResolveProvider _trackResolveProvider; private readonly IVoiceProxy _proxy; private readonly ISongBuffer _songBuffer; private bool _skipped; private int? _forceIndex; private readonly Thread _thread; private readonly Random _rng; public MusicPlayer( IMusicQueue queue, ITrackResolveProvider trackResolveProvider, IVoiceProxy proxy, QualityPreset qualityPreset) { _queue = queue; _trackResolveProvider = trackResolveProvider; _proxy = proxy; _rng = new NadekoRandom(); _vc = GetVoiceClient(qualityPreset); if (_vc.BitDepth == 16) AdjustVolume = AdjustVolumeInt16; else AdjustVolume = AdjustVolumeFloat32; _songBuffer = new PoopyBufferImmortalized(_vc.InputLength); _thread = new Thread(async () => { await PlayLoop(); }); _thread.Start(); } private static VoiceClient GetVoiceClient(QualityPreset qualityPreset) => qualityPreset switch { QualityPreset.Highest => new VoiceClient( SampleRate._48k, Bitrate._192k, Channels.Two, FrameDelay.Delay20, BitDepthEnum.Float32 ), QualityPreset.High => new VoiceClient( SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40, BitDepthEnum.Float32 ), QualityPreset.Medium => new VoiceClient( SampleRate._48k, Bitrate._96k, Channels.Two, FrameDelay.Delay40, BitDepthEnum.UInt16 ), QualityPreset.Low => new VoiceClient( SampleRate._48k, Bitrate._64k, Channels.Two, FrameDelay.Delay40, BitDepthEnum.UInt16 ), _ => throw new ArgumentOutOfRangeException(nameof(qualityPreset), qualityPreset, null) }; private async Task PlayLoop() { var sw = new Stopwatch(); while (!IsKilled) { // wait until a song is available in the queue // or until the queue is resumed var track = _queue.GetCurrent(out int index); if (track is null || IsStopped) { await Task.Delay(500); continue; } if (_skipped) { _skipped = false; _queue.Advance(); continue; } using var cancellationTokenSource = new CancellationTokenSource(); var token = cancellationTokenSource.Token; try { // light up green in vc _ = _proxy.StartSpeakingAsync(); _ = OnStarted?.Invoke(this, track, index); // make sure song buffer is ready to be (re)used _songBuffer.Reset(); var streamUrl = await track.GetStreamUrl(); // start up the data source using var source = FfmpegTrackDataSource.CreateAsync( _vc.BitDepth, streamUrl, track.Platform == MusicPlatform.Local ); // start moving data from the source into the buffer // this method will return once the sufficient prebuffering is done await _songBuffer.BufferAsync(source, token); // // Implemenation with multimedia timer. Works but a hassle because no support for switching // // vcs, as any error in copying will cancel the song. Also no idea how to use this as an option // // for selfhosters. // if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) // { // var cancelSource = new CancellationTokenSource(); // var cancelToken = cancelSource.Token; // using var timer = new MultimediaTimer(_ => // { // if (IsStopped || IsKilled) // { // cancelSource.Cancel(); // return; // } // // if (_skipped) // { // _skipped = false; // cancelSource.Cancel(); // return; // } // // if (IsPaused) // return; // // try // { // // this should tolerate certain number of errors // var result = CopyChunkToOutput(_songBuffer, _vc); // if (!result) // cancelSource.Cancel(); // // } // catch (Exception ex) // { // Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message); // cancelSource.Cancel(); // } // // }, null, 20); // // while(true) // await Task.Delay(1000, cancelToken); // } // start sending data var ticksPerMs = 1000f / Stopwatch.Frequency; sw.Start(); Thread.Sleep(2); var delay = sw.ElapsedTicks * ticksPerMs > 3f ? _vc.Delay - 16 : _vc.Delay - 3; var errorCount = 0; while (!IsStopped && !IsKilled) { // doing the skip this way instead of in the condition // ensures that a song will for sure be skipped if (_skipped) { _skipped = false; break; } if (IsPaused) { await Task.Delay(200); continue; } sw.Restart(); var ticks = sw.ElapsedTicks; try { var result = CopyChunkToOutput(_songBuffer, _vc); // if song is finished if (result is null) break; if (result is true) { if (errorCount > 0) { _ = _proxy.StartSpeakingAsync(); errorCount = 0; } // todo future windows multimedia api // wait for slightly less than the latency Thread.Sleep(delay); // and then spin out the rest while ((sw.ElapsedTicks - ticks) * ticksPerMs <= _vc.Delay - 0.1f) Thread.SpinWait(100); } else { // result is false is either when the gateway is being swapped // or if the bot is reconnecting, or just disconnected for whatever reason // tolerate up to 15x200ms of failures (3 seconds) if (++errorCount <= 15) { await Task.Delay(200); continue; } Log.Warning("Can't send data to voice channel"); IsStopped = true; // if errors are happening for more than 3 seconds // Stop the player break; } } catch (Exception ex) { Log.Warning(ex, "Something went wrong sending voice data: {ErrorMessage}", ex.Message); } } } catch (Win32Exception) { IsStopped = true; Log.Error("Please install ffmpeg and make sure it's added to your " + "PATH environment variable before trying again"); } catch (OperationCanceledException) { Log.Information("Song skipped"); } catch (Exception ex) { Log.Error(ex, "Unknown error in music loop: {ErrorMessage}", ex.Message); } finally { cancellationTokenSource.Cancel(); // turn off green in vc _ = OnCompleted?.Invoke(this, track); HandleQueuePostTrack(); _skipped = false; _ = _proxy.StopSpeakingAsync();; await Task.Delay(100); } } } private bool? CopyChunkToOutput(ISongBuffer sb, VoiceClient vc) { var data = sb.Read(vc.InputLength, out var length); // if nothing is read from the buffer, song is finished if (data.Length == 0) { return null; } AdjustVolume(data, _volume); return _proxy.SendPcmFrame(vc, data, length); } private void HandleQueuePostTrack() { if (_forceIndex is int forceIndex) { _queue.SetIndex(forceIndex); _forceIndex = null; return; } var (repeat, isStopped) = (Repeat, IsStopped); if (repeat == PlayerRepeatType.Track || isStopped) return; // if queue is being repeated, advance no matter what if (repeat == PlayerRepeatType.None) { // if this is the last song, // stop the queue if (_queue.IsLast()) { IsStopped = true; OnQueueStopped?.Invoke(this); return; } _queue.Advance(); return; } _queue.Advance(); } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void AdjustVolumeInt16(Span audioSamples, float volume) { if (Math.Abs(volume - 1f) < 0.0001f) return; var samples = MemoryMarshal.Cast(audioSamples); for (var i = 0; i < samples.Length; i++) { ref var sample = ref samples[i]; sample = (short) (sample * volume); } } [MethodImpl(MethodImplOptions.AggressiveInlining)] private static void AdjustVolumeFloat32(Span audioSamples, float volume) { if (Math.Abs(volume - 1f) < 0.0001f) return; var samples = MemoryMarshal.Cast(audioSamples); for (var i = 0; i < samples.Length; i++) { ref var sample = ref samples[i]; sample = (float) (sample * volume); } } public async Task<(IQueuedTrackInfo? QueuedTrack, int Index)> TryEnqueueTrackAsync( string query, string queuer, bool asNext, MusicPlatform? forcePlatform = null) { var song = await _trackResolveProvider.QuerySongAsync(query, forcePlatform); if (song is null) return default; int index; if (asNext) return (_queue.EnqueueNext(song, queuer, out index), index); return (_queue.Enqueue(song, queuer, out index), index); } public async Task EnqueueManyAsync(IEnumerable<(string Query, MusicPlatform Platform)> queries, string queuer) { var errorCount = 0; foreach (var chunk in queries.Chunk(5)) { if (IsKilled) break; var queueTasks = chunk.Select(async data => { var (query, platform) = data; try { await TryEnqueueTrackAsync(query, queuer, false, forcePlatform: platform).ConfigureAwait(false); errorCount = 0; } catch (Exception ex) { Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query); ++errorCount; } }); await Task.WhenAll(queueTasks); await Task.Delay(1000); // > 10 errors in a row = kill if (errorCount > 10) break; } } public void EnqueueTrack(ITrackInfo track, string queuer) { _queue.Enqueue(track, queuer, out _); } public void EnqueueTracks(IEnumerable tracks, string queuer) { _queue.EnqueueMany(tracks, queuer); } public void SetRepeat(PlayerRepeatType type) { Repeat = type; } public void ShuffleQueue() { _queue.Shuffle(_rng); } public void Stop() { IsStopped = true; } public void Clear() { _queue.Clear(); _skipped = true; } public IReadOnlyCollection GetQueuedTracks() => _queue.List(); public IQueuedTrackInfo? GetCurrentTrack(out int index) => _queue.GetCurrent(out index); public void Next() { _skipped = true; IsStopped = false; IsPaused = false; } public bool MoveTo(int index) { if (_queue.SetIndex(index)) { _forceIndex = index; _skipped = true; IsStopped = false; IsPaused = false; return true; } return false; } public void SetVolume(int newVolume) { var normalizedVolume = newVolume / 100f; if (normalizedVolume < 0f || normalizedVolume > 1f) throw new ArgumentOutOfRangeException(nameof(newVolume), "Volume must be in range 0-100"); _volume = normalizedVolume; } public void Kill() { IsKilled = true; IsStopped = true; IsPaused = false; _skipped = true; } public bool TryRemoveTrackAt(int index, out IQueuedTrackInfo? trackInfo) { if (!_queue.TryRemoveAt(index, out trackInfo, out var isCurrent)) return false; if (isCurrent) _skipped = true; return true; } public bool TogglePause() => IsPaused = !IsPaused; public IQueuedTrackInfo? MoveTrack(int from, int to) => _queue.MoveTrack(from, to); public void Dispose() { IsKilled = true; OnCompleted = null; OnStarted = null; OnQueueStopped = null; _queue.Clear(); _songBuffer.Dispose(); _vc.Dispose(); } public event Func? OnCompleted; public event Func? OnStarted; public event Func? OnQueueStopped; } }