Skip to content

feat: Remove output tracks from sources feeding the input and add the source to every scene - #63

Merged
semnil merged 2 commits into
masterfrom
feat/auto-clear-output-tracks
Nov 1, 2026
Merged

semnil merged 2 commits into
masterfrom
feat/auto-clear-output-tracks

Conversation

@semnil

@semnil semnil commented Oct 23, 2026 •

Copy link
Copy Markdown
Owner

What

When another audio source is routed to the plugin's input mix track, the plugin captures that mix and re-emits it on its output tracks. If the same source is also routed directly to one of those output tracks, its audio is doubled there. This change removes the plugin's output tracks from any source that feeds its input track.

OBS outputs only the audio of the sources in the shown scene, so the plugin stopped working while a scene without it was shown. The second commit adds the plugin to every scene.

Behavior

  • Clears on three triggers: the plugin's own output changing, a source being created/loaded, and the input track changing.
  • Only affects sources routed to the input track; a source that only outputs directly (and is not captured) is left alone.
  • Skips other MixTrack2Source sources.
  • Remembers what it cleared and restores untouched bits when the plugin is removed or its input/output tracks change, and gives them back to a source that no longer sends audio to the input track at the next check; bits the user re-enables are left alone, also across an input-track change. The record persists across scene save/load.
  • Signal callbacks and the settings update only note what changed. The sweeps and give-backs, with the source lookups and enumerations they need, run as UI tasks outside the signal that queued them; none of that runs on the audio thread. A task queued from the UI thread goes through the destroy thread first, because the frontend runs a UI task queued from the UI thread at once.
  • The input-track loopback guard from fix: Keep the input track out of the plugin's outputs when they are set #66 and this change share the plugin's own audio_mixers handler: it takes the input track out of the outputs, hands the result to libobs, and queues a sweep only when the outputs other than the input track change. The sweeps take the plugin's output tracks from that value. libobs stores it only after the signal returns, so a task that runs in between, for example while the loopback guard's write from the audio thread is still inside its signal, applies the new value.
  • A source created on the UI thread is checked once its creator has set its routing, so the check never sees the default routing (all tracks on) a source has while it is being created. This covers loading a scene collection, adding a source, pasting a duplicate, and undo. Loading a source, including the reload that undo/redo does for every source in the scene, checks it again with the same result.
  • A cleared output track is tracked by a module-wide refcount shared across instances, so when several instances clear the same source the track is restored only once the last of them stops needing it cleared, and a track that was off from the start is never restored.
  • When a plugin instance is destroyed, its cleared tracks are given back on the UI thread, in order with the checks the UI thread runs.
  • A source that is destroyed keeps its record for the rest of the session; such a record is not saved. A track whose last holder lets go while its source is gone waits for a source with the same uuid, as undo brings back: an instance that still needs the track cleared takes it over, otherwise it is turned back on. A waiting track applies once.
  • Waiting tracks are dropped when the frontend cleans up a scene collection (OBS_FRONTEND_EVENT_SCENE_COLLECTION_CLEANUP, on a collection switch and at exit), and none start waiting until the next collection has loaded (OBS_FRONTEND_EVENT_SCENE_COLLECTION_CHANGED). Emptying the output channels for a while, as the auto-configuration wizard's tests do, keeps them.
  • With ENABLE_FRONTEND_API (on in the CMake presets), CMake defines ENABLE_FRONTEND_API for the plugin, obs-frontend-api is linked, and the plugin registers its event callback through obs-frontend-api.h at module load. Without it, at module load the plugin looks up obs_frontend_add_event_callback and obs_frontend_remove_event_callback once (dlopen(NULL) and dlsym on macOS and Linux, GetModuleHandleW and GetProcAddress on Windows). Either way it logs frontend events connected or frontend events not available. When no event arrives, a waiting track stays until a source with its uuid appears or the module unloads. The callback is removed on OBS_FRONTEND_EVENT_EXIT, before the frontend API goes away.
  • Only the six real mix tracks are cleared and restored.
  • Every scene (second commit): once the frontend has put a new plugin instance in a scene, the plugin adds it to every other scene of the main canvas, once, and adds each such instance to the scenes the user adds later. A scene the user removes it from keeps going without it. Scenes loaded from a scene collection keep their sources as saved (a scene that got source_load is skipped), and an instance saved with this keeps its scenes as saved (settings key mt2s_every_scene); an instance saved by an earlier version is added to every scene the first time it is loaded. No output channel is used, so each scene mixes the plugin once.

Docs

