Backend:
- One pw_stream (INPUT, F32) per audio node; for sinks use
stream.capture.sink=true to read the monitor port
- RT process callback computes instantaneous linear peak with no
allocations; stored in std::atomic<float>
- Meter streams are filtered from the registry so they never appear
in the graph or trigger recursive meter creation
- Meter streams are created on first node-ready event, destroyed on
node removal, and cleaned up on engine close
- GET /api/peaks → {node_id: linear_peak} (polled by frontend)
Frontend:
- peaks store polled at 100 ms via setInterval; starts/stops with
initGraph/destroyGraph
- Each node card grows 8 px and shows a 3 px meter bar at the bottom
(green below -12 dB, yellow -12 to -3 dB, red above -3 dB)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add VirtualNodeDef type; track it in PatchbayState.virtual_nodes
- createNullSink/createLoopback now register the node in the global
virtual_nodes registry on success
- Destroying a node via context menu removes it from the registry
- saveProfile snapshots virtual_nodes into the profile
- loadProfile recreates any missing virtual nodes before applying
connections (waits 1.5s for graph to settle after creation)
- Backward compat: virtual_nodes defaults to [] for old save files
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setNodeVolume/setNodeMute only send API requests; the nodes store
wasn't updated until the backend broadcast a graph change, so sliders
showed stale values. Now the store is updated immediately before
firing the API calls.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Hide rules now use anchored regex (^rule$) so plain text like
"Speaker" matches exactly "Speaker", not "Gaming Speaker".
To match a substring, use e.g. ".*Speaker.*". Rules that already
start with ^ or end with $ are used as-is.
- Add "Hide" to node right-click context menu — adds the node's
display name as a hide rule immediately
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Snapshot node volumes and mutes when saving a profile; restore
them on load via setNodeVolume/setNodeMute for each matching node
- Fix hide rules to do exact case-insensitive match on display name
(alias/nick) instead of regex substring match on PW name, so
"Speaker" no longer accidentally hides "Gaming Speaker"
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Right-click any node → Rename to set a custom label. The alias is shown
in the node header and Properties dialog instead of the raw PipeWire name.
Leave the input blank (or press Reset) to revert to the PW name.
Aliases are stored in aliases: Record<string,string> inside the patchbay
state (keyed by PW node name, which is stable for hardware devices) and
persisted automatically with the rest of the patchbay config. Old save
files without an aliases key are handled gracefully via the ?? {} fallback.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Issue 1 - Volume/mute changes from external apps (browser YT player, pulsemixer)
not reflected in the frontend:
- on_node_param and on_node_info updated node state but never called notifyChanged(),
so the SSE broadcast was never triggered for external changes.
- Add engine_ref back-pointer to Object (set in create_proxy_for_object).
- Call notifyChanged() at the end of on_node_info and after updating Props
in on_node_param, so any external volume/mute change immediately broadcasts.
Issue 2 - Volume slider has no audible effect on browser/app streams:
- setNodeVolume only set SPA_PROP_volume (single float). Browser streams
(Chromium, Firefox) use SPA_PROP_channelVolumes (per-channel float array)
and ignore the single-float property.
- Now set both SPA_PROP_volume AND SPA_PROP_channelVolumes (using the node's
known channel count, defaulting to stereo if unknown). ALSA hardware nodes
respond to SPA_PROP_volume; app streams respond to SPA_PROP_channelVolumes.
Note: wires auto-reconnecting is WirePlumber session policy (by design) —
WirePlumber re-links any stream that loses its connection to the default sink.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Bug 1 - Mute unmute not sticking (G560 and others):
- Root cause: on_node_info was reading volume/mute from info->props which
contains static initial values only — NOT updated at runtime. When any
node info event fired, it overwrote the correct runtime state with stale
initial data, causing the unmute to revert on the next graph event.
- Fix: Subscribe nodes to SPA_PARAM_Props in addition to SPA_PARAM_Format.
Handle SPA_PARAM_Props in on_node_param to track volume (both SPA_PROP_volume
and SPA_PROP_channelVolumes averaged) and mute from the authoritative live
parameter stream. Remove stale volume/mute reads from on_node_info.
- Also fix mute detection in /api/mute: check "mute":true precisely instead
of searching for bare "true" anywhere in the body.
Bug 2 - Loading profiles does not work:
- loadProfile was only applying connections when already in "activated" mode.
Load now always applies the profile connections immediately.
Bug 3 - No option to update an existing profile:
- Add "Update" button in profile list that overwrites the profile with current
connections (calls saveProfile with the existing name).
- Clear the profile name input after "Save Current" succeeds.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Rewrote applyVolumeAtMouse to use hitarea element's screen rect directly
- Works correctly regardless of zoom/pan level
- Made hitarea taller (18px) for easier grabbing
- Toggle button next to 'Merge Nodes' in toolbar
- When active: skips merge rules, each PipeWire node shown separately
- Green=output, red=input (no confusing duplex merging)
- Defaults to off (merged mode)
- When merge rules combine input+output nodes, mode is set to 'duplex'
- Duplex nodes: blue border (#49a), blue-tinted bg, dark blue header
- Visually distinct from green=output, red=input nodes
Buffer Control:
- GET /api/quantum - reads current graph quantum via pw-metadata
- POST /api/quantum {quantum:N} - sets quantum via pw-metadata
- Toolbar dropdown with presets: 32, 64, 128, 256, 512, 1024, 2048, 4096
- Loads current quantum on startup
Text Selection Fix:
- Added user-select: none to wrap and canvas CSS
- Node text no longer gets selected when dragging
- Subscribe to SPA_PARAM_Format on each node via pw_node_subscribe_params
- Parse audio format with spa_format_audio_raw_parse to get actual rate/channels
- Shows real negotiated rates (48000 Hz, 44100 Hz, etc.) instead of defaults
- Added spa/param/format-utils.h, audio/format-utils.h, audio/raw-types.h
- Removed duplicated/broken code from property parsing
- Always shows Channels and Sample Rate for audio nodes
- Shows 'default' if sample rate not available on node
- Latency shows ms calculation when sample rate known
- Added fallback: clock.rate, api.alsa.rate
- node.rate was a PipeWire internal flag (always 1), not sample rate
- Now reads from node.latency (format: '256/48000') for quantum + rate
- Fallback to clock.rate if latency not available
- Reuses rate field for ALSA period-size
- Shows 'Latency: 256 samples @ 48000 Hz' and 'Period Size: 256'
- TCP Tunnel Sink/Source now open a dialog asking for host:port
- TCP Network Server uses configurable port (shown in dropdown)
- Host defaults to 127.0.0.1, port defaults to 4713
- Config persists across clicks (netHost/netPort state)
Backend:
- POST /api/create-null-sink {name} - loads null-sink module
- POST /api/create-loopback {name} - loads loopback module
- POST /api/unload-module {module_id} - unloads a module
- Fixed double-proxy-destroy crash in GraphEngine
- Graceful failure when module not available (no crash)
Frontend:
- + Add Device button in toolbar with dropdown menu
- Null Sink option (creates virtual audio output)
- Loopback Device option (creates paired input+output)
- Dropdown closes on outside click
Note: null-sink requires libpipewire-module-null-sink to be installed.
Loopback works on all PipeWire installations.
Added transparent hitarea rect over the full slider area (14px tall).
Handle is pointer-events:none so clicks pass through to hitarea.
Clicking anywhere on the slider bar now jumps volume there.
- Mute button click no longer triggers node drag (priority check in onMouseDown)
- Volume slider clamped to 0-100% in both display and API
- Added draggable circle handle on slider end
- Volume drag state tracked globally, not per-element
- Backend: fixed spa_pod_builder API usage (push_object/pop with frame)
- Volume calculated from SVG coordinates properly via svgPoint conversion
- Volume slider on every node (green bar, draggable)
- Mute toggle button (M/m) on every node
- Backend: read volume/mute from PipeWire node props
- Backend: POST /api/volume {node_id, volume} to set volume
- Backend: POST /api/mute {node_id, mute} to toggle mute
- Graph JSON includes volume and mute fields per node
- Slider supports drag-to-adjust with mouse
C++ backend with SSE streaming (reuses qpwgraph PipeWire callbacks).
Svelte frontend with custom SVG canvas for port-level connections.
Features:
- Live PipeWire graph via SSE
- Drag output->input port to connect
- Double-click or select+Delete to disconnect
- Node positions saved to localStorage
- Pan (drag bg) and zoom (scroll)
- Port type coloring (audio=green, midi=red, video=blue)