User:Echo Nolan/Reactive Banana: Straight to the Point
Revision as of 15:51, 7 October 2012
So I'm writing this tutorial as a means of teaching myself FRP and reactive-banana. It'll probably be full of errors and bad advice, use it at your own risk.
All the tutorials on FRP I've read start with a long boring theory section. This is an instant gratification article. For starters, imagine a man attempting to sharpen a banana into a deadly weapon. See? You're gratified already! Here, I'll write some code for playing musical notes on your computer, attach that to reactive-banana and build increasingly complicated and amusing "sequencers" using it. Now for a boring bit:
Go install sox:
apt-get install sox # Or equivalent for your OS/Distro
Get the git repository associated with this tutorial:
git clone https://github.com/enolan/rbsttp.git
cabal install reactive-banana
2 Musical interlude
Cd into the git repo and open rbsttp.hs in GHCi:
cd rbsttp ghci rbsttp.hs
Now, we can make some beepy noises. Try these:
playNote (negate 5) C playNote (negate 5) Fsharp sequence_ . intersperse (threadDelay 1000000) $ map (playNote (negate 5)) [C ..]
Play with the value passed to threadDelay a bit for some more interesting noises. It's the time to wait between
Notes, expresssed in microseconds.
sequence_ . intersperse (threadDelay 500000) $ map (playNote (negate 5)) [C ..] sequence_ . intersperse (threadDelay 250000) $ map (playNote (negate 5)) [C ..] sequence_ . intersperse (threadDelay 125000) $ map (playNote (negate 5)) [C ..] sequence_ . intersperse (threadDelay 62500) $ map (playNote (negate 5)) [C ..]
You've probably figured out by now that C and Fsharp are data constructors. Here's the definition for my Note type.
-- 12 note chromatic scale starting at middle C. data Note = C | Csharp | D | Dsharp | E | F | Fsharp | G | Gsharp | A | Asharp | B deriving (Show, Enum)
playNote is a very hacky synthesizer. It's also asynchronous, which is why
mapM_ playNote (negate 5) [C ..] doesn't sound too interesting. Here's
-- Play a note with a given gain relative to max volume (this should be -- negative), asynchronously. playNote :: Int -> Note -> IO ()
3 Ground yourself, then insert the electrodes into the banana
Everything we've done so far is plain old regular Haskell in the IO monad. Try this now:
(sendNote, network) <- go1 sendNote ((negate 10), C) sendNote ((negate 10), Fsharp)
Congratulations! You just compiled your first
EventNetwork and sent your first
Events. I know this looks like I just made a excessively complicated version of
uncurry playNote, but bear with me for a moment. Let's look at the code for
go1 :: IO ((Int, Note) -> IO (), EventNetwork) go1 = do (addH, sendNoteEvent) <- newAddHandler let networkDescription :: forall t. Frameworks t => Moment t () networkDescription = do noteEvent <- fromAddHandler addH reactimate $ fmap (uncurry playNote) noteEvent network <- compile networkDescription actuate network return (sendNoteEvent, network)
From it's type we can see that this is an IO action that returns a tuple of what is, yes, just fancy
uncurry playNote and something called a
EventNetwork is the new, interesting bit. The two new important abstractions that reactive-banana introduces are
Behaviors, we'll get to a bit later.
Events are values that occur at discrete points in time. You can think of an
Event t a(ignore the t for now) as a
[(Time, a)] with the times monotonically increasing as you walk down the list.
In general, to get
Events from IO we'll need to use
fromAddHandler. Unsurprisingly, it wants an
addHandler as its argument. Let's take a look at those types:
type AddHandler a = (a -> IO ()) -> IO (IO ()) fromAddHandler :: Frameworks t => AddHandler a -> Moment t (Event t a)
Reactive-banana makes a pretty strong assumption that you're hooking it up to some callback-based, "event driven programming" library. An
AddHandler a takes a function that takes an
a and does some IO and "registers the callback" and returns a "cleanup" action. Reactive-banana will hook that callback into FRP, and call the cleanup action whenever we
EventNetwork. (You can
EventNetwork as many times as you like.)
Since we don't have anything that looks like an
AddHandler, we need a convenience function to make one for us. Ta-da:
newAddHandler :: IO (AddHandler a, a -> IO ())
That gives us an
AddHandler and the function that triggers the
Event, which we bound to the name
sendNote way back when we ran go1.
go1 has two
Events in it. The first is
noteEvent :: Event t (Int, Note) the one you send at the ghci prompt. The second is anonymous, but it's type is
Event t (IO ()). We build that one using
uncurry playNote. In general, we'll be manipulating
Applicative and some reactive-banana specific combinators.
Put the weird type constraint on
networkDescription out of your mind for now. The
Moment monad is what we use to build network descriptions. I don't understand exactly what's going on with
forall Frameworks t. => Moment t (), but it makes GHC happy and probably stops me from writing incorrect code somehow.
compile turns a network description into an
actuate is fancy-FRP-talk for "turn on".
4 Plug a metronome into the banana
Since GHC has such great concurrency support, and we were already using
threadDelay back in section 2, we're going to use a couple of threads and a
Chan () to build and attach our metronome. Here's a function that lets us build
AddHandler as out of IO functions that take
Chan a as an argument.
addHandlerFromThread :: (Chan a -> IO ()) -> AddHandler a addHandlerFromThread writerThread handler = do chan <- newChan tId1 <- forkIO (writerThread chan) tId2 <- forkIO $ forever $ (readChan chan >>= handler) return (killThread tId1 >> killThread tId2)
So, basically, we make a new
forkIO the given function, passing the new
Chan to it as an argument, create a second thread that triggers the callback handler whenever a new item appears on the
Chan and returns a cleanup action that kills both threads. Some version of
addHandlerFromThread may or may not become part of reactive-banana in the future, filing a ticket is on my to-do list.
On to the actual metronome bit:
bpmToAddHandler :: Int -> AddHandler () bpmToAddHandler x = addHandlerFromThread go where go chan = forever $ writeChan chan () >> threadDelay microsecs microsecs :: Int microsecs = round $ (1/(fromIntegral x) * 60 * 1000000)
goBpm is basically the same as
go1, with a different event source and fixed gain and pitch.
goBpm :: Int -> IO EventNetwork goBpm bpm = do let networkDescription :: forall t. Frameworks t => Moment t () networkDescription = do tickEvent <- fromAddHandler (bpmToAddHandler bpm) reactimate $ fmap (const $ playNote (negate 5) Fsharp) tickEvent network <- compile networkDescription actuate network return network
Try it out:
goBpm 240 -- Wait until you get tired of that noise pause it
If you've gotten confused here,
it is a special variable only available in GHCi, holding the return value of the last expression, and
pause stops the operation of an
5 Warming things up: Banana, meet Microwave
Let's play some chords instead of just single notes. First, the easy part:
-- The last two will sound ugly, but whatever I'm not an actual musician and -- this is a tutorial. chordify :: Note -> [Note] chordify n = let n' = fromEnum n in map (toEnum . (`mod` 12)) [n', n'+1, n'+2]
Now how do we hook that up to FRP? We already know fmap, so we can get something of type
Event t Note -> Event t [Note], but how do we get a list of
Notes to play at the same time? Meet a new combinator:
spill :: Event t [a] -> Event t a
So, now we can define:
chordify' :: Event t Note -> Event t Note chordify' = spill . fmap chordify
Integrating that into goBpm, we have:
goBpmChord :: Int -> IO EventNetwork goBpmChord bpm = do let networkDescription :: forall t. Frameworks t => Moment t () networkDescription = do tickEvent <- fromAddHandler (bpmToAddHandler bpm) let noteEvent = chordify' $ fmap (const C) tickEvent reactimate $ fmap (uncurry playNote . (negate 5,)) noteEvent network <- compile networkDescription actuate network return network
6 This banana is getting crowded! Plugging in a clock
Let's take our metronome and turn it into a beat counting metronome. Then we can play some scales and other patterns - like when we played around with
sequence_ back in section 2. Meet
accumE :: a -> Event t (a -> a) -> Event t a
Given an initial value and a time-stream of functions for combining values, this will emit a stream of combined values, accumulating over time. Behold:
counterify :: Event t () -> Event t Integer counterify ev = accumE 0 (const (+1) <$> ev) justCount :: IO EventNetwork justCount = do let networkDescription :: forall t. Frameworks t => Moment t () networkDescription = do beats <- fromAddHandler (bpmToAddHandler 60) let counting = counterify beats reactimate $ fmap print counting network <- compile networkDescription actuate network return network
This will spew numbers into your GHCi prompt, but you can still do the
pause it thing to stop it counting at you.
7 Putting the banana on a diet: scales!
You can probably figure this one out yourself:
scale :: Int -> IO EventNetwork scale bpm = do let networkDescription :: forall t. Frameworks t => Moment t () networkDescription = do idxE <- counterify <$> fromAddHandler (bpmToAddHandler bpm) let notesE = (toEnum . ((`mod` 12))) . fromEnum <$> idxE reactimate $ fmap (uncurry playNote . (negate 5,)) notesE network <- compile networkDescription actuate network return network
For the next one you need to understand reactive-banana's
union combinator. It just takes two events of the same type and merges them. Then we can do two scales at once!
scales :: Int -> IO EventNetwork scales bpm = do let networkDescription :: forall t. Frameworks t => Moment t () networkDescription = do idxAscE <- counterify <$> fromAddHandler (bpmToAddHandler bpm) let idxDscE = negate <$> idxAscE notesAscE = (toEnum . ((`mod` 12))) . fromEnum <$> idxAscE notesDscE = (toEnum . ((`mod` 12))) . fromEnum <$> idxDscE reactimate $ fmap (uncurry playNote . (negate 5,)) $ RB.union notesAscE notesDscE network <- compile networkDescription actuate network return network
That's as far as I'm going for now. Hooking this up to keyboard input would be a logical next step, but I'm off to help my step-family move.