Functions for custom scales

The original reason I tried Sonic Pi was for its xenharmonic capabilities. I’ve written a bunch of variations of functions for building scales, but these are probably more useful for how we usually use Sonic Pi.

# Equal Temperament, edx = "equal divisions of x"
define :edx do
  | mode = [2,2,1,2,2,2],
    tet = 12,
    equave = 2,
    root = 60 |
  notes = [midi_to_hz(root)]
  for i in 0..(mode.length-1) do
    notes = notes.append(notes[i] * equave ** (1.0*mode[i] / tet))
  end
  notes.ring.map { |n| hz_to_midi(n) }
end

# Just Intonation
define :ji do
  | values = [9/8.0, 5/4.0, 4/3.0, 3/2.0, 5/3.0, 15/8.0],
    root = 60 |
  notes = [midi_to_hz(root)]
  for i in 0..(values.length - 1) do
    notes = notes.append(notes[0] * values[i])
  end
  notes.ring.map { |n| hz_to_midi(n) }
end

# convert midi to sample pitch: values
define :midi_pitch do |notes, center=nil|
  if center.nil?
    center = notes[0] + (notes[notes.length - 1] - notes[0]) / 2
  end
  
  notes.map { |n| n - center }
end


# Usage examples

use_synth :bass_foundation

# Bohlen-Pierce scale, equal tempered (13 equal divisions of tritave)
bp = edx([1]*23, 13, 3, 52)

# Bohlen-Pierce, Moll I (decatonic mode)
moll1 = edx([2,1,2,1,1,2,1,2,1], 13, 3, 60)

# Diatonic 12 TET
play_pattern(edx(), sustain: 0.75)
sleep 0.5

# Diatonic 12 JI
play_pattern(ji(), sustain: 0.75)
sleep 0.5

# `sample` example
10.times do
  sample :bass_thick_c, pitch: midi_pitch(moll1).shuffle.tick(:tic1), amp: 1
  sleep 1.5
end
sleep 2

# BP Just Intonation
bpj = ji([27/25.0,25/21.0,9/7.0,7/5.0,75/49.0,5/3.0,9/5.0,49/25.0,15/7.0,7/3.0,63/25.0,25/9.0,3/1.0],
         48)
play_pattern_timed(bpj.values_at(6, 4, 8, 9) + [:r] + bpj.values_at(3, 2, 4, 6),
                   [1, 0.5, 0.5, 0.5, 0.5])

sleep 1

# 7 EDO/TET
t7 = edx([1]*10, 7, 2, 54)
play_pattern_timed(t7.values_at(6, 4, 8, 9) + [:r] + t7.values_at(3, 2, 4, 6),
                   [1, 0.5, 0.5, 0.5, 0.5])

Update: A function for finding rational approximations of values above 1.
val is a float, the value to be approximated.
stop is the largest allowed denominator.
match is the minimum proximity to val required for inclusion as an approximation.
Returns an array of size three arrays:
best_ratios[i][0] is the numerator of the rational.
best_ratios[i][1] is the denominator of the rational.
best_ratios[i][2] is a decimal representation of the rational.

define :rationalize do |val, stop=17, match=0.1|
  n = 2.0
  d = 1.0
  
  # Initialize
  best = (n/d - val).abs
  init = true
  n2 = n
  while init do
    n2 += 1
    new = (n2/d - val).abs
    if (new > best)
      init = false
    else
      n = n2
    end
  end
  if best <= match
    best_ratios = [[n,d,n/d]]
  else
    best_ratios = []
  end
  
  # Main loop
  while d < stop do
    update = false
    n += 1
    d += 1
    ratio = n/d
    if (ratio - val).abs < best
      update = true
    elsif ratio.abs < val
      n += 1
      ratio = n/d
      if (ratio - val).abs < best
        update = true
      end
    elsif ratio > val
      n -= 1
      ratio = n/d
      if (ratio - val).abs < best
        update = true
      end
    end
    if update
      best = (ratio - val).abs
      if best <= match
        best_ratios = best_ratios.append([n,d,ratio])
      end
    end
  end
  
  best_ratios
end

Any feedback for better functions and usage are appreciated.

I can’t offer any help but I do wanna say thanks! This is super cool and I’m messing with it a bit to help with some ear training – would love to get a better sense of different tunings, etc.

Thanks for sharing!

1 Like
midinumber=[:r]                 # midinumber[0]=rest
for justnumber in 1..255        # tune to A 440
  midinumber[justnumber]=hz_to_midi(11*justnumber)
end

scale=[24,27,30,32,36,40,45,48] # just scale

