TR808 Cymbal Hardware Accurate Modeling

TR808 Cymbal Hardware Accurate Modeling

Derived from Roland TR-808 service manual schematics and Werner/Abel/Smith, “The TR-808 Cymbal: a Physically-Informed, Circuit-Bendable, Digital Model”

Overview

The basic signal flow for 808 cymbals (hi-hats open and closed, cymbal), is approximately:

Oscillators -> Mixer -> BPF Stage -> HPF Stage -> VCA -> Tone Shaping Stage
                                                  ^Amplitude Envelope 

** The tone shaper is a 5th order filter beyond the scope of this writeup that is less integral in synthesizing the 808 cymbal. It isn’t adding any extra resonance that the other filter stages are and solving the discrete-time transfer function for this filter is impractical due to the complexity of the circuit.

Oscillators

The TR-808 uses six Schmitt-trigger oscillators two of which are tunable by internal trimpots. Their nominal frequency values are:

  1. 800 Hz (factory setting) with a range of 359.4 to 1149.9 Hz
  2. 540 Hz (factory setting) with a range of 254.3 to 627.2 Hz
  3. 522.7 Hz
  4. 369.6 Hz
  5. 304.4 Hz
  6. 205.3 Hz

Schmitt trigger oscillators are pulse waves. The 808 oscillator circuit yields a duty cycle of 47.98% for each one. They all have the same amplitude (5V).

SC Implementation
(
// Using the nominal values and the factory settings for the tunable oscillators

// You could certainly make the tunable oscillator frequencies control values

var freqs = [800.0, 540.0, 522.7, 369.6, 304.4, 205.3];
var width = 0.4798;
var oscs = freqs.collect{|freq|
	Pulse.ar(freq, width);
}.sum;
oscs = oscs * 0.15; // we'll scale this down some for sanity
~oscs = oscs; // store in an environment variable so things are easy
)

BPF Stage

The bandpass stage is two parallel third-order bandpass filters with center frequencies of approximately 3440 Hz and 7100 Hz. The output of the filters are then mixed.

General Form

Transfer function shape: H(s) = (β₂s² + β₁s) / (α₃s³ + α₂s² + α₁s + α₀)

Factoring the denominator gives one real pole and a complex-conjugate pair. In practice this is achieved by cascading a first (FOS) and second order section (SOS).

**I used scipy to derive these values from the schematic. See Werner et al for a list of the requisite components and a more detailed analysis.

BPF1 (center ≈ 3440 Hz)

  • Zeros: -131302.521, 0
  • Poles: -1793.400 ± j21627.290 (complex pair), -9182.736 (real)
  • Gain: -8912.656

BPF2 (center ≈ 7100 Hz)

  • Zeros: -270562.771, 0
  • Poles: -3695.492 ± j44565.325 (complex pair), -45454.545 (real)
  • Gain: -27548.209

These are continuous time values – sample rate agnostic. We can use SampleRate.ir to ensure the values get calculated correctly regardless of the server sample rate via the bilinear transform. The real pole is the FOS stage and the complex pair is the SOS stage.

SC Implementation
(
var calcBpf3 = {|in, zRe0, pRe0, pIm0, pRe1, kS|
	var fs = SampleRate.ir;

	var fosPole = (2*fs + pRe1) / (2*fs - pRe1);

	var denom = (2*fs - pRe0).squared + pIm0.squared;
	var pReZ = ((2*fs).squared - (pRe0.squared + pIm0.squared)) / denom;
	var pImZ = (2*pIm0 * 2*fs) / denom;
	var sosB1 = 2*pReZ;
	var sosB2 = -1 * (pReZ.squared + pImZ.squared);

	var zZeroD = (2*fs + zRe0) / (2*fs - zRe0);

	var kD = kS * (2*fs - zRe0) * (2*fs) /
		((2*fs - pRe1) * ((2*fs - pRe0).squared + pIm0.squared));

	var sosA0 = kD;
	var sosA1 = kD * -1 * (zZeroD - 1);
	var sosA2 = kD * -1 * zZeroD;

	var out = FOS.ar(in, 1.0, -1.0, fosPole);
	out = SOS.ar(out, sosA0, sosA1, sosA2, sosB1, sosB2);
};

var bpf0 = calcBpf3.(
	~oscs, // in
	-131302.5210084, // zRe0
	-1793.40028694, // pRe0
	21627.29000861, // pIm0
	-9182.73645546, // pRe1
	-8912.6559714795 // kS
);

var bpf1 = calcBpf3.(
	~oscs, // in
	-270562.77056277, // zRe0
	-3695.49150037, // pRe0
	44565.32486623, // pIm0
	-45454.54545455, // pRe1
	-27548.20936639118 // kS
);

~bpfStage = bpf0 + bpf1;
)

