loading

Support and context state

IsSupported / Resume / Suspend

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.

C#
@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 down
Live sample
context output
Results will appear here when you interact with the samples.

Play a tone

PlayTone

PlayTone 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.

C#
// 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();
Live sample
Frequency (440 Hz)
Waveform
Tone gain (0.5)
tone output
Results will appear here when you interact with the samples.

Play an audio buffer

PlayBuffer

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.

Razor
@inject HttpClient httpClient
@inject Bit.Butil.WebAudio webAudio

@code {
    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();
    }
}
Live sample
Playback gain (1.0)
buffer output
Results will appear here when you interact with the samples.

Master gain

SetMasterGain

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.

C#
await webAudio.SetMasterGain(0.5); // half volume for everything

await webAudio.SetMasterGain(0);   // mute
Live sample
Master gain (1.0)
master gain output
Results will appear here when you interact with the samples.

The context's clock

GetState / GetCurrentTime / GetSampleRate / IsWorkletSupported

currentTime 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.

C#
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 devices
Live sample
clock output
Results will appear here when you interact with the samples.

Decode once, play many times

DecodeAudioData / CreateBufferSource / Start / Stop

Decoding 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.

C#
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 thread
Live sample
Playback rate (1.00x)
Loop
graph output
Results will appear here when you interact with the samples.

A filter chain

CreateOscillator / CreateBiquadFilter / CreateGain / Connect / RampParam

This 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.

C#
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 sweep
Live sample
Filter type
Resonance / Q (8)
filter chain output
Results will appear here when you interact with the samples.

See the signal

CreateAnalyser / GetByteFrequencyData / GetByteTimeDomainData

An 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.

Razor
@inject Bit.Butil.WebAudio webAudio

@code {
    // 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
    }
}
Live sample
analyser output
Results will appear here when you interact with the samples.

Space

CreatePanner / CreateStereoPanner / SetListener

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.

Razor
@inject Bit.Butil.WebAudio webAudio

@code {
    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
    }
}
Live sample
Stereo pan (0.0)
spatial output
Results will appear here when you interact with the samples.

Delay, compression and distortion

CreateDelay / CreateDynamicsCompressor / CreateWaveShaper / CreateConvolver

An 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.

C#
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 sample
effects output
Results will appear here when you interact with the samples.

Live sources, and audio that leaves the graph

CreateMediaElementSource / CreateMediaStreamSource / CreateMediaStreamDestination

A 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.

Razor
@inject Bit.Butil.WebAudio webAudio
@inject Bit.Butil.MediaDevices mediaDevices
@inject Bit.Butil.MediaRecorder mediaRecorder

<audio @ref="audioElement" src="music/chime.wav" controls></audio>

@code {
    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
    }
}
Live sample
routing output
Results will appear here when you interact with the samples.

Your own DSP

AddWorkletModule / CreateWorkletNode / PostMessage

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.

@inject Bit.Butil.WebAudio webAudio

@code {
    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
    }
}
Live sample
Worklet gain (1.00)
worklet output
Results will appear here when you interact with the samples.
Warning:
Autoplay policies block un-gestured audio Browsers create the AudioContext in a suspended state until the user interacts with the page. Start your first playback - or call Resume - from a click or key handler; audio triggered from OnInitializedAsync will stay silent on mobile Safari and produce a console warning on Chromium.
Note:
Two levels, one context PlayTone and PlayBuffer are the shortcuts - a beep, a sound effect, no graph to wire up. Everything else builds a graph out of node handles, and both routes end at the same master gain, so SetMasterGain still ducks the lot. Nodes hold browser resources: dispose them when they leave the graph for good.
Note:
Ramp, do not set Setting a gain on a running signal is audible as a click. RampParam schedules the change on the audio thread instead - exponentially for anything the ear judges (loudness, pitch), because a linear fade to silence sounds like it stops abruptly at the end.

API reference

