summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/draw.js197
-rw-r--r--client/index.js106
-rw-r--r--client/lib/color.js33
-rw-r--r--client/lib/hall.js81
-rw-r--r--client/lib/kalimba.js56
-rw-r--r--client/lib/keys.js41
-rw-r--r--client/lib/mouse.js67
-rw-r--r--client/lib/output.js8
-rw-r--r--client/lib/sampler.js61
-rw-r--r--client/lib/startAudioContext.js181
-rw-r--r--client/lib/util.js111
11 files changed, 942 insertions, 0 deletions
diff --git a/client/draw.js b/client/draw.js
new file mode 100644
index 0000000..a9c182d
--- /dev/null
+++ b/client/draw.js
@@ -0,0 +1,197 @@
+import {
+ browser, requestAudioContext,
+ randint, randrange, clamp,
+} from './lib/util'
+
+import mouse from './lib/mouse'
+import color from './lib/color'
+
+let w, h
+let rx, ry
+
+const canvas = document.createElement('canvas')
+const ctx = canvas.getContext('2d')
+
+document.body.appendChild(canvas)
+document.body.addEventListener('resize', resize)
+resize()
+recenter()
+requestAnimationFrame(animate)
+
+function resize(){
+ w = canvas.width = window.innerWidth
+ h = canvas.height = window.innerHeight
+ clear()
+}
+function recenter(){
+ rx = randint(w), ry = randint(h)
+}
+function animate(t){
+ requestAnimationFrame(animate)
+ ctx.save()
+ ctx.globalAlpha = 0.0001
+ ctx.translate(w/2, h/2)
+ ctx.rotate(0.1)
+ ctx.translate(-rx, -ry)
+ ctx.drawImage(canvas, 0, 0)
+ ctx.restore()
+}
+function clear(n, x, y, ww, hh){
+ ctx.fillStyle = 'rgba(255,255,255,' + (n || 0.5) + ')'
+ ctx.fillRect(x || 0, y || 0, ww || w, hh || h)
+ recenter()
+}
+function triangle(px,py,r){
+ setTimeout( () => tri(px,py,r), Math.random()*10)
+ // setTimeout( () => tri(px,py,r), Math.random()*200)
+ // setTimeout( () => tri(px,py,r), Math.random()*300)
+}
+function tri(px, py, r) {
+ ctx.save()
+ ctx.globalCompositeOperation = 'multiply'
+ ctx.fillStyle = color((px+py)/(w+h), 0, 1, 1)
+ function p(){
+ let theta = randrange(0, Math.PI*2)
+ let x = px + Math.cos(theta) * r
+ let y = py + Math.sin(theta) * r
+ return { x, y }
+ }
+ ctx.beginPath()
+ const p0 = p(), p1 = p(), p2 = p()
+ ctx.moveTo(p0.x, p0.y)
+ ctx.lineTo(p1.x, p1.y)
+ ctx.lineTo(p2.x, p2.y)
+ ctx.lineTo(p0.x, p0.y)
+ ctx.fill()
+ ctx.restore()
+}
+function line(y){
+ ctx.beginPath()
+ ctx.moveTo(0, y)
+ ctx.lineTo(w, y)
+ ctx.strokeStyle = "#888"
+ ctx.strokeWidth = 1
+ ctx.stroke()
+}
+function dot(x, y, r){
+ ctx.fillStyle = "#f00"
+ ctx.beginPath()
+ ctx.moveTo(x, y)
+ ctx.arc(x, y, r, 0, 2*Math.PI)
+ ctx.fill()
+}
+function waveform(pcm, sr, pos, zoom){
+ sr = sr || 44100
+ pos = pos || 0
+
+ var width = w
+ var height = Math.floor(h/4)
+ var half_height = Math.floor(height/2)
+ var x0 = 0
+ var y0 = 20
+ var ymid = y0 + half_height
+ var pixels_per_second = 1024
+ var max_width_in_seconds = width / pixels_per_second
+ var max_width_in_samples = max_width_in_seconds * sr
+ var pcm_length = pcm.length
+ var len = Math.min(pcm_length, max_width_in_samples)
+ var pcm_step = sr / pixels_per_second
+ var i
+ ctx.save()
+
+ clear(1, x0, y0, width, height)
+
+ line(ymid)
+ ctx.beginPath()
+ for (i = 0; i < width; i += 0.5) {
+ var si = Math.floor(pcm_step * i + pos)
+ if (si > pcm_length) break
+ var val = pcm[si] // -1, 1
+ // ctx.moveTo(x0 + i, ymid)
+ ctx.lineTo(x0 + i, ymid + val * half_height)
+ }
+ ctx.strokeStyle = "rgba(250,20,0,0.9)"
+ ctx.strokeWidth = 1
+ ctx.stroke()
+ ctx.restore()
+}
+
+const signalWindows = require('signal-windows').windows
+const FFTJS = require('fft.js')
+const fft_size = 1024
+const fft = new FFTJS(fft_size)
+
+function toSpectrum(pcm){
+ const ham = signalWindows.construct('ham', fft_size)
+ const pcm_in = new Array(fft_size)
+ const pcm_length = pcm.length
+ const pcm_q_length = Math.ceil(pcm_length / fft_size) * fft_size
+
+ let i, j, fft_out, spec = [];
+ for (i = 0; i < pcm_q_length; i += fft_size/4) {
+ for (j = 0; j < fft_size; j++) {
+ pcm_in[j] = pcm[i+j] * ham[j] || 0
+ }
+ fft_out = fft.createComplexArray()
+ fft.realTransform(fft_out, pcm_in)
+ spec.push(fft_out)
+ }
+
+ return spec
+}
+function spectrum(pcm, sr){
+ sr = sr || 44100
+ const spec = toSpectrum(pcm)
+
+ ctx.save()
+
+ const scratch = document.createElement('canvas')
+ scratch.width = spec.length
+ scratch.height = fft_size
+ const scratchCtx = scratch.getContext('2d')
+
+ var imageData = ctx.createImageData(scratch.width, scratch.height)
+ var data = imageData.data
+
+ let i, j, u, v, _r, _i, col, spec_len = spec.length
+ for (i = 0; i < spec_len; i++) {
+ col = spec[i]
+ for (j = 0; j < fft_size; j++) {
+ u = (j * spec_len + i) * 4
+ v = j * 2
+ _r = col[v]
+ _i = col[v+1]
+ // red - real part
+ data[u] = _r * 127 + 127
+ // green - imag part
+ data[u+1] = _i * 127 + 127
+ // blue - magnitude
+ data[u+2] = Math.sqrt(Math.pow(_r, 2) + Math.pow(_i, 2)) * 128 + 127
+ // data[u] = 128
+ // data[u+1] = 128
+ data[u+2] = 128
+ data[u+3] = 255
+ }
+ }
+
+ scratchCtx.putImageData(imageData, 0, 0)
+
+ var pcm_length = pcm.length
+ var pixels_per_second = 1024
+
+ const width = Math.round(pcm_length / sr * pixels_per_second) // ok not really this
+ const height = Math.floor(h*3/4)
+
+ const x0 = 0
+ const y0 = Math.floor(h/4) + 20
+ clear(1, x0, y0, w, height)
+ ctx.drawImage(scratch, x0, y0, width, height)
+
+ ctx.restore()
+}
+
+export default {
+ canvas, ctx,
+ triangle, clear, line, dot,
+ waveform, spectrum
+} \ No newline at end of file
diff --git a/client/index.js b/client/index.js
new file mode 100644
index 0000000..b1351fc
--- /dev/null
+++ b/client/index.js
@@ -0,0 +1,106 @@
+import Tone from 'tone'
+
+import Sampler from './lib/sampler'
+
+import draw from './draw'
+import keys from './lib/keys'
+import color from './lib/color'
+import mouse from './lib/mouse'
+
+import { Hall } from './lib/hall'
+
+import {
+ browser, requestAudioContext,
+ randint, randrange, choice, clamp, lerp, dist, shuffle,
+ isMobile,
+} from './lib/util'
+
+const root = 440
+const s = 50
+const w = window.innerWidth
+const h = window.innerHeight
+const ws = w/s, hs = h/s
+
+const HALLWAY_LENGTH = 147
+const SPEAKER_COUNT = 16
+
+let notes = [299, 336, 374, 399, 449, 498, 561, 598].map(i => i/2)
+notes = notes.concat(notes.map(i => i/2))
+notes = notes.concat(notes.map(i => i*2))
+notes = shuffle(notes)
+
+let samplers = {}
+let sampler
+
+requestAudioContext( () => {
+ samplers.smash = new Sampler('samples/smash/g{}.mp3', 12)
+ // samplers.glass = new Sampler('samples/glass/0{}Particle.mp3', 20)
+ // samplers.kalimba = new Sampler('samples/kalimba/380731__cabled-mess__sansula-08-c-raw.wav', 10)
+ samplers.choice = (m,n) => {
+ const r = Math.random()
+ if (r < m) return samplers.smash
+ if (r < m+n) return samplers.kalimba
+ return samplers.glass
+ }
+ Tone.Buffer.on('load', function(){
+ console.log('all buffers are loaded.')
+ redraw()
+ })
+})
+
+
+const hall = new Hall ({
+ length: HALLWAY_LENGTH,
+ speakers: SPEAKER_COUNT,
+})
+
+function redraw(){
+ draw.clear()
+}
+
+keys.listen(index => {
+ // trigger(Math.random(), ((index+7) % SPEAKER_COUNT) / SPEAKER_COUNT, 0, samplers.smash)
+ const sample = samplers.smash.choice()
+ const buf = sample.players[0]._buffer.get()
+ if (! buf) return
+ const pcm = buf.getChannelData(0)
+ const sr = buf.sampleRate
+ const duration = buf.duration
+ console.log(buf)
+ console.log(duration.toFixed(2) + " s.")
+ draw.clear()
+ draw.waveform(pcm)
+ draw.spectrum(pcm)
+})
+
+mouse.register({
+ down: (x, y) => {
+ redraw()
+ },
+ move: (x, y, dx, dy) => {
+ },
+ up: (x, y) => {
+ },
+})
+
+let timeout, px = 0, py = 0
+function play(x, y){
+}
+function trigger(x, y, t, sampler){
+ t = t || 0
+ t += Tone.now()
+ sampler = sampler || last_dist > 40
+ ? samplers.choice(0.2, 0.2)
+ : samplers.choice((1-y) * 0.2, y*0.02)
+ const freq = notes[Math.floor(x * notes.length)]
+ const speaker = hall.play(sampler, y, freq, x, t)
+
+ draw.triangle(
+ lerp(x, 0, 1) * window.innerWidth,
+ lerp(y, 0, 1) * window.innerHeight - 20,
+ 40
+ )
+}
+
+
+
diff --git a/client/lib/color.js b/client/lib/color.js
new file mode 100644
index 0000000..5f873b3
--- /dev/null
+++ b/client/lib/color.js
@@ -0,0 +1,33 @@
+
+const palettes = [
+ [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.00, 0.33, 0.67]],
+ [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.00, 0.10, 0.20]],
+ [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 1.0, 1.0], [0.30, 0.20, 0.20]],
+ [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 1.0, 0.5], [0.80, 0.90, 0.30]],
+ [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [1.0, 0.7, 0.4], [0.00, 0.15, 0.20]],
+ [[0.5, 0.5, 0.5], [0.5, 0.5, 0.5], [2.0, 1.0, 0.0], [0.50, 0.20, 0.25]],
+ [[0.8, 0.5, 0.4], [0.2, 0.4, 0.2], [2.0, 1.0, 1.0], [0.00, 0.25, 0.25]],
+]
+
+let palette = palettes[0]
+
+function channel (t, a, b, c, d, add, mul) {
+ return a + b * Math.cos(2 * Math.PI * (c * t + d)) * mul + add
+}
+
+function color (t, add, mul, alpha) {
+ add = add || 0
+ mul = mul || 1
+ let a, b, c, d
+ const rgb = []
+ for (var i = 0; i < 3; i++) {
+ a = palette[0][i]
+ b = palette[1][i]
+ c = palette[2][i]
+ d = palette[3][i]
+ rgb[i] = Math.round(channel(t, a, b, c, d, add, mul) * 255)
+ }
+ return 'rgba(' + rgb + ',' + alpha + ')'
+}
+
+export default color
diff --git a/client/lib/hall.js b/client/lib/hall.js
new file mode 100644
index 0000000..e07db80
--- /dev/null
+++ b/client/lib/hall.js
@@ -0,0 +1,81 @@
+import Tone from 'tone'
+import output from './output'
+import { clamp, choice } from './util'
+
+const SPEED_OF_SOUND = 0.0029154519 // ms/m
+
+const reverb = new Tone.Freeverb({
+ roomSize: 0.8,
+ dampening: 12000,
+}).connect(output)
+
+function db_per_meter(distance){
+ return 1+20*(Math.log(1/distance)/Math.log(10))
+}
+function db_to_percentage(db){
+ return Math.pow(10, db/10)
+}
+
+class Hall {
+ constructor(props) {
+ let speakers = this.speakers = []
+ let gain, gainEl
+ let delay, delayEl, z
+ for (let i = 0; i < props.speakers; i++) {
+ speakers.push(new Speaker({
+ speakers: props.speakers,
+ length: props.length,
+ i: i,
+ }))
+ }
+ }
+ getSpeaker(i){
+ if (i < 1 && i !== 0) {
+ i *= this.speakers.length
+ }
+ return this.speakers[Math.floor(i) % this.speakers.length]
+ }
+ play(sound, i, freq, pan, time){
+ const speaker = this.getSpeaker(i)
+ sound.play(freq, time || 0, pan < 0.5 ? speaker.panLeft : speaker.panRight)
+ return speaker
+ }
+}
+
+class Speaker {
+ constructor(props){
+ const z = props.length * (props.i + 0.5) / props.speakers + 3
+ const reverb_z = props.length * (props.i + 0.5 + props.speakers - 4) / props.speakers
+ const z_db = db_per_meter(z)
+ const reverb_db = db_per_meter(reverb_z)
+
+ this.z = z
+
+ this.pan = Math.atan(3/this.z)
+ this.panLeft = new Tone.Panner(-this.pan)
+ this.panRight = new Tone.Panner(-this.pan)
+
+ this.gain = db_to_percentage(z_db + 8)
+ this.reverbGain = db_to_percentage(reverb_db + 15)
+
+ this.delay = z * SPEED_OF_SOUND
+ // console.log(z, this.gain.toFixed(4), this.reverbGain.toFixed(4), this.pan)
+
+ this.input = new Tone.Delay(this.delay)
+ this.dryOutput = new Tone.Gain (this.gain)
+ this.wetOutput = new Tone.Gain (this.reverbGain)
+
+ this.panLeft.connect(this.dryOutput)
+ this.panLeft.connect(this.wetOutput)
+ this.panRight.connect(this.dryOutput)
+ this.panRight.connect(this.wetOutput)
+
+ this.input.connect(this.dryOutput)
+ this.input.connect(this.wetOutput)
+
+ this.dryOutput.connect(output)
+ this.wetOutput.connect(reverb)
+ }
+}
+
+export { Hall, Speaker } \ No newline at end of file
diff --git a/client/lib/kalimba.js b/client/lib/kalimba.js
new file mode 100644
index 0000000..bc048ff
--- /dev/null
+++ b/client/lib/kalimba.js
@@ -0,0 +1,56 @@
+import Tone from 'tone'
+import { choice } from './util'
+
+const player_count = 1
+
+const compressor = new Tone.Compressor(-30, 3).toMaster()
+
+const samples = [
+ { root: 226, fn: 'samples/kalimba/380737__cabled-mess__sansula-01-a-raw.wav', },
+ { root: 267, fn: 'samples/kalimba/380736__cabled-mess__sansula-02-c-raw.wav', },
+ { root: 340, fn: 'samples/kalimba/380735__cabled-mess__sansula-03-e-raw.wav', },
+ { root: 452, fn: 'samples/kalimba/380733__cabled-mess__sansula-06-a-02-raw.wav', },
+// { root: 507, fn: 'samples/380734__cabled-mess__sansula-07-b-h-raw.wav', },
+// { root: 535, fn: 'samples/380731__cabled-mess__sansula-08-c-raw.wav', },
+// { root: 671, fn: 'samples/380732__cabled-mess__sansula-09-e-raw.wav', },
+]
+
+samples.forEach((sample) => {
+ sample.players = []
+ sample.index = -1
+ for (let i = 0; i < player_count; i++) {
+ let fn = sample.fn
+ if (window.location.href.match(/asdf.us/)) {
+ fn = 'http://asdf.us/kalimba/' + fn.replace('wav','mp3')
+ }
+ let player = new Tone.Player({
+ url: fn,
+ retrigger: true,
+ playbackRate: 1,
+ })
+ player.connect(compressor)
+ sample.players.push(player)
+ }
+})
+
+function play (freq, time) {
+/*
+ while (freq < 440) {
+ freq *= 2
+ }
+ while (freq > 880) {
+ freq /= 2
+ }
+ freq /= 2
+*/
+ time = time || Tone.now()
+ const best = { sample: choice(samples) }
+ best.sample.index = (best.sample.index + 1) % player_count
+
+ const player = best.sample.players[ best.sample.index ]
+ player.playbackRate = freq / best.sample.root
+ player.start(time)
+}
+
+export default { play }
+
diff --git a/client/lib/keys.js b/client/lib/keys.js
new file mode 100644
index 0000000..5b98ace
--- /dev/null
+++ b/client/lib/keys.js
@@ -0,0 +1,41 @@
+const keys = {}
+const key_numbers = {}
+const letters = "yxcvbnmasdfghjklqwertuiopz"
+const numbers = "1234567890"
+
+let callback = function(){}
+
+letters.toUpperCase().split("").map(function(k,i){
+ keys[k.charCodeAt(0)] = i
+})
+
+keys['Z'.charCodeAt(0)] = 0
+
+numbers.split("").map(function(k,i){
+ keys[k.charCodeAt(0)] = i+letters.length
+ key_numbers[k.charCodeAt(0)] = true
+})
+
+window.addEventListener("keydown", keydown, true)
+function keydown (e) {
+ if (e.altKey || e.ctrlKey || e.metaKey) {
+ e.stopPropagation()
+ return
+ }
+ if (document.activeElement instanceof HTMLInputElement &&
+ (e.keyCode in key_numbers)) {
+ e.stopPropagation()
+ return
+ }
+ if (! (e.keyCode in keys)) return
+ var index = keys[e.keyCode]
+ if (e.shiftKey) index += letters.length
+ index -= 7
+ callback(index)
+}
+
+function listen (fn) {
+ callback = fn
+}
+
+export default { listen } \ No newline at end of file
diff --git a/client/lib/mouse.js b/client/lib/mouse.js
new file mode 100644
index 0000000..990b8ef
--- /dev/null
+++ b/client/lib/mouse.js
@@ -0,0 +1,67 @@
+/*
+
+mouse.register({
+ down: (x, y) => {
+ },
+ move: (x, y, dx, dy) => {
+ },
+ up: (x, y) => {
+ },
+})
+
+*/
+
+
+let fns = {
+ down: [],
+ move: [],
+ up: [],
+}
+
+document.body.scrollTo(0,0)
+document.body.parentNode.scrollTo(0,0)
+document.body.addEventListener("scroll", function(e){ e.preventDefault() })
+document.body.parentNode.addEventListener("scroll", function(e){ e.preventDefault() })
+
+export default {
+ register: (callbacks) => {
+ callbacks.down && fns.down.push(callbacks.down)
+ callbacks.move && fns.move.push(callbacks.move)
+ callbacks.up && fns.up.push(callbacks.up)
+ }
+}
+
+let x, y, dragging = false
+
+function down(e){
+ x = e.pageX
+ y = e.pageY
+ dragging = true
+ fns.down.map(f => f(x, y))
+}
+function move(e){
+ if (!dragging) return
+ let dx = e.pageX - x
+ let dy = e.pageY - y
+ x = e.pageX
+ y = e.pageY
+ dragging = true
+ fns.move.map(f => f(x, y, dx, dy))
+}
+function up(e){
+ dragging = false
+ fns.up.map(f => f(x, y))
+}
+
+function touch(f){
+ return (e) => {
+ e.preventDefault()
+ f(e.touches[0])
+ }
+}
+document.body.addEventListener("mousedown", down)
+document.body.addEventListener("mousemove", move)
+document.body.addEventListener("mouseup", up)
+document.body.addEventListener("touchstart", touch(down))
+document.body.addEventListener("touchmove", touch(move))
+document.body.addEventListener("touchup", touch(up))
diff --git a/client/lib/output.js b/client/lib/output.js
new file mode 100644
index 0000000..53901b3
--- /dev/null
+++ b/client/lib/output.js
@@ -0,0 +1,8 @@
+import Tone from 'tone'
+
+// const compressor = new Tone.Compressor(-30, 3).toMaster()
+
+const compressor = new Tone.Compressor(-30, 3).toMaster()
+const gain = new Tone.Gain(1).connect(compressor)
+
+export default gain
diff --git a/client/lib/sampler.js b/client/lib/sampler.js
new file mode 100644
index 0000000..481a940
--- /dev/null
+++ b/client/lib/sampler.js
@@ -0,0 +1,61 @@
+import Tone from 'tone'
+import { lerp, choice } from './util'
+
+const player_count = 2
+const filter_count = 3
+
+const crossfaders = []
+
+export default class Sampler {
+ constructor(path, count){
+ this.samples = (() => {
+ let s = '', a = []
+ for (let i = 1; i < count; i++) {
+ const s = i < 10 ? '0' + i : i;
+ a.push({ root: 100, fn: path.replace(/{}/, s) })
+ }
+ return a
+ })()
+
+ this.samples.forEach((sample) => {
+ sample.players = []
+ sample.index = -1
+ for (let i = 0; i < player_count; i++) {
+ let fn = sample.fn
+ if (window.location.href.match(/asdf.us/)) {
+ fn = 'http://asdf.us/glass/' + fn.replace('wav','mp3')
+ }
+ let player = new Tone.Player({
+ url: fn,
+ retrigger: true,
+ playbackRate: 1,
+ })
+ sample.players.push(player)
+ }
+ })
+ }
+ choice(){
+ return choice(this.samples)
+ }
+ play(freq, time, output) {
+ const best = this.choice()
+ best.index = (best.index + 1) % player_count
+
+ const player = best.players[ best.index ]
+
+ freq = freq || best.root
+ time = time || Tone.now()
+
+ player.playbackRate = freq / best.root
+ if (player.loaded) {
+ player.stop()
+ player.disconnect()
+ player.connect(output)
+ player.start(time)
+ } else {
+ console.log('loading')
+ }
+
+ return player
+ }
+}
diff --git a/client/lib/startAudioContext.js b/client/lib/startAudioContext.js
new file mode 100644
index 0000000..f3a9793
--- /dev/null
+++ b/client/lib/startAudioContext.js
@@ -0,0 +1,181 @@
+/**
+ * StartAudioContext.js
+ * @author Yotam Mann
+ * @license http://opensource.org/licenses/MIT MIT License
+ * @copyright 2016 Yotam Mann
+ */
+(function (root, factory) {
+ if (typeof define === "function" && define.amd) {
+ define([], factory);
+ } else if (typeof module === 'object' && module.exports) {
+ module.exports = factory();
+ } else {
+ root.StartAudioContext = factory();
+ }
+}(this, function () {
+
+ /**
+ * The StartAudioContext object
+ */
+ var StartAudioContext = {
+ /**
+ * The audio context passed in by the user
+ * @type {AudioContext}
+ */
+ context : null,
+ /**
+ * The TapListeners bound to the elements
+ * @type {Array}
+ * @private
+ */
+ _tapListeners : [],
+ /**
+ * Callbacks to invoke when the audio context is started
+ * @type {Array}
+ * @private
+ */
+ _onStarted : [],
+ };
+
+
+ /**
+ * Set the context
+ * @param {AudioContext} ctx
+ * @returns {StartAudioContext}
+ */
+ StartAudioContext.setContext = function(ctx){
+ StartAudioContext.context = ctx;
+ return StartAudioContext;
+ };
+
+ /**
+ * Add a tap listener to the audio context
+ * @param {Array|Element|String|jQuery} element
+ * @returns {StartAudioContext}
+ */
+ StartAudioContext.on = function(element){
+ if (Array.isArray(element) || (NodeList && element instanceof NodeList)){
+ for (var i = 0; i < element.length; i++){
+ StartAudioContext.on(element[i]);
+ }
+ } else if (typeof element === "string"){
+ StartAudioContext.on(document.querySelectorAll(element));
+ } else if (element.jquery && typeof element.toArray === "function"){
+ StartAudioContext.on(element.toArray());
+ } else if (Element && element instanceof Element){
+ //if it's an element, create a TapListener
+ var tap = new TapListener(element, onTap);
+ StartAudioContext._tapListeners.push(tap);
+ }
+ return StartAudioContext;
+ };
+
+ /**
+ * Bind a callback to when the audio context is started.
+ * @param {Function} cb
+ * @return {StartAudioContext}
+ */
+ StartAudioContext.onStarted = function(cb){
+ //if it's already started, invoke the callback
+ if (StartAudioContext.isStarted()){
+ cb();
+ } else {
+ StartAudioContext._onStarted.push(cb);
+ }
+ return StartAudioContext;
+ };
+
+ /**
+ * returns true if the context is started
+ * @return {Boolean}
+ */
+ StartAudioContext.isStarted = function(){
+ return (StartAudioContext.context !== null && StartAudioContext.context.state === "running");
+ };
+
+ /**
+ * @class Listens for non-dragging tap ends on the given element
+ * @param {Element} element
+ * @internal
+ */
+ var TapListener = function(element){
+
+ this._dragged = false;
+
+ this._element = element;
+
+ this._bindedMove = this._moved.bind(this);
+ this._bindedEnd = this._ended.bind(this);
+
+ element.addEventListener("touchmove", this._bindedMove);
+ element.addEventListener("touchend", this._bindedEnd);
+ element.addEventListener("mouseup", this._bindedEnd);
+ };
+
+ /**
+ * drag move event
+ */
+ TapListener.prototype._moved = function(e){
+ this._dragged = true;
+ };
+
+ /**
+ * tap ended listener
+ */
+ TapListener.prototype._ended = function(e){
+ if (!this._dragged){
+ onTap();
+ }
+ this._dragged = false;
+ };
+
+ /**
+ * remove all the bound events
+ */
+ TapListener.prototype.dispose = function(){
+ this._element.removeEventListener("touchmove", this._bindedMove);
+ this._element.removeEventListener("touchend", this._bindedEnd);
+ this._element.removeEventListener("mouseup", this._bindedEnd);
+ this._bindedMove = null;
+ this._bindedEnd = null;
+ this._element = null;
+ };
+
+ /**
+ * Invoked the first time of the elements is tapped.
+ * Creates a silent oscillator when a non-dragging touchend
+ * event has been triggered.
+ */
+ function onTap(){
+ //start the audio context with a silent oscillator
+ if (StartAudioContext.context && !StartAudioContext.isStarted()){
+ var osc = StartAudioContext.context.createOscillator();
+ var silent = StartAudioContext.context.createGain();
+ silent.gain.value = 0;
+ osc.connect(silent);
+ silent.connect(StartAudioContext.context.destination);
+ var now = StartAudioContext.context.currentTime;
+ osc.start(now);
+ osc.stop(now+0.5);
+ }
+
+ //dispose all the tap listeners
+ if (StartAudioContext._tapListeners){
+ for (var i = 0; i < StartAudioContext._tapListeners.length; i++){
+ StartAudioContext._tapListeners[i].dispose();
+ }
+ StartAudioContext._tapListeners = null;
+ }
+ //the onstarted callbacks
+ if (StartAudioContext._onStarted){
+ for (var j = 0; j < StartAudioContext._onStarted.length; j++){
+ StartAudioContext._onStarted[j]();
+ }
+ StartAudioContext._onStarted = null;
+ }
+ }
+
+ return StartAudioContext;
+}));
+
+
diff --git a/client/lib/util.js b/client/lib/util.js
new file mode 100644
index 0000000..fd48768
--- /dev/null
+++ b/client/lib/util.js
@@ -0,0 +1,111 @@
+import Tone from 'tone'
+import StartAudioContext from './startAudioContext'
+
+const isIphone = (navigator.userAgent.match(/iPhone/i)) || (navigator.userAgent.match(/iPod/i))
+const isIpad = (navigator.userAgent.match(/iPad/i))
+const isAndroid = (navigator.userAgent.match(/Android/i))
+const isMobile = isIphone || isIpad || isAndroid
+const isDesktop = ! isMobile
+
+document.body.classList.add(isMobile ? 'mobile' : 'desktop')
+
+const browser = { isIphone, isIpad, isAndroid, isMobile, isDesktop }
+
+function clamp(n,a,b){ return n<a?a:n<b?n:b }
+function choice (a){ return a[ Math.floor(Math.random() * a.length) ] }
+function mod(n,m){ return n-(m * Math.floor(n/m)) }
+function randint(n){ return (Math.random()*n)|0 }
+function randrange(a,b){ return a + Math.random() * (b-a) }
+function randsign(){ return Math.random() >= 0.5 ? -1 : 1 }
+function lerp(n,a,b){ return (b-a)*n+a }
+function angle(x0,y0,x1,y1){ return Math.atan2(y1-y0,x1-x0) }
+function dist(x0,y0,x1,y1){ return Math.sqrt(Math.pow(x1-x0,2)+Math.pow(y1-y0,2)) }
+function xor(a,b){ a=!!a; b=!!b; return (a||b) && !(a&&b) }
+function shuffle(a){
+ for (var i = a.length; i > 0; i--){
+ var r = randint(i)
+ var swap = a[i-1]
+ a[i-1] = a[r]
+ a[r] = swap
+ }
+ return a
+}
+// returns a gaussian random function with the given mean and stdev.
+function gaussian(mean, stdev) {
+ let y2;
+ let use_last = false;
+ return () => {
+ let y1;
+ if (use_last) {
+ y1 = y2;
+ use_last = false;
+ }
+ else {
+ let x1, x2, w;
+ do {
+ x1 = 2.0 * Math.random() - 1.0;
+ x2 = 2.0 * Math.random() - 1.0;
+ w = x1 * x1 + x2 * x2;
+ } while( w >= 1.0);
+ w = Math.sqrt((-2.0 * Math.log(w))/w);
+ y1 = x1 * w;
+ y2 = x2 * w;
+ use_last = true;
+ }
+
+ let retval = mean + stdev * y1;
+ if (retval > 0)
+ return retval;
+ return -retval;
+ }
+}
+
+function requestAudioContext (fn) {
+ if (isMobile) {
+ const container = document.createElement('div')
+ const button = document.createElement('div')
+ button.innerHTML = 'Tap to start - please unmute your phone'
+ Object.assign(container.style, {
+ display: 'block',
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+ zIndex: '10000',
+ top: '0px',
+ left: '0px',
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ })
+ Object.assign(button.style, {
+ display: 'block',
+ position: 'absolute',
+ left: '50%',
+ top: '50%',
+ padding: '20px',
+ backgroundColor: '#7F33ED',
+ color: 'white',
+ fontFamily: 'monospace',
+ borderRadius: '3px',
+ transform: 'translate3D(-50%,-50%,0)',
+ textAlign: 'center',
+ lineHeight: '1.5',
+ width: '150px',
+ })
+ container.appendChild(button)
+ document.body.appendChild(container)
+ StartAudioContext.setContext(Tone.context)
+ StartAudioContext.on(button)
+ StartAudioContext.onStarted(_ => {
+ container.remove()
+ fn()
+ })
+ } else {
+ fn()
+ }
+}
+
+export {
+ clamp, choice, mod, lerp, angle, dist, xor,
+ randint, randrange, randsign, shuffle, gaussian,
+ browser, requestAudioContext,
+}
+