The Sound of Life is an audio automata playground built on Conway's Game of Life using a combination of Canvas and Web Audio.
You can view a demo here: http://sgentle.github.io/soundoflife
Some standard macguffins for dealing with the DOM and setting up our canvas.
$ = document.querySelector.bind(document)
canvas = $('#canvas')
ctx = canvas.getContext("2d")
ctx.fillRect(0, 0, canvas.width, canvas.height)
Constants. Uh, "constants". We can change these from the UI because, like, change is the only constant, man.
TONES = 5 # Also segment width
OCTAVES = 4 # Also segment height
MID_NOTE = 440
SCALE_X = 4 # GoL cells per tone
SCALE_Y = 5 # GoL cells per octave
TIME_SCALE = 5 # Draws per step
This is the best way I can think of to put these in the top scope while still being able to re-derive them when I need to.
data = newdata = drawdata = DATA_W = DATA_H = DATA_LENGTH = null #Eww
We re-run this whenever the constants change. The two data buffers run the GoL itself, holding the current and previous step respectively. We swap them a lot.
setupBuffers = ->
DATA_W = SCALE_X * TONES
DATA_H = SCALE_Y * OCTAVES
DATA_LENGTH = DATA_W*DATA_H
data = new Uint8ClampedArray(DATA_LENGTH)
newdata = new Uint8ClampedArray(DATA_LENGTH)
drawdata = new Uint8ClampedArray(DATA_LENGTH*4) #RGBA
for i in [0...data.length]
data[i] = newdata[i] = 0
for i in [0...drawdata.length]
drawdata[i] = if i % 4 is 3 then 255 #Alpha channel
We divide the grid into equally-sized regions. Each region has an oscillator, a gain node and a color. We set the frequency here and then vary the gain with the sum of the active cells in that region. Similarly, we set the hue and saturation of the cells here and then set the value based on whether the cell's alive.
genColor = (h, s) ->
i = Math.floor(h * 6)
f = h * 6 - i
p = 1 - s
q = 1 - f * s
t = 1 - (1 - f) * s
v = 1
[r, g, b] =
switch i % 6
when 0 then [v, t, p]
when 1 then [q, v, p]
when 2 then [p, v, t]
when 3 then [p, q, v]
when 4 then [t, p, v]
when 5 then [v, p, q]
{r, g, b}
Everything connects to the master gain in case we want to add a volume control or something. I set it to 0.75 even though the correct value is 1 because people aren't used to the raw power of properly normalised audio.
context = mastergain = null
setupAudio = ->
context = new (window.AudioContext or window.webkitAudioContext)()
mastergain = context.createGain()
mastergain.gain.value = 0.75
mastergain.connect context.destination
Set up our regions. The heavy lifting for the colors is done already, but we still have to set up the frequencies. The magic formula below creates an N-tone equal temperament scale centered on MID_NOTE which is A440 because it's the only frequency I can remember.
regions = []
setupRegions = ->
region.osc.stop() for region in regions
regions = []
for i in [0...TONES * OCTAVES]
freq = MID_NOTE * Math.pow(2, i/TONES - OCTAVES/2)
osc = context.createOscillator()
osc.frequency.value = freq
gain = context.createGain()
gain.gain.value = 0
osc.connect gain
osc.start()
gain.connect mastergain
x = i % TONES / TONES
y = i // TONES / OCTAVES
color = genColor x, 1-y
regions.push {osc, gain, color, newgain: 0}
Turn grid coordinates into regions. Slight quirk: regions are numbered from the bottom left, grids are from the top left. Canvas likes the latter, but it seems more natural for pitch to go up as it goes... up.
getRegion = (x, y) ->
y = (DATA_H - 1 - y) // SCALE_Y
x //= SCALE_X
reg = regions[x + y*TONES]
To draw fast and feel like super cool hackers, we just render the pixels directly onto the canvas and then scale them up. To do this - I'm not even kidding - we draw the data to the canvas, then draw the canvas into itself.
setupCanvas = ->
ctx.setTransform(canvas.width/DATA_W, 0, 0, canvas.height/DATA_H, 0, 0)
ctx.globalCompositeOperation = "copy"
ctx.imageSmoothingEnabled = false
ctx[x+"ImageSmoothingEnabled"] = false for x in 'moz ms webkit'.split(' ') # Vendor prefixes are gross
Because of the magic of cinema, we want to draw as quickly as we can even though the GoL can look nicer at a slow speed. To bridge this gap we use interpolation, which I'm told is latin for "making it look blurry".
We step the GoL every TIME_SCALE draws, and drawcount reflects how many draws we've done.
drawcount = 0
draw = ->
totalgain = 0
drawR = drawcount/TIME_SCALE
for i in [0...drawdata.length] by 4
region = getRegion(i/4 % DATA_W, i/4 // DATA_W)
val = data[i>>2]
oldval = newdata[i>>2]
v = 255 * (val*drawR + oldval*(1-drawR))
region.newgain += v
totalgain += v
c = region.color
drawdata[i] = c.r * v
drawdata[i+1] = c.g * v
drawdata[i+2] = c.b * v
max_region = SCALE_X * SCALE_Y
max_global = DATA_W * DATA_H
for region in regions
region.gain.gain.value = region.newgain / totalgain or 0
region.newgain = 0
imageData = new ImageData(drawdata, DATA_W, DATA_H)
ctx.putImageData(imageData, 0, 0)
ctx.drawImage(ctx.canvas, 0, 0)
Here's the code that actually does things. Everything else is really just window dressing. Rel gets us the value of each neighbour, or 0 if we go past the boundary of the screen.
rel = (n, x, y) ->
i = n + x + y * DATA_W
b = n % (DATA_W) + x
return 0 if b > (DATA_W) or b < 0 or i >= DATA_LENGTH or i < 0 # prevent overflows
data[i]
Step implements the GoL logic itself. You know I saw someone do this in one line of APL once? I bet it ran faster too...
step = ->
for i in [0...data.length] by 1
neighbours = [
rel i, -1, -1 #top left
rel i, 0, -1 #top
rel i, +1, -1 #top right
rel i, -1, 0 #left
rel i, +1, 0 #right
rel i, -1, +1 #bottom left
rel i, 0, +1 #bottom
rel i, +1, +1 #bottom right
]
alive = 0
for n in neighbours
alive++ if n == 1
if alive < 2
newdata[i] = 0
else if alive > 3
newdata[i] = 0
else if alive == 3
newdata[i] = 1
else
newdata[i] = data[i]
[data, newdata] = [newdata, data]
Actually call our code on the graphics loop. Logic as described above in the draw section.
paused = false
raf = ->
drawcount++ unless paused
draw()
if drawcount >= TIME_SCALE
drawcount = 0
step()
requestAnimationFrame raf
These are how we start things running. We do this as late as possible so I can embed this on my website without loading a billion oscillators every time.
inited = false
init = ->
inited = true
setupAudio()
setupBuffers()
setupRegions()
setupCanvas()
step()
raf()
reInit = ->
setupBuffers()
setupRegions()
setupCanvas()
clicking = false
click = (ev) ->
return unless clicking
x = Math.floor(ev.offsetX / canvas.offsetWidth * DATA_W)
y = Math.floor(ev.offsetY / canvas.offsetHeight * DATA_H)
i = y * DATA_W + x
data[i] = newdata[i] = 1
canvas.addEventListener 'mousedown', (ev) ->
init() unless inited
clicking = true if ev.button is 0
click(ev)
canvas.addEventListener 'mouseup', -> clicking = false
canvas.addEventListener 'mouseout', -> clicking = false
canvas.addEventListener 'mousemove', click
$('#clear').addEventListener 'click', ->
data[i] = newdata[i] = 0 for i in [0...data.length]
$('#random').addEventListener 'click', ->
data[i] = newdata[i] = Math.round(Math.random()) for i in [0...data.length]
$('#pause').addEventListener 'click', ->
paused = !paused
$('#pause').textContent = if paused then 'Play' else 'Pause'
$('#octaves').value = OCTAVES
$('#octaves').addEventListener 'input', -> OCTAVES = $('#octaves').value * 1 or OCTAVES; reInit()
$('#tones').value = TONES
$('#tones').addEventListener 'input', -> TONES = $('#tones').value * 1 or TONES; reInit()
$('#scale_x').value = SCALE_X
$('#scale_x').addEventListener 'input', -> SCALE_X = $('#scale_x').value * 1 or SCALE_X; reInit()
$('#scale_y').value = SCALE_Y
$('#scale_y').addEventListener 'input', -> SCALE_Y = $('#scale_y').value * 1 or SCALE_Y; reInit()
$('#mid_note').value = MID_NOTE
$('#mid_note').addEventListener 'input', -> MID_NOTE = $('#mid_note').value * 1 or MID_NOTE; setupRegions()
$('#time_scale').value = TIME_SCALE
$('#time_scale').addEventListener 'input', -> TIME_SCALE = $('#time_scale').value * 1 or TIME_SCALE