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
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ Working today, over USB with ADB:
| Locally drawn cursor | ✅ | ✅ | 🧪 | 🧪 | 🧪 |
| Zero-copy hardware encode | ✅ | ✅ | 🧪 | ❌ shared-memory capture | ❌ shared-memory capture |
| Touch, pen pressure, tilt and hover | ✅ | ✅ | 🧪 | 🧪 | 🧪 |
| PC audio on the tablet, with volume kept in sync | 🧪 audio only, no volume sync | ✅ | 🧪 | 🧪 | 🧪 |
| PC audio on the tablet, with volume kept in sync | 🧪 | ✅ | 🧪 | 🧪 | 🧪 |
| Tray app with settings and live statistics | ✅ | ✅ | 🧪 | 🧪 | 🧪 |
| Installer / packages | ✅ `.exe` | ✅ AppImage, `.deb`, Arch | 🧪 AppImage, `.deb`, Arch | 🧪 AppImage, `.deb`, Arch | 🧪 AppImage, `.deb`, Arch |

Expand Down Expand Up @@ -103,8 +103,12 @@ The PC's sound plays on the tablet while a session runs.
- **Linux:** the host adds a PipeWire output named `ezdisplay` and makes it the default for as long
as the session lasts, then puts the previous default back. Its volume and mute follow the tablet's
and the other way round.
- **Windows:** the host captures what the default output plays (WASAPI loopback). The PC keeps
playing it too, and the volume is not synced.
- **Windows:** the host captures what the default output plays (WASAPI loopback) and mutes that
output for as long as the session lasts, so the PC stays quiet; the capture is unaffected by the
mute and the volume slider. The slider follows the tablet's volume and the other way round; the
tablet's mute stays on the tablet. Afterwards the output gets its volume back and is unmuted.
Unmute it with the speaker button to hear the PC as well. If the host crashes, it restores the
output on its next start.
- Over a fast link the audio is uncompressed PCM, otherwise Opus. Choose with `--audio
auto|pcm|opus|off` or `audio = "..."` in `config.toml`, or in the tray settings.
- The tablet buffers 20 to 80 ms of audio, growing the buffer when playback stutters and shrinking
Expand Down
6 changes: 6 additions & 0 deletions crates/ezd-host-service/src/platform.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ pub fn audio_source() -> Option<Box<dyn AudioSource>> {
}
}

/// Puts back what a crashed session left changed in the PC audio output (Windows mutes it).
pub fn recover_audio_after_crash() {
#[cfg(windows)]
imp::recover_audio_after_crash();
}

