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>
b33f288 parent: 9fcd0b4 modified
deps.edn +14 -22 | @@ -16,37 +16,29 @@ | ||
| 16 | 16 | :git/sha "d970307ccf1fe67e2e971f2837d2282d8ba79a62" |
| 17 | 17 | :deps/root "glimmer-backends/glimmer-vidya"}} |
| 18 | 18 | |
| 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: | |
| 21 | 20 | ;; |
| 22 | 21 | ;; 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 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. | |
| 28 | 27 | ;; |
| 29 | 28 | ;; just run |
| 30 | 29 | :jolt/native [{:name "vidya" |
| 31 | 30 | :darwin ["libvidya.dylib"] |
| 32 | 31 | :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. | |
| 43 | 36 | ;; |
| 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. | |
| 50 | 42 | ;; |
| 51 | 43 | ;; No :darwin: :systems is x86_64-linux and aarch64-linux, so |
| 52 | 44 | ;; 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 egui | 21 | ;; 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 and | 23 | + ;; 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 codec | 24 | + ;; 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 run | 28 | ;; 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 a | 35 | + ;; 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 video | 37 | + ;; The codecs are NOT in it. moq-ffi's `audio` and `video` |
| 45 | - ;; features are off in every Linux artifact upstream | 38 | + ;; features are off in every Linux artifact upstream ships, |
| 46 | - ;; publishes, and turning them on means compiling a | 39 | + ;; so Opus and H.264 come from their own C libraries below, |
| 47 | - ;; 1062-crate workspace. Opus and H.264 come from their own C | 40 | + ;; which is the whole point of doing this over FFI rather |
| 48 | - ;; libraries instead, which is the whole point of doing this | 41 | + ;; 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, so | 43 | ;; 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 @@ | ||
| 180 | 180 | # and says it once. It also builds cpal with the `pipewire` feature, |
| 181 | 181 | # which the restatement did not — so device names in a call are |
| 182 | 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 | 203 | # libmoq_ffi — MoQ over QUIC behind UniFFI's C ABI, FETCHED rather |
| 186 | 204 | # 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 are | 181 | # 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 rather | 203 | # 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 @@ | ||
| 6 | 6 | server broadcasts `+freeq.at/av-state` back, which is IRC and nothing more, |
| 7 | 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 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. | |
| 13 | 15 | |
| 14 | 16 | Two rules come from that side and shape everything here: |
| 15 | 17 | |
| @@ -18,69 +20,43 @@ | ||
| 18 | 20 | * **A video frame is borrowed**, valid only until the next poll. `pump!` |
| 19 | 21 | hands each one to Vidya as it arrives and never keeps one, which is also |
| 20 | 22 | 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 | 28 | (:require [clojure.string :as str] |
| 23 | 29 | [glimmer.ratom :as r :refer [atom]] |
| 24 | 30 | [glimmer-vidya.core :as vidya] |
| 25 | 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 | 37 | ;; --- 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) | |
| 75 | 46 | |
| 76 | 47 | (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". | |
| 82 | 58 | (delay |
| 83 | - (try (raw-init-logging) true | |
| 59 | + (try (boolean (seq (alsa/devices :playback))) | |
| 84 | 60 | (catch Exception _ false)))) |
| 85 | 61 | |
| 86 | 62 | (defn available? |
| @@ -88,42 +64,37 @@ | ||
| 88 | 64 | [] |
| 89 | 65 | @media-plane) |
| 90 | 66 | |
| 91 | -(defn- flag [b] (if b 1 0)) | |
| 92 | 67 | (defn- pref [s] (or s "")) |
| 93 | 68 | |
| 94 | -(defn live? [] (not (zero? (raw-is-live)))) | |
| 69 | +(defn live? [] (plane/live?)) | |
| 70 | + | |
| 95 | 71 | (defn can-dial? |
| 96 | 72 | "Whether dialling this server is worth attempting. A remote SFU with no token |
| 97 | 73 | accepts the connection and closes it, and the MoQ client then retries in a |
| 98 | 74 | tight loop that looks exactly like a hang." |
| 99 | 75 | [server jwt] |
| 100 | - (not (zero? (raw-can-dial (pref server) (pref jwt))))) | |
| 76 | + (dial/can-dial? server jwt)) | |
| 101 | 77 | |
| 102 | 78 | (defn sfu-url |
| 103 | 79 | "The SFU to dial for this server, or nil when the server is not one a URL can |
| 104 | 80 | be made of." |
| 105 | 81 | [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 _ []))) | |
| 127 | 98 | |
| 128 | 99 | ;; --- the signaling tags ------------------------------------------------------ |
| 129 | 100 | ;; Every one of these is a TAGMSG to the channel. The server answers with an |
| @@ -298,7 +269,7 @@ | ||
| 298 | 269 | or the caller is about to send an `av-leave` of its own. Anything else wants |
| 299 | 270 | `dropped!`, or freeq goes on counting a participant who is not there." |
| 300 | 271 | [] |
| 301 | - (raw-stop) | |
| 272 | + (plane/stop!) | |
| 302 | 273 | (drop-feeds!) |
| 303 | 274 | (reset! feeds []) |
| 304 | 275 | (reset! local-call nil) |
| @@ -331,11 +302,23 @@ | ||
| 331 | 302 | (do |
| 332 | 303 | (swap! local-call assoc :media :dialling) |
| 333 | 304 | (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"))))) | |
| 339 | 322 | (do (swap! local-call assoc :media :failed) |
| 340 | 323 | (reset! media-error (str "no SFU for " server)))))) |
| 341 | 324 | |
| @@ -417,64 +400,69 @@ | ||
| 417 | 400 | |
| 418 | 401 | (defn set-muted! [muted?] |
| 419 | 402 | (swap! local-call assoc :muted? muted?) |
| 420 | - (raw-set-muted (flag muted?))) | |
| 403 | + (plane/set-muted! muted?)) | |
| 421 | 404 | |
| 422 | 405 | (defn set-speaker-muted! [muted?] |
| 423 | 406 | (swap! local-call assoc :speaker-muted? muted?) |
| 424 | - (raw-set-speaker-muted (flag muted?))) | |
| 407 | + (plane/set-speaker-muted! muted?)) | |
| 425 | 408 | |
| 426 | 409 | (defn set-camera! [on?] |
| 427 | 410 | (swap! local-call assoc :camera? on?) |
| 428 | - (raw-set-camera (flag on?)) | |
| 411 | + (plane/set-camera! on?) | |
| 429 | 412 | ;; The tile goes when the camera does: the plane stops publishing, so no |
| 430 | 413 | ;; frame arrives to replace the last one. |
| 431 | 414 | (when-not on? |
| 432 | 415 | (vidya/frame-drop! local-feed) |
| 433 | 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 | 423 | (defn set-camera-device! [id] |
| 436 | 424 | (swap! local-call assoc :camera-id id) |
| 437 | - (raw-set-camera-device (pref id))) | |
| 425 | + (plane/set-camera-device! id)) | |
| 438 | 426 | |
| 439 | 427 | (defn set-mic-device! [id] |
| 440 | 428 | (swap! local-call assoc :mic-id id) |
| 441 | - (raw-set-mic-device (pref id))) | |
| 429 | + (plane/set-mic-device! id)) | |
| 442 | 430 | |
| 443 | 431 | (defn set-speaker-device! [id] |
| 444 | 432 | (swap! local-call assoc :speaker-id id) |
| 445 | - (raw-set-speaker-device (pref id))) | |
| 433 | + (plane/set-speaker-device! id)) | |
| 446 | 434 | |
| 447 | 435 | ;; --- the pump ---------------------------------------------------------------- |
| 448 | 436 | |
| 449 | 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 [] | |
| 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))))))) | |
| 478 | 466 | |
| 479 | 467 | (defn- pump-frames! |
| 480 | 468 | "Hand every new frame straight to Vidya. |
| @@ -482,18 +470,16 @@ | ||
| 482 | 470 | The pointer is borrowed until the next poll, so it is used and dropped inside |
| 483 | 471 | this loop and never held. Nothing is copied on this side: the pixels go from |
| 484 | 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 [] | |
| 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)))) | |
| 497 | 483 | |
| 498 | 484 | (defn- order-feeds |
| 499 | 485 | "Everyone with a picture, the self-view last. |
| @@ -514,7 +500,7 @@ | ||
| 514 | 500 | thing, and every `:image` node under it would be rebuilt around a texture |
| 515 | 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 | 504 | (doseq [k (remove live @painted-feeds)] |
| 519 | 505 | (vidya/frame-drop! k) |
| 520 | 506 | (swap! painted-feeds disj k)) |
| @@ -552,14 +538,16 @@ | ||
| 552 | 538 | (pump-feeds!))) |
| 553 | 539 | |
| 554 | 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`, | |
| 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. | |
| 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 | 552 | (available?)) |
| 565 | 553 | |
| @@ -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 of | 9 | + 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 in | 10 | + — 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 this | 11 | + 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 also | 21 | 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 buffer | 22 | 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 the | 38 | +;; `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-plane | 47 | (defonce ^:private media-plane |
| 77 | - ;; Whether `libjoltmoq` is here at all, asked once by calling the smallest | 48 | + ;; 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 media | 49 | + ;; 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. Every | 51 | + ;; 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 | (delay | 58 | (delay |
| 83 | - (try (raw-init-logging) true | 59 | + (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 token | 72 | "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 a | 73 | 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-url | 78 | (defn sfu-url |
| 103 | "The SFU to dial for this server, or nil when the server is not one a URL can | 79 | "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-devices | 85 | + "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 — a | 88 | + (dial/new-instance)) |
| 113 | - webcam called \"EMEET SmartCam C960, Mono\" has spaces and a comma in it, and | 89 | + |
| 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 id | 96 | +(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 an | 100 | ;; 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 wants | 269 | 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 | (do | 302 | (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) instance | 305 | + ;; 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 no | 412 | ;; 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 | - (cond | 448 | + (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 :live | 451 | + |
| 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 at | 455 | + ;; 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 the | 456 | + ;; whether or not the person has read why yet. |
| 464 | - ;; local call up afterwards so the reason stays on screen — but the | 457 | + (= code status-ended) |
| 465 | - ;; server is told either way, because we are no longer in the call | 458 | + (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 inside | 470 | 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 from | 471 | 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-feeds | 484 | (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 texture | 500 | 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 — MoQ | 544 | + 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 of | 545 | + 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 @@ | ||
| 67 | 67 | ;; One plane per process, as joltmoq had: its C API was all globals, and the |
| 68 | 68 | ;; call surface above it assumes a single call at a time. |
| 69 | 69 | |
| 70 | -(declare stop!) | |
| 70 | +(declare stop! start!) | |
| 71 | 71 | |
| 72 | 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 | 81 | ;; --- starting ---------------------------------------------------------------- |
| 77 | 82 | |
| @@ -167,6 +172,54 @@ | ||
| 167 | 172 | :fps fps}) |
| 168 | 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 | 223 | (defn stop! |
| 171 | 224 | "Take the plane down and release everything it holds." |
| 172 | 225 | [] |
| @@ -206,6 +259,25 @@ | ||
| 206 | 259 | (swap! plane #(when % (assoc % :muted? (boolean muted?)))) |
| 207 | 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 | 281 | (defn force-keyframe! |
| 210 | 282 | "Make the next published frame an IDR. |
| 211 | 283 | |
| @@ -464,6 +536,9 @@ | ||
| 464 | 536 | [] |
| 465 | 537 | (when-let [p @plane] |
| 466 | 538 | (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 | 542 | (let [p (pump-closed! p)] |
| 468 | 543 | (pump-out! p) |
| 469 | 544 | (pump-mic! p) |
| @@ -480,9 +555,9 @@ | ||
| 480 | 555 | ;; Straight out to the speaker if there is one. A caller with its |
| 481 | 556 | ;; own output — the terminal backend, a test — reads poll-audio! |
| 482 | 557 | ;; instead and this stays nil. |
| 483 | - (when (and mixed (:speaker p')) | |
| 558 | + (when (and mixed (:speaker p') (not (:speaker-muted? p'))) | |
| 484 | 559 | ((:play! (:speaker p')) mixed)) |
| 485 | - (reset! plane (assoc p' :mixed mixed))))) | |
| 560 | + (reset! plane (assoc p' :mixed mixed)))))) | |
| 486 | 561 | (catch Exception e |
| 487 | 562 | (note! p {:code :failed |
| 488 | 563 | :text (or (:message (ex-data e)) (ex-message e))}) |
| @@ -521,6 +596,20 @@ | ||
| 521 | 596 | [] |
| 522 | 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 | 613 | (defn poll-status! |
| 525 | 614 | "Drain what the plane has learned since the last call, oldest first. |
| 526 | 615 | |
| @@ -67,11 +67,16 @@ | |||
| 67 | ;; One plane per process, as joltmoq had: its C API was all globals, and the | 67 | ;; 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 | (try | 538 | (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 its | 555 | ;; 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 e | 561 | (catch Exception e |
| 487 | (note! p {:code :failed | 562 | (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 @@ | ||
| 44 | 44 | [frq.capture.source :as source] |
| 45 | 45 | [frq.av.plane :as plane] |
| 46 | 46 | [frq.av.audio :as audio] |
| 47 | + [frq.av.dial :as dial] | |
| 47 | 48 | [jolt.ffi :as ffi])) |
| 48 | 49 | |
| 49 | 50 | (defn- check-contract [] |
| @@ -816,6 +817,61 @@ | ||
| 816 | 817 | |
| 817 | 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 | 875 | (defn -main [& _] |
| 820 | 876 | (println "libmoq_ffi smoke test") |
| 821 | 877 | (let [steps [["contract" check-contract] |
| @@ -832,7 +888,8 @@ | ||
| 832 | 888 | ["audio" check-audio] |
| 833 | 889 | ["session" check-session] |
| 834 | 890 | ["wired" check-wired-devices] |
| 835 | - ["status" check-status]]] | |
| 891 | + ["status" check-status] | |
| 892 | + ["dial" check-dial]]] | |
| 836 | 893 | (doseq [[name f] steps] |
| 837 | 894 | (println (str name ":")) |
| 838 | 895 | (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)) |