tidal-innards
A brief tour of how Tidal Cycles patterns work
Introduction
I impulsively applied to play music at a live coding show that took place on September 4th. I haven't livecoded music with a live in-person audience before, so it was very scary! But it went even better than I could have hoped. I had a blast, and I learned a lot about Tidal Cycles in my preparation.
I thought I'd share some of what I learned to help other live coders hack on and extend tidal cycles.
Tidal Basics
For the uninitiated, Tidal Cycles, also known as tidal
d1
$ every 4 (fast 2)
$ note "A'maj'9 C'min'9"
# sound "superpiano"
# room 0.1 # size 0.4
tidal is a music sequencer, implemented as a haskell library. It supports
live manipulation of patterns in a Read-Eval-Print Loop or REPL
Tidal Cycles supports several standards for interfacing with instruments, but
by default it gets paired with a synthesizer called SuperDirt. tidal does
not make its own sounds!
tidal works on the notion of a cycle, which is a repeating pattern of
events. tidal overrides string literals to represent these cycles in a
notation called mininotation:
"0 1 2 3"
Each event in the patern subdivides the overal cycle into equal parts. This is
a pattern of 4 evenly spaced events (in this case numbers). mininotation has
its own set of operators to make it easier to express complex patterns. tidal
also supports manipulating patterns in order to make more interesting musical
structures:
d1
$ every 4 (fast 2)
$ note (scale "minPent" "0 1 2 3") # sound "superpiano"
Every 4 cycles, the pattern of notes will play twice!
tidal also supports merging multiple patterns together:
d1
$ note "a f g d"
# sound "superpiano supergong" -- this is also a pattern
The # operator will match up the events from the pattern on the left
with the pattern on the right, taking the superpiano sound for the
notes a f, and the supergong sound for g d.
But what is a pattern?
Innards
We can ask the tidal
REPL about the type of d1 with
:t d1. It will dutifully report that:
d1 :: Tidally => ControlPattern -> IO ()Tidally- For our purposes, this parameter represents the global configuration, clock,
connections, and other state of the running
tidalinstance. Through some magic, we don't actually need to pass it in! ControlPattern- The actual data structure that represents the sequence of musical events.
IO- Think of this as
d1being of typevoidbut declaring that it hasIOside effects. In this case, this means sending messages to the connected instruments.
The real meat of this is the ControlPattern. We can ask the
REPL for its definition with :info ControlPattern:
type ControlPattern = Pattern ValueMap
So a ControlPattern is a Pattern paramaterized against a ValueMap, which
the REPL reports is:
type ValueMap = Data.Map.Internal.Map String Value
So we see now that each event in a pattern represents a collection of names (keys) with associated values. Let's look at an example of this in action. If we print this tidal pattern:
print $ note "c" # sound "superpiano"
The REPL reports that the output pattern represents an event single event from
time 0 to 1 with a ValueMap equvalent to the following:
{s: "superpiano", note: 0.0}
tidal will generate an OSC message
which encodes the Key-Value pairs of this ValueMap and sends it to the
configured instruments. By default, this means SuperDirt.
In the SuperDirt source, we can find the definition for superpiano:
SynthDef(\superpiano,{|out, sustain=1, pan, velocity=1, detune=0.1, muffle=1, stereo=0.2, freq=440, accelerate=0, speed=1|
var env = EnvGen.ar(Env.linen(0.002, 0.996, 0.002, 1,-3), timeScale:sustain, doneAction:2);
// the +0.01 to freq is because of edge case rounding internal to the MdaPiano synth
var sound = MdaPiano.ar(freq*DirtFreqScale.kr(speed, accelerate, sustain)+0.01,
vel:velocity*100, hard:0.8*velocity, decay:0.1*sustain,
tune:0.5, random:0.05, stretch:detune, muffle:0.8*muffle, stereo:stereo);
Out.ar(out, DirtPan.ar(sound, ~dirt.numChannels, pan, env))
}).add
SuperDirt uses the s parameter to look up which synth to play. It
will will also convert note events to frequencies (freq), but most other
parameters in a ValueMap just get passed straight from the message to the
chosen SynthDef.
It's worth nothing that some of these parameters are relatively standardized
across SuperDirt synths and thus have dedicated functions. See pan and
speed.
Pattern, Eh?
Let's revisit the other half of a ControlPattern.
If a ValueMap represents the parameters passed to a synth instance, then a
Pattern adds the dimension of time:
data Pattern a
= Pattern {query :: State -> [Event a],
steps :: Maybe Rational,
pureValue :: Maybe a}
type Arc = ArcF Time
data ArcF a = Arc {start :: a, stop :: a}
data State = State {arc :: Arc, controls :: ValueMap}
type Event a = EventF (ArcF Time) a
data EventF a b
= Event {context :: Context,
whole :: Maybe a,
part :: a,
value :: b}
An interesting thing to note here is that the query member is a function.
It takes a State which contains an Arc (and ValueMap), and returns a
list of events. A fun consequence is that we can create different effects by
manipulating the input or output of the query function.
Functions like withQueryArc can manipulate the time before resolving
the query, to query a different range of events than normal.
withQueryArc :: (Arc -> Arc) -> Pattern a -> Pattern a
withQueryArc f pat = pat {query = query pat . (\(State a m) -> State (f a) m)}
Functions like withResultArc are able to manipulate the time after resolving
the query instead:
withResultArc :: (Arc -> Arc) -> Pattern a -> Pattern a
withResultArc f pat =
pat
{ query = map (\(Event c w p e) -> Event c (f <$> w) (f p) e) . query pat
}
fast and slow work by combining both of these effects at the same time.
We can create ControlPatterns from simpler patterns too: tidal defines a
few different helper functions for defining our own parameters.
gain :: Pattern Double -> ControlPattern
gain = pF "gain"
It places each value v from the pattern into a map { gain: v } using
the pF helper function. The F stands for float, but we can choose other
value types:
pF :: String -> Pattern Double -> ControlPattern
pF name = fmap (Map.singleton name . VF)
pI :: String -> Pattern Int -> ControlPattern
pI name = fmap (Map.singleton name . VI)
pB :: String -> Pattern Bool -> ControlPattern
pB name = fmap (Map.singleton name . VB)
pR :: String -> Pattern Rational -> ControlPattern
pR name = fmap (Map.singleton name . VR)
pN :: String -> Pattern Note -> ControlPattern
pN name = fmap (Map.singleton name . VN)
pS :: String -> Pattern String -> ControlPattern
pS name = fmap (Map.singleton name . VS)
tidal also has a handy helper function for allowing : separated lists
in mininotation which to become ValueMaps:
grp :: [String -> ValueMap] -> Pattern String -> ControlPattern
grp [] _ = empty
grp fs p = splitby <$> p
where
splitby name = Map.unions $ map (\(v, f) -> f v) $ zip (split name) fs
split :: String -> [String]
split = wordsBy (== ':')
For example, tidal defines the sound in terms of grp:
sound :: Pattern String -> ControlPattern
sound = grp [mS "s", mF "n"]
With the use of some helper functions:
mF :: String -> String -> ValueMap
mF name v = fromMaybe Map.empty $ do
f <- readMaybe v
return $ Map.singleton name (VF f)
mI :: String -> String -> ValueMap
mI name v = fromMaybe Map.empty $ do
i <- readMaybe v
return $ Map.singleton name (VI i)
mS :: String -> String -> ValueMap
mS name v = Map.singleton name (VS v)
Note the helper functions mF and mS which operate like pF and pS, except
that they parse the value from a String, and create a ValueMap instead of a
Pattern ValueMap.
If we break down an exmaple pattern:
sound "arpy:0"
We can manually applying the transformations grp applies to each event,
1-by-1:
- Initial value
"arpry:0" - Split into parts,
["arpy", "0"] - Convert to maps,
"[{s: "apry"}, {n: 0}]" - Union the maps
{s: "arpy", n: 0}
And thus we arrive at a full ControlPattern. If we omit the :0 part, the
ValueMap will not contain the n: 0 key-value pair.
Unions in 2D
tidal's ControlPatterns have 2 dimensions:
Values associated with namesArcs ofTime,
Lets revisit the # operator.
In plain words, # will apply the controls from the right to all events on the
left. Let's confirm this by looking at its implementation:
(#) :: Unionable b => Pattern b -> Pattern b -> Pattern b
(#) = (|>)
(|>) :: (Unionable a) => Pattern a -> Pattern a -> Pattern a
a |> b = flip union <$> a <* b
(<*) :: Pattern (a -> b) -> Pattern a -> Pattern b
(<*) a b = keepSteps a $ applyPatToPatLeft a b
applyPatToPatLeft :: Pattern (a -> b) -> Pattern a -> Pattern b
applyPatToPatLeft pf px = pattern q
where
q st = catMaybes $ concatMap match $ query pf st
where
match ef = map (withFX ef) (query px $ st {arc = wholeOrPart ef})
withFX ef ex = do
let whole' = whole ef
part' <- subArc (part ef) (part ex)
return (Event (combineContexts [context ef, context ex]) whole' part' (value ef $ value ex))
<$> will change the Pattern of values to a Pattern of functions, and
applyPatToPatLeft will:
- Iterate the events from the
Patternof functions - query the right
Patternwith the arc from the leftEvent - apply the function to the first matching value in the query list
ValueMap implements the Unionable typeclass (interface) by merging the maps
together. If the same key exists in both, it prefers the one from the left.
However, if we flip union, we prefer values from the right, creating the
"overriding" behavior.
We could have, however, chosen to prefer structure from the right and values
from the left. In fact, tidal defines an operator each of the 4 possible ways
to merge two ControlPatterns:
| Operator | Value | Structure |
|---|---|---|
|> | Right | Left |
|< | Left | Left |
<| | Left | Right |
>| | Right | Right |
Applying our knowledge
Knowing all this, lets try do define a handy function for controlling a drum machine.
I use the Pulsar-23 as my live-performance drum machine. I sequence it with
tidal via a midi connection. I map each instrument to the same note
on different midi channels. A basic patch looks something like this:
-- bass (melodic)
d4 $ note "- g . g f a -" # sound "pulsar" # midichan 0
-- bass drum
d1 $ note "0*4" # sound "pulsar" # midichan 1 # gain 1.8 # legato 0.4
-- snare drum
d2 $ note "[- 0]*2" # sound "pulsar" # midichan 2 # gain 1.8 # legato 0.2
-- high hat
d3 $ note "[- 0]*4" # sound "pulsar" # midichan 3 # gain 1.8 # legato 0.1
Some observations:
- It's not really obvious from looking at it which one is which
- This particular setup makes it difficult to write a pattern with events for more than 1 voice.
- I boost the
gainto compensate for a quirk of how the Pulsar-23 handles midi velocity, and howSuperDirttranslatesgainto velocity. - I shorten the the
legato(sustain) because the Pulsar-23 uses Attack-Sustain-Release envelopes. The defaultlegatowill cause the drums to stay open and drone instead of sounding percussive. - I tuned the
gainandlegatoto sound better for each different voice
We can use a handy function called inhabit that can convert a more friendly
and flexible Pattern String into a ControlPattern from a lookup table:
inhabit :: [(String, Pattern a)] -> Pattern String -> Pattern a
inhabit ps p = squeezeJoin $ (\s -> fromMaybe silence $ lookup s ps) <$> p
To define our drums function:
drums = inhabit
[ ("bd", note 0 # sound "pulsar" # midichan 1 # gain 1.8 # legato 0.4)
, ("sd", note 0 # sound "pulsar" # midichan 2 # gain 1.8 # legato 0.2)
, ("hh", note 0 # sound "pulsar" # midichan 3 # gain 1.8 # legato 0.1)
]
d1 $ drums "bd sd bd sd"
d2 $ drums "[- hh]*4"
This is already much better and much easier to manipulate live! However, some
of the secret sauce in messing with drums comes from
d1 $ drums "bd sd bd sd" * legato "1 0.8"
Note that we have to multiply the legato and not use the # operator, since
we will override pre-applied scaling.
Challenge: Inline Accents
Here's a fun challenge, can you improve the drums function to make it possible
to
- Specify the
legato/accent inline like in the example below? - Optionally specify the
gainas well? - Preserve different scaling for the
gainandlegatoparameters for each voice?
d3 $ drums "<hh hh:0.8 hh:0.5>*8"
It's much easier to think in terms of 1.0 being a full accent for a particular
voice, without the need to mentally convert the scale for each.
My Solution
Did you already attempt the challenge your own way?
My first attempt was to use grp! Let's check its type again:
grp :: [String -> ValueMap] -> Pattern String -> ControlPattern
It takes a list of functions which will produce a ValueMap and converts
a Pattern String to a ControlPattern. A first instinct might be to try
doing this:
drums pat = grp [mS "???", mF "gain", mF "legato"] pat
# sound "plusar" # note "0"
But it quickly becomes clear that the handy mS helper will not work: we
don't want a ValueMap with the string as the value, but a different base
ValueMap for each voice name.
So instead, we can define our own function taking the idea from inhabit to look
up our values from a table:
type ParamList = [(String, Value)]
drumMachine :: [(String, ParamList)] -> Pattern String -> ControlPattern
drumMachine voices = grp [ base , mF "legato", mF "gain" ]
where
-- Create base ValueMap from the voice table
base :: String -> ValueMap
base voice = fromMaybe Map.empty $ do
baseParams <- lookup voice voices
return $ Map.fromList baseParams
The heavy lifting here comes from Map.fromList, which we can use to turn a
list of (String, Value) into a ValueMap.
We can use the drumMachine helper function to define our concrete drums
like so:
-- | Control the pulsar drum machine via MIDI
--
-- > drums "bd hh hh sd . bd sd"
drums :: Pattern String -> ControlPattern
drums pat = drumMachine voices pat # sound "pulsar" # n "0"
where
params c l g =
[("midichan", (VI c)), ("legato", (VF l)), ("gain", (VF (g)))]
voices =
[ ("bd", params 1 0.4 2)
, ("sd", params 2 0.1 1.8)
, ("hh", params 3 0.1 1.8)
]
But we run into a catch! grp uses Map.unions to combine the ValueMap
defined by mF, which does not preserve the scaling of legato and gain.
I found a couple of helpful functions that could help:
The Map module defines Map.unionsWith which accepts a user function for
combining the values, and a tidal function called fNum2:
fNum2 :: (Int -> Int -> Int) -> (Double -> Double -> Double) -> Value -> Value -> Value
fNum2 fInt _ (VI a) (VI b) = VI (fInt a b)
fNum2 _ fFloat (VF a) (VF b) = VF (fFloat a b)
fNum2 _ fFloat (VN (Note a)) (VN (Note b)) = VN (Note $ fFloat a b)
fNum2 _ fFloat (VF a) (VN (Note b)) = VN (Note $ fFloat a b)
fNum2 _ fFloat (VN (Note a)) (VF b) = VN (Note $ fFloat a b)
fNum2 _ fFloat (VI a) (VF b) = VF (fFloat (fromIntegral a) b)
fNum2 _ fFloat (VF a) (VI b) = VF (fFloat a (fromIntegral b))
fNum2 fInt fFloat (VState a) b = VState (fmap (\a' -> fNum2 fInt fFloat a' b) . a)
fNum2 fInt fFloat a (VState b) = VState (fmap (fNum2 fInt fFloat a) . b)
fNum2 _ _ x _ = x
From this we can glean that fNum2 lifts an arithmetic operator to operate
on any compatible pair of Values, keeping the value on the right otherwise.
We can do this for multiplication with my favorite line of haskell I've ever
written:
fNum2 (*) (*) -- I can't unsee the tits
So I created a custom version of the grp function that uses unionsWith:
-- | Like @grp@, but combines each `ValueMap` with a union function `u`
grpWith ::
-- | Union function
(Value -> Value -> Value)
-> [String -> ValueMap]
-> Pattern String
-> ControlPattern
grpWith u fs p = splitby <$> p
where
splitby :: String -> ValueMap
splitby s = Map.unionsWith u
$ map (\(v, f) -> f v) $ zip (split s) fs
split :: String -> [String]
split = wordsBy (== ':')
And updated my drumMachine helper to use the tits:
drumMachine :: [(String, ParamList)] -> Pattern String -> ControlPattern
drumMachine voices = grpWith (fNum2 (*) (*)) [ base , mF "legato" , mF "gain" ]
where
-- Create base ValueMap from the voice table
base :: String -> ValueMap
base voice = fromMaybe Map.empty $ do
baseParams <- lookup voice voices
return $ Map.fromList baseParams
Now instead of overriding, the legato and gain parameters scale the base
value via multiplication.
I used this version of drumMachine to control my Pulsar-23 during the show,
and it was really fun and expressive. So there we have it, I hope you are
inspired to create your own helper functions.
If you want another excercise, try to make a version of scale which works on a
ControlPattern instead of a Pattern Int.
scale :: Pattern String -> ControlPattern -> ControlPatternLicense
Tidal Cycles is licensed under the GPL v3.0:
{-
UI.hs - Tidal's main 'user interface' functions, for transforming
patterns, building on the Core ones.
Copyright (C) 2025, Alex McLean and contributors
This library is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This library is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with this library. If not, see <http://www.gnu.org/licenses/>.
-}
SuperDirt uses the GNU General Public License Version 2.
Snippets included in this post are presented and relicensed under the terms of the compatible Affero GNU General Public License version 3.