forked from unom/punktfunk
Codec negotiation (M2 polish):
- ServerCodecModeSupport now advertises what we encode: H264|HEVC|AV1_MAIN8
= 65793 (flags verified against moonlight-common-c Limelight.h). The old
placeholder 3843 wrongly claimed HEVC Main10 + 4:4:4 and no AV1. Main10
bits stay off on purpose: Moonlight ties 10-bit to HDR, and capture is
8-bit SDR BGRx with no HDR metadata path (av1_nvenc -highbitdepth was
validated working for later).
- RTSP ANNOUNCE: bitStreamFormat 0/1/2 -> H264/HEVC/AV1 (already plumbed to
av1_nvenc; validated e2e via `m0 --codec av1` + ffprobe av01), and a
dynamicRangeMode!=0 request now logs + falls back to 8-bit SDR.
Surround audio (M2 polish):
- ANNOUNCE x-nv-audio.surround.{numChannels,AudioQuality} +
x-nv-aqos.packetDuration -> per-session AudioParams; DESCRIBE advertises
all six Opus configs (normal before HQ per channel count). Normal-quality
mappings are pre-rotated for the client's GFE-order LFE swap
(RtspConnection.c, verified verbatim) so its derived decoder mapping
equals our encoder mapping — including 7.1, where Sunshine's rotate only
covers [3,6) and scrambles LFE/SL/SR.
- 5.1/7.1 encode via libopus multistream (audiopus_sys, the sys layer the
opus crate already links) with Sunshine's layouts/bitrates, RAII wrapper;
the live-validated stereo wire is byte-identical (plain Opus, no FEC).
- Surround sessions add Sunshine-style RS(4,2) audio FEC (packetType 127 +
AUDIO_FEC_HEADER, the OpenFEC parity matrix both ends hardcode, nanors
gemm semantics verified from nanors/rs.c).
- PipeWire capture generalized to the negotiated channel count with explicit
FL FR FC LFE RL RR [SL SR] positions; missing sink channels are zero-
filled by the channel-mixer. PwAudioCapturer now tears down cleanly on
Drop (pipewire channel -> loop quit), so a channel-count change can
reopen without leaking a capture stream.
Tests: serverinfo mask, RTSP codec/audio param parsing, DESCRIBE contents,
surround-params strings + client-swap round trip, FEC parity self-recovery
and packet layout, real-codec 5.1 channel-identity round trip, and an
ignored live test (ran green against a 6ch null sink monitor).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
246 lines
9.4 KiB
Rust
246 lines
9.4 KiB
Rust
//! PipeWire audio capture of the default sink's monitor (system output).
|
|
//!
|
|
//! Connects to the user's PipeWire daemon (via `XDG_RUNTIME_DIR`, inherited from the Sway
|
|
//! session) and opens an input stream with `stream.capture.sink=true`, which routes the
|
|
//! default sink's monitor into us — no portal needed (unlike screen capture). The (`!Send`)
|
|
//! MainLoop/Stream live on a dedicated thread; interleaved `f32` chunks leave over a bounded
|
|
//! channel (dropped if the encoder falls behind, never blocking the PipeWire loop).
|
|
//!
|
|
//! The stream is opened at the *session's* channel count (2/6/8). If the sink has fewer
|
|
//! channels than requested, PipeWire's channel-mixer fills the extra positions with silence
|
|
//! (zero upmix), so a stereo desktop still produces a valid 5.1/7.1 capture. Dropping the
|
|
//! capturer quits the loop thread (via a `pipewire::channel` Terminate message), tearing the
|
|
//! stream down promptly — required so a surround session can replace a stereo capturer
|
|
//! without leaking a PipeWire consumer (see CLAUDE.md: a wedged link head-blocks the daemon).
|
|
|
|
use super::{AudioCapturer, SAMPLE_RATE};
|
|
use anyhow::{anyhow, Context, Result};
|
|
use std::sync::mpsc::{sync_channel, Receiver, RecvTimeoutError};
|
|
use std::thread;
|
|
use std::time::Duration;
|
|
|
|
/// Message asking the PipeWire loop thread to quit (sent from `Drop`).
|
|
struct Terminate;
|
|
|
|
pub struct PwAudioCapturer {
|
|
chunks: Receiver<Vec<f32>>,
|
|
channels: u32,
|
|
quit: pipewire::channel::Sender<Terminate>,
|
|
}
|
|
|
|
impl PwAudioCapturer {
|
|
pub fn open(channels: u32) -> Result<PwAudioCapturer> {
|
|
anyhow::ensure!(
|
|
matches!(channels, 1 | 2 | 6 | 8),
|
|
"unsupported audio channel count {channels} (want 2, 6 or 8)"
|
|
);
|
|
let (tx, rx) = sync_channel::<Vec<f32>>(64);
|
|
let (quit_tx, quit_rx) = pipewire::channel::channel::<Terminate>();
|
|
thread::Builder::new()
|
|
.name("punktfunk-pw-audio".into())
|
|
.spawn(move || {
|
|
if let Err(e) = pw_thread(tx, quit_rx, channels) {
|
|
tracing::error!(error = %format!("{e:#}"), "pipewire audio thread failed");
|
|
}
|
|
})
|
|
.context("spawn pipewire audio thread")?;
|
|
Ok(PwAudioCapturer {
|
|
chunks: rx,
|
|
channels,
|
|
quit: quit_tx,
|
|
})
|
|
}
|
|
}
|
|
|
|
impl Drop for PwAudioCapturer {
|
|
fn drop(&mut self) {
|
|
// Ask the loop thread to quit; the stream/core/loop unwind there (RAII). A failed
|
|
// send means the thread already exited — nothing to tear down.
|
|
let _ = self.quit.send(Terminate);
|
|
}
|
|
}
|
|
|
|
impl AudioCapturer for PwAudioCapturer {
|
|
fn next_chunk(&mut self) -> Result<Vec<f32>> {
|
|
match self.chunks.recv_timeout(Duration::from_secs(5)) {
|
|
Ok(c) => Ok(c),
|
|
Err(RecvTimeoutError::Timeout) => Err(anyhow!("no PipeWire audio within 5s")),
|
|
Err(RecvTimeoutError::Disconnected) => Err(anyhow!("pipewire audio thread ended")),
|
|
}
|
|
}
|
|
|
|
fn channels(&self) -> u32 {
|
|
self.channels
|
|
}
|
|
|
|
fn drain(&mut self) {
|
|
while self.chunks.try_recv().is_ok() {}
|
|
}
|
|
}
|
|
|
|
/// SPA channel position array for the GameStream surround order FL FR FC LFE RL RR [SL SR]
|
|
/// (= the PipeWire/PulseAudio default map for 6/8 channels, and the order Moonlight's
|
|
/// renderers expect — moonlight-common-c: "we use FL FR C LFE RL RR SL SR"). Values are
|
|
/// `enum spa_audio_channel` (spa/param/audio/raw.h): FL=3 FR=4 FC=5 LFE=6 SL=7 SR=8 RL=12
|
|
/// RR=13.
|
|
fn spa_positions(channels: u32) -> [u32; 64] {
|
|
const FL: u32 = 3;
|
|
const FR: u32 = 4;
|
|
const FC: u32 = 5;
|
|
const LFE: u32 = 6;
|
|
const SL: u32 = 7;
|
|
const SR: u32 = 8;
|
|
const RL: u32 = 12;
|
|
const RR: u32 = 13;
|
|
const MONO: u32 = 2;
|
|
let mut pos = [0u32; 64];
|
|
let order: &[u32] = match channels {
|
|
1 => &[MONO],
|
|
2 => &[FL, FR],
|
|
6 => &[FL, FR, FC, LFE, RL, RR],
|
|
8 => &[FL, FR, FC, LFE, RL, RR, SL, SR],
|
|
_ => unreachable!("validated in open()"),
|
|
};
|
|
pos[..order.len()].copy_from_slice(order);
|
|
pos
|
|
}
|
|
|
|
fn pw_thread(
|
|
tx: std::sync::mpsc::SyncSender<Vec<f32>>,
|
|
quit_rx: pipewire::channel::Receiver<Terminate>,
|
|
channels: u32,
|
|
) -> Result<()> {
|
|
use pipewire as pw;
|
|
use pw::{properties::properties, spa};
|
|
use spa::param::audio::{AudioFormat, AudioInfoRaw};
|
|
use spa::pod::Pod;
|
|
|
|
crate::pwinit::ensure_init();
|
|
let mainloop = pw::main_loop::MainLoopRc::new(None).context("pw audio MainLoop")?;
|
|
let context = pw::context::ContextRc::new(&mainloop, None).context("pw audio Context")?;
|
|
let core = context
|
|
.connect_rc(None)
|
|
.context("pw audio connect (is PipeWire running in this session?)")?;
|
|
|
|
// Cross-thread teardown: the capturer's Drop sends Terminate; quit the loop here.
|
|
let _quit_guard = quit_rx.attach(mainloop.loop_(), {
|
|
let mainloop = mainloop.clone();
|
|
move |_| mainloop.quit()
|
|
});
|
|
|
|
let stream = pw::stream::StreamBox::new(
|
|
&core,
|
|
"punktfunk-audio",
|
|
properties! {
|
|
*pw::keys::MEDIA_TYPE => "Audio",
|
|
*pw::keys::MEDIA_CATEGORY => "Capture",
|
|
*pw::keys::MEDIA_ROLE => "Music",
|
|
// Capture the default sink's monitor (system output), not a microphone.
|
|
*pw::keys::STREAM_CAPTURE_SINK => "true",
|
|
// Ask for a ~5ms quantum (= one Opus frame) so buffers arrive smoothly rather than
|
|
// in large bursts the client's low-latency jitter buffer would hear as glitching.
|
|
*pw::keys::NODE_LATENCY => "240/48000",
|
|
},
|
|
)
|
|
.context("pw audio Stream")?;
|
|
|
|
let _listener = stream
|
|
.add_local_listener_with_user_data(tx)
|
|
.state_changed(|_s, _ud, old, new| {
|
|
tracing::info!(?old, ?new, "pipewire audio stream state");
|
|
})
|
|
.param_changed(|_stream, _tx, id, param| {
|
|
let Some(param) = param else { return };
|
|
if id != pw::spa::param::ParamType::Format.as_raw() {
|
|
return;
|
|
}
|
|
let mut info = AudioInfoRaw::default();
|
|
if info.parse(param).is_ok() {
|
|
tracing::info!(
|
|
format = ?info.format(),
|
|
rate = info.rate(),
|
|
channels = info.channels(),
|
|
"audio format negotiated"
|
|
);
|
|
}
|
|
})
|
|
.process(|stream, tx| {
|
|
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
|
let Some(mut buffer) = stream.dequeue_buffer() else {
|
|
return;
|
|
};
|
|
let datas = buffer.datas_mut();
|
|
if datas.is_empty() {
|
|
return;
|
|
}
|
|
let d = &mut datas[0];
|
|
let (offset, size) = {
|
|
let c = d.chunk();
|
|
(c.offset() as usize, c.size() as usize)
|
|
};
|
|
let Some(buf) = d.data() else { return };
|
|
if offset > buf.len() {
|
|
return;
|
|
}
|
|
let region = &buf[offset..(offset + size).min(buf.len())];
|
|
// Negotiated as F32LE; reinterpret the byte region as interleaved f32.
|
|
let n = region.len() / 4;
|
|
static FIRST: std::sync::atomic::AtomicBool =
|
|
std::sync::atomic::AtomicBool::new(true);
|
|
if FIRST.swap(false, std::sync::atomic::Ordering::Relaxed) {
|
|
tracing::info!(samples = n, "audio first capture buffer");
|
|
}
|
|
let mut samples = Vec::with_capacity(n);
|
|
for i in 0..n {
|
|
let b = [
|
|
region[i * 4],
|
|
region[i * 4 + 1],
|
|
region[i * 4 + 2],
|
|
region[i * 4 + 3],
|
|
];
|
|
samples.push(f32::from_le_bytes(b));
|
|
}
|
|
let _ = tx.try_send(samples); // drop if the encoder is behind
|
|
}));
|
|
if outcome.is_err() {
|
|
tracing::error!("panic in pipewire audio callback — chunk dropped");
|
|
}
|
|
})
|
|
.register()
|
|
.context("register audio stream listener")?;
|
|
|
|
// Request F32LE, 48 kHz, at the session's channel count with explicit positions —
|
|
// PipeWire's channel-mixer up/downmixes the sink monitor to this layout.
|
|
let mut info = AudioInfoRaw::new();
|
|
info.set_format(AudioFormat::F32LE);
|
|
info.set_rate(SAMPLE_RATE);
|
|
info.set_channels(channels);
|
|
info.set_position(spa_positions(channels));
|
|
let obj = pw::spa::pod::Object {
|
|
type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
|
|
id: pw::spa::param::ParamType::EnumFormat.as_raw(),
|
|
properties: info.into(),
|
|
};
|
|
let values: Vec<u8> = pw::spa::pod::serialize::PodSerializer::serialize(
|
|
std::io::Cursor::new(Vec::new()),
|
|
&pw::spa::pod::Value::Object(obj),
|
|
)
|
|
.context("serialize audio format pod")?
|
|
.0
|
|
.into_inner();
|
|
let mut params = [Pod::from_bytes(&values).context("audio pod from bytes")?];
|
|
|
|
stream
|
|
.connect(
|
|
spa::utils::Direction::Input,
|
|
None, // PW_ID_ANY — autoconnect to the default sink monitor
|
|
pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
|
|
&mut params,
|
|
)
|
|
.context("pw audio stream connect")?;
|
|
|
|
mainloop.run();
|
|
tracing::debug!("pipewire audio loop exited (capturer dropped)");
|
|
Ok(())
|
|
}
|