diff --git a/Screenbox.Core/Helpers/CircularBuffer.cs b/Screenbox.Core/Helpers/CircularBuffer.cs new file mode 100644 index 000000000..72e5739cc --- /dev/null +++ b/Screenbox.Core/Helpers/CircularBuffer.cs @@ -0,0 +1,417 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Screenbox.Core.Helpers +{ + /// + /// + /// Circular buffer. + /// + /// When writing to a full buffer: + /// PushBack -> removes this[0] / Front() + /// PushFront -> removes this[Size-1] / Back() + /// + /// this implementation is inspired by + /// http://www.boost.org/doc/libs/1_53_0/libs/circular_buffer/doc/circular_buffer.html + /// because I liked their interface. + /// + public class CircularBuffer : IEnumerable + { + private readonly T[] _buffer; + + /// + /// The _start. Index of the first element in buffer. + /// + private int _start; + + /// + /// The _end. Index after the last element in the buffer. + /// + private int _end; + + /// + /// The _size. Buffer size. + /// + private int _size; + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// Buffer capacity. Must be positive. + /// + public CircularBuffer(int capacity) + : this(capacity, new T[] { }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// + /// + /// Buffer capacity. Must be positive. + /// + /// + /// Items to fill buffer with. Items length must be less than capacity. + /// Suggestion: use Skip(x).Take(y).ToArray() to build this argument from + /// any enumerable. + /// + public CircularBuffer(int capacity, T[] items) + { + if (capacity < 1) + { + throw new ArgumentException( + "Circular buffer cannot have negative or zero capacity.", nameof(capacity)); + } + if (items == null) + { + throw new ArgumentNullException(nameof(items)); + } + if (items.Length > capacity) + { + throw new ArgumentException( + "Too many items to fit circular buffer", nameof(items)); + } + + _buffer = new T[capacity]; + + Array.Copy(items, _buffer, items.Length); + _size = items.Length; + + _start = 0; + _end = _size == capacity ? 0 : _size; + } + + /// + /// Maximum capacity of the buffer. Elements pushed into the buffer after + /// maximum capacity is reached (IsFull = true), will remove an element. + /// + public int Capacity { get { return _buffer.Length; } } + + /// + /// Boolean indicating if Circular is at full capacity. + /// Adding more elements when the buffer is full will + /// cause elements to be removed from the other end + /// of the buffer. + /// + public bool IsFull + { + get + { + return Size == Capacity; + } + } + + /// + /// True if has no elements. + /// + public bool IsEmpty + { + get + { + return Size == 0; + } + } + + /// + /// Current buffer size (the number of elements that the buffer has). + /// + public int Size { get { return _size; } } + + /// + /// Element at the front of the buffer - this[0]. + /// + /// The value of the element of type T at the front of the buffer. + public T Front() + { + ThrowIfEmpty(); + return _buffer[_start]; + } + + /// + /// Element at the back of the buffer - this[Size - 1]. + /// + /// The value of the element of type T at the back of the buffer. + public T Back() + { + ThrowIfEmpty(); + return _buffer[(_end != 0 ? _end : Capacity) - 1]; + } + + /// + /// Index access to elements in buffer. + /// Index does not loop around like when adding elements, + /// valid interval is [0;Size[ + /// + /// Index of element to access. + /// Thrown when index is outside of [; Size[ interval. + public T this[int index] + { + get + { + if (IsEmpty) + { + throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer is empty", index)); + } + if (index >= _size) + { + throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer size is {1}", index, _size)); + } + int actualIndex = InternalIndex(index); + return _buffer[actualIndex]; + } + set + { + if (IsEmpty) + { + throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer is empty", index)); + } + if (index >= _size) + { + throw new IndexOutOfRangeException(string.Format("Cannot access index {0}. Buffer size is {1}", index, _size)); + } + int actualIndex = InternalIndex(index); + _buffer[actualIndex] = value; + } + } + + /// + /// Pushes a new element to the back of the buffer. Back()/this[Size-1] + /// will now return this element. + /// + /// When the buffer is full, the element at Front()/this[0] will be + /// popped to allow for this new element to fit. + /// + /// Item to push to the back of the buffer + public void PushBack(T item) + { + if (IsFull) + { + _buffer[_end] = item; + Increment(ref _end); + _start = _end; + } + else + { + _buffer[_end] = item; + Increment(ref _end); + ++_size; + } + } + + /// + /// Pushes a new element to the front of the buffer. Front()/this[0] + /// will now return this element. + /// + /// When the buffer is full, the element at Back()/this[Size-1] will be + /// popped to allow for this new element to fit. + /// + /// Item to push to the front of the buffer + public void PushFront(T item) + { + if (IsFull) + { + Decrement(ref _start); + _end = _start; + _buffer[_start] = item; + } + else + { + Decrement(ref _start); + _buffer[_start] = item; + ++_size; + } + } + + /// + /// Removes the element at the back of the buffer. Decreasing the + /// Buffer size by 1. + /// + public void PopBack() + { + ThrowIfEmpty("Cannot take elements from an empty buffer."); + Decrement(ref _end); + _buffer[_end] = default(T); + --_size; + } + + /// + /// Removes the element at the front of the buffer. Decreasing the + /// Buffer size by 1. + /// + public void PopFront() + { + ThrowIfEmpty("Cannot take elements from an empty buffer."); + _buffer[_start] = default(T); + Increment(ref _start); + --_size; + } + + /// + /// Clears the contents of the array. Size = 0, Capacity is unchanged. + /// + /// + public void Clear() + { + // to clear we just reset everything. + _start = 0; + _end = 0; + _size = 0; + Array.Clear(_buffer, 0, _buffer.Length); + } + + /// + /// Copies the buffer contents to an array, according to the logical + /// contents of the buffer (i.e. independent of the internal + /// order/contents) + /// + /// A new array with a copy of the buffer contents. + public T[] ToArray() + { + T[] newArray = new T[Size]; + int newArrayOffset = 0; + var segments = ToArraySegments(); + foreach (ArraySegment segment in segments) + { + Array.Copy(segment.Array, segment.Offset, newArray, newArrayOffset, segment.Count); + newArrayOffset += segment.Count; + } + return newArray; + } + + /// + /// Get the contents of the buffer as 2 ArraySegments. + /// Respects the logical contents of the buffer, where + /// each segment and items in each segment are ordered + /// according to insertion. + /// + /// Fast: does not copy the array elements. + /// Useful for methods like Send(IList<ArraySegment<Byte>>). + /// + /// Segments may be empty. + /// + /// An IList with 2 segments corresponding to the buffer content. + public IList> ToArraySegments() + { + return new[] { ArrayOne(), ArrayTwo() }; + } + + #region IEnumerable implementation + /// + /// Returns an enumerator that iterates through this buffer. + /// + /// An enumerator that can be used to iterate this collection. + public IEnumerator GetEnumerator() + { + var segments = ToArraySegments(); + foreach (ArraySegment segment in segments) + { + for (int i = 0; i < segment.Count; i++) + { + yield return segment.Array[segment.Offset + i]; + } + } + } + #endregion + #region IEnumerable implementation + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + #endregion + + private void ThrowIfEmpty(string message = "Cannot access an empty buffer.") + { + if (IsEmpty) + { + throw new InvalidOperationException(message); + } + } + + /// + /// Increments the provided index variable by one, wrapping + /// around if necessary. + /// + /// + private void Increment(ref int index) + { + if (++index == Capacity) + { + index = 0; + } + } + + /// + /// Decrements the provided index variable by one, wrapping + /// around if necessary. + /// + /// + private void Decrement(ref int index) + { + if (index == 0) + { + index = Capacity; + } + index--; + } + + /// + /// Converts the index in the argument to an index in _buffer + /// + /// + /// The transformed index. + /// + /// + /// External index. + /// + private int InternalIndex(int index) + { + return _start + (index < (Capacity - _start) ? index : index - Capacity); + } + + // doing ArrayOne and ArrayTwo methods returning ArraySegment as seen here: + // http://www.boost.org/doc/libs/1_37_0/libs/circular_buffer/doc/circular_buffer.html#classboost_1_1circular__buffer_1957cccdcb0c4ef7d80a34a990065818d + // http://www.boost.org/doc/libs/1_37_0/libs/circular_buffer/doc/circular_buffer.html#classboost_1_1circular__buffer_1f5081a54afbc2dfc1a7fb20329df7d5b + // should help a lot with the code. + + #region Array items easy access. + // The array is composed by at most two non-contiguous segments, + // the next two methods allow easy access to those. + + private ArraySegment ArrayOne() + { + if (IsEmpty) + { + return new ArraySegment(new T[0]); + } + else if (_start < _end) + { + return new ArraySegment(_buffer, _start, _end - _start); + } + else + { + return new ArraySegment(_buffer, _start, _buffer.Length - _start); + } + } + + private ArraySegment ArrayTwo() + { + if (IsEmpty) + { + return new ArraySegment(Array.Empty()); + } + else if (_start < _end) + { + return new ArraySegment(_buffer, _end, 0); + } + else + { + return new ArraySegment(_buffer, 0, _end); + } + } + #endregion + } +} \ No newline at end of file diff --git a/Screenbox.Core/Playback/VlcMediaPlayer.cs b/Screenbox.Core/Playback/VlcMediaPlayer.cs index 04567fd9c..79296cda7 100644 --- a/Screenbox.Core/Playback/VlcMediaPlayer.cs +++ b/Screenbox.Core/Playback/VlcMediaPlayer.cs @@ -2,11 +2,17 @@ using LibVLCSharp.Shared; using Screenbox.Core.Events; +using Screenbox.Core.Helpers; using System; +using System.Runtime.InteropServices; +using System.Threading.Tasks; using Windows.Devices.Enumeration; using Windows.Foundation; +using Windows.Media; +using Windows.Media.Audio; using Windows.Media.Core; using Windows.Media.Devices; +using Windows.Media.MediaProperties; using Windows.Media.Playback; using Windows.Storage; using Windows.Storage.AccessCache; @@ -92,26 +98,32 @@ public TimeSpan Position public bool IsMuted { - get => VlcPlayer.Mute; + get => _isMuted; set { + _isMuted = value; if (VlcPlayer.Mute != value) { VlcPlayer.Mute = value; } + + UpdateOutputNodeGain(); } } public double Volume { - get => VlcPlayer.Volume / 100d; + get => _volume; set { + _volume = value; int iVal = (int)(value * 100); if (VlcPlayer.Volume != iVal && VlcPlayer.Volume >= 0) { VlcPlayer.Volume = iVal; } + + UpdateOutputNodeGain(); } } @@ -242,17 +254,34 @@ public PlaybackItem? PlaybackItem private readonly Rect _defaultSourceRect; private ChapterCue? _chapter; private Rect _normalizedSourceRect; + private bool _isMuted; + private double _volume; private bool _readyToPlay; private bool _updateMediaProperties; private TimeSpan _naturalDuration; private TimeSpan _position; private MediaPlaybackState _playbackState; private PlaybackItem? _playbackItem; + private AudioGraph? _audioGraph; + private AudioFrameInputNode? _inputNode; + private AudioDeviceOutputNode? _outputNode; + private CircularBuffer? _audioBuffer; + private readonly object _audioBufferLock; + + [ComImport] + [Guid("5B0D3235-4DBA-4D44-865E-8F1D0E4FD04D")] + [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + unsafe interface IMemoryBufferByteAccess + { + void GetBuffer(out byte* buffer, out uint capacity); + } public VlcMediaPlayer(LibVLC libVlc) { LibVlc = libVlc; VlcPlayer = new MediaPlayer(libVlc); + _volume = VlcPlayer.Volume / 100d; + _audioBufferLock = new object(); _defaultSourceRect = new Rect(0, 0, 1, 1); _normalizedSourceRect = _defaultSourceRect; @@ -277,6 +306,154 @@ public VlcMediaPlayer(LibVLC libVlc) MediaDevice.DefaultAudioRenderDeviceChanged += MediaDevice_DefaultAudioRenderDeviceChanged; } + public async Task InitAudioGraphAsync() + { + AudioGraphSettings settings = new(Windows.Media.Render.AudioRenderCategory.Media); + CreateAudioGraphResult result = await AudioGraph.CreateAsync(settings); + if (result.Status != AudioGraphCreationStatus.Success) return; + AudioGraph audioGraph = _audioGraph = result.Graph; + var outputNodeResult = await audioGraph.CreateDeviceOutputNodeAsync(); + if (outputNodeResult.Status != AudioDeviceNodeCreationStatus.Success) return; + _outputNode = outputNodeResult.DeviceOutputNode; + + // VLC callbacks + VlcPlayer.SetAudioFormatCallback(SetupCb, CleanupCb); + VlcPlayer.SetAudioCallbacks(PlayCb, PauseCb, ResumeCb, FlushCb, DrainCb); + VlcPlayer.SetVolumeCallback(VolumeCb); + + // VLC setup callback starts the audio graph + audioGraph.Stop(); + } + + private void CleanupCb(IntPtr opaque) + { + if (_inputNode == null) return; + _audioGraph?.Stop(); + _audioGraph?.ResetAllNodes(); + + _inputNode.RemoveOutgoingConnection(_outputNode); + _inputNode.Dispose(); + } + + private int SetupCb(ref IntPtr opaque, ref IntPtr format, ref uint rate, ref uint channels) + { + // Format is always "S16N" + if (_audioGraph == null) return 0; + + lock (_audioBufferLock) + { + // Create an audio buffer for 500ms of audio + _audioBuffer = new CircularBuffer((int)(rate * channels / 2)); + } + + // Create input node that has 16-bit integer PCM encoding to match VLC + var inputEncoding = AudioEncodingProperties.CreatePcm(rate, channels, 16); + var inputNode = _inputNode = _audioGraph.CreateFrameInputNode(inputEncoding); + inputNode.AddOutgoingConnection(_outputNode); + inputNode.QuantumStarted += InputNodeOnQuantumStarted; + + UpdateOutputNodeGain(); + + _audioGraph.Start(); + + return 0; + } + + private void UpdateOutputNodeGain() + { + if (_outputNode == null) return; + _outputNode.OutgoingGain = IsMuted ? 0 : Volume; + } + + private void VolumeCb(IntPtr data, float volume, bool mute) + { + UpdateOutputNodeGain(); + } + + private void InputNodeOnQuantumStarted(AudioFrameInputNode sender, FrameInputNodeQuantumStartedEventArgs args) + { + if (_audioBuffer == null) return; + AudioFrame frame; + lock (_audioBufferLock) + { + if (_audioBuffer.Size < args.RequiredSamples * sender.EncodingProperties.ChannelCount) return; + frame = GenerateAudioFrame(_audioBuffer, (uint)args.RequiredSamples, + sender.EncodingProperties.ChannelCount, sender.EncodingProperties.SampleRate); + } + + sender.AddFrame(frame); + } + + private unsafe AudioFrame GenerateAudioFrame(CircularBuffer audioBuffer, uint sampleCountPerChannel, uint channelCount, uint sampleRate) + { + uint totalSampleCount = sampleCountPerChannel * channelCount; + uint bufferSize = totalSampleCount * sizeof(short); + AudioFrame frame = new(bufferSize) + { + Duration = TimeSpan.FromSeconds((double)sampleCountPerChannel / sampleRate) + }; + + using AudioBuffer buffer = frame.LockBuffer(AudioBufferAccessMode.Write); + using IMemoryBufferReference reference = buffer.CreateReference(); + + // Get the buffer from the AudioFrame + ((IMemoryBufferByteAccess)reference).GetBuffer(out byte* dataInBytes, out _); + + // Then create a span for easy access + Span dest = new(dataInBytes, (int)totalSampleCount); + + for (int i = 0; i < totalSampleCount; i++) + { + dest[i] = audioBuffer.Front(); + audioBuffer.PopFront(); + } + + return frame; + } + + private void DrainCb(IntPtr data) + { + + } + + private void FlushCb(IntPtr data, long pts) + { + _inputNode?.DiscardQueuedFrames(); + if (_audioBuffer == null) return; + lock (_audioBufferLock) + { + _audioBuffer.Clear(); + } + } + + private void ResumeCb(IntPtr data, long pts) + { + _audioGraph?.Start(); + } + + private void PauseCb(IntPtr data, long pts) + { + _audioGraph?.Stop(); + } + + private unsafe void PlayCb(IntPtr data, IntPtr samplesPtr, uint countPerChannel, long pts) + { + if (_audioGraph == null || _inputNode == null || _audioBuffer == null) return; + // Assume VLC has the same number of channels as the audio graph input node + // as the input node is created by VLC set up callback + uint channelCount = _inputNode.EncodingProperties.ChannelCount; + uint sampleCount = countPerChannel * channelCount; + + ReadOnlySpan src = new((void*)samplesPtr, (int)sampleCount); + lock (_audioBufferLock) + { + for (int i = 0; i < sampleCount; i++) + { + _audioBuffer.PushBack(src[i]); + } + } + } + private void VlcPlayer_SeekableChanged(object sender, MediaPlayerSeekableChangedEventArgs e) { bool seekable = e.Seekable > 0; diff --git a/Screenbox.Core/Screenbox.Core.csproj b/Screenbox.Core/Screenbox.Core.csproj index e44826aaf..4f9cd077b 100644 --- a/Screenbox.Core/Screenbox.Core.csproj +++ b/Screenbox.Core/Screenbox.Core.csproj @@ -46,6 +46,7 @@ full false prompt + true x86 @@ -56,6 +57,7 @@ pdbonly false prompt + true ARM @@ -66,6 +68,7 @@ full false prompt + true ARM @@ -76,6 +79,7 @@ pdbonly false prompt + true ARM64 @@ -86,6 +90,7 @@ full false prompt + true ARM64 @@ -96,6 +101,7 @@ pdbonly false prompt + true x64 @@ -106,6 +112,7 @@ full false prompt + true x64 @@ -116,6 +123,7 @@ pdbonly false prompt + true PackageReference @@ -144,6 +152,7 @@ + diff --git a/Screenbox.Core/ViewModels/PlayerElementViewModel.cs b/Screenbox.Core/ViewModels/PlayerElementViewModel.cs index bacdc5933..4974ab340 100644 --- a/Screenbox.Core/ViewModels/PlayerElementViewModel.cs +++ b/Screenbox.Core/ViewModels/PlayerElementViewModel.cs @@ -11,7 +11,6 @@ using Screenbox.Core.Services; using System; using System.Linq; -using System.Threading.Tasks; using Windows.Foundation; using Windows.Media; using Windows.Media.Playback; @@ -83,34 +82,33 @@ public void Receive(MediaPlayerRequestMessage message) message.Reply(_mediaPlayer); } - public void Initialize(string[] swapChainOptions) + public async void Initialize(string[] swapChainOptions) { - Task.Run(() => + string[] args = _settingsService.GlobalArguments.Length > 0 + ? _settingsService.GlobalArguments.Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Concat(swapChainOptions).ToArray() + : swapChainOptions; + + VlcMediaPlayer player; + try + { + player = _libVlcService.Initialize(args); + } + catch (VLCException e) { - string[] args = _settingsService.GlobalArguments.Length > 0 - ? _settingsService.GlobalArguments.Split(' ', StringSplitOptions.RemoveEmptyEntries) - .Concat(swapChainOptions).ToArray() - : swapChainOptions; + player = _libVlcService.Initialize(swapChainOptions); + Messenger.Send(new ErrorMessage( + _resourceService.GetString(ResourceName.FailedToInitializeNotificationTitle), e.Message)); + } - VlcMediaPlayer player; - try - { - player = _libVlcService.Initialize(args); - } - catch (VLCException e) - { - player = _libVlcService.Initialize(swapChainOptions); - Messenger.Send(new ErrorMessage( - _resourceService.GetString(ResourceName.FailedToInitializeNotificationTitle), e.Message)); - } + _mediaPlayer = player; + VlcPlayer = player.VlcPlayer; + player.PlaybackStateChanged += OnPlaybackStateChanged; + player.PositionChanged += OnPositionChanged; + player.MediaFailed += OnMediaFailed; + Messenger.Send(new MediaPlayerChangedMessage(player)); - _mediaPlayer = player; - VlcPlayer = player.VlcPlayer; - player.PlaybackStateChanged += OnPlaybackStateChanged; - player.PositionChanged += OnPositionChanged; - player.MediaFailed += OnMediaFailed; - Messenger.Send(new MediaPlayerChangedMessage(player)); - }); + await player.InitAudioGraphAsync(); } public void OnClick() diff --git a/Screenbox/Controls/VolumeControl.xaml b/Screenbox/Controls/VolumeControl.xaml index 4de4cace5..4c26fc026 100644 --- a/Screenbox/Controls/VolumeControl.xaml +++ b/Screenbox/Controls/VolumeControl.xaml @@ -66,6 +66,7 @@ VerticalAlignment="Center" IsThumbToolTipEnabled="{x:Bind ShowValueText, Converter={StaticResource BoolNegationConverter}, Mode=OneWay}" Maximum="{x:Bind ViewModel.MaxVolume, Mode=OneWay}" + Minimum="0" PointerWheelChanged="{x:Bind ViewModel.OnPointerWheelChanged}" Value="{x:Bind ViewModel.Volume, Mode=TwoWay}" />