Simple pseudo-random number generator

Clojure comes with a fine set of functions for generating random numbers, but in the context of music programming, being able to seed the number generator to produce random numbers in a predictable series is critical. There are some capable libraries to solve this, but they mostly use the same interface for reading numbers (lazy sequence) and don't guarantee consistency across host platforms.

A recent exploration of rytmic and melodic patterns as functions of time, inspired by TidalCycles, presented the need for a pseudo-random number generator (PRNG) that takes a seed and the current time, measured on an infinite scale of "cycles", to produce a value. We need to be able to retrieve a random number at any resolution of this time scale, and the result should feel random and be somewhat uniformly distributed.

Intuition for randomness

To get a better feeling for what a "somewhat uniform" distribution looks like, let's first see how Clojure's built-in random number generator performs. Below is the result of generating 1000 random numbers with clojure.core/rand and sorting them by the first decimal. As you can see if you click "Regenerate" multiple times, the ratios change a bit but always remains in the 7-13% range. And if we 10x the sample size, the ratios get closer to an even 10%.

Our version

Most examples of PRNGs that produce a series of random numbers use a method of bitshifting a continuously mutated state, or multiple. With my hard requirement of having no mutable state, making it a pure function of the seed and time parameters, I instead came up with a version that combines the two parameters and applies a few arithmetic operations to get a predictable random number.

(defn custom-rand [seed t]
  (-> (inc t)
      (+ seed)
      (* 17.1737)
      (mod 10)
      (/ 10)))

The "magic numbers" here were just made up on the spot, once and never changed. Seven feels like the most uneven number below ten, so I intuitively used that a lot. The time is increased by one so that multiplication operations always have some effect even though time starts at 0. Let's look at the result.

Quite good distribution, comparable to clojure.core/rand above. Performance-wise it's about 2-3x slower, which is acceptable for this exercise, but I might want to revisit that topic in the future.

Published: 2024-08-10