Instrument.js 19 KB

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