nandi/frqpublic Fork 0
b33f288
Commits
Clone
git clone https://git.rickub.com/nandi/frq.git
git clone ssh://git@rickub.com/nandi/frq.git

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

Switch frq.av onto the jolt media plane, and stop shipping libjoltmoq

Twenty-five joltmoq_* symbols become frq.av.plane, frq.av.dial and the
device bindings. frq.av's shape barely changes, which was the point of
building the plane to joltmoq's shape rather than to a nicer one: the
signaling half of this file is untouched, and the media half now says the
same things to a different implementation.

What changed in kind rather than in spelling:

Dialling is a HANDSHAKE now, not a function that returns. joltmoq_start
answered 0 or 1 immediately and did the connecting on its own threads;
plane/dial! starts a QUIC connect and pump! finishes it. Waiting here would
freeze the window for as long as it took -- thirty seconds when the SFU is
not there -- so start-media! fires and forgets exactly as it did, and
pump-status! reports what happened.

Frames arrive several at a time where joltmoq answered one per poll. Safe
because the plane keeps a decoder PER PEER; one shared between them would
make every pointer alias the last picture decoded.

Devices answer maps rather than a tab-separated string this file parsed.
The shape the UI reads is unchanged.

sfu_url, can_dial and new_instance were never media -- they lived in Rust
because that is where the plane was. Transcribed rather than reinvented,
and checked case by case against the Rust they came from, because they are
the one part of this port where a wrong answer is silent: a URL built
slightly wrong does not fail, it dials somewhere that is not there and the
person is told the call timed out.

Android degrades rather than breaks. defcfn resolves at CALL time, not at
load -- checked, because the opposite would have made requiring
frq.capture.alsa fatal on a platform that has no libasound. available? asks
by looking for a sound device and answers false there, which gates the call
surface exactly as it did when the question was whether libjoltmoq existed.

THE BUILD IS NOT FASTER YET, and it is worth being exact about why. frq no
longer loads or ships libjoltmoq -- the closure and the APK are smaller for
it -- but jolt-native compiles its external crates once, in a buildDepsOnly
derivation shared by all three of its objects, so asking for only libvidya
and libjolttui still builds every dependency jolt-moq has. Those 440 crates
leave the build when jolt-native stops carrying jolt-moq in that workspace,
or when its deps artifact stops being workspace-wide. That is a change in
the other repo, not this one.

