Optional arguments for define

I have been having trouble working out how to use define with optional arguments. I would like to make a number of standard synthesiser voices which I can configure when I use them.

E.g. the following code is a standard FM synth pad which I would like to be able to use with some of the defaults overridden. I couldn’t find much in the documentation or the forum about using optional arguments, (most likely because I didn’t look in the right place), but the following works.

Is there a better way of doing what I’m trying to do? I end up with a LOT of if statements and local variables.

use_bpm 120

define :fmpad do |note,values|
  
  a = 1
  if values[ :attack ] != nil
    a = values[ :attack ]
  end
  
  d = 0
  if values[ :decay ] != nil
    d = values[ :decay ]
  end
  
  s = 2
  if values[ :sustain ] != nil
    s = values[ :sustain ]
  end
  
  sl = 1
  if values[ :sustain_level ] != nil
    sl = values[ :sustain ]
  end
  
  r = 1
  if values[ :release ] != nil
    r = values[ :release ]
  end
  
  depth = 6
  
  if values[ :depth ] != nil
    depth = values[ :depth ]
  end
  
  div1 = 1/3.001
  div2 = 1/4.001
  div3 = 1/2.001
  
  if values[ :div1 ] != nil
    div1 = values[ :div1 ]
  end
  
  if values[ :div2 ] != nil
    div2 = values[ :div2 ]
  end
  
  if values[ :div3 ] != nil
    div3 = values[ :div3 ]
  end
  
  amp = 0.1
  
  if values[ :amp ] != nil
    amp = values[ :amp ]
  end
  
  use_synth :fm
  
  use_synth_defaults amp: amp, depth: 0, depth_slide: a * 2, attack: a,
    sustain: s, decay: d, sustain_level: sl, release: r
  
  sn1 = synth :fm, note: note, divisor: div1
  sn2 = synth :fm, note: note + 0.1, divisor: div2
  sn3 = synth :fm, note: note - 0.1, divisor: div3
  
  control sn1, depth: depth
  control sn2, depth: depth
  control sn3, depth: depth
  
end


tscale = (scale :c4, :dorian, num_octaves: 2 )

8.times do |n|
  
  fmpad tscale[n], sustain: 4, depth: 4, amp: 0.2
  fmpad tscale[n+2], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+4], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+6], sustain: 4, depth: 4, amp: 0.1
  
  sleep 6
  
end
1 Like

I had a play with this and the following code seems to work OK.
Basically you store all the optional args in a hash and then go through the default list of args (also stored in a hash) and update the values with those provided.
I passed in the three div values, and these will be stored in the synth defaults list, but will be ignored there. The three values (updated if any new ones are supplied) are extracted and stored in local variables div1,div2 and div3 which are then used to set the divisor: setting required. Similarly the supplied depth value is extracted to be used in the control lines in the code. I am not a Ruby programmer and there is probably a neater way to do the updating of the arguments, but this does seem to work.
I include some puts statements to indicate what is going on which you can comment out.

use_bpm 120
define :fmpad do |note,*args|
  defaultargs={attack: 1,decay: 0,sustain: 2,sustain_level: 1,
               release: 1,depth: 6,div1: 1/3.001,div2: 1/4.001,div3: 1/2.001,
               amp: 0.1 }
  
  use_synth :fm
  puts "default args #{defaultargs}"
  ag=args[0] #supplied optional arguments as a hash
  puts "Supplied args #{ag}"
  ag=defaultargs if ag==nil #no args supplied. Use all defaults
  defaultargs.length.times do |i| #update default args with those supplied
    if !ag.keys.include? defaultargs.keys[i]
      ag[defaultargs.keys[i]]=defaultargs[defaultargs.keys[i]]
    end
  end
  puts "updated args #{ag}"
  #extract local values for div1,div2,div3 and depth
  div1= ag[:div1]
  div2= ag[:div2]
  div3= ag[:div3]
  depth=ag[:depth]
  
  use_synth_defaults ag #ag contains hash of all option values (includes :div1-3 which are ignored)
  sn1 = synth :fm, note: note, divisor: div1
  sn2 = synth :fm, note: note + 0.1, divisor: div2
  sn3 = synth :fm, note: note - 0.1, divisor: div3
  
  control sn1, depth: depth
  control sn2, depth: depth
  control sn3, depth: depth
