-
Notifications
You must be signed in to change notification settings - Fork 2
/
Copy pathapp.coffee
380 lines (323 loc) Β· 12.2 KB
/
app.coffee
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
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
generate_form = document.querySelector(".generate-form")
keywords_input = document.querySelector(".keywords-input")
generate_button = document.querySelector(".generate-button")
songs_output_ul = document.querySelector(".songs-output")
status_indicator = document.querySelector(".status-indicator")
generate_form.onsubmit = (event)-> event.preventDefault()
state = {}
update = (new_state)->
status_indicator.classList.remove(state.status)
state[k] = v for k, v of new_state
{status, collecting} = state
status_indicator.classList.add(status)
status_indicator.innerHTML =
switch status
when "loading"
"Checking..."
when "offline"
"Offline"
when "online"
"Online"
generate_button.disabled = collecting
generate_button.value =
if collecting
"Collecting Sounds..."
else
"Generate Song"
concatArrayBuffers = (arrayBuffers)->
offset = 0
bytes = 0
arrayBuffers.forEach (buf)->
bytes += buf.byteLength
combined = new ArrayBuffer(bytes)
store = new Uint8Array(combined)
arrayBuffers.forEach (buf)->
store.set(new Uint8Array(buf.buffer ? buf, buf.byteOffset), offset)
offset += buf.byteLength
return combined
generateId = (len=40)->
to_hex = (n)-> "0#{n.toString(16)}".substr(-2)
arr = new Uint8Array(len / 2)
window.crypto.getRandomValues(arr)
Array.from(arr, to_hex).join('')
# Taken from https://github.com/parshap/node-sanitize-filename/blob/master/index.js
# but without utf8 truncation, just a slice.
# I haven't looked into the security implications of that because the browser will already do sanitization on this,
# I just want to control the resulting filename.
sanitizeFileName = (input, replacement="")->
if typeof input isnt "string"
throw new TypeError("input must be a string")
illegalRe = /[\/\?<>\\:\*\|"]/g
controlRe = /[\x00-\x1f\x80-\x9f]/g
reservedRe = /^\.+$/
windowsReservedRe = /^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i
windowsTrailingRe = /[\. ]+$/
input
.replace(illegalRe, replacement)
.replace(controlRe, replacement)
.replace(reservedRe, replacement)
.replace(windowsReservedRe, replacement)
.replace(windowsTrailingRe, replacement)
.slice(0, 255)
sound_search = ({query, song_id, midi}, on_progress, callback)->
query_id = "#{if midi then "midi" else "sounds"}-for-#{song_id}"
metadatas_received = []
socket.emit "sound-search", {query, midi, query_id}
socket.on "sound-metadata:#{query_id}", (metadata)->
metadatas_received.push(metadata)
{sound_id} = metadata
chunk_array_buffers = []
socket.on "sound-data:#{sound_id}", (array_buffer)->
chunk_array_buffers.push(array_buffer)
on_progress()
socket.once "sound-data-end:#{sound_id}", ->
socket.off "sound-data:#{sound_id}"
file_array_buffer = concatArrayBuffers(chunk_array_buffers)
chunk_array_buffers = null
callback(file_array_buffer, metadata)
file_array_buffer = null
cancel = ->
socket.off "sound-metadata:#{query_id}"
for {sound_id} in metadatas_received
socket.off "sound-data:#{sound_id}"
socket.off "sound-data-end:#{sound_id}"
return cancel
generate_button.onclick = ->
window.audioContext ?= new (window.AudioContext || window.webkitAudioContext)()
update collecting: true
audio_buffers = []
metadatas_used = []
query = keywords_input.value
song_id = sanitizeFileName("song-#{generateId(6)}-#{query}").replace(/\s/g, "-")
song_output_li = document.createElement("li")
song_output_li.className = "song"
song_search_terms = document.createElement("div")
song_search_terms.className = "song-search-terms"
song_search_terms.textContent = "π #{query}"
song_search_terms.onclick = -> keywords_input.value = query
song_status = document.createElement("div")
song_status.className = "song-status"
song_status.textContent = "Collecting sounds..."
song_audio_row = document.createElement("div")
song_audio_row.className = "song-audio-row"
song_output_audio = document.createElement("audio")
song_output_audio.controls = true
song_output_li.appendChild(song_search_terms)
song_audio_row.appendChild(song_status)
song_audio_row.appendChild(song_output_audio)
song_output_li.appendChild(song_audio_row)
songs_output_ul.prepend(song_output_li)
audio_buffers = []
midi_array_buffer = null
collection_tid = null
on_progress = ->
clearTimeout(collection_tid)
collection_tid = setTimeout collection_timed_out, 1000 * 10
cancel_getting_midi = sound_search {query, song_id, midi: true}, on_progress, (file_array_buffer, metadata)->
console.log "Got a midi file", metadata
midi_array_buffer ?= file_array_buffer
metadatas_used.push(metadata) if midi_array_buffer is file_array_buffer
check_sources_ready()
# actually,
cancel_getting_midi()
# and then we don't really need the ?= and if above, but whatever
canceled = false
cancel_getting_audio = sound_search {query, song_id}, on_progress, (file_array_buffer, metadata)->
console.log "Got a sound file (decoding...)", metadata
audioContext.decodeAudioData(file_array_buffer).then(
(audio_buffer)->
return if canceled
audio_buffers.push(audio_buffer)
metadatas_used.push(metadata)
console.log "Collected #{audio_buffers.length} audio buffers so far"
check_sources_ready()
(error)-> console.warn(error)
)
cancel = ->
cancel_getting_midi()
cancel_getting_audio()
canceled = true
clearTimeout(collection_tid)
check_sources_ready = ->
if audio_buffers.length >= 5
if midi_array_buffer
sources_ready()
collection_timed_out = ->
cancel()
if audio_buffers.length >= 1
if midi_array_buffer
sources_ready()
return
else
message = "Didn't find a midi track to base the structure off of."
else
if midi_array_buffer
message = "Didn't find enough tracks to sample from."
else
message = "Didn't find enough tracks to sample from, and didn't find a midi track to base the structure off of."
if socket.disconnected
message = "Offline. Server access needed to fetch sound sources."
update collecting: false
alert message
song_status.textContent = "Failed"
song_output_li.classList.add("failed")
clearTimeout collection_tid
collection_tid = setTimeout collection_timed_out, 1000 * 10
# target = 5
# active = 0
# parallelism = 2
# get_one = ->
# active += 1
# setTimeout ->
# fetch_audio_buffer((error, audio_buffer)->
# active -= 1
# if error
# console.warn(error)
# else
# audio_buffers.push(audio_buffer)
# console.log("collected #{audio_buffers.length} audio buffers so far")
# if audio_buffers.length is target
# console.log("reached target of #{target} audio buffers")
# sources_ready()
# if audio_buffers.length > target
# console.log("extraneous audio buffer collected (#{audio_buffers.length} / #{target})")
# console.log("collected #{audio_buffers.length} audio buffers so far, plus #{active} active requests; target: #{target}")
# if audio_buffers.length + active < target
# get_one()
# )
# , Math.random() * 500
# for [0..parallelism]
# get_one()
already_started = false
sources_ready = ->
return if already_started
already_started = true
update collecting: false
song_status.textContent = "Generating..."
song = null
tid = null
stop_generating = ->
console.trace "stop_generating", song_id
console.log {tid, "cancel_button.parentElement": cancel_button.parentElement, song}
mediaRecorder?.stop()
song?.output.disconnect()
song = null
clearTimeout tid
cancel_button.remove()
cancel_button = document.createElement("button")
cancel_button.onclick = stop_generating
cancel_button.textContent = "Stop"
song_status.appendChild(cancel_button)
song_output_li.appendChild(show_attribution(metadatas_used, song_id))
try
song = new Song([audio_buffers...], midi_array_buffer)
catch error
# to handle bad midi file at least
console.error error
stop_generating()
song_status.textContent = "Failed"
song_output_li.classList.add("failed")
return
destination = window.audioContext.createMediaStreamDestination()
mediaRecorder = new MediaRecorder(destination.stream)
mediaRecorder.start()
song.output.connect(destination)
end_time = song.schedule()
tid = setTimeout(stop_generating, end_time * 1000)
song_output_audio.srcObject = destination.stream
song_output_audio.play()
chunks = []
mediaRecorder.ondataavailable = (event)->
chunks.push(event.data)
mediaRecorder.onstop = (event)->
blob = new Blob(chunks, { 'type' : 'audio/ogg; codecs=opus' })
chunks = null
blob_url = URL.createObjectURL(blob)
currentTime = song_output_audio.currentTime
song_output_audio.srcObject = null
song_output_audio.src = blob_url
song_output_audio.currentTime = currentTime
# FIXME: there's a case where pressing play will play the tiniest bit because it's at the end
# maybe only set currentTime if it was playing?
# or compare with duration to see how near it is to the end
song_download_link = document.createElement("a")
song_download_link.className = "download-link"
song_download_link.textContent = "Download"
song_download_link.href = blob_url
song_download_link.download = "#{song_id}.ogg"
song_status.innerHTML = ""
song_status.appendChild(song_download_link)
provider_to_icon =
"filesystem": "icon-folder"
"soundcloud": "icon-soundcloud"
"spotify": "icon-spotify"
"bandcamp": "icon-bandcamp"
"lastfm": "icon-lastfm"
"opengameart": "icon-globe" # TODO: specific icon (probably ditch this font icon business, and use favicons)
"bitmidi": "icon-globe" # TODO: specific icon? but it's not very midi-indicative I feel
provider_to_acquisition_method_description =
"filesystem": "Via the filesystem"
"soundcloud": "Via the SoundCloud API"
# "spotify": "Via the Spotify API"
# "bandcamp": "Via the Bandcamp API"
# "lastfm": "Via the Last.fm API"
# "napster": "Via the Napster API"
"opengameart": "Scraped from OpenGameArt.org"
"bitmidi": "Scraped from BitMidi.com"
show_attribution = (metadatas, song_id)->
attribution_links_details = document.createElement("details")
attribution_links_summary = document.createElement("summary")
attribution_links_details.appendChild(attribution_links_summary)
attribution_links_summary.textContent = "Audio Sources"
attribution_links_ul = document.createElement("ul")
attribution_links_ul.className = "attribution-links"
attribution_links_details.appendChild(attribution_links_ul)
for metadata in metadatas
li = document.createElement("li")
provider_icon = document.createElement("i")
provider_icon.className = (provider_to_icon[metadata.provider] ? "icon-file-audio") + " provider-icon"
provider_icon.title = provider_to_acquisition_method_description[metadata.provider] ? "Procured somehow, probably"
li.appendChild(provider_icon)
track_link = document.createElement("a")
track_link.textContent = metadata.name or "something"
if metadata.link
track_link.href = metadata.link
track_link.setAttribute("target", "_blank")
li.appendChild(track_link)
if metadata.author?.link or metadata.author?.name
author_link = document.createElement("a")
author_link.textContent = metadata.author?.name or "someone"
if metadata.author?.link
author_link.href = metadata.author.link
author_link.setAttribute("target", "_blank")
li.appendChild(document.createTextNode(" by "))
li.appendChild(author_link)
# li.appendChild(document.createTextNode(" (#{source.number_of_samples} samples)"))
attribution_links_ul.appendChild(li)
attribution_html = """
<!doctype html>
<html>
<head>
<title>Attribution</title>
</head>
<body>
#{attribution_links_ul.outerHTML}
</body>
</html>
"""
attribution_blob = new Blob([attribution_html], {type: "text/html"})
attribution_download_link = document.createElement("a")
attribution_download_link.download = "#{song_id}-attribution.html"
attribution_download_link.href = URL.createObjectURL(attribution_blob)
attribution_download_link.textContent = "Download Attribution as HTML"
attribution_links_details.appendChild(attribution_download_link)
return attribution_links_details
socket = io()
# give it a bit to connect (while saying "Checking...") before saying "Offline" if it hasn't
setTimeout ->
update status: if socket.connected then "online" else "offline"
, 500
socket.on "connect", ->
update status: "online"
socket.on "disconnect", ->
update status: "offline"