Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🎡 Implement deserialization for ogg files #72

Merged
merged 8 commits into from
Oct 16, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 1 addition & 6 deletions src/Bearded.Audio.Tests/Bearded.Audio.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,6 @@
</ProjectReference>
</ItemGroup>
<ItemGroup>
<None Update="assets\pcm_11025hz_8bit_mono.wav">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="assets\pcm_8000hz_8bit_stereo.wav">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Include="assets/*" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project>
34 changes: 29 additions & 5 deletions src/Bearded.Audio.Tests/Core/SoundBufferDataTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using VerifyTests;
using VerifyXunit;
using Xunit;

Expand All @@ -16,20 +17,43 @@ public sealed class SoundBufferDataTests
public Task VerifyWavDeserialization(string filename)
{
var soundBufferData = SoundBufferData.FromWav($"assets/{filename}.wav");
var buffers = soundBufferData.Buffers.Select(toBase64String).ToArray();

var settings = StaticConfig.DefaultVerifySettings;
settings.UseParameters(filename);
return Verifier.Verify(new
var obj = objectToCompare(soundBufferData);
var settings = settingsForVerify(filename);
return Verifier.Verify(obj, settings);
}

[Theory]
[InlineData("44100hz_mono")]
public Task VerifyOggDeserialization(string filename)
{
var soundBufferData = SoundBufferData.FromOgg($"assets/{filename}.ogg");

var obj = objectToCompare(soundBufferData);
var settings = settingsForVerify(filename);
return Verifier.Verify(obj, settings);
}

private static object objectToCompare(SoundBufferData soundBufferData)
{
var buffers = soundBufferData.Buffers.Select(toBase64String).ToArray();
return new
{
Buffers = buffers,
soundBufferData.Format,
soundBufferData.SampleRate,
}, settings);
};
}

private static string toBase64String(IEnumerable<short> arr)
{
return string.Join(' ', arr.Select(i => Convert.ToBase64String(BitConverter.GetBytes(i))));
}

private static VerifySettings settingsForVerify(string filename)
{
var settings = StaticConfig.DefaultVerifySettings;
settings.UseParameters(filename);
return settings;
}
}

Large diffs are not rendered by default.

Binary file added src/Bearded.Audio.Tests/assets/44100hz_mono.ogg
Binary file not shown.
116 changes: 116 additions & 0 deletions src/Bearded.Audio/Core/OggReader.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
using System;
using System.Collections.Immutable;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using NVorbis;

namespace Bearded.Audio;

