Skip to content

Commit 7eafeb7

Browse files
committed
Add OSC Spirograph notebook
1 parent 21716c0 commit 7eafeb7

File tree

4 files changed

+259
-2
lines changed

4 files changed

+259
-2
lines changed

deps.edn

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
{:paths ["dev" "notebooks"]
1+
{:paths ["dev" "notebooks" "resources"]
22
:deps {io.github.nextjournal/clerk {:mvn/version "0.7.418"}
33

44
;; input various external data formats
@@ -21,7 +21,10 @@
2121
arrowic/arrowic {:mvn/version "0.1.1"}
2222

2323
;; 2D drawing routines
24-
clojure2d/clojure2d {:mvn/version "1.4.4"}}
24+
clojure2d/clojure2d {:mvn/version "1.4.4"}
25+
26+
;; OSC server
27+
com.illposed.osc/javaosc-core {:mvn/version "0.8"}}
2528

2629
:aliases
2730
{:nextjournal/clerk

notebooks/osc_spirograph.clj

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
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!)))

resources/log4j.xml

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?xml version="1.0" encoding="UTF-8" ?>
2+
<!DOCTYPE log4j:configuration SYSTEM "log4j.dtd">
3+
<log4j:configuration debug="true"
4+
xmlns:log4j='http://jakarta.apache.org/log4j/'>
5+
6+
<appender name="console" class="org.apache.log4j.ConsoleAppender">
7+
<layout class="org.apache.log4j.PatternLayout">
8+
<param name="ConversionPattern"
9+
value="%d{yyyy-MM-dd HH:mm:ss} %-5p %c{1}:%L - %m%n" />
10+
</layout>
11+
</appender>
12+
13+
<root>
14+
<level value="DEBUG" />
15+
<appender-ref ref="console" />
16+
</root>
17+
18+
</log4j:configuration>

resources/spirograph.png

272 KB
Loading

0 commit comments

Comments
 (0)