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

refactor speak.js to allow pre-loaded voices. #7

Merged
merged 13 commits into from
Jan 3, 2023
Merged
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
34 changes: 21 additions & 13 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" type="text/css" href="style.css" />
<script src="speak.js"></script>
<script src="script.js" defer></script>
<title>quackspeak: Text-to-quack</title>
<link
rel="icon"
Expand All @@ -23,36 +23,44 @@
<main>
<div class="container">
<textarea
placeholder="Write some text here!"
id="inputText"
spellcheck="false"
placeholder="Write some text here!"
class="js-input-text"
spellcheck="false"
></textarea>
<div class="voice-selector-container">
<label class="text-container" for="voice-selector">Voice</label>
<select id="voice-selector" class="js-voice-selector voice-selector">
<option default value="quack">Quack</option>
<option default value="bark">Bark</option>
<option default value="meow">Meow</option>
</select>
</div>
<div class="sliders">
<div class="slider-container">
<div class="text-container">Pitch</div>
<label class="text-container" for="pitch-slider">Pitch</label>
<input
type="range"
min="0"
max="2"
value="0.45"
value="0.5"
step="0.05"
class="slider"
id="pitchSlider" />
id="pitch-slider"
class="js-pitch-slider slider" />
</div>
<div class="slider-container">
<div class="text-container">Interval</div>
<label class="text-container" for="interval-slider">Interval</label>
<input
type="range"
min="50"
max="200"
value="175"
step="1"
class="slider"
id="intervalSlider" />
id="interval-slider"
class="js-interval-slider slider" />
</div>
</div>
<div class="button-container utils-flex-align-center">
<button onclick="playClip()">Say it!</button>
<button class="js-say-it-button">Say it!</button>
</div>
</div>

Expand All @@ -66,7 +74,7 @@ <h1 class="utils-text-align-center">quackSpeak 🦆</h1>
src="assets/dialogue.png"
alt=""
draggable="false" />
<div id="dialogue-text"></div>
<div class="js-dialogue-text dialogue-text"></div>
</div>
<div class="share">
<p class="utils-text-align-center">
Expand Down
197 changes: 197 additions & 0 deletions script.js
Original file line number Diff line number Diff line change
@@ -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 localVoicesDecodedAudioData;
burntcarrot marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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<number>}
*/
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<AudioBuffer>} 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 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));
});
}

Loading