Member
Signature
Description
IsSupported
ValueTask<bool> IsSupported()
True when the runtime exposes AudioContext.
Resume
ValueTask Resume()
Resumes a suspended AudioContext. Mobile Safari requires this on the first user interaction.
Suspend
ValueTask Suspend()
Suspends the shared AudioContext.
SetMasterGain
ValueTask SetMasterGain(double value)
Sets the master gain (in [0, 1]) applied to every Butil-managed playback.
PlayBuffer
ValueTask<AudioPlaybackHandle> PlayBuffer(byte[] data, double startGain = 1.0, bool loop = false)
Decodes and plays the given audio bytes. Returns a handle for stop/gain control.
PlayTone
ValueTask<AudioPlaybackHandle> PlayTone(double frequency, double durationMs = 0, string waveform = &quot;sine&quot;, double startGain = 0.5)
Plays a sine/triangle/square/sawtooth oscillator; durationMs of 0 means an open-ended tone you stop manually.
DisposeAsync
ValueTask DisposeAsync()
Closes the underlying AudioContext and stops any in-flight playback; called automatically when the scoped service is disposed.
AudioPlaybackHandle.Id
Guid Id
The internal playback id.
AudioPlaybackHandle.Stop
ValueTask Stop()
Stops playback immediately.
AudioPlaybackHandle.SetGain
ValueTask SetGain(double value)
Sets per-source gain in [0, 1].
AudioPlaybackHandle.DisposeAsync
ValueTask DisposeAsync()
Stops the playback if it is still running.
IsWorkletSupported
ValueTask<bool> IsWorkletSupported()
True when the runtime exposes AudioWorkletNode. Worklets also need a secure context.
GetState
ValueTask<AudioContextState> GetState()
Suspended, Running or Closed.
GetCurrentTime
ValueTask<double> GetCurrentTime()
The audio clock in seconds - what every scheduled start, stop and ramp is measured against.
GetSampleRate
ValueTask<double> GetSampleRate()
The device's sample rate; everything decoded is resampled to it.
GetMasterGain
ValueTask<double> GetMasterGain()
The current master gain.
DecodeAudioData
ValueTask<AudioBufferHandle?> DecodeAudioData(byte[] data)
Decodes any container the browser understands into samples that can be played over and over.
CreateGain
ValueTask<AudioNodeHandle?> CreateGain(double gain = 1)
A volume control - the node to reach for whenever something needs to fade.
CreateBiquadFilter
ValueTask<AudioNodeHandle?> CreateBiquadFilter(BiquadFilterType type, double frequency, double q = 1, double gain = 0, double detune = 0)
One band of equalisation: a low-pass, a shelf, a notch.
CreateAnalyser
ValueTask<AnalyserNodeHandle?> CreateAnalyser(int fftSize = 2048, double smoothingTimeConstant = 0.8, double minDecibels = -100, double maxDecibels = -30)
Reads the signal without changing it - the basis of every meter and spectrum.
CreateConvolver
ValueTask<AudioNodeHandle?> CreateConvolver(AudioBufferHandle impulseResponse, bool normalize = true)
Convolution reverb: makes the signal sound as though played in the space the impulse was recorded in.
CreatePanner
ValueTask<AudioNodeHandle?> CreatePanner(AudioPannerOptions options)
Places a sound at a point in space relative to the listener.
CreateStereoPanner
ValueTask<AudioNodeHandle?> CreateStereoPanner(double pan = 0)
Simple left/right placement, at a fraction of a full panner's cost.
CreateDelay
ValueTask<AudioNodeHandle?> CreateDelay(double maxDelaySeconds = 1, double delaySeconds = 0)
Holds the signal back by a set time; fed back into itself, it is an echo.
CreateDynamicsCompressor
ValueTask<AudioNodeHandle?> CreateDynamicsCompressor(double threshold = -24, double knee = 30, double ratio = 12, double attack = 0.003, double release = 0.25)
Pulls loud passages down - also the standard guard against clipping a mix.
CreateWaveShaper
ValueTask<AudioNodeHandle?> CreateWaveShaper(double[] curve, string oversample = &quot;none&quot;)
Maps every sample through a curve: distortion, saturation, bit-crushing.
CreateOscillator
ValueTask<AudioSourceNodeHandle?> CreateOscillator(AudioOscillatorType type, double frequency, double detune = 0)
A tone generator. Connect it, then Start it.
CreateBufferSource
ValueTask<AudioSourceNodeHandle?> CreateBufferSource(AudioBufferHandle buffer, bool loop = false, double loopStartSeconds = 0, double loopEndSeconds = 0, double playbackRate = 1, double detune = 0)
Plays a decoded buffer. Single-use: one source per playback.
CreateConstantSource
ValueTask<AudioSourceNodeHandle?> CreateConstantSource(double offset = 1)
Emits a steady value - for driving several AudioParams from one ramp.
CreateMediaElementSource
ValueTask<AudioNodeHandle?> CreateMediaElementSource(ElementReference mediaElement)
Routes an audio or video element's sound through the graph. One node per element, ever.
CreateMediaStreamSource
ValueTask<AudioNodeHandle?> CreateMediaStreamSource(MediaStreamHandle stream)
Feeds a microphone or a screen share's audio into the graph.
CreateMediaStreamDestination
ValueTask<MediaStreamAudioDestinationHandle?> CreateMediaStreamDestination()
Ends a graph in a MediaStream instead of the speakers - ready for MediaRecorder.
AddWorkletModule
ValueTask<bool> AddWorkletModule(string moduleUrl)
Loads a JavaScript module that registers audio worklet processors. Needs a secure context.
CreateWorkletNode
ValueTask<AudioWorkletNodeHandle?> CreateWorkletNode(string processorName, AudioWorkletNodeOptions? options = null, Action<string>? onMessage = null)
Puts your own DSP code, running on the audio thread, into the graph.
SetListener
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)
Where the ears are and which way they face - what every panner is measured against.
AudioNodeHandle.Connect
ValueTask<bool> Connect(AudioNodeHandle destination)
Routes this node's output into another. Connecting to several destinations splits the signal.
AudioNodeHandle.ConnectToDestination
ValueTask<bool> ConnectToDestination()
Connects to the output, through Butil's shared master gain.
AudioNodeHandle.Disconnect
ValueTask<bool> Disconnect()
Detaches every connection this node's output makes.
AudioNodeHandle.SetParam
ValueTask<bool> SetParam(string name, double value, double afterSeconds = 0)
Sets an AudioParam by name, now or at a scheduled moment.
AudioNodeHandle.RampParam
ValueTask<bool> RampParam(string name, double value, double overSeconds, bool exponential = false)
Moves an AudioParam smoothly, on the audio thread. What to use for anything the user hears.
AudioNodeHandle.CancelScheduledParam
ValueTask<bool> CancelScheduledParam(string name)
Drops everything scheduled on a parameter from now on.
AudioNodeHandle.SetProperty
ValueTask<bool> SetProperty(string name, string | double | bool value)
Sets a plain property - a filter's type, an analyser's fftSize, a buffer source's loop.
AudioSourceNodeHandle.Start
ValueTask<bool> Start(double whenSeconds = 0, double offsetSeconds = 0, double durationSeconds = 0)
Begins playback, optionally scheduled and offset into the buffer.
AudioSourceNodeHandle.Stop
ValueTask<bool> Stop(double whenSeconds = 0)
Ends playback, optionally at a scheduled moment.
AnalyserNodeHandle.GetByteFrequencyData
ValueTask<byte[]?> GetByteFrequencyData()
The current spectrum, one byte per bin - what a bar chart is drawn from.
AnalyserNodeHandle.GetByteTimeDomainData
ValueTask<byte[]?> GetByteTimeDomainData()
The current waveform, one byte per sample - what an oscilloscope draws.
AnalyserNodeHandle.GetFloatFrequencyData
ValueTask<double[]?> GetFloatFrequencyData()
The spectrum in decibels, unscaled - for measurement rather than for drawing.
AnalyserNodeHandle.SetFftSize
ValueTask<bool> SetFftSize(int fftSize)
Samples per analysis: resolution against latency.
AudioWorkletNodeHandle.PostMessage
ValueTask<bool> PostMessage(string message)
Sends a message to the processor. For continuous values, prefer a declared AudioParam.
MediaStreamAudioDestinationHandle.GetStream
MediaStreamHandle GetStream()
The node's output as an ordinary Butil media stream.
AudioBufferHandle
Id, Duration, SampleRate, NumberOfChannels, Length
A decoded buffer. Dispose it when no new source will be built over it.
An unhandled error has occurred. Reload 🗙