// ==UserScript==
// @name Duolingo HearEverything
// @namespace http://tampermonkey.net/
// @version 0.20
// @description Let's you hear phrases and words from single choice questions based on your browsers speech synthesis (right now only phrases).
// @author Esh
// @match https://*.duolingo.com/*
// @grant GM_setValue
// @grant GM_getValue
// @downloadURL none
// ==/UserScript==
// 0.7: Mutation Observer instead of setInterval
// 0.6: Add voice to choices on click
// 0.6.1: check why not the innerText of the answer is displayed in the full sentence?
// 0.8.1: fix speaking numbers for options
// 0.9: Move speak button near the continue button
// 0.10.2: set better newPage = true - deleted
// maybe bind it to the continue button
// Continue
// data-test="blame blame-correct" and blame-incorrect could be a good try
// maybe bound it to the check button?
// if check-button && reset = false newPage = true, reset = true
// if read-button created reset = false
// div class= _10vOG && data-test="player-next" && disabled
// 0.10.3: debug quirks from setting newPage
// 0.11: cleaned up some code
// 0.12: finally got rid of the new page problem
// 0.13: show some debug infos on the page
// 0.14: more working reading
// 0.15: added more challenges to read
// 0.16: Challenges, which work (some partially)
// FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION, TAP_COMPLETE
// 0.17: added shortcut ALT+l
// 0.18: listening button for DIALOGUE and bugfixing TRANSLATE
// 0.19: better listening button
// 0.20: Voice selection
// ALT + l could work for Duo original listening also
// TODO: clean up the script
// TODO: translateInput
// data-test="challenge-translate-input
// but data-test="challenge challenge-listen" is not good
// TODO: selectTranscription should not bind EventListeners because they already have some from Duo
// data-test="challenge challenge-selectTranscription"
// TODO: gapFill
// data-test="challenge challenge-gapFill" to find out if it is such a challenge
// TODO: data-test="challenge challenge-translate
// data-test="challenge-translate-prompt
// select words to a sentence, word bank
// TODO: challenge dialogue
// data-test="challenge challenge-dialogue"
// looks like the same rules as challenge-form-prompt
// data-test="challenge-choice" instead of aria-select?
// TODO: completeReverseTranslation
// data-test="challenge challenge-completeReverseTranslation"
// make harder gives you: data-test="challenge-translate-input"
// TODO: start also on first page
// TODO: Tipp-Page
// TODO: Hoots
// TODO: selector where the speak button should be
// TODO: translateInput speaker position top - where to place?
// TODO: Voice selector (automagic) - 1/4 done
// TODO: Add autoplay selector
// more examples on the bottom
const buttonPosition = 'bottom'; // bottom / top allowed
let voiceSelect;
let lang = 'fr';
let autoplay = false;
const DEBUG = false;
/* Find your language number here
0: SpeechSynthesisVoice {voiceURI: "Microsoft Katja Online (Natural) - German (Germany)", name: "Microsoft Katja Online (Natural) - German (Germany)", lang: "de-DE", localService: false, default: true}
1: SpeechSynthesisVoice {voiceURI: "Microsoft Natasha Online (Natural) - English (Australia)", name: "Microsoft Natasha Online (Natural) - English (Australia)", lang: "en-AU", localService: false, default: false}
2: SpeechSynthesisVoice {voiceURI: "Microsoft Clara Online (Natural) - English (Canada)", name: "Microsoft Clara Online (Natural) - English (Canada)", lang: "en-CA", localService: false, default: false}
3: SpeechSynthesisVoice {voiceURI: "Microsoft Mia Online (Natural) - English (United Kingdom)", name: "Microsoft Mia Online (Natural) - English (United Kingdom)", lang: "en-GB", localService: false, default: false}
4: SpeechSynthesisVoice {voiceURI: "Microsoft Neerja Online (Natural) - English (India)", name: "Microsoft Neerja Online (Natural) - English (India)", lang: "en-IN", localService: false, default: false}
5: SpeechSynthesisVoice {voiceURI: "Microsoft Aria Online (Natural) - English (United States)", name: "Microsoft Aria Online (Natural) - English (United States)", lang: "en-US", localService: false, default: false}
6: SpeechSynthesisVoice {voiceURI: "Microsoft Guy Online (Natural) - English (United States)", name: "Microsoft Guy Online (Natural) - English (United States)", lang: "en-US", localService: false, default: false}
7: SpeechSynthesisVoice {voiceURI: "Microsoft Elvira Online (Natural) - Spanish (Spain)", name: "Microsoft Elvira Online (Natural) - Spanish (Spain)", lang: "es-ES", localService: false, default: false}
8: SpeechSynthesisVoice {voiceURI: "Microsoft Dalia Online (Natural) - Spanish (Mexico)", name: "Microsoft Dalia Online (Natural) - Spanish (Mexico)", lang: "es-MX", localService: false, default: false}
9: SpeechSynthesisVoice {voiceURI: "Microsoft Sylvie Online (Natural) - French (Canada)", name: "Microsoft Sylvie Online (Natural) - French (Canada)", lang: "fr-CA", localService: false, default: false}
10: SpeechSynthesisVoice {voiceURI: "Microsoft Denise Online (Natural) - French (France)", name: "Microsoft Denise Online (Natural) - French (France)", lang: "fr-FR", localService: false, default: false}
11: SpeechSynthesisVoice {voiceURI: "Microsoft Swara Online (Natural) - Hindi (India)", name: "Microsoft Swara Online (Natural) - Hindi (India)", lang: "hi-IN", localService: false, default: false}
12: SpeechSynthesisVoice {voiceURI: "Microsoft Elsa Online (Natural) - Italian (Italy)", name: "Microsoft Elsa Online (Natural) - Italian (Italy)", lang: "it-IT", localService: false, default: false}
13: SpeechSynthesisVoice {voiceURI: "Microsoft Nanami Online (Natural) - Japanese (Japan)", name: "Microsoft Nanami Online (Natural) - Japanese (Japan)", lang: "ja-JP", localService: false, default: false}
14: SpeechSynthesisVoice {voiceURI: "Microsoft SunHi Online (Natural) - Korean (Korea)", name: "Microsoft SunHi Online (Natural) - Korean (Korea)", lang: "ko-KR", localService: false, default: false}
15: SpeechSynthesisVoice {voiceURI: "Microsoft Colette Online (Natural) - Dutch (Netherlands)", name: "Microsoft Colette Online (Natural) - Dutch (Netherlands)", lang: "nl-NL", localService: false, default: false}
16: SpeechSynthesisVoice {voiceURI: "Microsoft Zofia Online (Natural) - Polish (Poland)", name: "Microsoft Zofia Online (Natural) - Polish (Poland)", lang: "pl-PL", localService: false, default: false}
17: SpeechSynthesisVoice {voiceURI: "Microsoft Francisca Online (Natural) - Portuguese (Brazil)", name: "Microsoft Francisca Online (Natural) - Portuguese (Brazil)", lang: "pt-BR", localService: false, default: false}
18: SpeechSynthesisVoice {voiceURI: "Microsoft Svetlana Online (Natural) - Russian (Russia)", name: "Microsoft Svetlana Online (Natural) - Russian (Russia)", lang: "ru-RU", localService: false, default: false}
19: SpeechSynthesisVoice {voiceURI: "Microsoft Emel Online (Natural) - Turkish (Turkey)", name: "Microsoft Emel Online (Natural) - Turkish (Turkey)", lang: "tr-TR", localService: false, default: false}
20: SpeechSynthesisVoice {voiceURI: "Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)", name: "Microsoft Xiaoxiao Online (Natural) - Chinese (Mainland)", lang: "zh-CN", localService: false, default: false}
21: SpeechSynthesisVoice {voiceURI: "Microsoft Yunyang Online (Natural) - Chinese (Mainland)", name: "Microsoft Yunyang Online (Natural) - Chinese (Mainland)", lang: "zh-CN", localService: false, default: false}
22: SpeechSynthesisVoice {voiceURI: "Microsoft HiuGaai Online (Natural) - Chinese (Hong Kong)", name: "Microsoft HiuGaai Online (Natural) - Chinese (Hong Kong)", lang: "zh-HK", localService: false, default: false}
23: SpeechSynthesisVoice {voiceURI: "Microsoft HsiaoYu Online (Natural) - Chinese (Taiwan)", name: "Microsoft HsiaoYu Online (Natural) - Chinese (Taiwan)", lang: "zh-TW", localService: false, default: false}
24: SpeechSynthesisVoice {voiceURI: "Microsoft Hedda - German (Germany)", name: "Microsoft Hedda - German (Germany)", lang: "de-DE", localService: true, default: false}
25: SpeechSynthesisVoice {voiceURI: "Microsoft Katja - German (Germany)", name: "Microsoft Katja - German (Germany)", lang: "de-DE", localService: true, default: false}
26: SpeechSynthesisVoice {voiceURI: "Microsoft Stefan - German (Germany)", name: "Microsoft Stefan - German (Germany)", lang: "de-DE", localService: true, default: false}
*/
var synth = window.speechSynthesis;
var voices = [];
//let parent = HTMLElement;
var newPage = false;
var addedSpeech = false;
var speakerButton = `
`;
speakerButton = `
`;
/* speakerButton = `
`;
*/
// Element definitions
const WRONG_ANSWER_CLASS = '._1UqAr._1sqiF';
const ANSWER_CLASS = '._1UqAr';
const RIGHT_ANSWER_CLASS = '._1UqAr._1Nmv6';
const RIGHT_CLASS = '._1Nmv6';
const WRONG_CLASS = '._1sqiF';
const ANSWER_HEADLINE = '._1x6Dk';
const ANSWER_CONTAINER = '._2ez4I';
// used page types
const FORM = 'challenge challenge-form';
const TRANSLATE = 'challenge challenge-translate';
// almost the same than challenge-form
// add
const DIALOGUE = 'challenge challenge-dialogue';
const DIALOGUE_SPEAKER_CLASS = '_29e-M _39MJv _2Hg6H';
// unused page types
// almost the same as challenge-form
const GAP_FILL = 'challenge challenge-gapFill';
// looks like it should be optional to read it loud
const LISTEN_COMPREHENSION = 'challenge challenge-listenComprehension';
// hint-sentence > hint-token (last one = ...) + aria-checked = true
const READ_COMPREHENSION = 'challenge challenge-readComprehension';
// almost the same as challenge-translate
const COMPLETE_REVERSE_TRANSLATION = 'challenge challenge-completeReverseTranslation';
const LISTEN = 'challenge challenge-listen';
// Word bank stuff
// is not read out by Duo all the time (but at "Tap what you hear");
const LISTEN_TAP = 'challenge challenge-listenTap';
// has word bank and some filled up answer to read
const TAP_COMPLETE = 'challenge challenge-tapComplete';
/* pack all spans and in the div only where _2Hlc9 is missing
C'
est
Junior
et
son
,Eddy.
*/
// duo reads aloud
const SELECT_TRANSCRIPTION = 'challenge challenge-selectTranscription';
// allowed challenge types
const TEST = [FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION, TAP_COMPLETE];
var buttonDisabled = true;
function setVoice() {
voiceSelect = GM_getValue('voiceSelect', 1000);
console.debug('HearEverything: stored voice = ' + voiceSelect);
var duoState = JSON.parse(localStorage.getItem('duo.state'));
lang = duoState.user.learningLanguage;
if(voiceSelect == 1000) {
for (let i = 0; i < voices.length; i++) {
if(voices[i].lang.includes(lang)) {
voiceSelect = i;
console.debug('HearEverything: auto set voice');
}
}
}
console.debug(`HearEverything: voice = ${voiceSelect}, learning language = ${lang}`);
}
// toggles visibility
function togglePopout(id) {
let popout = document.getElementById(id);
popout.style.display === "none" ? popout.style.display = "block" : popout.style.display = "none";
}
function addConfig() {
if(!document.querySelector('#hearEverythingGear') && document.querySelector('[role="progressbar"]')) {
let configButton = document.createElement('button');
configButton.setAttribute('id', 'hearEverythingGear');
configButton.setAttribute('class', '_2hiHn _2kfEr _1nlVc _2fOC9 UCrz7 t5wFJ _1DC8p _2jNpf');
configButton.setAttribute('style', `grid-column: 3/3; background-image:url(//d35aaqx5ub95lt.cloudfront.net/images/gear.svg);
background-position: 0px 0px; background-repeat: no-repeat; background-size: contain;`);
let config = document.createElement('div');
config.setAttribute('class','_3yqw1 np6Tv _1Xlh1');
config.setAttribute('style','display: none; position: fixed; margin-top: 1rem;');
config.setAttribute('id','hearEverythingConfig');
let options = '';
for (let i = 0; i < voices.length; i++) {
options += ``;
}
config.innerHTML = `
Language
`;
document.querySelector('[role="progressbar"]').insertAdjacentElement('afterend',configButton);
configButton.insertAdjacentElement('afterend', config);
configButton.addEventListener('click', function () { togglePopout('hearEverythingConfig'); });
let configLanguage = document.getElementById('configLanguage')
configLanguage.querySelector('[value="' + voiceSelect + '"]').setAttribute('selected', true);
configLanguage.addEventListener('change', function() {
voiceSelect = configLanguage.options[configLanguage.selectedIndex].value;
GM_setValue('voiceSelect', voiceSelect);
setVoice();
});
}
}
function start() {
if (document.querySelector('[data-test="challenge-header"]')) {
// console.debug(new Date().getTime() + ' - Mutation');
//buttonCheck();
addConfig();
buildDebug();
checkNewPage();
let challenge = getChallengeType();
// we should wait after the first add of listeners for the user to click 'check'
/* let formPrompt = document.querySelector('[data-test="challenge-form-prompt"]');
let translateInput = document.querySelector('[data-test="challenge-translate-input"]'); */
if(challenge!=null) {
// if we have an accepted challenge, we want to process this page, so we set newPage;
if (newPage === true) {
// if answer (right or wrong) is displayed
// .GNWQJ instead of _1UqAr - so the element discussion to append is available
// ._3MD8I also available for wrong answers
if (document.querySelector('._3MD8I')!=null) {
renderAnswerSpeakButton();
} /*else if((addedSpeech===false)&&(document.querySelectorAll('[aria-checked]').length!==0)) {
console.groupCollapsed('Add hearing abilities (options)');
console.debug(getChallengeType()[0]);
// if eventListeners were not bound yet
addSpeech();
addedSpeech = true;
console.debug('Now speech is attached to options');
console.groupEnd('Add hearing abilities (options)');
} */
}
} else {
// we detected no content to use, so we are not interested in this page
newPage = false;
//document.querySelector('[data-test="player-skip"]') ? console.debug('Possible new page') : '';
}
} // end challenge-header detection
}
function renderAnswerSpeakButton() {
// document.querySelector('._1UqAr._1Nmv6').innerText; Always the answer?
// let test = [FORM, TRANSLATE, DIALOGUE, GAP_FILL, COMPLETE_REVERSE_TRANSLATION];
console.groupCollapsed('Add hearing abilities (input)');
console.debug(getChallengeType()[0]);
console.debug('Solution detected');
let read = '';
let challenge = getChallengeType()[0];
if (challenge === FORM) {
let formPrompt = document.querySelector('[data-test="challenge-form-prompt"]');
if (formPrompt !== null) { read = readFormPrompt(formPrompt); }
}
if (challenge === TRANSLATE) {
if(document.querySelector('._1UqAr._1Nmv6')) {
read = document.querySelector('._1UqAr._1Nmv6').innerText;
} else if(document.querySelector('._1UqAr._1sqiF')) {
if (document.querySelector('._1UqAr._1sqiF').lastElementChild) {
read = document.querySelector('._1UqAr._1sqiF').lastElementChild.innerText;
} else {
read = document.querySelector('._1UqAr._1sqiF').innerText;
}
} else {
let translateInput = document.querySelector('[data-test="challenge-translate-input"]');
if (translateInput !== null && translateInput.lang === lang) {
//read = readTranslateInput(translateInput);
read = translateInput.textContent;
}
}
}
if (challenge === DIALOGUE) {
//let hints = document.querySelectorAll('[data-test="hint-token"]');
let speaker1 = document.querySelector('[class="' + DIALOGUE_SPEAKER_CLASS + '"]').innerText;
//hints.forEach(function(hint) { speaker1 += hint; });
let speaker2;
if(document.querySelector('[data-test="blame blame-incorrect"]')) {
speaker2 = document.querySelector('._1UqAr._1sqiF').innerText;
} else {
speaker2 = document.querySelector('[aria-checked="true"]').querySelector('[data-test="challenge-judge-text"]').innerText;
}
read = speaker1 + '\n' + speaker2;
}
if (challenge === GAP_FILL) {}
if (challenge === COMPLETE_REVERSE_TRANSLATION) {
let translateInput = document.querySelector('[data-test="challenge-translate-input"]');
if (translateInput !== null && translateInput.lang === lang) { read = readTranslateInput(translateInput); }
}
if (challenge === TAP_COMPLETE) {
let tap = document.querySelector('[data-test="hint-sentence"]').parentNode;
if (tap !== null) {
if(document.querySelector('._1UqAr._1Nmv6')) {
read = document.querySelector('._1UqAr._1Nmv6').innerText;
} else if(document.querySelector('._1UqAr._1sqiF')) {
read = document.querySelector('._1UqAr._1sqiF').innerText;
} else {
read = readTapComplete(tap);
}
}
}
console.debug('Listening to: ' + read);
let utter = new SpeechSynthesisUtterance(read);
utter.voice = voices[voiceSelect];
// synth.speak(utter);
//dataPrompt.insertAdjacentHTML('afterend',``);
// add speaker button to answer and fill in the correct answer in the headline
updateText(read);
// if we have added the speaker button, we find it in the document
let speak = document.querySelector('#speak');
if(speak) {
speak.addEventListener('click',function(){synth.speak(utter);});
console.debug('EventListener bound to speak button');
if(document.querySelector('#mySentence')) document.querySelector('#mySentence').innerText = read;
document.removeEventListener('keydown', myShortcutListener);
document.addEventListener('keydown', myShortcutListener);
document.getElementById('speak').title = read;
} else {
console.debug('No speak button found');
}
newPage = false;
console.debug('Now it\'s an old page');
addedSpeech = false;
console.debug('Reset: speech isn\'t attached to options any more');
if (DEBUG) document.querySelector('#myOptions').innerText = 'disabled';
// if you like autoplay, it waits 1 second an plays it
if (autoplay) setTimeout(synth.speak(utter),1000);
console.groupEnd('Add hearing abilities (input)');
}
function myShortcutListener(event) {
let speak = document.querySelector('#speak');
// ALT + Enter combo
if (event.altKey && event.key === 'l') {
speak.click();
console.debug('Duolingo HearEverything: alt = ' + event.altKey + ' + ' + event.key);
}
}
function readTapComplete(tap) {
let read = '';
let words = tap.childNodes;
words.forEach(function(word) {
if (word.nodeName === 'SPAN') read += word.children[0].innerText;
if (word.nodeName === 'DIV') {
read += word.querySelector('[class="_2Z2xv"]').children[0].innerText;
}
});
return read;
}
// gives some debug information directly in the Duo-GUI
function buildDebug() {
if(DEBUG) {
if(!document.querySelector('#myChallenge')) {
let debug = document.createElement('div');
debug.innerHTML = `Challenge-Name: ${getChallengeType(true)[0]}Sentence to speak: Speak options: disabled`;
debug.style = "font-size: small; text-align:left; display:grid;";
document.querySelector('[data-test="challenge-header"]').insertAdjacentElement('afterend', debug);
}
}
}
function checkNewPage() {
if(!document.querySelector('#myNewPage')) {
let nP = document.createElement('div');
nP.id = 'myNewPage';
document.querySelector('[data-test="challenge-header"]').insertAdjacentElement('afterend', nP);
console.debug('---- div - newPage ----');
newPage = true;
} else {
console.debug('---- div - oldPage ----');
}
}
function readTranslateInput(translateInput) {
/* TODO: Word bank
*/
// does not exist on right answers?!
let read = '';
let solution = document.querySelector(ANSWER_CLASS);
if (document.querySelector(ANSWER_CONTAINER).childNodes.length === 1) {
// right answer
read = translateInput.innerHTML;
console.debug('Translate Input: right answer');
/* another correct answer seems not to be recognized as correct answer
it reads the other answer given not my answer
Another correct solution:
Une pizza, s'il vous plaît.
*/
} else if (solution.classList.contains(RIGHT_CLASS)) {
read = '';
// pay attention to accents
// better read the solution here
/*
Pay attention to the accents.
J'habite en Europe.
*/
// or
/*
Julia travaille à Paris.
*/
let span = solution.querySelector('span');
if(span) {
for (let i=0; i
You have a typo.
un passeport américain
*/
/* did not work for - it reads out "undefinedundefined"
Common mistake!
Correct solution:Tu prends la voiture ?
*/
console.debug('Translate Input: right answer with little mistake');
} else {
/* // totally wrong answer
let span = solution.querySelector('span');
if(span) {
for (let i=0; i
${speakerButton}
${t}