HPF Stage

The highpass stage consists of three Sallen-Key highpass filters – two second order filters and one third order.

SC Implementation
(
// Filter calculation functions

var calcHpf2 = {|in, pRe, pIm, kS|
	var fs = SampleRate.ir;
	
	var denom = (2*fs - pRe).squared + pIm.squared;
	var pReZ = ((2*fs).squared - (pRe.squared + pIm.squared)) / denom;
	var pImZ = (2*pIm * 2*fs) / denom;
	var b1 = 2 * pReZ;
	var b2 = -1 * (pReZ.squared + pImZ.squared);
	
	var kd = kS * (2*fs).squared / denom;
	var a0 = kd;
	var a1 = -2 * kd;
	var a2 = kd;
	
	SOS.ar(in, a0, a1, a2, b1, b2);
};

var calcHpf3 = {|in, pRe0, pIm0, pRe1, kS|
	var fs = SampleRate.ir;

	var fosPole = (2*fs + pRe1) / (2*fs - pRe1);

	var denom = (2*fs - pRe0).squared + pIm0.squared;
	var pReZ = ((2*fs).squared - (pRe0.squared + pIm0.squared)) / denom;
	var pImZ = (2*pIm0 * 2*fs) / denom;
	var sosB1 = 2 * pReZ;
	var sosB2 = -1 * (pReZ.squared + pImZ.squared);

	var kd = kS * (2*fs).cubed / ((2*fs - pRe1) * denom);
	var sosA0 = kd;
	var sosA1 = -2 * kd;
	var sosA2 = kd;

	var out = FOS.ar(in, 1.0, -1.0, fosPole);
	out = SOS.ar(out, sosA0, sosA1, sosA2, sosB1, sosB2);
};

// Filters

var hpf0 = calcHpf2.(
	~bpfStage, // in
	-8130.08130081, // pRe
	13426.38737959, // pIm
	1.0 // kS
);

var hpf1 = calcHpf2.(
	~bpfStage, // in
	-27777.77777778, // pRe
	48112.52243247, // pIm
	2.0 // kS
);

var hpf2 = calcHpf3.(
	~bpfStage, // in
	-5749.67889223, // pRe0
	64618.05691577, // pIm0
	-32681.30805396, // pRe1
	2.7727272727272725 // kS
);

// Final HPF stage sum

~hpfStage = hpf0 + hpf1 + hpf2;
)

Amplitude Envelope

The 808 cymbal’s amplitude envelope is generated by three “swing-type VCAs,” each driven by a diode-gated envelope generator with a switched (attack vs. release) time constant and a nonlinear clipping stage. The nonlinearity is difficult to model and because its effect on spectral character of the 808 cymbal is “perceptually insignificant” (Werner et al., §12), a reasonable substitute is EnvGen with Env.perc.

Some Useful Values

Attack: 1.0244e-4 seconds (Werner et al.)

Release: (according to the service manual)

  • Cymbal: 0.35 - 1.2 seconds
  • Open hat: 0.09 - 0.6 seconds
  • Closed hat: 0.05 seconds

The SynthDef

