|
| 1 | +;; # ꩜ An OSC _fourieristic_ Spirograph |
| 2 | +;; _This short text shows how to use an [OSC](https://en.wikipedia.org/wiki/Open_Sound_Control) |
| 3 | +;; driven controller running on your phone to interact with vector graphic animations in a Clerk notebook. OSC is generally employed in live multimedia |
| 4 | +;; devices and sound synthesizers, but as [remarked a while ago by Joe Armstrong](https://joearms.github.io/published/2016-01-28-A-Badass-Way-To-Connect-Programs-Together.html) |
| 5 | +;; its properties make it an interesting choice for exchanging data across machines in a broader range of applications._ |
| 6 | +^{:nextjournal.clerk/visibility :hide-ns} |
| 7 | +(ns osc-spirograph |
| 8 | + (:require [nextjournal.clerk :as clerk] |
| 9 | + [clojure.java.io :as io]) |
| 10 | + (:import (com.illposed.osc ConsoleEchoServer OSCMessageListener OSCMessageEvent OSCMessage) |
| 11 | + (com.illposed.osc.messageselector JavaRegexAddressMessageSelector) |
| 12 | + (org.slf4j LoggerFactory) |
| 13 | + (java.net InetSocketAddress) |
| 14 | + (javax.imageio ImageIO))) |
| 15 | + |
| 16 | +^{::clerk/visibility :fold :nextjournal.clerk/viewer :hide-result} |
| 17 | +(def client-model-sync |
| 18 | + ;; This viewer is used to sync models between clojure values and those on the client side |
| 19 | + {:fetch-fn (fn [_ x] x) |
| 20 | + :transform-fn (fn [{::clerk/keys [var-from-def]}] {:value @@var-from-def}) |
| 21 | + :render-fn '(fn [{:keys [value]}] |
| 22 | + (defonce model (atom nil)) |
| 23 | + (-> (swap! model (partial merge-with (fn [old new] (if (vector? old) (mapv merge old new) new))) value) |
| 24 | + (update :phasors (partial mapv #(dissoc % :group))) |
| 25 | + (dissoc :drawing :curve)))}) |
| 26 | + |
| 27 | +;; This is the model representing the constituents of our spirograph. |
| 28 | +;; Three [phasors](https://en.wikipedia.org/wiki/Phasor), each one carrying an amplitude and an angular frequency. |
| 29 | +^{::clerk/viewer client-model-sync} |
| 30 | +(defonce model |
| 31 | + (atom {:phasors [{:amplitude 0.41 :frequency 0.46} |
| 32 | + {:amplitude 0.46 :frequency -0.44} |
| 33 | + {:amplitude 1.00 :frequency -0.45}]})) |
| 34 | + |
| 35 | +;; Our drawing is a function of time with values in the complex plane. |
| 36 | +;; |
| 37 | +;; $$\zeta(t) = \sum_{k=1}^3 \mathsf{amplitude}_k\,\large{e}^{2\pi\,\mathsf{frequency}_k \,i\, t}$$ |
| 38 | +;; |
| 39 | + |
| 40 | +^{::clerk/visibility :fold ::clerk/viewer :hide-result} |
| 41 | +(def spirograph-viewer |
| 42 | + {:render-fn '(fn [_] |
| 43 | + (v/html |
| 44 | + [v/with-d3-require { :package "[email protected]"} |
| 45 | + (fn [Two] |
| 46 | + (reagent/with-let |
| 47 | + [Vector (.-Vector Two) Line (.-Line Two) Group (.-Group Two) |
| 48 | + world-matrix (.. Two -Utils -getComputedMatrix) |
| 49 | + R 200 MAXV 1000 time-scale 0.09 frequency-factor (* 2 js/Math.PI 0.025) |
| 50 | + arm-color ["#f43f5e" "#65a30d" "#4338ca"] ;; [ r , g , b ] |
| 51 | + phasor-group (fn [drawing parent {:keys [amplitude color]}] |
| 52 | + (let [G (doto (Group.) |
| 53 | + (j/assoc! :position |
| 54 | + (j/get-in parent [:children 0 :vertices 1] |
| 55 | + (Vector. (/ (.-width drawing) 2) |
| 56 | + (/ (.-height drawing) 2)))))] |
| 57 | + (.add parent G) |
| 58 | + (.add G (doto (Line. 0.0 0.0 (* amplitude R) 0.0) |
| 59 | + (j/assoc! :linewidth 7) |
| 60 | + (j/assoc! :stroke color) |
| 61 | + (j/assoc! :cap "round"))) |
| 62 | + G)) |
| 63 | + build-phasors (fn [{:as m :keys [drawing]}] |
| 64 | + (update m :phasors (fn [phasors] |
| 65 | + (->> phasors |
| 66 | + (transduce (map-indexed (fn [i ph] (assoc ph :color (arm-color i)))) |
| 67 | + (fn |
| 68 | + ([] {:phasors [] :parent-group (.-scene drawing)}) |
| 69 | + ([ret] (:phasors ret)) |
| 70 | + ([{:as acc :keys [parent-group]} params] |
| 71 | + (let [g (phasor-group drawing parent-group params)] |
| 72 | + (-> acc |
| 73 | + (update :phasors conj (-> params (assoc :group g) (dissoc :color))) |
| 74 | + (assoc :parent-group g)))))))))) |
| 75 | + update-phasor! (fn [{:keys [amplitude frequency group]} dt] |
| 76 | + (when group |
| 77 | + (j/assoc-in! group [:children 0 :vertices 1 :x] (* amplitude R)) |
| 78 | + (j/update! group :rotation + (* frequency-factor frequency dt)))) |
| 79 | + build-curve (fn [{:as m :keys [drawing]}] |
| 80 | + (assoc m :curve |
| 81 | + (doto (.makeCurve drawing) |
| 82 | + (j/assoc! :closed false) |
| 83 | + (j/assoc! :stroke "#5b21b6") |
| 84 | + (j/assoc! :linewidth 5) |
| 85 | + (j/assoc! :opacity 0.8) |
| 86 | + .noFill))) |
| 87 | + pen-position (fn [{:keys [phasors]}] |
| 88 | + (let [{:keys [amplitude group]} (last phasors)] |
| 89 | + (.copy (Vector.) |
| 90 | + (-> group world-matrix (.multiply (* amplitude R) 0.0 1.0))))) |
| 91 | + ->color (fn [{:keys [phasors]}] |
| 92 | + (let [[r g b] (map (comp js/Math.floor (partial * 200) :amplitude) phasors)] |
| 93 | + (str "rgb(" r "," g "," b ")"))) |
| 94 | + update-curve! (fn [{:as model :keys [drawing mode curve]} dt] |
| 95 | + (when curve |
| 96 | + (let [vxs (.-vertices curve) size (.-length vxs)] |
| 97 | + (case (or mode 0) |
| 98 | + 0 ;; spirograph |
| 99 | + (.push vxs (pen-position model)) |
| 100 | + 1 ;; fourier |
| 101 | + (doto vxs |
| 102 | + (.push (j/assoc! (pen-position model) :x (/ (.-width drawing) 2))) |
| 103 | + (.forEach (fn [p] (j/update! p :x - dt)))) |
| 104 | + nil) |
| 105 | + (when (< MAXV size) (.splice vxs 0 (- size MAXV))) |
| 106 | + (j/assoc! curve :stroke (->color model))))) |
| 107 | + apply-model (fn [{:as model :keys [clean? curve drawing phasors]} dt] |
| 108 | + (doseq [rot phasors] (update-phasor! rot dt)) |
| 109 | + (js/requestAnimationFrame #(update-curve! model dt)) ;; draw curve at next tick |
| 110 | + (when clean? (.remove drawing curve)) |
| 111 | + (cond-> model clean? build-curve)) |
| 112 | + update! (fn [_frames dt] (swap! model apply-model (* time-scale dt))) |
| 113 | + refn (fn [el] |
| 114 | + (when (and el (not (:drawing @model))) |
| 115 | + (let [drawing (Two. (j/obj :type (.. Two -Types -svg) :autostart true :fitted true))] |
| 116 | + (.appendTo drawing el) |
| 117 | + (.bind drawing "update" update!) |
| 118 | + (swap! model #(-> % (assoc :drawing drawing) build-phasors build-curve)))))] |
| 119 | + [:div {:ref refn :style {:width "100%" :height "800px"}}]))]))}) |
| 120 | + |
| 121 | +^{::clerk/width :full ::clerk/visibility :hide ::clerk/viewer spirograph-viewer} |
| 122 | +(Object.) |
| 123 | + |
| 124 | +;; We'll be interacting with the spirograph by means of [TouchOSC](https://hexler.net/touchosc) an application for building OSC (or MIDI) driven interfaces runnable on smartphones and the like. |
| 125 | +;; Our controller is looking like this: |
| 126 | +^{::clerk/visibility :hide} |
| 127 | +(ImageIO/read (io/resource "spirograph.png")) |
| 128 | +;; the linear faders on the left will control the phasors amplitudes while the radial ones change their frequencies. This |
| 129 | +;; specific layout is saved in [this file](https://github.com/zampino/osc-spirograph/blob/main/spirograph.tosc). |
| 130 | +;; |
| 131 | +;; OSC binary messages are composed of an _address_ and sequential _arguments_. We configured our interface to emit message |
| 132 | +;; arguments of the form `[value & path]` where the first entry is an integer in the range `0` to `100` while the tail is a valid path in |
| 133 | +;; the model. We're actually ignoring the message address. |
| 134 | +;; |
| 135 | +;; In order to receive OSC messages, we instantiate an OSC Server. We're overlaying an extra broadcast layer on top of the simple echo server |
| 136 | +;; provided by the [JavaOSC library](https://github.com/hoijui/JavaOSC). This will, in addition, allow to debug incoming messages in the terminal. |
| 137 | +;; |
| 138 | +;; Received events are tapped into the JVM for them to be handled with clojure functions, this piece shows Java interop at its best! |
| 139 | +(when-not (System/getenv "NOSC") |
| 140 | + (defonce osc |
| 141 | + (doto (proxy [ConsoleEchoServer] |
| 142 | + [(InetSocketAddress. "0.0.0.0" 6669) |
| 143 | + (LoggerFactory/getLogger ConsoleEchoServer)] |
| 144 | + (start [] |
| 145 | + (proxy-super start) |
| 146 | + (.. this |
| 147 | + getDispatcher |
| 148 | + (addListener (JavaRegexAddressMessageSelector. ".*") |
| 149 | + (reify |
| 150 | + OSCMessageListener |
| 151 | + (^void acceptMessage [_this ^OSCMessageEvent event] |
| 152 | + (tap> (.getMessage event)))))))) |
| 153 | + .start))) |
| 154 | + |
| 155 | +;; Next, we need a function to convert OSC messages into normalized clojure data |
| 156 | +(defn osc->map [^OSCMessage m] |
| 157 | + (let [[v & path] (map #(cond-> % (string? %) keyword) (.getArguments m))] |
| 158 | + {:value (if (= :phasors (first path)) (float (/ v 100)) v) |
| 159 | + :path path})) |
| 160 | + |
| 161 | +;; and a helper for updating our model and recomputing the notebook |
| 162 | +(defn update-model! [f] |
| 163 | + (swap! model f) |
| 164 | + (binding [*ns* (find-ns 'osc-spirograph)] |
| 165 | + (clerk/recompute!))) |
| 166 | + |
| 167 | +;; finally, a message handler to be added to tap callbacks |
| 168 | +(defn osc-message-handler [osc-message] |
| 169 | + (let [{:keys [path value]} (osc->map osc-message)] |
| 170 | + (update-model! #(assoc-in % path value)))) |
| 171 | + |
| 172 | +;; Clerk won't cache forms returning nil values, hence the do here to ensure we register our handler just once when the notebook is evaluated |
| 173 | +(do |
| 174 | + (add-tap osc-message-handler) |
| 175 | + true) |
| 176 | + |
| 177 | +;; And that's it I guess. Now, if you're looking at a static version of this notebook, you might want to clone [this repo](https://github.com/zampino/osc-spirograph), launch |
| 178 | +;; Clerk with `(nextjournal.clerk/serve! {})` and see it in action with `(nextjournal.clerk/show! "notebooks/osc_spirograph.clj")`. |
| 179 | +;; |
| 180 | +;; This project has been partly inspired by Jack Schaedler's interactive article ["SEEING CIRCLES, SINES, AND SIGNALS"](https://jackschaedler.github.io/circles-sines-signals/index.html) |
| 181 | +;; to which I refer the reader to explore the implications of Fourier analysis with digital signal processing. |
| 182 | + |
| 183 | +^{::clerk/visibility :hide ::clerk/viewer :hide-result} |
| 184 | +(comment |
| 185 | + (clerk/serve! {:port 7779}) |
| 186 | + (clerk/clear-cache!) |
| 187 | + |
| 188 | + (remove-tap osc-message-handler) |
| 189 | + |
| 190 | + (.start osc) |
| 191 | + (.isListening osc) |
| 192 | + (.stopListening osc) |
| 193 | + |
| 194 | + (update-model (fn [m] (assoc-in m [:phasors 0 :amplitude] 2.0))) |
| 195 | + (update-model (fn [m] (assoc-in m [:phasors 1 :frequency] 0.9))) |
| 196 | + |
| 197 | + ;; save nice models |
| 198 | + @model |
| 199 | + (do |
| 200 | + (reset! model |
| 201 | + #_ |
| 202 | + {:mode 0, |
| 203 | + :phasors [{:amplitude 0.4, :frequency 0.2} |
| 204 | + {:amplitude 1.0, :frequency -0.2} |
| 205 | + {:amplitude 0.4, :frequency 0.6}]} |
| 206 | + #_ {:mode 0 |
| 207 | + :phasors [{:amplitude 0.41, :frequency 0.46} |
| 208 | + {:amplitude 0.71, :frequency -0.44} |
| 209 | + {:amplitude 0.6, :frequency -0.45}]} |
| 210 | + |
| 211 | + #_ {:mode 0, |
| 212 | + :phasors [{:amplitude 0.41, :frequency 0.46} |
| 213 | + {:amplitude 0.46, :frequency -0.44} |
| 214 | + {:amplitude 1.0, :frequency -0.45}]} |
| 215 | + #_ |
| 216 | + {:mode 0 |
| 217 | + :phasors [{:amplitude 0.57, :frequency 0.39} |
| 218 | + {:amplitude 0.5, :frequency -0.27} |
| 219 | + {:amplitude 0.125, :frequency 0.27}]} |
| 220 | + |
| 221 | + #_ {:mode 0, |
| 222 | + :phasors [{:amplitude 0.72, :frequency -0.25} |
| 223 | + {:amplitude 0.59, :frequency 0.45} |
| 224 | + {:amplitude 0.52, :frequency 0.3}]} |
| 225 | + |
| 226 | + {:mode 0, |
| 227 | + :phasors [{:amplitude 0.80, :frequency 0.55} |
| 228 | + {:amplitude 0.5, :frequency -0.27} |
| 229 | + {:amplitude 0.75, :frequency 0.27}]}) |
| 230 | + (clerk/recompute!)) |
| 231 | + |
| 232 | + ;; clean |
| 233 | + (do (swap! model assoc :clean? true) |
| 234 | + (clerk/recompute!) |
| 235 | + (swap! model assoc :clean? false) |
| 236 | + (clerk/recompute!))) |
0 commit comments