Skip to content

Feature/linkaudio - #360

Merged
x37v merged 27 commits into
developfrom
feature/linkaudio
Sep 14, 2026
Merged

x37v merged 27 commits into
developfrom
feature/linkaudio

Conversation

@x37v

@x37v x37v commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

enabling link audio control via jack transport link thru the runner.
small additional thing, using jack ORDER metadata to sort ports for node

x37v added 7 commits August 6, 2026 07:10
Ports within a group are sorted by display name, which puts a client's ports in
alphabetical rather than signal order. Read the JACK "order" metadata key when
present and sort by it first, falling back to the display name when either port
in a comparison doesn't provide one.
/rnbo/jack/transport/linksync makes JACK transport follow the Link session's
tempo and beat grid, which is distinct from the existing runner<->JACK "sync"
node. Track it in the transport slice (state, action, selector) and dispatch
updates from the bridge; the UI control follows on the Link page.

INIT/UPDATE use an undefined-check rather than || so an explicit false from the
server survives a reconnect.
New "Link" page (route /link, nav entry, and a double-click target on the
jack-link-audio graph node) backed by a linkAudio redux slice, actions,
selectors and OSCQuery bridge wiring for /rnbo/jack/link. It hosts the
session-level controls — Link enabled, the device's Link name, and Sync
transport to Link — alongside the audio ones, so they stay reachable whether or
not Link Audio is available on the runner.

Link Audio is two explicit, ordered lists: Sources (incoming) and Sinks
(outgoing). A device starts with none of either and nothing connects on its own.

  - Add Source is a menu of the channels currently advertised on the network,
    grouped by peer, with already-added channels disabled. A source names one
    exact peer/channel; if that peer goes offline the row stays, marked
    disconnected, and reconnects by itself when the identity returns. Removing
    one takes no confirmation — re-adding is a click.
  - Add Sink prompts for the channel name announced to the session, prefilled
    with the first unused "Send <n>". Non-empty and collision checks run
    client-side, so a bad name is a real error message rather than a silently
    reverted field; renaming in place validates the same way.
  - Up/down buttons reorder either list, which drives the JACK port order shown
    in the graph editor. Buttons rather than drag-and-drop: no new dependency,
    and it works on the Move's touch screen.
  - Latency (ms) sets the receive buffer, and Sync to Incoming Audio (off by
    default) controls whether the local timeline is delayed to align with
    incoming audio. Incoming audio is buffered and audible either way.
  - Per-source receive health — buffered, jitter, dropouts — plus a device
    summary. Dropout counts are cumulative, so rows and the summary both offer
    a reset, letting you measure from the moment you changed a setting. A row
    distinguishes "connected" from "connected, but receiving no audio" and names
    the cause: nothing arriving, or audio from a different Link session that
    can't be beat-aligned. Both used to look identical to a healthy source.

Slot records are keyed by the key the runner publishes rather than a positional
index, and list order comes from the server-authoritative `order` params rather
than a local sort. A rename changes the key, so it arrives as one slot node
removed and another added, which the existing subtree re-read path handles.

On touch devices a focused input is nudged toward the middle of the viewport
once the on-screen keyboard has animated in, and the page grows bottom padding
while a field has focus, so the Sinks fields at the bottom of the page can
actually clear the keyboard.
A source row reports "Behind: N ms" from the runner's arrival_offset_ms, and the
silent-source hint quotes it: Latency has to exceed that figure for any audio to
play, which turns "how much buffer does this need?" into a reading instead of
trial and error.

The hint also points at the sender when the figure is large. A sender that
delays its own transport to monitor incoming audio stamps its outgoing audio
with the delayed beat, so its latency and monitoring settings are paid for by
whoever receives it — and "raise Latency" on its own would send you looking in
the wrong place.
The runner moved per-slot nodes to `{sources,sinks}/list/<key>`, so slot keys no
longer share a namespace with the add/remove/order commands.

Drops the slot-key recognition that existed only to work around that: the
matchers take any `[^/]+` key, and initLinkAudio reads every child of `list`
without filtering. The web ui no longer encodes jack_transport_link's hash
width, so a change to the key format wouldn't reach here.
@x37v
x37v requested a review from fde31 August 6, 2026 15:29
@changeset-bot

