Skip to content

Commit

Permalink
feat: add intervals for enhanced natural speech (#9)
Browse files Browse the repository at this point in the history
* feat: add random intervals to timeouts.

I add a function to generate random timeouts for the execution of the speak function. This adds a more natural speech as was intended by the issue #8.

* refactor: reorganize code by using functions.

* refactor: change  to number.

* fix: write to dialogueText now matches the speak timeout.

* feat: add extra treatment to remove extra white spaces.

* docs: update some of the JSDocs to match new functions.

* refactor: separate white space treatments to its own function.

* refactor: make random intervals relative to interval chosen.

Now, all the random intervals are inversely proportional to the interval
chosen. Which means:

  + if the interval chosen is low, the random interval is high.
  + if the interval chosen is high, the random interval is low.

This makes the voices more natural.

* refactor: remove unecessary console.log.

* fix: make only one audio context available for the code.

* refactor: follow suggestions for refactoring.
  • Loading branch information
Sherman Rofeman authored Jan 8, 2023
1 parent d3f9f33 commit b3c9a75
Show file tree
Hide file tree
Showing 2 changed files with 116 additions and 57 deletions.
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
));
});
}

1 comment on commit b3c9a75

@vercel
Copy link

@vercel vercel bot commented on b3c9a75 Jan 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

quackspeak – ./

quackspeak.vercel.app
quackspeak-burntcarrot.vercel.app
quackspeak-git-main-burntcarrot.vercel.app

Please sign in to comment.