[Unsupported] [Userscript] WaniKani Review Audio Tweak (reupload/fixed)

@mochismiles Did you find this script?

2 Likes

I did not, thank you for letting me know about it!

1 Like

Question: I have a ~400 loc patch which adds a handful of features and configuration options:

  • preloads audio before question is answered to avoid lag in audio playing and caches previously loaded audio in memory for the duration of the quiz
  • ui for configuring settings via wkof (uses default settings if wkof not installed)
  • optional setting to randomize voice actor
  • optional setting so audio matches the reading you typed (matching WaniKani’s behavior)
  • optional setting so audio is different from the reading you typed
  • optional setting for audio to be random reading
  • broke out “autoplay on wrong answer” setting into separate settings for reading and meaning questions
  • fixes bug (also present in WaniKani’s own code) that could cause audio loading to fail if your browser does not support the first audio <source> (the spec/browsers require calling load() after adding all sources; if this isn’t done, only the first source will be checked)
  • changes “play” icon to circle with line through it if audio is unavailable after answering (e.g., for radicals)

Would you consider accepting a contribution along these lines or would it be better if I just post it on my own?

1 Like

That’s an impressive set of changes! Given how much is added (and that it adds a dependency on wkof, even if optional), it might be better for you to release your new and improved version separately. I wouldn’t be against putting your changes in this script per se, but I don’t really want to deal with the impact if there are bugs and further changes are needed. And if you upload the script yourself, you have a lot more flexibility and don’t have to deal with me either.

1 Like

FWIW: I’ve been looking for a script that does many of these things and I’d love if you released this :slight_smile:

Here’s my version of the script with the features I mentioned.

// ==UserScript==
// @name          WaniKani Review Audio Tweak 2 Fork
// @namespace     est_fills_cando
// @description   Allow audio to be played after review meaning questions, when reading has been previously answered correctly. Also includes setting for enabling autoplay when answer is incorrect (default: off). Originally by Takuya Kobayashi.
// @author        est_fills_cando
// @version       1.0.12
// @include       https://www.wanikani.com/review/session*
// @include       https://preview.wanikani.com/review/session*
// @run-at        document-end
// @grant         none
// ==/UserScript==

// Original version by Takuya Kobayashi: https://greasyfork.org/en/scripts/10184-wanikani-review-audio-tweak
// Based on Version 2 by seanblue

