Anki audio download (#477)
* Update how audio is added to Anki cards * Upgrade Anki templates * Update comments
This commit is contained in:
parent
08ada6844a
commit
cae6b657ab
@ -14,7 +14,9 @@
|
|||||||
{{~/if~}}
|
{{~/if~}}
|
||||||
{{/inline}}
|
{{/inline}}
|
||||||
|
|
||||||
{{#*inline "audio"}}{{/inline}}
|
{{#*inline "audio"~}}
|
||||||
|
[sound:{{definition.audioFileName}}]
|
||||||
|
{{~/inline}}
|
||||||
|
|
||||||
{{#*inline "character"}}
|
{{#*inline "character"}}
|
||||||
{{~definition.character~}}
|
{{~definition.character~}}
|
||||||
|
@ -42,25 +42,6 @@ class AnkiNoteBuilder {
|
|||||||
note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null);
|
note.fields[fieldName] = await this.formatField(fieldValue, definition, mode, context, options, templates, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isKanji && definition.audio) {
|
|
||||||
const audioFields = [];
|
|
||||||
|
|
||||||
for (const [fieldName, fieldValue] of modeOptionsFieldEntries) {
|
|
||||||
if (fieldValue.includes('{audio}')) {
|
|
||||||
audioFields.push(fieldName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (audioFields.length > 0) {
|
|
||||||
note.audio = {
|
|
||||||
url: definition.audio.url,
|
|
||||||
filename: definition.audio.filename,
|
|
||||||
skipHash: '7e2c2f954ef6051373ba916f000168dc', // hash of audio data that should be skipped
|
|
||||||
fields: audioFields
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return note;
|
return note;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -88,18 +69,31 @@ class AnkiNoteBuilder {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async injectAudio(definition, fields, sources, details) {
|
async injectAudio(definition, fields, sources, customSourceUrl) {
|
||||||
if (!this._containsMarker(fields, 'audio')) { return; }
|
if (!this._containsMarker(fields, 'audio')) { return; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const expressions = definition.expressions;
|
const expressions = definition.expressions;
|
||||||
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
|
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;
|
||||||
|
|
||||||
const {uri} = await this._audioSystem.getDefinitionAudio(audioSourceDefinition, sources, details);
|
|
||||||
const filename = this._createInjectedAudioFileName(audioSourceDefinition);
|
const filename = this._createInjectedAudioFileName(audioSourceDefinition);
|
||||||
if (filename !== null) {
|
if (filename === null) { return; }
|
||||||
definition.audio = {url: uri, filename};
|
|
||||||
|
const {audio} = await this._audioSystem.getDefinitionAudio(
|
||||||
|
audioSourceDefinition,
|
||||||
|
sources,
|
||||||
|
{
|
||||||
|
textToSpeechVoice: null,
|
||||||
|
customSourceUrl,
|
||||||
|
binary: true,
|
||||||
|
disableCache: true
|
||||||
}
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const data = AnkiNoteBuilder.arrayBufferToBase64(audio);
|
||||||
|
await this._anki.storeMediaFile(filename, data);
|
||||||
|
|
||||||
|
definition.audioFileName = filename;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// NOP
|
// NOP
|
||||||
}
|
}
|
||||||
@ -129,6 +123,7 @@ class AnkiNoteBuilder {
|
|||||||
if (reading) { filename += `_${reading}`; }
|
if (reading) { filename += `_${reading}`; }
|
||||||
if (expression) { filename += `_${expression}`; }
|
if (expression) { filename += `_${expression}`; }
|
||||||
filename += '.mp3';
|
filename += '.mp3';
|
||||||
|
filename = filename.replace(/\]/g, '');
|
||||||
return filename;
|
return filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,6 +147,10 @@ class AnkiNoteBuilder {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static arrayBufferToBase64(arrayBuffer) {
|
||||||
|
return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
|
||||||
|
}
|
||||||
|
|
||||||
static stringReplaceAsync(str, regex, replacer) {
|
static stringReplaceAsync(str, regex, replacer) {
|
||||||
let match;
|
let match;
|
||||||
let index = 0;
|
let index = 0;
|
||||||
|
@ -507,7 +507,7 @@ class Backend {
|
|||||||
definition,
|
definition,
|
||||||
options.anki.terms.fields,
|
options.anki.terms.fields,
|
||||||
options.audio.sources,
|
options.audio.sources,
|
||||||
{textToSpeechVoice: null, customSourceUrl}
|
customSourceUrl
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -108,6 +108,25 @@ const profileOptionsVersionUpdates = [
|
|||||||
fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
|
fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
|
||||||
options.anki.fieldTemplates = fieldTemplates;
|
options.anki.fieldTemplates = fieldTemplates;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
(options) => {
|
||||||
|
// Version 14 changes:
|
||||||
|
// Changed template for Anki audio.
|
||||||
|
let fieldTemplates = options.anki.fieldTemplates;
|
||||||
|
if (typeof fieldTemplates !== 'string') { return; }
|
||||||
|
|
||||||
|
const replacement = '{{#*inline "audio"~}}\n [sound:{{definition.audioFileName}}]\n{{~/inline}}';
|
||||||
|
let replaced = false;
|
||||||
|
fieldTemplates = fieldTemplates.replace(/\{\{#\*inline "audio"\}\}\{\{\/inline\}\}/g, () => {
|
||||||
|
replaced = true;
|
||||||
|
return replacement;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!replaced) {
|
||||||
|
fieldTemplates += '\n\n' + replacement;
|
||||||
|
}
|
||||||
|
|
||||||
|
options.anki.fieldTemplates = fieldTemplates;
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -79,7 +79,7 @@ class AudioSystem {
|
|||||||
|
|
||||||
async getDefinitionAudio(definition, sources, details) {
|
async getDefinitionAudio(definition, sources, details) {
|
||||||
const key = `${definition.expression}:${definition.reading}`;
|
const key = `${definition.expression}:${definition.reading}`;
|
||||||
const hasCache = (this._cache !== null);
|
const hasCache = (this._cache !== null && !details.disableCache);
|
||||||
|
|
||||||
if (hasCache) {
|
if (hasCache) {
|
||||||
const cacheValue = this._cache.get(key);
|
const cacheValue = this._cache.get(key);
|
||||||
@ -98,7 +98,11 @@ class AudioSystem {
|
|||||||
if (uri === null) { continue; }
|
if (uri === null) { continue; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const audio = await this._createAudio(uri);
|
const audio = (
|
||||||
|
details.binary ?
|
||||||
|
await this._createAudioBinary(uri) :
|
||||||
|
await this._createAudio(uri)
|
||||||
|
);
|
||||||
if (hasCache) {
|
if (hasCache) {
|
||||||
this._cacheCheck();
|
this._cacheCheck();
|
||||||
this._cache.set(key, {audio, uri, source});
|
this._cache.set(key, {audio, uri, source});
|
||||||
@ -124,6 +128,14 @@ class AudioSystem {
|
|||||||
// NOP
|
// NOP
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getAudioUri(definition, source, details) {
|
||||||
|
return (
|
||||||
|
this._audioUriBuilder !== null ?
|
||||||
|
this._audioUriBuilder.getUri(definition, source, details) :
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async _createAudio(uri) {
|
async _createAudio(uri) {
|
||||||
const ttsParameters = this._getTextToSpeechParameters(uri);
|
const ttsParameters = this._getTextToSpeechParameters(uri);
|
||||||
if (ttsParameters !== null) {
|
if (ttsParameters !== null) {
|
||||||
@ -134,21 +146,20 @@ class AudioSystem {
|
|||||||
return await this._createAudioFromUrl(uri);
|
return await this._createAudioFromUrl(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
_getAudioUri(definition, source, details) {
|
async _createAudioBinary(uri) {
|
||||||
return (
|
const ttsParameters = this._getTextToSpeechParameters(uri);
|
||||||
this._audioUriBuilder !== null ?
|
if (ttsParameters !== null) {
|
||||||
this._audioUriBuilder.getUri(definition, source, details) :
|
throw new Error('Cannot create audio from text-to-speech');
|
||||||
null
|
}
|
||||||
);
|
|
||||||
|
return await this._createAudioBinaryFromUrl(uri);
|
||||||
}
|
}
|
||||||
|
|
||||||
_createAudioFromUrl(url) {
|
_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', () => {
|
||||||
const duration = audio.duration;
|
if (!this._isAudioValid(audio)) {
|
||||||
if (duration === 5.694694 || duration === 5.720718) {
|
|
||||||
// Hardcoded values for invalid audio
|
|
||||||
reject(new Error('Could not retrieve audio'));
|
reject(new Error('Could not retrieve audio'));
|
||||||
} else {
|
} else {
|
||||||
resolve(audio);
|
resolve(audio);
|
||||||
@ -158,6 +169,42 @@ class AudioSystem {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_createAudioBinaryFromUrl(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.responseType = 'arraybuffer';
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
const arrayBuffer = xhr.response;
|
||||||
|
if (!this._isAudioBinaryValid(arrayBuffer)) {
|
||||||
|
reject(new Error('Could not retrieve audio'));
|
||||||
|
} else {
|
||||||
|
resolve(arrayBuffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
xhr.addEventListener('error', () => reject(new Error('Failed to connect')));
|
||||||
|
xhr.open('GET', url);
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAudioValid(audio) {
|
||||||
|
const duration = audio.duration;
|
||||||
|
return (
|
||||||
|
duration !== 5.694694 && // jpod101 invalid audio (Chrome)
|
||||||
|
duration !== 5.720718 // jpod101 invalid audio (Firefox)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_isAudioBinaryValid(arrayBuffer) {
|
||||||
|
const digest = TextToSpeechAudio.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()) {
|
||||||
@ -195,4 +242,13 @@ class AudioSystem {
|
|||||||
this._cache.delete(key);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user