diff --git a/ext/bg/data/default-anki-field-templates.handlebars b/ext/bg/data/default-anki-field-templates.handlebars index 6061851f..77818a43 100644 --- a/ext/bg/data/default-anki-field-templates.handlebars +++ b/ext/bg/data/default-anki-field-templates.handlebars @@ -14,7 +14,9 @@ {{~/if~}} {{/inline}} -{{#*inline "audio"}}{{/inline}} +{{#*inline "audio"~}} + [sound:{{definition.audioFileName}}] +{{~/inline}} {{#*inline "character"}} {{~definition.character~}} diff --git a/ext/bg/js/anki-note-builder.js b/ext/bg/js/anki-note-builder.js index dc1e9427..1f9c6ed2 100644 --- a/ext/bg/js/anki-note-builder.js +++ b/ext/bg/js/anki-note-builder.js @@ -42,25 +42,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; } @@ -88,18 +69,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 } @@ -129,6 +123,7 @@ class AnkiNoteBuilder { if (reading) { filename += `_${reading}`; } if (expression) { filename += `_${expression}`; } filename += '.mp3'; + filename = filename.replace(/\]/g, ''); return filename; } @@ -152,6 +147,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; diff --git a/ext/bg/js/backend.js b/ext/bg/js/backend.js index dd1fd8e9..8a8f00eb 100644 --- a/ext/bg/js/backend.js +++ b/ext/bg/js/backend.js @@ -507,7 +507,7 @@ class Backend { definition, options.anki.terms.fields, options.audio.sources, - {textToSpeechVoice: null, customSourceUrl} + customSourceUrl ); } diff --git a/ext/bg/js/options.js b/ext/bg/js/options.js index 8e1814ed..47101b49 100644 --- a/ext/bg/js/options.js +++ b/ext/bg/js/options.js @@ -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; } ]; diff --git a/ext/mixed/js/audio-system.js b/ext/mixed/js/audio-system.js index 3273f982..108cfc72 100644 --- a/ext/mixed/js/audio-system.js +++ b/ext/mixed/js/audio-system.js @@ -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); @@ -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}); @@ -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) { @@ -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); @@ -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()) { @@ -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; + } }