changeset-bot Bot commented Aug 6, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd8346c

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@rnbo-runner-panel/client Minor
@rnbo-runner-panel/server Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

fde31 and others added 18 commits August 10, 2026 16:55
jack_transport_link no longer publishes a fixed "jack-link-audio" port
group. Its groups are now display strings -- "Link: Sinks" for the local
sinks, "Link: <peer>" per source peer -- so matching the group string to
find the Link Audio node stopped working, and with it the double click
through to the Link page.

Match on the new .../link/audio/slot property instead. Its presence marks
a port as Link Audio (the same presence-of-key idiom rnbo-instance-id
already uses), and its value is the slot key, which joins the port back
to its LinkAudioSourceRecord/LinkAudioSinkRecord. Nothing reads the slot
key yet, but per-peer filtering wants it and it is what makes the marker
useful rather than a bare boolean.

Promote the result to a third NodeType so the decision lives in one
place, GraphPortRecord.nodeType, rather than in the patcher/system
ternary inlined at each of the three node-construction sites.

Two spots assumed NodeType had exactly two members and tested for System
where they meant "not Patcher": removeEditorNodeById would have called
unloadPatcherNodeOnRemote with an undefined instanceId for a Link Audio
node, and getEditorNodesAndPorts would have looked one up as a patcher
instance. Both inverted.
The Link page had grown into two unrelated halves: device-wide settings
(Link on/off, transport sync, Link name, latency, sync to incoming) and
the per-slot Receive/Send lists. The settings half belongs in Settings,
and the slot lists are about to move into the Devices pane, so split them
apart now.

Extract the pieces both halves share into components/linkAudio/ first --
LinkAudioNameInput and scrollInputIntoView, SlotControls, the source and
sink rows, and the add-slot menu and modal -- plus the reorder swap into
lib/linkAudio. No behavior change beyond hiding the reorder arrows on a
list too short to reorder, where they were inert controls anyway.

The new Link tab is a hand-built Tabs.Panel rather than a
SettingsTabPanel: Link state lives in state.linkAudio and the transport
store, not in app settings or the runner config, so it has no
ConfigRecord to render through. It borrows settings.module.css's row
classes so it lines up with the generic tabs. Receive health sits outside
that row layout -- three stats and a button don't fit itemInputWrap's
250px control column, which clipped the button.

Sources and sinks are "Receives" and "Sends" in the UI now, matching
jack_transport_link's renamed port group. Display only: the model,
actions and OSC paths keep jtl's own vocabulary.

The Link page keeps the slot lists until the Devices pane absorbs them,
at which point the page and its route go away.
The stats and the Reset button shared one nowrap flex row, so on a phone
the button ran off the right edge. Stack them on a narrow screen and put
them side by side from the sm breakpoint, matching where .item in
settings.module.css changes shape. The three stats also wrap among
themselves before the row does, so a very narrow screen breaks the stats
apart rather than pushing the button off.
Link Audio slots were only reachable from a standalone /link page that
sat outside the Devices pane, even though a Link node is a device in the
graph like any other. Fold them in: the device select now lists the
patcher instances and then, in a second group, every Link node, and
double clicking a Link node in the graph opens its page instead of the
old catch-all route.

getLinkDevices groups the LinkAudio nodes by walking their ports'
link/audio/slot property -- never by parsing "Link: <peer>", which is a
display string. Port direction picks the key space: jack_transport_link
registers a Send as a JACK input and a Receive as a JACK output, and sink
and source slot keys are drawn from separate hashes, so a key alone
doesn't identify a slot.

A Receive page lists that peer's channels with their dropout counts and
reset; a Send page lists the announced channels with an Add Send button.
Both allow reordering once there are two or more slots.

Reordering needs care. jack_transport_link keeps one order list for every
source, but a Receive device shows a single peer's slots, so a move has
to permute that peer's keys among the positions they already occupy in
the global list -- reordering the subset and concatenating would
reshuffle every other peer. That's swappedWithin, which assumes the
subset is in global relative order, which is how getLinkDevices builds
slotKeys.