end


tscale = (scale :c4, :dorian, num_octaves: 2 )

8.times do |n|
  
  fmpad tscale[n], sustain: 4, depth: 4, amp: 0.2
  fmpad tscale[n+2], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+4], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+6], sustain: 4, depth: 4, amp: 0.1
  
  sleep 6
  
end

Usual proviso. This uses some Ruby constructs which are not necessarily guaranteed to work in future version of SP

EDIT oops I forgot to include depth_slide
New version below

use_bpm 120
define :fmpad do |note,*args|
  defaultargs={attack: 1,decay: 0,sustain: 2,sustain_level: 1,
               release: 1,depth: 6,div1: 1/3.001,div2: 1/4.001,div3: 1/2.001,
               amp: 0.1 }
  
  use_synth :fm
  puts "default args #{defaultargs}"
  ag=args[0] #supplied optional arguments as a hash
  puts "Supplied args #{ag}"
  ag=defaultargs if ag==nil #no args supplied. Use all defaults
  defaultargs.length.times do |i| #update default args with those supplied
    if !ag.keys.include? defaultargs.keys[i]
      ag[defaultargs.keys[i]]=defaultargs[defaultargs.keys[i]]
    end
  end
  
  #extract local values for div1,div2,div3 and depth
  div1= ag[:div1]
  div2= ag[:div2]
  div3= ag[:div3]
  depth=ag[:depth]
  ag[:depth_slide]=depth*ag[:attack] #add depth_slide value
  puts "updated args #{ag}"
  use_synth_defaults ag #ag contains hash of all option values (includes :div1-3 which are ignored)
  sn1 = synth :fm, note: note, divisor: div1
  sn2 = synth :fm, note: note + 0.1, divisor: div2
  sn3 = synth :fm, note: note - 0.1, divisor: div3
  
  control sn1, depth: depth
  control sn2, depth: depth
  control sn3, depth: depth
end


tscale = (scale :c4, :dorian, num_octaves: 2 )

8.times do |n|
  
  fmpad tscale[n], sustain: 4, depth: 4, amp: 0.2
  fmpad tscale[n+2], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+4], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+6], sustain: 4, depth: 4, amp: 0.1
  
  sleep 6
  
end
1 Like

Thank you very much. I haven’t programmed in Ruby, and didn’t know of many if not all of the methods that you used here. I’m now going to properly research your solution and see if I can come up with a reusable function to update arguments that I can use in different synth functions. (As I hope to create a variety of such functions.)

This principle works in general. Here is a simpler version

define :testArgs do |n,*args|
  adef={sustain: 1,release: 1} #default arguments
  puts "default args #{adef}" #default arguments
  ag=args[0] #supplied optional arguments
  puts "Supplied args #{ag}"
  ag=adef if ag==nil #no args supplied. Use all defaults
  adef.length.times do |i| #update default args with those supplied
    if !ag.keys.include? adef.keys[i]
      puts "add missing",adef.keys[i],adef[adef.keys[i]]
      ag[adef.keys[i]]=adef[adef.keys[i]]
    end
  end
  puts #updated args #{ag}"
  play n,ag
end
use_synth :fm
testArgs :D4,sustain: 2,attack: 1

n is supplied separately as you always need a note value. Anything else is optional, but some defaults are used if arguments you want are missing. This default list is then updated with anything you provide to give a complete list of arguments, default and optional.
You may find this reference on hashes useful
also this one

1 Like

Thanks. I wrote this version.

use_bpm 120

# Merge synth voice defaults and over-riding arguments
# into one hash

define :rationalise do |defs, args=nil|
  
  if args == nil
    args = defs
  else
    defs.length.times do |i|
      if !args.keys.include? defs.keys[i]
        args[defs.keys[i]]=defs[defs.keys[i]]
      end
    end
  end
  
  return args
