mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-03-02 06:22:42 -08:00
feat(assets): bundle runtime assets and vendor dependencies
This commit is contained in:
648
vendor/yomitan/js/media/audio-downloader.js
vendored
Normal file
648
vendor/yomitan/js/media/audio-downloader.js
vendored
Normal file
@@ -0,0 +1,648 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2017-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {RequestBuilder} from '../background/request-builder.js';
|
||||
import {ExtensionError} from '../core/extension-error.js';
|
||||
import {readResponseJson} from '../core/json.js';
|
||||
import {arrayBufferToBase64} from '../data/array-buffer-util.js';
|
||||
import {JsonSchema} from '../data/json-schema.js';
|
||||
import {NativeSimpleDOMParser} from '../dom/native-simple-dom-parser.js';
|
||||
import {SimpleDOMParser} from '../dom/simple-dom-parser.js';
|
||||
import {isStringEntirelyKana} from '../language/ja/japanese.js';
|
||||
|
||||
/** @type {RequestInit} */
|
||||
const DEFAULT_REQUEST_INIT_PARAMS = {
|
||||
method: 'GET',
|
||||
mode: 'cors',
|
||||
cache: 'default',
|
||||
credentials: 'omit',
|
||||
redirect: 'follow',
|
||||
referrerPolicy: 'no-referrer',
|
||||
};
|
||||
|
||||
export class AudioDownloader {
|
||||
/**
|
||||
* @param {RequestBuilder} requestBuilder
|
||||
*/
|
||||
constructor(requestBuilder) {
|
||||
/** @type {RequestBuilder} */
|
||||
this._requestBuilder = requestBuilder;
|
||||
/** @type {?JsonSchema} */
|
||||
this._customAudioListSchema = null;
|
||||
/** @type {Map<import('settings').AudioSourceType, import('audio-downloader').GetInfoHandler>} */
|
||||
this._getInfoHandlers = new Map(/** @type {[name: import('settings').AudioSourceType, handler: import('audio-downloader').GetInfoHandler][]} */ ([
|
||||
['jpod101', this._getInfoJpod101.bind(this)],
|
||||
['language-pod-101', this._getInfoLanguagePod101.bind(this)],
|
||||
['jisho', this._getInfoJisho.bind(this)],
|
||||
['lingua-libre', this._getInfoLinguaLibre.bind(this)],
|
||||
['wiktionary', this._getInfoWiktionary.bind(this)],
|
||||
['text-to-speech', this._getInfoTextToSpeech.bind(this)],
|
||||
['text-to-speech-reading', this._getInfoTextToSpeechReading.bind(this)],
|
||||
['custom', this._getInfoCustom.bind(this)],
|
||||
['custom-json', this._getInfoCustomJson.bind(this)],
|
||||
]));
|
||||
/** @type {Intl.DisplayNames} */
|
||||
this._regionNames = new Intl.DisplayNames(['en'], {type: 'region'});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('audio').AudioSourceInfo} source
|
||||
* @param {string} term
|
||||
* @param {string} reading
|
||||
* @param {import('language').LanguageSummary} languageSummary
|
||||
* @returns {Promise<import('audio-downloader').Info[]>}
|
||||
*/
|
||||
async getTermAudioInfoList(source, term, reading, languageSummary) {
|
||||
const handler = this._getInfoHandlers.get(source.type);
|
||||
if (typeof handler === 'function') {
|
||||
try {
|
||||
return await handler(term, reading, source, languageSummary);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('audio').AudioSourceInfo[]} sources
|
||||
* @param {?number} preferredAudioIndex
|
||||
* @param {string} term
|
||||
* @param {string} reading
|
||||
* @param {?number} idleTimeout
|
||||
* @param {import('language').LanguageSummary} languageSummary
|
||||
* @param {boolean} enableDefaultAudioSources
|
||||
* @returns {Promise<import('audio-downloader').AudioBinaryBase64>}
|
||||
*/
|
||||
async downloadTermAudio(sources, preferredAudioIndex, term, reading, idleTimeout, languageSummary, enableDefaultAudioSources) {
|
||||
const errors = [];
|
||||
const requiredAudioSources = enableDefaultAudioSources ? getRequiredAudioSources(languageSummary.iso, sources) : [];
|
||||
for (const source of [...sources, ...requiredAudioSources]) {
|
||||
let infoList = await this.getTermAudioInfoList(source, term, reading, languageSummary);
|
||||
if (typeof preferredAudioIndex === 'number') {
|
||||
infoList = (preferredAudioIndex >= 0 && preferredAudioIndex < infoList.length ? [infoList[preferredAudioIndex]] : []);
|
||||
}
|
||||
for (const info of infoList) {
|
||||
switch (info.type) {
|
||||
case 'url':
|
||||
try {
|
||||
return await this._downloadAudioFromUrl(info.url, source.type, idleTimeout);
|
||||
} catch (e) {
|
||||
errors.push(e);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const error = new ExtensionError('Could not download audio');
|
||||
error.data = {errors};
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {string} base
|
||||
* @returns {string}
|
||||
*/
|
||||
_normalizeUrl(url, base) {
|
||||
return new URL(url, base).href;
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoJpod101(term, reading) {
|
||||
if (reading === term && isStringEntirelyKana(term)) {
|
||||
reading = term;
|
||||
term = '';
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (term.length > 0) {
|
||||
params.set('kanji', term);
|
||||
}
|
||||
if (reading.length > 0) {
|
||||
params.set('kana', reading);
|
||||
}
|
||||
|
||||
const url = `https://assets.languagepod101.com/dictionary/japanese/audiomp3.php?${params.toString()}`;
|
||||
return [{type: 'url', url}];
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoLanguagePod101(term, reading, _details, languageSummary) {
|
||||
const {name: language} = languageSummary;
|
||||
|
||||
const fetchUrl = this._getLanguagePod101FetchUrl(language);
|
||||
const data = new URLSearchParams({
|
||||
post: 'dictionary_reference',
|
||||
match_type: 'exact',
|
||||
search_query: term,
|
||||
vulgar: 'true',
|
||||
});
|
||||
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, {
|
||||
...DEFAULT_REQUEST_INIT_PARAMS,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: data,
|
||||
});
|
||||
const responseText = await response.text();
|
||||
|
||||
const dom = this._createSimpleDOMParser(responseText);
|
||||
/** @type {Set<string>} */
|
||||
const urls = new Set();
|
||||
for (const row of dom.getElementsByClassName('dc-result-row')) {
|
||||
try {
|
||||
const audio = dom.getElementByTagName('audio', row);
|
||||
if (audio === null) { continue; }
|
||||
|
||||
const source = dom.getElementByTagName('source', audio);
|
||||
if (source === null) { continue; }
|
||||
|
||||
let url = dom.getAttribute(source, 'src');
|
||||
if (url === null) { continue; }
|
||||
|
||||
if (!this._validateLanguagePod101Row(language, dom, row, term, reading)) { continue; }
|
||||
url = this._normalizeUrl(url, response.url);
|
||||
urls.add(url);
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
return [...urls].map((url) => ({type: 'url', url}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} language
|
||||
* @param {import('simple-dom-parser').ISimpleDomParser} dom
|
||||
* @param {import('simple-dom-parser').Element} row
|
||||
* @param {string} term
|
||||
* @param {string} reading
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_validateLanguagePod101Row(language, dom, row, term, reading) {
|
||||
switch (language) {
|
||||
case 'Japanese': {
|
||||
const htmlReadings = dom.getElementsByClassName('dc-vocab_kana', row);
|
||||
if (htmlReadings.length === 0) { return false; }
|
||||
|
||||
const htmlReading = dom.getTextContent(htmlReadings[0]);
|
||||
if (!htmlReading) { return false; }
|
||||
if (reading !== term && reading !== htmlReading) { return false; }
|
||||
} break;
|
||||
default: {
|
||||
const vocab = dom.getElementsByClassName('dc-vocab', row);
|
||||
if (vocab.length === 0) { return false; }
|
||||
|
||||
if (term !== dom.getTextContent(vocab[0])) { return false; }
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} language
|
||||
* @returns {string}
|
||||
*/
|
||||
_getLanguagePod101FetchUrl(language) {
|
||||
const podOrClass = this._getLanguagePod101PodOrClass(language);
|
||||
const lowerCaseLanguage = language.toLowerCase();
|
||||
return `https://www.${lowerCaseLanguage}${podOrClass}101.com/learningcenter/reference/dictionary_post`;
|
||||
}
|
||||
|
||||
/**
|
||||
* - https://languagepod101.com/
|
||||
* @param {string} language
|
||||
* @returns {'pod'|'class'}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_getLanguagePod101PodOrClass(language) {
|
||||
switch (language) {
|
||||
case 'Afrikaans':
|
||||
case 'Arabic':
|
||||
case 'Bulgarian':
|
||||
case 'Dutch':
|
||||
case 'Filipino':
|
||||
case 'Finnish':
|
||||
case 'French':
|
||||
case 'German':
|
||||
case 'Greek':
|
||||
case 'Hebrew':
|
||||
case 'Hindi':
|
||||
case 'Hungarian':
|
||||
case 'Indonesian':
|
||||
case 'Italian':
|
||||
case 'Japanese':
|
||||
case 'Persian':
|
||||
case 'Polish':
|
||||
case 'Portuguese':
|
||||
case 'Romanian':
|
||||
case 'Russian':
|
||||
case 'Spanish':
|
||||
case 'Swahili':
|
||||
case 'Swedish':
|
||||
case 'Thai':
|
||||
case 'Urdu':
|
||||
case 'Vietnamese':
|
||||
return 'pod';
|
||||
case 'Cantonese':
|
||||
case 'Chinese':
|
||||
case 'Czech':
|
||||
case 'Danish':
|
||||
case 'English':
|
||||
case 'Korean':
|
||||
case 'Norwegian':
|
||||
case 'Turkish':
|
||||
return 'class';
|
||||
default:
|
||||
throw new Error('Invalid language for LanguagePod101');
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoJisho(term, reading) {
|
||||
const fetchUrl = `https://jisho.org/search/${term}`;
|
||||
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, DEFAULT_REQUEST_INIT_PARAMS);
|
||||
const responseText = await response.text();
|
||||
|
||||
const dom = this._createSimpleDOMParser(responseText);
|
||||
try {
|
||||
const audio = dom.getElementById(`audio_${term}:${reading}`);
|
||||
if (audio !== null) {
|
||||
const source = dom.getElementByTagName('source', audio);
|
||||
if (source !== null) {
|
||||
let url = dom.getAttribute(source, 'src');
|
||||
if (url !== null) {
|
||||
url = this._normalizeUrl(url, response.url);
|
||||
return [{type: 'url', url}];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
throw new Error('Failed to find audio URL');
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoLinguaLibre(term, _reading, _details, languageSummary) {
|
||||
if (typeof languageSummary !== 'object' || languageSummary === null) {
|
||||
throw new Error('Invalid arguments');
|
||||
}
|
||||
const {iso639_3} = languageSummary;
|
||||
const searchCategory = `incategory:"Lingua_Libre_pronunciation-${iso639_3}"`;
|
||||
const searchString = `-${term}.wav`;
|
||||
const fetchUrl = `https://commons.wikimedia.org/w/api.php?action=query&format=json&list=search&srsearch=intitle:/${searchString}/i+${searchCategory}&srnamespace=6&origin=*`;
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {string} fileUser
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const validateFilename = (filename, fileUser) => {
|
||||
const validFilenameTest = new RegExp(`^File:LL-Q\\d+\\s+\\(${iso639_3}\\)-${fileUser}-${term}\\.wav$`, 'i');
|
||||
return validFilenameTest.test(filename);
|
||||
};
|
||||
|
||||
return await this._getInfoWikimediaCommons(fetchUrl, validateFilename);
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoWiktionary(term, _reading, _details, languageSummary) {
|
||||
if (typeof languageSummary !== 'object' || languageSummary === null) {
|
||||
throw new Error('Invalid arguments');
|
||||
}
|
||||
const {iso} = languageSummary;
|
||||
const searchString = `${iso}(-[a-zA-Z]{2})?-${term}[0123456789]*.ogg`;
|
||||
const fetchUrl = `https://commons.wikimedia.org/w/api.php?action=query&format=json&list=search&srsearch=intitle:/${searchString}/i&srnamespace=6&origin=*`;
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const validateFilename = (filename) => {
|
||||
const validFilenameTest = new RegExp(`^File:${iso}(-\\w\\w)?-${term}\\d*\\.ogg$`, 'i');
|
||||
return validFilenameTest.test(filename);
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {string} filename
|
||||
* @param {string} fileUser
|
||||
* @returns {string}
|
||||
*/
|
||||
const displayName = (filename, fileUser) => {
|
||||
const match = filename.match(new RegExp(`^File:${iso}(-\\w\\w)-${term}`, 'i'));
|
||||
if (match === null) {
|
||||
return fileUser;
|
||||
}
|
||||
const region = match[1].substring(1).toUpperCase();
|
||||
const regionName = this._regionNames.of(region);
|
||||
return `(${regionName}) ${fileUser}`;
|
||||
};
|
||||
|
||||
return await this._getInfoWikimediaCommons(fetchUrl, validateFilename, displayName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} fetchUrl
|
||||
* @param {(filename: string, fileUser: string) => boolean} validateFilename
|
||||
* @param {(filename: string, fileUser: string) => string} [displayName]
|
||||
* @returns {Promise<import('audio-downloader').Info1[]>}
|
||||
*/
|
||||
async _getInfoWikimediaCommons(fetchUrl, validateFilename, displayName = (_filename, fileUser) => fileUser) {
|
||||
const response = await this._requestBuilder.fetchAnonymous(fetchUrl, DEFAULT_REQUEST_INIT_PARAMS);
|
||||
|
||||
/** @type {import('audio-downloader').WikimediaCommonsLookupResponse} */
|
||||
const lookupResponse = await readResponseJson(response);
|
||||
const lookupResults = lookupResponse.query.search;
|
||||
|
||||
const fetchFileInfos = lookupResults.map(async ({title}) => {
|
||||
const fileInfoURL = `https://commons.wikimedia.org/w/api.php?action=query&format=json&titles=${title}&prop=imageinfo&iiprop=user|url&origin=*`;
|
||||
const response2 = await this._requestBuilder.fetchAnonymous(fileInfoURL, DEFAULT_REQUEST_INIT_PARAMS);
|
||||
/** @type {import('audio-downloader').WikimediaCommonsFileResponse} */
|
||||
const fileResponse = await readResponseJson(response2);
|
||||
const fileResults = fileResponse.query.pages;
|
||||
const results = [];
|
||||
for (const page of Object.values(fileResults)) {
|
||||
const fileUrl = page.imageinfo[0].url;
|
||||
const fileUser = page.imageinfo[0].user;
|
||||
if (validateFilename(title, fileUser)) {
|
||||
results.push({type: 'url', url: fileUrl, name: displayName(title, fileUser)});
|
||||
}
|
||||
}
|
||||
return /** @type {import('audio-downloader').Info1[]} */ (results);
|
||||
});
|
||||
|
||||
return (await Promise.all(fetchFileInfos)).flat();
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoTextToSpeech(term, reading, details) {
|
||||
if (typeof details !== 'object' || details === null) {
|
||||
throw new Error('Invalid arguments');
|
||||
}
|
||||
const {voice} = details;
|
||||
if (typeof voice !== 'string') {
|
||||
throw new Error('Invalid voice');
|
||||
}
|
||||
return [{type: 'tts', text: term, voice: voice}];
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoTextToSpeechReading(term, reading, details) {
|
||||
if (typeof details !== 'object' || details === null) {
|
||||
throw new Error('Invalid arguments');
|
||||
}
|
||||
const {voice} = details;
|
||||
if (typeof voice !== 'string') {
|
||||
throw new Error('Invalid voice');
|
||||
}
|
||||
return [{type: 'tts', text: reading, voice: voice}];
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoCustom(term, reading, details, languageSummary) {
|
||||
if (typeof details !== 'object' || details === null) {
|
||||
throw new Error('Invalid arguments');
|
||||
}
|
||||
let {url} = details;
|
||||
if (typeof url !== 'string') {
|
||||
throw new Error('Invalid url');
|
||||
}
|
||||
url = this._getCustomUrl(term, reading, url, languageSummary);
|
||||
return [{type: 'url', url}];
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').GetInfoHandler} */
|
||||
async _getInfoCustomJson(term, reading, details, languageSummary) {
|
||||
if (typeof details !== 'object' || details === null) {
|
||||
throw new Error('Invalid arguments');
|
||||
}
|
||||
let {url} = details;
|
||||
if (typeof url !== 'string') {
|
||||
throw new Error('Invalid url');
|
||||
}
|
||||
url = this._getCustomUrl(term, reading, url, languageSummary);
|
||||
|
||||
const response = await this._requestBuilder.fetchAnonymous(url, DEFAULT_REQUEST_INIT_PARAMS);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
/** @type {import('audio-downloader').CustomAudioList} */
|
||||
const responseJson = await readResponseJson(response);
|
||||
|
||||
if (this._customAudioListSchema === null) {
|
||||
const schema = await this._getCustomAudioListSchema();
|
||||
this._customAudioListSchema = new JsonSchema(/** @type {import('ext/json-schema').Schema} */ (schema));
|
||||
}
|
||||
this._customAudioListSchema.validate(responseJson);
|
||||
|
||||
/** @type {import('audio-downloader').Info[]} */
|
||||
const results = [];
|
||||
for (const {url: url2, name} of responseJson.audioSources) {
|
||||
/** @type {import('audio-downloader').Info1} */
|
||||
const info = {type: 'url', url: url2};
|
||||
if (typeof name === 'string') { info.name = name; }
|
||||
results.push(info);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} term
|
||||
* @param {string} reading
|
||||
* @param {string} url
|
||||
* @param {import('language').LanguageSummary} languageSummary
|
||||
* @returns {string}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_getCustomUrl(term, reading, url, languageSummary) {
|
||||
if (typeof url !== 'string') {
|
||||
throw new Error('No custom URL defined');
|
||||
}
|
||||
const data = {
|
||||
term,
|
||||
reading,
|
||||
language: languageSummary.iso,
|
||||
};
|
||||
/**
|
||||
* @param {string} m0
|
||||
* @param {string} m1
|
||||
* @returns {string}
|
||||
*/
|
||||
const replacer = (m0, m1) => (
|
||||
Object.prototype.hasOwnProperty.call(data, m1) ?
|
||||
`${data[/** @type {'term'|'reading'|'language'} */ (m1)]}` :
|
||||
m0
|
||||
);
|
||||
return url.replace(/\{([^}]*)\}/g, replacer);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {import('settings').AudioSourceType} sourceType
|
||||
* @param {?number} idleTimeout
|
||||
* @returns {Promise<import('audio-downloader').AudioBinaryBase64>}
|
||||
*/
|
||||
async _downloadAudioFromUrl(url, sourceType, idleTimeout) {
|
||||
let signal;
|
||||
/** @type {?import('request-builder.js').ProgressCallback} */
|
||||
let onProgress = null;
|
||||
/** @type {?import('core').Timeout} */
|
||||
let idleTimer = null;
|
||||
if (typeof idleTimeout === 'number') {
|
||||
const abortController = new AbortController();
|
||||
({signal} = abortController);
|
||||
const onIdleTimeout = () => {
|
||||
abortController.abort('Idle timeout');
|
||||
};
|
||||
onProgress = (done) => {
|
||||
if (idleTimer !== null) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
idleTimer = done ? null : setTimeout(onIdleTimeout, idleTimeout);
|
||||
};
|
||||
idleTimer = setTimeout(onIdleTimeout, idleTimeout);
|
||||
}
|
||||
|
||||
const response = await this._requestBuilder.fetchAnonymous(url, {
|
||||
...DEFAULT_REQUEST_INIT_PARAMS,
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Invalid response: ${response.status}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await RequestBuilder.readFetchResponseArrayBuffer(response, onProgress);
|
||||
|
||||
if (idleTimer !== null) {
|
||||
clearTimeout(idleTimer);
|
||||
}
|
||||
|
||||
if (!await this._isAudioBinaryValid(arrayBuffer, sourceType)) {
|
||||
throw new Error('Could not retrieve audio');
|
||||
}
|
||||
|
||||
const data = arrayBufferToBase64(arrayBuffer);
|
||||
const contentType = response.headers.get('Content-Type');
|
||||
return {data, contentType};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
* @param {import('settings').AudioSourceType} sourceType
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async _isAudioBinaryValid(arrayBuffer, sourceType) {
|
||||
switch (sourceType) {
|
||||
case 'jpod101':
|
||||
{
|
||||
const digest = await this._arrayBufferDigest(arrayBuffer);
|
||||
switch (digest) {
|
||||
case 'ae6398b5a27bc8c0a771df6c907ade794be15518174773c58c7c7ddd17098906': // Invalid audio
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} arrayBuffer
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} content
|
||||
* @returns {import('simple-dom-parser').ISimpleDomParser}
|
||||
* @throws {Error}
|
||||
*/
|
||||
_createSimpleDOMParser(content) {
|
||||
if (typeof NativeSimpleDOMParser !== 'undefined' && NativeSimpleDOMParser.isSupported()) {
|
||||
return new NativeSimpleDOMParser(content);
|
||||
} else if (typeof SimpleDOMParser !== 'undefined' && SimpleDOMParser.isSupported()) {
|
||||
return new SimpleDOMParser(content);
|
||||
} else {
|
||||
throw new Error('DOM parsing not supported');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<unknown>}
|
||||
*/
|
||||
async _getCustomAudioListSchema() {
|
||||
const url = chrome.runtime.getURL('/data/schemas/custom-audio-list-schema.json');
|
||||
const response = await fetch(url, {
|
||||
...DEFAULT_REQUEST_INIT_PARAMS,
|
||||
mode: 'no-cors',
|
||||
});
|
||||
return await readResponseJson(response);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} language
|
||||
* @returns {Set<import('settings').AudioSourceType>}
|
||||
*/
|
||||
export function getRequiredAudioSourceList(language) {
|
||||
return language === 'ja' ?
|
||||
new Set([
|
||||
'jpod101',
|
||||
'language-pod-101',
|
||||
'jisho',
|
||||
]) :
|
||||
new Set([
|
||||
'lingua-libre',
|
||||
'language-pod-101',
|
||||
'wiktionary',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} language
|
||||
* @param {import('audio').AudioSourceInfo[]} sources
|
||||
* @returns {import('audio').AudioSourceInfo[]}
|
||||
*/
|
||||
export function getRequiredAudioSources(language, sources) {
|
||||
/** @type {Set<import('settings').AudioSourceType>} */
|
||||
const requiredSources = getRequiredAudioSourceList(language);
|
||||
|
||||
for (const {type} of sources) {
|
||||
requiredSources.delete(type);
|
||||
}
|
||||
|
||||
return [...requiredSources].map((type) => ({type, url: '', voice: ''}));
|
||||
}
|
||||
154
vendor/yomitan/js/media/audio-system.js
vendored
Normal file
154
vendor/yomitan/js/media/audio-system.js
vendored
Normal file
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2019-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {EventDispatcher} from '../core/event-dispatcher.js';
|
||||
import {TextToSpeechAudio} from './text-to-speech-audio.js';
|
||||
|
||||
/**
|
||||
* @augments EventDispatcher<import('audio-system').Events>
|
||||
*/
|
||||
export class AudioSystem extends EventDispatcher {
|
||||
constructor() {
|
||||
super();
|
||||
/** @type {?HTMLAudioElement} */
|
||||
this._fallbackAudio = null;
|
||||
/** @type {?import('settings').FallbackSoundType} */
|
||||
this._fallbackSoundType = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
prepare() {
|
||||
// speechSynthesis.getVoices() will not be populated unless some API call is made.
|
||||
if (
|
||||
typeof speechSynthesis !== 'undefined' &&
|
||||
typeof speechSynthesis.addEventListener === 'function'
|
||||
) {
|
||||
speechSynthesis.addEventListener('voiceschanged', this._onVoicesChanged.bind(this), false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('settings').FallbackSoundType} fallbackSoundType
|
||||
* @returns {HTMLAudioElement}
|
||||
*/
|
||||
getFallbackAudio(fallbackSoundType) {
|
||||
if (this._fallbackAudio === null || this._fallbackSoundType !== fallbackSoundType) {
|
||||
this._fallbackSoundType = fallbackSoundType;
|
||||
switch (fallbackSoundType) {
|
||||
case 'click':
|
||||
this._fallbackAudio = new Audio('/data/audio/fallback-click.mp3');
|
||||
break;
|
||||
case 'bloop':
|
||||
this._fallbackAudio = new Audio('/data/audio/fallback-bloop.mp3');
|
||||
break;
|
||||
case 'none':
|
||||
// audio handler expects audio url to always be present, empty string must be used instead of `new Audio()`
|
||||
this._fallbackAudio = new Audio('');
|
||||
break;
|
||||
}
|
||||
}
|
||||
return this._fallbackAudio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} url
|
||||
* @param {import('settings').AudioSourceType} sourceType
|
||||
* @returns {Promise<HTMLAudioElement>}
|
||||
*/
|
||||
async createAudio(url, sourceType) {
|
||||
const audio = new Audio(url);
|
||||
await this._waitForData(audio);
|
||||
if (!this._isAudioValid(audio, sourceType)) {
|
||||
throw new Error('Could not retrieve audio');
|
||||
}
|
||||
return audio;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {string} voiceUri
|
||||
* @returns {TextToSpeechAudio}
|
||||
* @throws {Error}
|
||||
*/
|
||||
createTextToSpeechAudio(text, voiceUri) {
|
||||
const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri);
|
||||
if (voice === null) {
|
||||
throw new Error('Invalid text-to-speech voice');
|
||||
}
|
||||
return new TextToSpeechAudio(text, voice);
|
||||
}
|
||||
|
||||
// Private
|
||||
|
||||
/**
|
||||
* @param {Event} event
|
||||
*/
|
||||
_onVoicesChanged(event) {
|
||||
this.trigger('voiceschanged', event);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLAudioElement} audio
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
_waitForData(audio) {
|
||||
return new Promise((resolve, reject) => {
|
||||
audio.addEventListener('loadeddata', () => resolve());
|
||||
audio.addEventListener('error', () => reject(audio.error));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {HTMLAudioElement} audio
|
||||
* @param {import('settings').AudioSourceType} sourceType
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_isAudioValid(audio, sourceType) {
|
||||
switch (sourceType) {
|
||||
case 'jpod101':
|
||||
{
|
||||
const duration = audio.duration;
|
||||
return (
|
||||
duration !== 5.694694 && // Invalid audio (Chrome)
|
||||
duration !== 5.651111 // Invalid audio (Firefox)
|
||||
);
|
||||
}
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} voiceUri
|
||||
* @returns {?SpeechSynthesisVoice}
|
||||
*/
|
||||
_getTextToSpeechVoiceFromVoiceUri(voiceUri) {
|
||||
try {
|
||||
for (const voice of speechSynthesis.getVoices()) {
|
||||
if (voice.voiceURI === voiceUri) {
|
||||
return voice;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
135
vendor/yomitan/js/media/media-util.js
vendored
Normal file
135
vendor/yomitan/js/media/media-util.js
vendored
Normal file
@@ -0,0 +1,135 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the file extension of a file path. URL search queries and hash
|
||||
* fragments are not handled.
|
||||
* @param {string} path The path to the file.
|
||||
* @returns {string} The file extension, including the '.', or an empty string
|
||||
* if there is no file extension.
|
||||
*/
|
||||
export function getFileNameExtension(path) {
|
||||
const match = /\.[^./\\]*$/.exec(path);
|
||||
return match !== null ? match[0] : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets an image file's media type using a file path.
|
||||
* @param {string} path The path to the file.
|
||||
* @returns {?string} The media type string if it can be determined from the file path,
|
||||
* otherwise `null`.
|
||||
*/
|
||||
export function getImageMediaTypeFromFileName(path) {
|
||||
switch (getFileNameExtension(path).toLowerCase()) {
|
||||
case '.apng':
|
||||
return 'image/apng';
|
||||
case '.avif':
|
||||
return 'image/avif';
|
||||
case '.bmp':
|
||||
return 'image/bmp';
|
||||
case '.gif':
|
||||
return 'image/gif';
|
||||
case '.ico':
|
||||
case '.cur':
|
||||
return 'image/x-icon';
|
||||
case '.jpg':
|
||||
case '.jpeg':
|
||||
case '.jfif':
|
||||
case '.pjpeg':
|
||||
case '.pjp':
|
||||
return 'image/jpeg';
|
||||
case '.png':
|
||||
return 'image/png';
|
||||
case '.svg':
|
||||
return 'image/svg+xml';
|
||||
case '.tif':
|
||||
case '.tiff':
|
||||
return 'image/tiff';
|
||||
case '.webp':
|
||||
return 'image/webp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file extension for a corresponding media type.
|
||||
* @param {string} mediaType The media type to use.
|
||||
* @returns {?string} A file extension including the dot for the media type,
|
||||
* otherwise `null`.
|
||||
*/
|
||||
export function getFileExtensionFromImageMediaType(mediaType) {
|
||||
switch (mediaType) {
|
||||
case 'image/apng':
|
||||
return '.apng';
|
||||
case 'image/avif':
|
||||
return '.avif';
|
||||
case 'image/bmp':
|
||||
return '.bmp';
|
||||
case 'image/gif':
|
||||
return '.gif';
|
||||
case 'image/x-icon':
|
||||
return '.ico';
|
||||
case 'image/jpeg':
|
||||
return '.jpeg';
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/svg+xml':
|
||||
return '.svg';
|
||||
case 'image/tiff':
|
||||
return '.tiff';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the file extension for a corresponding media type.
|
||||
* @param {string} mediaType The media type to use.
|
||||
* @returns {?string} A file extension including the dot for the media type,
|
||||
* otherwise `null`.
|
||||
*/
|
||||
export function getFileExtensionFromAudioMediaType(mediaType) {
|
||||
switch (mediaType) {
|
||||
case 'audio/aac':
|
||||
return '.aac';
|
||||
case 'audio/mpeg':
|
||||
case 'audio/mp3':
|
||||
return '.mp3';
|
||||
case 'audio/mp4':
|
||||
return '.mp4';
|
||||
case 'audio/ogg':
|
||||
case 'audio/vorbis':
|
||||
case 'application/ogg':
|
||||
return '.ogg';
|
||||
case 'audio/vnd.wav':
|
||||
case 'audio/wave':
|
||||
case 'audio/wav':
|
||||
case 'audio/x-wav':
|
||||
case 'audio/x-pn-wav':
|
||||
return '.wav';
|
||||
case 'audio/flac':
|
||||
return '.flac';
|
||||
case 'audio/webm':
|
||||
return '.webm';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
85
vendor/yomitan/js/media/text-to-speech-audio.js
vendored
Normal file
85
vendor/yomitan/js/media/text-to-speech-audio.js
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright (C) 2023-2025 Yomitan Authors
|
||||
* Copyright (C) 2020-2022 Yomichan Authors
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export class TextToSpeechAudio {
|
||||
/**
|
||||
* @param {string} text
|
||||
* @param {SpeechSynthesisVoice} voice
|
||||
*/
|
||||
constructor(text, voice) {
|
||||
/** @type {string} */
|
||||
this._text = text;
|
||||
/** @type {SpeechSynthesisVoice} */
|
||||
this._voice = voice;
|
||||
/** @type {?SpeechSynthesisUtterance} */
|
||||
this._utterance = null;
|
||||
/** @type {number} */
|
||||
this._volume = 1;
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get currentTime() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
set currentTime(value) {
|
||||
// NOP
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
get volume() {
|
||||
return this._volume;
|
||||
}
|
||||
|
||||
set volume(value) {
|
||||
this._volume = value;
|
||||
if (this._utterance !== null) {
|
||||
this._utterance.volume = value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async play() {
|
||||
try {
|
||||
if (this._utterance === null) {
|
||||
this._utterance = new SpeechSynthesisUtterance(typeof this._text === 'string' ? 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {void}
|
||||
*/
|
||||
pause() {
|
||||
try {
|
||||
speechSynthesis.cancel();
|
||||
} catch (e) {
|
||||
// NOP
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user