diff --git a/ext/mixed/js/audio.js b/ext/mixed/js/audio.js index b5a025be..6a338cca 100644 --- a/ext/mixed/js/audio.js +++ b/ext/mixed/js/audio.js @@ -83,6 +83,124 @@ class TextToSpeechAudio { } } +class AudioSystem { + constructor() { + this._cache = new Map(); + this._cacheSizeMaximum = 32; + + if (typeof speechSynthesis !== 'undefined') { + // speechSynthesis.getVoices() will not be populated unless some API call is made. + speechSynthesis.addEventListener('voiceschanged', this._onVoicesChanged.bind(this)); + } + } + + async getExpressionAudio(expression, sources, optionsContext, details) { + const key = `${expression.expression}:${expression.reading}`; + const cacheValue = this._cache.get(expression); + if (typeof cacheValue !== 'undefined') { + const {audio, uri, source} = cacheValue; + return {audio, uri, source}; + } + + for (const source of sources) { + const uri = await apiAudioGetUrl(expression, source, optionsContext); + if (uri === null) { continue; } + + try { + const audio = await this._createAudio(uri, details); + this._cacheCheck(); + this._cache.set(key, {audio, uri, source}); + return {audio, uri, source}; + } catch (e) { + // NOP + } + } + + throw new Error('Could not create audio'); + } + + createTextToSpeechAudio({text, voiceUri}) { + const voice = this._getTextToSpeechVoiceFromVoiceUri(voiceUri); + if (voice === null) { + throw new Error('Invalid text-to-speech voice'); + } + return new TextToSpeechAudio(text, voice); + } + + _onVoicesChanged() { + // NOP + } + + async _createAudio(uri, details) { + const ttsParameters = this._getTextToSpeechParameters(uri); + if (ttsParameters !== null) { + if (typeof details === 'object' && details !== null) { + if (details.tts === false) { + throw new Error('Text-to-speech not permitted'); + } + } + return this.createTextToSpeechAudio(ttsParameters); + } + + return await this._createAudioFromUrl(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 + reject(new Error('Could not retrieve audio')); + } else { + resolve(audio); + } + }); + audio.addEventListener('error', () => reject(audio.error)); + }); + } + + _getTextToSpeechVoiceFromVoiceUri(voiceUri) { + try { + for (const voice of speechSynthesis.getVoices()) { + if (voice.voiceURI === voiceUri) { + return voice; + } + } + } catch (e) { + // NOP + } + 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); + } + } +} + + function audioGetFromUrl(url, willDownload) { const tts = TextToSpeechAudio.createFromUri(url); if (tts !== null) {