WebAudio
Sound from C# with the Web Audio API: the one-line ways to make a noise, and the node graph everything interesting is built from - filters, analysers, reverb, spatial panning, live streams and your own DSP in an audio worklet.
@inject Bit.Butil.WebAudio webAudioMDN reference
IsSupported reports whether the runtime exposes AudioContext. Butil manages a single shared context behind the scenes: Resume wakes it up (required on some mobile browsers before any sound can play) and Suspend releases the audio hardware while keeping the graph intact.
@inject Bit.Butil.WebAudio webAudio
var isSupported = await webAudio.IsSupported();
await webAudio.Resume(); // call from a click handler to satisfy autoplay policies
await webAudio.Suspend(); // silence everything without tearing the context downPlayTone spins up an oscillator - sine, triangle, square or sawtooth - at the given frequency. A positive durationMs stops it automatically; a duration of 0 keeps the tone running until you stop it through the returned AudioPlaybackHandle. The startGain sets the per-source volume.
// one-shot beep, stops itself after 600 ms:
await webAudio.PlayTone(440, durationMs: 600, waveform: "sine", startGain: 0.5);
// open-ended tone you stop manually:
var handle = await webAudio.PlayTone(220, durationMs: 0, waveform: "sawtooth");
// ...
await handle.Stop();PlayBuffer decodes any container the browser understands (wav, mp3, ogg, ...) from a byte array and plays it - bytes you fetched over HTTP, read from a user file or, as here, synthesized in C#. Set loop to keep it repeating; the returned handle stops it and adjusts its gain while playing.
byte[] bytes = await httpClient.GetByteArrayAsync("music/chime.wav");
var handle = await webAudio.PlayBuffer(bytes, startGain: 1.0, loop: false);
await handle.SetGain(0.3); // duck the volume while it plays
await handle.Stop();Every Butil-managed playback routes through one master gain node. SetMasterGain (0 to 1) scales tones and buffers alike - the natural hook for an app-wide volume slider or a mute toggle.
await webAudio.SetMasterGain(0.5); // half volume for everything
await webAudio.SetMasterGain(0); // mutecurrentTime is the clock every scheduled start, stop and ramp is measured against - it moves only while the context runs, and it is what makes a sequence stay in time in a way a .NET timer cannot. The sample rate is the device's, and everything decoded is resampled to it.
var state = await webAudio.GetState(); // Suspended until a gesture resumes it
var now = await webAudio.GetCurrentTime(); // seconds, on the audio clock
var rate = await webAudio.GetSampleRate(); // 48000 on most devicesDecoding is the expensive step and the samples are large, so decode once and build a cheap source per playback - that is how a game plays the same effect fifty times without fifty decodes. A source is single-use: once started and stopped it cannot be restarted, which is why they are made per playback rather than kept.
var buffer = await webAudio.DecodeAudioData(bytes);
// buffer.Duration, buffer.SampleRate, buffer.NumberOfChannels
var source = await webAudio.CreateBufferSource(buffer!, loop: false, playbackRate: 1);
await source!.ConnectToDestination();
await source.Start();
await source.SetParam("playbackRate", 1.5); // faster, and higher in pitch
await source.Stop(whenSeconds: 2); // scheduled on the audio threadThis is the whole idea of Web Audio in one sample: an oscillator into a filter into a gain into the output, wired up from C#. The sweep ramps the filter's cutoff on the audio thread, which is why it sounds smooth - a ramp is scheduled once and runs at sample rate, where a timer in .NET would step audibly.
var osc = await webAudio.CreateOscillator(AudioOscillatorType.Sawtooth, 110);
var filter = await webAudio.CreateBiquadFilter(BiquadFilterType.Lowpass, frequency: 300, q: 12);
var gain = await webAudio.CreateGain(0.0001);
await osc!.Connect(filter!);
await filter.Connect(gain!);
await gain.ConnectToDestination();
await osc.Start();
await gain.RampParam("gain", 0.3, overSeconds: 0.05, exponential: true); // no click
await filter.RampParam("frequency", 4000, overSeconds: 2); // the sweepAn analyser passes audio through unchanged and lets you read it - which is what every level meter, spectrum display and oscilloscope is made of. Connect it as a second connection that goes nowhere else and it measures without being in the way. Each read crosses the interop boundary, so poll at the rate the UI updates, not faster.
var analyser = await webAudio.CreateAnalyser(fftSize: 1024, smoothingTimeConstant: 0.8);
await source.Connect(analyser!); // in addition to whatever else source feeds
var spectrum = await analyser!.GetByteFrequencyData(); // fftSize / 2 bins, 0-255
var wave = await analyser.GetByteTimeDomainData(); // fftSize samples around 128A stereo panner is a mixer's pan knob: left to right, and cheap. A full panner places a sound at a point relative to the listener, and with the HRTF model it can be above, behind or beside them - convincing on headphones, and noticeably more expensive. Only the relationship between listener and source matters, so either can move.
var panner = await webAudio.CreatePanner(new AudioPannerOptions
{
PanningModel = AudioPanningModel.Hrtf,
DistanceModel = AudioDistanceModel.Inverse,
PositionX = 2, PositionZ = -3,
RefDistance = 1, RolloffFactor = 1
});
await source.Connect(panner!);
await panner!.ConnectToDestination();
await panner.SetParam("positionX", -2); // jump
await panner.RampParam("positionX", 2, overSeconds: 4); // or glide across
await webAudio.SetListener(0, 0, 0); // where the ears areDelay, compression and distortion
CreateDelay / CreateDynamicsCompressor / CreateWaveShaper / CreateConvolverAn echo is a delay fed back into itself through a gain of less than one - the one place a Web Audio graph is allowed to contain a cycle. A compressor pulls loud passages down, which is also the standard guard against clipping when sources are mixed. A wave shaper maps every sample through a curve, and a convolver makes the signal sound as though it were played in whatever space its impulse response was recorded in.
var delay = await webAudio.CreateDelay(maxDelaySeconds: 1, delaySeconds: 0.25);
var feedback = await webAudio.CreateGain(0.4);
await delay!.Connect(feedback!);
await feedback.Connect(delay); // the cycle that makes it an echo
await delay.ConnectToDestination();
var compressor = await webAudio.CreateDynamicsCompressor(threshold: -30, ratio: 12);
var shaper = await webAudio.CreateWaveShaper(curve, oversample: "4x");
var reverb = await webAudio.CreateConvolver(impulseResponseBuffer!);Live sources, and audio that leaves the graph
CreateMediaElementSource / CreateMediaStreamSource / CreateMediaStreamDestinationA media element's audio can be routed through the graph and filtered - but note that it then reaches the speakers only through that graph, so a chain that is not connected to the destination silences it, and an element can be the source of exactly one node ever. A stream destination is the way out: the processed audio becomes an ordinary MediaStream, ready for MediaRecorder.
var elementSource = await webAudio.CreateMediaElementSource(audioElement);
var filter = await webAudio.CreateBiquadFilter(BiquadFilterType.Lowpass, 800);
await elementSource!.Connect(filter!);
await filter.ConnectToDestination(); // without this, the element is now silent
var mic = await mediaDevices.GetUserMedia(audio: true, video: false);
var micSource = await webAudio.CreateMediaStreamSource(mic!); // do not connect this to the speakers
var destination = await webAudio.CreateMediaStreamDestination();
await filter.Connect(destination!);
await mediaRecorder.Start(destination!.GetStream()); // records the processed signalAn audio worklet is the only place custom per-sample processing can live. The processor is JavaScript by necessity - it runs on the audio thread, which cannot call into .NET and must never block - and .NET drives it two ways: declared AudioParams, which are sample-accurate, and messages, which arrive whenever the thread gets to them. This one applies a gain and reports its peak level back.
await webAudio.AddWorkletModule("js/butil-gain-processor.js");
var node = await webAudio.CreateWorkletNode("butil-gain-processor",
new AudioWorkletNodeOptions { ProcessorOptions = "{\"reportIntervalSeconds\":0.1}" },
onMessage: json => peak = JsonSerializer.Deserialize<Report>(json)!.Peak);
await source.Connect(node!);
await node!.ConnectToDestination();
await node.SetParam("gain", 0.5); // the declared AudioParam - sample-accurate
await node.PostMessage("mute"); // a command - whenever the thread gets to itAPI reference
ValueTask<bool> IsSupported()ValueTask Resume()ValueTask Suspend()ValueTask SetMasterGain(double value)ValueTask<AudioPlaybackHandle> PlayBuffer(byte[] data, double startGain = 1.0, bool loop = false)ValueTask<AudioPlaybackHandle> PlayTone(double frequency, double durationMs = 0, string waveform = "sine", double startGain = 0.5)ValueTask DisposeAsync()Guid IdValueTask Stop()ValueTask SetGain(double value)ValueTask DisposeAsync()ValueTask<bool> IsWorkletSupported()ValueTask<AudioContextState> GetState()ValueTask<double> GetCurrentTime()ValueTask<double> GetSampleRate()ValueTask<double> GetMasterGain()ValueTask<AudioBufferHandle?> DecodeAudioData(byte[] data)ValueTask<AudioNodeHandle?> CreateGain(double gain = 1)ValueTask<AudioNodeHandle?> CreateBiquadFilter(BiquadFilterType type, double frequency, double q = 1, double gain = 0, double detune = 0)ValueTask<AnalyserNodeHandle?> CreateAnalyser(int fftSize = 2048, double smoothingTimeConstant = 0.8, double minDecibels = -100, double maxDecibels = -30)ValueTask<AudioNodeHandle?> CreateConvolver(AudioBufferHandle impulseResponse, bool normalize = true)ValueTask<AudioNodeHandle?> CreatePanner(AudioPannerOptions options)ValueTask<AudioNodeHandle?> CreateStereoPanner(double pan = 0)ValueTask<AudioNodeHandle?> CreateDelay(double maxDelaySeconds = 1, double delaySeconds = 0)ValueTask<AudioNodeHandle?> CreateDynamicsCompressor(double threshold = -24, double knee = 30, double ratio = 12, double attack = 0.003, double release = 0.25)ValueTask<AudioNodeHandle?> CreateWaveShaper(double[] curve, string oversample = "none")ValueTask<AudioSourceNodeHandle?> CreateOscillator(AudioOscillatorType type, double frequency, double detune = 0)ValueTask<AudioSourceNodeHandle?> CreateBufferSource(AudioBufferHandle buffer, bool loop = false, double loopStartSeconds = 0, double loopEndSeconds = 0, double playbackRate = 1, double detune = 0)ValueTask<AudioSourceNodeHandle?> CreateConstantSource(double offset = 1)ValueTask<AudioNodeHandle?> CreateMediaElementSource(ElementReference mediaElement)ValueTask<AudioNodeHandle?> CreateMediaStreamSource(MediaStreamHandle stream)ValueTask<MediaStreamAudioDestinationHandle?> CreateMediaStreamDestination()ValueTask<bool> AddWorkletModule(string moduleUrl)ValueTask<AudioWorkletNodeHandle?> CreateWorkletNode(string processorName, AudioWorkletNodeOptions? options = null, Action<string>? onMessage = null)ValueTask<bool> SetListener(double x, double y, double z, double forwardX = 0, double forwardY = 0, double forwardZ = -1, double upX = 0, double upY = 1, double upZ = 0)ValueTask<bool> Connect(AudioNodeHandle destination)ValueTask<bool> ConnectToDestination()ValueTask<bool> Disconnect()ValueTask<bool> SetParam(string name, double value, double afterSeconds = 0)ValueTask<bool> RampParam(string name, double value, double overSeconds, bool exponential = false)ValueTask<bool> CancelScheduledParam(string name)ValueTask<bool> SetProperty(string name, string | double | bool value)ValueTask<bool> Start(double whenSeconds = 0, double offsetSeconds = 0, double durationSeconds = 0)ValueTask<bool> Stop(double whenSeconds = 0)ValueTask<byte[]?> GetByteFrequencyData()ValueTask<byte[]?> GetByteTimeDomainData()ValueTask<double[]?> GetFloatFrequencyData()ValueTask<bool> SetFftSize(int fftSize)ValueTask<bool> PostMessage(string message)MediaStreamHandle GetStream()Id, Duration, SampleRate, NumberOfChannels, Length