(
SynthDef(\cymbal, {
	// Controls
	var rel = \rel.kr(0.05), 
	trig = \trig.tr(1), 
	out = \out.kr(0), 
	amp = \amp.kr(1), 
	pan = \pan.kr(0), 
	done = \doneAction.ir(2);
	
	var freqs = #[205.3, 369.6, 304.4, 522.7, 800, 540]; // if you want the two tunable oscillators to be controls, you can do that too
	
	// Filter calculations
	var fs = SampleRate.ir; // we'll pull out of each function and just share it
	
	var calcBpf3 = {|in, zRe0, pRe0, pIm0, pRe1, kS|
		var fosPole = (2*fs + pRe1) / (2*fs - pRe1);
		
		var denom = (2*fs - pRe0).squared + pIm0.squared;
		var pReZ = ((2*fs).squared - (pRe0.squared + pIm0.squared)) / denom;
		var pImZ = (2*pIm0 * 2*fs) / denom;
		var sosB1 = 2*pReZ;
		var sosB2 = -1 * (pReZ.squared + pImZ.squared);
		
		var zZeroD = (2*fs + zRe0) / (2*fs - zRe0);
		
		var kD = kS * (2*fs - zRe0) * (2*fs) /
		((2*fs - pRe1) * ((2*fs - pRe0).squared + pIm0.squared));
		
		var sosA0 = kD;
		var sosA1 = kD * -1 * (zZeroD - 1);
		var sosA2 = kD * -1 * zZeroD;
		
		var out = FOS.ar(in, 1.0, -1.0, fosPole);
		out = SOS.ar(out, sosA0, sosA1, sosA2, sosB1, sosB2);
	};
	
	var calcHpf2 = {|in, pRe, pIm, kS|
		var denom = (2*fs - pRe).squared + pIm.squared;
		var pReZ = ((2*fs).squared - (pRe.squared + pIm.squared)) / denom;
		var pImZ = (2*pIm * 2*fs) / denom;
		var b1 = 2 * pReZ;
		var b2 = -1 * (pReZ.squared + pImZ.squared);
		
		var kd = kS * (2*fs).squared / denom;
		var a0 = kd;
		var a1 = -2 * kd;
		var a2 = kd;
		
		SOS.ar(in, a0, a1, a2, b1, b2);
	};
	
	var calcHpf3 = {|in, pRe0, pIm0, pRe1, kS|
		var fosPole = (2*fs + pRe1) / (2*fs - pRe1);
		
		var denom = (2*fs - pRe0).squared + pIm0.squared;
		var pReZ = ((2*fs).squared - (pRe0.squared + pIm0.squared)) / denom;
		var pImZ = (2*pIm0 * 2*fs) / denom;
		var sosB1 = 2 * pReZ;
		var sosB2 = -1 * (pReZ.squared + pImZ.squared);
		
		var kd = kS * (2*fs).cubed / ((2*fs - pRe1) * denom);
		var sosA0 = kd;
		var sosA1 = -2 * kd;
		var sosA2 = kd;
		
		var out = FOS.ar(in, 1.0, -1.0, fosPole);
		out = SOS.ar(out, sosA0, sosA1, sosA2, sosB1, sosB2);
	};
	
	// Oscillator stage
	
	var width = 0.4798;
	var oscs = freqs.collect{|freq|
		Pulse.ar(freq, width);
	}.sum * 0.15;
	
	// BPF stage
	
	var bpf0 = calcBpf3.(
		oscs, // in
		-131302.5210084, // zRe0
		-1793.40028694, // pRe0
		21627.29000861, // pIm0
		-9182.73645546, // pRe1
		-8912.6559714795 // kS
	);
	
	var bpf1 = calcBpf3.(
		oscs, // in
		-270562.77056277, // zRe0
		-3695.49150037, // pRe0
		44565.32486623, // pIm0
		-45454.54545455, // pRe1
		-27548.20936639118 // kS
	);
	
	var bpfStage = bpf0 + bpf1;
	
	// HPF stage
	
	var hpf0 = calcHpf2.(
		bpfStage, // in
		-8130.08130081, // pRe
		13426.38737959, // pIm
		1.0 // kS
	);
	
	var hpf1 = calcHpf2.(
		bpfStage, // in
		-27777.77777778, // pRe
		48112.52243247, // pIm
		2.0 // kS
	);
	
	var hpf2 = calcHpf3.(
		bpfStage, // in
		-5749.67889223, // pRe0
		64618.05691577, // pIm0
		-32681.30805396, // pRe1
		2.7727272727272725 // kS
	);
	
	var hpfStage = hpf0 + hpf1 + hpf2;
	
	// Envelope
	var atk = 1.0244e-4;
	var env = Env.perc(atk, rel).ar(done, trig);
	
	var sig = hpfStage * env * amp;
	
	sig = Pan2.ar(sig, pan);
	
	Out.ar(out, sig);
}).add;
)

