Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,9 @@ tar xzf hypr-rdp-v*.tar.gz
sudo install -Dm755 hypr-rdp /usr/local/bin/hypr-rdp
```

Runtime dependencies: `ffmpeg`/`libavcodec`, `libva`, `pipewire`, `libxkbcommon`
Runtime dependencies: `ffmpeg`/`libavcodec`, `libva`, `pipewire`, `libxkbcommon`,
and `pactl` through PipeWire's PulseAudio compatibility layer for the default
remote-audio routing mode.

For VA-API hardware encoding, install a VA-API driver such as
`intel-media-driver` for Intel GPUs or `libva-mesa-driver` for AMD GPUs.
Expand Down Expand Up @@ -101,6 +103,7 @@ bitrate = 10000000
quality = 23
fps = 30
egfx_codec = "avc420"
# audio_mode = "redirect"
# keyboard_layout_policy = "client"
# output = "DP-1"
```
Expand All @@ -124,6 +127,7 @@ CLI arguments override config file values.
| `--fps` | Max framerate | `30` |
| `--max-frames-in-flight` | Max unacknowledged EGFX frames | `3` |
| `--egfx-codec` | EGFX codec policy: `avc420`, experimental `avc444`, or `auto` | `avc420` |
| `--audio-mode` | Audio policy: `redirect` routes playback to a temporary RDP sink while connected, `mirror` captures the current sink audio, `off` disables RDPSND | `redirect` |
| `--keyboard-layout-policy` | Keyboard layout policy: `client` applies the RDP client layout; `compositor` keeps the compositor/Hyprland keymap | `client` |
| `--output` | Specific output name | _(headless)_ |
| `--config` | Config file path | `~/.config/hypr-rdp/config.toml` |
Expand Down
1 change: 1 addition & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
packages = with pkgs; [
cargo
clippy
pulseaudio
rustc
rustfmt
];
Expand Down
1 change: 1 addition & 0 deletions pkg/aur-git/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ license=('MIT')
options=(!debug)
depends=(
'ffmpeg'
'libpulse'
'libva'
'libxkbcommon'
'mesa'
Expand Down
1 change: 1 addition & 0 deletions pkg/aur/PKGBUILD
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ license=('MIT')
options=(!debug)
depends=(
'ffmpeg'
'libpulse'
'libva'
'libxkbcommon'
'mesa'
Expand Down
8 changes: 8 additions & 0 deletions pkg/nix/package.nix
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
pkg-config,
cmake,
clang,
makeWrapper,
ffmpeg,
libdrm,
libgbm,
libva,
libxkbcommon,
mesa,
pipewire,
pulseaudio,
wayland,
}:

Expand All @@ -29,6 +31,7 @@ rustPlatform.buildRustPackage {
pkg-config
cmake
clang
makeWrapper
rustPlatform.bindgenHook
];

Expand All @@ -43,6 +46,11 @@ rustPlatform.buildRustPackage {
wayland
];

postInstall = ''
wrapProgram $out/bin/hypr-rdp \
--prefix PATH : ${lib.makeBinPath [ pulseaudio ]}
'';

doCheck = false;

meta = {
Expand Down
113 changes: 110 additions & 3 deletions src/audio/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use tokio::sync::mpsc;

use super::format::{advertised_format, BITS_PER_SAMPLE, CHANNELS, SAMPLE_RATE};
use super::pipewire::run_capture;
use super::routing::{ActiveAudioRouting, AudioMode, AudioRoutingRunner, PipeWireRoutingRunner};

const AUDIO_STARTUP_TIMEOUT: Duration = Duration::from_secs(2);
type AudioStartupStatus = Result<(), String>;
Expand Down Expand Up @@ -54,11 +55,15 @@ impl AudioCaptureRunner for PipeWireCaptureRunner {

pub struct HyprSoundFactory {
event_sender: Option<mpsc::UnboundedSender<ServerEvent>>,
audio_mode: AudioMode,
}

impl HyprSoundFactory {
pub fn new() -> Self {
Self { event_sender: None }
pub fn new(audio_mode: AudioMode) -> Self {
Self {
event_sender: None,
audio_mode,
}
}
}

Expand All @@ -75,7 +80,10 @@ impl SoundServerFactory for HyprSoundFactory {
stop_signal: None,
capture_thread: None,
capture_runner: Arc::new(PipeWireCaptureRunner),
routing_runner: Arc::new(PipeWireRoutingRunner::new()),
active_routing: None,
formats: vec![advertised_format()],
audio_mode: self.audio_mode,
})
}
}
Expand All @@ -85,7 +93,10 @@ struct HyprSoundHandler {
stop_signal: Option<Arc<AtomicBool>>,
capture_thread: Option<thread::JoinHandle<()>>,
capture_runner: Arc<dyn AudioCaptureRunner>,
routing_runner: Arc<dyn AudioRoutingRunner>,
active_routing: Option<Box<dyn ActiveAudioRouting>>,
formats: Vec<AudioFormat>,
audio_mode: AudioMode,
}

impl fmt::Debug for HyprSoundHandler {
Expand Down Expand Up @@ -123,6 +134,14 @@ impl RdpsndServerHandler for HyprSoundHandler {
return None;
};

let active_routing = match self.routing_runner.start(self.audio_mode) {
Ok(active_routing) => active_routing,
Err(e) => {
tracing::error!("Audio: failed to configure audio routing: {:#}", e);
return None;
}
};

let stop_signal = Arc::new(AtomicBool::new(false));
let (startup_tx, startup_rx) = std_mpsc::channel();

Expand All @@ -134,6 +153,7 @@ impl RdpsndServerHandler for HyprSoundHandler {
Ok(handle) => handle,
Err(e) => {
tracing::error!("Audio: failed to spawn capture thread: {}", e);
drop(active_routing);
return None;
}
};
Expand All @@ -146,6 +166,7 @@ impl RdpsndServerHandler for HyprSoundHandler {
Ok(Err(e)) => {
tracing::error!("Audio: PipeWire startup failed: {}", e);
let _ = handle.join();
drop(active_routing);
return None;
}
Err(std_mpsc::RecvTimeoutError::Timeout) => {
Expand All @@ -156,15 +177,18 @@ impl RdpsndServerHandler for HyprSoundHandler {
stop_signal.store(true, Ordering::SeqCst);
self.stop_signal = Some(stop_signal);
self.capture_thread = Some(handle);
drop(active_routing);
return None;
}
Err(std_mpsc::RecvTimeoutError::Disconnected) => {
tracing::error!("Audio: capture thread exited before reporting startup");
let _ = handle.join();
drop(active_routing);
return None;
}
}

self.active_routing = active_routing;
tracing::trace!(client_format_index, "Audio: PipeWire capture started");
Some(client_format_index)
}
Expand All @@ -179,6 +203,8 @@ impl RdpsndServerHandler for HyprSoundHandler {
if let Some(handle) = self.capture_thread.take() {
let _ = handle.join();
}

self.active_routing.take();
}
}

Expand Down Expand Up @@ -210,6 +236,35 @@ mod tests {
use super::*;
use crate::audio::format::BLOCK_ALIGN;

struct NoopRoutingGuard;

impl ActiveAudioRouting for NoopRoutingGuard {}

struct NoopRoutingRunner;

impl AudioRoutingRunner for NoopRoutingRunner {
fn start(&self, _mode: AudioMode) -> anyhow::Result<Option<Box<dyn ActiveAudioRouting>>> {
Ok(None)
}
}

struct ReadyRoutingRunner;

impl AudioRoutingRunner for ReadyRoutingRunner {
fn start(&self, mode: AudioMode) -> anyhow::Result<Option<Box<dyn ActiveAudioRouting>>> {
Ok((mode == AudioMode::Redirect)
.then(|| Box::new(NoopRoutingGuard) as Box<dyn ActiveAudioRouting>))
}
}

struct FailingRoutingRunner;

impl AudioRoutingRunner for FailingRoutingRunner {
fn start(&self, _mode: AudioMode) -> anyhow::Result<Option<Box<dyn ActiveAudioRouting>>> {
anyhow::bail!("routing unavailable")
}
}

struct PanicRunner;

impl AudioCaptureRunner for PanicRunner {
Expand Down Expand Up @@ -272,13 +327,30 @@ mod tests {
fn handler_with_runner(
event_sender: Option<mpsc::UnboundedSender<ServerEvent>>,
capture_runner: Arc<dyn AudioCaptureRunner>,
) -> HyprSoundHandler {
handler_with_runner_and_routing(
event_sender,
capture_runner,
Arc::new(NoopRoutingRunner),
AudioMode::Mirror,
)
}

fn handler_with_runner_and_routing(
event_sender: Option<mpsc::UnboundedSender<ServerEvent>>,
capture_runner: Arc<dyn AudioCaptureRunner>,
routing_runner: Arc<dyn AudioRoutingRunner>,
audio_mode: AudioMode,
) -> HyprSoundHandler {
HyprSoundHandler {
event_sender,
stop_signal: None,
capture_thread: None,
capture_runner,
routing_runner,
active_routing: None,
formats: vec![advertised_format()],
audio_mode,
}
}

Expand Down Expand Up @@ -396,9 +468,44 @@ mod tests {
assert!(handler.capture_thread.is_none());
}

#[test]
fn start_accepts_redirect_mode_after_routing_and_capture_start() {
let (sender, _receiver) = mpsc::unbounded_channel::<ServerEvent>();
let mut handler = handler_with_runner_and_routing(
Some(sender),
Arc::new(ReadyRunner),
Arc::new(ReadyRoutingRunner),
AudioMode::Redirect,
);
let client_format = client_formats(vec![advertised_format()]);

assert_eq!(handler.start(&client_format), Some(0));
assert!(handler.active_routing.is_some());

handler.stop();
assert!(handler.active_routing.is_none());
}

#[test]
fn start_rejects_redirect_mode_when_routing_fails_before_capture_spawn() {
let (sender, _receiver) = mpsc::unbounded_channel::<ServerEvent>();
let mut handler = handler_with_runner_and_routing(
Some(sender),
Arc::new(PanicRunner),
Arc::new(FailingRoutingRunner),
AudioMode::Redirect,
);
let client_format = client_formats(vec![advertised_format()]);

assert_eq!(handler.start(&client_format), None);
assert!(handler.stop_signal.is_none());
assert!(handler.capture_thread.is_none());
assert!(handler.active_routing.is_none());
}

#[test]
fn sound_factory_backend_advertises_the_local_audio_format() {
let handler = HyprSoundFactory::new().build_backend();
let handler = HyprSoundFactory::new(AudioMode::Mirror).build_backend();

assert_eq!(handler.get_formats(), &[advertised_format()]);
}
Expand Down
2 changes: 2 additions & 0 deletions src/audio/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@
mod backend;
mod format;
mod pipewire;
mod routing;

pub use backend::HyprSoundFactory;
pub use routing::AudioMode;
Loading
Loading