summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--src/unheard/cycles.clj111
-rw-r--r--test/unheard/cycles_test.clj65
2 files changed, 176 insertions, 0 deletions
diff --git a/src/unheard/cycles.clj b/src/unheard/cycles.clj
new file mode 100644
index 0000000..513b090
--- /dev/null
+++ b/src/unheard/cycles.clj
@@ -0,0 +1,111 @@
+(ns unheard.cycles)
+
+(defn l
+ "List combinator: subdivides time evenly among children, advancing in lockstep.
+
+ Each child receives an equal portion of the parent's time duration.
+ All children advance synchronously through their patterns.
+
+ Equivalent to TidalCycles/Strudel 'fastcat' operator.
+
+ Example:
+ (l :a :b :c) with cycle-length 1
+ => [[0 1/3 :a] [1/3 2/3 :b] [2/3 1 :c]]"
+ [& args]
+ {:v (vec args) :type :l})
+
+(defn f
+ "Fork combinator: cycles through children sequentially across iterations.
+
+ Each iteration selects one child in round-robin fashion.
+ The selected child gets the full time duration for that iteration.
+ Forks extend the total pattern duration by (num-children × child-cycles).
+
+ Equivalent to TidalCycles/Strudel 'slowcat' operator.
+
+ Example:
+ (f :a :b :c) with cycle-length 1
+ => [[0 1 :a] [1 2 :b] [2 3 :c]]"
+ [& args]
+ {:v (vec args) :type :f})
+
+(defn scalar? [x]
+ (not (and (map? x) (:type x))))
+
+(defn gcd [a b]
+ (if (zero? b) a (recur b (mod a b))))
+
+(defn lcm [a b]
+ (/ (* a b) (gcd a b)))
+
+(defn compute-cycle [node]
+ (cond
+ (scalar? node) 1
+
+ (= :f (:type node))
+ (let [children (:v node)
+ n (count children)]
+ (* n (reduce lcm 1 (map compute-cycle children))))
+
+ (= :l (:type node))
+ (let [children (:v node)]
+ (reduce lcm 1 (map compute-cycle children)))))
+
+(defn unfold-node [node start end iteration]
+ (let [duration (- end start)]
+ (cond
+ (scalar? node)
+ [[start end node]]
+
+ (= :l (:type node))
+ (let [children (:v node)
+ n (count children)
+ slice-size (/ duration n)]
+ (mapcat (fn [i child]
+ (let [child-start (+ start (* i slice-size))
+ child-end (+ start (* (inc i) slice-size))]
+ (unfold-node child child-start child-end iteration)))
+ (range n)
+ children))
+
+ (= :f (:type node))
+ (let [children (:v node)
+ n (count children)
+ child-idx (mod iteration n)]
+ (unfold-node (nth children child-idx) start end (quot iteration n))))))
+
+(defn unfold
+ "Unfolds a pattern tree into concrete time intervals.
+
+ Takes a cycle-length and a pattern node (scalar, list, or fork),
+ and returns a vector of [start end value] intervals representing
+ when each scalar value is active.
+
+ Args:
+ cycle-length - Duration of each iteration (can be any number)
+ node - Pattern tree built from scalars, (l ...), and (f ...)
+
+ Returns:
+ Vector of [start end value] tuples, where start and end are rational
+ numbers representing time positions.
+
+ The total duration of the result is (* cycle-length (compute-cycle node)).
+
+ Examples:
+ (unfold 1 :a)
+ => [[0 1 :a]]
+
+ (unfold 1 (l :a :b))
+ => [[0 1/2 :a] [1/2 1 :b]]
+
+ (unfold 1 (f :a :b))
+ => [[0 1 :a] [1 2 :b]]"
+ [cycle-length node]
+ (let [cycle-count (compute-cycle node)]
+ (vec (mapcat (fn [i]
+ (unfold-node node
+ (* i cycle-length)
+ (* (inc i) cycle-length)
+ i))
+ (range cycle-count)))))
+
diff --git a/test/unheard/cycles_test.clj b/test/unheard/cycles_test.clj
new file mode 100644
index 0000000..b912895
--- /dev/null
+++ b/test/unheard/cycles_test.clj
@@ -0,0 +1,65 @@
+(ns unheard.cycles-test
+ (:require [clojure.test :refer [deftest is testing]]
+ [unheard.cycles :refer [l f unfold]]))
+
+(deftest unfold-tests
+ (testing "single scalar"
+ (is (= [[0 1 :a]]
+ (unfold 1 :a))))
+
+ (testing "simple list - time subdivision"
+ (is (= [[0 1/3 :a] [1/3 2/3 :b] [2/3 1 :c]]
+ (unfold 1 (l :a :b :c)))))
+
+ (testing "simple fork - cycles through children"
+ (is (= [[0 1 :a]
+ [1 2 :b]
+ [2 3 :c]]
+ (unfold 1 (f :a :b :c)))))
+
+ (testing "list with fork - fork gets time slice"
+ (is (= [[0 1/2 :1] [1/2 1 :2]
+ [1 3/2 :1] [3/2 2 :3]]
+ (unfold 1 (l :1 (f :2 :3))))))
+
+ (testing "fork with list - each child subdivides its cycle"
+ (is (= [[0 1/2 :a] [1/2 1 :b]
+ [1 2 :c]]
+ (unfold 1 (f (l :a :b) :c)))))
+
+ (testing "nested lists"
+ (is (= [[0 1/2 :a] [1/2 3/4 :b] [3/4 1 :c]]
+ (unfold 1 (l :a (l :b :c))))))
+
+ (testing "nested forks"
+ (is (= [[0 1 :a]
+ [1 2 :b]
+ [2 3 :c]
+ [3 4 :d]
+ [4 5 :a]
+ [5 6 :e]
+ [6 7 :c]
+ [7 8 :d]]
+ (unfold 1 (f :a (f :b :e) :c :d)))))
+
+ (testing "complex nested structure from example"
+ (is (= [[0 1/2 :1] [1/2 3/4 :2] [3/4 1 :9] [1 3/2 :6] [3/2 2 :7]
+ [2 5/2 :1] [5/2 3 :3] [3 7/2 :6] [7/2 4 :8]
+ [4 9/2 :1] [9/2 5 :4] [5 11/2 :6] [11/2 6 :7]
+ [6 13/2 :1] [13/2 27/4 :2] [27/4 7 :9] [7 15/2 :6] [15/2 8 :8]
+ [8 17/2 :1] [17/2 9 :3] [9 19/2 :6] [19/2 10 :7]
+ [10 21/2 :1] [21/2 11 :5] [11 23/2 :6] [23/2 12 :8]]
+ (unfold 2 (l :1 (f (l :2 :9) :3 (f :4 :5)) :6 (f :7 :8))))))
+
+ (testing "different cycle lengths"
+ (is (= [[0 1/2 :a] [1/2 1 :b]]
+ (unfold 1 (l :a :b))))
+ (is (= [[0 1 :a] [1 2 :b]]
+ (unfold 2 (l :a :b))))
+ (is (= [[0 5/2 :a] [5/2 5 :b]]
+ (unfold 5 (l :a :b)))))
+
+ (testing "fork with nested list subdivides correctly"
+ (is (= [[0 1/3 :a] [1/3 2/3 :b] [2/3 1 :c]
+ [1 2 :x]]
+ (unfold 1 (f (l :a :b :c) :x))))))