Describing musical domain with F#

bohdanstupak1

Bohdan Stupak

Posted on August 16, 2024

Describing musical domain with F#

One of my recent projects was to create software that would automatically generate music based on a predefined set of rules. The degree of randomness I've planned to introduce would let me create different melodies every time, while the set of rules I was planning to create would ensure that it still would sound nice. You can access the complete source code here. Below we'll dive more deeply into the details regarding what it does.

Domain description

The rules I was planning to expand upon are the concept of functional harmony. This concept is based on the idea that each chord has its own function. The functionality of a given chord is based on where the chord “wants” to go next because a harmonic progression has two dimensions: the chord’s pitches and how they are interacting (interval hierarchy); and its function in the overall harmonic context. So, functional harmony goes through the cycle of creating and releasing tension and as a result, we have stable and unstable moments that vary in different degrees of intensity.

The three most important functions are:

  • The Tonic; can either be or feel very stable, and generally is the final chord of a piece of music or a section
  • The Sub-Dominant; prepares the harmonic cadence and introduces some degree of instability
  • The Dominant; the most unstable chord that wants to resolve to another chord

Encoding the domain

Now let's encode this knowledge into code. I've chosen F# for this task since its type system is quite handy for expressing all sorts of domains.

Let's start from the basics and describe what chords we have in our palette.

type ChordQuality =
    | Major
    | Minor
Enter fullscreen mode Exit fullscreen mode

There are many more chord qualities, but this is just enough for our needs.

Now, let's describe the knowledge we've obtained from the previous paragraph.

type HarmonyItem =
    | Tonic
    | SubDominant
    | Dominant
Enter fullscreen mode Exit fullscreen mode

The transitions between them will look as below

type HarmonyTransition =
    | Dublicate
    | IncreaseTension
    | MaximizeTension
    | DecreaseTension
    | Resolve
Enter fullscreen mode Exit fullscreen mode

Now let's see how the transitions are applied

let applyCommand command chord =
    match command with
    | Dublicate -> dublicate chord
    | IncreaseTension -> increaseTension chord
    | DecreaseTension -> decreaseTension chord
    | MaximizeTension -> maximizeTension chord
    | Resolve -> resolve chord

let dublicate harmonyItem =
    harmonyItem

let increaseTension harmonyItem =
    match harmonyItem with
    | Tonic -> SubDominant
    | SubDominant -> Dominant
    | Dominant -> Dominant

let decreaseTension harmonyItem =
    match harmonyItem with
    | Tonic -> Tonic
    | SubDominant -> Tonic
    | Dominant -> SubDominant

let maximizeTension harmonyItem =
    Dominant

let resolve harmonyItem =
    Tonic
Enter fullscreen mode Exit fullscreen mode

With that said let's have a look at what hides behind each item in our functional progression. So basically each chord will have a quality and it's offset in notes from the root note.

type HarmonyItemValue = {
    value: int
    chordQuality: ChordQuality
}

let getHarmonyItemValue item =
    match item with
    | Tonic -> { value = 0; chordQuality = Major }
    | SubDominant -> { value = 5; chordQuality = Major }
    | Dominant -> { value = 7; chordQuality = Major }
Enter fullscreen mode Exit fullscreen mode

Given this, we can create an array of pitches from each harmony item.

type Pitch = {
    midiNote: int
    duration: float
}

let createChordFromRootNote rootNote item =
    let itemValue = getHarmonyItemValue item
    match (itemValue.value, itemValue.chordQuality) with
    | (value, Major) -> [|
        {
            midiNote = rootNote + value
            duration = 1.0
        };
        {
            midiNote = rootNote + value + 4
            duration = 0.125
        };
        {
            midiNote = rootNote + value + 7
            duration = 1.0
        }|]
    | (value, Minor) -> [|
        {
            midiNote = rootNote + value
            duration = 1.0
        };
        {
            midiNote = rootNote + value + 4
            duration = 0.125
        };
        {
            midiNote = rootNote + value + 7
            duration = 1.0
        }|]
Enter fullscreen mode Exit fullscreen mode

Generating the progression