The /link page, its route and its nav entry are gone; the settings tab
took the session controls and this took the slot lists. "Devices" in the
nav now falls back to the first Link device when no patcher is loaded,
so a graph with only Link nodes still has a reachable Devices pane.
Two additions to the Add Node menu.

Patchers are grouped once the list is longer than a configurable
threshold (default 10). Grouping buckets by first letter and then merges
adjacent buckets while they fit, so an initial with many patchers keeps a
category to itself while sparse ones collapse into a range: a list heavy
in A names yields "A" and "C-F" rather than an even slice. A single
initial holding more than the threshold stays oversized -- there is
nothing to split it on. Navigation is a drill-down with a back item, not
hover submenus, because this menu is used on the Move's touch screen.
The threshold also sizes the categories, so it is one knob rather than
two.

The Link section lists every advertised channel not already bound to a
Receive -- unlike the old Add Source menu these are omitted rather than
disabled, since this menu is a list of things you can add -- plus an Add
Send item. The section is hidden entirely when jack_transport_link isn't
running.

Supporting changes: AppSettingType gains Numeric, and min/max now reach
SettingsItem from an app setting, not just from a runner config. The Add
Send modal lives on the page rather than in the menu, since Mantine
unmounts a dropdown's children when it closes.

Also fixes settings persistence: loadSettingsState fell back to the
default whenever a stored value was falsy, so a boolean setting toggled
off reverted to on after a reload, and a numeric one could never be
stored as 0.
A Link node had no way to be removed: it rendered with the plain system
node component, deletable was false, and removeEditorNodeById bailed on
anything that wasn't a patcher.

Link nodes now render with their own component, modelled on the patcher
node but without rename -- the name is the JACK port group, which
jack_transport_link derives from the peer, so there is nothing for the
user to rename. Its menu opens the node's device page or deletes it. The
node is also deletable through reactflow now, so the backspace hotkey
reaches the same path.

Deleting fans out: jack_transport_link has no notion of removing a
device, only a channel, so a node is torn down by removing every slot it
stands for -- each Send by its announced name, each Receive by its slot
key. They batch on the far side, since each mutates jtl's desired list
and the reconcile that follows sees them all. A confirm dialog gates it,
matching device unload, and it names how many channels are going and
whether they were being announced or received.

Afterwards the node's position is dropped from the set meta, and if the
user is looking at that device's page they're routed back to the graph
rather than left on a page whose device no longer exists.
The Link device page could add and reorder channels but not get rid of
the device, so tearing one down meant going back to the graph. Give the
page the same actions menu the node has, in the same spot the device page
keeps its menu.

It reuses removeLinkDeviceOnRemote, so a delete from here gets the same
confirm and the same per-slot fan-out, and the route back to the graph
that thunk already does now matters -- this is the page whose device is
about to stop existing.

The Add Send button moves inside the shared action group so a Send device
shows both controls side by side.
The Link section ran straight on from the patcher list, and the only
rule in the menu sat directly above "Add Send", which made that entry
read as a footer of the whole menu rather than as part of Link. The peer
names nested inside the section also used plain Menu.Label, so "Live"
looked like a heading of the same rank as "Link" itself.

Give every section but the first a rule above it, make the section
headings (Patchers, Link, a drilled-into patcher group) uppercase and
bold so they outrank anything nested under them, and indent each peer's
channels. "Add Send" now sits inside the Link block with the receives,
with no divider of its own. The empty-list placeholder becomes plain
dimmed text instead of a disabled Menu.Item, so it no longer carries the
row affordances of something clickable.
The max-height was set on an unstyled wrapper div with no overflow, and every
section inherited that same max-height, so the Patchers and Link sections
together could be twice the height of the menu and simply spilled out past the
bottom of the dropdown -- on a phone the "Add Send" row drew outside the panel.

