From 72a989b20b06d8be1add55a08ef2a5822d0c8790 Mon Sep 17 00:00:00 2001 From: rfiorentino1 Date: Sun, 26 Jul 2026 14:20:11 -0500 Subject: [PATCH] Multi-channel devices: pick which channels TeamTalk uses per device 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. --- App/ttaccessible/AppDelegate.swift | 15 +- .../MediaStreamSourceViewController.swift | 112 ++++++++++- App/ttaccessible/AudioRTSupport.h | 26 ++- App/ttaccessible/Models/AppPreferences.swift | 17 ++ .../Models/OutputChannelSelection.swift | 114 ++++++++++++ .../Services/AppPreferencesStore.swift | 119 +++++++++++- .../Services/AudioDeviceStreamSource.swift | 7 +- .../Services/DeviceInputCaptureBackend.swift | 75 +++++++- .../Services/InputAudioDeviceResolver.swift | 58 +++++- .../Services/OutputAudioRenderEngine.swift | 52 +++++- .../TeamTalkConnectionController+Audio.swift | 26 +++ ...kConnectionController+MediaStreaming.swift | 7 + .../SwiftUI/PreferencesAudioView.swift | 65 +++++-- App/ttaccessible/en.lproj/Localizable.strings | 5 + App/ttaccessible/fr.lproj/Localizable.strings | 5 + .../OutputChannelSelectionTests.swift | 174 ++++++++++++++++++ 16 files changed, 825 insertions(+), 52 deletions(-) create mode 100644 App/ttaccessible/Models/OutputChannelSelection.swift create mode 100644 App/ttaccessibleTests/OutputChannelSelectionTests.swift diff --git a/App/ttaccessible/AppDelegate.swift b/App/ttaccessible/AppDelegate.swift index ce49134..8f1ed48 100644 --- a/App/ttaccessible/AppDelegate.swift +++ b/App/ttaccessible/AppDelegate.swift @@ -1701,15 +1701,24 @@ final class AppDelegate: NSObject, NSApplicationDelegate { allowsApplicationBrowsing: allowsApplicationBrowsing, preselectedToken: preferencesStore.preferences.deviceStreamLastSource ?? preferencesStore.preferences.deviceStreamLastDeviceUID.map { "device:\($0)" }, - fallbackDeviceUID: InputAudioDeviceResolver.defaultInputDeviceUID() + fallbackDeviceUID: InputAudioDeviceResolver.defaultInputDeviceUID(), + storedChannelPreset: { [weak self] uid in + self?.preferencesStore.deviceStreamChannelPreset(for: uid) ?? .auto + } ) - controller.onStream = { [weak self] spec, monitorEnabled, muteSourceOutput in + controller.onStream = { [weak self] spec, channelPreset, monitorEnabled, muteSourceOutput in guard let self else { return } self.preferencesStore.mutateDeviceStreamLastSource(spec) + // Remembered per device UID, so a desk always comes back on the pair + // the user chose for it. + if case .inputDevice(let device) = spec { + self.preferencesStore.updateDeviceStreamChannelPreset(channelPreset, for: device.uid) + } self.connectionController.startStreamingCaptureSource( spec: spec, monitorEnabled: monitorEnabled, - muteSourceOutput: muteSourceOutput + muteSourceOutput: muteSourceOutput, + channelPreset: channelPreset ) { [weak self] result in DispatchQueue.main.async { switch result { diff --git a/App/ttaccessible/AppKit/MediaStreamSourceViewController.swift b/App/ttaccessible/AppKit/MediaStreamSourceViewController.swift index b1a7010..4e27f47 100644 --- a/App/ttaccessible/AppKit/MediaStreamSourceViewController.swift +++ b/App/ttaccessible/AppKit/MediaStreamSourceViewController.swift @@ -19,9 +19,10 @@ import UniformTypeIdentifiers final class MediaStreamSourceViewController: NSViewController { - /// Confirmed with the chosen source, whether to monitor it locally, and - /// whether to mute it on this Mac while streaming. - var onStream: ((DeviceStreamCaptureSpec, Bool, Bool) -> Void)? + /// Confirmed with the chosen source, which of its channels to broadcast, + /// whether to monitor it locally, and whether to mute it on this Mac while + /// streaming. + var onStream: ((DeviceStreamCaptureSpec, InputChannelPreset, Bool, Bool) -> Void)? private let devices: [InputAudioDeviceInfo] private var applicationSources: [DeviceStreamCaptureSpec] @@ -29,6 +30,10 @@ final class MediaStreamSourceViewController: NSViewController { private let allowsApplicationBrowsing: Bool private let preselectedToken: String? private let fallbackDeviceUID: String? + /// This device's remembered channel routing, asked for as the selection + /// changes rather than passed up front — the answer depends on which device + /// the user lands on. + private let storedChannelPreset: (String?) -> InputChannelPreset /// An application picked by browsing that isn't in the running list — kept /// so it stays visible and checkable in the submenu. @@ -36,10 +41,18 @@ final class MediaStreamSourceViewController: NSViewController { private var selectedSource: DeviceStreamCaptureSpec? private var sourceButton: NSButton! + private var channelButton: NSButton! private var monitorCheckbox: NSButton! private var muteSourceCheckbox: NSButton? private var streamButton: NSButton! + /// Channel routing for the selected device. Only devices with more than a + /// stereo pair have anything to choose between, so for anything else the + /// button is HIDDEN rather than disabled — VoiceOver still announces a + /// dimmed control, and there is nothing to say about this one. + private var channelOptions: [InputChannelPresetOption] = [] + private var channelSelection: InputChannelPreset = .auto + /// - Parameters: /// - allowsApplicationBrowsing: browsing for a not-yet-running app needs /// the process-tap backend's wait-and-attach (macOS 14.2+); the @@ -49,13 +62,15 @@ final class MediaStreamSourceViewController: NSViewController { voiceOverAvailable: Bool, allowsApplicationBrowsing: Bool, preselectedToken: String?, - fallbackDeviceUID: String?) { + fallbackDeviceUID: String?, + storedChannelPreset: @escaping (String?) -> InputChannelPreset = { _ in .auto }) { self.devices = devices self.applicationSources = applicationSources self.voiceOverAvailable = voiceOverAvailable self.allowsApplicationBrowsing = allowsApplicationBrowsing self.preselectedToken = preselectedToken self.fallbackDeviceUID = fallbackDeviceUID + self.storedChannelPreset = storedChannelPreset super.init(nibName: nil, bundle: nil) } @@ -119,6 +134,16 @@ final class MediaStreamSourceViewController: NSViewController { sourceButton.setAccessibilityRole(.popUpButton) sourceButton.setAccessibilityLabel(L10n.text("mediaStream.device.prompt.sourceLabel")) + // Which channels of a multi-channel device are broadcast: a 32-channel + // desk can send 5/6 rather than always 1/2. Same pop-up treatment as the + // source button, and it sits directly under it. + channelButton = NSButton(title: "", target: self, action: #selector(showChannelMenu)) + channelButton.bezelStyle = .rounded + channelButton.translatesAutoresizingMaskIntoConstraints = false + channelButton.setAccessibilityRole(.popUpButton) + channelButton.setAccessibilityLabel(L10n.text("mediaStream.device.prompt.channelsLabel")) + channelButton.isHidden = true + // Off by default on purpose: the source is usually audible locally // already, and hearing it back a second time reads as an echo. monitorCheckbox = NSButton(checkboxWithTitle: L10n.text("mediaStream.device.prompt.monitor"), @@ -156,7 +181,16 @@ final class MediaStreamSourceViewController: NSViewController { streamButton.keyEquivalent = "\r" streamButton.translatesAutoresizingMaskIntoConstraints = false - [header, message, sourceButton, optionsStack, cancelButton, streamButton] + // A stack, so hiding the channel button closes its gap instead of + // leaving a hole where a control used to be. + let pickerStack = NSStackView(views: [sourceButton, channelButton]) + pickerStack.orientation = .vertical + pickerStack.alignment = .leading + pickerStack.distribution = .fill + pickerStack.spacing = 8 + pickerStack.translatesAutoresizingMaskIntoConstraints = false + + [header, message, pickerStack, optionsStack, cancelButton, streamButton] .forEach { view.addSubview($0) } NSLayoutConstraint.activate([ @@ -168,11 +202,13 @@ final class MediaStreamSourceViewController: NSViewController { message.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 14), message.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14), - sourceButton.topAnchor.constraint(equalTo: message.bottomAnchor, constant: 12), - sourceButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 14), - sourceButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14), + pickerStack.topAnchor.constraint(equalTo: message.bottomAnchor, constant: 12), + pickerStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 14), + pickerStack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -14), + sourceButton.widthAnchor.constraint(equalTo: pickerStack.widthAnchor), + channelButton.widthAnchor.constraint(equalTo: pickerStack.widthAnchor), - optionsStack.topAnchor.constraint(equalTo: sourceButton.bottomAnchor, constant: 12), + optionsStack.topAnchor.constraint(equalTo: pickerStack.bottomAnchor, constant: 12), optionsStack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 14), optionsStack.trailingAnchor.constraint(lessThanOrEqualTo: view.trailingAnchor, constant: -14), @@ -249,6 +285,7 @@ final class MediaStreamSourceViewController: NSViewController { private func applySelection(_ spec: DeviceStreamCaptureSpec) { selectedSource = spec sourceButton.title = spec.displayName + updateChannelOptions(for: spec) updateMuteAvailability() // The button's VALUE can't be overridden, so the selection is announced // explicitly — otherwise a VoiceOver user gets no feedback that the @@ -279,6 +316,61 @@ final class MediaStreamSourceViewController: NSViewController { if let first = orderedSources.first { applySelection(first) } } + /// Repopulates the channel picker for the newly-selected source. Only an + /// input device with more than a stereo pair offers a choice; app and + /// VoiceOver sources are already a stereo mixdown. A remembered routing that + /// no longer fits the device (it was swapped for a smaller one) falls back + /// to Auto rather than silently pointing at channels that aren't there. + private func updateChannelOptions(for spec: DeviceStreamCaptureSpec) { + guard case .inputDevice(let device) = spec, device.inputChannels > 2 else { + channelOptions = [] + channelSelection = .auto + channelButton.isHidden = true + return + } + channelOptions = InputAudioDeviceResolver.availablePresetOptions(for: device) + let stored = storedChannelPreset(device.uid) + channelSelection = InputAudioDeviceResolver.contains(stored, for: device) ? stored : .auto + channelButton.isHidden = false + refreshChannelTitle() + } + + private func refreshChannelTitle() { + channelButton.title = channelOptions.first { $0.preset == channelSelection }?.title + ?? InputAudioDeviceResolver.title(for: channelSelection) + } + + @objc private func showChannelMenu() { + guard channelOptions.isEmpty == false else { return } + let menu = NSMenu() + for option in channelOptions { + let item = NSMenuItem(title: option.title, action: #selector(selectChannelOption(_:)), keyEquivalent: "") + item.target = self + item.representedObject = option.preset + item.state = option.preset == channelSelection ? .on : .off + menu.addItem(item) + } + menu.popUp(positioning: nil, + at: NSPoint(x: 0, y: channelButton.bounds.height + 2), + in: channelButton) + } + + @objc private func selectChannelOption(_ sender: NSMenuItem) { + guard let preset = sender.representedObject as? InputChannelPreset else { return } + channelSelection = preset + refreshChannelTitle() + // Same reason as the source button: an NSButton's VALUE can't be + // overridden, so the new routing is announced explicitly. + NSAccessibility.post( + element: channelButton as Any, + notification: .announcementRequested, + userInfo: [ + .announcement: channelButton.title, + .priority: NSAccessibilityPriorityLevel.high.rawValue + ] + ) + } + private func updateMuteAvailability() { guard let muteSourceCheckbox else { return } let isProcessSource: Bool @@ -311,7 +403,7 @@ final class MediaStreamSourceViewController: NSViewController { @objc private func confirm() { guard let spec = selectedSource else { return } dismiss(nil) - onStream?(spec, monitorCheckbox.state == .on, muteSourceCheckbox?.state == .on) + onStream?(spec, channelSelection, monitorCheckbox.state == .on, muteSourceCheckbox?.state == .on) } @objc private func cancel() { diff --git a/App/ttaccessible/AudioRTSupport.h b/App/ttaccessible/AudioRTSupport.h index 30e9dd9..aa8d10a 100644 --- a/App/ttaccessible/AudioRTSupport.h +++ b/App/ttaccessible/AudioRTSupport.h @@ -102,8 +102,10 @@ static inline void ttac_mix_clamp(int16_t *out, const int32_t *acc, int count) { /// - planes: `devCh` non-null plane pointers (caller has already null-checked). /// - framesAvailable: frames actually pulled from the ring; the remainder up /// to `frameCount` is filled with silence (gain smoothing still advances). -/// - devCh == 1 downmixes L/R by average; devCh >= 2 maps L->0, R->1 and -/// silences the extra channels. +/// - leftPlane / rightPlane: which physical channels the mix lands on (see +/// OutputChannelSelection). `rightPlane < 0` downmixes L/R by average onto +/// `leftPlane` alone. Every other plane is silenced. Out-of-range indices +/// fall back to 0/1 — a bad mapping must never render into foreign memory. /// Returns the smoothed gain after `frameCount` frames. static inline float ttac_render_planes(float *const *planes, int devCh, @@ -112,13 +114,21 @@ static inline float ttac_render_planes(float *const *planes, int frameCount, float gain, float gainTarget, - float smoothCoeff) { + float smoothCoeff, + int leftPlane, + int rightPlane) { const float invScale = 1.0f / 32768.0f; - for (int ch = 2; ch < devCh; ch++) { + if (leftPlane < 0 || leftPlane >= devCh) leftPlane = 0; + if (rightPlane >= devCh) rightPlane = (devCh >= 2) ? 1 : -1; + if (rightPlane == leftPlane) rightPlane = -1; + if (devCh < 2) rightPlane = -1; + + for (int ch = 0; ch < devCh; ch++) { + if (ch == leftPlane || ch == rightPlane) continue; memset(planes[ch], 0, (size_t)frameCount * sizeof(float)); } - if (devCh == 1) { - float *mono = planes[0]; + if (rightPlane < 0) { + float *mono = planes[leftPlane]; for (int f = 0; f < framesAvailable; f++) { gain += (gainTarget - gain) * smoothCoeff; const int32_t sum = ((int32_t)pull[f * 2] + (int32_t)pull[f * 2 + 1]) / 2; @@ -129,8 +139,8 @@ static inline float ttac_render_planes(float *const *planes, (size_t)(frameCount - framesAvailable) * sizeof(float)); } } else { - float *left = planes[0]; - float *right = planes[1]; + float *left = planes[leftPlane]; + float *right = planes[rightPlane]; for (int f = 0; f < framesAvailable; f++) { gain += (gainTarget - gain) * smoothCoeff; const float g = invScale * gain; diff --git a/App/ttaccessible/Models/AppPreferences.swift b/App/ttaccessible/Models/AppPreferences.swift index 7d3e6ca..41dce00 100644 --- a/App/ttaccessible/Models/AppPreferences.swift +++ b/App/ttaccessible/Models/AppPreferences.swift @@ -85,6 +85,8 @@ struct AppPreferences: Codable, Equatable { case preferredOutputDevice case advancedInputAudioProfiles case advancedInputAudio + case outputChannelSelections + case deviceStreamChannelPresets case voiceOverAnnouncements case inputGainDB case outputGainDB @@ -186,6 +188,13 @@ struct AppPreferences: Codable, Equatable { var preferredInputDevice: AudioDevicePreference var preferredOutputDevice: AudioDevicePreference var advancedInputAudioProfiles: AdvancedInputAudioProfiles + /// Which physical output channels carry the mix, per output-device UID — + /// so a 32-out interface can keep TeamTalk on 5/6 while the built-in + /// speakers stay on their only pair. Absent = `.auto` (channels 1/2). + var outputChannelSelections: [String: OutputChannelSelection] + /// Which channels of a captured input device the "Stream Audio Device" + /// broadcast takes, per input-device UID. Absent = `.auto` (first pair). + var deviceStreamChannelPresets: [String: InputChannelPreset] var voiceOverAnnouncements: VoiceOverAnnouncementPreferences var inputGainDB: Double var outputGainDB: Double @@ -250,6 +259,8 @@ struct AppPreferences: Codable, Equatable { preferredInputDevice: AudioDevicePreference = .systemDefault, preferredOutputDevice: AudioDevicePreference = .systemDefault, advancedInputAudioProfiles: AdvancedInputAudioProfiles = AdvancedInputAudioProfiles(), + outputChannelSelections: [String: OutputChannelSelection] = [:], + deviceStreamChannelPresets: [String: InputChannelPreset] = [:], voiceOverAnnouncements: VoiceOverAnnouncementPreferences = VoiceOverAnnouncementPreferences(), inputGainDB: Double = 0, outputGainDB: Double = 0, @@ -319,6 +330,8 @@ struct AppPreferences: Codable, Equatable { self.preferredInputDevice = preferredInputDevice self.preferredOutputDevice = preferredOutputDevice self.advancedInputAudioProfiles = advancedInputAudioProfiles + self.outputChannelSelections = outputChannelSelections + self.deviceStreamChannelPresets = deviceStreamChannelPresets self.voiceOverAnnouncements = voiceOverAnnouncements self.inputGainDB = Self.clampGainDB(inputGainDB) self.outputGainDB = Self.clampGainDB(outputGainDB) @@ -434,6 +447,8 @@ struct AppPreferences: Codable, Equatable { ) } } + outputChannelSelections = try container.decodeIfPresent([String: OutputChannelSelection].self, forKey: .outputChannelSelections) ?? [:] + deviceStreamChannelPresets = try container.decodeIfPresent([String: InputChannelPreset].self, forKey: .deviceStreamChannelPresets) ?? [:] voiceOverAnnouncements = try container.decodeIfPresent(VoiceOverAnnouncementPreferences.self, forKey: .voiceOverAnnouncements) ?? VoiceOverAnnouncementPreferences() inputGainDB = Self.clampGainDB(try container.decodeIfPresent(Double.self, forKey: .inputGainDB) ?? 0) outputGainDB = Self.clampGainDB(try container.decodeIfPresent(Double.self, forKey: .outputGainDB) ?? 0) @@ -519,6 +534,8 @@ struct AppPreferences: Codable, Equatable { try container.encode(preferredInputDevice, forKey: .preferredInputDevice) try container.encode(preferredOutputDevice, forKey: .preferredOutputDevice) try container.encode(advancedInputAudioProfiles, forKey: .advancedInputAudioProfiles) + try container.encode(outputChannelSelections, forKey: .outputChannelSelections) + try container.encode(deviceStreamChannelPresets, forKey: .deviceStreamChannelPresets) try container.encode(voiceOverAnnouncements, forKey: .voiceOverAnnouncements) try container.encode(Self.clampGainDB(inputGainDB), forKey: .inputGainDB) try container.encode(Self.clampGainDB(outputGainDB), forKey: .outputGainDB) diff --git a/App/ttaccessible/Models/OutputChannelSelection.swift b/App/ttaccessible/Models/OutputChannelSelection.swift new file mode 100644 index 0000000..5a07d47 --- /dev/null +++ b/App/ttaccessible/Models/OutputChannelSelection.swift @@ -0,0 +1,114 @@ +// +// OutputChannelSelection.swift +// ttaccessible +// +// Which physical channels of the selected output device the mix is played to. +// The symmetric counterpart of InputChannelPreset: on an interface with more +// than two outputs (a 32-channel mixer, an aggregate rig) TeamTalk audio does +// not have to land on outputs 1/2 — it can be sent to 5/6, or to a single +// mono feed on output 11. +// +// Stored per output-device UID (AppPreferences.outputChannelSelections), like +// the per-device microphone profiles, so each interface keeps its own routing. +// + +import Foundation + +enum OutputChannelSelection: Codable, Hashable { + /// The device's first stereo pair (or its single channel on a mono device) — + /// the behavior that shipped before this setting existed. + case auto + /// Sum the mix to mono and play it on one physical channel (1-based). + case mono(channel: Int) + /// Play left/right on two physical channels (1-based). + case stereoPair(first: Int, second: Int) + + private enum CodingKeys: String, CodingKey { + case kind + case first + case second + } + + private enum Kind: String, Codable { + case auto + case mono + case stereoPair + } + + var identifier: String { + switch self { + case .auto: + return "auto" + case .mono(let channel): + return "mono:\(channel)" + case .stereoPair(let first, let second): + return "stereo:\(first):\(second)" + } + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(Kind.self, forKey: .kind) + switch kind { + case .auto: + self = .auto + case .mono: + self = .mono(channel: try container.decode(Int.self, forKey: .first)) + case .stereoPair: + self = .stereoPair( + first: try container.decode(Int.self, forKey: .first), + second: try container.decode(Int.self, forKey: .second) + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + switch self { + case .auto: + try container.encode(Kind.auto, forKey: .kind) + case .mono(let channel): + try container.encode(Kind.mono, forKey: .kind) + try container.encode(channel, forKey: .first) + case .stereoPair(let first, let second): + try container.encode(Kind.stereoPair, forKey: .kind) + try container.encode(first, forKey: .first) + try container.encode(second, forKey: .second) + } + } + + /// Zero-based plane indices for the render callback. `right == nil` means the + /// mix is summed to mono onto `left`. Clamped to the device's channel count; + /// a selection that no longer fits (device swapped for a smaller one) falls + /// back to the first pair rather than playing nothing. + func planeIndices(deviceChannels: Int) -> (left: Int, right: Int?) { + guard deviceChannels > 0 else { return (0, nil) } + switch self { + case .auto: + return deviceChannels >= 2 ? (0, 1) : (0, nil) + case .mono(let channel): + let index = channel - 1 + guard (0..= 2 ? (0, 1) : (0, nil) + } + return (index, nil) + case .stereoPair(let first, let second): + let leftIndex = first - 1 + let rightIndex = second - 1 + guard (0..= 2 ? (0, 1) : (0, nil) + } + return (leftIndex, rightIndex) + } + } +} + +struct OutputChannelSelectionOption: Identifiable, Equatable { + let selection: OutputChannelSelection + let title: String + + var id: String { + selection.identifier + } +} diff --git a/App/ttaccessible/Services/AppPreferencesStore.swift b/App/ttaccessible/Services/AppPreferencesStore.swift index 8b97ed8..f398d2e 100644 --- a/App/ttaccessible/Services/AppPreferencesStore.swift +++ b/App/ttaccessible/Services/AppPreferencesStore.swift @@ -222,6 +222,29 @@ final class AppPreferencesStore: ObservableObject { mutate { $0.advancedInputAudioProfiles.fallbackProfile = nil } } + /// Which physical output channels the mix plays on for a given output device + /// (keyed by its stable CoreAudio UID). Unknown device → `.auto` (1/2). + func outputChannelSelection(for deviceUID: String?) -> OutputChannelSelection { + guard let deviceUID, deviceUID.isEmpty == false else { return .auto } + return preferences.outputChannelSelections[deviceUID] ?? .auto + } + + func updateOutputChannelSelection(_ selection: OutputChannelSelection, for deviceUID: String?) { + guard let deviceUID, deviceUID.isEmpty == false else { return } + mutate { $0.outputChannelSelections[deviceUID] = selection } + } + + /// Which channels a device stream broadcasts, keyed by input-device UID. + func deviceStreamChannelPreset(for deviceUID: String?) -> InputChannelPreset { + guard let deviceUID, deviceUID.isEmpty == false else { return .auto } + return preferences.deviceStreamChannelPresets[deviceUID] ?? .auto + } + + func updateDeviceStreamChannelPreset(_ preset: InputChannelPreset, for deviceUID: String?) { + guard let deviceUID, deviceUID.isEmpty == false else { return } + mutate { $0.deviceStreamChannelPresets[deviceUID] = preset } + } + func updateVoiceOverChannelMessagesEnabled(_ enabled: Bool) { mutate { $0.voiceOverAnnouncements.channelMessagesEnabled = enabled } } @@ -694,6 +717,87 @@ final class AudioPreferencesStore: ObservableObject { advancedSettingsStore.deviceInfo } + /// The CoreAudio output device the render engine will actually bind to, and + /// the channel routing offered for it. Only devices with more than a single + /// stereo pair get a routing picker — everything else has nothing to choose. + @Published private(set) var outputDeviceInfo: InputAudioDeviceResolver.OutputAudioDeviceInfo? + @Published private(set) var outputChannelOptions: [OutputChannelSelectionOption] = [] + /// Published rather than computed from the root store: the root store's + /// preference changes don't drive this object's objectWillChange, so a + /// computed value would leave the picker showing the previous routing. + @Published private(set) var outputChannelSelection: OutputChannelSelection = .auto + + var offersOutputChannelSelection: Bool { + (outputDeviceInfo?.outputChannels ?? 0) > 2 + } + + /// Deliberately `>= 2`, not `> 2` like the output side: this picker already + /// existed (in the microphone block) and a plain stereo device has real + /// choices on it — "Input 1 mono" for an XLR plugged into the left channel + /// only, or the mono sum. Moving the control must not delete those. + var offersInputChannelSelection: Bool { + (advancedDeviceInfo?.inputChannels ?? 0) >= 2 + } + + func updateOutputChannelSelection(_ selection: OutputChannelSelection) { + guard let uid = outputDeviceInfo?.uid else { return } + outputChannelSelection = selection + rootStore.updateOutputChannelSelection(selection, for: uid) + // Applies live: the render engine remaps planes without reopening the + // device, so a routing change is heard on the next buffer. + connectionController.applyAudioPreferences(rootStore.preferences) { _ in } + } + + /// Re-resolve the bound output device (its UID keys the stored routing, its + /// channel count sizes the option list). Cheap CoreAudio enumeration, same + /// as the input side does in AdvancedMicrophoneSettingsStore.refreshState. + /// + /// ⚠️ `preferences` must be passed explicitly when called from the + /// `rootStore.$preferences` sink. That publisher fires on *willSet*, so + /// `rootStore.preferences` still holds the PREVIOUS value inside the sink — + /// reading it there resolved the device the user just switched AWAY from, + /// which is why the picker stayed hidden after selecting a 24-channel + /// interface. (The input picker sidesteps this because updateSelectedDevices + /// pokes AdvancedMicrophoneSettingsStore directly, after the write lands.) + private func refreshOutputChannelState(preferences: AppPreferences? = nil) { + let preferences = preferences ?? rootStore.preferences + let preference = preferences.preferredOutputDevice + let resolved: InputAudioDeviceResolver.OutputAudioDeviceInfo? + if preference.usesNoOutput { + resolved = nil + } else if preference.usesSystemDefault { + let devices = InputAudioDeviceResolver.availableOutputDevices() + let defaultUID = InputAudioDeviceResolver.defaultOutputDeviceUID() + resolved = devices.first(where: { $0.uid == defaultUID }) ?? devices.first + } else { + resolved = InputAudioDeviceResolver.resolveOutputDevice( + persistentID: preference.persistentID, + displayName: preference.displayName + ) + } + + outputDeviceInfo = resolved + let channelCount = resolved?.outputChannels ?? 0 + outputChannelOptions = channelCount > 2 + ? InputAudioDeviceResolver.availableOutputChannelOptions(channelCount: channelCount) + : [] + + // A stored routing that no longer fits the device as it is RIGHT NOW + // (interface in a smaller mode, or gone) displays as Auto, matching what + // the engine falls back to. Deliberately NOT written back: the stored + // intent for that UID survives, so the routing returns when the device + // does. (Writing here would also be a reentrant mutation of the very + // preference object mid-publish when called from the sink.) + guard let uid = resolved?.uid else { + outputChannelSelection = .auto + return + } + let stored = preferences.outputChannelSelections[uid] ?? .auto + outputChannelSelection = InputAudioDeviceResolver.contains(stored, channelCount: channelCount) + ? stored + : .auto + } + init( rootStore: AppPreferencesStore, connectionController: TeamTalkConnectionController, @@ -734,7 +838,13 @@ final class AudioPreferencesStore: ObservableObject { let muteGlobal = preferences.muteHotkeyGlobal let muteBinding = preferences.muteHotkeyBinding if self.state.preferredInputDevice != input { self.state.preferredInputDevice = input } - if self.state.preferredOutputDevice != output { self.state.preferredOutputDevice = output } + if self.state.preferredOutputDevice != output { + self.state.preferredOutputDevice = output + // A different output device has its own channel count and its + // own stored routing. Fed the sink's OWN value — see the + // willSet warning on refreshOutputChannelState. + self.refreshOutputChannelState(preferences: preferences) + } if self.state.microphoneMode != mode { self.state.microphoneMode = mode } if self.state.pushToTalkBeepEnabled != beep { self.state.pushToTalkBeepEnabled = beep } if self.state.pushToTalkKey != pttKey { self.state.pushToTalkKey = pttKey } @@ -777,6 +887,7 @@ final class AudioPreferencesStore: ObservableObject { catalogStale = false loadCatalogIfNeeded(forceRefresh: true) advancedSettingsStore.refresh() + refreshOutputChannelState() hasPrepared = true return } @@ -786,6 +897,7 @@ final class AudioPreferencesStore: ObservableObject { hasPrepared = true loadCatalogIfNeeded(forceRefresh: false) advancedSettingsStore.refresh() + refreshOutputChannelState() } func refreshIfVisible() { @@ -794,6 +906,7 @@ final class AudioPreferencesStore: ObservableObject { } loadCatalogIfNeeded(forceRefresh: true) advancedSettingsStore.refresh() + refreshOutputChannelState() } private var catalogStale = false @@ -809,6 +922,7 @@ final class AudioPreferencesStore: ObservableObject { let workItem = DispatchWorkItem { [weak self] in self?.loadCatalogIfNeeded(forceRefresh: true) self?.advancedSettingsStore.refresh() + self?.refreshOutputChannelState() } deviceChangeRefreshWorkItem = workItem DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(600), execute: workItem) @@ -910,6 +1024,9 @@ final class AudioPreferencesStore: ObservableObject { rootStore.updatePreferredOutputDevice(outputPreference) rootStore.updatePreferredInputDevice(inputPreference) advancedSettingsStore.handleInputDevicePreferenceChange() + // Same direct poke as the input side above: both stores must re-resolve + // against the values that have now actually landed in the root store. + refreshOutputChannelState() scheduleApplyAudioPreferencesIfNeeded( inputPreference: inputPreference, outputPreference: outputPreference diff --git a/App/ttaccessible/Services/AudioDeviceStreamSource.swift b/App/ttaccessible/Services/AudioDeviceStreamSource.swift index b4a62d4..8e0a9fa 100644 --- a/App/ttaccessible/Services/AudioDeviceStreamSource.swift +++ b/App/ttaccessible/Services/AudioDeviceStreamSource.swift @@ -112,6 +112,9 @@ final class AudioDeviceStreamSource { nonisolated static let outputChannels = 2 private let spec: DeviceStreamCaptureSpec + /// Which channels of an input-device source are broadcast. Process/VoiceOver + /// sources are already a stereo mixdown, so it only applies to devices. + private let channelPreset: InputChannelPreset private let muteSourceOutput: Bool /// Passed to the process-tap backend so its transient aggregate device /// doesn't trigger a sound-system restart. See `ProcessTapCaptureBackend`. @@ -143,10 +146,12 @@ final class AudioDeviceStreamSource { /// muted-while-tapped behavior). Ignored for devices and the SCK backend. init( spec: DeviceStreamCaptureSpec, + channelPreset: InputChannelPreset = .auto, muteSourceOutput: Bool = false, suppressDeviceChanges: ((TimeInterval) -> Void)? = nil ) { self.spec = spec + self.channelPreset = channelPreset self.muteSourceOutput = muteSourceOutput self.suppressDeviceChanges = suppressDeviceChanges self.syncClock = MediaSyncClock(ring: ring) @@ -185,7 +190,7 @@ final class AudioDeviceStreamSource { private func makeBackend() throws -> DeviceStreamCaptureBackend { switch spec { case .inputDevice(let device): - return DeviceInputCaptureBackend(device: device, ring: ring) + return DeviceInputCaptureBackend(device: device, channelPreset: channelPreset, ring: ring) case .processes(let selection): if #available(macOS 14.2, *) { return ProcessTapCaptureBackend( diff --git a/App/ttaccessible/Services/DeviceInputCaptureBackend.swift b/App/ttaccessible/Services/DeviceInputCaptureBackend.swift index 0b78c90..5c3e115 100644 --- a/App/ttaccessible/Services/DeviceInputCaptureBackend.swift +++ b/App/ttaccessible/Services/DeviceInputCaptureBackend.swift @@ -16,6 +16,10 @@ import Foundation final class DeviceInputCaptureBackend: DeviceStreamCaptureBackend { private let device: InputAudioDeviceInfo + /// Which of the device's channels to broadcast (mirrors the microphone's + /// InputChannelPreset). Resolved into concrete indices once the AUHAL + /// reports the real channel count — see `resolveChannelSelection`. + private let channelPreset: InputChannelPreset private let ring: AudioDeviceStreamSource.PCMRing // Capture state (mutated on start/stop only; callback reads via unmanaged self). @@ -24,6 +28,12 @@ final class DeviceInputCaptureBackend: DeviceStreamCaptureBackend { private var captureBufferCapacity: Int = 0 private var captureSampleRate: Double = 48_000 private var captureChannels: Int = 2 + /// Resolved source channel indices for the broadcast stereo pair (equal for a + /// single-channel selection), plus whether the two are summed to mono. + /// Written on start only; read by the RT callback. + private var captureLeftIndex: Int = 0 + private var captureRightIndex: Int = 1 + private var captureSumsToMono = false /// Pre-allocated stereo scratch reused by the RT input callback (sized to the /// AUHAL's max frames), so `handleInput` doesn't heap-allocate per callback. private var captureStereoScratch = [Int16]() @@ -35,8 +45,13 @@ final class DeviceInputCaptureBackend: DeviceStreamCaptureBackend { /// live loopback). Plain Bool: a one-buffer-late transition is inaudible. private var isMuted = false - init(device: InputAudioDeviceInfo, ring: AudioDeviceStreamSource.PCMRing) { + init( + device: InputAudioDeviceInfo, + channelPreset: InputChannelPreset = .auto, + ring: AudioDeviceStreamSource.PCMRing + ) { self.device = device + self.channelPreset = channelPreset self.ring = ring } @@ -130,6 +145,7 @@ final class DeviceInputCaptureBackend: DeviceStreamCaptureBackend { captureBufferCapacity = byteCapacity captureSampleRate = sampleRate captureChannels = channelCount + resolveChannelSelection(channelCount: channelCount) captureStereoScratch = [Int16](repeating: 0, count: Int(maxFrames) * 2) // Worst case the device runs below 48 kHz, so resampling grows the frame // count; size the scratch for that ceiling (+ margin) once, up front. @@ -157,8 +173,40 @@ final class DeviceInputCaptureBackend: DeviceStreamCaptureBackend { throw fail() } audioUnit = au - AudioLogger.log("device stream: capture started device=%@ rate=%d ch=%d", - device.name, Int(sampleRate.rounded()), channelCount) + AudioLogger.log("device stream: capture started device=%@ rate=%d ch=%d broadcasting=%d/%d%@", + device.name, Int(sampleRate.rounded()), channelCount, + captureLeftIndex + 1, captureRightIndex + 1, + captureSumsToMono ? " summed" : "") + } + + /// Map the user's channel preset onto concrete source indices for this + /// device's actual channel count. Out-of-range selections (device swapped + /// for one with fewer inputs) clamp instead of reading past the buffer. + private func resolveChannelSelection(channelCount: Int) { + let lastIndex = max(channelCount - 1, 0) + func clamp(_ channel: Int) -> Int { min(max(channel - 1, 0), lastIndex) } + + switch channelPreset { + case .auto: + captureLeftIndex = 0 + captureRightIndex = channelCount >= 2 ? 1 : 0 + captureSumsToMono = false + case .mono(let channel): + // Single channel: the same source sample feeds both sides, so the + // broadcast is centered rather than hard-panned to one side. + let index = clamp(channel) + captureLeftIndex = index + captureRightIndex = index + captureSumsToMono = false + case .stereoPair(let first, let second): + captureLeftIndex = clamp(first) + captureRightIndex = clamp(second) + captureSumsToMono = false + case .monoMix(let first, let second): + captureLeftIndex = clamp(first) + captureRightIndex = clamp(second) + captureSumsToMono = true + } } private func stopCapture() { @@ -202,19 +250,32 @@ final class DeviceInputCaptureBackend: DeviceStreamCaptureBackend { } let input = rawData.assumingMemoryBound(to: Int16.self) - // Map to stereo into the pre-allocated scratch (no RT-thread allocation): - // mono duplicates, >2 channels keep the first two. + // Map the selected channels to stereo in the pre-allocated scratch (no + // RT-thread allocation). The indices were resolved (and clamped) at start + // from the user's channel preset, so a 32-channel desk can broadcast 5/6 + // — or channel 11 alone, centered — instead of always outputs 1/2. guard frames * 2 <= captureStereoScratch.count else { return } + let leftIndex = captureLeftIndex + let rightIndex = captureRightIndex if channels == 1 { for frame in 0.. [OutputChannelSelectionOption] { + var options = [OutputChannelSelectionOption(selection: .auto, title: outputChannelTitle(for: .auto))] + guard channelCount > 0 else { return options } + + for channel in 1...channelCount { + let selection = OutputChannelSelection.mono(channel: channel) + options.append(OutputChannelSelectionOption(selection: selection, title: outputChannelTitle(for: selection))) + } + + var firstChannel = 1 + while firstChannel + 1 <= channelCount { + let selection = OutputChannelSelection.stereoPair(first: firstChannel, second: firstChannel + 1) + options.append(OutputChannelSelectionOption(selection: selection, title: outputChannelTitle(for: selection))) + firstChannel += 2 + } + + return options + } + + nonisolated static func outputChannelTitle(for selection: OutputChannelSelection) -> String { + switch selection { + case .auto: + return L10n.text("preferences.audio.outputChannels.auto") + case .mono(let channel): + return L10n.format("preferences.audio.outputChannels.mono", channel) + case .stereoPair(let first, let second): + return L10n.format("preferences.audio.outputChannels.stereoPair", first, second) + } + } + + nonisolated static func contains(_ selection: OutputChannelSelection, channelCount: Int) -> Bool { + switch selection { + case .auto: + return true + case .mono(let channel): + return channel >= 1 && channel <= channelCount + case .stereoPair(let first, let second): + return first >= 1 && second == first + 1 && second <= channelCount + } + } + nonisolated static func normalizedPreferences( _ preferences: AdvancedInputAudioPreferences, for device: InputAudioDeviceInfo? @@ -162,6 +206,9 @@ enum InputAudioDeviceResolver { let uid: String let name: String let nominalSampleRate: Double + /// Physical output channels. More than two means the device can carry + /// TeamTalk audio on a pair other than 1/2 — see OutputChannelSelection. + let outputChannels: Int } /// Resolve the user's selected output device to a CoreAudio device so preview @@ -202,7 +249,8 @@ enum InputAudioDeviceResolver { } private nonisolated static func makeOutputDeviceInfo(for objectID: AudioObjectID) -> OutputAudioDeviceInfo? { - guard outputChannelCount(for: objectID) > 0, + let channelCount = outputChannelCount(for: objectID) + guard channelCount > 0, let name = stringProperty(objectID: objectID, selector: kAudioObjectPropertyName, scope: kAudioObjectPropertyScopeGlobal), let uid = stringProperty(objectID: objectID, selector: kAudioDevicePropertyDeviceUID, scope: kAudioObjectPropertyScopeGlobal) else { return nil @@ -212,7 +260,13 @@ enum InputAudioDeviceResolver { selector: kAudioDevicePropertyNominalSampleRate, scope: kAudioObjectPropertyScopeGlobal ) ?? 48_000 - return OutputAudioDeviceInfo(deviceID: objectID, uid: uid, name: name, nominalSampleRate: sampleRate) + return OutputAudioDeviceInfo( + deviceID: objectID, + uid: uid, + name: name, + nominalSampleRate: sampleRate, + outputChannels: channelCount + ) } private nonisolated static func outputChannelCount(for objectID: AudioObjectID) -> Int { diff --git a/App/ttaccessible/Services/OutputAudioRenderEngine.swift b/App/ttaccessible/Services/OutputAudioRenderEngine.swift index 3eafd80..a43e829 100644 --- a/App/ttaccessible/Services/OutputAudioRenderEngine.swift +++ b/App/ttaccessible/Services/OutputAudioRenderEngine.swift @@ -288,6 +288,17 @@ final class OutputAudioRenderEngine { private let gainCell = UnsafeMutablePointer.allocate(capacity: 1) // master linear gain private let muteCell = UnsafeMutablePointer.allocate(capacity: 1) private let primedCell = UnsafeMutablePointer.allocate(capacity: 1) + /// Which physical device channels the stereo mix lands on, packed into ONE + /// word so the render thread reads a coherent pair without a lock: + /// `(left << 16) | right`, with `right == monoSentinel` meaning "sum to mono + /// on `left`". Written on engineQueue (a device switch or a preference + /// change), read every render callback. Single-word = benign, like gain/mute. + private let planeMapCell = UnsafeMutablePointer.allocate(capacity: 1) + private static let monoSentinel: Int32 = 0xFFFF + + /// The user's selection for the CURRENT device (engineQueue). Kept so a + /// device switch can re-resolve it against the new device's channel count. + private var channelSelection: OutputChannelSelection = .auto // MARK: RT-only state private let mixChannels = 2 // the ring is always stereo @@ -318,6 +329,7 @@ final class OutputAudioRenderEngine { gainCell.initialize(to: 1) muteCell.initialize(to: 0) primedCell.initialize(to: 0) + planeMapCell.initialize(to: Self.packedMapping(left: 0, right: 1)) } deinit { @@ -325,6 +337,7 @@ final class OutputAudioRenderEngine { gainCell.deallocate() muteCell.deallocate() primedCell.deallocate() + planeMapCell.deallocate() } var isRunning: Bool { auhal != nil } @@ -453,6 +466,10 @@ final class OutputAudioRenderEngine { self.gainSmoothCoeff = Float(1.0 - exp(-1.0 / (0.008 * devRate))) self.underflowCount = 0 primedCell.pointee = 0 + // Re-resolve the routing against THIS device's channel count (deviceChannels + // was just set above) — a switch to a device with fewer outputs must fall + // back to 1/2 instead of rendering nowhere. + publishChannelMappingLocked() // Publish the RT render state written just above (rtPull / rtPullCapacity / // rtPlanePtrs / rtDeviceChannels / currentGain / ring) with a release fence, @@ -532,6 +549,34 @@ final class OutputAudioRenderEngine { } } + // MARK: - Output channel routing + + /// Choose which physical channels of the current device carry the mix. + /// Cheap and glitch-free: no AudioUnit rebind, just a new plane mapping the + /// next render callback picks up (the previously-used planes are silenced by + /// the same callback, so nothing is left ringing on the old outputs). + func setChannelSelection(_ selection: OutputChannelSelection) { + engineQueue.async { [weak self] in + guard let self else { return } + self.channelSelection = selection + self.publishChannelMappingLocked() + } + } + + /// engineQueue only. Resolves the selection against the CURRENT device's + /// channel count (a 32-out interface swapped for built-in stereo falls back + /// to 1/2 rather than going silent) and publishes it to the render thread. + private func publishChannelMappingLocked() { + let channels = deviceChannels > 0 ? deviceChannels : 2 + let indices = channelSelection.planeIndices(deviceChannels: channels) + planeMapCell.pointee = Self.packedMapping(left: indices.left, right: indices.right) + } + + private static func packedMapping(left: Int, right: Int?) -> Int32 { + let rightValue = right.map { Int32($0) & 0xFFFF } ?? monoSentinel + return (Int32(left) & 0xFFFF) << 16 | rightValue + } + // MARK: - Master gain / mute (serial queue) func setMasterGainDB(_ gainDB: Double) { @@ -779,10 +824,15 @@ final class OutputAudioRenderEngine { // Per-frame conversion + gain smoothing in the C hot loop (RT-safe in // every build configuration; -Onone Swift measurably missed deadlines). let target: Float = (muteCell.pointee != 0) ? 0 : gainCell.pointee + let packedMap = planeMapCell.pointee + let leftPlane = Int32((packedMap >> 16) & 0xFFFF) + let rightRaw = packedMap & 0xFFFF + let rightPlane: Int32 = rightRaw == Self.monoSentinel ? -1 : rightRaw currentGain = ttac_render_planes( planes, Int32(devCh), pull, Int32(framesAvailable), Int32(frameCount), - currentGain, target, gainSmoothCoeff + currentGain, target, gainSmoothCoeff, + leftPlane, rightPlane ) return noErr diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift b/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift index aedea6f..a1aac7a 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+Audio.swift @@ -432,6 +432,11 @@ extension TeamTalkConnectionController { // only takes effect after the user manually stops & restarts transmission. let micProcessingChanged = self.advancedMicrophoneProcessingChangedLocked(preferences: preferences) + // Output channel routing (which physical outputs carry the mix) is a + // plane remap inside our render engine — no device reopen, no gap — + // so it is pushed unconditionally rather than gated behind a reinit. + self.applyOutputChannelSelectionLocked(preferences: preferences) + guard outputChanged || inputChanged || micProcessingChanged else { self.appliedOutputPreference = preferences.preferredOutputDevice self.appliedInputPreference = preferences.preferredInputDevice @@ -702,6 +707,12 @@ extension TeamTalkConnectionController { // all our code, fast, and no SDK audio mutex involved. Master gain/mute // persist in the engine across the switch. if let device = resolveOutputEngineDeviceLocked() { + // Push the new device's channel routing BEFORE the rebind, so the + // very first render on the new device already lands on the right + // outputs (the engine re-resolves it against the new channel count). + outputRenderEngine.setChannelSelection( + preferencesStore.outputChannelSelection(for: device.uid) + ) if outputRenderEngine.isRunning { AudioLogger.log("reinit: switching output engine to %@", device.name) try outputRenderEngine.switchDevice(device.deviceID) @@ -812,6 +823,18 @@ extension TeamTalkConnectionController { return devices.first } + /// Push the stored channel routing for whichever output device is currently + /// bound. Cheap (a CoreAudio enumeration plus a word written on the engine + /// queue) and safe to call when the engine is idle — the selection is stored + /// and re-resolved the next time it starts. + func applyOutputChannelSelectionLocked(preferences: AppPreferences) { + guard preferences.preferredOutputDevice.usesNoOutput == false, + let device = resolveOutputEngineDeviceLocked() else { return } + outputRenderEngine.setChannelSelection( + preferences.outputChannelSelections[device.uid] ?? .auto + ) + } + /// Start the output render engine on the currently-selected output device. func startOutputRenderEngineLocked() { guard outputAudioReady, outputRenderEngine.isRunning == false else { return } @@ -821,6 +844,9 @@ extension TeamTalkConnectionController { } outputRenderEngine.setMasterGainDB(preferencesStore.preferences.outputGainDB) outputRenderEngine.setMuted(masterMuted) + outputRenderEngine.setChannelSelection( + preferencesStore.outputChannelSelection(for: device.uid) + ) do { try outputRenderEngine.start(deviceID: device.deviceID) AudioLogger.log("outputRenderEngine: started on %@", device.name) diff --git a/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift b/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift index a0d5b89..0ab71a2 100644 --- a/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift +++ b/App/ttaccessible/Services/TeamTalkConnectionController+MediaStreaming.swift @@ -147,6 +147,7 @@ extension TeamTalkConnectionController { self?.startStreamingCaptureSource( spec: .inputDevice(device), monitorEnabled: monitorEnabled, + channelPreset: self?.preferencesStore.deviceStreamChannelPreset(for: deviceUID) ?? .auto, completion: completion ) } @@ -157,10 +158,15 @@ extension TeamTalkConnectionController { /// served as endless Ogg Opus on a loopback URL, which the SDK broadcasts /// exactly like a URL stream — the source paces itself and pads silence, /// so a quiet source never ends the broadcast. + /// `channelPreset` picks which channels of an input-device source are + /// broadcast (mono channel N, the pair N/N+1, or their mono sum) — the + /// streaming counterpart of the microphone's input-channel preset. Ignored + /// by process/VoiceOver sources, which are always a stereo mixdown. func startStreamingCaptureSource( spec: DeviceStreamCaptureSpec, monitorEnabled: Bool, muteSourceOutput: Bool = false, + channelPreset: InputChannelPreset = .auto, completion: @escaping (Result) -> Void ) { // Open the capture OFF the controller queue: capture setup and the @@ -171,6 +177,7 @@ extension TeamTalkConnectionController { let source = AudioDeviceStreamSource( spec: spec, + channelPreset: channelPreset, muteSourceOutput: muteSourceOutput, suppressDeviceChanges: { [weak self] duration in self?.suppressNextDeviceChange(for: duration) diff --git a/App/ttaccessible/SwiftUI/PreferencesAudioView.swift b/App/ttaccessible/SwiftUI/PreferencesAudioView.swift index 86aadb7..39c0ea3 100644 --- a/App/ttaccessible/SwiftUI/PreferencesAudioView.swift +++ b/App/ttaccessible/SwiftUI/PreferencesAudioView.swift @@ -34,6 +34,28 @@ struct PreferencesAudioView: View { } } + // Only devices with more than one stereo pair get a routing + // picker — on a plain stereo output there is nothing to choose. + if store.offersOutputChannelSelection { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.text("preferences.audio.outputChannels")) + .accessibilityHidden(true) + Picker( + "", + selection: Binding( + get: { store.outputChannelSelection }, + set: { store.updateOutputChannelSelection($0) } + ) + ) { + ForEach(store.outputChannelOptions) { option in + Text(option.title).tag(option.selection) + } + } + .labelsHidden() + .accessibilityLabel(L10n.text("preferences.audio.outputChannels")) + } + } + VStack(alignment: .leading, spacing: 6) { Text(L10n.text("preferences.audio.inputDevice")) .accessibilityHidden(true) @@ -50,12 +72,35 @@ struct PreferencesAudioView: View { } } + // Same rule as the output side: shown only when the device has + // more inputs than a single stereo pair to choose between. + if store.offersInputChannelSelection { + VStack(alignment: .leading, spacing: 6) { + Text(L10n.text("preferences.audio.advanced.preset.label")) + .accessibilityHidden(true) + Picker( + "", + selection: Binding( + get: { store.advancedPreferences.preset }, + set: { store.updatePreset($0) } + ) + ) { + ForEach(store.presetOptions) { option in + Text(option.title).tag(option.preset) + } + } + .labelsHidden() + .accessibilityLabel(L10n.text("preferences.audio.advanced.preset.label")) + } + } + Button(L10n.text("preferences.audio.refreshDevices")) { store.restartSoundSystem() } .disabled(store.state.isCatalogLoading) - // Microphone settings (processing mode, channel preset, preview). + // Microphone settings (processing mode, preview). The input + // channel picker lives with the input device picker above. VStack(alignment: .leading, spacing: 12) { Text(L10n.text("preferences.audio.advanced.title")) .font(.headline) @@ -86,24 +131,6 @@ struct PreferencesAudioView: View { .font(.caption) .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 6) { - Text(L10n.text("preferences.audio.advanced.preset.label")) - .accessibilityHidden(true) - Picker( - "", - selection: Binding( - get: { store.advancedPreferences.preset }, - set: { store.updatePreset($0) } - ) - ) { - ForEach(store.presetOptions) { option in - Text(option.title).tag(option.preset) - } - } - .labelsHidden() - .accessibilityLabel(L10n.text("preferences.audio.advanced.preset.label")) - } - Button( store.isPreviewRunning ? L10n.text("preferences.audio.advanced.preview.stop") diff --git a/App/ttaccessible/en.lproj/Localizable.strings b/App/ttaccessible/en.lproj/Localizable.strings index 944b04a..bb3e1b7 100644 --- a/App/ttaccessible/en.lproj/Localizable.strings +++ b/App/ttaccessible/en.lproj/Localizable.strings @@ -404,6 +404,10 @@ "preferences.audio.title" = "Audio"; "preferences.audio.outputDevice" = "Output device"; "preferences.audio.inputDevice" = "Input device"; +"preferences.audio.outputChannels" = "Output channels"; +"preferences.audio.outputChannels.auto" = "Auto"; +"preferences.audio.outputChannels.mono" = "Output %d mono"; +"preferences.audio.outputChannels.stereoPair" = "Outputs %d/%d stereo"; "preferences.audio.systemDefault" = "System default"; "preferences.audio.noOutput" = "No output device"; "preferences.audio.advanced.title" = "Microphone"; @@ -624,6 +628,7 @@ "mediaStream.device.error.deviceUnavailable" = "The selected audio device is no longer available."; "mediaStream.device.error.startFailed" = "Failed to start streaming the audio device."; "mediaStream.device.prompt.muteSource" = "Mute this source on this Mac while streaming"; +"mediaStream.device.prompt.channelsLabel" = "Channels"; "mediaStream.device.source.voiceOver" = "VoiceOver"; "mediaStream.device.source.applicationMenu" = "Application"; "mediaStream.device.source.chooseApplication" = "Select Application…"; diff --git a/App/ttaccessible/fr.lproj/Localizable.strings b/App/ttaccessible/fr.lproj/Localizable.strings index ffe4c93..4eca786 100644 --- a/App/ttaccessible/fr.lproj/Localizable.strings +++ b/App/ttaccessible/fr.lproj/Localizable.strings @@ -404,6 +404,10 @@ "preferences.audio.title" = "Audio"; "preferences.audio.outputDevice" = "Périphérique de sortie"; "preferences.audio.inputDevice" = "Périphérique d'entrée"; +"preferences.audio.outputChannels" = "Canaux de sortie"; +"preferences.audio.outputChannels.auto" = "Auto"; +"preferences.audio.outputChannels.mono" = "Sortie %d mono"; +"preferences.audio.outputChannels.stereoPair" = "Sorties %d/%d stéréo"; "preferences.audio.systemDefault" = "Par défaut du système"; "preferences.audio.noOutput" = "Aucune sortie audio"; "preferences.audio.advanced.title" = "Microphone"; @@ -624,6 +628,7 @@ "mediaStream.device.error.deviceUnavailable" = "Le périphérique audio sélectionné n'est plus disponible."; "mediaStream.device.error.startFailed" = "Impossible de démarrer la diffusion du périphérique audio."; "mediaStream.device.prompt.muteSource" = "Couper le son de cette source sur ce Mac pendant la diffusion"; +"mediaStream.device.prompt.channelsLabel" = "Canaux"; "mediaStream.device.source.voiceOver" = "VoiceOver"; "mediaStream.device.source.applicationMenu" = "Application"; "mediaStream.device.source.chooseApplication" = "Sélectionner une application…"; diff --git a/App/ttaccessibleTests/OutputChannelSelectionTests.swift b/App/ttaccessibleTests/OutputChannelSelectionTests.swift new file mode 100644 index 0000000..fb65920 --- /dev/null +++ b/App/ttaccessibleTests/OutputChannelSelectionTests.swift @@ -0,0 +1,174 @@ +// +// OutputChannelSelectionTests.swift +// ttaccessibleTests +// +// Pure logic behind multi-channel device routing: +// - OutputChannelSelection -> render plane indices (incl. the fallbacks that +// keep audio audible when a selection outgrows the bound device) +// - the option lists offered for a given channel count +// - Codable round-trip (these are persisted per device UID) +// + +import XCTest +@testable import ttaccessible + +final class OutputChannelPlaneMappingTests: XCTestCase { + + func testAutoUsesFirstStereoPair() { + let indices = OutputChannelSelection.auto.planeIndices(deviceChannels: 32) + XCTAssertEqual(indices.left, 0) + XCTAssertEqual(indices.right, 1) + } + + func testAutoOnMonoDeviceCollapsesToSinglePlane() { + let indices = OutputChannelSelection.auto.planeIndices(deviceChannels: 1) + XCTAssertEqual(indices.left, 0) + XCTAssertNil(indices.right) + } + + func testStereoPairMapsToZeroBasedPlanes() { + // "Outputs 5/6" on a 32-channel desk = planes 4 and 5. + let indices = OutputChannelSelection.stereoPair(first: 5, second: 6).planeIndices(deviceChannels: 32) + XCTAssertEqual(indices.left, 4) + XCTAssertEqual(indices.right, 5) + } + + func testMonoSelectionHasNoRightPlane() { + let indices = OutputChannelSelection.mono(channel: 11).planeIndices(deviceChannels: 32) + XCTAssertEqual(indices.left, 10) + XCTAssertNil(indices.right) + } + + /// The interface was swapped for a smaller one (or unplugged and replaced by + /// the built-in speakers): the routing must fall back to the first pair, not + /// address a plane that doesn't exist. + func testOutOfRangeSelectionFallsBackToFirstPair() { + let stereo = OutputChannelSelection.stereoPair(first: 11, second: 12).planeIndices(deviceChannels: 2) + XCTAssertEqual(stereo.left, 0) + XCTAssertEqual(stereo.right, 1) + + let mono = OutputChannelSelection.mono(channel: 30).planeIndices(deviceChannels: 2) + XCTAssertEqual(mono.left, 0) + XCTAssertEqual(mono.right, 1) + } + + func testNoChannelsIsSafe() { + let indices = OutputChannelSelection.stereoPair(first: 5, second: 6).planeIndices(deviceChannels: 0) + XCTAssertEqual(indices.left, 0) + XCTAssertNil(indices.right) + } +} + +final class OutputChannelOptionListTests: XCTestCase { + + func testOptionsForEightChannelDevice() { + let options = InputAudioDeviceResolver.availableOutputChannelOptions(channelCount: 8) + // Auto + 8 mono + 4 odd/even pairs. + XCTAssertEqual(options.count, 13) + XCTAssertEqual(options.first?.selection, .auto) + XCTAssertTrue(options.contains { $0.selection == .mono(channel: 8) }) + XCTAssertTrue(options.contains { $0.selection == .stereoPair(first: 5, second: 6) }) + // Odd-start pairs only — 2/3 is not how interfaces pair their outputs. + XCTAssertFalse(options.contains { $0.selection == .stereoPair(first: 2, second: 3) }) + } + + func testOptionIdentifiersAreUnique() { + let options = InputAudioDeviceResolver.availableOutputChannelOptions(channelCount: 32) + XCTAssertEqual(Set(options.map(\.id)).count, options.count) + } + + func testZeroChannelDeviceOffersAutoOnly() { + let options = InputAudioDeviceResolver.availableOutputChannelOptions(channelCount: 0) + XCTAssertEqual(options.count, 1) + XCTAssertEqual(options.first?.selection, .auto) + } + + func testContainsRejectsSelectionsThatOutgrewTheDevice() { + XCTAssertTrue(InputAudioDeviceResolver.contains(.stereoPair(first: 5, second: 6), channelCount: 8)) + XCTAssertFalse(InputAudioDeviceResolver.contains(.stereoPair(first: 5, second: 6), channelCount: 4)) + XCTAssertFalse(InputAudioDeviceResolver.contains(.mono(channel: 9), channelCount: 8)) + XCTAssertTrue(InputAudioDeviceResolver.contains(.auto, channelCount: 0)) + } +} + +/// Regression test for the bug that made the picker never appear: the audio +/// store re-resolved the output device from inside the `rootStore.$preferences` +/// sink, but `@Published` fires on *willSet*, so `rootStore.preferences` there +/// still held the device the user had just switched AWAY from. Selecting a +/// 24-channel interface resolved the previous stereo device and hid the picker. +/// +/// Uses an isolated UserDefaults suite — it must never touch real preferences. +@MainActor +final class OutputChannelPickerVisibilityTests: XCTestCase { + + func testPickerAppearsWhenSwitchingToMultiChannelDevice() throws { + let outputs = InputAudioDeviceResolver.availableOutputDevices() + guard let multiChannel = outputs.first(where: { $0.outputChannels > 2 }) else { + throw XCTSkip("No output device with more than 2 channels on this machine") + } + + let defaults = try XCTUnwrap(UserDefaults(suiteName: "ttaccessible.tests.outputChannels")) + defaults.removePersistentDomain(forName: "ttaccessible.tests.outputChannels") + let root = AppPreferencesStore(userDefaults: defaults) + + if let stereo = outputs.first(where: { $0.outputChannels <= 2 }) { + root.updatePreferredOutputDevice( + AudioDevicePreference(persistentID: stereo.uid, displayName: stereo.name) + ) + } + + let controller = TeamTalkConnectionController( + preferencesStore: root, + passwordStore: ServerPasswordStore() + ) + let advanced = AdvancedMicrophoneSettingsStore( + preferencesStore: root, + connectionController: controller + ) + let audio = root.makeAudioStore( + connectionController: controller, + advancedSettingsStore: advanced + ) + audio.prepareIfNeeded() + + // Switching devices goes through the root store, which is what feeds the + // sink — the exact path that was reading a stale preference. + root.updatePreferredOutputDevice( + AudioDevicePreference(persistentID: multiChannel.uid, displayName: multiChannel.name) + ) + + XCTAssertTrue(audio.offersOutputChannelSelection, + "picker stayed hidden for a \(multiChannel.outputChannels)-channel device") + XCTAssertFalse(audio.outputChannelOptions.isEmpty) + XCTAssertEqual(audio.outputDeviceInfo?.uid, multiChannel.uid) + + defaults.removePersistentDomain(forName: "ttaccessible.tests.outputChannels") + } +} + +final class OutputChannelSelectionCodableTests: XCTestCase { + + func testRoundTripsThroughPreferences() throws { + var preferences = AppPreferences() + preferences.outputChannelSelections = [ + "uid-desk": .stereoPair(first: 5, second: 6), + "uid-mono": .mono(channel: 11), + ] + preferences.deviceStreamChannelPresets = ["uid-desk": .monoMix(first: 3, second: 4)] + + let data = try JSONEncoder().encode(preferences) + let decoded = try JSONDecoder().decode(AppPreferences.self, from: data) + + XCTAssertEqual(decoded.outputChannelSelections["uid-desk"], .stereoPair(first: 5, second: 6)) + XCTAssertEqual(decoded.outputChannelSelections["uid-mono"], .mono(channel: 11)) + XCTAssertEqual(decoded.deviceStreamChannelPresets["uid-desk"], .monoMix(first: 3, second: 4)) + } + + /// Preferences written before this feature existed must still decode. + func testLegacyPreferencesWithoutChannelRoutingDecode() throws { + let json = Data(#"{"defaultNickname":"tester"}"#.utf8) + let decoded = try JSONDecoder().decode(AppPreferences.self, from: json) + XCTAssertTrue(decoded.outputChannelSelections.isEmpty) + XCTAssertTrue(decoded.deviceStreamChannelPresets.isEmpty) + } +}