From 556f9313ff951bd00eb146cdb39cb460bbb6c936 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 16:34:36 -0300 Subject: [PATCH 01/13] refactor: change name of HTML elements variable and remove some of the global variables. --- index.html | 12 +++---- speak.js | 98 ++++++++++++++++++++++++++---------------------------- style.css | 4 +-- 3 files changed, 55 insertions(+), 59 deletions(-) diff --git a/index.html b/index.html index 1d91edb..0598bfe 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + quackspeak: Text-to-quack
@@ -36,8 +36,7 @@ max="2" value="0.45" step="0.05" - class="slider" - id="pitchSlider" /> + class="js-pitch-slider slider" />
Interval
@@ -47,8 +46,7 @@ max="200" value="175" step="1" - class="slider" - id="intervalSlider" /> + class="js-interval-slider slider" />
@@ -66,7 +64,7 @@

quackSpeak 🦆

src="assets/dialogue.png" alt="" draggable="false" /> -
+

diff --git a/speak.js b/speak.js index 68b41c0..7f9a00e 100644 --- a/speak.js +++ b/speak.js @@ -1,41 +1,40 @@ const voicePath = "assets/voice/"; var audioContext; var sounds = []; -var inputText; -var dialogueText; -var pitchSlider; -var intervalSlider; var speaking = false; var speakingInterval = "CLEARED"; -var pitchRate = 1; -var interval = 175; -document.addEventListener("DOMContentLoaded", function (e) { - inputText = document.getElementById("inputText"); - dialogueText = document.getElementById("dialogue-text"); - pitchSlider = document.getElementById("pitchSlider"); - intervalSlider = document.getElementById("intervalSlider"); - - pitchSlider.oninput = function () { - pitchRate = 0.4 * Math.pow(4, Number(this.value)) + 0.2; // Had to look this up on the internet! - }; - - intervalSlider.oninput = function () { - interval = Number(this.value); - }; - - document.addEventListener("pointerdown", init); -}); - -function init() { - // Create AudioContext - audioContext = new AudioContext(); - - // Load sound files. - loadSounds(); +/** @type {HTMLTextAreaElement} */ +const $inputText = document.querySelector('.js-input-text'); +/** @type {HTMLDivElement} */ +const $dialogueText = document.querySelector('.js-dialogue-text'); +/** @type {HTMLInputElement} */ +const $pitchSlider = document.querySelector('.js-pitch-slider'); +/** @type {HTMLInputElement} */ +const $intervalSlider = document.querySelector('.js-interval-slider'); + +/** + * Returns the pitch rate value based on the value of the $pitchSlider + * element in the page. + * + * If the $pitchSlider has not been changed, it returns 1 by default. + * + * @returns {number} The pitch rate. + */ +function calculatePitchRate() { + return 0.4 * Math.pow(4, Number(this.value)) + 0.2 | 1; +} - // Remove event listener. - document.removeEventListener("pointerdown", init); +/** + * Returns the interval based in the value of the $intervalSlider element + * in the page. + * + * If the $intervalSlider has not been changed, it returns 175 by default. + * + * @returns {number} The interval. + */ +function calculateInterval() { + return $intervalSlider.value | 175; } function loadSounds() { @@ -47,7 +46,6 @@ function loadSounds() { addSound(voicePath + "woof.mp3", "woof"); } -// Play from buffer. function play(buffer, rate) { const source = audioContext.createBufferSource(); source.buffer = buffer; @@ -56,14 +54,12 @@ function play(buffer, rate) { source.start(); } -// Load sound buffer from path async function load(path) { const response = await fetch("./" + path); const arrayBuffer = await response.arrayBuffer(); return audioContext.decodeAudioData(arrayBuffer); } -// Add sound file to sounds array function addSound(path, index) { load(path).then((response) => { sounds[index] = response; @@ -73,36 +69,35 @@ function addSound(path, index) { } function playClip() { - // clear speaking intervals and dialogue box clear(); - if (inputText.value !== "") { - if (inputText.value.length > 90) { - dialogueText.style.fontSize = - (3 * Math.pow(0.993, inputText.value.length) + 0.9) + if ($inputText.value !== "") { + if ($inputText.value.length > 90) { + $dialogueText.style.fontSize = + (3 * Math.pow(0.993, $inputText.value.length) + 0.9) .toFixed(1) - .toString() + "vw"; // I don't remember how I computed this, but it works + .toString() + "vw"; } else { - dialogueText.style.fontSize = "2.5vw"; + $dialogueText.style.fontSize = "2.5vw"; } - // get interval ID + const pitchRate = calculatePitchRate(); + const interval = calculateInterval(); + speakingInterval = speak( - inputText.value.replace(/(\r|\n)/gm, " "), // Replace newlines with whitespace. + $inputText.value.replace(/(\r|\n)/gm, " "), interval, pitchRate ); } } -// Clear speaking intervals and dialogue box function clear() { if (speakingInterval !== "CLEARED") { clearInterval(speakingInterval); } - // reset everything. - dialogueText.innerHTML = ""; + $dialogueText.innerHTML = ""; speaking = false; speakingInterval = "CLEARED"; } @@ -111,25 +106,24 @@ function speak(text, time, rate, ignore = false) { if (!speaking || ignore) { speaking = true; - // text processing var arrayTxt = text.split(""); arrayTxt.push(" "); var length = text.length; + const pitchRate = calculatePitchRate(); + var intervalID = setLimitedInterval( function (i) { if (text[i] != " ") { - // for quack, pitch rate = 1 is fine! play(sounds["quack"], pitchRate); } - dialogueText.innerHTML += arrayTxt[i]; + $dialogueText.innerHTML += arrayTxt[i]; }, time, length, null, function () { - // set to false when it ends speaking = false; } ); @@ -165,3 +159,7 @@ function setLimitedInterval( return id; } + +audioContext = new AudioContext(); +loadSounds(); + diff --git a/style.css b/style.css index a8120e9..8d0fa4c 100644 --- a/style.css +++ b/style.css @@ -149,7 +149,7 @@ button { margin-top: 2rem; } -#dialogue-text { +.dialogue-text { left: 14%; max-height: 70%; max-width: 70%; @@ -158,7 +158,7 @@ button { top: 20%; } -#dialogue-text::-webkit-scrollbar { +.dialogue-text::-webkit-scrollbar { display: none; } From 92ddba65cf9b2d1b8946b195c6fd3f61d0d33720 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 16:52:32 -0300 Subject: [PATCH 02/13] refactor: add function to decode audio data and rename `assets/voice` to `assets/voices`. --- assets/{voice => voices}/bark.wav | Bin assets/{voice => voices}/meow.wav | Bin assets/{voice => voices}/moo.mp3 | Bin assets/{voice => voices}/quack.mp3 | Bin assets/{voice => voices}/santa.wav | Bin assets/{voice => voices}/woof.mp3 | Bin speak.js | 28 +++++++++++++++++++++++++++- 7 files changed, 27 insertions(+), 1 deletion(-) rename assets/{voice => voices}/bark.wav (100%) rename assets/{voice => voices}/meow.wav (100%) rename assets/{voice => voices}/moo.mp3 (100%) rename assets/{voice => voices}/quack.mp3 (100%) rename assets/{voice => voices}/santa.wav (100%) rename assets/{voice => voices}/woof.mp3 (100%) diff --git a/assets/voice/bark.wav b/assets/voices/bark.wav similarity index 100% rename from assets/voice/bark.wav rename to assets/voices/bark.wav diff --git a/assets/voice/meow.wav b/assets/voices/meow.wav similarity index 100% rename from assets/voice/meow.wav rename to assets/voices/meow.wav diff --git a/assets/voice/moo.mp3 b/assets/voices/moo.mp3 similarity index 100% rename from assets/voice/moo.mp3 rename to assets/voices/moo.mp3 diff --git a/assets/voice/quack.mp3 b/assets/voices/quack.mp3 similarity index 100% rename from assets/voice/quack.mp3 rename to assets/voices/quack.mp3 diff --git a/assets/voice/santa.wav b/assets/voices/santa.wav similarity index 100% rename from assets/voice/santa.wav rename to assets/voices/santa.wav diff --git a/assets/voice/woof.mp3 b/assets/voices/woof.mp3 similarity index 100% rename from assets/voice/woof.mp3 rename to assets/voices/woof.mp3 diff --git a/speak.js b/speak.js index 7f9a00e..838767d 100644 --- a/speak.js +++ b/speak.js @@ -1,4 +1,4 @@ -const voicePath = "assets/voice/"; +const voicePath = "assets/voices/"; var audioContext; var sounds = []; var speaking = false; @@ -37,6 +37,21 @@ function calculateInterval() { return $intervalSlider.value | 175; } +/** + * Returns the decoded audio data from a voice file in the server. + * + * @param {string} The file name that is under the path `assets/voices/`, + * for example: `quack.mp3`. + */ +async function createDecodedAudioDataFromVoiceFile(fileName) { + const voiceFileDirectoryPath = 'assets/voices/' + fileName; + const fileResponse = await fetch(voiceFileDirectoryPath); + const arrayBuffer = await fileResponse.arrayBuffer(); + const audioContext = new AudioContext(); + + return audioContext.decodeAudioData(arrayBuffer); +} + function loadSounds() { addSound(voicePath + "quack.mp3", "quack"); addSound(voicePath + "santa.wav", "santa"); @@ -163,3 +178,14 @@ function setLimitedInterval( audioContext = new AudioContext(); loadSounds(); +(async () => { + const voices = { + quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), + santa: await createDecodedAudioDataFromVoiceFile('santa.wav'), + bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), + meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), + moo: await createDecodedAudioDataFromVoiceFile('moo.mp3'), + woof: await createDecodedAudioDataFromVoiceFile('woof.mp3'), + }; +})(); + From ba2a87cdef66264822126d56ea2a8790b1bf4df4 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 17:10:59 -0300 Subject: [PATCH 03/13] refactor: add function to calculate dialogue text font size. I have identified that the problem reported in the issue #6 is due to the fact that the dialogue text font size uses the `vw` unit. This make it look small in small screen and look large in large screen size as well. Marked for further refactor. --- speak.js | 52 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/speak.js b/speak.js index 838767d..9d4a91d 100644 --- a/speak.js +++ b/speak.js @@ -52,16 +52,28 @@ async function createDecodedAudioDataFromVoiceFile(fileName) { return audioContext.decodeAudioData(arrayBuffer); } -function loadSounds() { - addSound(voicePath + "quack.mp3", "quack"); - addSound(voicePath + "santa.wav", "santa"); - addSound(voicePath + "bark.wav", "bark"); - addSound(voicePath + "meow.wav", "meow"); - addSound(voicePath + "moo.mp3", "moo"); - addSound(voicePath + "woof.mp3", "woof"); +/** + * Calculates the font size in rem units to be used in the $dialogueText element + * based on the quantity of characters that were inserted in the $inputText + * element. + * + * @returns {number} The font size in rem units to be used in the $dialogueText + * element. + */ +function calculateDialogueTextFontSize() { + const numberOfCharacters = $inputText.value.length; + const numberOfCharactersToStartChangingStyle = 90; + + return numberOfCharacters > numberOfCharactersToStartChangingStyle + ? 3 * 0.993 ** numberOfCharacters + 0.9 + : 2.5 } +/** + * Plays audio + */ function play(buffer, rate) { + const audioContext = new AudioContext(); const source = audioContext.createBufferSource(); source.buffer = buffer; source.playbackRate.value = rate; @@ -69,6 +81,15 @@ function play(buffer, rate) { source.start(); } +function loadSounds() { + addSound(voicePath + "quack.mp3", "quack"); + addSound(voicePath + "santa.wav", "santa"); + addSound(voicePath + "bark.wav", "bark"); + addSound(voicePath + "meow.wav", "meow"); + addSound(voicePath + "moo.mp3", "moo"); + addSound(voicePath + "woof.mp3", "woof"); +} + async function load(path) { const response = await fetch("./" + path); const arrayBuffer = await response.arrayBuffer(); @@ -86,15 +107,14 @@ function addSound(path, index) { function playClip() { clear(); - if ($inputText.value !== "") { - if ($inputText.value.length > 90) { - $dialogueText.style.fontSize = - (3 * Math.pow(0.993, $inputText.value.length) + 0.9) - .toFixed(1) - .toString() + "vw"; - } else { - $dialogueText.style.fontSize = "2.5vw"; - } + if ($inputText.value) { + /** + * TODO: the fact that the dialogue text font size is using `vw` unit is + * making it look small in small width screen, such as mobile devices, as + * reported in the issue #6. + */ + const dialogueTextFontSize = calculateDialogueTextFontSize() + 'vw'; + $dialogueText.style.fontSize = dialogueTextFontSize; const pitchRate = calculatePitchRate(); const interval = calculateInterval(); From 7d6da87629004a9208ca5fa0e213d2654e82f9d7 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 18:36:21 -0300 Subject: [PATCH 04/13] feat: add function to get the input text with some treatments. --- speak.js | 65 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 39 insertions(+), 26 deletions(-) diff --git a/speak.js b/speak.js index 9d4a91d..ecb929b 100644 --- a/speak.js +++ b/speak.js @@ -13,6 +13,18 @@ const $pitchSlider = document.querySelector('.js-pitch-slider'); /** @type {HTMLInputElement} */ const $intervalSlider = document.querySelector('.js-interval-slider'); +/** + * Returns the input text that was inserted in the $inputText element. + * It makes a treatment to remove whitespaces that are on the start and end + * of the text. + * + * @returns {string} The input text that was inserted in the $inputText element + * with some treatments. + */ +function getInputText() { + return $inputText.value.trim(); +} + /** * Returns the pitch rate value based on the value of the $pitchSlider * element in the page. @@ -61,7 +73,8 @@ async function createDecodedAudioDataFromVoiceFile(fileName) { * element. */ function calculateDialogueTextFontSize() { - const numberOfCharacters = $inputText.value.length; + const inputText = getInputText(); + const numberOfCharacters = inputText.length; const numberOfCharactersToStartChangingStyle = 90; return numberOfCharacters > numberOfCharactersToStartChangingStyle @@ -104,29 +117,6 @@ function addSound(path, index) { return index; } -function playClip() { - clear(); - - if ($inputText.value) { - /** - * TODO: the fact that the dialogue text font size is using `vw` unit is - * making it look small in small width screen, such as mobile devices, as - * reported in the issue #6. - */ - const dialogueTextFontSize = calculateDialogueTextFontSize() + 'vw'; - $dialogueText.style.fontSize = dialogueTextFontSize; - - const pitchRate = calculatePitchRate(); - const interval = calculateInterval(); - - speakingInterval = speak( - $inputText.value.replace(/(\r|\n)/gm, " "), - interval, - pitchRate - ); - } -} - function clear() { if (speakingInterval !== "CLEARED") { clearInterval(speakingInterval); @@ -137,8 +127,8 @@ function clear() { speakingInterval = "CLEARED"; } -function speak(text, time, rate, ignore = false) { - if (!speaking || ignore) { +function speak(text, time, rate) { + if (!speaking) { speaking = true; var arrayTxt = text.split(""); @@ -198,6 +188,29 @@ function setLimitedInterval( audioContext = new AudioContext(); loadSounds(); +function playClip() { + clear(); + + if ($inputText.value) { + /** + * TODO: the fact that the dialogue text font size is using `vw` unit is + * making it look small in small width screen, such as mobile devices, as + * reported in the issue #6. + */ + const dialogueTextFontSize = calculateDialogueTextFontSize() + 'vw'; + $dialogueText.style.fontSize = dialogueTextFontSize; + + const pitchRate = calculatePitchRate(); + const interval = calculateInterval(); + + speakingInterval = speak( + $inputText.value, + interval, + pitchRate + ); + } +} + (async () => { const voices = { quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), From 862cf5088de4f581d2576888d9bf108c3913a8c9 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 18:53:58 -0300 Subject: [PATCH 05/13] feat: add voice selector and function to play selected audio. I have added a voice selector that allows the user to select between the pre-loaded audio files. Later, I will be adding a custom option to insert an audio file. These are the requiments for the issues #3 and #4. --- index.html | 16 ++++-- speak.js | 157 +++++++++-------------------------------------------- 2 files changed, 39 insertions(+), 134 deletions(-) diff --git a/index.html b/index.html index 0598bfe..d0f54e0 100644 --- a/index.html +++ b/index.html @@ -23,10 +23,18 @@

+
Pitch
@@ -50,7 +58,7 @@
- +
diff --git a/speak.js b/speak.js index ecb929b..8a08fe1 100644 --- a/speak.js +++ b/speak.js @@ -1,9 +1,3 @@ -const voicePath = "assets/voices/"; -var audioContext; -var sounds = []; -var speaking = false; -var speakingInterval = "CLEARED"; - /** @type {HTMLTextAreaElement} */ const $inputText = document.querySelector('.js-input-text'); /** @type {HTMLDivElement} */ @@ -12,6 +6,7 @@ const $dialogueText = document.querySelector('.js-dialogue-text'); const $pitchSlider = document.querySelector('.js-pitch-slider'); /** @type {HTMLInputElement} */ const $intervalSlider = document.querySelector('.js-interval-slider'); +const $voiceSelector = document.querySelector('.js-voice-selector'); /** * Returns the input text that was inserted in the $inputText element. @@ -83,136 +78,34 @@ function calculateDialogueTextFontSize() { } /** - * Plays audio + * Plays the audio from the decoded audio data. + * + * @param {AudioBuffer} decodedAudioData An audio buffer that contains the + * decoded audio data. It may be a result of the function + * `createDecodedAudioDataFromVoiceFile`. + * + * @param {number} The pitch rate to be used when playing the audio data. */ -function play(buffer, rate) { +function playDecodedAudioData(decodedAudioData, pitchRate) { const audioContext = new AudioContext(); const source = audioContext.createBufferSource(); - source.buffer = buffer; - source.playbackRate.value = rate; + + source.buffer = decodedAudioData; + source.playbackRate.value = pitchRate; source.connect(audioContext.destination); source.start(); } -function loadSounds() { - addSound(voicePath + "quack.mp3", "quack"); - addSound(voicePath + "santa.wav", "santa"); - addSound(voicePath + "bark.wav", "bark"); - addSound(voicePath + "meow.wav", "meow"); - addSound(voicePath + "moo.mp3", "moo"); - addSound(voicePath + "woof.mp3", "woof"); -} - -async function load(path) { - const response = await fetch("./" + path); - const arrayBuffer = await response.arrayBuffer(); - return audioContext.decodeAudioData(arrayBuffer); -} - -function addSound(path, index) { - load(path).then((response) => { - sounds[index] = response; - }); - - return index; -} - -function clear() { - if (speakingInterval !== "CLEARED") { - clearInterval(speakingInterval); - } - - $dialogueText.innerHTML = ""; - speaking = false; - speakingInterval = "CLEARED"; -} - -function speak(text, time, rate) { - if (!speaking) { - speaking = true; - - var arrayTxt = text.split(""); - arrayTxt.push(" "); - var length = text.length; - - const pitchRate = calculatePitchRate(); - - var intervalID = setLimitedInterval( - function (i) { - if (text[i] != " ") { - play(sounds["quack"], pitchRate); - } - - $dialogueText.innerHTML += arrayTxt[i]; - }, - time, - length, - null, - function () { - speaking = false; - } - ); - - return intervalID; - } -} - -function setLimitedInterval( - func, - time, - iterations, - beginning = null, - ending = null -) { - var i = 0; - - if (beginning !== null) { - beginning(); - } - - var id = setInterval(function () { - func(i); - i++; - - if (i === iterations) { - if (ending !== null) { - ending(); - } - clearInterval(id); - } - }, time); - - return id; -} - -audioContext = new AudioContext(); -loadSounds(); - -function playClip() { - clear(); - - if ($inputText.value) { - /** - * TODO: the fact that the dialogue text font size is using `vw` unit is - * making it look small in small width screen, such as mobile devices, as - * reported in the issue #6. - */ - const dialogueTextFontSize = calculateDialogueTextFontSize() + 'vw'; - $dialogueText.style.fontSize = dialogueTextFontSize; - - const pitchRate = calculatePitchRate(); - const interval = calculateInterval(); - - speakingInterval = speak( - $inputText.value, - interval, - pitchRate - ); - } -} - -(async () => { - const voices = { +/** + * Creates and plays a speak with the input text inserted in the $inputText + * element. + */ +async function speak() { + /** + * An object containing the decoded audio data of the voices that are + * stored locally in the server. + */ + const localVoicesDecodedAudioData = { quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), santa: await createDecodedAudioDataFromVoiceFile('santa.wav'), bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), @@ -220,5 +113,9 @@ function playClip() { moo: await createDecodedAudioDataFromVoiceFile('moo.mp3'), woof: await createDecodedAudioDataFromVoiceFile('woof.mp3'), }; -})(); + const inputText = getInputText(); + const selectedVoiceDecodedAudioData = + localVoicesDecodedAudioData[$voiceSelector.value]; + const pitchRate = calculatePitchRate(); +} From 9c5bd40c033effb65d9f7a48f685b62933bdcb66 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 19:18:13 -0300 Subject: [PATCH 06/13] feat: add function to speak the text. Some of the audio files have not been included because they overlap itself when playing, causing a loud noise: , and . --- index.html | 9 ++-- script.js | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++++ speak.js | 121 --------------------------------------------- 3 files changed, 145 insertions(+), 127 deletions(-) create mode 100644 script.js delete mode 100644 speak.js diff --git a/index.html b/index.html index d0f54e0..93ef83b 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + quackspeak: Text-to-quack
@@ -42,7 +39,7 @@ type="range" min="0" max="2" - value="0.45" + value="0.5" step="0.05" class="js-pitch-slider slider" />
@@ -58,7 +55,7 @@
- +
diff --git a/script.js b/script.js new file mode 100644 index 0000000..7068a47 --- /dev/null +++ b/script.js @@ -0,0 +1,142 @@ +(async () => { + /** + * An object containing the decoded audio data of the voices that are + * stored locally in the server. + */ + const localVoicesDecodedAudioData = { + quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), + bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), + meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), + }; + + /** @type {HTMLTextAreaElement} */ + const $inputText = document.querySelector('.js-input-text'); + /** @type {HTMLDivElement} */ + const $dialogueText = document.querySelector('.js-dialogue-text'); + /** @type {HTMLInputElement} */ + const $pitchSlider = document.querySelector('.js-pitch-slider'); + /** @type {HTMLInputElement} */ + const $intervalSlider = document.querySelector('.js-interval-slider'); + /** @type {HTMLSelectElement} */ + const $voiceSelector = document.querySelector('.js-voice-selector'); + /** @type {HTMLButtonElement} */ + const $sayItButton = document.querySelector('.js-say-it-button'); + + $sayItButton.addEventListener('pointerdown', speak); + + /** + * Returns the input text that was inserted in the $inputText element. + * It makes a treatment to remove whitespaces that are on the start and end + * of the text. + * + * @returns {string} The input text that was inserted in the $inputText element + * with some treatments. + */ + function getInputText() { + return $inputText.value.trim(); + } + + /** + * Returns the pitch rate value based on the value of the $pitchSlider + * element in the page. + * + * If the $pitchSlider has not been changed, it returns 1 by default, that is + * the value set in the HTML page. + * + * @returns {number} The pitch rate. + */ + function calculatePitchRate() { + return 0.4 * 4 ** $pitchSlider.value + 0.2; + } + + /** + * Returns the speak interval based in the value of the $intervalSlider element + * in the page. + * + * If the $intervalSlider has not been changed, it returns 175 by default, + * that is the value set in the HTML page. + * + * @returns {number} The interval in miliseconds. + */ + function calculateIntervalInMiliseconds() { + return $intervalSlider.value; + } + + /** + * Returns the decoded audio data from a voice file in the server. + * + * @param {string} The file name that is under the path `assets/voices/`, + * for example: `quack.mp3`. + */ + async function createDecodedAudioDataFromVoiceFile(fileName) { + const voiceFileDirectoryPath = 'assets/voices/' + fileName; + const fileResponse = await fetch(voiceFileDirectoryPath); + const arrayBuffer = await fileResponse.arrayBuffer(); + const audioContext = new AudioContext(); + + return audioContext.decodeAudioData(arrayBuffer); + } + + /** + * Calculates the font size in rem units to be used in the $dialogueText element + * based on the quantity of characters that were inserted in the $inputText + * element. + * + * @returns {number} The font size in rem units to be used in the $dialogueText + * element. + */ + function calculateDialogueTextFontSize() { + const inputText = getInputText(); + const numberOfCharacters = inputText.length; + const numberOfCharactersToStartChangingStyle = 90; + + return numberOfCharacters > numberOfCharactersToStartChangingStyle + ? 3 * 0.993 ** numberOfCharacters + 0.9 + : 2.5 + } + + /** + * Plays the audio from the decoded audio data. + * + * @param {AudioBuffer} decodedAudioData An audio buffer that contains the + * decoded audio data. It may be a result of the function + * `createDecodedAudioDataFromVoiceFile`. + */ + function playDecodedAudioData(decodedAudioData) { + const audioContext = new AudioContext(); + const source = audioContext.createBufferSource(); + const pitchRate = calculatePitchRate(); + + source.buffer = decodedAudioData; + source.playbackRate.value = pitchRate; + source.connect(audioContext.destination); + source.start(); + } + + /** + * Creates and plays a speak with the input text inserted in the $inputText + * element. + */ + function speak() { + const inputText = getInputText(); + const selectedVoiceDecodedAudioData = + localVoicesDecodedAudioData[$voiceSelector.value]; + const numberOfCharacters = inputText.length; + const intervalInMiliseconds = calculateIntervalInMiliseconds(); + + /** + * TODO: needs to add a way to cancel previous intervals set by the setTimeout + * function to avoid multiple audios being played. + */ + for ( + let characterIndex = 0; + characterIndex < numberOfCharacters; + characterIndex++ + ) { + setTimeout(() => { + playDecodedAudioData(selectedVoiceDecodedAudioData); + }, intervalInMiliseconds * characterIndex); + } + } +})(); + diff --git a/speak.js b/speak.js deleted file mode 100644 index 8a08fe1..0000000 --- a/speak.js +++ /dev/null @@ -1,121 +0,0 @@ -/** @type {HTMLTextAreaElement} */ -const $inputText = document.querySelector('.js-input-text'); -/** @type {HTMLDivElement} */ -const $dialogueText = document.querySelector('.js-dialogue-text'); -/** @type {HTMLInputElement} */ -const $pitchSlider = document.querySelector('.js-pitch-slider'); -/** @type {HTMLInputElement} */ -const $intervalSlider = document.querySelector('.js-interval-slider'); -const $voiceSelector = document.querySelector('.js-voice-selector'); - -/** - * Returns the input text that was inserted in the $inputText element. - * It makes a treatment to remove whitespaces that are on the start and end - * of the text. - * - * @returns {string} The input text that was inserted in the $inputText element - * with some treatments. - */ -function getInputText() { - return $inputText.value.trim(); -} - -/** - * Returns the pitch rate value based on the value of the $pitchSlider - * element in the page. - * - * If the $pitchSlider has not been changed, it returns 1 by default. - * - * @returns {number} The pitch rate. - */ -function calculatePitchRate() { - return 0.4 * Math.pow(4, Number(this.value)) + 0.2 | 1; -} - -/** - * Returns the interval based in the value of the $intervalSlider element - * in the page. - * - * If the $intervalSlider has not been changed, it returns 175 by default. - * - * @returns {number} The interval. - */ -function calculateInterval() { - return $intervalSlider.value | 175; -} - -/** - * Returns the decoded audio data from a voice file in the server. - * - * @param {string} The file name that is under the path `assets/voices/`, - * for example: `quack.mp3`. - */ -async function createDecodedAudioDataFromVoiceFile(fileName) { - const voiceFileDirectoryPath = 'assets/voices/' + fileName; - const fileResponse = await fetch(voiceFileDirectoryPath); - const arrayBuffer = await fileResponse.arrayBuffer(); - const audioContext = new AudioContext(); - - return audioContext.decodeAudioData(arrayBuffer); -} - -/** - * Calculates the font size in rem units to be used in the $dialogueText element - * based on the quantity of characters that were inserted in the $inputText - * element. - * - * @returns {number} The font size in rem units to be used in the $dialogueText - * element. - */ -function calculateDialogueTextFontSize() { - const inputText = getInputText(); - const numberOfCharacters = inputText.length; - const numberOfCharactersToStartChangingStyle = 90; - - return numberOfCharacters > numberOfCharactersToStartChangingStyle - ? 3 * 0.993 ** numberOfCharacters + 0.9 - : 2.5 -} - -/** - * Plays the audio from the decoded audio data. - * - * @param {AudioBuffer} decodedAudioData An audio buffer that contains the - * decoded audio data. It may be a result of the function - * `createDecodedAudioDataFromVoiceFile`. - * - * @param {number} The pitch rate to be used when playing the audio data. - */ -function playDecodedAudioData(decodedAudioData, pitchRate) { - const audioContext = new AudioContext(); - const source = audioContext.createBufferSource(); - - source.buffer = decodedAudioData; - source.playbackRate.value = pitchRate; - source.connect(audioContext.destination); - source.start(); -} - -/** - * Creates and plays a speak with the input text inserted in the $inputText - * element. - */ -async function speak() { - /** - * An object containing the decoded audio data of the voices that are - * stored locally in the server. - */ - const localVoicesDecodedAudioData = { - quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), - santa: await createDecodedAudioDataFromVoiceFile('santa.wav'), - bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), - meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), - moo: await createDecodedAudioDataFromVoiceFile('moo.mp3'), - woof: await createDecodedAudioDataFromVoiceFile('woof.mp3'), - }; - const inputText = getInputText(); - const selectedVoiceDecodedAudioData = - localVoicesDecodedAudioData[$voiceSelector.value]; - const pitchRate = calculatePitchRate(); -} - From da23ad36398ffab8f517682877cb27cadb5a49cd Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 19:30:01 -0300 Subject: [PATCH 07/13] feat: add function to cancel previous timeouts. I added a function to avoid the audio to overlap each other. --- script.js | 51 +++++++++++++++++++++++++++++---------------------- 1 file changed, 29 insertions(+), 22 deletions(-) diff --git a/script.js b/script.js index 7068a47..b6feddd 100644 --- a/script.js +++ b/script.js @@ -8,6 +8,17 @@ bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), }; + /** + * An array that contains all the ids of the timeouts set by the `speak` + * function. Each timeout is corresponded to the speak of one of the + * characters inserted in the $inputText element. + * + * This array needs to exist to the code be able to cancel previous timeouts + * avoiding the audio to overlap each other. + * + * @type {Array} + */ + let speakTimeoutsIds = []; /** @type {HTMLTextAreaElement} */ const $inputText = document.querySelector('.js-input-text'); @@ -77,24 +88,6 @@ return audioContext.decodeAudioData(arrayBuffer); } - /** - * Calculates the font size in rem units to be used in the $dialogueText element - * based on the quantity of characters that were inserted in the $inputText - * element. - * - * @returns {number} The font size in rem units to be used in the $dialogueText - * element. - */ - function calculateDialogueTextFontSize() { - const inputText = getInputText(); - const numberOfCharacters = inputText.length; - const numberOfCharactersToStartChangingStyle = 90; - - return numberOfCharacters > numberOfCharactersToStartChangingStyle - ? 3 * 0.993 ** numberOfCharacters + 0.9 - : 2.5 - } - /** * Plays the audio from the decoded audio data. * @@ -113,11 +106,26 @@ source.start(); } + /** + * Cancels previous speak timeouts to avoid the audio to overlap each other + * and make too much noise. + */ + function cancelPreviousSpeakTimeouts() { + if (speakTimeoutsIds.length > 0) { + speakTimeoutsIds.forEach((speakTimeoutId) => { + clearTimeout(speakTimeoutId); + }); + speakTimeoutsIds = []; + } + } + /** * Creates and plays a speak with the input text inserted in the $inputText * element. */ function speak() { + cancelPreviousSpeakTimeouts(); + const inputText = getInputText(); const selectedVoiceDecodedAudioData = localVoicesDecodedAudioData[$voiceSelector.value]; @@ -125,17 +133,16 @@ const intervalInMiliseconds = calculateIntervalInMiliseconds(); /** - * TODO: needs to add a way to cancel previous intervals set by the setTimeout - * function to avoid multiple audios being played. + * TODO: add a way to put each character in the $dialogueText element. */ for ( let characterIndex = 0; characterIndex < numberOfCharacters; characterIndex++ ) { - setTimeout(() => { + speakTimeoutsIds.push(setTimeout(() => { playDecodedAudioData(selectedVoiceDecodedAudioData); - }, intervalInMiliseconds * characterIndex); + }, intervalInMiliseconds * characterIndex)); } } })(); From 4a3218afd08325f7235a7ec1afb42e88ec5d6acb Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 19:43:19 -0300 Subject: [PATCH 08/13] feat: add function to write to the . --- script.js | 47 ++++++++++++++++++++++++++++++++++------------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/script.js b/script.js index b6feddd..df9cf14 100644 --- a/script.js +++ b/script.js @@ -33,7 +33,10 @@ /** @type {HTMLButtonElement} */ const $sayItButton = document.querySelector('.js-say-it-button'); - $sayItButton.addEventListener('pointerdown', speak); + $sayItButton.addEventListener('pointerdown', () => { + speak(); + writeDialogueText(); + }); /** * Returns the input text that was inserted in the $inputText element. @@ -111,7 +114,8 @@ * and make too much noise. */ function cancelPreviousSpeakTimeouts() { - if (speakTimeoutsIds.length > 0) { + const hasSpeakTimeoutIds = speakTimeoutsIds.length > 0; + if (hasSpeakTimeoutIds) { speakTimeoutsIds.forEach((speakTimeoutId) => { clearTimeout(speakTimeoutId); }); @@ -119,6 +123,27 @@ } } + /** + * Writes the input text inserted at the $inputText element to the + * $dialogueText element. + */ + function writeDialogueText() { + const inputText = getInputText(); + const inputTextCharacters = inputText.split(''); + const intervalInMiliseconds = calculateIntervalInMiliseconds(); + + $dialogueText.innerHTML = ''; + + inputTextCharacters.forEach(( + inputTextCharacter, + inputTextCharacterIndex + ) => { + speakTimeoutsIds.push(setTimeout(() => { + $dialogueText.innerHTML += inputTextCharacter; + }, intervalInMiliseconds * inputTextCharacterIndex)); + }); + } + /** * Creates and plays a speak with the input text inserted in the $inputText * element. @@ -127,23 +152,19 @@ cancelPreviousSpeakTimeouts(); const inputText = getInputText(); + const inputTextCharacters = inputText.split(''); const selectedVoiceDecodedAudioData = localVoicesDecodedAudioData[$voiceSelector.value]; - const numberOfCharacters = inputText.length; const intervalInMiliseconds = calculateIntervalInMiliseconds(); - /** - * TODO: add a way to put each character in the $dialogueText element. - */ - for ( - let characterIndex = 0; - characterIndex < numberOfCharacters; - characterIndex++ - ) { + inputTextCharacters.forEach(( + inputTextCharacter, + inputTextCharacterIndex + ) => { speakTimeoutsIds.push(setTimeout(() => { playDecodedAudioData(selectedVoiceDecodedAudioData); - }, intervalInMiliseconds * characterIndex)); - } + }, intervalInMiliseconds * inputTextCharacterIndex)); + }); } })(); From 31caf83858fd04f689409513d96f4eae56ec2706 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 20:33:17 -0300 Subject: [PATCH 09/13] feat: add styles for voice selector. --- index.html | 17 ++++++++++------- style.css | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/index.html b/index.html index 93ef83b..f14eca5 100644 --- a/index.html +++ b/index.html @@ -27,14 +27,17 @@ class="js-input-text" spellcheck="false" > - +
+ Voice + +
-
Pitch
+ Pitch
-
Interval
+ Interval Date: Mon, 2 Jan 2023 21:59:11 -0300 Subject: [PATCH 10/13] feat: add fixed font size to lake font. --- index.html | 10 ++++++---- style.css | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/index.html b/index.html index f14eca5..8fbde79 100644 --- a/index.html +++ b/index.html @@ -28,8 +28,8 @@ spellcheck="false" >
- Voice - @@ -37,23 +37,25 @@
- Pitch +
- Interval +
diff --git a/style.css b/style.css index a387423..2319165 100644 --- a/style.css +++ b/style.css @@ -51,6 +51,7 @@ body, input, textarea, +select, button { font-family: "noto sans", "sans-serif"; } @@ -117,6 +118,7 @@ textarea:focus { .voice-selector { width: 100%; + cursor: pointer; } select { @@ -174,6 +176,7 @@ button { overflow: scroll; position: absolute; top: 20%; + font-size: 1.5rem; } .dialogue-text::-webkit-scrollbar { From 54274b114f73c0f33a006acc71152cc02cdca6a7 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 22:50:56 -0300 Subject: [PATCH 11/13] feat: add function to load voices. Previously, the code was giving the warning 'The AudioContext was not allowed to start'. This commit fixes this issue and adds the @async flag to all the async functions. --- script.js | 65 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 47 insertions(+), 18 deletions(-) diff --git a/script.js b/script.js index df9cf14..fc8b74d 100644 --- a/script.js +++ b/script.js @@ -2,12 +2,17 @@ /** * An object containing the decoded audio data of the voices that are * stored locally in the server. + * + * Each key of this object is the name of the voice file and also corresponds + * to one of the options of the voice selector in the page. + * + * This variable starts `undefined`, and is only defined when the user + * interacts with the page by using the function + * `loadLocalVoicesDecodedAudioData`. This is needed to fix the error `The + * AudioContext was not allowed to start`, loading the local voices only when + * the user interacts with the page. */ - const localVoicesDecodedAudioData = { - quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), - bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), - meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), - }; + let localVoicesDecodedAudioData; /** * An array that contains all the ids of the timeouts set by the `speak` * function. Each timeout is corresponded to the speak of one of the @@ -33,8 +38,8 @@ /** @type {HTMLButtonElement} */ const $sayItButton = document.querySelector('.js-say-it-button'); - $sayItButton.addEventListener('pointerdown', () => { - speak(); + $sayItButton.addEventListener('pointerdown', async () => { + await speak(); writeDialogueText(); }); @@ -43,8 +48,8 @@ * It makes a treatment to remove whitespaces that are on the start and end * of the text. * - * @returns {string} The input text that was inserted in the $inputText element - * with some treatments. + * @returns {string} The input text that was inserted in the $inputText + * element with some treatments. */ function getInputText() { return $inputText.value.trim(); @@ -54,8 +59,7 @@ * Returns the pitch rate value based on the value of the $pitchSlider * element in the page. * - * If the $pitchSlider has not been changed, it returns 1 by default, that is - * the value set in the HTML page. + * If the $pitchSlider has not been changed, it returns 1 by default. * * @returns {number} The pitch rate. */ @@ -64,23 +68,28 @@ } /** - * Returns the speak interval based in the value of the $intervalSlider element - * in the page. + * Returns the speak interval in miliseconds based on the value of the + * $intervalSlider element in the page. * * If the $intervalSlider has not been changed, it returns 175 by default, * that is the value set in the HTML page. * - * @returns {number} The interval in miliseconds. + * @returns {number} The speak interval in miliseconds. */ function calculateIntervalInMiliseconds() { return $intervalSlider.value; } /** - * Returns the decoded audio data from a voice file in the server. + * Returns a promise that gives the decoded audio data from a voice file in + * the server. + * + * @async * - * @param {string} The file name that is under the path `assets/voices/`, - * for example: `quack.mp3`. + * @param {string} fileName The file name that is under the path + * `assets/voices/`, for example: `quack.mp3`. + * + * @returns {Promise} The decoded audio data. */ async function createDecodedAudioDataFromVoiceFile(fileName) { const voiceFileDirectoryPath = 'assets/voices/' + fileName; @@ -144,11 +153,31 @@ }); } + /** + * Loads the local voices. This is needed to fix the error + * `The AudioContext was not allowed to start`, loading the local voices only + * when the user interact with the page. + * + * @async + */ + async function loadLocalVoicesDecodedAudioData() { + if (!localVoicesDecodedAudioData) { + localVoicesDecodedAudioData = { + quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), + bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), + meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), + }; + } + } + /** * Creates and plays a speak with the input text inserted in the $inputText * element. + * + * @async */ - function speak() { + async function speak() { + await loadLocalVoicesDecodedAudioData(); cancelPreviousSpeakTimeouts(); const inputText = getInputText(); From d9cd33c22134b0a15ee5e5a2ddb7038725b4167b Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Mon, 2 Jan 2023 22:59:28 -0300 Subject: [PATCH 12/13] refactor: remove top-level async function. --- script.js | 388 +++++++++++++++++++++++++++--------------------------- 1 file changed, 193 insertions(+), 195 deletions(-) diff --git a/script.js b/script.js index fc8b74d..f75a61b 100644 --- a/script.js +++ b/script.js @@ -1,199 +1,197 @@ -(async () => { - /** - * An object containing the decoded audio data of the voices that are - * stored locally in the server. - * - * Each key of this object is the name of the voice file and also corresponds - * to one of the options of the voice selector in the page. - * - * This variable starts `undefined`, and is only defined when the user - * interacts with the page by using the function - * `loadLocalVoicesDecodedAudioData`. This is needed to fix the error `The - * AudioContext was not allowed to start`, loading the local voices only when - * the user interacts with the page. - */ - let localVoicesDecodedAudioData; - /** - * An array that contains all the ids of the timeouts set by the `speak` - * function. Each timeout is corresponded to the speak of one of the - * characters inserted in the $inputText element. - * - * This array needs to exist to the code be able to cancel previous timeouts - * avoiding the audio to overlap each other. - * - * @type {Array} - */ - let speakTimeoutsIds = []; - - /** @type {HTMLTextAreaElement} */ - const $inputText = document.querySelector('.js-input-text'); - /** @type {HTMLDivElement} */ - const $dialogueText = document.querySelector('.js-dialogue-text'); - /** @type {HTMLInputElement} */ - const $pitchSlider = document.querySelector('.js-pitch-slider'); - /** @type {HTMLInputElement} */ - const $intervalSlider = document.querySelector('.js-interval-slider'); - /** @type {HTMLSelectElement} */ - const $voiceSelector = document.querySelector('.js-voice-selector'); - /** @type {HTMLButtonElement} */ - const $sayItButton = document.querySelector('.js-say-it-button'); - - $sayItButton.addEventListener('pointerdown', async () => { - await speak(); - writeDialogueText(); - }); - - /** - * Returns the input text that was inserted in the $inputText element. - * It makes a treatment to remove whitespaces that are on the start and end - * of the text. - * - * @returns {string} The input text that was inserted in the $inputText - * element with some treatments. - */ - function getInputText() { - return $inputText.value.trim(); - } - - /** - * Returns the pitch rate value based on the value of the $pitchSlider - * element in the page. - * - * If the $pitchSlider has not been changed, it returns 1 by default. - * - * @returns {number} The pitch rate. - */ - function calculatePitchRate() { - return 0.4 * 4 ** $pitchSlider.value + 0.2; - } - - /** - * Returns the speak interval in miliseconds based on the value of the - * $intervalSlider element in the page. - * - * If the $intervalSlider has not been changed, it returns 175 by default, - * that is the value set in the HTML page. - * - * @returns {number} The speak interval in miliseconds. - */ - function calculateIntervalInMiliseconds() { - return $intervalSlider.value; - } - - /** - * Returns a promise that gives the decoded audio data from a voice file in - * the server. - * - * @async - * - * @param {string} fileName The file name that is under the path - * `assets/voices/`, for example: `quack.mp3`. - * - * @returns {Promise} The decoded audio data. - */ - async function createDecodedAudioDataFromVoiceFile(fileName) { - const voiceFileDirectoryPath = 'assets/voices/' + fileName; - const fileResponse = await fetch(voiceFileDirectoryPath); - const arrayBuffer = await fileResponse.arrayBuffer(); - const audioContext = new AudioContext(); - - return audioContext.decodeAudioData(arrayBuffer); - } - - /** - * Plays the audio from the decoded audio data. - * - * @param {AudioBuffer} decodedAudioData An audio buffer that contains the - * decoded audio data. It may be a result of the function - * `createDecodedAudioDataFromVoiceFile`. - */ - function playDecodedAudioData(decodedAudioData) { - const audioContext = new AudioContext(); - const source = audioContext.createBufferSource(); - const pitchRate = calculatePitchRate(); - - source.buffer = decodedAudioData; - source.playbackRate.value = pitchRate; - source.connect(audioContext.destination); - source.start(); - } - - /** - * Cancels previous speak timeouts to avoid the audio to overlap each other - * and make too much noise. - */ - function cancelPreviousSpeakTimeouts() { - const hasSpeakTimeoutIds = speakTimeoutsIds.length > 0; - if (hasSpeakTimeoutIds) { - speakTimeoutsIds.forEach((speakTimeoutId) => { - clearTimeout(speakTimeoutId); - }); - speakTimeoutsIds = []; - } - } - - /** - * Writes the input text inserted at the $inputText element to the - * $dialogueText element. - */ - function writeDialogueText() { - const inputText = getInputText(); - const inputTextCharacters = inputText.split(''); - const intervalInMiliseconds = calculateIntervalInMiliseconds(); - - $dialogueText.innerHTML = ''; - - inputTextCharacters.forEach(( - inputTextCharacter, - inputTextCharacterIndex - ) => { - speakTimeoutsIds.push(setTimeout(() => { - $dialogueText.innerHTML += inputTextCharacter; - }, intervalInMiliseconds * inputTextCharacterIndex)); +/** + * An object containing the decoded audio data of the voices that are + * stored locally in the server. + * + * Each key of this object is the name of the voice file and also corresponds + * to one of the options of the voice selector in the page. + * + * This variable starts `undefined`, and is only defined when the user + * interacts with the page by using the function + * `loadLocalVoicesDecodedAudioData`. This is needed to fix the error `The + * AudioContext was not allowed to start`, loading the local voices only when + * the user interacts with the page. + */ +let localVoicesDecodedAudioData; +/** + * An array that contains all the ids of the timeouts set by the `speak` + * function. Each timeout is corresponded to the speak of one of the + * characters inserted in the $inputText element. + * + * This array needs to exist to the code be able to cancel previous timeouts + * avoiding the audio to overlap each other. + * + * @type {Array} + */ +let speakTimeoutsIds = []; + +/** @type {HTMLTextAreaElement} */ +const $inputText = document.querySelector('.js-input-text'); +/** @type {HTMLDivElement} */ +const $dialogueText = document.querySelector('.js-dialogue-text'); +/** @type {HTMLInputElement} */ +const $pitchSlider = document.querySelector('.js-pitch-slider'); +/** @type {HTMLInputElement} */ +const $intervalSlider = document.querySelector('.js-interval-slider'); +/** @type {HTMLSelectElement} */ +const $voiceSelector = document.querySelector('.js-voice-selector'); +/** @type {HTMLButtonElement} */ +const $sayItButton = document.querySelector('.js-say-it-button'); + +$sayItButton.addEventListener('pointerdown', async () => { + await speak(); + writeDialogueText(); +}); + +/** + * Returns the input text that was inserted in the $inputText element. + * It makes a treatment to remove whitespaces that are on the start and end + * of the text. + * + * @returns {string} The input text that was inserted in the $inputText + * element with some treatments. + */ +function getInputText() { + return $inputText.value.trim(); +} + +/** + * Returns the pitch rate value based on the value of the $pitchSlider + * element in the page. + * + * If the $pitchSlider has not been changed, it returns 1 by default. + * + * @returns {number} The pitch rate. + */ +function calculatePitchRate() { + return 0.4 * 4 ** $pitchSlider.value + 0.2; +} + +/** + * Returns the speak interval in miliseconds based on the value of the + * $intervalSlider element in the page. + * + * If the $intervalSlider has not been changed, it returns 175 by default, + * that is the value set in the HTML page. + * + * @returns {number} The speak interval in miliseconds. + */ +function calculateIntervalInMiliseconds() { + return $intervalSlider.value; +} + +/** + * Returns a promise that gives the decoded audio data from a voice file in + * the server. + * + * @async + * + * @param {string} fileName The file name that is under the path + * `assets/voices/`, for example: `quack.mp3`. + * + * @returns {Promise} The decoded audio data. + */ +async function createDecodedAudioDataFromVoiceFile(fileName) { + const voiceFileDirectoryPath = 'assets/voices/' + fileName; + const fileResponse = await fetch(voiceFileDirectoryPath); + const arrayBuffer = await fileResponse.arrayBuffer(); + const audioContext = new AudioContext(); + + return audioContext.decodeAudioData(arrayBuffer); +} + +/** + * Plays the audio from the decoded audio data. + * + * @param {AudioBuffer} decodedAudioData An audio buffer that contains the + * decoded audio data. It may be a result of the function + * `createDecodedAudioDataFromVoiceFile`. + */ +function playDecodedAudioData(decodedAudioData) { + const audioContext = new AudioContext(); + const source = audioContext.createBufferSource(); + const pitchRate = calculatePitchRate(); + + source.buffer = decodedAudioData; + source.playbackRate.value = pitchRate; + source.connect(audioContext.destination); + source.start(); +} + +/** + * Cancels previous speak timeouts to avoid the audio to overlap each other + * and make too much noise. + */ +function cancelPreviousSpeakTimeouts() { + const hasSpeakTimeoutIds = speakTimeoutsIds.length > 0; + if (hasSpeakTimeoutIds) { + speakTimeoutsIds.forEach((speakTimeoutId) => { + clearTimeout(speakTimeoutId); }); + speakTimeoutsIds = []; } - - /** - * Loads the local voices. This is needed to fix the error - * `The AudioContext was not allowed to start`, loading the local voices only - * when the user interact with the page. - * - * @async - */ - async function loadLocalVoicesDecodedAudioData() { - if (!localVoicesDecodedAudioData) { - localVoicesDecodedAudioData = { - quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), - bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), - meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), - }; - } - } - - /** - * Creates and plays a speak with the input text inserted in the $inputText - * element. - * - * @async - */ - async function speak() { - await loadLocalVoicesDecodedAudioData(); - cancelPreviousSpeakTimeouts(); - - const inputText = getInputText(); - const inputTextCharacters = inputText.split(''); - const selectedVoiceDecodedAudioData = - localVoicesDecodedAudioData[$voiceSelector.value]; - const intervalInMiliseconds = calculateIntervalInMiliseconds(); - - inputTextCharacters.forEach(( - inputTextCharacter, - inputTextCharacterIndex - ) => { - speakTimeoutsIds.push(setTimeout(() => { - playDecodedAudioData(selectedVoiceDecodedAudioData); - }, intervalInMiliseconds * inputTextCharacterIndex)); - }); +} + +/** + * Writes the input text inserted at the $inputText element to the + * $dialogueText element. + */ +function writeDialogueText() { + const inputText = getInputText(); + const inputTextCharacters = inputText.split(''); + const intervalInMiliseconds = calculateIntervalInMiliseconds(); + + $dialogueText.innerHTML = ''; + + inputTextCharacters.forEach(( + inputTextCharacter, + inputTextCharacterIndex + ) => { + speakTimeoutsIds.push(setTimeout(() => { + $dialogueText.innerHTML += inputTextCharacter; + }, intervalInMiliseconds * inputTextCharacterIndex)); + }); +} + +/** + * Loads the local voices. This is needed to fix the error + * `The AudioContext was not allowed to start`, loading the local voices only + * when the user interact with the page. + * + * @async + */ +async function loadLocalVoicesDecodedAudioData() { + if (!localVoicesDecodedAudioData) { + localVoicesDecodedAudioData = { + quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), + bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), + meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), + }; } -})(); +} + +/** + * Creates and plays a speak with the input text inserted in the $inputText + * element. + * + * @async + */ +async function speak() { + await loadLocalVoicesDecodedAudioData(); + cancelPreviousSpeakTimeouts(); + + const inputText = getInputText(); + const inputTextCharacters = inputText.split(''); + const selectedVoiceDecodedAudioData = + localVoicesDecodedAudioData[$voiceSelector.value]; + const intervalInMiliseconds = calculateIntervalInMiliseconds(); + + inputTextCharacters.forEach(( + inputTextCharacter, + inputTextCharacterIndex + ) => { + speakTimeoutsIds.push(setTimeout(() => { + playDecodedAudioData(selectedVoiceDecodedAudioData); + }, intervalInMiliseconds * inputTextCharacterIndex)); + }); +} From cb9ec26d83e7c1850df492d4163e0c1818a61046 Mon Sep 17 00:00:00 2001 From: Sherman Rofeman Date: Tue, 3 Jan 2023 09:22:44 -0300 Subject: [PATCH 13/13] refactor: short 'localVoicesDecodedAudioData' to 'localVoices'. --- script.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/script.js b/script.js index f75a61b..14453c6 100644 --- a/script.js +++ b/script.js @@ -11,7 +11,7 @@ * AudioContext was not allowed to start`, loading the local voices only when * the user interacts with the page. */ -let localVoicesDecodedAudioData; +let localVoices; /** * An array that contains all the ids of the timeouts set by the `speak` * function. Each timeout is corresponded to the speak of one of the @@ -159,9 +159,9 @@ function writeDialogueText() { * * @async */ -async function loadLocalVoicesDecodedAudioData() { - if (!localVoicesDecodedAudioData) { - localVoicesDecodedAudioData = { +async function loadLocalVoices() { + if (!localVoices) { + localVoices = { quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'), bark: await createDecodedAudioDataFromVoiceFile('bark.wav'), meow: await createDecodedAudioDataFromVoiceFile('meow.wav'), @@ -176,17 +176,17 @@ async function loadLocalVoicesDecodedAudioData() { * @async */ async function speak() { - await loadLocalVoicesDecodedAudioData(); + await loadLocalVoices(); cancelPreviousSpeakTimeouts(); const inputText = getInputText(); const inputTextCharacters = inputText.split(''); const selectedVoiceDecodedAudioData = - localVoicesDecodedAudioData[$voiceSelector.value]; + localVoices[$voiceSelector.value]; const intervalInMiliseconds = calculateIntervalInMiliseconds(); inputTextCharacters.forEach(( - inputTextCharacter, + _inputTextCharacter, inputTextCharacterIndex ) => { speakTimeoutsIds.push(setTimeout(() => {