Skip to content
This repository has been archived by the owner on Feb 25, 2023. It is now read-only.

Anki audio download #477

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ext/bg/data/default-anki-field-templates.handlebars
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,9 @@
{{~/if~}}
{{/inline}}

{{#*inline "audio"}}{{/inline}}
{{#*inline "audio"~}}
[sound:{{definition.audioFileName}}]
{{~/inline}}

{{#*inline "character"}}
{{~definition.character~}}
Expand Down
47 changes: 23 additions & 24 deletions ext/bg/js/anki-note-builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,25 +39,6 @@ class AnkiNoteBuilder {
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;
}

Expand Down Expand Up @@ -85,18 +66,31 @@ class AnkiNoteBuilder {
});
}

async injectAudio(definition, fields, sources, details) {
async injectAudio(definition, fields, sources, customSourceUrl) {
if (!this._containsMarker(fields, 'audio')) { return; }

try {
const expressions = definition.expressions;
const audioSourceDefinition = Array.isArray(expressions) ? expressions[0] : definition;

const {uri} = await this._audioSystem.getDefinitionAudio(audioSourceDefinition, sources, details);
const filename = this._createInjectedAudioFileName(audioSourceDefinition);
if (filename !== null) {
definition.audio = {url: uri, filename};
}
if (filename === null) { return; }

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) {
// NOP
}
Expand Down Expand Up @@ -126,6 +120,7 @@ class AnkiNoteBuilder {
if (reading) { filename += `_${reading}`; }
if (expression) { filename += `_${expression}`; }
filename += '.mp3';
filename = filename.replace(/\]/g, '');
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you added it, are there other characters that have to be escaped or sanitized from the filename? If it's actually stored in the filesystem under that name, then / and \ can't be used either, but there are also other characters that are disallowed in filenames.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, makes sense that it would remove the ] character since it's used to terminate the [audio:...] block.
(offtopic, Anki seems to ignore empty [audio:] audio blocks in those functions)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are there other characters that have to be escaped or sanitized from the filename?

(Just FYI, I consider this out of scope for this change, but will gladly make the update in a follow-up PR.)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for clarifying, I was waiting for a new commit addressing this issue

return filename;
}

Expand All @@ -149,6 +144,10 @@ class AnkiNoteBuilder {
return false;
}

static arrayBufferToBase64(arrayBuffer) {
return window.btoa(String.fromCharCode(...new Uint8Array(arrayBuffer)));
}

static stringReplaceAsync(str, regex, replacer) {
let match;
let index = 0;
Expand Down
2 changes: 1 addition & 1 deletion ext/bg/js/backend.js
Original file line number Diff line number Diff line change
Expand Up @@ -504,7 +504,7 @@ class Backend {
definition,
options.anki.terms.fields,
options.audio.sources,
{textToSpeechVoice: null, customSourceUrl}
customSourceUrl
);
}

Expand Down
19 changes: 19 additions & 0 deletions ext/bg/js/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,25 @@ const profileOptionsVersionUpdates = [
fieldTemplates += '\n\n{{#*inline "document-title"}}\n {{~context.document.title~}}\n{{/inline}}';
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;
}
];

Expand Down
78 changes: 67 additions & 11 deletions ext/mixed/js/audio-system.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ class AudioSystem {

async getDefinitionAudio(definition, sources, details) {
const key = `${definition.expression}:${definition.reading}`;
const hasCache = (this._cache !== null);
const hasCache = (this._cache !== null && !details.disableCache);

if (hasCache) {
const cacheValue = this._cache.get(key);
Expand All @@ -98,7 +98,11 @@ class AudioSystem {
if (uri === null) { continue; }

try {
const audio = await this._createAudio(uri);
const audio = (
details.binary ?
await this._createAudioBinary(uri) :
await this._createAudio(uri)
);
if (hasCache) {
this._cacheCheck();
this._cache.set(key, {audio, uri, source});
Expand All @@ -124,6 +128,14 @@ class AudioSystem {
// NOP
}

_getAudioUri(definition, source, details) {
return (
this._audioUriBuilder !== null ?
this._audioUriBuilder.getUri(definition, source, details) :
null
);
}

async _createAudio(uri) {
const ttsParameters = this._getTextToSpeechParameters(uri);
if (ttsParameters !== null) {
Expand All @@ -134,21 +146,20 @@ class AudioSystem {
return await this._createAudioFromUrl(uri);
}

_getAudioUri(definition, source, details) {
return (
this._audioUriBuilder !== null ?
this._audioUriBuilder.getUri(definition, source, details) :
null
);
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) => {
const audio = new Audio(url);
audio.addEventListener('loadeddata', () => {
const duration = audio.duration;
if (duration === 5.694694 || duration === 5.720718) {
// Hardcoded values for invalid audio
if (!this._isAudioValid(audio)) {
reject(new Error('Could not retrieve audio'));
} else {
resolve(audio);
Expand All @@ -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) {
try {
for (const voice of speechSynthesis.getVoices()) {
Expand Down Expand Up @@ -195,4 +242,13 @@ class AudioSystem {
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;
}
}