diff --git a/src/StreamShorts.Console/Commands/DefaultCommand.cs b/src/StreamShorts.Console/Commands/DefaultCommand.cs index 152dc91..9fe3b9d 100644 --- a/src/StreamShorts.Console/Commands/DefaultCommand.cs +++ b/src/StreamShorts.Console/Commands/DefaultCommand.cs @@ -3,12 +3,14 @@ namespace StreamShorts.Console.Commands; internal class DefaultCommand( IFileSystem fileSystem, IAnsiConsole console, - IAudioExtractor audioExtractor + IAudioExtractor audioExtractor, + IAudioConverter audioConverter ) : AsyncCommand { private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem)); private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console)); private readonly IAudioExtractor _audioExtractor = audioExtractor ?? throw new ArgumentNullException(nameof(audioExtractor)); + private readonly IAudioConverter _audioConverter = audioConverter ?? throw new ArgumentNullException(nameof(audioConverter)); internal class Settings : CommandSettings { @@ -61,6 +63,23 @@ internal class DefaultCommand( _console.MarkupLine($"[blue]Audio extracted[/] [green]successfully![/]"); + Stream? wavStream = null; + + _console.Status() + .Spinner(Spinner.Known.Dots) + .Start("Converting audio...", ctx => + { + wavStream = _audioConverter.ConvertMp3ToWav16(audioStream); + }); + + if (wavStream is null) + { + _console.MarkupLine("[red]Failed[/] to convert audio to WAV format."); + return 2; + } + + _console.MarkupLine($"[blue]Audio converted[/] [green]successfully![/]"); + return 0; } } \ No newline at end of file diff --git a/src/StreamShorts.Console/Program.cs b/src/StreamShorts.Console/Program.cs index 8d518aa..1743853 100644 --- a/src/StreamShorts.Console/Program.cs +++ b/src/StreamShorts.Console/Program.cs @@ -37,6 +37,7 @@ try services.AddSingleton(AnsiConsole.Console); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); }) .BuildApp() .RunAsync(args); @@ -53,25 +54,6 @@ finally await Log.CloseAndFlushAsync(); } -// using var mp3Stream = new MemoryStream(); -// using var mp4Stream = new FileStream(args[0], FileMode.Open, FileAccess.Read); - -// var wasExtracted = await FFMpegArguments -// .FromPipeInput(new StreamPipeSource(mp4Stream)) -// .OutputToPipe( -// new StreamPipeSink(mp3Stream), -// o => o.DisableChannel(Channel.Video).ForceFormat("mp3") -// ) -// .ProcessAsynchronously(); - -// // Step 2: Convert MP3 stream to 16khz wave format -// mp3Stream.Position = 0; -// using var reader = new Mp3FileReader(mp3Stream); -// var outFormat = new WaveFormat(16000, reader.WaveFormat.Channels); -// using var resampler = new MediaFoundationResampler(reader, outFormat); -// using var waveStream = new MemoryStream(); -// WaveFileWriter.WriteWavFileToStream(waveStream, resampler); - // // Step 3: Split the wave stream into 2 minute segments // waveStream.Position = 0; // var segmentDuration = TimeSpan.FromMinutes(2); diff --git a/src/StreamShorts.Library/Media/Audio/AudioConverter.cs b/src/StreamShorts.Library/Media/Audio/AudioConverter.cs new file mode 100644 index 0000000..b90245e --- /dev/null +++ b/src/StreamShorts.Library/Media/Audio/AudioConverter.cs @@ -0,0 +1,18 @@ + +using NAudio.Wave; + +namespace StreamShorts.Library.Media.Audio; + +public class AudioConverter : IAudioConverter +{ + public Stream ConvertMp3ToWav16(Stream mp3) + { + using var reader = new Mp3FileReader(mp3); + var outFormat = new WaveFormat(16000, reader.WaveFormat.Channels); + using var resampler = new MediaFoundationResampler(reader, outFormat); + var waveStream = new MemoryStream(); + WaveFileWriter.WriteWavFileToStream(waveStream, resampler); + waveStream.Position = 0; + return waveStream; + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs index c43563a..a78b13d 100644 --- a/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs +++ b/src/StreamShorts.Library/Media/Audio/AudioExtractor.cs @@ -1,13 +1,19 @@ -using FFMpegCore; -using FFMpegCore.Pipes; - -using Channel = FFMpegCore.Enums.Channel; - namespace StreamShorts.Library.Media.Audio; /// public class AudioExtractor : IAudioExtractor { + private readonly IFFMpegService _ffmpegService = new FFMpegService(); + + public AudioExtractor() + { + } + + internal AudioExtractor(IFFMpegService ffmpegService) + { + _ffmpegService = ffmpegService; + } + public async Task ExtractMp3FromMp4Async(Stream video) { if (video is null) @@ -29,26 +35,20 @@ public class AudioExtractor : IAudioExtractor try { - using var mp3Stream = new MemoryStream(); + var mp3Stream = new MemoryStream(); using var mp4Stream = new MemoryStream(); await video.CopyToAsync(mp4Stream).ConfigureAwait(false); mp4Stream.Position = 0; - var wasExtracted = await FFMpegArguments - .FromPipeInput(new StreamPipeSource(mp4Stream)) - .OutputToPipe( - new StreamPipeSink(mp3Stream), - o => o.DisableChannel(Channel.Video).ForceFormat("mp3") - ) - .ProcessAsynchronously() - .ConfigureAwait(false); + var wasExtracted = await _ffmpegService.ExtractAudioFromVideoAsync(mp4Stream, mp3Stream).ConfigureAwait(false); if (wasExtracted is false) { throw new FailedAudioExtractionException("Failed to extract audio from the video stream."); } + mp3Stream.Position = 0; return mp3Stream; } catch (Exception e) when (e is not FailedAudioExtractionException) diff --git a/src/StreamShorts.Library/Media/Audio/IAudioConverter.cs b/src/StreamShorts.Library/Media/Audio/IAudioConverter.cs new file mode 100644 index 0000000..2022b31 --- /dev/null +++ b/src/StreamShorts.Library/Media/Audio/IAudioConverter.cs @@ -0,0 +1,6 @@ +namespace StreamShorts.Library.Media.Audio; + +public interface IAudioConverter +{ + Stream ConvertMp3ToWav16(Stream mp3); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/FFMpegService.cs b/src/StreamShorts.Library/Media/FFMpegService.cs new file mode 100644 index 0000000..509de62 --- /dev/null +++ b/src/StreamShorts.Library/Media/FFMpegService.cs @@ -0,0 +1,23 @@ +using System.Diagnostics.CodeAnalysis; + +using FFMpegCore; +using FFMpegCore.Enums; +using FFMpegCore.Pipes; + +namespace StreamShorts.Library.Media; + +[ExcludeFromCodeCoverage] +internal class FFMpegService : IFFMpegService +{ + public async Task ExtractAudioFromVideoAsync(Stream video, Stream audio) + { + return await FFMpegArguments + .FromPipeInput(new StreamPipeSource(video)) + .OutputToPipe( + new StreamPipeSink(audio), + static o => o.DisableChannel(Channel.Video).ForceFormat("mp3") + ) + .ProcessAsynchronously() + .ConfigureAwait(false); + } +} \ No newline at end of file diff --git a/src/StreamShorts.Library/Media/IFFMpegService.cs b/src/StreamShorts.Library/Media/IFFMpegService.cs new file mode 100644 index 0000000..50e10c8 --- /dev/null +++ b/src/StreamShorts.Library/Media/IFFMpegService.cs @@ -0,0 +1,6 @@ +namespace StreamShorts.Library.Media; + +internal interface IFFMpegService +{ + Task ExtractAudioFromVideoAsync(Stream video, Stream audio); +} \ No newline at end of file diff --git a/src/StreamShorts.Library/StreamShorts.Library.csproj b/src/StreamShorts.Library/StreamShorts.Library.csproj index 7c67d16..281f941 100644 --- a/src/StreamShorts.Library/StreamShorts.Library.csproj +++ b/src/StreamShorts.Library/StreamShorts.Library.csproj @@ -1,5 +1,6 @@  + diff --git a/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioConverterTests.cs b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioConverterTests.cs new file mode 100644 index 0000000..a5dcf03 --- /dev/null +++ b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioConverterTests.cs @@ -0,0 +1,10 @@ +namespace StreamShorts.Library.Tests.Unit.Media.Audio; + +public class AudioConverterTests +{ + [Fact] + public void Test() + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs index 7736e2b..4f0707f 100644 --- a/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs +++ b/tests/StreamShorts.Library.Tests/Unit/Media/Audio/AudioExtractorTests.cs @@ -1,8 +1,18 @@ +using System.Text; + +using StreamShorts.Library.Media; + namespace StreamShorts.Library.Tests.Unit.Media.Audio; public class AudioExtractorTests { - private readonly AudioExtractor _sut = new(); + private readonly Mock _mockFfmpegService = new(); + private readonly AudioExtractor _sut; + + public AudioExtractorTests() + { + _sut = new(_mockFfmpegService.Object); + } [Fact] public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow() @@ -33,4 +43,93 @@ public class AudioExtractorTests await action.Should().ThrowAsync(); } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceThrows_ItShouldThrow() + { + var mockStream = new Mock(); + mockStream.Setup(static s => s.CanRead).Returns(true); + mockStream.Setup(static s => s.CanSeek).Returns(true); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ThrowsAsync(new Exception()); + + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceFailsExtraction_ItShouldThrow() + { + var mockStream = new Mock(); + mockStream.Setup(static s => s.CanRead).Returns(true); + mockStream.Setup(static s => s.CanSeek).Returns(true); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(false); + + var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + await action.Should().ThrowAsync(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceSucceedsAtExtractingAudio_ItShouldReturnStream() + { + var mockStream = new Mock(); + mockStream.Setup(static s => s.CanRead).Returns(true); + mockStream.Setup(static s => s.CanSeek).Returns(true); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(true); + + var result = await _sut.ExtractMp3FromMp4Async(mockStream.Object); + + result.Should().BeAssignableTo(); + result.Should().BeOfType(); + } + + [Fact] + public async Task ExtractMp3FromMp4Async_WhenCalled_ItShouldNotMutatePositionOfPassedStream() + { + var text = "hello world"; + var textByte = Encoding.UTF8.GetBytes(text); + var stream = new MemoryStream(textByte); + + var positionToRead = 5; + var buffer = new byte[5]; + await stream.ReadAsync(buffer.AsMemory(0, positionToRead), TestContext.Current.CancellationToken); + + _mockFfmpegService. + Setup( + static m => m.ExtractAudioFromVideoAsync( + It.IsAny(), + It.IsAny() + ) + ) + .ReturnsAsync(true); + + await _sut.ExtractMp3FromMp4Async(stream); + + stream.Position.Should().Be(positionToRead); + } } \ No newline at end of file