Memrise Audio Provider

Automatically generates Audio for items you're learning. Configure in left panel.

// ==UserScript==
// @name           Memrise Audio Provider
// @description    Automatically generates Audio for items you're learning. Configure in left panel.
// @match          https://*.memrise.com/course/*/garden/*
// @match          https://*.memrise.com/garden/review/*
// @run-at         document-end
// @version        1.1.4
// @grant          none
// @namespace      https://greasyfork.org/users/213706
// ==/UserScript==

// Based on https://github.com/cooljingle/memrise-audio-provider

function main() {

  // This script has three different methos to generate audio
  // Speech Synthesis API
  const SPEECHSYNTHESIS_LANG = {
    "German": "de-DE",
    "English": "en-GB",
    "Spanish (Mexico)": "es-ES",
    "Spanish (Spain)": "es-ES",
    "French": "fr-FR",
    "Hindi": "hi-IN",
    "Indonesian": "id-ID",
    "Italian": "it-IT",
    "Japanese": "ja-JP",
    "Kanji": "ja-JP",
    "Korean": "ko-KR",
    "Dutch": "nl-NL",
    "Polish": "pl-PL",
    "Portuguese (Brazil)": "pt-BR",
    "Russian": "ru-RU",
    "Chinese (Simplified)": "zh-CN",
    "Cantonese": "zh-HK",
    "Chinese (Traditional)": "zh-TW"
  };

  // Google TTS API
  const GOOGLETTS_LANG = {
    "Afrikaans": "af",
    "Albanian": "sq",
    "Arabic": "ar",
    "Armenian": "hy",
    "Bengali": "bn",
    "Bosnian": "bs",
    "Catalan": "ca",
    "Chinese (Simplified)": "zh-CN",
    "Chinese (Traditional)": "zh-TW",
    "Croatian": "hr",
    "Czech": "cs",
    "Danish": "da",
    "Dutch": "nl",
    "English": "en",
    "Esperanto": "eo",
    "Finnish": "fi",
    "French": "fr",
    "German": "de",
    "Greek": "el",
    "Hindi": "hi",
    "Hungarian": "hu",
    "Icelandic": "is",
    "Indonesian": "id",
    "Italian": "it",
    "Japanese": "ja",
    "Kanji": "ja",
    "Khmer": "km",
    "Korean": "ko",
    "Latin": "la",
    "Latvian": "lv",
    "Macedonian": "mk",
    "Nepali": "ne",
    "Norwegian": "no",
    "Polish": "pl",
    "Portuguese (Brazil)": "pt-BR",
    "Portuguese (Portugal)": "pt-PT",
    "Romanian": "ro",
    "Russian": "ru",
    "Serbian": "sr",
    "Sinhalese": "si",
    "Slovak": "sk",
    "Spanish (Mexico)": "es",
    "Spanish (Spain)": "es",
    "Swahili": "sw",
    "Swedish": "sv",
    "Tamil": "ta",
    "Thai": "th",
    "Turkish": "tr",
    "Ukrainian": "uk",
    "Vietnamese": "vi",
    "Welsh": "cy"
  };

  // Voice RSS API
  const VOICERSS_LANG = {
    "Catalan": "ca-es",
    "Chinese (Simplified)": "zh-cn",
    "Chinese (Traditional)": "zh-tw",
    "Danish": "da-dk",
    "Dutch": "nl-nl",
    "English": "en-gb",
    "fi-fi": "fi",
    "French": "fr-fr",
    "German": "de-de",
    "Italian": "it-it",
    "Japanese": "ja-jp",
    "Kanji": "ja-jp",
    "Korean": "ko-kr",
    "Norwegian": "nb-no",
    "Polish": "pl-pl",
    "Portuguese (Brazil)": "pt-br",
    "Portuguese (Portugal)": "pt-pt",
    "Russian": "ru-ru",
    "Spanish (Mexico)": "es-es",
    "Spanish (Spain)": "es-es",
    "Swedish": "sv-se"
  };

  const LOCALSTORAGE_ID         = "memrise-audio-provider-storagev2",
        LOCALSTORAGE_VOICERSSID = "memrise-audio-provider-voicerss",
        LOCALSTORAGE_OVERRIDEID = "memrise-audio-provider-override-all",
        NONE                    = "-- None";

  var AudioProvider = {

    /**
     * Entrypoint
     */
    init: function(){

      // Check current referer
      this.referrerState          = "";
      this.requestCount           = 0;
      this.watchNetwork();

      // Which API to use ?
      this.speechSynthesis        = window.speechSynthesis && new window.SpeechSynthesisUtterance();
      this.speechSynthesisPlaying = false;

      this.canSpeechSynthesize    = true;
      this.canGoogleTts           = true;
      this.canVoiceRss            = true;

      // Current state
      this.language               = null; // current language (label)
      this.courseId               = null; // current course id
      this.wordColumn             = null; // name of field containing word
      this.currentItem            = null; // current item to refresh current word when changing settings
      this.currentWord            = "";   // current word
      this.cachedAudioElements    = [];   // cached audio DOMElements

      // Add Meta to get Google TTS working
      var meta     = document.createElement('meta');
      meta.name    = "referrer";
      meta.content = "origin";
      document.getElementsByTagName('head')[0].appendChild(meta);

      // Add AudioProvider to window
      this.add_settings();

      if(MEMRISE.garden.session) {
        this.override_memrise();

      } else if(MEMRISE.garden._events && MEMRISE.garden._events.start) {
        MEMRISE.garden._events.start.push(this.override_memrise.bind(this));

      } else {
      	setTimeout(function(){
          this.override_memrise();
        }.bind(this), 1000);
      }
    },

    //+------------------------------------------------------
    //|
    //| SETTINGS
    //|
    //+------------------------------------------------------

    /**
     * Add Settings block to left area
     */
    add_settings: function(){

      // Retrieve saved settings
      this.savedChoices           = JSON.parse(localStorage.getItem(LOCALSTORAGE_ID)) || {};
      this.voiceRssKey            = localStorage.getItem(LOCALSTORAGE_VOICERSSID) || "";
      this.overrideAllAudio       = localStorage.getItem(LOCALSTORAGE_OVERRIDEID) === "true";

      if(!this.voiceRssKey) {
        this.canVoiceRss = false;
      }

      // Add "audio provider" settings to left area
      var div = document.createElement('div'),
          linkHtml = `
      <p id='audio-provider-link'>Audio Provider</p>

      <div id='audio-provider-box' style='display:none'>
                <div style='display:table; padding: 5px; background: rgba(255,255,255,0.6);'>
          <em style='font-size:85%; white-space: nowrap;'>Column to use:</em><br>
          <select id='audio-provider-options'></select>

          <em style='font-size:85%; white-space: nowrap;'>Override all audio:</em>
          <input id='audio-provider-override'
                 type="checkbox" ${this.overrideAllAudio ? 'checked="checked"': ''}>

          <em style='font-size:85%; white-space: nowrap;'>Voice RSS key:</em><br>
          <input id='audio-provider-voicerss'
                 type='text' placeholder='enter Voice RSS key'
                 value="${this.voiceRssKey.replace(/"/g, '\"')}"
                 style='width: 150px; padding: 8px;'>
                </div>
      </div>`;
      div.innerHTML = linkHtml;
      document.getElementById('left-area').appendChild(div);

      // Show/hide settings
      document.getElementById('audio-provider-link').addEventListener('click', function () {
        var box = document.getElementById('audio-provider-box');

        box.style.display = (box.style.display == "none" ? "block" : "none");
      });

      // User changes settings
      document.getElementById('audio-provider-voicerss').addEventListener('change', function () {
        localStorage.setItem(LOCALSTORAGE_VOICERSSID, this.value);
      });
      document.getElementById('audio-provider-override').addEventListener('change', function () {
        var checked = this.checked;

        AudioProvider.overrideAllAudio = checked;
        localStorage.setItem(LOCALSTORAGE_OVERRIDEID, checked);
      });
      document.getElementById('audio-provider-options').addEventListener('change', function () {
        var wordColumn = this.value;

        AudioProvider.wordColumn = wordColumn;
        AudioProvider.savedChoices[AudioProvider.courseId] = wordColumn;
        localStorage.setItem(LOCALSTORAGE_ID, JSON.stringify(AudioProvider.savedChoices));

        AudioProvider.updateCurrentWord();
      });
    },

    /**
     * Set speechSynthesis language
     * Called by MEMRISE when ready (cf override_memrise)
     */
    init_speechSynthesis: function(){
      if(!this.speechSynthesis || !this.canSpeechSynthesize) {
        return;
      }
      var langCode = SPEECHSYNTHESIS_LANG[this.language];

      this.speechSynthesis.lang  = langCode || "";
      this.speechSynthesis.voice = speechSynthesis.getVoices().filter(function (voice) {
        return voice.lang === langCode;
      })[0];
      this.canSpeechSynthesize = !!(this.speechSynthesis.lang && this.speechSynthesis.voice);
    },

    /**
     * @param object context
     */
    set_content: function(context) {
      if (context.template == "end_of_session" || context.template == "speed-count-down") {
        return;
      }

      // Which field to use to generate audio ?
      var courseId = context.course_id
        || MEMRISE.garden.session_params.course_id
        || MEMRISE.garden.session_data.learnables_to_courses[context.learnable.learnable_id];

      if (this.courseId != courseId) {
        this.courseId   = courseId;
        this.wordColumn = this.savedChoices[courseId]
          || (context.learnable.item.kind == "text" ? context.learnable.item.label
              : (context.learnable.definition.kind == "text" ? context.learnable.definition.label
                : NONE));

        this.editAudioOptions(context.learnable);
      }

      // Set current word
      if (!this.canSpeechSynthesize && !this.canGoogleTts && !this.canVoiceRss){
        document.getElementById('audio-provider-link').style.display = "none";

        log("could not find a way to generate audio for language " + language);
        return;
      }

      this.currentItem = {
      	item:       context.learnable.item,
        definition: context.learnable.definition,
        audio     : (context.presentationData || context.testData).audio.value
      };
      this.updateCurrentWord(context);
    },

    /**
     * Populate settings content
     * Called by MEMRISE whenever we change current course (cf make_box > set_content)
     *
     * @param object word
     */
    editAudioOptions: function(word) {
      var html = `<option value="${NONE}">${NONE}</option>`;
      if(word.definition.kind == "text") {
        html += `<option value="${word.definition.label}">${word.definition.label}</option>`;
      }
      if(word.item.kind == "text") {
        html += `<option value="${word.item.label}">${word.item.label}</option>`;
      }

      var options       = document.getElementById('audio-provider-options');
      options.innerHTML = html;
      options.value     = this.wordColumn;
    },

    /**
     * Update currentWord
     * Called by MEMRISE when rendering word (cf make_box > set_content)
     * And by AudioProvider when updating wordColumn
     */
    updateCurrentWord: function() {
      var item = this.currentItem;
      if(!item) {
      	return;
      }

      // Should we generate audio ?
      var handleAudio = this.overrideAllAudio
      	|| (_.isArray(item.audio) ? item.audio[0] : item.audio).normal === "AUDIO_PROVIDER";

      if(!handleAudio || this.wordColumn === NONE) {
        this.currentWord = "";
        return;
      }

      // Get word
      this.currentWord = _.find([item.definition, item.item], x => x.label === this.wordColumn).value;

      // GoogleTTS: preload as we change referrer header while loading (we don't want to conflict with memrise calls)
      if (!this.canSpeechSynthesize && this.canGoogleTts) {
        this.getGoogleTtsElement(this.currentWord);
      }
    },

    //+------------------------------------------------------
    //|
    //| OVERRIDE MEMRISE
    //|
    //+------------------------------------------------------

    /**
     * Override Memrise functions with our owns
     */
    override_memrise: function(){

      // Override MEMRISE's functions
      this.cached = {
        make_box    : MEMRISE.garden.session.make_box,
        fixMediaUrl : MEMRISE.renderer.fixMediaUrl,
        play        : MEMRISE.audioPlayer.play
      };

      MEMRISE.garden.session.make_box = this.make_box;
      MEMRISE.renderer.fixMediaUrl    = this.fixMediaUrl;
      MEMRISE.audioPlayer.play        = this.play;

      // Populate audio
      this.language = MEMRISE.garden.session.category.name;
      this.init_speechSynthesis();
      this.populateScreenAudios();

      // Manually call make_box if MEMRISE already loaded content
      if(MEMRISE.garden.box) {
        this.set_content(MEMRISE.garden.box);

        // Add audio player to window
        var columns = document.querySelector('.columns');

        // Presentation
        if(columns) {
          if(!columns.querySelector('.first-audio')) {
            var div = document.createElement('div');
            div.setAttribute('class', 'row column first-audio');
            div.innerHTML =  '<div class="row-value"><a class="audio-player audio-player-hover" href=""></a></div>';
            columns.appendChild(div);
          }

        // Multi-choice/typing/tapping
        } else {
          var audio = document.querySelector('.hidden-audio');
          if(audio && audio.children.length == 0) {
            audio.innerHTML = '<a class="audio-player audio-player-hover" href=""></a>';
          }
        }
      }
    },

    populateScreenAudios: function() {
      var learnables = MEMRISE.garden.learnables
        || _.indexBy(MEMRISE.garden.session_data.learnables, 'learnable_id');

      _.each(learnables, function(v, k) {
        var learnableScreens = (MEMRISE.garden.screens || MEMRISE.garden.session_data.screens)[k],
            screenMap        = MEMRISE.garden.screen_template_map[k];

        _.each([learnableScreens, screenMap], screens => {
          _.each(screens, s => {
            s = _.isArray(s) ? s[0] : s;

            var hasAudio = s.audio && s.audio.value && s.audio.value.length;
            if(!hasAudio){
              s.audio = {
                alternatives: [],
                direction: "target",
                kind: "audio",
                label: "Audio",
                style: [],
                value: [{
                  normal: "AUDIO_PROVIDER",
                  slow: "AUDIO_PROVIDER"
                }]
              };
            } // end if
          });
        });

      });
    },

    /**
     * @return string
     */
    make_box: function(){
      var context = AudioProvider.cached.make_box.apply(this, arguments);

      AudioProvider.set_content(context);
      return context;
    },

    /**
     * @return string
     */
    fixMediaUrl: function () {
      if (AudioProvider.overrideAllAudio
          || arguments[0] === "AUDIO_PROVIDER"
          || (_.isArray(arguments[0]) && arguments[0][0] === "AUDIO_PROVIDER")) {
        return "";
      } else {
        return AudioProvider.cached.fixMediaUrl.apply(this, arguments);
      }
    },

    /**
     * Play audio
     * Generates automatically if necessary
     */
    play: function () {
      if(document.body.classList.contains("audio-muted")) {
      	return;
      }
      var shouldGenerateAudio = (arguments[0].url === "");
      if (shouldGenerateAudio) {
        AudioProvider.playGeneratedAudio(AudioProvider.currentWord);
      } else {
        AudioProvider.cached.play.apply(this, arguments);
      }
    },

    //+------------------------------------------------------
    //|
    //| GENERATE AUDIO
    //|
    //+------------------------------------------------------

    /**
     * Generates audio for given word
     * With the first API that is enabled
     */
    playGeneratedAudio: function(word) {
      if (!word) {
        return;
      }
      if (this.canSpeechSynthesize) {
        this.playSpeechSynthesisAudio(word);
      } else if (this.canGoogleTts) {
        this.playGoogleTtsAudio(word);
      } else if (this.canVoiceRss) {
        this.playVoiceRssAudio(word);
      } else {
        log("no playable sources found");
      }
    },

    /**
     * Generate audio with SpeechSynthesis API
     * for the given word and plays it
     *
     * @param string word
     */
    playSpeechSynthesisAudio: function(word) {
      if(this.speechSynthesis.text === word && this.speechSynthesisPlaying){
        return;
      }
      log("generating speechSynthesis audio for word: " + word);

      this.speechSynthesis.text   = word;
      window.speechSynthesis.speak(this.speechSynthesis);
      this.speechSynthesisPlaying = true;

      this.speechSynthesis.onend  = function (event) {
        this.speechSynthesisPlaying = false;

        // Firefox utterances don't play more than once
        if (navigator.userAgent.search("Firefox") > -1) {
          var lang  = this.speechSynthesis.lang,
              voice = this.speechSynthesis.voice,
              test  = this.speechSynthesis.text;

          this.speechSynthesis = new window.SpeechSynthesisUtterance();
          this.speechSynthesis.lang = lang;
          this.speechSynthesis.voice = voice;
          this.speechSynthesis.text = text;
        }
      }.bind(this);
    },

    /**
     * Generate audio with Google TTS API
     * for the given word and plays it
     *
     * @param string word
     */
    playGoogleTtsAudio: function(word) {
      this.getGoogleTtsElement(word, true);
    },

    getGoogleTtsElement: function(word, play) {

      // Check language is recognized by Google
      var languageCode = GOOGLETTS_LANG[this.language];
      if (!languageCode) {
        return;
      }

      // Is audio cached ?
      var source        = "google tts",
          cachedElement = this.getCachedElement(source, word);
      if (cachedElement) {
        play && cachedElement.play();
        return;
      }

      // If network is busy: delay TTS request
      if (this.isNetworkBusy()) {
        log("network busy - delaying google tts load");

        setTimeout(function(){
          AudioProvider.getGoogleTtsElement(word, play);
        }, 300);
        return;
      }

      // Generate audio
      log("generating google tts link for word: " + word);

      var audioElement = this.makeAudioElement({
            label  : source,
            word   : word,
            url    : this.getGoogleTtsUrl(languageCode, word),

            onError: function (e) {
              if(referrerState === "origin") {
                console.log("referrer header was set prematurely");
                this.removeCachedElement(source, word);
              } else {
                this.canGoogleTts = false;
                this.setReferrerOrigin();
              }
              this.playGeneratedAudio(word);
            }.bind(this)
          });
      this.setCachedElement(source, word, audioElement);

      if (navigator.userAgent.search("Firefox") > -1) {
        audioElement.addEventListener('loadstart', this.setReferrerNoReferrer.bind(this));
      } else {
        this.setReferrerNoReferrer();
      }
      audioElement.addEventListener('loadedmetadata', this.setReferrerOrigin.bind(this));

      if(play) {
        audioElement.play();
      }
    },

    /**
     * Returns Google TTS url
     * for the given word and language
     *
     * @return string
     */
    getGoogleTtsUrl: function(languageCode, word) {
      // Extra params help to stop google from complaining about too many requests
      return `https://translate.google.com/translate_tts?ie=UTF-8&tl=${languageCode}&client=tw-ob&q=${encodeURIComponent(word)}&tk=${Math.floor(Math.random() * 1000000)}`;
    },

    /**
     * Generate audio with Voice RSS API
     * for the given word and plays it
     *
     * @param string word
     */
    playVoiceRssAudio: function(word) {

      // Check language is recognized by Voice RSS
      var languageCode = VOICERSS_LANG[language];
      if(!languageCode){
        return;
      }

      // Is audio cached ?
      var source        = "voice rss",
          cachedElement = this.getCachedElement(source, word);
      if (cachedElement) {
        cachedElement.play();
        return;
      }

      // Generate audio
      log("generating voice rss link for word: " + word);

      var audioElement = this.makeAudioElement({
        label  : source,
        word   : word,
        url    : this.getVoiceRssUrl(languageCode, word),

        onError: function (e) {
          this.canVoiceRss = false;
          this.playGeneratedAudio(word);
        }.bind(this)
      });
      this.setCachedElement(source, word, audioElement);
      audioElement.play();
    },

    /**
     * Returns Voice RRS url
     * for the given word and language
     *
     * @return string
     */
    getVoiceRssUrl: function(languageCode, word) {
      return `https://api.voicerss.org/?key=${this.voiceRssKey}&src=${encodeURIComponent(word)}&hl=${languageCode}&f=48khz_16bit_stereo`;
    },

    /**
     * Creates an audio DOMElement
     * @param object options - {source: string, word: string, url: string, onError: function}
     * @return DOMElement
     */
    makeAudioElement: function({source, word, url, onError}) {
      var audioElement = document.createElement('audio');
      audioElement.setAttribute('src', url);

      audioElement.addEventListener('error', function(e) {
        log(source + " failed");
        console.log(e);
        onError(e);
      });
      return audioElement;
    },

    //+------------------------------------------------------
    //|
    //| CACHE FUNCTIONS
    //|
    //+------------------------------------------------------

    getCachedElement: function(source, word) {
      var cachedElem = this.cachedAudioElements.find((obj) => {
        return obj.source === source && obj.word === word;
      });
      return cachedElem && cachedElem.element;
    },

    removeCachedElement: function(source, word) {
      _.remove(this.cachedAudioElements, (e) => {
        return e.source === source && e.word === word
      });
    },

    setCachedElement: function(source, word, element) {
      this.cachedAudioElements.push({source, word, element});
    },

    //+------------------------------------------------------
    //|
    //| REFERER FUNCTIONS
    //|
    //+------------------------------------------------------

    setReferrerOrigin: function() {
      document.getElementsByName("referrer")[0].setAttribute("content", "origin");
      this.referrerState = "origin";
    },

    setReferrerNoReferrer: function() {
      document.getElementsByName("referrer")[0].setAttribute("content", "no-referrer");
      this.referrerState = "no-referrer";
    },

    isNetworkBusy: function() {
      return this.requestCount > 0;
    },

    watchNetwork: function() {
      $(document).ajaxSend(function (e, xhr, settings) {

        this.requestCount++;

        if(this.referrerState === "no-referrer") {
          this.setReferrerOrigin();
        }
        xhr.always(function() {
          this.requestCount--;
        }.bind(this));
      }.bind(this));
    }
  };

  function log(message) {
    console.log("Audio Provider: " + message);
  }

  window.addEventListener('load', function(){
    AudioProvider.init();
  }, false);
}

// Inject JS directly in page to prevent limitations of access
var script = document.createElement('script');

script.setAttribute("type", "application/javascript");
script.appendChild(document.createTextNode('('+ main +')();'));
document.body.appendChild(script);