Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions App/ttaccessible/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
112 changes: 102 additions & 10 deletions App/ttaccessible/AppKit/MediaStreamSourceViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,40 @@ 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]
private let voiceOverAvailable: Bool
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.
private var browsedApplication: DeviceStreamCaptureSpec?
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
Expand All @@ -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)
}

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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([
Expand All @@ -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),

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
26 changes: 18 additions & 8 deletions App/ttaccessible/AudioRTSupport.h
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand All @@ -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;
Expand Down
17 changes: 17 additions & 0 deletions App/ttaccessible/Models/AppPreferences.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ struct AppPreferences: Codable, Equatable {
case preferredOutputDevice
case advancedInputAudioProfiles
case advancedInputAudio
case outputChannelSelections
case deviceStreamChannelPresets
case voiceOverAnnouncements
case inputGainDB
case outputGainDB
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading