feat(audio): end-to-end 5.1/7.1 surround across the native path + all clients
apple / swift (push) Failing after 10s
release / apple (push) Failing after 7s
apple / screenshots (push) Has been skipped
audit / cargo-audit (push) Failing after 1m19s
windows-host / package (push) Failing after 2m44s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Failing after 39s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 45s
android / android (push) Successful in 5m17s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 45s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 56s
ci / rust (push) Successful in 9m19s
ci / bench (push) Successful in 4m40s
decky / build-publish (push) Successful in 26s
deb / build-publish (push) Successful in 2m57s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
flatpak / build-publish (push) Successful in 4m22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m51s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m50s
apple / swift (push) Failing after 10s
release / apple (push) Failing after 7s
apple / screenshots (push) Has been skipped
audit / cargo-audit (push) Failing after 1m19s
windows-host / package (push) Failing after 2m44s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, aarch64-pc-windows-msvc, C:\t-a64) (push) Failing after 39s
windows-msix / package (x64, C:\Users\Public\ffmpeg, x86_64-pc-windows-msvc, C:\t) (push) Failing after 39s
windows / build (aarch64-pc-windows-msvc) (push) Failing after 45s
android / android (push) Successful in 5m17s
windows / build (x86_64-pc-windows-msvc) (push) Failing after 45s
ci / web (push) Successful in 57s
ci / docs-site (push) Successful in 56s
ci / rust (push) Successful in 9m19s
ci / bench (push) Successful in 4m40s
decky / build-publish (push) Successful in 26s
deb / build-publish (push) Successful in 2m57s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 33s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 2m56s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 2m35s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 2m20s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 53s
flatpak / build-publish (push) Successful in 4m22s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 8m51s
docker / deploy-docs (push) Successful in 21s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 8m50s
Adds negotiated 5.1/7.1 surround to the punktfunk/1 protocol and every client (previously stereo-only): - core: new shared `audio` layout table (LAYOUT_51/71 + identity multistream mapping, canonical wire order FL FR FC LFE RL RR SL SR); Hello/Welcome `audio_channels` negotiation via the trailing-byte back-compat pattern (old peers fall back to stereo); C-ABI `punktfunk_connect_ex6`, `punktfunk_connection_audio_channels`, and in-core multistream decode `punktfunk_connection_next_audio_pcm` for embedders without a multistream Opus decoder. Real-libopus channel-identity round-trip test. - host: native audio thread captures + Opus-(multi)stream-encodes at the negotiated count (with a cross-session cached-capturer channel-mismatch fix); GameStream surround unified onto the safe `opus::MSEncoder`, dropping `audiopus_sys` (~4 unsafe blocks) and un-gating Windows GameStream surround; WASAPI loopback capture relaxed to 2/6/8 with the correct dwChannelMask. - clients: Linux (PipeWire), Windows (WASAPI), Android (AAudio) decode via `opus::MSDecoder` + render multichannel; Apple decodes in-core to PCM → AVAudioEngine with an explicit wire-order channel layout; each gains a Stereo/5.1/7.1 setting. `punktfunk-probe --audio-channels N` is the headless validator. Verified on Linux: core/host/linux/probe test suites + the Android Rust (cargo-ndk) build, clippy -D warnings, and rustfmt all green. Windows/Apple builds, all on-glass checks, and the live native loopback are pending (CI / a free box). Also lands the concurrent in-tree HEVC 4:4:4 host work (PUNKTFUNK_444): it shares the same touched files (quic.rs, punktfunk1.rs, encode/*, ...) and so cannot be committed separately from the surround changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ crate-type = ["lib", "cdylib", "staticlib"]
|
||||
default = []
|
||||
# Control-plane QUIC (pairing, config, reverse audio). tokio is permitted ONLY here,
|
||||
# never on the per-frame hot path. Off by default so the core stays runtime-free.
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2"]
|
||||
quic = ["dep:quinn", "dep:tokio", "dep:rustls", "dep:rcgen", "dep:rustls-pki-types", "dep:sha2", "dep:hmac", "dep:spake2", "dep:opus"]
|
||||
|
||||
[dependencies]
|
||||
reed-solomon-simd = "3.1" # GF(2^16) Leopard-RS, SIMD, O(n log n) — the wall-breaker (P2)
|
||||
@@ -51,6 +51,12 @@ sha2 = { version = "0.10", optional = true }
|
||||
hmac = { version = "0.12", optional = true }
|
||||
spake2 = { version = "0.4", optional = true }
|
||||
tokio = { version = "1", optional = true, features = ["rt-multi-thread", "net", "sync", "macros"] }
|
||||
# In-core Opus (multistream) DECODE for the C-ABI `punktfunk_connection_next_audio_pcm` path —
|
||||
# used by embedders without a multistream-capable Opus decoder (Apple's AudioToolbox is
|
||||
# stereo-only). The Rust clients link `opus` themselves and decode the raw `next_audio` frames,
|
||||
# so this only matters when the connection API (quic) is built. Same libopus the host vendors;
|
||||
# cargo unifies the build. Multistream API: `opus::MSDecoder` (lib.rs:1187).
|
||||
opus = { version = "0.3", optional = true }
|
||||
|
||||
# `libc` for batched UDP syscalls: `sendmmsg`/`recvmmsg` on Linux (the 1 Gbps+ lever) and the
|
||||
# `recv(MSG_DONTWAIT)` drain on the other unix (Apple/BSD) targets, which have no `recvmmsg`
|
||||
|
||||
@@ -467,6 +467,23 @@ pub struct PunktfunkConnection {
|
||||
last: std::sync::Mutex<Option<crate::session::Frame>>,
|
||||
/// Same, for `punktfunk_connection_next_audio` (independent of the video slot).
|
||||
last_audio: std::sync::Mutex<Option<crate::client::AudioPacket>>,
|
||||
/// Decode-in-core state for `punktfunk_connection_next_audio_pcm` (Apple / any embedder
|
||||
/// without a multistream Opus decoder). The decoder is built lazily from the negotiated
|
||||
/// `inner.audio_channels`; `pcm` is a fixed-capacity reusable buffer the returned pointer
|
||||
/// borrows until the next PCM call (same contract as `last_audio`).
|
||||
audio_pcm: std::sync::Mutex<AudioPcmState>,
|
||||
}
|
||||
|
||||
/// Lazily-initialized in-core Opus decode state. A coupled-1-stream multistream decoder is
|
||||
/// equivalent to a plain stereo decoder, so one [`opus::MSDecoder`] handles 2/6/8 channels.
|
||||
#[cfg(feature = "quic")]
|
||||
#[derive(Default)]
|
||||
struct AudioPcmState {
|
||||
decoder: Option<opus::MSDecoder>,
|
||||
/// Interleaved f32 PCM, wire channel order. Pre-sized to the largest legal Opus frame
|
||||
/// (120 ms @ 48 kHz = 5760 samples/ch) × 8 channels so decode never reallocates (which would
|
||||
/// dangle the pointer handed to the embedder).
|
||||
pcm: Vec<f32>,
|
||||
}
|
||||
|
||||
/// `PunktfunkHidOutput::kind` — lightbar RGB (`r`/`g`/`b` valid).
|
||||
@@ -708,12 +725,18 @@ pub const PUNKTFUNK_VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can present
|
||||
/// BT.2020 PQ HDR10 (implies 10-bit). (Mirrors `quic::VIDEO_CAP_HDR`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_HDR: u8 = 0x02;
|
||||
/// Video-capability bit for [`punktfunk_connect_ex5`] (`video_caps`): the client can decode a
|
||||
/// full-chroma 4:4:4 HEVC stream (Range Extensions). The host emits 4:4:4 only when this is set,
|
||||
/// the host opted in, the codec is HEVC, and the GPU supports it — else the stream stays 4:2:0 and
|
||||
/// [`punktfunk_connection_chroma_format`] reports the real value. (Mirrors `quic::VIDEO_CAP_444`.)
|
||||
pub const PUNKTFUNK_VIDEO_CAP_444: u8 = 0x04;
|
||||
|
||||
// Keep the ABI cap bits in lockstep with the wire constants (compile-time guard against drift).
|
||||
#[cfg(feature = "quic")]
|
||||
const _: () = {
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_10BIT == crate::quic::VIDEO_CAP_10BIT);
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_HDR == crate::quic::VIDEO_CAP_HDR);
|
||||
assert!(PUNKTFUNK_VIDEO_CAP_444 == crate::quic::VIDEO_CAP_444);
|
||||
};
|
||||
|
||||
// Keep the ABI gamepad constants in lockstep with the wire enum (compile-time guard against drift).
|
||||
@@ -980,6 +1003,58 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
// Delegate to the surround-aware variant requesting stereo (the pre-surround behaviour).
|
||||
unsafe {
|
||||
punktfunk_connect_ex6(
|
||||
host,
|
||||
port,
|
||||
width,
|
||||
height,
|
||||
refresh_hz,
|
||||
compositor,
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
2, // audio_channels = stereo
|
||||
launch_id,
|
||||
pin_sha256,
|
||||
observed_sha256_out,
|
||||
client_cert_pem,
|
||||
client_key_pem,
|
||||
timeout_ms,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Like [`punktfunk_connect_ex5`], but additionally requests the audio channel count:
|
||||
/// `2` (stereo, the default behaviour of every earlier variant), `6` (5.1) or `8` (7.1). The host
|
||||
/// clamps the request to what it can actually capture and echoes the resolved count via
|
||||
/// [`punktfunk_connection_audio_channels`]; the `0xC9` audio frames are Opus-(multi)stream encoded
|
||||
/// for that layout. A client that wants surround calls this; everything else inherits stereo.
|
||||
///
|
||||
/// # Safety
|
||||
/// Same as [`punktfunk_connect`].
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub unsafe extern "C" fn punktfunk_connect_ex6(
|
||||
host: *const std::os::raw::c_char,
|
||||
port: u16,
|
||||
width: u32,
|
||||
height: u32,
|
||||
refresh_hz: u32,
|
||||
compositor: u32,
|
||||
gamepad: u32,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
launch_id: *const std::os::raw::c_char,
|
||||
pin_sha256: *const u8,
|
||||
observed_sha256_out: *mut u8,
|
||||
client_cert_pem: *const std::os::raw::c_char,
|
||||
client_key_pem: *const std::os::raw::c_char,
|
||||
timeout_ms: u32,
|
||||
) -> *mut PunktfunkConnection {
|
||||
let r = std::panic::catch_unwind(AssertUnwindSafe(|| {
|
||||
if host.is_null() {
|
||||
@@ -1029,6 +1104,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
crate::audio::normalize_channels(audio_channels),
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -1045,6 +1121,7 @@ pub unsafe extern "C" fn punktfunk_connect_ex5(
|
||||
inner: c,
|
||||
last: std::sync::Mutex::new(None),
|
||||
last_audio: std::sync::Mutex::new(None),
|
||||
audio_pcm: std::sync::Mutex::new(AudioPcmState::default()),
|
||||
}))
|
||||
}
|
||||
Err(_) => std::ptr::null_mut(),
|
||||
@@ -1250,6 +1327,121 @@ pub unsafe extern "C" fn punktfunk_connection_next_audio(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the audio channel count the host resolved for this session (from its Welcome): `2`
|
||||
/// (stereo), `6` (5.1) or `8` (7.1). `*out` is filled when non-NULL. The `0xC9` Opus frames are
|
||||
/// (multistream-)encoded for this layout; an embedder decoding raw frames itself must build its
|
||||
/// decoder from THIS value (see [`crate::audio::layout_for`]) — or use
|
||||
/// [`punktfunk_connection_next_audio_pcm`], which decodes in-core. Available immediately after a
|
||||
/// successful connect (it doesn't change without a reconfigure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_audio_channels(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if !out.is_null() {
|
||||
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
|
||||
unsafe { *out = c.inner.audio_channels };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// One decoded audio frame from [`punktfunk_connection_next_audio_pcm`]: interleaved 32-bit
|
||||
/// float PCM at 48 kHz, in the canonical wire channel order `FL FR FC LFE RL RR SL SR` (the
|
||||
/// first `channels` of it). `samples` points at `frame_count * channels` floats and borrows
|
||||
/// connection memory **until the next PCM call** on this handle.
|
||||
#[cfg(feature = "quic")]
|
||||
#[repr(C)]
|
||||
pub struct PunktfunkAudioPcm {
|
||||
/// Interleaved f32 samples (wire channel order), `frame_count * channels` long.
|
||||
pub samples: *const f32,
|
||||
/// Samples per channel in this frame.
|
||||
pub frame_count: u32,
|
||||
/// Channel count (2/6/8) — the negotiated [`punktfunk_connection_audio_channels`].
|
||||
pub channels: u8,
|
||||
/// Source packet sequence number.
|
||||
pub seq: u32,
|
||||
/// Capture presentation timestamp (ns).
|
||||
pub pts_ns: u64,
|
||||
}
|
||||
|
||||
/// Pull the next audio frame and **decode it in-core** to interleaved f32 PCM — for embedders
|
||||
/// without a multistream-capable Opus decoder (e.g. Apple, whose AudioToolbox Opus path is
|
||||
/// stereo-only). The decoder is built once from the negotiated channel count and handles 2/6/8
|
||||
/// channels (a 1-coupled-stream multistream decoder is exactly a stereo decoder). Same
|
||||
/// timeout/closed semantics as [`punktfunk_connection_next_audio`]; `out->samples` borrows
|
||||
/// connection memory until the next PCM call on this handle. Use EITHER this or
|
||||
/// [`punktfunk_connection_next_audio`] on a given connection, from one dedicated audio thread —
|
||||
/// not both (they share the underlying queue).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is writable. At most one thread pulls audio.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_next_audio_pcm(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut PunktfunkAudioPcm,
|
||||
timeout_ms: u32,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if out.is_null() {
|
||||
return PunktfunkStatus::NullPointer;
|
||||
}
|
||||
let channels = crate::audio::normalize_channels(c.inner.audio_channels);
|
||||
let pkt = match c
|
||||
.inner
|
||||
.next_audio(std::time::Duration::from_millis(timeout_ms as u64))
|
||||
{
|
||||
Ok(pkt) => pkt,
|
||||
Err(e) => return e.status(),
|
||||
};
|
||||
let mut state = c.audio_pcm.lock().unwrap();
|
||||
if state.decoder.is_none() {
|
||||
let layout = crate::audio::layout_for(channels, false);
|
||||
match opus::MSDecoder::new(48_000, layout.streams, layout.coupled, layout.mapping) {
|
||||
Ok(d) => {
|
||||
// Largest legal Opus frame is 120 ms = 5760 samples/ch.
|
||||
state.pcm = vec![0f32; 5760 * channels as usize];
|
||||
state.decoder = Some(d);
|
||||
}
|
||||
Err(_) => return PunktfunkStatus::Unsupported,
|
||||
}
|
||||
}
|
||||
let AudioPcmState { decoder, pcm } = &mut *state;
|
||||
let dec = decoder.as_mut().unwrap();
|
||||
// `decode_float` divides the output buffer length by the channel count to get the
|
||||
// per-channel capacity; an empty payload requests packet-loss concealment.
|
||||
match dec.decode_float(&pkt.data, pcm, false) {
|
||||
Ok(frame_count) => {
|
||||
unsafe {
|
||||
*out = PunktfunkAudioPcm {
|
||||
samples: pcm.as_ptr(),
|
||||
frame_count: frame_count as u32,
|
||||
channels,
|
||||
seq: pkt.seq,
|
||||
pts_ns: pkt.pts_ns,
|
||||
};
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
}
|
||||
Err(_) => PunktfunkStatus::BadPacket,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Pull the next rumble (force-feedback) update, waiting up to `timeout_ms`. Amplitudes
|
||||
/// are 0..0xFFFF (`low` = low-frequency motor, `high` = high-frequency), `(0, 0)` = stop.
|
||||
/// Same timeout/closed semantics as [`punktfunk_connection_next_audio`].
|
||||
@@ -1414,6 +1606,33 @@ pub unsafe extern "C" fn punktfunk_connection_color_info(
|
||||
})
|
||||
}
|
||||
|
||||
/// Read the session's resolved chroma subsampling (from the host's Welcome) as the HEVC
|
||||
/// `chroma_format_idc`: `1` = 4:2:0 (the default every pre-4:4:4 host produced), `3` = full-chroma
|
||||
/// 4:4:4. `*out` is filled when non-NULL. The in-band SPS is authoritative; this lets the embedder
|
||||
/// pre-size its decoder / pick a 4:4:4 pixel format up front. Available immediately after a
|
||||
/// successful connect (it doesn't change without a reconfigure).
|
||||
///
|
||||
/// # Safety
|
||||
/// `c` is a valid connection handle; `out` is NULL or writable for one `u8`.
|
||||
#[cfg(feature = "quic")]
|
||||
#[no_mangle]
|
||||
pub unsafe extern "C" fn punktfunk_connection_chroma_format(
|
||||
c: *mut PunktfunkConnection,
|
||||
out: *mut u8,
|
||||
) -> PunktfunkStatus {
|
||||
guard(|| {
|
||||
let c = match unsafe { c.as_ref() } {
|
||||
Some(c) => c,
|
||||
None => return PunktfunkStatus::NullPointer,
|
||||
};
|
||||
if !out.is_null() {
|
||||
// SAFETY: `out` is non-null and the caller guarantees it is writable for one `u8`.
|
||||
unsafe { *out = c.inner.chroma_format };
|
||||
}
|
||||
PunktfunkStatus::Ok
|
||||
})
|
||||
}
|
||||
|
||||
/// Send one input event to the host as a QUIC datagram (non-blocking enqueue).
|
||||
///
|
||||
/// # Safety
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Shared audio layout: the single source of truth for Opus (multi)stream surround across the
|
||||
//! host, the GameStream compatibility path, and every client decoder.
|
||||
//!
|
||||
//! **Canonical wire channel order** is `FL FR FC LFE RL RR SL SR` (the GameStream/Moonlight
|
||||
//! order, and the PipeWire/PulseAudio default map for 6/8 channels). Every host capturer
|
||||
//! delivers PCM in this order and every client decodes into it, so the Opus multistream
|
||||
//! `mapping` is the **identity** (`[0, 1, …, channels-1]`) on both ends — punktfunk owns the
|
||||
//! encoder and every decoder, so the GFE-style pre-rotation Moonlight needs over SDP
|
||||
//! (`gamestream::audio::surround_params`) is a GameStream-only concern and never touches the
|
||||
//! native `punktfunk/1` path.
|
||||
//!
|
||||
//! Channel counts the protocol negotiates: `2` (stereo), `6` (5.1) and `8` (7.1). Anything
|
||||
//! else clamps to stereo ([`normalize_channels`]).
|
||||
|
||||
/// Canonical wire channel positions; the index is the channel's slot in the interleaved PCM
|
||||
/// frame. A count of N uses positions `0..N` (always a prefix of this 8-channel order).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
#[repr(u8)]
|
||||
pub enum WirePos {
|
||||
FrontLeft = 0,
|
||||
FrontRight = 1,
|
||||
FrontCenter = 2,
|
||||
Lfe = 3,
|
||||
RearLeft = 4,
|
||||
RearRight = 5,
|
||||
SideLeft = 6,
|
||||
SideRight = 7,
|
||||
}
|
||||
|
||||
/// The full 8-channel wire order; the N-channel order is its first N entries.
|
||||
pub const WIRE_ORDER_8: [WirePos; 8] = {
|
||||
use WirePos::*;
|
||||
[
|
||||
FrontLeft,
|
||||
FrontRight,
|
||||
FrontCenter,
|
||||
Lfe,
|
||||
RearLeft,
|
||||
RearRight,
|
||||
SideLeft,
|
||||
SideRight,
|
||||
]
|
||||
};
|
||||
|
||||
/// One Opus (multi)stream layout. `mapping` is the libopus multistream mapping we encode AND
|
||||
/// decode with — identity, since punktfunk owns both ends. `streams`/`coupled` give the
|
||||
/// normal-quality coupling (FL,FR)+(FC,LFE) [+(RL,RR) on 7.1] with the remaining channels as
|
||||
/// mono streams; high quality is one mono stream per channel. Bitrates match Sunshine's
|
||||
/// per-config values (stereo keeps punktfunk's live-validated 128 kbps).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct OpusLayout {
|
||||
/// Interleaved channel count (2, 6 or 8).
|
||||
pub channels: u8,
|
||||
/// Number of Opus streams in the multistream packet.
|
||||
pub streams: u8,
|
||||
/// How many of those streams are coupled (stereo) pairs.
|
||||
pub coupled: u8,
|
||||
/// libopus multistream channel mapping — identity `[0, 1, …, channels-1]`.
|
||||
pub mapping: &'static [u8],
|
||||
/// Target Opus bitrate in bits/sec (hard CBR; constant packet size, which GameStream's
|
||||
/// audio FEC relies on).
|
||||
pub bitrate: i32,
|
||||
}
|
||||
|
||||
/// Stereo: a plain coupled pair. The 128 kbps live-validated config.
|
||||
pub const LAYOUT_STEREO: OpusLayout = OpusLayout {
|
||||
channels: 2,
|
||||
streams: 1,
|
||||
coupled: 1,
|
||||
mapping: &[0, 1],
|
||||
bitrate: 128_000,
|
||||
};
|
||||
/// 5.1 normal quality: (FL,FR)+(FC,LFE) coupled, RL+RR mono.
|
||||
pub const LAYOUT_51: OpusLayout = OpusLayout {
|
||||
channels: 6,
|
||||
streams: 4,
|
||||
coupled: 2,
|
||||
mapping: &[0, 1, 2, 3, 4, 5],
|
||||
bitrate: 256_000,
|
||||
};
|
||||
/// 5.1 high quality: one mono stream per channel.
|
||||
pub const LAYOUT_51_HQ: OpusLayout = OpusLayout {
|
||||
channels: 6,
|
||||
streams: 6,
|
||||
coupled: 0,
|
||||
mapping: &[0, 1, 2, 3, 4, 5],
|
||||
bitrate: 1_536_000,
|
||||
};
|
||||
/// 7.1 normal quality: (FL,FR)+(FC,LFE)+(RL,RR) coupled, SL+SR mono.
|
||||
pub const LAYOUT_71: OpusLayout = OpusLayout {
|
||||
channels: 8,
|
||||
streams: 5,
|
||||
coupled: 3,
|
||||
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
bitrate: 450_000,
|
||||
};
|
||||
/// 7.1 high quality: one mono stream per channel.
|
||||
pub const LAYOUT_71_HQ: OpusLayout = OpusLayout {
|
||||
channels: 8,
|
||||
streams: 8,
|
||||
coupled: 0,
|
||||
mapping: &[0, 1, 2, 3, 4, 5, 6, 7],
|
||||
bitrate: 2_048_000,
|
||||
};
|
||||
|
||||
/// Pick the layout for a negotiated channel count. Unknown counts fall back to stereo (clients
|
||||
/// only ever request 2/6/8). `high_quality` selects the uncoupled high-bitrate config.
|
||||
pub fn layout_for(channels: u8, high_quality: bool) -> &'static OpusLayout {
|
||||
match (channels, high_quality) {
|
||||
(6, false) => &LAYOUT_51,
|
||||
(6, true) => &LAYOUT_51_HQ,
|
||||
(8, false) => &LAYOUT_71,
|
||||
(8, true) => &LAYOUT_71_HQ,
|
||||
_ => &LAYOUT_STEREO,
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp an arbitrary (wire / requested) channel count to one the protocol negotiates. `0`,
|
||||
/// absent, or any unsupported value becomes stereo.
|
||||
pub fn normalize_channels(requested: u8) -> u8 {
|
||||
match requested {
|
||||
6 => 6,
|
||||
8 => 8,
|
||||
_ => 2,
|
||||
}
|
||||
}
|
||||
|
||||
// ---- per-platform channel-layout helpers (pure data; no platform deps) --------------------
|
||||
|
||||
/// Windows `WAVEFORMATEXTENSIBLE.dwChannelMask` for the wire layout.
|
||||
///
|
||||
/// NB 7.1 == `0x63F` (FL FR FC LFE **BL BR SL SR**), NOT `0xFF` — `0xFF` selects the
|
||||
/// front-of-center pair FLC/FRC, the wrong speakers. WASAPI delivers channels in ascending
|
||||
/// mask-bit order, which equals the wire order, so the decoded PCM needs no permutation.
|
||||
pub const fn wasapi_channel_mask(channels: u8) -> u32 {
|
||||
const FL: u32 = 0x1;
|
||||
const FR: u32 = 0x2;
|
||||
const FC: u32 = 0x4;
|
||||
const LFE: u32 = 0x8;
|
||||
const BL: u32 = 0x10; // back left (wire RL)
|
||||
const BR: u32 = 0x20; // back right (wire RR)
|
||||
const SL: u32 = 0x200; // side left
|
||||
const SR: u32 = 0x400; // side right
|
||||
match channels {
|
||||
6 => FL | FR | FC | LFE | BL | BR, // 0x3F
|
||||
8 => FL | FR | FC | LFE | BL | BR | SL | SR, // 0x63F
|
||||
_ => FL | FR, // 0x3 (stereo)
|
||||
}
|
||||
}
|
||||
|
||||
/// PipeWire / SPA `enum spa_audio_channel` positions in wire order — identical to the host
|
||||
/// capture side (`punktfunk-host` `audio::linux::spa_positions`): FL=3 FR=4 FC=5 LFE=6 SL=7
|
||||
/// SR=8 RL=12 RR=13. Identity routing: the client sets these on its playback node so PipeWire
|
||||
/// maps each wire slot to the matching speaker (and downmixes when the sink has fewer).
|
||||
pub fn spa_positions(channels: u8) -> &'static [u32] {
|
||||
const STEREO: [u32; 2] = [3, 4]; // FL FR
|
||||
const C51: [u32; 6] = [3, 4, 5, 6, 12, 13]; // FL FR FC LFE RL RR
|
||||
const C71: [u32; 8] = [3, 4, 5, 6, 12, 13, 7, 8]; // FL FR FC LFE RL RR SL SR
|
||||
match channels {
|
||||
6 => &C51,
|
||||
8 => &C71,
|
||||
_ => &STEREO,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn layout_table_is_consistent() {
|
||||
for l in [
|
||||
&LAYOUT_STEREO,
|
||||
&LAYOUT_51,
|
||||
&LAYOUT_51_HQ,
|
||||
&LAYOUT_71,
|
||||
&LAYOUT_71_HQ,
|
||||
] {
|
||||
// Mapping is identity and exactly `channels` entries long.
|
||||
assert_eq!(l.mapping.len(), l.channels as usize);
|
||||
for (i, &m) in l.mapping.iter().enumerate() {
|
||||
assert_eq!(m as usize, i, "mapping must be identity for {l:?}");
|
||||
}
|
||||
// libopus invariant: total channels == coupled*2 + (streams - coupled).
|
||||
assert_eq!(
|
||||
l.coupled * 2 + (l.streams - l.coupled),
|
||||
l.channels,
|
||||
"stream/coupled accounting for {l:?}"
|
||||
);
|
||||
assert!(l.coupled <= l.streams);
|
||||
assert!(l.bitrate > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layout_for_picks_expected() {
|
||||
assert_eq!(layout_for(2, false), &LAYOUT_STEREO);
|
||||
assert_eq!(layout_for(6, false), &LAYOUT_51);
|
||||
assert_eq!(layout_for(6, true), &LAYOUT_51_HQ);
|
||||
assert_eq!(layout_for(8, false), &LAYOUT_71);
|
||||
assert_eq!(layout_for(8, true), &LAYOUT_71_HQ);
|
||||
// Unknown / 0 → stereo.
|
||||
assert_eq!(layout_for(0, false), &LAYOUT_STEREO);
|
||||
assert_eq!(layout_for(3, false), &LAYOUT_STEREO);
|
||||
assert_eq!(layout_for(7, true), &LAYOUT_STEREO);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_clamps_to_negotiable() {
|
||||
assert_eq!(normalize_channels(2), 2);
|
||||
assert_eq!(normalize_channels(6), 6);
|
||||
assert_eq!(normalize_channels(8), 8);
|
||||
for bad in [0u8, 1, 3, 4, 5, 7, 9, 255] {
|
||||
assert_eq!(normalize_channels(bad), 2, "{bad} must clamp to stereo");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wasapi_masks_are_correct() {
|
||||
assert_eq!(wasapi_channel_mask(2), 0x3);
|
||||
assert_eq!(wasapi_channel_mask(6), 0x3F);
|
||||
assert_eq!(wasapi_channel_mask(8), 0x63F); // NOT 0xFF
|
||||
// Bit count must equal the channel count.
|
||||
assert_eq!(wasapi_channel_mask(2).count_ones(), 2);
|
||||
assert_eq!(wasapi_channel_mask(6).count_ones(), 6);
|
||||
assert_eq!(wasapi_channel_mask(8).count_ones(), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spa_positions_match_wire_order() {
|
||||
assert_eq!(spa_positions(2), &[3, 4]);
|
||||
assert_eq!(spa_positions(6), &[3, 4, 5, 6, 12, 13]);
|
||||
assert_eq!(spa_positions(8), &[3, 4, 5, 6, 12, 13, 7, 8]);
|
||||
assert_eq!(spa_positions(2).len(), 2);
|
||||
assert_eq!(spa_positions(6).len(), 6);
|
||||
assert_eq!(spa_positions(8).len(), 8);
|
||||
}
|
||||
|
||||
/// Real-libopus proof that the shared layout round-trips with channel identity: a tone fed
|
||||
/// into wire channel N (host `opus::MSEncoder`) comes back out on channel N (client
|
||||
/// `opus::MSDecoder`), for stereo / 5.1 / 7.1. This is the single guarantee the whole
|
||||
/// feature rests on — encoder layout == decoder layout == identity mapping — so if a layout
|
||||
/// constant is ever wrong, this fails. Gated on `quic` (where `opus` is a dependency).
|
||||
#[cfg(feature = "quic")]
|
||||
#[test]
|
||||
fn multistream_layout_roundtrips_with_channel_identity() {
|
||||
const SR: u32 = 48_000;
|
||||
const SAMPLES: usize = 240; // 5 ms @ 48 kHz
|
||||
for &channels in &[2u8, 6, 8] {
|
||||
let l = layout_for(channels, false);
|
||||
let ch = l.channels as usize;
|
||||
let mut enc = opus::MSEncoder::new(
|
||||
SR,
|
||||
l.streams,
|
||||
l.coupled,
|
||||
l.mapping,
|
||||
opus::Application::LowDelay,
|
||||
)
|
||||
.expect("MSEncoder");
|
||||
enc.set_bitrate(opus::Bitrate::Bits(l.bitrate)).unwrap();
|
||||
enc.set_vbr(false).unwrap();
|
||||
let mut dec =
|
||||
opus::MSDecoder::new(SR, l.streams, l.coupled, l.mapping).expect("MSDecoder");
|
||||
|
||||
for tone_ch in 0..ch {
|
||||
let mut out = vec![0u8; 4000];
|
||||
let mut energy = vec![0f64; ch];
|
||||
// A few frames to clear the codec startup transient before measuring.
|
||||
for f in 0..8 {
|
||||
let mut frame = vec![0f32; SAMPLES * ch];
|
||||
for t in 0..SAMPLES {
|
||||
let phase = (f * SAMPLES + t) as f32 * 440.0 * 2.0 * std::f32::consts::PI
|
||||
/ SR as f32;
|
||||
frame[t * ch + tone_ch] = 0.5 * phase.sin();
|
||||
}
|
||||
let n = enc.encode_float(&frame, &mut out).unwrap();
|
||||
let mut decoded = vec![0f32; SAMPLES * ch];
|
||||
let got = dec.decode_float(&out[..n], &mut decoded, false).unwrap();
|
||||
assert_eq!(got, SAMPLES, "{channels}ch frame size");
|
||||
if f >= 4 {
|
||||
for t in 0..SAMPLES {
|
||||
for (c, e) in energy.iter_mut().enumerate() {
|
||||
*e += (decoded[t * ch + c] as f64).powi(2);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let loudest = (0..ch)
|
||||
.max_by(|&a, &b| energy[a].total_cmp(&energy[b]))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
loudest, tone_ch,
|
||||
"{channels}ch: tone in channel {tone_ch} must come out on {tone_ch} (energies {energy:?})"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,8 +40,9 @@ enum CtrlRequest {
|
||||
/// mode, the host-resolved compositor backend, the host-resolved gamepad backend, the host's
|
||||
/// certificate fingerprint, the resolved encoder bitrate (kbps), and the host↔client clock offset
|
||||
/// (ns, host minus client; 0 = no skew correction / an old host that didn't answer the handshake).
|
||||
/// The trailing `u8` is the resolved encode bit depth (8/10) and [`ColorInfo`] the resolved colour
|
||||
/// signalling, both from the [`Welcome`].
|
||||
/// The trailing `u8`s are the resolved encode bit depth (8/10), the chroma `chroma_format_idc`
|
||||
/// (1 = 4:2:0, 3 = 4:4:4), and the resolved audio channel count (2/6/8), with [`ColorInfo`] the
|
||||
/// resolved colour signalling — all from the [`Welcome`].
|
||||
type Negotiated = (
|
||||
Mode,
|
||||
CompositorPref,
|
||||
@@ -51,6 +52,8 @@ type Negotiated = (
|
||||
i64,
|
||||
u8,
|
||||
ColorInfo,
|
||||
u8,
|
||||
u8,
|
||||
);
|
||||
|
||||
/// Accumulated state of an in-flight / finished speed test. The data-plane pump mirrors the
|
||||
@@ -202,6 +205,17 @@ pub struct NativeClient {
|
||||
/// decoder/presenter from this. [`ColorInfo::SDR_BT709`] for an older host. The static HDR
|
||||
/// mastering metadata (when [`ColorInfo::is_hdr`]) arrives via [`NativeClient::next_hdr_meta`].
|
||||
pub color: ColorInfo,
|
||||
/// The chroma subsampling the host resolved for this session ([`Welcome::chroma_format`]), as the
|
||||
/// HEVC `chroma_format_idc`: [`quic::CHROMA_IDC_420`] (4:2:0, the default / older host) or
|
||||
/// [`quic::CHROMA_IDC_444`] (full-chroma 4:4:4). The in-band SPS is authoritative; this lets the
|
||||
/// client pre-size its decoder. `CHROMA_IDC_420` for an older host that didn't report it.
|
||||
pub chroma_format: u8,
|
||||
/// The audio channel count the host resolved for this session ([`Welcome::audio_channels`]):
|
||||
/// `2` (stereo), `6` (5.1) or `8` (7.1). The client MUST build its Opus (multistream) decoder
|
||||
/// from this value (via [`crate::audio::layout_for`]) — never from its own request — so an older
|
||||
/// host that omits it (→ `2`) yields working stereo. The `0xC9` audio frames are encoded with the
|
||||
/// matching layout.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
/// Pin the calling thread to the user-interactive QoS class on Apple targets.
|
||||
@@ -246,6 +260,9 @@ impl NativeClient {
|
||||
// VIDEO_CAP_HDR) — the host upgrades to a 10-bit / HDR encode only when the matching bit is
|
||||
// set. 0 = the 8-bit BT.709 stream every client understands.
|
||||
video_caps: u8,
|
||||
// Requested audio channel count (2 = stereo / 6 = 5.1 / 8 = 7.1); the host clamps to what it
|
||||
// can capture and echoes the result in [`NativeClient::audio_channels`].
|
||||
audio_channels: u8,
|
||||
launch: Option<String>,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
@@ -298,6 +315,7 @@ impl NativeClient {
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -329,6 +347,8 @@ impl NativeClient {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
) = match ready_rx.recv_timeout(timeout) {
|
||||
Ok(Ok(t)) => t,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
@@ -360,6 +380,8 @@ impl NativeClient {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -666,6 +688,7 @@ struct WorkerArgs {
|
||||
gamepad: GamepadPref,
|
||||
bitrate_kbps: u32,
|
||||
video_caps: u8,
|
||||
audio_channels: u8,
|
||||
launch: Option<String>,
|
||||
pin: Option<[u8; 32]>,
|
||||
identity: Option<(String, String)>,
|
||||
@@ -697,6 +720,7 @@ async fn worker_main(args: WorkerArgs) {
|
||||
gamepad,
|
||||
bitrate_kbps,
|
||||
video_caps,
|
||||
audio_channels,
|
||||
launch,
|
||||
pin,
|
||||
identity,
|
||||
@@ -763,6 +787,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
// VIDEO_CAP_10BIT | VIDEO_CAP_HDR). The host only upgrades to a 10-bit / HDR encode
|
||||
// when the matching bit is set, so `0` stays an 8-bit BT.709 stream.
|
||||
video_caps,
|
||||
// Requested surround channel count; the host echoes the resolved value in Welcome.
|
||||
audio_channels,
|
||||
}
|
||||
.encode(),
|
||||
)
|
||||
@@ -834,6 +860,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
clock_offset_ns,
|
||||
welcome.bit_depth,
|
||||
welcome.color,
|
||||
welcome.chroma_format,
|
||||
welcome.audio_channels,
|
||||
))
|
||||
};
|
||||
|
||||
@@ -850,6 +878,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
) = match setup.await {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
@@ -866,6 +896,8 @@ async fn worker_main(args: WorkerArgs) {
|
||||
clock_offset_ns,
|
||||
bit_depth,
|
||||
color,
|
||||
chroma_format,
|
||||
audio_channels,
|
||||
)));
|
||||
|
||||
// Input task: embedder events → QUIC datagrams.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
#![forbid(unsafe_op_in_unsafe_fn)]
|
||||
|
||||
pub mod abi;
|
||||
pub mod audio;
|
||||
#[cfg(feature = "quic")]
|
||||
pub mod client;
|
||||
pub mod config;
|
||||
|
||||
@@ -78,12 +78,33 @@ pub struct Hello {
|
||||
/// zero-length name/launch placeholder precedes it when those are absent so the offset stays
|
||||
/// deterministic. Omitted by older clients (decodes to `0`).
|
||||
pub video_caps: u8,
|
||||
/// Requested audio channel count: `2` (stereo, default), `6` (5.1) or `8` (7.1). The host
|
||||
/// resolves it against what it can capture and echoes the final count in
|
||||
/// [`Welcome::audio_channels`], which is what both ends build their Opus (multistream)
|
||||
/// codec from. Appended after `video_caps` as a single trailing byte; when it differs from
|
||||
/// the stereo default the name/launch/video_caps placeholders are forced (0) so it lands at a
|
||||
/// deterministic offset. Omitted by older clients / when `2` (decodes to `2`, i.e. stereo) so
|
||||
/// the stereo wire form stays byte-identical to the pre-surround build.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
/// [`Hello::video_caps`] bit: the client can decode a 10-bit (Main10) HEVC stream.
|
||||
pub const VIDEO_CAP_10BIT: u8 = 0x01;
|
||||
/// [`Hello::video_caps`] bit: the client can present BT.2020 PQ HDR10 (implies 10-bit).
|
||||
pub const VIDEO_CAP_HDR: u8 = 0x02;
|
||||
/// [`Hello::video_caps`] bit: the client can decode a full-chroma **4:4:4** HEVC stream (HEVC
|
||||
/// Range Extensions / Rec.ITU-T H.265 `chroma_format_idc = 3`). The host emits 4:4:4 ONLY when this
|
||||
/// bit is set, the host opted in (`PUNKTFUNK_444`), the codec is HEVC, **and** the GPU/driver
|
||||
/// actually supports a 4:4:4 encode (probed) — otherwise the session stays 4:2:0 and
|
||||
/// [`Welcome::chroma_format`] reflects the real resolved value. Independent of 10-bit/HDR (4:4:4 is a
|
||||
/// chroma decision, bit depth is a depth decision; the two may combine where the hardware allows).
|
||||
pub const VIDEO_CAP_444: u8 = 0x04;
|
||||
|
||||
/// HEVC `chroma_format_idc` for 4:2:0 — what every pre-4:4:4 build produced and the back-compat
|
||||
/// default when a peer omits [`Welcome::chroma_format`].
|
||||
pub const CHROMA_IDC_420: u8 = 1;
|
||||
/// HEVC `chroma_format_idc` for full-chroma 4:4:4 (Range Extensions).
|
||||
pub const CHROMA_IDC_444: u8 = 3;
|
||||
|
||||
/// Per-session colour signalling (CICP / ITU-T H.273 code points) the host resolved for the
|
||||
/// encoded video, carried on [`Welcome`]. A client configures its decoder/presenter from these
|
||||
@@ -198,6 +219,22 @@ pub struct Welcome {
|
||||
/// [`ColorInfo::SDR_BT709`]. The client configures its decoder/presenter from this instead of
|
||||
/// guessing from the bitstream; the mastering metadata arrives separately on [`HDR_META_MAGIC`].
|
||||
pub color: ColorInfo,
|
||||
/// The chroma subsampling the host actually encodes at, as the HEVC `chroma_format_idc`:
|
||||
/// [`CHROMA_IDC_420`] (4:2:0, default / older host) or [`CHROMA_IDC_444`] (full-chroma 4:4:4,
|
||||
/// enabled only when the client advertised [`VIDEO_CAP_444`] *and* the host could open a real
|
||||
/// 4:4:4 encode). The client sizes its decoder/surface pool from this; the in-band SPS carries
|
||||
/// the authoritative value, so this is a hint (and the honest-downgrade channel — if the host
|
||||
/// requested 4:4:4 but the GPU declined, this reads `CHROMA_IDC_420`). Appended after the colour
|
||||
/// bytes as a single trailing byte; an older host that omits it decodes to [`CHROMA_IDC_420`].
|
||||
pub chroma_format: u8,
|
||||
/// The audio channel count the host actually resolved and **will** send on the `0xC9` plane:
|
||||
/// `2` (stereo, default), `6` (5.1) or `8` (7.1). Echoes [`Hello::audio_channels`] clamped to
|
||||
/// what the host can capture (Linux PipeWire always synthesizes the count; Windows WASAPI
|
||||
/// loopback is clamped to the render endpoint's mix-format channels). The client builds its Opus
|
||||
/// (multistream) decoder from THIS value via [`crate::audio::layout_for`] — never from its own
|
||||
/// request — so an older host that omits the byte (→ `2`) always yields working stereo. Appended
|
||||
/// after `chroma_format` as a single trailing byte.
|
||||
pub audio_channels: u8,
|
||||
}
|
||||
|
||||
/// `client → host`: data plane is bound, begin streaming.
|
||||
@@ -630,10 +667,11 @@ impl Hello {
|
||||
// so a Hello with neither name nor launch stays byte-identical to the bitrate-era form
|
||||
// (26 bytes). When `launch` is present we must still emit name's length byte (0 for None)
|
||||
// so `launch` lands at a deterministic offset.
|
||||
// `video_caps` is the last trailing field, after `launch`; when it's present (non-zero)
|
||||
// the name/launch length bytes must still be emitted (0 for absent) so it lands at a
|
||||
// `video_caps`/`audio_channels` are the trailing fields, after `launch`; when either is
|
||||
// present (video_caps non-zero / audio_channels not stereo) the name/launch length bytes
|
||||
// AND the video_caps byte must still be emitted (0 / 0) so the later byte lands at a
|
||||
// deterministic offset — the same discipline `launch` already imposes on `name`.
|
||||
let need_placeholders = self.video_caps != 0;
|
||||
let need_placeholders = self.video_caps != 0 || self.audio_channels != 2;
|
||||
match (&self.name, &self.launch) {
|
||||
(None, None) if !need_placeholders => {}
|
||||
(name, _) => {
|
||||
@@ -648,10 +686,15 @@ impl Hello {
|
||||
b.push(l.len() as u8);
|
||||
b.extend_from_slice(l.as_bytes());
|
||||
}
|
||||
// video_caps: single trailing byte. Last field.
|
||||
if self.video_caps != 0 {
|
||||
// video_caps: single trailing byte. Emitted when non-zero OR when audio_channels follows
|
||||
// (so audio_channels lands at a deterministic offset right after it).
|
||||
if self.video_caps != 0 || self.audio_channels != 2 {
|
||||
b.push(self.video_caps);
|
||||
}
|
||||
// audio_channels: single trailing byte. Last field; omitted when stereo (default).
|
||||
if self.audio_channels != 2 {
|
||||
b.push(self.audio_channels);
|
||||
}
|
||||
b
|
||||
}
|
||||
|
||||
@@ -714,6 +757,15 @@ impl Hello {
|
||||
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
|
||||
b.get(launch_off + 1 + launch_len).copied().unwrap_or(0)
|
||||
},
|
||||
// Optional trailing audio-channel byte, one past video_caps. Absent on an older client
|
||||
// → stereo. Normalized so a corrupt/unsupported value can't build a bad decoder.
|
||||
audio_channels: {
|
||||
let name_len = b.get(26).copied().unwrap_or(0) as usize;
|
||||
let launch_off = 27 + name_len;
|
||||
let launch_len = b.get(launch_off).copied().unwrap_or(0) as usize;
|
||||
let video_caps_off = launch_off + 1 + launch_len;
|
||||
crate::audio::normalize_channels(b.get(video_caps_off + 1).copied().unwrap_or(2))
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -747,6 +799,10 @@ impl Welcome {
|
||||
b.push(self.color.transfer);
|
||||
b.push(self.color.matrix);
|
||||
b.push(self.color.full_range);
|
||||
// Chroma subsampling at offset 64 — older clients stop before this → 4:2:0 (CHROMA_IDC_420).
|
||||
b.push(self.chroma_format);
|
||||
// Audio channel count at offset 65 — older clients stop before this → stereo (2).
|
||||
b.push(self.audio_channels);
|
||||
b
|
||||
}
|
||||
|
||||
@@ -755,7 +811,8 @@ impl Welcome {
|
||||
// scheme[22] pct[23] max_data[24..26] shard[26..28] encrypt[28] key[29..45]
|
||||
// salt[45..49] frames[49..53] compositor[53] gamepad[54] bitrate_kbps[55..59]
|
||||
// bit_depth[59] color.primaries[60] color.transfer[61] color.matrix[62] color.range[63]
|
||||
// (everything from compositor on is an optional trailing byte; an older host stops earlier).
|
||||
// chroma_format[64] audio_channels[65] (everything from compositor on is an optional
|
||||
// trailing byte; an older host stops earlier).
|
||||
if b.len() < 53 || &b[0..4] != MAGIC {
|
||||
return Err(PunktfunkError::InvalidArg("bad Welcome"));
|
||||
}
|
||||
@@ -812,6 +869,15 @@ impl Welcome {
|
||||
matrix: b.get(62).copied().unwrap_or(ColorInfo::MC_BT709),
|
||||
full_range: b.get(63).copied().unwrap_or(0),
|
||||
},
|
||||
// Optional trailing chroma byte — absent on an older host (or an explicit 0 / unknown
|
||||
// value) → 4:2:0. Only `CHROMA_IDC_444` flips the client to a 4:4:4 decode.
|
||||
chroma_format: match b.get(64).copied() {
|
||||
Some(CHROMA_IDC_444) => CHROMA_IDC_444,
|
||||
_ => CHROMA_IDC_420,
|
||||
},
|
||||
// Optional trailing audio-channel byte — absent on an older host → stereo. Any
|
||||
// non-{6,8} value normalizes to stereo so a corrupt byte never builds a bad decoder.
|
||||
audio_channels: crate::audio::normalize_channels(b.get(65).copied().unwrap_or(2)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1809,6 +1875,8 @@ mod tests {
|
||||
bitrate_kbps: 50_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
chroma_format: CHROMA_IDC_444,
|
||||
audio_channels: 2,
|
||||
};
|
||||
assert_eq!(Welcome::decode(&w.encode()).unwrap(), w);
|
||||
}
|
||||
@@ -1851,6 +1919,7 @@ mod tests {
|
||||
name: Some("Test Device".into()),
|
||||
launch: Some("steam:570".into()),
|
||||
video_caps: VIDEO_CAP_10BIT,
|
||||
audio_channels: 2,
|
||||
};
|
||||
assert_eq!(Hello::decode(&h.encode()).unwrap(), h);
|
||||
let s = Start {
|
||||
@@ -1930,6 +1999,7 @@ mod tests {
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
};
|
||||
let enc = h.encode();
|
||||
assert_eq!(enc.len(), 26);
|
||||
@@ -1969,9 +2039,11 @@ mod tests {
|
||||
bitrate_kbps: 120_000,
|
||||
bit_depth: 10,
|
||||
color: ColorInfo::HDR10_BT2020_PQ,
|
||||
chroma_format: CHROMA_IDC_444,
|
||||
audio_channels: 6, // 5.1 — exercises the non-default trailing byte
|
||||
};
|
||||
let wenc = w.encode();
|
||||
assert_eq!(wenc.len(), 64); // 60 base + 4 colour bytes
|
||||
assert_eq!(wenc.len(), 66); // 60 base + 4 colour + 1 chroma + 1 audio-channels byte
|
||||
let legacy_w = Welcome::decode(&wenc[..53]).unwrap();
|
||||
assert_eq!(legacy_w.compositor, CompositorPref::Auto);
|
||||
assert_eq!(legacy_w.gamepad, GamepadPref::Auto);
|
||||
@@ -1991,13 +2063,29 @@ mod tests {
|
||||
let pre_color_w = Welcome::decode(&wenc[..60]).unwrap();
|
||||
assert_eq!(pre_color_w.bit_depth, 10);
|
||||
assert_eq!(pre_color_w.color, ColorInfo::SDR_BT709);
|
||||
assert_eq!(pre_color_w.chroma_format, CHROMA_IDC_420); // pre-chroma host → 4:2:0
|
||||
assert_eq!(legacy_w.color, ColorInfo::SDR_BT709);
|
||||
assert_eq!(legacy_w.chroma_format, CHROMA_IDC_420);
|
||||
// A pre-chroma (64-byte) Welcome carries colour but no chroma/audio bytes → 4:2:0 + stereo.
|
||||
let pre_chroma_w = Welcome::decode(&wenc[..64]).unwrap();
|
||||
assert_eq!(pre_chroma_w.color, ColorInfo::HDR10_BT2020_PQ);
|
||||
assert_eq!(pre_chroma_w.chroma_format, CHROMA_IDC_420);
|
||||
assert_eq!(pre_chroma_w.audio_channels, 2); // audio byte (offset 65) absent → stereo
|
||||
// A pre-audio (65-byte) Welcome carries chroma but no audio byte → 4:4:4 + stereo.
|
||||
let pre_audio_w = Welcome::decode(&wenc[..65]).unwrap();
|
||||
assert_eq!(pre_audio_w.chroma_format, CHROMA_IDC_444);
|
||||
assert_eq!(pre_audio_w.audio_channels, 2);
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().bitrate_kbps, 120_000);
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().bit_depth, 10); // full form carries it
|
||||
assert_eq!(
|
||||
Welcome::decode(&wenc).unwrap().color,
|
||||
ColorInfo::HDR10_BT2020_PQ
|
||||
);
|
||||
assert_eq!(
|
||||
Welcome::decode(&wenc).unwrap().chroma_format,
|
||||
CHROMA_IDC_444
|
||||
); // full form carries 4:4:4
|
||||
assert_eq!(Welcome::decode(&wenc).unwrap().audio_channels, 6); // ...and 5.1
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2015,6 +2103,7 @@ mod tests {
|
||||
name: Some("Enrico's MacBook".into()),
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
};
|
||||
let enc = base.encode();
|
||||
assert_eq!(
|
||||
@@ -2062,6 +2151,7 @@ mod tests {
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
};
|
||||
// launch alone (no name): a zero-length name placeholder keeps the offset deterministic.
|
||||
let with_launch = Hello {
|
||||
@@ -2268,6 +2358,7 @@ mod tests {
|
||||
name: None,
|
||||
launch: None,
|
||||
video_caps: 0,
|
||||
audio_channels: 2,
|
||||
}
|
||||
.encode();
|
||||
assert!(PairRequest::decode(&h).is_err(), "abi {abi} parsed as pair");
|
||||
|
||||
@@ -13,8 +13,10 @@ use std::process::Command;
|
||||
fn native_libs() -> &'static [&'static str] {
|
||||
if cfg!(target_os = "macos") {
|
||||
// The workspace build unifies features into the staticlib, and `quic` pulls
|
||||
// rustls's platform verifier → Security/CoreFoundation.
|
||||
// rustls's platform verifier → Security/CoreFoundation, plus libopus (the in-core
|
||||
// `next_audio_pcm` decode path) which the `abi.rs` object references.
|
||||
&[
|
||||
"-lopus",
|
||||
"-liconv",
|
||||
"-lm",
|
||||
"-framework",
|
||||
@@ -23,7 +25,17 @@ fn native_libs() -> &'static [&'static str] {
|
||||
"CoreFoundation",
|
||||
]
|
||||
} else if cfg!(target_os = "linux") {
|
||||
&["-lgcc_s", "-lutil", "-lrt", "-lpthread", "-lm", "-ldl"]
|
||||
// `-lopus`: the `quic` feature pulls in-core Opus decode (`next_audio_pcm`), whose
|
||||
// symbols the linked `abi.rs` object references. Before `-lm` (opus needs libm).
|
||||
&[
|
||||
"-lopus",
|
||||
"-lgcc_s",
|
||||
"-lutil",
|
||||
"-lrt",
|
||||
"-lpthread",
|
||||
"-lm",
|
||||
"-ldl",
|
||||
]
|
||||
} else {
|
||||
&[]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user