Make the dropdown body a single scroll region instead of giving each section its
own nested scroll area, close the unbalanced calc() in the height, and measure
against visualViewport, re-measuring while the menu is open so the mobile
browser chrome collapsing or a rotation doesn't leave a stale height.
The peers listed their channels inline, so the top level of the menu grew with
every channel every peer advertised. Give the Link section the same one-level
drill-down the patcher categories use: a row per peer with its available-channel
count, then that peer's channels behind it.

Both sections share a single drill slot, so whatever the user opened is the only
thing on screen -- drilling into a peer hides the Patchers section just as
drilling into a category already hid Link. "Add Send" stays at the top level; it
isn't peer specific.
Double clicking the graph record sink opened a Link device page showing the Send
device's details, because the record ports carry a Link Audio slot key that
isn't theirs:

  rnbo-record:in1 -> { port-group: "rnbo-graph-record-sink",
                       pretty-name: "Record In 1",
                       link/audio/slot: "372cb3d7e804", order: 1 }

JackAudioRecord only sets pretty-name and port-group on those ports. The slot
key and order are jack_transport_link leftovers: JACK keys metadata by port
UUID, keeps it after the port is unregistered and recycles the UUIDs, and jtl
unregisters its ports without removing their properties -- so a deleted Send's
metadata reattaches to whichever port claims that UUID next. The slot key is
derived from the send identity, so recreating the Send produced the same key
that's now on both, and the record sink rendered as a duplicate of Link: Sends.

Slot-key presence was the only test for a Link Audio port, and any client's
leftovers satisfy it. Ignore the key on the port groups the runner sets on its
own ports, which are never Link Audio whatever their metadata says.

The root cause still stands: jtl should remove its properties before it
unregisters a port.
The editor listed each node's ports in Immutable.Map iteration order, which is
hash order rather than insertion order once the map outgrows a handful of
entries -- so the order had nothing to do with anything the user or the port's
owner had chosen. On a Link node with four channels that put Main L first and
Main R last, split the stereo pairs, dropped a newly added channel into the
middle, and rearranged again on reload. GraphPortRecord.order existed for
exactly this and had no readers.

Sort by that JACK order metadata, which jack_transport_link and the runner both
set, and fall back to the port id with numeric collation so a node whose ports
carry no order (the system groups, the record sink) is at least listed the same
way every time.
Two changes to how a peer's channels are offered.

In the Add Node menu, a channel already in the graph keeps its row, disabled and
check-marked, instead of being filtered out. Hiding them meant the list changed
shape as you added to it, and gave no way to tell "already have this" apart from
"the peer stopped advertising it". A peer whose channels are all taken now stays
listed too, so the count changes meaning: it reads "(2 of 5)" for what is still
addable, or "(all added)".

On a Receive device's page, Add Receive now sits where a Send device has Add
Send, offering the rest of what that peer advertises -- previously the page sent
you back to the graph editor for it. It reuses AddSourceMenu, which was already
in the tree with no callers, now marking taken channels the same way the node
menu does and labelling peers only when it is given more than one. The device's
peer comes from one of its slots rather than from parsing the node id, which is
a display string.
…ts of the UI to trigger delete and renaming actions, also reuse the async dialogues here rather than deleting directly as well as when asking for naming / renaming input

@fde31 fde31 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note on the comments. Noticed references to a made up touchscreen on the move.

I also have some changes that consolidate the logic to use the already existing dialogues and confirmation patters. The comments says that deleting a Link channel can easily be restored by adding it again but in reality you also lose all the connections to the ports. So I'd say it's not that easy to revert in practice plus the UX sort of agrees that all desctructive operations are gated by confirm dialoges.

Will put these changes on a PR on top of this one for you to review @x37v

Otherwise this seems good for me at this stage. I had a moment of thinking that rather than squeezing everything into the dropdown we could use the slide in overlay and provide a more tree like "browser" experience that also supports searching across the patchers / peers. But guess at this stage that can just be a FR / improvement down the line and shouldn't get in the way of landing the feature.

Comment thread client/src/components/editor/addNodeMenu.tsx
fde31 and others added 2 commits September 14, 2026 17:04
@x37v
x37v merged commit 2a469e4 into develop Sep 14, 2026
1 check passed
@x37v
x37v deleted the feature/linkaudio branch September 14, 2026 19:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants