Intro to Intonal, Part 3

This article is part 3 of a 5 part series introducing the core concepts of Intonal. It assumes a basic knowledge of music and terms MIDI, tempo, synthesizer, DAW, and lowpass filter. It assumes no prior programming knowledge, and is written for all audiences new to Intonal.

In this tutorial, we'll be rolling our own simple DAW. The end goal is to easily define multiple sequences of notes and feed those into a track with it's own synthesizer and effects. To get there, we need to cover a little theory.

This example uses two synths - a drum synth and a plucked string synth - and one effect - a lowpass filter. As this is a language tutorial and not a DSP tutorial we will not be covering the implementation of these synths and filters, but after working through this guide you will be able to easily copy/paste them into your own code.

The bird's eye view of the structure (aka the architecture) of the Intro Part 3 example looks like this:

In the previous tutorials we looked at streams of numbers and streams of arrays of numbers. Every stream has to have a type otherwise the code won't compile, and there are many more types than the ones we've looked at so far. Understanding how different types are defined and used (aka the type system) is the key to building larger, expressive systems.

Here are some of the simple types built-in to Intonal:

Describing the ranges for floating point values gets complicated - here is a good intro
In the previous example we mentioned the Intonal standard library (stdlib) which is a collection of commonly used stuff written in Intonal. This is different from built-in, which is implemented "under the hood."

There are also built-in types that take one or more types as a parameter. We already saw arrays, which were defined as [float32] - this is actually syntax sugar for array<float32>. The type inside the <> brackets is the parameter to the type. These kind of types are called generic types as you can reuse them to contain any other type that you want. Here are some built-in generic types in Intonal:

When we talk about generic types without specifying what type it contains, we use an upper-case letter, such as array<T>. This is called an abstract type as it cannot exist in runtime. When we give it a concrete type as a parameter, such as array<float32>, it becomes a concrete type. Every stream needs a concrete type or else the code cannot run. The process of figuring out the concrete types is called type inferencing. Type inferencing is a deep topic, outside of the scope of this tutorial, but for the advanced users out there, Intonal uses Hindleyโ€“Milner type inferencing.

All of the above are data types. You can also define your own data types, which we use to wrap up all the parameters to our synths.

data drumData {
  dd_skinAmp: float32,
  dd_snareAmp: float32,
  dd_decay: float32
}

data pluckData {
  pd_decay: float32
}

Data types work well for values that smoothly change, but it's difficult to represent types that have hard boundaries between their values. For example a value of type fruit can be ๐ŸŠ, ๐Ÿ, ๐ŸŒ, or ๐Ÿ and can't smoothly transition from ๐Ÿ to ๐ŸŠ. For these we use enum types (enum is short for enumerated). The only built-in enum in Intonal is the generic optional<T> which is used to represent a value that may not exist. Despite being built-in, it's easily definable within Intonal:

enum optional<T> {
  none
  value(T)
}

In this example we use an enum to represent pitch, that way we can represent pitch either as a raw frequency or as a MIDI note number.

enum noteEnum {
  nd_hz(float32)
  nd_midi(uint8)
}

noteToHz: (noteEnum) -> float32 = {d in
  d.noteEnumSelect(
    {hz: float32 in hz},
    {midi: uint8 in midi.midiToHz()}
  )
}

To build our sequence of notes, we'll make an array that gets advanced for every beat. This could be typed as

sequence: [noteEnum]

We want to pass in parameters to the synth for each note, so we can use pair to combine the note with some parameters

sequence: [pair<noteEnum, D>]
D would be drumData for the drum synth or pluckData for the pluck synth

However this would mean the note gets triggered for every beat, so if want to include rests we could wrap it in an optional, giving us:

sequence: [optional<pair<noteEnum, D>>]

If this type looks intuitively ugly or confusing to you, congratulations, you have a good nose for bad code smell (for you new programmers, yes this is a real programming term). It smells bad because it's unclear what you're meant to do with it.

One of the strengths of a good type system is making the intended use of the type clear (aka signalling intent). User defined types should be used liberally to signal intent and ultimately make your code easier to read. Using built-in types are also harder to extend and change (aka they are brittle), and defining our own type allows us to make changes if needed. So instead of optional<pair<noteEnum, D>> we use a generic enum.

enum soundEvent<D> {
  se_trigger(noteEnum, D)
  se_none
}

sequence: [soundEvent<D>]

