diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..988fa42 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,25 @@ +# Changelog + +## 1.1.0 — 2016.03.24 + +### New features +* Allow notes to be sustained. [#12](https://github.com/sunyatasattva/overtones/issues/12) +* Sound details show intervals between sustained notes. +* Allows decimals in frequency input. [#35](https://github.com/sunyatasattva/overtones/issues/35) +* Added frequency input shortcuts: ARROW UP/DOWN changes the frequency in increments of one; doing so while holding SHIFT, changes the frequency in increments of 10, holding ALT in increments of 0.1. [#33](https://github.com/sunyatasattva/overtones/issues/33) +* Adds a secret Easter Egg. :see_no_evil: + +### Changes +* Changed the way notes name appear in the detail section. [#30](https://github.com/sunyatasattva/overtones/issues/30) +* Changed *"Issue"* text with *"Feedback"*. + +### Fixes +* Correct accidental symbols (showing ♯ and♭instead of # and b). [#32](https://github.com/sunyatasattva/overtones/issues/32) +* Enforces frequency input validation. [#26](https://github.com/sunyatasattva/overtones/issues/26) + +### Behind the hood +* Support for ES6 +* Functions to calculate Equal Temperament notes given any reference note and any kind of ET. As such, removed hard coded ET frequency data. [#16](https://github.com/sunyatasattva/overtones/issues/16) +* Refactored octave reduction function to allow for independent calculations + +### [Full changelog](https://github.com/sunyatasattva/overtones/compare/1.0.0...1.1.0) \ No newline at end of file diff --git a/assets/data/12-tet.json b/assets/data/12-tet.json deleted file mode 100644 index 8dff4dd..0000000 --- a/assets/data/12-tet.json +++ /dev/null @@ -1,131 +0,0 @@ -{ - "c0": 16.351, - "c#0": 17.324, - "d0": 18.354, - "d#0": 19.445, - "e0": 20.601, - "f0": 21.827, - "f#0": 23.124, - "g0": 24.499, - "g#0": 25.956, - "a0": 27.5, - "a#0": 29.135, - "b0": 30.868, - - "c1": 32.703, - "c#1": 34.648, - "d1": 36.708, - "d#1": 38.891, - "e1": 41.203, - "f1": 43.654, - "f#1": 46.249, - "g1": 48.999, - "g#1": 51.913, - "a1": 55, - "a#1": 58.27, - "b1": 61.735, - - "c2": 65.406, - "c#2": 69.296, - "d2": 73.416, - "d#2": 77.782, - "e2": 82.407, - "f2": 87.307, - "f#2": 92.499, - "g2": 97.999, - "g#2": 103.826, - "a2": 110, - "a#2": 116.541, - "b2": 123.471, - - "c3": 130.813, - "c#3": 138.591, - "d3": 146.832, - "d#3": 155.563, - "e3": 164.814, - "f3": 174.614, - "f#3": 184.997, - "g3": 195.998, - "g#3": 207.652, - "a3": 220, - "a#3": 233.082, - "b3": 246.942, - - "c4": 261.626, - "c#4": 277.183, - "d4": 293.665, - "d#4": 311.127, - "e4": 329.628, - "f4": 349.228, - "f#4": 369.994, - "g4": 391.995, - "g#4": 415.305, - "a4": 440, - "a#4": 466.164, - "b4": 493.883, - - "c5": 523.251, - "c#5": 554.365, - "d5": 587.33, - "d#5": 622.254, - "e5": 659.255, - "f5": 698.456, - "f#5": 739.989, - "g5": 783.991, - "g#5": 830.609, - "a5": 880, - "a#5": 932.328, - "b5": 987.767, - - "c6": 1046.502, - "c#6": 1108.731, - "d6": 1174.659, - "d#6": 1244.508, - "e6": 1318.51, - "f6": 1396.913, - "f#6": 1479.978, - "g6": 1567.982, - "g#6": 1661.219, - "a6": 1760, - "a#6": 1864.655, - "b6": 1975.533, - - "c7": 2093.005, - "c#7": 2217.461, - "d7": 2349.318, - "d#7": 2489.016, - "e7": 2637.021, - "f7": 2793.826, - "f#7": 2959.955, - "g7": 3135.964, - "g#7": 3322.438, - "a7": 3520, - "a#7": 3729.31, - "b7": 3951.066, - - "c8": 4186.009, - "c#8": 4434.922, - "d8": 4698.636, - "d#8": 4978.032, - "e8": 5274.042, - "f8": 5587.652, - "f#8": 5919.91, - "g8": 6271.928, - "g#8": 6644.876, - "a8": 7040, - "a#8": 7458.62, - "b8": 7902.132, - - "c9": 8372.018, - "c#9": 8869.844, - "d9": 9397.272, - "d#9": 9956.064, - "e9": 10548.084, - "f9": 11175.304, - "f#9": 11839.82, - "g9": 12543.856, - "g#9": 13289.752, - "a9": 14080, - "a#9": 14917.24, - "b9": 15804.264 -} diff --git a/assets/images/ico-sustain.svg b/assets/images/ico-sustain.svg new file mode 100644 index 0000000..6aca2b4 --- /dev/null +++ b/assets/images/ico-sustain.svg @@ -0,0 +1,25 @@ + + + diff --git a/assets/js/analytics.js b/assets/js/analytics.js index 4f11e2d..2f6d472 100644 --- a/assets/js/analytics.js +++ b/assets/js/analytics.js @@ -15,5 +15,8 @@ module.exports = function($){ e.idx = e.idx || null; window.dataLayer.push({ event: e.type, idx: e.idx, action: action, target: target }); - }); + }) + .on('overtones:play:all overtones:easteregg:share', function(e){ + window.dataLayer.push({ event: e.type }); + }); }; \ No newline at end of file diff --git a/assets/js/easter-egg.js b/assets/js/easter-egg.js new file mode 100644 index 0000000..198baf4 --- /dev/null +++ b/assets/js/easter-egg.js @@ -0,0 +1,63 @@ +var $ = require("jquery"); +var once = require("./lib/once"); +var Tones = require("./lib/tones"); + +function secretMusic(){ + var opts = { + attack: 75, + decay: 100 + }; + + // Equal temperament + Tones.playFrequency(780, opts) + .then( ()=>Tones.playFrequency(739, opts) ) + .then( ()=>Tones.playFrequency(622, opts) ) + .then( ()=>Tones.playFrequency(440, opts) ) + .then( ()=>Tones.playFrequency(415, opts) ) + .then( ()=>Tones.playFrequency(659, opts) ) + .then( ()=>Tones.playFrequency(830, opts) ) + .then( ()=>Tones.playFrequency(1046, opts) ) + + // Just intonation + Tones.playFrequency(780, opts) + .then( ()=>Tones.playFrequency(731.25, opts) ) + .then( ()=>Tones.playFrequency(624, opts) ) + .then( ()=>Tones.playFrequency(438, opts) ) + .then( ()=>Tones.playFrequency(416, opts) ) + .then( ()=>Tones.playFrequency(650, opts) ) + .then( ()=>Tones.playFrequency(832, opts) ) + .then( ()=>Tones.playFrequency(1040, opts) ) +} + +module.exports = { + init: function() { + $(".easter-egg-announcement .shepherd-cancel-link").on("click", function(e){ + e.preventDefault(); + + $(this).closest(".easter-egg-announcement").removeClass("show"); + }); + + $(document).on("overtones:play:all", function(){ + once(function(){ $(".easter-egg-announcement").addClass("show"); }, 'easterEggFound'); + + $("body").addClass("easter-egg"); + secretMusic(); + + $(".share-easter-egg").on("click", function(e){ + e.preventDefault(); + try { + FB.ui({ + method: "share", + href: "http://www.suonoterapia.org/overtones" + }, function(response){ + console.log(response); + $(".easter-egg-announcement").removeClass("shepherd-open"); + + $(document).trigger("overtones:easteregg:share") + }); + } + catch(e){ $(".easter-egg-announcement").removeClass("shepherd-open"); } + }); + }); + } +} \ No newline at end of file diff --git a/assets/js/lib/tones.js b/assets/js/lib/tones.js index 0c6d992..da94268 100644 --- a/assets/js/lib/tones.js +++ b/assets/js/lib/tones.js @@ -44,7 +44,8 @@ masterGain.connect(ctx.destination); * * @param {number} attack The amount of time the sound will take to reach full amplitude * @param {number} decay The amount of time for the sound to reach sustain amplitude after attack - * @param {number} sustain The duration of the sound is kept being played + * @param {number} sustain The duration of the sound is kept being played. If `sustain` is < 0, the + * sound will be played until manually stopped * @param {number} release The amount of time for the sound to fade out * * @return {Envelope} The envelope object, containing the gain node. @@ -131,6 +132,8 @@ Sound.prototype.play = function(){ self = this; this.oscillator.start(); + + this.isPlaying = true; /** * Using `setTargetAtTime` because `exponentialRampToValueAtTime` doesn't seem to work properly under @@ -151,10 +154,7 @@ Sound.prototype.play = function(){ this.envelope.node.gain .setTargetAtTime( this.envelope.volume, now + this.envelope.attack, this.envelope.decay / 5 ); - // @todo if sustain is null, note has to be stopped manually. Also document this. - if( this.envelope.sustain !== null ) { - - + if( this.envelope.sustain >= 0 ) { // Setting a "keyframe" for the volume to be kept until `sustain` seconds have passed // (plus all the rest) this.envelope.node.gain @@ -176,9 +176,15 @@ Sound.prototype.play = function(){ // Start the removal of the sound process after a little more than the sound duration to account for // the approximation. (To make sure that the sound doesn't get cut off while still audible) - setTimeout( function() { - self.stop(); - }, this.duration * 1250 ); + return new Promise(function(resolve, reject){ + let effectiveSoundDuration = self.envelope.attack + self.envelope.decay + self.envelope.sustain; + + setTimeout( ()=>resolve(self), effectiveSoundDuration * 1000 ); + + setTimeout( function() { + if( !self.isStopping ) self.stop(); + }, self.duration * 1250 ); + }); } return this; @@ -190,11 +196,17 @@ Sound.prototype.play = function(){ * @return {Sound} The Sound object that was removed */ Sound.prototype.remove = function(){ - this.oscillator.disconnect(this.envelope.node); - this.envelope.node.gain.cancelScheduledValues(ctx.currentTime); - this.envelope.node.disconnect(masterGain); + try { + this.oscillator.disconnect(this.envelope.node); + this.envelope.node.gain.cancelScheduledValues(ctx.currentTime); + this.envelope.node.disconnect(masterGain); + } + catch (e) { + console.trace(); + } - return sounds.splice( sounds.indexOf(this), 1 )[0]; + if( sounds.indexOf(this) !== -1 ) + return sounds.splice( sounds.indexOf(this), 1 )[0]; }; /** @@ -210,6 +222,27 @@ Sound.prototype.stop = function(){ return this.remove(); }; +/** + * Fades out a sound according to its release value. Useful for sustained sounds. + * + * @return void + */ +Sound.prototype.fadeOut = function(){ + var now = ctx.currentTime, + self = this; + + this.envelope.node.gain.setTargetAtTime( 0, now, this.envelope.release / 5 ); + this.isPlaying = false; + this.isStopping = true; + + return new Promise(function(resolve, reject){ + setTimeout( function() { + self.stop(); + resolve(self); + }, self.envelope.release * 1250 ); + }); +}; + /** * Calculates the interval in cents with another tone. * @@ -264,28 +297,7 @@ Sound.prototype.isOctaveOf = function(tone){ * @return {Sound} The original Sound object is returned */ Sound.prototype.reduceToSameOctaveAs = function(tone, excludeOctave){ - var ratio = this.frequency / tone.frequency; - - if( excludeOctave ) { - while( ratio < 1 || ratio >= 2 ){ - if( ratio < 1 ) - this.frequency = this.frequency * 2; - else - this.frequency = this.frequency / 2; - - ratio = this.frequency / tone.frequency; - } - } - else { - while( ratio <= 1 || ratio > 2 ){ - if( ratio <= 1 ) - this.frequency = this.frequency * 2; - else - this.frequency = this.frequency / 2; - - ratio = this.frequency / tone.frequency; - } - } + this.frequency = reduceToSameOctave(this, tone, excludeOctave); this.oscillator.frequency.setValueAtTime( this.frequency, ctx.currentTime ); @@ -348,6 +360,48 @@ function playFrequency(frequency, opts) { return thisSound.play(); } +/** + * Reduces the Sound pitch to a tone within an octave of the tone. + * + * @see {@link Sound.prototype.reduceToSameOctaveAs} + * @alias module:tones.reduceToSameOctave + * + * @param {Sound|Object} firstTone A Sound object (or an object containing a `frequency` property) + * @param {Sound|Object} referenceTone The first sound will adjust its frequency + * to the same octave as this tone + * @param {bool} excludeOctave If this option is `true`, the exact octave will be reduced + * to the unison of the original sound + * + * @return {Number} The frequency of the first sound within the same octave as the reference tone. + */ +function reduceToSameOctave(firstTone, referenceTone, excludeOctave){ + var targetFrequency = firstTone.frequency, + ratio = targetFrequency / referenceTone.frequency; + + if( excludeOctave ) { + while( ratio <= 0.5 || ratio >= 2 ){ + if( ratio <= 0.5 ) + targetFrequency = targetFrequency * 2; + else + targetFrequency = targetFrequency / 2; + + ratio = targetFrequency / referenceTone.frequency; + } + } + else { + while( ratio < 0.5 || ratio > 2 ){ + if( ratio < 0.5 ) + targetFrequency = targetFrequency * 2; + else + targetFrequency = targetFrequency / 2; + + ratio = targetFrequency / referenceTone.frequency; + } + } + + return targetFrequency; +}; + module.exports = { /** * The Audio Context where the module operates @@ -361,6 +415,7 @@ module.exports = { */ masterGain: masterGain, playFrequency: playFrequency, + reduceToSameOctave: reduceToSameOctave, /** * A list of currently active sounds for manipulation * @type {array} diff --git a/assets/js/lib/utils.js b/assets/js/lib/utils.js index 261988c..f067b07 100644 --- a/assets/js/lib/utils.js +++ b/assets/js/lib/utils.js @@ -59,6 +59,105 @@ module.exports = { logBase: function(base,n) { return Math.log(n) / Math.log(base); }, + /** + * Derives the note number in an equal tempered system given a reference frequency. + * + * @param {Number} frequency The frequency to check + * @param {Number} [referenceFrequency=440] A reference point for the Equal Tempered system + * @param {Int} [referencePoint=0] The point in the scale for the reference frequency. + * E.g. in MIDI A440 is 69, while in a regular + * piano the same note is in the 49th position. + * @param {Int} [semitones=12] The number of semitones in an octave + * @param {Bool} [round=true] Whether or not to round the note position + * + * @return {Number} The note position in relationship to the reference point + */ + /* jshint ignore:start */ // JsHint can't deal with this pro destructuring syntax + getEqualTemperedNoteNumber: function(frequency, + { referenceFrequency = 440, + referencePoint = 0, + semitones = 12, + round = true } = {} + ){ + let n = semitones * this.logBase(2, frequency / referenceFrequency) + + referencePoint; + + return round ? Math.round(n) : n; + }, + /* jshint ignore:end */ + /** + * Given a MIDI note number it returns the name for that note. + * + * Technically it would work with any 12 tone Equal Temperament reference + * as long as it starts on a C. + * + * @param {Number} n The MIDI number + * @param {Array} [pitchSet] An Array of Pitches to get the desired name + * of the note. + * + * @return {Object} The name of the note and the relative octave + */ + MIDIToName: function(n, pitchSet){ + let name, + octave; + + n = Math.round(n); + pitchSet = pitchSet ? this.pitchSort(pitchSet) : + this.pitchSet("P1 m2 M2 m3 M3 P4 4A P5 m6 M6 m7 M7", "C"); + + name = pitchSet[n % 12]; + octave = Math.floor(n / 12) - 1; + + return { name: name, octave: octave }; + }, + /** + * Transforms the decimals in a number into the difference in cents to the closest integer. + * + * @param {Number} n The number + * + * @return {Number} The cents (within -50 and +50) difference to the closest integer. + */ + decimalsToCents: function(n){ + let decimals = n % 1; + + return decimals > 0.5 ? -Math.round( (1 - decimals) * 100 ) : + Math.round(decimals * 100); + }, + /** + * Encodes the accidentals in a string with the correct HTML entity. + * + * @todo Currently doesn't correctly encode double/triple accidentals e.g. F## + * + * @param {string} str A string to check for accidentals + * + * @return {string} The correctly encoded accidental + */ + encodeAccidentals: function(str){ + if( str.match(/#/g) ) + return "♯"; + else if( str.match(/b/g) ) + return "♭"; + else + return ""; + }, + /** + * Finds the last element of an array which satisfies a given condition. + * + * @param {Array} arr The array to transverse + * @param {Function} matches A function that will be called to check + * if the current element satisfies a condition + * @param {Number} [pad=0] Skips this amount of elements from the right + * + * @return {mixed} The element which satisfies the given condition + */ + findLast: function(arr, matches, pad = 0){ + for( let i = arr.length - 1 - pad; i >= 0; i-- ) { + if( matches.call(arr[i]) ) + return arr[i]; + } + + return null; + }, /** * Transforms an rgb value into an hex value * @@ -91,5 +190,13 @@ module.exports = { /** * @see {@link https://lodash.com/docs#debounce|lodash.values} */ - values: require("lodash.values") + values: require("lodash.values"), + /** + * @see {@link https://github.com/danigb/tonal/tree/master/packages/pitch-set|pitch-set} + */ + pitchSet: require("pitch-set"), + /** + * @see {@link https://github.com/danigb/tonal/tree/master/packages/music-gamut|music-gamut.sort} + */ + pitchSort: require("music-gamut").sort }; \ No newline at end of file diff --git a/assets/js/overtones.js b/assets/js/overtones.js index 6e45af3..8dbf14a 100644 --- a/assets/js/overtones.js +++ b/assets/js/overtones.js @@ -14,12 +14,15 @@ var $ = jQuery = require("jquery"); require("velocity-animate"); require("jquery.animate-number"); -var utils = require("./lib/utils.js"), - intervals = require("../data/intervals.json"), - tones = require("./lib/tones.js"), - $ = require("jquery"); +var utils = require("./lib/utils.js"), + intervals = require("../data/intervals.json"), + tones = require("./lib/tones.js"), + $ = require("jquery"); -var tTET = require("../data/12-tet.json"); +// Partially applied function to get the names of pitches +// @see {@link https://github.com/sunyatasattva/overtones/issues/30} +const PITCH_SET = utils.pitchSet("P1 m2 M2 m3 M3 P4 4A P5 m6 M6 m7 M7"), + MIDI_A4 = 69; /** * Will hide the elements if this function is not called again for 5 seconds @@ -32,6 +35,48 @@ var hideElementWhenIdle = utils.debounce(function($element){ $element.removeClass("visible"); }, 5000); +/** + * Given a frequency, it gets the closest A440 12T Equal tempered note. + * + * It outputs the name according to a specific pitch set (@see {@link PITCH_SET}). + * + * @param {Number} frequency The frequency to check + * @param {String} [tonic="C"] The tonic to calculate the scale + * + * @return {Object} The details containing the note name, octave, accidentals, + * encoded accidentals, and the cents difference to the closest + * equal tempered note + */ +function frequencyToNoteDetails(frequency, tonic = "C") { + let noteNumber = utils.getEqualTemperedNoteNumber( frequency, + { referencePoint: MIDI_A4, round: false } ), + noteName = utils.MIDIToName( noteNumber, PITCH_SET(tonic) ); + + noteName.centsDifference = utils.decimalsToCents(noteNumber); + noteName.accidentals = utils.encodeAccidentals(noteName.name); + + return noteName; +} + +/** + * Fades out all playing sounds + * + * @return void + */ +function stopAllPlayingSounds() { + tones.sounds.forEach(function(sound){ + if(sound.isPlaying) + sound.fadeOut(); + }); + + $(".overtone") + .removeData("isPlaying") + .removeClass("is-playing"); + + if( $("body").hasClass("easter-egg") ) + $("body").removeClass("easter-egg") // @todo decouple this from here +} + /** * Animates an overtone for a given duration * @@ -47,7 +92,7 @@ function animateOvertone(el, duration) { // why we are reversing the array $circles = $( $spacesGroup.find("g").get().reverse() ), numbersOfCircles = $circles.length, - originalFill = utils.rgbToHex( $spacesGroup.css("fill") ), + originalFill = "#FFFFFF", fillColor = "#FFE08D"; // If it's already animating, it won't animate again @@ -98,15 +143,9 @@ function animateOvertone(el, duration) { */ function showIntervalDifferenceWithTuning(tone, tuning) { var tuning = tuning || "12-TET", // @todo this doesn't do anything currently, placeholder - frequencies = utils.values(tTET), - closestFrequency = utils.binarySearch(tone.frequency, frequencies), - centsDifference = tone.intervalInCents( { frequency: closestFrequency } ), - note; - - note = utils.findKey( tTET, - function(frequency){ - return frequency === closestFrequency; - } ).split(/(\d)/); + + note = frequencyToNoteDetails(tone.frequency, App.baseTone.name), + centsDifference = note.centsDifference; $("#note-frequency") // Set the base number from which to animate to the current frequency @@ -120,11 +159,13 @@ function showIntervalDifferenceWithTuning(tone, tuning) { $target.text(flooredNumber + " Hz"); } }, 200); - - // Fills up the note name - $("#note-name").text( note[0] ); + + // Fills up the note name (disregarding the accidental) + $("#note-name").text( note.name[0] ); + // Fills up the note accidentals + $("#note-accidentals").html( note.accidentals ); // Fills up the note octave - $("#note sub").text( note[1] ); + $("#note sub").text( note.octave ); // Fills up the bar indicating the cents difference: a difference of 0 // would have the pointer at the center, with the extremes being 50 @@ -141,7 +182,7 @@ function showIntervalDifferenceWithTuning(tone, tuning) { $(".tuning .cent-bar").css("left", 50 + centsDifference / 2 + "%"); - console.log(note[0], closestFrequency, centsDifference); + console.log(note.name, centsDifference); } /** @@ -165,7 +206,12 @@ function getIntervalName(a, b) { intervalName = intervals[ ratio[1] + "/" + ratio[2] ].name; } catch(e) { - intervalName = "Unknown interval"; + try { + intervalName = intervals[ ratio[2] + "/" + ratio[1] ].name; + } + catch(e) { + intervalName = "Unknown interval"; + } } return intervalName; @@ -180,9 +226,12 @@ function getIntervalName(a, b) { * @return {string} The interval name; */ function showIntervalName(firstTone, secondTone) { - var ratio = utils.fraction(secondTone.frequency/firstTone.frequency, 999), + var octaveReducedFrequency = tones.reduceToSameOctave(secondTone, firstTone), + ratio = utils.fraction(octaveReducedFrequency/firstTone.frequency, 999), intervalName = getIntervalName(ratio), - centsDifference = Math.abs( firstTone.intervalInCents(secondTone) ); + centsDifference = Math.abs( firstTone.intervalInCents( + { frequency: octaveReducedFrequency } + ) ); $("#interval-name").text(intervalName); @@ -352,11 +401,23 @@ function toggleOption(option) { * @return {number} The new frequency */ function updateBaseFrequency(val, mute) { - var frequency = Math.floor(val); - App.baseTone = tones.createSound(frequency); - $("#base, #base-detail").val(frequency); + const $base = $("#base"); + + // Enforce minimum and maximums + if( val > +$base.attr("max") ) + val = +$base.attr("max"); + else if( val < +$base.attr("min") ) + val = +$base.attr("min"); + + tones.sounds[ tones.sounds.indexOf(App.baseTone) ].remove(); // Remove the base tone + stopAllPlayingSounds(); + + App.baseTone = tones.createSound(val); + App.baseTone.name = frequencyToNoteDetails(val).name; + + $("#base, #base-detail").val(val); - if( !mute ) + if( !mute && !App.options.sustain ) $("#overtone-1").click(); $(document).trigger({ @@ -364,7 +425,7 @@ function updateBaseFrequency(val, mute) { details: { optionName: "baseFrequency", optionValue: val } }); - return frequency; + return val; } /** @@ -404,18 +465,52 @@ function updateVolume(val, mute) { */ function overtoneClickHandler() { var idx = $(this).index() + 1, + soundPlaying = $(this).data("isPlaying"), self = this, noteFrequency = idx * App.baseTone.frequency, - tone = tones.createSound(noteFrequency); - - if( App.options.octaveReduction ) + tone; + + if(soundPlaying){ + soundPlaying.fadeOut(); + $(this) + .removeData("isPlaying") + .removeClass("is-playing"); + + if( $("body").hasClass("easter-egg") ) + $("body").removeClass("easter-egg") // @todo decouple this from here + return; + } + + tone = tones.createSound(noteFrequency); + tone.fromClick = true; + + if( App.options.octaveReduction && tone.frequency !== App.baseTone.frequency ) tone.reduceToSameOctaveAs(App.baseTone); + + if( App.options.sustain ){ + let lastSoundPlaying = utils.findLast(tones.sounds, function(){ return this.isPlaying; }, 1); + tone.envelope.sustain = -1; + $(this).data("isPlaying", tone); + $(this).addClass("is-playing"); // For styling purposes + + // Show the interval between this sound and the sound before it which + // is still playing. + if( lastSoundPlaying ) { + fillSoundDetails([ + lastSoundPlaying, + tone + ]); + } + else fillSoundDetails(tone); + + if( $(".overtone.is-playing").length === $(".overtone").length ) + $(document).trigger("overtones:play:all"); + } + else fillSoundDetails(tone); tone.play(); animateOvertone( self, tone.envelope ); - - fillSoundDetails(tone); $(document).trigger({ type: "overtones:play", @@ -489,6 +584,46 @@ function axisClickHandler() { }); } +/** + * Base Input handler + * + * Allows usage of arrow up and arrow down keys + * to easy modify the value of the input. Using SHIFT allows + * for increments of 10, while ALT for increments of 0.1. + * + * @return void + */ +function baseInputHandler(e){ + const ARROW_UP = 38, + ARROW_DOWN = 40, + $this = $(this); + + let currentValue = +$this.get(0).value; + + switch(e.keyCode){ + case ARROW_UP: + e.preventDefault(); + + if (e.altKey) $this.val(currentValue + 0.1); + else if(e.shiftKey) $this.val(currentValue + 10); + else $this.val(currentValue + 1); + + $this.change(); + + break; + case ARROW_DOWN: + e.preventDefault(); + + if (e.altKey) $this.val(currentValue - 0.1); + else if(e.shiftKey) $this.val(currentValue - 10); + else $this.val(currentValue - 1); + + $this.change(); + + break; + } +} + /** * Initializes the application * @@ -499,10 +634,13 @@ function axisClickHandler() { */ function init() { updateVolume( $("#volume-control").val(), true ); + App.baseTone.name = frequencyToNoteDetails(App.baseTone.frequency).name; $(".overtone").on("click", overtoneClickHandler); $(".spiral-piece").on("click", spiralPieceClickHandler); $(".axis").on("click", axisClickHandler); + + $("#base-detail").on("keydown", baseInputHandler); $("#base, #base-detail").on("change", function(){ updateBaseFrequency( $(this).val() ); @@ -515,6 +653,12 @@ function init() { $("[data-option]").on("click", function(){ toggleOption( $(this).data("option") ); }); + + $(document).on("overtones:options:change", function(e){ + if( e.details.optionName === "sustain" && e.details.optionValue === false ) + stopAllPlayingSounds(); + }); + } var App = { @@ -547,10 +691,8 @@ var App = { * @alias module:overtones.tunings * * @type {object} - * @property {Object} _12TET 12-TET frequency data */ tunings: { - _12TET: tTET } }; diff --git a/assets/js/script.js b/assets/js/script.js index b08f43e..77d58f8 100644 --- a/assets/js/script.js +++ b/assets/js/script.js @@ -5,7 +5,8 @@ var jQuery = require("jquery"), browser = require("detect-browser"), analytics = require("./analytics"), - tour = require("./shepherd.conf.js"); + tour = require("./shepherd.conf.js"), + easterEgg = require("./easter-egg"); window.Tones = require("./lib/tones"); window.Overtones = require("./overtones"); @@ -16,4 +17,6 @@ jQuery(document).ready(function($){ tour.init(); Overtones.init(); analytics($); + + easterEgg.init(); }); \ No newline at end of file diff --git a/assets/js/shepherd.conf.js b/assets/js/shepherd.conf.js index 033c8e3..f78848d 100644 --- a/assets/js/shepherd.conf.js +++ b/assets/js/shepherd.conf.js @@ -45,7 +45,7 @@ mainTour text: ['This is a visual representation of a fundamental sound and its overtones.', 'Each complete spiral revolution represents an octave.'], attachTo: '#Overtone-Spiral right', - title: '1/10', + title: '1/11', tetherOptions: { offset: '0 -220px' }, @@ -65,7 +65,7 @@ mainTour 'Each other circle represents the following partials, up to the 16th.', 'Click on the circle to hear the sound.'], attachTo: '#overtone-1 bottom', - title: '2/10', + title: '2/11', buttons: {}, advanceOn: { selector: '.overtone, .space', @@ -75,7 +75,7 @@ mainTour .addStep('note-details', { text: ['Here you can see information about the sound you just heard'], attachTo: '#sound-details bottom', - title: '3/10', + title: '3/11', when: { show: function() { $('body').chardinJs('start'); @@ -93,7 +93,7 @@ mainTour .addStep('spiral-pieces', { text: ['You can also click on other places of the spiral, such as the purple pieces connecting two circles.'], attachTo: '.spiral-piece:nth-of-type(2) bottom', - title: '4/10', + title: '4/11', buttons: {}, advanceOn: { selector: '.spiral-piece', @@ -104,7 +104,7 @@ mainTour text: ['In this case you see the information about the relationship between the notes you just heard.', 'The spiral pieces are thus representations of intervals in the harmonic series.'], attachTo: '#sound-details bottom', - title: '5/10', + title: '5/11', when: { show: function() { $('body').chardinJs('start'); @@ -123,7 +123,7 @@ mainTour text: ['You can change the frequency of the fundamental note by using this slider.', 'You can also directly change the number if you want finer tuning!'], attachTo: '#base-wrapper right', - title: '6/10', + title: '6/11', advanceOn: { selector: '#base', event: 'change' @@ -140,12 +140,12 @@ mainTour selector: '#volume-control', event: 'change' }, - title: '7/10' + title: '7/11' }) .addStep('options-group', { text: ['If you turn this option off, you will hear notes separately when playing intervals.'], attachTo: '#group-notes bottom', - title: '8/10', + title: '8/11', tetherOptions: { offset: '-20px 0' } @@ -153,14 +153,24 @@ mainTour .addStep('options-octave', { text: ['If you turn this option on, all the sounds will be played on frequencies within one octave of the fundamental tone.'], attachTo: '#reduce-to-octave bottom', - title: '9/10', + title: '9/11', + tetherOptions: { + offset: '-20px 0' + } +}) +.addStep('options-sustain', { + text: ['If you turn this option on, the sounds will play continuously until manually stopped.', + 'You can manually stop the sounds by clicking again on the circle, turning this option off, or changing the base tone', + 'When this option is on, the information displayed is the interval between the sound you play and the last active sound. This allows you to explore all the intervals within the spiral.'], + attachTo: '#sustain bottom', + title: '10/11', tetherOptions: { offset: '-20px 0' } }) .addStep('end-tour', { text: ['That is all! Enjoy!'], - title: '10/10', + title: '11/11', buttons: [ { text: 'Thank you!', diff --git a/assets/styles/_base.scss b/assets/styles/_base.scss index 9a16a8e..326017e 100644 --- a/assets/styles/_base.scss +++ b/assets/styles/_base.scss @@ -67,4 +67,6 @@ p.credits { margin-left: 50px; } +.version { margin-left: 1em; } + .github-button { color: transparent; } \ No newline at end of file diff --git a/assets/styles/_components/_easter-egg.scss b/assets/styles/_components/_easter-egg.scss new file mode 100644 index 0000000..ddb6d3a --- /dev/null +++ b/assets/styles/_components/_easter-egg.scss @@ -0,0 +1,53 @@ +.easter-egg { + .spiral-piece, + .share-easter-egg { + animation: 2s ease-in-out 0s infinite alternate rainbow-background; + } +} + +.easter-egg-announcement { + display: block; + opacity: 0; + overflow: hidden; + max-height: 0; + position: fixed; + top: 0; + right: 0; + + transition: max-height 0s, opacity 0.5s ease-out; + + &.show { + margin-left: -12em; + max-height: 100%; + opacity: 1; + top: 25%; + left: 50%; + + animation: 0.25s ease-in-out 0s 4 alternate pulsate; + } + + h3 { + animation: 2s ease-in-out 0s infinite alternate rainbow-color; + } +} + +@keyframes rainbow-background { + 0% { fill: hsl(240, 34%, 55%); background-color: hsl(240, 34%, 55%); } + 25% { fill: hsl(168, 34%, 55%); background-color: hsl(168, 34%, 55%); } + 50% { fill: hsl(96, 34%, 55%); background-color: hsl(96, 34%, 55%); } + 75% { fill: hsl(24, 34%, 55%); background-color: hsl(24, 34%, 55%); } + 100% { fill: hsl(326, 34%, 55%); background-color: hsl(326, 34%, 55%); } +} + +@keyframes rainbow-color { + 0% { color: hsl(240, 34%, 55%); } + 25% { color: hsl(168, 34%, 55%); } + 50% { color: hsl(96, 34%, 55%); } + 75% { color: hsl(24, 34%, 55%); } + 100% { color: hsl(326, 34%, 55%); } +} + +@keyframes pulsate { + from { transform: scale(1); } + to { transform: scale(1.05); } +} \ No newline at end of file diff --git a/assets/styles/_components/_options.scss b/assets/styles/_components/_options.scss index 3bfec38..4caea5e 100644 --- a/assets/styles/_components/_options.scss +++ b/assets/styles/_components/_options.scss @@ -74,6 +74,8 @@ a.option-button { width: 35px; height: 35px; position: relative; + + &:not(.off) { background-color: #d4d4d4; } } a#group-notes { @@ -82,7 +84,6 @@ a#group-notes { &::after { content: "Group notes"; } &:not(.off) { - background-color: #d4d4d4; background-image: url("../images/ico-link.svg"); &::after { content: "Ungroup notes"; } @@ -93,8 +94,10 @@ a#reduce-to-octave { background-image: url("../images/ico-octave-reduction.svg"); &::after { content: "Reduce to octave"; } +} + +a#sustain { + background-image: url("../images/ico-sustain.svg"); - &:not(.off) { - background-color: #d4d4d4; - } + &::after { content: "Sustain notes"; } } \ No newline at end of file diff --git a/assets/styles/_components/_overtone-spiral.scss b/assets/styles/_components/_overtone-spiral.scss index 33bb1d9..2409356 100644 --- a/assets/styles/_components/_overtone-spiral.scss +++ b/assets/styles/_components/_overtone-spiral.scss @@ -28,17 +28,37 @@ } .overtone { + transform: scale(1); transform-origin: 50% 50%; transition: all 0.2s ease-in-out; + + &:hover { + transform: scale(1.1); + cursor: pointer; + } + + &:active, + &.active { + transform: scale(1.25); + transition: all 0.1s ease-out; + } + + &.is-playing { + animation: 1s ease-in-out 0s infinite alternate is-playing-scale; + + .spaces, + .spaces g { + animation: 1s ease-in-out 0s infinite alternate is-playing-color; + } + } } -.overtone:hover { - transform: scale(1.1); - cursor: pointer; +@keyframes is-playing-scale { + from { transform: scale(1.25); } + to { transform: scale(1.15); } } -.overtone:active, -.overtone.active { - transform: scale(1.25); - transition: all 0.1s ease-out; +@keyframes is-playing-color { + from { fill: #FFE08D; } + to { fill: #FFFFFF; } } \ No newline at end of file diff --git a/assets/styles/_components/_sound-details.scss b/assets/styles/_components/_sound-details.scss index ab1559a..fa9791c 100644 --- a/assets/styles/_components/_sound-details.scss +++ b/assets/styles/_components/_sound-details.scss @@ -81,4 +81,6 @@ .cents { margin-left: -13px; } .cent-bar { left: 0; } -} \ No newline at end of file +} + +.accidental { font-size: 0.5em; } \ No newline at end of file diff --git a/assets/styles/style.scss b/assets/styles/style.scss index 2fb5fbe..3ae294e 100644 --- a/assets/styles/style.scss +++ b/assets/styles/style.scss @@ -7,4 +7,6 @@ @import "_components/overtone-spiral"; +@import "_components/_easter-egg"; + @import "_browsers"; \ No newline at end of file diff --git a/components/_easter-egg.html b/components/_easter-egg.html new file mode 100644 index 0000000..1171798 --- /dev/null +++ b/components/_easter-egg.html @@ -0,0 +1,19 @@ +
+ Sorry! No pot of gold. But it's the journey that counts, no? +
+Why don't you share your love for the Overtones world with your friends and see if they can also find the rainbow?
+