<commit_before>(ns
  ^{:doc "An interface to the SuperCollider synthesis server.
          This is at heart an OSC client library for the SuperCollider
          scsynth DSP engine."
     :author "<NAME>"}
  overtone.sc.core
  (:import
    (java.net InetSocketAddress)
    (java.util.regex Pattern)
    (java.util.concurrent TimeUnit TimeoutException)
    (java.io BufferedInputStream)
    (supercollider ScSynth ScSynthStartedListener MessageReceivedListener)
    (java.util BitSet))
  (:require [overtone.log :as log])
  (:use
    [overtone event config setup util time-utils]
    [overtone.sc synthdef boot]
    [clojure.contrib.java-utils :only [file]]
    [clojure.contrib pprint]
    osc))

; TODO: Make this work correctly
; NOTE: "localhost" doesn't work, at least on my laptopt
(defonce SERVER-HOST "127.0.0.1")
(defonce SERVER-PORT nil) ; nil means a random port

; Max number of milliseconds to wait for a reply from the server
(defonce REPLY-TIMEOUT 500)

; Server limits
(defonce MAX-NODES 1024)
(defonce MAX-BUFFERS 1024)
(defonce MAX-SDEFS 1024)
(defonce MAX-AUDIO-BUS 128)
(defonce MAX-CONTROL-BUS 4096)
(defonce MAX-OSC-SAMPLES 8192)

; We use bit sets to store the allocation state of resources on the audio server.
; These typically get allocated on usage by the client, and then freed either by
; client request or automatically by receiving notifications from the server.
; (e.g. When an envelope trigger fires to free a synth.)
(defonce allocator-bits
  {:node         (BitSet. MAX-NODES)
   :audio-buffer (BitSet. MAX-BUFFERS)
   :audio-bus    (BitSet. MAX-NODES)
   :control-bus  (BitSet. MAX-NODES)})

(defonce allocator-limits
  {:node        MAX-NODES
   :sdefs       MAX-SDEFS
   :audio-bus   MAX-AUDIO-BUS
   :control-bus MAX-CONTROL-BUS})

(defn alloc-id
  "Allocate a new ID for the type corresponding to key."
  [k]
  (let [bits  (get allocator-bits k)
        limit (get allocator-limits k)]
    (locking bits
      (let [id (.nextClearBit bits 0)]
        (if (= limit id)
          (throw (Exception. (str "No more " (name k) " ids available!")))
          (do
            (.set bits id)
            id))))))

; The root group is implicitly allocated
(defonce _root-group_ (alloc-id :node))

(defn free-id
  "Free the id of type key."
  [k id]
  (let [bits (get allocator-bits k)
        limit (get allocator-limits k)]
    (locking bits
      (.clear bits id))))

(defn all-ids
  "Get all of the currently allocated ids for key."
  [k]
  (let [bits (get allocator-bits k)
        limit (get allocator-limits k)]
    (locking bits
      (loop [ids []
             idx 0]
        (let [id (.nextSetBit bits idx)]
          (if (and (> id -1) (< idx limit))
            (recur (conj ids id) (inc id))
            ids))))))

(defn clear-ids
  "Clear all ids allocated for key."
  [k]
  (println "clear-ids........................")
  (locking (get allocator-bits k)
    (doseq [id (all-ids k)]
      (free-id k id))))

(defonce ROOT-GROUP 0)
(defonce synth-group* (ref nil))

