Skip to content

Multi-channel devices: pick which channels TeamTalk uses per device - #39

Open
rfiorentino1 wants to merge 1 commit into
math65:mainfrom
rfiorentino1:feat/multichannel-device-routing
Open

Multi-channel devices: pick which channels TeamTalk uses per device#39
rfiorentino1 wants to merge 1 commit into
math65:mainfrom
rfiorentino1:feat/multichannel-device-routing

Conversation

@rfiorentino1

Copy link
Copy Markdown
Contributor

On an interface with more than a single stereo pair, TeamTalk no longer has to sit on channels 1/2. Each picker sits next to the device popup it belongs to, and every choice is remembered per CoreAudio device UID.

Output (new). OutputChannelSelection (auto / mono channel N / odd-even stereo pair) in Preferences ▸ Audio, shown for devices with more than two outputs. The RT path was hardcoded: ttac_render_planes wrote planes 0/1 and zeroed the rest. It now takes left/right plane indices — right < 0 sums to mono — and validates them against devCh in C. The engine publishes the pair packed into ONE word (planeMapCell) so the render thread reads a coherent pair lock-free, the same benign single-word pattern as the existing gain and mute cells. No AudioUnit rebind: a routing change is heard on the next buffer, so it applies unconditionally rather than through a device reinit. startImpl re-resolves against the new device's channel count, so swapping a 32-out for a stereo device falls back to 1/2 instead of going silent.

Input (moved, not new). The channel-preset picker already existed, buried in the Microphone settings block; it now sits under the Input Device popup. Gated at >= 2 channels rather than > 2 on purpose — a plain stereo device already had real options there (Input 1 mono, mono mix), and moving the control must not delete them.

Stream a Device or Application (new). The capture backend kept "the first two channels"; it now resolves an InputChannelPreset into clamped source indices at start (mono feeds both sides so it stays centred, mono mix averages). Threaded as a channelPreset: parameter rather than baked into DeviceStreamCaptureSpec, whose Equatable and persistence token would have churned.

The picker for that last one lives in MediaStreamSourceViewController next to the source button: a plain NSButton with the role overridden to .popUpButton and a menu popped on press, matching what you built there. It's in a stack with the source button so hiding it closes the gap, and it is hidden rather than dimmed when it doesn't apply — a disabled control is still announced by VoiceOver, and there's nothing to say about this one. Selecting a routing posts an announcement, for the same reason your source button does: an NSButton's VALUE can't be overridden.

One implementation note worth recording, since it cost real debugging time: rootStore.$preferences fires on willSet, so rootStore.preferences still holds the OLD value inside that sink. Reading it there resolved the device the user had just switched away from and kept the output picker hidden entirely. The sink now uses the value it is handed, with a direct refresh from updateSelectedDevices as well — which is why the input picker never had the bug. OutputChannelPickerVisibilityTests covers it and was verified to fail without the fix.

Cut from current main (1.10.0); builds clean and the suite passes. Verified on real hardware (Audient iD44, 24 out / 22 in) — the mix lands on the chosen output channels — though that pass was on an earlier base, before the source picker was ported onto the new sheet, so the stream-side picker deserves your own listen.

On an interface with more than a single stereo pair, TeamTalk no longer has
to sit on channels 1/2. Each picker sits next to the device popup it belongs
to, and every choice is remembered per CoreAudio device UID.

- Output (new): OutputChannelSelection (.auto / mono channel N / odd-even
  stereo pair) in Preferences > Audio, shown for devices with >2 outputs.
  ttac_render_planes hardcoded planes 0/1 and zeroed the rest; it now takes
  left/right plane indices (right < 0 = sum to mono) and validates them
  against devCh in C. The engine publishes the pair packed into ONE word
  (planeMapCell) so the render thread reads a coherent pair lock-free, the
  same benign single-word pattern as the gain/mute cells. No AudioUnit
  rebind: a routing change is heard on the next buffer, so it applies
  unconditionally rather than through a device reinit. startImpl re-resolves
  against the new device's channel count, so swapping a 32-out for stereo
  falls back to 1/2 instead of going silent.
- Input (moved): the channel preset picker was buried in the Microphone
  settings block; it now sits under the Input Device popup. Gated at >= 2
  channels, not > 2, because a plain stereo device already had real options
  there (Input 1 mono, mono mix) and moving the control must not delete them.
- Stream a Device or Application (new): the capture backend kept "the first
  two channels"; it now resolves an InputChannelPreset into clamped source
  indices at start (mono feeds both sides so it stays centered, mono mix
  averages). Threaded as a channelPreset: parameter rather than baked into
  DeviceStreamCaptureSpec, whose Equatable and persistence token would have
  churned. The picker lives in MediaStreamSourceViewController next to the
  source button, hidden rather than dimmed when it does not apply - a
  disabled control is still announced by VoiceOver - and it announces the new
  routing on selection, since an NSButton's VALUE cannot be overridden.

