diff --git a/Audio/AudioAlgos.cs b/Audio/AudioAlgos.cs index 05e8959..42722dd 100644 --- a/Audio/AudioAlgos.cs +++ b/Audio/AudioAlgos.cs @@ -35,6 +35,32 @@ private static byte[] ResampleSampleProviderToPcm16(ISampleProvider source, int return outStream.ToArray(); } + /// + /// 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. + /// + 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(); + } + /// /// Normalizes 16-bit PCM audio to a specified maximum amplitude to prevent clipping. /// @@ -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; } @@ -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) @@ -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; } diff --git a/tests/SipBotLib.Tests/WavDecodeTests.cs b/tests/SipBotLib.Tests/WavDecodeTests.cs new file mode 100644 index 0000000..7b1577e --- /dev/null +++ b/tests/SipBotLib.Tests/WavDecodeTests.cs @@ -0,0 +1,141 @@ +using NAudio.Codecs; +using NAudio.Wave; +using SipBot; +using SipBot.Cli; +using Xunit; + +namespace SipBotLib.Tests; + +/// +/// 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. +/// +public class WavDecodeTests : IDisposable +{ + private const int Rate = 8000; + private readonly List _files = new(); + + public void Dispose() + { + foreach (var f in _files) + File.Delete(f); + } + + /// One second of a 440 Hz tone as 16-bit samples. + 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 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); + } +}