Friday, 21 February 2014

The Code Behind Valley Of The Sythons

The entire patch and some description of how it works beneath that:

The key to understanding what comes next is that Sonic Field now works as a big extension to Python (Jython actually - Python running on the Java Vertual Machine). A Sonic Field patch is created using Python statements. However, the data being passed around is not Python Data it is hidden from the view of Python inside opaque Java objects. 

Valley Of The Sythons

The original idea was that Sonic Field sounds were 'signals' which were passed between 'processors'. Control of processing was also done via signals. The metaphor continues into Sython (Sonic Field Python). The syntax of Python makes the approach less obvious but it is still there.

sf.Multiply(sf.NumericShape((0,0),(len,1)),trem)

For example the above creates a signal which starts at 0 and works up to 1 at length len in milliseconds. The signal is then multiplied with another signal held in variable trem.  All Sonic Field processors are exposed to Python as methods on the sf object.

One last example before the code dump:

sf.Monitor(sf.SineWave(1000,440))

The above is a very simple Sython patch. It just makes an A4 tone for one second. However, the tone will have clicks at each end because it has no attack or release. So:

sf.Monitor(
    sf.Multiply(
        sf.SimpleShape((0,-90),(100,0),(900,0),(1000,-90)),
        sf.SineWave(1000,440)
    )
)

Now that does have an attack and release so it will sound much more like the beep one might expect.

Valley Of The Sythons:

import math
import random

execfile("patches/python/concurrent.py")

def randWalk(value,size,uBound):
    value  = float(value)
    size   = float(size)
    uBound = float(uBound)
    r=random.random()
    r=math.floor(r*size)-math.floor((size/2.0))    
    value+=r
    if value<1:
        value=2
    elif value>uBound:
        value=uBound-2
    return value

def randWalk3(value,uBound):
    return randWalk(value,3,uBound)

def fixSize(signal):
    mag=sf.MaxValue(signal)
    return sf.NumericVolume(signal,1.0/mag)
 
def fixSizeSat(signal):
     return fixSize(sf.Saturate(fixSize(signal)))
    
def saturatedNode(beat,pPitch,pitch,a,d,s,r,v):
    def saturateNode_():
        l=a+d+s+r
        if l>beat*2:
            iPitch=(pitch+pPitch)/2.0
            pos=beat/8
            signal1=sf.Slide((0,iPitch),(pos,pitch),(l,pitch))
            signal2=sf.Slide((0,iPitch),(pos,pitch*2),(l,pitch*2.02))
            signal3=sf.Slide((0,iPitch),(pos,pitch*3),(l,pitch*3.03))
        else:
            signal1=sf.SineWave(l,pitch)
            signal2=sf.SineWave(l,2*pitch*1.003)
            signal3=sf.SineWave(l,3*pitch*1.005)
            
        envelope= sf.NumericShape(
                 (0,0),
                 (a,1),
                 (a+d,0.75),
                 (a+d+s,0.25),
                 (a+d+s+r,0)
        )
        
        sat=(20-pitch/1000)
        if sat<1:
            sat=1
                        
        def doSat(sigIn):
            temp=sf.NumericVolume(sf.Multiply(sigIn,envelope),sat)
            return sf.Normalise(sf.Clean(sf.Saturate(temp)))

        signal=sf.Mix(
            doSat(signal1),
            sf.DB_6(doSat(signal2)),
            sf.DB_15(doSat(signal3))
        )
        
        envelope= sf.NumericShape(
                 (0,0),
                 (a,0.1),
                 (a+d,0),
                 (a+d+s,0.1),
                 (a+d+s+r,0)
        )
        
        signal=sf.Mix(
            sf.Multiply(
                sf.ButterworthLowPass(sf.WhiteNoise(l),pitch*5,1),
                envelope),
            signal
        )
        
        signal=fixSize(signal)
        
        hf=sf.Clip(sf.NumericVolume(signal,3))
    
        r1=fixSizeSat(sf.RBJPeaking(hf,pitch*1.3,0.5,85))
        r2=fixSizeSat(sf.RBJPeaking(hf,pitch*2.1,0.5,85))
        r3=fixSizeSat(sf.RBJPeaking(hf,pitch*2.9,0.5,85))
    
        signal=sf.Mix(
            sf.DB_6(signal),
            sf.DB_1(r1),
            sf.DB_4(r2),
            sf.DB_6(r3)
        )
        
        signal=sf.Clean(sf.NumericVolume(signal,v))

        signal=sf.BesselLowPass(signal,pitch*2,4)

        envelope= sf.NumericShape(
                 (0,1),
                 (a+d+s+r-125,1),
                 (a+d+s+r,0)
        )
        signal=sf.Multiply(envelope,signal)
        
        trem=sf.Slide((0,6*random.random()),(l,0.5*random.random()))
        trem=sf.Multiply(sf.NumericShape((0,0),(l,1)),trem)
        trem=sf.Mix(
            sf.NumericShape((0,1),(l,1)),
            trem
        )
        return sf.Multiply(signal,trem)
        
    return sf_do(saturateNode_)

