Merge branch 'audio/apple-mic-mute' into audio/mic-latency-echo
This commit is contained in:
@@ -315,7 +315,15 @@ struct ContentView: View {
|
||||
clipboardAvailable: model.connection?.hostSupportsClipboard == true,
|
||||
clipboardOn: model.clipboardEnabled,
|
||||
toggleClipboard: { model.toggleClipboardSync() },
|
||||
micAvailable: model.micAvailable,
|
||||
micMuted: model.micMuted,
|
||||
toggleMicMute: { model.toggleMicMute() },
|
||||
disconnect: { model.disconnect() }))
|
||||
// ⌃⌥⇧A fired while input was CAPTURED (InputCapture's chord path posts it — the menu's
|
||||
// identical equivalent can't reach a captured stream). Same toggle either way.
|
||||
.onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in
|
||||
model.toggleMicMute()
|
||||
}
|
||||
#endif
|
||||
#if os(macOS)
|
||||
// Fullscreen only while a session is up (incl. the trust prompt over the blurred stream),
|
||||
@@ -724,31 +732,48 @@ struct ContentView: View {
|
||||
}
|
||||
.animation(.smooth(duration: 0.28), value: statsVerbosity)
|
||||
}
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the platform's
|
||||
// reserved controls on a glass pill, bottom-centre, for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are discoverable
|
||||
// even with statistics off. The banner's own task drops it (cancelled cleanly
|
||||
// if the session view goes away first). On tvOS it carries the ONLY exits —
|
||||
// Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS
|
||||
// session branch), so the hold gestures must be told to the user.
|
||||
// The bottom-centre stack: the muted-microphone badge over the start-of-stream
|
||||
// shortcut banner. ONE overlay for both, so the two can never land on top of each
|
||||
// other in the seconds where they overlap.
|
||||
.overlay(alignment: .bottom) {
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(Self.shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.padding(.bottom, 24)
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false }
|
||||
}
|
||||
VStack(spacing: 8) {
|
||||
#if !os(tvOS)
|
||||
// Shown for as long as the mic is muted, at every stats tier and with the
|
||||
// overlay off — see MicMutedBadge. tvOS has no microphone to mute.
|
||||
if captureEnabled && model.micMuted {
|
||||
MicMutedBadge { model.setMicMuted(false) }
|
||||
.transition(.opacity.combined(with: .scale(scale: 0.9)))
|
||||
}
|
||||
#endif
|
||||
#if os(macOS) || os(tvOS)
|
||||
// The start-of-stream shortcut banner (Windows-client parity): the
|
||||
// platform's reserved controls on a glass pill for the first 6 seconds of
|
||||
// every session — independent of the stats HUD, so the keys are
|
||||
// discoverable even with statistics off. The banner's own task drops it
|
||||
// (cancelled cleanly if the session view goes away first). On tvOS it
|
||||
// carries the ONLY exits — Menu/B is swallowed during a session (the
|
||||
// `.onExitCommand {}` in the tvOS session branch), so the hold gestures
|
||||
// must be told to the user.
|
||||
if captureEnabled && showShortcutHint {
|
||||
Text(shortcutHintText)
|
||||
.font(.geist(Self.shortcutHintFont, relativeTo: .caption))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
.glassBackground(Capsule())
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
try? await Task.sleep(for: .seconds(6))
|
||||
withAnimation(.easeOut(duration: 0.6)) {
|
||||
showShortcutHint = false
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.padding(.bottom, 24)
|
||||
.animation(.easeOut(duration: 0.2), value: model.micMuted)
|
||||
}
|
||||
#endif
|
||||
#if os(iOS)
|
||||
// Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on
|
||||
// screen — the overlay off, or the compact pill (which carries no button) —
|
||||
@@ -766,21 +791,24 @@ struct ContentView: View {
|
||||
.overlay(alignment: .topLeading) {
|
||||
if captureEnabled,
|
||||
statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) {
|
||||
Button { model.disconnect() } label: {
|
||||
Image(systemName: "xmark")
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
// Floating glass disc over the frame (26+, material fallback).
|
||||
// interactive: the disc IS the tap target, so the glass reacts
|
||||
// to press.
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
// Match the hit region to the visible disc so every tap also
|
||||
// triggers the interactive-glass press highlight.
|
||||
.contentShape(Circle())
|
||||
HStack(spacing: 10) {
|
||||
Button { model.disconnect() } label: { touchDisc("xmark") }
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel("Disconnect")
|
||||
// The mic toggle rides the same discs, for the same reason: in these
|
||||
// tiers the HUD carries no buttons (compact is a stat pill, off is
|
||||
// nothing), so this is a touch-only user's ONLY way to mute. Absent —
|
||||
// not greyed — when the session sends no microphone at all.
|
||||
if model.micAvailable {
|
||||
Button { model.toggleMicMute() } label: {
|
||||
touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill")
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.accessibilityLabel(
|
||||
model.micMuted ? "Unmute microphone" : "Mute microphone")
|
||||
}
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(12)
|
||||
.accessibilityLabel("Disconnect")
|
||||
.transition(.opacity)
|
||||
.task {
|
||||
guard statsVerbosity == .off else { return }
|
||||
@@ -794,14 +822,34 @@ struct ContentView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if os(iOS)
|
||||
/// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+,
|
||||
/// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap
|
||||
/// target, so the glass reacts to press, and the hit region is matched to the visible disc so
|
||||
/// every tap triggers that press highlight.
|
||||
private func touchDisc(_ symbol: String) -> some View {
|
||||
Image(systemName: symbol)
|
||||
.font(.headline.weight(.semibold))
|
||||
.frame(width: 36, height: 36)
|
||||
.glassBackground(Circle(), interactive: true)
|
||||
.contentShape(Circle())
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(macOS)
|
||||
private static let shortcutHintText =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
/// The reserved combos, told once per session. The mute segment appears only when the session
|
||||
/// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie.
|
||||
private var shortcutHintText: String {
|
||||
let base =
|
||||
"Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats"
|
||||
return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 12
|
||||
#elseif os(tvOS)
|
||||
private static let shortcutHintText =
|
||||
private var shortcutHintText: String {
|
||||
"Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect"
|
||||
+ " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks"
|
||||
}
|
||||
private static let shortcutHintFont: CGFloat = 22 // read from the couch
|
||||
#endif
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Session state for the app shell: owns the connection, the input capture, the trust
|
||||
// handshake phase, and the pump-thread → main-actor stats relay.
|
||||
|
||||
// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`)
|
||||
// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate).
|
||||
import AVFoundation
|
||||
import Foundation
|
||||
import os
|
||||
import PunktfunkKit
|
||||
@@ -11,9 +14,6 @@ import SwiftUI
|
||||
#elseif canImport(UIKit)
|
||||
import UIKit
|
||||
#endif
|
||||
#if os(tvOS)
|
||||
import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate
|
||||
#endif
|
||||
|
||||
/// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the
|
||||
/// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral
|
||||
@@ -137,6 +137,14 @@ final class SessionModel: ObservableObject {
|
||||
/// Mirrors StreamView's capture state (it owns the input capture; this drives the
|
||||
/// HUD's "click to capture" / "⌘⎋ releases" hint).
|
||||
@Published var mouseCaptured = false
|
||||
/// The USER's in-stream mic mute (the HUD button, the Stream menu's ⌃⌥⇧A, the captured-state
|
||||
/// chord, the iOS mic disc) — session state, deliberately NOT persisted: a mute is for the
|
||||
/// people in the room right now, so every new session starts live if the mic is on at all.
|
||||
/// One of the two inputs to the effective mute; `isBackgrounded` is the other, and
|
||||
/// `applyMicMute` composes them — a user mute survives a trip through the background, and the
|
||||
/// background's privacy mute never clears the user's choice. Local and instant: it gates
|
||||
/// capture on this device, nothing is sent to the host.
|
||||
@Published private(set) var micMuted = false
|
||||
/// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the
|
||||
/// instant a Match-window resize starts steering toward a new size until a frame at that size
|
||||
/// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay
|
||||
@@ -440,7 +448,7 @@ final class SessionModel: ObservableObject {
|
||||
guard phase == .streaming, let conn = connection, !isBackgrounded else { return }
|
||||
isBackgrounded = true
|
||||
conn.setVideoDropped(true)
|
||||
audio?.setMicMuted(true)
|
||||
applyMicMute() // now muted for privacy — on top of the user's own mute, not instead of it
|
||||
// Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast,
|
||||
// exactly like today's network-drop path. min 1 minute guards a nonsense setting.
|
||||
let minutes = max(1, timeoutMinutes)
|
||||
@@ -465,13 +473,57 @@ final class SessionModel: ObservableObject {
|
||||
backgroundDeadline = nil
|
||||
backgroundTimer?.cancel()
|
||||
backgroundTimer = nil
|
||||
audio?.setMicMuted(false)
|
||||
applyMicMute() // back to the user's own choice — which may well still be "muted"
|
||||
if let conn = connection {
|
||||
conn.setVideoDropped(false)
|
||||
conn.requestKeyframe()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Microphone mute (in-stream, per session)
|
||||
|
||||
/// Whether this session has a mic uplink there is any point in muting: the mic must be on in
|
||||
/// the session's RESOLVED settings (a profile can turn it on or off), the platform must have
|
||||
/// an app-accessible input at all, and the OS must not have refused us one. Drives whether the
|
||||
/// mute control is offered — a live-looking mute button over a session that sends no
|
||||
/// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on
|
||||
/// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session).
|
||||
var micAvailable: Bool {
|
||||
#if os(tvOS)
|
||||
return false // no app-accessible microphone — SessionAudio never opens an uplink either
|
||||
#else
|
||||
guard settings.micEnabled else { return false }
|
||||
switch AVCaptureDevice.authorizationStatus(for: .audio) {
|
||||
case .authorized, .notDetermined: return true
|
||||
default: return false // denied / restricted — there is no uplink to mute
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
/// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, ⌃⌥⇧A while
|
||||
/// captured, the iOS mic disc) all land here.
|
||||
func toggleMicMute() {
|
||||
setMicMuted(!micMuted)
|
||||
}
|
||||
|
||||
/// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no
|
||||
/// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was
|
||||
/// never sending anything.
|
||||
func setMicMuted(_ muted: Bool) {
|
||||
guard micAvailable, micMuted != muted else { return }
|
||||
micMuted = muted
|
||||
applyMicMute()
|
||||
}
|
||||
|
||||
/// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute —
|
||||
/// onto the audio engine. The two reasons are composed here and nowhere else: whichever one
|
||||
/// changed, the other still holds, so returning from the background can't un-mute a user who
|
||||
/// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window)
|
||||
/// doesn't open the mic behind their back.
|
||||
private func applyMicMute() {
|
||||
audio?.setMicMuted(micMuted || isBackgrounded)
|
||||
}
|
||||
|
||||
/// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those
|
||||
/// surfaces write the GLOBAL setting as they always have; this moves the session's own tier
|
||||
/// with it, so cycling still works in a session a profile put on a different tier.
|
||||
@@ -509,6 +561,9 @@ final class SessionModel: ObservableObject {
|
||||
backgroundTimer = nil
|
||||
isBackgrounded = false
|
||||
backgroundDeadline = nil
|
||||
// The mic mute is per-session and never persisted: the next stream starts live (if the
|
||||
// mic is enabled), rather than silently carrying a mute nobody remembers making.
|
||||
micMuted = false
|
||||
let audio = self.audio
|
||||
self.audio = nil
|
||||
// Gamepad capture is main-actor (releases held buttons on the wire while the
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at
|
||||
// the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the
|
||||
// CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured
|
||||
// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS
|
||||
// mouse) / +D (disconnect) / +S (stats), plus +A (mute the microphone), the Apple clients'
|
||||
// addition to it — and the menu is their discoverable surface on macOS
|
||||
// (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While
|
||||
// input is CAPTURED these key equivalents never reach the menu (the stream view swallows
|
||||
// keys); InputCapture's monitor detects the same combos there and performs the same actions —
|
||||
@@ -27,6 +28,12 @@ struct SessionFocus {
|
||||
/// Clipboard sync is live (host-acked) — drives the item's Stop/Share title.
|
||||
var clipboardOn: Bool
|
||||
var toggleClipboard: () -> Void
|
||||
/// The session has a mic uplink at all (its resolved `micEnabled`) — gates the mute item, so
|
||||
/// it is never an enabled control over a session that sends no microphone.
|
||||
var micAvailable: Bool
|
||||
/// The user's mic mute is engaged — drives the item's Mute/Unmute title.
|
||||
var micMuted: Bool
|
||||
var toggleMicMute: () -> Void
|
||||
var disconnect: () -> Void
|
||||
}
|
||||
|
||||
@@ -60,6 +67,17 @@ struct StreamCommands: Commands {
|
||||
}
|
||||
.keyboardShortcut("q", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true)
|
||||
// Mic mute, local and instant (it gates capture on this device — the host is never
|
||||
// asked). Per SESSION: it starts off every time, so this item is a live toggle, not a
|
||||
// setting. Greyed when the session sends no microphone at all (Settings → mic off, or
|
||||
// a profile that turns it off) rather than pretending there is something to mute.
|
||||
// Captured, the combo is handled by InputCapture's chord path before menus see it;
|
||||
// this item is the released-state path and the shortcut's documentation.
|
||||
Button(session?.micMuted == true ? "Unmute Microphone" : "Mute Microphone") {
|
||||
session?.toggleMicMute()
|
||||
}
|
||||
.keyboardShortcut("a", modifiers: [.control, .option, .shift])
|
||||
.disabled(session?.isStreaming != true || session?.micAvailable != true)
|
||||
#if os(macOS)
|
||||
// Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed
|
||||
// when the host doesn't advertise the cap (older host / operator policy off).
|
||||
|
||||
@@ -179,6 +179,18 @@ struct StreamHUDView: View {
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
#endif
|
||||
// Mic mute — the in-stream toggle, on the same card as the other in-overlay action.
|
||||
// Absent (not greyed) when the session sends no microphone: the HUD is a status card,
|
||||
// and a dead control on it would read as "there is a mic, and it is on". The muted
|
||||
// STATE is not this button's job — the badge over the stream says that at every tier
|
||||
// and with the overlay off entirely. tvOS gets no control: no microphone, and a
|
||||
// focusable one would steal the controller's A press from the host.
|
||||
#if !os(tvOS)
|
||||
if model.micAvailable {
|
||||
Button(micButtonTitle) { model.toggleMicMute() }
|
||||
.font(.geist(12, relativeTo: .caption))
|
||||
}
|
||||
#endif
|
||||
// ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden)
|
||||
// and in InputCapture's monitor while captured; this button is the in-overlay,
|
||||
// click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a
|
||||
@@ -195,6 +207,19 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The mute button's wording. macOS names the chord, exactly as its Disconnect button does;
|
||||
/// iOS/iPadOS spells the action out (the HUD's buttons there carry no shortcuts, even where a
|
||||
/// hardware keyboard could fire one — the Stream menu is that keyboard's surface).
|
||||
private var micButtonTitle: String {
|
||||
#if os(macOS)
|
||||
return model.micMuted ? "Unmute Mic (⌃⌥⇧A)" : "Mute Mic (⌃⌥⇧A)"
|
||||
#else
|
||||
return model.micMuted ? "Unmute Microphone" : "Mute Microphone"
|
||||
#endif
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Card metrics
|
||||
|
||||
/// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the
|
||||
@@ -242,6 +267,42 @@ struct StreamHUDView: View {
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides
|
||||
/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user
|
||||
/// may have cycled off, and which the compact tier reduces to a stat line): "am I muted?" is not a
|
||||
/// statistic, and a mute you can't see is how people talk to nobody for a minute. Same glass
|
||||
/// language as the HUD, sized like the start-of-stream banner it shares the bottom edge with.
|
||||
///
|
||||
/// It is also a control: tapping it unmutes. That is the guaranteed way back for a touch user who
|
||||
/// muted with the overlay off, and it costs the badge nothing (it is on screen either way).
|
||||
struct MicMutedBadge: View {
|
||||
let onUnmute: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onUnmute) {
|
||||
HStack(spacing: 7) {
|
||||
Image(systemName: "mic.slash.fill")
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.red)
|
||||
Text("Microphone muted")
|
||||
.font(.geist(12, .medium, relativeTo: .caption))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 8)
|
||||
// interactive: the badge IS the tap target, so the glass reacts to press.
|
||||
.glassBackground(Capsule(), interactive: true)
|
||||
.contentShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay
|
||||
.accessibilityLabel("Microphone muted")
|
||||
.accessibilityHint("Unmutes the microphone")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
#if os(iOS)
|
||||
/// Device display geometry the overlay needs but UIKit doesn't expose publicly.
|
||||
enum DeviceMetrics {
|
||||
|
||||
@@ -51,6 +51,12 @@ public final class SessionAudio {
|
||||
/// `playbackEngine`/`captureEngine` stay nil while this is set.
|
||||
private var combinedEngine: AVAudioEngine?
|
||||
private var drainStarted = false
|
||||
/// The mute LATCH: the effective mute the owner last asked for (see `setMicMuted`). Held
|
||||
/// because the uplink engine can appear LATER than the request — the mic permission prompt
|
||||
/// is answered at the user's leisure, and the engine is built on the grant — so a mute set
|
||||
/// in the meantime must be waiting for it. Applied by whichever start path wins the race.
|
||||
/// Guarded by `stateLock`, like the engines it applies to.
|
||||
private var micMuted = 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,
|
||||
@@ -259,19 +265,34 @@ 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. 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.
|
||||
/// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting
|
||||
/// mechanism: the owner composes its reasons — the user's in-stream mute and the background
|
||||
/// keep-alive's privacy mute — into one effective state and passes that here, so neither can
|
||||
/// clear the other (see `SessionModel.applyMicMute`).
|
||||
///
|
||||
/// 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).
|
||||
/// Local and instant either way: nothing is negotiated with the host, and the packets that do
|
||||
/// leave carry silence. A no-op when there's no uplink (playback-only / tvOS / mic disabled),
|
||||
/// except that the state is LATCHED for an uplink that starts later. 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()
|
||||
micMuted = muted
|
||||
let capture = captureEngine
|
||||
let combined = combinedEngine
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: capture, combined: combined)
|
||||
}
|
||||
|
||||
/// Push the latched mute onto whichever engine carries the uplink. Split out from
|
||||
/// `setMicMuted` because the start paths call it too, with the engine they just started —
|
||||
/// that's how a mute requested before the permission grant lands on the engine the grant
|
||||
/// creates. Never resumes a stopped session's engine.
|
||||
private func apply(micMuted muted: Bool, capture: AVAudioEngine?, combined: AVAudioEngine?) {
|
||||
if let combined {
|
||||
combined.inputNode.isVoiceProcessingInputMuted = muted
|
||||
return
|
||||
@@ -473,7 +494,9 @@ public final class SessionAudio {
|
||||
return
|
||||
}
|
||||
combinedEngine = engine
|
||||
let muted = micMuted // latched before this engine existed (a mute during the prompt)
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: nil, combined: engine)
|
||||
startDrain(into: ring)
|
||||
log.info("audio engines joined — voice processing (echo cancellation) active")
|
||||
}
|
||||
@@ -517,7 +540,9 @@ public final class SessionAudio {
|
||||
return
|
||||
}
|
||||
captureEngine = engine
|
||||
let muted = micMuted // latched before this engine existed (a mute during the prompt)
|
||||
stateLock.unlock()
|
||||
apply(micMuted: muted, capture: engine, combined: nil)
|
||||
log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))")
|
||||
}
|
||||
|
||||
|
||||
@@ -128,11 +128,19 @@ public final class InputCapture {
|
||||
/// carries the same key equivalents for discoverability) can't see them, so the monitor is the
|
||||
/// captured-state delivery path; released, the events pass through and the menu handles them.
|
||||
/// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats
|
||||
/// overlay tier (off → compact → normal → detailed). Main queue.
|
||||
/// overlay tier (off → compact → normal → detailed). ⌃⌥⇧A (`onToggleMicMute`, below) rides
|
||||
/// the same path. Main queue.
|
||||
public var onReleaseCapture: (() -> Void)?
|
||||
public var onDisconnect: (() -> Void)?
|
||||
public var onCycleStats: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌥⇧A — mute/unmute the microphone uplink, the one in-stream control a captured
|
||||
/// session can't otherwise reach (the HUD's button is behind a grabbed cursor). Same delivery
|
||||
/// rule as the combos above: only WHILE FORWARDING, because that's when the menu's identical
|
||||
/// key equivalent can't fire. ⌃⌥⇧M — the obvious letter — is long since the mouse-model flip
|
||||
/// (cross-client), so A ("audio in") is the mic's. Main queue.
|
||||
public var onToggleMicMute: (() -> Void)?
|
||||
|
||||
/// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the
|
||||
/// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view
|
||||
/// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the
|
||||
@@ -140,13 +148,13 @@ public final class InputCapture {
|
||||
public var onToggleFullscreen: (() -> Void)?
|
||||
|
||||
#if os(iOS)
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides:
|
||||
/// Windows VKs of the three modifier classes in the ⌃⌥⇧ chords, both L/R sides:
|
||||
/// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream.
|
||||
private static let chordModifierVKs: Set<UInt32> = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1]
|
||||
|
||||
/// Whether Control AND Option AND Shift are all currently held (either side of each counts) —
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧Q release chord.
|
||||
private var hasReleaseChordModifiers: Bool {
|
||||
/// the modifier precondition for the iPad ⌃⌥⇧ chords (Q releases capture, A mutes the mic).
|
||||
private var hasChordModifiers: Bool {
|
||||
let m = chordModifiersDown
|
||||
return (m.contains(0xA2) || m.contains(0xA3)) // control
|
||||
&& (m.contains(0xA4) || m.contains(0xA5)) // option
|
||||
@@ -284,6 +292,10 @@ public final class InputCapture {
|
||||
self.suppressedVK = 0x53
|
||||
self.onCycleStats?()
|
||||
return nil
|
||||
case 0 /* A */:
|
||||
self.suppressedVK = 0x41
|
||||
self.onToggleMicMute?()
|
||||
return nil
|
||||
default:
|
||||
break
|
||||
}
|
||||
@@ -704,7 +716,7 @@ public final class InputCapture {
|
||||
}
|
||||
}
|
||||
#if os(iOS)
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding
|
||||
// Track Control/Option/Shift for the ⌃⌥⇧ chords below — in both forwarding
|
||||
// states (like `cmdKeysDown`) so a modifier held before capture engaged still counts.
|
||||
if Self.chordModifierVKs.contains(vk) {
|
||||
if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) }
|
||||
@@ -732,11 +744,19 @@ public final class InputCapture {
|
||||
// otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host;
|
||||
// the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release
|
||||
// path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q).
|
||||
if pressed, vk == 0x51, self.hasReleaseChordModifiers {
|
||||
if pressed, vk == 0x51, self.hasChordModifiers {
|
||||
self.suppressedVK = 0x51
|
||||
self.onReleaseCapture?()
|
||||
return
|
||||
}
|
||||
// ⌃⌥⇧A mutes/unmutes the mic uplink — same detection, same latching, and needed here
|
||||
// for the same reason as on macOS: a captured iPad swallows the Stream menu's
|
||||
// identical key equivalent. VK 0x41 is layout-independent (physical A).
|
||||
if pressed, vk == 0x41, self.hasChordModifiers {
|
||||
self.suppressedVK = 0x41
|
||||
self.onToggleMicMute?()
|
||||
return
|
||||
}
|
||||
#endif
|
||||
// Release direction of the toggle: GC's Esc-down can beat the NSEvent
|
||||
// monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved).
|
||||
|
||||
@@ -881,6 +881,12 @@ public final class StreamLayerView: NSView {
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil)
|
||||
}
|
||||
capture.onToggleMicMute = { [weak self] in
|
||||
// Session-level state the view doesn't own — post to the app (same routing as the
|
||||
// fullscreen chord), so the captured and released paths end at one toggle.
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil)
|
||||
}
|
||||
capture.onCycleStats = { [weak self] in
|
||||
guard self?.window?.isKeyWindow == true else { return }
|
||||
// Advance the shared tier setting directly — every @AppStorage reader (the HUD's
|
||||
|
||||
@@ -424,6 +424,12 @@ public final class StreamViewController: StreamViewControllerBase {
|
||||
capture.onReleaseCapture = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
// ⌃⌥⇧A mutes/unmutes the mic uplink. Session state this controller doesn't own, so it
|
||||
// posts to the app exactly as the macOS chord does — the Stream menu's identical
|
||||
// equivalent (which a captured scene swallows) ends at the same toggle.
|
||||
capture.onToggleMicMute = {
|
||||
NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil)
|
||||
}
|
||||
capture.onPreempted = { [weak self] in
|
||||
self?.setCaptured(false)
|
||||
}
|
||||
|
||||
@@ -200,6 +200,12 @@ extension Notification.Name {
|
||||
/// state. macOS only.
|
||||
public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen")
|
||||
|
||||
/// Posted by InputCapture's chord path (⌃⌥⇧A) when the combo fires while input is CAPTURED —
|
||||
/// the state in which the Stream menu's identical key equivalent never reaches the app. The
|
||||
/// live session's owner (ContentView) flips the session's mic mute. Released, the menu item
|
||||
/// handles the same combo directly; both end at `SessionModel.toggleMicMute`.
|
||||
public static let punktfunkToggleMicMute = Notification.Name("io.unom.punktfunk.toggle-mic-mute")
|
||||
|
||||
/// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`,
|
||||
/// which runs in the app's process): the app tears the active session down deliberately
|
||||
/// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —
|
||||
|
||||
Reference in New Issue
Block a user