7a66e708e920c10367758522af2c1eead5e25d74
[pwl6.git] / src / yuu / audio.js
1 /* Copyright 2014 Yukkuri Games
2 Licensed under the terms of the GNU GPL v2 or later
3 @license https://www.gnu.org/licenses/gpl-2.0.html
4 @source: https://yukkurigames.com/yuu/
5 */
6
7 (function (yuu) {
8 "use strict";
9
10 var yT = this.yT || require("./yT");
11 var yf = this.yf || require("./yf");
12
13 yuu.Audio = yT({
14 /** Audio context/source/buffer accessor
15
16 You probably don't need to make this yourself; one is made
17 named yuu.audio during initialization.
18
19 You can set the master volume with yuu.audio.masterVolume.
20 */
21 constructor: function () {
22 this._ctx = new window.AudioContext();
23 this._compressor = this._ctx.createDynamicsCompressor();
24 this._masterVolume = this._ctx.createGain();
25 this._masterVolume.connect(this._compressor);
26 this._compressor.connect(this._ctx.destination);
27 this._musicVolume = this._ctx.createGain();
28 this._musicVolume.connect(this._masterVolume);
29 this._masterVolume.gain.value = 0.5;
30 this._musicVolume.gain.value = 0.5;
31
32 this._bufferCache = {};
33 this._mute = false;
34 this._storage = null;
35 this._volume = this._masterVolume.gain.value;
36 },
37
38 destination: { alias: "_masterVolume", readonly: true },
39 music: { alias: "_musicVolume", readonly: true },
40
41 _readStorage: function () {
42 if (!this._storage)
43 return;
44 yf.each.call(this, function (prop) {
45 this[prop] = this._storage.getObject(prop, this[prop]);
46 }, ["volume", "musicVolume", "mute"]);
47 },
48
49 _writeStorage: yf.debounce(function () {
50 if (!this._storage)
51 return;
52 yf.each.call(this, function (prop) {
53 this._storage.setObject(prop, this[prop]);
54 }, ["volume", "musicVolume", "mute"]);
55 }),
56
57 storage: {
58 get: function () { return this._storage; },
59 set: function (v) {
60 this._storage = v;
61 this._readStorage();
62 }
63 },
64
65 mute: {
66 get: function () { return this._mute; },
67 set: function (v) {
68 this._mute = !!v;
69 this.volume = this.volume;
70 }
71 },
72
73 volume: {
74 get: function () { return this._volume; },
75 set: function (v) {
76 this._volume = v;
77 v = this._mute ? 0 : v;
78 this._masterVolume.gain.value = v;
79 this._writeStorage();
80 }
81 },
82
83 musicVolume: {
84 get: function () { return this._musicVolume.gain.value; },
85 set: function (v) {
86 this._musicVolume.gain.value = v;
87 this._writeStorage();
88 }
89 },
90
91 currentTime: { alias: "_ctx.currentTime" },
92
93 decodeAudioData: function (data) {
94 var ctx = this._ctx;
95 try {
96 return ctx.decodeAudioData(data);
97 } catch (exc) {
98 return new Promise(function (resolve, reject) {
99 ctx.decodeAudioData(data, function (buffer) {
100 resolve(buffer);
101 }, function () {
102 reject(new Error("Error decoding audio buffer"));
103 });
104 });
105 }
106 },
107
108 createBufferSource: function (path) {
109 var source = this._ctx.createBufferSource();
110 var sample = yf.isString(path)
111 ? new yuu.AudioSample(path, this)
112 : path;
113 if ((source.buffer = sample.buffer) === null) {
114 sample.ready.then(function () {
115 source.buffer = sample.buffer;
116 });
117 }
118 return source;
119 },
120
121 sampleRate: { alias: "_ctx.sampleRate" },
122 createGain: { proxy: "_ctx.createGain" },
123 createOscillator: { proxy: "_ctx.createOscillator" },
124 });
125
126 // FIXME: This parsing is garbagey, would be better to parse when
127 // first handed a dfn and turn everything into a function.
128 function applyMod (s, v) {
129 if (yf.isFunction(s))
130 return s(v);
131 else if (s === +s)
132 return s;
133 else if (s[0] === "-" || s[0] === "+")
134 return v + (+s);
135 else if (s[0] === "x" || s[0] === "*")
136 return v * +s.substring(1);
137 else if (s[s.length - 1] === "%")
138 return v * (parseFloat(s) / 100);
139 else
140 return +s;
141 }
142
143 var Envelope = yuu.Envelope = yT({
144 constructor: yf.argcd(
145 function (pairs) {
146 Envelope.call(this, pairs, 1);
147 },
148 function (pairs, scale) {
149 pairs = pairs || { "0": 1, "100%": 1 };
150 this.ts = Object.keys(pairs);
151 this.vs = yf.map.call(pairs, yf.getter, this.ts);
152 this.scale = scale;
153 var a = 0, b = 0;
154 var unlimited = false;
155 yf.each(function (t) {
156 if (+t) {
157 a = Math.max(+t, a);
158 b = Math.min(+t, b);
159 }
160 unlimited = unlimited || (t[t.length - 1] === "%");
161 }, this.ts);
162 this.minDuration = a - b;
163 this.maxDuration = (unlimited || a === b)
164 ? Infinity
165 : this.minDuration;
166 var vMin = Math.min.apply(Math, this.vs);
167 var vMax = Math.max.apply(Math, this.vs);
168 this.constant = vMin === vMax && this.vs[0] * this.scale;
169 }
170 ),
171
172 schedule: function (param, t0, scale, duration) {
173 if (this.constant !== false) {
174 param.setValueAtTime(scale * this.constant, t0);
175 } else {
176 yf.each.call(this, function (s, v) {
177 v = v * scale * this.scale;
178 var t = t0 + applyMod(s, duration);
179 if (t === t0)
180 param.setValueAtTime(v, t);
181 else
182 param.linearRampToValueAtTime(v, t);
183 }, this.ts, this.vs);
184 }
185 }
186 });
187
188 yuu.AudioSample = yuu.Caching(yT({
189 constructor: function (path, ctx) {
190 ctx = ctx || yuu.audio;
191 var url = yuu.resourcePath(path, "sound", "wav");
192 this.data = null;
193 this.ready = yuu.GET(url, { responseType: "arraybuffer" })
194 .then(ctx.decodeAudioData.bind(ctx))
195 .then(yf.setter.bind(this, "buffer"))
196 .then(yf.K(this));
197 }
198 }), function (args) { return args.length <= 2 ? args[0] : null; });
199
200 yuu.Modulator = yT({
201 constructor: function (dfn) {
202 this.envelope = new yuu.Envelope(dfn.envelope);
203 this.frequency = dfn.frequency;
204 this.index = dfn.index || 1.0;
205 },
206
207 createModulator: function (ctx, t0, fundamental, duration) {
208 var modulator = ctx.createOscillator();
209 modulator.frequency.value = applyMod(
210 this.frequency, fundamental);
211 modulator.start(t0);
212 modulator.stop(t0 + duration);
213 var modulatorG = ctx.createGain();
214 modulator.connect(modulatorG);
215 this.envelope.schedule(
216 modulatorG.gain, t0, this.index * fundamental, duration);
217 return modulatorG;
218 }
219 });
220
221 yuu.Instrument = yT({
222 constructor: function (dfn) {
223 if (yf.isString(dfn)) {
224 var sampleName = dfn;
225 dfn = { sample: {} };
226 dfn.sample[sampleName] = {};
227 }
228 this.envelope = new yuu.Envelope(dfn.envelope);
229 this.frequency = dfn.frequency || (dfn.sample ? {} : { "x1": 1.0 });
230 this.modulator = yf.map(
231 yf.new_(yuu.Modulator), yf.arrayify(dfn.modulator || []));
232 this.sample = dfn.sample || {};
233 this.ready = yuu.ready(
234 yf.map(yf.new_(yuu.AudioSample), Object.keys(this.sample)),
235 this);
236 },
237
238 createSound: function (ctx, t0, fundamental, amplitude, duration) {
239 // TODO: In the case of exactly one sample with a constant
240 // envelope, optimize out the extra gain node.
241 duration = yf.clamp(duration || 0,
242 this.envelope.minDuration,
243 this.envelope.maxDuration);
244 var ret = ctx.createGain();
245 var dst = ret;
246
247 yf.ipairs(function (name, params) {
248 var buffer = new yuu.AudioSample(name).buffer;
249 if (buffer && !params.loop)
250 duration = Math.max(buffer.duration, duration);
251 }, this.sample);
252
253 var modulators = yf.map(function (modulator) {
254 return modulator.createModulator(
255 ctx, t0, fundamental, duration);
256 }, this.modulator);
257
258 yf.ipairs.call(this, function (name, params) {
259 var src = ctx.createBufferSource(name);
260 src.loop = params.loop || false;
261 src.playbackRate.value = applyMod(
262 params.playbackRate || 1, fundamental || ctx.sampleRate);
263 yf.each(function (mod) { mod.connect(src.playbackRate); },
264 modulators);
265 if (params.duration)
266 src.start(t0, params.offset || 0, params.duration);
267 else
268 src.start(t0, params.offset || 0);
269 src.stop(t0 + duration);
270 src.connect(dst);
271 }, this.sample);
272
273 yf.ipairs.call(this, function (mfreq, mamp) {
274 var osc = ctx.createOscillator();
275 osc.frequency.value = applyMod(mfreq, fundamental);
276 osc.start(t0);
277 osc.stop(t0 + duration);
278 yf.each(function (mod) { mod.connect(osc.frequency); },
279 modulators);
280 if (mamp !== 1) {
281 var gain = ctx.createGain();
282 gain.gain.value = mamp;
283 osc.connect(gain);
284 gain.connect(dst);
285 } else {
286 osc.connect(dst);
287 }
288 }, this.frequency);
289
290 ret.gain.value = 0;
291 this.envelope.schedule(ret.gain, t0, amplitude, duration);
292 return ret;
293 },
294
295 play: yf.argcd(
296 function () {
297 return this.play(null, 0, 0, 1, 1);
298 },
299 function (ctx, t, freq, amp, duration) {
300 ctx = ctx || yuu.audio;
301 t = t || ctx.currentTime;
302 var g = this.createSound(ctx, t, freq, amp, duration);
303 g.connect(ctx.destination);
304 return g;
305 }
306 )
307 });
308
309 yuu.Instruments = yf.mapValues(yf.new_(yuu.Instrument), {
310 SINE: {
311 envelope: { "0": 0, "0.016": 1, "-0.016": 1, "100%": 0 },
312 },
313
314 ORGAN: {
315 envelope: { "0": 0, "0.016": 1, "-0.016": 1, "100%": 0 },
316 frequency: { "x1": 0.83, "x1.5": 0.17 }
317 },
318
319 SIREN: {
320 envelope: { "0": 1 },
321 modulator: {
322 envelope: { "0": 1 },
323 frequency: "1",
324 index: 0.2
325 }
326 },
327
328 BELL: {
329 envelope: { "0": 1, "2.5": 0.2, "5": 0 },
330 modulator: {
331 envelope: { "0": 1, "2.5": 0.2, "5": 0 },
332 frequency: "x1.5",
333 }
334 },
335
336 BRASS: {
337 envelope: { "0": 0, "0.2": 1, "0.4": 0.6, "-0.1": 0.5, "100%": 0 },
338 modulator: {
339 envelope: { "0": 0, "0.2": 1, "0.4": 0.6,
340 "-0.1": 0.5, "100%": 0 },
341 frequency: "x1",
342 index: 5.0
343 }
344 },
345 });
346
347 // Tune to A440 by default, although every interface should provide
348 // some ways to work around this. This gives C4 = ~261.63 Hz.
349 // https://en.wikipedia.org/wiki/Scientific_pitch_notation
350 yuu.C4_HZ = 440 * Math.pow(2, -9/12);
351
352 yuu.Scale = yT({
353 constructor: function (intervals) {
354 this.intervals = intervals;
355 this.length = this.intervals.length;
356 this.span = yf.foldl(function (a, b) { return a + b; }, intervals);
357 },
358
359 hz: function (tonic, degree, accidental) {
360 accidental = accidental || 0;
361 var s = this.span * ((degree / this.intervals.length) | 0)
362 + accidental;
363 degree %= this.intervals.length;
364 if (degree < 0) {
365 degree += this.intervals.length;
366 s -= this.span;
367 }
368 var i = 0;
369 while (degree >= 1) {
370 degree -= 1;
371 s += this.intervals[i];
372 i++;
373 }
374 if (degree > 0)
375 s += this.intervals[i] * degree;
376 return tonic * Math.pow(2, s / 1200.0);
377 }
378 });
379
380 yuu.Scales = yf.mapValues(yf.new_(yuu.Scale), {
381 CHROMATIC: yf.repeat(100, 12),
382 MINOR: [200, 100, 200, 200, 100, 200, 200],
383 MAJOR: [200, 200, 100, 200, 200, 200, 100],
384 WHOLE_TONE: [200, 200, 200, 200, 200, 200],
385 AUGMENTED: [300, 100, 300, 100, 300, 100],
386 _17ET: yf.repeat(1200 / 17, 17),
387 DOUBLE_HARMONIC: [100, 300, 100, 200, 100, 300, 100],
388 });
389
390 var DURATION = { T: 1/8, S: 1/4, I: 1/2, Q: 1, H: 2, W: 4,
391 ".": 1.5, "/": 1/3, "<": -1 };
392 var ACCIDENTAL = { b: -1, "#": 1, t: 0.5, d: -0.5 };
393
394 var NOTE = /([TSIQHW][<.\/]*)?(?:([XZ]|(?:[A-G][b#dt]*[0-9]+))|([+-]?[0-9.]+)([b#dt]*))|([-+<>{]|(?:[TSIQHW][.\/]?))/g;
395
396 var LETTERS = { Z: null, X: null };
397
398 yuu.parseNote = function (note, scale, C4) {
399 return (C4 || yuu.C4_HZ) * Math.pow(2, LETTERS[note] / 12);
400 };
401
402 yuu.parseScore = function (score, scale, tonic, C4) {
403 // Note language:
404 //
405 // To play a scientific pitch note and advance the time, just
406 // use its name: G4, Cb2, A#0
407 //
408 // To adjust the length of the note, use T, S, I, Q (default),
409 // H, W for 32nd through whole. Append . to do
410 // time-and-a-half. Append / to cut into a third. Append < to
411 // go back in time.
412 //
413 // To play a note on the provided scale, use a 0-based number
414 // (which can be negative). To move the current scale up or
415 // down, use + or -. For example, in C major, 0 and C4 produce
416 // the same note; after a -, 0 and C3 produce the same note.
417 //
418 // To rest, use Z or X.
419 //
420 // To play multiple notes at the same time, enclose them all with
421 // < ... >. The time will advance in accordance with the shortest
422 // one.
423 //
424 // To reset the time, scale offset, and duration, use a {.
425 // This can be more convenient when writing pieces with
426 // multiple parts than grouping, e.g.
427 // H < 1 8 > < 2 7 > < 3 6 > < 4 5 >
428 // is easier to understand when split into multiple lines:
429 // H 1 2 3 4
430 // { H 8 7 6 5
431
432 scale = scale || yuu.Scales.MAJOR;
433 C4 = C4 || yuu.C4_HZ;
434 tonic = tonic || scale.tonic || C4;
435 if (yf.isString(tonic))
436 tonic = yuu.parseNote(tonic, C4);
437
438 var t = 0;
439 var notes = [];
440 var degree = 0;
441 var groupLength = 0;
442 var defaultDuration = "Q";
443 var match;
444
445 function calcDuration (d, m) { return d * DURATION[m]; }
446 function calcAccidental (d, m) { return d * ACCIDENTAL[m]; }
447
448 while ((match = NOTE.exec(score))) {
449 switch (match[5]) {
450 case "<":
451 groupLength = Infinity;
452 break;
453 case ">":
454 t += groupLength === Infinity ? 0 : groupLength;
455 groupLength = 0;
456 break;
457 case "+":
458 degree += scale.length;
459 break;
460 case "-":
461 degree -= scale.length;
462 break;
463 case "{":
464 t = 0;
465 degree = 0;
466 groupLength = 0;
467 defaultDuration = "Q";
468 break;
469 default:
470 if (match[5]) {
471 defaultDuration = match[5];
472 continue;
473 }
474 var letter = match[2];
475 var duration = yf.foldl(
476 calcDuration, match[1] || defaultDuration, 1);
477 if (LETTERS[letter] !== null) {
478 var offset = match[3];
479 var accidental = yf.foldl(
480 calcAccidental, match[4] || "", 0) * 100;
481 notes.push({
482 time: t,
483 duration: duration,
484 hz: letter
485 ? C4 * Math.pow(2, LETTERS[letter]/12.0)
486 : scale.hz(tonic, degree + (+offset || 0), accidental)
487 });
488 }
489 if (groupLength && duration > 0)
490 groupLength = Math.min(groupLength, duration);
491 else
492 t += duration;
493 }
494 }
495
496 notes.sort(function (a, b) { return a.time - b.time; });
497 return notes;
498 };
499
500 yf.irange.call(LETTERS, function (i) {
501 yf.ipairs.call(this, function (l, o) {
502 var b = o + 12 * (i - 4);
503 this[l + i] = b;
504 yf.ipairs.call(this, function (s, m) {
505 this[l + s + i] = b + m;
506 }, ACCIDENTAL);
507 }, { C: 0, D: 2, E: 4, F: 5, G: 7, A: 9, B: 11 });
508 }, 11);
509
510 yuu.registerInitHook(function () {
511 if (!window.AudioContext)
512 throw new Error("Web Audio isn't supported.");
513 yuu.audio = new yuu.Audio();
514 yuu.defaultCommands.volume = yuu.propcmd(
515 yuu.audio, "volume",
516 "get/set the current master audio volume", "0...1");
517 yuu.defaultCommands.musicVolume = yuu.propcmd(
518 yuu.audio, "musicVolume",
519 "get/set the current music volume", "0...1");
520 yuu.defaultCommands.mute = yuu.propcmd(
521 yuu.audio, "mute", "mute or unmute audio");
522 });
523
524 }).call(typeof exports === "undefined" ? this : exports,
525 typeof exports === "undefined"
526 ? this.yuu : (module.exports = require('./core')));