end

define :fmpad do |note, args=nil|
  
  defaults = { attack: 1, decay: 0, sustain: 2, sustain_level: 1,
               release: 1, depth: 6, div1: 1/3.001, div2: 1/4.001, div3: 1/2.001,
               amp: 0.1 }
  
  ag = rationalise( defaults, args )
  
  #extract local values for div1,div2,div3 and depth
  div1= ag[:div1]
  div2= ag[:div2]
  div3= ag[:div3]
  depth=ag[:depth]
  ag[:depth_slide]=depth*ag[:attack] #add depth_slide value
  
  use_synth :fm
  use_synth_defaults ag #ag contains hash of all option values (includes :div1-3 which are ignored)
  
  sn1 = synth :fm, note: note, divisor: div1
  sn2 = synth :fm, note: note + 0.1, divisor: div2
  sn3 = synth :fm, note: note - 0.1, divisor: div3
  
  control sn1, depth: depth
  control sn2, depth: depth
  control sn3, depth: depth
end


tscale = (scale :c4, :dorian, num_octaves: 2 )

8.times do |n|
  
  fmpad tscale[n], sustain: 4, depth: 4, amp: 0.2
  fmpad tscale[n+2], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+4], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+6], sustain: 4, depth: 4, amp: 0.1
  
  sleep 6
  
end

I’ll look up the references on hashes, as it seems to be one of the bits of knowledge about Sonic Pi that I’m missing. (Though, with this and some other things I think I’ve made decent progress in the last 24 hours.) I’m not sure why you are putting the * on |note,*args|. But, I guess that I’m about to find out with your references :slight_smile:

1 Like

I’m not great on them either, but google is useful :slight_smile:
Using that I’ve just simplified further.

 use_bpm 120
define :fmpad do |note,*args|
  defaultargs={attack: 1,decay: 0,sustain: 2,sustain_level: 1,
               release: 1,depth: 6,div1: 1/3.001,div2: 1/4.001,div3: 1/2.001,
               amp: 0.1 }
  
  use_synth :fm
  puts "default args #{defaultargs}"
  ag=args[0] #supplied optional arguments as a hash
  puts "Supplied args #{ag}"
  ag=defaultargs if ag==nil #no args supplied. Use all defaults
  defaultargs.merge!(ag) #merge overwriting default with ag
  ag=defaultargs #set ag to new values
  
  #extract local values for div1,div2,div3 and depth
  div1= ag[:div1]
  div2= ag[:div2]
  div3= ag[:div3]
  depth=ag[:depth]
  ag[:depth_slide]=depth*ag[:attack] #add depth_slide value
  puts "updated args #{ag}"
  use_synth_defaults ag #ag contains hash of all option values (includes :div1-3 which are ignored)
  sn1 = synth :fm, note: note, divisor: div1
  sn2 = synth :fm, note: note + 0.1, divisor: div2
  sn3 = synth :fm, note: note - 0.1, divisor: div3
  
  control sn1, depth: depth
  control sn2, depth: depth
  control sn3, depth: depth
end


tscale = (scale :c4, :dorian, num_octaves: 2 )

8.times do |n|
  
  fmpad tscale[n], sustain: 4, depth: 4, amp: 0.2
  fmpad tscale[n+2], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+4], sustain: 4, depth: 4, amp: 0.1
  fmpad tscale[n+6], sustain: 4, depth: 4, amp: 0.1
  
  sleep 6
  
end

This uses the hash .merge method. The two hashes are merged, with the second one overwriting any keys existing in the first with are the same.

As I say there are lots of examples out there if you google a suitable question.

1 Like

Oh, the hash.merge method does exactly what my rationalise() function does. No need for rationalise() then :smiley: Thanks.

I’ve found what appears to be a limitation in the :fm synth. It seems that both the carrier and the modulator have the same envelope. This means that (e.g.) during the release, as the volume of the modulating oscillator decreases the timbre of the sound changes as the volume does.

