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:
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 asarray<T>
. This is called an abstract type as it cannot exist in runtime. When we give it a concrete type as a parameter, such asarray<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
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
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.
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 isse_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 inTempoPulsecurNote
keeps track of the last triggered sound event as apair<noteEnum, D>
trigger
represents the start of a new sound, IE whense_trigger
happens on theinTempoPulse
f
is the synth transform itself (in this example eitherdrumSynth
orpluckSynth
) 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!