feat: extract audio conversion to separate class and refine audio extraction
This commit is contained in:
@@ -3,12 +3,14 @@ namespace StreamShorts.Console.Commands;
|
|||||||
internal class DefaultCommand(
|
internal class DefaultCommand(
|
||||||
IFileSystem fileSystem,
|
IFileSystem fileSystem,
|
||||||
IAnsiConsole console,
|
IAnsiConsole console,
|
||||||
IAudioExtractor audioExtractor
|
IAudioExtractor audioExtractor,
|
||||||
|
IAudioConverter audioConverter
|
||||||
) : AsyncCommand<DefaultCommand.Settings>
|
) : AsyncCommand<DefaultCommand.Settings>
|
||||||
{
|
{
|
||||||
private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
private readonly IFileSystem _fileSystem = fileSystem ?? throw new ArgumentNullException(nameof(fileSystem));
|
||||||
private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console));
|
private readonly IAnsiConsole _console = console ?? throw new ArgumentNullException(nameof(console));
|
||||||
private readonly IAudioExtractor _audioExtractor = audioExtractor ?? throw new ArgumentNullException(nameof(audioExtractor));
|
private readonly IAudioExtractor _audioExtractor = audioExtractor ?? throw new ArgumentNullException(nameof(audioExtractor));
|
||||||
|
private readonly IAudioConverter _audioConverter = audioConverter ?? throw new ArgumentNullException(nameof(audioConverter));
|
||||||
|
|
||||||
internal class Settings : CommandSettings
|
internal class Settings : CommandSettings
|
||||||
{
|
{
|
||||||
@@ -61,6 +63,23 @@ internal class DefaultCommand(
|
|||||||
|
|
||||||
_console.MarkupLine($"[blue]Audio extracted[/] [green]successfully![/]");
|
_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;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -37,6 +37,7 @@ try
|
|||||||
services.AddSingleton(AnsiConsole.Console);
|
services.AddSingleton(AnsiConsole.Console);
|
||||||
services.AddSingleton<IFileSystem, FileSystem>();
|
services.AddSingleton<IFileSystem, FileSystem>();
|
||||||
services.AddSingleton<IAudioExtractor, AudioExtractor>();
|
services.AddSingleton<IAudioExtractor, AudioExtractor>();
|
||||||
|
services.AddSingleton<IAudioConverter, AudioConverter>();
|
||||||
})
|
})
|
||||||
.BuildApp()
|
.BuildApp()
|
||||||
.RunAsync(args);
|
.RunAsync(args);
|
||||||
@@ -53,25 +54,6 @@ finally
|
|||||||
await Log.CloseAndFlushAsync();
|
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
|
// // Step 3: Split the wave stream into 2 minute segments
|
||||||
// waveStream.Position = 0;
|
// waveStream.Position = 0;
|
||||||
// var segmentDuration = TimeSpan.FromMinutes(2);
|
// var segmentDuration = TimeSpan.FromMinutes(2);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
using FFMpegCore;
|
|
||||||
using FFMpegCore.Pipes;
|
|
||||||
|
|
||||||
using Channel = FFMpegCore.Enums.Channel;
|
|
||||||
|
|
||||||
namespace StreamShorts.Library.Media.Audio;
|
namespace StreamShorts.Library.Media.Audio;
|
||||||
|
|
||||||
/// <inheritdoc/>
|
/// <inheritdoc/>
|
||||||
public class AudioExtractor : IAudioExtractor
|
public class AudioExtractor : IAudioExtractor
|
||||||
{
|
{
|
||||||
|
private readonly IFFMpegService _ffmpegService = new FFMpegService();
|
||||||
|
|
||||||
|
public AudioExtractor()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal AudioExtractor(IFFMpegService ffmpegService)
|
||||||
|
{
|
||||||
|
_ffmpegService = ffmpegService;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Stream> ExtractMp3FromMp4Async(Stream video)
|
public async Task<Stream> ExtractMp3FromMp4Async(Stream video)
|
||||||
{
|
{
|
||||||
if (video is null)
|
if (video is null)
|
||||||
@@ -29,26 +35,20 @@ public class AudioExtractor : IAudioExtractor
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var mp3Stream = new MemoryStream();
|
var mp3Stream = new MemoryStream();
|
||||||
using var mp4Stream = new MemoryStream();
|
using var mp4Stream = new MemoryStream();
|
||||||
|
|
||||||
await video.CopyToAsync(mp4Stream).ConfigureAwait(false);
|
await video.CopyToAsync(mp4Stream).ConfigureAwait(false);
|
||||||
mp4Stream.Position = 0;
|
mp4Stream.Position = 0;
|
||||||
|
|
||||||
var wasExtracted = await FFMpegArguments
|
var wasExtracted = await _ffmpegService.ExtractAudioFromVideoAsync(mp4Stream, mp3Stream).ConfigureAwait(false);
|
||||||
.FromPipeInput(new StreamPipeSource(mp4Stream))
|
|
||||||
.OutputToPipe(
|
|
||||||
new StreamPipeSink(mp3Stream),
|
|
||||||
o => o.DisableChannel(Channel.Video).ForceFormat("mp3")
|
|
||||||
)
|
|
||||||
.ProcessAsynchronously()
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
if (wasExtracted is false)
|
if (wasExtracted is false)
|
||||||
{
|
{
|
||||||
throw new FailedAudioExtractionException("Failed to extract audio from the video stream.");
|
throw new FailedAudioExtractionException("Failed to extract audio from the video stream.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mp3Stream.Position = 0;
|
||||||
return mp3Stream;
|
return mp3Stream;
|
||||||
}
|
}
|
||||||
catch (Exception e) when (e is not FailedAudioExtractionException)
|
catch (Exception e) when (e is not FailedAudioExtractionException)
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace StreamShorts.Library.Media.Audio;
|
||||||
|
|
||||||
|
public interface IAudioConverter
|
||||||
|
{
|
||||||
|
Stream ConvertMp3ToWav16(Stream mp3);
|
||||||
|
}
|
||||||
@@ -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<bool> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace StreamShorts.Library.Media;
|
||||||
|
|
||||||
|
internal interface IFFMpegService
|
||||||
|
{
|
||||||
|
Task<bool> ExtractAudioFromVideoAsync(Stream video, Stream audio);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FFMpegCore" />
|
<PackageReference Include="FFMpegCore" />
|
||||||
|
<PackageReference Include="NAudio" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace StreamShorts.Library.Tests.Unit.Media.Audio;
|
||||||
|
|
||||||
|
public class AudioConverterTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Test()
|
||||||
|
{
|
||||||
|
throw new NotImplementedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
|
using System.Text;
|
||||||
|
|
||||||
|
using StreamShorts.Library.Media;
|
||||||
|
|
||||||
namespace StreamShorts.Library.Tests.Unit.Media.Audio;
|
namespace StreamShorts.Library.Tests.Unit.Media.Audio;
|
||||||
|
|
||||||
public class AudioExtractorTests
|
public class AudioExtractorTests
|
||||||
{
|
{
|
||||||
private readonly AudioExtractor _sut = new();
|
private readonly Mock<IFFMpegService> _mockFfmpegService = new();
|
||||||
|
private readonly AudioExtractor _sut;
|
||||||
|
|
||||||
|
public AudioExtractorTests()
|
||||||
|
{
|
||||||
|
_sut = new(_mockFfmpegService.Object);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow()
|
public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow()
|
||||||
@@ -33,4 +43,93 @@ public class AudioExtractorTests
|
|||||||
|
|
||||||
await action.Should().ThrowAsync<ArgumentException>();
|
await action.Should().ThrowAsync<ArgumentException>();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceThrows_ItShouldThrow()
|
||||||
|
{
|
||||||
|
var mockStream = new Mock<Stream>();
|
||||||
|
mockStream.Setup(static s => s.CanRead).Returns(true);
|
||||||
|
mockStream.Setup(static s => s.CanSeek).Returns(true);
|
||||||
|
|
||||||
|
_mockFfmpegService.
|
||||||
|
Setup(
|
||||||
|
static m => m.ExtractAudioFromVideoAsync(
|
||||||
|
It.IsAny<Stream>(),
|
||||||
|
It.IsAny<Stream>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ThrowsAsync(new Exception());
|
||||||
|
|
||||||
|
var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object);
|
||||||
|
|
||||||
|
await action.Should().ThrowAsync<FailedAudioExtractionException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceFailsExtraction_ItShouldThrow()
|
||||||
|
{
|
||||||
|
var mockStream = new Mock<Stream>();
|
||||||
|
mockStream.Setup(static s => s.CanRead).Returns(true);
|
||||||
|
mockStream.Setup(static s => s.CanSeek).Returns(true);
|
||||||
|
|
||||||
|
_mockFfmpegService.
|
||||||
|
Setup(
|
||||||
|
static m => m.ExtractAudioFromVideoAsync(
|
||||||
|
It.IsAny<Stream>(),
|
||||||
|
It.IsAny<Stream>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(false);
|
||||||
|
|
||||||
|
var action = async () => await _sut.ExtractMp3FromMp4Async(mockStream.Object);
|
||||||
|
|
||||||
|
await action.Should().ThrowAsync<FailedAudioExtractionException>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ExtractMp3FromMp4Async_WhenFFMpegServiceSucceedsAtExtractingAudio_ItShouldReturnStream()
|
||||||
|
{
|
||||||
|
var mockStream = new Mock<Stream>();
|
||||||
|
mockStream.Setup(static s => s.CanRead).Returns(true);
|
||||||
|
mockStream.Setup(static s => s.CanSeek).Returns(true);
|
||||||
|
|
||||||
|
_mockFfmpegService.
|
||||||
|
Setup(
|
||||||
|
static m => m.ExtractAudioFromVideoAsync(
|
||||||
|
It.IsAny<Stream>(),
|
||||||
|
It.IsAny<Stream>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
var result = await _sut.ExtractMp3FromMp4Async(mockStream.Object);
|
||||||
|
|
||||||
|
result.Should().BeAssignableTo<Stream>();
|
||||||
|
result.Should().BeOfType<MemoryStream>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[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<Stream>(),
|
||||||
|
It.IsAny<Stream>()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.ReturnsAsync(true);
|
||||||
|
|
||||||
|
await _sut.ExtractMp3FromMp4Async(stream);
|
||||||
|
|
||||||
|
stream.Position.Should().Be(positionToRead);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user