AI Disclosure:

AI was used for the following purposes:

  1. Proofreading (prose and code legibility/consistency)
  2. Verifying and searching for electronic component values, as well as verifying analog circuit analysis
3 Likes

Sorry for OT but is this AI generated? To me it shows some signs of it and if it was AI generated I wonder what is the motivation of sharing it here w/o any kind of personal comment.
I would love to limit this forum to human interaction.
Also see the CoC on usage of ai tools

Sorry if my perception is wrong here.

No it was not. I used AI to proofread the post, but not to write it. I also used it to verify some of the component values in the schematic, but I’d call that a very reasonable use case. To answer your other question – why not? I thought it was useful information but if it’s not, I can take it down. It took me three days to make the original code I used, then optimize it and do the writeup (the writeup itself took two days of 1-3 hour sessions).

1 Like

oh sorry, maybe I am being too paranoid these days - the formatting is really polished which I often associate w/ ai these days, but I appreciate the effort of the really clean layout!
And please keep it, I actually find it interesting how the filters are constructed! Thanks for sharing! :slight_smile:

1 Like

Totally understand. I use markdown for my own website, so I’ve gotten used to using it :+1:

I find it just keeps things nicely organized and it’s a pretty minimal investment. Glad you found the information useful!

2 Likes

To address your initial concern, I added a disclosure. Good criticism!

4 Likes

sounds fab - thanks for this…

1 Like

huh mad, i always thought the oscs were combined with an MS type ringmod thing

is there any way to reverse engineer those SOS coefs to be freq+rq so the whole thing can be tuned/follow kybd?

Interesting work, but am I not understanding something here? When I run the code, I get a very simple closed hi-hat sound, which doesn’t remind me much of the classic 808 cymbal sounds.

I notice there’s one short-duration envelope; again I’d expect a much longer duration for the 808 cymbals.

I also don’t see SynthDef arguments to change parameters for decay, tone, etc., which are mentioned as tunable in the linked paper.

set \rel longer for cymbal sound: Synth(\cymbal, [\rel, 2]) for example…

I don’t really do arg or pipe synthdefs. There is a list of what would be the arguments but they’re variables and NamedControls

The tunable oscillator frequencies you can pull out of the array and turn into more NamedControls, I just didn’t bother in the write up.

Not really since these are physical R/C values. You could scale the poles and zeroes by a frequency value before the bilinear transform if you wanted to though if that’s what you’re asking

1 Like

I think this is some super clean sclang code!

One thought, you could use keyword arguments rather than comments for the argument ordering.

You might also enjoy pipe syntax (which is an extension to object).

+Object {
   |> { |f| ^f.(this) }
}
 
hpfStage * env * amp
   |> Pan2.ar(_, pan)
   |> Out.ar(out, _);
2 Likes

I knew there had to be a way to do that but I couldn’t figure out what it was. Thanks!!

typo! - no terminal ; when piping (until you’re actually done)

 |> Pan2.ar(_, pan)
 * 8
 |> _.poll
 |> Out.ar(out, _);

there’s another 808 patch floating around with RLPF->3 x RHPF on the output, but your one sounds much better in the filter department at least,
ta

1 Like