README.md and README.ja.md describe the removal in the feature list, the usage (a source routed to the input track later keeps its tracks, and a source that stops sending audio to it gets them back, when the plugin's input or output tracks change or the scene collection is loaded again), the usage example, and adding the audio source to every scene.

Verification

  • Compile: src/plugin-main.c of 272d6cc passes clang -fsyntax-only -Wall -Wextra -Wswitch -Wenum-conversion against the OBS 31.1.1 headers without ENABLE_FRONTEND_API, and with ENABLE_FRONTEND_API and ENABLE_UPDATE_CHECK. The only warning comes from the OBS header util_uint64.h (_udiv128 under clang on Windows). clang-format reports no changes. CI builds the full plugin.
  • A libobs 31.1.1 test program with the plugin source compiled in (UI tasks queued and run on the main thread) checks the give-back and adding to every scene. On 272d6cc every check passes (failures=0): the cleared track 6 comes back after the source drops the input track and the plugin output changes; a plugin the user adds to scene A is also in scene B (once in each) and stays active while B is shown; a scene added afterwards gets it, a scene that got source_load does not; it stays out of B after being removed there; an instance created with mt2s_every_scene set stays only where it was put. On e50d13c the three every-scene checks that need adding fail (failures=3), and without the give-back the give-back check fails as well. Not marking loaded scenes fails the loaded-scene check, and ignoring mt2s_every_scene fails the check for an instance created with it.
  • The Codex review of the same tree measured the final mix of track 6 with libobs 33.0.1 and a stand-in source sending 0.125 from scenes A and B: 0.125 steady on A, across 1 s fades A to B and B to A, and across a cut.

The checks below were run before the rebase onto master (cfbf018), on code that had neither the loopback guard of #66 nor the direct obs-frontend-api calls, the give-back, or adding to every scene.

  • Routing logic: a test program linked against libobs 31.1.1 drives real source create / update / save / load / duplicate / undo-style reload / destroy and checks the resulting track masks. It installs a UI task handler that behaves like the frontend's (runs a task at once on the UI thread, queues it from any other thread), drains it the way OBS clears scene data, and stands in for obs-frontend-api, sending CLEANUP, CHANGED and EXIT in the order OBS's frontend/widgets/OBSBasic_SceneCollections.cpp does.
    • It passes basic clear and restore, manual re-enable then off, input detach, scene (de)activation, both save/load orders (including a source loaded with the track still on), two instances sharing a source in all three live creation orders (source before, between, or after the plugins), a source destroyed before the plugin, a destroyed source's record left out of the save, a pasted duplicate (including one whose track was off from the start, also after save/reload), undo/redo reloading a source added after the plugin (including after the user re-enabled the track), a user re-enable kept across an input-track change and back, a source deleted and brought back with the same uuid, output bits above the real mix tracks, and every load order of two instances and one source with both removal orders, for a track on at the start, a track off from the start, and instances with different output tracks.
    • It also passes an output change made on another thread, input and output both changing before the queued work runs, an output change still queued when the instance is deleted, a deleted source whose plugin changes its output or input and back (or is deleted and restored) before the source comes back, the plugin deleted while its cleared source is deleted, two instances where one moves away while the source is deleted, another instance taking a waiting track over without turning it on in between, tracks off from the start or turned off by hand staying off, a scene collection switch (nothing of the old collection waits, give-backs during the cleanup add nothing, a source with the same uuid in the next collection keeps its routing), the auto-configuration test's temporary emptying of channels 0–5 (waiting tracks kept and restored on undo), a waiting track applying once, and the frontend callback added once and removed once.
    • Built without the obs-frontend-api stand-in, it passes the fallback: a waiting track outlives a collection switch, applies once on the first appearance of its uuid, and module unload drops what still waits.
  • A second, separately written test program, run against libobs 31.1.1 and against 33.0.0 from OBS.app with its collection-switch case sending CLEANUP and CHANGED, passes all its checks. They include a write from the audio thread held inside the plugin's own audio_mixers signal while the UI task runs (the records match the output that write stores), deleting and reloading real scene items, waiting tracks taken over by several instances, and two instances on different input tracks. Its lock-order run (the audio thread inside the plugin's own audio_mixers signal while the UI thread enumerates sources and connects to their signals, as Advanced Audio Properties does) completes on both.
  • Concurrency: destroying one instance while another changes its output turned no still-needed track on and left no released track off in 3000 iterations. With another instance's UI task running during the destruction (the window opened 2521 times in 3000 iterations), neither happened either; running the give-back on the destroy thread instead makes that run find 75 such cases. 3000 input-track changes while the audio thread drops the input track from the plugin's own mixers ran no sweep off the UI thread.
  • Frontend lookup (the path without ENABLE_FRONTEND_API): the event numbers written in the source are checked at compile time against obs-frontend-api.h of OBS 31.1.1 and 33.0.0. A module loaded with dlopen into a process that links OBS 33.0.0's obs-frontend-api.dylib finds both functions, and does not find them in a process without it.
  • Guards: each change is checked by a mutation run — reverting any one of them alone makes at least one of the checks above fail.
  • Deliberate non-goal: routing an existing source into the input track later is not auto-cleared; it stays until the next trigger.
  • Not verified: behavior in the OBS GUI itself, including the frontend events connected log line and adding the instance to every scene there (undo and redo, scene duplication, a scene collection switch, studio mode); scenes in canvases other than the main one; the routing checks above against the current code; sources created by scripts or obs-websocket off the UI thread.

