The host-audio switch reaches every client, not just the desktop ones #423

Merged
enricobuehler merged 2 commits from worktree-keep-host-audio-parity into main 2026-08-27 23:26:45 +00:00
21 changed files with 121 additions and 10 deletions
@@ -101,6 +101,9 @@ suspend fun connectToHost(
// Tier-A pad audio: ask for the 0xD1 plane only when a setting would render it, so a
// user with it off does not make the host provision endpoints it will never feed.
settings.padHaptics || settings.padSpeaker,
// "Keep host audio playing": the host taps its own default output rather than
// silencing it for the session. Free to ask for — an older host just ignores it.
settings.keepHostAudio,
)
}
}
@@ -45,6 +45,7 @@ data class SettingsOverlay(
val audioFormat: String? = null,
val micEnabled: Boolean? = null,
val echoCancel: Boolean? = null,
val keepHostAudio: Boolean? = null,
val touchMode: TouchMode? = null,
val mouseMode: MouseMode? = null,
val invertScroll: Boolean? = null,
@@ -82,6 +83,7 @@ data class SettingsOverlay(
audioFormat = audioFormat ?: base.audioFormat,
micEnabled = micEnabled ?: base.micEnabled,
echoCancel = echoCancel ?: base.echoCancel,
keepHostAudio = keepHostAudio ?: base.keepHostAudio,
touchMode = touchMode ?: base.touchMode,
mouseMode = mouseMode ?: base.mouseMode,
invertScroll = invertScroll ?: base.invertScroll,
@@ -120,6 +122,8 @@ data class SettingsOverlay(
audioFormat = if (after.audioFormat != before.audioFormat) after.audioFormat else audioFormat,
micEnabled = if (after.micEnabled != before.micEnabled) after.micEnabled else micEnabled,
echoCancel = if (after.echoCancel != before.echoCancel) after.echoCancel else echoCancel,
keepHostAudio =
if (after.keepHostAudio != before.keepHostAudio) after.keepHostAudio else keepHostAudio,
touchMode = if (after.touchMode != before.touchMode) after.touchMode else touchMode,
mouseMode = if (after.mouseMode != before.mouseMode) after.mouseMode else mouseMode,
invertScroll = if (after.invertScroll != before.invertScroll) after.invertScroll else invertScroll,
@@ -152,6 +156,7 @@ data class SettingsOverlay(
"audio_format" -> copy(audioFormat = null)
"mic_enabled" -> copy(micEnabled = null)
"echo_cancel" -> copy(echoCancel = null)
"keep_host_audio" -> copy(keepHostAudio = null)
"touch_mode" -> copy(touchMode = null)
"mouse_mode" -> copy(mouseMode = null)
"invert_scroll" -> copy(invertScroll = null)
@@ -179,6 +184,7 @@ data class SettingsOverlay(
if (audioFormat != null) add("audio_format")
if (micEnabled != null) add("mic_enabled")
if (echoCancel != null) add("echo_cancel")
if (keepHostAudio != null) add("keep_host_audio")
if (touchMode != null) add("touch_mode")
if (mouseMode != null) add("mouse_mode")
if (invertScroll != null) add("invert_scroll")
@@ -214,6 +220,7 @@ data class SettingsOverlay(
audioFormat?.let { j.put("audio_format", it) }
micEnabled?.let { j.put("mic_enabled", it) }
echoCancel?.let { j.put("echo_cancel", it) }
keepHostAudio?.let { j.put("keep_host_audio", it) }
touchMode?.let { j.put("touch_mode", it.name) }
mouseMode?.let { j.put("mouse_mode", it.storedName) }
invertScroll?.let { j.put("invert_scroll", it) }
@@ -236,6 +243,7 @@ data class SettingsOverlay(
private val KNOWN = setOf(
"width", "height", "refresh_hz", "bitrate_kbps", "render_scale", "codec",
"hdr_enabled", "compositor", "audio_channels", "audio_format", "mic_enabled", "echo_cancel",
"keep_host_audio",
"touch_mode", "mouse_mode", "invert_scroll", "gamepad", "gamepad_forwarding",
"system_buttons", "guide_gesture",
"stats_verbosity",
@@ -255,6 +263,7 @@ data class SettingsOverlay(
audioFormat = j.optStringOrNull("audio_format"),
micEnabled = j.optBooleanOrNull("mic_enabled"),
echoCancel = j.optBooleanOrNull("echo_cancel"),
keepHostAudio = j.optBooleanOrNull("keep_host_audio"),
touchMode = j.optStringOrNull("touch_mode")
?.let { n -> TouchMode.entries.firstOrNull { it.name == n } },
mouseMode = j.optStringOrNull("mouse_mode")
@@ -91,6 +91,17 @@ data class Settings(
* Only meaningful while [micEnabled] is on.
*/
val echoCancel: Boolean = true,
/**
* Ask the host to leave ITS OWN audio devices alone for this session
* (`CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback device already is,
* so the speakers or headphones on the host PC keep playing while this device hears the same
* audio. Off — the default, and what every build before this did — has the host park playback
* on a silent endpoint, which is why the host goes quiet the moment a stream starts.
*
* REQUEST-only: there is no host-cap echo, so an older host ignores the ask and re-routes as it
* always did ("audio still works, the host went quiet"), never a broken session.
*/
val keepHostAudio: Boolean = false,
/**
* How much the in-stream stats overlay shows — see [StatsVerbosity]. Defaults to
* [StatsVerbosity.NORMAL] (the res/fps line + latency headline + reliability counters); the full
@@ -330,6 +341,7 @@ class SettingsStore(context: Context) {
codec = prefs.getString(K_CODEC, "auto") ?: "auto",
micEnabled = prefs.getBoolean(K_MIC, false),
echoCancel = prefs.getBoolean(K_ECHO_CANCEL, true),
keepHostAudio = prefs.getBoolean(K_KEEP_HOST_AUDIO, false),
statsVerbosity = prefs.getString(K_STATS_VERBOSITY, null)
?.let { name -> StatsVerbosity.entries.firstOrNull { it.name == name } }
// Migration from the pre-tier Boolean "stats_hud_enabled": an explicit OFF stays off;
@@ -387,6 +399,7 @@ class SettingsStore(context: Context) {
.putString(K_CODEC, s.codec)
.putBoolean(K_MIC, s.micEnabled)
.putBoolean(K_ECHO_CANCEL, s.echoCancel)
.putBoolean(K_KEEP_HOST_AUDIO, s.keepHostAudio)
.putString(K_STATS_VERBOSITY, s.statsVerbosity.name)
.putString(K_TOUCH_MODE, s.touchMode.name)
.putBoolean(K_GAMEPAD_UI, s.gamepadUiEnabled)
@@ -426,6 +439,7 @@ class SettingsStore(context: Context) {
const val K_CODEC = "codec"
const val K_MIC = "mic_enabled"
const val K_ECHO_CANCEL = "echo_cancel"
const val K_KEEP_HOST_AUDIO = "keep_host_audio"
const val K_STATS_VERBOSITY = "stats_verbosity"
/** Pre-tier Boolean the [K_STATS_VERBOSITY] enum replaced — read once for migration, never
@@ -844,6 +844,13 @@ private fun AudioSettings(s: Settings, update: (Settings) -> Unit, onMicChange:
"otherwise the session stays on Opus, which is already effectively " +
"transparent. The overlay shows what a session actually got.",
) { f -> update(s.copy(audioFormat = f)) }
ToggleRow(
title = "Keep host audio playing",
subtitle = "The host's speakers or headphones keep playing while you stream",
checked = s.keepHostAudio,
field = "keep_host_audio",
onCheckedChange = { on -> update(s.copy(keepHostAudio = on)) },
)
ToggleRow(
title = "Microphone",
subtitle = "Feeds this device's microphone to the host",
@@ -304,6 +304,7 @@ internal object ConsoleJson {
j.put("mouse_mode", s.mouseMode.storedName)
j.put("mic_enabled", s.micEnabled)
j.put("echo_cancel", s.echoCancel)
j.put("keep_host_audio", s.keepHostAudio)
j.put("audio_channels", s.audioChannels)
j.put("audio_format", s.audioFormat)
j.put("codec", s.codec)
@@ -361,6 +362,7 @@ internal object ConsoleJson {
?: s.mouseMode,
micEnabled = j.optBoolean("mic_enabled", s.micEnabled),
echoCancel = j.optBoolean("echo_cancel", s.echoCancel),
keepHostAudio = j.optBoolean("keep_host_audio", s.keepHostAudio),
audioChannels = j.optInt("audio_channels", s.audioChannels),
audioFormat = str("audio_format", s.audioFormat),
codec = str("codec", s.codec),
@@ -95,6 +95,11 @@ object NativeBridge {
* DualSense plane. Without it the host never sets `HOST_CAP_PAD_AUDIO` and emits nothing,
* so a captured pad's own render capabilities would have nothing to gate. */
padAudioOk: Boolean,
/** Advertise `CLIENT_CAP_KEEP_HOST_AUDIO` — ask the host to tap its default playback
* device instead of parking it on a silent endpoint, so the host PC's own speakers keep
* playing. REQUEST-only (no host-cap echo): an older host ignores it and goes quiet
* exactly as it always did. */
keepHostAudio: Boolean,
): Long
/** 64-hex SHA-256 of the cert the host presented on [handle]; valid after a successful connect. */
@@ -275,6 +275,7 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
launch: JString<'local>,
device_name: JString<'local>,
pad_audio_ok: jboolean,
keep_host_audio: jboolean,
) -> jlong {
// Every JNI string this method needs, read up front in the one `Env` scope jni 0.22 grants a
// native method; everything below is pure Rust over owned `String`s. `None` = the mandatory
@@ -438,6 +439,16 @@ pub extern "system" fn Java_io_unom_punktfunk_kit_NativeBridge_nativeConnect<'lo
punktfunk_core::quic::CLIENT_CAP_PAD_AUDIO
} else {
0
}
// The user's "Keep host audio playing" setting: the host taps whatever its default
// playback device already is instead of parking the desktop mix on a silent
// endpoint, so the speakers on the host PC stay live. REQUEST-only — there is no
// host-cap echo — so an older host ignores the bit and re-routes exactly as it
// always did ("the host went quiet"), never a broken session.
| if keep_host_audio {
punktfunk_core::quic::CLIENT_CAP_KEEP_HOST_AUDIO
} else {
0
},
// Slice-progressive delivery, by decoder truth (Kotlin probes FEATURE_PartialFrame on
// every decoder this device would use; `debug.punktfunk.force_parts` overrides for the
@@ -511,14 +511,20 @@ final class SessionModel: ObservableObject {
// the pointer and forwards shape/state, which StreamView draws as the real
// NSCursor. Capture-mode sessions keep today's composited pointer.
#if os(macOS)
let clientCaps: UInt8 =
let presentCaps: UInt8 =
(MouseInputMode(rawValue: effective.mouseMode) ?? .capture) == .desktop ? 0x01 : 0
#else
// iOS/tvOS run the stage-4 deadline presenter, whose link thread feeds
// reportPhase advertise the vsync-aware presenter (0x02, CLIENT_CAP_PHASE_LOCK).
// macOS stays without it: the stage-2 arrival presenter has no latch grid.
let clientCaps: UInt8 = 0x02
let presentCaps: UInt8 = 0x02
#endif
// "Keep host audio playing": the host taps its default playback device instead of
// parking it on a silent endpoint, so the speakers on the host PC stay live. Pure
// REQUEST no host-cap echo so an older host simply goes quiet as it always did.
let clientCaps =
presentCaps
| (effective.keepHostAudio ? PunktfunkConnection.clientCapKeepHostAudio : 0)
let result = Result { try PunktfunkConnection(
host: host.address, port: host.port,
width: width, height: height, refreshHz: hz,
@@ -106,6 +106,10 @@ enum SettingsFields {
.init(name: "echo_cancel", key: DefaultsKey.echoCancel,
overlay: \.echoCancel, effective: \.echoCancel)
}
static var keepHostAudio: SettingsField<Bool> {
.init(name: "keep_host_audio", key: DefaultsKey.keepHostAudio,
overlay: \.keepHostAudio, effective: \.keepHostAudio)
}
static var touchMode: SettingsField<String> {
.init(name: "touch_mode", key: DefaultsKey.touchMode,
overlay: \.touchMode, effective: \.touchMode)
@@ -201,6 +205,7 @@ extension SettingsView {
base.audioFormat = audioFormat
base.micEnabled = micEnabled
base.echoCancel = echoCancel
base.keepHostAudio = keepHostAudio
base.gamepadType = gamepadType
base.gamepadForwarding = gamepadForwarding
base.statsVerbosity = statsVerbosityRaw
@@ -627,6 +627,11 @@ extension SettingsView {
}
}
}
described("The host's speakers or headphones keep playing while you stream — "
+ "needs a host on 0.32+",
field: "keep_host_audio") {
Toggle("Keep host audio playing", isOn: scoped(SettingsFields.keepHostAudio))
}
#if os(macOS)
// Which speaker THIS Mac plays through is this device's audio routing (tier G).
if !inProfileScope {
@@ -71,6 +71,7 @@ struct SettingsView: View {
@AppStorage(DefaultsKey.fullscreenWhileStreaming) var fullscreenWhileStreaming = true
@AppStorage(DefaultsKey.micEnabled) var micEnabled = true
@AppStorage(DefaultsKey.echoCancel) var echoCancel = true
@AppStorage(DefaultsKey.keepHostAudio) var keepHostAudio = false
@AppStorage(DefaultsKey.audioChannels) var audioChannels = 2
@AppStorage(DefaultsKey.audioFormat) var audioFormat = AudioFormatChoice.opus.rawValue
@AppStorage(DefaultsKey.codec) var codec = "auto"
@@ -1446,6 +1446,13 @@ public final class PunktfunkConnection {
/// auto-selected. Decoded by the Metal wavelet decoder, not VideoToolbox.
public static let codecPyroWave: UInt8 = UInt8(PUNKTFUNK_CODEC_PYROWAVE)
/// `clientCaps` bit: ask the host to leave ITS OWN audio devices alone for this session
/// it taps whatever its default playback device already is instead of parking the desktop
/// mix on a silent endpoint, so the host PC's speakers keep playing and this device hears
/// the same audio. REQUEST-only, no host-cap echo: an older host ignores it and goes quiet
/// exactly as it always did, so it is safe to set unconditionally from the user's setting.
public static let clientCapKeepHostAudio: UInt8 = UInt8(PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO)
/// The `codec` SETTING (a `DefaultsKey.codec` / profile-overlay string) as a soft-preference
/// byte; `0` = Automatic, i.e. the host decides. Lives here beside the bits so the settings
/// string is mapped to the wire in exactly one place a session and a speed test that
@@ -89,6 +89,13 @@ public enum DefaultsKey {
/// 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"
/// Ask the host to leave ITS OWN audio devices alone for this session
/// (`PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO`): it captures whatever its default playback
/// device already is, so the speakers/headphones on the host PC keep playing while this
/// device hears the same audio. Off (the default) is today's behaviour the host parks
/// playback on a silent endpoint and goes quiet for the session. Best-effort: an older
/// host ignores the ask and re-routes as it always did.
public static let keepHostAudio = "punktfunk.keepHostAudio"
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
@@ -33,6 +33,7 @@ public struct EffectiveSettings: Equatable, Sendable {
public var audioFormat = AudioFormatChoice.opus.rawValue
public var micEnabled = true
public var echoCancel = true
public var keepHostAudio = false
public var touchMode = "trackpad"
public var mouseMode = "capture"
public var invertScroll = false
@@ -101,6 +102,7 @@ public struct EffectiveSettings: Equatable, Sendable {
audioFormat = str(DefaultsKey.audioFormat, audioFormat)
micEnabled = bool(DefaultsKey.micEnabled, micEnabled)
echoCancel = bool(DefaultsKey.echoCancel, echoCancel)
keepHostAudio = bool(DefaultsKey.keepHostAudio, keepHostAudio)
touchMode = str(DefaultsKey.touchMode, touchMode)
mouseMode = str(DefaultsKey.mouseMode, mouseMode)
invertScroll = bool(DefaultsKey.invertScroll, invertScroll)
@@ -183,6 +185,7 @@ public struct EffectiveSettings: Equatable, Sendable {
if let v = overlay.audioFormat { s.audioFormat = v }
if let v = overlay.micEnabled { s.micEnabled = v }
if let v = overlay.echoCancel { s.echoCancel = v }
if let v = overlay.keepHostAudio { s.keepHostAudio = 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 }
@@ -115,6 +115,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
public var audioFormat: String?
public var micEnabled: Bool?
public var echoCancel: Bool?
public var keepHostAudio: Bool?
public var touchMode: String?
public var mouseMode: String?
public var invertScroll: Bool?
@@ -161,6 +162,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
case audioFormat = "audio_format"
case micEnabled = "mic_enabled"
case echoCancel = "echo_cancel"
case keepHostAudio = "keep_host_audio"
case touchMode = "touch_mode"
case mouseMode = "mouse_mode"
case invertScroll = "invert_scroll"
@@ -199,6 +201,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
audioFormat = str(.audioFormat)
micEnabled = bool(.micEnabled)
echoCancel = bool(.echoCancel)
keepHostAudio = bool(.keepHostAudio)
touchMode = str(.touchMode)
mouseMode = str(.mouseMode)
invertScroll = bool(.invertScroll)
@@ -239,6 +242,7 @@ public struct SettingsOverlay: Codable, Equatable, Sendable {
try c.encodeIfPresent(audioFormat, forKey: AnyKey(Key.audioFormat.rawValue))
try c.encodeIfPresent(micEnabled, forKey: AnyKey(Key.micEnabled.rawValue))
try c.encodeIfPresent(echoCancel, forKey: AnyKey(Key.echoCancel.rawValue))
try c.encodeIfPresent(keepHostAudio, forKey: AnyKey(Key.keepHostAudio.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))
@@ -297,6 +301,7 @@ public enum OverlayField {
case "audio_format": overlay.audioFormat = nil
case "mic_enabled": overlay.micEnabled = nil
case "echo_cancel": overlay.echoCancel = nil
case "keep_host_audio": overlay.keepHostAudio = nil
case "touch_mode": overlay.touchMode = nil
case "mouse_mode": overlay.mouseMode = nil
case "invert_scroll": overlay.invertScroll = nil
@@ -337,6 +342,7 @@ public enum OverlayField {
case "audio_format": return o.audioFormat != nil
case "mic_enabled": return o.micEnabled != nil
case "echo_cancel": return o.echoCancel != nil
case "keep_host_audio": return o.keepHostAudio != nil
case "touch_mode": return o.touchMode != nil
case "mouse_mode": return o.mouseMode != nil
case "invert_scroll": return o.invertScroll != nil
+1 -5
View File
@@ -55,7 +55,7 @@ enum RowId {
/// see the `enabled` note in [`row_spec`].
AudioFormat,
/// The per-session `CLIENT_CAP_KEEP_HOST_AUDIO` ask — the host keeps playing on its own
/// output while it streams. Desktop-only until the Android session advertises the bit.
/// output while it streams. Every platform: the Android session advertises the bit too.
KeepHostAudio,
Mic,
EchoCancel,
@@ -990,9 +990,6 @@ fn row_on(id: RowId, platform: crate::platform::Platform) -> bool {
| RowId::AllowVrr
| RowId::Fullscreen
| RowId::Shortcuts
// Desktop-only until the Android session advertises CLIENT_CAP_KEEP_HOST_AUDIO —
// a row whose bit never goes out would be a dead toggle.
| RowId::KeepHostAudio
);
match platform {
Platform::Desktop => !android_only,
@@ -2665,7 +2662,6 @@ pub(super) mod tests {
RowId::TenBitSdr,
RowId::Vsync,
RowId::AllowVrr,
RowId::KeepHostAudio,
RowId::Shortcuts,
RowId::Fullscreen,
]
+1
View File
@@ -98,6 +98,7 @@ include = ["PunktfunkEndReason"]
# a benign redefinition in C. One name for one bit is the point — an embedder that reaches for
# either spelling gets the same constant.
"CLIENT_CAP_AUDIO_HIRES" = "PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES"
"CLIENT_CAP_KEEP_HOST_AUDIO" = "PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO"
"HOST_CAP_AUDIO_HIRES" = "PUNKTFUNK_HOST_CAP_AUDIO_HIRES"
# R21: every remaining exported constant, prefixed. cbindgen emits a bare `#define` per
+12
View File
@@ -1743,6 +1743,7 @@ const _: () = {
assert!(PUNKTFUNK_HOST_CAP_AUDIO_HIRES == crate::quic::HOST_CAP_AUDIO_HIRES);
assert!(PUNKTFUNK_CLIENT_CAP_PAD_AUDIO == crate::quic::CLIENT_CAP_PAD_AUDIO);
assert!(PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES == crate::quic::CLIENT_CAP_AUDIO_HIRES);
assert!(PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO == crate::quic::CLIENT_CAP_KEEP_HOST_AUDIO);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_HAPTICS == crate::quic::PAD_AUDIO_KIND_HAPTICS);
assert!(PUNKTFUNK_PAD_AUDIO_KIND_SPEAKER == crate::quic::PAD_AUDIO_KIND_SPEAKER);
// The setter's caps bits are the arrival flags bits 8/9 shifted down (the wire packing
@@ -2567,6 +2568,17 @@ pub const PUNKTFUNK_CLIENT_CAP_PAD_AUDIO: u8 = 0x08;
/// 24-bit is where the plane earns its bandwidth. (Mirrors `quic::CLIENT_CAP_AUDIO_HIRES`.)
pub const PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES: u8 = 0x10;
/// [`punktfunk_connect_ex9`] `client_caps` bit: ask the host to leave its OWN audio devices
/// alone for this session — capture whatever the operator's default playback device already
/// is, instead of parking the desktop mix on a silent endpoint. The host keeps playing (the
/// headphones plugged into the host PC stay live) and this client hears the same audio:
/// Moonlight's "Mute host PC speakers" box, unchecked, per session.
///
/// REQUEST-only — there is no host-cap echo. An older host ignores the bit and re-routes as it
/// always did, which degrades to "audio still works, the host went quiet", so an embedder may
/// set it unconditionally from its user's setting. (Mirrors `quic::CLIENT_CAP_KEEP_HOST_AUDIO`.)
pub const PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO: u8 = 0x20;
/// A [`punktfunk_connect_ex10`] device name cut to what a [`crate::quic::Hello`] carries.
/// [`crate::quic::HELLO_NAME_MAX`] is a BYTE cap while the cut must land on a character
/// boundary — "Wohnzimmer-Fernseher überm Sofa" is 33 characters and 34 bytes, and slicing a
+1 -2
View File
@@ -129,8 +129,7 @@ headphones plugged into the host keep playing, and both ends hear the same audio
host's headphones live while the TV profile mutes them. Best-effort: it needs a host on 0.32 or
newer, and with several clients streaming at once, any one asking wins for all of them. The
host-wide equivalent is
[`PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`](/docs/configuration). Linux, Windows and the
desktop console.
[`PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`](/docs/configuration). Offered everywhere.
**Microphone** — *default: off on Linux, Windows, Android and the console home; on in the Apple
app.* Sends this device's microphone to the host's virtual mic. Spelled *Stream microphone* on
+12 -1
View File
@@ -482,6 +482,17 @@
// 24-bit is where the plane earns its bandwidth. (Mirrors `quic::CLIENT_CAP_AUDIO_HIRES`.)
#define PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES 16
// [`punktfunk_connect_ex9`] `client_caps` bit: ask the host to leave its OWN audio devices
// alone for this session — capture whatever the operator's default playback device already
// is, instead of parking the desktop mix on a silent endpoint. The host keeps playing (the
// headphones plugged into the host PC stay live) and this client hears the same audio:
// Moonlight's "Mute host PC speakers" box, unchecked, per session.
//
// REQUEST-only — there is no host-cap echo. An older host ignores the bit and re-routes as it
// always did, which degrades to "audio still works, the host went quiet", so an embedder may
// set it unconditionally from its user's setting. (Mirrors `quic::CLIENT_CAP_KEEP_HOST_AUDIO`.)
#define PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO 32
// `*ttl_ms` sentinel written by [`punktfunk_connection_next_rumble2`] for a legacy (v1) rumble
// datagram — an old host that sent no self-termination lease. The client then falls back to its
// own staleness heuristic for that update instead of a host-supplied deadline.
@@ -1100,7 +1111,7 @@
// asked wins for all of them until it ends. Composes with the host-wide
// `PUNKTFUNK_AUDIO_OUTPUT_MODE=follow_default`, which is this behaviour for every session.
// `0x20` — `0x10` is [`CLIENT_CAP_AUDIO_HIRES`]; `0x40`/`0x80` remain free.
#define CLIENT_CAP_KEEP_HOST_AUDIO 32
#define PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO 32
#endif
#if defined(PUNKTFUNK_FEATURE_QUIC)
@@ -61,6 +61,7 @@ PUNKTFUNK_CLIENT_444
PUNKTFUNK_CLIENT_CAP_AUDIO_HIRES
PUNKTFUNK_CLIENT_CAP_AUDIO_RED
PUNKTFUNK_CLIENT_CAP_CURSOR
PUNKTFUNK_CLIENT_CAP_KEEP_HOST_AUDIO
PUNKTFUNK_CLIENT_CAP_PHASE_LOCK
PUNKTFUNK_CLIENT_CHACHA20
PUNKTFUNK_CLIENT_PEAK_NITS