(function ($, wkof, wanakana) {
    'use strict';

    let cachedAudio = new Map(); // map of lists, each list is cached jquery audio object
    let callback, audioCallback, enableAudioCallback;
    let audChain = [];

    function main() {
        audioCallback = window.additionalContent.Audio;
        window.additionalContent.audio = function() { return audioCallback(...arguments); }; // wrapped so we can still change audioCallback even after additionalContent.audio is copied
        let enableAudioLoaded = new Promise(function (resolve, reject) {
            waitProperty(window, 'wkEnableAudio', function() {
                enableAudioCallback = window.wkEnableAudio;
                window.wkEnableAudio = function() { return enableAudioCallback(window.audioAutoplay); };
                resolve();
            });
        });

        let promise = Promise.resolve();
        if (wkof) {
            wkof.include('Settings,Menu');
            promise = wkof.ready('Settings');
        }
        promise = promise.then(load_settings);
        if (wkof) {
            Promise.all([wkof.ready('Menu'),promise]).then(install_menu);
        }
        promise.then(install_tweaks);
        Promise.all([promise, enableAudioLoaded]).then(install_enableAudio_callback);
    }


    //
    // Core Logic
    //

    function install_tweaks() {
        // start buffering audio for new items whenever activeQueue changes
        onEachJStorageChange('activeQueue', cache_audios);

        // remove audio of completed items from cachedAudio to prevent memory leak
        // for compatibility with scripts that move items from activeQueue to reviewQueue,
        // we also check reviewQueue before deleting
        onEachJStorageChange('activeQueue', function() {
            let wholeQueue = $.jStorage.get('reviewQueue').concat($.jStorage.get('activeQueue'));
            //cleanup_cache(wholeQueue);
            cleanup_cache($.jStorage.get('activeQueue'));
        });

        onKeydownOnce('j', function(evt) {
            $('#option-audio').click();
            evt.stopImmediatePropagation();
        });

        // play / allow playing audio after submitting answer
        callback = function (version, audioAutoplay) {
            let currentItem = $.jStorage.get('currentItem');
            let questionType = $.jStorage.get('questionType');
            let liElem = $('#option-audio');
            let buttonElem = liElem.find('button');

            $('audio').remove();
            if (hasAudio(currentItem)) {
                buttonElem.removeClass("audio-unavailable");
                buttonElem.addClass("audio-idle");
                buttonElem.attr("title", "Pronunciation");
                buttonElem.removeAttr('disabled');
            } else {
                buttonElem.addClass("audio-unavailable");
                buttonElem.removeClass("audio-idle");
                buttonElem.attr("title", "Audio not available");
                buttonElem.attr('disabled', '');
                // for some reason, WK code doesn't add disabled attribute here
            }

            if (mightWantAudio(currentItem, questionType) && hasAudio(currentItem)) {
                let select = null;
                let play_when_wrong = null;
                let typed = null;
                let typedAbnormal = null;
                let heard = null;
                let audioElems = cachedAudio.get(currentItem.id);
                if (questionType == 'reading') {
                    select = settings.reading_select
                    play_when_wrong = settings.reading_play_when_wrong;
                    typedAbnormal = $('#answer-form fieldset input[type="text"]').val().trim() || '';
                    typed = audioElems.map(e => normalize_kana(e.pronunciation)).filter(e => e == normalize_kana(typedAbnormal))[0] || '';
                    $.jStorage.set(typedKey(currentItem.id), typed);
                    $.jStorage.setTTL(typedKey(currentItem.id), 2*3600*1000);
                } else if (questionType == 'meaning') {
                    select = settings.meaning_select;
                    play_when_wrong = settings.meaning_play_when_wrong;
                    typed = $.jStorage.get(typedKey(currentItem.id)) || '';
                    heard = $.jStorage.get(heardKey(currentItem.id)) || '';
                }

                if (!play_when_wrong && !$('#answer-form fieldset').hasClass('correct'))
                    audioAutoplay = false;
                else if (play_when_wrong && $('#answer-form fieldset').hasClass('incorrect'))
                    audioAutoplay = true;

                if (select == 'same_as_typed')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) == normalize_kana(typed))
                else if (select == 'different_from_typed')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(typed))
                else if (select == 'same_as_heard')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) == normalize_kana(heard))
                else if (select == 'different_from_heard')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(heard))
                else if (select == 'different_from_all')
                    audioElems = audioElems.looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(typed))
                        .looseFilter((e) => normalize_kana(e.pronunciation) != normalize_kana(heard));
                else if (select == 'random') {}
                // pass
                else
                    throw 'unrecognized select'

                let audioElem = audioElems[Math.floor(real_random() * audioElems.length)];
                if (questionType == 'reading') {
                    heard = audioElem.pronunciation;
                    $.jStorage.set(heardKey(currentItem.id), heard);
                    $.jStorage.setTTL(heardKey(currentItem.id), 2*3600*1000);
                }

                liElem.removeClass('disabled');
                if (version==='old')
                    audioElem.appendTo(liElem.children('span'));
                audioElem[0].autoplay = audioAutoplay;
                audioElem[0].playbackRate = settings.speed;
                audioElem[0].defaultPlaybackRate = settings.speed;

                // Changing autoplay won't trigger playback unless we call load(). However, we
                // don't use load() here because it laso triggers an unecessary network request to try downloading the audio again.
                // Instead, we just directly call play() which doesn't have this issue.
                if (audioAutoplay)
                    audioElem[0].play()

                buttonElem.off('click');
                liElem.off('click');
                liElem.on('click', function () {
                     audioElem[0].play();
                });
                audChain = [audioElem];
            }
        };
        audioCallback = autoplay => callback('old', autoplay);
    }

    function install_enableAudio_callback() {
        enableAudioCallback = autoplay => callback('new', autoplay);
    }

    function cache_audios(items, force_update) {
        for (const item of items)
            cache_audio(item, force_update)
    }

    function cache_audio(item, force_update) {
        if ( (!cachedAudio.has(item.id) || force_update) && hasAudio(item) ) {
            let liElem = $('#option-audio');
            let buttonElem = liElem.find('button');

            // group audio by pronunciation and voice actor
            let groups = {};
            for (const audio of getAudios(item)) {
                const key = JSON.stringify([audio.pronunciation, audio.voice_actor_id]);
                if (!groups[key])
                    groups[key] = [];
                groups[key].push(audio);
            }

            let audioElems = new Array();
            for (const key in groups) {
                const group = groups[key].sort((a,b) => a.content_type == 'audio/ogg' && b.content_type == 'audio/mpeg' ? 1 : -1);
                let audioElem = $('<audio></audio>', { preload: 'none' });
                for (const audio of group)
                    $('<source></source>', {
                        src: audio.url,
                        type: audio.content_type
                    }).appendTo(audioElem);

                audioElem[0].preload = 'auto';
                audioElem[0].autoplay = false;
                audioElem[0].load();

                [audioElem.pronunciation, audioElem.voice_actor_id] = JSON.parse(key);

                audioElem[0].addEventListener('play', function () {
                    buttonElem.removeClass('audio-idle').addClass('audio-play');
                });

                audioElem[0].addEventListener('ended', function () {
                    buttonElem.removeClass('audio-play').addClass('audio-idle');
                });

                audioElems.push(audioElem);
            }

            cachedAudio.set(item.id, audioElems);
        }
    }

    function cleanup_cache(queue) {
        let remainingIds = new Set(queue.map((it) => it.id));
        let plainChain = audChain;
        for (const id of new Set(cachedAudio.keys())) {
            if (!remainingIds.has(id)) {
                let del = true;
                for (let jq of cachedAudio.get(id)) {
                    if (!plainChain.includes(jq)) {
                        for (let a of jq) {
                            a.src = null;
                            a.srcObject = null;
                            a.preload = 'none';
                        }
                    } else {
                        del = false;
                    }
                }
                if (del)
                    cachedAudio.delete(id);
                $.jStorage.deleteKey(typedKey(id));
                $.jStorage.deleteKey(heardKey(id));
            }
        }
    }


    //
    // Helper / Utility Methods
    //

    // key for typed response for reading stored in jstorage
    function typedKey(id) {
        return 'rat/' + id + '/typed';
    }

    // key for heard audio after reading stored in jstorage
    function heardKey(id) {
        return 'rat/' + id + '/heard';
    }

    function itemStat(item) {
        let itemStatKey = (item.voc ? 'v' : item.kan ? 'k' : 'r') + item.id;
        return ($.jStorage.get(itemStatKey) || {});
    }

    function isDefaultVA(audio) {
        return audio.voice_actor_id === window.WaniKani.default_voice_actor_id;
    }

    function getAudios(currentItem) {
        let auds = currentItem.aud;
        if (!settings.random_va) {
            let default_va_readings = new Set(auds
                                              .filter(isDefaultVA)
                                              .map((aud) => normalize_kana(aud.pronunciation))
                                             );
            auds = auds.filter( (e) =>  isDefaultVA(e) || !default_va_readings.has(normalize_kana(e.pronunciation)));
        }
        return auds;
    }

    function mightWantAudio(currentItem, questionType) {
        if (questionType === 'reading') {
            return true;
        } else if (questionType == 'meaning') {
            if (settings.meaning_select == 'none')
                return false;
            else if (itemStat(currentItem).rc >= 1 || !settings.meaning_no_reading_spoilers)
                return true;
        }
    }

    function hasAudio(currentItem) {
        return currentItem.aud && getAudios(currentItem).length > 0;
    }

    function normalize_kana(kana) {
        return wanakana.toKatakana(wanakana.toRomaji(kana))
    }

    //
    // Generic Library Functions
    //

    // get fresh Math.random in case it was altered by another script
    let real_random = null;
    (function() {
        var iframe = document.createElement('iframe');
        document.body.appendChild(iframe);
        real_random = iframe.contentWindow.Math.random;
        iframe.remove();
    })();

    // like filter but returns the input if the filtered result would be empty
    Object.defineProperty(Array.prototype, 'looseFilter', {value:function (callback) {
        let filtered = this.filter(callback);
        if (filtered.length != 0)
            return filtered;
        else
            return this.slice();
    }});

    // Calls callback each time jStorage value stored at key,
    // is updated with the updated value,
    // including cases where the value is overwritten with itself,
    // including once for the initial value when this function is executed
    // but never when the new value is null
    //
    // The call is executed asynchronously as a microtask, which is useful
    // when several jStorage keys are being updated at once to avoid
    // running the callback part way through the updates.
    //
    // Since the callback runs as a microtask, the value stored could
    // be different from the value passed to the callback if another change
    // is made to it before the microtask runs.
    function onEachJStorageChange(key, callback) {
        callback = (function(callback) {return function() {
            queueMicrotask(function() {
                let val = $.jStorage.get(key);
                if (val !== null)
                    callback(val);
            });
        }})(callback);
        $.jStorage.listenKeyChange(key, function (key, action) {
            if (action === 'updated') {
                callback();
            }
        });
        callback();
    }

    // helper method for waiting for a property to be defined on an element
    // callback is called synchronously immediately after the property is defined
    if (!window.waitProperty) {
        let objPropCallbacks = new Map();
        window.waitProperty = function (obj, prop, callback) {
            if (obj[prop] !== undefined) {
                callback(obj[prop]);
                return;
            }
            if (!objPropCallbacks.has(obj))
                objPropCallbacks.set(obj, new Map());
            let propCallbacks = objPropCallbacks.get(obj);

            let callbacks;
            if (!propCallbacks.has(prop)) {
                propCallbacks.set(prop, []);

                function runCallbacks(val) {
                    for (let callback of callbacks) {
                        callback(val);
                    }
                }

                let _val;
                Object.defineProperty(obj, prop, {
                    get: () => _val,
                    set: function(val) {_val = val; delete obj[prop]; obj[prop] = val; runCallbacks(val); callbacks.length = 0;},
                    configurable: true,
                    enumerable: true
                });
            }
            callbacks = propCallbacks.get(prop);
            callbacks.push(callback);
        }
    }

        function onKeydownOnce(key, handler, options) {
        if (!options)
            options = {};
        if (!options.ignoredTargets)
            options.ignoredTargets = [];
        let handled = false;
        document.addEventListener('keydown', function(evt) {
            if (evt.key == key && !handled && !options.ignoredTargets.includes(evt.target.tagName.toLowerCase())) {
                handled = true;
                return handler(evt);
            }
        }, true);

        document.body.addEventListener('keyup', function(evt) {
            if (evt.key == key && handled) {
                handled = false;
            }
        }, true);
    }


    //
    // Settings and Menu
    //
    let default_settings =
        {reading_select: 'same_as_typed',
         meaning_select: 'same_as_heard',
         reading_play_when_wrong: false,
         meaning_play_when_wrong: true,
         meaning_no_reading_spoilers: true,
         random_va: false,
         speed: 1,
        };
    let settings = default_settings;
    let settings_key = 'review_audio_tweak';
    async function load_settings() {
        if (wkof)
            settings = await wkof.Settings.load(settings_key,default_settings)
    }

    function install_menu() {
        let menu_meta =
            {name: 'review_audio_tweak_settings',
             submenu: 'Settings',
             title: 'Review Audio Tweak 2',
             on_click: () => (new wkof.Settings(settings_schema)).open(),
            };
        wkof.Menu.insert_script_link(menu_meta);
    }

    let settings_schema =
        {script_id: 'review_audio_tweak',
         title: 'Review Audio Tweak 2',
         on_close: function () {
             settings = wkof.settings[settings_key];
             cache_audios($.jStorage.get('activeQueue'), true);
         },
         content: {
             reading: {
                 type: 'group',
                 label: 'Reading Questions',
                 content: {
                     reading_select: {
                         type: 'dropdown',
                         label: 'Reading to Play',
                         hover_tip: 'Select which reading should be played after answering a reading question.',
                         //full_width: true,
                         content: {
                             same_as_typed: 'reading you typed',
                             different_from_typed: 'reading you didn\'t type',
                             random: 'random reading',
                         }

                     },
                     reading_play_when_wrong: {
                         type: 'checkbox',
                         label: 'Autoplay on Incorrect Answer',
                         hover_tip: 'Autoplay audio even when you get the answer wrong.',
                     },
                 },
             },
             meaning: {
                 type: 'group',
                 label: 'Meaning Questions',
                 content: {
                     meaning_select: {
                         type: 'dropdown',
                         label: 'Reading to Play',
                         hover_tip: 'Select which reading should be played after answering a meaning question.',
                         //full_width: true,
                         content: {
                             same_as_heard: 'reading heard in reading question',
                             different_from_heard: 'reading you didn\'t hear',
                             same_as_typed: 'reading typed in reading question',
                             different_from_typed: 'reading you didn\'t type',
                             different_from_all: 'reading you didn\'t type / hear',
                             random: 'random reading',
                             none: 'none',
                         }
                     },
                     meaning_no_reading_spoilers: {
                         type: 'checkbox',
                         label: 'Don\'t Play Reading if Reading Question still Unfinished',
                         hover_tip: 'Do not play the reading if the reading question has not yet been correctly answered for this review session.',
                     },
                     meaning_play_when_wrong: {
                         type: 'checkbox',
                         label: 'Autoplay on Incorrect Answer too',
                         hover_tip: 'Autoplay audio even when you get the answer wrong.',
                     },
                 },
             },
             random_va: {
                 type: 'checkbox',
                 label: 'Randomize Voice Actor',
                 hover_tip: 'Randomize the voice actor each time audio is played.',
             },
             speed: {
                 type: 'number',
                 label: 'Speed',
                 hover_tip: 'Speed Factor at which to Play Audio.',
             },
         },
        }


    //
    // Invoke main()
    //
    main();

})(window.jQuery, window.wkof, window.wanakana);
3 Likes

