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) + } +}