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}" />