From 50c10c2de324496a92adfbf6f58d4c38f50eaf52 Mon Sep 17 00:00:00 2001 From: Laurent Garnier Date: Thu, 9 Mar 2023 21:13:29 +0100 Subject: [PATCH] [voicerss] Add LRU cache Signed-off-by: Laurent Garnier --- bundles/org.openhab.voice.voicerss/README.md | 5 +- .../internal/VoiceRSSRawAudioStream.java | 65 +++++++++++++++++++ .../voicerss/internal/VoiceRSSTTSService.java | 57 ++++++++++++++-- .../cloudapi/CachedVoiceRSSCloudImpl.java | 14 +++- .../internal/cloudapi/VoiceRSSCloudAPI.java | 8 +-- .../internal/cloudapi/VoiceRSSCloudImpl.java | 9 ++- .../internal/VoiceRSSTTSServiceTest.java | 11 +++- 7 files changed, 150 insertions(+), 19 deletions(-) create mode 100644 bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSRawAudioStream.java diff --git a/bundles/org.openhab.voice.voicerss/README.md b/bundles/org.openhab.voice.voicerss/README.md index 881289ee51c78..bff099818d15a 100644 --- a/bundles/org.openhab.voice.voicerss/README.md +++ b/bundles/org.openhab.voice.voicerss/README.md @@ -166,9 +166,10 @@ It supports the following audio formats: MP3, OGG, AAC and WAV. ## Caching -The VoiceRSS extension does cache audio files from previous requests, to reduce traffic, improve performance, reduce number of requests and provide same time offline capability. +The VoiceRSS TTS service uses the openHAB TTS cache to cache audio files produced from the most recent queries in order to reduce traffic, improve performance and reduce number of requests. -For convenience, there is a tool where the audio cache can be generated in advance, to have a prefilled cache when starting this extension. +An additional and specific cache can be prepared in advance to provide offline capability for predefined queries. +For convenience, there is a tool where this cache can be generated in advance, to have a prefilled cache when starting this service. You have to copy the generated data to your userdata/voicerss/cache folder. Synopsis of this tool: diff --git a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSRawAudioStream.java b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSRawAudioStream.java new file mode 100644 index 0000000000000..04bfeee8d4155 --- /dev/null +++ b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSRawAudioStream.java @@ -0,0 +1,65 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.voice.voicerss.internal; + +import java.io.IOException; +import java.io.InputStream; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.audio.AudioFormat; +import org.openhab.core.audio.AudioStream; +import org.openhab.core.audio.SizeableAudioStream; + +/** + * Implementation of the {@link AudioStream} interface for the + * {@link VoiceRSSTTSService}. It simply uses a {@link AudioStream}. + * + * @author Laurent Garnier - Initial contribution + */ +@NonNullByDefault +public class VoiceRSSRawAudioStream extends AudioStream implements SizeableAudioStream { + + private InputStream inputStream; + private AudioFormat format; + private long length; + + public VoiceRSSRawAudioStream(InputStream inputStream, AudioFormat format, long length) { + this.inputStream = inputStream; + this.format = format; + this.length = length; + } + + public InputStream getInputStream() { + return inputStream; + } + + @Override + public AudioFormat getFormat() { + return format; + } + + @Override + public long length() { + return length; + } + + @Override + public int read() throws IOException { + return inputStream.read(); + } + + @Override + public void close() throws IOException { + inputStream.close(); + } +} diff --git a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSService.java b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSService.java index 40bb18b5bdf84..a2c1daf18db49 100644 --- a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSService.java +++ b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSService.java @@ -27,6 +27,8 @@ import org.openhab.core.audio.AudioFormat; import org.openhab.core.audio.AudioStream; import org.openhab.core.config.core.ConfigurableService; +import org.openhab.core.voice.AbstractCachedTTSService; +import org.openhab.core.voice.TTSCache; import org.openhab.core.voice.TTSException; import org.openhab.core.voice.TTSService; import org.openhab.core.voice.Voice; @@ -35,6 +37,7 @@ import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; import org.osgi.service.component.annotations.Modified; +import org.osgi.service.component.annotations.Reference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -45,9 +48,10 @@ * @author Laurent Garnier - add support for OGG and AAC audio formats */ @NonNullByDefault -@Component(configurationPid = "org.openhab.voicerss", property = Constants.SERVICE_PID + "=org.openhab.voicerss") +@Component(service = TTSService.class, configurationPid = "org.openhab.voicerss", property = Constants.SERVICE_PID + + "=org.openhab.voicerss") @ConfigurableService(category = "voice", label = "VoiceRSS Text-to-Speech", description_uri = "voice:voicerss") -public class VoiceRSSTTSService implements TTSService { +public class VoiceRSSTTSService extends AbstractCachedTTSService { /** Cache folder name is below userdata/voicerss/cache. */ private static final String CACHE_FOLDER_NAME = "voicerss" + File.separator + "cache"; @@ -87,6 +91,11 @@ public class VoiceRSSTTSService implements TTSService { */ private @Nullable Set audioFormats; + @Activate + public VoiceRSSTTSService(final @Reference TTSCache ttsCache) { + super(ttsCache); + } + /** * DS activate, with access to ConfigAdmin */ @@ -130,6 +139,43 @@ public AudioStream synthesize(String text, Voice voice, AudioFormat requestedFor if (voiceRssCloud == null) { throw new TTSException("The service is not correctly initialized"); } + // trim text + String trimmedText = text.trim(); + if (trimmedText.isEmpty()) { + throw new TTSException("The passed text is empty"); + } + Set localVoices = voices; + if (localVoices == null || !localVoices.contains(voice)) { + throw new TTSException("The passed voice is unsupported"); + } + + // If one predefined cache entry for given text, locale, voice, codec and format exists, + // create the input from this file stream and return it. + try { + File cacheAudioFile = voiceRssCloud.getTextToSpeechInCache(trimmedText, voice.getLocale().toLanguageTag(), + voice.getLabel(), getApiAudioCodec(requestedFormat), getApiAudioFormat(requestedFormat)); + if (cacheAudioFile != null) { + logger.debug("Use cache entry '{}'", cacheAudioFile.getName()); + return new VoiceRSSAudioStream(cacheAudioFile, requestedFormat); + } + } catch (AudioException ex) { + throw new TTSException("Could not create AudioStream: " + ex.getMessage(), ex); + } catch (IOException ex) { + throw new TTSException("Could not read from VoiceRSS service: " + ex.getMessage(), ex); + } + + // If no predefined cache entry exists, use the common TTS cache mechanism from core framework + logger.debug("Use common TTS cache mechanism"); + return super.synthesize(text, voice, requestedFormat); + } + + @Override + public AudioStream synthesizeForCache(String text, Voice voice, AudioFormat requestedFormat) throws TTSException { + logger.debug("synthesizeForCache '{}' for voice '{}' in format {}", text, voice.getUID(), requestedFormat); + CachedVoiceRSSCloudImpl voiceRssCloud = voiceRssImpl; + if (voiceRssCloud == null) { + throw new TTSException("The service is not correctly initialized"); + } // Validate known api key String key = apiKey; if (key == null) { @@ -145,14 +191,11 @@ public AudioStream synthesize(String text, Voice voice, AudioFormat requestedFor throw new TTSException("The passed voice is unsupported"); } - // now create the input stream for given text, locale, voice, codec and format. try { - File cacheAudioFile = voiceRssCloud.getTextToSpeechAsFile(key, trimmedText, + VoiceRSSRawAudioStream audioStream = voiceRssCloud.getTextToSpeech(key, trimmedText, voice.getLocale().toLanguageTag(), voice.getLabel(), getApiAudioCodec(requestedFormat), getApiAudioFormat(requestedFormat)); - return new VoiceRSSAudioStream(cacheAudioFile, requestedFormat); - } catch (AudioException ex) { - throw new TTSException("Could not create AudioStream: " + ex.getMessage(), ex); + return new VoiceRSSRawAudioStream(audioStream.getInputStream(), requestedFormat, audioStream.length()); } catch (IOException ex) { throw new TTSException("Could not read from VoiceRSS service: " + ex.getMessage(), ex); } diff --git a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/CachedVoiceRSSCloudImpl.java b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/CachedVoiceRSSCloudImpl.java index 0dcc964814d81..de7cfb556cde9 100644 --- a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/CachedVoiceRSSCloudImpl.java +++ b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/CachedVoiceRSSCloudImpl.java @@ -69,8 +69,8 @@ public File getTextToSpeechAsFile(String apiKey, String text, String locale, Str } // if not in cache, get audio data and put to cache - try (InputStream is = super.getTextToSpeech(apiKey, text, locale, voice, audioCodec, audioFormat); - FileOutputStream fos = new FileOutputStream(audioFileInCache)) { + try (InputStream is = super.getTextToSpeech(apiKey, text, locale, voice, audioCodec, audioFormat) + .getInputStream(); FileOutputStream fos = new FileOutputStream(audioFileInCache)) { copyStream(is, fos); // write text to file for transparency too // this allows to know which contents is in which audio file @@ -83,6 +83,16 @@ public File getTextToSpeechAsFile(String apiKey, String text, String locale, Str } } + public @Nullable File getTextToSpeechInCache(String text, String locale, String voice, String audioCodec, + String audioFormat) throws IOException { + String fileNameInCache = getUniqueFilenameForText(text, locale, voice, audioFormat); + if (fileNameInCache == null) { + throw new IOException("Could not infer cache file name"); + } + File audioFileInCache = new File(cacheFolder, fileNameInCache + "." + audioCodec.toLowerCase()); + return audioFileInCache.exists() ? audioFileInCache : null; + } + /** * Gets a unique filename for a give text, by creating a MD5 hash of it. It * will be preceded by the locale and suffixed by the format if it is not the diff --git a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudAPI.java b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudAPI.java index 7eac0507ee9c5..a461bf639f266 100644 --- a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudAPI.java +++ b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudAPI.java @@ -13,11 +13,11 @@ package org.openhab.voice.voicerss.internal.cloudapi; import java.io.IOException; -import java.io.InputStream; import java.util.Locale; import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.voice.voicerss.internal.VoiceRSSRawAudioStream; /** * Interface which represents the functionality needed from the VoiceRSS TTS @@ -31,7 +31,7 @@ public interface VoiceRSSCloudAPI { /** * Get all supported locales by the TTS service. * - * @return A set of @{link {@link Locale} supported + * @return A set of {@link Locale} supported */ Set getAvailableLocales(); @@ -74,11 +74,11 @@ public interface VoiceRSSCloudAPI { * the audio codec to use * @param audioFormat * the audio format to use - * @return an InputStream to the audio data in specified format + * @return a {@link VoiceRSSRawAudioStream} to the audio data in specified format * @throws IOException * will be raised if the audio data can not be retrieved from * cloud service */ - InputStream getTextToSpeech(String apiKey, String text, String locale, String voice, String audioCodec, + VoiceRSSRawAudioStream getTextToSpeech(String apiKey, String text, String locale, String voice, String audioCodec, String audioFormat) throws IOException; } diff --git a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudImpl.java b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudImpl.java index b81da7e98f9f9..d65f696788dbd 100644 --- a/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudImpl.java +++ b/bundles/org.openhab.voice.voicerss/src/main/java/org/openhab/voice/voicerss/internal/cloudapi/VoiceRSSCloudImpl.java @@ -28,6 +28,8 @@ import java.util.Set; import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.audio.AudioFormat; +import org.openhab.voice.voicerss.internal.VoiceRSSRawAudioStream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -215,8 +217,8 @@ public Set getAvailableVoices(Locale locale) { * dependencies. */ @Override - public InputStream getTextToSpeech(String apiKey, String text, String locale, String voice, String audioCodec, - String audioFormat) throws IOException { + public VoiceRSSRawAudioStream getTextToSpeech(String apiKey, String text, String locale, String voice, + String audioCodec, String audioFormat) throws IOException { String url = createURL(apiKey, text, locale, voice, audioCodec, audioFormat); if (logging) { LoggerFactory.getLogger(VoiceRSSCloudImpl.class).debug("Call {}", url.replace(apiKey, "***")); @@ -259,7 +261,8 @@ public InputStream getTextToSpeech(String apiKey, String text, String locale, St throw new IOException( "Could not read audio content, service returned an error: " + new String(bytes, "UTF-8")); } else { - return is; + // Set any audio format + return new VoiceRSSRawAudioStream(is, AudioFormat.MP3, connection.getContentLengthLong()); } } diff --git a/bundles/org.openhab.voice.voicerss/src/test/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSServiceTest.java b/bundles/org.openhab.voice.voicerss/src/test/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSServiceTest.java index 2957bf48c3828..b1fef188161aa 100644 --- a/bundles/org.openhab.voice.voicerss/src/test/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSServiceTest.java +++ b/bundles/org.openhab.voice.voicerss/src/test/java/org/openhab/voice/voicerss/internal/VoiceRSSTTSServiceTest.java @@ -18,12 +18,16 @@ import static org.openhab.core.audio.AudioFormat.*; import static org.openhab.voice.voicerss.internal.CompatibleAudioFormatMatcher.compatibleAudioFormat; +import java.util.HashMap; +import java.util.Map; import java.util.Set; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.openhab.core.audio.AudioFormat; +import org.openhab.core.storage.StorageService; import org.openhab.core.voice.TTSService; +import org.openhab.core.voice.internal.cache.TTSLRUCacheImpl; /** * Tests for {@link VoiceRSSTTSService}. @@ -43,6 +47,8 @@ public class VoiceRSSTTSServiceTest { private static final AudioFormat WAV_48KHZ_16BIT = new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false, 16, null, 48_000L); + private StorageService storageService; + /** * The {@link VoiceRSSTTSService} under test. */ @@ -50,7 +56,10 @@ public class VoiceRSSTTSServiceTest { @BeforeEach public void setUp() { - final VoiceRSSTTSService ttsService = new VoiceRSSTTSService(); + Map config = new HashMap<>(); + config.put("enableCacheTTS", false); + TTSLRUCacheImpl voiceLRUCache = new TTSLRUCacheImpl(storageService, config); + final VoiceRSSTTSService ttsService = new VoiceRSSTTSService(voiceLRUCache); ttsService.activate(null); this.ttsService = ttsService;