I had a go at getting around this by having an FM synth that’s full volume for its whole length, and then the envelope is added by automation of the amplitude. I then tried to add a LFO controlling FM depth. Which I mostly did but it stops just before the end of the sound. (I could make the LFO continue right to the end, but didn’t as this is just an experiment.)

Are there more straightforward ways that I could implement LFOs? And is there a better way of making timbre and volume independent with the :fm synth?

I’m not sure how far I can take this approach, because automating both a LFO and an envelope at the same time would be … tricky.

I was thinking that it would be not too difficult to make the :fm synth do everything I’d do with, say, FM8, except that there are fewer modulation choices. But, with this difficulty, I think that :fm is more restricted than I thought. Please correct me if I’m wrong.

This is what I was doing:

use_debug false

define :strings do |note,args={}|
  defaults = { :attack => 0.5, :decay => 0.3, :sustain_level => 0.6,
               :sustain => 1, :release => 0.5, :divisor => 1, :depth => 6,
               :detune => 0.1, :amp => 0.3,
               :lfo_phase => 0.25, :lfo_depth => 0 }
  
  ag = defaults.merge( args )
  
  time = ag[ :attack ] + ag[ :decay ] + ag[ :sustain ] + ag[ :release ]
  
  use_synth :fm
  use_synth_defaults ag
  
  detune = ag[ :detune ];
  
  syn = play [ note, note-detune, note+detune ], amp: 0, attack: 0, decay: 0, sustain: time, release: 0
  
  envelope( syn, ag[ :attack ], ag[ :decay ], ag[ :sustain ],
            ag[ :release ], ag[ :sustain_level ], ag[ :amp ] )
  
  if ag[ :lfo_depth ] > 0
    print "phase: ", ag[ :lfo_phase ]
    
    lfo( syn, ag[ :lfo_phase ], ag[ :lfo_depth ], ag[ :depth ], time )
  end
end

define :lfo do |syn,phase,depth,initial_depth, time|
  
  index = 0
  direction = "up"
  control syn, depth_slide: phase/4.0
  
  while index + phase < time
    index = index + phase / 2.0
    
    if direction == "up"
      at index do
        control syn, depth_slide: phase/2.0, depth: initial_depth + depth/2.0
      end
      
      direction = "down"
    else
      at index do
        control syn, depth_slide: phase/2.0, depth: initial_depth - depth/2.0
      end
      direction = "up"
    end
  end
end


define :envelope do |syn,a,d,s,r,sl, amp|
  
  control syn, amp_slide: a, amp: amp
  
  at a do
    control syn, amp_slide: d, amp: amp * sl
  end
  
  at a + d + s do
    control syn, amp_slide: r, amp: 0
  end
  
end

notes = (chord, :a3, 'm7', invert: 2 )

notes.each do |n|
  strings n, sustain: 4, attack: 1, decay: 1, release: 1, lfo_depth: 4, lfo_phase: 0.5
end

EDIT: Sorry that I’m posting so much. I had a look into adding a new synth, and it seems that while I may not have programmed in supercollider for well over a decade, that I’ll be able to re-learn it.

I modified the sample synthdef from the tutorial material here, to make an extremely simple FM synthdef:

(
SynthDef(\testsynth,
         {|note = 60, pan=0, amp = 1, ratio1=1, ratio2=2, depth1=7, depth2=0 
		   carrier_decay=1, modulator1_decay=1000, modulator2_decay=1000, out_bus = 0 |

		var freq = note.midicps;
		var out = SinOsc.ar( freq + 
			SinOsc.ar( freq * ratio1 + 
				SinOsc.ar( freq * ratio2, 0, depth2 * freq * Line.kr( 1, 0, modulator2_decay )), 
				             0, depth1 * freq * Line.kr( 1, 0, modulator1_decay)), 0, 0.5 );
		
		var left = 0.5 - pan * 0.5;
		var right = 0.5 + pan * 0.5;
		
           Out.ar(out_bus,
			[out * left,out * right]* Line.kr(1, 0, carrier_decay, amp, doneAction: 2))}
	
).writeDefFile("/Users/pieater/Sonic Pi/synthdefs") ;
)

