-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathsonification.js
276 lines (200 loc) · 8.84 KB
/
sonification.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"use strict"
let MidiWriter = require("midi-writer-js")
let Segmentation = require("./segmentation")
let helpers = require("./helpers")
let map_to_range = helpers.map_to_range
let mean = helpers.mean
/*
Returns a function that adds a desired MIDI CC event, created from examining the given array
of numbers, to a given MIDI track.
Why a function? See the sonify_parameter() comments.
Credit: this iss an expansion of Last, M., & Usyskin, A. See README for full citation.
array:
An array of numbers.
cc_number:
The MIDI CC number of the continuous controller that the event will modify.
Should be between 0 and 127, inclusive.
value_function:
A function to be applied to the array, reducing the array to a single value.
The resulting value is then mapped to the range 0 to 127 as the CC change value.
ts_statistics:
An object containing information used to map the output of value_function to
the range 0 to 127, inclusive.
It must contain at least the following properties:
min:
The minimum value that value_function is expected to output
max:
The maximum value that value_function is expected to output
config:
An object containing information about the range of desired output note values.
It must contain at least the following properties:
volume_low:
The lowest desired volume level (0-127) that the CC event will possibly receive.
volume_high:
The highest desired volume level (0-127) that the CC event will possibly receive.
*/
let create_cc_event_from = (array, cc_number, value_function, ts_statistics, config) => {
let segment_value = value_function(array)
let cc_value = Math.round(
map_to_range(
segment_value,
ts_statistics.min,
ts_statistics.max,
config.volume_low,
config.volume_high
)
)
return (track) => track.controllerChange(cc_number, cc_value)
}
/*
Returns a function that adds a desired MIDI note event, created from examining the given array
of numbers, to a given MIDI track.
Why a function? See the sonify_parameter() comments.
Credit: this is an expansion of Last, M., & Usyskin, A. See README for full citation.
array:
An array of numbers.
value_function:
A function to be applied to the array, reducing the array to a single value.
The resulting value is then mapped to the desired range of notes specified in config.
ts_statistics:
An object containing information used to map the output of value_function to
the desired range of notes specified in config.
It must contain at least the following properties:
min:
The minimum value that value_function is expected to output
max:
The maximum value that value_function is expected to output
config:
An object containing information about the range of desired output note values.
It must contain at least the following properties:
low:
The MIDI note number of the desired lowest note to possibly be returned.
range:
The desired number of unique MIDI notes to possibly be returned.
The actual range of notes starts at low and ends at low + range, inclusive.
*/
let create_note_event_from = (array, value_function, ts_statistics, config) => {
let duration = array.length * config.ticks_per_samp
let segment_value = value_function(array)
// Map the segment value (within the total range of the data) to the range of pitches
let note = Math.round(
map_to_range(
segment_value,
ts_statistics.min,
ts_statistics.max,
config.low,
config.low + config.range
)
)
let note_event = new MidiWriter.NoteEvent({
pitch: get_scale_tone_for(note, config.scale),
duration: "T" + duration
})
return (track) => track.addEvent(note_event)
}
/*
Rounds a MIDI note value up or down to the closest note in a given scale.
The paper hardcoded a Cmaj scale, so it only ever needed to decrement a given note.
midi_note:
An integer representing a MIDI note.
scale:
An array of integers representing a scale of MIDI notes.
*/
let get_scale_tone_for = (midi_note, scale) => {
let decreased = midi_note
let increased = midi_note
let is_tonal = note => scale.includes(note % 12)
while (!is_tonal(decreased) || !is_tonal(increased)) {
decreased--
increased++
}
return is_tonal(decreased) ? decreased : increased
}
/*
Returns the sonification of the given data & configuration, in MIDI format.
parameter_map, measurement_types, config:
Objects that follow the structures laid out in the API documentation.
*/
let sonification_of = (parameter_map, measurement_types, config) => {
let midi_track = new MidiWriter.Track()
// midi_event_generators[parameter][time] = a function that adds a midi event to a given track
// See sonify_parameter() comments for why
let midi_event_generators = {}
Object.keys(parameter_map).forEach(parameter => {
midi_event_generators[parameter] = sonify_parameter(parameter, parameter_map[parameter], measurement_types[parameter], config)
})
// We need to add the events of every parameter in order of time
// otherwise we'll see all the CC events occur before any notes even play
for(let time = 0; time < Object.values(midi_event_generators)[0].length; time++)
{
// We need to separate out the CC events from the note events
// because all CC events should occur before we schedule the note
let cc_event_generators = []
let note_event_generators = [] // should only contain one, but in case we ever support polysynths
Object.keys(parameter_map).forEach(parameter => {
if(parameter == "pitch")
note_event_generators.push(midi_event_generators[parameter][time])
else
cc_event_generators.push(midi_event_generators[parameter][time])
})
cc_event_generators.forEach(add_event_to => add_event_to(midi_track))
note_event_generators.forEach(add_event_to => add_event_to(midi_track))
}
return new MidiWriter.Writer(midi_track).dataUri()
}
/*
Returns a function that adds a MIDI event to a given MIDI track.
Why this kind of function and not the MIDI event object itself?
The MidiWriter API uses different methods to add note-on and CC events to a track.
Note-on uses track.addEvent(), but CC uses track.controllerChange().
By returning a function, the caller of this function does not have to worry about
what kind of event they are actually receiving; they can simply use the result
to build their track.
Credit: this is an expansion of Last, M., & Usyskin, A. See README for full citation.
parameter:
A string with the name of one of the supported parameters as listed in the API documentation.
ts:
An array of numbers, representing time series data.
measurement_type:
A string representing one of the supported measurement types as listed in the API documentation.
config:
An object that follows the structure laid out in the API documentation.
*/
let sonify_parameter = (parameter, ts, measurement_type, config) => {
let segments = Segmentation.segmentation_of(ts)
let value_function = null
// Decide how each segment will be reduced to a value
switch(measurement_type) {
case "mean":
value_function = mean
break
case "min":
value_function = (segment => Math.min(...segment))
break
case "max":
value_function = (segment => Math.max(...segment))
break
case "length":
value_function = (segment => segment.length)
break
}
// Get the min/max of those reduced segments, for mapping to note parameters
let ts_statistics = {
min: segments.map(value_function).reduce((a,b) => Math.min(a,b)),
max: segments.map(value_function).reduce((a,b) => Math.max(a,b))
}
switch(parameter) {
case "pitch":
return segments.map(segment => create_note_event_from(segment, value_function, ts_statistics, config))
case "volume":
return segments.map(segment => create_cc_event_from(segment, 7, value_function, ts_statistics, config))
case "pan":
return segments.map(segment => create_cc_event_from(segment, 10, value_function, ts_statistics, config))
}
}
/*
Enables the use of "require(./sonification)" to access sonification_of()
*/
module.exports = {
sonification_of
}