diff options
-rw-r--r-- | ext/bg/js/audio.js | 18 | ||||
-rw-r--r-- | ext/bg/settings.html | 4 | ||||
-rw-r--r-- | ext/mixed/js/audio.js | 113 | ||||
-rw-r--r-- | ext/mixed/js/display.js | 1 |
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) { |