From 22e849a4c0bef4e3e86a03481fe3e5d9f679c31a Mon Sep 17 00:00:00 2001 From: FacuM Date: Tue, 27 Oct 2026 17:03:32 -0300 Subject: [PATCH] Mute the Windows output while streaming its audio and sync its volume WASAPI loopback still captures a muted device, before its master volume is applied, so the host mutes the default output for the session: the PC stays quiet and the tablet gets full-level audio. The volume slider now follows the tablet's volume and the other way round. The output gets its volume back and is unmuted when capture stops; the state before is saved in the registry first and restored on the next start if the host crashed. Unmuting by hand keeps local playback; a slider move that unmutes the device is undone. --- README.md | 10 +- crates/ezd-host-service/src/platform.rs | 6 + crates/ezd-host-service/src/service.rs | 1 + crates/ezd-host-windows/Cargo.toml | 1 + crates/ezd-host-windows/src/audio_math.rs | 87 +++++- crates/ezd-host-windows/src/lib.rs | 11 +- crates/ezd-host-windows/src/win/audio.rs | 281 +++++++++++++++++- .../src/win/touch_keyboard.rs | 10 +- docs/architecture.md | 2 +- 9 files changed, 387 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 214c4c9..4fcce9f 100644 --- a/README.md +++ b/README.md @@ -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 | @@ -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 diff --git a/crates/ezd-host-service/src/platform.rs b/crates/ezd-host-service/src/platform.rs index 8511264..26286b4 100644 --- a/crates/ezd-host-service/src/platform.rs +++ b/crates/ezd-host-service/src/platform.rs @@ -47,6 +47,12 @@ pub fn audio_source() -> Option> { } } +/// 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)] diff --git a/crates/ezd-host-service/src/service.rs b/crates/ezd-host-service/src/service.rs index 518b753..80fdd7f 100644 --- a/crates/ezd-host-service/src/service.rs +++ b/crates/ezd-host-service/src/service.rs @@ -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 { diff --git a/crates/ezd-host-windows/Cargo.toml b/crates/ezd-host-windows/Cargo.toml index e3d44ab..20f7124 100644 --- a/crates/ezd-host-windows/Cargo.toml +++ b/crates/ezd-host-windows/Cargo.toml @@ -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", diff --git a/crates/ezd-host-windows/src/audio_math.rs b/crates/ezd-host-windows/src/audio_math.rs index 892ce42..4f4078b 100644 --- a/crates/ezd-host-windows/src/audio_math.rs +++ b/crates/ezd-host-windows/src/audio_math.rs @@ -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; @@ -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;;;`. + 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 { + 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::*; @@ -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:?}"); + } + } } diff --git a/crates/ezd-host-windows/src/lib.rs b/crates/ezd-host-windows/src/lib.rs index dc3d861..fda4029 100644 --- a/crates/ezd-host-windows/src/lib.rs +++ b/crates/ezd-host-windows/src/lib.rs @@ -61,8 +61,8 @@ pub fn input_backend() -> Option> { } /// 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> { #[cfg(windows)] { @@ -74,6 +74,13 @@ pub fn audio_source() -> Option> { } } +/// 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)] diff --git a/crates/ezd-host-windows/src/win/audio.rs b/crates/ezd-host-windows/src/win/audio.rs index acdea07..68587f9 100644 --- a/crates/ezd-host-windows/src/win/audio.rs +++ b/crates/ezd-host-windows/src/win/audio.rs @@ -1,7 +1,13 @@ //! System audio capture through WASAPI loopback of the default render device. //! -//! Known limitation: loopback taps the mix, it does not redirect it, so the PC keeps playing the -//! audio locally while it is streamed. +//! Loopback taps the mix, it does not redirect it, so to keep the PC quiet while it streams the +//! capture thread mutes the device: loopback still captures a muted device, and before its master +//! volume is applied, so the stream carries full-level audio. The volume slider becomes a control +//! value that is kept in step with the client (`take_volume_change` / `set_volume`); this PC's +//! mute is taken, so the client's mute flag is only echoed back. When capture ends the device gets +//! its volume back and is unmuted. If the user unmutes it during the session without moving the +//! slider (moving it unmutes too, and is undone) it keeps playing locally. The state before the session is saved in the registry first, and +//! [`recover_after_crash`] puts it back if the host died with the PC muted. //! //! `start` spawns the capture thread `ezd-audio-wasapi`, which owns all COM objects (it joins the //! MTA), opens the default render endpoint in shared mode with the loopback flag and reads @@ -9,26 +15,31 @@ //! If the device goes away (`AUDCLNT_E_DEVICE_INVALIDATED`, default device switched, ...) the //! thread ends and `next_samples` reports the error; the session then stops the audio only. -use crate::audio_math::{self, WaveFormat}; +use super::touch_keyboard::{delete_value, read_string, write_string}; +use crate::audio_math::{self, SavedOutput, WaveFormat}; +use crate::touch_keyboard::OWN_KEY; use ezd_host_core::{AudioBlock, AudioSource, SourceError, now_us}; +use ezd_proto::AudioVolume; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Receiver, RecvTimeoutError, TrySendError}; -use std::sync::{Arc, Mutex}; +use std::sync::{Arc, Mutex, MutexGuard}; use std::thread::JoinHandle; -use std::time::Duration; +use std::time::{Duration, Instant}; use windows::Win32::Foundation::{CloseHandle, HANDLE}; +use windows::Win32::Media::Audio::Endpoints::IAudioEndpointVolume; use windows::Win32::Media::Audio::{ AUDCLNT_SHAREMODE_SHARED, AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM, AUDCLNT_STREAMFLAGS_EVENTCALLBACK, AUDCLNT_STREAMFLAGS_LOOPBACK, - AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, IAudioCaptureClient, IAudioClient, + AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, IAudioCaptureClient, IAudioClient, IMMDevice, IMMDeviceEnumerator, MMDeviceEnumerator, WAVEFORMATEX, WAVEFORMATEXTENSIBLE, WAVEFORMATEXTENSIBLE_0, eConsole, eRender, }; use windows::Win32::System::Com::{ - CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoUninitialize, + CLSCTX_ALL, COINIT_MULTITHREADED, CoCreateInstance, CoInitializeEx, CoTaskMemFree, + CoUninitialize, }; use windows::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; -use windows::core::GUID; +use windows::core::{GUID, HSTRING}; /// Blocks queued between the capture thread and `next_samples` (about 1 s at 20 ms); newer /// blocks are dropped when the consumer is behind. @@ -40,14 +51,34 @@ const WAIT_MS: u32 = 100; const INIT_TIMEOUT: Duration = Duration::from_secs(3); /// `KSDATAFORMAT_SUBTYPE_IEEE_FLOAT`. const SUBTYPE_IEEE_FLOAT: GUID = GUID::from_u128(0x00000003_0000_0010_8000_00aa00389b71); +/// REG_SZ under [`OWN_KEY`] with the [`SavedOutput`] while a session holds the output device. +const SAVED_OUTPUT_VALUE: &str = "SavedAudioOutput"; +/// How often the capture thread reads the volume slider and the mute state of the device. +const VOLUME_POLL: Duration = Duration::from_millis(100); type Failure = Arc>>; +/// Volume handoff between the session and the capture thread. +#[derive(Default)] +struct VolumeState { + /// A slider move on this PC that the session has not taken yet. + changed: Option, + /// A volume from the client that the capture thread has not applied yet. + requested: Option, +} + +type SharedVolume = Arc>; + +fn lock(v: &SharedVolume) -> MutexGuard<'_, VolumeState> { + v.lock().unwrap_or_else(|e| e.into_inner()) +} + /// WASAPI loopback [`AudioSource`]. pub struct WasapiLoopback { rx: Option>, stop: Arc, failure: Failure, + volume: SharedVolume, thread: Option>, } @@ -57,6 +88,7 @@ impl WasapiLoopback { rx: None, stop: Arc::new(AtomicBool::new(false)), failure: Arc::new(Mutex::new(None)), + volume: SharedVolume::default(), thread: None, } } @@ -73,13 +105,15 @@ impl AudioSource for WasapiLoopback { self.stop(); self.stop = Arc::new(AtomicBool::new(false)); self.failure = Arc::new(Mutex::new(None)); + self.volume = SharedVolume::default(); let (tx, rx) = mpsc::sync_channel::(QUEUE_BLOCKS); let (init_tx, init_rx) = mpsc::sync_channel::>(1); let stop = self.stop.clone(); let failure = self.failure.clone(); + let volume = self.volume.clone(); let thread = std::thread::Builder::new() .name("ezd-audio-wasapi".into()) - .spawn(move || capture_thread(channels, tx, init_tx, stop, failure)) + .spawn(move || capture_thread(channels, tx, init_tx, stop, failure, volume)) .map_err(SourceError::backend)?; self.thread = Some(thread); match init_rx.recv_timeout(INIT_TIMEOUT) { @@ -116,6 +150,14 @@ impl AudioSource for WasapiLoopback { } } + fn take_volume_change(&mut self) -> Option { + lock(&self.volume).changed.take() + } + + fn set_volume(&mut self, volume: AudioVolume) { + lock(&self.volume).requested = Some(volume); + } + fn stop(&mut self) { self.stop.store(true, Ordering::SeqCst); self.rx = None; @@ -157,6 +199,7 @@ fn capture_thread( init_tx: mpsc::SyncSender>, stop: Arc, failure: Failure, + volume: SharedVolume, ) { // SAFETY: COM is initialised on this thread and released by `ComGuard` after all COM objects // created inside `run` are dropped. @@ -167,7 +210,7 @@ fn capture_thread( } let _com = ComGuard; let fmt = audio_math::wave_format(channels); - if let Err(e) = run(&fmt, &tx, &init_tx, &stop) { + if let Err(e) = run(&fmt, &tx, &init_tx, &stop, &volume) { tracing::warn!("WASAPI loopback ended: {e}"); // Reports a failure that happened before `init_tx` was answered, too. let _ = init_tx.try_send(Err(e.clone())); @@ -182,6 +225,7 @@ fn run( tx: &mpsc::SyncSender, init_tx: &mpsc::SyncSender>, stop: &AtomicBool, + volume: &SharedVolume, ) -> Result<(), String> { let err = |what: &str, e: windows::core::Error| format!("{what}: {e}"); // SAFETY: plain COM calls on objects owned by this function; pointers passed to @@ -239,16 +283,28 @@ fn run( .GetService() .map_err(|e| err("getting the capture client", e))?; client.Start().map_err(|e| err("starting capture", e))?; + // Without the guard the audio still streams, the PC just keeps playing it too. + let mut output = OutputGuard::engage(&device) + .inspect_err(|e| tracing::warn!("not muting the PC output: {e}")) + .ok(); tracing::info!( channels = fmt.channels, + muted = output.as_ref().is_some_and(|o| o.saved.muted_by_us), "WASAPI loopback capture started (default output device)" ); let _ = init_tx.send(Ok(())); let mut result = Ok(()); + let mut next_poll = Instant::now(); 'outer: while !stop.load(Ordering::SeqCst) { // Event or timeout: either way drain whatever is queued. let _ = WaitForSingleObject(event, WAIT_MS); + if let Some(o) = &mut output + && Instant::now() >= next_poll + { + o.poll(volume); + next_poll = Instant::now() + VOLUME_POLL; + } loop { if stop.load(Ordering::SeqCst) { break 'outer; @@ -295,3 +351,208 @@ fn run( result } } + +/// The output device while a session streams it: muted (unless it already was) and given back +/// its volume and mute state on drop. The state before is saved in the registry first. +struct OutputGuard { + endpoint: IAudioEndpointVolume, + saved: SavedOutput, + /// The slider value last seen or set, to tell the user's moves apart from ours. + last_volume: f32, + /// The client's mute flag, sent back with every slider move (this PC's mute is ours). + client_muted: bool, +} + +impl OutputGuard { + /// Saves the state of `device` and mutes it. Needs COM on the calling thread. + fn engage(device: &IMMDevice) -> Result { + // A device left muted by a crashed run is restored first; while that fails its saved + // state must not be overwritten. + restore_saved().map_err(|e| format!("an earlier session's output is not restored: {e}"))?; + let err = |what: &str, e: windows::core::Error| format!("{what}: {e}"); + // SAFETY: COM calls on a live device; the id string is copied before it is freed. + let (endpoint, device_id) = unsafe { + let endpoint: IAudioEndpointVolume = device + .Activate(CLSCTX_ALL, None) + .map_err(|e| err("activating the endpoint volume", e))?; + let id = device + .GetId() + .map_err(|e| err("reading the device id", e))?; + let device_id = id.to_string(); + CoTaskMemFree(Some(id.0 as *const _)); + (endpoint, device_id.map_err(|e| format!("device id: {e}"))?) + }; + // SAFETY: plain getters on a live interface. + let (volume, was_muted) = unsafe { + ( + endpoint + .GetMasterVolumeLevelScalar() + .map_err(|e| err("reading the volume", e))?, + endpoint + .GetMute() + .map_err(|e| err("reading the mute state", e))? + .as_bool(), + ) + }; + let saved = SavedOutput { + device_id, + volume, + muted_by_us: !was_muted, + }; + // Persist first so a crash cannot leave the PC muted. + write_string(OWN_KEY, SAVED_OUTPUT_VALUE, &saved.encode()) + .map_err(|e| format!("saving the output state: {e}"))?; + if saved.muted_by_us { + // SAFETY: plain setter on a live interface; a null event context is allowed. + if let Err(e) = unsafe { endpoint.SetMute(true, std::ptr::null()) } { + let _ = delete_value(OWN_KEY, SAVED_OUTPUT_VALUE); + return Err(err("muting the output", e)); + } + } + Ok(Self { + endpoint, + saved, + last_volume: volume, + client_muted: false, + }) + } + + /// Applies a volume from the client and reports a slider move on this PC. The first value + /// is not reported: the client's volume wins when a session starts. + fn poll(&mut self, shared: &SharedVolume) { + let requested = lock(shared).requested.take(); + if let Some(v) = requested { + self.client_muted = v.muted; + let level = if v.volume.is_nan() { + 0.0 + } else { + v.volume.clamp(0.0, 1.0) + }; + // SAFETY: plain setter on a live interface; a null event context is allowed. + match unsafe { + self.endpoint + .SetMasterVolumeLevelScalar(level, std::ptr::null()) + } { + Ok(()) => self.last_volume = level, + Err(e) => tracing::warn!("setting the PC volume failed: {e}"), + } + } + let mut moved = false; + // SAFETY: plain getter on a live interface. + if let Ok(level) = unsafe { self.endpoint.GetMasterVolumeLevelScalar() } + && (level - self.last_volume).abs() >= AudioVolume::EPSILON + { + moved = true; + self.last_volume = level; + lock(shared).changed = Some(AudioVolume { + volume: level, + muted: self.client_muted, + }); + } + // SAFETY: plain getter on a live interface. + if !self.saved.muted_by_us + || !matches!(unsafe { self.endpoint.GetMute() }, Ok(m) if !m.as_bool()) + { + return; + } + if moved { + // Moving the Windows volume slider unmutes the device: that is the user setting the + // tablet's volume, not asking for local sound. + // SAFETY: plain setter on a live interface; a null event context is allowed. + if let Err(e) = unsafe { self.endpoint.SetMute(true, std::ptr::null()) } { + tracing::warn!("muting the PC output again failed: {e}"); + } + return; + } + tracing::info!("the PC output was unmuted by hand; it plays locally from now on"); + self.saved.muted_by_us = false; + let _ = write_string(OWN_KEY, SAVED_OUTPUT_VALUE, &self.saved.encode()); + } +} + +impl Drop for OutputGuard { + fn drop(&mut self) { + match restore(&self.endpoint, &self.saved) { + Ok(()) => { + if let Err(e) = delete_value(OWN_KEY, SAVED_OUTPUT_VALUE) { + tracing::warn!("cannot clear the saved audio output: {e}"); + } + } + // The saved state stays for `recover_after_crash`. + Err(e) => tracing::warn!("restoring the PC output failed: {e}"), + } + } +} + +/// Puts the volume and mute state of `saved` back on `endpoint`. +fn restore(endpoint: &IAudioEndpointVolume, saved: &SavedOutput) -> Result<(), String> { + // SAFETY: plain setters on a live interface; a null event context is allowed. + unsafe { + endpoint + .SetMasterVolumeLevelScalar(saved.volume, std::ptr::null()) + .map_err(|e| format!("setting the volume: {e}"))?; + if saved.muted_by_us { + endpoint + .SetMute(false, std::ptr::null()) + .map_err(|e| format!("unmuting: {e}"))?; + } + } + Ok(()) +} + +/// Restores the device in the saved state left by a run that did not end cleanly, if any, and +/// clears it. A device that no longer exists is forgotten. Needs COM on the calling thread. +fn restore_saved() -> Result<(), String> { + let Some(raw) = read_string(OWN_KEY, SAVED_OUTPUT_VALUE)? else { + return Ok(()); + }; + let Some(saved) = SavedOutput::decode(&raw) else { + tracing::warn!("ignoring malformed saved audio output {raw:?}"); + return delete_value(OWN_KEY, SAVED_OUTPUT_VALUE); + }; + // SAFETY: plain COM calls on objects owned by this block. + let endpoint = unsafe { + let enumerator: IMMDeviceEnumerator = + CoCreateInstance(&MMDeviceEnumerator, None, CLSCTX_ALL) + .map_err(|e| format!("creating the device enumerator: {e}"))?; + match enumerator.GetDevice(&HSTRING::from(saved.device_id.as_str())) { + Ok(device) => device + .Activate::(CLSCTX_ALL, None) + .map_err(|e| format!("activating the endpoint volume: {e}"))?, + Err(e) => { + tracing::info!( + "the audio output saved by an unclean exit is gone ({e}); forgetting it" + ); + return delete_value(OWN_KEY, SAVED_OUTPUT_VALUE); + } + } + }; + restore(&endpoint, &saved)?; + tracing::info!( + volume = saved.volume, + unmuted = saved.muted_by_us, + "restored the PC audio output after an unclean exit" + ); + delete_value(OWN_KEY, SAVED_OUTPUT_VALUE) +} + +/// Puts back the PC audio output if a host that crashed during a session left it muted. Call +/// once when the host starts. Runs on its own thread, which joins the MTA whatever COM mode the +/// caller uses. +pub fn recover_after_crash() { + let worker = std::thread::Builder::new() + .name("ezd-audio-recover".into()) + .spawn(|| { + // SAFETY: COM is released by `ComGuard` after `restore_saved` dropped its objects. + if unsafe { CoInitializeEx(None, COINIT_MULTITHREADED) }.is_err() { + return; + } + let _com = ComGuard; + if let Err(e) = restore_saved() { + tracing::warn!("audio output: restore after an unclean exit failed: {e}"); + } + }); + if let Ok(w) = worker { + let _ = w.join(); + } +} diff --git a/crates/ezd-host-windows/src/win/touch_keyboard.rs b/crates/ezd-host-windows/src/win/touch_keyboard.rs index 1a58dbb..93134bd 100644 --- a/crates/ezd-host-windows/src/win/touch_keyboard.rs +++ b/crates/ezd-host-windows/src/win/touch_keyboard.rs @@ -52,9 +52,11 @@ fn read_dword(subkey: &str, name: &str) -> Result, String> { check(r).map(|()| Some(v)) } -fn read_string(subkey: &str, name: &str) -> Result, String> { +/// Reads a REG_SZ value of the current user (up to 255 characters); `Ok(None)` if it does not +/// exist. +pub(super) fn read_string(subkey: &str, name: &str) -> Result, String> { let (sk, n) = (wide(subkey), wide(name)); - let mut buf = [0u16; 64]; + let mut buf = [0u16; 256]; let mut bytes = std::mem::size_of_val(&buf) as u32; // SAFETY: valid NUL-terminated strings; `buf` is `bytes` bytes long. let r = unsafe { @@ -89,7 +91,7 @@ fn write_dword(subkey: &str, name: &str, v: u32) -> Result<(), String> { }) } -fn write_string(subkey: &str, name: &str, s: &str) -> Result<(), String> { +pub(super) fn write_string(subkey: &str, name: &str, s: &str) -> Result<(), String> { let (sk, n, data) = (wide(subkey), wide(name), wide(s)); // SAFETY: valid NUL-terminated strings; `data` is `2 * len` bytes including the NUL. check(unsafe { @@ -105,7 +107,7 @@ fn write_string(subkey: &str, name: &str, s: &str) -> Result<(), String> { } /// Deletes a value; a value that does not exist counts as deleted. -fn delete_value(subkey: &str, name: &str) -> Result<(), String> { +pub(super) fn delete_value(subkey: &str, name: &str) -> Result<(), String> { let (sk, n) = (wide(subkey), wide(name)); // SAFETY: valid NUL-terminated strings. let r = diff --git a/docs/architecture.md b/docs/architecture.md index 878598a..8a7762d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -129,6 +129,6 @@ flowchart TB 1. Vertical slice on both hosts: video and local cursor over ADB. 1a core → 1b Android, 1c Linux KDE, 1d Windows in parallel. 2. Input: touch, pen, S Pen button gestures. 3. Transports: Wi-Fi (QUIC with PIN or QR pairing, mDNS discovery) in progress; AOA (USB without developer mode, libusb) done. -4. Audio: PC audio on the tablet as PCM or Opus (WASAPI loopback on Windows, a PipeWire output on Linux), with volume sync on Linux; done. +4. Audio: PC audio on the tablet as PCM or Opus (WASAPI loopback of the muted default output on Windows, a PipeWire output on Linux), with volume sync; done. 5. GNOME, wlroots and X11 backends. 6. Packaging: Windows installer (done, with the tray app), Linux packages (Arch, .deb, AppImage; done) and udev rules, Android `full`/`play` builds.