fix: preserve GPUI lifetime and avoid WASAPI silence warnings

This commit is contained in:
2026-10-13 18:58:37 +02:00
parent d065ad7775
commit 072a4ab7ca
17 changed files with 470 additions and 188 deletions
+112 -10
View File
@@ -153,6 +153,8 @@ unsafe extern "C" fn log_to_stderr(
if let Some(state) = pipewire_state(&text) {
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
}
#[cfg(windows)]
record_wasapi_event(&text);
eprintln!("obs {label}: {text}");
}
// SAFETY: frees the buffer dstr_vprintf allocated (null is allowed).
@@ -612,9 +614,89 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
stem.strip_prefix("segment-")?.parse().ok()
}
#[cfg(windows)]
static AUDIO_FAULTS: Mutex<Vec<(String, std::sync::Weak<std::sync::atomic::AtomicBool>)>> =
Mutex::new(Vec::new());
#[cfg(any(windows, test))]
fn wasapi_event(message: &str) -> Option<(&str, bool)> {
let (message, source) = message.rsplit_once(" (source: ")?;
let source = source.strip_suffix(')')?;
if !source.starts_with("clipbin-") {
return None;
}
if message.starts_with("WASAPI: Device '") && message.contains(" initialized") {
Some((source, false))
} else if (message.starts_with("WASAPI: Device '") && message.contains(" failed to start"))
|| (message.starts_with("Device '") && message.contains(" invalidated. Retrying"))
{
Some((source, true))
} else {
None
}
}
#[cfg(windows)]
fn record_wasapi_event(message: &str) {
if let Some((source, failed)) = wasapi_event(message)
&& let Ok(faults) = AUDIO_FAULTS.lock()
&& let Some(flag) = faults
.iter()
.find(|(name, _)| name == source)
.and_then(|(_, flag)| flag.upgrade())
{
flag.store(failed, Ordering::Relaxed);
}
}
struct AudioClock {
started: Instant,
last_millis: AtomicU64,
#[cfg(windows)]
source_name: String,
#[cfg(windows)]
failed: Arc<std::sync::atomic::AtomicBool>,
}
impl AudioClock {
fn new(source_name: &str) -> Self {
#[cfg(windows)]
let failed = Arc::new(std::sync::atomic::AtomicBool::new(false));
#[cfg(windows)]
AUDIO_FAULTS
.lock()
.unwrap()
.push((source_name.into(), Arc::downgrade(&failed)));
#[cfg(not(windows))]
let _ = source_name;
Self {
started: Instant::now(),
last_millis: AtomicU64::new(0),
#[cfg(windows)]
source_name: source_name.into(),
#[cfg(windows)]
failed,
}
}
fn lost(&self) -> bool {
#[cfg(windows)]
{
self.failed.load(Ordering::Relaxed)
}
#[cfg(not(windows))]
{
(self.started.elapsed().as_millis() as u64)
.saturating_sub(self.last_millis.load(Ordering::Relaxed))
>= 5_000
}
}
}
#[cfg(windows)]
impl Drop for AudioClock {
fn drop(&mut self) {
if let Ok(mut faults) = AUDIO_FAULTS.lock() {
faults.retain(|(name, _)| name != &self.source_name);
}
}
}
unsafe extern "C" fn audio_captured(
@@ -626,6 +708,8 @@ unsafe extern "C" fn audio_captured(
// SAFETY: each callback holds the stable AudioClock allocation until removal;
// OBS serialises removal against callbacks with source->audio_cb_mutex.
let clock = unsafe { &*param.cast::<AudioClock>() };
#[cfg(windows)]
clock.failed.store(false, Ordering::Relaxed);
clock.last_millis.store(
clock.started.elapsed().as_millis() as u64,
Ordering::Relaxed,
@@ -693,12 +777,10 @@ impl Pipeline {
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
] {
if let Some(source_spec) = source_spec {
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
let source_name = format!("{name_prefix}-audio-{channel}");
let mut clock = Box::new(AudioClock::new(&source_name));
let source = create_source(source_spec, &cstring(&source_name))?;
pipeline.sources.push((Some(channel), source));
let mut clock = Box::new(AudioClock {
started: Instant::now(),
last_millis: AtomicU64::new(0),
});
sys::obs_source_add_audio_capture_callback(
source,
Some(audio_captured),
@@ -802,11 +884,7 @@ impl Pipeline {
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
written_bytes: 0,
disk_available_bytes: None,
audio_lost: self.audio_clocks.iter().any(|(_, clock)| {
(clock.started.elapsed().as_millis() as u64)
.saturating_sub(clock.last_millis.load(Ordering::Relaxed))
>= 5_000
}),
audio_lost: self.audio_clocks.iter().any(|(_, clock)| clock.lost()),
}
}
@@ -1033,6 +1111,30 @@ unsafe fn create_source(spec: &SourceSpec, name: &CStr) -> Result<*mut sys::obs_
mod tests {
use super::*;
#[test]
fn wasapi_events_are_scoped_and_distinguish_failure_from_silence() {
assert_eq!(
wasapi_event(
"WASAPI: Device 'Speakers' [48000 Hz] initialized (source: clipbin-7-audio-1)"
),
Some(("clipbin-7-audio-1", false))
);
assert_eq!(
wasapi_event("Device 'Speakers' invalidated. Retrying (source: clipbin-7-audio-1)"),
Some(("clipbin-7-audio-1", true))
);
assert_eq!(
wasapi_event("WASAPI: Device 'default' failed to start (source: clipbin-7-audio-3)"),
Some(("clipbin-7-audio-3", true))
);
assert_eq!(
wasapi_event("Device 'Speakers' invalidated. Retrying (source: another-app)"),
None
);
assert_eq!(wasapi_event("WASAPI: Device 'Speakers' Terminated"), None);
assert_eq!(wasapi_event("WASAPI: Default output device changed"), None);
}
#[test]
fn pipewire_state_is_read_from_the_plugin_log() {
let state = |line| pipewire_state(line);
+1 -1
View File
@@ -304,7 +304,7 @@ pub struct Health {
/// PipeWire capture's portal and stream state. `None` for other sources,
/// which report loss through their size, and before the portal answers.
pub pipewire: Option<PipeWireState>,
/// A configured capture source has delivered no audio for five seconds.
/// A configured capture source lost delivery (Linux) or reported a device error (Windows).
pub audio_lost: bool,
pub output_active: bool,
pub total_frames: u64,