Markov chains for beginners - Part 3

Finally, the last bit on that matter, concluding the trilogy on Markov chains (Markov chains for beginners, Markov chains for beginners - Part 2). This one is for our friends with an interest in rhythms rather than in melodies and harmonies. First the code, then the explanation:

use_debug false
use_bpm 110
use_random_seed 9

# base drum
live_loop :bd do
  4.times do
    sample :bd_sone
    sleep 1
  end
end

# drum patterns for snare
patterns = [
  #1   2   3   4
  "--------------xx", # 0
  "--x---x---x---x-", # 1
  "--x---x---x---xx", # 2
  "--x---x---xx--xx", # 3
  "--x---xx--xx--xx", # 4
  "--xx--xx--xx--xx", # 5
  "--x---x---x----x", # 6
  "--x---x----x---x", # 7
  "--x----x---x---x", # 8
  "---x---x---x---x", # 9
  "---x---x---x--xx", # 10
  "---x---x--xx--x-", # 11
  "---x--xx--x---x-", # 12
  "--xx--x---x---x-", # 13
  "-xxx-xxx-xxx-xxx", # 14
  "xxxxxxxxxxxxxxxx", # 15
  "--x---x---xx--x-", # 16
  "--x---xx--x---x-", # 17
  "--xx--x---x---x-", # 18
  "--x---x----x--x-", # 19
  "--x----x--x---x-", # 20
  "---x--x---x---x-", # 21
]

# markov matrix as 2-dimensional hash map
mm = Hash.new
patterns.length.times do |i|
  mm[i] = Hash.new
end

# mm[n][m] = p: if playing pattern n, then with probability p play as next pattern m
mm[0][0]   = 0.2
mm[0][1]   = 0.6
mm[0][14]  = 0.2
mm[1][1]   = 0.4
mm[1][2]   = 0.2
mm[1][6]   = 0.2
mm[1][14]  = 0.2
mm[2][1]   = 0.2
mm[2][3]   = 0.4
mm[2][16]  = 0.4
mm[3][4]   = 1.0
mm[4][5]   = 1.0
mm[5][1]   = 0.2
mm[5][14]  = 0.6
mm[5][15]  = 0.2
mm[6][1]   = 0.2
mm[6][7]   = 0.4
mm[6][19]  = 0.4
mm[7][8]   = 1.0
mm[8][9]   = 1.0
mm[9][5]   = 0.3
mm[9][10]  = 0.5
mm[9][14]  = 0.2
mm[10][9]  = 0.2
mm[10][11] = 0.8
mm[11][12] = 1.0
mm[12][13] = 1.0
mm[13][1]  = 0.2
mm[13][5]  = 0.2
mm[13][2]  = 0.2
mm[13][14] = 0.2
mm[13][15] = 0.2
mm[14][1]  = 0.2
mm[14][5]  = 0.2
mm[14][15] = 0.6
mm[15][0]  = 0.3
mm[15][1]  = 0.5
mm[15][14] = 0.2
mm[16][17] = 1.0
mm[17][18] = 1.0
mm[18][1]  = 0.3
mm[18][2]  = 0.3
mm[18][5]  = 0.4
mm[19][20] = 1.0
mm[20][21] = 1.0
mm[21][1]  = 0.2
mm[21][18] = 0.4
mm[21][14] = 0.4

# make sure each row sum is 1.0
mm.each do |i, row|
  pp = 0
  row.each do |c, p|
    pp += p
  end
  row.each do |c, p|
    row[c] /= pp
  end
end

# function to get next index of pattern according to markov matrix
define :next_idx do |current_idx|
  next_idx = 0
  r = rand
  pp = 0
  mm[current_idx].each do |c, p|
    next_idx = c
    pp += p
    break if pp > r
  end
  return next_idx
end