🤖 Generated with Claude Code

@semnil
semnil force-pushed the feat/auto-clear-output-tracks branch 3 times, most recently from f46fb81 to 9808141 Compare October 25, 2026 04:35
@semnil
semnil force-pushed the feat/auto-clear-output-tracks branch from 9808141 to 6a7f6d9 Compare October 25, 2026 06:30
@semnil
semnil force-pushed the feat/auto-clear-output-tracks branch 2 times, most recently from cfbf018 to 00bdfbb Compare November 1, 2026 05:53
semnil and others added 2 commits November 1, 2026 16:09
When another audio source is routed to the plugin's input mix track, the
plugin captures that mix and re-emits it on its output tracks. If the
same source also outputs directly on one of those tracks, its audio is
doubled. Remove the plugin's output tracks from any source that feeds
its input track.

- Clears on three triggers: the plugin's own output changing, a source
  being created or loaded, and the input track changing. Only sources
  routed to the input track are affected; other MixTrack2Source sources
  are skipped.
- Remembers what it cleared and restores untouched bits when the plugin
  is removed or its input/output tracks change, and gives them back to
  a source that no longer sends audio to the input track at the next
  check. Bits the user re-enables are left alone, also across an
  input-track change. The record persists across scene save/load.
- Signal callbacks and the settings update only note what changed. The
  sweeps and give-backs, with the source lookups and enumerations they
  need, run as UI tasks outside the signal that queued them, never on
  the audio thread.
- The input-track loopback guard shares the plugin's own audio_mixers
  handler: it first takes the input track out of the outputs and hands
  the result to libobs, then queues a sweep only when the other outputs
  change. The sweeps take the plugin's output tracks from that value.
- A source created on the UI thread is checked once its creator has set
  its routing. Loading a source, including the reload that undo/redo
  does, checks it again with the same result.
- A cleared track is refcounted module-wide across instances, so it is
  restored only once the last instance stops needing it, and a track off
  from the start is never restored.
- When an instance is destroyed, its cleared tracks are given back on
  the UI thread, in order with the checks the UI thread runs.
- A destroyed source keeps its record for the session; such a record is
  not saved. A track whose last holder lets go while its source is gone
  waits for a source with the same uuid (undo): an instance that still
  needs it cleared takes it over, otherwise it is turned back on, once.
- Waiting tracks are dropped when the frontend cleans up a scene
  collection (switch or exit), and none start waiting until the next one
  has loaded. With ENABLE_FRONTEND_API, CMake defines it for the plugin
  and the plugin calls obs-frontend-api directly; without it, the
  plugin looks obs-frontend-api up once at module load. When neither
  reaches it, a waiting track stays until its source appears or the
  module unloads.
- Only the real mix tracks are cleared and restored.
- README.md and README.ja.md describe the removal in the feature list,
  the usage and the usage example.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
OBS outputs only the audio of the sources in the shown scene, so the
audio source stopped working while a scene without it was shown. Once
the frontend has put a new instance in a scene, the plugin adds it to
every other scene of the main canvas, once, and adds each such instance
to the scenes the user adds later. A scene the user removes it from
keeps going without it. Scenes loaded from a scene collection keep
their sources as saved, and an instance saved after this keeps its
scenes as saved. The README describes this.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@semnil
semnil force-pushed the feat/auto-clear-output-tracks branch from 3aa8154 to 272d6cc Compare November 1, 2026 07:09
@semnil semnil changed the title feat: Remove the plugin's output tracks from sources feeding its input feat: Remove output tracks from sources feeding the input and add the source to every scene Nov 1, 2026
@semnil
semnil merged commit 527f59b into master Nov 1, 2026
8 checks passed
@semnil
semnil deleted the feat/auto-clear-output-tracks branch November 1, 2026 07:14
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.

1 participant