Skip to content
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
32 changes: 29 additions & 3 deletions Audio/AudioAlgos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@ private static byte[] ResampleSampleProviderToPcm16(ISampleProvider source, int
return outStream.ToArray();
}

/// <summary>
/// Sample provider for a WAV reader. NAudio's ToSampleProvider() only takes PCM and IEEE float,
/// so G.711 data (mu-law or A-law) is decoded to 16-bit PCM here first with NAudio's managed
/// decoders.
/// </summary>
private static ISampleProvider WavSampleProvider(WaveFileReader reader)
{
var format = reader.WaveFormat;
if (format.Encoding is not (WaveFormatEncoding.MuLaw or WaveFormatEncoding.ALaw))
return reader.ToSampleProvider();

using var g711 = new MemoryStream();
reader.CopyTo(g711);
byte[] encoded = g711.ToArray();
byte[] pcm = new byte[encoded.Length * 2];
bool muLaw = format.Encoding == WaveFormatEncoding.MuLaw;
for (int i = 0; i < encoded.Length; i++)
{
short sample = muLaw ? MuLawDecoder.MuLawToLinearSample(encoded[i]) : ALawDecoder.ALawToLinearSample(encoded[i]);
pcm[i * 2] = (byte)sample;
pcm[i * 2 + 1] = (byte)(sample >> 8);
}
var pcmFormat = new WaveFormat(format.SampleRate, 16, format.Channels);
return new RawSourceWaveStream(new MemoryStream(pcm), pcmFormat).ToSampleProvider();
}

/// <summary>
/// Normalizes 16-bit PCM audio to a specified maximum amplitude to prevent clipping.
/// </summary>
Expand Down Expand Up @@ -156,7 +182,7 @@ public static byte[] ConvertWavToPcm(byte[] wavAudio, int targetSampleRateHz)
using var wavStream = new MemoryStream(wavAudio);
using var reader = new WaveFileReader(wavStream);

byte[] rawPcm = ResampleSampleProviderToPcm16(reader.ToSampleProvider(), targetSampleRateHz);
byte[] rawPcm = ResampleSampleProviderToPcm16(WavSampleProvider(reader), targetSampleRateHz);
Log.Debug($"Converted WAV to PCM: {rawPcm.Length} bytes, {targetSampleRateHz} Hz, 16-bit mono");
return rawPcm;
}
Expand Down Expand Up @@ -297,7 +323,7 @@ public static byte[] ReadWelcomeWavBytesAsPcmu(string filePath)

using var wavStream = new FileStream(filePath, FileMode.Open, FileAccess.Read);
using var reader = new WaveFileReader(wavStream);
byte[] pcm8kHz = ResampleSampleProviderToPcm16(reader.ToSampleProvider(), 8000);
byte[] pcm8kHz = ResampleSampleProviderToPcm16(WavSampleProvider(reader), 8000);

// Encode to PCMU
if (pcm8kHz.Length % 2 != 0)
Expand Down Expand Up @@ -342,7 +368,7 @@ public static byte[] ReadWelcomeWavBytesAsPcm16kHz(string filePath)
using var reader = new WaveFileReader(wavStream);

// Resample to 16kHz mono 16-bit PCM
byte[] pcm16kHz = ResampleSampleProviderToPcm16(reader.ToSampleProvider(), 16000);
byte[] pcm16kHz = ResampleSampleProviderToPcm16(WavSampleProvider(reader), 16000);
Log.Debug($"Converted WAV to 16kHz PCM: {pcm16kHz.Length} bytes");
return pcm16kHz;
}
Expand Down
141 changes: 141 additions & 0 deletions tests/SipBotLib.Tests/WavDecodeTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
using NAudio.Codecs;
using NAudio.Wave;
using SipBot;
using SipBot.Cli;
using Xunit;

namespace SipBotLib.Tests;

/// <summary>
/// WAV decoding accepts G.711 (mu-law, A-law) files as well as PCM: NAudio's ToSampleProvider()
/// alone throws "Unsupported source encoding" on them, which made `sipbot play` reject mu-law WAVs.
/// </summary>
public class WavDecodeTests : IDisposable
{
private const int Rate = 8000;
private readonly List<string> _files = new();

public void Dispose()
{
foreach (var f in _files)
File.Delete(f);
}

/// <summary>One second of a 440 Hz tone as 16-bit samples.</summary>
private static short[] Tone()
{
var samples = new short[Rate];
for (int i = 0; i < samples.Length; i++)
samples[i] = (short)(8000 * Math.Sin(2 * Math.PI * 440 * i / Rate));
return samples;
}

private static byte[] Wav(WaveFormat format, byte[] data)
{
using var ms = new MemoryStream();
using (var writer = new WaveFileWriter(ms, format))
writer.Write(data, 0, data.Length);
return ms.ToArray();
}

private static byte[] MuLawWav(short[] samples) =>
Wav(WaveFormat.CreateMuLawFormat(Rate, 1), samples.Select(MuLawEncoder.LinearToMuLawSample).ToArray());

private static byte[] ALawWav(short[] samples) =>
Wav(WaveFormat.CreateALawFormat(Rate, 1), samples.Select(ALawEncoder.LinearToALawSample).ToArray());

private static short[] Samples(byte[] pcm)
{
var s = new short[pcm.Length / 2];
Buffer.BlockCopy(pcm, 0, s, 0, s.Length * 2);
return s;
}

private string TempFile(byte[] contents)
{
string path = Path.Combine(Path.GetTempPath(), $"sipbotlib-wav-{Guid.NewGuid():N}.wav");
File.WriteAllBytes(path, contents);
_files.Add(path);
return path;
}

public static TheoryData<string> G711Encodings() => new() { "mulaw", "alaw" };

private static (byte[] Wav, short[] Expected) G711(string encoding)
{
var tone = Tone();
return encoding == "mulaw"
? (MuLawWav(tone), tone.Select(MuLawEncoder.LinearToMuLawSample).Select(MuLawDecoder.MuLawToLinearSample).ToArray())
: (ALawWav(tone), tone.Select(ALawEncoder.LinearToALawSample).Select(ALawDecoder.ALawToLinearSample).ToArray());
}

[Theory]
[MemberData(nameof(G711Encodings))]
public void ConvertWavToPcm_decodes_G711(string encoding)
{
var (wav, expected) = G711(encoding);

var pcm = Samples(AudioAlgos.ConvertWavToPcm(wav, Rate));

Assert.Equal(expected.Length, pcm.Length);
// Float round trip through the sample provider can move a sample by one step.
Assert.All(expected.Zip(pcm), p => Assert.InRange(p.Second - p.First, -1, 1));
}

[Theory]
[MemberData(nameof(G711Encodings))]
public void ConvertWavToPcm_resamples_G711(string encoding)
{
var (wav, _) = G711(encoding);

var pcm = AudioAlgos.ConvertWavToPcm(wav, 16000);

Assert.InRange(pcm.Length / 2, 2 * Rate - 64, 2 * Rate + 64);
}

[Fact]
public void ConvertWavToPcm_still_reads_PCM()
{
var tone = Tone();
var data = new byte[tone.Length * 2];
Buffer.BlockCopy(tone, 0, data, 0, data.Length);

var pcm = Samples(AudioAlgos.ConvertWavToPcm(Wav(new WaveFormat(Rate, 16, 1), data), Rate));

Assert.Equal(tone.Length, pcm.Length);
Assert.All(tone.Zip(pcm), p => Assert.InRange(p.Second - p.First, -1, 1));
}

[Fact]
public void ReadWelcomeWavBytesAsPcmu_reads_a_mulaw_file()
{
var tone = Tone();
var mulaw = tone.Select(MuLawEncoder.LinearToMuLawSample).ToArray();

var pcmu = AudioAlgos.ReadWelcomeWavBytesAsPcmu(TempFile(MuLawWav(tone)));

Assert.Equal(mulaw.Length, pcmu.Length);
// Re-encoding a decoded sample can land on the neighboring code.
Assert.All(mulaw.Zip(pcmu), p => Assert.InRange(
MuLawDecoder.MuLawToLinearSample(p.Second) - MuLawDecoder.MuLawToLinearSample(p.First), -300, 300));
}

[Fact]
public void ReadWelcomeWavBytesAsPcm16kHz_reads_an_alaw_file()
{
var pcm = AudioAlgos.ReadWelcomeWavBytesAsPcm16kHz(TempFile(ALawWav(Tone())));

Assert.InRange(pcm.Length / 2, 2 * Rate - 64, 2 * Rate + 64);
}

[Fact]
public void PlayWav_accepts_a_mulaw_file()
{
var endpoint = new AgentAudioEndPoint(keepAlive: false, wideband: false);

// Before the fix this threw "… is not a readable WAV … decode produced no PCM."
var ex = Record.Exception(() => endpoint.PlayWav(TempFile(MuLawWav(Tone()))));

Assert.Null(ex);
}
}
Loading