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) -
aliasandimport - 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
withprovide 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!