which I can run from Sonic Pi.

use_bpm 120

load_synthdefs "/Users/pieater/Sonic Pi/synthdefs"

use_synth :testsynth

notes = (chord, :c4, 'm7', num_octaves: 3)

live_loop :bongs do
  note = notes.choose
  p = rrand( -1, 1 )
  play note,  amp: 0.5, pan: p, carrier_decay: 1,
    depth1: 7, ratio1: 3.7, modulator1_decay: 2/3.0,
    depth2: 3, ratio2: 11.1, modulator2_decay: 1/3.0
  play note + 0.1, amp: 0.5, pan: p, carrier_decay: 1,
    depth1: 7, ratio1: 3.7, modulator1_decay: 2/3.0,
    depth2: 3, ratio2: 11.1, modulator2_decay: 1/3.0
  sleep 0.25
end

which I think is promising. I do like the idea of being able to create things solely in Sonic Pi, but I think that to do what I want to do, I will have to go outside of the standard synths.

To add to all the fab answers so far, this is how I might approach this:

use_bpm 120

define :fmpad2 do |note, opts={}|
  
  defaults = {attack: 1,
              decay: 0,
              sustain: 2,
              release: 1,
              depth: 6,
              div1: 1/3.001,
              div2: 1/4.001,
              div3: 1/2.001}
  opts = defaults.merge(opts)
  
  with_synth :fm do
    with_synth_defaults opts, {depth_slide: opts[:depth] * 2, depth: 0} do
      
      sn1 = synth :fm, note: note, divisor: opts[:div1]
      sn2 = synth :fm, note: note + 0.1, divisor: opts[:div2]
      sn3 = synth :fm, note: note - 0.1, divisor: opts[:div3]
      
      control sn1, depth: opts[:depth]
      control sn2, depth: opts[:depth]
      control sn3, depth: opts[:depth]
    end
  end
  
end

tscale = (scale :c4, :dorian, num_octaves: 2 )

8.times do |n|
  
  fmpad2 tscale[n], sustain: 4, depth: 4, amp: 0.2
  fmpad2 tscale[n+2], sustain: 4, depth: 4, amp: 0.1
  fmpad2 tscale[n+4], sustain: 4, depth: 4, amp: 0.1
  fmpad2 tscale[n+6], sustain: 4, depth: 4, amp: 0.1
  
  sleep 6
  
end

I’m using the default hash-map argument to define. I’m also using with_* in favour of use_* as this won’t pollute the current thread with these changes - and only keep them local to the function. This makes it safer to use the fmpad function from arbitrary. threads without it changing that thread’s synth default after it has finished executing.

I should also add that for some time now I’ve had ideas about implementing a defsynth which would likely make this kind of code easier to write and maintain. defsynth would essentially be a way for you to create your own synths which from a code perspective behave the same as the built-in ones but are actually just fancy functions that can call a bunch of them in the same way your’e doing here. :slight_smile:

3 Likes

Thanks for the answer. I’m still working out how to use Sonic Pi. The ‘hidden’, not-in-the-help ruby constructs make that a bit tricky as I don’t know Ruby. But, I seem to be getting there.

Thinking about synthdefs, it would be useful is there was a way of embedding them into a Sonic Pi script. E.g. Store a synthdef in a multi-line string, and then have a command to load that synthdef. Particularly if the maximum buffer length could be made longer, that would allow custom synthesis to be distributed along with Sonic Pi script as a single file.

use_bpm 120

