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