diff options
| author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2019-10-12 23:59:21 -0400 | 
|---|---|---|
| committer | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2019-10-13 12:21:19 -0400 | 
| commit | 7bae3824e74461fbd5c9f66f921b05a47a40cbf4 (patch) | |
| tree | d0cdc8580497e447e05a31ab1e4806c4f325390b /ext | |
| parent | 69b28571bdf8fb4c13198223e8a5668cf490840c (diff) | |
Add support for text-to-speech playback
Diffstat (limited to 'ext')
| -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) { |