Instrument.js 18 KB

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