def run(pitch,beat,minutes,startP,initial,overV):
    notesL=[]
    notesR=[]
    oPitch=float(pitch)
    pitchScaleDenom = 1.0
    pitchScaleNume  = float(startP)
    lengthScale     = 4.0
    volumeScale     = 4.0
    oVolume         = 4.0
    at=beat*float(initial)
    pPitch=float(pitch)

    while at/60000 < minutes:
        pitchScale = pitchScaleNume/pitchScaleDenom
        rvs        = 1.0/volumeScale
        volume     = rvs*oVolume
        pitch      = pitchScale*oPitch
        length     = lengthScale*beat

        
        # Create a consistent envelope
        a          = length*0.25
        d          = length*0.5
        s          = length*1.0
        r          = length*2.0
        if a<50:
            a=50
        if d<50:
            d=50
        if a>d-50:
           a=d/2
        
        r=r-s-d-a
        s=s-d-a
        d=d-a       
        
        vCorrection = 1/pitchScale
        
        # Do not over correct very & v high low frequencies 
        #  or very quiet notes. This aim it to stop loud highs
        #  dominating (psycho-acoustics)
        if rvs<0.2:
            if vCorrection<1:
                vCorrection=1
        
        if vCorrection>4:
            vCorrection=4
                                  
        print (
            at,
            "PitchNume: ",  pitchScaleNume,
            "PitchDenom: ", pitchScaleDenom,
            "Volume: ",     volumeScale,
            "Pitch: ",      pitch,
            "Length: ",     length,
            "Rvs: ",        rvs,
            "VCorr: ",      vCorrection
        ).__str__()    
            
        signal = saturatedNode(
            beat,
            pPitch,
            pitch,
            a,
            d,
            s,
            r,
            volume * vCorrection
        )

        lr=random.random()
        rl=1.0-lr
        notesL.append([sf.NumericVolume(signal,lr),at+30*rl])
        notesR.append([sf.NumericVolume(signal,rl),at+30*lr])

        at+=length
        
        pitchScaleDenom = randWalk3(pitchScaleDenom,10)

        pitchScaleNume  = randWalk3(pitchScaleNume,16)
        
        lengthScale     = randWalk3(lengthScale,8)

        volumeScale     = randWalk3(volumeScale,8)
        
        pPitch          = pitch

    return (
        sf.NumericVolume(sf.Normalise(sf.Clean(sf.MixAt(notesL))),overV),
        sf.NumericVolume(sf.Normalise(sf.Clean(sf.MixAt(notesR))),overV)
    )

