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 @@
-
+
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;
}