Audio system refactor (#858)

* Refactor AudioUriBuilder

* Add downloadAudio function

* Refactor AudioSystem

* Update API usage

* Rename file

* Update scripts

* Add prepare calls
This commit is contained in:
toasted-nutbread 2020-09-26 13:41:26 -04:00 committed by GitHub
parent 22932e02cb
commit 0b51488f1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 190 additions and 209 deletions

View File

@ -22,13 +22,12 @@
<script src="/mixed/js/environment.js"></script> <script src="/mixed/js/environment.js"></script>
<script src="/mixed/js/japanese.js"></script> <script src="/mixed/js/japanese.js"></script>
<script src="/mixed/js/audio-system.js"></script>
<script src="/mixed/js/cache-map.js"></script> <script src="/mixed/js/cache-map.js"></script>
<script src="/mixed/js/dictionary-data-util.js"></script> <script src="/mixed/js/dictionary-data-util.js"></script>
<script src="/mixed/js/object-property-accessor.js"></script> <script src="/mixed/js/object-property-accessor.js"></script>
<script src="/bg/js/anki.js"></script> <script src="/bg/js/anki.js"></script>
<script src="/bg/js/audio-uri-builder.js"></script> <script src="/bg/js/audio-downloader.js"></script>
<script src="/bg/js/clipboard-monitor.js"></script> <script src="/bg/js/clipboard-monitor.js"></script>
<script src="/bg/js/clipboard-reader.js"></script> <script src="/bg/js/clipboard-reader.js"></script>
<script src="/bg/js/database.js"></script> <script src="/bg/js/database.js"></script>

View File

@ -20,20 +20,54 @@
* jp * jp
*/ */
class AudioUriBuilder { class AudioDownloader {
constructor({requestBuilder}) { constructor({requestBuilder}) {
this._requestBuilder = requestBuilder; this._requestBuilder = requestBuilder;
this._getUrlHandlers = new Map([ this._getInfoHandlers = new Map([
['jpod101', this._getUriJpod101.bind(this)], ['jpod101', this._getInfoJpod101.bind(this)],
['jpod101-alternate', this._getUriJpod101Alternate.bind(this)], ['jpod101-alternate', this._getInfoJpod101Alternate.bind(this)],
['jisho', this._getUriJisho.bind(this)], ['jisho', this._getInfoJisho.bind(this)],
['text-to-speech', this._getUriTextToSpeech.bind(this)], ['text-to-speech', this._getInfoTextToSpeech.bind(this)],
['text-to-speech-reading', this._getUriTextToSpeechReading.bind(this)], ['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)],
['custom', this._getUriCustom.bind(this)] ['custom', this._getInfoCustom.bind(this)]
]); ]);
} }
normalizeUrl(url, baseUrl, basePath) { async getInfo(source, expression, reading, details) {
const handler = this._getInfoHandlers.get(source);
if (typeof handler === 'function') {
try {
return await handler(expression, reading, details);
} catch (e) {
// NOP
}
}
return null;
}
async downloadAudio(sources, expression, reading, details) {
for (const source of sources) {
const info = await this.getInfo(source, expression, reading, details);
if (info === null) { continue; }
switch (info.type) {
case 'url':
try {
const {details: {url}} = info;
return await this._downloadAudioFromUrl(url);
} catch (e) {
// NOP
}
break;
}
}
throw new Error('Could not download audio');
}
// Private
_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] === '/') {
@ -51,19 +85,7 @@ class AudioUriBuilder {
return url; return url;
} }
async getUri(source, expression, reading, details) { async _getInfoJpod101(expression, reading) {
const handler = this._getUrlHandlers.get(source);
if (typeof handler === 'function') {
try {
return await handler(expression, reading, details);
} catch (e) {
// NOP
}
}
return null;
}
async _getUriJpod101(expression, reading) {
let kana = reading; let kana = reading;
let kanji = expression; let kanji = expression;
@ -80,10 +102,11 @@ class AudioUriBuilder {
params.push(`kana=${encodeURIComponent(kana)}`); params.push(`kana=${encodeURIComponent(kana)}`);
} }
return `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`; const url = `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.join('&')}`;
return {type: 'url', details: {url}};
} }
async _getUriJpod101Alternate(expression, reading) { async _getInfoJpod101Alternate(expression, reading) {
const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post'; const fetchUrl = 'https://www.japanesepod101.com/learningcenter/reference/dictionary_post';
const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(expression)}&vulgar=true`; const data = `post=dictionary_reference&match_type=exact&search_query=${encodeURIComponent(expression)}&vulgar=true`;
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { const response = await this._requestBuilder.fetchAnonymous(fetchUrl, {
@ -109,7 +132,7 @@ class AudioUriBuilder {
const source = dom.getElementByTagName('source', audio); const source = dom.getElementByTagName('source', audio);
if (source === null) { continue; } if (source === null) { continue; }
const url = dom.getAttribute(source, 'src'); let url = dom.getAttribute(source, 'src');
if (url === null) { continue; } if (url === null) { continue; }
const htmlReadings = dom.getElementsByClassName('dc-vocab_kana'); const htmlReadings = dom.getElementsByClassName('dc-vocab_kana');
@ -117,7 +140,8 @@ class AudioUriBuilder {
const htmlReading = dom.getTextContent(htmlReadings[0]); const htmlReading = dom.getTextContent(htmlReadings[0]);
if (htmlReading && (!reading || reading === htmlReading)) { if (htmlReading && (!reading || reading === htmlReading)) {
return this.normalizeUrl(url, 'https://www.japanesepod101.com', '/learningcenter/reference/'); url = this._normalizeUrl(url, 'https://www.japanesepod101.com', '/learningcenter/reference/');
return {type: 'url', details: {url}};
} }
} catch (e) { } catch (e) {
// NOP // NOP
@ -127,7 +151,7 @@ class AudioUriBuilder {
throw new Error('Failed to find audio URL'); throw new Error('Failed to find audio URL');
} }
async _getUriJisho(expression, reading) { async _getInfoJisho(expression, reading) {
const fetchUrl = `https://jisho.org/search/${expression}`; const fetchUrl = `https://jisho.org/search/${expression}`;
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, { const response = await this._requestBuilder.fetchAnonymous(fetchUrl, {
method: 'GET', method: 'GET',
@ -145,9 +169,10 @@ class AudioUriBuilder {
if (audio !== null) { if (audio !== null) {
const source = dom.getElementByTagName('source', audio); const source = dom.getElementByTagName('source', audio);
if (source !== null) { if (source !== null) {
const url = dom.getAttribute(source, 'src'); let url = dom.getAttribute(source, 'src');
if (url !== null) { if (url !== null) {
return this.normalizeUrl(url, 'https://jisho.org', '/search/'); url = this._normalizeUrl(url, 'https://jisho.org', '/search/');
return {type: 'url', details: {url}};
} }
} }
} }
@ -158,25 +183,72 @@ class AudioUriBuilder {
throw new Error('Failed to find audio URL'); throw new Error('Failed to find audio URL');
} }
async _getUriTextToSpeech(expression, reading, {textToSpeechVoice}) { async _getInfoTextToSpeech(expression, reading, {textToSpeechVoice}) {
if (!textToSpeechVoice) { if (!textToSpeechVoice) {
throw new Error('No voice'); throw new Error('No voice');
} }
return `tts:?text=${encodeURIComponent(expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`; return {type: 'tts', details: {text: expression, voice: textToSpeechVoice}};
} }
async _getUriTextToSpeechReading(expression, reading, {textToSpeechVoice}) { async _getInfoTextToSpeechReading(expression, reading, {textToSpeechVoice}) {
if (!textToSpeechVoice) { if (!textToSpeechVoice) {
throw new Error('No voice'); throw new Error('No voice');
} }
return `tts:?text=${encodeURIComponent(reading || expression)}&voice=${encodeURIComponent(textToSpeechVoice)}`; return {type: 'tts', details: {text: reading || expression, voice: textToSpeechVoice}};
} }
async _getUriCustom(expression, reading, {customSourceUrl}) { async _getInfoCustom(expression, reading, {customSourceUrl}) {
if (typeof customSourceUrl !== 'string') { if (typeof customSourceUrl !== 'string') {
throw new Error('No custom URL defined'); throw new Error('No custom URL defined');
} }
const data = {expression, reading}; const data = {expression, reading};
return customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(data, m1) ? `${data[m1]}` : m0)); const url = customSourceUrl.replace(/\{([^}]*)\}/g, (m0, m1) => (hasOwn(data, m1) ? `${data[m1]}` : m0));
return {type: 'url', details: {url}};
}
async _downloadAudioFromUrl(url) {
const response = await this._requestBuilder.fetchAnonymous(url, {
method: 'GET',
mode: 'cors',
cache: 'default',
credentials: 'omit',
redirect: 'follow',
referrerPolicy: 'no-referrer'
});
if (!response.ok) {
throw new Error(`Invalid response: ${response.status}`);
}
const arrayBuffer = await response.arrayBuffer();
if (!await this._isAudioBinaryValid(arrayBuffer)) {
throw new Error('Could not retrieve audio');
}
return this._arrayBufferToBase64(arrayBuffer);
}
async _isAudioBinaryValid(arrayBuffer) {
const digest = await this._arrayBufferDigest(arrayBuffer);
switch (digest) {
case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // jpod101 invalid audio
return false;
default:
return true;
}
}
async _arrayBufferDigest(arrayBuffer) {
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer)));
let digest = '';
for (const byte of hash) {
digest += byte.toString(16).padStart(2, '0');
}
return digest;
}
_arrayBufferToBase64(arrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
} }
} }

