Audio synth (Web Audio)
A real synth built on the graph — Oscillator → Filter → Gain → Output. Knobs are node widgets; Play wires live AudioNodes and lights the active chain. It makes sound.
{
"version": "xenolith.v1",
"nodes": [
{
"id": "osc",
"type": "Oscillator",
"position": {
"x": 0,
"y": 60
},
"render": {
"title": "Oscillator"
},
"state": {
"wave": "sawtooth",
"freq": 220
},
"pins": [
{
"id": "osc:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "wave",
"type": "combo",
"key": "wave",
"label": "Wave",
"values": [
"sine",
"square",
"sawtooth",
"triangle"
],
"freeFloating": true
},
{
"id": "freq",
"type": "slider",
"key": "freq",
"label": "Freq",
"min": 50,
"max": 1200,
"step": 1,
"freeFloating": true
}
]
},
{
"id": "filter",
"type": "Filter",
"position": {
"x": 260,
"y": 60
},
"render": {
"title": "Filter"
},
"state": {
"ftype": "lowpass",
"cutoff": 900
},
"pins": [
{
"id": "filter:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "filter:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "ftype",
"type": "combo",
"key": "ftype",
"label": "Type",
"values": [
"lowpass",
"highpass",
"bandpass"
],
"freeFloating": true
},
{
"id": "cutoff",
"type": "slider",
"key": "cutoff",
"label": "Cutoff",
"min": 80,
"max": 8000,
"step": 10,
"freeFloating": true
}
]
},
{
"id": "gain",
"type": "Gain",
"position": {
"x": 520,
"y": 60
},
"render": {
"title": "Gain"
},
"state": {
"level": 0.25
},
"pins": [
{
"id": "gain:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "gain:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "level",
"type": "slider",
"key": "level",
"label": "Level",
"min": 0,
"max": 1,
"step": 0.01,
"freeFloating": true
}
]
},
{
"id": "output",
"type": "Output",
"position": {
"x": 780,
"y": 90
},
"render": {
"title": "🔊 Output"
},
"pins": [
{
"id": "output:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
}
]
}
],
"edges": [
{
"id": "e-of",
"from": {
"node": "osc",
"pin": "osc:out"
},
"to": {
"node": "filter",
"pin": "filter:in"
}
},
{
"id": "e-fg",
"from": {
"node": "filter",
"pin": "filter:out"
},
"to": {
"node": "gain",
"pin": "gain:in"
}
},
{
"id": "e-go",
"from": {
"node": "gain",
"pin": "gain:out"
},
"to": {
"node": "output",
"pin": "output:in"
}
}
]
} import { useEffect, useRef, useState } from 'react'
import { XenolithGraph, XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-react'
import { loadAudioGraph, createAudioEngine, type AudioSynthHandle } from '@xenolithengine/demo/audio-synth'
import { DemoStage } from '../Layout.js'
// A tiny Web Audio synth built ON the graph: Oscillator → Filter → Gain → Output.
//
// Canon: the engine (an external system: AudioContext + event subscriptions) lives where it's
// used — inside the panel that drives it. `useEditor()` gives the editor directly; a ref holds
// the engine because it's imperative, not state; the effect's cleanup disposes on unmount.
function AudioPanel() {
const editor = useEditor()
const engineRef = useRef<AudioSynthHandle | null>(null)
const [playing, setPlaying] = useState(false)
useEffect(() => {
engineRef.current = createAudioEngine(editor)
return () => { engineRef.current?.dispose(); engineRef.current = null }
}, [editor])
const toggle = () => {
const eng = engineRef.current
if (!eng) return
if (playing) eng.stop()
else eng.play()
setPlaying(!playing)
}
return (
<XenolithPanel position="top-left" style={{ display: 'flex', flexDirection: 'column', gap: 6, maxWidth: 220 }}>
<XenolithButton active={playing} onClick={toggle} style={{ width: '100%' }}>
{playing ? '■ Stop' : '▶ Play'}
</XenolithButton>
<span style={{ color: 'var(--xeno-muted)', fontSize: 11, lineHeight: 1.4 }}>
Tweak the knobs while it plays — the chain is wired from the graph; the active path glows.
</span>
</XenolithPanel>
)
}
/** Showcase: a real Web Audio synth built on the node graph. */
export function AudioSynthDemo() {
return (
<DemoStage>
<XenolithGraph className="xeno" resizeToWindow={false} onReady={loadAudioGraph}>
<AudioPanel />
</XenolithGraph>
</DemoStage>
)
} // A tiny Web Audio synth built ON the graph: Oscillator → Filter → Gain → Output. The graph is DATA
// (audio-synth.json, loaded with editor.loadJSON); the node widgets are the synth's knobs.
//
// Everything here is framework-agnostic editor + Web Audio API. play() owns an AudioContext, wires
// real AudioNodes from the current edges and lights the active chain; while playing, it subscribes to
// the editor's own events so any knob tweak retunes the matching AudioParam and any edge/node change
// re-wires the audio + re-lights the chain (cutting Filter→Gain silences and darkens everything past
// it). reachableFrom keeps a node past a broken/deleted edge out of the live chain.
import { reachableFrom } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './audio-synth.json'
export interface AudioSynthHandle {
play(): void
stop(): void
/** Tear down audio + event subscriptions; call on host unmount. */
dispose(): void
}
/** Build live Web Audio nodes from the graph and connect them per the edges. */
function startAudio(editor: XenolithEditor, ctx: AudioContext): Map<NodeId, AudioNode> {
const map = new Map<NodeId, AudioNode>()
for (const n of editor.graphNodes()) {
if (n.type === 'Oscillator') {
const o = ctx.createOscillator()
o.type = String(n.state['wave']) as OscillatorType
o.frequency.value = Number(n.state['freq']) || 220
o.start()
map.set(n.id, o)
} else if (n.type === 'Filter') {
const f = ctx.createBiquadFilter()
f.type = String(n.state['ftype']) as BiquadFilterType
f.frequency.value = Number(n.state['cutoff']) || 900
map.set(n.id, f)
} else if (n.type === 'Gain') {
const g = ctx.createGain()
g.gain.value = Math.min(0.6, Number(n.state['level']) || 0)
map.set(n.id, g)
} else if (n.type === 'Output') {
map.set(n.id, ctx.destination)
}
}
for (const e of editor.graphEdges()) {
const a = map.get(e.from.node), b = map.get(e.to.node)
if (a && b && 'connect' in a) (a as AudioNode).connect(b)
}
return map
}
function teardownAudio(map: Map<NodeId, AudioNode> | null): void {
if (!map) return
for (const an of map.values()) {
if (an instanceof OscillatorNode) { try { an.stop() } catch { /* already stopped */ } }
if ('disconnect' in an && an !== an.context.destination) { try { an.disconnect() } catch { /* noop */ } }
}
map.clear()
}
/** Load the synth graph into the editor. Pass to `<XenolithGraph onReady>` so the canvas paints
* with the graph on the very first frame — no flicker, no useEffect-after-mount delay. */
export function loadAudioGraph(editor: XenolithEditor): void {
editor.loadJSON(graph)
editor.fitView({ padding: 64, maxZoom: 1 })
}
/** Spin up the Web Audio engine on top of an already-loaded graph. Returns an imperative handle —
* the engine owns an AudioContext + event subscriptions; `dispose()` releases both. */
export function createAudioEngine(editor: XenolithEditor): AudioSynthHandle {
let ctx: AudioContext | null = null
let map: Map<NodeId, AudioNode> | null = null
let offs: Array<() => void> = []
const sourceId = (): NodeId | undefined => [...editor.graphNodes()].find((n) => n.type === 'Oscillator')?.id
// Light ONLY the nodes still reachable from the oscillator through connected edges — mirrors the
// audible chain, so a node past a broken/deleted edge stops glowing.
const lightActiveChain = (): void => {
editor.clearNodeStatuses()
const src = sourceId()
if (!src) return
for (const id of reachableFrom(editor.readGraph(), src)) editor.setNodeStatus(id, 'running')
}
// (Re)build the audio graph from the CURRENT edges and re-light the chain.
const rebuild = (): void => {
if (!ctx) return
teardownAudio(map)
map = startAudio(editor, ctx)
lightActiveChain()
}
const play = (): void => {
if (ctx) return
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
ctx = new Ctor()
rebuild()
// Debounce: a single user action (delete, and especially undo/redo of it) fires a burst of
// graph events as the transaction restores the node + its edges one by one. Rebuilding on each
// would wire the audio from a half-restored graph and leave it stuck on a partial chain. Coalesce
// to one rebuild after the burst settles, when editor.graph is whole again. node:added is needed
// so undo of a delete (which re-adds the node + cascades its edges) triggers it.
let rebuildTimer: ReturnType<typeof setTimeout> | undefined
const scheduleRebuild = (): void => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(rebuild, 0) }
offs = [
editor.on('edge:connected', scheduleRebuild),
editor.on('edge:disconnected', scheduleRebuild),
editor.on('node:removed', scheduleRebuild),
editor.on('node:added', scheduleRebuild),
// A knob moved → retune the matching AudioParam live.
editor.on('widget:changed', ({ nodeId, widgetId, value }) => {
const an = map?.get(nodeId)
if (!an) return
if (widgetId === 'freq') (an as OscillatorNode).frequency.value = Number(value)
else if (widgetId === 'wave') (an as OscillatorNode).type = String(value) as OscillatorType
else if (widgetId === 'cutoff') (an as BiquadFilterNode).frequency.value = Number(value)
else if (widgetId === 'ftype') (an as BiquadFilterNode).type = String(value) as BiquadFilterType
else if (widgetId === 'level') (an as GainNode).gain.value = Math.min(0.6, Number(value))
}),
]
}
const stop = (): void => {
for (const off of offs) off()
offs = []
teardownAudio(map)
ctx?.close().catch(() => {})
ctx = null; map = null
editor.clearNodeStatuses()
}
return { play, stop, dispose: stop }
}
/** @deprecated Combined loader+engine for back-compat with non-React hosts. Prefer
* `loadAudioGraph` (in `onReady`) + `createAudioEngine` (in an effect) in fresh code. */
export function buildAudioSynth(editor: XenolithEditor): AudioSynthHandle {
loadAudioGraph(editor)
return createAudioEngine(editor)
} {
"version": "xenolith.v1",
"nodes": [
{
"id": "osc",
"type": "Oscillator",
"position": {
"x": 0,
"y": 60
},
"render": {
"title": "Oscillator"
},
"state": {
"wave": "sawtooth",
"freq": 220
},
"pins": [
{
"id": "osc:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "wave",
"type": "combo",
"key": "wave",
"label": "Wave",
"values": [
"sine",
"square",
"sawtooth",
"triangle"
],
"freeFloating": true
},
{
"id": "freq",
"type": "slider",
"key": "freq",
"label": "Freq",
"min": 50,
"max": 1200,
"step": 1,
"freeFloating": true
}
]
},
{
"id": "filter",
"type": "Filter",
"position": {
"x": 260,
"y": 60
},
"render": {
"title": "Filter"
},
"state": {
"ftype": "lowpass",
"cutoff": 900
},
"pins": [
{
"id": "filter:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "filter:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "ftype",
"type": "combo",
"key": "ftype",
"label": "Type",
"values": [
"lowpass",
"highpass",
"bandpass"
],
"freeFloating": true
},
{
"id": "cutoff",
"type": "slider",
"key": "cutoff",
"label": "Cutoff",
"min": 80,
"max": 8000,
"step": 10,
"freeFloating": true
}
]
},
{
"id": "gain",
"type": "Gain",
"position": {
"x": 520,
"y": 60
},
"render": {
"title": "Gain"
},
"state": {
"level": 0.25
},
"pins": [
{
"id": "gain:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "gain:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "level",
"type": "slider",
"key": "level",
"label": "Level",
"min": 0,
"max": 1,
"step": 0.01,
"freeFloating": true
}
]
},
{
"id": "output",
"type": "Output",
"position": {
"x": 780,
"y": 90
},
"render": {
"title": "🔊 Output"
},
"pins": [
{
"id": "output:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
}
]
}
],
"edges": [
{
"id": "e-of",
"from": {
"node": "osc",
"pin": "osc:out"
},
"to": {
"node": "filter",
"pin": "filter:in"
}
},
{
"id": "e-fg",
"from": {
"node": "filter",
"pin": "filter:out"
},
"to": {
"node": "gain",
"pin": "gain:in"
}
},
{
"id": "e-go",
"from": {
"node": "gain",
"pin": "gain:out"
},
"to": {
"node": "output",
"pin": "output:in"
}
}
]
} <script setup lang="ts">
// Play lives in a child so useEditor() injects inside <XenolithGraph>.
import { XenolithGraph } from '@xenolithengine/graph-vue'
import AudioPanel from './AudioPanel.vue'
</script>
<template>
<div class="app" style="position:absolute;inset:0;">
<XenolithGraph class="xeno" :resize-to-window="false">
<AudioPanel />
</XenolithGraph>
</div>
</template> <script setup lang="ts">
import { onUnmounted, ref, watch } from 'vue'
import { XenolithPanel, XenolithButton, useEditor } from '@xenolithengine/graph-vue'
import { loadAudioGraph, createAudioEngine, type AudioSynthHandle } from '@xenolithengine/demo/audio-synth'
const editor = useEditor()
const playing = ref(false)
let engine: AudioSynthHandle | null = null
// Editor ref flips null → instance before @ready. Load and own the AudioContext here.
watch(editor, (ed) => {
engine?.dispose()
engine = null
playing.value = false
if (!ed) return
loadAudioGraph(ed)
engine = createAudioEngine(ed)
}, { immediate: true })
onUnmounted(() => { engine?.dispose(); engine = null })
function toggle(): void {
if (!engine) return
if (playing.value) engine.stop()
else engine.play()
playing.value = !playing.value
}
</script>
<template>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;max-width:220px;">
<XenolithButton :active="playing" style="width:100%" @click="toggle">
{{ playing ? '■ Stop' : '▶ Play' }}
</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Tweak the knobs while it plays — the chain is wired from the graph; the active path glows.
</span>
</div>
</XenolithPanel>
</template> // A tiny Web Audio synth built ON the graph: Oscillator → Filter → Gain → Output. The graph is DATA
// (audio-synth.json, loaded with editor.loadJSON); the node widgets are the synth's knobs.
//
// Everything here is framework-agnostic editor + Web Audio API. play() owns an AudioContext, wires
// real AudioNodes from the current edges and lights the active chain; while playing, it subscribes to
// the editor's own events so any knob tweak retunes the matching AudioParam and any edge/node change
// re-wires the audio + re-lights the chain (cutting Filter→Gain silences and darkens everything past
// it). reachableFrom keeps a node past a broken/deleted edge out of the live chain.
import { reachableFrom } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './audio-synth.json'
export interface AudioSynthHandle {
play(): void
stop(): void
/** Tear down audio + event subscriptions; call on host unmount. */
dispose(): void
}
/** Build live Web Audio nodes from the graph and connect them per the edges. */
function startAudio(editor: XenolithEditor, ctx: AudioContext): Map<NodeId, AudioNode> {
const map = new Map<NodeId, AudioNode>()
for (const n of editor.graphNodes()) {
if (n.type === 'Oscillator') {
const o = ctx.createOscillator()
o.type = String(n.state['wave']) as OscillatorType
o.frequency.value = Number(n.state['freq']) || 220
o.start()
map.set(n.id, o)
} else if (n.type === 'Filter') {
const f = ctx.createBiquadFilter()
f.type = String(n.state['ftype']) as BiquadFilterType
f.frequency.value = Number(n.state['cutoff']) || 900
map.set(n.id, f)
} else if (n.type === 'Gain') {
const g = ctx.createGain()
g.gain.value = Math.min(0.6, Number(n.state['level']) || 0)
map.set(n.id, g)
} else if (n.type === 'Output') {
map.set(n.id, ctx.destination)
}
}
for (const e of editor.graphEdges()) {
const a = map.get(e.from.node), b = map.get(e.to.node)
if (a && b && 'connect' in a) (a as AudioNode).connect(b)
}
return map
}
function teardownAudio(map: Map<NodeId, AudioNode> | null): void {
if (!map) return
for (const an of map.values()) {
if (an instanceof OscillatorNode) { try { an.stop() } catch { /* already stopped */ } }
if ('disconnect' in an && an !== an.context.destination) { try { an.disconnect() } catch { /* noop */ } }
}
map.clear()
}
/** Load the synth graph into the editor. Pass to `<XenolithGraph onReady>` so the canvas paints
* with the graph on the very first frame — no flicker, no useEffect-after-mount delay. */
export function loadAudioGraph(editor: XenolithEditor): void {
editor.loadJSON(graph)
editor.fitView({ padding: 64, maxZoom: 1 })
}
/** Spin up the Web Audio engine on top of an already-loaded graph. Returns an imperative handle —
* the engine owns an AudioContext + event subscriptions; `dispose()` releases both. */
export function createAudioEngine(editor: XenolithEditor): AudioSynthHandle {
let ctx: AudioContext | null = null
let map: Map<NodeId, AudioNode> | null = null
let offs: Array<() => void> = []
const sourceId = (): NodeId | undefined => [...editor.graphNodes()].find((n) => n.type === 'Oscillator')?.id
// Light ONLY the nodes still reachable from the oscillator through connected edges — mirrors the
// audible chain, so a node past a broken/deleted edge stops glowing.
const lightActiveChain = (): void => {
editor.clearNodeStatuses()
const src = sourceId()
if (!src) return
for (const id of reachableFrom(editor.readGraph(), src)) editor.setNodeStatus(id, 'running')
}
// (Re)build the audio graph from the CURRENT edges and re-light the chain.
const rebuild = (): void => {
if (!ctx) return
teardownAudio(map)
map = startAudio(editor, ctx)
lightActiveChain()
}
const play = (): void => {
if (ctx) return
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
ctx = new Ctor()
rebuild()
// Debounce: a single user action (delete, and especially undo/redo of it) fires a burst of
// graph events as the transaction restores the node + its edges one by one. Rebuilding on each
// would wire the audio from a half-restored graph and leave it stuck on a partial chain. Coalesce
// to one rebuild after the burst settles, when editor.graph is whole again. node:added is needed
// so undo of a delete (which re-adds the node + cascades its edges) triggers it.
let rebuildTimer: ReturnType<typeof setTimeout> | undefined
const scheduleRebuild = (): void => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(rebuild, 0) }
offs = [
editor.on('edge:connected', scheduleRebuild),
editor.on('edge:disconnected', scheduleRebuild),
editor.on('node:removed', scheduleRebuild),
editor.on('node:added', scheduleRebuild),
// A knob moved → retune the matching AudioParam live.
editor.on('widget:changed', ({ nodeId, widgetId, value }) => {
const an = map?.get(nodeId)
if (!an) return
if (widgetId === 'freq') (an as OscillatorNode).frequency.value = Number(value)
else if (widgetId === 'wave') (an as OscillatorNode).type = String(value) as OscillatorType
else if (widgetId === 'cutoff') (an as BiquadFilterNode).frequency.value = Number(value)
else if (widgetId === 'ftype') (an as BiquadFilterNode).type = String(value) as BiquadFilterType
else if (widgetId === 'level') (an as GainNode).gain.value = Math.min(0.6, Number(value))
}),
]
}
const stop = (): void => {
for (const off of offs) off()
offs = []
teardownAudio(map)
ctx?.close().catch(() => {})
ctx = null; map = null
editor.clearNodeStatuses()
}
return { play, stop, dispose: stop }
}
/** @deprecated Combined loader+engine for back-compat with non-React hosts. Prefer
* `loadAudioGraph` (in `onReady`) + `createAudioEngine` (in an effect) in fresh code. */
export function buildAudioSynth(editor: XenolithEditor): AudioSynthHandle {
loadAudioGraph(editor)
return createAudioEngine(editor)
} {
"version": "xenolith.v1",
"nodes": [
{
"id": "osc",
"type": "Oscillator",
"position": {
"x": 0,
"y": 60
},
"render": {
"title": "Oscillator"
},
"state": {
"wave": "sawtooth",
"freq": 220
},
"pins": [
{
"id": "osc:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "wave",
"type": "combo",
"key": "wave",
"label": "Wave",
"values": [
"sine",
"square",
"sawtooth",
"triangle"
],
"freeFloating": true
},
{
"id": "freq",
"type": "slider",
"key": "freq",
"label": "Freq",
"min": 50,
"max": 1200,
"step": 1,
"freeFloating": true
}
]
},
{
"id": "filter",
"type": "Filter",
"position": {
"x": 260,
"y": 60
},
"render": {
"title": "Filter"
},
"state": {
"ftype": "lowpass",
"cutoff": 900
},
"pins": [
{
"id": "filter:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "filter:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "ftype",
"type": "combo",
"key": "ftype",
"label": "Type",
"values": [
"lowpass",
"highpass",
"bandpass"
],
"freeFloating": true
},
{
"id": "cutoff",
"type": "slider",
"key": "cutoff",
"label": "Cutoff",
"min": 80,
"max": 8000,
"step": 10,
"freeFloating": true
}
]
},
{
"id": "gain",
"type": "Gain",
"position": {
"x": 520,
"y": 60
},
"render": {
"title": "Gain"
},
"state": {
"level": 0.25
},
"pins": [
{
"id": "gain:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "gain:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "level",
"type": "slider",
"key": "level",
"label": "Level",
"min": 0,
"max": 1,
"step": 0.01,
"freeFloating": true
}
]
},
{
"id": "output",
"type": "Output",
"position": {
"x": 780,
"y": 90
},
"render": {
"title": "🔊 Output"
},
"pins": [
{
"id": "output:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
}
]
}
],
"edges": [
{
"id": "e-of",
"from": {
"node": "osc",
"pin": "osc:out"
},
"to": {
"node": "filter",
"pin": "filter:in"
}
},
{
"id": "e-fg",
"from": {
"node": "filter",
"pin": "filter:out"
},
"to": {
"node": "gain",
"pin": "gain:in"
}
},
{
"id": "e-go",
"from": {
"node": "gain",
"pin": "gain:out"
},
"to": {
"node": "output",
"pin": "output:in"
}
}
]
} <script lang="ts">
import { onDestroy } from 'svelte'
import { xenolith, createXenolithEditorContext } from '@xenolithengine/graph-svelte'
import { XenolithPanel, XenolithButton } from '@xenolithengine/graph-svelte/components'
import { loadAudioGraph, createAudioEngine, type AudioSynthHandle } from '@xenolithengine/demo/audio-synth'
const panelEditor = createXenolithEditorContext()
let playing = $state(false)
let engine: AudioSynthHandle | null = null
function toggle(): void {
if (!engine) return
if (playing) engine.stop()
else engine.play()
playing = !playing
}
onDestroy(() => { engine?.dispose(); engine = null })
</script>
<div
use:xenolith={{ resizeToWindow: false }}
onready={(e) => { panelEditor.set(e.detail); loadAudioGraph(e.detail); engine = createAudioEngine(e.detail) }}
style="position:absolute;inset:0"
></div>
<XenolithPanel position="top-left">
<div style="display:flex;flex-direction:column;gap:6px;max-width:220px;">
<XenolithButton active={playing} style="width:100%" onclick={toggle}>
{playing ? '■ Stop' : '▶ Play'}
</XenolithButton>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Tweak the knobs while it plays — the chain is wired from the graph; the active path glows.
</span>
</div>
</XenolithPanel> // A tiny Web Audio synth built ON the graph: Oscillator → Filter → Gain → Output. The graph is DATA
// (audio-synth.json, loaded with editor.loadJSON); the node widgets are the synth's knobs.
//
// Everything here is framework-agnostic editor + Web Audio API. play() owns an AudioContext, wires
// real AudioNodes from the current edges and lights the active chain; while playing, it subscribes to
// the editor's own events so any knob tweak retunes the matching AudioParam and any edge/node change
// re-wires the audio + re-lights the chain (cutting Filter→Gain silences and darkens everything past
// it). reachableFrom keeps a node past a broken/deleted edge out of the live chain.
import { reachableFrom } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './audio-synth.json'
export interface AudioSynthHandle {
play(): void
stop(): void
/** Tear down audio + event subscriptions; call on host unmount. */
dispose(): void
}
/** Build live Web Audio nodes from the graph and connect them per the edges. */
function startAudio(editor: XenolithEditor, ctx: AudioContext): Map<NodeId, AudioNode> {
const map = new Map<NodeId, AudioNode>()
for (const n of editor.graphNodes()) {
if (n.type === 'Oscillator') {
const o = ctx.createOscillator()
o.type = String(n.state['wave']) as OscillatorType
o.frequency.value = Number(n.state['freq']) || 220
o.start()
map.set(n.id, o)
} else if (n.type === 'Filter') {
const f = ctx.createBiquadFilter()
f.type = String(n.state['ftype']) as BiquadFilterType
f.frequency.value = Number(n.state['cutoff']) || 900
map.set(n.id, f)
} else if (n.type === 'Gain') {
const g = ctx.createGain()
g.gain.value = Math.min(0.6, Number(n.state['level']) || 0)
map.set(n.id, g)
} else if (n.type === 'Output') {
map.set(n.id, ctx.destination)
}
}
for (const e of editor.graphEdges()) {
const a = map.get(e.from.node), b = map.get(e.to.node)
if (a && b && 'connect' in a) (a as AudioNode).connect(b)
}
return map
}
function teardownAudio(map: Map<NodeId, AudioNode> | null): void {
if (!map) return
for (const an of map.values()) {
if (an instanceof OscillatorNode) { try { an.stop() } catch { /* already stopped */ } }
if ('disconnect' in an && an !== an.context.destination) { try { an.disconnect() } catch { /* noop */ } }
}
map.clear()
}
/** Load the synth graph into the editor. Pass to `<XenolithGraph onReady>` so the canvas paints
* with the graph on the very first frame — no flicker, no useEffect-after-mount delay. */
export function loadAudioGraph(editor: XenolithEditor): void {
editor.loadJSON(graph)
editor.fitView({ padding: 64, maxZoom: 1 })
}
/** Spin up the Web Audio engine on top of an already-loaded graph. Returns an imperative handle —
* the engine owns an AudioContext + event subscriptions; `dispose()` releases both. */
export function createAudioEngine(editor: XenolithEditor): AudioSynthHandle {
let ctx: AudioContext | null = null
let map: Map<NodeId, AudioNode> | null = null
let offs: Array<() => void> = []
const sourceId = (): NodeId | undefined => [...editor.graphNodes()].find((n) => n.type === 'Oscillator')?.id
// Light ONLY the nodes still reachable from the oscillator through connected edges — mirrors the
// audible chain, so a node past a broken/deleted edge stops glowing.
const lightActiveChain = (): void => {
editor.clearNodeStatuses()
const src = sourceId()
if (!src) return
for (const id of reachableFrom(editor.readGraph(), src)) editor.setNodeStatus(id, 'running')
}
// (Re)build the audio graph from the CURRENT edges and re-light the chain.
const rebuild = (): void => {
if (!ctx) return
teardownAudio(map)
map = startAudio(editor, ctx)
lightActiveChain()
}
const play = (): void => {
if (ctx) return
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
ctx = new Ctor()
rebuild()
// Debounce: a single user action (delete, and especially undo/redo of it) fires a burst of
// graph events as the transaction restores the node + its edges one by one. Rebuilding on each
// would wire the audio from a half-restored graph and leave it stuck on a partial chain. Coalesce
// to one rebuild after the burst settles, when editor.graph is whole again. node:added is needed
// so undo of a delete (which re-adds the node + cascades its edges) triggers it.
let rebuildTimer: ReturnType<typeof setTimeout> | undefined
const scheduleRebuild = (): void => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(rebuild, 0) }
offs = [
editor.on('edge:connected', scheduleRebuild),
editor.on('edge:disconnected', scheduleRebuild),
editor.on('node:removed', scheduleRebuild),
editor.on('node:added', scheduleRebuild),
// A knob moved → retune the matching AudioParam live.
editor.on('widget:changed', ({ nodeId, widgetId, value }) => {
const an = map?.get(nodeId)
if (!an) return
if (widgetId === 'freq') (an as OscillatorNode).frequency.value = Number(value)
else if (widgetId === 'wave') (an as OscillatorNode).type = String(value) as OscillatorType
else if (widgetId === 'cutoff') (an as BiquadFilterNode).frequency.value = Number(value)
else if (widgetId === 'ftype') (an as BiquadFilterNode).type = String(value) as BiquadFilterType
else if (widgetId === 'level') (an as GainNode).gain.value = Math.min(0.6, Number(value))
}),
]
}
const stop = (): void => {
for (const off of offs) off()
offs = []
teardownAudio(map)
ctx?.close().catch(() => {})
ctx = null; map = null
editor.clearNodeStatuses()
}
return { play, stop, dispose: stop }
}
/** @deprecated Combined loader+engine for back-compat with non-React hosts. Prefer
* `loadAudioGraph` (in `onReady`) + `createAudioEngine` (in an effect) in fresh code. */
export function buildAudioSynth(editor: XenolithEditor): AudioSynthHandle {
loadAudioGraph(editor)
return createAudioEngine(editor)
} {
"version": "xenolith.v1",
"nodes": [
{
"id": "osc",
"type": "Oscillator",
"position": {
"x": 0,
"y": 60
},
"render": {
"title": "Oscillator"
},
"state": {
"wave": "sawtooth",
"freq": 220
},
"pins": [
{
"id": "osc:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "wave",
"type": "combo",
"key": "wave",
"label": "Wave",
"values": [
"sine",
"square",
"sawtooth",
"triangle"
],
"freeFloating": true
},
{
"id": "freq",
"type": "slider",
"key": "freq",
"label": "Freq",
"min": 50,
"max": 1200,
"step": 1,
"freeFloating": true
}
]
},
{
"id": "filter",
"type": "Filter",
"position": {
"x": 260,
"y": 60
},
"render": {
"title": "Filter"
},
"state": {
"ftype": "lowpass",
"cutoff": 900
},
"pins": [
{
"id": "filter:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "filter:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "ftype",
"type": "combo",
"key": "ftype",
"label": "Type",
"values": [
"lowpass",
"highpass",
"bandpass"
],
"freeFloating": true
},
{
"id": "cutoff",
"type": "slider",
"key": "cutoff",
"label": "Cutoff",
"min": 80,
"max": 8000,
"step": 10,
"freeFloating": true
}
]
},
{
"id": "gain",
"type": "Gain",
"position": {
"x": 520,
"y": 60
},
"render": {
"title": "Gain"
},
"state": {
"level": 0.25
},
"pins": [
{
"id": "gain:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "gain:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "level",
"type": "slider",
"key": "level",
"label": "Level",
"min": 0,
"max": 1,
"step": 0.01,
"freeFloating": true
}
]
},
{
"id": "output",
"type": "Output",
"position": {
"x": 780,
"y": 90
},
"render": {
"title": "🔊 Output"
},
"pins": [
{
"id": "output:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
}
]
}
],
"edges": [
{
"id": "e-of",
"from": {
"node": "osc",
"pin": "osc:out"
},
"to": {
"node": "filter",
"pin": "filter:in"
}
},
{
"id": "e-fg",
"from": {
"node": "filter",
"pin": "filter:out"
},
"to": {
"node": "gain",
"pin": "gain:in"
}
},
{
"id": "e-go",
"from": {
"node": "gain",
"pin": "gain:out"
},
"to": {
"node": "output",
"pin": "output:in"
}
}
]
} // Solid — no panel package. The card is DOM; the engine is created once on ready.
import { createSignal, onCleanup } from 'solid-js'
import { xenolith } from '@xenolithengine/graph-solid'
import { loadAudioGraph, createAudioEngine, type AudioSynthHandle } from '@xenolithengine/demo/audio-synth'
export function AudioSynthDemo() {
const [playing, setPlaying] = createSignal(false)
let engine: AudioSynthHandle | null = null
onCleanup(() => { engine?.dispose(); engine = null })
const toggle = (): void => {
if (!engine) return
if (playing()) engine.stop()
else engine.play()
setPlaying(!playing())
}
const btn = (on: boolean): string =>
`font:inherit;font-size:13px;padding:7px 12px;border-radius:8px;cursor:pointer;width:100%;border:1px solid ${on ? 'var(--xeno-accent)' : 'var(--xeno-border)'};background:${on ? 'var(--xeno-accent)' : 'var(--xeno-elevated)'};color:${on ? 'var(--xeno-canvas)' : 'var(--xeno-text)'};`
return (
<div
use:xenolith={{ resizeToWindow: false }}
on:ready={(e) => { loadAudioGraph(e.detail); engine = createAudioEngine(e.detail) }}
style="position:absolute;inset:0"
>
<div style="position:absolute;top:12px;left:12px;z-index:5;display:flex;flex-direction:column;gap:6px;max-width:220px;padding:10px;background:var(--xeno-panel);border:1px solid var(--xeno-border);border-radius:10px;color:var(--xeno-text);font:13px system-ui,sans-serif;">
<button type="button" style={btn(playing())} onClick={toggle}>{playing() ? '■ Stop' : '▶ Play'}</button>
<span style="color:var(--xeno-muted);font-size:11px;line-height:1.4;">
Tweak the knobs while it plays — the chain is wired from the graph; the active path glows.
</span>
</div>
</div>
)
} // A tiny Web Audio synth built ON the graph: Oscillator → Filter → Gain → Output. The graph is DATA
// (audio-synth.json, loaded with editor.loadJSON); the node widgets are the synth's knobs.
//
// Everything here is framework-agnostic editor + Web Audio API. play() owns an AudioContext, wires
// real AudioNodes from the current edges and lights the active chain; while playing, it subscribes to
// the editor's own events so any knob tweak retunes the matching AudioParam and any edge/node change
// re-wires the audio + re-lights the chain (cutting Filter→Gain silences and darkens everything past
// it). reachableFrom keeps a node past a broken/deleted edge out of the live chain.
import { reachableFrom } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './audio-synth.json'
export interface AudioSynthHandle {
play(): void
stop(): void
/** Tear down audio + event subscriptions; call on host unmount. */
dispose(): void
}
/** Build live Web Audio nodes from the graph and connect them per the edges. */
function startAudio(editor: XenolithEditor, ctx: AudioContext): Map<NodeId, AudioNode> {
const map = new Map<NodeId, AudioNode>()
for (const n of editor.graphNodes()) {
if (n.type === 'Oscillator') {
const o = ctx.createOscillator()
o.type = String(n.state['wave']) as OscillatorType
o.frequency.value = Number(n.state['freq']) || 220
o.start()
map.set(n.id, o)
} else if (n.type === 'Filter') {
const f = ctx.createBiquadFilter()
f.type = String(n.state['ftype']) as BiquadFilterType
f.frequency.value = Number(n.state['cutoff']) || 900
map.set(n.id, f)
} else if (n.type === 'Gain') {
const g = ctx.createGain()
g.gain.value = Math.min(0.6, Number(n.state['level']) || 0)
map.set(n.id, g)
} else if (n.type === 'Output') {
map.set(n.id, ctx.destination)
}
}
for (const e of editor.graphEdges()) {
const a = map.get(e.from.node), b = map.get(e.to.node)
if (a && b && 'connect' in a) (a as AudioNode).connect(b)
}
return map
}
function teardownAudio(map: Map<NodeId, AudioNode> | null): void {
if (!map) return
for (const an of map.values()) {
if (an instanceof OscillatorNode) { try { an.stop() } catch { /* already stopped */ } }
if ('disconnect' in an && an !== an.context.destination) { try { an.disconnect() } catch { /* noop */ } }
}
map.clear()
}
/** Load the synth graph into the editor. Pass to `<XenolithGraph onReady>` so the canvas paints
* with the graph on the very first frame — no flicker, no useEffect-after-mount delay. */
export function loadAudioGraph(editor: XenolithEditor): void {
editor.loadJSON(graph)
editor.fitView({ padding: 64, maxZoom: 1 })
}
/** Spin up the Web Audio engine on top of an already-loaded graph. Returns an imperative handle —
* the engine owns an AudioContext + event subscriptions; `dispose()` releases both. */
export function createAudioEngine(editor: XenolithEditor): AudioSynthHandle {
let ctx: AudioContext | null = null
let map: Map<NodeId, AudioNode> | null = null
let offs: Array<() => void> = []
const sourceId = (): NodeId | undefined => [...editor.graphNodes()].find((n) => n.type === 'Oscillator')?.id
// Light ONLY the nodes still reachable from the oscillator through connected edges — mirrors the
// audible chain, so a node past a broken/deleted edge stops glowing.
const lightActiveChain = (): void => {
editor.clearNodeStatuses()
const src = sourceId()
if (!src) return
for (const id of reachableFrom(editor.readGraph(), src)) editor.setNodeStatus(id, 'running')
}
// (Re)build the audio graph from the CURRENT edges and re-light the chain.
const rebuild = (): void => {
if (!ctx) return
teardownAudio(map)
map = startAudio(editor, ctx)
lightActiveChain()
}
const play = (): void => {
if (ctx) return
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
ctx = new Ctor()
rebuild()
// Debounce: a single user action (delete, and especially undo/redo of it) fires a burst of
// graph events as the transaction restores the node + its edges one by one. Rebuilding on each
// would wire the audio from a half-restored graph and leave it stuck on a partial chain. Coalesce
// to one rebuild after the burst settles, when editor.graph is whole again. node:added is needed
// so undo of a delete (which re-adds the node + cascades its edges) triggers it.
let rebuildTimer: ReturnType<typeof setTimeout> | undefined
const scheduleRebuild = (): void => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(rebuild, 0) }
offs = [
editor.on('edge:connected', scheduleRebuild),
editor.on('edge:disconnected', scheduleRebuild),
editor.on('node:removed', scheduleRebuild),
editor.on('node:added', scheduleRebuild),
// A knob moved → retune the matching AudioParam live.
editor.on('widget:changed', ({ nodeId, widgetId, value }) => {
const an = map?.get(nodeId)
if (!an) return
if (widgetId === 'freq') (an as OscillatorNode).frequency.value = Number(value)
else if (widgetId === 'wave') (an as OscillatorNode).type = String(value) as OscillatorType
else if (widgetId === 'cutoff') (an as BiquadFilterNode).frequency.value = Number(value)
else if (widgetId === 'ftype') (an as BiquadFilterNode).type = String(value) as BiquadFilterType
else if (widgetId === 'level') (an as GainNode).gain.value = Math.min(0.6, Number(value))
}),
]
}
const stop = (): void => {
for (const off of offs) off()
offs = []
teardownAudio(map)
ctx?.close().catch(() => {})
ctx = null; map = null
editor.clearNodeStatuses()
}
return { play, stop, dispose: stop }
}
/** @deprecated Combined loader+engine for back-compat with non-React hosts. Prefer
* `loadAudioGraph` (in `onReady`) + `createAudioEngine` (in an effect) in fresh code. */
export function buildAudioSynth(editor: XenolithEditor): AudioSynthHandle {
loadAudioGraph(editor)
return createAudioEngine(editor)
} {
"version": "xenolith.v1",
"nodes": [
{
"id": "osc",
"type": "Oscillator",
"position": {
"x": 0,
"y": 60
},
"render": {
"title": "Oscillator"
},
"state": {
"wave": "sawtooth",
"freq": 220
},
"pins": [
{
"id": "osc:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "wave",
"type": "combo",
"key": "wave",
"label": "Wave",
"values": [
"sine",
"square",
"sawtooth",
"triangle"
],
"freeFloating": true
},
{
"id": "freq",
"type": "slider",
"key": "freq",
"label": "Freq",
"min": 50,
"max": 1200,
"step": 1,
"freeFloating": true
}
]
},
{
"id": "filter",
"type": "Filter",
"position": {
"x": 260,
"y": 60
},
"render": {
"title": "Filter"
},
"state": {
"ftype": "lowpass",
"cutoff": 900
},
"pins": [
{
"id": "filter:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "filter:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "ftype",
"type": "combo",
"key": "ftype",
"label": "Type",
"values": [
"lowpass",
"highpass",
"bandpass"
],
"freeFloating": true
},
{
"id": "cutoff",
"type": "slider",
"key": "cutoff",
"label": "Cutoff",
"min": 80,
"max": 8000,
"step": 10,
"freeFloating": true
}
]
},
{
"id": "gain",
"type": "Gain",
"position": {
"x": 520,
"y": 60
},
"render": {
"title": "Gain"
},
"state": {
"level": 0.25
},
"pins": [
{
"id": "gain:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
},
{
"id": "gain:out",
"kind": "data",
"direction": "out",
"type": "audio",
"multiple": true,
"label": "Out"
}
],
"widgets": [
{
"id": "level",
"type": "slider",
"key": "level",
"label": "Level",
"min": 0,
"max": 1,
"step": 0.01,
"freeFloating": true
}
]
},
{
"id": "output",
"type": "Output",
"position": {
"x": 780,
"y": 90
},
"render": {
"title": "🔊 Output"
},
"pins": [
{
"id": "output:in",
"kind": "data",
"direction": "in",
"type": "audio",
"multiple": false,
"label": "In"
}
]
}
],
"edges": [
{
"id": "e-of",
"from": {
"node": "osc",
"pin": "osc:out"
},
"to": {
"node": "filter",
"pin": "filter:in"
}
},
{
"id": "e-fg",
"from": {
"node": "filter",
"pin": "filter:out"
},
"to": {
"node": "gain",
"pin": "gain:in"
}
},
{
"id": "e-go",
"from": {
"node": "gain",
"pin": "gain:out"
},
"to": {
"node": "output",
"pin": "output:in"
}
}
]
} // Angular — no panel component. Play sits in a DOM card; the service owns the editor.
import { AfterViewInit, Component, ElementRef, OnDestroy, inject, signal, viewChild } from '@angular/core'
import { XenolithGraphService } from '@xenolithengine/graph-angular'
import { loadAudioGraph, createAudioEngine, type AudioSynthHandle } from '@xenolithengine/demo/audio-synth'
@Component({
selector: 'audio-synth-demo',
standalone: true,
providers: [XenolithGraphService],
template: `
<div style="position:absolute;inset:0;">
<div #host class="xeno" style="position:absolute;inset:0;"></div>
<div class="card">
<button type="button" class="btn" [class.on]="playing()" (click)="toggle()">
{{ playing() ? '■ Stop' : '▶ Play' }}
</button>
<span class="note">Tweak the knobs while it plays — the chain is wired from the graph; the active path glows.</span>
</div>
</div>
`,
styles: [`
.card { position:absolute; top:12px; left:12px; z-index:5; display:flex; flex-direction:column;
gap:6px; max-width:220px; padding:10px; background:var(--xeno-panel,#1d1d1d);
border:1px solid var(--xeno-border,#333); border-radius:10px;
color:var(--xeno-text,#cfcfcf); font:13px system-ui,sans-serif; }
.btn { font:inherit; font-size:13px; padding:7px 12px; cursor:pointer; border-radius:8px; width:100%;
border:1px solid var(--xeno-border,#333); background:var(--xeno-elevated,#262626); color:var(--xeno-text,#cfcfcf); }
.btn.on { border-color:var(--xeno-accent,#FCB400); background:var(--xeno-accent,#FCB400); color:var(--xeno-canvas,#111); }
.note { color:var(--xeno-muted,#9a9a9a); font-size:11px; line-height:1.4; }
`],
})
export class AudioSynthDemoComponent implements AfterViewInit, OnDestroy {
private graph = inject(XenolithGraphService)
private host = viewChild.required<ElementRef<HTMLDivElement>>('host')
private engine: AudioSynthHandle | null = null
playing = signal(false)
async ngAfterViewInit(): Promise<void> {
const editor = await this.graph.mount(this.host().nativeElement, { resizeToWindow: false })
loadAudioGraph(editor)
this.engine = createAudioEngine(editor)
}
toggle(): void {
const engine = this.engine
if (!engine) return
if (this.playing()) engine.stop()
else engine.play()
this.playing.update((on) => !on)
}
ngOnDestroy(): void {
this.engine?.dispose()
this.engine = null
this.graph.destroy()
}
} // A tiny Web Audio synth built ON the graph: Oscillator → Filter → Gain → Output. The graph is DATA
// (audio-synth.json, loaded with editor.loadJSON); the node widgets are the synth's knobs.
//
// Everything here is framework-agnostic editor + Web Audio API. play() owns an AudioContext, wires
// real AudioNodes from the current edges and lights the active chain; while playing, it subscribes to
// the editor's own events so any knob tweak retunes the matching AudioParam and any edge/node change
// re-wires the audio + re-lights the chain (cutting Filter→Gain silences and darkens everything past
// it). reachableFrom keeps a node past a broken/deleted edge out of the live chain.
import { reachableFrom } from '@xenolithengine/graph-core'
import type { XenolithEditor, NodeId } from '@xenolithengine/graph-editor'
import graph from './audio-synth.json'
export interface AudioSynthHandle {
play(): void
stop(): void
/** Tear down audio + event subscriptions; call on host unmount. */
dispose(): void
}
/** Build live Web Audio nodes from the graph and connect them per the edges. */
function startAudio(editor: XenolithEditor, ctx: AudioContext): Map<NodeId, AudioNode> {
const map = new Map<NodeId, AudioNode>()
for (const n of editor.graphNodes()) {
if (n.type === 'Oscillator') {
const o = ctx.createOscillator()
o.type = String(n.state['wave']) as OscillatorType
o.frequency.value = Number(n.state['freq']) || 220
o.start()
map.set(n.id, o)
} else if (n.type === 'Filter') {
const f = ctx.createBiquadFilter()
f.type = String(n.state['ftype']) as BiquadFilterType
f.frequency.value = Number(n.state['cutoff']) || 900
map.set(n.id, f)
} else if (n.type === 'Gain') {
const g = ctx.createGain()
g.gain.value = Math.min(0.6, Number(n.state['level']) || 0)
map.set(n.id, g)
} else if (n.type === 'Output') {
map.set(n.id, ctx.destination)
}
}
for (const e of editor.graphEdges()) {
const a = map.get(e.from.node), b = map.get(e.to.node)
if (a && b && 'connect' in a) (a as AudioNode).connect(b)
}
return map
}
function teardownAudio(map: Map<NodeId, AudioNode> | null): void {
if (!map) return
for (const an of map.values()) {
if (an instanceof OscillatorNode) { try { an.stop() } catch { /* already stopped */ } }
if ('disconnect' in an && an !== an.context.destination) { try { an.disconnect() } catch { /* noop */ } }
}
map.clear()
}
/** Load the synth graph into the editor. Pass to `<XenolithGraph onReady>` so the canvas paints
* with the graph on the very first frame — no flicker, no useEffect-after-mount delay. */
export function loadAudioGraph(editor: XenolithEditor): void {
editor.loadJSON(graph)
editor.fitView({ padding: 64, maxZoom: 1 })
}
/** Spin up the Web Audio engine on top of an already-loaded graph. Returns an imperative handle —
* the engine owns an AudioContext + event subscriptions; `dispose()` releases both. */
export function createAudioEngine(editor: XenolithEditor): AudioSynthHandle {
let ctx: AudioContext | null = null
let map: Map<NodeId, AudioNode> | null = null
let offs: Array<() => void> = []
const sourceId = (): NodeId | undefined => [...editor.graphNodes()].find((n) => n.type === 'Oscillator')?.id
// Light ONLY the nodes still reachable from the oscillator through connected edges — mirrors the
// audible chain, so a node past a broken/deleted edge stops glowing.
const lightActiveChain = (): void => {
editor.clearNodeStatuses()
const src = sourceId()
if (!src) return
for (const id of reachableFrom(editor.readGraph(), src)) editor.setNodeStatus(id, 'running')
}
// (Re)build the audio graph from the CURRENT edges and re-light the chain.
const rebuild = (): void => {
if (!ctx) return
teardownAudio(map)
map = startAudio(editor, ctx)
lightActiveChain()
}
const play = (): void => {
if (ctx) return
const Ctor = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
ctx = new Ctor()
rebuild()
// Debounce: a single user action (delete, and especially undo/redo of it) fires a burst of
// graph events as the transaction restores the node + its edges one by one. Rebuilding on each
// would wire the audio from a half-restored graph and leave it stuck on a partial chain. Coalesce
// to one rebuild after the burst settles, when editor.graph is whole again. node:added is needed
// so undo of a delete (which re-adds the node + cascades its edges) triggers it.
let rebuildTimer: ReturnType<typeof setTimeout> | undefined
const scheduleRebuild = (): void => { clearTimeout(rebuildTimer); rebuildTimer = setTimeout(rebuild, 0) }
offs = [
editor.on('edge:connected', scheduleRebuild),
editor.on('edge:disconnected', scheduleRebuild),
editor.on('node:removed', scheduleRebuild),
editor.on('node:added', scheduleRebuild),
// A knob moved → retune the matching AudioParam live.
editor.on('widget:changed', ({ nodeId, widgetId, value }) => {
const an = map?.get(nodeId)
if (!an) return
if (widgetId === 'freq') (an as OscillatorNode).frequency.value = Number(value)
else if (widgetId === 'wave') (an as OscillatorNode).type = String(value) as OscillatorType
else if (widgetId === 'cutoff') (an as BiquadFilterNode).frequency.value = Number(value)
else if (widgetId === 'ftype') (an as BiquadFilterNode).type = String(value) as BiquadFilterType
else if (widgetId === 'level') (an as GainNode).gain.value = Math.min(0.6, Number(value))
}),
]
}
const stop = (): void => {
for (const off of offs) off()
offs = []
teardownAudio(map)
ctx?.close().catch(() => {})
ctx = null; map = null
editor.clearNodeStatuses()
}
return { play, stop, dispose: stop }
}
/** @deprecated Combined loader+engine for back-compat with non-React hosts. Prefer
* `loadAudioGraph` (in `onReady`) + `createAudioEngine` (in an effect) in fresh code. */
export function buildAudioSynth(editor: XenolithEditor): AudioSynthHandle {
loadAudioGraph(editor)
return createAudioEngine(editor)
}