More field notes on audio-rate concurrency, where "concurrency" is something very different from "parallelization". This is another attempt at
My earlier proof of concept launched an audio thread and a REPL thread as siblings. It then required the REPL thread to update audio state through wonky overwriting of a specific function. It's theoretically simple, but needlessly abstract. There's no reason why the REPL can't live in the audio loop itself and have immediate access to all audio state, so long as its reads are non-blocking. I didn't do this earlier because it was easier to reach for Chicken's builtin (repl)
, but I'm doing it now.
(import (srfi 18) (chicken file posix) (chicken type))
(define-syntax λ (syntax-rules () ((_ . ω) (lambda . ω))))
(define-syntax ? (syntax-rules () ((_ . α) (if . α))))
(define-syntax ← (syntax-rules () ((_ . ω) (define . ω))))
(← □ print)
(define-type condition-variable (struct condition-variable))
(: clock (condition-variable number -> void))
(← (clock ω div)
(letrec* ((start (time->seconds (current-time)))
(sleep (/ 1 div))
(▽ (λ (n) (condition-variable-specific-set! ω n)
(condition-variable-broadcast! ω)
(thread-sleep! (seconds->time (+ start (* n sleep))))
(▽ (+ n 1)))))
(▽ 0)))
(: echo (condition-variable number -> void))
(← (echo ω n)
(letrec ((lock (make-mutex))
(▽ (λ ()
(mutex-lock! lock)
(? (= 0 (modulo (condition-variable-specific ω) n)) (print 'b!))
(mutex-unlock! lock ω)
(▽))))
(▽)))
(: pseduo-repl (condition-variable -> void))
(← (pseudo-repl ω)
(letrec* ((lock (make-mutex))
(▽ (λ () (mutex-lock! lock)
(receive (i _)
(file-select fileno/stdin #f 0)
(? i (□ (eval (read))))) ; do audio shit here
(mutex-unlock! lock ω)
(▽))))
(□ "Opening repl")
(▽)))
(: example (number --> number))
(← (example ω) (+ ω 1))
(← clock-var (make-condition-variable))
(← clock-thread (make-thread (lambda () (clock clock-var 375))))
(← echo-thread (make-thread (lambda () (echo clock-var 750))))
(← repl-thread (make-thread (lambda () (pseudo-repl clock-var))))
(thread-start! clock-thread)
(thread-start! echo-thread)
(thread-start! repl-thread)
(thread-join! repl-thread)
You may notice some other fun concepts here.
Time can be abstracted out of the main audio loop and live in its own thread. srfi-18 ensures that the following loop won't drift.
(define-type condition-variable (struct condition-variable))
(: clock (condition-variable number -> void))
(← (clock ω div)
(letrec* ((start (time->seconds (current-time)))
(sleep (/ 1 div))
(▽ (λ (n) (condition-variable-specific-set! ω n)
(condition-variable-broadcast! ω)
(thread-sleep! (seconds->time (+ start (* n sleep))))
(▽ (+ n 1)))))
(▽ 0)))
The condition variable is new here. This piece of data ω
is shared between threads. The children threads sleep against it, while the parent thread can fire off (condition-variable-broadcast!)
to wake them up. Much like (thread-specific)
, a condition variable can hold arbitrary state. After sleeping for 1÷div
seconds, the clock will broadcast its iteration n
to any other thread listening on ω
.
The audio/REPL loop can be slaved to this condition, along with anything else. This is not unlike the triggers in a modular synthesizer.
In lieu of actual audio (want to avoid boilerplate for these minimal examples) we can prove that reads aren't blocking by writing bangs of b!
to the console after every n
iterations of the master clock. This echo
loop takes the same clock condition variable of ω
, but sleeps against it instead. Every time the clock thread does a (condition-variable-broadcast!)
, this thread will wake up and check the clock's iteration count. If it's divisible by n
, the bang is sent. Think a clock divider in modular synthesis.
(: echo (condition-variable number -> void))
(← (echo ω n)
(letrec ((lock (make-mutex))
(▽ (λ ()
(mutex-lock! lock)
(? (= 0 (modulo (condition-variable-specific ω) n)) (print 'b!))
(mutex-unlock! lock ω)
(▽))))
(▽)))
Sleeping against a condition variable is enabled with a mutex, which guards against conflicting writes to shared state. The mutex is generated internally in this loop because there's no state to worry about, but if there were, the same mutex would have to be available between threads.
The POSIX file module allows this. Don't know/care about Windows. Rather than hanging indefinitely on user input with a normal (read)
, the main loop can poll stdin first, and only call (read)
if there's something there.
(receive (i _)
(file-select fileno/stdin #f 0)
(? i (□ (eval (read))))) ; do audio shit here
(file-select α ω)
returns two booleans, where α
is an input file and ω
is an output. They'll be true if something's available for reading/writing. The 0
is the timeout (instant).
(receive (α ω …) f …)
assigns the multiple returns of f
to variables α
, ω
, etc. Since only input is being polled, it's enough to ignore the second value.
If stdin is free, the arbitrary user input is REP'd. All that's left is to L.
(: pseduo-repl (condition-variable -> void))
(← (pseudo-repl ω)
(letrec* ((lock (make-mutex))
(▽ (λ () (mutex-lock! lock)
(receive (i _)
(file-select fileno/stdin #f 0)
(? i (□ (eval (read))))) ; do audio shit here
(mutex-unlock! lock ω)
(▽))))
(□ "Opening repl")
(▽)))
You can see that the rest of the code is similar to the other clock slave.
Any functions declared globally will be available to the user inside the audio loop, like (example)
here.
(: example (number --> number))
(← (example ω) (+ ω 1))
(← clock-var (make-condition-variable))
(← clock-thread (make-thread (lambda () (clock clock-var 375))))
(← echo-thread (make-thread (lambda () (echo clock-var 750))))
(← repl-thread (make-thread (lambda () (pseudo-repl clock-var))))
(thread-start! clock-thread)
(thread-start! echo-thread)
(thread-start! repl-thread)
(thread-join! repl-thread)
Running this will drop the user into a REPL, but b!
will be printed every 2 seconds, proving that input is not blocking anything. You can use Scheme as you please alongside this.
In production code, audio state may perhaps be modified by storing it in (thread-specific (current-thread))
, or maybe by providing higher order functions like previously. Not worried about this yet.
The user also has access to clock-var
, meaning it'd be possible to launch threads within this thread and simulate an actual modular synth. Try that with C.