Merge pull request #401 from toasted-nutbread/audio-refactor

Audio refactor
This commit is contained in:
toasted-nutbread 2020-03-10 19:20:34 -04:00 committed by GitHub
commit 36c55f0b17
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 115 additions and 104 deletions

View File

@ -24,7 +24,7 @@
<script src="/bg/js/anki.js"></script> <script src="/bg/js/anki.js"></script>
<script src="/bg/js/anki-note-builder.js"></script> <script src="/bg/js/anki-note-builder.js"></script>
<script src="/bg/js/mecab.js"></script> <script src="/bg/js/mecab.js"></script>
<script src="/bg/js/audio.js"></script> <script src="/bg/js/audio-uri-builder.js"></script>
<script src="/bg/js/backend-api-forwarder.js"></script> <script src="/bg/js/backend-api-forwarder.js"></script>
<script src="/bg/js/clipboard-monitor.js"></script> <script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/conditions.js"></script> <script src="/bg/js/conditions.js"></script>

View File

@ -18,110 +18,19 @@
/*global jpIsStringEntirelyKana*/ /*global jpIsStringEntirelyKana*/
const audioUrlBuilders = new Map([ class AudioUriBuilder {
['jpod101', async (definition) => { constructor() {
let kana = definition.reading; this._getUrlHandlers = new Map([
let kanji = definition.expression; ['jpod101', this._getUriJpod101.bind(this)],
['jpod101-alternate', this._getUriJpod101Alternate.bind(this)],
if (!kana && jpIsStringEntirelyKana(kanji)) { ['jisho', this._getUriJisho.bind(this)],
kana = kanji; ['text-to-speech', this._getUriTextToSpeech.bind(this)],
kanji = null; ['text-to-speech-reading', this._getUriTextToSpeechReading.bind(this)],
} ['custom', this._getUriCustom.bind(this)]
const params = [];
if (kanji) {
params.push(`kanji=${encodeURIComponent(kanji)}`);
}
if (kana) {
params.push(`kana=${encodeURIComponent(kana)}`);
}
return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`;
}],
['jpod101-alternate', async (definition) => {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post');
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data')));
xhr.addEventListener('load', () => resolve(xhr.responseText));
xhr.send(`post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`);
});
const dom = new DOMParser().parseFromString(response, 'text/html');
for (const row of dom.getElementsByClassName('dc-result-row')) {
try {
const url = row.querySelector('audio>source[src]').getAttribute('src');
const reading = row.getElementsByClassName('dc-vocab_kana').item(0).textContent;
if (url && reading && (!definition.reading || definition.reading === reading)) {
return audioUrlNormalize(url, 'https://www.japanesepod101.com', '/learningcenter/reference/');
}
} catch (e) {
// NOP
}
}
throw new Error('Failed to find audio URL');
}],
['jisho', async (definition) => {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `https://jisho.org/search/${definition.expression}`);
xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data')));
xhr.addEventListener('load', () => resolve(xhr.responseText));
xhr.send();
});
const dom = new DOMParser().parseFromString(response, 'text/html');
try {
const audio = dom.getElementById(`audio_${definition.expression}:${definition.reading}`);
if (audio !== null) {
const url = audio.getElementsByTagName('source').item(0).getAttribute('src');
if (url) {
return audioUrlNormalize(url, 'https://jisho.org', '/search/');
}
}
} catch (e) {
// NOP
}
throw new Error('Failed to find audio URL');
}],
['text-to-speech', async (definition, options) => {
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, options) => {
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, options) => {
const customSourceUrl = options.audio.customSourceUrl;
return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0));
}]
]); ]);
async function audioGetUrl(definition, mode, options, download) {
const handler = audioUrlBuilders.get(mode);
if (typeof handler === 'function') {
try {
return await handler(definition, options, download);
} catch (e) {
// NOP
}
}
return null;
} }
function audioUrlNormalize(url, baseUrl, basePath) { normalizeUrl(url, baseUrl, basePath) {
if (url) { if (url) {
if (url[0] === '/') { if (url[0] === '/') {
if (url.length >= 2 && url[1] === '/') { if (url.length >= 2 && url[1] === '/') {
@ -139,46 +48,109 @@ function audioUrlNormalize(url, baseUrl, basePath) {
return url; return url;
} }
function audioBuildFilename(definition) { async getUri(definition, source, options) {
if (definition.reading || definition.expression) { const handler = this._getUrlHandlers.get(source);
let filename = 'yomichan'; if (typeof handler === 'function') {
if (definition.reading) { try {
filename += `_${definition.reading}`; return await handler(definition, options);
} catch (e) {
// NOP
} }
if (definition.expression) {
filename += `_${definition.expression}`;
}
return filename += '.mp3';
} }
return null; return null;
} }
async function audioInject(definition, fields, sources, optionsContext, audioSystem) { async _getUriJpod101(definition) {
let usesAudio = false; let kana = definition.reading;
for (const fieldValue of Object.values(fields)) { let kanji = definition.expression;
if (fieldValue.includes('{audio}')) {
usesAudio = true; if (!kana && jpIsStringEntirelyKana(kanji)) {
break; kana = kanji;
} kanji = null;
} }
if (!usesAudio) { const params = [];
return true; if (kanji) {
params.push(`kanji=${encodeURIComponent(kanji)}`);
}
if (kana) {
params.push(`kana=${encodeURIComponent(kana)}`);
} }
return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`;
}
async _getUriJpod101Alternate(definition) {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post');
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data')));
xhr.addEventListener('load', () => resolve(xhr.responseText));
xhr.send(`post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(definition.expression)}`);
});
const dom = new DOMParser().parseFromString(response, 'text/html');
for (const row of dom.getElementsByClassName('dc-result-row')) {
try { try {
const expressions = definition.expressions; const url = row.querySelector('audio>source[src]').getAttribute('src');
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition; const reading = row.getElementsByClassName('dc-vocab_kana').item(0).textContent;
if (url && reading && (!definition.reading || definition.reading === reading)) {
const {uri} = await audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext}); return this.normalizeUrl(url, 'https://www.japanesepod101.com', '/learningcenter/reference/');
const filename = audioBuildFilename(audioSourceDefinition);
if (filename !== null) {
definition.audio = {url: uri, filename};
} }
return true;
} catch (e) { } catch (e) {
return false; // NOP
}
}
throw new Error('Failed to find audio URL');
}
async _getUriJisho(definition) {
const response = await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', `https://jisho.org/search/${definition.expression}`);
xhr.addEventListener('error', () => reject(new Error('Failed to scrape audio data')));
xhr.addEventListener('load', () => resolve(xhr.responseText));
xhr.send();
});
const dom = new DOMParser().parseFromString(response, 'text/html');
try {
const audio = dom.getElementById(`audio_${definition.expression}:${definition.reading}`);
if (audio !== null) {
const url = audio.getElementsByTagName('source').item(0).getAttribute('src');
if (url) {
return this.normalizeUrl(url, 'https://jisho.org', '/search/');
}
}
} catch (e) {
// NOP
}
throw new Error('Failed to find audio URL');
}
async _getUriTextToSpeech(definition, options) {
const voiceURI = options.audio.textToSpeechVoice;
if (!voiceURI) {
throw new Error('No voice');
}
return `tts:?text=${encodeURIComponent(definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
}
async _getUriTextToSpeechReading(definition, options) {
const voiceURI = options.audio.textToSpeechVoice;
if (!voiceURI) {
throw new Error('No voice');
}
return `tts:?text=${encodeURIComponent(definition.reading || definition.expression)}&voice=${encodeURIComponent(voiceURI)}`;
}
async _getUriCustom(definition, options) {
const customSourceUrl = options.audio.customSourceUrl;
return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(definition, m1) ? `${definition[m1]}` : m0));
} }
} }

View File

@ -21,9 +21,8 @@ conditionsTestValue, profileConditionsDescriptor
handlebarsRenderDynamic handlebarsRenderDynamic
requestText, requestJson, optionsLoad requestText, requestJson, optionsLoad
dictConfigured, dictTermsSort, dictEnabledSet dictConfigured, dictTermsSort, dictEnabledSet
audioGetUrl, audioInject
jpConvertReading, jpDistributeFuriganaInflected, jpKatakanaToHiragana jpConvertReading, jpDistributeFuriganaInflected, jpKatakanaToHiragana
AnkiNoteBuilder, AudioSystem, Translator, AnkiConnect, AnkiNull, Mecab, BackendApiForwarder, JsonSchema, ClipboardMonitor*/ AnkiNoteBuilder, AudioSystem, AudioUriBuilder, Translator, AnkiConnect, AnkiNull, Mecab, BackendApiForwarder, JsonSchema, ClipboardMonitor*/
class Backend { class Backend {
constructor() { constructor() {
@ -36,6 +35,7 @@ class Backend {
this.optionsSchema = null; this.optionsSchema = null;
this.defaultAnkiFieldTemplates = null; this.defaultAnkiFieldTemplates = null;
this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)}); this.audioSystem = new AudioSystem({getAudioUri: this._getAudioUri.bind(this)});
this.audioUriBuilder = new AudioUriBuilder();
this.optionsContext = { this.optionsContext = {
depth: 0, depth: 0,
url: window.location.href url: window.location.href
@ -67,7 +67,7 @@ class Backend {
['noteView', this._onApiNoteView.bind(this)], ['noteView', this._onApiNoteView.bind(this)],
['templateRender', this._onApiTemplateRender.bind(this)], ['templateRender', this._onApiTemplateRender.bind(this)],
['commandExec', this._onApiCommandExec.bind(this)], ['commandExec', this._onApiCommandExec.bind(this)],
['audioGetUrl', this._onApiAudioGetUrl.bind(this)], ['audioGetUri', this._onApiAudioGetUri.bind(this)],
['screenshotGet', this._onApiScreenshotGet.bind(this)], ['screenshotGet', this._onApiScreenshotGet.bind(this)],
['forward', this._onApiForward.bind(this)], ['forward', this._onApiForward.bind(this)],
['frameInformationGet', this._onApiFrameInformationGet.bind(this)], ['frameInformationGet', this._onApiFrameInformationGet.bind(this)],
@ -434,12 +434,11 @@ class Backend {
const templates = this.defaultAnkiFieldTemplates; const templates = this.defaultAnkiFieldTemplates;
if (mode !== 'kanji') { if (mode !== 'kanji') {
await audioInject( await this._audioInject(
definition, definition,
options.anki.terms.fields, options.anki.terms.fields,
options.audio.sources, options.audio.sources,
optionsContext, optionsContext
this.audioSystem
); );
} }
@ -514,9 +513,9 @@ class Backend {
return this._runCommand(command, params); return this._runCommand(command, params);
} }
async _onApiAudioGetUrl({definition, source, optionsContext}) { async _onApiAudioGetUri({definition, source, optionsContext}) {
const options = this.getOptions(optionsContext); const options = this.getOptions(optionsContext);
return await audioGetUrl(definition, source, options); return await this.audioUriBuilder.getUri(definition, source, options);
} }
_onApiScreenshotGet({options}, sender) { _onApiScreenshotGet({options}, sender) {
@ -772,7 +771,36 @@ class Backend {
} }
const options = this.getOptions(optionsContext); const options = this.getOptions(optionsContext);
return await audioGetUrl(definition, source, options); return await this.audioUriBuilder.getUri(definition, source, options);
}
async _audioInject(definition, fields, sources, optionsContext) {
let usesAudio = false;
for (const fieldValue of Object.values(fields)) {
if (fieldValue.includes('{audio}')) {
usesAudio = true;
break;
}
}
if (!usesAudio) {
return true;
}
try {
const expressions = definition.expressions;
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
const {uri} = await this.audioSystem.getDefinitionAudio(audioSourceDefinition, sources, {tts: false, optionsContext});
const filename = this._createInjectedAudioFileName(audioSourceDefinition);
if (filename !== null) {
definition.audio = {url: uri, filename};
}
return true;
} catch (e) {
return false;
}
} }
async _injectScreenshot(definition, fields, screenshot) { async _injectScreenshot(definition, fields, screenshot) {
@ -815,6 +843,17 @@ class Backend {
return handlebarsRenderDynamic(template, data); return handlebarsRenderDynamic(template, data);
} }
_createInjectedAudioFileName(definition) {
const {reading, expression} = definition;
if (!reading && !expression) { return null; }
let filename = 'yomichan';
if (reading) { filename += `_${reading}`; }
if (expression) { filename += `_${expression}`; }
filename += '.mp3';
return filename;
}
static _getTabUrl(tab) { static _getTabUrl(tab) {
return new Promise((resolve) => { return new Promise((resolve) => {
chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => { chrome.tabs.sendMessage(tab.id, {action: 'getUrl'}, {frameId: 0}, (response) => {

View File

@ -16,7 +16,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
/*global getOptionsContext, getOptionsMutable, settingsSaveOptions, apiAudioGetUrl /*global getOptionsContext, getOptionsMutable, settingsSaveOptions, apiAudioGetUri
AudioSystem, AudioSourceUI*/ AudioSystem, AudioSourceUI*/
let audioSourceUI = null; let audioSourceUI = null;
@ -26,7 +26,7 @@ async function audioSettingsInitialize() {
audioSystem = new AudioSystem({ audioSystem = new AudioSystem({
getAudioUri: async (definition, source) => { getAudioUri: async (definition, source) => {
const optionsContext = getOptionsContext(); const optionsContext = getOptionsContext();
return await apiAudioGetUrl(definition, source, optionsContext); return await apiAudioGetUri(definition, source, optionsContext);
} }
}); });

View File

@ -69,8 +69,8 @@ function apiTemplateRender(template, data) {
return _apiInvoke('templateRender', {data, template}); return _apiInvoke('templateRender', {data, template});
} }
function apiAudioGetUrl(definition, source, optionsContext) { function apiAudioGetUri(definition, source, optionsContext) {
return _apiInvoke('audioGetUrl', {definition, source, optionsContext}); return _apiInvoke('audioGetUri', {definition, source, optionsContext});
} }
function apiCommandExec(command, params) { function apiCommandExec(command, params) {

View File

@ -18,7 +18,7 @@
/*global docRangeFromPoint, docSentenceExtract /*global docRangeFromPoint, docSentenceExtract
apiKanjiFind, apiTermsFind, apiNoteView, apiOptionsGet, apiDefinitionsAddable, apiDefinitionAdd apiKanjiFind, apiTermsFind, apiNoteView, apiOptionsGet, apiDefinitionsAddable, apiDefinitionAdd
apiScreenshotGet, apiForward, apiAudioGetUrl apiScreenshotGet, apiForward, apiAudioGetUri
AudioSystem, DisplayGenerator, WindowScroll, DisplayContext, DOM*/ AudioSystem, DisplayGenerator, WindowScroll, DisplayContext, DOM*/
class Display { class Display {
@ -919,6 +919,6 @@ class Display {
async _getAudioUri(definition, source) { async _getAudioUri(definition, source) {
const optionsContext = this.getOptionsContext(); const optionsContext = this.getOptionsContext();
return await apiAudioGetUrl(definition, source, optionsContext); return await apiAudioGetUri(definition, source, optionsContext);
} }
} }