Snippet

Convert capture buffers to 16 kHz PCM

A complete C# audio converter that turns native capture buffers into 16 kHz, mono, signed 16-bit PCM frames for live speech recognition.

csharpOct 6, 2026MIT

AI-generated, human-reviewed: the first draft was generated by an AI agent; I fact-checked, tested, and edited it. AI use policy

What it does#

Converts native capture buffers into the live-audio wire format used by Azure Speech: PCM signed 16-bit, mono, 16 kHz, little-endian. The converter keeps one resampling pipeline for the capture session, so resampler state survives buffer boundaries. Use one instance from one capture thread; create a separate instance per input stream.

You need#

  • .NET SDK 10
  • A project with ImplicitUsings enabled (the dotnet new default)
  • NAudio 2.2.1
  • Input buffers from a capture API such as WASAPI

Where it goes#

Save it in the native helper project as LivePcm16MonoConverter.cs. Create one converter per input WaveFormat, call Convert for each capture buffer on the capture thread, and dispose it when capture stops.

LivePcm16MonoConverter.cs
// SPDX-License-Identifier: MIT
// Copyright (c) 2026 Sarang Datye
using NAudio.Wave;
using NAudio.Wave.SampleProviders;

public sealed class LivePcm16MonoConverter : IDisposable
{
    public const int TargetSampleRate = 16_000;
    public const int TargetChannels = 1;

    private readonly BufferedWaveProvider _bufferedInput;
    private readonly ISampleProvider _pipeline;
    private readonly float[] _floatBuffer = new float[4096];
    private bool _disposed;

    public LivePcm16MonoConverter(WaveFormat inputFormat)
    {
        _bufferedInput = new BufferedWaveProvider(inputFormat)
        {
            BufferDuration = TimeSpan.FromSeconds(5),
            DiscardOnBufferOverflow = false,
            ReadFully = false
        };

        ISampleProvider samples = _bufferedInput.ToSampleProvider();
        if (samples.WaveFormat.Channels > TargetChannels)
        {
            samples = new MonoDownmixSampleProvider(samples);
        }

        _pipeline = samples.WaveFormat.SampleRate == TargetSampleRate
            ? samples
            : new WdlResamplingSampleProvider(samples, TargetSampleRate);
    }

    public byte[] Convert(byte[] buffer, int bytesRecorded)
    {
        ObjectDisposedException.ThrowIf(_disposed, this);

        if (buffer.Length == 0 || bytesRecorded <= 0)
        {
            return [];
        }

        if (bytesRecorded > buffer.Length)
        {
            throw new ArgumentOutOfRangeException(nameof(bytesRecorded), "The recorded byte count exceeds the buffer length.");
        }

        _bufferedInput.AddSamples(buffer, 0, bytesRecorded);

        using var output = new MemoryStream();
        int read;
        while ((read = _pipeline.Read(_floatBuffer, 0, _floatBuffer.Length)) > 0)
        {
            for (var i = 0; i < read; i++)
            {
                var scaled = (int)Math.Round(_floatBuffer[i] * short.MaxValue);
                var sample = (short)Math.Clamp(scaled, short.MinValue, short.MaxValue);
                output.WriteByte((byte)(sample & 0xFF));
                output.WriteByte((byte)((sample >> 8) & 0xFF));
            }
        }

        return output.ToArray();
    }

    public void Dispose()
    {
        _disposed = true;
    }

    private sealed class MonoDownmixSampleProvider : ISampleProvider
    {
        private readonly ISampleProvider _source;
        private readonly int _channels;
        private float[] _scratch = [];

        public MonoDownmixSampleProvider(ISampleProvider source)
        {
            _source = source;
            _channels = source.WaveFormat.Channels;
            WaveFormat = WaveFormat.CreateIeeeFloatWaveFormat(source.WaveFormat.SampleRate, TargetChannels);
        }

        public WaveFormat WaveFormat { get; }

        public int Read(float[] buffer, int offset, int count)
        {
            var needed = count * _channels;
            if (_scratch.Length < needed)
            {
                _scratch = new float[needed];
            }

            var read = _source.Read(_scratch, 0, needed);
            var frames = read / _channels;
            for (var frame = 0; frame < frames; frame++)
            {
                var sum = 0f;
                for (var channel = 0; channel < _channels; channel++)
                {
                    sum += _scratch[(frame * _channels) + channel];
                }

                buffer[offset + frame] = sum / _channels;
            }

            return frames;
        }
    }
}

Check that it works#

Create a stereo float sine wave, feed it once as one buffer and again through another converter as many small buffers, and compare the 16-bit PCM output. The outputs should match within a small sample tolerance, and the chunked output should have no boundary-sized dropouts. For one second of audio, the target output is 32,000 bytes.