summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--ext/bg/js/audio.js18
-rw-r--r--ext/bg/settings.html4
-rw-r--r--ext/mixed/js/audio.js113
-rw-r--r--ext/mixed/js/display.js1
4 files changed, 132 insertions, 4 deletions
diff --git a/ext/bg/js/audio.js b/ext/bg/js/audio.js
index 9508abf0..3efcce46 100644
--- a/ext/bg/js/audio.js
+++ b/ext/bg/js/audio.js
@@ -86,6 +86,24 @@ const audioUrlBuilders = {
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) => {
const options = await apiOptionsGet(optionsContext);
const customSourceUrl = options.audio.customSourceUrl;
diff --git a/ext/bg/settings.html b/ext/bg/settings.html
index ffa5533e..15425b44 100644
--- a/ext/bg/settings.html
+++ b/ext/bg/settings.html
@@ -319,8 +319,10 @@
<div class="input-group-addon audio-source-prefix"></div>
<select class="form-control audio-source-select">
<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="text-to-speech">Text-to-speech</option>
+ <option value="text-to-speech-reading">Text-to-speech (Kana reading)</option>
<option value="custom">Custom</option>
</select>
<div class="input-group-btn"><button class="btn btn-danger audio-source-remove" title="Remove"><span class="glyphicon glyphicon-remove"></span></button></div>
diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js
index 50bd321f..cf8b8d24 100644
--- a/ext/mixed/js/audio.js
+++ b/ext/mixed/js/audio.js
@@ -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) => {
const audio = new Audio(url);
audio.addEventListener('loadeddata', () => {
@@ -46,7 +129,7 @@ async function audioGetFromSources(expression, sources, optionsContext, download
}
try {
- const audio = download ? null : await audioGetFromUrl(url);
+ const audio = await audioGetFromUrl(url, download);
const result = {audio, url, source};
if (cache !== null) {
cache[key] = result;
@@ -56,7 +139,7 @@ async function audioGetFromSources(expression, sources, optionsContext, download
// NOP
}
}
- return {audio: null, source: null};
+ return {audio: null, url: null, source: null};
}
function audioGetTextToSpeechVoice(voiceURI) {
@@ -71,3 +154,27 @@ function audioGetTextToSpeechVoice(voiceURI) {
}
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;
diff --git a/ext/mixed/js/display.js b/ext/mixed/js/display.js
index cf38d09d..e0994f8a 100644
--- a/ext/mixed/js/display.js
+++ b/ext/mixed/js/display.js
@@ -197,6 +197,7 @@ class Display {
this.options = options ? options : await apiOptionsGet(this.getOptionsContext());
this.updateTheme(this.options.general.popupTheme);
this.setCustomCss(this.options.general.customPopupCss);
+ audioPrepareTextToSpeech(this.options);
}
updateTheme(themeName) {