Skip to content

FX engine overhaul (4.7.0) - #38

Merged
JacobCoffee merged 72 commits into
mainfrom
feat/fx-engine
Oct 30, 2026
Merged

JacobCoffee merged 72 commits into
mainfrom
feat/fx-engine

Conversation

@JacobCoffee

Copy link
Copy Markdown
Owner

Effects engine rebuild plus plugins, echo cancellation, hotkeys, automation and Stream Mix. Tracking: #23 (issues #5–#22).

Opened to run CI on the runner's Xcode before the 4.7.0 release; release notes are drafted separately.

Verified locally: swift test -c release (328 tests) on both the swiftbuild and native build systems, a signed throwaway bundle in /tmp (codesign --verify --deep --strict), offscreen renders of the Effects page. Not verified here: the driver's third device, the system-audio tap/permission, and AUv3 plugins, which need the installed app.

Refs #23

…alog)

Vendors baconpaul/airwin2rack (MIT) at commit a1eb817579e86215f76aa22dfcccecd49b103368
into a new CAirwindows SwiftPM C++ target: AirwinRegistry, the VST-era
AirwinConsolidatedBase, and the ~528 generated effects under autogen_airwin/.
The CMakeRC docs resource is replaced with a generated C++ translation unit
(Scripts/generate_airwin_docs.py embeds awpdoc/*.txt as base64, decoded on
lookup) so no app-bundle resource is needed for documentation.

A small C shim (include/aw_shim.h, shim.cpp) exposes effect enumeration,
parameter name/display/get/set, and planar-stereo block processing, so Swift
never sees C++. AirwindowsNode (FXHostedNode) loads one effect by registry
name, builds FXParamSpec from its own 0...1 parameters, applies pending
parameter changes on the IO thread by generation, and always renders into
preallocated scratch buffers (not every one of 528 effects is verified safe
for in-place aliased processing). Airwindows exposes no native reset, so
AirwindowsNode.reset() is a documented no-op rather than an IO-thread
allocation. AirwindowsCatalog lists the registry for the effect browser, and
is empty when the new AirwindowsLibrary.isEnabled (UserDefaults, default
true) setting is off.

Resources/Airwindows-LICENSE.txt is added with Makefile bundle and
release.yml 'Create .app bundle' copy steps, matching the existing
RNNoise/FastEnhancer pattern.

Refs #13
AUCatalog scans AVAudioUnitComponentManager for aufx/aumf components that can
run stereo or mono at 48 kHz. AUHostNode loads v2 components in-process (user
accepted that risk) and v3 out of process; captures renderBlock and
scheduleParameterBlock at load; process() is allocation/lock/ObjC-free,
pulling input through a preallocated AudioBufferList and adapting channel
count by summing/duplicating. Render errors pass the block through untouched
and are counted atomically for a future UI badge. Specs are built from the
flattened parameter tree (hostAddress = AUParameterAddress); a parameter-tree
observer feeds the plugin's own UI changes back into params without an echo
loop (lastKnown bit-pattern comparison). captureState()/restore round-trip
fullState as a binary plist. makeEditor() uses requestViewController.

PluginEditorWindowController presents any FXHostedNode's editor in one
utility NSWindow per node (keyed by ObjectIdentifier), posting
.fxPluginEditorClosed on close so the engine can capture state while still
loaded.

Adds Loopbacker.entitlements (disable-library-validation for in-process AUv2,
device.audio-input for mic capture, allow-jit for JSFX's EEL2 JIT) and wires
it into the Makefile bundle codesign and both release.yml app codesign steps.

Tests against Apple's built-in AUDelay/AUNBandEQ/AUPeakLimiter/
AUDynamicsProcessor: catalog discovery, spec/parameter-tree extraction, delay
shifts an impulse, limiter caps a +24 dB sine, parameter writes reach the
audio, state round-trips a changed parameter, nonzero limiter latency, mono
channel adaptation, and varying block sizes 1..4096 from a background thread.

Discovered along the way: AUAudioUnit input busses must be explicitly
enabled (isEnabled = true) before allocateRenderResources(), or renderBlock
fails with kAudioUnitErr_NoConnection — nothing in AUHostNode's contract
mentioned this, but every built-in AU needed it.

Refs #11
… editor

Vendors JoepVanlier/ysfx (Apache-2.0) + Cockos WDL's EEL2/LICE/SWELL (zlib-style)
as a new SwiftPM C/C++ target, CYSFX, pinned at commit
5c3452fee62583aa3d1b7e877d0c758c4024af89. Patches EEL2's JIT allocator for Apple
Silicon's MAP_JIT + pthread_jit_write_protect_np (the hardened runtime's
com.apple.security.cs.allow-jit), and serializes ysfx_compile/ysfx_init behind a
process-wide lock after finding upstream's optimizeOpcodes caches a function
lookup in a thread-unsafe static. Full rationale, allocation-safety analysis, and
license details in App/Loopbacker/CYSFX/README.md.

Adds:
- JSFXCatalog: scans ~/Library/Application Support/REAPER/Effects (recursive) and
  .../Loopbacker/JSFX for desc:/tags: headers only (no compile), skipping
  instrument/synth-tagged effects.
- JSFXNode: FXHostedNode loading/compiling off the IO thread, planar float
  process() via ysfx_process_float, slider params (incl. enum choices) synced
  from FXParamStore by generation, pdc_delay latency, state capture/restore via
  ysfx_save_state/ysfx_load_state.
- JSFXEditorViewController: renders @gfx effects into a bitmap at ~30 fps with
  mouse/keyboard/scroll forwarding and Retina scale; makeEditor() returns nil for
  effects with no @gfx section.
- Three Loopbacker-authored JSFX fixtures (gain/pan with an enum slider, a
  pdc_delay fixed delay, an @gfx level meter) and swift-testing coverage: catalog
  discovery, gain application, slider changes via params, latency, state
  round-trip, and background-thread processing across block sizes 1..4096.
- Resources/YSFX-LICENSE.txt, Resources/WDL-LICENSE.txt, with Makefile bundle and
  release.yml bundle-step copies.

Refs #12
…cument gfx/dsp concurrency

Verification requested after the first JSFX commit, no code changes needed:

- Allocation: interposed-malloc harness (scripts/bench/count.c pattern, built
  standalone against CYSFX.o, deleted after use) confirms zero heap allocations
  in JSFXNode.process's steady state across 10s/512-frame blocks for all three
  committed fixtures and the 9 real-world ReaTeam/JSFX scripts that loaded.
- Binary size: stripped release Loopbacker +927,664 bytes (~0.88MB, ~12.6%) with
  CYSFX vs. without, measured via a temporary Package.swift/Sources swap in this
  worktree, restored and verified clean before committing.
- Real-world smoke test: 10 ReaTeam/JSFX scripts across dynamics, EQ,
  distortion, delay, reverb, gfx, and import categories (cloned to /tmp, not
  committed) -- 9/10 compiled, ran NaN-free; 1 failed due to a missing sibling
  import file in that script's own repo (file name lacks .jsfx-inc, not an ysfx
  or host bug).
- Documented in CYSFX/README.md: ysfx runs @gfx (main thread) and @sample (IO
  thread) against the same EEL VM with no general VM-wide lock (matches
  upstream's own plugin, which does the same on JUCE's message thread) --
  confirmed JSFXEditorViewController and JSFXNode.process take no lock between
  them (only JSFXNode.compileLock, used solely inside load()), so no deadlock
  risk; flagged EEL2's lock-free lazy RAM/gmem first-touch calloc as the one
  spot this concurrency model is a genuine (upstream-inherent, unmitigated)
  memory-safety risk for a script that races both threads into the same
  never-pre-touched block.

Refs #12
The AUv2-bridge 'ratchet' reported earlier (a render call at size N failing
after a prior call at size >N) was actually a host bug: process() reused
outputList across calls without resetting mDataByteSize/mData, both of which
the AU may rewrite. Fixed for both v2 and v3: the buffer list is rebuilt to
point at our own storage, sized to the current block, before every render
call. The block-size test is now strict (one instance, increasing then
random order, renderErrorCount == 0, exact output correctness against an
AUDelay dry/wet=0 passthrough) instead of tolerating per-call failures.

Separately, benchmarked where the render hot path's 2 allocations/call (see
prior report) came from: constant vs varying frame count, AUDelay vs
AUPeakLimiter vs AUNBandEQ vs AUDynamicsProcessor, and the classic C API
(AudioComponentInstanceNew + AudioUnitRender via a registered
kAudioUnitProperty_SetRenderCallback) vs AUAudioUnit.renderBlock. Verdict:
constant at 2/call regardless of plugin, frame size, or whether the size
varies call to call -- fixed overhead in the renderBlock bridge itself. The
C API measures 0/call under the same conditions, confirmed again against the
final production code path.

v2 Audio Units (in-process) are now hosted entirely through the classic C
API: AudioComponentInstanceNew/AudioUnitInitialize/AudioUnitRender/
AudioUnitReset/AudioUnitUninitialize/AudioComponentInstanceDispose for the
lifecycle and render path (all plain C calls, allocation-free, IO-thread
safe per the shared contract); AudioUnitSetParameter (not AUParameterSet,
which would notify listeners) for host-driven parameter writes; a
registered kAudioUnitProperty_SetRenderCallback (a non-capturing C function
pointer, refCon = the node's own stable address) instead of a per-call
pull-input block; kAudioUnitProperty_ParameterList/ParameterInfo/
ParameterValueStrings to build specs, seeded from each parameter's *live*
value (AudioUnitGetParameter) rather than its static declared default, so a
restored state is reflected immediately; kAudioUnitProperty_ClassInfo for
state capture/restore; kAudioUnitProperty_CocoaUI loaded dynamically
(Bundle + ObjC runtime IMP lookup, since the real AUCocoaUIBase protocol
header is Objective-C-only and most factories don't formally declare
conformance anyway) for makeEditor(), wrapped in a small NSViewController;
an AUEventListener for plugin-UI-driven parameter feedback (AUParameterSet
notifies listeners, plain AudioUnitSetParameter does not -- confirmed by a
standalone harness -- so the host's own writes need no echo guard on this
path, only value deduplication).

v3 app extensions are unchanged: out-of-process, hosted via AUAudioUnit
(no C-API equivalent exists for them), still through the Swift/ObjC bridge.

AUCatalog, PluginEditorWindowController, Loopbacker.entitlements, Makefile
and release.yml codesign steps are unaffected by this change.

Tests: block-size test rewritten strict (exact output correctness, zero
render errors, single instance, increasing + random order); all other
existing AU tests (catalog, delay shift, limiter cap, parameter writes,
state round-trip, latency, mono adaptation) continue to pass against the
new backend, including catching the state-round-trip regression this
rewrite introduced (defaultValue vs live value bug, fixed) before it shipped.

Verified: swift build clean (no warnings), swift test full suite (19/19)
green, allocation harness (throwaway, deleted) confirms 0 allocations/call
for the new v2 path across AUDelay/AUPeakLimiter/AUNBandEQ/
AUDynamicsProcessor, constant and varying block sizes, vs 2/call measured
for the AUAudioUnit bridge under identical conditions.

Refs #11
…cessor

- Fix graph-freed-on-IO-thread: activeGraph is now a non-owning Unmanaged
  reference; the sole strong owner (ownedGraphs) lives off the IO thread.
- Remove the 3s release timeout that could free a graph the IO thread
  still needed after a device stall; release now waits (no deadline) for
  the IO thread's retired ack, or frees immediately via the new
  ioStopped() teardown hook once the audio unit is provably stopped.
- Fix non-IO readers (liveNode, totalLatencySamples, nodeLoadErrors,
  captureHostedStates, applyParamsOnly) racing a concurrent release by
  reading a configLock-guarded strong latestGraph instead of graphPtr.
- Serialize/coalesce updateConfigs via a ConfigCoalescer actor so a slow
  hosted-plugin load racing a fast param edit can no longer publish out
  of order.
- Rewrite AppAudioProcessor to lock-free per-field atomics (no NSLock on
  the IO thread).
- A/B bypass now crossfades over ~10ms against a chain-latency-delayed
  dry tap (new per-chain ring) instead of an instant, undelayed switch.
- AudioRouter: detach the FX chain only after AudioOutputUnitStop
  returns, and call FXChain.ioStopped() when the last voice route on a
  device detaches.
- Fix a real UAF this surfaced: process() now bails via a cheap atomic
  torn-down flag before touching graph state, so a stray call after
  ioStopped() can't dereference freed memory.
- Add FXChainConcurrencyTests: stress test (concurrent process() +200
  random structural/param edits), concurrent-updateConfigs corruption
  check, IO-pause-then-resume (outlives the old timeout), and
  ioStopped() idempotency/safety.
- AppAudioProcessorTests: make the VoiceBus-dependent release assertion
  robust to other suites' real FXChains concurrently driving the shared
  VoiceBus singleton.

Full suite verified clean under swift test, --sanitize=address, and
--sanitize=thread. Allocation check via a throwaway interposed-malloc
harness (deleted) confirmed 0 heap allocations on the simulated IO
thread, steady-state and across a structural graph swap.

Refs #9 #15
- SystemAudioTap: Core Audio process tap (macOS 14.2+ CATapDescription) excluding
  Loopbacker's own process, wrapped in a private tap-only aggregate device, IOProc
  writing the far-end reference into a DriftCompensatedRing; rebuilds on default
  output device change; publishes start/stop state and TCC permission.
- EchoCanceller: SpeexDSP MDF adaptive echo canceller (BSD-3) vendored as the
  CSpeexAEC SwiftPM C target (plain C, no abseil/rtc_base dependency closure,
  used after a real attempt to vendor WebRTC AEC3 proved infeasible within
  SwiftPM's target model -- see CSpeexAEC/README.md for the full account).
  48 kHz, 10 ms hops, mono capture, mono/stereo reference downmix, click-free
  enable/disable crossfade, zero heap allocation in process().
- NSAudioCaptureUsageDescription added to Info.plist; SpeexDSP-LICENSE.txt added
  to Resources with bundle copy steps in both Makefile and release.yml.
- DriftCompensatedRing.sampleRate exposed (was private) for cross-component
  sample-rate validation; no behavioral change.

Refs #14
Vendored webrtc-audio-processing v2.1 (846fe90, 2025-01-22) + its pinned abseil-cpp
20240722.0 subset as CWebRTCAEC, generated mechanically from a meson+ninja build via
ninja's own compdb/query/deps introspection (CWebRTCAEC/generate.py reproduces this;
CWebRTCAEC/README.md has the full account) rather than hand-reconstructing GN's
dependency graph. 368 vendored sources (244 webrtc-side, 124 abseil); AudioProcessingImpl
is monolithic so AGC/NS/VAD object code comes along unused (never enabled at runtime) --
documented in the README.

EchoCanceller.swift now drives webrtc::AudioProcessing through a small C shim
(CWebRTCAEC/shim/webrtc_aec3_shim.cpp) with only the echo canceller + high-pass filter
enabled, injecting a custom EchoControlFactory so EchoCanceller3Config's dominant-nearend
thresholds can be tuned (stock values over-suppress near-end speech during double talk
for this app's synthetic test; tuning improves it from -10.6dB to -6.85dB, still short
of the 3dB target -- flagged in both the README and EchoCanceller.swift's doc comment).

Removed CSpeexAEC (vendored SpeexDSP MDF) and its license copy entirely per the
'no two implementations' directive -- AEC3 replaces it:
- ERLE (far-end only, 40/90/150ms bulk delay): both pass >=20dB; AEC3 ~32dB flat vs
  MDF's 31.7->26.1dB degrading with delay.
- Nonlinear (tanh soft-clipped speaker) ERLE: AEC3 stays ~32dB; MDF drops 2-3dB.
- CPU: AEC3 ~121us/10ms frame (1.2%) vs MDF's ~158us (1.6%).
- Binary size delta vs pre-AEC baseline: +207KB (was +54KB with MDF).
- Clean release build: ~27s.
- Zero allocations in process()/ProcessStream()/ProcessReverseStream() steady state
  (interposed-malloc check, not committed -- throwaway per contract).
- Double talk: FAILS at -6.85dB vs the 3dB target (regression from MDF, which passed);
  open item, see CWebRTCAEC/README.md 'Double-talk tuning'.

Refs #14
Swept echo:near-end loudness ratios 0/-6/-12/-18 dB (real desk speakers measure
roughly -10 to -20 dB below the near-end talker's own mic level, per CWebRTCAEC/README.md)
across stock AEC3 vs two dominant_nearend_detection tuning candidates. The shipped
config (snr_threshold=5, enr_threshold=1, enr_exit_threshold=2, hold_duration=10,
trigger_threshold=1 -- faster entry/re-entry into AEC3's gentler nearend_tuning
suppression curve) wins at -6/-12 dB:

| ratio  | stock   | slow-entry | shipped |
|--------|---------|------------|---------|
| 0 dB   | -12.95  | -8.01      | -8.10   |
| -6 dB  | -4.19   | -2.99      | -2.92   |
| -12 dB | -2.23   | -1.61      | -1.58   |
| -18 dB | -2.66   | -2.66      | -2.67   |

Far-end-only ERLE unaffected: 31.6 dB linear / 31.3 dB nonlinear (vs stock's
32.0/31.9 dB) -- both well above the 25 dB sweep bar and the shipped
EchoCancellerTests bars (20 dB / 10 dB), which are unchanged.

EchoCancellerTests.doubleTalk now asserts the -12 dB case within 3 dB (passes,
all swept configs do). Replaced the old 0 dB-only double-talk test (not a realistic
target) with doubleTalkEqualLevelRegressionGuard, a non-target regression guard at
10 dB (measured -8.10 dB rounded up +1 dB margin).

Other suppressor/ep_strength/erle knobs Main suggested (use_during_initial_phase,
high_bands_suppression, normal/nearend_tuning max_inc_factor/max_dec_factor_lf,
ep_strength.default_gain/echo_can_saturate, erle.max_l/max_h) were tried in
combination with the entry-speed tuning during the sweep; none improved on the
shipped config at -6/-12 dB beyond measurement noise, so they were dropped rather
than carried as dead config -- see CWebRTCAEC/README.md's "Knobs tried and
rejected" note.

Full table and methodology in CWebRTCAEC/README.md#double-talk-tuning.

Refs #14
# Conflicts:
#	App/Loopbacker/Package.swift
…, chorus, pitch shift, reverb, delay, limiter) + VAD

Implements the 10 FXNode built-in stages per the shared contract, plus
VoiceActivityDetector (RNNoise-based, own state, 480-sample hop buffering).

- GateNode: key = max(|L|,|R|) through per-channel high-pass (filtering
  before rectification, not after — see commit history for why), level/voice
  trigger modes, hysteresis, hold, dB-ramp attack/release, ~2ms lookahead.
- EQNode: RBJ cookbook biquads (Double-precision state), HP/LP slopes
  12/24/48 dB/oct via cascaded Butterworth-Q sections, lock-free
  double-buffered setBands() with ~5ms coefficient interpolation, max 16
  bands.
- CompressorNode: log-domain gain computer with soft knee (Giannoulis/
  Massberg/Reiss 2012), peak/RMS detector, parallel mix.
- LevelerNode: ITU-R BS.1770 K-weighting + 400ms momentary loudness, gain
  moves only while voiceProbability > 0.5 (holds during pauses).
- DeEsserNode: LR4 split-band de-essing with fast attack / slow release.
- ChorusNode / DelayNode: flat preallocated ring buffers, Catmull-Rom
  interpolation, slewed delay time, soft-clipped feedback.
- PitchShiftNode: STFT phase vocoder (vDSP FFT) with identity phase locking
  and cepstral formant preservation; 0 semitones/formant is an exact
  latency-aligned passthrough.
- ReverbNode: 8-line FDN with Hadamard mixing, modulated delays, damping,
  pre-delay.
- LimiterNode: 4x oversampled true-peak detection (polyphase FIR) feeding a
  monotonic-deque sliding-window-minimum gain over a 1.5ms lookahead.

Every stage: reads params once per block on params.generation change,
smooths audible parameters, publishes meters, declares fixed
latencySamples, denormal-safe (fxFlush), reset() clears history.

Tests (FXDSPTests.swift, swift-testing): per-stage behavior (gate
attenuation/hysteresis/voice-trigger, EQ magnitude response and HP slope,
compressor static curve, leveler convergence and VAD-gated hold, de-esser
split-band selectivity, limiter true-peak bound on sine/impulse/square,
pitch-shift frequency accuracy and formant preservation, reverb/delay/
chorus NaN-safety at extreme params, mid-stream param-change declick) — 28
tests, all passing; full repo suite (37 tests) also green.

Refs #6 #7 #8 #10
Deletes the integration stand-ins now that every contract type is real, and points the
chain tests that assumed pass-through nodes at identity EQs and real latencies.

Refs #6 #7 #8 #10
- NoiseSuppressionSettings.echoCancellation: global toggle, default false,
  old configs decode false. AudioSource.echoCancellation: per-source
  opt-out mirroring noiseSuppression's pattern, default true.
- AudioRouter: EchoCanceller shared per capture device across its voice
  routes (virtual mic + monitor), same attach/detach/fade-out/sampleTime
  sharing shape as NoiseSuppressor (duplicated rather than factored into
  a shared helper since NoiseSuppressor.swift belongs to another slice).
  Order: mix -> echo cancellation -> noise suppression -> FX chain.
  EchoCanceller gets a new stride/sampleTime-aware process() wrapper and
  setStreamDelay(ms:), fed from a new HAL-latency-based estimate (device
  latency + safety offset + stream latency + buffer size, tap ring
  latency, scheduling margin).
- SystemAudioTap: no longer excludes Loopbacker's own process from the
  global tap. The only audio Loopbacker renders to a real output device
  is the monitor route (+ incidental UI sounds) -- genuine acoustic-echo
  sources the mic actually picks up, same as any other app's output; the
  virtual mic never appears in an output tap regardless, since it's an
  input device other apps capture from, not something mixed into any
  process's rendered output. Excluding self would have left the monitor
  path's real echo uncancelled for no benefit.
- RoutingSync: owns SystemAudioTap, starts/stops it with demand (any
  enabled, non-excluded source wanting echo cancellation), injects the
  reference ring into AudioRouter once at startup.
- EchoCancellationSection: toggle, permission states (unknown/denied/
  granted), tap state (stopped/running/failed), live ERLE indicator fed
  by a new cheap per-hop atomic metric on EchoCanceller.
- Tests: settings/source decoding defaults and back-compat, and a new
  router-level suite exercising the stride/stereo wrapper (cancellation,
  sampleTime-based sharing between two simulated routes, disable
  crossfade) via the lowest-level pieces AudioRouter composes.
- DriftCompensatedRing: marked @unchecked Sendable per its documented
  SPSC contract, to pass the far-end reference ring across the actor
  boundary from RoutingSync (@mainactor) to AudioRouter's queue cleanly.

Refs #14 #15
A global stereo tap mixes every process's rendered audio regardless of
which device it plays to, so audio apps that render into one of
Loopbacker's own virtual output devices (e.g. Discord -> "Loopbacker 2")
polluted the reference: once with the direct capture, again offset in
time if an output route replays it to real speakers, or not at all if
nothing replays it (nothing a real mic could ever pick up).

- SystemAudioTap.rebuild(): use CATapDescription's device-scoped
  initializer (init(excludingProcesses:deviceUID:stream:), confirmed
  against the macOS 27 SDK's CATapDescription.h -- "Mix all process
  audio streams destined for the selected device stream except the
  given processes") against the current default output device's UID
  and stream 0, instead of a global tap. Still no process exclusions
  (Loopbacker's own output stays in; see the existing class doc). Sets
  isMixdown/isMono explicitly since the device-scoped initializer
  otherwise matches the tapped stream's native channel count, which the
  aggregate/IOProc/ring assume is always 2.
- New SystemAudioTapState.noPhysicalOutput: when the default output is
  one of Loopbacker's own virtual devices (UID contains "Loopbacker",
  matching DefaultDeviceRestorer's own check) or there's no default
  output device at all, rebuild() skips creating a tap entirely (no
  coreaudiod round trip, no TCC prompt) and reports why. EchoCanceller
  stays attached to voice routes regardless -- with nothing written to
  the reference ring, AEC3's reverse stream is silence, so it passes
  the mic through unchanged rather than mis-cancelling.
- The default-output-device listener's rebuild() call no longer gates
  on `state == .running`: it must also fire while `.noPhysicalOutput`,
  to pick up a later switch back to a real device. Gated on whether the
  listener is installed (i.e. start() was called) instead.
- AudioRouter: new default-output-device listener (installed once,
  queue-dispatched) recomputes and re-pushes every attached echo
  canceller's `setStreamDelay` via the existing estimateStreamDelayMs,
  since the default output changing can change its HAL latency the
  same way it changes which device SystemAudioTap targets.
- EchoCancellationSection: handles the new .noPhysicalOutput state with
  an explanatory, non-alarming status line (distinct from .stopped and
  .failed).

Verified: swift build and swift test (96 tests / 25 suites) both pass.
Spot-checked defaultOutputDeviceUID()'s HAL query standalone against
this machine's real default output device (a MOTU M2 interface) --
returns its real UID, confirming the lookup and the "not a Loopbacker
virtual device" branch both work as intended; the tap-creation path
itself still needs the integration owner's TCC-consented run to verify
end-to-end (unchanged from the prior report).

Refs #14
…URL scheme control

- MicGate (Services/Control): lock-free, click-free (10ms ramp) mute gate wired into
  AudioRouter.processCapturedBlock for voice routes feeding the virtual mic only (not
  monitor, so self-monitoring stays live while muted).
- MicControl + GlobalHotkeyManager (Carbon RegisterEventHotKey, press+release, no
  Accessibility permission) + HotkeyChord: open/push-to-talk/push-to-mute modes with a
  configurable release tail.
- MutedOverlayView/MutedOverlayPanel: non-activating, click-through, all-Spaces floating
  NSPanel showing Muted (red, fault color per DESIGN.md) / PTT (green, live accent).
- PresetAutoSwitchRule/Engine/Switcher (#20): NSWorkspace-driven app rules (running/
  frontmost), list-order priority, debounce, fallback preset, restore-previous-on-quit,
  and an isSuppressedByEditing flag the Effects UI can set.
- AppIntents + loopbacker:// URL scheme (#21): toggle/set mute, set preset, toggle
  effects, load scene, routed through a single ControlCenter dispatcher; CFBundleURLTypes
  registered in Info.plist; application(_:open:) handles it without a window.
- Settings scene: General (login item, Airwindows toggle, overlay settings), Hotkeys
  (shortcut recorder), Automation (preset auto-switch rules).
- _PendingControl.swift: NamedPreset/EffectsPresetLibrary stand-ins for the Presets slice.
- Tests: MicGate ramp/click-free/boundary behavior, HotkeyChord codable/display, preset
  auto-switch priority/restore/fallback decision logic, URL command parsing, MicControl
  mode state machine (incl. PTT release-tail timing).

Refs #19 #20 #21
Driver: adds a third virtual device 'Loopbacker Stream'
(LoopbackerDevice_UID_Stream, object IDs 30-34), mic/speaker terminal
types like device 0, same generic per-device channel-count property.
Bumps kMaxDevices to 3; LoopbackerDriver.cpp already loops generically
over kMaxDevices so no other driver changes were needed.

App: AudioRoute gains targetId: RouteTarget (.virtualMic/.streamMix),
decoding old configs as .virtualMic. RoutingState grows a parallel
streamOutputChannels/streamMixEnabled channel model (same add/remove-pair
API as the virtual mic), hidden until the user enables it via a header
toggle in the routing canvas so existing users see no change.

AudioRouter now runs one RouteContext per (source, target) pair, keyed
by a private RouteKey, mirroring the existing mic-route/monitor-route
split. Noise suppression and the voice FXChain still run once per
capture device: both caches already keyed their processed block by
sample time and read/wrote it through the calling route's own stride,
so they already handled two routes of different output widths sharing
one computation -- verified by code inspection, no fix needed.
setVirtualDeviceChannels, the sample-rate-change listener, and the
wake/restart path are generalized to resize/restart every route
targeting a given device rather than assuming one route per source.

MeterLevels tracks post-mix output levels per target device so the
virtual mic and Stream Mix never bleed into each other's meters despite
sharing channel numbers. ConnectorEnd/CableView/ChannelStripView/
OutputChannelView are target-aware; OutputChannelView now takes a
RouteTarget and renders either column.

RoutingSync starts/stops each (source, target) pair independently and
pushes Stream Mix's channel count to the driver alongside the virtual
mic's.

Tests: StreamMixRoutingTests (config decoding/migration, route grouping
by target, independent channel models); MeterLevelsTests updated for
the target-aware output API.

Refs #22
# Conflicts:
#	App/Loopbacker/Sources/LoopbackerApp.swift
# Conflicts:
#	App/Loopbacker/Sources/Services/AudioRouter.swift
…, auto setup)

Refs #16, #18

- EffectsView: header (master toggle, grouped preset picker, A/B compare
  with hold-to-preview/click-to-toggle, latency readout, call-app advice
  banner), Simple/Advanced segmented control.
- Simple mode (EffectsSimpleView): four macro sliders (Clarity, Warmth,
  Noise, Loudness) via FXMacros, noise engine picker, echo cancellation,
  live input/output meter against the leveler target, Auto Setup entry.
- Advanced mode: drag-to-reorder chain editor (keyboard move up/down
  alternative), per-tile activity indicator, duplicate/remove/reset/open
  editor context menu, hosted-node load-error recovery (Replace/Remove),
  Add Effect opens EffectBrowserSheet (Built-in/Audio Units/JSFX/
  Airwindows tabs, search, categories, Airwindows doc pane, AUv2/AUv3
  badge, Airwindows library toggle, install-path empty states).
- Per-stage live detail visuals (StageDetailViews): Gate threshold/
  hysteresis bar, Compressor transfer curve + GR meter, Leveler loudness
  vs target, De-Esser activity, Limiter GR/ceiling; hosted stages get
  generic params + Open Plugin Window, captured into pluginState on
  .fxPluginEditorClosed.
- InteractiveEQView/EQBandRow: lowpass/notch band types, HP/LP slope
  picker, per-band enable, add (double-click)/remove, 16-band cap.
- AppAudioSection: ducking/leveler controls with a live duck-gain meter.
- MicCheckView/AutoSetupView: record-and-compare raw vs. processed
  playback through the default output, and measured auto-tuning with
  findings + before/after macro summary.
- Sources/_PendingWave2.swift: stand-ins for the Presets slice
  (FactoryPresets, FXMacros, AutoSetup, CallAppAdvice) and a temporary
  EchoCancellationSection, exact signatures per fx-contract-wave2; the
  integration owner deletes this file once those slices land.
- LoopbackerApp.swift: additive SystemAudioTap environment wiring.
- Tests/EffectsRenderTests.swift: offscreen ImageRenderer smoke renders
  (construction + non-trivial output) for Simple, Advanced x
  Gate/Compressor/EQ, effect browser x4 tabs, mic check result, and
  auto setup result, light and dark.
SwiftPM never runs appintentsmetadataprocessor, so the App Intents compiled into the binary but Shortcuts couldn't list them. scripts/extract-app-intents.sh now writes Metadata.appintents into the bundle in both the Makefile and the release workflow, which asks the native build system for the const values it needs.

The vendored C++ targets passed -std=c++17 as an unsafe flag, which the native build system also hands to their C files and fails on; the package-wide cxxLanguageStandard already sets it.

Refs #21
…ts modes

Four call presets switched echo cancellation on, which starts a system-audio tap and asks for System Audio Recording permission even for headphone users; three others switched it off. Only Speakers – Gaming sets it now, and applying a preset's noise settings no longer resets it. The echo card moved out of Simple mode so Advanced users can reach it, lost a doubled panel wrapper, and now describes what it removes correctly.

Refs #14 #17
…hadows on the routing canvas

A test built a RoutingState without a directory and saved it, overwriting ~/Library/Application Support/Loopbacker/config.json with an empty config: the maintainer's sources and routes were gone at the next launch. Every data path (config, scenes, effects presets, soundboard, automation, preset auto-switch, plugin crash sentinels) now goes through AppDataDirectory, which is the real folder only inside the Loopbacker.app bundle and a private temp folder in any other process.

The routing canvas's two columns scroll, and a ScrollView clips to its bounds, so the glass cards' shadows were cut into hard grey rectangles at the card edges. The scrolled content now has room for them, with the scroll views pulled out by the same amount so nothing moves.

Refs #23

@github-advanced-security github-advanced-security AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CodeQL found more than 20 potential problems in the proposed changes. Check the Files changed tab for more details.

Default setup's Swift autobuild wedged the macOS runner on this branch
(three runs, none finished) and can't skip vendored C/C++. Same languages
and categories, Swift built with swift build -j 2, vendored dirs ignored.

Refs #23
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