Using this we can fill out the first part of our architecture:

Using generic types makes it easier to reason about structure at different levels. By creating generic types for soundEvent<D>s, we can think about how those soundEvent<D>s move around without worrying about what synth they will end up going to. This is called abstraction and it's a powerful way of building larger structure in a way that can be easily reused.

In this example the transform track is the glue that connects the synth with the soundEvent<D>. If you understand how the types connect in track you'll have a good grasp of the Intonal type system.

Transforms have types just like streams, which define the inputs and outputs of the transform.

/* Main output method
 *   In: float32 (sample rate)
 *   Out: float32 (mono audio stream)
 */
main: (float32) -> float32

/* Adding two float32s
 *   In: float32 (value a), float32 (value b)
 *   Out: float32 (a + b) 
 */
add: (float32, float32) -> float32

/* Generating noise
 *   In: Nothing
 *   Out: float32 (mono audio stream)
 */
uniformRand: () -> float32

And they can be abstract types as well

/* Getting an element from an array
 * a[i] is syntax sugar for get(a, i)
 *   In: array<T> (array of values), uint64 (index into array)
 *   Out: T (element from array)
 */
get: [T] => (array<T>, uint64) -> T

a: [float32] = [1, 2, 3, 4]

/* The following get has a concrete type of
 *   (array<float32>, uint64) -> float32
 */
get(a, 0)
At the time of writing, it is not possible to use the [T] => generic syntax for transform types in Intonal, though it still works this way under the hood

Both our synths, drumSynth and pluckSynth, have a standardized format

drumSynth: (noteEnum, bool, drumData, float32) -> float32
pluckSynth: (noteEnum, bool, pluckData, float32) -> float32

The generic transform type is defined as:

synthFunc: [D] => (noteEnum, bool, D, float32) -> float32

The transform track connects a synth with the tempo pulse (type: bool) and a note sequence (type: [soundEvent<D>]). Using a standard generic transform type allows us to easily swap out the synth used in a track while still being able to pass specific parameters.

track: [D] => ((noteEnum, bool, D, float32) -> float32,
               bool,
               [soundEvent<D>],
               D,
               float32) -> float32
Transform type inception: The first input parameter is the synth generator transform, passed in like any other stream.
The fourth parameter, defaultData, exists because Intonal guarantees a stream will always have a valid value. In the case that the first value in the stream is se_none, we still need to send a valid value to the synth transform. As of the time of writing, defining default values on user types is on the road map but not implemented. Once implemented, the fourth parameter can be dropped.
track = {f, inTempoPulse, sequence, defaultData, sr in
  // Used to ensure valid value to start curNote stream
  defaultNotePair = pair(nd_midi(0), defaultData)

  i = 0 fby (prev + 1) % len(sequence) on inTempoPulse

  inEvent = sequence[i]
  curNote = inEvent.curNotePair(defaultNotePair) fby {prev in
    inEvent.curNotePair(prev)
  }
  trigger = inTempoPulse && inEvent.isTrigger()
  out = f(curNote.left(), trigger, curNote.right(), sr)
}
  • i is an index into sequence that gets advanced on inTempoPulse
  • curNote keeps track of the last triggered sound event as a pair<noteEnum, D>
  • trigger represents the start of a new sound, IE when se_trigger happens on the inTempoPulse
  • f is the synth transform itself (in this example either drumSynth or pluckSynth) and is called with calculated values

Now that we have our sequences and synthesizers connected into tracks, mixing and applying effects is straightforward:

  drumMix: float32
  = drumTrack1 + drumTrack2

  stringMix: float32
  = (chordTrack(0) + chordTrack(1) + chordTrack(2) + chordTrack(3))
    .rbjLowpass(sineRange(0.02, 3000, 7000, sr), // Frequency
                sineRange(0.1, 0.5, 10, sr), // Q
                sr)

  drumAmp: float32 = sineRange(0.01, 0, 0.5, sr)
  stringAmp: float32 = sineRange(0.005, 0, 0.5, sr)

  out: float32
  = drumMix * drumAmp
  + stringMix * stringAmp

rbjLowpass is a resonant lowpass filter (part of stdlib), and sineRange is used to add slowly shifting parameter changes to the sound.

After working through this tutorial you should have a basic understanding of the type system in Intonal. In the next tutorials we'll talk about control flow and using bag to create polyphony.

As always, feel free to reach out or join our Discord channel to ask questions or just say hi!