synthesizer-core-0.6: Audio signal processing coded in Haskell: Low level part

Safe HaskellNone



Deprecated: do not import that module, it is only intended for demonstration

In this module we demonstrate techniques for getting sound in real-time. Getting real-time performance is mostly an issue of the right signal data structure. However, there is no one-size-fits-all data structure. For choosing the right one, you need to understand how various data structures work, what are their strengths and what are their weaknesses.



play :: T Double -> IO ExitCodeSource

First, we define a play routine for lazy storable vectors. Storable lazy vectors are lazy lists of low-level arrays. They are both efficient in time and memory consumption, but the blocks disallow feedback by small delays. Elements of a storable vector must be of type class Storable. This means that elements must have fixed size and advanced data types like functions cannot be used.

oscillator :: IO ExitCodeSource

Here is a simple oscillator generated as lazy storable vector. An oscillator is a signal generator, that is it produces a signal without consuming other signals that correspond in time. Signal generators have the maximal block size as parameter. This is the lower limit of possible feedback delays.

write :: FilePath -> T Double -> IO ExitCodeSource

A routine just for the case that we want to post-process a signal somewhere else.

brass :: IO ExitCodeSource

The simple brass sound demonstrates how to generate piecewise defined curves. Some infix operators are used in order to make the pieces fit in height. There are also operators for intended jumps.

filterSawSig :: (Write sig Double, Transform sig (Result Double), Transform sig (Parameter Double)) => sig DoubleSource

We rewrite the filter example filterSaw in terms of type classes for more signal types. The constraints become quite large because we must assert, that a particular sample type can be used in the addressed signal type.

filterSaw :: IO ExitCodeSource

Here we instantiate filterSawSig for storable vectors and play it. This means that all operations convert a storable vector into another storable vector. While every single operation probably is as efficient as possible, the composition of all those processes could be more efficient. So keep on reading.

playState :: T Double -> IO ExitCodeSource

The next signal type we want to consider is the stateful signal generator. It is not a common data structure, where the sample values are materialized. Instead it is a description of how to generate sample values iteratively. This is almost identical to the Data.Stream module from the stream-fusion package. With respect to laziness and restrictions of the sample type (namely none), this signal representation is equivalent to lists. You can convert one into the other in a lossless way. That is, function as sample type is possible. Combination of such signal generators is easily possible and does not require temporary storage, because this signal representation needs no sample value storage at all. However at the end of such processes, the signal must be materialized. Here we write the result into a lazy storable vector and play that. What the compiler actually does is to create a single loop, that generates the storable vector to be played in one go.

filterSawState :: IO ExitCodeSource

We demonstrate the stateful signal generator using the known filterSaw example. Actually we can reuse the code from above, because the signal generator is also an instance of the generic signal class.

filterPingStateProc :: T Double -> T DoubleSource

Merging subsequent signal processes based on signal generators into an efficient large signal processor is easy. Not storing intermediate results is however a problem in another situation: Sometimes you want to share one signal between several processes.

filterPingState :: IO ExitCodeSource

In the following example we generate an exponential curve which shall be used both as envelope and as resonance frequency control of a resonant lowpass. Actually, recomputing an exponential curve is not an issue, since it only needs one multiplication per sample. But it is simple enough to demonstrate the problem and its solutions. The expression let env = exponential2 50000 1 fools the reader of the program, since the env that is shared, is only the signal generator, that is, the description of how to compute the exponential curve successively. That is wherever a signal process reads env, it is computed again.

filterPingShare :: IO ExitCodeSource

You can achieve sharing by a very simple way. You can write the result of the signal generator in a list (toList) and use this list as source for a new generator (fromList). fromList provides a signal generator that generates new sample values by delivering the next sample from the list.

In a real world implementation you would move the Sig.fromList . Sig.toList to filterPingStateProc, since the caller cannot know, that this function uses the signal twice, and the implementor of filterPingStateProc cannot know, how expensive the computation of env is.

You can use any other signal type for sharing, e.g. storable vectors, but whatever type you choose, you also get its disadvantages. Namely, storable vectors only work for storable samples and lists are generally slow, and they also cannot be optimized away, since this only works, when no sharing is required.

Whenever a signal is shared as input between several signal processes, the actual materialized data is that between the slowest and the fastest reading process. This is due to lazy evaluation and garbage collection. If the different readers read with different speed, then you will certainly need a temporary sample storage.

filterPingCausal :: IO ExitCodeSource

It is however not uncommon that all readers read with the same speed. In this case we would in principle only need to share the input signal per sample. This way we would not need a data structure for storing a sub-sequence of samples temporarily. But how to do that practically?

The solution is not to think in terms of signals and signal processors, e.g. Sig.T a and Sig.T a -> Sig.T b -> Sig.T c, respectively, but in terms of signal processors, that are guaranteed to run in sync. That is we must assert that signal processors process the samples in chronological order and emit one sample per input sample. We call such processes "causal" processes. For example Causal.T (a,b) c represents the function Sig.T (a,b) -> Sig.T c but it also carries the guarantee, that for each input of type (a,b) one sample of type c is emitted or the output terminates. Internally it is the Kleisli arrow of the StateT Maybe monad.

Another important application of the Causal arrow is feedback. Using causal processes guarantees, that a process cannot read ahead, such that it runs into future data, which does still not exist due to recursion.

Programming with arrows needs a bit experience or Haskell extensions. Haskell extensions are either an Arrow syntax preprocessor or the preprocessor that is built into GHC. However, for computing with physical dimensions you can no longer use the original Arrow class and thus you cannot use the arrow syntax. So here is an example of how to program filterPingShare using Arrow combinators.