From b36ddf074a3df44214cf77209018c04e7d92023e Mon Sep 17 00:00:00 2001 From: calebtt Date: Sat, 26 Sep 2026 01:16:43 -0500 Subject: [PATCH] Decode mu-law and A-law WAV files before resampling sipbot serve's play command rejected 8 kHz mu-law WAV files with "not a readable WAV ... decode produced no PCM", although AGENTS.md and --help say mu-law is accepted. AudioAlgos passed the WaveFileReader to NAudio's ToSampleProvider(), which only takes PCM and IEEE float and throws "Unsupported source encoding" for G.711 data. WAV readers now go through WavSampleProvider, which decodes mu-law and A-law data to 16-bit PCM with NAudio's managed decoders first. This covers ConvertWavToPcm (sipbot play) and both ReadWelcomeWav* helpers. Closes #29 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01SGcDAANyU5QKVK2Vtur1xc --- Audio/AudioAlgos.cs | 32 +++++- tests/SipBotLib.Tests/WavDecodeTests.cs | 141 ++++++++++++++++++++++++ 2 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 tests/SipBotLib.Tests/WavDecodeTests.cs 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); + } +}