sealed class OggReader : IDisposable
{
private readonly VorbisReader reader;
private float[] readBuffer = Array.Empty<float>();

public int ChannelCount => reader.Channels;

public int SampleRate => reader.SampleRate;

public bool Ended => reader.IsEndOfStream;

private OggReader(VorbisReader reader)
{
this.reader = reader;
}

public ImmutableArray<short[]> ReadAllRemainingBuffers(int maxBufferSize)
{
if (maxBufferSize <= 0)
{
throw new ArgumentException("Max buffer size must be positive.", nameof(maxBufferSize));
}
if (Ended)
{
return ImmutableArray<short[]>.Empty;
}

var bufferSize = largestBufferSizeDivisibleByChannelCount(maxBufferSize);
var totalSampleCountRemaining = reader.TotalSamples - reader.SamplePosition;
var fullBuffersNeeded = totalSampleCountRemaining / bufferSize;
var partialBuffersNeeded = totalSampleCountRemaining % bufferSize > 0 ? 1 : 0;
var totalBuffersNeeded = fullBuffersNeeded + partialBuffersNeeded;

if (totalBuffersNeeded > int.MaxValue)
{
throw new InvalidOperationException(
$"Cannot read a stream with more than {int.MaxValue} buffers remaining.");
}

var builder = ImmutableArray.CreateBuilder<short[]>((int) totalBuffersNeeded);
for (var i = 0; i < builder.Capacity; i++)
{
builder.Add(readSamples(bufferSize));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we need to treat a potentially partial last buffer differently?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The readSamples already handles the partial buffer correctly.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see - I did not realise that Reader.ReadSamples did this automatically. I was looking at the array/spam allocation and couldn't find it there. It's fine then (and so was the name of the readSamples parameter) though it is perhaps slightly unfortunate that we may allocate a big float array even if we are only gonna use a part of it.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sadly that's the way it is, and we try to limit this problem by not making the buffer size too big. In practice - especially for ogg files - the files will hopefully be so long that most of the time you're filling at least a few buffers fully.

}

return builder.MoveToImmutable();
}

public bool TryReadSingleBuffer([NotNullWhen(true)] out short[]? buffer, int maxBufferSize)
{
if (maxBufferSize <= 0)
{
throw new ArgumentException("Max buffer size must be positive.", nameof(maxBufferSize));
}

if (Ended)
{
buffer = default;
return false;
}

var bufferSize = largestBufferSizeDivisibleByChannelCount(maxBufferSize);
buffer = readSamples(bufferSize);

return true;
}

private short[] readSamples(int sampleCount)
{
const short maxSafeValue = 32767;

ensureReadBufferCapacity(sampleCount);
var readSpan = new Span<float>(readBuffer, 0, sampleCount);

var readSampleCount = reader.ReadSamples(readSpan);

var buffer = new short[readSampleCount];
for (var i = 0; i < buffer.Length; i++)
{
buffer[i] = (short) (maxSafeValue * readSpan[i]);
}

return buffer;
}

private void ensureReadBufferCapacity(int capacity)
{
if (readBuffer.Length < capacity)
{
readBuffer = new float[capacity];
}
}

private int largestBufferSizeDivisibleByChannelCount(int maxBufferSize)
{
return maxBufferSize - (maxBufferSize % ChannelCount);
}

public void Dispose()
{
reader.Dispose();
}

public static OggReader FromStream(Stream file)
{
return new OggReader(new VorbisReader(file) { ClipSamples = true });
}
}
37 changes: 37 additions & 0 deletions src/Bearded.Audio/Core/SoundBufferData.FromOgg.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System;
using System.IO;
using OpenTK.Audio.OpenAL;

namespace Bearded.Audio;

public sealed partial class SoundBufferData
{
/// <summary>
/// Extracts the buffer data from an ogg file.
/// </summary>
/// <param name="file">The file to load the data from.</param>
/// <returns>A SoundBufferData object containing the data from the specified file.</returns>
public static SoundBufferData FromOgg(string file)
{
return FromOgg(File.OpenRead(file));
}

/// <summary>
/// Extracts the buffer data from an ogg file.
/// </summary>
/// <param name="file">The file to load the data from.</param>
/// <returns>A SoundBufferData object containing the data from the specified file.</returns>
public static SoundBufferData FromOgg(Stream file)
{
using var reader = OggReader.FromStream(file);
var buffers = reader.ReadAllRemainingBuffers(maxBufferSize);
return new SoundBufferData(buffers, getSoundFormat(reader.ChannelCount), reader.SampleRate);
}

private static ALFormat getSoundFormat(int channels) => channels switch
{
1 => ALFormat.Mono16,
2 => ALFormat.Stereo16,
_ => throw new NotSupportedException("The specified sound format is not supported.")
};
}
2 changes: 0 additions & 2 deletions src/Bearded.Audio/Core/SoundBufferData.FromWav.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,6 @@ private static void readFileHeader(

private static List<short[]> convertToBuffers(byte[] data)
{
const int maxBufferSize = 16384;

var dataPointCount = data.Length / 2;
var buffersNeeded = dataPointCount / maxBufferSize + (dataPointCount % maxBufferSize == 0 ? 0 : 1);

Expand Down
2 changes: 2 additions & 0 deletions src/Bearded.Audio/Core/SoundBufferData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ namespace Bearded.Audio;
[PublicAPI]
public sealed partial class SoundBufferData
{
private const int maxBufferSize = 16384;

internal IList<short[]> Buffers { get; }
internal ALFormat Format { get; }
internal int SampleRate { get; }
Expand Down