No Man's Bleep

ѰҴϑŁ¶¤ЖӚӠℓ‡Ԓ∫ѰҴϑŁ¶¤ЖӚӠℓ‡Ԓ

Just about the most anticipated computer game of last year, No Man’s Sky was notable for the algorithmically-generated galaxy in which it’s set - trillions of planets, all unique. It’s awe-inspiring. Unique creatures roam among unique plants and rocks on unique landforms, but when you land your nearly-unique starship at a rather familiar-looking trading station, the trading terminal is exactly the same as all the others. (I’ve picked one example here; there are many other objects which are exactly the same all over the galaxy, and which could benefit from some randomisation.)

It’s the sound I particularly want to write about. Each terminal emits the same bleeps and bloops. It would have been easy to make them all different, right? So that’s what I’ve mocked up here. I’ve taken the opportunity to try out the web audio API, so this demo works in the browser (unless your browser is rubbish, of course). Click the trading terminal (that eye thing) above to hear examples.

In the game, I assume the parameters used to generate each object are derived from its co-ordinates in space. I’m using the standard random number generator, but it would be easy to feed it with a seed value based on the location of the terminal, so that each one had its own unique noise.

Make some noise

The web audio API is pretty basic compared with CSound, but it provides us with the parts we need: oscillators, filters and amplifiers. Here’s the plan:

Bleepy synth schematic Oscillator Filter Amplifier LFO gain Random curve Random curve frequency frequency Out

There’s also a random curve going to the LFO rate, and random choices of oscillator waveform and filter type.

First, we have to create an AudioContext, which is our way into the API. The AudioContext creates our oscillators and other nodes, co-ordinates timing, handles routing, and sends the sound to the speakers. So it’s pretty clever, and it’s the central object you’ll work with in Web Audio. (In Safari, it’s a webkitAudioContext instead, though it works in the same way.) So this is how we get hold of it:

var context = new (window.AudioContext || window.webkitAudioContext)();

And this is how we use it to create audio nodes:

var osc = context.createOscillator();
var lfo = context.createOscillator();
var fil = context.createBiquadFilter();
var amp = context.createGain();

Connecting them up is easy too. Just use the source audio node’s connect function to plug it into the next one. The final node connects to context.destination, which is the main output:

osc.connect(fil);
fil.connect(amp);
amp.connect(context.destination);

We choose the waveforms for the oscillator and LFO randomly from the four predefined types: sine, square, sawtooth and triangle. Likewise, we choose the filter type from lowpass, highpass, bandpass and allpass. The filter Q is also random, between 10 and 42. I chose thse bounds by trial and error - although the Q can go up to 1000, anything higher leads to self-oscillation, and it drowns out the actual oscillator. Anything lower than 10 just isn’t so much fun.

Here are the random generator functions I used for those:

/**
Random number between min and max
*/
function randomBetween(min, max) {
	min = min || 0;
	max = max || 1;
	return Math.random() * (max - min) + min;
}

/**
Returns the name of one of the four standard oscillator waveforms
*/
function randomOscType() {
	var rnd = Math.random();
	if (rnd < 0.25) return 'sine';
	if (rnd < 0.5) return 'square';
	if (rnd < 0.75) return 'sawtooth';
	return 'triangle';
}

/**
Returns the name of one of the four standard filter types
*/
function randomFilterType() {
	var rnd = Math.random();
	if (rnd < 0.25) return 'lowpass';
	if (rnd < 0.5) return 'highpass';
	if (rnd < 0.75) return 'bandpass';
	return 'allpass';
}

There are three Float32Arrays of frequencies: one for the oscillator frequency, one for the filter cutoff, and one for the amplifier LFO frequency. These audio nodes are picky; they won’t duck-type ordinary arrays of numbers into these exotic typed structures. We choose random lengths for the arrays (within appropriate limits), and fill them with random frequencies. For the LFO, the randomBetween function we used for the array lengths and note length will do just fine for the frequency generation. But for the audio frequencies of the oscillator and filter, we need something extra. Because an octave is a doubling in frequency, each successive semitone interval is a bigger leap in terms of Hz. This means that if we just pick random numbers in the audio range (I chose a comfortable 80 to 3200 Hz), they will tend to be many more high notes than low. To get around that, I raise 2 to the power of the raw random number (which is between 0 and 1). That gives a number between 1 and 2, but tending towards the lower end of the range. Take away 1 and stretch it up to the range we want, and it gives a nice even spread of pitches.

/**
Random audio frequency between 80 and 3200
*/
function randomFrequency() {
	// Weighted random number for audio taper
	var rnd = Math.pow(2, Math.random())-1;
	return 3120 * rnd + 80;
}

These Float32Arrays get interpolated into a-rate curves when we pass them into setValueCurveAtTime on the frequency property of an oscillator or filter.

Here’s the play() function, which sets up the note and plays it.

function play() {
	// length of sound in seconds
	var length = randomBetween(0.5, 1.5); 
	
	// Set up the audio nodes we need
	var osc = context.createOscillator();
	var lfo = context.createOscillator();
	var fil = context.createBiquadFilter();
	var amp = context.createGain();
	
	osc.type = randomOscType();
	lfo.type = randomOscType();
	fil.type = randomFilterType();
	
	// Q is an a-rate param, so to set it to a constant level use Q.value.
	fil.Q.value = randomBetween(10, 42);
	
	// Wire them up
	osc.connect(fil);
	fil.connect(amp);
	amp.connect(context.destination);
	
	// List of 2 to 8 frequencies. An ordinary array won't work.
	var freqArray = new Float32Array(Math.floor(randomBetween(2, 9)));

	for (var i = 0; i < freqArray.length; i++) {
		freqArray[i] = randomFrequency();
	}
	
	// Sweep the oscillator to each frequency in turn
	osc.frequency.setValueCurveAtTime(freqArray, context.currentTime, length);
	
	// Same for the amplitude LFO, but lower
	var lfArray = new Float32Array(Math.floor(randomBetween(1, 4)));
	
	for (var i = 0; i < lfArray.length; i++) {
		lfArray[i] = randomBetween(0, 20);
	}
	
	lfo.frequency.setValueCurveAtTime(lfArray, context.currentTime, length);
	lfo.connect(amp.gain);
	
	// And the filter frequency
	var lfArray2 = new Float32Array(Math.floor(randomBetween(1, 8)));
	
	for (var i = 0; i < lfArray2.length; i++) {
		lfArray2[i] = randomFrequency();
	}
	
	fil.frequency.setValueCurveAtTime(lfArray2, context.currentTime, length);

	// Start the oscillators
	osc.start();
	lfo.start();
	
	// Set them to stop when we're finished
	osc.stop(context.currentTime + length);
	lfo.stop(context.currentTime + length);
}

Sound and vision

I’ve also added some randomisation to the visual, as you probably noticed. It’s an SVG image, so JavaScript can manipulate it as a document, just as it can the text on the page. When it’s clicked, as well as playing the sound, I change the colour and the text of the “iris” part of the terminal. To get an equally saturated colour each time, I use the hsla() notation, rather than the more familiar #BADA55 RGB type. Keeping the s, l and a the same, I insert a random hue, like 'hsla(' + hue + ',99%,50%,1)'. The text is picked from a collection of alien-looking but not-too-rare characters, which I hope show up on most systems. If the game designers did this, they’d be able to use some properly canon alien writing.

Just view the source of this page to see the whole script, with the sound and the visual tweaks together.

Footnotes

Image credit: my own screenshot of No Man’s Sky.


By Hugh