Patch the signal
Connect two generated sources through a filter or directly to an output, then audition the resulting route.

Drag an output socket to an input, or select both with keyboard or touch. Listen opts into audio; connection chips remove cables.
Subframe Original · MIT
Add to your project
Component source
Standalone React component. Styles are included.
React only. No additional packages.
Render the default export inside a container with a defined height. Its optional active prop stops movement when false. This example works with React 19; Next.js projects can use it as a client component.
View source code
/**
* MIT License
* Copyright (c) 2026 Subframe
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
"use client"
import * as React from "react"
export type Config = {
portSpacing: number
signalFrequency: number
showIncompatiblePorts: boolean
cableSettleMs: number
}
export const DEFAULT_CONFIG: Config = {
portSpacing: 64,
signalFrequency: 220,
showIncompatiblePorts: true,
cableSettleMs: 160,
}
type Output = "sine" | "triangle" | "filter"
type Input = "filter-in" | "output"
type Cable = { from: Output; to: Input; animate?: boolean }
type Point = { x: number; y: number }
type AudioRack = {
context: AudioContext
oscillators: OscillatorNode[]
sources: GainNode[]
filter: BiquadFilterNode
analyser: AnalyserNode
master: GainNode
}
const NAMES = { sine: "Sine", triangle: "Triangle", filter: "Filter", "filter-in": "Filter", output: "Output" }
const COLORS = { sine: "#deddd3", triangle: "#ec6856", filter: "#ec6856" }
const bound = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, Number.isFinite(value) ? value : min))
const curve = (from: Point, to: Point) =>
`M${from.x} ${from.y}C${from.x + 55} ${from.y + 20},${to.x - 55} ${to.y + 20},${to.x} ${to.y}`
export default function Example({ active = true, config }: { active?: boolean; config?: Partial<Config> }) {
const settings = { ...DEFAULT_CONFIG, ...config }
settings.portSpacing = bound(settings.portSpacing, 48, 80)
settings.signalFrequency = bound(settings.signalFrequency, 110, 440)
settings.cableSettleMs = bound(settings.cableSettleMs, 0, 240)
const [cables, setCables] = React.useState<Cable[]>([
{ from: "sine", to: "filter-in" },
{ from: "filter", to: "output" },
])
const [pending, setPending] = React.useState<Output | null>(null)
const [point, setPoint] = React.useState<Point | null>(null)
const [playing, setPlaying] = React.useState(false)
const [status, setStatus] = React.useState("Connect a source to an input")
const [keyboard, setKeyboard] = React.useState(false)
const body = React.useRef<HTMLDivElement>(null)
const meter = React.useRef<HTMLSpanElement>(null)
const rack = React.useRef<AudioRack | null>(null)
const frame = React.useRef(0)
const drag = React.useRef<{ from: Output; start: Point; owner: HTMLButtonElement; pointerId: number } | null>(null)
const positions: Record<Output | Input, Point> = {
sine: { x: 132, y: 106 - settings.portSpacing / 2 },
triangle: { x: 132, y: 106 + settings.portSpacing / 2 },
"filter-in": { x: 244, y: 106 },
filter: { x: 344, y: 106 },
output: { x: 444, y: 106 },
}
const cancelDrag = React.useCallback(() => {
const previous = drag.current
drag.current = null
if (previous?.owner.hasPointerCapture(previous.pointerId)) {
previous.owner.releasePointerCapture(previous.pointerId)
}
setPending(null)
setPoint(null)
}, [])
const stop = React.useCallback(() => {
cancelAnimationFrame(frame.current)
const previous = rack.current
rack.current = null
if (previous) {
previous.oscillators.forEach((node) => node.stop())
previous.master.disconnect()
if (previous.context.state !== "closed") {
void previous.context.close()
}
}
if (meter.current) {
meter.current.style.transform = "scaleX(0)"
}
setPlaying(false)
}, [])
React.useEffect(() => {
if (!active) {
stop()
cancelDrag()
}
}, [active, stop, cancelDrag])
React.useEffect(() => {
const hide = () => {
if (document.hidden) {
stop()
cancelDrag()
}
}
document.addEventListener("visibilitychange", hide)
return () => {
document.removeEventListener("visibilitychange", hide)
stop()
}
}, [stop, cancelDrag])
React.useEffect(() => {
cancelDrag()
}, [settings.portSpacing, cancelDrag])
React.useEffect(() => {
const audio = rack.current
if (!audio || !playing) {
return
}
audio.sources.forEach((node) => node.disconnect())
audio.filter.disconnect()
cables.forEach((cable) => {
const from = cable.from === "filter" ? audio.filter : audio.sources[cable.from === "sine" ? 0 : 1]
from.connect(cable.to === "filter-in" ? audio.filter : audio.master)
})
}, [cables, playing])
React.useEffect(() => {
if (!playing) {
return
}
const samples = new Float32Array(256)
const update = () => {
const audio = rack.current
if (!audio) {
return
}
audio.analyser.getFloatTimeDomainData(samples)
const rms = Math.sqrt(samples.reduce((sum, value) => sum + value * value, 0) / samples.length)
if (meter.current) {
meter.current.style.transform = `scaleX(${Math.min(1, rms * 18)})`
}
frame.current = requestAnimationFrame(update)
}
frame.current = requestAnimationFrame(update)
return () => cancelAnimationFrame(frame.current)
}, [playing])
const listen = async () => {
if (!active) {
return
}
if (rack.current) {
stop()
return
}
if (typeof AudioContext === "undefined") {
setStatus("Audio is unavailable in this browser")
return
}
const context = new AudioContext()
const master = context.createGain()
master.gain.value = 0.55
const analyser = context.createAnalyser()
analyser.fftSize = 256
master.connect(analyser)
analyser.connect(context.destination)
const filter = context.createBiquadFilter()
filter.type = "lowpass"
filter.frequency.value = 650
filter.Q.value = 0.7
const sources = [context.createGain(), context.createGain()]
sources.forEach((node) => {
node.gain.value = 0.045
})
const oscillators = sources.map((gain, i) => {
const oscillator = context.createOscillator()
oscillator.type = i ? "triangle" : "sine"
oscillator.frequency.value = settings.signalFrequency * (i ? 1.5 : 1)
oscillator.connect(gain)
oscillator.start()
return oscillator
})
const audio = { context, master, analyser, filter, sources, oscillators }
rack.current = audio
try {
await context.resume()
if (rack.current === audio) {
setPlaying(true)
setStatus("Listening to the connected route")
}
} catch {
if (rack.current === audio) {
stop()
setStatus("Audio could not start. Try Listen again.")
}
}
}
const connect = (from: Output, to: Input) => {
if (!active) {
return
}
if (from === "filter" && to === "filter-in") {
setStatus("A filter cannot connect to itself")
return
}
if (cables.some((c) => c.from === from && c.to === to)) {
setStatus("That connection already exists")
cancelDrag()
return
}
const retained = to === "filter-in" ? cables.filter((c) => c.to !== "filter-in") : cables
if (retained.length >= 3) {
setStatus("Remove a cable before adding another")
cancelDrag()
return
}
setCables([...retained, { from, to, animate: true }])
setStatus(`${NAMES[from]} connected to ${NAMES[to]}`)
cancelDrag()
}
const localPoint = (event: React.PointerEvent): Point => {
const rect = body.current!.getBoundingClientRect()
return { x: ((event.clientX - rect.left) / rect.width) * 520, y: ((event.clientY - rect.top) / rect.height) * 210 }
}
const begin = (event: React.PointerEvent<HTMLButtonElement>, from: Output) => {
if (!active || event.button !== 0) {
return
}
event.currentTarget.setPointerCapture(event.pointerId)
const start = localPoint(event)
drag.current = { from, start, owner: event.currentTarget, pointerId: event.pointerId }
setPending(from)
setPoint(null)
setStatus(`Choose an input for ${NAMES[from]}`)
}
const end = (event: React.PointerEvent<HTMLButtonElement>) => {
const current = drag.current
if (!current) {
return
}
const endPoint = localPoint(event)
const moved = Math.hypot(endPoint.x - current.start.x, endPoint.y - current.start.y) > 7
drag.current = null
if (current.owner.hasPointerCapture(current.pointerId)) {
current.owner.releasePointerCapture(current.pointerId)
}
if (moved) {
const target = (["filter-in", "output"] as Input[]).find(
(id) => Math.hypot(positions[id].x - endPoint.x, positions[id].y - endPoint.y) < 28,
)
if (target) {
connect(current.from, target)
} else {
setPending(null)
setStatus("Connection cancelled")
}
}
setPoint(null)
}
return (
<section
className="sf-audio-patchbay"
aria-label="Audio routing patchbay"
data-still={!active || keyboard}
onPointerDownCapture={() => setKeyboard(false)}
onKeyDownCapture={(event) => {
setKeyboard(true)
if (event.key === "Escape") {
cancelDrag()
setStatus("Connection cancelled")
}
}}
style={{ "--ap-duration": `${settings.cableSettleMs}ms` } as React.CSSProperties}
>
<div className="ap-rack">
<header>
<strong>Signal path</strong>
<button
type="button"
className="ap-listen"
disabled={!active}
onClick={() => {
void listen()
}}
aria-pressed={playing}
>
<svg viewBox="0 0 16 16" aria-hidden>
{playing ? <path d="M5 4v8M11 4v8" /> : <path d="m5 3 8 5-8 5Z" />}
</svg>
{playing ? "Stop" : "Listen"}
</button>
</header>
<div className="ap-body" ref={body}>
<svg viewBox="0 0 520 210" aria-hidden>
{["sine", "triangle"].map((id) => {
const key = id as "sine" | "triangle"
const p = positions[key]
return (
<g key={id}>
<rect x="24" y={p.y - 22} width="108" height="44" rx="7" fill="#343a3f" stroke="#5b6368" />
<text x="39" y={p.y - 2} className="ap-node-label">
{NAMES[key]}
</text>
<path
d={key === "sine" ? `M39 ${p.y + 10}q5-9 10 0t10 0t10 0` : `M39 ${p.y + 10}l8-5 8 10 8-10 8 5`}
fill="none"
stroke="#9ba4a8"
strokeWidth="1.2"
/>
</g>
)
})}
<rect x="244" y="72" width="100" height="68" rx="7" fill="#343a3f" stroke="#5b6368" />
<text x="294" y="96" textAnchor="middle" className="ap-node-label">
Low-pass
</text>
<path d="M272 119h20q5 0 8 6l8 8" fill="none" stroke="#9ba4a8" strokeWidth="1.3" />
<rect x="444" y="72" width="60" height="68" rx="7" fill="#343a3f" stroke="#5b6368" />
<text x="474" y="97" textAnchor="middle" className="ap-node-label">
Output
</text>
<path d="M465 114h4l5-4v16l-5-4h-4Zm13-1q7 5 0 10" fill="none" stroke="#9ba4a8" strokeWidth="1.3" />
{cables.map((cable) => (
<g key={`${cable.from}:${cable.to}`} className={cable.animate ? "ap-cable" : undefined}>
<path
d={curve(positions[cable.from], positions[cable.to])}
fill="none"
stroke="#111518"
strokeWidth="7"
transform="translate(0 3)"
opacity=".45"
/>
<path
d={curve(positions[cable.from], positions[cable.to])}
fill="none"
stroke={COLORS[cable.from]}
strokeWidth="3.5"
strokeLinecap="round"
/>
</g>
))}
{pending && point ? (
<path
d={curve(positions[pending], point)}
fill="none"
stroke={COLORS[pending]}
strokeWidth="2"
strokeDasharray="5 5"
/>
) : null}
{(Object.keys(positions) as (Output | Input)[]).map((id) => {
const p = positions[id]
const incompatible = pending === "filter" && id === "filter-in"
return (
<g key={id} opacity={incompatible ? (settings.showIncompatiblePorts ? 0.3 : 0) : 1}>
<circle
cx={p.x}
cy={p.y}
r="10"
fill="#191d20"
stroke={pending === id ? "#f2f1e7" : "#a4adaf"}
strokeWidth="2"
/>
<circle cx={p.x} cy={p.y} r="4" fill={pending === id ? "#deddd3" : "#858f92"} />
</g>
)
})}
<text x="24" y="188" className="ap-section-label">
SOURCES
</text>
<text x="244" y="188" className="ap-section-label">
PROCESS
</text>
<text x="444" y="188" className="ap-section-label">
OUT
</text>
</svg>
{(["sine", "triangle", "filter"] as Output[]).map((id) => (
<button
type="button"
key={id}
className="ap-socket"
style={{ left: `${(positions[id].x / 520) * 100}%`, top: `${(positions[id].y / 210) * 100}%` }}
aria-label={`Connect ${NAMES[id]} output`}
aria-pressed={pending === id}
disabled={!active}
onPointerDown={(event) => begin(event, id)}
onPointerMove={(event) => {
if (drag.current) {
setPoint(localPoint(event))
}
}}
onPointerUp={end}
onPointerCancel={cancelDrag}
onLostPointerCapture={() => {
if (drag.current) {
cancelDrag()
}
}}
onClick={(event) => {
if (event.detail === 0 && active) {
setPending(pending === id ? null : id)
setPoint(null)
setStatus(`Choose an input for ${NAMES[id]}`)
}
}}
/>
))}
{(["filter-in", "output"] as Input[]).map((id) => (
<button
type="button"
key={id}
className="ap-socket ap-input"
style={{
left: `${(positions[id].x / 520) * 100}%`,
top: `${(positions[id].y / 210) * 100}%`,
visibility:
pending === "filter" && id === "filter-in" && !settings.showIncompatiblePorts ? "hidden" : undefined,
}}
aria-label={`${NAMES[id]} input${pending ? `, connect ${NAMES[pending]}` : ", select a source first"}`}
disabled={!active || !pending}
onClick={() => {
if (pending) {
connect(pending, id)
}
}}
/>
))}
</div>
<footer>
<div className="ap-routes" aria-label="Connections">
{cables.map((cable) => (
<button
type="button"
key={`${cable.from}:${cable.to}`}
disabled={!active}
aria-label={`Disconnect ${NAMES[cable.from]} from ${NAMES[cable.to]}`}
onClick={() => {
setCables((previous) => previous.filter((c) => c !== cable))
setStatus(`${NAMES[cable.from]} disconnected from ${NAMES[cable.to]}`)
}}
>
<i style={{ background: COLORS[cable.from] }} />
{NAMES[cable.from]}
<span>→</span>
{NAMES[cable.to]}
<b aria-hidden>×</b>
</button>
))}
</div>
<div className="ap-level" aria-label={playing ? "Output level" : "Audio stopped"}>
<span ref={meter} />
</div>
</footer>
<span className="ap-status" role="status">
{status}
</span>
</div>
<style>{`
.sf-audio-patchbay{width:100%;height:100%;min-height:260px;box-sizing:border-box;display:grid;place-items:center;padding:25px;background:#202427;color:#ecece6;font:12px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;overflow:hidden;isolation:isolate}.sf-audio-patchbay *{box-sizing:border-box}.sf-audio-patchbay button{font:inherit;color:inherit;cursor:pointer;touch-action:manipulation}.sf-audio-patchbay button:disabled{cursor:default}.sf-audio-patchbay .ap-rack{width:100%;max-width:550px;background:#2d3134;border:1px solid #51575b;border-radius:14px;box-shadow:0 2px 4px #10131430,0 18px 35px #10131435;overflow:hidden}.sf-audio-patchbay header{height:59px;display:flex;align-items:center;justify-content:space-between;padding:0 22px;border-bottom:1px solid #444b50}.sf-audio-patchbay header strong{font-size:14px;font-weight:500;letter-spacing:-.15px}.sf-audio-patchbay .ap-listen{height:44px;min-width:84px;border:1px solid #727b80;background:#3b4247;border-radius:7px;display:flex;align-items:center;justify-content:center;gap:8px;font-size:11px}.sf-audio-patchbay .ap-listen svg{width:12px;height:12px;fill:currentColor;stroke:currentColor;stroke-width:1.7}.sf-audio-patchbay .ap-listen[aria-pressed=true]{background:#deddd3;border-color:#deddd3;color:#2b302d}.sf-audio-patchbay .ap-body{position:relative;aspect-ratio:520/210;width:100%}.sf-audio-patchbay .ap-body>svg{display:block;width:100%;height:100%}.sf-audio-patchbay .ap-node-label{font:11px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;fill:#ecece6}.sf-audio-patchbay .ap-section-label{font:8px ui-monospace,SFMono-Regular,monospace;letter-spacing:1px;fill:#8f999e}.sf-audio-patchbay .ap-socket{position:absolute;transform:translate(-50%,-50%);width:44px;height:44px;border:0;background:transparent;border-radius:50%;padding:0;touch-action:none}.sf-audio-patchbay .ap-socket:focus-visible{outline:2px solid #fff2c7;outline-offset:-7px}.sf-audio-patchbay .ap-input:not(:disabled){background:radial-gradient(circle,transparent 12px,#deddd315 12px,#deddd315 19px,transparent 19px)}.sf-audio-patchbay .ap-cable{animation:ap-connect var(--ap-duration) ease-out}.sf-audio-patchbay footer{min-height:54px;display:flex;align-items:center;gap:12px;justify-content:space-between;padding:4px 18px;border-top:1px solid #444b50}.sf-audio-patchbay .ap-routes{display:flex;flex-wrap:wrap;gap:4px}.sf-audio-patchbay .ap-routes button{display:flex;align-items:center;gap:5px;min-height:44px;background:none;border:0;border-radius:5px;padding:4px;font-size:9px;color:#c1c9cb;white-space:nowrap}.sf-audio-patchbay .ap-routes i{width:4px;height:4px;border-radius:50%}.sf-audio-patchbay .ap-routes span{color:#879297}.sf-audio-patchbay .ap-routes b{font-size:13px;font-weight:400;color:#9eaaae;padding-left:2px}.sf-audio-patchbay .ap-level{width:40px;height:5px;border-radius:1px;background:#191d20;flex:none;overflow:hidden}.sf-audio-patchbay .ap-level span{display:block;width:100%;height:100%;background:#deddd3;transform:scaleX(0);transform-origin:left}.sf-audio-patchbay .ap-status{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}.sf-audio-patchbay button:focus-visible{outline:2px solid #fff2c7;outline-offset:2px}.sf-audio-patchbay[data-still=true] *{animation:none!important;transition:none!important}@keyframes ap-connect{from{opacity:0}to{opacity:1}}@media(prefers-reduced-motion:reduce){.sf-audio-patchbay *{animation:none!important;transition:none!important}}@media(max-width:430px){.sf-audio-patchbay{padding:14px}.sf-audio-patchbay header{padding:0 15px}.sf-audio-patchbay footer{padding:4px 10px}.sf-audio-patchbay .ap-routes button{font-size:8px;gap:3px}}
`}</style>
</section>
)
}
Where it works well
Make audio routing topology editable and show which connections actually reach the output.
Reduced motion
New-cable fades are removed for keyboard input and reduced motion. Audio remains explicit opt-in; host pause, tab hiding and unmount stop and close the owned audio context.
Things to know
Two quiet generated oscillators, one fixed low-pass filter, one output and at most three cables. No microphone, samples, node creation or arbitrary graphs. The output meter reads an AnalyserNode; audio quality is not a physical-device certification.