/// Removes a virtual display left on the desktop for a quick reconnect (Windows).
pub fn release_virtual_display() {
#[cfg(windows)]
Expand Down
1 change: 1 addition & 0 deletions crates/ezd-host-service/src/service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,7 @@ impl HostService {
TcpListener::from_std(std_listener).map_err(bind_err)?
};
let local = listener.local_addr().map_err(bind_err)?;
crate::platform::recover_audio_after_crash();

#[cfg(feature = "wifi")]
let wifi = match &cfg.wifi {
Expand Down
1 change: 1 addition & 0 deletions crates/ezd-host-windows/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ windows = { version = "0.62.2", features = [
"Win32_Graphics_Dxgi_Common",
"Win32_Graphics_Gdi",
"Win32_Media_Audio",
"Win32_Media_Audio_Endpoints",
"Win32_Devices_Display",
"Win32_Storage_FileSystem",
"Win32_System_Com",
Expand Down
87 changes: 85 additions & 2 deletions crates/ezd-host-windows/src/audio_math.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Pure logic of the WASAPI loopback capture (`win/audio.rs`): the shared-mode format request,
//! frame/sample math, the silent-packet flag and the packet timestamp. Platform independent and
//! unit-tested everywhere.
//! frame/sample math, the silent-packet flag, the packet timestamp and the saved state of the
//! output device. Platform independent and unit-tested everywhere.

/// Sample rate of the audio stream (fixed by the protocol).
pub const SAMPLE_RATE: u32 = 48_000;
Expand Down Expand Up @@ -83,6 +83,56 @@ pub fn packet_samples(raw: &[u8], frames: u32, channels: u16, flags: u32) -> Vec
.collect()
}

/// What a session changed on the output device, persisted while it runs so a host that crashed
/// can put it back on its next start.
#[derive(Debug, Clone, PartialEq)]
pub struct SavedOutput {
/// `IMMDevice::GetId` of the output device.
pub device_id: String,
/// Master volume (the slider value, 0..=1) before the session.
pub volume: f32,
/// True if the session muted the device (it was not muted before).
pub muted_by_us: bool,
}

impl SavedOutput {
const VERSION: &str = "v1";

/// `v1;<muted_by_us 0|1>;<volume>;<device id>`.
pub fn encode(&self) -> String {
format!(
"{};{};{};{}",
Self::VERSION,
u8::from(self.muted_by_us),
self.volume,
self.device_id
)
}

/// Inverse of [`encode`](Self::encode); `None` for anything malformed.
pub fn decode(s: &str) -> Option<Self> {
let mut parts = s.splitn(4, ';');
if parts.next()? != Self::VERSION {
return None;
}
let muted_by_us = match parts.next()? {
"0" => false,
"1" => true,
_ => return None,
};
let volume: f32 = parts.next()?.parse().ok()?;
let device_id = parts.next()?;
if !(0.0..=1.0).contains(&volume) || device_id.is_empty() {
return None;
}
Some(Self {
device_id: device_id.to_owned(),
volume,
muted_by_us,
})
}
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -139,4 +189,37 @@ mod tests {
assert_eq!(packet_samples(&raw, 1, 2, 0), vec![0.5, -0.25]);
assert_eq!(packet_samples(&raw[..6], 2, 2, 0), vec![0.5]);
}

#[test]
fn saved_output_round_trips() {
let saved = SavedOutput {
device_id: "{0.0.0.00000000}.{6a1f2c3d-0000-4e5f-8a9b-0123456789ab}".into(),
volume: 0.42,
muted_by_us: true,
};
assert_eq!(SavedOutput::decode(&saved.encode()), Some(saved.clone()));
let unmuted = SavedOutput {
muted_by_us: false,
volume: 1.0,
..saved
};
assert_eq!(SavedOutput::decode(&unmuted.encode()), Some(unmuted));
}

#[test]
fn malformed_saved_output_is_rejected() {
for s in [
"",
"v1",
"v2;1;0.5;{dev}",
"v1;2;0.5;{dev}",
"v1;1;loud;{dev}",
"v1;1;1.5;{dev}",
"v1;1;NaN;{dev}",
"v1;1;0.5;",
"v1;1;0.5",
] {
assert_eq!(SavedOutput::decode(s), None, "{s:?}");
}
}
}
11 changes: 9 additions & 2 deletions crates/ezd-host-windows/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,8 @@ pub fn input_backend() -> Option<Arc<dyn InputBackend>> {
}

/// System audio capture (WASAPI loopback of the default output device); `None` on other
/// targets. Creating it does no device work: that starts in `AudioSource::start`. The PC keeps
/// playing the audio locally.
/// targets. Creating it does no device work: that starts in `AudioSource::start`, which also
/// mutes the PC output until capture stops.
pub fn audio_source() -> Option<Box<dyn AudioSource>> {
#[cfg(windows)]
{
Expand All @@ -74,6 +74,13 @@ pub fn audio_source() -> Option<Box<dyn AudioSource>> {
}
}

/// Puts back the PC audio output if a host that crashed during a session left it muted. Call
/// once when the host starts serving. No-op on other targets.
pub fn recover_audio_after_crash() {
#[cfg(windows)]
win::audio::recover_after_crash();
}

/// Platform-specific diagnostics for `ezd-host diag`.
pub fn diag_section() -> serde_json::Value {
#[cfg(windows)]
Expand Down
Loading
Loading