Add support for text-to-speech playback

This commit is contained in:
toasted-nutbread 2019-10-12 23:59:21 -04:00
parent 69b28571bd
commit 7bae3824e7
4 changed files with 132 additions and 4 deletions

View File

@ -86,6 +86,24 @@ const audioUrlBuilders = {
throw new Error('Failed to find audio URL'); throw new Error('Failed to find audio URL');
}, },
'text-to-speech': async (definition, optionsContext) => {
const options = await apiOptionsGet(optionsContext);
const voiceURI = options.audio.textToSpeechVoice;
if (!voiceURI) {
throw new Error('No voice');
}
return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
},
'text-to-speech-reading': async (definition, optionsContext) => {
const options = await apiOptionsGet(optionsContext);
const voiceURI = options.audio.textToSpeechVoice;
if (!voiceURI) {
throw new Error('No voice');
}
return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
},
'custom': async (definition, optionsContext) => { 'custom': async (definition, optionsContext) => {
const options = await apiOptionsGet(optionsContext); const options = await apiOptionsGet(optionsContext);
const customSourceUrl = options.audio.customSourceUrl; const customSourceUrl = options.audio.customSourceUrl;

View File

@ -319,8 +319,10 @@
<div class="input-group-addon audio-source-prefix"></div> <div class="input-group-addon audio-source-prefix"></div>
<select class="form-control audio-source-select"> <select class="form-control audio-source-select">
<option value="jpod101">JapanesePod101</option> <option value="jpod101">JapanesePod101</option>
<option value="jpod101-alternate">JapanesePod101 (alternate)</option> <option value="jpod101-alternate">JapanesePod101 (Alternate)</option>
<option value="jisho">Jisho.org</option> <option value="jisho">Jisho.org</option>
<option value="text-to-speech">Text-to-speech</option>
<option value="text-to-speech-reading">Text-to-speech (Kana reading)</option>
<option value="custom">Custom</option> <option value="custom">Custom</option>
</select> </select>
<div class="input-group-btn"><button class="btn btn-danger audio-source-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div> <div class="input-group-btn"><button class="btn btn-danger audio-source-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div>

View File

@ -17,7 +17,90 @@
*/ */
function audioGetFromUrl(url) { class TextToSpeechAudio {
constructor(text, voice) {
this.text = text;
this.voice = voice;
this._utterance = null;
this._volume = 1;
}
get currentTime() {
return 0;
}
set currentTime(value) {
// NOP
}
get volume() {
return this._volume;
}
set volume(value) {
this._volume = value;
if (this._utterance !== null) {
this._utterance.volume = value;
}
}
play() {
try {
if (this._utterance === null) {
this._utterance = new SpeechSynthesisUtterance(this.text || '');
this._utterance.lang = 'ja-JP';
this._utterance.volume = this._volume;
this._utterance.voice = this.voice;
}
speechSynthesis.cancel();
speechSynthesis.speak(this._utterance);
} catch (e) {
// NOP
}
}
pause() {
try {
speechSynthesis.cancel();
} catch (e) {
// NOP
}
}
static createFromUri(ttsUri) {
const m = /^tts:[^#\?]*\?([^#]*)/.exec(ttsUri);
if (m === null) { return null; }
const searchParameters = {};
for (const group of m[1].split('&')) {
const sep = group.indexOf('=');
if (sep < 0) { continue; }
searchParameters[decodeURIComponent(group.substr(0, sep))] = decodeURIComponent(group.substr(sep + 1));
}
if (!searchParameters.text) { return null; }
const voice = audioGetTextToSpeechVoice(searchParameters.voice);
if (voice === null) { return null; }
return new TextToSpeechAudio(searchParameters.text, voice);
}
}
function audioGetFromUrl(url, download) {
const tts = TextToSpeechAudio.createFromUri(url);
if (tts !== null) {
if (download) {
throw new Error('Download not supported for text-to-speech');
}
return Promise.resolve(tts);
}
if (download) {
return Promise.resolve(null);
}
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const audio = new Audio(url); const audio = new Audio(url);
audio.addEventListener('loadeddata', () => { audio.addEventListener('loadeddata', () => {
@ -46,7 +129,7 @@ async function audioGetFromSources(expression, sources, optionsContext, download
} }
try { try {
const audio = download ? null : await audioGetFromUrl(url); const audio = await audioGetFromUrl(url, download);
const result = {audio, url, source}; const result = {audio, url, source};
if (cache !== null) { if (cache !== null) {
cache[key] = result; cache[key] = result;
@ -56,7 +139,7 @@ async function audioGetFromSources(expression, sources, optionsContext, download
// NOP // NOP
} }
} }
return {audio: null, source: null}; return {audio: null, url: null, source: null};
} }
function audioGetTextToSpeechVoice(voiceURI) { function audioGetTextToSpeechVoice(voiceURI) {
@ -71,3 +154,27 @@ function audioGetTextToSpeechVoice(voiceURI) {
} }
return null; return null;
} }
function audioPrepareTextToSpeech(options) {
if (
audioPrepareTextToSpeech.state ||
!options.audio.textToSpeechVoice ||
!(
options.audio.sources.includes('text-to-speech') ||
options.audio.sources.includes('text-to-speech-reading')
)
) {
// Text-to-speech not in use.
return;
}
// Chrome needs this value called once before it will become populated.
// The first call will return an empty list.
audioPrepareTextToSpeech.state = true;
try {
speechSynthesis.getVoices();
} catch (e) {
// NOP
}
}
audioPrepareTextToSpeech.state = false;

View File

@ -197,6 +197,7 @@ class Display {
this.options = options ? options : await apiOptionsGet(this.getOptionsContext()); this.options = options ? options : await apiOptionsGet(this.getOptionsContext());
this.updateTheme(this.options.general.popupTheme); this.updateTheme(this.options.general.popupTheme);
this.setCustomCss(this.options.general.customPopupCss); this.setCustomCss(this.options.general.customPopupCss);
audioPrepareTextToSpeech(this.options);
} }
updateTheme(themeName) { updateTheme(themeName) {