Powered by AppSignal & Oban Pro

Chapter 1: Building Blocks

livebooks/chapter-01.livemd

Chapter 1: Building Blocks

Introduction

This chapter focuses on foundational data structures and how we model music as pure data. We’ll explore:

  • Modules, structs, and typespecs
  • Basic Elixir constructs (def/defp, with, cond, defguard)
  • alias and import
  • ExUnit for example-based testing

The Note Module

Notes are the fundamental building blocks of our music system. A note represents a musical event with:

  • MIDI note number (0-127): represents the pitch
  • Duration (in seconds): how long the note plays
  • Amplitude (0.0-1.0): the volume/loudness
defmodule Note do
  @moduledoc """
  A pure value representing a musical note event
  """

  @enforce_keys [:midi, :dur, :amp]
  defstruct [:midi, :dur, :amp]

  @type t :: %__MODULE__{midi: 0..127, dur: float(), amp: float()}

  @doc """
  Create a new note with validation
  """
  @spec new(integer(), number(), number()) :: {:ok, t()} | {:error, String.t()}
  def new(midi, dur, amp) when is_integer(midi) and is_number(dur) and is_number(amp) do
    with {:ok, midi} <- validate_midi(midi),
         {:ok, dur} <- validate_dur(dur),
         {:ok, amp} <- validate_amp(amp) do
      {:ok, %__MODULE__{midi: midi, dur: dur, amp: amp}}
    end
  end

  @doc """
  Transpose a note by semitones
  """
  @spec transpose(t(), integer()) :: t()
  def transpose(%__MODULE__{midi: m} = note, semitones) do
    %{note | midi: clamp(m + semitones, 0, 127)}
  end

  @doc """
  Scale amplitude by a factor
  """
  @spec scale_amp(t(), number()) :: t()
  def scale_amp(%__MODULE__{amp: a} = note, factor) do
    %{note | amp: clamp(a * factor, 0.0, 1.0)}
  end

  @doc """
  Convert to OSC arguments [midi, duration, amplitude]
  """
  @spec to_osc_args(t()) :: [integer() | float()]
  def to_osc_args(%__MODULE__{midi: m, dur: d, amp: a}), do: [m, d, a]

  # Private helpers
  defp validate_midi(m) when m >= 0 and m <= 127, do: {:ok, m}
  defp validate_midi(m), do: {:error, "MIDI out of range: #{m}"}

  defp validate_dur(d) when d > 0, do: {:ok, d * 1.0}
  defp validate_dur(d), do: {:error, "Duration must be positive: #{d}"}

  defp validate_amp(a) when is_number(a), do: {:ok, clamp(a * 1.0, 0.0, 1.0)}

  defp clamp(val, min, max) do
    val |> max(min) |> min(max)
  end
end

Working with Notes

Let’s create and manipulate some notes:

# Create middle C (MIDI 60)
{:ok, note} = Note.new(60, 0.25, 0.5)
IO.inspect(note, label: "Middle C")

# Transpose up an octave (+12 semitones)
transposed = Note.transpose(note, 12)
IO.inspect(transposed, label: "C one octave higher")

# Make it quieter
quieter = Note.scale_amp(note, 0.5)
IO.inspect(quieter, label: "Quieter note")

# Convert to OSC format
osc_args = Note.to_osc_args(note)
IO.inspect(osc_args, label: "OSC Args")

Music Module - Pure Transformations

Now let’s build sequences of notes:

defmodule Music do
  @moduledoc """
  Pure music primitives and transformations
  """

  alias Note

  # Major scale intervals
  @major [0, 2, 4, 5, 7, 9, 11, 12]

  @doc """
  Create an ascending major scale
  """
  @spec melody(integer(), float(), float()) :: [Note.t()]
  def melody(root \\ 60, dur \\ 0.25, amp \\ 0.2) do
    @major
    |> Enum.map(&(root + &1))
    |> Enum.map(&Note.new(&1, dur, amp))
    |> Enum.map(fn {:ok, note} -> note end)
  end

  @doc """
  Transpose an entire sequence
  """
  @spec transpose([Note.t()], integer()) :: [Note.t()]
  def transpose(sequence, semitones) do
    Enum.map(sequence, &Note.transpose(&1, semitones))
  end

  @doc """
  Insert rests (amp=0) every N notes
  """
  @spec with_rests([Note.t()], pos_integer()) :: [Note.t()]
  def with_rests(sequence, every \\ 3) do
    sequence
    |> Enum.with_index(1)
    |> Enum.map(fn
      {note, i} when rem(i, every) == 0 -> %{note | amp: 0.0}
      {note, _} -> note
    end)
  end
