DEV Community

loading...
Cover image for Amazing animations using the Reader monad

Amazing animations using the Reader monad

Mike Solomon
Making machines that make machines believe the machines they make are other machines.
・5 min read

Ho ho ho! ⛄ 🎅

It's the Most Wonderful Time of the Year, and to celebrate, I created a small web-arrangement of Silent Night using PureScript and LilyPond. It can be also be found here in developer mode . The work uses twenty-four different recordings my wife and I made of Silent Night, blending them together in different combinations and proposing different digital accompaniments depending on your interactions with the site.

In this article, I'd like to show a small example of what I found to be an efficient pattern for making interactive work on klank.dev. The full example will be around 300 lines of PureScript and will create a small bell symphony where you can click on circles before they disappear. We'll go over three main parts:

  • How to update the model using a reader.
  • How to write the animation.
  • How to write the sound component.

The end result is live on klank.dev and in developer mode here.

I hope that, by the end of the article, you'll have enough information to compare PureScript Drawing and PureScript Audio Behaviors to libraries like EaselJS ToneJS.

Working with a model

First, let's create a model that keeps track of currently-active visual and audio objects, writing information that will be important for rendering later on.

In imperative languages, two interrelated problems often arise when a model is updated:

  • The model's previous state needs to be accessed.
  • New information needs to percolate through the model.

Both of these problems can be solved by the Reader monad. The reader monad persists a read-only data structure through a computation, allowing arbitrary elements of the computation to access the data.

In the example below, we see how a reader monad allows us to access the current time, the canvas's width and height, information about the mouse and the previous state. Because the data is read-only, there's no danger that we'll change it accidentally. Furthermore, because the data is available through all the functions, there's no need for monster function signatures. We'll use the predefined commands ask, which returns the whole read-only environment,and asks, which applies a function to the environment before returning it.

In the definitions of advance, accountForClick, treatCircle and makeCircles, look at how ask and asks retrieve only the information we need. Another thing you may notice is that the resulting code looks more declarative. In a way, it resembles a data structure more than code. This is, in my opinion, a good thing. Instead of giving the browser a series of instructions telling it how to do something, we tell PureScript what we want and let lower-level libraries figure out the details.

type CircleInfo
  = { direction :: Direction
    , generation :: Int
    , startPos :: Point
    , currentPos :: Point
    , radius :: Number
    , startOpacity :: Number
    , currentOpacity :: Number
    , startTime :: Number
    }

type UpdateEnv
  = { time :: Number
    , mouseDown :: Maybe Point
    , w :: Number
    , h :: Number
    , circs :: List CircleInfo
    }

type UpdateR
  = Reader UpdateEnv

advance :: CircleInfo -> UpdateR CircleInfo
advance circle@{ direction
, generation
, startPos
, currentPos
, startOpacity
, startTime
} = do
  { time, w, h } <- ask
  pure
    $ circle
        { currentPos =
          if generation == 0 then
            currentPos
          else
            { x:
                startPos.x
                  + ((time - startTime) * w * 0.1)
                  * (toNumber (generation + 1))
                  * dirToNumber direction Xc
            , y:
                startPos.y
                  + ((time - startTime) * h * 0.1)
                  * (toNumber (generation + 1))
                  * dirToNumber direction Yc
            }
        , currentOpacity =
          if generation == 0 then
            1.0
          else
            calcSlope startTime
              startOpacity
              (startTime + timeAlive)
              0.0
              time
        }

accountForClick :: CircleInfo -> UpdateR (List CircleInfo)
accountForClick circle = do
  { mouseDown } <- ask
  case mouseDown of
    Nothing -> pure mempty
    Just { x, y }
      | inRadius { x, y } circle -> do
        { time } <- ask
        pure
          $ map
              ( circle
                  { direction = _
                  , generation = circle.generation + 1
                  , startPos = circle.currentPos
                  , startOpacity = circle.currentOpacity * 0.8
                  , radius = circle.radius * 0.8
                  , startTime = time
                  }
              )
              directions
      | otherwise -> pure mempty

treatCircle ::
  CircleInfo ->
  UpdateR (List CircleInfo)
treatCircle circle = do
  { time } <- ask
  if circle.generation /= 0
    && timeAlive
    + circle.startTime
    <= time then
    pure mempty
  else
    append
      <$> (pure <$> advance circle)
      <*> (accountForClick circle)

makeCircles :: UpdateR (List CircleInfo)
makeCircles =
  asks _.circs
    >>= map join
    <<< sequence
    <<< map treatCircle
Enter fullscreen mode Exit fullscreen mode

Creating the visuals

Now that we have an updated list of CircleInfo, we can use it to create both visuals. Because the model has already been calculated, the actual drawing is quite short.

background :: Number -> Number -> Drawing
background w h =
  filled
    (fillColor $ rgba 0 0 0 1.0)
    (rectangle 0.0 0.0 w h)

circlesToDrawing ::
  Number ->
  Number ->
  List CircleInfo ->
  Drawing
circlesToDrawing w h =
  append (background w h)
    <<< fold
    <<< map go
  where
  go { currentPos: { x, y }
  , currentOpacity
  , radius
  } =
    filled
      (fillColor $ rgba 255 255 255 currentOpacity)
      (circle x y radius)
Enter fullscreen mode Exit fullscreen mode

Creating the audio

Similar to the drawings, the audio is derived completely from the model and is also quite short.

toNel :: forall a. Semiring a => List a -> NonEmpty List a
toNel Nil = zero :| Nil

toNel (a : b) = a :| b

directionToPitchOffset :: Direction -> Number
directionToPitchOffset NorthEast = 0.0

directionToPitchOffset NorthWest = 0.25

directionToPitchOffset SouthEast = 0.5

directionToPitchOffset SouthWest = 0.75

circlesToSounds ::
  Number ->
  List CircleInfo ->
  NonEmpty List (AudioUnit D2)
circlesToSounds time = toNel <<< catMaybes <<< map go
  where
  go { startTime, startPos, direction, generation }
    | generation == 0 = Nothing
    | otherwise =
      Just
        $ playBuf_
            ( show startTime
                <> show startPos
                <> show direction
                <> show generation
            )
            "ring" -- the name of the soundfile we'll play
            ( toNumber generation
                + directionToPitchOffset direction
            )
Enter fullscreen mode Exit fullscreen mode

Conclusion

This entire demo clocks in at around 300 lines of code and can be found on GitHub as well as on klank.dev.

The larger piece, Silent Night, uses the same exact patterns on a larger scale. Because individual sections of Silent Night are no more complicated than this smaller example, and because the sections are gated by pattern matching, the execution time is also quite fast and there is no noticeable jank.

I hope that you enjoy playing around with both the shorter example and the larger piece. I find PureScript to be incredibly expressive for making creative work, and I would love to see it gain greater traction amongst visual and sound artists. If you have time over the holidays, try to make your first creation on klank.dev and share it - I'd love to see it!

Discussion (0)