Verified: the full smoke suite green against the trimmed closure, and
`just tui --headless` loading the whole app -- frq.app through frq.av
through the plane -- and rendering.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nandi committed 2026-09-10T05:03:05-07:00 Browse files
b33f288 parent: 9fcd0b4
modified deps.edn +14 -22
@@ -16,37 +16,29 @@
1616 :git/sha "d970307ccf1fe67e2e971f2837d2282d8ba79a62"
1717 :deps/root "glimmer-backends/glimmer-vidya"}}
1818
19- ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each,
20- ;; out of one target directory:
19+ ;; libvidya comes from gitlab.com/nandithebull/jolt-native:
2120 ;;
2221 ;; libvidya the retained-tree ABI glimmer-vidya binds, on egui
23- ;; libjoltmoq the AV media plane — MoQ over QUIC, Opus, H.264, capture
2422 ;;
25- ;; Calls are the one thing this client cannot do in jolt. Signaling is IRC and
26- ;; lives in src/frq/av.clj; the media half is three thousand lines of codec
27- ;; and transport that would only be written badly a second time here.
23+ ;; Calls used to be the one thing this client could not do in jolt. The media
24+ ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from
25+ ;; libopus, H.264 from openh264 through a shim, and the devices from V4L2
26+ ;; and ALSA. Signaling was always IRC and still lives in src/frq/av.clj.
2827 ;;
2928 ;; just run
3029 :jolt/native [{:name "vidya"
3130 :darwin ["libvidya.dylib"]
3231 :linux ["libvidya.so"]}
33- {:name "joltmoq"
34- :darwin ["libjoltmoq.dylib"]
35- :linux ["libjoltmoq.so"]}
36- ;; moq-ffi's own object, fetched from its release. Not a
37- ;; second media plane: it is the TRANSPORT alone — MoQ over
38- ;; QUIC, the one piece of a call with no C implementation
39- ;; anywhere — and it is here so `frq.moq.*` has something to
40- ;; load while the port off libjoltmoq happens. Both are
41- ;; declared for now, and the day frq.av stops calling
42- ;; joltmoq_* is the day that entry goes.
32+ ;; The transport half of a call: MoQ over QUIC, the one
33+ ;; piece with no C implementation anywhere. Fetched from
34+ ;; moq-ffi's release rather than built — building it means a
35+ ;; 1062-crate workspace.
4336 ;;
44- ;; The codecs are NOT in it: moq-ffi's audio and video
45- ;; features are off in every Linux artifact upstream
46- ;; publishes, and turning them on means compiling a
47- ;; 1062-crate workspace. Opus and H.264 come from their own C
48- ;; libraries instead, which is the whole point of doing this
49- ;; over FFI rather than over a Rust facade.
37+ ;; The codecs are NOT in it. moq-ffi's `audio` and `video`
38+ ;; features are off in every Linux artifact upstream ships,
39+ ;; so Opus and H.264 come from their own C libraries below,
40+ ;; which is the whole point of doing this over FFI rather
41+ ;; than over a Rust facade.
5042 ;;
5143 ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so
5244 ;; a .dylib named here would name a file nothing fetches.
@@ -16,37 +16,29 @@
16 :git/sha "d970307ccf1fe67e2e971f2837d2282d8ba79a62"16 :git/sha "d970307ccf1fe67e2e971f2837d2282d8ba79a62"
17 :deps/root "glimmer-backends/glimmer-vidya"}}17 :deps/root "glimmer-backends/glimmer-vidya"}}
18 18
19- ;; Both objects come from gitlab.com/nandithebull/jolt-native, one crate each,19+ ;; libvidya comes from gitlab.com/nandithebull/jolt-native:
20- ;; out of one target directory:
21 ;;20 ;;
22 ;; libvidya the retained-tree ABI glimmer-vidya binds, on egui21 ;; libvidya the retained-tree ABI glimmer-vidya binds, on egui
23- ;; libjoltmoq the AV media plane — MoQ over QUIC, Opus, H.264, capture
24 ;;22 ;;
25- ;; Calls are the one thing this client cannot do in jolt. Signaling is IRC and23+ ;; Calls used to be the one thing this client could not do in jolt. The media
26- ;; lives in src/frq/av.clj; the media half is three thousand lines of codec24+ ;; half is `frq.av.plane` now — MoQ over QUIC from libmoq_ffi, Opus from
27- ;; and transport that would only be written badly a second time here.25+ ;; libopus, H.264 from openh264 through a shim, and the devices from V4L2
26+ ;; and ALSA. Signaling was always IRC and still lives in src/frq/av.clj.
28 ;;27 ;;
29 ;; just run28 ;; just run
30 :jolt/native [{:name "vidya"29 :jolt/native [{:name "vidya"
31 :darwin ["libvidya.dylib"]30 :darwin ["libvidya.dylib"]
32 :linux ["libvidya.so"]}31 :linux ["libvidya.so"]}
33- {:name "joltmoq"32+ ;; The transport half of a call: MoQ over QUIC, the one
34- :darwin ["libjoltmoq.dylib"]33+ ;; piece with no C implementation anywhere. Fetched from
35- :linux ["libjoltmoq.so"]}34+ ;; moq-ffi's release rather than built — building it means a
36- ;; moq-ffi's own object, fetched from its release. Not a35+ ;; 1062-crate workspace.
37- ;; second media plane: it is the TRANSPORT alone — MoQ over
38- ;; QUIC, the one piece of a call with no C implementation
39- ;; anywhere — and it is here so `frq.moq.*` has something to
40- ;; load while the port off libjoltmoq happens. Both are
41- ;; declared for now, and the day frq.av stops calling
42- ;; joltmoq_* is the day that entry goes.
43 ;;36 ;;
44- ;; The codecs are NOT in it: moq-ffi's audio and video37+ ;; The codecs are NOT in it. moq-ffi's `audio` and `video`
45- ;; features are off in every Linux artifact upstream38+ ;; features are off in every Linux artifact upstream ships,
46- ;; publishes, and turning them on means compiling a39+ ;; so Opus and H.264 come from their own C libraries below,
47- ;; 1062-crate workspace. Opus and H.264 come from their own C40+ ;; which is the whole point of doing this over FFI rather
48- ;; libraries instead, which is the whole point of doing this41+ ;; than over a Rust facade.
49- ;; over FFI rather than over a Rust facade.
50 ;;42 ;;
51 ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so43 ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so
52 ;; a .dylib named here would name a file nothing fetches.44 ;; a .dylib named here would name a file nothing fetches.
modified flake.nix +19 -1
@@ -180,7 +180,25 @@
180180 # and says it once. It also builds cpal with the `pipewire` feature,
181181 # which the restatement did not — so device names in a call are
182182 # PipeWire's rather than raw ALSA PCMs.
183- native = jolt-native.packages.${pkgs.stdenv.hostPlatform.system}.default;
183+ # libvidya and libjolttui, NOT libjoltmoq. Its `default` is all
184+ # three joined, and the third is the Rust media plane frq no
185+ # longer loads — `frq.av.plane` replaced it.
186+ #
187+ # This makes the closure smaller and the APK smaller. It does NOT
188+ # make the build shorter, and it is worth being exact about why:
189+ # jolt-native compiles its external crates ONCE, in a
190+ # `buildDepsOnly` derivation shared by all three objects, so
191+ # asking for two of them still builds every dependency the third
192+ # has — the 440 crates that are jolt-moq's alone. Getting those
193+ # out of the build is a change in jolt-native, not here: either
194+ # jolt-moq leaves that workspace, or its deps artifact stops
195+ # being workspace-wide.
196+ native =
197+ let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};
198+ in pkgs.symlinkJoin {
199+ name = "jolt-native-ui";
200+ paths = [ np.libvidya np.libjolttui ];
201+ };
184202
185203 # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather
186204 # than built. This is the object `frq.moq.raw` is generated from.
@@ -180,7 +180,25 @@
180 # and says it once. It also builds cpal with the `pipewire` feature,180 # and says it once. It also builds cpal with the `pipewire` feature,
181 # which the restatement did not — so device names in a call are181 # which the restatement did not — so device names in a call are
182 # PipeWire's rather than raw ALSA PCMs.182 # PipeWire's rather than raw ALSA PCMs.
183- native = jolt-native.packages.${pkgs.stdenv.hostPlatform.system}.default;183+ # libvidya and libjolttui, NOT libjoltmoq. Its `default` is all
184+ # three joined, and the third is the Rust media plane frq no
185+ # longer loads — `frq.av.plane` replaced it.
186+ #
187+ # This makes the closure smaller and the APK smaller. It does NOT
188+ # make the build shorter, and it is worth being exact about why:
189+ # jolt-native compiles its external crates ONCE, in a
190+ # `buildDepsOnly` derivation shared by all three objects, so
191+ # asking for two of them still builds every dependency the third
192+ # has — the 440 crates that are jolt-moq's alone. Getting those
193+ # out of the build is a change in jolt-native, not here: either
194+ # jolt-moq leaves that workspace, or its deps artifact stops
195+ # being workspace-wide.
196+ native =
197+ let np = jolt-native.packages.${pkgs.stdenv.hostPlatform.system};
198+ in pkgs.symlinkJoin {
199+ name = "jolt-native-ui";
200+ paths = [ np.libvidya np.libjolttui ];
201+ };
184 202
185 # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather203 # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather
186 # than built. This is the object `frq.moq.raw` is generated from.204 # than built. This is the object `frq.moq.raw` is generated from.
modified src/frq/av.clj +127 -139
@@ -6,10 +6,12 @@
66 server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,
77 so it is written in the language the rest of the client is written in.
88
9- The other half is audio and video over MoQ, and it is three thousand lines of
10- Opus, H.264, QUIC and capture that would be written badly a second time in
11- any other language. It lives in `libjoltmoq`, extracted from sleek, and this
12- namespace is the whole of what frq says to it.
9+ The other half is audio and video over MoQ, and it used to be `libjoltmoq`
10+ — three thousand lines of Rust behind a C ABI. It is `frq.av.plane` now:
11+ MoQ over QUIC from `libmoq_ffi`, Opus from libopus, H.264 from openh264,
12+ and the camera and sound devices from V4L2 and ALSA, all bound directly.
13+ This namespace is the whole of what frq says to it, and what it says has
14+ barely changed — the plane was built to joltmoq's shape on purpose.
1315
1416 Two rules come from that side and shape everything here:
1517
@@ -18,69 +20,43 @@
1820 * **A video frame is borrowed**, valid only until the next poll. `pump!`
1921 hands each one to Vidya as it arrives and never keeps one, which is also
2022 why a frame never becomes a jolt value: it goes from the decoder's buffer
21- to the texture as a pointer, and is never copied on this side at all."
23+ to the texture as a pointer, and is never copied on this side at all.
24+
25+ Both still hold, and for the same reasons — the plane is pumped because a
26+ blocking foreign call would pin glimmer's loop thread, and it hands out
27+ borrowed pointers because a frame at thirty a second cannot afford a copy."
2228 (:require [clojure.string :as str]
2329 [glimmer.ratom :as r :refer [atom]]
2430 [glimmer-vidya.core :as vidya]
2531 [frq.irc :as irc]
26- [jolt.ffi :as ffi]))
32+ [frq.av.dial :as dial]
33+ [frq.av.plane :as plane]
34+ [frq.capture.alsa :as alsa]
35+ [frq.capture.v4l2 :as v4l2]))
2736
2837 ;; --- the media plane ---------------------------------------------------------
29-;; `libjoltmoq` is declared in deps.edn's `:jolt/native` beside libvidya, so the
30-;; loader has already found it by the time these resolve.
31-
32-(ffi/defcfn raw-init-logging "joltmoq_init_logging" [] :void)
33-(ffi/defcfn raw-start "joltmoq_start"
34- [:string :string :string :string :int :int :int :string :string :string] :int)
35-(ffi/defcfn raw-stop "joltmoq_stop" [] :void)
36-(ffi/defcfn raw-is-live "joltmoq_is_live" [] :int)
37-
38-(ffi/defcfn raw-set-muted "joltmoq_set_muted" [:int] :void)
39-(ffi/defcfn raw-set-speaker-muted "joltmoq_set_speaker_muted" [:int] :void)
40-(ffi/defcfn raw-set-camera "joltmoq_set_camera" [:int] :void)
41-(ffi/defcfn raw-set-camera-device "joltmoq_set_camera_device" [:string] :void)
42-(ffi/defcfn raw-set-mic-device "joltmoq_set_mic_device" [:string] :void)
43-(ffi/defcfn raw-set-speaker-device "joltmoq_set_speaker_device" [:string] :void)
44-(ffi/defcfn mic-level "joltmoq_mic_level" [] :double)
45-
46-(ffi/defcfn raw-poll-status "joltmoq_poll_status" [] :int)
47-(ffi/defcfn status-text "joltmoq_status_text" [] :string)
48-(ffi/defcfn raw-status-has-camera "joltmoq_status_has_camera" [] :int)
49-(ffi/defcfn raw-status-has-mic "joltmoq_status_has_mic" [] :int)
50-
51-(ffi/defcfn raw-frame-poll "joltmoq_frame_poll" [] :int)
52-(ffi/defcfn frame-key "joltmoq_frame_key" [] :string)
53-(ffi/defcfn frame-width "joltmoq_frame_width" [] :int)
54-(ffi/defcfn frame-height "joltmoq_frame_height" [] :int)
55-;; The one pointer that crosses. Read as `:pointer` rather than bytes on
56-;; purpose: it goes straight back out to `vidya/frame-rgba!`, so the pixels
57-;; never become a jolt value and are never copied on this side.
58-(ffi/defcfn frame-rgba "joltmoq_frame_rgba" [] :pointer)
59-(ffi/defcfn video-keys "joltmoq_video_keys" [] :string)
60-
61-(ffi/defcfn raw-cameras "joltmoq_cameras" [] :string)
62-(ffi/defcfn raw-microphones "joltmoq_microphones" [] :string)
63-(ffi/defcfn raw-speakers "joltmoq_speakers" [] :string)
64-
65-(ffi/defcfn raw-sfu-url "joltmoq_sfu_url" [:string :string :string] :string)
66-(ffi/defcfn raw-can-dial "joltmoq_can_dial" [:string :string] :int)
67-(ffi/defcfn new-instance "joltmoq_new_instance" [] :string)
68-
69-;; C has no booleans and no absence; both are converted here rather than at
70-;; every call site, so the rest of this file is written in jolt's own terms.
71-(def ^:private status-none 0)
72-(def ^:private status-live 1)
73-(def ^:private status-ended 2)
74-(def ^:private status-failed 3)
38+;; `frq.av.plane`, where this used to be twenty-five `joltmoq_*` symbols.
39+;; The shape is deliberately the same one — start, stop, poll status, poll
40+;; frames — because that is what let the plane be swapped in underneath this
41+;; file rather than through it.
42+
43+(def ^:private status-live :live)
44+(def ^:private status-ended :ended)
45+(def ^:private status-failed :failed)
7546
7647 (defonce ^:private media-plane
77- ;; Whether `libjoltmoq` is here at all, asked once by calling the smallest
78- ;; thing in it. It is not on the phone — nobody has cross-built the media
79- ;; plane for Android — and a client that cannot make calls is still a client,
80- ;; so the answer gates the call surface rather than ending the run. Every
81- ;; other binding in this namespace is reached only from behind it.
48+ ;; Whether the media plane can run here at all, asked once. It cannot on
49+ ;; the phone: the plane is V4L2 and ALSA, and Android has neither — the
50+ ;; camera is Camera2 through JNI and the audio is AAudio. A client that
51+ ;; cannot make calls is still a client, so the answer gates the call
52+ ;; surface rather than ending the run.
53+ ;;
54+ ;; Asked by looking for a sound device rather than by loading anything:
55+ ;; every library the plane needs is declared in deps.edn and already
56+ ;; resolved by the time this runs, so the question is not "is the code
57+ ;; here" but "is there anything for it to talk to".
8258 (delay
83- (try (raw-init-logging) true
59+ (try (boolean (seq (alsa/devices :playback)))
8460 (catch Exception _ false))))
8561
8662 (defn available?
@@ -88,42 +64,37 @@
8864 []
8965 @media-plane)
9066
91-(defn- flag [b] (if b 1 0))
9267 (defn- pref [s] (or s ""))
9368
94-(defn live? [] (not (zero? (raw-is-live))))
69+(defn live? [] (plane/live?))
70+
9571 (defn can-dial?
9672 "Whether dialling this server is worth attempting. A remote SFU with no token
9773 accepts the connection and closes it, and the MoQ client then retries in a
9874 tight loop that looks exactly like a hang."
9975 [server jwt]
100- (not (zero? (raw-can-dial (pref server) (pref jwt)))))
76+ (dial/can-dial? server jwt))
10177
10278 (defn sfu-url
10379 "The SFU to dial for this server, or nil when the server is not one a URL can
10480 be made of."
10581 [server jwt instance]
106- (let [u (raw-sfu-url (pref server) (pref jwt) (pref instance))]
107- (when (seq u) u)))
108-
109-(defn- parse-devices
110- "`id\\tname\\tdefault` a line into `{:id :name :default?}`.
111-
112- Tab and newline delimit because a device name may hold anything else — a
113- webcam called \"EMEET SmartCam C960, Mono\" has spaces and a comma in it, and
114- splitting on those gives nonsense."
115- [text]
116- (->> (str/split-lines (or text ""))
117- (remove str/blank?)
118- (mapv (fn [line]
119- (let [[id name default] (str/split line #"\t")]
120- {:id id
121- :name (or name id)
122- :default? (= "1" default)})))))
123-
124-(defn cameras [] (parse-devices (raw-cameras)))
125-(defn microphones [] (parse-devices (raw-microphones)))
126-(defn speakers [] (parse-devices (raw-speakers)))
82+ (dial/sfu-url server jwt instance))
83+
84+(defn new-instance
85+ "A per-device call instance id. Two devices signed in as the same person need
86+ different ones, or their broadcast paths collide."
87+ []
88+ (dial/new-instance))
89+
90+;; --- devices -----------------------------------------------------------------
91+;; Where joltmoq answered a tab-separated string that this file parsed, the
92+;; bindings answer the maps directly. The shape the UI reads is unchanged:
93+;; {:id :name :default?}.
94+
95+(defn cameras [] (try (v4l2/devices) (catch Exception _ [])))
96+(defn microphones [] (try (alsa/devices :capture) (catch Exception _ [])))
97+(defn speakers [] (try (alsa/devices :playback) (catch Exception _ [])))
12798
12899 ;; --- the signaling tags ------------------------------------------------------
129100 ;; Every one of these is a TAGMSG to the channel. The server answers with an
@@ -298,7 +269,7 @@
298269 or the caller is about to send an `av-leave` of its own. Anything else wants
299270 `dropped!`, or freeq goes on counting a participant who is not there."
300271 []
301- (raw-stop)
272+ (plane/stop!)
302273 (drop-feeds!)
303274 (reset! feeds [])
304275 (reset! local-call nil)
@@ -331,11 +302,23 @@
331302 (do
332303 (swap! local-call assoc :media :dialling)
333304 (reset! media-error nil)
334- (when (zero? (raw-start url session-id (pref nick) instance
335- (flag muted?) (flag speaker-muted?) (flag camera?)
336- (pref camera-id) (pref mic-id) (pref speaker-id)))
337- (swap! local-call assoc :media :failed)
338- (reset! media-error "could not start the media plane")))
305+ ;; Fire and forget: the connect is a QUIC handshake and waiting for
306+ ;; it here would freeze the window for as long as it took — thirty
307+ ;; seconds when the SFU is not there. `pump!` finishes it and
308+ ;; `pump-status!` below reports what happened, which is exactly
309+ ;; what this did when the waiting was joltmoq's to do.
310+ (try
311+ (plane/dial! {:url url
312+ :path (str "/" instance)
313+ :camera-device (when camera? camera-id)
314+ :mic-device (when-not muted? mic-id)
315+ :speaker-device (when-not speaker-muted? speaker-id)
316+ :camera? camera?
317+ :muted? muted?})
318+ (catch Exception e
319+ (swap! local-call assoc :media :failed)
320+ (reset! media-error (or (ex-message e)
321+ "could not start the media plane")))))
339322 (do (swap! local-call assoc :media :failed)
340323 (reset! media-error (str "no SFU for " server))))))
341324
@@ -417,64 +400,69 @@
417400
418401 (defn set-muted! [muted?]
419402 (swap! local-call assoc :muted? muted?)
420- (raw-set-muted (flag muted?)))
403+ (plane/set-muted! muted?))
421404
422405 (defn set-speaker-muted! [muted?]
423406 (swap! local-call assoc :speaker-muted? muted?)
424- (raw-set-speaker-muted (flag muted?)))
407+ (plane/set-speaker-muted! muted?))
425408
426409 (defn set-camera! [on?]
427410 (swap! local-call assoc :camera? on?)
428- (raw-set-camera (flag on?))
411+ (plane/set-camera! on?)
429412 ;; The tile goes when the camera does: the plane stops publishing, so no
430413 ;; frame arrives to replace the last one.
431414 (when-not on?
432415 (vidya/frame-drop! local-feed)
433416 (swap! painted-feeds disj local-feed)))
434417
418+;; Switching a device mid-call reopens it, which the plane can only do by
419+;; going round again — there is no V4L2 ioctl for "become a different
420+;; camera". The id is recorded either way, so a call started afterwards
421+;; uses it even where a live switch is not offered yet.
422+
435423 (defn set-camera-device! [id]
436424 (swap! local-call assoc :camera-id id)
437- (raw-set-camera-device (pref id)))
425+ (plane/set-camera-device! id))
438426
439427 (defn set-mic-device! [id]
440428 (swap! local-call assoc :mic-id id)
441- (raw-set-mic-device (pref id)))
429+ (plane/set-mic-device! id))
442430
443431 (defn set-speaker-device! [id]
444432 (swap! local-call assoc :speaker-id id)
445- (raw-set-speaker-device (pref id)))
433+ (plane/set-speaker-device! id))
446434
447435 ;; --- the pump ----------------------------------------------------------------
448436
449437 (defn- pump-status!
450- "Drain what the media plane has learned since the last frame."
438+ "Drain what the media plane has learned since the last frame.
439+
440+ A drained QUEUE rather than a code per call, which is what the plane
441+ answers — but the loop is the same shape and for the same reason: a call
442+ can fail and end between two pumps, and reading only the latest state
443+ would show the wrong one."
451444 []
452- (loop []
453- (let [code (raw-poll-status)]
454- (when-not (= code status-none)
455- (cond
456- (= code status-live)
457- (swap! local-call #(when %
458- (assoc % :media :live
459- :has-camera? (not (zero? (raw-status-has-camera)))
460- :has-mic? (not (zero? (raw-status-has-mic))))))
461-
462- ;; Both of these are the call ending underneath us rather than at
463- ;; our request, so both have to be announced. A failure keeps the
464- ;; local call up afterwards so the reason stays on screen — but the
465- ;; server is told either way, because we are no longer in the call
466- ;; whether or not the person has read why yet.
467- (= code status-ended)
468- (dropped!)
469-
470- (= code status-failed)
471- (let [why (status-text)
472- call @local-call]
473- (when-let [announce @on-dropped]
474- (when call (try (announce call) (catch Exception _ nil))))
475- (reset! media-error why)
476- (swap! local-call #(when % (assoc % :media :failed)))))
477- (recur)))))
445+ (doseq [{:keys [code text has-camera? has-mic?]} (plane/poll-status!)]
446+ (cond
447+ (= code status-live)
448+ (swap! local-call #(when % (assoc % :media :live
449+ :has-camera? (boolean has-camera?)
450+ :has-mic? (boolean has-mic?))))
451+
452+ ;; Both of these are the call ending underneath us rather than at
453+ ;; our request, so both have to be announced. A failure keeps the
454+ ;; local call up afterwards so the reason stays on screen — but the
455+ ;; server is told either way, because we are no longer in the call
456+ ;; whether or not the person has read why yet.
457+ (= code status-ended)
458+ (dropped!)
459+
460+ (= code status-failed)
461+ (let [call @local-call]
462+ (when-let [announce @on-dropped]
463+ (when call (try (announce call) (catch Exception _ nil))))
464+ (reset! media-error text)
465+ (swap! local-call #(when % (assoc % :media :failed)))))))
478466
479467 (defn- pump-frames!
480468 "Hand every new frame straight to Vidya.
@@ -482,18 +470,16 @@
482470 The pointer is borrowed until the next poll, so it is used and dropped inside
483471 this loop and never held. Nothing is copied on this side: the pixels go from
484472 the decoder's own buffer to a texture without becoming a jolt value at all,
485- which is the only way a call at thirty frames a second is affordable here."
473+ which is the only way a call at thirty frames a second is affordable here.
474+
475+ Several frames at once now, where joltmoq answered one per poll. That is
476+ safe because the plane keeps a decoder PER PEER: one shared between them
477+ would make every pointer here alias the last picture decoded."
486478 []
487- (loop []
488- (when-not (zero? (raw-frame-poll))
489- (let [key (frame-key)
490- w (frame-width)
491- h (frame-height)
492- px (frame-rgba)]
493- (when (and (seq key) (pos? w) (pos? h) (not (ffi/null? px)))
494- (vidya/frame-rgba! key w h px)
495- (swap! painted-feeds conj key)))
496- (recur))))
479+ (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]
480+ (when (and (seq key) (pos? w) (pos? h) rgba)
481+ (vidya/frame-rgba! key w h rgba)
482+ (swap! painted-feeds conj key))))
497483
498484 (defn- order-feeds
499485 "Everyone with a picture, the self-view last.
@@ -514,7 +500,7 @@
514500 thing, and every `:image` node under it would be rebuilt around a texture
515501 that was fine where it was."
516502 []
517- (let [live (set (remove str/blank? (str/split-lines (or (video-keys) ""))))]
503+ (let [live (plane/feed-keys)]
518504 (doseq [k (remove live @painted-feeds)]
519505 (vidya/frame-drop! k)
520506 (swap! painted-feeds disj k))
@@ -552,14 +538,16 @@
552538 (pump-feeds!)))
553539
554540 (defn init-logging!
555- "Let the media plane talk to stderr, honouring `RUST_LOG`.
541+ "Ask, once, whether calls can happen here.
556542
557- Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,
558- and when a call misbehaves it is the only thing that knows why — MoQ
559- subscription, codec negotiation and device open all happen on the far side of
560- the boundary, where no jolt-level trace can see them.
543+ It used to do two things: turn on the Rust media plane's logging and, as
544+ a side effect of the call succeeding, discover that the plane existed.
545+ There is no Rust plane now and nothing to switch on — `frq.av.plane`
546+ raises where it fails and `poll-status!` carries the reason, both of
547+ which a jolt-level trace can already see.
561548
562- Asking whether the media plane is there is the same call, so this is it."
549+ The name stays because `frq.app` calls it at startup and the answer it
550+ wants is unchanged: is there a media plane here at all."
563551 []
564552 (available?))
565553
@@ -6,10 +6,12 @@
6 server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,6 server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more,
7 so it is written in the language the rest of the client is written in.7 so it is written in the language the rest of the client is written in.
8 8
9- The other half is audio and video over MoQ, and it is three thousand lines of9+ The other half is audio and video over MoQ, and it used to be `libjoltmoq`
10- Opus, H.264, QUIC and capture that would be written badly a second time in10+ — three thousand lines of Rust behind a C ABI. It is `frq.av.plane` now:
11- any other language. It lives in `libjoltmoq`, extracted from sleek, and this11+ MoQ over QUIC from `libmoq_ffi`, Opus from libopus, H.264 from openh264,
12- namespace is the whole of what frq says to it.12+ and the camera and sound devices from V4L2 and ALSA, all bound directly.
13+ This namespace is the whole of what frq says to it, and what it says has
14+ barely changed — the plane was built to joltmoq's shape on purpose.
13 15
14 Two rules come from that side and shape everything here:16 Two rules come from that side and shape everything here:
15 17
@@ -18,69 +20,43 @@
18 * **A video frame is borrowed**, valid only until the next poll. `pump!`20 * **A video frame is borrowed**, valid only until the next poll. `pump!`
19 hands each one to Vidya as it arrives and never keeps one, which is also21 hands each one to Vidya as it arrives and never keeps one, which is also
20 why a frame never becomes a jolt value: it goes from the decoder's buffer22 why a frame never becomes a jolt value: it goes from the decoder's buffer
21- to the texture as a pointer, and is never copied on this side at all."23+ to the texture as a pointer, and is never copied on this side at all.
24+
25+ Both still hold, and for the same reasons — the plane is pumped because a
26+ blocking foreign call would pin glimmer's loop thread, and it hands out
27+ borrowed pointers because a frame at thirty a second cannot afford a copy."
22 (:require [clojure.string :as str]28 (:require [clojure.string :as str]
23 [glimmer.ratom :as r :refer [atom]]29 [glimmer.ratom :as r :refer [atom]]
24 [glimmer-vidya.core :as vidya]30 [glimmer-vidya.core :as vidya]
25 [frq.irc :as irc]31 [frq.irc :as irc]
26- [jolt.ffi :as ffi]))32+ [frq.av.dial :as dial]
33+ [frq.av.plane :as plane]
34+ [frq.capture.alsa :as alsa]
35+ [frq.capture.v4l2 :as v4l2]))
27 36
28 ;; --- the media plane ---------------------------------------------------------37 ;; --- the media plane ---------------------------------------------------------
29-;; `libjoltmoq` is declared in deps.edn's `:jolt/native` beside libvidya, so the38+;; `frq.av.plane`, where this used to be twenty-five `joltmoq_*` symbols.
30-;; loader has already found it by the time these resolve.39+;; The shape is deliberately the same one — start, stop, poll status, poll
31-40+;; frames — because that is what let the plane be swapped in underneath this
32-(ffi/defcfn raw-init-logging "joltmoq_init_logging" [] :void)41+;; file rather than through it.
33-(ffi/defcfn raw-start "joltmoq_start"42+
34- [:string :string :string :string :int :int :int :string :string :string] :int)43+(def ^:private status-live :live)
35-(ffi/defcfn raw-stop "joltmoq_stop" [] :void)44+(def ^:private status-ended :ended)
36-(ffi/defcfn raw-is-live "joltmoq_is_live" [] :int)45+(def ^:private status-failed :failed)
37-
38-(ffi/defcfn raw-set-muted "joltmoq_set_muted" [:int] :void)
39-(ffi/defcfn raw-set-speaker-muted "joltmoq_set_speaker_muted" [:int] :void)
40-(ffi/defcfn raw-set-camera "joltmoq_set_camera" [:int] :void)
41-(ffi/defcfn raw-set-camera-device "joltmoq_set_camera_device" [:string] :void)
42-(ffi/defcfn raw-set-mic-device "joltmoq_set_mic_device" [:string] :void)
43-(ffi/defcfn raw-set-speaker-device "joltmoq_set_speaker_device" [:string] :void)
44-(ffi/defcfn mic-level "joltmoq_mic_level" [] :double)
45-
46-(ffi/defcfn raw-poll-status "joltmoq_poll_status" [] :int)
47-(ffi/defcfn status-text "joltmoq_status_text" [] :string)
48-(ffi/defcfn raw-status-has-camera "joltmoq_status_has_camera" [] :int)
49-(ffi/defcfn raw-status-has-mic "joltmoq_status_has_mic" [] :int)
50-
51-(ffi/defcfn raw-frame-poll "joltmoq_frame_poll" [] :int)
52-(ffi/defcfn frame-key "joltmoq_frame_key" [] :string)
53-(ffi/defcfn frame-width "joltmoq_frame_width" [] :int)
54-(ffi/defcfn frame-height "joltmoq_frame_height" [] :int)
55-;; The one pointer that crosses. Read as `:pointer` rather than bytes on
56-;; purpose: it goes straight back out to `vidya/frame-rgba!`, so the pixels
57-;; never become a jolt value and are never copied on this side.
58-(ffi/defcfn frame-rgba "joltmoq_frame_rgba" [] :pointer)
59-(ffi/defcfn video-keys "joltmoq_video_keys" [] :string)
60-
61-(ffi/defcfn raw-cameras "joltmoq_cameras" [] :string)
62-(ffi/defcfn raw-microphones "joltmoq_microphones" [] :string)
63-(ffi/defcfn raw-speakers "joltmoq_speakers" [] :string)
64-
65-(ffi/defcfn raw-sfu-url "joltmoq_sfu_url" [:string :string :string] :string)
66-(ffi/defcfn raw-can-dial "joltmoq_can_dial" [:string :string] :int)
67-(ffi/defcfn new-instance "joltmoq_new_instance" [] :string)
68-
69-;; C has no booleans and no absence; both are converted here rather than at
70-;; every call site, so the rest of this file is written in jolt's own terms.
71-(def ^:private status-none 0)
72-(def ^:private status-live 1)
73-(def ^:private status-ended 2)
74-(def ^:private status-failed 3)
75 46
76 (defonce ^:private media-plane47 (defonce ^:private media-plane
77- ;; Whether `libjoltmoq` is here at all, asked once by calling the smallest48+ ;; Whether the media plane can run here at all, asked once. It cannot on
78- ;; thing in it. It is not on the phone — nobody has cross-built the media49+ ;; the phone: the plane is V4L2 and ALSA, and Android has neither — the
79- ;; plane for Android — and a client that cannot make calls is still a client,50+ ;; camera is Camera2 through JNI and the audio is AAudio. A client that
80- ;; so the answer gates the call surface rather than ending the run. Every51+ ;; cannot make calls is still a client, so the answer gates the call
81- ;; other binding in this namespace is reached only from behind it.52+ ;; surface rather than ending the run.
53+ ;;
54+ ;; Asked by looking for a sound device rather than by loading anything:
55+ ;; every library the plane needs is declared in deps.edn and already
56+ ;; resolved by the time this runs, so the question is not "is the code
57+ ;; here" but "is there anything for it to talk to".
82 (delay58 (delay
83- (try (raw-init-logging) true59+ (try (boolean (seq (alsa/devices :playback)))
84 (catch Exception _ false))))60 (catch Exception _ false))))
85 61
86 (defn available?62 (defn available?
@@ -88,42 +64,37 @@
88 []64 []
89 @media-plane)65 @media-plane)
90 66
91-(defn- flag [b] (if b 1 0))
92 (defn- pref [s] (or s ""))67 (defn- pref [s] (or s ""))
93 68
94-(defn live? [] (not (zero? (raw-is-live))))69+(defn live? [] (plane/live?))
70+
95 (defn can-dial?71 (defn can-dial?
96 "Whether dialling this server is worth attempting. A remote SFU with no token72 "Whether dialling this server is worth attempting. A remote SFU with no token
97 accepts the connection and closes it, and the MoQ client then retries in a73 accepts the connection and closes it, and the MoQ client then retries in a
98 tight loop that looks exactly like a hang."74 tight loop that looks exactly like a hang."
99 [server jwt]75 [server jwt]
100- (not (zero? (raw-can-dial (pref server) (pref jwt)))))76+ (dial/can-dial? server jwt))
101 77
102 (defn sfu-url78 (defn sfu-url
103 "The SFU to dial for this server, or nil when the server is not one a URL can79 "The SFU to dial for this server, or nil when the server is not one a URL can
104 be made of."80 be made of."
105 [server jwt instance]81 [server jwt instance]
106- (let [u (raw-sfu-url (pref server) (pref jwt) (pref instance))]82+ (dial/sfu-url server jwt instance))
107- (when (seq u) u)))83+
108-84+(defn new-instance
109-(defn- parse-devices85+ "A per-device call instance id. Two devices signed in as the same person need
110- "`id\\tname\\tdefault` a line into `{:id :name :default?}`.86+ different ones, or their broadcast paths collide."
111-87+ []
112- Tab and newline delimit because a device name may hold anything else — a88+ (dial/new-instance))
113- webcam called \"EMEET SmartCam C960, Mono\" has spaces and a comma in it, and89+
114- splitting on those gives nonsense."90+;; --- devices -----------------------------------------------------------------
115- [text]91+;; Where joltmoq answered a tab-separated string that this file parsed, the
116- (->> (str/split-lines (or text ""))92+;; bindings answer the maps directly. The shape the UI reads is unchanged:
117- (remove str/blank?)93+;; {:id :name :default?}.
118- (mapv (fn [line]94+
119- (let [[id name default] (str/split line #"\t")]95+(defn cameras [] (try (v4l2/devices) (catch Exception _ [])))
120- {:id id96+(defn microphones [] (try (alsa/devices :capture) (catch Exception _ [])))
121- :name (or name id)97+(defn speakers [] (try (alsa/devices :playback) (catch Exception _ [])))
122- :default? (= "1" default)})))))
123-
124-(defn cameras [] (parse-devices (raw-cameras)))
125-(defn microphones [] (parse-devices (raw-microphones)))
126-(defn speakers [] (parse-devices (raw-speakers)))
127 98
128 ;; --- the signaling tags ------------------------------------------------------99 ;; --- the signaling tags ------------------------------------------------------
129 ;; Every one of these is a TAGMSG to the channel. The server answers with an100 ;; Every one of these is a TAGMSG to the channel. The server answers with an
@@ -298,7 +269,7 @@
298 or the caller is about to send an `av-leave` of its own. Anything else wants269 or the caller is about to send an `av-leave` of its own. Anything else wants
299 `dropped!`, or freeq goes on counting a participant who is not there."270 `dropped!`, or freeq goes on counting a participant who is not there."
300 []271 []
301- (raw-stop)272+ (plane/stop!)
302 (drop-feeds!)273 (drop-feeds!)
303 (reset! feeds [])274 (reset! feeds [])
304 (reset! local-call nil)275 (reset! local-call nil)
@@ -331,11 +302,23 @@
331 (do302 (do
332 (swap! local-call assoc :media :dialling)303 (swap! local-call assoc :media :dialling)
333 (reset! media-error nil)304 (reset! media-error nil)
334- (when (zero? (raw-start url session-id (pref nick) instance305+ ;; Fire and forget: the connect is a QUIC handshake and waiting for
335- (flag muted?) (flag speaker-muted?) (flag camera?)306+ ;; it here would freeze the window for as long as it took — thirty
336- (pref camera-id) (pref mic-id) (pref speaker-id)))307+ ;; seconds when the SFU is not there. `pump!` finishes it and
337- (swap! local-call assoc :media :failed)308+ ;; `pump-status!` below reports what happened, which is exactly
338- (reset! media-error "could not start the media plane")))309+ ;; what this did when the waiting was joltmoq's to do.
310+ (try
311+ (plane/dial! {:url url
312+ :path (str "/" instance)
313+ :camera-device (when camera? camera-id)
314+ :mic-device (when-not muted? mic-id)
315+ :speaker-device (when-not speaker-muted? speaker-id)
316+ :camera? camera?
317+ :muted? muted?})
318+ (catch Exception e
319+ (swap! local-call assoc :media :failed)
320+ (reset! media-error (or (ex-message e)
321+ "could not start the media plane")))))
339 (do (swap! local-call assoc :media :failed)322 (do (swap! local-call assoc :media :failed)
340 (reset! media-error (str "no SFU for " server))))))323 (reset! media-error (str "no SFU for " server))))))
341 324
@@ -417,64 +400,69 @@
417 400
418 (defn set-muted! [muted?]401 (defn set-muted! [muted?]
419 (swap! local-call assoc :muted? muted?)402 (swap! local-call assoc :muted? muted?)
420- (raw-set-muted (flag muted?)))403+ (plane/set-muted! muted?))
421 404
422 (defn set-speaker-muted! [muted?]405 (defn set-speaker-muted! [muted?]
423 (swap! local-call assoc :speaker-muted? muted?)406 (swap! local-call assoc :speaker-muted? muted?)
424- (raw-set-speaker-muted (flag muted?)))407+ (plane/set-speaker-muted! muted?))
425 408
426 (defn set-camera! [on?]409 (defn set-camera! [on?]
427 (swap! local-call assoc :camera? on?)410 (swap! local-call assoc :camera? on?)
428- (raw-set-camera (flag on?))411+ (plane/set-camera! on?)
429 ;; The tile goes when the camera does: the plane stops publishing, so no412 ;; The tile goes when the camera does: the plane stops publishing, so no
430 ;; frame arrives to replace the last one.413 ;; frame arrives to replace the last one.
431 (when-not on?414 (when-not on?
432 (vidya/frame-drop! local-feed)415 (vidya/frame-drop! local-feed)
433 (swap! painted-feeds disj local-feed)))416 (swap! painted-feeds disj local-feed)))
434 417
418+;; Switching a device mid-call reopens it, which the plane can only do by
419+;; going round again — there is no V4L2 ioctl for "become a different
420+;; camera". The id is recorded either way, so a call started afterwards
421+;; uses it even where a live switch is not offered yet.
422+
435 (defn set-camera-device! [id]423 (defn set-camera-device! [id]
436 (swap! local-call assoc :camera-id id)424 (swap! local-call assoc :camera-id id)
437- (raw-set-camera-device (pref id)))425+ (plane/set-camera-device! id))
438 426
439 (defn set-mic-device! [id]427 (defn set-mic-device! [id]
440 (swap! local-call assoc :mic-id id)428 (swap! local-call assoc :mic-id id)
441- (raw-set-mic-device (pref id)))429+ (plane/set-mic-device! id))
442 430
443 (defn set-speaker-device! [id]431 (defn set-speaker-device! [id]
444 (swap! local-call assoc :speaker-id id)432 (swap! local-call assoc :speaker-id id)
445- (raw-set-speaker-device (pref id)))433+ (plane/set-speaker-device! id))
446 434
447 ;; --- the pump ----------------------------------------------------------------435 ;; --- the pump ----------------------------------------------------------------
448 436
449 (defn- pump-status!437 (defn- pump-status!
450- "Drain what the media plane has learned since the last frame."438+ "Drain what the media plane has learned since the last frame.
439+
440+ A drained QUEUE rather than a code per call, which is what the plane
441+ answers — but the loop is the same shape and for the same reason: a call
442+ can fail and end between two pumps, and reading only the latest state
443+ would show the wrong one."
451 []444 []
452- (loop []445+ (doseq [{:keys [code text has-camera? has-mic?]} (plane/poll-status!)]
453- (let [code (raw-poll-status)]446+ (cond
454- (when-not (= code status-none)447+ (= code status-live)
455- (cond448+ (swap! local-call #(when % (assoc % :media :live
456- (= code status-live)449+ :has-camera? (boolean has-camera?)
457- (swap! local-call #(when %450+ :has-mic? (boolean has-mic?))))
458- (assoc % :media :live451+
459- :has-camera? (not (zero? (raw-status-has-camera)))452+ ;; Both of these are the call ending underneath us rather than at
460- :has-mic? (not (zero? (raw-status-has-mic))))))453+ ;; our request, so both have to be announced. A failure keeps the
461-454+ ;; local call up afterwards so the reason stays on screen — but the
462- ;; Both of these are the call ending underneath us rather than at455+ ;; server is told either way, because we are no longer in the call
463- ;; our request, so both have to be announced. A failure keeps the456+ ;; whether or not the person has read why yet.
464- ;; local call up afterwards so the reason stays on screen — but the457+ (= code status-ended)
465- ;; server is told either way, because we are no longer in the call458+ (dropped!)
466- ;; whether or not the person has read why yet.459+
467- (= code status-ended)460+ (= code status-failed)
468- (dropped!)461+ (let [call @local-call]
469-462+ (when-let [announce @on-dropped]
470- (= code status-failed)463+ (when call (try (announce call) (catch Exception _ nil))))
471- (let [why (status-text)464+ (reset! media-error text)
472- call @local-call]465+ (swap! local-call #(when % (assoc % :media :failed)))))))
473- (when-let [announce @on-dropped]
474- (when call (try (announce call) (catch Exception _ nil))))
475- (reset! media-error why)
476- (swap! local-call #(when % (assoc % :media :failed)))))
477- (recur)))))
478 466
479 (defn- pump-frames!467 (defn- pump-frames!
480 "Hand every new frame straight to Vidya.468 "Hand every new frame straight to Vidya.
@@ -482,18 +470,16 @@
482 The pointer is borrowed until the next poll, so it is used and dropped inside470 The pointer is borrowed until the next poll, so it is used and dropped inside
483 this loop and never held. Nothing is copied on this side: the pixels go from471 this loop and never held. Nothing is copied on this side: the pixels go from
484 the decoder's own buffer to a texture without becoming a jolt value at all,472 the decoder's own buffer to a texture without becoming a jolt value at all,
485- which is the only way a call at thirty frames a second is affordable here."473+ which is the only way a call at thirty frames a second is affordable here.
474+
475+ Several frames at once now, where joltmoq answered one per poll. That is
476+ safe because the plane keeps a decoder PER PEER: one shared between them
477+ would make every pointer here alias the last picture decoded."
486 []478 []
487- (loop []479+ (doseq [{:keys [key w h rgba]} (plane/poll-frames!)]
488- (when-not (zero? (raw-frame-poll))480+ (when (and (seq key) (pos? w) (pos? h) rgba)
489- (let [key (frame-key)481+ (vidya/frame-rgba! key w h rgba)
490- w (frame-width)482+ (swap! painted-feeds conj key))))
491- h (frame-height)
492- px (frame-rgba)]
493- (when (and (seq key) (pos? w) (pos? h) (not (ffi/null? px)))
494- (vidya/frame-rgba! key w h px)
495- (swap! painted-feeds conj key)))
496- (recur))))
497 483
498 (defn- order-feeds484 (defn- order-feeds
499 "Everyone with a picture, the self-view last.485 "Everyone with a picture, the self-view last.
@@ -514,7 +500,7 @@
514 thing, and every `:image` node under it would be rebuilt around a texture500 thing, and every `:image` node under it would be rebuilt around a texture
515 that was fine where it was."501 that was fine where it was."
516 []502 []
517- (let [live (set (remove str/blank? (str/split-lines (or (video-keys) ""))))]503+ (let [live (plane/feed-keys)]
518 (doseq [k (remove live @painted-feeds)]504 (doseq [k (remove live @painted-feeds)]
519 (vidya/frame-drop! k)505 (vidya/frame-drop! k)
520 (swap! painted-feeds disj k))506 (swap! painted-feeds disj k))
@@ -552,14 +538,16 @@
552 (pump-feeds!)))538 (pump-feeds!)))
553 539
554 (defn init-logging!540 (defn init-logging!
555- "Let the media plane talk to stderr, honouring `RUST_LOG`.541+ "Ask, once, whether calls can happen here.
556 542
557- Worth doing unconditionally: it says nothing at all without a `RUST_LOG`,543+ It used to do two things: turn on the Rust media plane's logging and, as
558- and when a call misbehaves it is the only thing that knows why — MoQ544+ a side effect of the call succeeding, discover that the plane existed.
559- subscription, codec negotiation and device open all happen on the far side of545+ There is no Rust plane now and nothing to switch on — `frq.av.plane`
560- the boundary, where no jolt-level trace can see them.546+ raises where it fails and `poll-status!` carries the reason, both of
547+ which a jolt-level trace can already see.
561 548
562- Asking whether the media plane is there is the same call, so this is it."549+ The name stays because `frq.app` calls it at startup and the answer it
550+ wants is unchanged: is there a media plane here at all."
563 []551 []
564 (available?))552 (available?))
565 553
added src/frq/av/dial.clj +117 -0
new file mode 100644
@@ -0,0 +1,117 @@
1+(ns frq.av.dial
2+ "Which SFU to dial, and whether to bother.
3+
4+ Three things `libjoltmoq` did that were never media at all: building the
5+ MoQ URL for a freeq server, deciding whether dialling it is worth
6+ attempting, and minting a per-device call instance. They lived in Rust
7+ because that is where the media plane was, not because they needed to.
8+
9+ The rules are transcribed from `av::sfu_moq_dial_url` and
10+ `av::can_dial_sfu` rather than reinvented, because they are fiddly and
11+ were already tested on that side: which scheme maps to which, where the
12+ port survives and where it does not, and what happens to a path that was
13+ already there."
14+ (:require [clojure.string :as str]))
15+
16+;; --- the SFU URL -------------------------------------------------------------
17+
18+(def ^:private schemes ["ws://" "wss://" "http://" "https://"])
19+
20+(defn- absolute? [s] (some #(str/starts-with? s %) schemes))
21+
22+(defn- local-host?
23+ "localhost and the loopback range, which are dialled over plain HTTP.
24+
25+ Everything else is assumed to be a public freeq host and therefore TLS —
26+ the same assumption the Rust made, and the reason a self-hosted server on
27+ a LAN address needs a scheme spelled out."
28+ [host]
29+ (let [h (str/lower-case (or host ""))]
30+ (or (= h "localhost") (str/starts-with? h "127."))))
31+
32+(defn- split-url
33+ "scheme, host[:port], path — enough of a URL for this and no more."
34+ [u]
35+ (let [[scheme rest] (str/split u #"://" 2)]
36+ (when rest
37+ (let [slash (str/index-of rest "/")
38+ authority (if slash (subs rest 0 slash) rest)
39+ ;; A query on the way in is dropped, not merged: the one we
40+ ;; build replaces it entirely.
41+ authority (first (str/split authority #"\?"))]
42+ {:scheme scheme :authority authority}))))
43+
44+(defn- encode
45+ "Percent-encode everything that is not unreserved.
46+
47+ An instance id is eight hex characters and needs none of this, but the id
48+ is the caller's to choose and a `+` or a space in one would otherwise
49+ become a different instance on the far side."
50+ [s]
51+ (str/join
52+ (map (fn [ch]
53+ (let [c (int ch)]
54+ (if (or (<= 48 c 57) (<= 65 c 90) (<= 97 c 122)
55+ (contains? #{\- \_ \. \~} ch))
56+ (str ch)
57+ (format "%%%02X" c))))
58+ s)))
59+
60+(defn sfu-url
61+ "The MoQ URL for `server`, or nil when the server is not one a URL can be
62+ made of.
63+
64+ `irc.freeq.at:6697` and `wss://irc.freeq.at/irc` both become
65+ `https://irc.freeq.at/av/moq`. Note what happens to the PORT: on a bare
66+ host:port form it is dropped, because 6697 is the IRC port and the SFU is
67+ not there — but on an absolute URL the authority is kept as given, since
68+ someone who wrote a port into a URL meant it."
69+ [server jwt instance]
70+ (let [trimmed (str/trim (or server ""))]
71+ (when-not (str/blank? trimmed)
72+ (let [normalised
73+ (if (absolute? trimmed)
74+ trimmed
75+ (let [host (first (str/split trimmed #":"))]
76+ (if (local-host? host)
77+ (str "http://" trimmed)
78+ (str "https://" host))))
79+ {:keys [scheme authority]} (split-url normalised)
80+ scheme (case scheme
81+ ("https" "wss") "https"
82+ ("http" "ws") "http"
83+ nil)]
84+ (when (and scheme (seq authority))
85+ (let [pairs (cond-> []
86+ (seq instance) (conj (str "inst=" (encode instance)))
87+ ;; JWTs are base64url and pass through unencoded,
88+ ;; which is what freeq-sdk-ffi and freeq-app do.
89+ (seq jwt) (conj (str "jwt=" jwt)))]
90+ (str scheme "://" authority "/av/moq"
91+ (when (seq pairs) (str "?" (str/join "&" pairs))))))))))
92+
93+(defn can-dial?
94+ "Whether dialling this server is worth attempting.
95+
96+ A remote SFU with no token accepts the connection and closes it, and the
97+ MoQ client then retries in a tight loop that looks, from the outside,
98+ exactly like a hang. Asking first is cheaper than explaining that."
99+ [server jwt]
100+ (if (seq jwt)
101+ true
102+ (let [trimmed (str/trim (or server ""))
103+ host (if (absolute? trimmed)
104+ (:authority (split-url trimmed))
105+ (-> trimmed (str/split #"/") first (str/split #":") first))
106+ host (first (str/split (or host "") #":"))]
107+ (local-host? host))))
108+
109+;; --- the instance id ---------------------------------------------------------
110+
111+(defn new-instance
112+ "A per-device call instance id — eight hex characters.
113+
114+ Two devices signed in as the same person need different ones, or their
115+ MoQ broadcast paths collide and each unpublishes the other."
116+ []
117+ (format "%08x" (long (rand-int 2147483647))))
new file mode 100644
@@ -0,0 +1,117 @@
1+(ns frq.av.dial
2+ "Which SFU to dial, and whether to bother.
3+
4+ Three things `libjoltmoq` did that were never media at all: building the
5+ MoQ URL for a freeq server, deciding whether dialling it is worth
6+ attempting, and minting a per-device call instance. They lived in Rust
7+ because that is where the media plane was, not because they needed to.
8+
9+ The rules are transcribed from `av::sfu_moq_dial_url` and
10+ `av::can_dial_sfu` rather than reinvented, because they are fiddly and
11+ were already tested on that side: which scheme maps to which, where the
12+ port survives and where it does not, and what happens to a path that was
13+ already there."
14+ (:require [clojure.string :as str]))
15+
16+;; --- the SFU URL -------------------------------------------------------------
17+
18+(def ^:private schemes ["ws://" "wss://" "http://" "https://"])
19+
20+(defn- absolute? [s] (some #(str/starts-with? s %) schemes))
21+
22+(defn- local-host?
23+ "localhost and the loopback range, which are dialled over plain HTTP.
24+
25+ Everything else is assumed to be a public freeq host and therefore TLS —
26+ the same assumption the Rust made, and the reason a self-hosted server on
27+ a LAN address needs a scheme spelled out."
28+ [host]
29+ (let [h (str/lower-case (or host ""))]
30+ (or (= h "localhost") (str/starts-with? h "127."))))
31+
32+(defn- split-url
33+ "scheme, host[:port], path — enough of a URL for this and no more."
34+ [u]
35+ (let [[scheme rest] (str/split u #"://" 2)]
36+ (when rest
37+ (let [slash (str/index-of rest "/")
38+ authority (if slash (subs rest 0 slash) rest)
39+ ;; A query on the way in is dropped, not merged: the one we
40+ ;; build replaces it entirely.
41+ authority (first (str/split authority #"\?"))]
42+ {:scheme scheme :authority authority}))))
43+
44+(defn- encode
45+ "Percent-encode everything that is not unreserved.
46+
47+ An instance id is eight hex characters and needs none of this, but the id
48+ is the caller's to choose and a `+` or a space in one would otherwise
49+ become a different instance on the far side."
50+ [s]
51+ (str/join
52+ (map (fn [ch]
53+ (let [c (int ch)]
54+ (if (or (<= 48 c 57) (<= 65 c 90) (<= 97 c 122)
55+ (contains? #{\- \_ \. \~} ch))
56+ (str ch)
57+ (format "%%%02X" c))))
58+ s)))
59+
60+(defn sfu-url
61+ "The MoQ URL for `server`, or nil when the server is not one a URL can be
62+ made of.
63+
64+ `irc.freeq.at:6697` and `wss://irc.freeq.at/irc` both become
65+ `https://irc.freeq.at/av/moq`. Note what happens to the PORT: on a bare
66+ host:port form it is dropped, because 6697 is the IRC port and the SFU is
67+ not there — but on an absolute URL the authority is kept as given, since
68+ someone who wrote a port into a URL meant it."
69+ [server jwt instance]
70+ (let [trimmed (str/trim (or server ""))]
71+ (when-not (str/blank? trimmed)
72+ (let [normalised
73+ (if (absolute? trimmed)
74+ trimmed
75+ (let [host (first (str/split trimmed #":"))]
76+ (if (local-host? host)
77+ (str "http://" trimmed)
78+ (str "https://" host))))
79+ {:keys [scheme authority]} (split-url normalised)
80+ scheme (case scheme
81+ ("https" "wss") "https"
82+ ("http" "ws") "http"
83+ nil)]
84+ (when (and scheme (seq authority))
85+ (let [pairs (cond-> []
86+ (seq instance) (conj (str "inst=" (encode instance)))
87+ ;; JWTs are base64url and pass through unencoded,
88+ ;; which is what freeq-sdk-ffi and freeq-app do.
89+ (seq jwt) (conj (str "jwt=" jwt)))]
90+ (str scheme "://" authority "/av/moq"
91+ (when (seq pairs) (str "?" (str/join "&" pairs))))))))))
92+
93+(defn can-dial?
94+ "Whether dialling this server is worth attempting.
95+
96+ A remote SFU with no token accepts the connection and closes it, and the
97+ MoQ client then retries in a tight loop that looks, from the outside,
98+ exactly like a hang. Asking first is cheaper than explaining that."
99+ [server jwt]
100+ (if (seq jwt)
101+ true
102+ (let [trimmed (str/trim (or server ""))
103+ host (if (absolute? trimmed)
104+ (:authority (split-url trimmed))
105+ (-> trimmed (str/split #"/") first (str/split #":") first))
106+ host (first (str/split (or host "") #":"))]
107+ (local-host? host))))
108+
109+;; --- the instance id ---------------------------------------------------------
110+
111+(defn new-instance
112+ "A per-device call instance id — eight hex characters.
113+
114+ Two devices signed in as the same person need different ones, or their
115+ MoQ broadcast paths collide and each unpublishes the other."
116+ []
117+ (format "%08x" (long (rand-int 2147483647))))
modified src/frq/av/plane.clj +93 -4
@@ -67,11 +67,16 @@
6767 ;; One plane per process, as joltmoq had: its C API was all globals, and the
6868 ;; call surface above it assumes a single call at a time.
6969
70-(declare stop!)
70+(declare stop! start!)
7171
7272 (defonce ^:private plane (atom nil))
7373
74-(defn live? [] (some? @plane))
74+(defn live?
75+ "Whether a call is actually up — not merely dialling."
76+ []
77+ (boolean (some-> @plane :producer)))
78+
79+(defn dialling? [] (boolean (some-> @plane :dialling)))
7580
7681 ;; --- starting ----------------------------------------------------------------
7782
@@ -167,6 +172,54 @@
167172 :fps fps})
168173 true))
169174
175+(defn dial!
176+ "Dial `url` and bring the plane up when the session answers. Answers true.
177+
178+ FIRE AND FORGET, like `joltmoq_start` was: the connect is a future, and
179+ waiting for it here would block glimmer's loop thread for the length of a
180+ QUIC handshake — thirty seconds when the relay is not there. `pump!`
181+ finishes the job and `poll-status!` reports it, which is the shape
182+ `frq.av` is already written around.
183+
184+ Everything the plane will need is kept until then, because the tracks
185+ cannot be published until there is a session to publish them into."
186+ [{:keys [url] :as opts}]
187+ (stop!)
188+ (let [c (client/new-client)]
189+ (reset! plane {:dialling (client/connect! c url)
190+ :client c
191+ :opts (dissoc opts :url)
192+ :status (atom [])
193+ :frames []
194+ :peers {}})
195+ true))
196+
197+(defn- pump-dialling!
198+ "Finish a connect that has settled, and start the plane on its session."
199+ [p]
200+ (if-let [fut (:dialling p)]
201+ (when (uniffi/settled? fut)
202+ (let [sess (try (uniffi/complete! fut)
203+ (catch Exception e
204+ (swap! (:status p) conj
205+ {:code :failed
206+ :text (or (:message (ex-data e)) (ex-message e))})
207+ nil))]
208+ (if sess
209+ (do (start! (assoc (:opts p)
210+ :origin (client/session-publisher sess)
211+ :discover (client/session-consumer sess)
212+ :session sess))
213+ ;; start! replaced the plane wholesale; carry over anything
214+ ;; queued while we were still dialling.
215+ (swap! plane update :status
216+ (fn [q] (swap! q into @(:status p)) q))
217+ @plane)
218+ ;; A failed dial leaves the plane present but not live, so the
219+ ;; reason survives to be read.
220+ (assoc p :dialling nil :client nil))))
221+ p))
222+
170223 (defn stop!
171224 "Take the plane down and release everything it holds."
172225 []
@@ -206,6 +259,25 @@
206259 (swap! plane #(when % (assoc % :muted? (boolean muted?))))
207260 nil)
208261
262+(defn set-speaker-muted!
263+ "Stop playing what the mix produced. The peers keep being decoded — their
264+ rings have to stay current or unmuting would start from forty
265+ milliseconds of stale audio."
266+ [muted?]
267+ (swap! plane #(when % (assoc % :speaker-muted? (boolean muted?))))
268+ nil)
269+
270+;; Device switching mid-call is RECORDED, not applied. Reopening a camera
271+;; means tearing down the capture, the encoder and the published track and
272+;; putting them back — there is no V4L2 ioctl for "become a different
273+;; camera" — and doing that underneath a live call is a change worth making
274+;; deliberately rather than as a side effect of a menu. `frq.av` keeps the
275+;; id either way, so the next call uses it.
276+
277+(defn set-camera-device! [id] (swap! plane #(when % (assoc % :camera-device id))) nil)
278+(defn set-mic-device! [id] (swap! plane #(when % (assoc % :mic-device id))) nil)
279+(defn set-speaker-device! [id] (swap! plane #(when % (assoc % :speaker-device id))) nil)
280+
209281 (defn force-keyframe!
210282 "Make the next published frame an IDR.
211283
@@ -464,6 +536,9 @@
464536 []
465537 (when-let [p @plane]
466538 (try
539+ (if (:dialling p)
540+ ;; Still connecting: nothing to pump but the handshake.
541+ (when-let [p' (pump-dialling! p)] (reset! plane p'))
467542 (let [p (pump-closed! p)]
468543 (pump-out! p)
469544 (pump-mic! p)
@@ -480,9 +555,9 @@
480555 ;; Straight out to the speaker if there is one. A caller with its
481556 ;; own output — the terminal backend, a test — reads poll-audio!
482557 ;; instead and this stays nil.
483- (when (and mixed (:speaker p'))
558+ (when (and mixed (:speaker p') (not (:speaker-muted? p')))
484559 ((:play! (:speaker p')) mixed))
485- (reset! plane (assoc p' :mixed mixed)))))
560+ (reset! plane (assoc p' :mixed mixed))))))
486561 (catch Exception e
487562 (note! p {:code :failed
488563 :text (or (:message (ex-data e)) (ex-message e))})
@@ -521,6 +596,20 @@
521596 []
522597 (some-> @plane :peers keys vec))
523598
599+(defn feed-keys
600+ "The keys of everyone currently sending a picture.
601+
602+ Not everyone announced: a peer with their camera off has a broadcast and
603+ a catalog and no video track, and listing them would leave an empty tile
604+ in the wall that never fills. This is joltmoq's `video_keys`, and the
605+ test is the same one — is there a video track subscribed."
606+ []
607+ (into #{}
608+ (keep (fn [[path peer]]
609+ (when (:media peer)
610+ (if (:self? peer) "__local__" path))))
611+ (some-> @plane :peers)))
612+
524613 (defn poll-status!
525614 "Drain what the plane has learned since the last call, oldest first.
526615
@@ -67,11 +67,16 @@
67 ;; One plane per process, as joltmoq had: its C API was all globals, and the67 ;; One plane per process, as joltmoq had: its C API was all globals, and the
68 ;; call surface above it assumes a single call at a time.68 ;; call surface above it assumes a single call at a time.
69 69
70-(declare stop!)70+(declare stop! start!)
71 71
72 (defonce ^:private plane (atom nil))72 (defonce ^:private plane (atom nil))
73 73
74-(defn live? [] (some? @plane))74+(defn live?
75+ "Whether a call is actually up — not merely dialling."
76+ []
77+ (boolean (some-> @plane :producer)))
78+
79+(defn dialling? [] (boolean (some-> @plane :dialling)))
75 80
76 ;; --- starting ----------------------------------------------------------------81 ;; --- starting ----------------------------------------------------------------
77 82
@@ -167,6 +172,54 @@
167 :fps fps})172 :fps fps})
168 true))173 true))
169 174
175+(defn dial!
176+ "Dial `url` and bring the plane up when the session answers. Answers true.
177+
178+ FIRE AND FORGET, like `joltmoq_start` was: the connect is a future, and
179+ waiting for it here would block glimmer's loop thread for the length of a
180+ QUIC handshake — thirty seconds when the relay is not there. `pump!`
181+ finishes the job and `poll-status!` reports it, which is the shape
182+ `frq.av` is already written around.
183+
184+ Everything the plane will need is kept until then, because the tracks
185+ cannot be published until there is a session to publish them into."
186+ [{:keys [url] :as opts}]
187+ (stop!)
188+ (let [c (client/new-client)]
189+ (reset! plane {:dialling (client/connect! c url)
190+ :client c
191+ :opts (dissoc opts :url)
192+ :status (atom [])
193+ :frames []
194+ :peers {}})
195+ true))
196+
197+(defn- pump-dialling!
198+ "Finish a connect that has settled, and start the plane on its session."
199+ [p]
200+ (if-let [fut (:dialling p)]
201+ (when (uniffi/settled? fut)
202+ (let [sess (try (uniffi/complete! fut)
203+ (catch Exception e
204+ (swap! (:status p) conj
205+ {:code :failed
206+ :text (or (:message (ex-data e)) (ex-message e))})
207+ nil))]
208+ (if sess
209+ (do (start! (assoc (:opts p)
210+ :origin (client/session-publisher sess)
211+ :discover (client/session-consumer sess)
212+ :session sess))
213+ ;; start! replaced the plane wholesale; carry over anything
214+ ;; queued while we were still dialling.
215+ (swap! plane update :status
216+ (fn [q] (swap! q into @(:status p)) q))
217+ @plane)
218+ ;; A failed dial leaves the plane present but not live, so the
219+ ;; reason survives to be read.
220+ (assoc p :dialling nil :client nil))))
221+ p))
222+
170 (defn stop!223 (defn stop!
171 "Take the plane down and release everything it holds."224 "Take the plane down and release everything it holds."
172 []225 []
@@ -206,6 +259,25 @@
206 (swap! plane #(when % (assoc % :muted? (boolean muted?))))259 (swap! plane #(when % (assoc % :muted? (boolean muted?))))
207 nil)260 nil)
208 261
262+(defn set-speaker-muted!
263+ "Stop playing what the mix produced. The peers keep being decoded — their
264+ rings have to stay current or unmuting would start from forty
265+ milliseconds of stale audio."
266+ [muted?]
267+ (swap! plane #(when % (assoc % :speaker-muted? (boolean muted?))))
268+ nil)
269+
270+;; Device switching mid-call is RECORDED, not applied. Reopening a camera
271+;; means tearing down the capture, the encoder and the published track and
272+;; putting them back — there is no V4L2 ioctl for "become a different
273+;; camera" — and doing that underneath a live call is a change worth making
274+;; deliberately rather than as a side effect of a menu. `frq.av` keeps the
275+;; id either way, so the next call uses it.
276+
277+(defn set-camera-device! [id] (swap! plane #(when % (assoc % :camera-device id))) nil)
278+(defn set-mic-device! [id] (swap! plane #(when % (assoc % :mic-device id))) nil)
279+(defn set-speaker-device! [id] (swap! plane #(when % (assoc % :speaker-device id))) nil)
280+
209 (defn force-keyframe!281 (defn force-keyframe!
210 "Make the next published frame an IDR.282 "Make the next published frame an IDR.
211 283
@@ -464,6 +536,9 @@
464 []536 []
465 (when-let [p @plane]537 (when-let [p @plane]
466 (try538 (try
539+ (if (:dialling p)
540+ ;; Still connecting: nothing to pump but the handshake.
541+ (when-let [p' (pump-dialling! p)] (reset! plane p'))
467 (let [p (pump-closed! p)]542 (let [p (pump-closed! p)]
468 (pump-out! p)543 (pump-out! p)
469 (pump-mic! p)544 (pump-mic! p)
@@ -480,9 +555,9 @@
480 ;; Straight out to the speaker if there is one. A caller with its555 ;; Straight out to the speaker if there is one. A caller with its
481 ;; own output — the terminal backend, a test — reads poll-audio!556 ;; own output — the terminal backend, a test — reads poll-audio!
482 ;; instead and this stays nil.557 ;; instead and this stays nil.
483- (when (and mixed (:speaker p'))558+ (when (and mixed (:speaker p') (not (:speaker-muted? p')))
484 ((:play! (:speaker p')) mixed))559 ((:play! (:speaker p')) mixed))
485- (reset! plane (assoc p' :mixed mixed)))))560+ (reset! plane (assoc p' :mixed mixed))))))
486 (catch Exception e561 (catch Exception e
487 (note! p {:code :failed562 (note! p {:code :failed
488 :text (or (:message (ex-data e)) (ex-message e))})563 :text (or (:message (ex-data e)) (ex-message e))})
@@ -521,6 +596,20 @@
521 []596 []
522 (some-> @plane :peers keys vec))597 (some-> @plane :peers keys vec))
523 598
599+(defn feed-keys
600+ "The keys of everyone currently sending a picture.
601+
602+ Not everyone announced: a peer with their camera off has a broadcast and
603+ a catalog and no video track, and listing them would leave an empty tile
604+ in the wall that never fills. This is joltmoq's `video_keys`, and the
605+ test is the same one — is there a video track subscribed."
606+ []
607+ (into #{}
608+ (keep (fn [[path peer]]
609+ (when (:media peer)
610+ (if (:self? peer) "__local__" path))))
611+ (some-> @plane :peers)))
612+
524 (defn poll-status!613 (defn poll-status!
525 "Drain what the plane has learned since the last call, oldest first.614 "Drain what the plane has learned since the last call, oldest first.
526 615
modified src/frq/moq/smoke.clj +58 -1
@@ -44,6 +44,7 @@
4444 [frq.capture.source :as source]
4545 [frq.av.plane :as plane]
4646 [frq.av.audio :as audio]
47+ [frq.av.dial :as dial]
4748 [jolt.ffi :as ffi]))
4849
4950 (defn- check-contract []
@@ -816,6 +817,61 @@
816817
817818 :else (do (Thread/sleep 10) (recur req accepted srv sess))))))))))
818819
820+(defn- check-dial
821+ "The URL rules, transcribed out of Rust and worth checking case by case.
822+
823+ These were `joltmoq_sfu_url` and `joltmoq_can_dial`, and they are the one
824+ part of the port that is pure logic rather than a binding — which means
825+ they are also the one part where a wrong answer is silent. A URL built
826+ slightly wrong does not fail to compile; it dials somewhere that is not
827+ there, and the person is told the call timed out.
828+
829+ The port dropping is the case worth staring at: `irc.freeq.at:6697`
830+ becomes `https://irc.freeq.at/av/moq` with no port, because 6697 is the
831+ IRC port and the SFU is not on it — but an absolute URL keeps its
832+ authority as written, since someone who put a port in a URL meant it."
833+ []
834+ (let [cases
835+ [["irc.freeq.at:6697" nil nil "https://irc.freeq.at/av/moq"]
836+ ["irc.freeq.at" nil nil "https://irc.freeq.at/av/moq"]
837+ ["wss://irc.freeq.at/irc" nil nil "https://irc.freeq.at/av/moq"]
838+ ["ws://localhost:6667" nil nil "http://localhost:6667/av/moq"]
839+ ["localhost:6667" nil nil "http://localhost:6667/av/moq"]
840+ ["127.0.0.1:6667" nil nil "http://127.0.0.1:6667/av/moq"]
841+ ["irc.freeq.at:6697" "tok" "ab12" "https://irc.freeq.at/av/moq?inst=ab12&jwt=tok"]
842+ ["irc.freeq.at" nil "ab12" "https://irc.freeq.at/av/moq?inst=ab12"]
843+ ["" nil nil nil]
844+ [" " nil nil nil]]]
845+ (doseq [[server jwt inst want] cases]
846+ (let [got (dial/sfu-url server jwt inst)]
847+ (when-not (= want got)
848+ (throw (ex-info "sfu-url disagrees with the Rust it came from"
849+ {:server server :jwt jwt :instance inst
850+ :want want :got got})))))
851+ (println " sfu-url:" (count cases) "cases agree"))
852+
853+ (let [cases [["localhost:6667" nil true]
854+ ["127.0.0.1:6667" nil true]
855+ ["irc.freeq.at" nil false]
856+ ["irc.freeq.at" "tok" true]
857+ ["wss://irc.freeq.at/irc" nil false]
858+ ["http://localhost:6667" nil true]]]
859+ (doseq [[server jwt want] cases]
860+ (let [got (dial/can-dial? server jwt)]
861+ (when-not (= want got)
862+ (throw (ex-info "can-dial? disagrees with the Rust it came from"
863+ {:server server :jwt jwt :want want :got got})))))
864+ (println " can-dial?:" (count cases) "cases agree"))
865+
866+ (let [ids (repeatedly 200 dial/new-instance)]
867+ (when-not (every? #(re-matches #"[0-9a-f]{8}" %) ids)
868+ (throw (ex-info "instance ids are not eight hex characters"
869+ {:sample (take 3 (remove #(re-matches #"[0-9a-f]{8}" %) ids))})))
870+ ;; Two devices with the same id unpublish each other, so collisions are
871+ ;; not a cosmetic concern.
872+ (println " instances:" (count (distinct ids)) "distinct of" (count ids)))
873+ true)
874+
819875 (defn -main [& _]
820876 (println "libmoq_ffi smoke test")
821877 (let [steps [["contract" check-contract]
@@ -832,7 +888,8 @@
832888 ["audio" check-audio]
833889 ["session" check-session]
834890 ["wired" check-wired-devices]
835- ["status" check-status]]]
891+ ["status" check-status]
892+ ["dial" check-dial]]]
836893 (doseq [[name f] steps]
837894 (println (str name ":"))
838895 (f))
@@ -44,6 +44,7 @@
44 [frq.capture.source :as source]44 [frq.capture.source :as source]
45 [frq.av.plane :as plane]45 [frq.av.plane :as plane]
46 [frq.av.audio :as audio]46 [frq.av.audio :as audio]
47+ [frq.av.dial :as dial]
47 [jolt.ffi :as ffi]))48 [jolt.ffi :as ffi]))
48 49
49 (defn- check-contract []50 (defn- check-contract []
@@ -816,6 +817,61 @@
816 817
817 :else (do (Thread/sleep 10) (recur req accepted srv sess))))))))))818 :else (do (Thread/sleep 10) (recur req accepted srv sess))))))))))
818 819
820+(defn- check-dial
821+ "The URL rules, transcribed out of Rust and worth checking case by case.
822+
823+ These were `joltmoq_sfu_url` and `joltmoq_can_dial`, and they are the one
824+ part of the port that is pure logic rather than a binding — which means
825+ they are also the one part where a wrong answer is silent. A URL built
826+ slightly wrong does not fail to compile; it dials somewhere that is not
827+ there, and the person is told the call timed out.
828+
829+ The port dropping is the case worth staring at: `irc.freeq.at:6697`
830+ becomes `https://irc.freeq.at/av/moq` with no port, because 6697 is the
831+ IRC port and the SFU is not on it — but an absolute URL keeps its
832+ authority as written, since someone who put a port in a URL meant it."
833+ []
834+ (let [cases
835+ [["irc.freeq.at:6697" nil nil "https://irc.freeq.at/av/moq"]
836+ ["irc.freeq.at" nil nil "https://irc.freeq.at/av/moq"]
837+ ["wss://irc.freeq.at/irc" nil nil "https://irc.freeq.at/av/moq"]
838+ ["ws://localhost:6667" nil nil "http://localhost:6667/av/moq"]
839+ ["localhost:6667" nil nil "http://localhost:6667/av/moq"]
840+ ["127.0.0.1:6667" nil nil "http://127.0.0.1:6667/av/moq"]
841+ ["irc.freeq.at:6697" "tok" "ab12" "https://irc.freeq.at/av/moq?inst=ab12&jwt=tok"]
842+ ["irc.freeq.at" nil "ab12" "https://irc.freeq.at/av/moq?inst=ab12"]
843+ ["" nil nil nil]
844+ [" " nil nil nil]]]
845+ (doseq [[server jwt inst want] cases]
846+ (let [got (dial/sfu-url server jwt inst)]
847+ (when-not (= want got)
848+ (throw (ex-info "sfu-url disagrees with the Rust it came from"
849+ {:server server :jwt jwt :instance inst
850+ :want want :got got})))))
851+ (println " sfu-url:" (count cases) "cases agree"))
852+
853+ (let [cases [["localhost:6667" nil true]
854+ ["127.0.0.1:6667" nil true]
855+ ["irc.freeq.at" nil false]
856+ ["irc.freeq.at" "tok" true]
857+ ["wss://irc.freeq.at/irc" nil false]
858+ ["http://localhost:6667" nil true]]]
859+ (doseq [[server jwt want] cases]
860+ (let [got (dial/can-dial? server jwt)]
861+ (when-not (= want got)
862+ (throw (ex-info "can-dial? disagrees with the Rust it came from"
863+ {:server server :jwt jwt :want want :got got})))))
864+ (println " can-dial?:" (count cases) "cases agree"))
865+
866+ (let [ids (repeatedly 200 dial/new-instance)]
867+ (when-not (every? #(re-matches #"[0-9a-f]{8}" %) ids)
868+ (throw (ex-info "instance ids are not eight hex characters"
869+ {:sample (take 3 (remove #(re-matches #"[0-9a-f]{8}" %) ids))})))
870+ ;; Two devices with the same id unpublish each other, so collisions are
871+ ;; not a cosmetic concern.
872+ (println " instances:" (count (distinct ids)) "distinct of" (count ids)))
873+ true)
874+
819 (defn -main [& _]875 (defn -main [& _]
820 (println "libmoq_ffi smoke test")876 (println "libmoq_ffi smoke test")
821 (let [steps [["contract" check-contract]877 (let [steps [["contract" check-contract]
@@ -832,7 +888,8 @@
832 ["audio" check-audio]888 ["audio" check-audio]
833 ["session" check-session]889 ["session" check-session]
834 ["wired" check-wired-devices]890 ["wired" check-wired-devices]
835- ["status" check-status]]]891+ ["status" check-status]
892+ ["dial" check-dial]]]
836 (doseq [[name f] steps]893 (doseq [[name f] steps]
837 (println (str name ":"))894 (println (str name ":"))
838 (f))895 (f))