for i in 0..7
  play midinumber[scale[i]]     # test scale
  sleep 0.5
end
1 Like

@hitsware These are frequencies that scale arithmetically. Could you explain the idea further?

I extended the functions to allow building a complete scale. I didn’t like making an options hash map.
Here “tile” is the size of the equave.

define :edx do
  | mode = [2,2,1,2,2,2],
    tet = 12,
    equave = 2,
    root = 60,
    tile = false |
  notes = [midi_to_hz(root)]
  for i in 0..(mode.length-1) do
    notes = notes.append(notes[i] * equave ** (1.0*mode[i] / tet))
  end
  
  if tile != false
    initial_notes = notes
    current_notes = initial_notes
    done = false
    
    while done != true do
      current_notes = current_notes.map { |n| n/tile }
      notes = current_notes + notes
      if notes[0] < midi_to_hz(0)
        s = 0
        while notes[s] < midi_to_hz(0) do
          s += 1
        end
        notes = notes.drop(s+1)
        done = true
      end
    end
    
    current_notes = initial_notes
    done = false
    
    while done != true do
      current_notes = current_notes.map { |n| n*tile }
      notes = notes + current_notes
      if notes[notes.length-1] >= midi_to_hz(128)
        s = notes.length - 1
        while notes[s] >= midi_to_hz(128) do
          s -= 1
        end
        notes = notes.take(s+1)
        done = true
      end
    end
  end
  
  notes.ring.map { |n| hz_to_midi(n) }
end

# Just Intonation
define :ji do
  | values = [9/8.0, 5/4.0, 4/3.0, 3/2.0, 5/3.0, 15/8.0],
    root = 60,
    tile = false |
  notes = [midi_to_hz(root)]
  for i in 0..(values.length - 1) do
    notes = notes.append(notes[0] * values[i])
  end
  
  if tile != false
    initial_notes = notes
    current_notes = initial_notes
    done = false
    
    while done != true do
      current_notes = current_notes.map { |n| n/tile }
      notes = current_notes + notes
      if notes[0] < midi_to_hz(0)
        s = 0
        while notes[s] < midi_to_hz(0) do
          s += 1
        end
        notes = notes.drop(s+1)
        done = true
      end
    end
    
    current_notes = initial_notes
    done = false
    
    while done != true do
      current_notes = current_notes.map { |n| n*tile }
      notes = notes + current_notes
      if notes[notes.length-1] >= midi_to_hz(128)
        s = notes.length - 1
        while notes[s] >= midi_to_hz(128) do
          s -= 1
        end
        notes = notes.take(s+1)
        done = true
      end
    end
  end
  
  notes.ring.map { |n| hz_to_midi(n) }
end

We could also make tile be its own function:

define :tile do |notes, tile = 2|
  notes = notes.map { |n| midi_to_hz(n) }
  
  initial_notes = notes
  current_notes = initial_notes
  done = false
  
  while done != true do
    current_notes = current_notes.map { |n| n/tile }
    notes = current_notes + notes
    if notes[0] < midi_to_hz(0)
      s = 0
      while notes[s] < midi_to_hz(0) do
        s += 1
      end
      notes = notes.drop(s+1)
      done = true
    end
  end
  
  current_notes = initial_notes
  done = false
  
  while done != true do
    current_notes = current_notes.map { |n| n*tile }
    notes = notes + current_notes
    if notes[notes.length-1] >= midi_to_hz(128)
      s = notes.length - 1
      while notes[s] >= midi_to_hz(128) do
        s -= 1
      end
      notes = notes.take(s+1)
      done = true
    end
  end
  
  notes.ring.map { |n| hz_to_midi(n) }
end
midinumber=[:r]#                                  midinumber[0]=rest
for justnumber in 1..255
  midinumber[justnumber]=hz_to_midi(11*justnumber)
end#                                              from you ....
values = [1/1, 9/8, 5/4, 4/3, 3/2, 5/3, 5/8, 2/1]
#                                                 to simplify your ' values '
#                                                 find a common denominator
values2=[24/24, 27/24, 30/24, 32/24, 36/24,
         40/24, 45/24, 48/24]
#                                                  removing denominator gives
#                                                  frequency ratios
#                                                  since 40 corresponds to A ...
#                                                  multiply everything by 11
#                                                  for A 440 Hz
#                                                  hz_to_midi gives midi note numbers
scale=[24,27,30,32,36,40,45,48]#                   just scale

for i in 0..7
  play midinumber[scale[i]]#                      test scale
  sleep 0.5
end
1 Like