def compressInner(signal,amount):
    def compressInnerDo():
        if sf.MaxValue(signal)<0.001:
            return signal
        signal_=sf.Normalise(signal)
        stf=sf.Normalise(sf.ButterworthLowPass(signal_,128,2))
    
        offset=1.0-amount    
        sr=sf.Reverse(sf.Follow(sf.Reverse(stf),1,1024))
        sw=sf.Follow(stf,1,1024)
        shape=sf.Mix(sr,sw)
        div=1.0/sf.MaxValue(shape)
        shape=sf.NumericVolume(shape,div)
        shape=sf.DirectMix(offset,sf.NumericVolume(shape,amount))
        return sf.Normalise(sf.Divide(signal_,shape))
    return sf_do(compressInnerDo)

def compress(signal,amount):
    def compressDo():
        cpo=amount
        signalM=sf.BesselBandPass(signal,200,2000,4)
        signalH=sf.BesselHighPass(signal    ,2000,4)
        signalL=sf.BesselLowPass( signal    , 200,4)
        amount_=cpo*cpo 
        
        signalM=compressInner(signalM, amount_)
        signalH=compressInner(signalH, amount_)
        signalL=compressInner(signalL, amount_)
    
        return sf.Normalise(sf.MixAt(
            (sf.Pcnt40(signalL),3.5),
            (sf.Pcnt20(signalM),0.0),
            (sf.Pcnt40(signalH),0.0)
        ))
    return sf_do(compressDo)
    

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)

def doRun1():
   return run(128,1024          ,6,1,0,1.0)
def doRun2():
   return run(128.0*4.0/3.0,1024,6,2,1,1.0)
def doRun3():
   return run(256.0*3.0/2.0,1024,6,1,5,0.5)
def doRun4():
   return run(512.0*5.0/4.0,1024,6,1,9,0.25)

random.seed(0.128)

x1=sf_do(doRun1)
x2=sf_do(doRun2)
(left1,right1) = x1.get()
sf.WriteSignal(left1,"temp/l1")
sf.WriteSignal(right1,"temp/r1")

(left2,right2) = x2.get()
sf.WriteSignal(left2,"temp/l2")
sf.WriteSignal(right2,"temp/r2")

x3=sf_do(doRun3)
x4=sf_do(doRun4)

(left3,right3) = x3.get()
sf.WriteSignal(left3,"temp/l3")
sf.WriteSignal(right3,"temp/r3")

(left4,right4) = x4.get()
sf.WriteSignal(left4,"temp/l4")
sf.WriteSignal(right4,"temp/r4")


left1=sf.ReadSignal("temp/l1")
left2=sf.ReadSignal("temp/l2")
left3=sf.ReadSignal("temp/l3")
left4=sf.ReadSignal("temp/l4")
left  = sf.Normalise(sf.Clean(fixSize(sf.Mix(left1,left2,left3,left4))))
left  = compress(left,0.33)
sf.WriteSignal(left,"temp/l")
left=""

right1=sf.ReadSignal("temp/r1")
right2=sf.ReadSignal("temp/r2")
right3=sf.ReadSignal("temp/r3")
right4=sf.ReadSignal("temp/r4")
right = sf.Normalise(sf.Clean(fixSize(sf.Mix(right1,right2,right3,right4))))

right = compress(right,0.33)
sf.WriteSignal(right,"temp/r")
right=""

sf.WriteFile32((sf.ReadSignal("temp/l"),sf.ReadSignal("temp/r")),"temp/temp.wav")

(left,right)=sf.ReadFile("temp/temp.wav")

(convoll,convolr)=sf.ReadFile("temp/terrys_warehouse_stereo_short.wav")
convoll=sf.Mix(
    convoll,
    sf.Pcnt15(sf.DirectRelength(convoll,0.2)),
    sf.Pcnt15(sf.Raise(sf.DirectRelength(convolr,0.2),2))
)
convolr=sf.Mix(
    convolr,
    sf.Pcnt15(sf.DirectRelength(convolr,0.2)),
    sf.Pcnt15(sf.Raise(sf.DirectRelength(convolr,0.2),2))
)
convoll=sf.Normalise(sf.Saturate(sf.Normalise(convoll)))
convolr=sf.Normalise(sf.Saturate(sf.Normalise(convolr)))