(declare group)
(on-sync-event :connected ::root-group-creator
  #(dosync (ref-set synth-group* (group :head ROOT-GROUP))))

(declare boot)

; The base handler for receiving osc messages just forwards the message on
; as an event using the osc path as the event key.
(on-sync-event :osc-msg-received ::osc-receiver
               (fn [{{path :path args :args} :msg}]
                 (event path :path path :args args)))

(defmacro at
  "All messages sent within the body will be sent in the same timestamped OSC
  bundle.  This bundling is thread-local, so you don't have to worry about
  accidentally scheduling packets into a bundle started on another thread."
  [time-ms & body]
  `(in-osc-bundle @server* ~time-ms (do ~@body)))

(defn connected? []
  (= :connected @status*))

(defn snd
  "Sends an OSC message."
  [path & args]
  (log/debug "(snd " path args ")")
  (if (connected?)
    (apply osc-send @server* path args)
    (log/debug "### trying to snd while disconnected! ###")))

(defn debug
  "Control debug output from both the Overtone and the audio server."
  [& [on-off]]
  (if (or on-off (nil? on-off))
    (do
      (log/level :debug)
      (osc-debug true)
      (snd "/dumpOSC" 1))
    (do
      (log/level :error)
      (osc-debug false)
      (snd "/dumpOSC" 0))))

; Trigger Notifications
;
; This command is the mechanism that synths can use to trigger events in
; clients.  The node ID is the node that is sending the trigger. The trigger ID
; and value are determined by inputs to the SendTrig unit generator which is
; the originator of this message.
;
; /tr a trigger message
;
;   int - node ID
;   int - trigger ID
;   float - trigger value
(defonce trigger-handlers* {})

(defn on-trigger [node-id trig-id f]
  (dosync (alter trigger-handlers* assoc [node-id trig-id] f)))

(defn remove-trigger [node-id trig-id]
  (dosync (alter trigger-handlers* dissoc [node-id trig-id])))

(on-event "/tr" ::trig-handler
  (fn [msg]
    (let [[node-id trig-id value] (:args msg)
          handler (get @trigger-handlers* [node-id trig-id])]
      (handler node-id trig-id value))))

(defn- node-destroyed
  "Frees up a synth node to keep in sync with the server."
  [id]
  (log/debug (format "node-destroyed: %d" id))
  (free-id :node id))

(defn- node-created
  "Called when a node is created on the synth."
  [id]
  (log/debug (format "node-created: %d" id)))

; Setup the feedback handlers with the audio server.
(on-event "/n_end" ::node-destroyer #(node-destroyed (first (:args %))))
(on-event "/n_go" ::node-creator #(node-created (first (:args %))))

(declare reset)

; TODO: setup an error-handler in the case that we can't connect to the server
(defn connect
  "Connect to an external SC audio server on the specified host and port."
  [& [host port]]
   (if (and host port)
     (.run (Thread. #(connect-external host port)))
     (connect-internal)))

(on-event :connect ::connect-handler
  (fn [event]
    (if (and (contains? event :host) (contains? event :port))
      (connect (:host event) (:port event))
      (connect))))

(defn server-log
  "Print the server log."
  []
  (doseq [msg @server-log*]
    (print msg)))

;Replies to sender with the following message.
;status.reply
;	int - 1. unused.
;	int - number of unit generators.
;	int - number of synths.
;	int - number of groups.
;	int - number of loaded synth definitions.
;	float - average percent CPU usage for signal processing
;	float - peak percent CPU usage for signal processing
;	double - nominal sample rate
;	double - actual sample rate


(defn recv
  "Register your intent to wait for a message associated with given path to be received from the server. Returns a promise that will contain the message once it has been received. Does not block current thread (this only happens once you try and look inside the promise and the reply has not yet been received)."
  [path]
  (let [p (promise)]
    (on-sync-event path (uuid) #(do (deliver p %) :done))
    p))

(defn await-promise
    "Read the reply received from the server, waiting for timeout ms if the message hasn't yet been received. Returns :timeout if a timeout occurs."
    ([prom] (await-promise prom REPLY-TIMEOUT))
    ([prom timeout]
       (try
         (.get (future @prom) timeout TimeUnit/MILLISECONDS)
         (catch TimeoutException t
           :timeout))))

(defn await-promise!
    "Read the reply received from the server, waiting for timeout ms if the message hasn't yet been received. Raises an exception if the message hasn't been received within timeout ms"
    ([prom] (await-promise prom REPLY-TIMEOUT))
    ([prom timeout]
       (.get (future @prom) timeout TimeUnit/MILLISECONDS)))

(defn- parse-status [args]
  (let [[_ ugens synths groups loaded avg peak nominal actual] args]
    {:n-ugens ugens
     :n-synths synths
     :n-groups groups
     :n-loaded-synths loaded
     :avg-cpu avg
     :peak-cpu peak
     :nominal-sample-rate nominal
     :actual-sample-rate actual}))

(def STATUS-TIMEOUT 500)

(defn status
  "Check the status of the audio server."
  []
  (if (= :connected @status*)
    (let [p (promise)]
      (on-event "/status.reply" ::status-check
                #(do
                   (deliver p (parse-status (:args %)))
                   :done))
      (snd "/status")
      (try
        (.get (future @p) STATUS-TIMEOUT TimeUnit/MILLISECONDS)
        (catch TimeoutException t
          :timeout)))
    @status*))

(defn wait-sync
  "Wait until the audio server has completed all asynchronous commands currently in execution."
  [& [timeout]]
  (let [sync-id (rand-int 999999)
        reply-p (recv "/synced")
        _ (snd "/sync" sync-id)
        reply (await-promise! reply-p (if timeout timeout REPLY-TIMEOUT))
        reply-id (first (:args reply))]
    (= sync-id reply-id)))

(defn boot
  "Boot either the internal or external audio server."
  ([] (boot (get @config* :server :internal) SERVER-HOST SERVER-PORT))
  ([which & [host port]]
   (let [port (if (nil? port) (+ (rand-int 50000) 2000) port)]
     (cond
       (= :internal which) (boot-internal port)
       (= :external which) (boot-external host port)))))

(defn quit
  "Quit the SuperCollider synth process."
  []
  (log/info "quiting supercollider")
  (event :quit)
  (when (connected?)
    (snd "/quit")
    (log/debug "SERVER: " @server*)
    (osc-close @server* true))
  (reset! running?* false)
  (dosync (ref-set server* nil)
    (ref-set status* :no-audio)))

; TODO: Come up with a better way to delay shutdown until all of the :quit event handlers
; have executed.  For now we just use 500ms.
(defonce _shutdown-hook (.addShutdownHook (Runtime/getRuntime) (Thread. #(do (quit) (Thread/sleep 500)))))

; Synths, Busses, Controls and Groups are all Nodes.  Groups are linked lists
; and group zero is the root of the graph.  Nodes can be added to a group in
; one of these 5 positions relative to either the full list, or a specified node.
(def POSITION
  {:head         0
   :tail         1
   :before-node  2
   :after-node   3
   :replace-node 4})

;; Sending a synth-id of -1 lets the server choose an ID
(defn node
  "Instantiate a synth node on the audio server.  Takes the synth name and a set of
  argument name/value pairs.  Optionally use :target <node/group-id> and :position <pos>
  to specify where the node should be located.  The position can be one of :head, :tail
  :before-node, :after-node, or :replace-node.

  (node \"foo\")
  (node \"foo\" :pitch 60)
  (node \"foo\" :pitch 60 :target 0)
  (node \"foo\" :pitch 60 :target 2 :position :tail)
  "
  [synth-name & args]
  (if (not (connected?))
    (throw (Exception. "Not connected to synthesis engine.  Please boot or connect.")))
  (let [id (alloc-id :node)
        argmap (apply hash-map args)
        position ((get argmap :position :tail) POSITION)
        target (get argmap :target 0)
        args (flatten (seq (-> argmap (dissoc :position) (dissoc :target))))
        args (stringify (floatify args))]
    ;(println "node " synth-name id position target args)
    (apply snd "/s_new" synth-name id position target args)
    id))

(defn node-free
  "Remove a synth node"
  [& node-ids]
  {:pre [(connected?)]}
  (apply snd "/n_free" node-ids)
  (doseq [id node-ids] (free-id :node id)))

(defn node-run
  "Start a stopped synth node."
  [node-id]
  {:pre [(connected?)]}
  (snd "/n_run" node-id 1))

(defn node-stop
  "Stop a running synth node."
  {:pre [(connected?)]}
  [node-id]
  (snd "/n_run" node-id 0))

(defn node-place
  "Place a node :before or :after another node."
  [node-id position target-id]
  {:pre [(connected?)]}
  (cond
    (= :before position) (snd "/n_before" node-id target-id)
    (= :after  position) (snd "/n_after" node-id target-id)))

(defn node-control
  "Set control values for a node."
  [node-id & name-values]
  {:pre [(connected?)]}
  (apply snd "/n_set" node-id (stringify name-values))
  node-id)

; This can be extended to support setting multiple ranges at once if necessary...
(defn node-control-range
  "Set a range of controls all at once, or if node-id is a group control
  all nodes in the group."
  [node-id ctl-start & ctl-vals]
  {:pre [(connected?)]}
  (apply snd "/n_setn" node-id ctl-start (count ctl-vals) ctl-vals))

(defn node-map-controls
  "Connect a node's controls to a control bus."
  [node-id & names-busses]
  {:pre [(connected?)]}
  (apply snd "/n_map" node-id names-busses))

(defn group
  "Create a new group as a child of the target group."
  [position target-id]<commit_msg>Fixed bug where numeric args to the ctl method weren't converted to floats.<commit_after>(ns
  ^{:doc "An interface to the SuperCollider synthesis server.
          This is at heart an OSC client library for the SuperCollider
          scsynth DSP engine."
     :author "<NAME>"}
  overtone.sc.core
  (:import
    (java.net InetSocketAddress)
    (java.util.regex Pattern)
    (java.util.concurrent TimeUnit TimeoutException)
    (java.io BufferedInputStream)
    (supercollider ScSynth ScSynthStartedListener MessageReceivedListener)
    (java.util BitSet))
  (:require [overtone.log :as log])
  (:use
    [overtone event config setup util time-utils]
    [overtone.sc synthdef boot]
    [clojure.contrib.java-utils :only [file]]
    [clojure.contrib pprint]
    osc))

; TODO: Make this work correctly
; NOTE: "localhost" doesn't work, at least on my laptopt
(defonce SERVER-HOST "127.0.0.1")
(defonce SERVER-PORT nil) ; nil means a random port

; Max number of milliseconds to wait for a reply from the server
(defonce REPLY-TIMEOUT 500)

; Server limits
(defonce MAX-NODES 1024)
(defonce MAX-BUFFERS 1024)
(defonce MAX-SDEFS 1024)
(defonce MAX-AUDIO-BUS 128)
(defonce MAX-CONTROL-BUS 4096)
(defonce MAX-OSC-SAMPLES 8192)

; We use bit sets to store the allocation state of resources on the audio server.
; These typically get allocated on usage by the client, and then freed either by
; client request or automatically by receiving notifications from the server.
; (e.g. When an envelope trigger fires to free a synth.)
(defonce allocator-bits
  {:node         (BitSet. MAX-NODES)
   :audio-buffer (BitSet. MAX-BUFFERS)
   :audio-bus    (BitSet. MAX-NODES)
   :control-bus  (BitSet. MAX-NODES)})

(defonce allocator-limits
  {:node        MAX-NODES
   :sdefs       MAX-SDEFS
   :audio-bus   MAX-AUDIO-BUS
   :control-bus MAX-CONTROL-BUS})

(defn alloc-id
  "Allocate a new ID for the type corresponding to key."
  [k]
  (let [bits  (get allocator-bits k)
        limit (get allocator-limits k)]
    (locking bits
      (let [id (.nextClearBit bits 0)]
        (if (= limit id)
          (throw (Exception. (str "No more " (name k) " ids available!")))
          (do
            (.set bits id)
            id))))))

; The root group is implicitly allocated
(defonce _root-group_ (alloc-id :node))

(defn free-id
  "Free the id of type key."
  [k id]
  (let [bits (get allocator-bits k)
        limit (get allocator-limits k)]
    (locking bits
      (.clear bits id))))

(defn all-ids
  "Get all of the currently allocated ids for key."
  [k]
  (let [bits (get allocator-bits k)
        limit (get allocator-limits k)]
    (locking bits
      (loop [ids []
             idx 0]
        (let [id (.nextSetBit bits idx)]
          (if (and (> id -1) (< idx limit))
            (recur (conj ids id) (inc id))
            ids))))))

(defn clear-ids
  "Clear all ids allocated for key."
  [k]
  (println "clear-ids........................")
  (locking (get allocator-bits k)
    (doseq [id (all-ids k)]
      (free-id k id))))

(defonce ROOT-GROUP 0)
(defonce synth-group* (ref nil))

(declare group)
(on-sync-event :connected ::root-group-creator
  #(dosync (ref-set synth-group* (group :head ROOT-GROUP))))

(declare boot)

; The base handler for receiving osc messages just forwards the message on
; as an event using the osc path as the event key.
(on-sync-event :osc-msg-received ::osc-receiver
               (fn [{{path :path args :args} :msg}]
                 (event path :path path :args args)))

(defmacro at
  "All messages sent within the body will be sent in the same timestamped OSC
  bundle.  This bundling is thread-local, so you don't have to worry about
  accidentally scheduling packets into a bundle started on another thread."
  [time-ms & body]
  `(in-osc-bundle @server* ~time-ms (do ~@body)))

(defn connected? []
  (= :connected @status*))

(defn snd
  "Sends an OSC message."
  [path & args]
  (log/debug "(snd " path args ")")
  (if (connected?)
    (apply osc-send @server* path args)
    (log/debug "### trying to snd while disconnected! ###")))

(defn debug
  "Control debug output from both the Overtone and the audio server."
  [& [on-off]]
  (if (or on-off (nil? on-off))
    (do
      (log/level :debug)
      (osc-debug true)
      (snd "/dumpOSC" 1))
    (do
      (log/level :error)
      (osc-debug false)
      (snd "/dumpOSC" 0))))

; Trigger Notifications
;
; This command is the mechanism that synths can use to trigger events in
; clients.  The node ID is the node that is sending the trigger. The trigger ID
; and value are determined by inputs to the SendTrig unit generator which is
; the originator of this message.
;
; /tr a trigger message
;
;   int - node ID
;   int - trigger ID
;   float - trigger value
(defonce trigger-handlers* {})

(defn on-trigger [node-id trig-id f]
  (dosync (alter trigger-handlers* assoc [node-id trig-id] f)))

(defn remove-trigger [node-id trig-id]
  (dosync (alter trigger-handlers* dissoc [node-id trig-id])))

(on-event "/tr" ::trig-handler
  (fn [msg]
    (let [[node-id trig-id value] (:args msg)
          handler (get @trigger-handlers* [node-id trig-id])]
      (handler node-id trig-id value))))

(defn- node-destroyed
  "Frees up a synth node to keep in sync with the server."
  [id]
  (log/debug (format "node-destroyed: %d" id))
  (free-id :node id))

(defn- node-created
  "Called when a node is created on the synth."
  [id]
  (log/debug (format "node-created: %d" id)))

; Setup the feedback handlers with the audio server.
(on-event "/n_end" ::node-destroyer #(node-destroyed (first (:args %))))
(on-event "/n_go" ::node-creator #(node-created (first (:args %))))

(declare reset)

; TODO: setup an error-handler in the case that we can't connect to the server
(defn connect
  "Connect to an external SC audio server on the specified host and port."
  [& [host port]]
   (if (and host port)
     (.run (Thread. #(connect-external host port)))
     (connect-internal)))

(on-event :connect ::connect-handler
  (fn [event]
    (if (and (contains? event :host) (contains? event :port))
      (connect (:host event) (:port event))
      (connect))))

(defn server-log
  "Print the server log."
  []
  (doseq [msg @server-log*]
    (print msg)))

;Replies to sender with the following message.
;status.reply
;	int - 1. unused.
;	int - number of unit generators.
;	int - number of synths.
;	int - number of groups.
;	int - number of loaded synth definitions.
;	float - average percent CPU usage for signal processing
;	float - peak percent CPU usage for signal processing
;	double - nominal sample rate
;	double - actual sample rate


(defn recv
  "Register your intent to wait for a message associated with given path to be received from the server. Returns a promise that will contain the message once it has been received. Does not block current thread (this only happens once you try and look inside the promise and the reply has not yet been received)."
  [path]
  (let [p (promise)]
    (on-sync-event path (uuid) #(do (deliver p %) :done))
    p))

(defn await-promise
    "Read the reply received from the server, waiting for timeout ms if the message hasn't yet been received. Returns :timeout if a timeout occurs."
    ([prom] (await-promise prom REPLY-TIMEOUT))
    ([prom timeout]
       (try
         (.get (future @prom) timeout TimeUnit/MILLISECONDS)
         (catch TimeoutException t
           :timeout))))

(defn await-promise!
    "Read the reply received from the server, waiting for timeout ms if the message hasn't yet been received. Raises an exception if the message hasn't been received within timeout ms"
    ([prom] (await-promise prom REPLY-TIMEOUT))
    ([prom timeout]
       (.get (future @prom) timeout TimeUnit/MILLISECONDS)))

(defn- parse-status [args]
  (let [[_ ugens synths groups loaded avg peak nominal actual] args]
    {:n-ugens ugens
     :n-synths synths
     :n-groups groups
     :n-loaded-synths loaded
     :avg-cpu avg
     :peak-cpu peak
     :nominal-sample-rate nominal
     :actual-sample-rate actual}))

(def STATUS-TIMEOUT 500)

(defn status
  "Check the status of the audio server."
  []
  (if (= :connected @status*)
    (let [p (promise)]
      (on-event "/status.reply" ::status-check
                #(do
                   (deliver p (parse-status (:args %)))
                   :done))
      (snd "/status")
      (try
        (.get (future @p) STATUS-TIMEOUT TimeUnit/MILLISECONDS)
        (catch TimeoutException t
          :timeout)))
    @status*))

(defn wait-sync
  "Wait until the audio server has completed all asynchronous commands currently in execution."
  [& [timeout]]
  (let [sync-id (rand-int 999999)
        reply-p (recv "/synced")
        _ (snd "/sync" sync-id)
        reply (await-promise! reply-p (if timeout timeout REPLY-TIMEOUT))
        reply-id (first (:args reply))]
    (= sync-id reply-id)))

(defn boot
  "Boot either the internal or external audio server."
  ([] (boot (get @config* :server :internal) SERVER-HOST SERVER-PORT))
  ([which & [host port]]
   (let [port (if (nil? port) (+ (rand-int 50000) 2000) port)]
     (cond
       (= :internal which) (boot-internal port)
       (= :external which) (boot-external host port)))))

(defn quit
  "Quit the SuperCollider synth process."
  []
  (log/info "quiting supercollider")
  (event :quit)
  (when (connected?)
    (snd "/quit")
    (log/debug "SERVER: " @server*)
    (osc-close @server* true))
  (reset! running?* false)
  (dosync (ref-set server* nil)
    (ref-set status* :no-audio)))

; TODO: Come up with a better way to delay shutdown until all of the :quit event handlers
; have executed.  For now we just use 500ms.
(defonce _shutdown-hook (.addShutdownHook (Runtime/getRuntime) (Thread. #(do (quit) (Thread/sleep 500)))))

; Synths, Busses, Controls and Groups are all Nodes.  Groups are linked lists
; and group zero is the root of the graph.  Nodes can be added to a group in
; one of these 5 positions relative to either the full list, or a specified node.
(def POSITION
  {:head         0
   :tail         1
   :before-node  2
   :after-node   3
   :replace-node 4})

;; Sending a synth-id of -1 lets the server choose an ID
(defn node
  "Instantiate a synth node on the audio server.  Takes the synth name and a set of
  argument name/value pairs.  Optionally use :target <node/group-id> and :position <pos>
  to specify where the node should be located.  The position can be one of :head, :tail
  :before-node, :after-node, or :replace-node.

  (node \"foo\")
  (node \"foo\" :pitch 60)
  (node \"foo\" :pitch 60 :target 0)
  (node \"foo\" :pitch 60 :target 2 :position :tail)
  "
  [synth-name & args]
  (if (not (connected?))
    (throw (Exception. "Not connected to synthesis engine.  Please boot or connect.")))
  (let [id (alloc-id :node)
        argmap (apply hash-map args)
        position ((get argmap :position :tail) POSITION)
        target (get argmap :target 0)
        args (flatten (seq (-> argmap (dissoc :position) (dissoc :target))))
        args (stringify (floatify args))]
    ;(println "node " synth-name id position target args)
    (apply snd "/s_new" synth-name id position target args)
    id))

(defn node-free
  "Remove a synth node"
  [& node-ids]
  {:pre [(connected?)]}
  (apply snd "/n_free" node-ids)
  (doseq [id node-ids] (free-id :node id)))

(defn node-run
  "Start a stopped synth node."
  [node-id]
  {:pre [(connected?)]}
  (snd "/n_run" node-id 1))

(defn node-stop
  "Stop a running synth node."
  {:pre [(connected?)]}
  [node-id]
  (snd "/n_run" node-id 0))

(defn node-place
  "Place a node :before or :after another node."
  [node-id position target-id]
  {:pre [(connected?)]}
  (cond
    (= :before position) (snd "/n_before" node-id target-id)
    (= :after  position) (snd "/n_after" node-id target-id)))

(defn node-control
  "Set control values for a node."
  [node-id & name-values]
  {:pre [(connected?)]}
  (apply snd "/n_set" node-id (floatify (stringify name-values)))
  node-id)

; This can be extended to support setting multiple ranges at once if necessary...
(defn node-control-range
  "Set a range of controls all at once, or if node-id is a group control
  all nodes in the group."
  [node-id ctl-start & ctl-vals]
  {:pre [(connected?)]}
  (apply snd "/n_setn" node-id ctl-start (count ctl-vals) ctl-vals))

(defn node-map-controls
  "Connect a node's controls to a control bus."
  [node-id & names-busses]
  {:pre [(connected?)]}
  (apply snd "/n_map" node-id names-busses))

(defn group
  "Create a new group as a child of the target group."
  [position target-id]
{'fraction_non_alphanumeric': 0.12686775311790255, 'fraction_numerical': 0.006454954775471172, 'mean_word_length': 2.97812648597242, 'pattern_counts': {'":': 0, '<': 11, '<?xml version=': 0, '>': 13, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 0}, 'pii_count': 2, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '6311025', 'n_tokens_mistral': 8502, 'n_tokens_neox': 8146, 'n_words': 3096}