nandi/jolt-nativepublic Fork 0
600f207e2f91fc746a6545b141494a260e410f60
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

lib.rs · 951 lines · 35.0 KBRust Blame HistoryRaw
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago1//! freeq's AV media plane, for callers that are not Rust.
2//!
3//! sleek reaches its calls by calling Rust from Rust. A client written in
4//! anything else cannot, and this is not a plane to write twice: MoQ over
5//! QUIC, Opus, H.264, camera capture and the SFU's own dial rules come to some
6//! three thousand lines that would say exactly the same thing again in another
7//! language, and be wrong in different places.
8//!
9//! So it was lifted out of sleek to here, and what crosses the boundary is
10//! only what jolt can hold: integers, doubles, borrowed UTF-8 strings, and one
11//! borrowed pixel pointer.
12//!
13//! # What is not here
14//!
15//! **Signaling.** A freeq call is opened, joined and left over IRC TAGMSGs —
16//! `+freeq.at/av-start` and its siblings — and the server answers with
17//! `+freeq.at/av-state`. Any client that can speak IRC already has everything
18//! it needs for that, in whatever language it speaks IRC in; crossing an FFI
19//! boundary to send a TAGMSG would be worse than not. This library begins once
20//! the session id and the SFU token are known, and ends when the call does.
21//!
22//! # The shape of it
23//!
24//! **Nothing calls back.** Status arrives through [`joltmoq_poll_status`],
25//! video through [`joltmoq_frame_poll`], both drained from whatever thread the
26//! caller paints on — see [`jolt_abi`] for why.
27//!
28//! **One call at a time.** There is one microphone, so there is one session,
29//! held in a global. A handle would imply a second call could run beside the
30//! first, which is not something the hardware or the person would enjoy.
31//!
32//! **Frames are borrowed, not copied.** [`joltmoq_frame_rgba`] hands out a
33//! pointer into the decoder's own buffer, good until the next poll. A frame is
34//! a megabyte or two; copying it out so the caller can hand it straight to a
35//! texture upload would be two copies a frame to no end.
36
37pub mod av;
38pub mod av_media;
39mod v4l2cam;
40
41use std::collections::HashMap;
42use std::ffi::{c_char, c_int};
43use std::sync::{Mutex, OnceLock};
44
45use jolt_abi::{borrowed, empty_str, guard, preference, Scratch};
46
47use av::{RgbaVideoFrame, VideoFrameStore};
48use av_media::{AvMediaConfig, AvMediaSession, AvMediaUpdate};
49
50/// One scratch per family of string-returning calls, so that asking for the
51/// video keys does not invalidate a device list the caller is still reading.
52static STATUS_TEXT: Scratch = Scratch::new();
53static FRAME_KEY: Scratch = Scratch::new();
54static VIDEO_KEYS: Scratch = Scratch::new();
55static DEVICES: Scratch = Scratch::new();
56static DIAL: Scratch = Scratch::new();
57
58// ── The one session ─────────────────────────────────────────────────────────
59
60/// Everything a live call holds on this side.
61#[derive(Default)]
62struct Slot {
63 session: Option<AvMediaSession>,
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago64 /// Which session the statuses below belong to.
65 ///
66 /// Tearing a call down is spawned rather than waited for, so the task of a
67 /// call that has ended outlives the call — and finishes, and reports that
68 /// it ended, possibly after the *next* call has started. Without a way to
69 /// tell whose news this is, the old session's dying breath tears down the
70 /// new one, which is exactly what rejoining a call did.
71 ///
72 /// Bumped on every start and every stop, so anything a departed session
73 /// still has to say is discarded rather than acted on.
74 generation: u64,
75 /// Status updates from the media task, waiting to be polled, each with the
76 /// generation it was produced under.
77 status: Vec<(u64, Status)>,
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago78 /// The status most recently handed out, whose fields the accessors read.
79 current: Option<Status>,
80 /// The live session's frame store, and the generation last handed out per
81 /// feed — which is how a poll knows what is new.
82 video: Option<VideoFrameStore>,
83 seen: HashMap<String, u64>,
84 /// The frame most recently handed out. Held so that the pixel pointer the
85 /// caller was given stays alive until the next poll replaces it.
86 frame: Option<(String, RgbaVideoFrame)>,
87}
88
89#[derive(Clone)]
90enum Status {
91 Live { has_camera: bool, has_mic: bool },
92 Ended,
93 Failed(String),
94}
95
96fn session_slot() -> &'static Mutex<Slot> {
97 static SLOT: OnceLock<Mutex<Slot>> = OnceLock::new();
98 SLOT.get_or_init(|| Mutex::new(Slot::default()))
99}
100
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago101/// A tokio runtime of our own: the caller has no reactor to lend us.
102///
103/// Deliberately *not* in the slot. Tearing a call down needs the runtime and
104/// must not be holding the slot lock while it does — see [`joltmoq_stop`].
105/// Made once and kept, since building one per call would tear worker threads
106/// down and up again on every join.
107fn runtime() -> Option<&'static tokio::runtime::Runtime> {
108 static RUNTIME: OnceLock<Option<tokio::runtime::Runtime>> = OnceLock::new();
109 RUNTIME
110 .get_or_init(|| {
111 tokio::runtime::Builder::new_multi_thread()
112 .enable_all()
113 .thread_name("joltmoq")
114 .build()
115 .map_err(|e| log::error!("joltmoq: no runtime: {e}"))
116 .ok()
117 })
118 .as_ref()
119}
120
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago121/// Run `f` against the one session, catching panics on the way out.
122///
123/// `fallback` answers both a panic and a poisoned lock, and is deliberately
124/// taken by value rather than requiring `Copy`: the answer is sometimes a
125/// `String`. The `Option` is how one value serves both arms without being
126/// moved twice.
127fn with_slot<R>(fallback: R, f: impl FnOnce(&mut Slot) -> R) -> R {
128 guard(None, || match session_slot().lock() {
129 Ok(mut slot) => Some(f(&mut slot)),
130 Err(_) => None,
131 })
132 .unwrap_or(fallback)
133}
134
135// ── Lifecycle ───────────────────────────────────────────────────────────────
136
137/// Turn on logging to stderr, honouring `RUST_LOG`. Safe to call twice.
138///
139/// Worth calling first while a call refuses to connect: the media plane says a
140/// great deal about why, and says none of it otherwise.
141#[no_mangle]
142pub extern "C" fn joltmoq_init_logging() {
143 guard((), || {
144 let _ = env_logger::try_init();
145 })
146}
147
148/// Join the call at `sfu_url` as `nick`, answering 1 when the media task
149/// started.
150///
151/// `sfu_url` is what [`joltmoq_sfu_url`] built, `session_id` is the id the
152/// server broadcast in `+freeq.at/av-id`, and `instance` is the per-device id
153/// the caller put in its own `av-join` — two devices signed in as the same
154/// person need different ones or their broadcast paths collide and each
155/// unpublishes the other.
156///
157/// The rest are pre-call preferences: `muted`, `speaker_muted` and `camera` as
158/// 0 or 1, then three device preferences, each an empty string for "whatever
159/// the system uses".
160///
161/// This returns as soon as the task is spawned. Connecting takes a moment, and
162/// it is [`joltmoq_poll_status`] that says whether it worked. Starting a second
163/// call while one is live is refused; stop the first.
164///
165/// # Safety
166/// Every pointer is null or a NUL-terminated UTF-8 string.
167#[no_mangle]
168#[allow(clippy::too_many_arguments)]
169pub unsafe extern "C" fn joltmoq_start(
170 sfu_url: *const c_char,
171 session_id: *const c_char,
172 nick: *const c_char,
173 instance: *const c_char,
174 muted: c_int,
175 speaker_muted: c_int,
176 camera: c_int,
177 camera_id: *const c_char,
178 mic_id: *const c_char,
179 speaker_id: *const c_char,
180) -> c_int {
181 let sfu_url = borrowed(sfu_url);
182 let session_id = borrowed(session_id);
183 let nick = borrowed(nick);
184 let instance = borrowed(instance);
185 let camera_id = preference(borrowed(camera_id));
186 let mic_id = preference(borrowed(mic_id));
187 let speaker_id = preference(borrowed(speaker_id));
188
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago189 let Some(runtime) = runtime() else {
190 return 0;
191 };
192
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago193 with_slot(0, |slot| {
194 if slot.session.is_some() {
195 log::warn!("joltmoq: a call is already live; stop it first");
196 return 0;
197 }
198 let Ok(url) = url::Url::parse(&sfu_url) else {
199 log::warn!("joltmoq: not a URL: {sfu_url}");
200 return 0;
201 };
202
203 let config = AvMediaConfig {
204 sfu_url: url,
205 session_id,
206 nick,
207 instance,
208 muted: muted != 0,
209 speaker_muted: speaker_muted != 0,
210 camera_enabled: camera != 0,
211 camera_id,
212 mic_id,
213 speaker_id,
214 };
215
216 // `AvMediaSession::start` spawns onto the ambient runtime, so it has to
217 // be entered. The status closure it takes runs on a worker thread,
218 // which is why all it does is push onto a queue the caller drains.
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago219 slot.generation = slot.generation.wrapping_add(1);
220 let generation = slot.generation;
221
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago222 let _entered = runtime.enter();
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago223 let session = AvMediaSession::start(config, move |update| {
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago224 let status = match update {
225 AvMediaUpdate::Live {
226 has_camera,
227 has_mic,
228 ..
229 } => Status::Live {
230 has_camera,
231 has_mic,
232 },
233 AvMediaUpdate::Ended => Status::Ended,
234 AvMediaUpdate::Failed(e) => Status::Failed(e),
235 };
236 if let Ok(mut slot) = session_slot().lock() {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago237 // Dropped on the floor if this session is no longer the
238 // current one — see `Slot::generation`.
239 if slot.generation == generation {
240 slot.status.push((generation, status));
241 }
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago242 }
243 });
244
245 slot.video = Some(session.video.clone());
246 slot.session = Some(session);
247 slot.seen.clear();
248 slot.frame = None;
249 1
250 })
251}
252
253/// Leave the call: unpublish, drop the devices, forget the frames.
254///
255/// Waits briefly for the media task to tear MoQ down rather than aborting it
256/// outright. An abandoned broadcast lingers on the SFU and peers subscribe to
257/// it, so they see someone present and hear silence.
258#[no_mangle]
259pub extern "C" fn joltmoq_stop() {
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago260 // Take the session out from under the lock and let the lock go *before*
261 // anything waits on it.
262 //
263 // Waiting while holding it deadlocks, and did: the status callback locks
264 // this same slot from a tokio worker, so a task that still had an update
265 // to deliver could not finish, while the thread that would have released
266 // the lock was waiting for exactly that task to finish. The caller's UI
267 // thread is the one that hangs, which is every thread it has.
268 let session = with_slot(None, |slot| {
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago269 slot.video = None;
270 slot.seen.clear();
271 slot.frame = None;
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago272 // Nothing this session says from here on is about the call the caller
273 // is in, because it is not in one — and may be in a different one by
274 // the time the task gets round to saying it.
275 slot.generation = slot.generation.wrapping_add(1);
276 slot.status.clear();
277 slot.current = None;
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago278 slot.session.take()
279 });
280 let Some(mut session) = session else {
281 return;
282 };
283
284 // Not waited for either. Tearing MoQ down is a network round trip, and the
285 // press that asked for it was on the thread that paints — half a second of
286 // frozen window is not the answer to "leave". The teardown still runs, and
287 // still unpublishes properly: an abandoned broadcast lingers on the SFU and
288 // peers subscribe to it, seeing someone present who is silent.
289 match runtime() {
290 Some(runtime) => {
291 runtime.spawn(async move {
292 session
293 .stop_and_wait(std::time::Duration::from_secs(2))
294 .await;
295 });
296 }
297 // No runtime means no call ever started; nothing to unwind gracefully.
298 None => session.stop(),
299 }
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago300}
301
302/// 1 while a call is live on this side.
303#[no_mangle]
304pub extern "C" fn joltmoq_is_live() -> c_int {
305 with_slot(0, |slot| slot.session.is_some() as c_int)
306}
307
308// ── Controls ────────────────────────────────────────────────────────────────
309
310/// Mute or unmute the microphone. Peers stop hearing you; you still hear them.
311#[no_mangle]
312pub extern "C" fn joltmoq_set_muted(muted: c_int) {
313 with_slot((), |slot| {
314 if let Some(s) = &slot.session {
315 s.set_muted(muted != 0);
316 }
317 })
318}
319
320/// Mute or unmute remote audio — deafen. Deliberately not the same control as
321/// [`joltmoq_set_muted`]: peers still hear you if the microphone is open.
322#[no_mangle]
323pub extern "C" fn joltmoq_set_speaker_muted(muted: c_int) {
324 with_slot((), |slot| {
325 if let Some(s) = &slot.session {
326 s.set_speaker_muted(muted != 0);
327 }
328 })
329}
330
331/// Start or stop publishing camera. The device is only held while publishing,
332/// so turning it off gives the hardware back to the rest of the machine.
333#[no_mangle]
334pub extern "C" fn joltmoq_set_camera(enabled: c_int) {
335 with_slot((), |slot| {
336 if let Some(s) = &slot.session {
337 s.set_camera_enabled(enabled != 0);
338 }
339 })
340}
341
342/// Re-open the camera by id; an empty string means the first available.
343///
344/// # Safety
345/// `id` is null or a NUL-terminated UTF-8 string.
346#[no_mangle]
347pub unsafe extern "C" fn joltmoq_set_camera_device(id: *const c_char) {
348 let id = preference(borrowed(id));
349 with_slot((), |slot| {
350 if let Some(s) = &slot.session {
351 s.set_camera_device(id);
352 }
353 })
354}
355
356/// Switch microphone by name; an empty string means the system default.
357///
358/// # Safety
359/// `id` is null or a NUL-terminated UTF-8 string.
360#[no_mangle]
361pub unsafe extern "C" fn joltmoq_set_mic_device(id: *const c_char) {
362 let id = preference(borrowed(id));
363 with_slot((), |slot| {
364 if let Some(s) = &slot.session {
365 s.set_mic_device(id);
366 }
367 })
368}
369
370/// Switch speaker by name; an empty string means the system default.
371///
372/// # Safety
373/// `id` is null or a NUL-terminated UTF-8 string.
374#[no_mangle]
375pub unsafe extern "C" fn joltmoq_set_speaker_device(id: *const c_char) {
376 let id = preference(borrowed(id));
377 with_slot((), |slot| {
378 if let Some(s) = &slot.session {
379 s.set_speaker_device(id);
380 }
381 })
382}
383
384/// The live microphone envelope, 0.0 to 1.0 — what a level meter draws. 0 when
385/// no call is up.
386#[no_mangle]
387pub extern "C" fn joltmoq_mic_level() -> f64 {
388 with_slot(0.0, |slot| {
389 slot.session
390 .as_ref()
391 .map_or(0.0, |s| s.mic_level.get() as f64)
392 })
393}
394
395// ── Status ──────────────────────────────────────────────────────────────────
396
397/// Nothing waiting.
398pub const JOLTMOQ_STATUS_NONE: c_int = 0;
399/// Connected and publishing.
400pub const JOLTMOQ_STATUS_LIVE: c_int = 1;
401/// The session ended cleanly.
402pub const JOLTMOQ_STATUS_ENDED: c_int = 2;
403/// Connect or runtime failure; [`joltmoq_status_text`] says what.
404pub const JOLTMOQ_STATUS_FAILED: c_int = 3;
405
406/// Dequeue one status update, or 0 when there is none.
407///
408/// The media task produces these on its own threads; this hands them to the
409/// caller's. Drain it until it answers 0 wherever the caller polls — beside a
410/// repaint is the natural place. Missing an update means a call that is up
411/// still looks like it is connecting.
412#[no_mangle]
413pub extern "C" fn joltmoq_poll_status() -> c_int {
414 with_slot(JOLTMOQ_STATUS_NONE, |slot| {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago415 // Drop anything a replaced session left behind, rather than stopping
416 // at it: answering NONE on a stale entry would strand every fresh one
417 // queued behind it.
418 let current = slot.generation;
419 slot.status.retain(|(generation, _)| *generation == current);
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago420 if slot.status.is_empty() {
421 slot.current = None;
422 return JOLTMOQ_STATUS_NONE;
423 }
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago424 let (_, status) = slot.status.remove(0);
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago425 let code = match &status {
426 Status::Live { .. } => JOLTMOQ_STATUS_LIVE,
427 Status::Ended => JOLTMOQ_STATUS_ENDED,
428 Status::Failed(_) => JOLTMOQ_STATUS_FAILED,
429 };
430 slot.current = Some(status);
431 code
432 })
433}
434
435/// Why the last polled status failed; the empty string for any other status.
436///
437/// # Safety
438/// The returned pointer is valid until the next call to this function.
439#[no_mangle]
440pub extern "C" fn joltmoq_status_text() -> *const c_char {
441 let text = with_slot(String::new(), |slot| match &slot.current {
442 Some(Status::Failed(e)) => e.clone(),
443 _ => String::new(),
444 });
445 if text.is_empty() {
446 return empty_str();
447 }
448 STATUS_TEXT.lend(text)
449}
450
451/// 1 when the last polled `live` status found a capture device for this call.
452///
453/// A call can be live with no camera at all, so a caller shows a camera control
454/// only when there is something for it to turn on.
455#[no_mangle]
456pub extern "C" fn joltmoq_status_has_camera() -> c_int {
457 with_slot(0, |slot| match &slot.current {
458 Some(Status::Live { has_camera, .. }) => *has_camera as c_int,
459 _ => 0,
460 })
461}
462
463/// 1 when the last polled `live` status had a real microphone feeding the
464/// outbound track. 0 means listen-only: audio is still published, as silence.
465#[no_mangle]
466pub extern "C" fn joltmoq_status_has_mic() -> c_int {
467 with_slot(0, |slot| match &slot.current {
468 Some(Status::Live { has_mic, .. }) => *has_mic as c_int,
469 _ => 0,
470 })
471}
472
473// ── Video ───────────────────────────────────────────────────────────────────
474
475/// Advance to the next feed carrying a frame the caller has not been given,
476/// answering 1 while there was one.
477///
478/// Loop until it answers 0, reading [`joltmoq_frame_key`],
479/// [`joltmoq_frame_width`], [`joltmoq_frame_height`] and
480/// [`joltmoq_frame_rgba`] for each and handing those to whatever paints. Only
481/// what changed comes over: a participant sitting still costs nothing, and a
482/// decoder running ahead of the window is coalesced to its newest frame rather
483/// than queued behind stale ones.
484///
485/// The key is the participant's nick, or `__local__` for the self-view.
486#[no_mangle]
487pub extern "C" fn joltmoq_frame_poll() -> c_int {
488 with_slot(0, |slot| {
489 slot.frame = None;
490 let Some(video) = slot.video.clone() else {
491 return 0;
492 };
493 let snapshot = video.snapshot();
494 for (key, frame) in &snapshot {
495 if slot.seen.get(key) == Some(&frame.gen) {
496 continue;
497 }
498 slot.seen.insert(key.clone(), frame.gen);
499 slot.frame = Some((key.clone(), frame.clone()));
500 return 1;
501 }
502 // Nothing new. Forget the keys nobody is publishing any more, so that
503 // if they come back the first frame of the new stream reads as new
504 // rather than as one already seen.
505 slot.seen
506 .retain(|k, _| snapshot.iter().any(|(live, _)| live == k));
507 0
508 })
509}
510
511/// Whose picture the last polled frame is: a nick, or `__local__`.
512///
513/// # Safety
514/// The returned pointer is valid until the next call to this function.
515#[no_mangle]
516pub extern "C" fn joltmoq_frame_key() -> *const c_char {
517 let key = with_slot(String::new(), |slot| {
518 slot.frame
519 .as_ref()
520 .map_or(String::new(), |(k, _)| k.clone())
521 });
522 if key.is_empty() {
523 return empty_str();
524 }
525 FRAME_KEY.lend(key)
526}
527
528/// The last polled frame's width in pixels, or 0.
529#[no_mangle]
530pub extern "C" fn joltmoq_frame_width() -> c_int {
531 with_slot(0, |slot| {
532 slot.frame.as_ref().map_or(0, |(_, f)| f.width as c_int)
533 })
534}
535
536/// The last polled frame's height in pixels, or 0.
537#[no_mangle]
538pub extern "C" fn joltmoq_frame_height() -> c_int {
539 with_slot(0, |slot| {
540 slot.frame.as_ref().map_or(0, |(_, f)| f.height as c_int)
541 })
542}
543
544/// The last polled frame's pixels: `width * height * 4` bytes, row-major, 8
545/// bits a channel, un-premultiplied and opaque. Null when there is no frame.
546///
547/// **Borrowed, and only until the next [`joltmoq_frame_poll`].** That is the
548/// point of polling: a frame is a megabyte or two, and copying it into the
549/// caller's memory so it can hand it straight to a texture upload would be two
550/// copies a frame for nothing. Upload it, then poll again.
551///
552/// # Safety
553/// Valid for reads of `width * height * 4` bytes until the next call to
554/// [`joltmoq_frame_poll`] or [`joltmoq_stop`].
555#[no_mangle]
556pub extern "C" fn joltmoq_frame_rgba() -> *const u8 {
557 with_slot(std::ptr::null(), |slot| match &slot.frame {
558 Some((_, frame)) => frame.rgba.as_ptr(),
559 None => std::ptr::null(),
560 })
561}
562
563/// Everyone whose picture the call is currently carrying, one key a line.
564///
565/// A caller painting a tile per feed uses this to notice a tile it should stop
566/// painting: someone who left stops appearing here, while their last frame
567/// would otherwise hang on the wall for the rest of the call.
568///
569/// # Safety
570/// The returned pointer is valid until the next call to this function.
571#[no_mangle]
572pub extern "C" fn joltmoq_video_keys() -> *const c_char {
573 let keys = with_slot(String::new(), |slot| match &slot.video {
574 Some(video) => {
575 let mut keys: Vec<String> = video.snapshot().into_iter().map(|(k, _)| k).collect();
576 keys.sort();
577 keys.join("\n")
578 }
579 None => String::new(),
580 });
581 if keys.is_empty() {
582 return empty_str();
583 }
584 VIDEO_KEYS.lend(keys)
585}
586
587// ── Devices ─────────────────────────────────────────────────────────────────
588
589/// The cameras, one a line, each `id\tname\tdefault`.
590///
591/// Enumerating opens nothing, so this is safe to ask before a call and during
592/// one. Empty on a machine with no camera, which is a normal answer.
593///
594/// # Safety
595/// The returned pointer is valid until the next device-listing call.
596#[no_mangle]
597pub extern "C" fn joltmoq_cameras() -> *const c_char {
598 lend_devices(guard(Vec::new(), av_media::list_cameras))
599}
600
601/// The microphones, one a line, each `id\tname\tdefault`.
602///
603/// # Safety
604/// The returned pointer is valid until the next device-listing call.
605#[no_mangle]
606pub extern "C" fn joltmoq_microphones() -> *const c_char {
607 lend_devices(guard(Vec::new(), av_media::list_microphones))
608}
609
610/// The speakers, one a line, each `id\tname\tdefault`.
611///
612/// # Safety
613/// The returned pointer is valid until the next device-listing call.
614#[no_mangle]
615pub extern "C" fn joltmoq_speakers() -> *const c_char {
616 lend_devices(guard(Vec::new(), av_media::list_speakers))
617}
618
619fn lend_devices(list: Vec<av_media::MediaDevice>) -> *const c_char {
620 let rendered = devices(list);
621 if rendered.is_empty() {
622 return empty_str();
623 }
624 DEVICES.lend(rendered)
625}
626
627/// A device list as lines of `id\tname\tdefault`, where the last column is 1
628/// for the system default and 0 otherwise.
629///
630/// Tab and newline are the delimiters because a device name may contain
631/// anything else — "EMEET SmartCam C960, Mono" has spaces and a comma in it,
632/// and a caller splitting on those gets nonsense. A name that somehow holds a
633/// delimiter has it replaced rather than being allowed to forge a row.
634///
635/// The default is worth a column of its own: a picker that cannot mark it has
636/// to guess, and "Default" is not reliably the first entry or a name anyone
637/// can match on.
638fn devices(list: Vec<av_media::MediaDevice>) -> String {
639 list.into_iter()
640 .map(|d| {
641 format!(
642 "{}\t{}\t{}",
643 d.id.replace(['\t', '\n'], " "),
644 d.name.replace(['\t', '\n'], " "),
645 d.is_default as u8
646 )
647 })
648 .collect::<Vec<_>>()
649 .join("\n")
650}
651
652// ── Dialling ────────────────────────────────────────────────────────────────
653
654/// The SFU URL to hand [`joltmoq_start`], built from the IRC server the caller
655/// is connected to and the JWT the server minted in `+freeq.at/av-token`.
656///
657/// `server` is whatever the client knows the server as — `irc.freeq.at:6697`,
658/// `wss://irc.freeq.at/irc` — and both become `https://irc.freeq.at/av/moq`.
659/// `jwt` and `instance` may be empty. The empty string comes back when the
660/// server is not something a URL can be made of.
661///
662/// This lives here rather than in the caller because the rules are fiddly and
663/// already tested on this side: which scheme maps to which, where the port
664/// goes, and what to do with a path that was there.
665///
666/// # Safety
667/// Every pointer is null or a NUL-terminated UTF-8 string; the returned pointer
668/// is valid until the next call to this function.
669#[no_mangle]
670pub unsafe extern "C" fn joltmoq_sfu_url(
671 server: *const c_char,
672 jwt: *const c_char,
673 instance: *const c_char,
674) -> *const c_char {
675 let server = borrowed(server);
676 let jwt = preference(borrowed(jwt));
677 let instance = preference(borrowed(instance));
678 let url = guard(String::new(), || {
679 av::sfu_moq_dial_url(&server, jwt.as_deref(), instance.as_deref())
680 .map(|u| u.to_string())
681 .unwrap_or_default()
682 });
683 if url.is_empty() {
684 return empty_str();
685 }
686 DIAL.lend(url)
687}
688
689/// 1 when dialling this server is worth attempting.
690///
691/// A remote SFU with no token accepts the connection and closes it, and
692/// moq-lite then retries in a tight loop that looks, from the outside, exactly
693/// like a hang. Asking first is cheaper than explaining that.
694///
695/// # Safety
696/// Both pointers are null or NUL-terminated UTF-8 strings.
697#[no_mangle]
698pub unsafe extern "C" fn joltmoq_can_dial(server: *const c_char, jwt: *const c_char) -> c_int {
699 let server = borrowed(server);
700 let jwt = preference(borrowed(jwt));
701 guard(0, || av::can_dial_sfu(&server, jwt.as_deref()) as c_int)
702}
703
704/// A per-device call instance id — eight hex characters.
705///
706/// Two devices signed in as the same person need different ones, or their MoQ
707/// broadcast paths collide and each unpublishes the other. The caller puts this
708/// in its own `+freeq.at/av-instance` tag and hands the same one to
709/// [`joltmoq_start`].
710///
711/// # Safety
712/// The returned pointer is valid until the next call to this function.
713#[no_mangle]
714pub extern "C" fn joltmoq_new_instance() -> *const c_char {
715 DIAL.lend(guard("00000000".to_owned(), || {
716 format!("{:08x}", rand::random::<u32>())
717 }))
718}
719
720#[cfg(test)]
721mod tests {
722 use super::*;
723 use std::ffi::{CStr, CString};
724
725 fn read(ptr: *const c_char) -> String {
726 unsafe { CStr::from_ptr(ptr) }.to_string_lossy().into_owned()
727 }
728
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago729 /// There is one session in this library, so there is one in its tests, and
730 /// cargo runs them in parallel. Anything touching the global slot takes
731 /// this first; without it a test asserting "nothing is live" fails because
732 /// another was mid-call at the time.
733 static ONE_AT_A_TIME: Mutex<()> = Mutex::new(());
734
735 fn exclusive() -> std::sync::MutexGuard<'static, ()> {
736 // A test that panicked while holding it poisoned it; that is the
737 // failure being reported, not a reason to fail every test after it.
738 ONE_AT_A_TIME.lock().unwrap_or_else(|e| e.into_inner())
739 }
740
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago741 #[test]
742 fn a_device_list_is_tab_and_newline_delimited() {
743 let list = vec![
744 av_media::MediaDevice {
745 id: "/dev/video0".to_owned(),
746 name: "EMEET SmartCam C960, Mono".to_owned(),
747 is_default: true,
748 },
749 av_media::MediaDevice {
750 id: "/dev/video2".to_owned(),
751 name: "Integrated".to_owned(),
752 is_default: false,
753 },
754 ];
755 assert_eq!(
756 devices(list),
757 "/dev/video0\tEMEET SmartCam C960, Mono\t1\n/dev/video2\tIntegrated\t0"
758 );
759 }
760
761 #[test]
762 fn a_name_holding_a_delimiter_cannot_forge_a_row() {
763 let list = vec![av_media::MediaDevice {
764 id: "a\tb".to_owned(),
765 name: "two\nlines".to_owned(),
766 is_default: false,
767 }];
768 let rendered = devices(list);
769 assert_eq!(rendered, "a b\ttwo lines\t0");
770 assert_eq!(rendered.lines().count(), 1);
771 }
772
773 #[test]
774 fn the_sfu_url_is_built_from_whatever_the_server_is_known_as() {
775 let jwt = CString::new("tok.jwt.value").unwrap();
776 let inst = CString::new("abcd1234").unwrap();
777 for known_as in ["irc.freeq.at:6697", "wss://irc.freeq.at/irc"] {
778 let server = CString::new(known_as).unwrap();
779 let url =
780 read(unsafe { joltmoq_sfu_url(server.as_ptr(), jwt.as_ptr(), inst.as_ptr()) });
781 assert!(url.starts_with("https://irc.freeq.at/av/moq"), "{url}");
782 assert!(url.contains("inst=abcd1234"), "{url}");
783 }
784 }
785
786 #[test]
787 fn a_server_that_is_not_a_url_answers_the_empty_string() {
788 let server = CString::new(" ").unwrap();
789 let url = read(unsafe {
790 joltmoq_sfu_url(server.as_ptr(), std::ptr::null(), std::ptr::null())
791 });
792 assert_eq!(url, "");
793 }
794
795 #[test]
796 fn a_remote_sfu_without_a_token_is_not_worth_dialling() {
797 let remote = CString::new("wss://chat.example.com").unwrap();
798 let local = CString::new("ws://localhost:4443").unwrap();
799 let jwt = CString::new("tok").unwrap();
800 assert_eq!(
801 unsafe { joltmoq_can_dial(remote.as_ptr(), std::ptr::null()) },
802 0
803 );
804 assert_eq!(unsafe { joltmoq_can_dial(remote.as_ptr(), jwt.as_ptr()) }, 1);
805 assert_eq!(
806 unsafe { joltmoq_can_dial(local.as_ptr(), std::ptr::null()) },
807 1
808 );
809 }
810
811 #[test]
812 fn an_instance_id_is_eight_hex_characters_and_differs() {
813 let one = read(joltmoq_new_instance());
814 let two = read(joltmoq_new_instance());
815 assert_eq!(one.len(), 8);
816 assert!(one.chars().all(|c| c.is_ascii_hexdigit()), "{one}");
817 assert_ne!(one, two, "two devices would collide on the SFU");
818 }
819
820 #[test]
821 fn nothing_is_live_before_a_call_and_every_poll_answers_empty() {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago822 let _guard = exclusive();
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago823 assert_eq!(joltmoq_is_live(), 0);
824 assert_eq!(joltmoq_poll_status(), JOLTMOQ_STATUS_NONE);
825 assert_eq!(read(joltmoq_status_text()), "");
826 assert_eq!(joltmoq_frame_poll(), 0);
827 assert!(joltmoq_frame_rgba().is_null());
828 assert_eq!(joltmoq_frame_width(), 0);
829 assert_eq!(read(joltmoq_frame_key()), "");
830 assert_eq!(read(joltmoq_video_keys()), "");
831 }
832
833 #[test]
834 fn a_control_with_no_call_under_it_is_inert() {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago835 let _guard = exclusive();
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago836 // A UI that sends a mute as the call is ending must not take the
837 // process with it.
838 joltmoq_set_muted(1);
839 joltmoq_set_speaker_muted(1);
840 joltmoq_set_camera(1);
841 unsafe { joltmoq_set_mic_device(std::ptr::null()) };
842 joltmoq_stop();
843 assert_eq!(joltmoq_mic_level(), 0.0);
844 assert_eq!(joltmoq_is_live(), 0);
845 }
846
847 #[test]
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago848 fn stopping_does_not_hold_the_lock_a_status_callback_needs() {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago849 let _guard = exclusive();
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago850 // The deadlock this guards: `stop` used to wait for the media task
851 // while holding the slot, and the task's status callback locks the
852 // slot to deliver an update — so neither could finish. Here a thread
853 // takes the slot the way that callback does, while the main thread
854 // stops. If stop waits under the lock, this never returns.
855 use std::sync::mpsc;
856 use std::time::Duration;
857
858 let (tx, rx) = mpsc::channel();
859 std::thread::spawn(move || {
860 for _ in 0..200 {
861 if let Ok(mut slot) = session_slot().lock() {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago862 let generation = slot.generation;
863 slot.status.push((generation, Status::Ended));
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago864 slot.status.clear();
865 }
866 std::thread::sleep(Duration::from_millis(1));
867 }
868 let _ = tx.send(());
869 });
870
871 for _ in 0..50 {
872 joltmoq_stop();
873 }
874 assert_eq!(joltmoq_is_live(), 0);
875 rx.recv_timeout(Duration::from_secs(10))
876 .expect("the status thread never got the lock back");
877 }
878
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago879 #[test]
880 fn a_departed_session_cannot_end_the_one_that_replaced_it() {
881 let _guard = exclusive();
882 // Rejoining a call did exactly this. Teardown is spawned, so the old
883 // task finishes after the new call has started, and its "ended" landed
884 // in the same queue — where it read as the new call ending.
885 if let Ok(mut slot) = session_slot().lock() {
886 slot.generation = 7;
887 slot.status.clear();
888 // The old session (6) signing off, and the live one (7) saying it
889 // is up. Queued in that order, which is the order that hurt.
890 slot.status.push((6, Status::Ended));
891 slot.status.push((
892 7,
893 Status::Live {
894 has_camera: false,
895 has_mic: true,
896 },
897 ));
898 }
899
900 // The stale "ended" is skipped, not acted on, and not left blocking
901 // the fresh one behind it.
902 assert_eq!(joltmoq_poll_status(), JOLTMOQ_STATUS_LIVE);
903 assert_eq!(joltmoq_status_has_mic(), 1);
904 assert_eq!(joltmoq_poll_status(), JOLTMOQ_STATUS_NONE);
905
906 if let Ok(mut slot) = session_slot().lock() {
907 slot.generation = 0;
908 slot.status.clear();
909 slot.current = None;
910 }
911 }
912
913 #[test]
914 fn stopping_makes_the_next_call_a_new_generation() {
915 let _guard = exclusive();
916 let before = session_slot().lock().map(|s| s.generation).unwrap_or(0);
917 joltmoq_stop();
918 let after = session_slot().lock().map(|s| s.generation).unwrap_or(0);
919 assert_ne!(before, after, "a stopped session must stop being current");
920 }
921
Fill the slot, and do not wedge on the way out e86c31c nandi 19d ago922 #[test]
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago923 fn a_frame_poll_yields_each_feed_once_until_it_changes() {
Do not let a departed session end the one that replaced it b1758f5 nandi 19d ago924 let _guard = exclusive();
Lift freeq's AV media plane out of sleek 90f8b89 nandi 20d ago925 let video = VideoFrameStore::new();
926 video.set("nandi", 1, 1, std::sync::Arc::from(vec![9u8; 4]));
927 if let Ok(mut slot) = session_slot().lock() {
928 slot.video = Some(video.clone());
929 slot.seen.clear();
930 }
931
932 assert_eq!(joltmoq_frame_poll(), 1);
933 assert_eq!(read(joltmoq_frame_key()), "nandi");
934 assert_eq!(joltmoq_frame_width(), 1);
935 // Same frame: nothing new to hand over.
936 assert_eq!(joltmoq_frame_poll(), 0);
937
938 // A new frame under the same key is new again.
939 video.set("nandi", 1, 1, std::sync::Arc::from(vec![3u8; 4]));
940 assert_eq!(joltmoq_frame_poll(), 1);
941 assert_eq!(read(joltmoq_frame_key()), "nandi");
942
943 assert_eq!(read(joltmoq_video_keys()), "nandi");
944
945 if let Ok(mut slot) = session_slot().lock() {
946 slot.video = None;
947 slot.seen.clear();
948 slot.frame = None;
949 }
950 }
951}