Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add random intervals to make a more natural speech (#8). #9

Merged
merged 11 commits into from
Jan 8, 2023
10 changes: 5 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" type="text/css" href="style.css" />
<script src="script.js" defer></script>
<title>quackspeak: Text-to-quack</title>
Expand All @@ -14,10 +14,10 @@

<body>
<img
class="background-img"
src="assets/ducks-background.png"
alt=""
draggable="false"
class="background-img"
src="assets/ducks-background.png"
alt=""
draggable="false"
/>

<main>
Expand Down
163 changes: 111 additions & 52 deletions script.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
//@ts-check

/**
* An object containing the decoded audio data of the voices that are
* stored locally in the server.
Expand All @@ -7,22 +9,41 @@
*
* 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.
* `loadLocalVoices`. This is needed to fix the warning `The AudioContext was
* not allowed to start`, by 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.
* An array that contains all the ids of the timeouts set by the
* `speakAndWriteToDialogueText` 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.
* This array needs to exist to the code be able to cancel previous timeouts,
* avoiding the audio to overlap.
*
* @type {Array<number>}
*/
let speakTimeoutsIds = [];
/**
* A number that will keep the sum of all the random intervals added to the
* timeouts of the `speakAndWriteToDialogueText` function. This is needed to
* make the timeout time work whenever a random interval has been added when a
* white space is found.
*/
let randomIntervalsInMilliseconds = 0;
/**
* A variable that hosts an AudioContext type to be used throughout the code.
* This variable needs to exists because, previously, creating an audio context
* to each function was causing the audios to overlap or mute in Chromium,
* Google Chrome and Brave. By using only one audio context solved that issue.
*
* This variable starts undefined by the same reason the `localVoices` variable
* does: to fix the warning `The AudioContext was not allowed to start`, by
* loading this audio context only when the user interacts with the page.
* @type {AudioContext}
*/
let audioContext;

/** @type {HTMLTextAreaElement} */
const $inputText = document.querySelector('.js-input-text');
Expand All @@ -38,20 +59,46 @@ const $voiceSelector = document.querySelector('.js-voice-selector');
const $sayItButton = document.querySelector('.js-say-it-button');

$sayItButton.addEventListener('pointerdown', async () => {
await speak();
writeDialogueText();
await speakAndWriteDialogue();
});

/**
* Trims and remove extra white spaces that are between the words of a string.
*
* @param {string} text The text to be treated.
*
* @returns {string} The treated string.
*/
function removeWhiteSpaces(text) {
const treatedCharacters = [];
let lastCharacter = '';
text
.trim()
.split('')
.forEach((character) => {
if (
(lastCharacter === ' ' && character !== ' ') ||
lastCharacter !== ' '
) {
treatedCharacters.push(character);
}
lastCharacter = character;
});
return treatedCharacters.join('');

}

/**
* 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.
*
* It removes white spaces from the start and end of the text as well as extra
* white spaces that have been added between the words.
*
* @returns {string} The input text that was inserted in the $inputText
* element with some treatments.
*/
function getInputText() {
return $inputText.value.trim();
return removeWhiteSpaces($inputText.value);
}

/**
Expand All @@ -63,20 +110,20 @@ function getInputText() {
* @returns {number} The pitch rate.
*/
function calculatePitchRate() {
return 0.4 * 4 ** $pitchSlider.value + 0.2;
return 0.4 * 4 ** + $pitchSlider.value + 0.2;
}

/**
* Returns the speak interval in miliseconds based on the value of the
* Returns the speak interval in milliseconds 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.
* @returns {number} The speak interval in milliseconds.
*/
function calculateIntervalInMiliseconds() {
return $intervalSlider.value;
function calculateIntervalInMilliseconds() {
return + $intervalSlider.value;
}

/**
Expand All @@ -94,7 +141,6 @@ 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);
}
Expand All @@ -107,7 +153,6 @@ async function createDecodedAudioDataFromVoiceFile(fileName) {
* `createDecodedAudioDataFromVoiceFile`.
*/
function playDecodedAudioData(decodedAudioData) {
const audioContext = new AudioContext();
const source = audioContext.createBufferSource();
const pitchRate = calculatePitchRate();

Expand All @@ -122,8 +167,7 @@ function playDecodedAudioData(decodedAudioData) {
* and make too much noise.
*/
function cancelPreviousSpeakTimeouts() {
const hasSpeakTimeoutIds = speakTimeoutsIds.length > 0;
if (hasSpeakTimeoutIds) {
if (speakTimeoutsIds.length > 0) {
speakTimeoutsIds.forEach((speakTimeoutId) => {
clearTimeout(speakTimeoutId);
});
Expand All @@ -132,35 +176,15 @@ function cancelPreviousSpeakTimeouts() {
}

/**
* 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
* Loads the local voices. This is needed to fix the warning
* `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) {
audioContext = new AudioContext();
localVoices = {
quack: await createDecodedAudioDataFromVoiceFile('quack.mp3'),
bark: await createDecodedAudioDataFromVoiceFile('bark.wav'),
Expand All @@ -169,29 +193,64 @@ async function loadLocalVoices() {
}
}

/**
* Adds a random interval, in milliseconds, into the
* `randomIntervalsInMilliseconds` number that may be used by the
* `speakAndWriteToDialogueText` function to create a more natural feeling,
* being applied whenever there is a white space in the input text.
*/
function addRandomIntervalInMilliseconds() {
const intervalInMilliseconds = calculateIntervalInMilliseconds();
const minimumIntervalValueInMilliseconds = 100;
const maximumIntervalValueInMilliseconds = 3e4 / intervalInMilliseconds;
const randomIntervalInMilliseconds =
Math.floor(Math.random() * maximumIntervalValueInMilliseconds) +
minimumIntervalValueInMilliseconds;

randomIntervalsInMilliseconds += randomIntervalInMilliseconds;
}

/**
* Cancels all the previous random intervals used, by reseting the
* `randomIntervalsInMilliseconds` to zero, as it was at the start.
*/
function cancelRandomIntervals() {
randomIntervalsInMilliseconds = 0;
}

/**
* Creates and plays a speak with the input text inserted in the $inputText
* element.
* element. It also, writes the text to the $dialogueText element at the same
* time it is speaking.
*
* @async
*/
async function speak() {
async function speakAndWriteDialogue() {
await loadLocalVoices();
cancelPreviousSpeakTimeouts();
cancelRandomIntervals();

const inputText = getInputText();
const inputTextCharacters = inputText.split('');
const selectedVoiceDecodedAudioData =
localVoices[$voiceSelector.value];
const intervalInMiliseconds = calculateIntervalInMiliseconds();
const selectedLocalVoice = localVoices[$voiceSelector.value];
const intervalInMilliseconds = calculateIntervalInMilliseconds();

$dialogueText.innerHTML = '';

inputTextCharacters.forEach((
_inputTextCharacter,
inputTextCharacter,
inputTextCharacterIndex
) => {
if (inputTextCharacter === ' ') {
addRandomIntervalInMilliseconds();
}

speakTimeoutsIds.push(setTimeout(() => {
playDecodedAudioData(selectedVoiceDecodedAudioData);
}, intervalInMiliseconds * inputTextCharacterIndex));
playDecodedAudioData(selectedLocalVoice);
$dialogueText.innerHTML += inputTextCharacter;
}, intervalInMilliseconds * inputTextCharacterIndex +
randomIntervalsInMilliseconds
));
});
}