Thursday, 20 March 2014
Journey - Further Experiments 7
It was about time I did some Further Experiments. This one is in high resolution resonance and phase modulation synthesis.
The video of 'Journey'
The theme of Sython Song seems to be random walks and experimentation in generative techniques rather than hard core synthesis; so, despite 'Journey' being created in Python I have grouped it under Further Experiments In Extreme Synthesis. It feels really good to be able to bring this piece to life not because it is somehow amazing but because it was so easy achieved computationally.
Of late I have been trying to create 'Perfect Fi' - in other words, synthesis without artefacts. Clearly that is impossible. However, it makes a good target to aim for. One of the biggest problems for digital synthesis is aliasing producing low frequency inharmonics. These build up until then form a low frequency 'swell' of sound which is something cool be usually horrible.
There are three ways to reduce this effect.
1) Don't make the aliased frequencies in the first place
2) Reduce them through filtering
3) Reduce them by lifting the Nyquist frequency
Using more additive synthesis helps with 1. Distortion synthesis has a nasty habit of creating aliased frequencies. For example, distorting a sine wave into a sawtooth generates frequencies all the way up to the sample rate; fully half of these will be aliased. Producing the saw tooth by adding frequencies up to the Nyquist limit produces a much cleaner sound.
The 'Clean' decimating filter helps with 2. Being more careful with filtering as one goes along helps two. If a signal is carefully filtered before passing to a distortion will help avoid he distortion injecting aliased frequenceis.
Journey uses 3 for the first time in a long time. It was created at 192 000 samples per second rather than the default 96000. This means that many less frequencies from the audio spectrum end up 'escaping' up above the Nyquist limit because that limit is up at 96KHz.
This has been a challenge until now because 192000 takes twice as much storage as 96000. Storage has been the major performance limit for Sonic Field. I originally designed SF to run in the Cloud. I had this idea that memory would be no limit. However, I soon realised I was unlikely to actually cluster SF in the cloud any time soon. I run it on a 16Gig Mac Book Pro myself. The runs out of memory very easily doing a complex patch.
But no longer - audio signals can not be backed by Memory Mapped Files. Thus I can run patches which required 30,40,50 or even 100 Gigs quite effectively. The performance drop is too bad if the patch is written to access small blocks of audio at a time.
Until the latest release of SF the previous data files system and the memory mapped one suffered from no having aggressive garbage collection. I tried to couple them to the Java garbage collector. That did not work well. It resulted in data which was going to be garbage collected still being written to disk. To get the system to work well, I needed to collect file backed garbage straight away at the point it was not longer wanted.
Now SF has reference counted garbage collection
The implementation under the covers will be published on Nerds Central.
From a patch writing point of view is it simple:
- All new signals are generated with a count of 1.
- Every time a signal is passed to a processor its count is reduced.
- When a count hits 0 the signal is garbage collected.
- The + operators (as in +mySignal) will increase the count of a signal.
- If the same signal is to be passed to more than one processor the + operator can be used to keep the correct count.
- In some situations, this can lead to the count being too high and the signal not being collected.
- The - operator reduces the count and so allows collection under these situations.
Here is an example of using the + and - operators:
convol_=sf.FrequencyDomain(sf.Concatenate(convol,sf.Silence(grainLength))) signal_=sf.Concatenate(signal,sf.Silence(grainLength)) out=[] for grain in sf.Granulate(signal_,grainLength): (signal_i,at)=grain out.append((reverbInner(signal_i,+convol_,grainLength),at)) -convol_
Here is the patch which created Journey:
import math import random sf.SetSampleRate(192000) # Single threaded for debug #def sf_do(toDo): # return toDo() def ring(pitch,length): print "Ring: " + str(pitch) + "/" + str(length) sig1 = sf.SineWave(length,pitch*1.2) sig2 = sf.SineWave(length,pitch*1.2 + 1) env = sf.SimpleShape((0,-60),(125,0),(length,-30)) sig1 = sf.Multiply(+env,sig1) sig1 = sf.Pcnt90(sf.DirectMix(1,sig1)) sig3 = sf.PhaseModulatedSineWave(pitch,sig1) sig3 = sf.Multiply(+env,sig3) sig2 = sf.Multiply(+env,sig2) sig2 = sf.Pcnt90(sf.DirectMix(1,sig2)) sig4 = sf.PhaseModulatedSineWave(pitch,sig2) sig4 = sf.Multiply(env,sig4) sig5 = sf.Volume(sf.Mix(sig3,sig4),6) sig=sf.Saturate(sig5) sig=sf.ResonantFilter(sig,0.99,0.05,1000.0/pitch) return sf.Realise(sf.Normalise(sig)) def doFormant(sig,f1,f2,f3): #sig=sf.BesselLowPass(sig,f3,1) def doFormantInner(a,b,c,d): def doFII(): return sf.RBJPeaking(a,b,c,d) return sf_do(doFII) sig1=doFormantInner(+sig,f1,1,40) sig2=doFormantInner(+sig,f2,2,20) sig3=doFormantInner( sig,f3,1,40) x=sf.Mix(sig1,sig2,sig3) x=sf.Normalise(x) return sf.Realise(x) def makeSingBase(pitch,length): pitch=float(pitch) length=float(length) drop=1.0 notes=[] for i in range(1,100): thisPitch=pitch*i if(thisPitch>10000): continue print thisPitch notes.append(sf.NumericVolume(sf.PhasedSineWave(length,thisPitch,random.random()),drop)) drop=drop*0.6 sig=sf.Normalise(sf.Mix(notes)) return sig def doSingEnv(sig): length=sf.Length(+sig) a=0 d=0 s=0 r=length k1=50.0 k2=length-50.0 if(length<1000): a=100.0 d=250.0 s=(length-d)/2.0+d else: a=length*0.1 d=length*0.25 s=length*0.5 env=sf.SimpleShape((0,-90),(k1,-30),(a,0),(d,-6),(s,-12),(k2,-30),(r,-90)) sig=sf.Multiply(sig,env) sig=sf.Normalise(sig) return sig #beat def doFormant1(sig): return doFormant(sig,300,2800,3300) #bit def doFormant2(sig): return doFormant(sig,430,2500,3100) #bet def doFormant3(sig): return doFormant(sig,600,2350,3000) #bat def doFormant4(sig): return doFormant(sig,860,2050,2850) #part def doFormant5(sig): return doFormant(sig,850,1200,2800) #pot def doFormant6(sig): return doFormant(sig,590,900,2700) #boat def doFormant7(sig): return doFormant(sig,470,1150,2700) #boat def doFormant8(sig): return doFormant(sig,470,1150,2700) #book def doFormant9(sig): return doFormant(sig,370,950,2650) #but def doFormant10(sig): return doFormant(sig,760,1400,2800) #pert def doFormant11(sig): return doFormant(sig,500,1650,1950) formants=[ doFormant1, doFormant2, doFormant3, doFormant4, doFormant5, doFormant6, doFormant7, doFormant8, doFormant9, doFormant10, doFormant11 ] def doNote(pitch,length,formant): def doNoteInner(): sig=ring(pitch,length) sig=formants[int(formant)](sig) length_=sf.Length(+sig) env=sf.NumericShape((0,0),(length_/2.0,1),(length_,0)) y=sf.Multiply(sig,env) x=sf.Realise(y) return x return sf_do(doNoteInner) root = 32 initial = sf.Silence(2000) nNotes = 64 length = 16384 #length = 1024 def makeTrack(): notesL=[] notesR=[] at=1000 for x in range(0,nNotes): print "Performing note: " + str(x) a = 1+x%7 b = 1+x%11 c = 2*(1+x%3) d = ((x+1)%3)*2 e = math.floor(x%22/2) f = math.floor((11+x)%22/2) g = 1.0+(x%8.0)/3.0 h = 1.0+(x%16.0)/6.0 i = x%5 print (a,b,c,d,e,f,g,h,i) fa = root*a fb = root*b na1 = doNote(fa,length*g,e) nb1 = doNote(fb,length*h,f) a = 8 - (x%7) b = 12 - (x%11) fa = root*a fb = root*b na2 = doNote(fa,length*g,e) nb2 = doNote(fb,length*h,f) signal=sf.Volume(na1,c) signal=sf.Concatenate(signal,nb1) signal=sf.Concatenate(signal,sf.Volume(na2,d)) signal=sf.Concatenate(signal,nb2) leftBias = i/4.0 rightBias = 1.0-leftBias leftT = 30*leftBias rightT = 30*rightBias signal=sf.Normalise(signal) sl=sf.NumericVolume(+signal,leftBias) sr=sf.NumericVolume( signal,rightBias) notesL.append((sl,at+leftT)) notesR.append((sr,at+rightT)) at=at+length/4 def mixL(): return sf.FixSize(sf.WaveShaper(-0.03,0.2,0,-1,0.2,2,sf.Normalise(sf.MixAt(notesL)))) def mixR(): return sf.FixSize(sf.WaveShaper(-0.03,0.2,0,-1,0.2,2,sf.Normalise(sf.MixAt(notesR)))) ret=(sf_do(mixL),sf_do(mixR)) return ret (left,right)=makeTrack() left=left.get() right=right.get() lr=sf.Length(+right) ll=sf.Length(+left) if(lr>ll): left=sf.Concatenate(left,sf.Silence(lr-ll)) elif(ll>lr): right=sf.Concatenate(right,sf.Silence(ll-lr)) def OnTop(signal,root): inp=sf.Saturate(sf.FixSize(+signal)) x=sf.RBJPeaking(+inp,root*20,0.25,24) y=sf.RBJPeaking(+inp,root*25,0.25,24) z=sf.RBJPeaking( inp,root*30,0.25,24) x=sf.Saturate(sf.FixSize(x)) y=sf.Saturate(sf.FixSize(y)) z=sf.Saturate(sf.FixSize(z)) return sf.FixSize(sf.Mix(signal,sf.Pcnt15(x),sf.Pcnt_10(y),sf.Pcnt10(z))) left=sf.Realise(OnTop(left,root)) right=sf.Realise(OnTop(right,root)) sf.WriteFile32((+left,+right),"temp/temp.wav") def reverbInner(signal,convol,grainLength): def reverbInnerDo(): mag=sf.Magnitude(+signal) if mag>0: signal_=sf.Concatenate(signal,sf.Silence(grainLength)) signal_=sf.FrequencyDomain(signal_) signal_=sf.CrossMultiply(convol,signal_) signal_=sf.TimeDomain(signal_) newMag=sf.Magnitude(+signal_) signal_=sf.NumericVolume(signal_,mag/newMag) # tail out clicks due to amplitude at end of signal return signal_ else: -convol return signal return sf_do(reverbInnerDo) def reverberate(signal,convol): def reverberateDo(): grainLength = sf.Length(+convol) convol_=sf.FrequencyDomain(sf.Concatenate(convol,sf.Silence(grainLength))) signal_=sf.Concatenate(signal,sf.Silence(grainLength)) out=[] for grain in sf.Granulate(signal_,grainLength): (signal_i,at)=grain out.append((reverbInner(signal_i,+convol_,grainLength),at)) -convol_ return sf.Realise(sf.Normalise(sf.MixAt(out))) return sf_do(reverberateDo) (convoll,convolr)=sf.ReadFile("temp/revb.wav") wleft =reverberate(+left,convoll) wright=reverberate(+right,convolr) wleft=wleft.get() wright=wright.get() left_out=sf.Normalise(sf.MixAt( (sf.Pcnt60(+wleft),10), (sf.Pcnt10(+wright),40), (sf.Pcnt10(+wleft),120), (sf.Pcnt15(+left),0), (sf.Pcnt5(+right),110) )) right_out=sf.Normalise(sf.MixAt( (sf.Pcnt70(+wright),10), (sf.Pcnt10(wleft),40), (sf.Pcnt10(wright),130), (sf.Pcnt20(right),0), (sf.Pcnt5(left),105) )) #left = sf.Realise(left) #right = sf.Realise(right) sf.WriteFile32((left_out,right_out),"temp/temp_post.wav")
Sunday, 2 March 2014
Python: Creating Oscillators In Python
What is an Oscillator and how can we great one using a generator in Python?
An oscillator is something which naturally passes back and forth through some fixed or semi-fixed pattern. A simple but effective transistor based oscillator is a phase delay (or phase shift) circuit:
Wiki Commons - see here http://en.wikipedia.org/wiki/File:RC_phase_shift_oscillator.svg |
The output is delayed and fed back into the input. The output is the inverse of the input. This means that without the delay the circuit would do nothing at all. However, because there is a delay it oscillates making a sine wave. We can make a Python generator do very much the same thing:
from com.nerdscentral.audio import SFData def oscillator(damping): damping = float(damping) weight = 0.1 value = 0.0 middle = value yield 0,0 while(1): if(value>middle): weight-=damping else: weight+=damping value+=weight yield value,weight
It almost looks too simple to work but it does. The phase shift delay is not caused by 'recording' a sequence of output values and feeding them into the input (inverted). It is done by making the feedback cumulative. The variable weight is slowly shifted to oppose the variable value. If we plot the two variables as waves we get this:
Waveforms of weight and value. value top. weight bottom. These are not plotted to amplitude scale. The max value of wave is ca 50 and the max value of weight is ca .01 |
We can see from the above that weight is 90 degrees out of phase with value. We have made a phase delay oscillator. This approach makes a passible sine wave: However, the amplitude is not controlled at all that the produced wave for is not a very good sine wave.
The spectrum of our oscillator output. The large number and magnitude of harmonics shows it not to be a very pure sine wave. |
def oscillator(damping): damping = float(damping) lower = -1.0 upper = 1.0 weight = 0.1 value = 0.0 middle = value yield 0,0 while(1): if(value>middle): weight-=damping else: weight+=damping value+=weight yield out,weight
if(out<lower): value=prev elif(out>upper): value=prev
This addition locks the oscillator between +-1 and improves the sine wave quite a bit. The new spectrum looks like this (it is higher frequency for the given damping):
Slightly enhanced oscillator spectrum |
Let's Make Some Sounds
Making oscillators is fun, but now we have an analogue style oscillator in Python, we really have a moral responsibility to make sounds with it! Will the computational equivalent of analogue make more complex and interesting signals and traditional digital stuff? Here is a much more interesting version of the oscillator:
def oscilator(damping,asym=1.0,mixer=0): damping = float(damping) lower = -1.0 upper = 1.0 weight = 0.1 value = 0.0 middle = value gain = 1.0 prev = 0.0 cross = 0.0 pos = 0.0 gainV = 0.9999 xcount = 0 asym = float(asym) yield 0,0,0 while(1): if(value>middle): weight-=damping*asym else: weight+=damping if(mixer != 0): value+=mixer.next() value+=weight out=value*gain yield out,weight,xcount if(out<lower): value=prev gain*=gainV elif(out>upper): value=prev gain*=gainV elif(prev>0 and value<0): gain/=gainV xcount+=1 pos+=1 prev=value def wobble(damping): wosc=oscilator(damping,1.0) while(1): s,t,xs=wosc.next() yield s*0.00001
The above uses recursive generators to make one oscillator inject instability into a second. We pass an instance of wobble into the mixer parameter of oscillator to get the effect. I have also added the ability to inject asymmetry into the oscillator to add harmonics. In have highlighted the bits of code which do these things.
We can put the output of our oscillator into a Sonic Field SFData object and then process it like any other sound:
from com.nerdscentral.audio import SFData
...
data=SFData.build(len)
for x in range(0,length): s,t,xs=osc.next() data.setSample(x,s)
Yes - it really is that simple to create an auto signal from a Python generator using Sython.
Warning - read the label carefully:
If you are familiar with the determinism of working with normal digital signals, this approach will come as a bit of a shock. What you end up with is unstable and pretty much unpredictable. Though the output signal is deterministic (you run it twice you get the same numbers) it is also highly unstable. That really nice sine wave I showed above is a 'attractor' for the equation. It is a well behaved oscillating attractor. What you get with the more complex recursive version is a 'strange attractor'; the signal does not repeat it self. It might not even be a real attractor but just a semi-stable state from which, after enough cycles, the system will escape. Also, forget normal tuning, the output frequency is not linearly dependant on the input one. To get any sort of accurate pitch I would suggest counting the crossovers and then changing the sample rate to lock the pitch to that required.
First Creation:
Above is the first creation I have made with this new technique. It is not music at all. I wanted to create a sound into which the listener is placed which conveys the menace of WWII era piston engine aircraft. The very rich and ever changing 'analogue' nature of the oscillators does this in a way much more convincing that I think I could have managed using the normal sine wave generator and post processing approach of digital synthesis (or at least, not as easily).
Here is the patch which created the piece:
import math import random from com.nerdscentral.audio import SFData def fixSize(signal): mag=sf.MaxValue(signal) return sf.NumericVolume(signal,1.0/mag) def nullMixer(): while(1): yield 0 def oscilator(damping,asym=1.0,mixer=0): damping = float(damping) lower = -1.0 upper = 1.0 weight = 0.1 value = 0.0 middle = value gain = 1.0 prev = 0.0 cross = 0.0 pos = 0.0 gainV = 0.9999 xcount = 0 asym = float(asym) yield 0,0,0 while(1): if(value>middle): weight-=damping*asym else: weight+=damping if(mixer != 0): value+=mixer.next() value+=weight out=value*gain yield out,weight,xcount if(out<lower): value=prev gain*=gainV elif(out>upper): value=prev gain*=gainV elif(prev>0 and value<0): gain/=gainV xcount+=1 pos+=1 prev=value def wobble(damping): wosc=oscilator(damping,1.0) while(1): s,t,xs=wosc.next() #print s yield s*0.00001 def invasion(d1,d2,seconds): osc1=oscilator(d1,2,wobble(0.000020)) osc2=oscilator(d1,2,wobble(0.000015)) osc3=oscilator(d1,2,wobble(0.000010)) osc4=oscilator(d1,2,wobble(0.000005)) osc5=oscilator(d2,1.5,wobble(0.000020)) osc6=oscilator(d2,1.5,wobble(0.000015)) osc7=oscilator(d2,1.5,wobble(0.000010)) osc8=oscilator(d2,1.5,wobble(0.000005)) length=96000*seconds xs=0 def drone(osc,len): def doDrone(): data=SFData.build(len) print "Doing Drone" for x in range(0,length): s,t,xs=osc.next() data.setSample(x,s) # Go to a lot of effort to remove # clicks due to DC offset of the start and end l=sf.Length(data) data=sf.ButterworthHighPass(sf.Normalise(data),10,2) data=sf.Multiply( data, sf.NumericShape((0,0),(256,0),(l/2,1),(l-256,0),(l,0)) ) data=sf.Multiply( sf.Saturate(data), sf.NumericShape((0,0),(256,1),(l-256,1),(l,0)) ) return sf.Realise(data) return sf_do(doDrone) data1=drone(osc1,length) data2=drone(osc2,length) data3=drone(osc3,length) data4=drone(osc4,length) data5=drone(osc5,length) data6=drone(osc6,length) data7=drone(osc7,length) data8=drone(osc8,length) def mix1(): return sf.Realise( fixSize( sf.MixAt( (sf.Pcnt10(data2),30), (sf.Pcnt20(data3),20), (data1,0), (data4,0), (sf.Pcnt10(data6),30), (sf.Pcnt20(data7),20), (data5,0), (data8,0) ) ) ) def mix2(): return sf.Realise( fixSize( sf.MixAt( (sf.Pcnt10(data1),30), (sf.Pcnt20(data4),20), (data2,0), (data3,0), (sf.Pcnt10(data6),30), (sf.Pcnt20(data7),20), (data5,0), (data8,0) ) ) ) dataL=sf_do(mix1) dataR=sf_do(mix2) return (dataL,dataR) dataL1,dataR1=invasion(0.000025,0.000015,45) dataL2,dataR2=invasion(0.000020,0.000007,45) dataL3,dataR3=invasion(0.000011,0.000010,45) dataL4,dataR4=invasion(0.000010,0.000012,45) dataL=sf.Normalise( sf.MixAt( (dataL1, 0), (dataL2, 30000), (dataL3, 60000), (dataL1, 90000), (dataL4,120000), (dataL1,150000), (dataL4,160000) ) ) dataR=sf.Normalise( sf.MixAt( (dataR1, 0), (dataR2, 30000), (dataR3, 60000), (dataR1, 90000), (dataR4,120000), (dataR1,150000), (dataR4,160000) ) ) sf.WriteFile32((dataL,dataR),"temp/temp.wav") dataL=0 dataR=0 def reverbInner(signal,convol,grainLength): def reverbInnerDo(): mag=sf.Magnitude(signal) if mag>0: signal_=sf.Concatenate(signal,sf.Silence(grainLength)) signal_=sf.FrequencyDomain(signal_) signal_=sf.CrossMultiply(convol,signal_) signal_=sf.TimeDomain(signal_) newMag=sf.Magnitude(signal_) signal_=sf.NumericVolume(signal_,mag/newMag) # tail out clicks due to amplitude at end of signal l=sf.Length(signal_) sf.Multiply( sf.NumericShape( (0,1), (l-100,1), (1,0) ), signal_ ) return signal_ else: return signal return sf_do(reverbInnerDo) def reverberate(signal,convol): def reverberateDo(): grainLength = sf.Length(convol) convol_=sf.FrequencyDomain(sf.Concatenate(convol,sf.Silence(grainLength))) signal_=sf.Concatenate(signal,sf.Silence(grainLength)) out=[] for grain in sf.Granulate(signal_,grainLength): (signal_,at)=grain out.append((reverbInner(signal_,convol_,grainLength),at)) return sf.Normalise(sf.MixAt(out)) return sf_do(reverberateDo) (left,right)=sf.ReadFile("temp/temp.wav") (convoll,convolr)=sf.ReadFile("temp/revb.wav") wleft =reverberate(left,convoll) wright=reverberate(right,convolr) left=sf.Normalise(sf.MixAt( (sf.Pcnt40(wleft),10), (sf.Pcnt5(wright),40), (sf.Pcnt5(wleft),120), (sf.Pcnt45(left),0), (sf.Pcnt5(right),110) )) right=sf.Normalise(sf.MixAt( (sf.Pcnt40(wright),10), (sf.Pcnt5(wleft),40), (sf.Pcnt5(wright),130), (sf.Pcnt45(right),0), (sf.Pcnt5(left),105) )) sf.WriteFile32((left,right),"temp/temp_post.wav")
Labels:
analogue,
generative,
generators,
oscillator,
python,
sython,
youtube
Subscribe to:
Posts (Atom)