summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorJake Zerrer <him@jakezerrer.com>2025-11-14 16:45:58 -0500
committerJake Zerrer <him@jakezerrer.com>2025-11-17 15:17:39 -0500
commitc28969a3fbeb10f3aae89c6e1829e4540114f31d (patch)
tree244e3a054088c920767c3d5c9621a5a6e6961ba5
parentb60b17e425b20d0fd0740f6e40fe47062c566a02 (diff)
Damn, I think timeline may even be mostly working, sans one bug
-rw-r--r--dev/scratch.clj38
-rw-r--r--src/unheard/time_object.clj161
2 files changed, 112 insertions, 87 deletions
diff --git a/dev/scratch.clj b/dev/scratch.clj
index 53267f9..d7d0da7 100644
--- a/dev/scratch.clj
+++ b/dev/scratch.clj
@@ -19,32 +19,32 @@
(note >c 0 32 (m/latest #(+ % 7) >tonic))))
(defn song
- [{:keys [clock tonic]}]
+ [{:keys [>clock >tonic]}]
(poly
- (triad clock tonic)
+ (triad >clock >tonic)
#_
- (triad clock (m/latest #(+ % 12) tonic))
+ (triad >clock (m/latest #(+ % 12) tonic))
;; The rest of the "song" is a drum pattern.
- (note clock 1 1 (m/ap kick))
- (note clock 9 1 (m/ap kick))
- (note clock 17 1 (m/ap kick))
- (note clock 25 1 (m/ap kick))
+ (note >clock 1 1 (m/ap kick))
+ (note >clock 9 1 (m/ap kick))
+ (note >clock 17 1 (m/ap kick))
+ (note >clock 25 1 (m/ap kick))
- (note clock 1 1 (m/ap hat))
- (note clock 5 1 (m/ap hat))
- (note clock 9 1 (m/ap hat))
- (note clock 13 1 (m/ap hat))
- (note clock 17 1 (m/ap hat))
- (note clock 21 1 (m/ap hat))
- (note clock 25 1 (m/ap hat))
- (note clock 29 1 (m/ap hat))
+ (note >clock 1 1 (m/ap hat))
+ (note >clock 5 1 (m/ap hat))
+ (note >clock 9 1 (m/ap hat))
+ (note >clock 13 1 (m/ap hat))
+ (note >clock 17 1 (m/ap hat))
+ (note >clock 21 1 (m/ap hat))
+ (note >clock 25 1 (m/ap hat))
+ (note >clock 29 1 (m/ap hat))
- (note clock 5 1 (m/ap snare))
- (note clock 13 1 (m/ap snare))
- (note clock 21 1 (m/ap snare))
- (note clock 29 1 (m/ap snare))))
+ (note >clock 5 1 (m/ap snare))
+ (note >clock 13 1 (m/ap snare))
+ (note >clock 21 1 (m/ap snare))
+ (note >clock 29 1 (m/ap snare))))
;; TODO: Move into /dev
;; Add logging
diff --git a/src/unheard/time_object.clj b/src/unheard/time_object.clj
index 3556321..50deabc 100644
--- a/src/unheard/time_object.clj
+++ b/src/unheard/time_object.clj
@@ -2,32 +2,36 @@
(:require [missionary.core :as m]
[helins.interval.map :as imap]))
-;; DESIGN
-;; A "time object" is any object with a lifetime that is temporally bounded. The
-;; goal of the time object abstraction is to allow for for efficient,
-;; low-latency allocation and deallocation of an unlimited number of time
-;; objects.
+;; A "time object" is any object with a lifetime that is temporally bounded.
+;; A musical note that is part of a greater composition is the canonical example:
+;; it exists between a start time and an end time, but doesn't exist otherwise.
;;
-;;It is important that a time tree can be efficiently queried by both a range
-;;(for UI) and a point (for a song).
+;; A complex musical composition might consist of tens of thousands of these
+;; time objects. However, during playback, only a small subset of all time
+;; objects are computationally relevant. That subset is any time objects
+;; whose timer interval overlaps with "now".
;;
-;; IMPLEMENTATION
-;; Time objects are stored in an interval tree.
-
-;; Requirements
-;; - time objects returned by a timeline range query will include metadata like
-;; start time and end time
-;; - Flows associated with time objects should only mount or dismount when
-;; the result of the query changes. I think the best way to accomplish this
-;; is to have the query return a flow of time objects, and then have some
-;; separate function responsible for "playing" these time objects.
-;;
-;; Question:
-;; Should time objects take an interval tree as an argument,
-;; or should they return a flow of interval information (start, end, value)
-;; that can be fed into some kind of reactive interval map bookkeeper?
-;; I think the latter.
+;; For example, imagine a musical composition with three notes:
+;;
+;; [note start-time end-time]
+;;
+;; - [:a 0 10]
+;; - [:b 5 15]
+;; - [:c 20 25]
+;;
+;; At time 2, :a is computationally relevant.
+;; At time 7, :a and :b are computationally relevant.
+;; At time 17, nothing is computationally relevant.
+;;
+;; This namespace wraps the helins.interval.map data structure in a small
+;; collection of functions producting missionary flows, building what amounts
+;; to a reactive interval tree. Library users:
;;
+;; - Create time objects using the `time-object` function.
+;; - Combine time objects with `time-object-collection`.
+;; - Combine time object collections with `merge-time-object-collection`.
+;; - Instantiate a reactive interval tree with `timeline`.
+;; - Query the timeline with `point-query` and `range-query`.
(def id-counter (atom 0))
@@ -36,36 +40,30 @@
Value is a flow that will be consumed when the corresponding
time tree is consumed at a point in time within the time-object's
interval."
- [>start >end >metadata >flow]
+ ;; NOTE: Might want to replace >flow with something more general like value.
+ ;; While it's true that in my usecase, I will ultimately end up collecting
+ ;; and booting flows from all time objects returned by `point-query`,
+ ;; that is kind of a separate concern.
+ [>range >metadata >flow]
(m/ap
- (let [id (swap! id-counter inc)]
+ (let [id (swap! id-counter inc)
+ v {:id id
+ :range >range
+ :metadata >metadata
+ :flow >flow}]
(m/amb=
- [id
- :add
- {:start >start
- :end >end
- :metadata >metadata
- :flow >flow}]
+ [:add v]
(try
(m/? m/never)
- (catch missionary.Cancelled _ [id :remove]))))))
+ (catch missionary.Cancelled _ [:remove v]))))))
(comment
(def cancel
- ((m/reduce prn nil (time-object 1 2 :a (m/ap)))
+ ((m/reduce prn nil (time-object (m/ap [1 2]) :a (m/ap)))
prn prn))
(cancel))
-(defn with-final
- []
- (m/ap
- (m/amb=
- :start
- (try
- (m/? m/never)
- (catch missionary.Cancelled _ :end)))))
-
(defn merge-flows [& flows]
(m/ap
(m/?<
@@ -86,9 +84,9 @@
(def cancel
((m/reduce prn nil
(time-object-collection
- (time-object 1 2 :a (m/ap))
- (time-object 3 4 :b (m/ap))
- (time-object 5 6 :c (m/ap)))) prn prn))
+ (time-object (m/ap [1 2]) :a (m/ap))
+ (time-object (m/ap [3 4]) :b (m/ap))
+ (time-object (m/ap [5 6]) :c (m/ap)))) prn prn))
(cancel))
@@ -105,13 +103,14 @@
((m/reduce prn nil
(merge-tocs
(time-object-collection
- (time-object 1 2 :a (m/ap))
- (time-object 3 4 :b (m/ap))
- (time-object 5 6 :c (m/ap)))
+ (time-object (m/ap [1 2]) :a (m/ap))
+ (time-object (m/ap [3 4]) :b (m/ap))
+ (time-object (m/ap [5 6]) :c (m/ap)))
(time-object-collection
- (time-object 1 2 :d (m/ap))
- (time-object 3 4 :e (m/ap))
- (time-object 5 6 :f (m/ap))))) prn prn))
+ (time-object (m/ap [1 2]) :d (m/ap))
+ (time-object (m/ap [3 4]) :e (m/ap))
+ (time-object (m/ap [5 6]) :f (m/ap))))) prn prn))
+
;; Whoa! Running cancel twice cancels twice...
;; https://clojurians.slack.com/archives/CL85MBPEF/p1763154775780589?thread_ts=1763149125.436899&cid=CL85MBPEF
(cancel))
@@ -119,7 +118,48 @@
(defn timeline
"Primary timeline bookkeeping mehanism."
[time-object-collection]
- (m/ap))
+ (m/ap
+ (let [actions (m/?< time-object-collection)]
+ (loop [tree imap/empty
+ actions actions]
+ (let [[k {:keys [id range] :as v}] (first actions)]
+ (case k
+ :add
+ ;; TODO: Raise if-let up a level to remove duplication
+ (let [[s e] (m/?< range)]
+ (if-let [next (seq (rest actions))]
+ (recur (imap/mark tree s e [id v]) next)
+ tree))
+ :remove
+ (let [[s e] (m/?< range)]
+ (if-let [next (seq (rest actions))]
+ (do
+ (println "REMOVING" s e)
+ (recur (imap/erase tree s e [id v]) next))
+ tree))
+ :else
+ (if-let [next (seq (rest actions))]
+ (recur tree next)
+ tree)))))))
+
+;; TODO: Don't forget to ensure that ranges are turned into signals
+;;
+(comment
+ (def cancel
+ ((m/reduce prn nil
+ (timeline
+ (merge-tocs
+ (time-object-collection
+ (time-object (m/ap [1 2]) :a (m/ap))
+ (time-object (m/ap [3 4]) :b (m/ap))
+ (time-object (m/ap [5 6]) :c (m/ap)))
+ (time-object-collection
+ (time-object (m/ap [1 2]) :d (m/ap))
+ (time-object (m/ap [3 4]) :e (m/ap))
+ (time-object (m/ap [5 6]) :f (m/ap)))))) prn prn))
+
+ ;; NOTE: Cancellation is currently broken due to the above bug in merge-tocs
+ (cancel))
(defn point-query
"Query a timeline. Returns a flow of time objects."
@@ -131,22 +171,7 @@
(defn range-query
"Range query. Returns a flow of time objects."
[timeline >range])
+
(defn run
"Runs the flows associated with a collection of time objects."
- ;; TODO: "Running" a time object has different meanings for different objects.
- ;; How should I think about that?
[>query-result])
-
-;; How should a time tree work?
-;; Well, we eventually want to end up with a single time tree
-;; Is it important to be able to merge time trees together?
-;; Part of me thinks "yes"
-;; Time objects cannot be composed together - that is, you can't
-;; take two time objects and add them together to get a new time object
-;; However, you _can_ add two time objects together to get a time tree
-;; The alternative to having intermediate time trees would be to have
-;; some kind of time-object-collection abstraction.
-;; time-object-collection would have a merge function.
-;; A time object collection would be responsible for all of the bookkeeping
-;; related to the lifetimes of time objects.
-;; My suspicion is that this will