end

Creating Melodies

# Create a C major scale
scale = Music.melody(60, 0.25, 0.3)
IO.inspect(scale, label: "C Major Scale")

# Transpose it up
transposed_scale = Music.transpose(scale, 7)
IO.inspect(transposed_scale, label: "G Major Scale")

# Add rests every 3 notes
with_rests = Music.with_rests(scale, 3)
IO.inspect(with_rests, label: "With Rests")

Testing with Concrete Examples

Rather than generating random inputs, we can pin down the invariants we care about with a handful of well-chosen concrete examples — including the boundaries. This is the same example-based style used in the workshop repo’s test suite (test/funchestra/music/*_test.exs). Property-based testing (generating many random inputs to hunt for edge cases automatically) comes back in Chapter 4, once we have more surface area to explore.

defmodule NoteExampleTest do
  use ExUnit.Case

  alias Note

  test "transposing preserves duration and amplitude" do
    {:ok, note} = Note.new(60, 0.25, 0.5)
    transposed = Note.transpose(note, 7)

    assert transposed.dur == note.dur
    assert transposed.amp == note.amp
    assert transposed.midi == 67
  end

  test "transposing clamps at the top of the MIDI range" do
    {:ok, note} = Note.new(120, 0.25, 0.5)
    transposed = Note.transpose(note, 20)

    assert transposed.midi == 127
  end

  test "transposing clamps at the bottom of the MIDI range" do
    {:ok, note} = Note.new(5, 0.25, 0.5)
    transposed = Note.transpose(note, -20)

    assert transposed.midi == 0
  end

  test "amplitude is clamped to [0.0, 1.0] at the boundaries and beyond" do
    {:ok, at_zero} = Note.new(60, 0.25, 0.0)
    {:ok, at_one} = Note.new(60, 0.25, 1.0)
    {:ok, above} = Note.new(60, 0.25, 100.0)
    {:ok, below} = Note.new(60, 0.25, -100.0)

    assert at_zero.amp == 0.0
    assert at_one.amp == 1.0
    assert above.amp == 1.0
    assert below.amp == 0.0
  end
end

ExUnit.run()

Exercises

Try these challenges:

# 1. Create a minor scale (intervals: [0, 2, 3, 5, 7, 8, 10, 12])
minor_intervals = [0, 2, 3, 5, 7, 8, 10, 12]

create_minor_scale = fn root, dur, amp ->
  minor_intervals
  |> Enum.map(&(root + &1))
  |> Enum.map(&Note.new(&1, dur, amp))
  |> Enum.map(fn {:ok, note} -> note end)
end

minor_scale = create_minor_scale.(60, 0.25, 0.3)
IO.inspect(minor_scale, label: "A Minor Scale")
# 2. Create a pentatonic scale and reverse it
pentatonic = [0, 2, 4, 7, 9, 12]

pentatonic_scale =
  pentatonic
  |> Enum.map(&(60 + &1))
  |> Enum.map(&Note.new(&1, 0.25, 0.3))
  |> Enum.map(fn {:ok, note} -> note end)
  |> Enum.reverse()

IO.inspect(pentatonic_scale, label: "Reversed Pentatonic")
# 3. Create an arpeggio (root, third, fifth)
arpeggio = [0, 4, 7]

create_arpeggio = fn root ->
  arpeggio
  |> Enum.map(&(root + &1))
  |> Enum.map(&Note.new(&1, 0.125, 0.4))
  |> Enum.map(fn {:ok, note} -> note end)
end

c_major_arpeggio = create_arpeggio.(60)
IO.inspect(c_major_arpeggio, label: "C Major Arpeggio")

Key Takeaways

  • Pure data structures make testing and reasoning easier
  • Pattern matching and with provide elegant error handling
  • Typespecs document intentions and enable static analysis
  • Example-based tests, especially at the boundaries, verify the invariants we care about
  • Pipe operator enables readable data transformations

In the next chapter, we’ll add side effects and make actual sound with Sonic Pi!