Skip to Content

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:

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:

App.vue
<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:

App.vue
<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):

Osc.vue
<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):

nodes.ts
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:

audio.ts
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:

useUpdateNode.ts
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:

App.vue
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:

Out.vue
<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:

App.vue
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.

Last updated on