Amazing! Really appreciate it (as well as the original script on which it’s based)

2 Likes

Hello,
I am encountering a bug where the audio is played before I was asked the item’s reading.
I am using seanblue’s version v1.0.2 of WaniKani Review Audio Tweak 2.

was this issue already observed and fixed a later version?

Thanks & Regards

Do you still get the bug if you disable all other scripts and reload the page? Does it happen before every reading vocab question or only some of them?

(I haven’t seen a bug like this in either seanblue’s version or my fork.)

1 Like

Anyway to get the audio to play for reading questions on both correct and incorrect answers? Currently it only plays audio when a meaning question is entered in wrong, and no audio plays during any reading questions.

image

Could you give a specific example where and walk me through exactly what you want to happen in that example?

Also, did you try checking the checkbox “Autoplay on Incorrect Answer” in the “Reading Questions” part of the settings menu you screenshotted? Also, keep in mind that no audio will play for kanji (pink) or radical (blue) questions since WaniKani doesn’t have audio for those question types.

Last, what version browser are you using?

I’ve updated my version of the script to allow setting the audio playback speed in settings.

Checking the “Autoplay on incorrect” answer will get the audio to play, but only on incorrect answers. I was wanting the audio to play on correct and incorrect answers. Might just have to add a setting for that. Seems like the booleans are false by default.

