diff options
author | toasted-nutbread <toasted-nutbread@users.noreply.github.com> | 2021-05-30 12:41:19 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2021-05-30 12:41:19 -0400 |
commit | cca01e85a35576225661699a7be63550e9500642 (patch) | |
tree | de52f621afa61afd6df2fdc6c91286e9247574ff | |
parent | efd35de67f6700ecf4f49a87d310d99cefbaa328 (diff) |
Improve multiple audio sources (#1718)
* Add url/voice options to audio sources
* Add help for TTS
* Remove old settings
* Update tests
* Update use of audio source URL
* Improve labels for sources with the same type
-rw-r--r-- | ext/css/settings.css | 39 | ||||
-rw-r--r-- | ext/data/schemas/options-schema.json | 55 | ||||
-rw-r--r-- | ext/js/data/options-util.js | 11 | ||||
-rw-r--r-- | ext/js/display/display-audio.js | 49 | ||||
-rw-r--r-- | ext/js/pages/settings/audio-controller.js | 146 | ||||
-rw-r--r-- | ext/js/pages/settings/backup-controller.js | 17 | ||||
-rw-r--r-- | ext/settings.html | 116 | ||||
-rw-r--r-- | test/test-options-util.js | 26 |
8 files changed, 296 insertions, 163 deletions
diff --git a/ext/css/settings.css b/ext/css/settings.css index 1bc2d1a7..86a6cdb3 100644 --- a/ext/css/settings.css +++ b/ext/css/settings.css @@ -1312,6 +1312,45 @@ body.preview-sidebar-visible .fab-container-item.fab-container-item-popup-previe #audio-source-list-empty { display: none; } +.audio-source { + display: flex; + flex-flow: row nowrap; + align-items: center; + align-content: flex-start; + justify-content: flex-start; +} +.audio-source-inner { + margin: 0 0.375em; + flex: 1 1 auto; + display: flex; + flex-flow: row wrap; + align-items: center; + align-content: flex-start; + justify-content: flex-start; +} +.audio-source-type-select { + flex: 1 0 auto; + width: calc(var(--input-width-large) + 2em); + margin: 0.125em 0; +} +.audio-source-parameter-container { + margin: 0.125em 0; + flex: 1e8 1 auto; + flex-flow: row nowrap; + align-items: center; + align-content: flex-start; + justify-content: flex-start; +} +.audio-source-parameter-container:not([hidden]) { + display: flex; +} +.audio-source-parameter-label { + flex: 0 0 auto; + margin: 0 0.375em; +} +.audio-source-parameter { + flex: 1 1 auto; +} .profile-add-button-container { display: flex; diff --git a/ext/data/schemas/options-schema.json b/ext/data/schemas/options-schema.json index 9afad1e3..4b97342c 100644 --- a/ext/data/schemas/options-schema.json +++ b/ext/data/schemas/options-schema.json @@ -343,8 +343,6 @@ "enabled", "volume", "autoPlay", - "customSourceUrl", - "textToSpeechVoice", "sources" ], "properties": { @@ -362,31 +360,46 @@ "type": "boolean", "default": false }, - "customSourceUrl": { - "type": "string", - "default": "" - }, - "textToSpeechVoice": { - "type": "string", - "default": "" - }, "sources": { "type": "array", "items": { - "type": "string", - "enum": [ - "jpod101", - "jpod101-alternate", - "jisho", - "text-to-speech", - "text-to-speech-reading", - "custom", - "custom-json" + "type": "object", + "additionalProperties": false, + "required": [ + "type", + "url", + "voice" ], - "default": "jpod101" + "properties": { + "type": { + "type": "string", + "enum": [ + "jpod101", + "jpod101-alternate", + "jisho", + "text-to-speech", + "text-to-speech-reading", + "custom", + "custom-json" + ], + "default": "jpod101" + }, + "url": { + "type": "string", + "default": "" + }, + "voice": { + "type": "string", + "default": "" + } + } }, "default": [ - "jpod101" + { + "type": "jpod101", + "url": "", + "voice": "" + } ] } } diff --git a/ext/js/data/options-util.js b/ext/js/data/options-util.js index 89d50903..eb29dae4 100644 --- a/ext/js/data/options-util.js +++ b/ext/js/data/options-util.js @@ -826,16 +826,21 @@ class OptionsUtil { sentenceParsing.terminationCharacterMode = sentenceParsing.enableTerminationCharacters ? 'custom' : 'newlines'; delete sentenceParsing.enableTerminationCharacters; - const {sources, customSourceType} = audio; + const {sources, customSourceUrl, customSourceType, textToSpeechVoice} = audio; audio.sources = sources.map((type) => { switch (type) { + case 'text-to-speech': + case 'text-to-speech-reading': + return {type, url: '', voice: textToSpeechVoice}; case 'custom': - return (customSourceType === 'json' ? 'custom-json' : 'custom'); + return {type: (customSourceType === 'json' ? 'custom-json' : 'custom'), url: customSourceUrl, voice: ''}; default: - return type; + return {type, url: '', voice: ''}; } }); delete audio.customSourceType; + delete audio.customSourceUrl; + delete audio.textToSpeechVoice; } return options; } diff --git a/ext/js/display/display-audio.js b/ext/js/display/display-audio.js index b7ec6ba1..6d2504e4 100644 --- a/ext/js/display/display-audio.js +++ b/ext/js/display/display-audio.js @@ -146,7 +146,7 @@ class DisplayAudio { _onOptionsUpdated({options}) { if (options === null) { return; } - const {enabled, autoPlay, textToSpeechVoice, customSourceUrl, volume, sources} = options.audio; + const {enabled, autoPlay, volume, sources} = options.audio; this._autoPlay = enabled && autoPlay; this._playbackVolume = Number.isFinite(volume) ? Math.max(0.0, Math.min(1.0, volume / 100.0)) : 1.0; @@ -155,13 +155,14 @@ class DisplayAudio { 'jpod101-alternate', 'jisho' ]); + const nameMap = new Map(); this._audioSources.length = 0; - for (const type of sources) { - this._addAudioSourceInfo(type, customSourceUrl, textToSpeechVoice, true); + for (const {type, url, voice} of sources) { + this._addAudioSourceInfo(type, url, voice, true, nameMap); requiredAudioSources.delete(type); } for (const type of requiredAudioSources) { - this._addAudioSourceInfo(type, '', '', false); + this._addAudioSourceInfo(type, '', '', false, nameMap); } const data = document.documentElement.dataset; @@ -170,20 +171,36 @@ class DisplayAudio { this._cache.clear(); } - _addAudioSourceInfo(type, url, voice, isInOptions) { + _addAudioSourceInfo(type, url, voice, isInOptions, nameMap) { const index = this._audioSources.length; const downloadable = this._sourceIsDownloadable(type); - let displayName = this._audioSourceTypeNames.get(type); - if (typeof displayName === 'undefined') { displayName = 'Unknown'; } - this._audioSources.push({ + let name = this._audioSourceTypeNames.get(type); + if (typeof name === 'undefined') { name = 'Unknown'; } + + let entries = nameMap.get(name); + if (typeof entries === 'undefined') { + entries = []; + nameMap.set(name, entries); + } + const nameIndex = entries.length; + if (nameIndex === 1) { + entries[0].nameUnique = false; + } + + const source = { index, type, url, voice, isInOptions, downloadable, - displayName - }); + name, + nameIndex, + nameUnique: (nameIndex === 0) + }; + + entries.push(source); + this._audioSources.push(source); } _onAudioPlayButtonClick(dictionaryEntryIndex, headwordIndex, e) { @@ -580,19 +597,23 @@ class DisplayAudio { let showIcons = false; const currentItems = [...menuItemContainer.children]; for (const source of this._audioSources) { - const {index, displayName, isInOptions, downloadable} = source; + const {index, name, nameIndex, nameUnique, isInOptions, downloadable} = source; const entries = this._getMenuItemEntries(source, term, reading); for (let i = 0, ii = entries.length; i < ii; ++i) { - const {valid, index: subIndex, name} = entries[i]; + const {valid, index: subIndex, name: subName} = entries[i]; let node = this._getOrCreateMenuItem(currentItems, index, subIndex); if (node === null) { node = displayGenerator.instantiateTemplate('audio-button-popup-menu-item'); } const labelNode = node.querySelector('.popup-menu-item-audio-button .popup-menu-item-label'); - let label = displayName; + let label = name; + if (!nameUnique) { + label = `${label} ${nameIndex + 1}`; + if (ii > 1) { label = `${label} -`; } + } if (ii > 1) { label = `${label} ${i + 1}`; } - if (typeof name === 'string' && name.length > 0) { label += `: ${name}`; } + if (typeof subName === 'string' && subName.length > 0) { label += `: ${subName}`; } labelNode.textContent = label; const cardButton = node.querySelector('.popup-menu-item-set-primary-audio-button'); diff --git a/ext/js/pages/settings/audio-controller.js b/ext/js/pages/settings/audio-controller.js index 2581893c..c74c1477 100644 --- a/ext/js/pages/settings/audio-controller.js +++ b/ext/js/pages/settings/audio-controller.js @@ -19,15 +19,17 @@ * AudioSystem */ -class AudioController { +class AudioController extends EventDispatcher { constructor(settingsController, modalController) { + super(); this._settingsController = settingsController; this._modalController = modalController; this._audioSystem = new AudioSystem(); this._audioSourceContainer = null; this._audioSourceAddButton = null; this._audioSourceEntries = []; - this._ttsVoiceTestTextInput = null; + this._voiceTestTextInput = null; + this._voices = []; } get settingsController() { @@ -41,7 +43,7 @@ class AudioController { async prepare() { this._audioSystem.prepare(); - this._ttsVoiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text'); + this._voiceTestTextInput = document.querySelector('#text-to-speech-voice-test-text'); this._audioSourceContainer = document.querySelector('#audio-source-list'); this._audioSourceAddButton = document.querySelector('#audio-source-add'); this._audioSourceContainer.textContent = ''; @@ -76,6 +78,14 @@ class AudioController { }]); } + getVoices() { + return this._voices; + } + + setTestVoice(voice) { + this._voiceTestTextInput.dataset.voice = voice; + } + // Private _onOptionsChanged({options}) { @@ -96,9 +106,8 @@ class AudioController { _onTestTextToSpeech() { try { - const text = this._ttsVoiceTestTextInput.value || ''; - const voiceUri = document.querySelector('[data-setting="audio.textToSpeechVoice"]').value; - + const text = this._voiceTestTextInput.value || ''; + const voiceUri = this._voiceTestTextInput.dataset.voice; const audio = this._audioSystem.createTextToSpeechAudio(text, voiceUri); audio.volume = 1.0; audio.play(); @@ -118,25 +127,8 @@ class AudioController { [] ); voices.sort(this._textToSpeechVoiceCompare.bind(this)); - - for (const select of document.querySelectorAll('[data-setting="audio.textToSpeechVoice"]')) { - const fragment = document.createDocumentFragment(); - - let option = document.createElement('option'); - option.value = ''; - option.textContent = 'None'; - fragment.appendChild(option); - - for (const {voice} of voices) { - option = document.createElement('option'); - option.value = voice.voiceURI; - option.textContent = `${voice.name} (${voice.lang})`; - fragment.appendChild(option); - } - - select.textContent = ''; - select.appendChild(fragment); - } + this._voices = voices; + this.trigger('voicesUpdated'); } _textToSpeechVoiceCompare(a, b) { @@ -163,9 +155,9 @@ class AudioController { ); } - _createAudioSourceEntry(index, type) { + _createAudioSourceEntry(index, source) { const node = this._settingsController.instantiateTemplate('audio-source'); - const entry = new AudioSourceEntry(this, index, type, node); + const entry = new AudioSourceEntry(this, index, source, node); this._audioSourceEntries.push(entry); this._audioSourceContainer.appendChild(node); entry.prepare(); @@ -188,25 +180,31 @@ class AudioController { async _addAudioSource() { const type = this._getUnusedAudioSourceType(); + const source = {type, url: '', voice: ''}; const index = this._audioSourceEntries.length; - this._createAudioSourceEntry(index, type); + this._createAudioSourceEntry(index, source); await this._settingsController.modifyProfileSettings([{ action: 'splice', path: 'audio.sources', start: index, deleteCount: 0, - items: [type] + items: [source] }]); } } class AudioSourceEntry { - constructor(parent, index, type, node) { + constructor(parent, index, source, node) { this._parent = parent; this._index = index; - this._type = type; + this._type = source.type; + this._url = source.url; + this._voice = source.voice; this._node = node; this._eventListeners = new EventListenerCollection(); + this._typeSelect = null; + this._urlInput = null; + this._voiceSelect = null; } get index() { @@ -222,14 +220,23 @@ class AudioSourceEntry { } prepare() { - const select = this._node.querySelector('.audio-source-select'); + this._updateTypeParameter(); + const menuButton = this._node.querySelector('.audio-source-menu-button'); + this._typeSelect = this._node.querySelector('.audio-source-type-select'); + this._urlInput = this._node.querySelector('.audio-source-parameter-container[data-field=url] .audio-source-parameter'); + this._voiceSelect = this._node.querySelector('.audio-source-parameter-container[data-field=voice] .audio-source-parameter'); - select.value = this._type; + this._typeSelect.value = this._type; + this._urlInput.value = this._url; - this._eventListeners.addEventListener(select, 'change', this._onAudioSourceSelectChange.bind(this), false); + this._eventListeners.addEventListener(this._typeSelect, 'change', this._onTypeSelectChange.bind(this), false); + this._eventListeners.addEventListener(this._urlInput, 'change', this._onUrlInputChange.bind(this), false); + this._eventListeners.addEventListener(this._voiceSelect, 'change', this._onVoiceSelectChange.bind(this), false); this._eventListeners.addEventListener(menuButton, 'menuOpen', this._onMenuOpen.bind(this), false); this._eventListeners.addEventListener(menuButton, 'menuClose', this._onMenuClose.bind(this), false); + this._eventListeners.on(this._parent, 'voicesUpdated', this._onVoicesUpdated.bind(this)); + this._onVoicesUpdated(); } cleanup() { @@ -241,8 +248,38 @@ class AudioSourceEntry { // Private - _onAudioSourceSelectChange(event) { - this._setType(event.currentTarget.value); + _onVoicesUpdated() { + const voices = this._parent.getVoices(); + + const fragment = document.createDocumentFragment(); + + let option = document.createElement('option'); + option.value = ''; + option.textContent = 'None'; + fragment.appendChild(option); + + for (const {voice} of voices) { + option = document.createElement('option'); + option.value = voice.voiceURI; + option.textContent = `${voice.name} (${voice.lang})`; + fragment.appendChild(option); + } + + this._voiceSelect.textContent = ''; + this._voiceSelect.appendChild(fragment); + this._voiceSelect.value = this._voice; + } + + _onTypeSelectChange(e) { + this._setType(e.currentTarget.value); + } + + _onUrlInputChange(e) { + this._setUrl(e.currentTarget.value); + } + + _onVoiceSelectChange(e) { + this._setVoice(e.currentTarget.value); } _onMenuOpen(e) { @@ -252,6 +289,8 @@ class AudioSourceEntry { switch (this._type) { case 'custom': case 'custom-json': + case 'text-to-speech': + case 'text-to-speech-reading': hasHelp = true; break; } @@ -272,7 +311,35 @@ class AudioSourceEntry { async _setType(value) { this._type = value; - await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}]`, value); + this._updateTypeParameter(); + await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].type`, value); + } + + async _setUrl(value) { + this._url = value; + await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].url`, value); + } + + async _setVoice(value) { + this._voice = value; + await this._parent.settingsController.setProfileSetting(`audio.sources[${this._index}].voice`, value); + } + + _updateTypeParameter() { + let field = null; + switch (this._type) { + case 'custom': + case 'custom-json': + field = 'url'; + break; + case 'text-to-speech': + case 'text-to-speech-reading': + field = 'voice'; + break; + } + for (const node of this._node.querySelectorAll('.audio-source-parameter-container')) { + node.hidden = (field === null || node.dataset.field !== field); + } } _showHelp(type) { @@ -283,6 +350,11 @@ class AudioSourceEntry { case 'custom-json': this._showModal('audio-source-help-custom-json'); break; + case 'text-to-speech': + case 'text-to-speech-reading': + this._parent.setTestVoice(this._voice); + this._showModal('audio-source-help-text-to-speech'); + break; } } diff --git a/ext/js/pages/settings/backup-controller.js b/ext/js/pages/settings/backup-controller.js index c961d40e..02ad368c 100644 --- a/ext/js/pages/settings/backup-controller.js +++ b/ext/js/pages/settings/backup-controller.js @@ -278,11 +278,18 @@ class BackupController { const audio = options.audio; if (isObject(audio)) { - const customSourceUrl = audio.customSourceUrl; - if (typeof customSourceUrl === 'string' && customSourceUrl.length > 0 && !this._isLocalhostUrl(customSourceUrl)) { - warnings.push('audio.customSourceUrl uses a non-localhost URL'); - if (!dryRun) { - audio.customSourceUrl = ''; + const sources = audio.sources; + if (Array.isArray(sources)) { + for (let i = 0, ii = sources.length; i < ii; ++i) { + const source = sources[i]; + if (!isObject(source)) { continue; } + const {url} = source; + if (typeof url === 'string' && url.length > 0 && !this._isLocalhostUrl(url)) { + warnings.push(`audio.sources[${i}].url uses a non-localhost URL`); + if (!dryRun) { + sources[i].url = ''; + } + } } } } diff --git a/ext/settings.html b/ext/settings.html index 9f1e688c..38c390c4 100644 --- a/ext/settings.html +++ b/ext/settings.html @@ -2369,72 +2369,6 @@ <div class="settings-item-inner"> <div class="settings-item-left"> <div class="settings-item-label"> - Text-to-speech voice - <a class="more-toggle more-only" data-parent-distance="4">(?)</a> - </div> - </div> - <div class="settings-item-right"> - <select data-setting="audio.textToSpeechVoice" id="text-to-speech-voice"></select> - </div> - </div> - <div class="settings-item-children more" hidden> - <p> - Change which voice is used for text-to-speech audio playback. - </p> - <div class="horizontal-flex"> - <input type="text" value="よみちゃん" id="text-to-speech-voice-test-text" autocomplete="off" lang="ja"> - <button id="text-to-speech-voice-test">Test</button> - <a class="more-toggle" data-parent-distance="3">Hide…</a> - </div> - </div> - </div> - <div class="settings-item"> - <div class="settings-item-inner"> - <div class="settings-item-left"> - <div class="settings-item-label"> - Custom audio source - <a class="more-toggle more-only" data-parent-distance="4">(?)</a> - </div> - </div> - <div class="settings-item-right"> - <div class="settings-item-group"> - <div class="settings-item-group-item"> - <div class="settings-item-group-item-label">URL</div> - <input class="short-height" type="text" spellcheck="false" autocomplete="off" data-setting="audio.customSourceUrl" placeholder="None"> - </div> - </div> - </div> - </div> - <div class="settings-item-children more" hidden> - <p> - The <em>URL</em> property specifies the URL format used for fetching audio clips in <em>Custom</em> mode. - The replacement tags <code data-select-on-click="">{term}</code> and <code data-select-on-click="">{reading}</code> can be used to specify which - term and reading is being looked up.<br> - </p> - <p> - The <em>Type</em> property specifies how the URL is handled when looking up audio: - </p> - <ul> - <li> - <strong>Audio</strong> - The link is treated as a direct link to an audio file that the browser can play. - </li> - <li> - <strong>JSON</strong> - The link is interpreted as a link to a JSON file, which is downloaded and parsed for audio URLs. - The format of the JSON file is specified in <a href="/data/schemas/custom-audio-list-schema.json" target="_blank" rel="noopener noreferrer">this schema file</a>. - </li> - </ul> - <p> - Example URL: <a data-select-on-click="">http://localhost/audio.mp3?term={term}&reading={reading}</a> - </p> - <p> - <a class="more-toggle" data-parent-distance="3">Less…</a> - </p> - </div> - </div> - <div class="settings-item"> - <div class="settings-item-inner"> - <div class="settings-item-left"> - <div class="settings-item-label"> Audio sources <a class="more-toggle more-only" data-parent-distance="4">(?)</a> </div> @@ -2505,19 +2439,47 @@ </div> </div></div> +<div id="audio-source-help-text-to-speech-modal" class="modal" tabindex="-1" role="dialog" hidden><div class="modal-content modal-content-small"> + <div class="modal-header"> + <div class="modal-title">Audio Source - Text-to-speech</div> + </div> + <div class="modal-body"> + <p> + A synthesized voice will speak the given text, using either the term text or the reading. + </p> + <div class="horizontal-flex margin-above"> + <input type="text" value="よみちゃん" id="text-to-speech-voice-test-text" autocomplete="off" lang="ja"> + <button id="text-to-speech-voice-test">Test</button> + </div> + </div> + <div class="modal-footer"> + <button data-modal-action="hide">Close</button> + </div> +</div></div> + <!-- Audio templates --> -<template id="audio-source-template"><div class="audio-source horizontal-flex"> - <div class="generic-list-index-prefix"></div> - <select class="audio-source-select horizontal-flex-fill"> - <option value="jpod101">JapanesePod101</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 URL</option> - <option value="custom-json">Custom URL (JSON)</option> - </select> +<template id="audio-source-template"><div class="audio-source"> + <div class="audio-source-index generic-list-index-prefix"></div> + <div class="audio-source-inner"> + <select class="audio-source-type-select"> + <option value="jpod101">JapanesePod101</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 URL</option> + <option value="custom-json">Custom URL (JSON)</option> + </select> + <div class="audio-source-parameter-container" data-field="url" hidden> + <span class="audio-source-parameter-label">URL:</span> + <input type="text" class="audio-source-parameter"> + </div> + <div class="audio-source-parameter-container" data-field="voice" hidden> + <span class="audio-source-parameter-label">Voice:</span> + <select class="audio-source-parameter"></select> + </div> + </div> <button class="icon-button audio-source-menu-button" data-menu="audio-source-menu" data-menu-position="below left"><span class="icon-button-inner"><span class="icon" data-icon="kebab-menu"></span></span></button> </div></template> diff --git a/test/test-options-util.js b/test/test-options-util.js index 80f935d1..d20eec3e 100644 --- a/test/test-options-util.js +++ b/test/test-options-util.js @@ -104,11 +104,11 @@ function createProfileOptionsTestData1() { }, audio: { enabled: true, - sources: ['jpod101'], + sources: ['jpod101', 'text-to-speech', 'custom'], volume: 100, autoPlay: false, customSourceUrl: 'http://localhost/audio.mp3?term={expression}&reading={reading}', - textToSpeechVoice: '' + textToSpeechVoice: 'example-voice' }, scanning: { middleMouse: true, @@ -306,11 +306,25 @@ function createProfileOptionsUpdatedTestData1() { }, audio: { enabled: true, - sources: ['jpod101'], + sources: [ + { + type: 'jpod101', + url: '', + voice: '' + }, + { + type: 'text-to-speech', + url: '', + voice: 'example-voice' + }, + { + type: 'custom', + url: 'http://localhost/audio.mp3?term={term}&reading={reading}', + voice: '' + } + ], volume: 100, - autoPlay: false, - customSourceUrl: 'http://localhost/audio.mp3?term={term}&reading={reading}', - textToSpeechVoice: '' + autoPlay: false }, scanning: { touchInputEnabled: true, |