using NadekoBot.Voice; using NadekoBot.Db.Models; using NadekoBot.Modules.Music.Resolvers; using System.ComponentModel; using System.Diagnostics; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; namespace NadekoBot.Modules.Music; public sealed class MusicPlayer : IMusicPlayer { public event Func? OnCompleted; public event Func? OnStarted; public event Func? OnQueueStopped; 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 { get; private set; } = 1.0f; private readonly AdjustVolumeDelegate _adjustVolume; private readonly VoiceClient _vc; private readonly IMusicQueue _queue; private readonly ITrackResolveProvider _trackResolveProvider; private readonly IYoutubeResolverFactory _ytResolverFactory; private readonly IVoiceProxy _proxy; private readonly IGoogleApiService _googleApiService; private readonly ISongBuffer _songBuffer; private bool skipped; private int? forceIndex; private readonly Thread _thread; private readonly Random _rng; public bool AutoPlay { get; set; } public MusicPlayer( IMusicQueue queue, ITrackResolveProvider trackResolveProvider, IYoutubeResolverFactory ytResolverFactory, IVoiceProxy proxy, IGoogleApiService googleApiService, QualityPreset qualityPreset, bool autoPlay) { _queue = queue; _trackResolveProvider = trackResolveProvider; _ytResolverFactory = ytResolverFactory; _proxy = proxy; _googleApiService = googleApiService; AutoPlay = autoPlay; _rng = new NadekoRandom(); _vc = GetVoiceClient(qualityPreset); if (_vc.BitDepth == 16) _adjustVolume = AdjustVolumeInt16; else _adjustVolume = AdjustVolumeFloat32; _songBuffer = new PoopyBufferImmortalized(_vc.InputLength); _thread = new(async () => { await PlayLoop(); }); _thread.Start(); } private static VoiceClient GetVoiceClient(QualityPreset qualityPreset) => qualityPreset switch { QualityPreset.Highest => new(), QualityPreset.High => new(SampleRate._48k, Bitrate._128k, Channels.Two, FrameDelay.Delay40), QualityPreset.Medium => new(SampleRate._48k, Bitrate._96k, Channels.Two, FrameDelay.Delay40, BitDepthEnum.UInt16), QualityPreset.Low => new(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 var 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 GetStreamUrl(track); // 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; } // 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); await Task.Delay(3_000); } finally { cancellationTokenSource.Cancel(); // turn off green in vc _ = OnCompleted?.Invoke(this, track); if (AutoPlay && track.Platform == MusicPlatform.Youtube) { try { var relatedSongs = await _googleApiService.GetRelatedVideosAsync(track.TrackInfo.Id, 5); var related = relatedSongs.Shuffle().FirstOrDefault(); if (related is not null) { var relatedTrack = await _trackResolveProvider.QuerySongAsync(related, MusicPlatform.Youtube); if (relatedTrack is not null) EnqueueTrack(relatedTrack, "Autoplay"); } } catch (Exception ex) { Log.Warning(ex, "Failed queueing a related song via autoplay"); } } HandleQueuePostTrack(); skipped = false; _ = _proxy.StopSpeakingAsync(); await Task.Delay(100); } } } private async Task GetStreamUrl(IQueuedTrackInfo track) { if (track.TrackInfo is SimpleTrackInfo sti) return sti.StreamUrl; return await _ytResolverFactory.GetYoutubeResolver().GetStreamUrl(track.TrackInfo.Id); } 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 { } index) { _queue.SetIndex(index); 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 *= 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; await chunk.Select(async data => { var (query, platform) = data; try { await TryEnqueueTrackAsync(query, queuer, false, platform); errorCount = 0; } catch (Exception ex) { Log.Warning(ex, "Error resolving {MusicPlatform} Track {TrackQuery}", platform, query); ++errorCount; } }) .WhenAll(); 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 is < 0f or > 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(); } private delegate void AdjustVolumeDelegate(Span data, float volume); public void SetFairplay() { _queue.ReorderFairly(); } public Task RemoveLastQueuedTrack() { var last = _queue.GetLastQueuedIndex(); if (last is null) return Task.FromResult(null); return TryRemoveTrackAt(last.Value, out var trackInfo) ? Task.FromResult(trackInfo) : Task.FromResult(null); } }