Instrument.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  1. /* *
  2. *
  3. * (c) 2009-2019 Øystein Moseng
  4. *
  5. * Instrument class for sonification module.
  6. *
  7. * License: www.highcharts.com/license
  8. *
  9. * */
  10. /**
  11. * A set of options for the Instrument class.
  12. *
  13. * @requires module:modules/sonification
  14. *
  15. * @interface Highcharts.InstrumentOptionsObject
  16. *//**
  17. * The type of instrument. Currently only `oscillator` is supported. Defaults
  18. * to `oscillator`.
  19. * @name Highcharts.InstrumentOptionsObject#type
  20. * @type {string|undefined}
  21. *//**
  22. * The unique ID of the instrument. Generated if not supplied.
  23. * @name Highcharts.InstrumentOptionsObject#id
  24. * @type {string|undefined}
  25. *//**
  26. * When using functions to determine frequency or other parameters during
  27. * playback, this options specifies how often to call the callback functions.
  28. * Number given in milliseconds. Defaults to 20.
  29. * @name Highcharts.InstrumentOptionsObject#playCallbackInterval
  30. * @type {number|undefined}
  31. *//**
  32. * A list of allowed frequencies for this instrument. If trying to play a
  33. * frequency not on this list, the closest frequency will be used. Set to `null`
  34. * to allow all frequencies to be used. Defaults to `null`.
  35. * @name Highcharts.InstrumentOptionsObject#allowedFrequencies
  36. * @type {Array<number>|undefined}
  37. *//**
  38. * Options specific to oscillator instruments.
  39. * @name Highcharts.InstrumentOptionsObject#oscillator
  40. * @type {Highcharts.OscillatorOptionsObject|undefined}
  41. */
  42. /**
  43. * Options for playing an instrument.
  44. *
  45. * @requires module:modules/sonification
  46. *
  47. * @interface Highcharts.InstrumentPlayOptionsObject
  48. *//**
  49. * The frequency of the note to play. Can be a fixed number, or a function. The
  50. * function receives one argument: the relative time of the note playing (0
  51. * being the start, and 1 being the end of the note). It should return the
  52. * frequency number for each point in time. The poll interval of this function
  53. * is specified by the Instrument.playCallbackInterval option.
  54. * @name Highcharts.InstrumentPlayOptionsObject#frequency
  55. * @type {number|Function}
  56. *//**
  57. * The duration of the note in milliseconds.
  58. * @name Highcharts.InstrumentPlayOptionsObject#duration
  59. * @type {number}
  60. *//**
  61. * The minimum frequency to allow. If the instrument has a set of allowed
  62. * frequencies, the closest frequency is used by default. Use this option to
  63. * stop too low frequencies from being used.
  64. * @name Highcharts.InstrumentPlayOptionsObject#minFrequency
  65. * @type {number|undefined}
  66. *//**
  67. * The maximum frequency to allow. If the instrument has a set of allowed
  68. * frequencies, the closest frequency is used by default. Use this option to
  69. * stop too high frequencies from being used.
  70. * @name Highcharts.InstrumentPlayOptionsObject#maxFrequency
  71. * @type {number|undefined}
  72. *//**
  73. * The volume of the instrument. Can be a fixed number between 0 and 1, or a
  74. * function. The function receives one argument: the relative time of the note
  75. * playing (0 being the start, and 1 being the end of the note). It should
  76. * return the volume for each point in time. The poll interval of this function
  77. * is specified by the Instrument.playCallbackInterval option. Defaults to 1.
  78. * @name Highcharts.InstrumentPlayOptionsObject#volume
  79. * @type {number|Function|undefined}
  80. *//**
  81. * The panning of the instrument. Can be a fixed number between -1 and 1, or a
  82. * function. The function receives one argument: the relative time of the note
  83. * playing (0 being the start, and 1 being the end of the note). It should
  84. * return the panning value for each point in time. The poll interval of this
  85. * function is specified by the Instrument.playCallbackInterval option.
  86. * Defaults to 0.
  87. * @name Highcharts.InstrumentPlayOptionsObject#pan
  88. * @type {number|Function|undefined}
  89. *//**
  90. * Callback function to be called when the play is completed.
  91. * @name Highcharts.InstrumentPlayOptionsObject#onEnd
  92. * @type {Function|undefined}
  93. */
  94. /**
  95. * @requires module:modules/sonification
  96. *
  97. * @interface Highcharts.OscillatorOptionsObject
  98. *//**
  99. * The waveform shape to use for oscillator instruments. Defaults to `sine`.
  100. * @name Highcharts.OscillatorOptionsObject#waveformShape
  101. * @type {string|undefined}
  102. */
  103. 'use strict';
  104. import H from '../../parts/Globals.js';
  105. // Default options for Instrument constructor
  106. var defaultOptions = {
  107. type: 'oscillator',
  108. playCallbackInterval: 20,
  109. oscillator: {
  110. waveformShape: 'sine'
  111. }
  112. };
  113. /**
  114. * The Instrument class. Instrument objects represent an instrument capable of
  115. * playing a certain pitch for a specified duration.
  116. *
  117. * @sample highcharts/sonification/instrument/
  118. * Using Instruments directly
  119. * @sample highcharts/sonification/instrument-advanced/
  120. * Using callbacks for instrument parameters
  121. *
  122. * @requires module:modules/sonification
  123. *
  124. * @class
  125. * @name Highcharts.Instrument
  126. *
  127. * @param {Highcharts.InstrumentOptionsObject} options
  128. * Options for the instrument instance.
  129. */
  130. function Instrument(options) {
  131. this.init(options);
  132. }
  133. Instrument.prototype.init = function (options) {
  134. if (!this.initAudioContext()) {
  135. H.error(29);
  136. return;
  137. }
  138. this.options = H.merge(defaultOptions, options);
  139. this.id = this.options.id = options && options.id || H.uniqueKey();
  140. // Init the audio nodes
  141. var ctx = H.audioContext;
  142. this.gainNode = ctx.createGain();
  143. this.setGain(0);
  144. this.panNode = ctx.createStereoPanner && ctx.createStereoPanner();
  145. if (this.panNode) {
  146. this.setPan(0);
  147. this.gainNode.connect(this.panNode);
  148. this.panNode.connect(ctx.destination);
  149. } else {
  150. this.gainNode.connect(ctx.destination);
  151. }
  152. // Oscillator initialization
  153. if (this.options.type === 'oscillator') {
  154. this.initOscillator(this.options.oscillator);
  155. }
  156. // Init timer list
  157. this.playCallbackTimers = [];
  158. };
  159. /**
  160. * Return a copy of an instrument. Only one instrument instance can play at a
  161. * time, so use this to get a new copy of the instrument that can play alongside
  162. * it. The new instrument copy will receive a new ID unless one is supplied in
  163. * options.
  164. *
  165. * @function Highcharts.Instrument#copy
  166. *
  167. * @param {Highcharts.InstrumentOptionsObject} [options]
  168. * Options to merge in for the copy.
  169. *
  170. * @return {Highcharts.Instrument}
  171. * A new Instrument instance with the same options.
  172. */
  173. Instrument.prototype.copy = function (options) {
  174. return new Instrument(H.merge(this.options, { id: null }, options));
  175. };
  176. /**
  177. * Init the audio context, if we do not have one.
  178. * @private
  179. * @return {boolean} True if successful, false if not.
  180. */
  181. Instrument.prototype.initAudioContext = function () {
  182. var Context = H.win.AudioContext || H.win.webkitAudioContext,
  183. hasOldContext = !!H.audioContext;
  184. if (Context) {
  185. H.audioContext = H.audioContext || new Context();
  186. if (
  187. !hasOldContext &&
  188. H.audioContext &&
  189. H.audioContext.state === 'running'
  190. ) {
  191. H.audioContext.suspend(); // Pause until we need it
  192. }
  193. return !!(
  194. H.audioContext &&
  195. H.audioContext.createOscillator &&
  196. H.audioContext.createGain
  197. );
  198. }
  199. return false;
  200. };
  201. /**
  202. * Init an oscillator instrument.
  203. * @private
  204. * @param {object} oscillatorOptions - The oscillator options passed to
  205. * Highcharts.Instrument#init.
  206. */
  207. Instrument.prototype.initOscillator = function (options) {
  208. var ctx = H.audioContext;
  209. this.oscillator = ctx.createOscillator();
  210. this.oscillator.type = options.waveformShape;
  211. this.oscillator.connect(this.gainNode);
  212. this.oscillatorStarted = false;
  213. };
  214. /**
  215. * Set pan position.
  216. * @private
  217. * @param {number} panValue - The pan position to set for the instrument.
  218. */
  219. Instrument.prototype.setPan = function (panValue) {
  220. if (this.panNode) {
  221. this.panNode.pan.setValueAtTime(panValue, H.audioContext.currentTime);
  222. }
  223. };
  224. /**
  225. * Set gain level. A maximum of 1.2 is allowed before we emit a warning. The
  226. * actual volume is not set above this level regardless of input.
  227. * @private
  228. * @param {number} gainValue - The gain level to set for the instrument.
  229. * @param {number} [rampTime=0] - Gradually change the gain level, time given in
  230. * milliseconds.
  231. */
  232. Instrument.prototype.setGain = function (gainValue, rampTime) {
  233. if (this.gainNode) {
  234. if (gainValue > 1.2) {
  235. console.warn( // eslint-disable-line
  236. 'Highcharts sonification warning: ' +
  237. 'Volume of instrument set too high.'
  238. );
  239. gainValue = 1.2;
  240. }
  241. if (rampTime) {
  242. this.gainNode.gain.setValueAtTime(
  243. this.gainNode.gain.value, H.audioContext.currentTime
  244. );
  245. this.gainNode.gain.linearRampToValueAtTime(
  246. gainValue,
  247. H.audioContext.currentTime + rampTime / 1000
  248. );
  249. } else {
  250. this.gainNode.gain.setValueAtTime(
  251. gainValue, H.audioContext.currentTime
  252. );
  253. }
  254. }
  255. };
  256. /**
  257. * Cancel ongoing gain ramps.
  258. * @private
  259. */
  260. Instrument.prototype.cancelGainRamp = function () {
  261. if (this.gainNode) {
  262. this.gainNode.gain.cancelScheduledValues(0);
  263. }
  264. };
  265. /**
  266. * Get the closest valid frequency for this instrument.
  267. * @private
  268. * @param {number} frequency - The target frequency.
  269. * @param {number} [min] - Minimum frequency to return.
  270. * @param {number} [max] - Maximum frequency to return.
  271. * @return {number} The closest valid frequency to the input frequency.
  272. */
  273. Instrument.prototype.getValidFrequency = function (frequency, min, max) {
  274. var validFrequencies = this.options.allowedFrequencies,
  275. maximum = H.pick(max, Infinity),
  276. minimum = H.pick(min, -Infinity);
  277. return !validFrequencies || !validFrequencies.length ?
  278. // No valid frequencies for this instrument, return the target
  279. frequency :
  280. // Use the valid frequencies and return the closest match
  281. validFrequencies.reduce(function (acc, cur) {
  282. // Find the closest allowed value
  283. return Math.abs(cur - frequency) < Math.abs(acc - frequency) &&
  284. cur < maximum && cur > minimum ?
  285. cur : acc;
  286. }, Infinity);
  287. };
  288. /**
  289. * Clear existing play callback timers.
  290. * @private
  291. */
  292. Instrument.prototype.clearPlayCallbackTimers = function () {
  293. this.playCallbackTimers.forEach(function (timer) {
  294. clearInterval(timer);
  295. });
  296. this.playCallbackTimers = [];
  297. };
  298. /**
  299. * Set the current frequency being played by the instrument. The closest valid
  300. * frequency between the frequency limits is used.
  301. * @param {number} frequency - The frequency to set.
  302. * @param {object} [frequencyLimits] - Object with maxFrequency and minFrequency
  303. */
  304. Instrument.prototype.setFrequency = function (frequency, frequencyLimits) {
  305. var limits = frequencyLimits || {},
  306. validFrequency = this.getValidFrequency(
  307. frequency, limits.min, limits.max
  308. );
  309. if (this.options.type === 'oscillator') {
  310. this.oscillatorPlay(validFrequency);
  311. }
  312. };
  313. /**
  314. * Play oscillator instrument.
  315. * @private
  316. * @param {number} frequency - The frequency to play.
  317. */
  318. Instrument.prototype.oscillatorPlay = function (frequency) {
  319. if (!this.oscillatorStarted) {
  320. this.oscillator.start();
  321. this.oscillatorStarted = true;
  322. }
  323. this.oscillator.frequency.setValueAtTime(
  324. frequency, H.audioContext.currentTime
  325. );
  326. };
  327. /**
  328. * Prepare instrument before playing. Resumes the audio context and starts the
  329. * oscillator.
  330. * @private
  331. */
  332. Instrument.prototype.preparePlay = function () {
  333. this.setGain(0.001);
  334. if (H.audioContext.state === 'suspended') {
  335. H.audioContext.resume();
  336. }
  337. if (this.oscillator && !this.oscillatorStarted) {
  338. this.oscillator.start();
  339. this.oscillatorStarted = true;
  340. }
  341. };
  342. /**
  343. * Play the instrument according to options.
  344. *
  345. * @sample highcharts/sonification/instrument/
  346. * Using Instruments directly
  347. * @sample highcharts/sonification/instrument-advanced/
  348. * Using callbacks for instrument parameters
  349. *
  350. * @function Highcharts.Instrument#play
  351. *
  352. * @param {Highcharts.InstrumentPlayOptionsObject} options
  353. * Options for the playback of the instrument.
  354. */
  355. Instrument.prototype.play = function (options) {
  356. var instrument = this,
  357. duration = options.duration || 0,
  358. // Set a value, or if it is a function, set it continously as a timer.
  359. // Pass in the value/function to set, the setter function, and any
  360. // additional data to pass through to the setter function.
  361. setOrStartTimer = function (value, setter, setterData) {
  362. var target = options.duration,
  363. currentDurationIx = 0,
  364. callbackInterval = instrument.options.playCallbackInterval;
  365. if (typeof value === 'function') {
  366. var timer = setInterval(function () {
  367. currentDurationIx++;
  368. var curTime = currentDurationIx * callbackInterval / target;
  369. if (curTime >= 1) {
  370. instrument[setter](value(1), setterData);
  371. clearInterval(timer);
  372. } else {
  373. instrument[setter](value(curTime), setterData);
  374. }
  375. }, callbackInterval);
  376. instrument.playCallbackTimers.push(timer);
  377. } else {
  378. instrument[setter](value, setterData);
  379. }
  380. };
  381. if (!instrument.id) {
  382. // No audio support - do nothing
  383. return;
  384. }
  385. // If the AudioContext is suspended we have to resume it before playing
  386. if (
  387. H.audioContext.state === 'suspended' ||
  388. this.oscillator && !this.oscillatorStarted
  389. ) {
  390. instrument.preparePlay();
  391. // Try again in 10ms
  392. setTimeout(function () {
  393. instrument.play(options);
  394. }, 10);
  395. return;
  396. }
  397. // Clear any existing play timers
  398. if (instrument.playCallbackTimers.length) {
  399. instrument.clearPlayCallbackTimers();
  400. }
  401. // Clear any gain ramps
  402. instrument.cancelGainRamp();
  403. // Clear stop oscillator timer
  404. if (instrument.stopOscillatorTimeout) {
  405. clearTimeout(instrument.stopOscillatorTimeout);
  406. delete instrument.stopOscillatorTimeout;
  407. }
  408. // If a note is playing right now, clear the stop timeout, and call the
  409. // callback.
  410. if (instrument.stopTimeout) {
  411. clearTimeout(instrument.stopTimeout);
  412. delete instrument.stopTimeout;
  413. if (instrument.stopCallback) {
  414. // We have a callback for the play we are interrupting. We do not
  415. // allow this callback to start a new play, because that leads to
  416. // chaos. We pass in 'cancelled' to indicate that this note did not
  417. // finish, but still stopped.
  418. instrument._play = instrument.play;
  419. instrument.play = function () { };
  420. instrument.stopCallback('cancelled');
  421. instrument.play = instrument._play;
  422. }
  423. }
  424. // Stop the note without fadeOut if the duration is too short to hear the
  425. // note otherwise.
  426. var immediate = duration < H.sonification.fadeOutDuration + 20;
  427. // Stop the instrument after the duration of the note
  428. instrument.stopCallback = options.onEnd;
  429. var onStop = function () {
  430. delete instrument.stopTimeout;
  431. instrument.stop(immediate);
  432. };
  433. if (duration) {
  434. instrument.stopTimeout = setTimeout(
  435. onStop,
  436. immediate ? duration :
  437. duration - H.sonification.fadeOutDuration
  438. );
  439. // Play the note
  440. setOrStartTimer(options.frequency, 'setFrequency', null, {
  441. minFrequency: options.minFrequency,
  442. maxFrequency: options.maxFrequency
  443. });
  444. // Set the volume and panning
  445. setOrStartTimer(H.pick(options.volume, 1), 'setGain', 4); // Slight ramp
  446. setOrStartTimer(H.pick(options.pan, 0), 'setPan');
  447. } else {
  448. // No note duration, so just stop immediately
  449. onStop();
  450. }
  451. };
  452. /**
  453. * Mute an instrument that is playing. If the instrument is not currently
  454. * playing, this function does nothing.
  455. *
  456. * @function Highcharts.Instrument#mute
  457. */
  458. Instrument.prototype.mute = function () {
  459. this.setGain(0.0001, H.sonification.fadeOutDuration * 0.8);
  460. };
  461. /**
  462. * Stop the instrument playing.
  463. *
  464. * @function Highcharts.Instrument#stop
  465. *
  466. * @param {boolean} immediately
  467. * Whether to do the stop immediately or fade out.
  468. *
  469. * @param {Function} onStopped
  470. * Callback function to be called when the stop is completed.
  471. *
  472. * @param {*} callbackData
  473. * Data to send to the onEnd callback functions.
  474. */
  475. Instrument.prototype.stop = function (immediately, onStopped, callbackData) {
  476. var instr = this,
  477. reset = function () {
  478. // Remove timeout reference
  479. if (instr.stopOscillatorTimeout) {
  480. delete instr.stopOscillatorTimeout;
  481. }
  482. // The oscillator may have stopped in the meantime here, so allow
  483. // this function to fail if so.
  484. try {
  485. instr.oscillator.stop();
  486. } catch (e) {}
  487. instr.oscillator.disconnect(instr.gainNode);
  488. // We need a new oscillator in order to restart it
  489. instr.initOscillator(instr.options.oscillator);
  490. // Done stopping, call the callback from the stop
  491. if (onStopped) {
  492. onStopped(callbackData);
  493. }
  494. // Call the callback for the play we finished
  495. if (instr.stopCallback) {
  496. instr.stopCallback(callbackData);
  497. }
  498. };
  499. // Clear any existing timers
  500. if (instr.playCallbackTimers.length) {
  501. instr.clearPlayCallbackTimers();
  502. }
  503. if (instr.stopTimeout) {
  504. clearTimeout(instr.stopTimeout);
  505. }
  506. if (immediately) {
  507. instr.setGain(0);
  508. reset();
  509. } else {
  510. instr.mute();
  511. // Stop the oscillator after the mute fade-out has finished
  512. instr.stopOscillatorTimeout =
  513. setTimeout(reset, H.sonification.fadeOutDuration + 100);
  514. }
  515. };
  516. export default Instrument;