-
Notifications
You must be signed in to change notification settings - Fork 1
/
script.js
256 lines (233 loc) · 7.97 KB
/
script.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
//@ts-check
/**
* 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
* `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
* `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.
*
* @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');
/** @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 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 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 removeWhiteSpaces($inputText.value);
}
/**
* 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 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 milliseconds.
*/
function calculateIntervalInMilliseconds() {
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();
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 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() {
if (speakTimeoutsIds.length > 0) {
speakTimeoutsIds.forEach((speakTimeoutId) => {
clearTimeout(speakTimeoutId);
});
speakTimeoutsIds = [];
}
}
/**
* 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'),
meow: await createDecodedAudioDataFromVoiceFile('meow.wav'),
};
}
}
/**
* 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. It also, writes the text to the $dialogueText element at the same
* time it is speaking.
*
* @async
*/
async function speakAndWriteDialogue() {
await loadLocalVoices();
cancelPreviousSpeakTimeouts();
cancelRandomIntervals();
const inputText = getInputText();
const inputTextCharacters = inputText.split('');
const selectedLocalVoice = localVoices[$voiceSelector.value];
const intervalInMilliseconds = calculateIntervalInMilliseconds();
$dialogueText.innerHTML = '';
inputTextCharacters.forEach((
inputTextCharacter,
inputTextCharacterIndex
) => {
if (inputTextCharacter === ' ') {
addRandomIntervalInMilliseconds();
}
speakTimeoutsIds.push(setTimeout(() => {
playDecodedAudioData(selectedLocalVoice);
$dialogueText.innerHTML += inputTextCharacter;
}, intervalInMilliseconds * inputTextCharacterIndex +
randomIntervalsInMilliseconds
));
});
}