n = 0
live_loop :snare do
  with_fx :gverb, room: 20, dry: 2, mix: 0.1 do
    puts patterns[n]
    tick_reset(:p)
    16.times do
      #sample :elec_hi_snare, rate: 1.8, amp: 0.8 if (patterns[n][tick(:p)]=="x")
      #sample :drum_snare_soft if (patterns[n][tick(:p)]=="x")
      sample :sn_dolf, sustain: 0.05, release: 0.03, hpf: 70 if (patterns[n][tick(:p)]=="x")
      sleep 4.0/16
    end
    n = next_idx(n)
  end
end

There are 22 different patterns for the snare drum. If we would use a 2-dimensional array for the markov matrix, we would need a 22x22 matrix with 484 entries. But I wanted to use only about 44 transitions between the 22 patterns. So, my 22x22 matrix would contain 44 entries > 0 and 440 zeros.

Therefore, I decided to use a different representation of the markov matrix: a 2-dimensional hash map. Using a hash map it is not necessary to write down all the entries with 0, just those with probabilities >0. The hash map is initialized as an empty map using this piece of code:

# markov matrix as 2-dimensional hash map
mm = Hash.new
patterns.length.times do |i|
  mm[i] = Hash.new
end

Then, the probabilities for the pattern transitions can be defined like

mm[0][1]   = 0.6

The interpretation is simple: If pattern 0 is played, then play as next pattern number 1 with a probability of 0.6.

As a convenience function there is the normalization of the rows to a total probability of 1.0. Therefore, you do not have to do the math very precicely in your head, the program will fix it.

Please also note that the function to select the next pattern is slightly different from the one in the previous posts. Using a hash map requires an adaption in one of the lines.

The main idea behind all those probabilities is to define paths where the sequence of patterns exhibits some development. For example, if there is some irregularity on beat 4, then it is a good idea in the next pattern to shift this irregularity to beat 3, then 2 and then 1. Also, a crescendo is followed by a more calm pattern.

1 Like

There was a new idea from @amiika in this post: Req: a function that indexes all Markov matrices.

The idea is to build matrices not by explicitely defining probabilities but by writing down the sequences you would like to hear. I have used this idea to demonstrate how it could be used as an alternative to the above code:

use_debug false
use_bpm 110
use_random_seed 9

# base drum
live_loop :bd do
  4.times do
    sample :bd_sone
    sleep 1
  end
end

# helper functions for matrix creation

define :to_mm do |idx, mm=8|
  # Length of the markov matrix
  length = mm.is_a?(Integer) ? mm : mm.length
  # Init with random matrix if mm=integer
  mm = Array.new(length) { Array.new(length, 0.0) } if mm.is_a?(Integer)
  # Treat integer as a markov chain: 121 = 1->2, 2->1
  degrees = idx.split("-")
  degrees.push(degrees[0]) if degrees.length==1
  degrees.each_with_index do |d,i|
    if degrees[i+1]
      # Overflow depending on mm length: 8->0, 9->1, 0->2
      row = (d.to_i==0 ? length : d.to_i-1)%length
      column = (degrees[(i+1)].to_i-1)%length
      mm[row][column] += 1.0
    end
  end
  mm
end

define :print_matrix do |mm|
  mm.each do |row|
    r = ""
    row.each do |c|
      r += "#{c.round(1)} "
    end
    puts r
  end
end


define :normalize do |mm|
  mm.length.times do |row|
    pp = 0.0
    mm[row].each do |p|
      pp += p
    end
    mm[row].length.times do |i|
      if pp == 0.0 then
        puts "warning: no transition defined for row #{row+1}!" if i == 0
        mm[row][i] = 1.0/mm[row].length
      else
        mm[row][i] /= pp
      end
    end
  end
  mm
end

# function to get next index according to markov matrix mm
define :next_idx do |current_idx, mm|
  n = 0
  r = rand
  pp = 0
  mm[current_idx].each do |p|
    pp += p
    break if pp > r
    n += 1
  end
  return n
end

