summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
Diffstat (limited to 'client')
-rw-r--r--client/index.js511
-rw-r--r--client/lib/color.js31
-rw-r--r--client/lib/intonation.js162
-rw-r--r--client/lib/kalimba.js47
-rw-r--r--client/lib/keys.js39
-rw-r--r--client/lib/scales.js299
-rw-r--r--client/lib/startAudioContext.js181
-rw-r--r--client/lib/util.js198
8 files changed, 1468 insertions, 0 deletions
diff --git a/client/index.js b/client/index.js
new file mode 100644
index 0000000..644b1ea
--- /dev/null
+++ b/client/index.js
@@ -0,0 +1,511 @@
+import Nexus from 'nexusui'
+import keys from './lib/keys'
+import {
+ requestAudioContext,
+ choice, shuffle, mod, randint, norm, dataURItoBlob,
+ update_value_on_change, update_radio_value_on_change, build_options,
+ Slider, Statistic
+} from './lib/util'
+
+const canvas = document.querySelector('canvas')
+const ctx = canvas.getContext('2d')
+const nx = window.nx = {}
+
+const COP_TEAM = 0, EMPTY_CELL = -1
+const types = { SIM_REBELLION: 0, SIM_GENOCIDE: 1 }
+
+const inactive_colors = [
+ '#000000', // cops
+ '#008000', // team 1
+ '#000080', // team 2
+ '#008080', // team 3
+ '#00ff80', // team 4
+ '#0080ff', // team 5
+]
+const active_colors = [
+ '#000000',
+ '#800000', // team 1
+ '#800080', // team 2
+ '#808080', // team 3
+ '#80ff80', // team 4
+ '#8080ff', // team 5
+]
+
+const neighbor_idxs = [
+ [-1, -1],
+ [-1, 0],
+ [-1, 1],
+ [0, -1],
+ [0, 1],
+ [1, -1],
+ [1, 0],
+ [1, 1],
+]
+const simulations = {
+ rebellion: {
+ id: types.SIM_REBELLION,
+ name: 'Civil Violence Model I: Generalized Rebellion Against Central Authority',
+ teams: 1,
+ update: (agent, N, T) => {
+ if (agent.J > 0) return
+ const is_active = agent.GN > T
+ if (is_active && !agent.active) {
+ agent.log.push('went active')
+ } else if (!is_active && agent.active) {
+ agent.log.push('went inactive')
+ }
+ agent.active = is_active
+ move(agent, N)
+ },
+ color: (agent) => {
+ return agent.active ? active_colors[agent.team] : inactive_colors[agent.team]
+ },
+ },
+ genocide: {
+ id: types.SIM_GENOCIDE,
+ name: 'Civil Violence Model II: Inter-Group Violence',
+ teams: 2,
+ update: (agent, N, T) => {
+ if (agent.dead) return
+ if (agent.Age-- <= 0) {
+ if (agent.J > 0) {
+ kill(agent, 'died in jail')
+ died.in_jail += 1
+ } else {
+ kill(agent, 'died of old age')
+ died.old_age += 1
+ }
+ return
+ }
+ agent.GN > T && N.some(n => {
+ if (n.team !== EMPTY_CELL && n.team !== COP_TEAM && n.team !== agent.team) {
+ kill(n, 'died of murder')
+ agent.active = true
+ agent.log.push('killed someone')
+ died.murder += 1
+ return true
+ }
+ return false
+ })
+ if (Math.random() < nx.Rep.value) {
+ birth(agent, N)
+ }
+ move(agent, N)
+ },
+ color: (agent) => {
+ return inactive_colors[agent.team]
+ },
+ }
+}
+const views = {
+ G: {
+ value: 'G',
+ name: 'Grievance',
+ },
+ CAv: {
+ value: 'CAv',
+ name: 'Cop-to-Active ratio'
+ },
+ P: {
+ value: 'P',
+ name: 'Arrest probability',
+ },
+ NetR: {
+ value: 'NetR',
+ name: 'Net risk of being arrested',
+ },
+ GN: {
+ value: 'GN',
+ name: 'Distance from threshold',
+ }
+}
+
+const Px = 10
+const Margin = 1
+
+let agents = []
+let board = []
+let stats = []
+let sim = simulations.rebellion
+let view = 'G'
+let paused = false
+let stepTimeout
+let agent_id = 0
+let selected_agent
+let time = 0
+let died = {}
+
+function step(){
+ stepTimeout = setTimeout(step, nx.Rate.value)
+ if (paused) return
+ time += 1
+ const T = nx.T.value
+ shuffle(agents).forEach(agent => {
+ if (agent.J > 1) {
+ agent.J -= 1
+ return
+ }
+ const N = neighbors(agent)
+ if (agent.J === 1) {
+ // check to see if we can get out of jail (if our previous square was free)
+ if (!board[agent.y][agent.x]) {
+ agent.J = 0
+ board[agent.y][agent.x] = agent
+ agent.log.push('left jail')
+ }
+ }
+ if (agent.team === COP_TEAM) {
+ update_cop(agent, N)
+ } else {
+ calculate_net_risk(agent, N)
+ sim.update(agent, N, T)
+ }
+ })
+ if (sim.id === types.SIM_GENOCIDE) {
+ agents = agents.filter(agent => !agent.dead)
+ }
+ draw()
+}
+function calculate_net_risk(agent, N) {
+ agent.CAv = get_cav(N)
+ // grievance equals hardship times illegitimacy
+ agent.G = agent.H * (1 - nx.L.value)
+ // arrest probability is logarithmically proportional to number of cops/active nearby
+ agent.P = 1 - Math.exp(-nx.k.value * agent.CAv)
+ // net risk of being arrested equals agent's risk aversion times arrest probability
+ agent.NetR = agent.R * agent.P * Math.pow(nx.J.value, nx.alpha.value)
+ // if G - NetR is above the threshold, go active
+ agent.GN = agent.G - agent.NetR
+}
+function get_cav(N){
+ let C = 0, A = 1
+ N.forEach(n => {
+ if (n.team === EMPTY_CELL) return
+ if (n.team === COP_TEAM) {
+ C += 1
+ } else {
+ A += 1
+ }
+ })
+ return C / A
+}
+
+function update_cop(agent, N){
+ N.some(n => {
+ if (n.team !== COP_TEAM && n.active) {
+ jail(n)
+ return true
+ }
+ return false
+ })
+ move(agent, N)
+}
+function jail(agent){
+ agent.J = randint(nx.J.value)
+ agent.active = false
+ board[agent.y][agent.x] = null
+ agent.log.push('sent to jail')
+}
+function kill(agent, cause_of_death){
+ agent.dead = true
+ agent.Age = 0
+ board[agent.y][agent.x] = null
+ agent.log.push(cause_of_death)
+}
+function birth(agent, N){
+ N.some(n => {
+ if (n.team === EMPTY_CELL) {
+ let { y, x } = n
+ board[y][x] = add_agent(y, x, agent)
+ agent.log.push('gave birth')
+ return true
+ }
+ return false
+ })
+}
+function add_agent(y, x, a){
+ if (agents.length >= nx.Size.value * nx.Size.value) return
+ const agent = {
+ id: agent_id++,
+ team: a ? a.team : randint(sim.teams) + 1,
+ H: a ? a.H : Math.random(),
+ R: Math.random(),
+ J: 0,
+ Age: randint(nx.MaxAge.value) + 1,
+ active: false,
+ G: 0, CAv: 0, P: 0, NetR: 0, GN: 0,
+ log: [],
+ y, x,
+ }
+ agents.push(agent)
+ return agent
+}
+function move(agent, N){
+ if (agent.dead || agent.J) return
+ N.some(n => {
+ if (n.team === EMPTY_CELL) {
+ let { y, x } = n
+ board[agent.y][agent.x] = null
+ board[y][x] = agent
+ agent.y = y
+ agent.x = x
+ return true
+ }
+ return false
+ })
+}
+function view_color(agent){
+ let x, H, S, L
+ if (view === 'GN') {
+ x = agent[view]
+ H = agent.active ? 0 : 180
+ S = 1-x
+ L = 1-x
+ }
+ else {
+ x = agent[view]
+ H = agent.active ? 0 : 100
+ S = 1
+ L = 1-x * 2
+ }
+ const color = 'hsl(' + [
+ ((H)|0),
+ ((S * 100)|0) + '%',
+ ((L * 100)|0) + '%',
+ ].join(',') + ')'
+ return color
+}
+function draw(){
+ const Size = nx.Size.value
+ ctx.clearRect(0, 0, (Size + 1) * Px * 2, Size * Px)
+ stride((y, x) => {
+ const agent = board[y][x]
+ if (! agent) {
+ ctx.fillStyle = '#ffffff'
+ ctx.fillRect((x + Size + 1) * Px, y * Px, Px - Margin, Px - Margin)
+ ctx.fillRect(x * Px, y * Px, Px - Margin, Px - Margin)
+ } else if (agent.team === COP_TEAM) {
+ ctx.fillStyle = '#000000'
+ ctx.fillRect((x + Size + 1) * Px, y * Px, Px - Margin, Px - Margin)
+ ctx.fillRect(x * Px, y * Px, Px - Margin, Px - Margin)
+ } else {
+ ctx.save()
+ if (agent === selected_agent) {
+ ctx.strokeStyle = '#000'
+ ctx.strokeRect((x + Size + 1) * Px, y * Px, Px, Px)
+ ctx.strokeRect(x * Px, y * Px, Px, Px)
+ }
+ ctx.fillStyle = view_color(agent)
+ ctx.fillRect((x + Size + 1) * Px, y * Px, Px - Margin, Px - Margin)
+ ctx.fillStyle = sim.color(agent)
+ ctx.fillRect(x * Px, y * Px, Px - Margin, Px - Margin)
+ ctx.save()
+ }
+ })
+ stats.forEach(stat => stat())
+ if (selected_agent.dead) {
+ observe_random_agent()
+ }
+}
+function observe_random_agent(){
+ let agent
+ do {
+ agent = choice(agents)
+ } while (agent.dead || agent.team === COP_TEAM)
+ selected_agent = agent
+}
+function reset(){
+ agents = []
+ board = []
+ agent_id = 0
+ time = 0
+ const Pop = nx.Pop.value
+ const Cops = nx.Cops.value
+ board = stride((y, x) => {
+ if (Math.random() < Pop) {
+ return add_agent(y, x)
+ }
+ return null
+ })
+ for (let i = 0, len = agents.length * Cops; i < len; i++) {
+ agents[randint(agents.length)].team = COP_TEAM
+ }
+ observe_random_agent(agents)
+ died = { old_age: 0, in_jail: 0, murder: 0 }
+}
+function restart(){
+ rescale()
+ play()
+}
+function play(){
+ clearTimeout(stepTimeout)
+ reset()
+ step()
+}
+function pause(){
+ paused = !paused
+ if (!paused) {
+ clearTimeout(stepTimeout)
+ step()
+ }
+}
+function stride(fn){
+ let A = []
+ let Size = nx.Size.value
+ let i = 0
+ for (let y = 0; y < Size; y++) {
+ A[y] = []
+ for (let x = 0; x < Size; x++) {
+ A[y][x] = fn(y, x, i)
+ }
+ }
+ return A
+}
+function rescale(){
+ if (! nx.Size) return
+ canvas.width = (nx.Size.value + 1) * Px * 2
+ canvas.height = nx.Size.value * Px
+}
+function get(y, x){
+ const Size = nx.Size.value
+ y = mod(y, Size)
+ x = mod(x, Size)
+ return board[y][x] || { y, x, team: EMPTY_CELL }
+}
+function neighbors(agent){
+ const { y, x } = agent
+ return shuffle(neighbor_idxs.map(pair => get(y + pair[0], x + pair[1])))
+}
+function pick_simulation(new_sim){
+ sim = simulations[new_sim]
+ // console.log(new_sim, sim)
+ console.log(sim.name)
+ reset()
+}
+function pick_view(new_view){
+ view = new_view
+ console.log('viewing', new_view + ':', views[new_view].name)
+}
+
+function build() {
+ build_options(document.querySelector('#sim'), simulations, pick_simulation)
+ build_options(document.querySelector('#view'), views, pick_view)
+
+ const parent = document.querySelector('#options')
+
+ nx.Size = Slider(parent, 'world_size', 'World size', {
+ min: 10,
+ max: 200,
+ step: 1,
+ value: 40,
+ }, true, restart)
+ nx.Rate = Slider(parent, 'rate', 'Frame rate', {
+ min: 10,
+ max: 1000,
+ step: 10,
+ value: 200,
+ }, true, reset)
+ nx.L = Slider(parent, 'governmental_legitimacy', 'Governmental legitimacy', {
+ min: 0,
+ max: 1,
+ step: 0.01,
+ value: 0.9,
+ }, false)
+ nx.T = Slider(parent, 'activation_threshold', 'Grievance threshold', {
+ min: 0,
+ max: 1,
+ step: 0.01,
+ value: 0.1,
+ }, false)
+ nx.Rep = Slider(parent, 'birth_rate', 'Birth rate', {
+ min: 0,
+ max: 1,
+ step: 0.001,
+ value: 0.05,
+ }, false)
+ nx.MaxAge = Slider(parent, 'max_age', 'Max age', {
+ min: 0,
+ max: 1000,
+ step: 1,
+ value: 200,
+ }, true)
+ nx.J = Slider(parent, 'max_jail_term', 'Maximum jail term', {
+ min: 1,
+ max: 100,
+ step: 1,
+ value: 30,
+ }, true)
+ nx.alpha = Slider(parent, 'jail_deterrent', 'Jail deterrent', {
+ min: 0,
+ max: 2,
+ step: 0.01,
+ value: 0,
+ }, false)
+ nx.k = Slider(parent, 'arrest_constant', 'Arrest probability constant', {
+ min: 1,
+ max: 10,
+ step: 0.01,
+ value: 2.3,
+ }, false)
+ nx.Pop = Slider(parent, 'population_density', 'Population density', {
+ min: 0,
+ max: 1,
+ step: 0.01,
+ value: 0.7
+ }, false, play)
+ nx.Cops = Slider(parent, 'cop_density', 'Cop density', {
+ min: 0,
+ max: 0.1,
+ step: 0.002,
+ value: 0.05
+ }, false, play)
+
+ const stats_el = document.querySelector('#stats')
+ const log_el = document.querySelector('#log')
+ stats = [
+ Statistic(stats_el, 'Time', () => time),
+ Statistic(stats_el, 'Agents', () => agents.length),
+ Statistic(stats_el, 'Cops', () => agents.filter(a => a.team === COP_TEAM).length),
+ Statistic(stats_el, 'Team #1', () => agents.filter(a => a.team === 1).length),
+ Statistic(stats_el, 'Team #2', () => agents.filter(a => a.team === 2).length),
+ Statistic(stats_el, 'Quiescent', () => agents.filter(a => !a.active).length),
+ Statistic(stats_el, 'Active', () => agents.filter(a => a.active).length),
+ Statistic(stats_el, 'Jailed', () => agents.filter(a => a.J > 0).length),
+ Statistic(stats_el, 'Died of old age', () => died.old_age),
+ Statistic(stats_el, 'Died in jail', () => died.in_jail),
+ Statistic(stats_el, 'Murder rate', () => died.murder),
+ Statistic(stats_el, '~ ~ ~', () => ''),
+ Statistic(stats_el, 'Selected Agent #', () => selected_agent && selected_agent.id),
+ Statistic(stats_el, 'Position', () => selected_agent && (selected_agent.x + ',' + selected_agent.y)),
+ Statistic(stats_el, 'Hardship', () => selected_agent && selected_agent.H.toFixed(2)),
+ Statistic(stats_el, 'Risk tolerance', () => selected_agent && selected_agent.R.toFixed(2)),
+ Statistic(stats_el, 'Grievance', () => selected_agent && selected_agent.G.toFixed(2)),
+ Statistic(stats_el, 'Score', () => selected_agent && selected_agent.GN.toFixed(2)),
+ Statistic(stats_el, 'Cop / Active ratio', () => selected_agent && selected_agent.CAv.toFixed(2)),
+ Statistic(stats_el, 'Arrest probability', () => selected_agent && selected_agent.P.toFixed(2)),
+ Statistic(stats_el, 'Net risk', () => selected_agent && selected_agent.NetR.toFixed(2)),
+ Statistic(stats_el, 'Active', () => selected_agent && yesno(selected_agent.active)),
+ Statistic(stats_el, 'Age', () => selected_agent && selected_agent.Age),
+ Statistic(stats_el, 'In Jail?', () => selected_agent && yesno(selected_agent.J)),
+ Statistic(stats_el, 'Dead?', () => selected_agent && yesno(selected_agent.dead)),
+ Statistic(stats_el, 'Jail term', () => selected_agent && (selected_agent.J ? selected_agent.J : '')),
+ Statistic(log_el, 'Log', () => selected_agent && (selected_agent.log.join('\n'))),
+ ]
+
+ const restart_button = document.querySelector('button#restart')
+ restart_button.addEventListener('click', play)
+
+ const pause_button = document.querySelector('button#pause')
+ pause_button.addEventListener('click', pause)
+
+ const observe_button = document.querySelector('button#observe')
+ observe_button.addEventListener('click', () => {
+ observe_random_agent()
+ })
+
+ document.querySelector('.loading').classList.remove('loading')
+ rescale()
+ play()
+}
+function yesno(x) { return x ? 'Yes' : 'No' }
+build()
diff --git a/client/lib/color.js b/client/lib/color.js
new file mode 100644
index 0000000..4c71cc1
--- /dev/null
+++ b/client/lib/color.js
@@ -0,0 +1,31 @@
+
+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[1]
+
+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=0, 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 'rgb(' + rgb + ')'
+}
+
+export default color
diff --git a/client/lib/intonation.js b/client/lib/intonation.js
new file mode 100644
index 0000000..e5465af
--- /dev/null
+++ b/client/lib/intonation.js
@@ -0,0 +1,162 @@
+module.exports = (function(){
+ var Intonation = function(opt){
+ opt = this.opt = Object.assign({
+ name: "",
+ root: 440,
+ octave: 0,
+ interval: 2,
+ tet: 0,
+ intervals: null,
+ }, opt || {})
+ this.generate()
+ }
+ Intonation.prototype.generate = function(opt){
+ opt = Object.assign(this.opt, opt || {})
+ if (opt.scl) {
+ this.generate_scl()
+ }
+ else if (opt.tet) {
+ this.generate_tet()
+ }
+ else if (opt.intervals) {
+ this.generate_intervals()
+ }
+ }
+ Intonation.prototype.generate_intervals = function(){
+ var root = this.opt.root
+ var interval_list = this.opt.intervals
+ if (typeof interval_list == "string") {
+ interval_list = interval_list.split(" ")
+ }
+ this.name = this.opt.name || "interval list"
+ this.intervals = interval_list
+ this.interval = this.opt.interval = parseInterval.call(this, interval_list.pop() )
+ this.scale = interval_list.map( parseIntervalString.bind(this) ).filter(function(v){
+ return !! v
+ })
+ }
+ Intonation.prototype.generate_tet = function(){
+ var scale = this.scale = []
+ var root = this.opt.root
+ var tet = this.opt.tet
+ var interval = this.interval = this.opt.interval
+ var ratio = Math.pow( interval, 1/tet )
+ var n = root
+ scale.push(n)
+ for (var i = 0; i < tet-1; i++) {
+ n *= ratio
+ scale.push(n)
+ }
+ this.name = this.opt.name || tet + "-tone equal temperament"
+ this.intervals = null
+ }
+ Intonation.prototype.generate_scl = function(){
+ var root = this.opt.root
+ var scl = this.parse_scl( this.opt.scl )
+ this.intervals = scl.notes
+ this.interval = scl.notes.pop()
+ this.name = this.opt.name || scl.description
+ this.scale = scl.notes.map(function(v){
+ return v * root
+ })
+ }
+ Intonation.prototype.parse_scl = function(s){
+ var scl = {}
+ scl.comments = []
+ scl.notes = []
+ s.trim().split("\n").forEach(function(line){
+ // Lines beginning with an exclamation mark are regarded as comments
+ // and are to be ignored.
+ if ( line.indexOf("!") !== -1 ) {
+ scl.comments.push(line)
+ }
+ // The first (non comment) line contains a short description of the scale.
+ // If there is no description, there should be an empty line. (nb: which is falsey)
+ else if ( ! ('description' in scl) ) {
+ scl.description = line
+ }
+ // The second line contains the number of notes.
+ // The first note of 1/1 or 0.0 cents is implicit and not in the files.
+ else if ( ! scl.notes.length) {
+ scl.notes.push(1)
+ }
+ else {
+ // If the value contains a period, it is a cents value, otherwise a ratio.
+ var note = line.replace(/^[^-\.0-9]+/,"").replace(/[^-\/\.0-9]+$/,"")
+ if ( note.indexOf(".") !== -1 ) {
+ note = Math.pow( 2, (parseFloat(note) / 1200) )
+ }
+ else {
+ note = parseInterval(note)
+ }
+ if (note) {
+ scl.notes.push(note)
+ }
+ }
+ })
+ return scl
+ }
+ Intonation.prototype.index = function(i, octave){
+ octave = octave || this.opt.octave
+ var f = this.scale[ mod(i, this.scale.length)|0 ]
+ var pow = Math.floor(norm(i, 0, this.scale.length)) + octave
+ f *= Math.pow(this.interval, pow)
+ return f
+ }
+ Intonation.prototype.range = function(min, max){
+ var a = []
+ for (var i = min; i < max; i++) {
+ a.push( this.index(i) )
+ }
+ return a
+ }
+ Intonation.prototype.set_root = function(f){
+ this.opt.root = f
+ this.generate()
+ }
+ Intonation.prototype.quantize_frequency = function(f){
+ if (f == 0) return 0
+ var scale_f = f
+ var pow = 0
+ var interval = this.interval
+ var scale = this.scale
+ while (scale_f < root) {
+ scale_f *= interval
+ pow -= 1
+ }
+ while (scale_f > root * interval) {
+ scale_f /= interval
+ pow += 1
+ }
+ for (var i = 0; i < scale.length; i++) {
+ if (scale_f > scale[i]) continue
+ scale_f = scale[i]
+ break
+ }
+ scale_f *= Math.pow(2, pow)
+ return scale_f
+ }
+ Intonation.prototype.quantize_index = function(i){
+ return mod(index-1, this.scale.length)|0
+ }
+ var parseInterval = Intonation.prototype.parse_interval = function (s) {
+ if (typeof s == "number") return s
+ if (! s.indexOf("/") == -1) return parseInt(s)
+ var pp = s.split("/")
+ var num = parseInt(pp[0])
+ var den = parseInt(pp[1])
+ if (isNaN(num)) return 1
+ if (isNaN(den) || den == 0) return num
+ if (num == den) return 1
+ return num / den
+ }
+ var parseIntervalString = Intonation.prototype.parse_interval_string = function(s){
+ if (s.indexOf("/") !== -1) return parseInterval(s) * this.opt.root // intervals
+ if (s.indexOf("f") !== -1) return parseFloat(s) // pure frequencies
+ return parseFloat(s)
+ }
+ function norm(n,a,b){ return (n-a) / (b-a) }
+ function mod(n,m){ return n-(m * Math.floor(n/m)) }
+
+ return Intonation
+})()
diff --git a/client/lib/kalimba.js b/client/lib/kalimba.js
new file mode 100644
index 0000000..60a50a9
--- /dev/null
+++ b/client/lib/kalimba.js
@@ -0,0 +1,47 @@
+import Tone from 'tone'
+import { choice } from './util'
+
+const player_count = 2
+
+const compressor = new Tone.Compressor(-30, 3).toMaster()
+
+const samples = [
+ { root: 226, fn: 'samples/380737__cabled-mess__sansula-01-a-raw.mp3', },
+ { root: 267, fn: 'samples/380736__cabled-mess__sansula-02-c-raw.mp3', },
+ { root: 340, fn: 'samples/380735__cabled-mess__sansula-03-e-raw.mp3', },
+ { root: 452, fn: 'samples/380733__cabled-mess__sansula-06-a-02-raw.mp3', },
+// { root: 507, fn: 'samples/380734__cabled-mess__sansula-07-b-h-raw.mp3', },
+// { root: 535, fn: 'samples/380731__cabled-mess__sansula-08-c-raw.mp3', },
+// { root: 671, fn: 'samples/380732__cabled-mess__sansula-09-e-raw.mp3', },
+]
+
+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 = '//asdf.us/kalimba/' + fn
+ }
+ let player = new Tone.Player({
+ url: fn,
+ retrigger: true,
+ playbackRate: 1,
+ })
+ player.connect(compressor)
+ sample.players.push(player)
+ }
+})
+
+function play (freq) {
+ 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
+ console.log(player)
+ player.start()
+}
+
+export default { play }
+
diff --git a/client/lib/keys.js b/client/lib/keys.js
new file mode 100644
index 0000000..c9e51ac
--- /dev/null
+++ b/client/lib/keys.js
@@ -0,0 +1,39 @@
+const keys = {}
+const key_numbers = {}
+const letters = "zxcvbnmasdfghjklqwertyuiop"
+const numbers = "1234567890"
+
+let callback = function(){}
+
+letters.toUpperCase().split("").map(function(k,i){
+ keys[k.charCodeAt(0)] = i
+})
+
+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/scales.js b/client/lib/scales.js
new file mode 100644
index 0000000..d85fe08
--- /dev/null
+++ b/client/lib/scales.js
@@ -0,0 +1,299 @@
+import Intonation from './intonation'
+
+const meantone = `! meanquar.scl
+!
+1/4-comma meantone scale. Pietro Aaron's temperament (1523)
+ 12
+!
+ 76.04900
+ 193.15686
+ 310.26471
+ 5/4
+ 503.42157
+ 579.47057
+ 696.57843
+ 25/16
+ 889.73529
+ 1006.84314
+ 1082.89214
+ 2/1
+`
+
+const shares = `! shares.scl
+!
+A scale based on shares of wealth
+!
+1.
+5.
+15.
+32.
+52.
+78.
+116.
+182.
+521.
+1000.
+`
+
+const shares_sum = `! shares_sum.scl
+!
+A scale based on summing shares of wealth
+!
+1
+6.0
+21.0
+53.0
+105.0
+183.0
+299.0
+481.0
+1002.0
+2/1
+`
+
+const mavila = `! mavila12.scl
+!
+A 12-note mavila scale (for warping meantone-based music), 5-limit TOP
+ 12
+!
+-30.99719
+ 163.50770
+ 358.01258
+ 327.01540
+ 521.52028
+ 490.52310
+ 685.02798
+ 654.03080
+ 848.53568
+ 1043.04057
+ 1012.04338
+ 1206.54826
+`
+
+const carlos_alpha = `! carlos_alpha.scl
+!
+Wendy Carlos' Alpha scale with perfect fifth divided in nine
+ 18
+!
+ 78.00000
+ 156.00000
+ 234.00000
+ 312.00000
+ 390.00000
+ 468.00000
+ 546.00000
+ 624.00000
+ 702.00000
+ 780.00000
+ 858.00000
+ 936.00000
+ 1014.00000
+ 1092.00000
+ 1170.00000
+ 1248.00000
+ 1326.00000
+ 1404.00000
+`
+
+const lamonte = `! young-lm_piano.scl
+!
+LaMonte Young's Well-Tempered Piano
+12
+!
+567/512
+9/8
+147/128
+21/16
+1323/1024
+189/128
+3/2
+49/32
+7/4
+441/256
+63/32
+2/1
+`
+
+const colundi = `! colundi.scl
+!
+Colundi scale
+10
+!
+9/8
+171/140
+137/112
+43/35
+3/2
+421/280
+213/140
+263/150
+66/35
+2/1
+`
+
+const liu_major = `! liu_major.scl
+!
+Linus Liu's Major Scale, see his 1978 book, "Intonation Theory"
+ 7
+!
+ 10/9
+ 100/81
+ 4/3
+ 3/2
+ 5/3
+ 50/27
+ 2/1
+`
+const liu_pentatonic = `! liu_pent.scl
+!
+Linus Liu's "pentatonic scale"
+ 7
+!
+ 9/8
+ 81/64
+ 27/20
+ 3/2
+ 27/16
+ 243/128
+ 81/40
+`
+
+const liu_minor = `! LIU_MINor.scl
+!
+Linus Liu's Harmonic Minor
+ 7
+!
+ 10/9
+ 6/5
+ 4/3
+ 40/27
+ 8/5
+ 50/27
+ 2/1
+`
+
+const liu_melodic_minor = `! liu_mel.scl
+!
+Linus Liu's Melodic Minor, use 5 and 7 descending and 6 and 8 ascending
+ 9
+!
+ 10/9
+ 6/5
+ 4/3
+ 3/2
+ 81/50
+ 5/3
+ 9/5
+ 50/27
+ 2/1
+`
+
+const scales = [
+ {
+ intervals: '1/1 9/8 5/4 4/3 3/2 5/3 15/8 2/1',
+ name: "harmonic scale",
+ },
+ {
+ root: 450,
+ intervals: '1/1 9/8 5/4 4/3 3/2 5/3 15/8 2/1',
+ name: "harmonic scale @ 450",
+ },
+ {
+ tet: 5,
+ },
+ {
+ tet: 12,
+ },
+ {
+ tet: 17,
+ },
+ {
+ intervals: '1/1 81/80 33/32 21/20 16/15 12/11 11/10 10/9 9/8 8/7 7/6 32/27 6/5 11/9 5/4 14/11 9/7 21/16 4/3 27/20 11/8 7/5 10/7 16/11 40/27 3/2 32/21 14/9 11/7 8/5 18/11 5/3 27/16 12/7 7/4 16/9 9/5 20/11 11/6 15/8 40/21 64/33 160/81 2/1',
+ name: "harry partch scale",
+ },
+ {
+ scl: lamonte,
+ },
+ {
+ scl: meantone,
+ },
+ {
+ scl: mavila,
+ },
+ {
+ scl: carlos_alpha,
+ },
+ {
+ scl: colundi,
+ },
+ {
+ scl: shares,
+ },
+ {
+ scl: shares_sum,
+ },
+ {
+ scl: liu_major,
+ },
+ {
+ scl: liu_minor,
+ },
+ {
+ scl: liu_melodic_minor,
+ },
+ {
+ scl: liu_pentatonic,
+ }
+].map( (opt) => new Intonation(opt) )
+
+let scale = scales[0]
+let handleChange = function(){}
+
+function build () {
+ scales.forEach( (scale, i) => {
+ scale.heading = document.createElement('div')
+ scale.heading.innerHTML = scale.name
+ scale.heading.classList.add('heading')
+ scale.heading.addEventListener('click', function(){
+ pick(i)
+ })
+ scale_list.appendChild(scale.heading)
+ })
+ pick(0)
+}
+function build_options(el) {
+ scales.forEach( (scale, i) => {
+ const option = document.createElement('option')
+ option.innerHTML = scale.name
+ option.value = i
+ el.appendChild(option)
+ })
+ el.addEventListener('input', function(e){
+ pick(e.target.value)
+ })
+ pick(0)
+}
+
+function pick (i) {
+ if (scale) {
+ scale.heading && scale.heading.classList.remove('selected')
+ }
+ scale = scales[i]
+ scale.heading && scale.heading.classList.add('selected')
+ handleChange(scale)
+}
+
+function current () {
+ return scale
+}
+
+function onChange (fn) {
+ handleChange = fn
+}
+
+function names () {
+ return scales.map( scale => scale.name )
+}
+
+
+export default { scales, current, build, build_options, pick, names, onChange }
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..35a537d
--- /dev/null
+++ b/client/lib/util.js
@@ -0,0 +1,198 @@
+import Nexus from 'nexusui'
+// 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, isMobile, isDesktop }
+
+function choice (a){ return a[ Math.floor(Math.random() * a.length) ] }
+function randint (n) { return Math.floor(Math.random() * n) }
+function mod(n,m){ return n-(m * Math.floor(n/m)) }
+function norm(n, min, max){ return (n - min) / (max - min) }
+function shuffle(a){
+ a = a.slice(0)
+ 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
+}
+function shuffleInPlace(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
+}
+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, {
+ position: 'absolute',
+ width: '100%',
+ height: '100%',
+ zIndex: '10000',
+ top: '0px',
+ left: '0px',
+ backgroundColor: 'rgba(0, 0, 0, 0.8)',
+ })
+ Object.assign(button.style, {
+ 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',
+ })
+ container.appendChild(button)
+ document.body.appendChild(container)
+ StartAudioContext.setContext(Tone.context)
+ StartAudioContext.on(button)
+ StartAudioContext.onStarted(_ => {
+ container.remove()
+ fn()
+ })
+ } else {
+ fn()
+ }
+}
+
+function dataURItoBlob(dataURI) {
+ // convert base64 to raw binary data held in a string
+ // doesn't handle URLEncoded DataURIs - see SO answer #6850276 for code that does this
+ var byteString = atob(dataURI.split(',')[1]);
+
+ // separate out the mime component
+ var mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
+
+ // write the bytes of the string to an ArrayBuffer
+ var ab = new ArrayBuffer(byteString.length);
+
+ // create a view into the buffer
+ var ia = new Uint8Array(ab);
+
+ // set the bytes of the buffer to the correct values
+ for (var i = 0; i < byteString.length; i++) {
+ ia[i] = byteString.charCodeAt(i);
+ }
+
+ // write the ArrayBuffer to a blob, and you're done
+ var blob = new Blob([ab], {type: mimeString});
+ return blob;
+
+}
+function ftom(f) {
+ // return (Math.log(f) - Math.log(261.626)) / Math.log(2) + 4.0
+ return 69 + 12 * Math.log2(f / 440)
+}
+function mtof(m) {
+ return 440 * Math.pow(2, (m - 69) / 12)
+}
+function tap (fn) {
+ return (e) => {
+ if (browser.isMobile) fn()
+ else if (e.press) fn()
+ }
+}
+
+function update_value_on_change(el, id, is_int, fn) {
+ const label = document.querySelector(id + ' + .val')
+ const update = v => {
+ label.innerHTML = is_int ? parseInt(v) : v.toFixed(2)
+ fn && fn(v)
+ }
+ el.on('change', update)
+ label.innerHTML = is_int ? parseInt(el.value) : el.value.toFixed(2)
+ el.update = update
+}
+function update_radio_value_on_change(el, id, values, fn) {
+ let old_v = el.active
+ const label = document.querySelector(id + ' + .val')
+ const update = v => {
+ if (v === -1) {
+ v = el.active = old_v
+ } else {
+ old_v = v
+ }
+ label.innerHTML = values[v][1]
+ fn && fn(v)
+ }
+ el.on('change', update)
+ update(el.active)
+ el.update = update
+}
+function build_options(el, lists, fn) {
+ Object.keys(lists).forEach(key => {
+ const list = lists[key]
+ const option = document.createElement('option')
+ option.innerHTML = list.name
+ option.value = key
+ el.appendChild(option)
+ })
+ el.addEventListener('input', function(e){
+ fn(e.target.value)
+ })
+}
+function Slider(parent, tag, title, options, is_int, fn){
+ const block = document.createElement('div')
+ block.classList.add('block')
+ const el = document.createElement('div')
+ el.setAttribute('id', tag)
+ const val = document.createElement('span')
+ val.classList.add('val')
+ const label = document.createElement('label')
+ label.innerHTML = title
+ block.appendChild(label)
+ block.appendChild(el)
+ block.appendChild(val)
+ parent.appendChild(block)
+ options.size = [200, 24]
+ const nx = new Nexus.Slider('#' + tag, options)
+ update_value_on_change(nx, '#' + tag, is_int, fn)
+ return nx
+}
+function Statistic(parent, label, fn){
+ const block = document.createElement('div')
+ block.classList.add('stat')
+ const key = document.createElement('div')
+ key.classList.add('key')
+ const val = document.createElement('div')
+ val.classList.add('val')
+ block.appendChild(key)
+ block.appendChild(val)
+ parent.appendChild(block)
+ key.innerHTML = label
+ val.innerHTML = fn()
+ let old_v
+ return () => {
+ const v = fn()
+ if (old_v === v) return
+ val.innerText = old_v = v
+ }
+}
+
+export {
+ mod, norm, choice, shuffle, randint,
+ browser, requestAudioContext, ftom, mtof, tap, dataURItoBlob,
+ update_value_on_change, update_radio_value_on_change, build_options,
+ Slider, Statistic,
+ }
+