Note for future work on this store: rootStore.$preferences fires on willSet,
so rootStore.preferences still holds the OLD value inside that sink. Reading
it there resolved the device the user had just switched away from and kept
the output picker hidden; the sink now uses the value it is handed, with a
direct refresh from updateSelectedDevices as well (which is why the input
picker never had the bug). OutputChannelPickerVisibilityTests covers it and
was verified to fail without the fix.
@math65

math65 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Reviewed against current main (06aaf77). I pulled the branch into a worktree, merged main into it, and built the result: no conflicts despite the 23-commit gap, clean Debug build with no new warnings in any of the touched files, and 78 tests green (65 from main + your 13). OutputChannelPickerVisibilityTests actually ran on my machine rather than skipping.

The design holds up well, and a few things are worth saying out loud because they were done right:

  • Bounds are checked twice on the RT path — planeIndices(deviceChannels:) clamps in Swift, ttac_render_planes re-validates against devCh in C before dereferencing. A stale selection falls back to 1/2 instead of writing into foreign memory. Same on the capture side: resolveChannelSelection clamps against the channel count the AUHAL actually reports, and handleInput reads that same captureChannels, so no out-of-buffer read is reachable.
  • Packing the pair into planeMapCell is the right call — it's exactly what stops a callback from reading a new left with an old right, and it stays consistent with the existing gain/mute cells.
  • The ordering works out: setChannelSelection is engineQueue.async while start/switchDevice are engineQueue.sync, so the async lands first; and publishChannelMappingLocked() sits after deviceChannels = devChannels and before the release fence.
  • The willSet note deserves the long comment it got.

Below is what I'd like changed before this goes in. Push back on any of it if you disagree — several are judgement calls and you know this hardware better than I do.

Tests

1. The regression test only fires on the right hardware. OutputChannelPickerVisibilityTests does XCTSkip without a >2-channel output device. It passed here, but on a machine without an interface it goes silently green — so the willSet bug it protects could come back with nothing turning red. The core of that bug is pure logic. If refreshOutputChannelState grew a small pure helper along the lines of resolveSelection(preferences:uid:channelCount:), that part would be testable with no CoreAudio at all, and the hardware test could stay as a complement.

2. Same test breaks the suite's rule. CLAUDE.md says the tests deliberately don't touch AppKit, CoreAudio, or the TeamTalk SDK runtime; this one builds a real TeamTalkConnectionController and enumerates CoreAudio. The split in (1) fixes both at once.

3. The UserDefaults cleanup isn't guaranteed. removePersistentDomain only runs at the end, so a failing assert leaves the plist behind. main just fixed that same pattern elsewhere in 2e82d45 — a defer or tearDown would match.

Code

4. "Glitch-free" is a little optimistic (OutputAudioRenderEngine.setChannelSelection). The remap is instant: the old plane drops to zero and the new one starts at full amplitude mid-waveform. Gain smoothing doesn't cover that discontinuity, so a click is possible on both outputs. It's a rare, deliberate action so I'm fine shipping it as is — but either the comment should say "no rebind" rather than "glitch-free", or it needs a short ramp. Your ears, your call.

5. The two input pickers disagree on the threshold. offersInputChannelSelection gates at >= 2, and your reasoning for that is right. But updateChannelOptions in the stream sheet gates at > 2, so on a plain stereo interface "Input 1 mono" is available for the mic and not for streaming. The same argument applies to both.

6. applyOutputChannelSelectionLocked sits before the early return. It enumerates CoreAudio (availableOutputDevices() in the system-default case) on every applyAudioPreferences, including the calls the guard was about to drop. Only four call sites and nothing real-time, so it's not urgent — it just gives back what the early return was buying.

7. Two sources of truth in one function. applyOutputChannelSelectionLocked(preferences:) reads usesNoOutput and outputChannelSelections from the parameter, but resolves the device through resolveOutputEngineDeviceLocked(), which reads preferencesStore.preferences. I checked all four callers — every one passes preferencesStore.preferences, so it's harmless today. It's a trap for whoever passes a snapshot next.

Docs

8. The user guide landed on main after you opened this. There's a Help Book now (Help/Source/{en,fr}), and neither "Output channels" in Preferences ▸ Audio nor the "Channels" picker in the stream sheet appears anywhere in it. After the rebase: 65-audio-setup.md and 75-streaming.md, both languages, then regenerate the bundle with ./scripts/build-help-book.sh.

Wording (VoiceOver)

9. "Auto" on its own doesn't say it means outputs 1/2 — something like "Auto (outputs 1/2)" would. And on a 24-out interface the menu is 49 items with all 24 monos ahead of the 12 pairs, when the pair is the common case; putting pairs first would cut the keyboard trip. Minor: "Outputs 5/6 stereo" reads as "5 slash 6" in speech — "Outputs 5 and 6" may land better. You're the better judge of all three.


Happy to merge once (1)–(3) and (8) are handled — the rest is polish that can follow. Two things I couldn't verify and that want your ears: the possible click in (4), and the stream-sheet picker, since you noted your iD44 pass predates the port onto the new sheet.

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