@desklink/host 0.5.4 → 0.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,398 @@
1
+ //! System audio probing and opt-in capture of the default PipeWire sink monitor.
2
+ //!
3
+ //! Linux captures through a PipeWire monitor of a sink, so the machine fact is
4
+ //! "a reachable sink exposes monitor ports". `capabilities` may answer that: it
5
+ //! must not prompt and must not start a capture, so this connects as any client
6
+ //! does, reads the registry and leaves — no portal, no D-Bus, no consent dialog,
7
+ //! no stream. It answers for the machine and not for the build: the same binary
8
+ //! says `false` where no PipeWire, or no sink, is reachable, and a host running
9
+ //! PulseAudio without PipeWire is `false` too, because this build has no
10
+ //! PulseAudio client and no capture path that could use one.
11
+
12
+ use anyhow::Context as _;
13
+ use pipewire as pw;
14
+ use std::cell::RefCell;
15
+ use std::collections::{HashSet, VecDeque};
16
+ use std::rc::Rc;
17
+ use std::result::Result;
18
+ use std::sync::{Arc, Mutex, MutexGuard, mpsc};
19
+ use std::thread;
20
+ use std::time::{Duration, Instant};
21
+
22
+ /// The sample format this seam carries. It describes this build, so it is
23
+ /// reported whether or not this machine has a sink; the capture asks PipeWire
24
+ /// for exactly it.
25
+ pub const FORMAT: &str = "s16le";
26
+ pub const SAMPLE_RATE: u32 = 48_000;
27
+ pub const CHANNELS: u32 = 2;
28
+
29
+ /// The sample format a session would carry, best first.
30
+ pub const FORMATS: &[&str] = &[FORMAT];
31
+
32
+ /// What a session would capture from, and in what format.
33
+ #[derive(Debug, Clone, Copy, PartialEq, Eq)]
34
+ pub struct AudioSource {
35
+ /// Names the mechanism, so a consumer can tell a sink monitor from a future
36
+ /// consent-carrying portal audio node.
37
+ pub name: &'static str,
38
+ pub formats: &'static [&'static str],
39
+ }
40
+
41
+ /// The desktop's own mix, as the sink sends it to its monitor ports.
42
+ const MONITOR: AudioSource = AudioSource {
43
+ name: "pipewire-monitor",
44
+ formats: FORMATS,
45
+ };
46
+
47
+ /// How long the registry may take to answer; `capabilities` must not hang
48
+ /// behind a server that is up but silent.
49
+ const TIMEOUT: Duration = Duration::from_millis(1500);
50
+
51
+ #[derive(Default)]
52
+ struct Scan {
53
+ /// Node ids whose `media.class` is `Audio/Sink`, and the node id each
54
+ /// `port.monitor` output port belongs to. A sink with a monitor is the node
55
+ /// a capture stream would link to; a sink without one cannot be captured
56
+ /// this way.
57
+ sinks: HashSet<u32>,
58
+ monitors: HashSet<u32>,
59
+ answered: bool,
60
+ failed: bool,
61
+ }
62
+
63
+ /// `Some(source)` exactly when opening a session with audio here would capture
64
+ /// the desktop's system audio without prompting; `None` otherwise.
65
+ pub fn probe() -> Option<AudioSource> {
66
+ pw::init();
67
+ let main_loop = pw::main_loop::MainLoopRc::new(None).ok()?;
68
+ let context = pw::context::ContextRc::new(&main_loop, None).ok()?;
69
+ let core = context.connect_rc(None).ok()?;
70
+ let registry = core.get_registry().ok()?;
71
+
72
+ // The round trip is asked for before its listener exists; a reply cannot be
73
+ // dispatched until the loop below iterates, so nothing is missed.
74
+ let pending = core.sync(0).ok();
75
+ let scan = Rc::new(RefCell::new(Scan::default()));
76
+
77
+ let _core = core
78
+ .add_listener_local()
79
+ .done({
80
+ let scan = Rc::clone(&scan);
81
+ move |_, seq| {
82
+ if Some(seq) == pending {
83
+ scan.borrow_mut().answered = true;
84
+ }
85
+ }
86
+ })
87
+ .error({
88
+ let scan = Rc::clone(&scan);
89
+ move |_, _, _, _| {
90
+ let mut scan = scan.borrow_mut();
91
+ scan.failed = true;
92
+ scan.answered = true;
93
+ }
94
+ })
95
+ .register();
96
+
97
+ let _registry = registry
98
+ .add_listener_local()
99
+ .global({
100
+ let scan = Rc::clone(&scan);
101
+ move |global| {
102
+ let Some(props) = global.props else { return };
103
+ let mut scan = scan.borrow_mut();
104
+ match global.type_ {
105
+ pw::types::ObjectType::Node
106
+ if props.get(*pw::keys::MEDIA_CLASS) == Some("Audio/Sink") =>
107
+ {
108
+ scan.sinks.insert(global.id);
109
+ }
110
+ pw::types::ObjectType::Port
111
+ if props.get(*pw::keys::PORT_MONITOR) == Some("true")
112
+ && props.get(*pw::keys::PORT_DIRECTION) == Some("out") =>
113
+ {
114
+ if let Some(node) = props.get(*pw::keys::NODE_ID).and_then(|id| id.parse().ok()) {
115
+ scan.monitors.insert(node);
116
+ }
117
+ }
118
+ _ => {}
119
+ }
120
+ }
121
+ })
122
+ .register();
123
+
124
+ let deadline = Instant::now() + TIMEOUT;
125
+ while Instant::now() < deadline {
126
+ if main_loop
127
+ .loop_()
128
+ .iterate(pw::loop_::Timeout::Finite(Duration::from_millis(50)))
129
+ < 0
130
+ {
131
+ break;
132
+ }
133
+ let scan = scan.borrow();
134
+ if scan.answered {
135
+ let capturable = !scan.failed && scan.sinks.intersection(&scan.monitors).next().is_some();
136
+ return capturable.then_some(MONITOR);
137
+ }
138
+ }
139
+ None
140
+ }
141
+
142
+ /// s16le interleaved: the bytes of one sample frame across every channel.
143
+ pub const BYTES_PER_FRAME: u64 = 2 * CHANNELS as u64;
144
+
145
+ /// 100 ms per chunk: prompt enough to show progress, calm enough that one
146
+ /// chunk is worth its own file write and its own change event.
147
+ const CHUNK_FRAMES: usize = SAMPLE_RATE as usize / 10;
148
+ const CHUNK_BYTES: usize = CHUNK_FRAMES * BYTES_PER_FRAME as usize;
149
+
150
+ /// Chunks that may wait for the consumer; past this the newest chunk is
151
+ /// dropped and counted, so what is read stays in order.
152
+ const QUEUE_CHUNKS: usize = 8;
153
+
154
+ /// One captured chunk, s16le interleaved, stamped on the engine clock.
155
+ pub struct Chunk {
156
+ pub seq: u64,
157
+ pub at: Instant,
158
+ pub bytes: Vec<u8>,
159
+ /// How far this chunk's start fell behind the previous chunk's end: an
160
+ /// under-run reads as a gap, never as silence.
161
+ pub gap_micros: u64,
162
+ }
163
+
164
+ /// A chunk announcement that borrows nothing, so the callback runs outside
165
+ /// the lock.
166
+ #[derive(Clone, Copy)]
167
+ pub struct ChunkNotice {
168
+ pub seq: u64,
169
+ pub at: Instant,
170
+ pub bytes: u64,
171
+ pub gap_micros: u64,
172
+ }
173
+
174
+ #[derive(Default)]
175
+ struct Queue {
176
+ chunks: VecDeque<Chunk>,
177
+ frames: u64,
178
+ bytes: u64,
179
+ dropped_bytes: u64,
180
+ gap_micros: u64,
181
+ }
182
+
183
+ /// What the capture thread and its session share.
184
+ struct Shared {
185
+ queue: Mutex<Queue>,
186
+ ready: Mutex<Option<mpsc::Sender<Result<(), String>>>>,
187
+ on_chunk: Box<dyn Fn(ChunkNotice) + Send + Sync>,
188
+ }
189
+
190
+ impl Shared {
191
+ fn report(&self, result: Result<(), String>) {
192
+ if let Some(ready) = lock(&self.ready).take() {
193
+ let _ = ready.send(result);
194
+ }
195
+ }
196
+ }
197
+
198
+ /// A running capture. Dropping it quits the PipeWire loop and joins the
199
+ /// thread; the session holds it for the session's lifetime.
200
+ pub struct AudioCapture {
201
+ shared: Arc<Shared>,
202
+ quit: pw::channel::Sender<()>,
203
+ thread: Option<thread::JoinHandle<()>>,
204
+ }
205
+
206
+ impl AudioCapture {
207
+ /// Capture the default sink's monitor over this session's own PipeWire
208
+ /// connection. Returns once the stream is up on the negotiated format; a
209
+ /// machine with no capturable sink fails here with a readable reason.
210
+ pub fn start(on_chunk: Box<dyn Fn(ChunkNotice) + Send + Sync>) -> Result<Self, String> {
211
+ let (ready, ready_rx) = mpsc::channel();
212
+ let shared = Arc::new(Shared {
213
+ queue: Mutex::new(Queue::default()),
214
+ ready: Mutex::new(Some(ready)),
215
+ on_chunk,
216
+ });
217
+ let (quit, stop) = pw::channel::channel();
218
+ let thread = {
219
+ let shared = Arc::clone(&shared);
220
+ thread::Builder::new()
221
+ .name("desklink-audio".into())
222
+ .spawn(move || {
223
+ if let Err(error) = run(stop, Arc::clone(&shared)) {
224
+ // After the stream is up this is meaningless; before
225
+ // that it is the only path the reason has out.
226
+ shared.report(Err(format!("{error:#}")));
227
+ }
228
+ })
229
+ .map_err(|error| format!("failed to spawn audio capture: {error}"))?
230
+ };
231
+ let reason = match ready_rx.recv_timeout(Duration::from_secs(5)) {
232
+ Ok(Ok(())) => return Ok(Self { shared, quit, thread: Some(thread) }),
233
+ Ok(Err(reason)) => reason,
234
+ Err(_) => String::from(
235
+ "the audio stream did not start within 5s: no sink to capture, or no session manager to link it",
236
+ ),
237
+ };
238
+ let _ = quit.send(());
239
+ let _ = thread.join();
240
+ Err(reason)
241
+ }
242
+
243
+ /// The next chunk in order, or `None` once everything captured is read.
244
+ pub fn take(&self) -> Option<Chunk> {
245
+ lock(&self.shared.queue).chunks.pop_front()
246
+ }
247
+
248
+ /// Captured frames, captured bytes, dropped bytes, under-run microseconds.
249
+ pub fn totals(&self) -> (u64, u64, u64, u64) {
250
+ let queue = lock(&self.shared.queue);
251
+ (queue.frames, queue.bytes, queue.dropped_bytes, queue.gap_micros)
252
+ }
253
+ }
254
+
255
+ impl Drop for AudioCapture {
256
+ fn drop(&mut self) {
257
+ let _ = self.quit.send(());
258
+ if let Some(thread) = self.thread.take() {
259
+ let _ = thread.join();
260
+ }
261
+ }
262
+ }
263
+
264
+ /// The capture thread's own state: the chunk being filled, where it started,
265
+ /// when the last one ended, and the next sequence number.
266
+ #[derive(Default)]
267
+ struct Stream {
268
+ pending: Vec<u8>,
269
+ started: Option<Instant>,
270
+ end: Option<Instant>,
271
+ seq: u64,
272
+ }
273
+
274
+ fn chunk_span() -> Duration {
275
+ Duration::from_secs_f64(CHUNK_FRAMES as f64 / SAMPLE_RATE as f64)
276
+ }
277
+
278
+ /// Hand one chunk over, or drop and count it when the consumer lags; the
279
+ /// sequence always advances, so a drop reads as a gap, not contiguous audio.
280
+ fn enqueue(shared: &Shared, state: &mut Stream, bytes: Vec<u8>, started: Instant) {
281
+ state.seq += 1;
282
+ let len = bytes.len() as u64;
283
+ let gap = state.end.map_or(Duration::ZERO, |end| started.saturating_duration_since(end));
284
+ state.end = Some(started + chunk_span());
285
+ let notice = ChunkNotice { seq: state.seq, at: started, bytes: len, gap_micros: gap.as_micros() as u64 };
286
+ let chunk = Chunk { seq: notice.seq, at: started, bytes, gap_micros: notice.gap_micros };
287
+ {
288
+ let mut queue = lock(&shared.queue);
289
+ queue.frames += len / BYTES_PER_FRAME;
290
+ queue.bytes += len;
291
+ queue.gap_micros += notice.gap_micros;
292
+ if queue.chunks.len() < QUEUE_CHUNKS {
293
+ queue.chunks.push_back(chunk);
294
+ } else {
295
+ queue.dropped_bytes += len;
296
+ }
297
+ }
298
+ (shared.on_chunk)(notice);
299
+ }
300
+
301
+ fn run(stop: pw::channel::Receiver<()>, shared: Arc<Shared>) -> anyhow::Result<()> {
302
+ pw::init();
303
+ let main_loop = pw::main_loop::MainLoopRc::new(None).context("pw_main_loop_new failed")?;
304
+ let _stop = stop.attach(main_loop.loop_(), {
305
+ let main_loop = main_loop.clone();
306
+ move |_| main_loop.quit()
307
+ });
308
+ let context = pw::context::ContextBox::new(main_loop.loop_(), None).context("pw_context_new failed")?;
309
+ // The video path arrives holding a portal fd; audio has none and connects
310
+ // to this session's own PipeWire, which is what makes a private runtime
311
+ // directory the isolation boundary.
312
+ let core = context.connect(None).context("pw_context_connect failed")?;
313
+
314
+ let props = pw::properties::properties! {
315
+ *pw::keys::MEDIA_TYPE => "Audio",
316
+ *pw::keys::MEDIA_CATEGORY => "Capture",
317
+ *pw::keys::MEDIA_ROLE => "Music",
318
+ // The sink's own output, not a source's input: the desktop's mix.
319
+ *pw::keys::STREAM_CAPTURE_SINK => "true",
320
+ };
321
+ let stream =
322
+ pw::stream::StreamBox::new(&core, "desklink-audio", props).context("pw_stream_new failed")?;
323
+
324
+ let listener = stream
325
+ .add_local_listener_with_user_data(Stream::default())
326
+ .state_changed({
327
+ let shared = Arc::clone(&shared);
328
+ move |_, _, _old, new| match new {
329
+ pw::stream::StreamState::Streaming => shared.report(Ok(())),
330
+ pw::stream::StreamState::Error(message) => shared.report(Err(format!("PipeWire audio stream stopped: {message}"))),
331
+ _ => {}
332
+ }
333
+ })
334
+ .process({
335
+ let shared = Arc::clone(&shared);
336
+ move |stream, state| {
337
+ let Some(mut buffer) = stream.dequeue_buffer() else { return };
338
+ let datas = buffer.datas_mut();
339
+ let Some(data) = datas.first_mut() else { return };
340
+ let c = data.chunk();
341
+ let (offset, size) = (c.offset() as usize, c.size() as usize);
342
+ let Some(bytes) = data.data() else { return };
343
+ let Some(bytes) = bytes.get(offset..offset.saturating_add(size)) else { return };
344
+ if bytes.is_empty() { return; }
345
+ // A chunk starts when its first byte arrives, so a stall reads
346
+ // as a gap; a split inside one buffer keeps the sample-derived span.
347
+ let mut started = Instant::now();
348
+ if !state.pending.is_empty() {
349
+ started = state.started.unwrap_or(started);
350
+ }
351
+ state.pending.extend_from_slice(bytes);
352
+ while state.pending.len() >= CHUNK_BYTES {
353
+ let rest = state.pending.split_off(CHUNK_BYTES);
354
+ let filled = std::mem::replace(&mut state.pending, rest);
355
+ enqueue(&shared, state, filled, started);
356
+ started += chunk_span();
357
+ }
358
+ state.started = (!state.pending.is_empty()).then_some(started);
359
+ }
360
+ })
361
+ .register()
362
+ .context("pw_stream_add_listener failed")?;
363
+
364
+ // Ask for the one format this seam carries; `audioconvert` adapts the sink.
365
+ let mut info = pw::spa::param::audio::AudioInfoRaw::new();
366
+ info.set_format(pw::spa::param::audio::AudioFormat::S16LE);
367
+ info.set_rate(SAMPLE_RATE);
368
+ info.set_channels(CHANNELS);
369
+ let object = pw::spa::pod::Object {
370
+ type_: pw::spa::utils::SpaTypes::ObjectParamFormat.as_raw(),
371
+ id: pw::spa::param::ParamType::EnumFormat.as_raw(),
372
+ properties: info.into(),
373
+ };
374
+ let pod = pw::spa::pod::serialize::PodSerializer::serialize(
375
+ std::io::Cursor::new(Vec::new()),
376
+ &pw::spa::pod::Value::Object(object),
377
+ )
378
+ .map_err(|error| anyhow::anyhow!("format pod serialization failed: {error}"))?;
379
+ let pod = pod.0.into_inner();
380
+ let mut params =
381
+ [pw::spa::pod::Pod::from_bytes(&pod).context("the format pod is not a valid pod")?];
382
+ stream
383
+ .connect(
384
+ pw::spa::utils::Direction::Input,
385
+ None,
386
+ pw::stream::StreamFlags::AUTOCONNECT | pw::stream::StreamFlags::MAP_BUFFERS,
387
+ &mut params,
388
+ )
389
+ .context("pw_stream_connect failed")?;
390
+
391
+ main_loop.run();
392
+ drop(listener);
393
+ Ok(())
394
+ }
395
+
396
+ fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
397
+ m.lock().unwrap_or_else(|poisoned| poisoned.into_inner())
398
+ }
package/engine/src/mac.rs CHANGED
@@ -184,6 +184,7 @@ fn capabilities() -> Value {
184
184
  "formats": ["bgra"],
185
185
  "cursor": "embedded",
186
186
  "cursor_modes": ["embedded"],
187
+ // ScreenCaptureKit audio is not wired up; macOS is out of scope.
187
188
  "audio": false,
188
189
  "displays": displays(),
189
190
  "grant": if capture_granted { "granted" } else { "missing-screen-recording" },
@@ -6,6 +6,8 @@
6
6
  //! process, owns its stdin/stdout, and carries the SDP/ICE the engine produces
7
7
  //! over whatever authenticated channel it already has.
8
8
 
9
+ #[cfg(target_os = "linux")]
10
+ mod audio;
9
11
  #[cfg(target_os = "linux")]
10
12
  mod capture;
11
13
  #[cfg(target_os = "macos")]
@@ -574,6 +576,15 @@ fn render_event(notice: session::Notice) -> Option<String> {
574
576
  "session.frame.changed",
575
577
  serde_json::json!({ "sessionId": session_id, "seq": seq, "damage": damage, "timestamp_us": timestamp_us }),
576
578
  ),
579
+ session::SessionEvent::Audio {
580
+ seq,
581
+ timestamp_us,
582
+ bytes,
583
+ gap_micros,
584
+ } => (
585
+ "session.audio.changed",
586
+ serde_json::json!({ "sessionId": session_id, "seq": seq, "timestamp_us": timestamp_us, "bytes": bytes, "gap_ms": gap_micros / 1000 }),
587
+ ),
577
588
  session::SessionEvent::Input { input } => (
578
589
  "session.input",
579
590
  serde_json::json!({ "sessionId": session_id, "input": input }),
@@ -658,6 +669,7 @@ async fn dispatch(
658
669
  loopback_tcp: params.loopback_tcp,
659
670
  agent_indicator: params.agent_indicator,
660
671
  local_frames: params.local_frames,
672
+ audio: params.audio,
661
673
  ice_servers: params
662
674
  .ice_servers
663
675
  .into_iter()
@@ -684,6 +696,7 @@ async fn dispatch(
684
696
  "geometry": session.geometry(),
685
697
  "cursor": session.cursor_info(),
686
698
  "agentIndicator": session.agent_indicator(),
699
+ "audio": session.audio_info(),
687
700
  });
688
701
  *current = Some(session);
689
702
  Ok(result)
@@ -787,6 +800,15 @@ async fn dispatch(
787
800
  .frame(params.since, &params.path, params.region)
788
801
  .map_err(|error| ErrorBody::new(error.code, error.message))
789
802
  }
803
+ "session.audio" => {
804
+ let session = require_session(current)?;
805
+ let params: protocol::AudioParams = serde_json::from_value(request.params.clone())
806
+ .map_err(|error| ErrorBody::new("malformed", error.to_string()))?;
807
+ check_session(session, &params.session_id, None)?;
808
+ session
809
+ .audio(&params.path, params.max_bytes)
810
+ .map_err(|error| ErrorBody::new(error.code, error.message))
811
+ }
790
812
  "session.feed" => {
791
813
  let session = require_session(current)?;
792
814
  let params: protocol::FeedParams = serde_json::from_value(request.params.clone())
@@ -47,6 +47,33 @@ use webrtc::peer_connection::{
47
47
  };
48
48
  use webrtc::runtime::default_runtime;
49
49
 
50
+ // desklink scout trace (dl-roam-postnom-attr): engine-side restart timeline.
51
+ // Enabled with `DESKLINK_ICE_TRACE=1`; stamps microseconds since the Unix epoch
52
+ // so these lines interleave with the vendored ICE agent's `[ice ...]` lines in
53
+ // one order. Droppable: grep `desklink scout trace`.
54
+ pub(crate) fn dl_trace_on() -> bool {
55
+ std::env::var("DESKLINK_ICE_TRACE")
56
+ .map(|v| v != "0" && !v.is_empty())
57
+ .unwrap_or(false)
58
+ }
59
+
60
+ pub(crate) fn dl_trace_us() -> u128 {
61
+ std::time::SystemTime::now()
62
+ .duration_since(std::time::UNIX_EPOCH)
63
+ .map(|d| d.as_micros())
64
+ .unwrap_or(0)
65
+ }
66
+
67
+ macro_rules! dl_eng {
68
+ ($($arg:tt)*) => {
69
+ if $crate::peer::dl_trace_on() {
70
+ eprintln!("[eng {}] {}", $crate::peer::dl_trace_us(), format_args!($($arg)*));
71
+ }
72
+ };
73
+ }
74
+
75
+ pub(crate) use dl_eng;
76
+
50
77
  /// VP9 clock rate, fixed by RFC 9628.
51
78
  const VP9_CLOCK_RATE: u32 = 90_000;
52
79
 
@@ -420,6 +447,12 @@ impl PeerConnectionEventHandler for Handler {
420
447
  }
421
448
 
422
449
  async fn on_connection_state_change(&self, state: RTCPeerConnectionState) {
450
+ // desklink scout trace (dl-roam-postnom-attr): the engine's rtc-level
451
+ // transport state event, on the same microsecond clock as the vendored
452
+ // agent's `[ice ...]` lines, so the rtc Connected gate is separable.
453
+ if dl_trace_on() {
454
+ dl_eng!("peer_state_change {state:?}");
455
+ }
423
456
  let connected = matches!(state, RTCPeerConnectionState::Connected);
424
457
  if connected {
425
458
  self.wants_keyframe.store(true, Ordering::SeqCst);
@@ -1590,6 +1623,15 @@ pub(crate) mod tests {
1590
1623
  .expect("the restart answer is accepted");
1591
1624
  }
1592
1625
 
1626
+ /// How long the restarted generation may take to nominate and reconnect,
1627
+ /// measured from the answered offer: two `check_interval` ticks. The vendored
1628
+ /// agent ticks every 200 ms (`vendor/rtc-ice/src/agent/agent_config.rs`), and
1629
+ /// the routed roam leg measured the engine's own first-valid-pair ->
1630
+ /// USE-CANDIDATE at 197 ms, one tick; the extra tick is headroom for the
1631
+ /// loopback's round trip and for a loaded machine. See the assertion in
1632
+ /// `a_restart_keeps_media_flowing_on_the_selected_pair_until_the_new_one_nominates`.
1633
+ const RECONNECT_BUDGET: Duration = Duration::from_millis(400);
1634
+
1593
1635
  /// A receiver peer that records when each inbound RTP packet arrives and
1594
1636
  /// reports its own ICE candidates, so two peers can really connect.
1595
1637
  struct MediaReceiver {
@@ -1783,6 +1825,37 @@ pub(crate) mod tests {
1783
1825
  .accept_answer(reanswer.sdp)
1784
1826
  .await
1785
1827
  .expect("the restart answer is accepted");
1828
+ let answered_at = Instant::now();
1829
+
1830
+ // The restarted generation has to be nominated off its own check cycle:
1831
+ // nomination is decided in the agent's `contact_candidates` pass, which the
1832
+ // driver reaches on a `check_interval` tick (200 ms) or when an inbound
1833
+ // Binding request arrives while a valid pair already exists. A pair that
1834
+ // becomes valid just after a tick would therefore wait a full tick for its
1835
+ // USE-CANDIDATE, and one tick is the whole engine-side cost measured on the
1836
+ // routed roam leg (first valid pair -> USE-CANDIDATE: 197 ms, i.e. one tick).
1837
+ //
1838
+ // What this asserts, and what it cannot: the engine's "first valid pair"
1839
+ // instant is not observable from here (the peer connection exposes no pair
1840
+ // state), so the bound is measured from the answer, the earliest instant the
1841
+ // engine could nominate, at two ticks. The loopback's client answers checks
1842
+ // and sends its own triggered checks in the same burst, so an inbound request
1843
+ // is nearly always available and the base engine meets this bound too; the
1844
+ // tick itself is graded by the routed roam leg, whose relay log carries both
1845
+ // instants (197 ms, one tick). This guards the property going forward: a
1846
+ // lost pass, a skipped tick or a stalled nomination blows the bound.
1847
+ let reconnected = async {
1848
+ while !engine.is_connected() {
1849
+ tokio::time::sleep(Duration::from_millis(5)).await;
1850
+ }
1851
+ answered_at.elapsed()
1852
+ };
1853
+ let reconnect = tokio::time::timeout(RECONNECT_BUDGET, reconnected)
1854
+ .await
1855
+ .expect("the restarted generation was never nominated");
1856
+ eprintln!(
1857
+ "restart: transport reconnected {reconnect:?} after the answer (budget {RECONNECT_BUDGET:?})"
1858
+ );
1786
1859
 
1787
1860
  // Keep the picture advancing across and after the restart.
1788
1861
  tokio::time::sleep(Duration::from_millis(800)).await;
@@ -144,6 +144,11 @@ pub struct OpenParams {
144
144
  /// the engine then spends nothing per frame on either.
145
145
  #[serde(default = "default_local_frames")]
146
146
  pub local_frames: bool,
147
+ /// Capture the desktop's own system audio for `session.audio`. Absent means
148
+ /// video only; there is never a silent downgrade, so a caller that asks and
149
+ /// cannot be answered is refused.
150
+ #[serde(default)]
151
+ pub audio: bool,
147
152
  }
148
153
 
149
154
  fn default_local_frames() -> bool {
@@ -244,6 +249,23 @@ pub struct SessionRef {
244
249
  pub generation: Option<u64>,
245
250
  }
246
251
 
252
+ /// Read the desktop's own audio, the way `FrameParams` reads a frame: bytes go
253
+ /// to the caller's path and JSON carries only metadata.
254
+ #[derive(Debug, Deserialize)]
255
+ pub struct AudioParams {
256
+ pub session_id: String,
257
+ #[serde(default)]
258
+ pub path: String,
259
+ /// The most bytes to write. Bounded to whole sample frames, so a truncated
260
+ /// chunk never ends mid-sample.
261
+ #[serde(default = "default_max_audio_bytes")]
262
+ pub max_bytes: usize,
263
+ }
264
+
265
+ fn default_max_audio_bytes() -> usize {
266
+ 1 << 20
267
+ }
268
+
247
269
  #[derive(Debug, Deserialize)]
248
270
  pub struct DescriptionParams {
249
271
  pub session_id: String,