wleft =reverberate(left,convoll)
wright=reverberate(right,convolr)

left=sf.Normalise(sf.MixAt(
    (sf.Pcnt70(wleft),10),
    (sf.Pcnt10(wright),40),
    (sf.Pcnt20(left),0)
))

right=sf.Normalise(sf.MixAt(
    (sf.Pcnt70(wright),10),
    (sf.Pcnt10(wleft),40),
    (sf.Pcnt20(right),0)
))

sf.WriteFile32((left,right),"temp/temp_post.wav")

(left,right)=sf.ReadFile("temp/temp_post.wav")

left  = compress(left,0.95)
right = compress(right,0.95)

sf.WriteFile32((left,right),"temp/temp_post_post.wav")

shutdownConcurrnt()

First the dirty! Why 'execfile("patches/python/concurrent.py")' The answer is that I could not be bothered to set up sys.path or the class path correctly - me bad :( [I have fixed it in later patches]

Now for the Random Walk code:


def randWalk(value,size,uBound):
    value  = float(value)
    size   = float(size)
    uBound = float(uBound)
    r=random.random()
    r=math.floor(r*size)-math.floor((size/2.0))    
    value+=r
    if value<1:
        value=2
    elif value>uBound:
        value=uBound-2
    return value

def randWalk3(value,uBound):
    return randWalk(value,3,uBound)

The core concept behind the piece is constraining randomness to give patters which shift around slowly forming shape and movement in the piece. Here we see a few key points. Working with Random numbers requires a close interaction between integer and non integer numbers. randWalk take an number (assumed to be an integer) and moves it randomly up or down. However, the maximum distance it can move is fixed by the size parameter. The maximum value it can reach is fixed by the uBound parameter and the minimum is 1. This causes the 'random walk' effect that the music is based upon. randWalk3 is simply a helper function (I prefer this to default parameters in some cases as it is more explicit).

        if l>beat*2:
            iPitch=(pitch+pPitch)/2.0
            pos=beat/8
            signal1=sf.Slide((0,iPitch),(pos,pitch),(l,pitch))
            signal2=sf.Slide((0,iPitch),(pos,pitch*2),(l,pitch*2.02))
            signal3=sf.Slide((0,iPitch),(pos,pitch*3),(l,pitch*3.03))
        else:
            signal1=sf.SineWave(l,pitch)
            signal2=sf.SineWave(l,2*pitch*1.003)
            signal3=sf.SineWave(l,3*pitch*1.005)

The above piece of code is interesting as it alters note articulation based on note length. Short notes will have the same pitch throughout. However, longer notes will have a short 'slur' or 'slide' between them by bending the start of the next note to the average to the two.

We can also see here that each note it made from 3 tones. However, what we hear in Valley is very much more harmonically rich than that.

        envelope= sf.NumericShape(
                 (0,0),
                 (a,1),
                 (a+d,0.75),
                 (a+d+s,0.25),
                 (a+d+s+r,0)
        )
        
        sat=(20-pitch/1000)
        if sat<1:
            sat=1
                        
        def doSat(sigIn):
            temp=sf.NumericVolume(sf.Multiply(sigIn,envelope),sat)
            return sf.Normalise(sf.Clean(sf.Saturate(temp)))

        signal=sf.Mix(
            doSat(signal1),
            sf.DB_6(doSat(signal2)),
            sf.DB_15(doSat(signal3))
        )

The addition of harmonic complexity is done with the above code. First we create a standard ADSR envelope. Then we work out an number related to pitch which will be used to control the amount of harmonic richness to add. The reason to base it on pitch is that physical instruments tend to have more harmonic in their lower registers and so mimicking this mathematically produces sounds which are more interesting to listen to. 

        @Override
        public double getSample(int index)
        {
            double x = getInputSample(index);
            double y = x >= 0 ? x / (x + 1) : x / (1 - x);
            return y;

        }

The above is the Java (remember that audio processing heavy work in Sonic Field is done in Java not Python). It is a rather magical formula because it is so simple and yet so effective. It simply forces any value in the incoming signal to fit between 1 and -1. It does this by asymptotically crushing the signal as it approaches 1 or -1.
X and X/(X+1)
I came up with the idea of using this as a audio processor (strictly a wave shaper) one evening whilst working in Cambridge a couple of years ago. It is so simple and yet so effective, I could not believe my luck in thinking of it (I was dreaming of complex polynomials and logs and things). We can see that to begin with (X near 0) X and X(X+1) are similar but as X grows the processed wave bends over to approach 1 (and -1 for the X/(1-X) version for negative numbers). As a result, the wave form is distorted to become closer to a square wave. This add odd harmonics. The larger the amplitude of the incoming wave the more the distortion and the greater the addition of harmonics. A sine wave a large magnitude entering the wave shaper will come out as a rounded square wave.

The effect of greater amplitude -> greater harmonic content also mimics natural instruments. By using the saturate processor after the application of an envelope we make the harmonic content follow the envelope just as it does with - for example - a Sax where the louder the note the 'brighter' it sounds. 

The link between pitch and harmonic content is performed the same way:

        def doSat(sigIn):
            temp=sf.NumericVolume(sf.Multiply(sigIn,envelope),sat)
            return sf.Normalise(sf.Clean(sf.Saturate(temp)))

We use the saturation processor on the output result of setting the over all volume (amplitude) of the signal by the variable sat. sat is bigger for lower notes and so amplitude will be bigger and so the harmonic content larger.

Note:

  1. sf.Clean removes higher frequencies using a special finite impulse response filter to avoid build up of those frequencies. This prevents further processing causing harmonics of high frequencies getting so high the alias.
  2. sf.Normalise removes any DC from the signal and sets the maximum excursion to 1 by scaling the whole signal. By DC I mean, the sum of all the samples in the signal is the DC component. Build up of DC is a constant problem in digital processing which does not happen in analogue as the capacitors used to link circuits automatically remove all DC.

Finally for this section: why three signals? I leave that up to you to think about.

Next - resonance and body sounds


        hf=sf.Clip(sf.NumericVolume(signal,3))
    
        r1=fixSizeSat(sf.RBJPeaking(hf,pitch*1.3,0.5,85))
        r2=fixSizeSat(sf.RBJPeaking(hf,pitch*2.1,0.5,85))
        r3=fixSizeSat(sf.RBJPeaking(hf,pitch*2.9,0.5,85))

When a real instrument is played it shakes. For strings the shanking in part of the projecting of the sound. For brass, it produces a percussive timbre on top of the fundamental sound of the instrument. In the patch fragment above, I am attempting to mimc the effect of such shaking. This is done by passing the signal into infinite impulse response filters which are set to near resonance. Any signal passed into them which contains frequencies near to their resonant frequency will cause them to ring.

The 'near to their resonant frequency' is important. They will not resonate if signal is passed in which does not contain the required references. We can see here that I have not set their resonant frequencies to those of the notes so how will they resonate? The trick is in the sf.Clip. This hard limits signals so that if a sample goes above 1 it is set to 1 and if it goes below -1 it is set to -1. That hard limiting sprays frequencies all over the spectrum (think electric guitar fuzz). The resonators can pick up some of that sprayed frequency and resonate form it. Because the clipping will be dependant of amplitude of the signal the resonance will as well, which again, is the way physical instruments tend to work.

In my next post I will discuss compression, reverberation and well the Sonic Field - that for which Sonic Field was first created.

No comments:

Post a Comment