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
8 changes: 8 additions & 0 deletions FineTune/Audio/Engine/AppListCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ final class AppListCoordinator {
settingsManager.setEQSettings(settings, for: identifier)
}

func getMonoDownmixForInactive(identifier: String) -> Bool {
settingsManager.getMonoDownmix(for: identifier) ?? false
}

func setMonoDownmixForInactive(identifier: String, to enabled: Bool) {
settingsManager.setMonoDownmix(for: identifier, to: enabled)
}

func getDeviceRoutingForInactive(identifier: String) -> String? {
settingsManager.getDeviceRouting(for: identifier)
}
Expand Down
27 changes: 26 additions & 1 deletion FineTune/Audio/Engine/AudioEngine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -628,6 +628,7 @@ final class AudioEngine {
tap.updateEQSettings(.flat)
tap.updateAutoEQProfile(nil)
tap.updateLoudnessCompensation(volume: effectiveLoudnessVolume(for: tap), enabled: false)
tap.setMonoDownmix(false)
}

// 6. Re-apply from clean settings (re-establishes routing to system default)
Expand Down Expand Up @@ -756,6 +757,28 @@ final class AudioEngine {
return settingsManager.getEQSettings(for: app.persistenceIdentifier)
}

// MARK: - Per-App Mono Downmix

/// Enables or disables mono downmix for an app: its tapped stereo is summed
/// to a single channel and duplicated to both left and right. Other apps keep
/// their normal stereo output.
func setMonoDownmix(_ enabled: Bool, for app: AudioApp) {
volumeState.setMonoDownmix(for: app.id, to: enabled, identifier: app.persistenceIdentifier)
taps[app.id]?.setMonoDownmix(enabled)
}

func isMonoDownmix(for app: AudioApp) -> Bool {
volumeState.getMonoDownmix(for: app.id)
}

func getMonoDownmixForInactive(identifier: String) -> Bool {
appListCoordinator.getMonoDownmixForInactive(identifier: identifier)
}

func setMonoDownmixForInactive(identifier: String, to enabled: Bool) {
appListCoordinator.setMonoDownmixForInactive(identifier: identifier, to: enabled)
}

// MARK: - Per-Device AutoEQ

func getAutoEQProfile(for deviceUID: String) -> AutoEQProfile? {
Expand Down Expand Up @@ -832,7 +855,8 @@ final class AudioEngine {
autoEQPreampEnabled: settingsManager.autoEQPreampEnabled,
loudnessVolume: deviceVolume * volumeState.getVolume(for: app.id),
loudnessCompensationEnabled: settingsManager.appSettings.loudnessCompensationEnabled,
loudnessEqualizerSettings: loudnessEqSettings
loudnessEqualizerSettings: loudnessEqSettings,
monoDownmix: volumeState.getMonoDownmix(for: app.id)
)
}

