diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index 82958475..e3c9a525 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -609,7 +609,8 @@ final class SessionModel: ObservableObject { speakerUID: settings.speakerUID, micUID: settings.micUID, micChannel: settings.micChannel, - micEnabled: settings.micEnabled) + micEnabled: settings.micEnabled, + echoCancel: settings.echoCancel) self.audio = audio // Gamepads: forward every controller GamepadManager selected — each on its own wire pad // index (a pin forwards only one, Automatic forwards all) — and render the host's feedback diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index 3153dafe..db89e264 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -32,6 +32,7 @@ struct GamepadSettingsView: View { @AppStorage(DefaultsKey.enable444) private var enable444 = false @AppStorage(DefaultsKey.codec) private var codec = "auto" @AppStorage(DefaultsKey.micEnabled) private var micEnabled = true + @AppStorage(DefaultsKey.echoCancel) private var echoCancel = true // The overlay tier's raw string (rows tag by rawValue); the absent-key default runs the // legacy-hudEnabled migration (same pattern as ContentView/SettingsView). @AppStorage(DefaultsKey.statsVerbosity) private var statsVerbosityRaw @@ -316,6 +317,11 @@ struct GamepadSettingsView: View { id: "mic", icon: "mic", label: "Microphone", detail: "Send this device's microphone to the host's virtual mic.", value: $micEnabled), + toggleRow( + id: "echoCancel", icon: "waveform", label: "Echo cancellation", + detail: "Cancel the audio this device plays out of the mic signal — stops " + + "speaker setups feeding the game back to the host.", + value: $echoCancel), choiceRow( id: "pad", header: "Controller", icon: "gamecontroller", label: "Use controller", diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift index f259426e..771375ef 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Scope.swift @@ -98,6 +98,10 @@ enum SettingsFields { .init(name: "mic_enabled", key: DefaultsKey.micEnabled, overlay: \.micEnabled, effective: \.micEnabled) } + static var echoCancel: SettingsField { + .init(name: "echo_cancel", key: DefaultsKey.echoCancel, + overlay: \.echoCancel, effective: \.echoCancel) + } static var touchMode: SettingsField { .init(name: "touch_mode", key: DefaultsKey.touchMode, overlay: \.touchMode, effective: \.touchMode) @@ -175,6 +179,7 @@ extension SettingsView { base.compositor = compositor base.audioChannels = audioChannels base.micEnabled = micEnabled + base.echoCancel = echoCancel base.gamepadType = gamepadType base.statsVerbosity = statsVerbosityRaw base.fullscreenWhileStreaming = fullscreenWhileStreaming diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift index 9bdcde38..76bafd68 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView+Sections.swift @@ -581,6 +581,10 @@ extension SettingsView { field: "mic_enabled") { Toggle("Send microphone to the host", isOn: scoped(SettingsFields.micEnabled)) } + described(echoCancelCaption, field: "echo_cancel") { + Toggle("Echo cancellation", isOn: scoped(SettingsFields.echoCancel)) + .disabled(!effective.micEnabled) + } #if os(macOS) if !inProfileScope { Picker("Microphone", selection: $micUID) { @@ -619,6 +623,20 @@ extension SettingsView { } } + /// Honest about the macOS escape hatch: the voice processor only follows the system + /// default devices, so hand-picked endpoints silently keep the raw path (see + /// SessionAudio's topology note) — better said here than discovered mid-call. + private var echoCancelCaption: String { + let base = "Voice processing cancels the audio this device plays out of the mic " + + "signal, so a speaker setup doesn't feed the game back to the host." + #if os(macOS) + return base + " Follows the system default devices — a hand-picked speaker, " + + "microphone or input channel streams the raw mic instead." + #else + return base + #endif + } + // MARK: - Controllers @ViewBuilder var controllersSection: some View { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift index 303fb618..eaa60ed0 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/SettingsView.swift @@ -65,6 +65,7 @@ struct SettingsView: View { @AppStorage(DefaultsKey.libraryEnabled) var libraryEnabled = true @AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true @AppStorage(DefaultsKey.micEnabled) var micEnabled = true + @AppStorage(DefaultsKey.echoCancel) var echoCancel = true @AppStorage(DefaultsKey.audioChannels) var audioChannels = 2 @AppStorage(DefaultsKey.codec) var codec = "auto" // The overlay tier's raw string (the pickers tag by rawValue); the absent-key default runs diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index bd228f7f..a5495671 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -5,15 +5,22 @@ // AVAudioSourceNode pulls from the ring (silence on underrun with re-priming, so a // network gap costs one dip, not permanent crackle). // -// mic → host: a second AVAudioEngine taps the input device, folds it to one mono bus (the -// chosen channel of a multi-channel interface, or a sum of all channels), resamples to 48 kHz -// mono, slices 10 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them -// into a virtual PipeWire source. +// mic → host: a tap on the input node folds the capture to one mono bus (the chosen channel +// of a multi-channel interface, or a sum of all channels), resamples to 48 kHz mono, slices +// 10 ms chunks, Opus-encodes, and sendMic()s each packet — the host feeds them into a +// virtual PipeWire source. +// +// Engine topology. With the mic enabled and echo cancellation on (both defaults), BOTH +// directions run on ONE AVAudioEngine with the system voice processor engaged +// (`setVoiceProcessingEnabled`) — AEC needs render and capture on the same unit so it can +// subtract what the speaker is playing from what the mic hears; without it, a loudspeaker +// client feeds the host's own game audio straight back to it (the primary reported echo +// source). The voice processor can only follow the system DEFAULT devices, so explicit +// endpoint choices fall back to the old two-engine topology — see `wantsCombined` for the +// exact decision, and `startCapture` for why two engines handle arbitrary device pairs. // // Devices are chosen by UID ("" = system default: the engine is then never pinned to a -// concrete device and follows default-device changes). Two engines, not one — a single -// AVAudioEngine ties input+output to one aggregate clock, separate engines keep -// arbitrary mic/speaker combinations trivial. +// concrete device and follows default-device changes). import AVFoundation import os @@ -40,7 +47,15 @@ public final class SessionAudio { private let stateLock = NSLock() private var playbackEngine: AVAudioEngine? private var captureEngine: AVAudioEngine? + /// The one engine running BOTH directions when the voice processor is engaged; + /// `playbackEngine`/`captureEngine` stay nil while this is set. + private var combinedEngine: AVAudioEngine? private var drainStarted = false + /// The playback jitter ring — created by whichever engine starts playback first and KEPT + /// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines, + /// not the ring, so the drain thread never has to be re-pointed). Main-thread confined, + /// like every start path. + private var ring: AudioRing? #if !os(macOS) /// AVAudioSession `setCategory`/`setActive` are synchronous and block on the audio server, so /// they must not run on the main thread (UI stall — AVFoundation warns about it). PROCESS-WIDE @@ -69,11 +84,15 @@ public final class SessionAudio { /// ASYNCHRONOUS: it activates the AVAudioSession off the main thread, then starts the engines on /// a later main-queue hop (gated by `!flag.isStopped`) — so playback is live shortly after, not /// on return. The mic may start later still if the permission prompt is pending. - public func start(speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool) { + /// `echoCancel` picks the engine topology — see the header note and `wantsCombined`. + public func start( + speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool + ) { #if os(macOS) // No AVAudioSession on macOS — start the engines directly (caller's thread, as before). startEngines( - speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, micEnabled: micEnabled) + speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, + micEnabled: micEnabled, echoCancel: echoCancel) #else // Configure + activate the session OFF the main thread (it blocks on the audio server), // then start the engines back on the main thread once it's active — engine routing/format @@ -85,7 +104,7 @@ public final class SessionAudio { guard let self, !self.flag.isStopped else { return } self.startEngines( speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, - micEnabled: micEnabled) + micEnabled: micEnabled, echoCancel: echoCancel) } } #endif @@ -123,32 +142,80 @@ public final class SessionAudio { } #endif - /// Build + start the playback engine (and the mic uplink when enabled + authorized). Main - /// thread (engine setup); on iOS/tvOS the session is already active by the time this runs. + /// Build + start the engines — combined (voice-processed) or split, per `wantsCombined` — + /// with the mic uplink only when enabled + authorized. Main thread (engine setup); on + /// iOS/tvOS the session is already active by the time this runs. private func startEngines( - speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool + speakerUID: String, micUID: String, micChannel: Int, micEnabled: Bool, echoCancel: Bool ) { - startPlayback(speakerUID: speakerUID) #if os(tvOS) // No app-accessible microphone input on tvOS — playback only. + startPlayback(speakerUID: speakerUID) #else - guard micEnabled else { return } + guard micEnabled else { + startPlayback(speakerUID: speakerUID) + return + } + let combined = wantsCombined( + speakerUID: speakerUID, micUID: micUID, micChannel: micChannel, + echoCancel: echoCancel) switch AVCaptureDevice.authorizationStatus(for: .audio) { case .authorized: - startCapture(micUID: micUID, micChannel: micChannel) + if combined { + startCombined(speakerUID: speakerUID, micUID: micUID, micChannel: micChannel) + } else { + startPlayback(speakerUID: speakerUID) + startCapture(micUID: micUID, micChannel: micChannel) + } case .notDetermined: + // Playback must not wait out the permission prompt (the user answers at their + // leisure) — start it now, and on a grant either bolt the capture engine on + // (split) or swap the playback engine for the combined one (the ring and its + // drain thread carry over — see `makePlaybackChain`). + startPlayback(speakerUID: speakerUID) AVCaptureDevice.requestAccess(for: .audio) { [weak self] granted in DispatchQueue.main.async { guard let self, granted, !self.flag.isStopped else { return } - self.startCapture(micUID: micUID, micChannel: micChannel) + if combined { + self.stateLock.lock() + let playback = self.playbackEngine + self.playbackEngine = nil + self.stateLock.unlock() + playback?.stop() + self.startCombined( + speakerUID: speakerUID, micUID: micUID, micChannel: micChannel) + } else { + self.startCapture(micUID: micUID, micChannel: micChannel) + } } } default: + startPlayback(speakerUID: speakerUID) log.warning("microphone access denied — mic uplink disabled (System Settings → Privacy)") } #endif } + #if !os(tvOS) + /// One engine or two: the voice processor requires render + capture on one unit, and that + /// unit can only follow the system DEFAULT devices — so echo cancellation gets the combined + /// engine only while nothing is explicitly pinned. On macOS a chosen speaker/mic UID or a + /// picked input channel (the voice processor's capture side is its own mono mix — a + /// per-channel pick can't survive it) keeps today's two-engine path, AEC-less but honoring + /// the exact endpoints the user named. On iOS routes are session-managed and the UIDs are + /// ignored, so the toggle alone decides. + private func wantsCombined( + speakerUID: String, micUID: String, micChannel: Int, echoCancel: Bool + ) -> Bool { + guard echoCancel else { return false } + #if os(macOS) + return speakerUID.isEmpty && micUID.isEmpty && micChannel == 0 + #else + return true + #endif + } + #endif + /// Stop both directions. Safe from any thread; waits the drain thread out (≤ its /// poll timeout) so the caller can close the connection right after. public func stop() { @@ -158,6 +225,8 @@ public final class SessionAudio { captureEngine = nil let playback = playbackEngine playbackEngine = nil + let combined = combinedEngine + combinedEngine = nil let wasDraining = drainStarted drainStarted = false stateLock.unlock() @@ -166,6 +235,10 @@ public final class SessionAudio { capture.stop() } playback?.stop() + if let combined { + combined.inputNode.removeTap(onBus: 0) + combined.stop() + } #if !os(macOS) // Release the session so audio we interrupted (Music, podcasts) gets its resume cue. Like // activation, setActive is synchronous/blocking — run it on the shared serial session queue @@ -187,14 +260,22 @@ public final class SessionAudio { } /// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio - /// leaves the device) and restore it on return. Pauses/resumes the capture engine; a no-op when - /// there's no uplink (playback-only / tvOS / mic disabled). The audio SESSION stays active for - /// background playback, so iOS may keep showing the recording indicator until a full reconfigure - /// — this stops the actual capture, which is the privacy-relevant part. Main thread. + /// leaves the device) and restore it on return. Two-engine sessions pause/resume the capture + /// engine; a combined session instead mutes the voice processor's input (playback shares that + /// engine and must keep running, so the engine itself never pauses — the mute zeroes the mic + /// at the IO unit, and the tap encodes silence). A no-op when there's no uplink (playback-only + /// / tvOS / mic disabled). The audio SESSION stays active for background playback, so iOS may + /// keep showing the recording indicator until a full reconfigure — either path stops room + /// audio leaving the device, which is the privacy-relevant part. Main thread. public func setMicMuted(_ muted: Bool) { stateLock.lock() let capture = captureEngine + let combined = combinedEngine stateLock.unlock() + if let combined { + combined.inputNode.isVoiceProcessingInputMuted = muted + return + } guard let capture else { return } if muted { capture.pause() @@ -205,28 +286,21 @@ public final class SessionAudio { // MARK: - Playback (host → speaker) - private func startPlayback(speakerUID: String) { + /// The playback jitter ring + the source node draining it — shared by the plain playback + /// engine and the combined voice-processing engine, and REUSED across an engine rebuild + /// (same session, same ring: the drain thread keeps writing right through the swap). nil + /// when the host's channel layout can't be expressed (already logged). Main thread. + private func makePlaybackChain() + -> (ring: AudioRing, source: AVAudioSourceNode, format: AVAudioFormat)? + { // Build the playback layout from the host-RESOLVED channel count (never the request): // 2 = stereo / 6 = 5.1 / 8 = 7.1, canonical wire order FL FR FC LFE RL RR SL SR. let channels = Int(connection.resolvedAudioChannels) // 1 s interleaved capacity, ~20 ms prefill (four 5 ms host packets of jitter absorption // before the first sample plays), both scaled by the channel count. - let ring = AudioRing( + let ring = self.ring ?? AudioRing( capacity: 48_000 * channels, prefill: 960 * channels, channels: channels) - - let engine = AVAudioEngine() - #if os(macOS) - if !speakerUID.isEmpty { - if let dev = AudioDevices.deviceID(forUID: speakerUID), - let unit = engine.outputNode.audioUnit { - if !Self.setDevice(dev, on: unit) { - log.error("could not select speaker \(speakerUID) — using default") - } - } else { - log.warning("speaker \(speakerUID) not present — using default") - } - } - #endif + self.ring = ring // Engine-native deinterleaved float; the render block deinterleaves from the ring. Surround // uses an explicit wire-order channel layout; the mixer downmixes to the output device when @@ -240,7 +314,7 @@ public final class SessionAudio { } guard let format else { log.error("could not build \(channels)-channel audio format — audio disabled") - return + return nil } let scratch = ScratchBuffer() // block-owned; freed with the closure let source = AVAudioSourceNode(format: format) { _, _, frameCount, abl -> OSStatus in @@ -258,6 +332,24 @@ public final class SessionAudio { } return noErr } + return (ring, source, format) + } + + private func startPlayback(speakerUID: String) { + guard let (ring, source, format) = makePlaybackChain() else { return } + let engine = AVAudioEngine() + #if os(macOS) + if !speakerUID.isEmpty { + if let dev = AudioDevices.deviceID(forUID: speakerUID), + let unit = engine.outputNode.audioUnit { + if !Self.setDevice(dev, on: unit) { + log.error("could not select speaker \(speakerUID) — using default") + } + } else { + log.warning("speaker \(speakerUID) not present — using default") + } + } + #endif engine.attach(source) engine.connect(source, to: engine.mainMixerNode, format: format) engine.prepare() @@ -278,8 +370,14 @@ public final class SessionAudio { startDrain(into: ring) } + /// Idempotent — the permission-grant engine swap reaches here a second time with the + /// drain thread already feeding the (carried-over) ring. private func startDrain(into ring: AudioRing) { stateLock.lock() + if drainStarted { + stateLock.unlock() + return + } drainStarted = true stateLock.unlock() let thread = Thread { [connection, flag, drainDone] in @@ -314,6 +412,78 @@ public final class SessionAudio { // MARK: - Mic (mic → host) #if !os(tvOS) + /// One engine, both directions: engage the system voice processor on the shared IO unit + /// (AEC + noise suppression + AGC), hang the playback source off its render side and the + /// mic tap off its capture side. Every failure falls back to a WORKING configuration — + /// the split path (no AEC) when the voice processor won't engage, plain playback when the + /// mic chain can't be built — a session never loses audio to the echo-cancel feature. + private func startCombined(speakerUID: String, micUID: String, micChannel: Int) { + let engine = AVAudioEngine() + let input = engine.inputNode + do { + // Before anything reads the input's format: the voice processor changes it (often + // to its own mono mix, sometimes at a lower rate) — installMicTap reads the format + // AFTER this, so the converter chain adapts to whatever the processor emits. + try input.setVoiceProcessingEnabled(true) + } catch { + log.warning(""" + voice processing unavailable (\(error.localizedDescription)) — separate \ + engines, no echo cancellation + """) + startPlayback(speakerUID: speakerUID) + startCapture(micUID: micUID, micChannel: micChannel) + return + } + // Symmetric enable for the render side; with both directions on one engine the + // input-node enable already covers it, so a refusal here is not a failure. + try? engine.outputNode.setVoiceProcessingEnabled(true) + // This is a game stream, not a call: never duck the host's audio under the outgoing + // voice. .min is the closest to "off" the API offers, and advanced (selective) + // ducking stays off with it. + input.voiceProcessingOtherAudioDuckingConfiguration = .init( + enableAdvancedDucking: false, duckingLevel: .min) + + guard let (ring, source, format) = makePlaybackChain() else { + // Playback impossible (logged) — keep the uplink alive, as the split path would. + startCapture(micUID: micUID, micChannel: micChannel) + return + } + engine.attach(source) + engine.connect(source, to: engine.mainMixerNode, format: format) + guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { + // Mic chain unavailable (logged) — keep the session audible on the plain playback + // engine rather than playing through an idle voice processor. + startPlayback(speakerUID: speakerUID) + return + } + engine.prepare() + do { + try engine.start() + } catch { + log.error("combined engine failed to start: \(error.localizedDescription)") + input.removeTap(onBus: 0) + startPlayback(speakerUID: speakerUID) // no echo cancellation beats no audio + return + } + stateLock.lock() + if flag.isStopped { + stateLock.unlock() + input.removeTap(onBus: 0) + engine.stop() // stop() already ran — don't strand a started engine (or a hot mic) + return + } + combinedEngine = engine + stateLock.unlock() + startDrain(into: ring) + log.info("audio engines joined — voice processing (echo cancellation) active") + } + + /// The split path: capture on its OWN engine, playback on another — the pre-echo-cancel + /// topology, kept verbatim. Two engines, not one — a single AVAudioEngine ties + /// input+output to one aggregate clock, separate engines keep arbitrary mic/speaker + /// combinations trivial. That freedom is exactly why the voice processor can't ride this + /// path (AEC needs both directions on one unit) and why explicitly pinned endpoints land + /// here — see `wantsCombined`. private func startCapture(micUID: String, micChannel: Int) { let engine = AVAudioEngine() let input = engine.inputNode @@ -328,11 +498,42 @@ public final class SessionAudio { } } #endif + guard installMicTap(on: input, micUID: micUID, micChannel: micChannel) else { return } + engine.prepare() + do { + try engine.start() + } catch { + log.error("capture engine failed to start: \(error.localizedDescription)") + input.removeTap(onBus: 0) + return + } + stateLock.lock() + if flag.isStopped { + // stop() ran while we were starting (the permission prompt resolves at the + // user's leisure) — tear the engine down ourselves, nobody else owns it now. + stateLock.unlock() + input.removeTap(onBus: 0) + engine.stop() + return + } + captureEngine = engine + stateLock.unlock() + log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))") + } + /// Resolve the input's live format + fold plan, build the mono→Opus chain, and install the + /// capture tap on `input` — everything mic except engine ownership, shared verbatim by the + /// combined and split topologies. Reads `input.outputFormat(forBus:)` at call time, so the + /// chain follows whatever the node emits: the raw device format, or the voice processor's + /// own mix when that's enabled. False (logged) when no input is usable or the encoder + /// can't be built; the tap is installed on true. + private func installMicTap( + on input: AVAudioInputNode, micUID: String, micChannel: Int + ) -> Bool { let inFormat = input.outputFormat(forBus: 0) guard inFormat.sampleRate > 0, inFormat.channelCount > 0 else { log.error("no usable input device — mic uplink disabled") - return + return false } // Multi-channel-interface handling. A pro interface exposes N discrete inputs with the mic @@ -409,7 +610,7 @@ public final class SessionAudio { pcmFormat: encoder.pcmFormat, frameCapacity: stagingCapacity(scratchFrames)) else { log.error("Opus encoder unavailable — mic uplink disabled") - return + return false } // Tap-thread-confined state: fold into `mono`, resample into `staging`, accumulate in @@ -521,27 +722,7 @@ public final class SessionAudio { } if head > 0 { fifo.removeFirst(head) } // keeps capacity — no realloc } - - engine.prepare() - do { - try engine.start() - } catch { - log.error("capture engine failed to start: \(error.localizedDescription)") - input.removeTap(onBus: 0) - return - } - stateLock.lock() - if flag.isStopped { - // stop() ran while we were starting (the permission prompt resolves at the - // user's leisure) — tear the engine down ourselves, nobody else owns it now. - stateLock.unlock() - input.removeTap(onBus: 0) - engine.stop() - return - } - captureEngine = engine - stateLock.unlock() - log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))") + return true } /// Fold `channels` of input (`floatChannelData` layout: `interleaved` → one buffer strided by diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index b4210b0c..8ede87ab 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -42,6 +42,13 @@ public enum DefaultsKey { /// falls back. Drives the decoder via `Welcome.codec`. public static let codec = "punktfunk.codec" public static let micEnabled = "punktfunk.micEnabled" + /// Echo cancellation for the mic uplink (on by default): playback + capture share ONE + /// audio engine so the system voice processor can subtract what this device is playing + /// from what its mic hears — without it a loudspeaker client feeds the game audio straight + /// back to the host. Off = the raw two-engine capture path. macOS: an explicitly pinned + /// speaker/mic or mic channel also bypasses it (the voice processor only follows the + /// system default devices) — see SessionAudio's topology note. + public static let echoCancel = "punktfunk.echoCancel" public static let speakerUID = "punktfunk.speakerUID" public static let micUID = "punktfunk.micUID" /// macOS: which input channel of the chosen mic device feeds the host. 0 = "Auto" (sum every diff --git a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift index 4797f10c..c3577be5 100644 --- a/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift +++ b/clients/apple/Sources/PunktfunkShared/EffectiveSettings.swift @@ -29,6 +29,7 @@ public struct EffectiveSettings: Equatable, Sendable { public var compositor = 0 public var audioChannels = 2 public var micEnabled = true + public var echoCancel = true public var touchMode = "trackpad" public var mouseMode = "capture" public var invertScroll = false @@ -87,6 +88,7 @@ public struct EffectiveSettings: Equatable, Sendable { compositor = int(DefaultsKey.compositor, compositor) audioChannels = int(DefaultsKey.audioChannels, audioChannels) micEnabled = bool(DefaultsKey.micEnabled, micEnabled) + echoCancel = bool(DefaultsKey.echoCancel, echoCancel) touchMode = str(DefaultsKey.touchMode, touchMode) mouseMode = str(DefaultsKey.mouseMode, mouseMode) invertScroll = bool(DefaultsKey.invertScroll, invertScroll) @@ -133,6 +135,7 @@ public struct EffectiveSettings: Equatable, Sendable { if let v = overlay.compositor { s.compositor = v } if let v = overlay.audioChannels { s.audioChannels = v } if let v = overlay.micEnabled { s.micEnabled = v } + if let v = overlay.echoCancel { s.echoCancel = v } if let v = overlay.touchMode { s.touchMode = v } if let v = overlay.mouseMode { s.mouseMode = v } if let v = overlay.invertScroll { s.invertScroll = v } diff --git a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift index 41cf9615..b4d903a9 100644 --- a/clients/apple/Sources/PunktfunkShared/StreamProfile.swift +++ b/clients/apple/Sources/PunktfunkShared/StreamProfile.swift @@ -105,6 +105,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { public var compositor: Int? public var audioChannels: Int? public var micEnabled: Bool? + public var echoCancel: Bool? public var touchMode: String? public var mouseMode: String? public var invertScroll: Bool? @@ -145,6 +146,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { case compositor case audioChannels = "audio_channels" case micEnabled = "mic_enabled" + case echoCancel = "echo_cancel" case touchMode = "touch_mode" case mouseMode = "mouse_mode" case invertScroll = "invert_scroll" @@ -177,6 +179,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { compositor = int(.compositor) audioChannels = int(.audioChannels) micEnabled = bool(.micEnabled) + echoCancel = bool(.echoCancel) touchMode = str(.touchMode) mouseMode = str(.mouseMode) invertScroll = bool(.invertScroll) @@ -211,6 +214,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable { try c.encodeIfPresent(compositor, forKey: AnyKey(Key.compositor.rawValue)) try c.encodeIfPresent(audioChannels, forKey: AnyKey(Key.audioChannels.rawValue)) try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue)) + try c.encodeIfPresent(echoCancel, forKey: AnyKey(Key.echoCancel.rawValue)) try c.encodeIfPresent(touchMode, forKey: AnyKey(Key.touchMode.rawValue)) try c.encodeIfPresent(mouseMode, forKey: AnyKey(Key.mouseMode.rawValue)) try c.encodeIfPresent(invertScroll, forKey: AnyKey(Key.invertScroll.rawValue)) @@ -262,6 +266,7 @@ public enum OverlayField { case "compositor": overlay.compositor = nil case "audio_channels": overlay.audioChannels = nil case "mic_enabled": overlay.micEnabled = nil + case "echo_cancel": overlay.echoCancel = nil case "touch_mode": overlay.touchMode = nil case "mouse_mode": overlay.mouseMode = nil case "invert_scroll": overlay.invertScroll = nil @@ -296,6 +301,7 @@ public enum OverlayField { case "compositor": return o.compositor != nil case "audio_channels": return o.audioChannels != nil case "mic_enabled": return o.micEnabled != nil + case "echo_cancel": return o.echoCancel != nil case "touch_mode": return o.touchMode != nil case "mouse_mode": return o.mouseMode != nil case "invert_scroll": return o.invertScroll != nil