These is my best attempt at explaining some design choices in https://hackage.haskell.org/package/csound-expression, “a library to make electronic music”, by Anton Kholomiov.
This will be like a compressed version of https://github.com/spell-music/csound-expression/blob/master/tutorial/chapters/BasicTypesTutorial.md which has lots of examples.
The basic types and type classes of c-e are:
Sig
: descrition of a signalSE a
(polymorphic): side-effecting action that produces a
Evt a
(polymorphic): a stream of events of type a
(I am ignoring Str
and Tab
)
For audio processing, the central concept is “Signal”, which is a function from time to (numerical) value. So why don’t we declare type Sig = (Time -> Double)
?
In the above, I was careful to write “description of signal”. The actual signal (the semantics of the description) is not known to the Haskell program, since it will only be realized by the csound back-end. There is no way to compute (in ghci) the value some Sig
at some specific time. What we can do, is
dac
function, which will compile the description to a csound expression, and send it to the csound server for rendering.What we can not do, is, e.g., progam a function f :: Double -> Double
in Haskell and map it over a Sig
. This would work if Sig
was an actual signal, but it is only a signal description. So, to map a function over the description of a signal, we would need a description of the function that the csound back-end understands. This works if we can write the function at a generic type f = \x -> 0.5 * x :: Fractional a => a -> a
, since we have instance Fractional Sig
and we can then just apply f
, as in f (osc 220)
.
For convenience of writing, we can use signal descriptions like numbers - sometimes. We have instance Num Sig
, so we can use operators +
and *
to combine signal descriptions. We can also write numeric literals to denote constant signals, e.g., 440 :: Sig
. This is needed, e.g., when writing osc 440
, where osc :: Sig -> Sig
expresses the fact that the frequency of this harmonic oscillation is given by the argument signal.
This allows to write osc (220 * exp (osc 1))
, where the outer osc
denotes a VCO, and the argument expression denotes the LFO that produces its control voltage. Note that exp
is used at type Sig -> Sig
here. This works because of instance Floating Sig
There is another type D
: description of a number. It is used, e.g., in the envelope generator xeg :: D -> D -> D -> D -> Sig
. We can produce D
by double :: Double -> D
, but this function has no inverse. We can make a constant signal by sig :: D -> Sig
. There is no inverse function, so we can not voltage-control this envelope generator.
Some CE functions use Sig
, some use SE Sig
, e.g., white noise is white :: SE Sig
. The difference is that Sig
is a signal description, while SE Sig
is an action that
Sig
as result.The type system enforces the distinction between an action and its result. We use actions in these ways:
return x
(without side effects) or by some pre-defined function, e.g., white
(create a white noise generator), slider ..
(create a GUI element)do
notation, where the result is still an actiondac
.CE has some convenience instances and functions that do allow to handle Sig
and SE Sig
alike in several common situations. For instance, given these functions
sqr :: Sig -> Sig
white :: SE Sig
mlp :: Sig -> Sig -> Sig -> Sig
this will work: mlp 400 0.8 $ sqr 300
but this will not: mlp 400 0.8 $ white
.
We could write mlp 400 0.8 <$> white
but it would still show the difference. The solution is to use at
:
at (mlp 400 0.8) $ sqr 300
at (mlp 400 0.8) $ white
This works because of
class SigSpace b => At a b c where
type family AtOut a b c :: *
at :: At a b c => (a -> b) -> c -> AtOut a b c
instance SigSpace a => At Sig Sig a where
type AtOut Sig Sig a = a
at f a = mapSig f a
instance SigSpace Sig
instance SigSpace a => SigSpace (SE a)
Since we also have
instance At Sig (SE Sig) (SE Sig)
we can
at (fvdelays 1 [(utri 0.1,0.9)] 0.8 )
$ hall 0.5 $ mul (upw 0.1 1) $ sqr 300
where the first argument of at
has type Sig -> SE Sig
because of
fvdelays :: MaxDelayTime
-> [(DelayTime, Feedback)] -> Balance -> Sig -> SE Sig
Actions of type SE a
are executed by the csound back-end. This means that their results can never be observed by the Haskell program (the front-end). So, the statement instance Monad SE
seems misleading: the bind
function (>>=)
has type
SE a -> (a -> SE b) -> SE b
indicating that in a >>= f
, the function f
can look at the result of action a
, and decide about the continuation afterwards.
I don’t think this can happen. Instead, instance Applicative SE
should be enough: we still can combine actions, with
(<*>) :: SE (a -> b) -> SE a -> SE b
, but the type makes clear that we have to decide on the continuation (after SE a
) beforehand, by providing an action SE (a -> b)
that produces the continuation.
An indicator for Applicative is that programs actually don’t use (>>=)
, but only fmap
. Example: https://hackage.haskell.org/package/csound-catalog-0.7.2/docs/src/Csound.Catalog.Wave.Deserted.html#wind
The do
notation for monads is used in CE in several places, e.g., https://hackage.haskell.org/package/csound-expression-5.3.2/docs/src/Csound.Control.Gui.html#lift2
lift2 gf f ma mb = source $ do
(ga, a) <- ma
(gb, b) <- mb
return $ (gf ga gb, f a b)
but this could be rewritten into
lift2 gf f ma mb = source $
(\ ((ga,a),(gb,b)) -> (gf ga gb, f a b) <$> ma <*> mb )
With recent compilers, that’s not even necessary, since “Applicative Do” is available. https://ghc.haskell.org/trac/ghc/wiki/ApplicativeDo.
We earlier said that dac
takes a Sig
argument, and now it’s SE Sig
? Both statements are true, since the actual type is
dac :: RenderCsd a => a -> IO ()
This uses type classes with instances
class RenderCsd a where ...
instance Sigs a => RenderCsd a
instance Sigs a => RenderCsd (SE a)
class Sigs a
instance Sigs Sig
There are (many) more signal-like things, e.g., some effects produce stereo signals, like magicCave :: Sig -> Sig2
, with
type Sig2 = (Sig, Sig)
instance (Sigs a1, Sigs a2) => Sigs (a1, a2)
(The documentation in https://github.com/spell-music/csound-expression/blob/master/tutorial/chapters/EventsTutorial.md is fine, I am just repeating essentials here.)
Evt a
is a stream of events of type a
. We can think of it as (a representation for) [(Time, a)]
, with increasing time.
Events can come from external sources (e.g., keyboard connected via MIDI), or from internal sources, e.g., metro 2 :: Evt Unit
is a unit event each half second.
This can be used to trigger sound generation. We need some extra types.
A score Sco a
is an event with a duration. This models notes: when read/play a note (in a written score), we see when to play it (that’s the time of the event), but we also see (immediately) for how long to play (graphical representation for quarter notes, halves, etc.) We make a stream of eighth notes from a metronome
withDur :: Sig -> Evt a -> Evt (Sco a)
withDur (1/8) $ metro 2 :: Evt (Sco Unit)
An instrument is of type a -> SE Sig
, it make signals from notes.
The sched
function produces a signal from an instrument and a score.
sched :: (Arg a, Sigs b) => (a -> SE b) -> Evt (Sco a) -> b
We will use this for a = Unit, b = Sig
, in:
dac $ sched (\ _ -> mul (fades 0.01 0.3) $ pink )
$ withDur (1/8) $ metro 2
let ticker a b c = sched (const $ return $ sqr a) $ withDur b $ metro c
dac $ at (echo 0.75 $ uosc 0.17)
$ hall 0.3 $ fvdelay 1 (0.005 * uosc 0.15) 0.2
$ mlp (exp (1 * osc 0.08) * 500) (utri 0.04 * 0.9) $ mul 0.3
$ mul (uosc 0.12) (ticker (sqr 180) 0.1 2)
+ mul (uosc 0.3) (ticker (sqr 150) 0.1 3)
+ mul (uosc 0.21) (ticker (sqr 120) 0.1 1)
+ mul (uosc 0.17) (ticker (sqr 240) 0.1 0.5)
sounds like