Expand Down Expand Up @@ -1109,6 +1133,7 @@ final class AudioEngine {
let savedVolume = volumeState.loadSavedVolume(for: app.id, identifier: app.persistenceIdentifier)
let savedMute = volumeState.loadSavedMute(for: app.id, identifier: app.persistenceIdentifier)
_ = volumeState.loadSavedBoost(for: app.id, identifier: app.persistenceIdentifier)
_ = volumeState.loadSavedMonoDownmix(for: app.id, identifier: app.persistenceIdentifier)

// Handle multi-device mode
if mode == .multi {
Expand Down
43 changes: 39 additions & 4 deletions FineTune/Audio/Engine/ProcessTapController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ final class ProcessTapController: ProcessTapControlling {
private nonisolated(unsafe) var _forceSilence: Bool = false
/// User-controlled mute - still tracks VU levels but outputs silence
private nonisolated(unsafe) var _isMuted: Bool = false
/// Per-app mono downmix - true means downmix this app's audio to a single
/// channel and duplicate it to both stereo channels. Other apps unaffected.
private nonisolated(unsafe) var _monoDownmix: Bool = false
// Device volume compensation removed — was dead code (always 1.0).
// If implementing, ensure both primary and secondary callbacks disable
// compensation during crossfade to avoid gain jumps (RT-013).
Expand Down Expand Up @@ -206,6 +209,11 @@ final class ProcessTapController: ProcessTapControlling {
set { _isMuted = newValue }
}

/// Enables or disables per-app mono downmix on the running audio thread.
func setMonoDownmix(_ enabled: Bool) {
_monoDownmix = enabled
}

// MARK: - Initialization

/// Initialize with multiple output devices for synchronized multi-device output.
Expand Down Expand Up @@ -655,6 +663,7 @@ final class ProcessTapController: ProcessTapControlling {
loudnessCompensator?.updateForVolume(initial.loudnessVolume)
}
_lastLoudnessVolume = initial.loudnessVolume
_monoDownmix = initial.monoDownmix

// Create IO proc with gain processing
nextCallbackID += 1
Expand Down Expand Up @@ -1328,7 +1337,8 @@ final class ProcessTapController: ProcessTapControlling {
eqProc: EQProcessor?,
autoEQProc: AutoEQProcessor?,
loudnessEqualizerProc: LoudnessEqualizer?,
loudnessCompensatorProc: LoudnessCompensator?
loudnessCompensatorProc: LoudnessCompensator?,
monoDownmix: Bool = false
) {
let inputBufferCount = inputBuffers.count
let outputBufferCount = outputBuffers.count
Expand Down Expand Up @@ -1376,7 +1386,31 @@ final class ProcessTapController: ProcessTapControlling {
let eq = eqProc // Parameter read — each callback passes its own processor
let eqCanProcessStereoInterleaved = (inputChannels == 2 && outputChannels == 2)

if inputChannels == outputChannels {
if monoDownmix {
// Per-app mono mode: downmix the source stereo pair to a single
// sample, then duplicate it across the preferred output pair.
// (all other output channels stay silent). Linear gains apply
// identically to the summed signal, so the volume ramp folds in.
for frame in 0..<frameCount {
currentVol += (targetVol - currentVol) * rampCoefficient
let gain = currentVol * crossfadeMultiplier * outputGateMultiplier
let inBase = frame * inputChannels
let mono = inputChannels == 1
? inputSamples[inBase] * gain
: (inputSamples[inBase] + inputSamples[inBase + 1]) * 0.5 * gain

let outBase = frame * outputChannels
for ch in 0..<outputChannels {
outputSamples[outBase + ch] = 0
}
outputSamples[outBase + safeLeft] = mono
outputSamples[outBase + safeRight] = mono
}
let writtenSamples = frameCount * outputChannels
if writtenSamples < outputSampleCount {
memset(outputSamples.advanced(by: writtenSamples), 0, (outputSampleCount - writtenSamples) * MemoryLayout<Float>.size)
}
} else if inputChannels == outputChannels {
let sampleCount = frameCount * inputChannels
for frame in 0..<frameCount {
currentVol += (targetVol - currentVol) * rampCoefficient
Expand Down Expand Up @@ -1535,7 +1569,7 @@ final class ProcessTapController: ProcessTapControlling {
if totalSamplesThisBuffer == 0 {
totalSamplesThisBuffer = sampleCount / channels
}
for i in stride(from: 0, to: sampleCount, by: channels) {
for i in 0..<sampleCount {
let absSample = abs(inputSamples[i])
if absSample > maxPeak { maxPeak = absSample }
}
Expand Down Expand Up @@ -1630,7 +1664,8 @@ final class ProcessTapController: ProcessTapControlling {
eqProc: eqProc,
autoEQProc: autoEQProc,
loudnessEqualizerProc: loudnessEqualizerProc,
loudnessCompensatorProc: loudnessCompensatorProc
loudnessCompensatorProc: loudnessCompensatorProc,
monoDownmix: _monoDownmix
)

if isPrimary {
Expand Down
5 changes: 5 additions & 0 deletions FineTune/Audio/Engine/ProcessTapControlling.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ protocol ProcessTapControlling: AnyObject, Sendable {
func setAutoEQPreampEnabled(_ enabled: Bool)
func updateLoudnessCompensation(volume: Float, enabled: Bool)
func updateLoudnessEqualization(_ settings: LoudnessEqualizerSettings)
func setMonoDownmix(_ enabled: Bool)
func switchDevice(to newDeviceUID: String, preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws
func updateDevices(to newDeviceUIDs: [String], preferredTapSourceDeviceUID: String?, sourceDeviceDead: Bool) async throws
func hasRecentAudioCallback(within seconds: Double) -> Bool
Expand Down Expand Up @@ -63,4 +64,8 @@ extension ProcessTapControlling {
func recreateForOutputRateChange() async throws {
// Default no-op for mocks that don't override
}

func setMonoDownmix(_ enabled: Bool) {
// Default no-op for mocks that don't override
}
}
2 changes: 2 additions & 0 deletions FineTune/Audio/Engine/TapInitialState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,6 @@ struct TapInitialState {
var loudnessVolume: Float = 1.0
var loudnessCompensationEnabled: Bool = false
var loudnessEqualizerSettings: LoudnessEqualizerSettings = .init()
/// Per-app mono mode — downmix this app's audio and duplicate to both channels.
var monoDownmix: Bool = false
}
31 changes: 31 additions & 0 deletions FineTune/Models/VolumeState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ struct AppAudioState {
var muted: Bool
var persistenceIdentifier: String
var boost: BoostLevel = .x1
var monoDownmix: Bool = false
var deviceSelectionMode: DeviceSelectionMode = .single
var selectedDeviceUIDs: Set<String> = [] // Used in multi mode
}
Expand Down Expand Up @@ -90,6 +91,36 @@ final class VolumeState {
return nil
}

// MARK: - Mono Downmix

func getMonoDownmix(for pid: pid_t) -> Bool {
states[pid]?.monoDownmix ?? false
}

func setMonoDownmix(for pid: pid_t, to enabled: Bool, identifier: String? = nil) {
if var state = states[pid] {
state.monoDownmix = enabled
if let identifier = identifier {
state.persistenceIdentifier = identifier
}
states[pid] = state
settingsManager?.setMonoDownmix(for: state.persistenceIdentifier, to: enabled)
} else if let identifier = identifier {
let defaultVolume = settingsManager?.appSettings.defaultNewAppVolume ?? 1.0
var newState = AppAudioState(volume: defaultVolume, muted: false, persistenceIdentifier: identifier)
newState.monoDownmix = enabled
states[pid] = newState
settingsManager?.setMonoDownmix(for: identifier, to: enabled)
}
}

func loadSavedMonoDownmix(for pid: pid_t, identifier: String) -> Bool? {
ensureState(for: pid, identifier: identifier)
let saved = settingsManager?.getMonoDownmix(for: identifier)
states[pid]?.monoDownmix = saved ?? false
return saved
}

// MARK: - Mute State

func getMute(for pid: pid_t) -> Bool {
Expand Down
22 changes: 21 additions & 1 deletion FineTune/Settings/SettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ final class SettingsManager {
var appMutes: [String: Bool] = [:] // bundleID → isMuted
var appBoosts: [String: Float] = [:] // bundleID → boost rawValue (1.0, 2.0, 3.0, 4.0)
var appEQSettings: [String: EQSettings] = [:] // bundleID → EQ settings
var appMonoDownmix: [String: Bool] = [:] // bundleID → per-app mono downmix enabled
var appSettings: AppSettings = AppSettings() // App-wide settings
var systemSoundsFollowsDefault: Bool = true // Whether system sounds follows macOS default
var appDeviceSelectionMode: [String: DeviceSelectionMode] = [:] // bundleID → selection mode
Expand Down Expand Up @@ -153,6 +154,7 @@ final class SettingsManager {
appMutes = try c.decodeIfPresent([String: Bool].self, forKey: .appMutes) ?? [:]
appBoosts = try c.decodeIfPresent([String: Float].self, forKey: .appBoosts) ?? [:]
appEQSettings = try c.decodeIfPresent([String: EQSettings].self, forKey: .appEQSettings) ?? [:]
appMonoDownmix = try c.decodeIfPresent([String: Bool].self, forKey: .appMonoDownmix) ?? [:]
var decodedAppSettings = try c.decodeIfPresent(AppSettings.self, forKey: .appSettings) ?? AppSettings()
if !decodedAppSettings.defaultNewAppVolume.isFinite || decodedAppSettings.defaultNewAppVolume < 0 {
decodedAppSettings.defaultNewAppVolume = 1.0
Expand Down Expand Up @@ -270,6 +272,18 @@ final class SettingsManager {
scheduleSave()
}

// MARK: - Per-App Mono Downmix

/// Returns the persisted mono-downmix flag for an app, or nil when unset.
func getMonoDownmix(for identifier: String) -> Bool? {
settings.appMonoDownmix[identifier]
}

func setMonoDownmix(for identifier: String, to enabled: Bool) {
settings.appMonoDownmix[identifier] = enabled
scheduleSave()
}

// MARK: - Device Selection Mode

func getDeviceSelectionMode(for identifier: String) -> DeviceSelectionMode? {
Expand Down Expand Up @@ -350,6 +364,7 @@ final class SettingsManager {
settings.appMutes.removeValue(forKey: identifier)
settings.appDeviceRouting.removeValue(forKey: identifier)
settings.appEQSettings.removeValue(forKey: identifier)
settings.appMonoDownmix.removeValue(forKey: identifier)
settings.appDeviceSelectionMode.removeValue(forKey: identifier)
settings.appSelectedDeviceUIDs.removeValue(forKey: identifier)
scheduleSave()
Expand Down Expand Up @@ -644,6 +659,7 @@ final class SettingsManager {
.union(settings.appBoosts.keys)
.union(settings.appMutes.keys)
.union(settings.appEQSettings.keys)
.union(settings.appMonoDownmix.keys)
.union(settings.appDeviceSelectionMode.keys)
.union(settings.appSelectedDeviceUIDs.keys)

Expand All @@ -660,6 +676,7 @@ final class SettingsManager {
let volume = settings.appVolumes[identifier]
let mute = settings.appMutes[identifier]
let eq = settings.appEQSettings[identifier]
let monoDownmix = settings.appMonoDownmix[identifier]
let selectionMode = settings.appDeviceSelectionMode[identifier]
let selectedUIDs = settings.appSelectedDeviceUIDs[identifier]

Expand All @@ -669,11 +686,12 @@ final class SettingsManager {
let isDefaultBoost = boost == nil || boost == BoostLevel.x1.rawValue
let isDefaultMute = mute == nil || mute == false
let isDefaultEQ = eq == nil || eq == .flat
let isDefaultMonoDownmix = monoDownmix == nil || monoDownmix == false
let isDefaultSelectionMode = selectionMode == nil
let isDefaultSelectedUIDs = selectedUIDs == nil || selectedUIDs?.isEmpty == true

guard isDefaultVolume && isDefaultBoost && isDefaultMute && isDefaultEQ
&& isDefaultSelectionMode && isDefaultSelectedUIDs else {
&& isDefaultMonoDownmix && isDefaultSelectionMode && isDefaultSelectedUIDs else {
continue
}

Expand All @@ -682,6 +700,7 @@ final class SettingsManager {
settings.appBoosts.removeValue(forKey: identifier)
settings.appMutes.removeValue(forKey: identifier)
settings.appEQSettings.removeValue(forKey: identifier)
settings.appMonoDownmix.removeValue(forKey: identifier)
settings.appDeviceSelectionMode.removeValue(forKey: identifier)
settings.appSelectedDeviceUIDs.removeValue(forKey: identifier)
pruned += 1
Expand Down Expand Up @@ -836,6 +855,7 @@ final class SettingsManager {
settings.appDeviceRouting.removeAll()
settings.appMutes.removeAll()
settings.appEQSettings.removeAll()
settings.appMonoDownmix.removeAll()
settings.pinnedApps.removeAll()
settings.pinnedAppInfo.removeAll()
settings.ignoredApps.removeAll()
Expand Down
8 changes: 8 additions & 0 deletions FineTune/Views/MenuBarPopupView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -923,6 +923,10 @@ struct MenuBarPopupView: View {
onEQToggle: {
toggleEQ(for: displayableApp.id, scrollProxy: scrollProxy)
},
isMonoDownmix: audioEngine.isMonoDownmix(for: app),
onMonoDownmixChange: { enabled in
audioEngine.setMonoDownmix(enabled, for: app)
},
isFocused: hasKeyboardEngaged && selectedRow == .app(persistenceID: displayableApp.id)
)
.id(PopupKeyboardNavModel.RowID.app(persistenceID: displayableApp.id))
Expand Down Expand Up @@ -991,6 +995,10 @@ struct MenuBarPopupView: View {
onEQToggle: {
toggleEQ(for: displayableApp.id, scrollProxy: scrollProxy)
},
isMonoDownmix: audioEngine.getMonoDownmixForInactive(identifier: identifier),
onMonoDownmixChange: { enabled in
audioEngine.setMonoDownmixForInactive(identifier: identifier, to: enabled)
},
isFocused: hasKeyboardEngaged && selectedRow == .app(persistenceID: displayableApp.id)
)
.id(PopupKeyboardNavModel.RowID.app(persistenceID: displayableApp.id))
Expand Down
8 changes: 8 additions & 0 deletions FineTune/Views/Rows/AppRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ struct AppRow: View {
let onRenameUserPreset: (UUID, String) -> Void
let isEQExpanded: Bool
let onEQToggle: () -> Void
let isMonoDownmix: Bool
let onMonoDownmixChange: (Bool) -> Void
let isFocused: Bool

@State private var isIconHovered = false
Expand Down Expand Up @@ -68,6 +70,8 @@ struct AppRow: View {
onRenameUserPreset: @escaping (UUID, String) -> Void = { _, _ in },
isEQExpanded: Bool = false,
onEQToggle: @escaping () -> Void = {},
isMonoDownmix: Bool = false,
onMonoDownmixChange: @escaping (Bool) -> Void = { _ in },
isFocused: Bool = false
) {
self.app = app
Expand Down Expand Up @@ -99,6 +103,8 @@ struct AppRow: View {
self.onRenameUserPreset = onRenameUserPreset
self.isEQExpanded = isEQExpanded
self.onEQToggle = onEQToggle
self.isMonoDownmix = isMonoDownmix
self.onMonoDownmixChange = onMonoDownmixChange
self.isFocused = isFocused
// Initialize local EQ state for reactive UI updates
self._localEQSettings = State(initialValue: eqSettings)
Expand Down Expand Up @@ -175,6 +181,8 @@ struct AppRow: View {
onDeviceModeChange: onDeviceModeChange,
onSelectFollowDefault: onSelectFollowDefault,
onEQToggle: onEQToggle,
isMonoDownmix: isMonoDownmix,
onMonoDownmixChange: onMonoDownmixChange,
isRowFocused: isFocused
)
}
Expand Down
Loading