/* * * * (c) 2009-2020 Øystein Moseng * * Instrument class for sonification module. * * License: www.highcharts.com/license * * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!! * * */ 'use strict'; import H from '../../parts/Globals.js'; import U from '../../parts/Utilities.js'; var error = U.error, merge = U.merge, pick = U.pick, uniqueKey = U.uniqueKey; /** * A set of options for the Instrument class. * * @requires module:modules/sonification * * @interface Highcharts.InstrumentOptionsObject */ /** * The type of instrument. Currently only `oscillator` is supported. Defaults * to `oscillator`. * @name Highcharts.InstrumentOptionsObject#type * @type {string|undefined} */ /** * The unique ID of the instrument. Generated if not supplied. * @name Highcharts.InstrumentOptionsObject#id * @type {string|undefined} */ /** * When using functions to determine frequency or other parameters during * playback, this options specifies how often to call the callback functions. * Number given in milliseconds. Defaults to 20. * @name Highcharts.InstrumentOptionsObject#playCallbackInterval * @type {number|undefined} */ /** * A list of allowed frequencies for this instrument. If trying to play a * frequency not on this list, the closest frequency will be used. Set to `null` * to allow all frequencies to be used. Defaults to `null`. * @name Highcharts.InstrumentOptionsObject#allowedFrequencies * @type {Array|undefined} */ /** * Options specific to oscillator instruments. * @name Highcharts.InstrumentOptionsObject#oscillator * @type {Highcharts.OscillatorOptionsObject|undefined} */ /** * Options for playing an instrument. * * @requires module:modules/sonification * * @interface Highcharts.InstrumentPlayOptionsObject */ /** * The frequency of the note to play. Can be a fixed number, or a function. The * function receives one argument: the relative time of the note playing (0 * being the start, and 1 being the end of the note). It should return the * frequency number for each point in time. The poll interval of this function * is specified by the Instrument.playCallbackInterval option. * @name Highcharts.InstrumentPlayOptionsObject#frequency * @type {number|Function} */ /** * The duration of the note in milliseconds. * @name Highcharts.InstrumentPlayOptionsObject#duration * @type {number} */ /** * The minimum frequency to allow. If the instrument has a set of allowed * frequencies, the closest frequency is used by default. Use this option to * stop too low frequencies from being used. * @name Highcharts.InstrumentPlayOptionsObject#minFrequency * @type {number|undefined} */ /** * The maximum frequency to allow. If the instrument has a set of allowed * frequencies, the closest frequency is used by default. Use this option to * stop too high frequencies from being used. * @name Highcharts.InstrumentPlayOptionsObject#maxFrequency * @type {number|undefined} */ /** * The volume of the instrument. Can be a fixed number between 0 and 1, or a * function. The function receives one argument: the relative time of the note * playing (0 being the start, and 1 being the end of the note). It should * return the volume for each point in time. The poll interval of this function * is specified by the Instrument.playCallbackInterval option. Defaults to 1. * @name Highcharts.InstrumentPlayOptionsObject#volume * @type {number|Function|undefined} */ /** * The panning of the instrument. Can be a fixed number between -1 and 1, or a * function. The function receives one argument: the relative time of the note * playing (0 being the start, and 1 being the end of the note). It should * return the panning value for each point in time. The poll interval of this * function is specified by the Instrument.playCallbackInterval option. * Defaults to 0. * @name Highcharts.InstrumentPlayOptionsObject#pan * @type {number|Function|undefined} */ /** * Callback function to be called when the play is completed. * @name Highcharts.InstrumentPlayOptionsObject#onEnd * @type {Function|undefined} */ /** * @requires module:modules/sonification * * @interface Highcharts.OscillatorOptionsObject */ /** * The waveform shape to use for oscillator instruments. Defaults to `sine`. * @name Highcharts.OscillatorOptionsObject#waveformShape * @type {string|undefined} */ // Default options for Instrument constructor var defaultOptions = { type: 'oscillator', playCallbackInterval: 20, oscillator: { waveformShape: 'sine' } }; /* eslint-disable no-invalid-this, valid-jsdoc */ /** * The Instrument class. Instrument objects represent an instrument capable of * playing a certain pitch for a specified duration. * * @sample highcharts/sonification/instrument/ * Using Instruments directly * @sample highcharts/sonification/instrument-advanced/ * Using callbacks for instrument parameters * * @requires module:modules/sonification * * @class * @name Highcharts.Instrument * * @param {Highcharts.InstrumentOptionsObject} options * Options for the instrument instance. */ function Instrument(options) { this.init(options); } Instrument.prototype.init = function (options) { if (!this.initAudioContext()) { error(29); return; } this.options = merge(defaultOptions, options); this.id = this.options.id = options && options.id || uniqueKey(); // Init the audio nodes var ctx = H.audioContext; this.gainNode = ctx.createGain(); this.setGain(0); this.panNode = ctx.createStereoPanner && ctx.createStereoPanner(); if (this.panNode) { this.setPan(0); this.gainNode.connect(this.panNode); this.panNode.connect(ctx.destination); } else { this.gainNode.connect(ctx.destination); } // Oscillator initialization if (this.options.type === 'oscillator') { this.initOscillator(this.options.oscillator); } // Init timer list this.playCallbackTimers = []; }; /** * Return a copy of an instrument. Only one instrument instance can play at a * time, so use this to get a new copy of the instrument that can play alongside * it. The new instrument copy will receive a new ID unless one is supplied in * options. * * @function Highcharts.Instrument#copy * * @param {Highcharts.InstrumentOptionsObject} [options] * Options to merge in for the copy. * * @return {Highcharts.Instrument} * A new Instrument instance with the same options. */ Instrument.prototype.copy = function (options) { return new Instrument(merge(this.options, { id: null }, options)); }; /** * Init the audio context, if we do not have one. * @private * @return {boolean} True if successful, false if not. */ Instrument.prototype.initAudioContext = function () { var Context = H.win.AudioContext || H.win.webkitAudioContext, hasOldContext = !!H.audioContext; if (Context) { H.audioContext = H.audioContext || new Context(); if (!hasOldContext && H.audioContext && H.audioContext.state === 'running') { H.audioContext.suspend(); // Pause until we need it } return !!(H.audioContext && H.audioContext.createOscillator && H.audioContext.createGain); } return false; }; /** * Init an oscillator instrument. * @private * @param {Highcharts.OscillatorOptionsObject} oscillatorOptions * The oscillator options passed to Highcharts.Instrument#init. * @return {void} */ Instrument.prototype.initOscillator = function (options) { var ctx = H.audioContext; this.oscillator = ctx.createOscillator(); this.oscillator.type = options.waveformShape; this.oscillator.connect(this.gainNode); this.oscillatorStarted = false; }; /** * Set pan position. * @private * @param {number} panValue * The pan position to set for the instrument. * @return {void} */ Instrument.prototype.setPan = function (panValue) { if (this.panNode) { this.panNode.pan.setValueAtTime(panValue, H.audioContext.currentTime); } }; /** * Set gain level. A maximum of 1.2 is allowed before we emit a warning. The * actual volume is not set above this level regardless of input. * @private * @param {number} gainValue * The gain level to set for the instrument. * @param {number} [rampTime=0] * Gradually change the gain level, time given in milliseconds. * @return {void} */ Instrument.prototype.setGain = function (gainValue, rampTime) { if (this.gainNode) { if (gainValue > 1.2) { console.warn(// eslint-disable-line 'Highcharts sonification warning: ' + 'Volume of instrument set too high.'); gainValue = 1.2; } if (rampTime) { this.gainNode.gain.setValueAtTime(this.gainNode.gain.value, H.audioContext.currentTime); this.gainNode.gain.linearRampToValueAtTime(gainValue, H.audioContext.currentTime + rampTime / 1000); } else { this.gainNode.gain.setValueAtTime(gainValue, H.audioContext.currentTime); } } }; /** * Cancel ongoing gain ramps. * @private * @return {void} */ Instrument.prototype.cancelGainRamp = function () { if (this.gainNode) { this.gainNode.gain.cancelScheduledValues(0); } }; /** * Get the closest valid frequency for this instrument. * @private * @param {number} frequency - The target frequency. * @param {number} [min] - Minimum frequency to return. * @param {number} [max] - Maximum frequency to return. * @return {number} The closest valid frequency to the input frequency. */ Instrument.prototype.getValidFrequency = function (frequency, min, max) { var validFrequencies = this.options.allowedFrequencies, maximum = pick(max, Infinity), minimum = pick(min, -Infinity); return !validFrequencies || !validFrequencies.length ? // No valid frequencies for this instrument, return the target frequency : // Use the valid frequencies and return the closest match validFrequencies.reduce(function (acc, cur) { // Find the closest allowed value return Math.abs(cur - frequency) < Math.abs(acc - frequency) && cur < maximum && cur > minimum ? cur : acc; }, Infinity); }; /** * Clear existing play callback timers. * @private * @return {void} */ Instrument.prototype.clearPlayCallbackTimers = function () { this.playCallbackTimers.forEach(function (timer) { clearInterval(timer); }); this.playCallbackTimers = []; }; /** * Set the current frequency being played by the instrument. The closest valid * frequency between the frequency limits is used. * @param {number} frequency * The frequency to set. * @param {Highcharts.Dictionary} [frequencyLimits] * Object with maxFrequency and minFrequency * @return {void} */ Instrument.prototype.setFrequency = function (frequency, frequencyLimits) { var limits = frequencyLimits || {}, validFrequency = this.getValidFrequency(frequency, limits.min, limits.max); if (this.options.type === 'oscillator') { this.oscillatorPlay(validFrequency); } }; /** * Play oscillator instrument. * @private * @param {number} frequency - The frequency to play. */ Instrument.prototype.oscillatorPlay = function (frequency) { if (!this.oscillatorStarted) { this.oscillator.start(); this.oscillatorStarted = true; } this.oscillator.frequency.setValueAtTime(frequency, H.audioContext.currentTime); }; /** * Prepare instrument before playing. Resumes the audio context and starts the * oscillator. * @private */ Instrument.prototype.preparePlay = function () { this.setGain(0.001); if (H.audioContext.state === 'suspended') { H.audioContext.resume(); } if (this.oscillator && !this.oscillatorStarted) { this.oscillator.start(); this.oscillatorStarted = true; } }; /** * Play the instrument according to options. * * @sample highcharts/sonification/instrument/ * Using Instruments directly * @sample highcharts/sonification/instrument-advanced/ * Using callbacks for instrument parameters * * @function Highcharts.Instrument#play * * @param {Highcharts.InstrumentPlayOptionsObject} options * Options for the playback of the instrument. * * @return {void} */ Instrument.prototype.play = function (options) { var instrument = this, duration = options.duration || 0, // Set a value, or if it is a function, set it continously as a timer. // Pass in the value/function to set, the setter function, and any // additional data to pass through to the setter function. setOrStartTimer = function (value, setter, setterData) { var target = options.duration, currentDurationIx = 0, callbackInterval = instrument.options.playCallbackInterval; if (typeof value === 'function') { var timer = setInterval(function () { currentDurationIx++; var curTime = (currentDurationIx * callbackInterval / target); if (curTime >= 1) { instrument[setter](value(1), setterData); clearInterval(timer); } else { instrument[setter](value(curTime), setterData); } }, callbackInterval); instrument.playCallbackTimers.push(timer); } else { instrument[setter](value, setterData); } }; if (!instrument.id) { // No audio support - do nothing return; } // If the AudioContext is suspended we have to resume it before playing if (H.audioContext.state === 'suspended' || this.oscillator && !this.oscillatorStarted) { instrument.preparePlay(); // Try again in 10ms setTimeout(function () { instrument.play(options); }, 10); return; } // Clear any existing play timers if (instrument.playCallbackTimers.length) { instrument.clearPlayCallbackTimers(); } // Clear any gain ramps instrument.cancelGainRamp(); // Clear stop oscillator timer if (instrument.stopOscillatorTimeout) { clearTimeout(instrument.stopOscillatorTimeout); delete instrument.stopOscillatorTimeout; } // If a note is playing right now, clear the stop timeout, and call the // callback. if (instrument.stopTimeout) { clearTimeout(instrument.stopTimeout); delete instrument.stopTimeout; if (instrument.stopCallback) { // We have a callback for the play we are interrupting. We do not // allow this callback to start a new play, because that leads to // chaos. We pass in 'cancelled' to indicate that this note did not // finish, but still stopped. instrument._play = instrument.play; instrument.play = function () { }; instrument.stopCallback('cancelled'); instrument.play = instrument._play; } } // Stop the note without fadeOut if the duration is too short to hear the // note otherwise. var immediate = duration < H.sonification.fadeOutDuration + 20; // Stop the instrument after the duration of the note instrument.stopCallback = options.onEnd; var onStop = function () { delete instrument.stopTimeout; instrument.stop(immediate); }; if (duration) { instrument.stopTimeout = setTimeout(onStop, immediate ? duration : duration - H.sonification.fadeOutDuration); // Play the note setOrStartTimer(options.frequency, 'setFrequency', { minFrequency: options.minFrequency, maxFrequency: options.maxFrequency }); // Set the volume and panning setOrStartTimer(pick(options.volume, 1), 'setGain', 4); // Slight ramp setOrStartTimer(pick(options.pan, 0), 'setPan'); } else { // No note duration, so just stop immediately onStop(); } }; /** * Mute an instrument that is playing. If the instrument is not currently * playing, this function does nothing. * * @function Highcharts.Instrument#mute */ Instrument.prototype.mute = function () { this.setGain(0.0001, H.sonification.fadeOutDuration * 0.8); }; /** * Stop the instrument playing. * * @function Highcharts.Instrument#stop * * @param {boolean} immediately * Whether to do the stop immediately or fade out. * * @param {Function} [onStopped] * Callback function to be called when the stop is completed. * * @param {*} [callbackData] * Data to send to the onEnd callback functions. * * @return {void} */ Instrument.prototype.stop = function (immediately, onStopped, callbackData) { var instr = this, reset = function () { // Remove timeout reference if (instr.stopOscillatorTimeout) { delete instr.stopOscillatorTimeout; } // The oscillator may have stopped in the meantime here, so allow // this function to fail if so. try { instr.oscillator.stop(); } catch (e) { // silent error } instr.oscillator.disconnect(instr.gainNode); // We need a new oscillator in order to restart it instr.initOscillator(instr.options.oscillator); // Done stopping, call the callback from the stop if (onStopped) { onStopped(callbackData); } // Call the callback for the play we finished if (instr.stopCallback) { instr.stopCallback(callbackData); } }; // Clear any existing timers if (instr.playCallbackTimers.length) { instr.clearPlayCallbackTimers(); } if (instr.stopTimeout) { clearTimeout(instr.stopTimeout); } if (immediately) { instr.setGain(0); reset(); } else { instr.mute(); // Stop the oscillator after the mute fade-out has finished instr.stopOscillatorTimeout = setTimeout(reset, H.sonification.fadeOutDuration + 100); } }; export default Instrument;