UNPKG

vmsg

Version:

Library for creating voice messages

892 lines (791 loc) 28.6 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.record = record; exports.default = exports.Form = exports.Recorder = void 0; function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } function _defineProperties(target, props) { for (var i = 0; i < props.length; i++) { var descriptor = props[i]; descriptor.enumerable = descriptor.enumerable || false; descriptor.configurable = true; if ("value" in descriptor) descriptor.writable = true; Object.defineProperty(target, descriptor.key, descriptor); } } function _createClass(Constructor, protoProps, staticProps) { if (protoProps) _defineProperties(Constructor.prototype, protoProps); if (staticProps) _defineProperties(Constructor, staticProps); return Constructor; } /* eslint-disable */ function pad2(n) { n |= 0; return n < 10 ? "0".concat(n) : "".concat(Math.min(n, 99)); } function inlineWorker() { // TODO(Kagami): Cache compiled module in IndexedDB? It works in FF // and Edge, see: https://github.com/mdn/webassembly-examples/issues/4 // Though gzipped WASM module currently weights ~70kb so it should be // perfectly cached by the browser itself. function fetchAndInstantiate(url, imports) { if (!WebAssembly.instantiateStreaming) return fetchAndInstantiateFallback(url, imports); var req = fetch(url, { credentials: "same-origin" }); return WebAssembly.instantiateStreaming(req, imports).catch(function (err) { // https://github.com/Kagami/vmsg/issues/11 if (err.message && err.message.indexOf("Argument 0 must be provided and must be a Response") > 0) { return fetchAndInstantiateFallback(url, imports); } else { throw err; } }); } function fetchAndInstantiateFallback(url, imports) { return new Promise(function (resolve, reject) { var req = new XMLHttpRequest(); req.open("GET", url); req.responseType = "arraybuffer"; req.onload = function () { resolve(WebAssembly.instantiate(req.response, imports)); }; req.onerror = reject; req.send(); }); } // Must be in sync with emcc settings! var TOTAL_STACK = 5 * 1024 * 1024; var TOTAL_MEMORY = 16 * 1024 * 1024; var WASM_PAGE_SIZE = 64 * 1024; var memory = null; var dynamicTop = TOTAL_STACK; // TODO(Kagami): Grow memory? function sbrk(increment) { var oldDynamicTop = dynamicTop; dynamicTop += increment; return oldDynamicTop; } // TODO(Kagami): LAME calls exit(-1) on internal error. Would be nice // to provide custom DEBUGF/ERRORF for easier debugging. Currenty // those functions do nothing. function exit(status) { postMessage({ type: "internal-error", data: status }); } var FFI = null; var ref = null; var pcm_l = null; function vmsg_init(rate) { ref = FFI.vmsg_init(rate); if (!ref) return false; var pcm_l_ref = new Uint32Array(memory.buffer, ref, 1)[0]; pcm_l = new Float32Array(memory.buffer, pcm_l_ref); return true; } function vmsg_encode(data) { pcm_l.set(data); return FFI.vmsg_encode(ref, data.length) >= 0; } function vmsg_flush() { if (FFI.vmsg_flush(ref) < 0) return null; var mp3_ref = new Uint32Array(memory.buffer, ref + 4, 1)[0]; var size = new Uint32Array(memory.buffer, ref + 8, 1)[0]; var mp3 = new Uint8Array(memory.buffer, mp3_ref, size); var blob = new Blob([mp3], { type: "audio/mpeg" }); FFI.vmsg_free(ref); ref = null; pcm_l = null; return blob; } // https://github.com/brion/min-wasm-fail function testSafariWebAssemblyBug() { var bin = new Uint8Array([0, 97, 115, 109, 1, 0, 0, 0, 1, 6, 1, 96, 1, 127, 1, 127, 3, 2, 1, 0, 5, 3, 1, 0, 1, 7, 8, 1, 4, 116, 101, 115, 116, 0, 0, 10, 16, 1, 14, 0, 32, 0, 65, 1, 54, 2, 0, 32, 0, 40, 2, 0, 11]); var mod = new WebAssembly.Module(bin); var inst = new WebAssembly.Instance(mod, {}); // test storing to and loading from a non-zero location via a parameter. // Safari on iOS 11.2.5 returns 0 unexpectedly at non-zero locations return inst.exports.test(4) !== 0; } onmessage = function onmessage(e) { var msg = e.data; switch (msg.type) { case "init": var _msg$data = msg.data, wasmURL = _msg$data.wasmURL, shimURL = _msg$data.shimURL; Promise.resolve().then(function () { if (self.WebAssembly && !testSafariWebAssemblyBug()) { delete self.WebAssembly; } if (!self.WebAssembly) { importScripts(shimURL); } memory = new WebAssembly.Memory({ initial: TOTAL_MEMORY / WASM_PAGE_SIZE, maximum: TOTAL_MEMORY / WASM_PAGE_SIZE }); return { memory: memory, pow: Math.pow, exit: exit, powf: Math.pow, exp: Math.exp, sqrtf: Math.sqrt, cos: Math.cos, log: Math.log, sin: Math.sin, sbrk: sbrk }; }).then(function (Runtime) { return fetchAndInstantiate(wasmURL, { env: Runtime }); }).then(function (wasm) { FFI = wasm.instance.exports; postMessage({ type: "init", data: null }); }).catch(function (err) { postMessage({ type: "init-error", data: err.toString() }); }); break; case "start": if (!vmsg_init(msg.data)) return postMessage({ type: "error", data: "vmsg_init" }); break; case "data": if (!vmsg_encode(msg.data)) return postMessage({ type: "error", data: "vmsg_encode" }); break; case "stop": var blob = vmsg_flush(); if (!blob) return postMessage({ type: "error", data: "vmsg_flush" }); postMessage({ type: "stop", data: blob }); break; } }; } var Recorder = /*#__PURE__*/ function () { function Recorder() { var opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var onStop = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : null; _classCallCheck(this, Recorder); // Can't use relative URL in blob worker, see: // https://stackoverflow.com/a/22582695 this.wasmURL = new URL(opts.wasmURL || "/static/js/vmsg.wasm", location).href; this.shimURL = new URL(opts.shimURL || "/static/js/wasm-polyfill.js", location).href; this.onStop = onStop; this.pitch = opts.pitch || 0; this.stream = null; this.audioCtx = null; this.gainNode = null; this.pitchFX = null; this.encNode = null; this.worker = null; this.workerURL = null; this.blob = null; this.blobURL = null; this.resolve = null; this.reject = null; Object.seal(this); } _createClass(Recorder, [{ key: "close", value: function close() { if (this.encNode) this.encNode.disconnect(); if (this.encNode) this.encNode.onaudioprocess = null; if (this.stream) this.stopTracks(); if (this.audioCtx) this.audioCtx.close(); if (this.worker) { this.worker.terminate(); this.worker = null; } if (this.workerURL) URL.revokeObjectURL(this.workerURL); if (this.blobURL) URL.revokeObjectURL(this.blobURL); } // Without pitch shift: // [sourceNode] -> [gainNode] -> [encNode] -> [audioCtx.destination] // | // -> [worker] // With pitch shift: // [sourceNode] -> [gainNode] -> [pitchFX] -> [encNode] -> [audioCtx.destination] // | // -> [worker] }, { key: "initAudio", value: function initAudio() { var _this = this; var getUserMedia = navigator.mediaDevices && navigator.mediaDevices.getUserMedia ? function (constraints) { return navigator.mediaDevices.getUserMedia(constraints); } : function (constraints) { var oldGetUserMedia = navigator.webkitGetUserMedia || navigator.mozGetUserMedia; if (!oldGetUserMedia) { return Promise.reject(new Error("getUserMedia is not implemented in this browser")); } return new Promise(function (resolve, reject) { oldGetUserMedia.call(navigator, constraints, resolve, reject); }); }; return getUserMedia({ audio: true }).then(function (stream) { _this.stream = stream; var audioCtx = _this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); var sourceNode = audioCtx.createMediaStreamSource(stream); var gainNode = _this.gainNode = (audioCtx.createGain || audioCtx.createGainNode).call(audioCtx); gainNode.gain.value = 1; sourceNode.connect(gainNode); var pitchFX = _this.pitchFX = new Jungle(audioCtx); pitchFX.setPitchOffset(_this.pitch); var encNode = _this.encNode = (audioCtx.createScriptProcessor || audioCtx.createJavaScriptNode).call(audioCtx, 0, 1, 1); pitchFX.output.connect(encNode); gainNode.connect(_this.pitch === 0 ? encNode : pitchFX.input); }); } }, { key: "initWorker", value: function initWorker() { var _this2 = this; if (this.worker) return Promise.resolve(); // https://stackoverflow.com/a/19201292 var blob = new Blob(["(", inlineWorker.toString(), ")()"], { type: "application/javascript" }); var workerURL = this.workerURL = URL.createObjectURL(blob); var worker = this.worker = new Worker(workerURL); var wasmURL = this.wasmURL, shimURL = this.shimURL; worker.postMessage({ type: "init", data: { wasmURL: wasmURL, shimURL: shimURL } }); return new Promise(function (resolve, reject) { worker.onmessage = function (e) { var msg = e.data; switch (msg.type) { case "init": resolve(); break; case "init-error": _this2.close(); reject(new Error(msg.data)); break; // TODO(Kagami): Error handling. case "error": case "internal-error": _this2.close(); console.error("Worker error:", msg.data); if (_this2.reject) _this2.reject(msg.data); break; case "stop": _this2.blob = msg.data; _this2.blobURL = URL.createObjectURL(msg.data); if (_this2.onStop) _this2.onStop(); if (_this2.resolve) _this2.resolve(_this2.blob); break; } }; }); } }, { key: "init", value: function init() { return this.initAudio().then(this.initWorker.bind(this)); } }, { key: "startRecording", value: function startRecording() { var _this3 = this; if (!this.stream) throw new Error("missing audio initialization"); if (!this.worker) throw new Error("missing worker initialization"); this.blob = null; if (this.blobURL) URL.revokeObjectURL(this.blobURL); this.blobURL = null; this.resolve = null; this.reject = null; this.worker.postMessage({ type: "start", data: this.audioCtx.sampleRate }); this.encNode.onaudioprocess = function (e) { var samples = e.inputBuffer.getChannelData(0); _this3.worker.postMessage({ type: "data", data: samples }); }; this.encNode.connect(this.audioCtx.destination); } }, { key: "stopRecording", value: function stopRecording() { var _this4 = this; if (!this.stream) throw new Error("missing audio initialization"); if (!this.worker) throw new Error("missing worker initialization"); this.encNode.disconnect(); this.encNode.onaudioprocess = null; this.stopTracks(); this.audioCtx.close(); this.worker.postMessage({ type: "stop", data: null }); return new Promise(function (resolve, reject) { _this4.resolve = resolve; _this4.reject = reject; }); } }, { key: "stopTracks", value: function stopTracks() { // Might be missed in Safari and old FF/Chrome per MDN. if (this.stream.getTracks) { // Hide browser's recording indicator. this.stream.getTracks().forEach(function (track) { return track.stop(); }); } } }]); return Recorder; }(); exports.Recorder = Recorder; var Form = /*#__PURE__*/ function () { function Form() { var _this5 = this; var opts = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : {}; var resolve = arguments.length > 1 ? arguments[1] : undefined; var reject = arguments.length > 2 ? arguments[2] : undefined; _classCallCheck(this, Form); this.recorder = new Recorder(opts, this.onStop.bind(this)); this.resolve = resolve; this.reject = reject; this.backdrop = null; this.popup = null; this.recordBtn = null; this.stopBtn = null; this.timer = null; this.audio = null; this.saveBtn = null; this.tid = 0; this.start = 0; Object.seal(this); this.recorder.initAudio().then(function () { return _this5.drawInit(); }).then(function () { return _this5.recorder.initWorker(); }).then(function () { return _this5.drawAll(); }).catch(function (err) { return _this5.drawError(err); }); } _createClass(Form, [{ key: "drawInit", value: function drawInit() { var _this6 = this; if (this.backdrop) return; var backdrop = this.backdrop = document.createElement("div"); backdrop.className = "vmsg-backdrop"; backdrop.addEventListener("click", function () { return _this6.close(null); }); var popup = this.popup = document.createElement("div"); popup.className = "vmsg-popup"; popup.addEventListener("click", function (e) { return e.stopPropagation(); }); var progress = document.createElement("div"); progress.className = "vmsg-progress"; for (var i = 0; i < 3; i++) { var progressDot = document.createElement("div"); progressDot.className = "vmsg-progress-dot"; progress.appendChild(progressDot); } popup.appendChild(progress); backdrop.appendChild(popup); document.body.appendChild(backdrop); } }, { key: "drawTime", value: function drawTime(msecs) { var secs = Math.round(msecs / 1000); this.timer.textContent = pad2(secs / 60) + ":" + pad2(secs % 60); } }, { key: "drawAll", value: function drawAll() { var _this7 = this; this.drawInit(); this.clearAll(); var recordRow = document.createElement("div"); recordRow.className = "vmsg-record-row"; this.popup.appendChild(recordRow); var recordBtn = this.recordBtn = document.createElement("button"); recordBtn.className = "vmsg-button vmsg-record-button"; recordBtn.textContent = "●"; recordBtn.title = "Start Recording"; recordBtn.addEventListener("click", function () { return _this7.startRecording(); }); recordRow.appendChild(recordBtn); var stopBtn = this.stopBtn = document.createElement("button"); stopBtn.className = "vmsg-button vmsg-stop-button"; stopBtn.style.display = "none"; stopBtn.textContent = "■"; stopBtn.title = "Stop Recording"; stopBtn.addEventListener("click", function () { return _this7.stopRecording(); }); recordRow.appendChild(stopBtn); var audio = this.audio = new Audio(); audio.autoplay = true; var timer = this.timer = document.createElement("span"); timer.className = "vmsg-timer"; timer.title = "Preview Recording"; timer.addEventListener("click", function () { if (audio.paused) { if (_this7.recorder.blobURL) { audio.src = _this7.recorder.blobURL; } } else { audio.pause(); } }); this.drawTime(0); recordRow.appendChild(timer); var saveBtn = this.saveBtn = document.createElement("button"); saveBtn.className = "vmsg-button vmsg-save-button"; saveBtn.textContent = "✓"; saveBtn.title = "Save Recording"; saveBtn.disabled = true; saveBtn.addEventListener("click", function () { return _this7.close(_this7.recorder.blob); }); recordRow.appendChild(saveBtn); var gainWrapper = document.createElement("div"); gainWrapper.className = "vmsg-slider-wrapper vmsg-gain-slider-wrapper"; var gainSlider = document.createElement("input"); gainSlider.className = "vmsg-slider vmsg-gain-slider"; gainSlider.setAttribute("type", "range"); gainSlider.min = 0; gainSlider.max = 2; gainSlider.step = 0.2; gainSlider.value = 1; gainSlider.onchange = function () { var gain = +gainSlider.value; _this7.recorder.gainNode.gain.value = gain; }; gainWrapper.appendChild(gainSlider); this.popup.appendChild(gainWrapper); var pitchWrapper = document.createElement("div"); pitchWrapper.className = "vmsg-slider-wrapper vmsg-pitch-slider-wrapper"; var pitchSlider = document.createElement("input"); pitchSlider.className = "vmsg-slider vmsg-pitch-slider"; pitchSlider.setAttribute("type", "range"); pitchSlider.min = -1; pitchSlider.max = 1; pitchSlider.step = 0.2; pitchSlider.value = this.recorder.pitch; pitchSlider.onchange = function () { var pitch = +pitchSlider.value; _this7.recorder.pitchFX.setPitchOffset(pitch); _this7.recorder.gainNode.disconnect(); _this7.recorder.gainNode.connect(pitch === 0 ? _this7.recorder.encNode : _this7.recorder.pitchFX.input); }; pitchWrapper.appendChild(pitchSlider); this.popup.appendChild(pitchWrapper); recordBtn.focus(); } }, { key: "drawError", value: function drawError(err) { console.error(err); this.drawInit(); this.clearAll(); var error = document.createElement("div"); error.className = "vmsg-error"; error.textContent = err.toString(); this.popup.appendChild(error); } }, { key: "clearAll", value: function clearAll() { if (!this.popup) return; this.popup.innerHTML = ""; } }, { key: "close", value: function close(blob) { if (this.audio) this.audio.pause(); if (this.tid) clearTimeout(this.tid); this.recorder.close(); this.backdrop.remove(); if (blob) { this.resolve(blob); } else { this.reject(new Error("No record made")); } } }, { key: "onStop", value: function onStop() { this.recordBtn.style.display = ""; this.stopBtn.style.display = "none"; this.stopBtn.disabled = false; this.saveBtn.disabled = false; } }, { key: "startRecording", value: function startRecording() { this.audio.pause(); this.start = Date.now(); this.updateTime(); this.recordBtn.style.display = "none"; this.stopBtn.style.display = ""; this.saveBtn.disabled = true; this.stopBtn.focus(); this.recorder.startRecording(); } }, { key: "stopRecording", value: function stopRecording() { clearTimeout(this.tid); this.tid = 0; this.stopBtn.disabled = true; this.recordBtn.focus(); this.recorder.stopRecording(); } }, { key: "updateTime", value: function updateTime() { var _this8 = this; // NOTE(Kagami): We can do this in `onaudioprocess` but that would // run too often and create unnecessary DOM updates. this.drawTime(Date.now() - this.start); this.tid = setTimeout(function () { return _this8.updateTime(); }, 300); } }]); return Form; }(); exports.Form = Form; var shown = false; /** * Record a new voice message. * * @param {Object=} opts - Options * @param {string=} opts.wasmURL - URL of the module * ("/static/js/vmsg.wasm" by default) * @param {string=} opts.shimURL - URL of the WebAssembly polyfill * ("/static/js/wasm-polyfill.js" by default) * @param {number=} opts.pitch - Initial pitch shift ([-1, 1], 0 by default) * @return {Promise.<Blob>} A promise that contains recorded blob when fulfilled. */ function record(opts) { return new Promise(function (resolve, reject) { if (shown) throw new Error("Record form is already opened"); shown = true; new Form(opts, resolve, reject); // Use `.finally` once it's available in Safari and Edge. }).then(function (result) { shown = false; return result; }, function (err) { shown = false; throw err; }); } /** * All available public items. */ var _default = { Recorder: Recorder, Form: Form, record: record }; // Borrowed from and slightly modified: // https://github.com/cwilso/Audio-Input-Effects/blob/master/js/jungle.js // Copyright 2012, Google Inc. // All rights reserved. // // Redistribution and use in source and binary forms, with or without // modification, are permitted provided that the following conditions are // met: // // * Redistributions of source code must retain the above copyright // notice, this list of conditions and the following disclaimer. // * Redistributions in binary form must reproduce the above // copyright notice, this list of conditions and the following disclaimer // in the documentation and/or other materials provided with the // distribution. // * Neither the name of Google Inc. nor the names of its // contributors may be used to endorse or promote products derived from // this software without specific prior written permission. // // THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS // "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT // LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR // A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT // OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, // SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT // LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, // DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY // THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT // (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE // OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. exports.default = _default; var delayTime = 0.100; var fadeTime = 0.050; var bufferTime = 0.100; function createFadeBuffer(context, activeTime, fadeTime) { var length1 = activeTime * context.sampleRate; var length2 = (activeTime - 2 * fadeTime) * context.sampleRate; var length = length1 + length2; var buffer = context.createBuffer(1, length, context.sampleRate); var p = buffer.getChannelData(0); var fadeLength = fadeTime * context.sampleRate; var fadeIndex1 = fadeLength; var fadeIndex2 = length1 - fadeLength; // 1st part of cycle for (var i = 0; i < length1; ++i) { var value; if (i < fadeIndex1) { value = Math.sqrt(i / fadeLength); } else if (i >= fadeIndex2) { value = Math.sqrt(1 - (i - fadeIndex2) / fadeLength); } else { value = 1; } p[i] = value; } // 2nd part for (var i = length1; i < length; ++i) { p[i] = 0; } return buffer; } function createDelayTimeBuffer(context, activeTime, fadeTime, shiftUp) { var length1 = activeTime * context.sampleRate; var length2 = (activeTime - 2 * fadeTime) * context.sampleRate; var length = length1 + length2; var buffer = context.createBuffer(1, length, context.sampleRate); var p = buffer.getChannelData(0); // 1st part of cycle for (var i = 0; i < length1; ++i) { if (shiftUp) // This line does shift-up transpose p[i] = (length1 - i) / length;else // This line does shift-down transpose p[i] = i / length1; } // 2nd part for (var i = length1; i < length; ++i) { p[i] = 0; } return buffer; } function Jungle(context) { this.context = context; // Create nodes for the input and output of this "module". var input = (context.createGain || context.createGainNode).call(context); var output = (context.createGain || context.createGainNode).call(context); this.input = input; this.output = output; // Delay modulation. var mod1 = context.createBufferSource(); var mod2 = context.createBufferSource(); var mod3 = context.createBufferSource(); var mod4 = context.createBufferSource(); this.shiftDownBuffer = createDelayTimeBuffer(context, bufferTime, fadeTime, false); this.shiftUpBuffer = createDelayTimeBuffer(context, bufferTime, fadeTime, true); mod1.buffer = this.shiftDownBuffer; mod2.buffer = this.shiftDownBuffer; mod3.buffer = this.shiftUpBuffer; mod4.buffer = this.shiftUpBuffer; mod1.loop = true; mod2.loop = true; mod3.loop = true; mod4.loop = true; // for switching between oct-up and oct-down var mod1Gain = (context.createGain || context.createGainNode).call(context); var mod2Gain = (context.createGain || context.createGainNode).call(context); var mod3Gain = (context.createGain || context.createGainNode).call(context); mod3Gain.gain.value = 0; var mod4Gain = (context.createGain || context.createGainNode).call(context); mod4Gain.gain.value = 0; mod1.connect(mod1Gain); mod2.connect(mod2Gain); mod3.connect(mod3Gain); mod4.connect(mod4Gain); // Delay amount for changing pitch. var modGain1 = (context.createGain || context.createGainNode).call(context); var modGain2 = (context.createGain || context.createGainNode).call(context); var delay1 = (context.createDelay || context.createDelayNode).call(context); var delay2 = (context.createDelay || context.createDelayNode).call(context); mod1Gain.connect(modGain1); mod2Gain.connect(modGain2); mod3Gain.connect(modGain1); mod4Gain.connect(modGain2); modGain1.connect(delay1.delayTime); modGain2.connect(delay2.delayTime); // Crossfading. var fade1 = context.createBufferSource(); var fade2 = context.createBufferSource(); var fadeBuffer = createFadeBuffer(context, bufferTime, fadeTime); fade1.buffer = fadeBuffer; fade2.buffer = fadeBuffer; fade1.loop = true; fade2.loop = true; var mix1 = (context.createGain || context.createGainNode).call(context); var mix2 = (context.createGain || context.createGainNode).call(context); mix1.gain.value = 0; mix2.gain.value = 0; fade1.connect(mix1.gain); fade2.connect(mix2.gain); // Connect processing graph. input.connect(delay1); input.connect(delay2); delay1.connect(mix1); delay2.connect(mix2); mix1.connect(output); mix2.connect(output); // Start var t = context.currentTime + 0.050; var t2 = t + bufferTime - fadeTime; mod1.start(t); mod2.start(t2); mod3.start(t); mod4.start(t2); fade1.start(t); fade2.start(t2); this.mod1 = mod1; this.mod2 = mod2; this.mod1Gain = mod1Gain; this.mod2Gain = mod2Gain; this.mod3Gain = mod3Gain; this.mod4Gain = mod4Gain; this.modGain1 = modGain1; this.modGain2 = modGain2; this.fade1 = fade1; this.fade2 = fade2; this.mix1 = mix1; this.mix2 = mix2; this.delay1 = delay1; this.delay2 = delay2; this.setDelay(delayTime); } Jungle.prototype.setDelay = function (delayTime) { this.modGain1.gain.setTargetAtTime(0.5 * delayTime, 0, 0.010); this.modGain2.gain.setTargetAtTime(0.5 * delayTime, 0, 0.010); }; Jungle.prototype.setPitchOffset = function (mult) { if (mult > 0) { // pitch up this.mod1Gain.gain.value = 0; this.mod2Gain.gain.value = 0; this.mod3Gain.gain.value = 1; this.mod4Gain.gain.value = 1; } else { // pitch down this.mod1Gain.gain.value = 1; this.mod2Gain.gain.value = 1; this.mod3Gain.gain.value = 0; this.mod4Gain.gain.value = 0; } this.setDelay(delayTime * Math.abs(mult)); };