View File

@ -17,8 +17,7 @@
/* global /* global
* AnkiConnect * AnkiConnect
* AudioSystem * AudioDownloader
* AudioUriBuilder
* ClipboardMonitor * ClipboardMonitor
* ClipboardReader * ClipboardReader
* DictionaryDatabase * DictionaryDatabase
@ -54,14 +53,9 @@ class Backend {
this._profileConditionsUtil = new ProfileConditions(); this._profileConditionsUtil = new ProfileConditions();
this._defaultAnkiFieldTemplates = null; this._defaultAnkiFieldTemplates = null;
this._requestBuilder = new RequestBuilder(); this._requestBuilder = new RequestBuilder();
this._audioUriBuilder = new AudioUriBuilder({ this._audioDownloader = new AudioDownloader({
requestBuilder: this._requestBuilder requestBuilder: this._requestBuilder
}); });
this._audioSystem = new AudioSystem({
audioUriBuilder: this._audioUriBuilder,
requestBuilder: this._requestBuilder,
useCache: false
});
this._optionsUtil = new OptionsUtil(); this._optionsUtil = new OptionsUtil();
this._searchPopupTabId = null; this._searchPopupTabId = null;
@ -91,7 +85,8 @@ class Backend {
['injectAnkiNoteMedia', {async: true, contentScript: true, handler: this._onApiInjectAnkiNoteMedia.bind(this)}], ['injectAnkiNoteMedia', {async: true, contentScript: true, handler: this._onApiInjectAnkiNoteMedia.bind(this)}],
['noteView', {async: true, contentScript: true, handler: this._onApiNoteView.bind(this)}], ['noteView', {async: true, contentScript: true, handler: this._onApiNoteView.bind(this)}],
['commandExec', {async: false, contentScript: true, handler: this._onApiCommandExec.bind(this)}], ['commandExec', {async: false, contentScript: true, handler: this._onApiCommandExec.bind(this)}],
['audioGetUri', {async: true, contentScript: true, handler: this._onApiAudioGetUri.bind(this)}], ['getDefinitionAudioInfo', {async: true, contentScript: true, handler: this._onApiGetDefinitionAudioInfo.bind(this)}],
['downloadDefinitionAudio', {async: true, contentScript: true, handler: this._onApiDownloadDefinitionAudio.bind(this)}],
['screenshotGet', {async: true, contentScript: true, handler: this._onApiScreenshotGet.bind(this)}], ['screenshotGet', {async: true, contentScript: true, handler: this._onApiScreenshotGet.bind(this)}],
['sendMessageToFrame', {async: false, contentScript: true, handler: this._onApiSendMessageToFrame.bind(this)}], ['sendMessageToFrame', {async: false, contentScript: true, handler: this._onApiSendMessageToFrame.bind(this)}],
['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}], ['broadcastTab', {async: false, contentScript: true, handler: this._onApiBroadcastTab.bind(this)}],
@ -117,7 +112,6 @@ class Backend {
['setAllSettings', {async: true, contentScript: false, handler: this._onApiSetAllSettings.bind(this)}], ['setAllSettings', {async: true, contentScript: false, handler: this._onApiSetAllSettings.bind(this)}],
['getOrCreateSearchPopup', {async: true, contentScript: true, handler: this._onApiGetOrCreateSearchPopup.bind(this)}], ['getOrCreateSearchPopup', {async: true, contentScript: true, handler: this._onApiGetOrCreateSearchPopup.bind(this)}],
['isTabSearchPopup', {async: true, contentScript: true, handler: this._onApiIsTabSearchPopup.bind(this)}], ['isTabSearchPopup', {async: true, contentScript: true, handler: this._onApiIsTabSearchPopup.bind(this)}],
['getDefinitionAudio', {async: true, contentScript: true, handler: this._onApiGetDefinitionAudio.bind(this)}],
['triggerDatabaseUpdated', {async: false, contentScript: true, handler: this._onApiTriggerDatabaseUpdated.bind(this)}] ['triggerDatabaseUpdated', {async: false, contentScript: true, handler: this._onApiTriggerDatabaseUpdated.bind(this)}]
]); ]);
this._messageHandlersWithProgress = new Map([ this._messageHandlersWithProgress = new Map([
@ -479,8 +473,12 @@ class Backend {
return this._runCommand(command, params); return this._runCommand(command, params);
} }
async _onApiAudioGetUri({source, expression, reading, details}) { async _onApiGetDefinitionAudioInfo({source, expression, reading, details}) {
return await this._audioUriBuilder.getUri(source, expression, reading, details); return await this._audioDownloader.getInfo(source, expression, reading, details);
}
async _onApiDownloadDefinitionAudio({sources, expression, reading, details}) {
return await this._downloadDefinitionAudio(sources, expression, reading, details);
} }
_onApiScreenshotGet({options}, sender) { _onApiScreenshotGet({options}, sender) {
@ -728,10 +726,6 @@ class Backend {
return (tab !== null); return (tab !== null);
} }
async _onApiGetDefinitionAudio({sources, expression, reading, details}) {
return this._getDefinitionAudio(sources, expression, reading, details);
}
_onApiTriggerDatabaseUpdated({type, cause}) { _onApiTriggerDatabaseUpdated({type, cause}) {
this._triggerDatabaseUpdated(type, cause); this._triggerDatabaseUpdated(type, cause);
} }
@ -1511,8 +1505,8 @@ class Backend {
} }
} }
async _getDefinitionAudio(sources, expression, reading, details) { async _downloadDefinitionAudio(sources, expression, reading, details) {
return await this._audioSystem.getDefinitionAudio(sources, expression, reading, details); return await this._audioDownloader.downloadAudio(sources, expression, reading, details);
} }
async _injectAnkNoteMedia(ankiConnect, expression, reading, timestamp, audioDetails, screenshotDetails, clipboardImage) { async _injectAnkNoteMedia(ankiConnect, expression, reading, timestamp, audioDetails, screenshotDetails, clipboardImage) {
@ -1548,7 +1542,7 @@ class Backend {
fileName = this._replaceInvalidFileNameCharacters(fileName); fileName = this._replaceInvalidFileNameCharacters(fileName);
const {sources, customSourceUrl} = details; const {sources, customSourceUrl} = details;
const {audio: data} = await this._getDefinitionAudio( const data = await this._downloadDefinitionAudio(
sources, sources,
expression, expression,
reading, reading,

View File

@ -22,17 +22,16 @@
class AudioController { class AudioController {
constructor(settingsController) { constructor(settingsController) {
this._settingsController = settingsController; this._settingsController = settingsController;
this._audioSystem = null; this._audioSystem = new AudioSystem({
cacheSize: 0
});
this._audioSourceContainer = null; this._audioSourceContainer = null;
this._audioSourceAddButton = null; this._audioSourceAddButton = null;
this._audioSourceEntries = []; this._audioSourceEntries = [];
} }
async prepare() { async prepare() {
this._audioSystem = new AudioSystem({ this._audioSystem.prepare();
audioUriBuilder: null,
useCache: true
});
this._audioSourceContainer = document.querySelector('.audio-source-list'); this._audioSourceContainer = document.querySelector('.audio-source-list');
this._audioSourceAddButton = document.querySelector('.audio-source-add'); this._audioSourceAddButton = document.querySelector('.audio-source-add');

View File

@ -75,6 +75,7 @@
<script src="/mixed/js/api.js"></script> <script src="/mixed/js/api.js"></script>
<script src="/mixed/js/japanese.js"></script> <script src="/mixed/js/japanese.js"></script>
<script src="/mixed/js/cache-map.js"></script>
<script src="/mixed/js/document-util.js"></script> <script src="/mixed/js/document-util.js"></script>
<script src="/fg/js/dom-text-scanner.js"></script> <script src="/fg/js/dom-text-scanner.js"></script>
<script src="/fg/js/source.js"></script> <script src="/fg/js/source.js"></script>

View File

@ -50,6 +50,7 @@
<script src="/mixed/js/api.js"></script> <script src="/mixed/js/api.js"></script>
<script src="/mixed/js/japanese.js"></script> <script src="/mixed/js/japanese.js"></script>
<script src="/mixed/js/cache-map.js"></script>
<script src="/mixed/js/document-util.js"></script> <script src="/mixed/js/document-util.js"></script>
<script src="/fg/js/dom-text-scanner.js"></script> <script src="/fg/js/dom-text-scanner.js"></script>
<script src="/fg/js/source.js"></script> <script src="/fg/js/source.js"></script>

View File

@ -85,8 +85,12 @@ const api = (() => {
return this._invoke('noteView', {noteId}); return this._invoke('noteView', {noteId});
} }
audioGetUri(source, expression, reading, details) { getDefinitionAudioInfo(source, expression, reading, details) {
return this._invoke('audioGetUri', {source, expression, reading, details}); return this._invoke('getDefinitionAudioInfo', {source, expression, reading, details});
}
downloadDefinitionAudio(sources, expression, reading, details) {
return this._invoke('downloadDefinitionAudio', {sources, expression, reading, details});
} }
commandExec(command, params) { commandExec(command, params) {
@ -189,10 +193,6 @@ const api = (() => {
return this._invoke('isTabSearchPopup', {tabId}); return this._invoke('isTabSearchPopup', {tabId});
} }
getDefinitionAudio(sources, expression, reading, details) {
return this._invoke('getDefinitionAudio', {sources, expression, reading, details});
}
triggerDatabaseUpdated(type, cause) { triggerDatabaseUpdated(type, cause) {
return this._invoke('triggerDatabaseUpdated', {type, cause}); return this._invoke('triggerDatabaseUpdated', {type, cause});
} }

View File

@ -16,101 +16,72 @@
*/ */
/* global /* global
* CacheMap
* TextToSpeechAudio * TextToSpeechAudio
*/ */
class AudioSystem { class AudioSystem {
constructor({audioUriBuilder, requestBuilder=null, useCache}) { constructor({getAudioInfo, cacheSize=32}) {
this._cache = useCache ? new Map() : null; this._cache = new CacheMap(cacheSize);
this._cacheSizeMaximum = 32; this._getAudioInfo = getAudioInfo;
this._audioUriBuilder = audioUriBuilder; }
this._requestBuilder = requestBuilder;
if (typeof speechSynthesis !== 'undefined') { prepare() {
// speechSynthesis.getVoices() will not be populated unless some API call is made. // speechSynthesis.getVoices() will not be populated unless some API call is made.
speechSynthesis.addEventListener('voiceschanged', this._onVoicesChanged.bind(this)); if (typeof speechSynthesis === 'undefined') { return; }
}
const eventListeners = new EventListenerCollection();
const onVoicesChanged = () => { eventListeners.removeAllEventListeners(); };
eventListeners.addEventListener(speechSynthesis, 'voiceschanged', onVoicesChanged, false);
} }
async getDefinitionAudio(sources, expression, reading, details) { async createDefinitionAudio(sources, expression, reading, details) {
const key = `${expression}:${reading}`; const key = [expression, reading];
const hasCache = (this._cache !== null && !details.disableCache);
if (hasCache) {
const cacheValue = this._cache.get(key); const cacheValue = this._cache.get(key);
if (typeof cacheValue !== 'undefined') { if (typeof cacheValue !== 'undefined') {
const {audio, uri, source} = cacheValue; const {audio, source} = cacheValue;
const index = sources.indexOf(source); const index = sources.indexOf(source);
if (index >= 0) { if (index >= 0) {
return {audio, uri, index}; return {audio, index};
}
} }
} }
for (let i = 0, ii = sources.length; i < ii; ++i) { for (let i = 0, ii = sources.length; i < ii; ++i) {
const source = sources[i]; const source = sources[i];
const uri = await this._getAudioUri(source, expression, reading, details); const info = await this._getAudioInfo(source, expression, reading, details);
if (uri === null) { continue; } if (info === null) { continue; }
let audio;
try { try {
const audio = ( switch (info.type) {
details.binary ? case 'url':
await this._createAudioBinary(uri) : {
await this._createAudio(uri) const {details: {url}} = info;
); audio = await this.createAudio(url);
if (hasCache) { }
this._cacheCheck(); break;
this._cache.set(key, {audio, uri, source}); case 'tts':
{
const {details: {text, voice}} = info;
audio = this.createTextToSpeechAudio(text, voice);
}
break;
default:
throw new Error(`Unsupported type: ${info.type}`);
} }
return {audio, uri, index: i};
} catch (e) { } catch (e) {
// NOP continue;
} }
this._cache.set(key, {audio, source});
return {audio, index: i};
} }
throw new Error('Could not create audio'); throw new Error('Could not create audio');
} }
createTextToSpeechAudio(text, voiceUri) { createAudio(url) {
const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri);
if (voice === null) {
throw new Error('Invalid text-to-speech voice');
}
return new TextToSpeechAudio(text, voice);
}
_onVoicesChanged() {
// NOP
}
_getAudioUri(source, expression, reading, details) {
return (
this._audioUriBuilder !== null ?
this._audioUriBuilder.getUri(source, expression, reading, details) :
null
);
}
async _createAudio(uri) {
const ttsParameters = this._getTextToSpeechParameters(uri);
if (ttsParameters !== null) {
const {text, voiceUri} = ttsParameters;
return this.createTextToSpeechAudio(text, voiceUri);
}
return await this._createAudioFromUrl(uri);
}
async _createAudioBinary(uri) {
const ttsParameters = this._getTextToSpeechParameters(uri);
if (ttsParameters !== null) {
throw new Error('Cannot create audio from text-to-speech');
}
return await this._createAudioBinaryFromUrl(uri);
}
_createAudioFromUrl(url) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const audio = new Audio(url); const audio = new Audio(url);
audio.addEventListener('loadeddata', () => { audio.addEventListener('loadeddata', () => {
@ -124,27 +95,15 @@ class AudioSystem {
}); });
} }
async _createAudioBinaryFromUrl(url) { createTextToSpeechAudio(text, voiceUri) {
const response = await this._requestBuilder.fetchAnonymous(url, { const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri);
method: 'GET', if (voice === null) {
mode: 'cors', throw new Error('Invalid text-to-speech voice');
cache: 'default', }
credentials: 'omit', return new TextToSpeechAudio(text, voice);
redirect: 'follow',
referrerPolicy: 'no-referrer'
});
const arrayBuffer = await response.arrayBuffer();
if (!await this._isAudioBinaryValid(arrayBuffer)) {
throw new Error('Could not retrieve audio');
} }
return this._arrayBufferToBase64(arrayBuffer); // Private
}
_arrayBufferToBase64(arrayBuffer) {
return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}
_isAudioValid(audio) { _isAudioValid(audio) {
const duration = audio.duration; const duration = audio.duration;
@ -154,16 +113,6 @@ class AudioSystem {
); );
} }
async _isAudioBinaryValid(arrayBuffer) {
const digest = await AudioSystem.arrayBufferDigest(arrayBuffer);
switch (digest) {
case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // jpod101 invalid audio
return false;
default:
return true;
}
}
_getTextToSpeechVoiceFromVoiceUri(voiceUri) { _getTextToSpeechVoiceFromVoiceUri(voiceUri) {
try { try {
for (const voice of speechSynthesis.getVoices()) { for (const voice of speechSynthesis.getVoices()) {
@ -176,38 +125,4 @@ class AudioSystem {
} }
return null; return null;
} }
_getTextToSpeechParameters(uri) {
const m = /^tts:[^#?]*\?([^#]*)/.exec(uri);
if (m === null) { return null; }
const searchParameters = new URLSearchParams(m[1]);
const text = searchParameters.get('text');
const voiceUri = searchParameters.get('voice');
return (text !== null && voiceUri !== null ? {text, voiceUri} : null);
}
_cacheCheck() {
const removeCount = this._cache.size - this._cacheSizeMaximum;
if (removeCount <= 0) { return; }
const removeKeys = [];
for (const key of this._cache.keys()) {
removeKeys.push(key);
if (removeKeys.length >= removeCount) { break; }
}
for (const key of removeKeys) {
this._cache.delete(key);
}
}
static async arrayBufferDigest(arrayBuffer) {
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', new Uint8Array(arrayBuffer)));
let digest = '';
for (const byte of hash) {
digest += byte.toString(16).padStart(2, '0');
}
return digest;
}
} }

View File

@ -43,12 +43,7 @@ class Display extends EventDispatcher {
this._audioPlaying = null; this._audioPlaying = null;
this._audioFallback = null; this._audioFallback = null;
this._audioSystem = new AudioSystem({ this._audioSystem = new AudioSystem({
audioUriBuilder: { getAudioInfo: this._getAudioInfo.bind(this)
getUri: async (source, expression, reading, details) => {
return await api.audioGetUri(source, expression, reading, details);
}
},
useCache: true
}); });
this._styleNode = null; this._styleNode = null;
this._eventListeners = new EventListenerCollection(); this._eventListeners = new EventListenerCollection();
@ -165,6 +160,7 @@ class Display extends EventDispatcher {
} }
async prepare() { async prepare() {
this._audioSystem.prepare();
this._updateMode(); this._updateMode();
this._setInteractive(true); this._setInteractive(true);
await this._displayGenerator.prepare(); await this._displayGenerator.prepare();
@ -1096,7 +1092,7 @@ class Display extends EventDispatcher {
try { try {
const {sources, textToSpeechVoice, customSourceUrl} = this._options.audio; const {sources, textToSpeechVoice, customSourceUrl} = this._options.audio;
let index; let index;
({audio, index} = await this._audioSystem.getDefinitionAudio(sources, expression, reading, {textToSpeechVoice, customSourceUrl})); ({audio, index} = await this._audioSystem.createDefinitionAudio(sources, expression, reading, {textToSpeechVoice, customSourceUrl}));
info = `From source ${1 + index}: ${sources[index]}`; info = `From source ${1 + index}: ${sources[index]}`;
} catch (e) { } catch (e) {
if (this._audioFallback === null) { if (this._audioFallback === null) {
@ -1419,4 +1415,8 @@ class Display extends EventDispatcher {
modeOptions modeOptions
}); });
} }
async _getAudioInfo(source, expression, reading, details) {
return await api.getDefinitionAudioInfo(source, expression, reading, details);
}
} }