Ok. This creates a lot of unused values, but it looks like it can extend to the entire midi set.

Edit:
Seems I can only extend in one direction. Need to use fractions going backwards where there are then not enough notes.

midinumber=[:r]#                                  midinumber[0]=rest
for justnumber in 1..255
  midinumber[justnumber]=hz_to_midi(11*justnumber)
end

scale=[24,27,30,32,36,40,45,48,54,60,64,72,80,90,96] #  just scale

for i in 0..14
  play midinumber[scale[i]]#                      test scale
  sleep 0.5
end

Ok. This creates a lot of unused values,
but it looks like it can extend to the entire midi set.

Yes … And fractional midi numbers

Seems I can only extend in one direction.
Need to use fractions going backwards where
there are then not enough notes.

For lower notes ? … the 11 can become 5.5 or 2.75
Also the 255 can be any number …

1 Like

This is great!

I created a function a while ago to do the same thing, but your’s is better :slight_smile:

define :micro_tune do |note, is_sample|
  # Bohlen–Pierce
  cents = [0,
           146.30,
           292.61,
           438.91,
           585.22,
           731.52,
           877.83,
           1024.13,
           1170.44,
           1316.74,
           1463.05,
           1609.35]
  
  tuned_notes = []
  idx = 0
  scale_length = 12
  
  if !note.kind_of?(Integer)
    note.each do |n|
      idx = n - (scale_length * (n / scale_length))
      tuned_notes.push(n + (cents[idx] - (idx * 100)) / 100)
    end
  else
    idx = note - (scale_length * (note / scale_length))
    tuned_notes.push(note + (cents[idx] - (idx * 100)) / 100)
  end
  
  if is_sample
    return (cents[idx] - (idx * 100)) / 100
  else
    return tuned_notes
  end
end

live_loop :test_notes do
  use_synth :tri
  notes = scale(:c4, :chromatic)
  play micro_tune(notes.tick, false), amp: 1, sustain: 0.75
  
  sleep 1
end
1 Like

In xenharmonics, there’s not a wrong way to do things. I like seeing these different ideas.

I have used Sevish’s Scale Workshop. And we can reformat things as needed with regex or another language. (I like using Sublime Text to just find and replace stuff.)

I’ve had mixed feelings about setting :rest to the 0 index. But I just realized that if I choose a value greater than the max index, I get nil and that gets treated as a rest. So, a similar thing is already kind of built in.

Rest is handy for me since I use numbers for notes ,
so ’ 0 ’ gives no note …

Ah yes, I met Sean on Jamendo in the late 00’s. The scale workshop is very innovative, as is his music.

1 Like

If you like Scale Workshop, you might like this:

require "~/ziffers/ziffers.rb"

s = parse_scala '
17/16 + 0.709
9/8 + 1.729
19/16 - 6.365
5/4 + 1.8
21/16 + 2.508
11/8 + 2.704
3/2 - 8.153
13/8 + 5.894
27/16 + 6.167
7/4 + 8.899
15/8 + 8.429
2/1'

# Parsed scale in cents
print s

# Format to semitones
ss = cents_to_semitones(s)

Uses ugly hacks and monkeypatches but should work just fine. Let me know if there is any bugs :smiley:

Ziffers is numered musical notation, which can also be used for microtonal stuff, it’s not especially made for it so there could be some room for improvement:

# Scale can be parsed directly using scale with zplay & loops. 
# Remeber to use ' ' to escape \ character in strings
zplay '0 e 2 4 s 5 7', scale: '1\3 2\4 3\5', key: :d3
1 Like

Directly reading scala files is very nice.

It can parse scala but doesnt support scala headers and comments. To parse “old” scala file remove the ! and title & length lines. Other than that these should be supported:

  • To specify a ratio, simply write it in the format e.g. 3/2
  • To specify an interval in cents, include a . in the line e.g. 701.9 or 1200.
  • To specify n steps out of m-EDO, write it in the format n\m
  • To specify arbitrary EDJI values, write it in the format n\m<p/q>
  • To specify a decimal ratio, include a , in the line e.g. 1,5 or 1,25
  • To specify a monzo enclose the exponents inside a square bracket and a closing angle bracket e.g. [-1 1 0>
  • You can combine intervals using + e.g. 4/3 + 1.23

(from xenharmonic-devs/scale-workshop(github.com)

1 Like

i just checked this out & it’s really awesome! thanks for putting it out there

Added rationalize function.
Uploaded to GitHub: GitHub - CUBICinfinity/sonic_pi_resources: Scripts, Samples, Synths, etc. for Sonic Pi

1 Like