# drum patterns for snare
patterns = [
  #1   2   3   4
  "--------------xx", # 1
  "--x---x---x---x-", # 2
  "--x---x---x---xx", # 3
  "--x---x---xx--xx", # 4
  "--x---xx--xx--xx", # 5
  "--xx--xx--xx--xx", # 6
  "--x---x---x----x", # 7
  "--x---x----x---x", # 8
  "--x----x---x---x", # 9
  "---x---x---x---x", # 10
  "---x---x---x--xx", # 11
  "---x---x--xx--x-", # 12
  "---x--xx--x---x-", # 13
  "--xx--x---x---x-", # 14
  "-xxx-xxx-xxx-xxx", # 15
  "xxxxxxxxxxxxxxxx", # 16
  "--x---x---xx--x-", # 17
  "--x---xx--x---x-", # 18
  "--xx--x---x---x-", # 19
  "--x---x----x--x-", # 20
  "--x----x--x---x-", # 21
  "---x--x---x---x-", # 22
]

mm = to_mm "1-2-2-2-3-4-5-6-2", patterns.length  # initialization with number of patterns = 22
mm = to_mm "2-7-8-9-10-11-12-13-14-2", mm
mm = to_mm "7-2", mm
mm = to_mm "2-11-12-13-14-2", mm
mm = to_mm "14-15-16-2", mm
mm = to_mm "16-15-16-2", mm
mm = to_mm "2-3-17-18-19-2", mm
mm = to_mm "7-20-21-22-2", mm
mm = to_mm "10-15", mm
mm = to_mm "16-1", mm
mm = to_mm "14-6", mm
mm = to_mm "10-6", mm
mm = to_mm "6-15", mm

normalize mm
#print_matrix mm

n = 0
live_loop :snare do
  with_fx :gverb, room: 20, dry: 2, mix: 0.1 do
    puts patterns[n]
    tick_reset(:p)
    16.times do
      #sample :elec_hi_snare, rate: 1.8, amp: 0.8 if (patterns[n][tick(:p)]=="x")
      #sample :drum_snare_soft if (patterns[n][tick(:p)]=="x")
      sample :sn_dolf, sustain: 0.05, release: 0.03, hpf: 70 if (patterns[n][tick(:p)]=="x")
      sleep 4.0/16
    end
    n = next_idx(n, mm)
  end
end

The actual composition is concentrated in these lines (other code is just needed for the execution) :

patterns = [
  #1   2   3   4
  "--------------xx", # 1
  "--x---x---x---x-", # 2
  "--x---x---x---xx", # 3
  "--x---x---xx--xx", # 4
  "--x---xx--xx--xx", # 5
  "--xx--xx--xx--xx", # 6
  "--x---x---x----x", # 7
  "--x---x----x---x", # 8
  "--x----x---x---x", # 9
  "---x---x---x---x", # 10
  "---x---x---x--xx", # 11
  "---x---x--xx--x-", # 12
  "---x--xx--x---x-", # 13
  "--xx--x---x---x-", # 14
  "-xxx-xxx-xxx-xxx", # 15
  "xxxxxxxxxxxxxxxx", # 16
  "--x---x---xx--x-", # 17
  "--x---xx--x---x-", # 18
  "--xx--x---x---x-", # 19
  "--x---x----x--x-", # 20
  "--x----x--x---x-", # 21
  "---x--x---x---x-", # 22
]

mm = to_mm "1-2-2-2-3-4-5-6-2", patterns.length  # initialization with number of patterns = 22
mm = to_mm "2-7-8-9-10-11-12-13-14-2", mm
mm = to_mm "7-2", mm
mm = to_mm "2-11-12-13-14-2", mm
mm = to_mm "14-15-16-2", mm
mm = to_mm "16-15-16-2", mm
mm = to_mm "2-3-17-18-19-2", mm
mm = to_mm "7-20-21-22-2", mm
mm = to_mm "10-15", mm
mm = to_mm "16-1", mm
mm = to_mm "14-6", mm
mm = to_mm "10-6", mm
mm = to_mm "6-15", mm

First, you define the patterns that are possible and then you write down the sequences that should be used. The more often a certain transition is used (such as 2-2) the more likely it is to happen.

1 Like