diff --git a/bundles/org.openhab.voice.pollytts/README.md b/bundles/org.openhab.voice.pollytts/README.md index 3f065c1e9..1946fd5c1 100644 --- a/bundles/org.openhab.voice.pollytts/README.md +++ b/bundles/org.openhab.voice.pollytts/README.md @@ -28,21 +28,11 @@ The following settings can be edited in UI (**Settings / Other Services - Polly * **Access Key** - The AWS credentials access key (required). * **Secret Key** - The AWS credentials secret key (required). * **Service Region** - The service region used for accessing Polly (required). To reduce latency select the region closest to you. E.g. "eu-west-1" (see [regions](https://docs.aws.amazon.com/general/latest/gr/rande.html#pol_region)) - -* **Cache Expiration** - Cache expiration in days. - -The PollyTTS service caches audio files from previous requests. -This reduces traffic, improves performance, reduces the number of requests and provides offline functionality. -When cache files are used their time stamps are updated, unused files are purged if their time stamp exceeds the specified age. -The default value of 0 disables this functionality. -A value of 365 removes files that have been unused for a year. - * **Audio Format** - Allows for overriding the system default audio format. Use "default" to select the system default audio format. The default audio format can be overriden with the value "mp3" or "ogg". - In case you would like to setup the service via a text file, create a new file in `$OPENHAB_ROOT/conf/services` named `pollytts.cfg` Its contents should look similar to: @@ -51,7 +41,6 @@ Its contents should look similar to: org.openhab.voice.pollytts:accessKey=ACCESS_KEY org.openhab.voice.pollytts:secretKey=SECRET_KEY org.openhab.voice.pollytts:serviceRegion=eu-west-1 -org.openhab.voice.pollytts:cacheExpiration=0 org.openhab.voice.pollytts:audioFormat=default ``` @@ -71,6 +60,10 @@ org.openhab.voice:defaultTTS=pollytts org.openhab.voice:defaultVoice=pollytts:Joanne ``` +## Caching + +The PolyTTS 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. + ## Rule Examples ``` diff --git a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSAudioStream.java b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSAudioStream.java index db9e68106..ba69c9dac 100644 --- a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSAudioStream.java +++ b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSAudioStream.java @@ -12,26 +12,101 @@ */ package org.openhab.voice.pollytts.internal; -import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; -import org.openhab.core.audio.AudioException; +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.audio.AudioFormat; import org.openhab.core.audio.AudioStream; -import org.openhab.core.audio.FileAudioStream; /** * Implementation of the {@link AudioStream} interface for the {@link PollyTTSService}. - * It simply uses a {@link FileAudioStream} which is doing all the necessary work, - * e.g. supporting MP3 and WAV files with fixed stream length. + * An AudioStream with an {@link InputStream} inside * * @author Robert Hillman - Initial contribution + * @author Gwendal Roulleau - Refactor to simple audiostream */ -class PollyTTSAudioStream extends FileAudioStream { +@NonNullByDefault +public class PollyTTSAudioStream extends AudioStream { - /** - * main method the passes the audio file to system audio services - */ - public PollyTTSAudioStream(File audioFile, AudioFormat format) throws AudioException { - super(audioFile, format); + public InputStream innerInputStream; + public AudioFormat audioFormat; + + public PollyTTSAudioStream(InputStream innerInputStream, AudioFormat audioFormat) { + super(); + this.innerInputStream = innerInputStream; + this.audioFormat = audioFormat; + } + + @Override + public AudioFormat getFormat() { + return audioFormat; + } + + @Override + public int read() throws IOException { + return innerInputStream.read(); + } + + @Override + public int read(byte @Nullable [] b) throws IOException { + return innerInputStream.read(b); + } + + @Override + public int read(byte @Nullable [] b, int off, int len) throws IOException { + return innerInputStream.read(b, off, len); + } + + @Override + public byte[] readAllBytes() throws IOException { + return innerInputStream.readAllBytes(); + } + + @Override + public byte[] readNBytes(int len) throws IOException { + return innerInputStream.readNBytes(len); + } + + @Override + public int readNBytes(byte @Nullable [] b, int off, int len) throws IOException { + return innerInputStream.readNBytes(b, off, len); + } + + @Override + public long skip(long n) throws IOException { + return innerInputStream.skip(n); + } + + @Override + public int available() throws IOException { + return innerInputStream.available(); + } + + @Override + public void close() throws IOException { + innerInputStream.close(); + } + + @Override + public synchronized void mark(int readlimit) { + innerInputStream.mark(readlimit); + } + + @Override + public synchronized void reset() throws IOException { + innerInputStream.reset(); + } + + @Override + public boolean markSupported() { + return innerInputStream.markSupported(); + } + + @Override + public long transferTo(@Nullable OutputStream out) throws IOException { + return innerInputStream.transferTo(out); } } diff --git a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSService.java b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSService.java index 483eb4d64..e2747df47 100644 --- a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSService.java +++ b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/PollyTTSService.java @@ -16,40 +16,48 @@ import static java.util.stream.Collectors.toSet; import static org.openhab.core.audio.AudioFormat.*; import static org.openhab.voice.pollytts.internal.PollyTTSService.*; -import java.io.File; -import java.io.IOException; +import java.io.InputStream; import java.util.Collections; import java.util.HashSet; import java.util.Locale; import java.util.Map; import java.util.Set; -import org.openhab.core.OpenHAB; -import org.openhab.core.audio.AudioException; 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; -import org.openhab.voice.pollytts.internal.cloudapi.CachedPollyTTSCloudImpl; +import org.openhab.voice.pollytts.internal.cloudapi.PollyTTSCloudImpl; import org.openhab.voice.pollytts.internal.cloudapi.PollyTTSConfig; import org.osgi.framework.Constants; 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; +import com.amazonaws.services.polly.model.AmazonPollyException; + /** * This is a TTS service implementation for using Polly Text-to-Speech. * * @author Robert Hillman - Initial contribution */ -@Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + SERVICE_PID) +@Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + + SERVICE_PID, service = TTSService.class) @ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME + " Text-to-Speech", description_uri = SERVICE_CATEGORY + ":" + SERVICE_ID) -public class PollyTTSService implements TTSService { +public class PollyTTSService extends AbstractCachedTTSService { + + @Activate + public PollyTTSService(final @Reference TTSCache ttsCache) { + super(ttsCache); + } /** * Service name @@ -71,17 +79,9 @@ public class PollyTTSService implements TTSService { */ static final String SERVICE_PID = "org.openhab." + SERVICE_CATEGORY + "." + SERVICE_ID; - /** - * Cache folder under $userdata - */ - private static final String CACHE_FOLDER_NAME = "cache"; - private final Logger logger = LoggerFactory.getLogger(PollyTTSService.class); - /** - * We need the cached implementation to allow for FixedLengthAudioStream. - */ - private CachedPollyTTSCloudImpl pollyTTSImpl; + private PollyTTSCloudImpl pollyTTSImpl; /** * Set of supported voices @@ -106,14 +106,7 @@ public class PollyTTSService implements TTSService { pollyTTSConfig = new PollyTTSConfig(config); logger.debug("Using configuration {}", config); - // create cache folder - File cacheFolder = new File(new File(OpenHAB.getUserDataFolder(), CACHE_FOLDER_NAME), SERVICE_PID); - if (!cacheFolder.exists()) { - cacheFolder.mkdirs(); - } - logger.info("Using cache folder {}", cacheFolder.getAbsolutePath()); - - pollyTTSImpl = new CachedPollyTTSCloudImpl(pollyTTSConfig, cacheFolder); + pollyTTSImpl = new PollyTTSCloudImpl(pollyTTSConfig); audioFormats.clear(); audioFormats.addAll(initAudioFormats()); @@ -143,7 +136,7 @@ public class PollyTTSService implements TTSService { * obtain audio stream from cache or Amazon Polly service and return it to play the audio */ @Override - public AudioStream synthesize(String inText, Voice voice, AudioFormat requestedFormat) throws TTSException { + public AudioStream synthesizeForCache(String inText, Voice voice, AudioFormat requestedFormat) throws TTSException { logger.debug("Synthesize '{}' in format {}", inText, requestedFormat); logger.debug("voice UID: '{}' voice label: '{}' voice Locale: {}", voice.getUID(), voice.getLabel(), voice.getLocale()); @@ -151,8 +144,8 @@ public class PollyTTSService implements TTSService { // Validate arguments // trim text String text = inText.trim(); - if (text == null || text.isEmpty()) { - throw new TTSException("The passed text is null or empty"); + if (text.isEmpty()) { + throw new TTSException("The passed text is empty"); } if (!voices.contains(voice)) { throw new TTSException("The passed voice is unsupported"); @@ -167,17 +160,15 @@ public class PollyTTSService implements TTSService { // now create the input stream for given text, locale, format. There is // only a default voice try { - File cacheAudioFile = pollyTTSImpl.getTextToSpeechAsFile(text, voice.getLabel(), + InputStream pollyAudioStream = pollyTTSImpl.getTextToSpeech(text, voice.getLabel(), getApiAudioFormat(requestedFormat)); - if (cacheAudioFile == null) { + if (pollyAudioStream == null) { throw new TTSException("Could not read from PollyTTS service"); } logger.debug("Audio Stream for '{}' in format {}", text, requestedFormat); - AudioStream audioStream = new PollyTTSAudioStream(cacheAudioFile, requestedFormat); + AudioStream audioStream = new PollyTTSAudioStream(pollyAudioStream, requestedFormat); return audioStream; - } catch (AudioException ex) { - throw new TTSException("Could not create AudioStream: " + ex.getMessage(), ex); - } catch (IOException ex) { + } catch (AmazonPollyException ex) { throw new TTSException("Could not read from PollyTTS service: " + ex.getMessage(), ex); } } diff --git a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/CachedPollyTTSCloudImpl.java b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/CachedPollyTTSCloudImpl.java deleted file mode 100644 index 6a3d57190..000000000 --- a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/CachedPollyTTSCloudImpl.java +++ /dev/null @@ -1,162 +0,0 @@ -/** - * 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.pollytts.internal.cloudapi; - -import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.math.BigInteger; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.Date; -import java.util.concurrent.TimeUnit; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -/** - * This class implements a cache for the retrieved audio data. It will preserve them in the file system, - * as audio files with an additional .txt file to indicate what content is in the audio file. - * - * @author Robert Hillman - Initial contribution - */ -public class CachedPollyTTSCloudImpl extends PollyTTSCloudImpl { - - private static final int READ_BUFFER_SIZE = 4096; - - private final Logger logger = LoggerFactory.getLogger(CachedPollyTTSCloudImpl.class); - - private final File cacheFolder; - - /** - * Create the file folder to hold the the cached speech files. - * check to make sure the directory exist and - * create it if necessary - */ - public CachedPollyTTSCloudImpl(PollyTTSConfig config, File cacheFolder) throws IOException { - super(config); - this.cacheFolder = cacheFolder; - } - - /** - * Fetch the specified text as an audio file. - * The audio file will be obtained from the cached folder if it - * exist or generated by use to the external voice service. - * The cached file txt description time stamp will be updated - * to identify last use. - */ - public File getTextToSpeechAsFile(String text, String label, String audioFormat) throws IOException { - String fileNameInCache = getUniqueFilenameForText(text, label); - // check if in cache - File audioFileInCache = new File(cacheFolder, fileNameInCache + "." + audioFormat.toLowerCase()); - if (audioFileInCache.exists()) { - // update use date - updateTimeStamp(audioFileInCache); - updateTimeStamp(new File(cacheFolder, fileNameInCache + ".txt")); - purgeAgedFiles(); - return audioFileInCache; - } - - // if not in cache, get audio data and put to cache - try (InputStream is = getTextToSpeech(text, label, audioFormat); - 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 - File txtFileInCache = new File(cacheFolder, fileNameInCache + ".txt"); - writeText(txtFileInCache, text); - // return from cache - return audioFileInCache; - } catch (IOException ex) { - logger.warn("Could not write {} to cache, return null", audioFileInCache, ex); - return null; - } - } - - /** - * Gets a unique filename for a give text, by creating a MD5 hash of it. It - * will be preceded by the voice label. - * - * Sample: "Robert_00a2653ac5f77063bc4ea2fee87318d3" - */ - private String getUniqueFilenameForText(String text, String label) { - MessageDigest md; - try { - md = MessageDigest.getInstance("MD5"); - } catch (NoSuchAlgorithmException ex) { - logger.error("Could not create MD5 hash for '{}'", text, ex); - return null; - } - byte[] md5Hash = md.digest(text.getBytes(StandardCharsets.UTF_8)); - BigInteger bigInt = new BigInteger(1, md5Hash); - String hashtext = bigInt.toString(16); - // Now we need to zero pad it if you actually want the full 32 - // chars. - while (hashtext.length() < 32) { - hashtext = "0" + hashtext; - } - String fileName = label + "_" + hashtext; - return fileName; - } - - // helper methods - - private void copyStream(InputStream inputStream, OutputStream outputStream) throws IOException { - byte[] bytes = new byte[READ_BUFFER_SIZE]; - int read = inputStream.read(bytes, 0, READ_BUFFER_SIZE); - while (read > 0) { - outputStream.write(bytes, 0, read); - read = inputStream.read(bytes, 0, READ_BUFFER_SIZE); - } - } - - private void writeText(File file, String text) throws IOException { - try (OutputStream outputStream = new FileOutputStream(file)) { - outputStream.write(text.getBytes(StandardCharsets.UTF_8)); - } - } - - private void updateTimeStamp(File file) throws IOException { - // update use date for cache management - file.setLastModified(System.currentTimeMillis()); - } - - private void purgeAgedFiles() throws IOException { - // just exit if expiration set to 0/disabled - if (config.getExpireDate() == 0) { - return; - } - long now = new Date().getTime(); - long diff = now - config.getLastDelete(); - // only execute ~ once every 2 days if cache called - long oneDayMillis = TimeUnit.DAYS.toMillis(1); - logger.debug("PollyTTS cache cleaner lastdelete {}", diff); - if (diff > (2 * oneDayMillis)) { - config.setLastDelete(now); - long xDaysAgo = config.getExpireDate() * oneDayMillis; - // Now search folders and delete old files - int filesDeleted = 0; - for (File file : cacheFolder.listFiles()) { - diff = now - file.lastModified(); - if (diff > xDaysAgo) { - filesDeleted++; - file.delete(); - } - } - logger.debug("PollyTTS cache cleaner deleted '{}' aged files", filesDeleted); - } - } -} diff --git a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/PollyTTSCloudImpl.java b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/PollyTTSCloudImpl.java index cf04c052d..6d03f6989 100644 --- a/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/PollyTTSCloudImpl.java +++ b/bundles/org.openhab.voice.pollytts/src/main/java/org/openhab/voice/pollytts/internal/cloudapi/PollyTTSCloudImpl.java @@ -15,7 +15,6 @@ package org.openhab.voice.pollytts.internal.cloudapi; import static java.util.stream.Collectors.*; import static org.openhab.core.audio.AudioFormat.*; -import java.io.IOException; import java.io.InputStream; import java.util.Collections; import java.util.List; @@ -29,6 +28,7 @@ import com.amazonaws.auth.AWSStaticCredentialsProvider; import com.amazonaws.auth.BasicAWSCredentials; import com.amazonaws.services.polly.AmazonPolly; import com.amazonaws.services.polly.AmazonPollyClientBuilder; +import com.amazonaws.services.polly.model.AmazonPollyException; import com.amazonaws.services.polly.model.DescribeVoicesRequest; import com.amazonaws.services.polly.model.OutputFormat; import com.amazonaws.services.polly.model.SynthesizeSpeechRequest; @@ -115,8 +115,7 @@ public class PollyTTSCloudImpl { * @param audioFormat * the audio format to use * @return an InputStream to the audio data in specified format - * @throws IOException - * will be raised if the audio data can not be retrieved from + * @throws AmazonPollyException will be raised if the audio data can not be retrieved from * cloud service */ public InputStream getTextToSpeech(String text, String label, String audioFormat) { diff --git a/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/config/config.xml index 962cb0142..964dd9e69 100644 --- a/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/config/config.xml @@ -52,13 +52,6 @@ default - - - - Determines the age in days when unused cached files are purged. - Use 0 to disable this functionality. - 0 - diff --git a/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/i18n/pollytts.properties b/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/i18n/pollytts.properties index c995baca0..3de588583 100644 --- a/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/i18n/pollytts.properties +++ b/bundles/org.openhab.voice.pollytts/src/main/resources/OH-INF/i18n/pollytts.properties @@ -5,8 +5,6 @@ voice.config.pollytts.audioFormat.description = Allows for overriding the system voice.config.pollytts.audioFormat.option.default = Use system default voice.config.pollytts.audioFormat.option.MP3 = MP3 voice.config.pollytts.audioFormat.option.OGG = OGG -voice.config.pollytts.cacheExpiration.label = Cache Expiration -voice.config.pollytts.cacheExpiration.description = Determines the age in days when unused cached files are purged. Use 0 to disable this functionality. voice.config.pollytts.secretKey.label = Secret Key voice.config.pollytts.secretKey.description = The secret key part of the AWS credentials. You need to register to get a key. voice.config.pollytts.serviceRegion.label = Service Region