Web Audio
In this tutorial we build a small modular synthesizer: an Osc node makes a tone, an Amp node controls its volume, and an Out node plays it through your speakers. Each Vue Flow node mirrors a node in a Web Audio graph, and connecting them on the canvas connects the audio.
The Web Audio API
A quick tour of the parts we need:
- The API gives you audio nodes — sources like an
OscillatorNode, effects like aGainNode, and the outputAudioDestinationNode. - You
connectnodes together to form a signal graph. - An
AudioContextis the brain: it creates nodes and can suspend or resume playback.
Everything runs on a separate audio thread, so it keeps making sound even when the UI is busy.
Hello, sound!
Before any Vue Flow, let’s make noise with a mouse theremin — the mouse’s x position sets the pitch
and its y position sets the volume. Create an AudioContext, wire an oscillator through a gain node
to the speakers, and start it:
<script setup lang="ts">
import { shallowRef } from 'vue';
const context = new AudioContext();
const osc = context.createOscillator();
const amp = context.createGain();
context.suspend();
osc.connect(amp);
amp.connect(context.destination);
osc.start();
const running = shallowRef(context.state === 'running');
function updateAudio(event: MouseEvent) {
osc.frequency.value = (event.clientX / window.innerWidth) * 1000;
amp.gain.value = 1 - event.clientY / window.innerHeight;
}
async function toggleAudio() {
await (context.state === 'suspended' ? context.resume() : context.suspend());
running.value = context.state === 'running';
}
</script>
<template>
<main @mousemove="updateAudio">
<button type="button" @click="toggleAudio">{{ running ? '🔊' : '🔇' }}</button>
</main>
</template>Two gotchas: oscillators must be start()-ed, and a context starts suspended (browsers block
autoplay), so nothing plays until a user gesture resumes it — hence the toggle button. Note also
osc.frequency.value / amp.gain.value: these are AudioParams,
set through .value, not plain properties.
Scaffolding a Vue Flow project
Now the graph. Unlike the React version of this tutorial, we don’t reach for a separate state
library — Vue Flow already has a store. Keep your nodes and edges in a shallowRef, bind them
with v-model (Vue Flow applies drags, selections, and removals for you), and add edges on
@connect with the addEdge helper:
<script setup lang="ts">
import type { Connection, Edge, Node } from '@xyflow/vue';
import { addEdge, Background, VueFlow } from '@xyflow/vue';
import { shallowRef } from 'vue';
const nodes = shallowRef<Node[]>([
{ id: 'a', data: { label: 'oscillator' }, position: { x: 0, y: 0 } },
{ id: 'b', data: { label: 'gain' }, position: { x: 50, y: 100 } },
{ id: 'c', data: { label: 'output' }, position: { x: -50, y: 200 } },
]);
const edges = shallowRef<Edge[]>([]);
function onConnect(connection: Connection) {
edges.value = addEdge(connection, edges.value);
}
</script>
<template>
<VueFlow v-model:nodes="nodes" v-model:edges="edges" fit-view @connect="onConnect">
<Background />
</VueFlow>
</template>That’s a fully interactive graph already — move the nodes, connect them, delete them.
Custom nodes
Plain labels won’t cut it — each node needs its own controls. Register custom node types with
#node-<type> slots and give each an <Handle> so it can be wired up. The Osc node has a
frequency slider and a waveform picker; changes write straight back to the node’s data with
updateNodeData (the graph stays the source of truth):
<script setup lang="ts">
import type { NodeProps } from '@xyflow/vue';
import type { OscNode } from './nodes';
import { Handle, Position, useVueFlow } from '@xyflow/vue';
const props = defineProps<NodeProps<OscNode>>();
const { updateNodeData } = useVueFlow();
function setFrequency(event: Event) {
updateNodeData(props.id, { frequency: +(event.target as HTMLInputElement).value });
}
</script>
<template>
<div class="node osc">
<p class="node__title osc__title">Osc</p>
<label class="node__row">
<span class="node__label">Frequency</span>
<input class="nodrag" type="range" min="10" max="1000" :value="data.frequency" @input="setFrequency">
</label>
<Handle type="source" :position="Position.Bottom" />
</div>
</template>The node data types double as the audio-node config, and — since Node<Data> requires the data to
satisfy Record<string, unknown> — they’re written as type aliases (an interface wouldn’t):
import type { Node } from '@xyflow/vue';
export type OscData = { frequency: number; type: OscillatorType };
export type AmpData = { gain: number };
export type OutData = Record<string, never>;
export type OscNode = Node<OscData, 'osc'>;
export type AmpNode = Node<AmpData, 'amp'>;
export type OutNode = Node<OutData, 'out'>;
export type SoundNode = OscNode | AmpNode | OutNode;The Amp node is the same idea with a gain slider; Out is a target handle and a toggle button. There’s no sound yet — the nodes only update the graph.
Doing sound to it
Time to connect the visual graph to a real audio graph. That audio graph lives in a plain
audio.ts module that has no idea Vue Flow exists — it holds the AudioContext and a Map of
audio nodes keyed by id, and exposes functions to create, update, connect, and remove them:
const context = new AudioContext();
const nodes = new Map<string, AudioNode>();
context.suspend();
nodes.set('output', context.destination);
export function createAudioNode(id: string, type: 'osc' | 'amp', data: OscData | AmpData) {
if (type === 'osc') {
const node = context.createOscillator();
node.frequency.value = (data as OscData).frequency;
node.type = (data as OscData).type;
node.start();
nodes.set(id, node);
} else {
const node = context.createGain();
node.gain.value = (data as AmpData).gain;
nodes.set(id, node);
}
}
export function connect(sourceId: string, targetId: string) {
nodes.get(sourceId)?.connect(nodes.get(targetId)!);
}
export function disconnect(sourceId: string, targetId: string) {
nodes.get(sourceId)?.disconnect(nodes.get(targetId)!);
}Node changes
When a slider moves we need to update both graphs: the node’s data (so the UI reflects it) and the matching audio node’s parameter. Rather than repeat that in every node, extract it into a composable:
import { useVueFlow } from '@xyflow/vue';
import { updateAudioNode } from './audio';
export function useUpdateNode() {
const { updateNodeData } = useVueFlow();
return function updateNode(id: string, data: Record<string, unknown>) {
updateNodeData(id, data);
updateAudioNode(id, data);
};
}updateAudioNode walks the changed fields and writes them back — through .value for AudioParams
like frequency/gain, directly for everything else (e.g. the oscillator’s type). Now the nodes
call updateNode instead of updateNodeData.
Edge changes
Connections and deletions are where the audio graph gets wired and torn down. Every graph event has
an audio side-effect: @connect adds the edge and connects the audio nodes; @nodes-delete and
@edges-delete clean up. Because v-model already applies the visual changes, these handlers only
keep the audio graph in sync:
function onConnect(connection: Connection) {
addEdges([connection]);
connect(connection.source, connection.target);
}
function onNodesDelete(deleted: SoundNode[]) {
for (const { id } of deleted) {
removeAudioNode(id);
}
}
function onEdgesDelete(deleted: Edge[]) {
for (const { source, target } of deleted) {
disconnect(source, target);
}
}Switching the speakers on
The Out node owns the play/pause control. audio.ts exposes a reactive isRunning ref and a
toggleAudio that resumes or suspends the context, so the node just reads and calls them:
<script setup lang="ts">
import { Handle, Position } from '@xyflow/vue';
import { isRunning, toggleAudio } from './audio';
</script>
<template>
<div class="node out">
<Handle type="target" :position="Position.Top" />
<button type="button" @click="toggleAudio">
<span v-if="isRunning">🔈</span>
<span v-else>🔇</span>
</button>
</div>
</template>Here the audio nodes are hardcoded in audio.ts and matched to three seeded visual nodes — wire
Osc → Amp → Out and hit the speaker to hear it.
Creating new nodes
Finally, make it a real instrument you build from scratch. Start with just the Out node and add
oscillators and amps with a couple of Panel buttons. Creating a
node means creating its audio node and adding it to the graph via
setupVueFlow’s addNodes:
const { addNodes, addEdges } = setupVueFlow();
function createNode(type: 'osc' | 'amp') {
const id = nanoid();
const data = type === 'osc' ? { frequency: 440, type: 'sine' as const } : { gain: 0.5 };
createAudioNode(id, type, data);
addNodes([{ id, type, data, position: { x: 0, y: 0 } }]);
}Final thoughts
That’s a working modular synth. The pattern — a framework-agnostic module for the “real” work, Vue Flow’s store for the graph, and event handlers that keep the two in sync — scales to all sorts of node-based tools: shader graphs, data pipelines, no-code builders. From here you could add more node types (a delay, a filter), or persist and restore patches with save and restore.