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.
HttpClient httpClient
Bit.Butil.WebAudio webAudio
{
private async Task Play()
{
// Any encoded format the browser can decode - the file itself is the sample, and it lives in
// wwwroot like any other static asset.
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.
Bit.Butil.WebAudio webAudio
{
// Whatever is making the sound - an oscillator, a decoded buffer, a media element or the
// microphone. An analyser reads what passes through it and changes nothing.
private AudioNodeHandle? source;
private async Task Watch()
{
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 128
}
}A 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.
Bit.Butil.WebAudio webAudio
{
private AudioNodeHandle? source; // whatever is making the sound
private async Task Place()
{
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 are
}
}Delay, 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.
Bit.Butil.WebAudio webAudio
Bit.Butil.MediaDevices mediaDevices
Bit.Butil.MediaRecorder mediaRecorder
<audio @ref="audioElement" src="music/chime.wav" controls></audio>
{
private ElementReference audioElement;
private async Task Route()
{
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 signal
}
}An 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.
Bit.Butil.WebAudio webAudio
{
private double? peak;
private AudioWorkletNodeHandle? node;
private record Report(double Peak, bool Muted);
private async Task Start(AudioNodeHandle source)
{
// The module has to be loaded before a node naming one of its processors can be created,
// and a worklet needs a secure context - IsWorkletSupported is false without one.
await webAudio.AddWorkletModule("js/butil-gain-processor.js");
node = await webAudio.CreateWorkletNode("butil-gain-processor",
new AudioWorkletNodeOptions { ProcessorOptions = "{\"reportIntervalSeconds\":0.1}" },
onMessage: json => InvokeAsync(() =>
{
peak = JsonSerializer.Deserialize<Report>(json,
new JsonSerializerOptions(JsonSerializerDefaults.Web))!.Peak;
StateHasChanged();
}));
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 it
}
}// It has to be JavaScript, and it has to be a file: an AudioWorklet processor runs on the audio
// thread, which cannot call into .NET and must never block. What crosses back to C# is what this
// file chooses to send - here, a peak level a few times a second, over the node's message port.
class ButilGainProcessor extends AudioWorkletProcessor {
// What SetParam addresses. A declared parameter is applied on the audio thread per sample,
// which is the difference between an automated gain and a stepped one.
static get parameterDescriptors() {
return [{ name: 'gain', defaultValue: 1, minValue: 0, maxValue: 4, automationRate: 'a-rate' }];
}
constructor(options) {
super();
// AudioWorkletNodeOptions.ProcessorOptions arrives here. Reporting every render quantum
// would be 375 messages a second for no benefit - the UI cannot use them.
this._interval = options?.processorOptions?.reportIntervalSeconds ?? 0.1;
this._muted = false;
this._peak = 0;
this._elapsed = 0;
// What PostMessage reaches.
this.port.onmessage = e => {
const command = typeof e.data === 'string' ? e.data : '';
if (command === 'mute') this._muted = true;
else if (command === 'unmute') this._muted = false;
};
}
process(inputs, outputs, parameters) {
const input = inputs[0];
const output = outputs[0];
const gain = parameters.gain;
for (let channel = 0; channel < output.length; channel++) {
const source = input[channel];
const target = output[channel];
if (!source) {
target.fill(0);
continue;
}
for (let i = 0; i < target.length; i++) {
// An a-rate parameter arrives as one value per sample; a k-rate one, or a parameter
// that happens not to be changing, arrives as a single value for the whole block.
const value = gain.length > 1 ? gain[i] : gain[0];
const sample = this._muted ? 0 : source[i] * value;
target[i] = sample;
const magnitude = sample < 0 ? -sample : sample;
if (magnitude > this._peak) this._peak = magnitude;
}
}
const quantum = output[0] ? output[0].length : 128;
this._elapsed += quantum / sampleRate;
if (this._elapsed >= this._interval) {
// What the onMessage callback on the C# side receives, as a string.
this.port.postMessage(JSON.stringify({ peak: this._peak, muted: this._muted }));
this._elapsed = 0;
this._peak = 0;
}
// Keeping the processor alive even with no input: the node stays in the graph until .NET
// disposes it, rather than being collected the first time the source falls silent.
return true;
}
}
registerProcessor('butil-gain-processor', ButilGainProcessor);API 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