tidal-innards(1)

glfmn.io tidal-innards(1)
Name

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, is a tool for writing code that makes music. Here's an example:

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. It is one of the original tools for livecoding: a type of performance art where the artist writes and shares code live that produces music, visuals, poetry, coreography and more.

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 tidal instance. 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 d1 being of type void but declaring that it has IO side 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:

library/default-synths-extra.scd
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.

tidal-core/src/Sound/Tidal/Pattern.hs
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:

tidal-core/src/Sound/Tidal/Pattern.hs
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.

tidal-core/src/Sound/Tidal/Params.hs
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:

tidal-core/src/Sound/Tidal/Params.hs
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:

tidal-core/src/Sound/Tidal/Params.hs
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:

tidal-core/src/Sound/Tidal/Params.hs
sound :: Pattern String -> ControlPattern
sound = grp [mS "s", mF "n"]

With the use of some helper functions:

tidal-core/src/Sound/Tidal/Params.hs
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:

  1. Initial value "arpry:0"
  2. Split into parts, ["arpy", "0"]
  3. Convert to maps, "[{s: "apry"}, {n: 0}]"
  4. 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:

  1. Values associated with names
  2. Arcs of Time,

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:

  1. Iterate the events from the Pattern of functions
  2. query the right Pattern with the arc from the left Event
  3. 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:

OperatorValueStructure
|>RightLeft
|<LeftLeft
<|LeftRight
>|RightRight

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:

  1. It's not really obvious from looking at it which one is which
  2. This particular setup makes it difficult to write a pattern with events for more than 1 voice.
  3. I boost the gain to compensate for a quirk of how the Pulsar-23 handles midi velocity, and how SuperDirt translates gain to velocity.
  4. I shorten the the legato (sustain) because the Pulsar-23 uses Attack-Sustain-Release envelopes. The default legato will cause the drums to stay open and drone instead of sounding percussive.
  5. I tuned the gain and legato to 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:

tidal-core/src/Sound/Tidal/UI.hs
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 accent patterns; that is, changing the velocity/gain or gate-length of the drums to accentuate certain parts of the beat.

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

  1. Specify the legato/accent inline like in the example below?
  2. Optionally specify the gain as well?
  3. Preserve different scaling for the gain and legato parameters 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:

tidal-core/src/Sound/Tidal/Pattern.hs
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 -> ControlPattern

License

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.