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

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 WaniKani — Log in 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.

I’ve updated the script to offer the option of not playing audio for meaning questions. If you select “none” for “Reading to Play” it should do what you want.

1 Like

Looks like this did the trick! Good work @est_fills_cando!

image

1 Like

@est_fills_cando Was using a different PC today and noticed that the script is playing the audio twice.

  • When you press enter to submit your answer the audio plays
  • When you press enter to go to the next question after seeing if the answer is correct or wrong the audio plays

My settings are the same for the script and I have not changed the WaniKani account settings.

So if I understand correctly, there is one computer where it works (only plays audio once) and another computer with the exact same settings for the script where it plays audio twice?

Are the browser versions the same on the two computers?

Is there any difference at all in the other installed scripts on the two computers?

Does the problem still happen if all other scripts except WaniKani Open Framework and Review Audio Tweak Fork are disabled?

Can you check whether all the settings at WaniKani — Log in are exactly the same when viewed on each computer? (I’m not sure whether those settings apply to your entire account or just the current pc you are using.)