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/index.html b/index.html index 1d91edb..8fbde79 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@ - + quackspeak: Text-to-quack
+
+ + +
-
Pitch
+ + id="pitch-slider" + class="js-pitch-slider slider" />
-
Interval
+ + id="interval-slider" + class="js-interval-slider slider" />
- +
@@ -66,7 +74,7 @@

quackSpeak 🦆

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

diff --git a/script.js b/script.js new file mode 100644 index 0000000..14453c6 --- /dev/null +++ b/script.js @@ -0,0 +1,197 @@ +/** + * 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 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 + * 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)); + }); +} + +/** + * 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 loadLocalVoices() { + if (!localVoices) { + localVoices = { + 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 loadLocalVoices(); + cancelPreviousSpeakTimeouts(); + + const inputText = getInputText(); + const inputTextCharacters = inputText.split(''); + const selectedVoiceDecodedAudioData = + localVoices[$voiceSelector.value]; + const intervalInMiliseconds = calculateIntervalInMiliseconds(); + + inputTextCharacters.forEach(( + _inputTextCharacter, + inputTextCharacterIndex + ) => { + speakTimeoutsIds.push(setTimeout(() => { + playDecodedAudioData(selectedVoiceDecodedAudioData); + }, intervalInMiliseconds * inputTextCharacterIndex)); + }); +} + diff --git a/speak.js b/speak.js deleted file mode 100644 index 68b41c0..0000000 --- a/speak.js +++ /dev/null @@ -1,167 +0,0 @@ -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(); - - // Remove event listener. - document.removeEventListener("pointerdown", init); -} - -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"); -} - -// Play from buffer. -function play(buffer, rate) { - const source = audioContext.createBufferSource(); - source.buffer = buffer; - source.playbackRate.value = rate; - source.connect(audioContext.destination); - 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; - }); - - return 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) - .toFixed(1) - .toString() + "vw"; // I don't remember how I computed this, but it works - } else { - dialogueText.style.fontSize = "2.5vw"; - } - - // get interval ID - speakingInterval = speak( - inputText.value.replace(/(\r|\n)/gm, " "), // Replace newlines with whitespace. - interval, - pitchRate - ); - } -} - -// Clear speaking intervals and dialogue box -function clear() { - if (speakingInterval !== "CLEARED") { - clearInterval(speakingInterval); - } - - // reset everything. - dialogueText.innerHTML = ""; - speaking = false; - speakingInterval = "CLEARED"; -} - -function speak(text, time, rate, ignore = false) { - if (!speaking || ignore) { - speaking = true; - - // text processing - var arrayTxt = text.split(""); - arrayTxt.push(" "); - var length = text.length; - - var intervalID = setLimitedInterval( - function (i) { - if (text[i] != " ") { - // for quack, pitch rate = 1 is fine! - play(sounds["quack"], pitchRate); - } - - dialogueText.innerHTML += arrayTxt[i]; - }, - time, - length, - null, - function () { - // set to false when it ends - 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; -} diff --git a/style.css b/style.css index a8120e9..2319165 100644 --- a/style.css +++ b/style.css @@ -51,6 +51,7 @@ body, input, textarea, +select, button { font-family: "noto sans", "sans-serif"; } @@ -109,6 +110,25 @@ textarea:focus { outline: none; } +.voice-selector-container { + align-items: center; + display: flex; + gap: 0.5rem; +} + +.voice-selector { + width: 100%; + cursor: pointer; +} + +select { + background: white; + border-radius: var(--small-border-radius); + border: var(--border-very-light-blue); + font-size: 0.9rem; + padding: 0.5rem; +} + input[type='range'] { background: white; appearance: none; @@ -149,16 +169,17 @@ button { margin-top: 2rem; } -#dialogue-text { +.dialogue-text { left: 14%; max-height: 70%; max-width: 70%; overflow: scroll; position: absolute; top: 20%; + font-size: 1.5rem; } -#dialogue-text::-webkit-scrollbar { +.dialogue-text::-webkit-scrollbar { display: none; }