SFX Maker

           

The sound effect scripting language is a shorthand way to make practical sound effects.

Rules

1.Node names begin with a # symbol. The last node is used as output.
2.Node outputs can be used as inputs for most parameters.
Ex: a square wave modulated by a sine wave: #osc: SIN F 200 #out: SQR F #osc
3.Combine inputs with reverse polish notation. Leftover terms are summed.
Ex: (#osca*2)+(#oscb-1) becomes #out: SQR F #osca 2 * #oscb 1 -
SymbolDescriptionParameters
'Line comment.
"Block comment. Terminate with "
#bass:Define a node named #bass.
#bassReference a node named #bass.
EXPRGeneric math expression. This is the default node type.Any equation
ENVEnvelope filter. [a]ttack = 0
[s]sustain = 0
[r]elease = 0
[i]nput = 1
TRI SAW
SIN SQR
Common oscillators. [f]req
[p]hase = 0
[h]igh = 1
[l]ow = -high
PULSEPulse oscillator. [w]idth = 0.5
and osc params
TBLWave table oscillator. List x,y points in order like t 0 1 0.5 -1 1 1[t]able
and osc params
NOISEWhite noise. [h]igh = 1
[l]ow = -high
DELDelay filter. Max must be constant. [t]ime = 0
[m]ax
[i]nput = 0
LPF HPF
BPF NPF
APF PKF
LSF HSF
Biquad filters: lowpass, highpass, etc. Gain is linear. [f]req
[b]andwidth = 1
[g]ain = 1
[i]nput = 0

Samples

Motivation

The sound effect scripting language is a simple, shorthand way to write sound effects. Like my music editor, I needed an easy way to create effects - explosions, beeps, etc. With the tools I had available I could create those effects, but it was very tedious and non portable. A simple hi-hat in my old notation took 658 bytes, whereas the same effect is only 144 bytes now.

OldNew
function createdrumhihat(volume=1.0,freq=7000,time=0.1,sndfreq=44100) { let len=Math.ceil(time*sndfreq); let snd=new Audio.Sound(sndfreq,len); let gain=new Audio.Envelope([Audio.Envelope.LIN,0.005,volume*0.7,Audio.Envelope.EXP,time-0.005,0]); let hp=new Audio.Biquad(Audio.Biquad.HIGHPASS,freq/sndfreq); let bp=new Audio.Biquad(Audio.Biquad.BANDPASS,freq*1.4/sndfreq); let data=snd.data; for (let i=0;i<len;i++) { let t=i/sndfreq,u=(1-0.2*i/len)*freq/sndfreq; let x=Audio.noise(); hp.updatecoefs(Audio.Biquad.HIGHPASS,u); bp.updatecoefs(Audio.Biquad.BANDPASS,u*1.4); x=bp.process(x)+hp.process(x); data[i]=x*gain.get(t); } return snd; }
#freq: 7000 #time: 0.1 #sig: NOISE #hpf: HPF F #freq I #sig G 0.7 #bpf: BPF F #freq 1.4 * I #sig G 0.7 #out: ENV A 0.005 R #time 0.005 - I #hpf #bpf

There were several attempts before settling on the current notation. The first being to rework my music sequencer to also allow waveform editing. This "worked", but the static nature of rendering everything to sounds made it difficult to work with.

One of the things I didn't realize was that working with time dependent data (as opposed to images) required a different way of thinking. For example: if we draw a rectangle with draw.fillrect(0,0,10,10), any data it needs can be contained in that one function call. There's no need to tweak the size or color while it's drawing, but if a sound is playing we may need to change the frequency as it moves away from us.

I decided to go with the dynamic, node-based nature that most editors and DAWs have. Effects can be built up from nodes, and multiple node inputs can be fed into a parameter and combined with reverse polish notation, like if we want to vary an oscillator's frequency. This notation is not only easier to parse, but lets us works better with the plugin nature of nodes.

'100hz square wave #out : SQR F 100 'Square wave modulated by a sine wave #osc1: SIN F 100 #out : SQR F #osc1 'Square wave modulated by two sine waves multiplied #osc1: SIN F 100 #osc2: SIN F 200 #out : SQR F #osc1 #osc2 *

Sound effects generate a lot of raw data, so to make it performant these effects would also be compiled down to a simple program. I created a decompiler to double check my audio programs - this is what peak hi-hat performance looks like:

Addr | Data | * | Description --------+------------+-----+------------------------------------------------- 0 | 00000004 | | #freq = 4 -> #time 1 | 00000002 | | #freq.in_stop = 2 -> #freq.type 2 | 00000000 | | #freq.type = expr 3 | 45dac000 | | #freq.out = 7000.000000 4 | 00000008 | | #time = 8 -> #sig 5 | 00000006 | | #time.in_stop = 6 -> #time.type 6 | 00000000 | | #time.type = expr 7 | 3dcccccd | | #time.out = 0.100000 8 | 00000010 | | #sig = 16 -> #hpf 9 | 0000000a | | #sig.in_stop = 10 -> #sig.type 10 | 00000008 | | #sig.type = noise 11 | 3f510fe2 | * | #sig.out = 0.816649 12 | 3f800000 | | #sig.h = 1.000000 13 | bf800000 | | #sig.l = -1.000000 14 | 0d43363c | * | #sig.acc = 222508604 15 | 7690614d | | #sig.inc = 1989173581 16 | 00000029 | | #hpf = 41 -> #bpf 17 | 00000018 | | #hpf.in_stop = 24 -> #hpf.type 18 | 00000014 | | #hpf.src_0 = 20 -> #hpf.dst_0 19 | 00000003 | | #hpf.src_0_0 = [NOP,3] -> #freq.out 20 | 0000001a | | #hpf.dst_0 = 26 -> #hpf.f 21 | 00000017 | | #hpf.src_1 = 23 -> #hpf.dst_1 22 | 0000000b | | #hpf.src_1_0 = [NOP,11] -> #sig.out 23 | 0000001d | | #hpf.dst_1 = 29 -> #hpf.i 24 | 0000000b | | #hpf.type = hpf 25 | 3f22a5e5 | * | #hpf.out = 0.635344 26 | 45dac000 | * | #hpf.f = 7000.000000 27 | 3f800000 | | #hpf.b = 1.000000 28 | 3f333333 | | #hpf.g = 0.700000 29 | 3f510fe2 | * | #hpf.i = 0.816649 ...

Notes

I'm really happy with how this turned out. Compared to the mangled music editor notation I was attempting to make, this allows for simple AND efficient handling of sound effects.

Among other failed attempts was an assembly-like notation and basically remaking LISP. My attempts to make a general purpose sound effect language were leading me to remake entire programming languages. Luckily I've worked my way out of that to create something purely focused on living time-dependent sounds.

Work remains to simplify the parser and handle a few situations (like delay filters) I chose to ignore to get something out the door.