summaryrefslogtreecommitdiff
path: root/client
diff options
context:
space:
mode:
authorJules Laplace <julescarbon@gmail.com>2018-10-07 03:16:22 +0200
committerJules Laplace <julescarbon@gmail.com>2018-10-07 03:16:22 +0200
commit5496464966ff34c848538d726819ed91119da1f2 (patch)
treef6fed075b6df3fb249f69118f25b743317ac1ac2 /client
parent852ed2e007deac47292d3e83a374070683c29894 (diff)
grep script
Diffstat (limited to 'client')
-rw-r--r--client/data.js22
-rw-r--r--client/index.js384
-rw-r--r--client/lib/midi.js142
-rw-r--r--client/lib/ui.js46
-rw-r--r--client/lib/util.js37
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 }