Combining Events
When Events Overlap
What happens when two notes land at the same time?
stack(
note("c4"),
note("e4"),
note("g4")
).sound("piano")Three notes, same time. We hear a chord. But what actually happened? Did they merge into one event, or stay as three?
stack(
note("c4"),
note("e4"),
note("g4")
).sound("piano").log()Check the console. Three separate events, all playing at once. stack doesnโt mergeโit layers.
Different Ways to Combine
There are multiple valid ways to handle overlapping events:
Layer (keep all):
stack(a, b) // Both events play
Union (merge properties):
a.set(b) // Properties from b override a
Replace (last wins):
layer(a, b) // Similar to stack, but with context merging
Each strategy makes sense in different contexts. This is the Semigroup question: when two things combine, whatโs the rule?
Semigroup: Things That Combine
A Semigroup is anything with a combining operation. The rule: (a โ b) โ c = a โ (b โ c)โgrouping doesnโt matter.
You already know semigroups:
- Numbers under addition:
(1 + 2) + 3 = 1 + (2 + 3) - Strings under concatenation:
("a" + "b") + "c" = "a" + ("b" + "c") - Arrays under concatenation:
[1,2].concat([3])same either way
Patterns form a semigroup under stack:
// These are equivalent:
stack(stack(sound("bd"), sound("sd")), sound("hh"))
// stack(sound("bd"), stack(sound("sd"), sound("hh")))Grouping doesnโt change the resultโall three play together.
Stack: Layering Events
stack is the most common way to combine patterns. It layers them:
stack(
sound("bd ~ bd ~"),
sound("~ sd ~ sd"),
sound("hh*8").gain(0.4)
)Each pattern contributes its events. Nothing mergesโthey coexist.
Variants with alignment:
// stackLeft: align to start, fill gaps
stackLeft(
sound("bd bd"),
sound("sd sd sd sd")
)// stackRight: align to end
stackRight(
sound("bd bd"),
sound("sd sd sd sd")
)Cat: Sequential Combining
cat (also called slowcat) plays one pattern per cycle:
// One pattern per cycle (3 cycles to hear all)
cat(
sound("bd bd bd bd"),
sound("sd sd"),
sound("hh hh hh hh")
)Cycle 1 plays kicks, cycle 2 plays snares, cycle 3 plays hi-hats, then repeats.
fastcat: Squeeze all into one cycle
// All three patterns in ONE cycle
fastcat(
sound("bd bd bd bd"),
sound("sd sd"),
sound("hh hh hh hh")
)Same three patterns, but squeezed into a single cycle. Each takes its proportional share.
Combining Parameters
When control patterns overlap, parameters merge:
note("c4 e4 g4 c5")
.sound("sawtooth")
.lpf("800 1600 2400 3200")
.gain("1 0.8 0.6 0.4")Each note gets its corresponding filter and gain value. The parameters combine by matching timing.
What if patterns have different lengths?
// 4 notes, 2 filter values
note("c4 e4 g4 c5")
.sound("sawtooth")
.lpf("800 2400")The filter cycles: 800, 2400, 800, 2400. Patterns with different lengths create evolving combinations.
Union: Merging Properties
Sometimes you want to merge, not layer. The .set() method combines control patterns:
// Base pattern
const base = note("c4 e4 g4").sound("piano")
// Override with new values
base.set(gain("0.5 0.8 1"))Properties from the right override the left. This is useful for creating variations:
const melody = note("c4 e4 g4 b4").sound("piano")
stack(
melody,
melody.set(note("e4 g4 b4 d5")), // harmonize up
melody.set(note("g3 b3 d4 f4")) // harmonize down
)The Semigroup Laws
For any semigroup operation โ:
Associativity: (a โ b) โ c = a โ (b โ c)
This is why you can write:
stack(a, b, c, d, e) // instead of nested stack(stack(stack(...)))
cat(a, b, c, d) // instead of nested cat(cat(...))
The implementation can group however it wantsโthe result is the same.
Associativity means you can:
- Restructure code without changing behavior
- Process elements in any order (parallelization)
- Build incrementally (
result = stack(result, newPart))
Itโs a guarantee that grouping is irrelevant. This makes combining operations predictable and safe to refactor.
From Semigroup to Monoid
Remember Building with Patterns? A Monoid is a Semigroup plus an identity element.
| Operation | Semigroup | Identity (Monoid) |
|---|---|---|
stack | Layer patterns | silence |
cat | Sequence patterns | silence |
+ on numbers | Add | 0 |
* on numbers | Multiply | 1 |
The identity doesnโt change anything:
// silence is the identity for stack
stack(
sound("bd ~ bd ~"),
silence,
sound("~ sd ~ sd")
)Same result as without silence. This lets you write conditional patterns cleanly:
const useBass = true
const useHats = false
stack(
sound("bd ~ bd ~"),
sound("~ sd ~ sd"),
useBass ? note("c2").sound("sawtooth").lpf(400) : silence,
useHats ? sound("hh*8").gain(0.3) : silence
)Practical Applications
Building arrangements:
const kick = sound("bd ~ bd ~")
const snare = sound("~ sd ~ sd")
const hats = sound("hh*8").gain(0.4)
const bass = note("c2 c2 g1 g1").sound("sawtooth").lpf(600)
// Build up section by section
cat(
kick,
stack(kick, hats),
stack(kick, hats, snare),
stack(kick, hats, snare, bass)
).slow(4)Layering variations:
const melody = note("c4 e4 g4 e4")
stack(
melody.sound("piano"),
melody.add(12).sound("piano").gain(0.3).delay(0.1),
melody.sub(12).sound("sawtooth").lpf(800).gain(0.5)
)Quick Reference
// Layering (simultaneous)
stack(a, b, c) // all play together
stackLeft(a, b) // align left
stackRight(a, b) // align right
// Sequencing (consecutive)
cat(a, b, c) // divide cycle equally
slowcat(a, b, c) // one per cycle
fastcat(a, b, c) // squeeze into one cycle
// Parameter merging
pattern.set(overrides) // right overrides left
// Identity
silence // empty pattern
Whatโs Next?
Weโve seen how to combine patterns by layering or sequencing. But what if you want to choose between patterns based on some condition?
Continue to Choice and Selection โ
Youโve learned to combine all patterns together. Next, youโll discover how to select from alternativesโplaying one pattern or another based on names, indices, or conditions.