diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index ab3d25c..93a5dcb 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -16,10 +16,19 @@ jobs: uses: actions/setup-dotnet@v4 with: dotnet-version: 8.0.x - - name: PulseAudio - run: sudo apt-get install pulseaudio && pulseaudio --start - - name: Null sink - run: pactl load-module module-null-sink sink_name=DummySink + - name: Set up PulseAudio + run: | + sudo apt-get update + sudo apt-get install -y pulseaudio + - name: Start PulseAudio + run: | + pulseaudio --start + - name: Create default PulseAudio virtual sink + run: | + pactl load-module module-null-sink sink_name=VirtualSink sink_properties=device.description=Virtual_Sink + - name: Set default sink to VirtualSink + run: | + pactl set-default-sink VirtualSink - name: Restore dependencies run: dotnet restore - name: Build diff --git a/Console/Audio/AudioSender.cs b/Console/Audio/AudioSender.cs index d1e4599..0caa981 100644 --- a/Console/Audio/AudioSender.cs +++ b/Console/Audio/AudioSender.cs @@ -5,7 +5,7 @@ namespace Console.Audio; internal class AudioSender(int sourceId, ALFormat targetFormat) : IAsyncDisposable { - private readonly Channel _queue = Channel.CreateBounded(50); + private readonly Channel _queue = Channel.CreateBounded(50); public readonly int SampleRate = 48000; public readonly int Channels = 2; private readonly int[] _buffers = AL.GenBuffers(50); @@ -14,10 +14,13 @@ internal class AudioSender(int sourceId, ALFormat targetFormat) : IAsyncDisposab public void ClearBuffer() { _clearBuffer = true; - while (_queue.Reader.TryRead(out var _)) { } + while (_queue.Reader.TryRead(out var next)) + { + next.Dispose(); + } } - public async ValueTask Add(byte[] data) => await _queue.Writer.WriteAsync(data); + public async ValueTask Add(PcmPacket data) => await _queue.Writer.WriteAsync(data); private async ValueTask ClearBufferAL(CancellationToken token) { @@ -32,8 +35,8 @@ private async ValueTask ClearBufferAL(CancellationToken token) AL.SourceUnqueueBuffers(sourceId, queuedCount, bufferIds); foreach (var buffer in bufferIds) { - var next = await _queue.Reader.ReadAsync(token); - AL.BufferData(buffer, targetFormat, next, SampleRate); + using var next = await _queue.Reader.ReadAsync(token); + AL.BufferData(buffer, targetFormat, next.Data, SampleRate); AL.SourceQueueBuffer(sourceId, buffer); } } @@ -61,8 +64,8 @@ public async Task StartSending(CancellationToken token = default) for (int i = 0; i < fillBuffers.Count; i++) { - var item = fillBuffers[i]; - AL.BufferData(_buffers[i], targetFormat, item, SampleRate); + using var item = fillBuffers[i]; + AL.BufferData(_buffers[i], targetFormat, item.Data, SampleRate); AL.SourceQueueBuffer(sourceId, _buffers[i]); } @@ -91,8 +94,8 @@ out int releasedCount AL.SourceUnqueueBuffers(sourceId, releasedCount, bufferIds); foreach (var buffer in bufferIds) { - var next = await _queue.Reader.ReadAsync(token); - AL.BufferData(buffer, targetFormat, next, SampleRate); + using var next = await _queue.Reader.ReadAsync(token); + AL.BufferData(buffer, targetFormat, next.Data, SampleRate); AL.SourceQueueBuffer(sourceId, buffer); } } diff --git a/Console/Audio/Containers/Matroska/HttpSegmentedStream.cs b/Console/Audio/Containers/Matroska/HttpSegmentedStream.cs index 1ca88c2..56387dd 100644 --- a/Console/Audio/Containers/Matroska/HttpSegmentedStream.cs +++ b/Console/Audio/Containers/Matroska/HttpSegmentedStream.cs @@ -9,6 +9,7 @@ internal sealed class HttpSegmentedStream : Stream private readonly HttpClient _httpClient; private Stream? _httpStream; private bool _positionChanged; + private long _chunkPosition; private HttpSegmentedStream( IDownloadUrlHandler downloadUrlHandler, @@ -67,11 +68,21 @@ public override async ValueTask ReadAsync( CancellationToken cancellationToken = default ) { - if (_httpStream is null || _positionChanged) + if ( + _httpStream is null + || _positionChanged + && Position < _chunkPosition + && Position > (_chunkPosition + _httpStream?.Length) + ) { await ReadNextChunk(cancellationToken); _positionChanged = false; } + else if (_positionChanged) + { + _httpStream?.Seek(Math.Abs(Position - _chunkPosition), SeekOrigin.Begin); + _positionChanged = false; + } int bytesLeftToRead; var totalRead = 0; @@ -153,13 +164,17 @@ private async Task ReadNextChunk(CancellationToken cancellationToken) if (_httpStream is not null) await _httpStream.DisposeAsync(); - _httpStream = await _httpClient.GetStreamAsync( + var response = await _httpClient.GetAsync( AppendRangeToUrl( await _downloadUrlHandler.GetUrl(), Position, Position + BufferSize - 1 ), + HttpCompletionOption.ResponseContentRead, cancellationToken ); + + _httpStream = await response.Content.ReadAsStreamAsync(cancellationToken); + _chunkPosition = Position; } } diff --git a/Console/Audio/Containers/Matroska/Matroska.cs b/Console/Audio/Containers/Matroska/Matroska.cs index 7dbba5d..884c29d 100644 --- a/Console/Audio/Containers/Matroska/Matroska.cs +++ b/Console/Audio/Containers/Matroska/Matroska.cs @@ -9,18 +9,18 @@ using Console.Audio.Containers.Matroska.Types; using Console.Audio.DownloadHandlers; using Console.Extensions; +using Nito.Disposables; namespace Console.Audio.Containers.Matroska; using static ElementTypes; -internal class Matroska +internal class Matroska : IDisposable, IAsyncDisposable { private readonly EbmlReader _ebmlReader; private readonly AudioSender _sender; - public readonly Stream InputStream; + private readonly Stream _inputStream; private readonly IOpusDecoder _decoder; - private List? _audioTracks; private List? _cuePoints; private IMemoryOwner _memoryOwner = MemoryPool.Shared.Rent(1024); @@ -32,7 +32,7 @@ internal class Matroska private Matroska(Stream stream, AudioSender sender) { - InputStream = stream; + _inputStream = stream; _ebmlReader = new EbmlReader(stream); _sender = sender; _decoder = OpusCodecFactory.CreateDecoder(_sender.SampleRate, _sender.Channels); @@ -42,7 +42,7 @@ private Matroska(Stream stream, AudioSender sender) public void Dispose() { _ebmlReader.Dispose(); - InputStream.Dispose(); + _inputStream.Dispose(); _memoryOwner.Dispose(); CurrentTime = default; TotalTime = default; @@ -51,7 +51,7 @@ public void Dispose() public async ValueTask DisposeAsync() { await _ebmlReader.DisposeAsync(); - await InputStream.DisposeAsync(); + await _inputStream.DisposeAsync(); _memoryOwner.Dispose(); CurrentTime = default; TotalTime = default; @@ -141,27 +141,40 @@ await WriteBlock( } } - private static byte[] ShortsToBytes(short[] input, int offset, int length) + private static void ShortsToBytes(ReadOnlySpan input, Span output) { - byte[] processedValues = new byte[length * 2]; - for (int i = 0; i < length; i++) + for (int i = 0; i < input.Length; i++) { - processedValues[i * 2] = (byte)input[i + offset]; - processedValues[i * 2 + 1] = (byte)(input[i + offset] >> 8); + output[i * 2] = (byte)input[i]; + output[i * 2 + 1] = (byte)(input[i] >> 8); } - - return processedValues; } - private async ValueTask AddOpusPacket(byte[] data) + private async ValueTask AddOpusPacket(ReadOnlyMemory data) { - var frames = OpusPacketInfo.GetNumFrames(data); - var samplePerFrame = OpusPacketInfo.GetNumSamplesPerFrame(data, _sender.SampleRate); + var frames = OpusPacketInfo.GetNumFrames(data.Span); + var samplePerFrame = OpusPacketInfo.GetNumSamplesPerFrame(data.Span, _sender.SampleRate); var frameSize = frames * samplePerFrame; - short[] pcm = new short[frameSize * _sender.Channels]; - _decoder.Decode(data, pcm, frameSize); - var result = ShortsToBytes(pcm, 0, pcm.Length); - await _sender.Add(result); + var pcmSize = frameSize * _sender.Channels; + + var pcm = ArrayPool.Shared.Rent(pcmSize); + var pcmBytes = ArrayPool.Shared.Rent(pcmSize * 2); + + try + { + _decoder.Decode(data.Span, pcm.AsSpan()[..pcmSize], frameSize); + ShortsToBytes(pcm.AsSpan()[..pcmSize], pcmBytes.AsSpan()[..(pcmSize * 2)]); + await _sender.Add(new PcmPacket(pcmBytes, pcmSize * 2)); + } + catch + { + ArrayPool.Shared.Return(pcmBytes); + throw; + } + finally + { + ArrayPool.Shared.Return(pcm); + } } private async ValueTask WriteBlock( @@ -189,7 +202,7 @@ CancellationToken cancellationToken return; foreach (var frame in block.GetFrames()) - await AddOpusPacket(frame.ToArray()); + await AddOpusPacket(frame); return; } diff --git a/Console/Audio/PcmPacket.cs b/Console/Audio/PcmPacket.cs new file mode 100644 index 0000000..a303fc3 --- /dev/null +++ b/Console/Audio/PcmPacket.cs @@ -0,0 +1,17 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Console.Audio; + +internal struct PcmPacket(byte[] Data, int Lenght) : IDisposable +{ + private readonly byte[] _data = Data; + public readonly ReadOnlySpan Data => _data.AsSpan()[..Lenght]; + public int Lenght { get; } = Lenght; + + public readonly void Dispose() => ArrayPool.Shared.Return(_data); +} diff --git a/Console/Audio/PlayerController.cs b/Console/Audio/PlayerController.cs index e6cb2ef..4868c07 100644 --- a/Console/Audio/PlayerController.cs +++ b/Console/Audio/PlayerController.cs @@ -51,19 +51,19 @@ public int Volume public ALSourceState? State => SourceState(); public IVideo? Song => _queue.ElementAtOrDefault(_currentSongIndex); public IReadOnlyCollection Songs => _queue; - public LoopState LoopState { get; private set; } + public LoopState LoopState { get; set; } public PlayerController() { - _device = ALC.OpenDevice(""); + _device = ALC.OpenDevice(null); _context = ALC.CreateContext(_device, new ALContextAttributes()); ALC.MakeContextCurrent(_context); + var error = ALC.GetError(_device); // Check for any errors - if (ALC.GetError(_device) != AlcError.NoError) + if (error != AlcError.NoError) { - MessageBox.ErrorQuery("Error", "An error ocurred ", "Ok"); - return; + throw new Exception($"Error code: {error}"); } _sourceId = AL.GenSource(); @@ -128,6 +128,11 @@ public async Task SetAsync(ISearchResult item) _currentSongTokenSource.Cancel(); _currentSongIndex = 0; + AL.SourceStop(_sourceId); + _audioSender?.ClearBuffer(); + _currentSongTokenSource.Cancel(); + _currentSongIndex = 0; + if (item is VideoSearchResult videoSearchResult) { _queue = [videoSearchResult]; @@ -179,6 +184,9 @@ public async Task PlayAsync() return; } + if (_audioSender is not null) + await _audioSender.DisposeAsync(); + if (_matroskaPlayerBuffer is not null) await _matroskaPlayerBuffer.DisposeAsync(); @@ -193,11 +201,11 @@ public async Task PlayAsync() _audioSender, _currentSongTokenSource.Token ); + _matroskaPlayerBuffer.OnFinish += async () => { _currentSongTokenSource.Cancel(); await _audioSender.DisposeAsync(); - OnFinish?.Invoke(); }; @@ -232,8 +240,8 @@ public async Task SkipAsync(bool bypassLoop = false) if (LoopState == LoopState.ALL && _currentSongIndex >= _queue.Count) _currentSongIndex = 0; - AL.SourceStop(_sourceId); _audioSender?.ClearBuffer(); + AL.SourceStop(_sourceId); } } @@ -250,12 +258,6 @@ public async Task GoBackAsync() } } - public async Task SetLoop(LoopState newState) - { - using var _ = await _lock.LockAsync(); - LoopState = newState; - } - public async Task PauseAsync() { using var _ = await _lock.LockAsync(); diff --git a/Console/Views/PlayerView.cs b/Console/Views/PlayerView.cs index f66fb0e..a1275ae 100644 --- a/Console/Views/PlayerView.cs +++ b/Console/Views/PlayerView.cs @@ -1,4 +1,4 @@ -using Console.Audio; +using Console.Audio; using Console.Extensions; using OpenTK.Audio.OpenAL; using Terminal.Gui; @@ -172,7 +172,7 @@ async Task NextSong(bool bypassLoop) } }; - loopButton.Accept += async (_, args) => + loopButton.Accept += (_, args) => { LoopState nextState = player.LoopState switch { @@ -182,7 +182,7 @@ async Task NextSong(bool bypassLoop) _ => throw new InvalidOperationException("Unknown loop state") }; - await player.SetLoop(nextState); + player.LoopState = nextState; loopButton.Text = player.LoopState switch {