diff options
Diffstat (limited to 'client')
| -rw-r--r-- | client/data.js | 22 | ||||
| -rw-r--r-- | client/index.js | 384 | ||||
| -rw-r--r-- | client/lib/midi.js | 142 | ||||
| -rw-r--r-- | client/lib/ui.js | 46 | ||||
| -rw-r--r-- | client/lib/util.js | 37 |
5 files changed, 417 insertions, 214 deletions
diff --git a/client/data.js b/client/data.js index 4eab9d3..733d3bf 100644 --- a/client/data.js +++ b/client/data.js @@ -1,26 +1,32 @@ const files = [ - "housing-costs-and-income-inequality", - "income-inequality-over-time", - "shares-of-wealth", - "weekly-earnings", - "household-wealth", + // "gun_violence", + "mass_shootings", ] +const parse = require('csv-parse') const dataPromises = files.map(name => { return fetch('./data/' + name + '.csv').then(rows => { return rows.text() }).then(text => { - let lines = text.split('\n').map(line => line.split(',')) + return new Promise((resolve, reject) => { + parse(text, {}, (err, lines) => resolve(lines)) + }) + }).then(lines => { + console.log(name, lines) const h = lines.shift() return { - name: name.replace(/-/g, ' '), + name: name.replace(/_/g, ' '), h, lines: lines.filter(s => !!s) } }) }) const allPromises = Promise.all(dataPromises).then(data => { - return data.reduce((a,b) => { a[b.name.replace(/-/g, '_')] = b; return a }, {}) + return data.reduce((a,b) => { + console.log(b) + a[b.name.replace(/-/g, '_')] = b + return a + }, {}) }) const load = () => { return allPromises diff --git a/client/index.js b/client/index.js index e6f0716..af1886a 100644 --- a/client/index.js +++ b/client/index.js @@ -1,92 +1,188 @@ import Tone from 'tone' -import WebMidi from 'webmidi' import Nexus from 'nexusui' +import { saveAs } from 'file-saver/FileSaver' + import keys from './lib/keys' -import kalimba from './lib/kalimba' import scales from './lib/scales' -import { requestAudioContext, ftom, dataURItoBlob } from './lib/util' -import { saveAs } from 'file-saver/FileSaver' +import { + midi_init, + play_note, + play_sequence, + play_interval_sequence, + note_values, + MidiWriter, +} from './lib/midi' +import { + requestAudioContext, ftom, norm, dataURItoBlob, + get_bounds, get_diff_bounds, +} from './lib/util' +import { + update_value_on_change, + update_radio_value_on_change, + build_options +} from './lib/ui' import * as data from './data' -const MidiWriter = require('midi-writer-js') - const DEFAULT_BPM = 60 const nx = window.nx = {} -let midi -let exporting = false -let recording = false let recorder = null +let recording = false +let sendPitchBend = false -const note_values = [ - [8, '8 measures', 8 * 512], - [4, '4 measures', 4 * 512], - [2, '2 measures', 2 * 512], - [1, 'whole note', 512], - [1/2, 'half note', 256], - [1/3, 'third note', [170, 170, 171]], - [1/4, 'quarter note', 128], - [1/5, 'fifth note', [51,51,51,51,52]], - [1/6, 'sixth note', [85, 85, 86, 85, 85, 86]], - [1/8, 'eighth note', 64], - [1/10, 'tenth note', [25,26,26,25,26,25,26,26,25,26]], - [1/12, 'twelfth note', [21,21,22, 21,21,22, 21,21,22, 21,21,22]], - [1/16, 'sixteenth note', 32], - [1/32, 'thirtysecond note', 16], -] +midi_init() -WebMidi.enable(midi_ready) -function midi_ready(err) { - if (err) { - console.error('webmidi failed to initialize') - return - } - if (!WebMidi.outputs.length) { - console.error('no MIDI output found') - return - } - console.log(WebMidi.inputs) - console.log(WebMidi.outputs) - if (WebMidi.outputs.length > 1) { - const filtered = WebMidi.outputs.filter(output => output.name.match(/prodipe/i)) - if (filtered.length) { - // midi = filtered[0] - } - } - // midi = midi || WebMidi.outputs[0] - // console.log(midi.name) -} +/* initialization */ let i = 0, datasets = {}, dataset = {}, bounds = {}, diff = [] let play_fn = play_sequence data.load().then(lists => { - // nx.dataset.choices = Object.keys(lists) console.log(lists) - datasets = lists - pick_dataset('housing costs and income inequality') + datasets = lists.map(list => { + list.shift() + switch(list.name) { + // case 'gun_violence': + // return { + // ...list, + // lines: list.lines.map(line => { + // // 0 incident_id + // // 1 date + // // 2 state + // // 3 city_or_county + // // 4 address + // // 5 n_killed + // // 6 n_injured + // }) + // } + // break + case 'gun_violence': + return gun_violence_melody(list) + case 'mass_shootings': + return { + ...list, + lines: list.lines.map(line => { + // 0 case name + // 1 location + // 2 date + // 3 summary + // 4 fatalities + // 5 injured + // 6 total_victims + // 7 location + // 8 age_of_shooter + // 9 prior_signs_mental_health_issues + // 10 mental_health_details + // 11 weapons_obtained_legally + // 12 where_obtained + // 13 weapon_type + // 14 weapon_details + // 15 race + // 16 gender + // 17 sources + // 18 mental_health_sources + // 19 sources_additional_age + // 20 latitude + // 21 longitude + // 22 type (Spree / Mass) + // 23 year + }) + } + break + } + }) + pick_dataset('mass shootings') requestAudioContext(ready) }) -function pick_dataset(key){ - console.log('pick dataset:', key) - i = 0 - dataset = datasets[key] - bounds = get_bounds(dataset) - diff = get_diff_bounds(bounds.rows) -} -var behaviors = { - sequence: { name: 'Sequence', fn: play_sequence }, - interval: { name: 'Intervals', fn: play_interval_sequence }, -} -function pick_behavior(name){ - play_fn = behaviors[name].fn +/* + 479363, + 2013-01-19,New Mexico,Albuquerque,2806 Long Lane, + 5,0, + http://www.gunviolencearchive.org/incident/479363, + http://hinterlandgazette.com/2013/01/pastor-greg-griego-identified-victims-killed-nehemiah-griego-albuquerque-nm-shooting.html, + False,1, + + gun_stolen, + 0::Unknown||1::Unknown, + + gun_type, + 0::22 LR||1::223 Rem [AR-15], + + incident_characteristics, + "Shot - Dead (murder, accidental, suicide) + ||Mass Shooting (4+ victims injured or killed excluding the subject/suspect/perpetrator, one location) + ||Domestic Violence", + + latitude, location_description, longitude, + 34.9791,,-106.716, + + n_guns_involved, notes, + 2,, + + participant_age, + 0::51||1::40||2::9||3::5||4::2||5::15, + + participant_age_group, + 0::Adult 18+||1::Adult 18+||2::Child 0-11||3::Child 0-11||4::Child 0-11||5::Teen 12-17, + + participant_gender, + 0::Male||1::Female||2::Male||3::Female||4::Female||5::Male, + + participant_name, + 0::Greg Griego||1::Sara Griego||2::Zephania Griego||3::Jael Griego||4::Angelina Griego||5::Nehemiah Griego, + + participant_relationship, + 5::Family, + + participant_status, + "0::Killed||1::Killed||2::Killed||3::Killed||4::Killed||5::Unharmed, Arrested", + + participant_type, + 0::Victim||1::Victim||2::Victim||3::Victim||4::Victim||5::Subject-Suspect, + + http://www.cbsnews.com/news/nehemiah-gringo-case-memorial-service-planned-for-family-allegedly-slain-by-new-mexico-teen/|| + http://www.thewire.com/national/2013/01/teenager-reportedly-used-ar-15-kill-five-new-mexico/61199/|| + http://bigstory.ap.org/article/officials-nm-teen-gunman-kills-5-inside-home|| + http://www.huffingtonpost.com/2013/01/21/nehemiah-griego-teen-shoots-parents-3-children_n_2519359.html|| + http://murderpedia.org/male.G/g/griego-nehemiah.htm|| + http://hinterlandgazette.com/2013/01/pastor-greg-griego-identified-victims-killed-nehemiah-griego-albuquerque-nm-shooting.html, + 10,14 +*/ + +function gun_violence_melody(list){ + let melody = [] + let lookup = {} + let last = Date.now() + let last_y = 2018 + let last_m = 3 + list.lines.forEach(line => { + let [ + incident_id, date, state, city_or_county, address, n_killed, n_injured, + incident_url, source_url, incident_url_fields_missing, congressional_district, + gun_stolen, gun_type, incident_characteristics, latitude, location_description, longitude, + n_guns_involved, notes, + participant_age, participant_age_group, participant_gender, + participant_name, participant_relationship, participant_status, + participant_type, + sources, + state_house_district, state_senate_district + ] = line + let [ y, m, d ] = date.split('-') + }) + return { + ...list, + lines: melody, + } } + +/* play next note according to sonification */ + function play_next(){ let note_time = 120000 / Tone.Transport.bpm.value * note_values[nx.timing.active][0] * nx.duration.value setTimeout(play_next, note_time) - let [new_i, notes] = play_fn(i, note_time) + let [new_i, notes] = play_fn(i, bounds, note_time) i = new_i if (recording) { let timing = note_values[nx.timing.active][2] @@ -94,131 +190,26 @@ function play_next(){ recorder.addEvent(new MidiWriter.NoteEvent({ pitch: notes, duration: 't' + timing })) } } -function play_sequence(i, note_time) { - const { rows, min, max } = bounds - const count = rows.length * rows[0].length - if (i >= count) i = 0 - const y = Math.floor(i / rows[0].length) - const x = i % rows[0].length - if (!x) console.log(y) - const n = rows[y][x] - i += 1 - if (i >= count) i = 0 - const midi_note = play( norm(n, min, max) * nx.multiply.value, note_time) - return [i, [midi_note]] -} -function play_interval_sequence(i, note_time) { - const { rows, min, max } = bounds - const count = rows.length - if (i >= count) i = 0 - const y = i % count - const row = rows[y] - if (! row) { i = 0; return } - const row_min = Math.min.apply(Math, row) - // const row_max = Math.max.apply(Math, row) - const row_f0 = norm(row_min, min, max) - const row_root = row_f0 * nx.multiply.value - const notes = row.map(n => { - const note = row_root + norm(n - row_min, diff.min, diff.max) * nx.interval.value - play(note, note_time) - }) - i += 1 - return [i, notes] -} -function norm(n, min, max){ - return (n - min) / (max - min) -} -function get_diff_bounds(rows){ - const diffs = rows.map(row => { - const row_min = Math.min.apply(Math, row) - const row_max = Math.max.apply(Math, row) - return row_max - row_min - }) - const min = Math.min.apply(Math, diffs) - const max = Math.max.apply(Math, diffs) - return { min, max } -} -function get_bounds(dataset){ - let rows = dataset.lines - rows.forEach(row => row.shift()) - rows = rows.map(a => a.map(n => parseFloat(n))) - const max = rows.reduce((a,b) => { - return b.reduce((z,bb) => { - return Math.max(z, bb) - }, a) - }, -Infinity) - const min = rows.reduce((a,b) => { - return b.reduce((z,bb) => { - return Math.min(z, bb) - }, a) - }, Infinity) - return { rows, max, min } -} -function play(index, duration){ - // console.log(index) - const scale = scales.current() - const freq = scale.index(index + Math.round(nx.offset.value), nx.octave.value) - let midi_note = ftom(freq) - let cents = midi_note % 1 - if (cents > 0.5) { - midi_note += 1 - cents -= 1 - } - cents *= 2 - midi_note = Math.floor(midi_note) - if ((midi || exporting) && midi_note > 127) return 0 - const note = Tone.Frequency(Math.floor(midi_note), "midi").toNote() - if (exporting || midi) { - duration = duration || 60000 / Tone.Transport.bpm.value - if (! exporting) { - midi.playNote(note, "all", { duration }) - midi.sendPitchBend(cents, "all") - } - } else { - kalimba.play(freq) - } - return note -} +/* bind selects */ -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) - update(el.value) - el.update = update +function pick_dataset(key){ + console.log('pick dataset:', key) + i = 0 + dataset = datasets[key] + bounds = get_bounds(dataset) + diff = get_diff_bounds(bounds.rows) } -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 +var behaviors = { + sequence: { name: 'Sequence', fn: play_sequence }, + interval: { name: 'Intervals', fn: play_interval_sequence }, } -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 pick_behavior(name){ + play_fn = behaviors[name].fn } + +/* build and bind the UI */ + function ready () { scales.build_options(document.querySelector('#scale')) build_options(document.querySelector('#dataset'), datasets, pick_dataset) @@ -282,7 +273,9 @@ function ready () { update_value_on_change(nx.interval, '#interval', true) const export_midi_button = document.querySelector('#export_midi') - export_midi_button.addEventListener('click', export_pattern_as_midi) + export_midi_button.addEventListener('click', () => { + export_pattern_as_midi(dataset.name, bounds, nx.tempo.value, nx.timing.active, play_fn) + }) const record_midi_button = document.querySelector('#record_midi') record_midi_button.addEventListener('click', () => { @@ -305,27 +298,8 @@ function ready () { document.querySelector('.loading').classList.remove('loading') play_next() } -function export_pattern_as_midi(){ - const behavior = document.querySelector('#behavior').value - const { rows } = bounds - let count = behavior === 'sequence' ? rows[0].length * rows.length : rows.length - let notes - let note_time - let timing = note_values[nx.timing.active][2] - exporting = true - let midi_track = new MidiWriter.Track() - midi_track.setTempo(nx.tempo.value) - for (let i = 0, len = count; i < len; i++) { - notes = play_fn(i)[1] - if (timing.length) note_time = timing[i % timing.length] - else note_time = timing - midi_track.addEvent(new MidiWriter.NoteEvent({ pitch: notes, duration: 't' + note_time })) - } - const writer = new MidiWriter.Writer([midi_track]) - const blob = dataURItoBlob(writer.dataUri()) - saveAs(blob, 'Recording - ' + dataset.name + '.mid') - exporting = false -} + +/* keys */ keys.listen(index => { nx.offset.value = index diff --git a/client/lib/midi.js b/client/lib/midi.js new file mode 100644 index 0000000..05fd708 --- /dev/null +++ b/client/lib/midi.js @@ -0,0 +1,142 @@ +import Tone from 'tone' +import WebMidi from 'webmidi' +import scales from './scales' +import { ftom } from './util' +import kalimba from './kalimba' + +let midiDevice + +export const MidiWriter = require('midi-writer-js') + +export const note_values = [ + [8, '8 measures', 8 * 512], + [4, '4 measures', 4 * 512], + [2, '2 measures', 2 * 512], + [1, 'whole note', 512], + [1/2, 'half note', 256], + [1/3, 'third note', [170, 170, 171]], + [1/4, 'quarter note', 128], + [1/5, 'fifth note', [51,51,51,51,52]], + [1/6, 'sixth note', [85, 85, 86, 85, 85, 86]], + [1/8, 'eighth note', 64], + [1/10, 'tenth note', [25,26,26,25,26,25,26,26,25,26]], + [1/12, 'twelfth note', [21,21,22, 21,21,22, 21,21,22, 21,21,22]], + [1/16, 'sixteenth note', 32], + [1/32, 'thirtysecond note', 16], +] + +export function midi_init() { + WebMidi.enable(midi_ready) + function midi_ready(err) { + if (err) { + console.error('webmidi failed to initialize') + return + } + if (!WebMidi.outputs.length) { + console.error('no MIDI output found') + return + } + console.log(WebMidi.inputs) + console.log(WebMidi.outputs) + if (WebMidi.outputs.length > 1) { + const filtered = WebMidi.outputs.filter(output => output.name.match(/prodipe/i)) + if (filtered.length) { + // midiDevice = filtered[0] + } + } + // midiDevice = midiDevice || WebMidi.outputs[0] + // console.log(midiDevice.name) + } +} + +/* play a single note */ + +export function play_note(index, duration, channel="all", exporting=false){ + // console.log(index) + const scale = scales.current() + const freq = scale.index(index + Math.round(nx.offset.value), nx.octave.value) + let midi_note = ftom(freq) + let cents = midi_note % 1 + if (cents > 0.5) { + midi_note += 1 + cents -= 1 + } + cents *= 2 + midi_note = Math.floor(midi_note) + if ((midiDevice || exporting) && midi_note > 127) return 0 + const note = Tone.Frequency(Math.floor(midi_note), "midi").toNote() + if (exporting || midiDevice) { + duration = duration || 60000 / Tone.Transport.bpm.value + if (! exporting) { + midiDevice.playNote(note, channel, { duration }) + if (sendPitchBend) { + midiDevice.sendPitchBend(cents, channel) + } + } + } else { + kalimba.play(freq) + } + return note +} + +/* play the next note in sequence */ + +function play_sequence(i, bounds, note_time, channel="all") { + const { rows, min, max } = bounds + const count = rows.length * rows[0].length + if (i >= count) i = 0 + const y = Math.floor(i / rows[0].length) + const x = i % rows[0].length + if (!x) console.log(y) + const n = rows[y][x] + i += 1 + if (i >= count) i = 0 + const midi_note = play_note( norm(n, min, max) * nx.multiply.value, note_time, channel, exporting) + return [i, [midi_note]] +} + +/* play the next row as an interval */ + +function play_interval_sequence(i, bounds, note_time, channel="all") { + const { rows, min, max } = bounds + const count = rows.length + if (i >= count) i = 0 + const y = i % count + const row = rows[y] + if (! row) { i = 0; return } + const row_min = Math.min.apply(Math, row) + // const row_max = Math.max.apply(Math, row) + const row_f0 = norm(row_min, min, max) + const row_root = row_f0 * nx.multiply.value + const notes = row.map(n => { + const note = row_root + norm(n - row_min, diff.min, diff.max) * nx.interval.value + play_note(note, note_time, channel, exporting) + }) + i += 1 + return [i, notes] +} + +/* generate a 1-track midi file by calling the play function repeatedly */ + +function export_pattern_as_midi(datasetName, bounds, tempo, timingIndex, play_fn) { + const behavior = document.querySelector('#behavior').value + const { rows } = bounds + let count = behavior === 'sequence' ? rows[0].length * rows.length : rows.length + let notes + let note_time + let timing = note_values[timingIndex][2] + let midi_track = new MidiWriter.Track() + midi_track.setTempo(tempo) + for (let i = 0, len = count; i < len; i++) { + notes = play_fn(i, bounds, exporting = true)[1] + if (timing.length) { + note_time = timing[i % timing.length] + } else { + note_time = timing + } + midi_track.addEvent(new MidiWriter.NoteEvent({ pitch: notes, duration: 't' + note_time })) + } + const writer = new MidiWriter.Writer([midi_track]) + const blob = dataURItoBlob(writer.dataUri()) + saveAs(blob, 'Recording - ' + datasetName + '.mid') +} diff --git a/client/lib/ui.js b/client/lib/ui.js new file mode 100644 index 0000000..f344f0e --- /dev/null +++ b/client/lib/ui.js @@ -0,0 +1,46 @@ +/* ui - update an int/float value */ + +export 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) + update(el.value) + el.update = update +} + +/* ui - update a radio button */ + +export 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 +} + +/* ui - bind/build a select dropdown */ + +export 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) + }) +} diff --git a/client/lib/util.js b/client/lib/util.js index e47b343..0685b9d 100644 --- a/client/lib/util.js +++ b/client/lib/util.js @@ -13,6 +13,7 @@ const browser = { isIphone, isIpad, isMobile, isDesktop } function choice (a){ return a[ Math.floor(Math.random() * a.length) ] } function mod(n,m){ return n-(m * Math.floor(n/m)) } +function norm(n, min, max){ return (n - min) / (max - min) } function requestAudioContext (fn) { if (isMobile) { @@ -92,5 +93,39 @@ function tap (fn) { } } -export { choice, mod, browser, requestAudioContext, ftom, mtof, tap, dataURItoBlob } +/* get minimum and maximum variance from row-to-row */ + +function get_diff_bounds(rows){ + const diffs = rows.map(row => { + const row_min = Math.min.apply(Math, row) + const row_max = Math.max.apply(Math, row) + return row_max - row_min + }) + const min = Math.min.apply(Math, diffs) + const max = Math.max.apply(Math, diffs) + return { min, max } +} + +/* get minimum and maximum values from a dataset */ + +function get_bounds(dataset){ + let rows = dataset.lines + // rows.forEach(row => row.shift()) + rows = rows.map(a => a.map(n => parseFloat(n))) + const max = rows.reduce((a,b) => { + return b.reduce((z,bb) => { + return Math.max(z, bb) + }, a) + }, -Infinity) + const min = rows.reduce((a,b) => { + return b.reduce((z,bb) => { + return Math.min(z, bb) + }, a) + }, Infinity) + return { rows, max, min } +} + + + +export { choice, mod, norm, browser, get_bounds, get_diff_bounds, requestAudioContext, ftom, mtof, tap, dataURItoBlob } |