data = '(
SynthDef(\testsynth,
         {|note = 60, pan=0, amp = 1, ratio1=1, ratio2=2, depth1=7, depth2=0 
		   carrier_decay=1, modulator1_decay=1000, modulator2_decay=1000, out_bus = 0 |

		var freq = note.midicps;
		var out = SinOsc.ar( freq + 
			SinOsc.ar( freq * ratio1 + 
				SinOsc.ar( freq * ratio2, 0, depth2 * freq * Line.kr( 1, 0, modulator2_decay )), 
				             0, depth1 * freq * Line.kr( 1, 0, modulator1_decay)), 0, 0.5 );

		var left = 0.5 - pan * 0.5;
		var right = 0.5 + pan * 0.5;

           Out.ar(out_bus,
			[out * left,out * right]* Line.kr(1, 0, carrier_decay, amp, doneAction: 2))}

)'

load_synthdef_from_string data

use_synth :testsynth

...

@samaaron Nice neat answer, looks very reminiscent of code ideas in the SP library util.rb for doing similar sorts of jobs :slight_smile:

1 Like

Agreed - however we don’t currently package SuperCollider language as part of Sonic Pi so we don’t have the ability to compile SuperCollider synthdefs into the appropriate binary format on-the-fly. Currently all the synths are pre-compiled at build-time.

Whilst it would be lovely to be able to insert SuperCollider code like this inline in Sonic Pi code, it opens up an even bigger can of worms than the issue of supporting arbitrary Ruby code. Supporting very large languages (well) with limited resources is pretty hard. What would compile errors look like? What about synthdefs that include runtime features that aren’t available in the server? How do we ensure that all synths have a stereo out and self-terminate (or at least warn users when they don’t and are therefore not compatible)?

I think that the logical next step for Sonic Pi towards this goal is to enable users to share compiled synthdefs with associated metadata which includes parameter names, documentation and runtime test functions.

1 Like

Sounds like we should have a separate discussion about a new FM synth that is much more sophisticated than the admittedly very simple built-in one. Perhaps you might consider starting a new topic and perhaps suggesting the kinds of features you’d like to see? If you’re able to create a first pass design in SuperCollider, even better! I’m absolutely interested in expanding the available synths as much as possible. Synths compile down to tiny files, so it’s no trouble shipping with thousands of them. We just need to design, test and document them first!

1 Like

Currently LFOs can only be implemented by calling control a lot. This works to a degree, but it will be much nicer when we support synths that input and output control rate signals. However, the work for this hasn’t been done yet. Now that things are a little more stable financially (although there’s still a long way to go before I’m really comfortable and can stop worrying about feeding my kids) I will be able to dedicate more of my brain to coding again, so hopefully things like this will finally see the light of day!

5 Likes

@samaaron Thanks for the replies. I did have a go at writing a LFO using control messages - and it was quite a bit of work. It made my brain hurt a bit to think about how I’d implement both a LFO and an envelope on the same synth parameter makes my brain hurt a bit.

While I note the comment elsewhere that one of the charms of Sonic Pi is the set of synths that it has, I can’t see that it would hurt if it had a few more advanced ones as well. E.g. a much more advanced FM synth.

I note that some FM synths from the 90s (I forget the model number) and the Korg Voica have some ‘easy edit’ buttons. It would be possible to make a Synthdef that has some build in presets, but also the ability to modify those presets with some ‘easy edit’ parameters. That might balance power, while still allowing neophytes to productively use the synths, without having to specify large numbers of arguments. Something like:

# play a mellow tubular bell sound, with the frequency of modulator2 overridden

synth :fm2, note: 60, preset: 17, brightness: -0.3, m2_divisor: 0.25

I’m only a newbie here, so perhaps I should be quiet concerning suggestions until I know more of the language and its use. (Says he after editing another ‘idea’ out of this post.)

Hi matey…

I’ve been around the SP forums for 3 years now, and have yet to see a ‘toxic’ post or reply.

Please dont let bad experiences elsewhere inhibit your posts or ideas about SP.

Truthfully, this place is ‘home’ to some very smart and friendly people.

Regards,

Eli…

1 Like

Thanks @Eli

In terms of not wanting to post too many ideas, I wasn’t concerned about negative responses. I am more concerned about getting things wrong. E.g. not knowing the ‘kill’ command when I answered a thread the other day.

2 Likes