So to create different progressions each time we need to add some randomness to the process. To achieve that we'll have some degree associated with the probability of each transition. Let's say we're in our tonic chord and we have the probability of 0.1 that we'll stay there for the next chord, while the probabilities of increasing tension are equal among themselves and are total to 0.45 each. In such a case let's assign a threshold for each transition. Say Tonic will be 0.1, SubDominant will be 0.55 which is the tonic threshold of 0.1 + tonic probability and Dominant will be 1.0 which is the probability of a full group of events. In such a case once we generate a random number between 0.0 and 1.0 we can select the minimal item that has a threshold greater than the given random number.

Here's how it looks in the code.

type HarmonyTransitionProbability = {
    transition: HarmonyTransition
    coinThreshold: float
}

let regenerateHarmonyTransitionProbability currentHarmonyItem =
    match currentHarmonyItem with
    | Tonic ->
        [|
            { transition = Dublicate; coinThreshold = 0.1 };
            { transition = IncreaseTension; coinThreshold = 0.55 };
            { transition = MaximizeTension; coinThreshold = 1.0 };
        |]
    | SubDominant ->
        [|
            { transition = Dublicate; coinThreshold = 0.1 };
            { transition = IncreaseTension; coinThreshold = 0.55 };
            { transition = Resolve; coinThreshold = 1.0 };
        |]
    | Dominant ->
        [|
            { transition = Dublicate; coinThreshold = 0.1 };
            { transition = Resolve; coinThreshold = 0.9 };
            { transition = DecreaseTension; coinThreshold = 1.0 };
        |]

let rnd = Random()

let generateNextChord currentChord coin =
    let probabilityMap = regenerateHarmonyTransitionProbability currentChord
    let command = (Array.filter (fun x -> coin <= x.coinThreshold) probabilityMap).[0].transition
    applyCommand command currentChord

let generateProgression (initialChord: HarmonyItem) (length: int) : HarmonyItem array =
    let rec generate (currentChord: HarmonyItem) (remaining: int) (progression: HarmonyItem list) =
        if remaining = 0 then
            List.toArray (List.rev progression)
        else
            let coin = rnd.NextDouble()
            Console.WriteLine(coin)
            let nextChord = generateNextChord currentChord coin
            generate nextChord (remaining - 1) (nextChord :: progression)
    generate initialChord (length - 1) [initialChord]
Enter fullscreen mode Exit fullscreen mode

Domain evolution

So far we have covered only some basic concepts. But even some more mainstream progressions such as Axis of Awesome 4 chord wamp operate on the concept of substitutes. Substitutes are the duplicates of harmonic functions we already know but are not as distinct as their counterpart. So let's introduce them in our domain as well.

To me, this was the most enjoyable part of expressing my domain in F# since I had to remember to add it in two places: harmony items and transitions between them.

type HarmonyItem =
    | Tonic
    | TonicSubstitute1
    | TonicSubstitute2
    | SubDominant
    | Dominant

type HarmonyTransition =
    | Dublicate
    | IncreaseTension
    | MaximizeTension
    | DecreaseTension
    | DecreaseTensionToFisrtSubstitute
    | DecreaseTensionToSecondSubstitute
    | Resolve
    | ResolveToFirstSubstitute
    | ResolveToSecondSubstitute
Enter fullscreen mode Exit fullscreen mode

At this point, in any place where I apply pattern matching, the compiler issues me a warning about incomplete pattern matching. So I just add missing cases until the compiler is satisfied and voila: a new version of the domain is complete. To some extent, this reminds me of lean on the compiler technique from the all-time classic "Working effectively with legacy code".

Generating sound

At this point, we can produce the array of pitches that are midi notes. To create sound from these notes I've used a specialized programming language called SuperCollider. I won't dive much into details here, but you may have a look at the code if you're interested. Beware, there are quite a lot of branches there and all of them contain some interesting code.

Conclusion

I've been a proponent of F# for quite a long time. So instead of expanding once again on the power of its type system, I'll just leave here a link to one of my favorite tracks that was created with the code in this article.

💖 💪 🙅 🚩
bohdanstupak1
Bohdan Stupak

Posted on August 16, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related