diff options
Diffstat (limited to 'client')
| -rw-r--r-- | client/index.js | 511 | ||||
| -rw-r--r-- | client/lib/color.js | 31 | ||||
| -rw-r--r-- | client/lib/intonation.js | 162 | ||||
| -rw-r--r-- | client/lib/kalimba.js | 47 | ||||
| -rw-r--r-- | client/lib/keys.js | 39 | ||||
| -rw-r--r-- | client/lib/scales.js | 299 | ||||
| -rw-r--r-- | client/lib/startAudioContext.js | 181 | ||||
| -rw-r--r-- | client/lib/util.js | 198 |
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, + } + |