image

Also I would create a Greasy Fork link for your script instead of updating a prior post.
https://greasyfork.org/en

No, by default (box unchecked) audio autoplays only on right answers. If box is checked, then it autoplays on both right and wrong answers. Is it not doing that when you check the box? What browser version are you using?

Correct, regardless of the box being checked or not. It is not autoplaying on correct answers.
Using Chrome Version 89.0.4389.82 (Official Build) (64-bit)

Is the following an accurate description of the problem you are encountering (or at least one of the problems you are encountering):

  1. Start an entirely new review session by opening a new tab that has not been used for anything before and typing in https://www.wanikani.com/review/session. Do not just go to this page in an existing tab.
  2. Refresh the page until the very first question is a reading question about a vocabulary item
  3. Answer the question correctly.
  4. Audio does not play.

Correct. I just followed those steps and this is the first review item that showed up, answered it. No audio play (unless I press “J”) on the keyboard of course.

Hm, when I do those same steps in the same browser, it works for me. (Audio plays automatically.) Can you try disabling all extensions except TamperMonkey (I assume you installed my script via TamperMonkey?) and disabling all other userscripts and try the steps again?

Also, if the audio still fails to play even when you do that, can you check the console (ctrl+shift+i) and see if there are any error messages? (Make sure to check the console after the audio should have played but before leaving the page.)

Edit: You can leave WKOF enabled too if you want (just make sure to mention whether or not you did).

But before you go to the trouble of trying to disable all other scripts, you can first go to https://www.wanikani.com/settings/app and make sure that “Autoplay audio in reviews” is set to “Yes” and see if that was the issue.

Didn’t even know these existed, it’s been so long since I’ve been to the settings page, lol.

That being said, during reviews audio plays according to the below:

Reading Meaning
Correct Yes Yes
Wrong Yes No

image

I was seeing if there was a way to prevent audio from playing during the “Meaning” question. If I disable the script then of course it won’t play audio on meaning questions, but also won’t play audio on wrong reading questions either.