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