feat: extract audio conversion to separate class and refine audio extraction

This commit is contained in:
Stevan Freeborn
2025-07-27 23:33:05 -05:00
parent a53b3893ad
commit a1864371dd
10 changed files with 199 additions and 35 deletions
@@ -3,12 +3,14 @@ namespace StreamShorts.Console.Commands;
internal class DefaultCommand(
IFileSystem fileSystem,
IAnsiConsole console,
IAudioExtractor audioExtractor
IAudioExtractor audioExtractor,
IAudioConverter audioConverter
) : AsyncCommand<DefaultCommand.Settings>
{
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;
}
}
+1 -19
View File
@@ -37,6 +37,7 @@ try
services.AddSingleton(AnsiConsole.Console);
services.AddSingleton<IFileSystem, FileSystem>();
services.AddSingleton<IAudioExtractor, AudioExtractor>();
services.AddSingleton<IAudioConverter, AudioConverter>();
})
.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);
@@ -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;
/// <inheritdoc/>
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)
{
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)
@@ -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">
<ItemGroup>
<PackageReference Include="FFMpegCore" />
<PackageReference Include="NAudio" />
</ItemGroup>
</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;
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]
public async Task ExtractMp3FromMp4Async_WhenVideoIsNull_ItShouldThrow()
@@ -33,4 +43,93 @@ public class AudioExtractorTests
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);
}
}