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
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)
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
)
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!
Top comments (0)