added migrated 2.x add-ons
Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
32
bundles/org.openhab.voice.voicerss/.classpath
Normal file
32
bundles/org.openhab.voice.voicerss/.classpath
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<classpath>
|
||||
<classpathentry kind="src" output="target/classes" path="src/main/java">
|
||||
<attributes>
|
||||
<attribute name="optional" value="true"/>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry excluding="**" kind="src" output="target/classes" path="src/main/resources">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.jdt.launching.JRE_CONTAINER/org.eclipse.jdt.internal.debug.ui.launcher.StandardVMType/JavaSE-11">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="con" path="org.eclipse.m2e.MAVEN2_CLASSPATH_CONTAINER">
|
||||
<attributes>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="src" output="target/test-classes" path="src/test/java">
|
||||
<attributes>
|
||||
<attribute name="optional" value="true"/>
|
||||
<attribute name="maven.pomderived" value="true"/>
|
||||
<attribute name="test" value="true"/>
|
||||
</attributes>
|
||||
</classpathentry>
|
||||
<classpathentry kind="output" path="target/classes"/>
|
||||
</classpath>
|
||||
23
bundles/org.openhab.voice.voicerss/.project
Normal file
23
bundles/org.openhab.voice.voicerss/.project
Normal file
@@ -0,0 +1,23 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<projectDescription>
|
||||
<name>org.openhab.voice.voicerss</name>
|
||||
<comment></comment>
|
||||
<projects>
|
||||
</projects>
|
||||
<buildSpec>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.jdt.core.javabuilder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
<buildCommand>
|
||||
<name>org.eclipse.m2e.core.maven2Builder</name>
|
||||
<arguments>
|
||||
</arguments>
|
||||
</buildCommand>
|
||||
</buildSpec>
|
||||
<natures>
|
||||
<nature>org.eclipse.jdt.core.javanature</nature>
|
||||
<nature>org.eclipse.m2e.core.maven2Nature</nature>
|
||||
</natures>
|
||||
</projectDescription>
|
||||
13
bundles/org.openhab.voice.voicerss/NOTICE
Normal file
13
bundles/org.openhab.voice.voicerss/NOTICE
Normal file
@@ -0,0 +1,13 @@
|
||||
This content is produced and maintained by the openHAB project.
|
||||
|
||||
* Project home: https://www.openhab.org
|
||||
|
||||
== Declared Project Licenses
|
||||
|
||||
This program and the accompanying materials are made available under the terms
|
||||
of the Eclipse Public License 2.0 which is available at
|
||||
https://www.eclipse.org/legal/epl-2.0/.
|
||||
|
||||
== Source Code
|
||||
|
||||
https://github.com/openhab/openhab-addons
|
||||
62
bundles/org.openhab.voice.voicerss/README.md
Normal file
62
bundles/org.openhab.voice.voicerss/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# VoiceRSS Text-to-Speech
|
||||
|
||||
## Overview
|
||||
|
||||
VoiceRSS is an Internet based TTS service hosted at <https://api.voicerss.org>.
|
||||
You must obtain an API Key to get access to this service.
|
||||
The free version allows you to make up to 350 requests per day; for more you may need a commercial subscription.
|
||||
|
||||
For more information, see <http://www.voicerss.org/>.
|
||||
|
||||
## Samples
|
||||
|
||||
Replace API_KEY with your personal API key for simple testing of different API calls:
|
||||
|
||||
```
|
||||
# EN
|
||||
https://api.voicerss.org/?key=API_KEY&hl=en-us&src=Hello%20World
|
||||
https://api.voicerss.org/?key=API_KEY&hl=en-us&c=WAV&src=Hello%20World
|
||||
https://api.voicerss.org/?key=API_KEY&hl=en-us&f=44khz_16bit_mono&src=Hello%20World
|
||||
https://api.voicerss.org/?key=API_KEY&hl=en-gb&f=44khz_16bit_stereo&src=Hello%20World
|
||||
|
||||
# DE
|
||||
https://api.voicerss.org/?key=API_KEY&hl=de-de&f=44khz_16bit_mono&src=Hallo%20Welt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
You must add your API_KEY to your configuration by adding a file "voicerss.cfg" to the services folder, with this entry:
|
||||
|
||||
```
|
||||
apiKey=1234567890
|
||||
```
|
||||
|
||||
It actually supports only one voice: "voicerss:default", which is configured to use 44kHz, mono, 16 bit sampling quality.
|
||||
|
||||
## 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.
|
||||
|
||||
For convenience, there is a tool where the audio cache can be generated in advance, to have a prefilled cache when starting this extension.
|
||||
You have to copy the generated data to your userdata/voicerss/cache folder.
|
||||
|
||||
Synopsis of this tool:
|
||||
|
||||
```
|
||||
Usage: java org.openhab.voice.voicerss.tool.CreateTTSCache <args>
|
||||
Arguments: --api-key <key> <cache-dir> <locale> { <text> | @inputfile }
|
||||
key the VoiceRSS API Key, e.g. "123456789"
|
||||
cache-dir is directory where the files will be stored, e.g. "voicerss-cache"
|
||||
locale the language locale, has to be valid, e.g. "en-us", "de-de"
|
||||
text the text to create audio file for, e.g. "Hello World"
|
||||
inputfile a name of a file, where all lines will be translatet to text, e.g. "@message.txt"
|
||||
|
||||
Sample: java org.openhab.voice.voicerss.tool.CreateTTSCache --api-key 1234567890 cache en-US @messages.txt
|
||||
```
|
||||
|
||||
|
||||
## Open Issues
|
||||
|
||||
* add all media formats
|
||||
* add all supported languages
|
||||
* do not log API-Key in plain text
|
||||
17
bundles/org.openhab.voice.voicerss/pom.xml
Normal file
17
bundles/org.openhab.voice.voicerss/pom.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>org.openhab.addons.bundles</groupId>
|
||||
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
|
||||
<version>3.0.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>org.openhab.voice.voicerss</artifactId>
|
||||
|
||||
<name>openHAB Add-ons :: Bundles :: Voice :: VoiceRSS Text-to-Speech</name>
|
||||
|
||||
</project>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<features name="org.openhab.voice.voicerss-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
|
||||
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
|
||||
|
||||
<feature name="openhab-voice-voicerss" description="VoiceRSS Text-to-Speech" version="${project.version}">
|
||||
<feature>openhab-runtime-base</feature>
|
||||
<configfile finalname="${openhab.conf}/services/voicerss.cfg" override="false">mvn:${project.groupId}/openhab-addons-external/${project.version}/cfg/voicerss</configfile>
|
||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.voice.voicerss/${project.version}</bundle>
|
||||
</feature>
|
||||
</features>
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.File;
|
||||
|
||||
import org.openhab.core.audio.AudioException;
|
||||
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 VoiceRSSTTSService}. It simply uses a {@link FileAudioStream} which is
|
||||
* doing all the necessary work, e.g. supporting MP3 and WAV files with fixed
|
||||
* stream length.
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution and API
|
||||
*/
|
||||
class VoiceRSSAudioStream extends FileAudioStream {
|
||||
|
||||
public VoiceRSSAudioStream(File audioFile, AudioFormat format) throws AudioException {
|
||||
super(audioFile, format);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.File;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import org.openhab.core.audio.AudioException;
|
||||
import org.openhab.core.audio.AudioFormat;
|
||||
import org.openhab.core.audio.AudioStream;
|
||||
import org.openhab.core.config.core.ConfigConstants;
|
||||
import org.openhab.core.config.core.ConfigurableService;
|
||||
import org.openhab.core.voice.TTSException;
|
||||
import org.openhab.core.voice.TTSService;
|
||||
import org.openhab.core.voice.Voice;
|
||||
import org.openhab.voice.voicerss.internal.cloudapi.CachedVoiceRSSCloudImpl;
|
||||
import org.osgi.framework.Constants;
|
||||
import org.osgi.service.component.annotations.Component;
|
||||
import org.osgi.service.component.annotations.Modified;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This is a TTS service implementation for using VoiceRSS TTS service.
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution and API
|
||||
* @author Laurent Garnier - add support for OGG and AAC audio formats
|
||||
*/
|
||||
@Component(configurationPid = "org.openhab.voicerss", property = { Constants.SERVICE_PID + "=org.openhab.voicerss",
|
||||
ConfigurableService.SERVICE_PROPERTY_DESCRIPTION_URI + "=voice:voicerss",
|
||||
ConfigurableService.SERVICE_PROPERTY_LABEL + "=VoiceRSS Text-to-Speech",
|
||||
ConfigurableService.SERVICE_PROPERTY_CATEGORY + "=voice" })
|
||||
public class VoiceRSSTTSService implements TTSService {
|
||||
|
||||
/** Cache folder name is below userdata/voicerss/cache. */
|
||||
private static final String CACHE_FOLDER_NAME = "voicerss" + File.separator + "cache";
|
||||
|
||||
// API Key comes from ConfigAdmin
|
||||
private static final String CONFIG_API_KEY = "apiKey";
|
||||
private String apiKey;
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(VoiceRSSTTSService.class);
|
||||
|
||||
/**
|
||||
* We need the cached implementation to allow for FixedLengthAudioStream.
|
||||
*/
|
||||
private CachedVoiceRSSCloudImpl voiceRssImpl;
|
||||
|
||||
/**
|
||||
* Set of supported voices
|
||||
*/
|
||||
private Set<Voice> voices;
|
||||
|
||||
/**
|
||||
* Set of supported audio formats
|
||||
*/
|
||||
private Set<AudioFormat> audioFormats;
|
||||
|
||||
/**
|
||||
* DS activate, with access to ConfigAdmin
|
||||
*/
|
||||
protected void activate(Map<String, Object> config) {
|
||||
try {
|
||||
modified(config);
|
||||
voiceRssImpl = initVoiceImplementation();
|
||||
voices = initVoices();
|
||||
audioFormats = initAudioFormats();
|
||||
|
||||
logger.debug("Using VoiceRSS cache folder {}", getCacheFolderName());
|
||||
} catch (IllegalStateException e) {
|
||||
logger.error("Failed to activate VoiceRSS: {}", e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
@Modified
|
||||
protected void modified(Map<String, Object> config) {
|
||||
if (config != null) {
|
||||
apiKey = config.containsKey(CONFIG_API_KEY) ? config.get(CONFIG_API_KEY).toString() : null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Voice> getAvailableVoices() {
|
||||
return Collections.unmodifiableSet(voices);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<AudioFormat> getSupportedFormats() {
|
||||
return Collections.unmodifiableSet(audioFormats);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AudioStream synthesize(String text, Voice voice, AudioFormat requestedFormat) throws TTSException {
|
||||
logger.debug("Synthesize '{}' for voice '{}' in format {}", text, voice.getUID(), requestedFormat);
|
||||
// Validate known api key
|
||||
if (apiKey == null) {
|
||||
throw new TTSException("Missing API key, configure it first before using");
|
||||
}
|
||||
// Validate arguments
|
||||
if (text == null) {
|
||||
throw new TTSException("The passed text is null");
|
||||
}
|
||||
// trim text
|
||||
String trimmedText = text.trim();
|
||||
if (trimmedText.isEmpty()) {
|
||||
throw new TTSException("The passed text is empty");
|
||||
}
|
||||
if (!voices.contains(voice)) {
|
||||
throw new TTSException("The passed voice is unsupported");
|
||||
}
|
||||
boolean isAudioFormatSupported = false;
|
||||
for (AudioFormat currentAudioFormat : audioFormats) {
|
||||
if (currentAudioFormat.isCompatible(requestedFormat)) {
|
||||
isAudioFormatSupported = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!isAudioFormatSupported) {
|
||||
throw new TTSException("The passed AudioFormat is unsupported");
|
||||
}
|
||||
|
||||
// now create the input stream for given text, locale, format. There is
|
||||
// only a default voice
|
||||
try {
|
||||
File cacheAudioFile = voiceRssImpl.getTextToSpeechAsFile(apiKey, trimmedText,
|
||||
voice.getLocale().toLanguageTag(), getApiAudioFormat(requestedFormat));
|
||||
if (cacheAudioFile == null) {
|
||||
throw new TTSException("Could not read from VoiceRSS service");
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes voices.
|
||||
*
|
||||
* @return The voices of this instance
|
||||
*/
|
||||
private Set<Voice> initVoices() {
|
||||
Set<Voice> voices = new HashSet<>();
|
||||
for (Locale locale : voiceRssImpl.getAvailableLocales()) {
|
||||
for (String voiceLabel : voiceRssImpl.getAvailableVoices(locale)) {
|
||||
voices.add(new VoiceRSSVoice(locale, voiceLabel));
|
||||
}
|
||||
}
|
||||
return voices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes audioFormats
|
||||
*
|
||||
* @return The audio formats of this instance
|
||||
*/
|
||||
private Set<AudioFormat> initAudioFormats() {
|
||||
Set<AudioFormat> audioFormats = new HashSet<>();
|
||||
for (String format : voiceRssImpl.getAvailableAudioFormats()) {
|
||||
audioFormats.add(getAudioFormat(format));
|
||||
}
|
||||
return audioFormats;
|
||||
}
|
||||
|
||||
private AudioFormat getAudioFormat(String apiFormat) {
|
||||
Boolean bigEndian = null;
|
||||
Integer bitDepth = 16;
|
||||
Integer bitRate = null;
|
||||
Long frequency = 44100L;
|
||||
|
||||
if ("MP3".equals(apiFormat)) {
|
||||
// we use by default: MP3, 44khz_16bit_mono with bitrate 64 kbps
|
||||
bitRate = 64000;
|
||||
return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_MP3, bigEndian, bitDepth, bitRate,
|
||||
frequency);
|
||||
} else if ("OGG".equals(apiFormat)) {
|
||||
// we use by default: OGG, 44khz_16bit_mono
|
||||
return new AudioFormat(AudioFormat.CONTAINER_OGG, AudioFormat.CODEC_VORBIS, bigEndian, bitDepth, bitRate,
|
||||
frequency);
|
||||
} else if ("AAC".equals(apiFormat)) {
|
||||
// we use by default: AAC, 44khz_16bit_mono
|
||||
return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_AAC, bigEndian, bitDepth, bitRate,
|
||||
frequency);
|
||||
} else {
|
||||
throw new IllegalArgumentException("Audio format " + apiFormat + " not yet supported");
|
||||
}
|
||||
}
|
||||
|
||||
private String getApiAudioFormat(AudioFormat format) {
|
||||
if (format.getCodec().equals(AudioFormat.CODEC_MP3)) {
|
||||
return "MP3";
|
||||
} else if (format.getCodec().equals(AudioFormat.CODEC_VORBIS)) {
|
||||
return "OGG";
|
||||
} else if (format.getCodec().equals(AudioFormat.CODEC_AAC)) {
|
||||
return "AAC";
|
||||
} else {
|
||||
throw new IllegalArgumentException("Audio format " + format.getCodec() + " not yet supported");
|
||||
}
|
||||
}
|
||||
|
||||
private CachedVoiceRSSCloudImpl initVoiceImplementation() {
|
||||
return new CachedVoiceRSSCloudImpl(getCacheFolderName());
|
||||
}
|
||||
|
||||
private String getCacheFolderName() {
|
||||
// we assume that this folder does NOT have a trailing separator
|
||||
return ConfigConstants.getUserDataFolder() + File.separator + CACHE_FOLDER_NAME;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return "voicerss";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getLabel(Locale locale) {
|
||||
return "VoiceRSS";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.util.Locale;
|
||||
|
||||
import org.openhab.core.voice.Voice;
|
||||
|
||||
/**
|
||||
* Implementation of the Voice interface for VoiceRSS. Label is only "default"
|
||||
* as only voice supported.
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution and API
|
||||
*/
|
||||
public class VoiceRSSVoice implements Voice {
|
||||
|
||||
/**
|
||||
* Voice locale
|
||||
*/
|
||||
private final Locale locale;
|
||||
|
||||
/**
|
||||
* Voice label
|
||||
*/
|
||||
private final String label;
|
||||
|
||||
/**
|
||||
* Constructs a VoiceRSS Voice for the passed data
|
||||
*
|
||||
* @param locale
|
||||
* The Locale of the voice
|
||||
* @param label
|
||||
* The label of the voice
|
||||
*/
|
||||
public VoiceRSSVoice(Locale locale, String label) {
|
||||
this.locale = locale;
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
/**
|
||||
* Globally unique identifier of the voice.
|
||||
*
|
||||
* @return A String uniquely identifying the voice globally
|
||||
*/
|
||||
@Override
|
||||
public String getUID() {
|
||||
return "voicerss:" + locale.toLanguageTag().replaceAll("[^a-zA-Z0-9_]", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* The voice label, used for GUI's or VUI's
|
||||
*
|
||||
* @return The voice label, may not be globally unique
|
||||
*/
|
||||
@Override
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritDoc
|
||||
*/
|
||||
@Override
|
||||
public Locale getLocale() {
|
||||
return locale;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.cloudapi;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
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 org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This class implements a cache for the retrieved audio data. It will preserve
|
||||
* them in file system, as audio files with an additional .txt file to indicate
|
||||
* what content is in the audio file.
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution
|
||||
*/
|
||||
public class CachedVoiceRSSCloudImpl extends VoiceRSSCloudImpl {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(CachedVoiceRSSCloudImpl.class);
|
||||
|
||||
private final File cacheFolder;
|
||||
|
||||
/**
|
||||
* Stream buffer size
|
||||
*/
|
||||
private static final int READ_BUFFER_SIZE = 4096;
|
||||
|
||||
public CachedVoiceRSSCloudImpl(String cacheFolderName) {
|
||||
if (cacheFolderName == null) {
|
||||
throw new IllegalStateException("Folder for cache must be defined");
|
||||
}
|
||||
// Lazy create the cache folder
|
||||
cacheFolder = new File(cacheFolderName);
|
||||
if (!cacheFolder.exists()) {
|
||||
cacheFolder.mkdirs();
|
||||
}
|
||||
}
|
||||
|
||||
public File getTextToSpeechAsFile(String apiKey, String text, String locale, String audioFormat)
|
||||
throws IOException {
|
||||
String fileNameInCache = getUniqueFilenameForText(text, locale);
|
||||
// check if in cache
|
||||
File audioFileInCache = new File(cacheFolder, fileNameInCache + "." + audioFormat.toLowerCase());
|
||||
if (audioFileInCache.exists()) {
|
||||
return audioFileInCache;
|
||||
}
|
||||
|
||||
// if not in cache, get audio data and put to cache
|
||||
try (InputStream is = super.getTextToSpeech(apiKey, text, locale, 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 (FileNotFoundException ex) {
|
||||
logger.warn("Could not write {} to cache", audioFileInCache, ex);
|
||||
return null;
|
||||
} catch (IOException ex) {
|
||||
logger.error("Could not write {} to cache", 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 locale.
|
||||
*
|
||||
* Sample: "en-US_00a2653ac5f77063bc4ea2fee87318d3"
|
||||
*/
|
||||
private String getUniqueFilenameForText(String text, String locale) {
|
||||
try {
|
||||
byte[] bytesOfMessage = text.getBytes(StandardCharsets.UTF_8);
|
||||
MessageDigest md = MessageDigest.getInstance("MD5");
|
||||
byte[] md5Hash = md.digest(bytesOfMessage);
|
||||
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;
|
||||
}
|
||||
return locale + "_" + hashtext;
|
||||
} catch (NoSuchAlgorithmException ex) {
|
||||
// should not happen
|
||||
logger.error("Could not create MD5 hash for '{}'", text, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.cloudapi;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
|
||||
import org.openhab.core.audio.AudioFormat;
|
||||
|
||||
/**
|
||||
* Interface which represents the functionality needed from the VoiceRSS TTS
|
||||
* service.
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution
|
||||
*/
|
||||
public interface VoiceRSSCloudAPI {
|
||||
|
||||
/**
|
||||
* Get all supported locales by the TTS service.
|
||||
*
|
||||
* @return A set of @{link {@link Locale} supported
|
||||
*/
|
||||
Set<Locale> getAvailableLocales();
|
||||
|
||||
/**
|
||||
* Get all supported audio formats by the TTS service. This includes MP3,
|
||||
* WAV and more audio formats as used in APIs. About supported audio
|
||||
* formats, see {@link AudioFormat}
|
||||
*
|
||||
* @return A set of all audio formats supported
|
||||
*/
|
||||
Set<String> getAvailableAudioFormats();
|
||||
|
||||
/**
|
||||
* Get all supported voices.
|
||||
*
|
||||
* @return A set of voice names supported
|
||||
*/
|
||||
Set<String> getAvailableVoices();
|
||||
|
||||
/**
|
||||
* Get all supported voices for a specified locale.
|
||||
*
|
||||
* @param locale
|
||||
* the locale to get all voices for
|
||||
* @return A set of voice names supported
|
||||
*/
|
||||
Set<String> getAvailableVoices(Locale locale);
|
||||
|
||||
/**
|
||||
* Get the given text in specified locale and auido format as input stream.
|
||||
*
|
||||
* @param apiKey
|
||||
* the API key to use for the cloud service
|
||||
* @param text
|
||||
* the text to translate into speech
|
||||
* @param locale
|
||||
* the locale to use
|
||||
* @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
|
||||
* cloud service
|
||||
*/
|
||||
InputStream getTextToSpeech(String apiKey, String text, String locale, String audioFormat) throws IOException;
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.cloudapi;
|
||||
|
||||
import static java.util.stream.Collectors.toSet;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.UnsupportedEncodingException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.net.URLEncoder;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map.Entry;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* This class implements the Cloud service from VoiceRSS. For more information,
|
||||
* see API documentation at http://www.voicerss.org/api/documentation.aspx.
|
||||
*
|
||||
* Current state of implementation:
|
||||
* <ul>
|
||||
* <li>All API languages supported</li>
|
||||
* <li>Only default voice supported with good audio quality</li>
|
||||
* <li>Only MP3, OGG and AAC audio formats supported</li>
|
||||
* <li>It uses HTTP and not HTTPS (for performance reasons)</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution
|
||||
* @author Laurent Garnier - add support for all API languages
|
||||
* @author Laurent Garnier - add support for OGG and AAC audio formats
|
||||
*/
|
||||
public class VoiceRSSCloudImpl implements VoiceRSSCloudAPI {
|
||||
|
||||
private final Logger logger = LoggerFactory.getLogger(VoiceRSSCloudImpl.class);
|
||||
|
||||
private static final Set<String> SUPPORTED_AUDIO_FORMATS = Stream.of("MP3", "OGG", "AAC").collect(toSet());
|
||||
|
||||
private static final Set<Locale> SUPPORTED_LOCALES = new HashSet<>();
|
||||
static {
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("ca-es"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("da-dk"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("de-de"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("en-au"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("en-ca"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("en-gb"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("en-in"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("en-us"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("es-es"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("es-mx"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("fi-fi"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("fr-ca"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("fr-fr"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("it-it"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("ja-jp"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("ko-kr"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("nb-no"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("nl-nl"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("pl-pl"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("pt-br"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("pt-pt"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("ru-ru"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("sv-se"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("zh-cn"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("zh-hk"));
|
||||
SUPPORTED_LOCALES.add(Locale.forLanguageTag("zh-tw"));
|
||||
}
|
||||
|
||||
private static final Set<String> SUPPORTED_VOICES = Collections.singleton("VoiceRSS");
|
||||
|
||||
@Override
|
||||
public Set<String> getAvailableAudioFormats() {
|
||||
return SUPPORTED_AUDIO_FORMATS;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Locale> getAvailableLocales() {
|
||||
return SUPPORTED_LOCALES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getAvailableVoices() {
|
||||
return SUPPORTED_VOICES;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getAvailableVoices(Locale locale) {
|
||||
for (Locale voiceLocale : SUPPORTED_LOCALES) {
|
||||
if (voiceLocale.toLanguageTag().equalsIgnoreCase(locale.toLanguageTag())) {
|
||||
return SUPPORTED_VOICES;
|
||||
}
|
||||
}
|
||||
return new HashSet<>();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method will return an input stream to an audio stream for the given
|
||||
* parameters.
|
||||
*
|
||||
* It will do that using a plain URL connection to avoid any external
|
||||
* dependencies.
|
||||
*/
|
||||
@Override
|
||||
public InputStream getTextToSpeech(String apiKey, String text, String locale, String audioFormat)
|
||||
throws IOException {
|
||||
String url = createURL(apiKey, text, locale, audioFormat);
|
||||
logger.debug("Call {}", url);
|
||||
URLConnection connection = new URL(url).openConnection();
|
||||
|
||||
// we will check return codes. The service will ALWAYS return a HTTP
|
||||
// 200, but for error messages, it will return a text/plain format and
|
||||
// the error message in body
|
||||
int status = ((HttpURLConnection) connection).getResponseCode();
|
||||
if (HttpURLConnection.HTTP_OK != status) {
|
||||
logger.error("Call {} returned HTTP {}", url, status);
|
||||
throw new IOException("Could not read from service: HTTP code " + status);
|
||||
}
|
||||
if (logger.isTraceEnabled()) {
|
||||
for (Entry<String, List<String>> header : connection.getHeaderFields().entrySet()) {
|
||||
logger.trace("Response.header: {}={}", header.getKey(), header.getValue());
|
||||
}
|
||||
}
|
||||
String contentType = connection.getHeaderField("Content-Type");
|
||||
InputStream is = connection.getInputStream();
|
||||
// check if content type is text/plain, then we have an error
|
||||
if (contentType.contains("text/plain")) {
|
||||
byte[] bytes = new byte[256];
|
||||
is.read(bytes, 0, 256);
|
||||
// close before throwing an exception
|
||||
try {
|
||||
is.close();
|
||||
} catch (IOException ex) {
|
||||
logger.debug("Failed to close inputstream", ex);
|
||||
}
|
||||
throw new IOException(
|
||||
"Could not read audio content, service return an error: " + new String(bytes, "UTF-8"));
|
||||
} else {
|
||||
return is;
|
||||
}
|
||||
}
|
||||
|
||||
// internal
|
||||
|
||||
/**
|
||||
* This method will create the URL for the cloud service. The text will be
|
||||
* URI encoded as it is part of the URL.
|
||||
*
|
||||
* It is in package scope to be accessed by tests.
|
||||
*/
|
||||
private String createURL(String apiKey, String text, String locale, String audioFormat) {
|
||||
String encodedMsg;
|
||||
try {
|
||||
encodedMsg = URLEncoder.encode(text, "UTF-8");
|
||||
} catch (UnsupportedEncodingException ex) {
|
||||
logger.error("UnsupportedEncodingException for UTF-8 MUST NEVER HAPPEN! Check your JVM configuration!", ex);
|
||||
// fall through and use msg un-encoded
|
||||
encodedMsg = text;
|
||||
}
|
||||
return "http://api.voicerss.org/?key=" + apiKey + "&hl=" + locale + "&c=" + audioFormat
|
||||
+ "&f=44khz_16bit_mono&src=" + encodedMsg;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Copyright (c) 2010-2020 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.tool;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.File;
|
||||
import java.io.FileReader;
|
||||
import java.io.IOException;
|
||||
|
||||
import org.openhab.voice.voicerss.internal.cloudapi.CachedVoiceRSSCloudImpl;
|
||||
|
||||
/**
|
||||
* This class fills a cache with data from the VoiceRSS TTS service.
|
||||
*
|
||||
* @author Jochen Hiller - Initial contribution
|
||||
*/
|
||||
public class CreateTTSCache {
|
||||
|
||||
public static final int RC_OK = 0;
|
||||
public static final int RC_USAGE = 1;
|
||||
public static final int RC_INPUT_FILE_NOT_FOUND = 2;
|
||||
public static final int RC_API_KEY_MISSING = 3;
|
||||
|
||||
public static void main(String[] args) throws IOException {
|
||||
CreateTTSCache tool = new CreateTTSCache();
|
||||
int rc = tool.doMain(args);
|
||||
System.exit(rc);
|
||||
}
|
||||
|
||||
public int doMain(String[] args) throws IOException {
|
||||
if ((args == null) || (args.length != 5)) {
|
||||
usage();
|
||||
return RC_USAGE;
|
||||
}
|
||||
if (!args[0].equalsIgnoreCase("--api-key")) {
|
||||
usage();
|
||||
return RC_API_KEY_MISSING;
|
||||
}
|
||||
String apiKey = args[1];
|
||||
String cacheDir = args[2];
|
||||
String locale = args[3];
|
||||
if (args[4].startsWith("@")) {
|
||||
String inputFileName = args[4].substring(1);
|
||||
File inputFile = new File(inputFileName);
|
||||
if (!inputFile.exists()) {
|
||||
usage();
|
||||
System.err.println("File " + inputFileName + " not found");
|
||||
return RC_INPUT_FILE_NOT_FOUND;
|
||||
}
|
||||
generateCacheForFile(apiKey, cacheDir, locale, inputFileName);
|
||||
} else {
|
||||
String text = args[4];
|
||||
generateCacheForMessage(apiKey, cacheDir, locale, text);
|
||||
}
|
||||
return RC_OK;
|
||||
}
|
||||
|
||||
private void usage() {
|
||||
System.out.println("Usage: java org.openhab.voice.voicerss.tool.CreateTTSCache <args>");
|
||||
System.out.println("Arguments: --api-key <key> <cache-dir> <locale> { <text> | @inputfile }");
|
||||
System.out.println(" key the VoiceRSS API Key, e.g. \"123456789\"");
|
||||
System.out.println(" cache-dir is directory where the files will be stored, e.g. \"voicerss-cache\"");
|
||||
System.out.println(" locale the language locale, has to be valid, e.g. \"en-us\", \"de-de\"");
|
||||
System.out.println(" text the text to create audio file for, e.g. \"Hello World\"");
|
||||
System.out.println(
|
||||
" inputfile a name of a file, where all lines will be translatet to text, e.g. \"@message.txt\"");
|
||||
System.out.println();
|
||||
System.out.println(
|
||||
"Sample: java org.openhab.voice.voicerss.tool.CreateTTSCache --api-key 1234567890 cache en-US @messages.txt");
|
||||
System.out.println();
|
||||
}
|
||||
|
||||
private void generateCacheForFile(String apiKey, String cacheDir, String locale, String inputFileName)
|
||||
throws IOException {
|
||||
File inputFile = new File(inputFileName);
|
||||
try (BufferedReader br = new BufferedReader(new FileReader(inputFile))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
// process the line.
|
||||
generateCacheForMessage(apiKey, cacheDir, locale, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void generateCacheForMessage(String apiKey, String cacheDir, String locale, String msg) throws IOException {
|
||||
if (msg == null) {
|
||||
System.err.println("Ignore msg=null");
|
||||
return;
|
||||
}
|
||||
String trimmedMsg = msg.trim();
|
||||
if (trimmedMsg.length() == 0) {
|
||||
System.err.println("Ignore msg=''");
|
||||
return;
|
||||
}
|
||||
CachedVoiceRSSCloudImpl impl = new CachedVoiceRSSCloudImpl(cacheDir);
|
||||
File cachedFile = impl.getTextToSpeechAsFile(apiKey, trimmedMsg, locale, "MP3");
|
||||
System.out.println(
|
||||
"Created cached audio for locale='" + locale + "', msg='" + trimmedMsg + "' to file=" + cachedFile);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<config-description:config-descriptions
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xmlns:config-description="https://openhab.org/schemas/config-description/v1.0.0"
|
||||
xsi:schemaLocation="https://openhab.org/schemas/config-description/v1.0.0
|
||||
https://openhab.org/schemas/config-description-1.0.0.xsd">
|
||||
|
||||
<config-description uri="voice:voicerss">
|
||||
<parameter name="apiKey" type="text" required="true">
|
||||
<label>VoiceRSS API Key</label>
|
||||
<description>The API Key to get access to http://www.voicerss.org. You need to register with at least a free account
|
||||
to get an API key.</description>
|
||||
</parameter>
|
||||
</config-description>
|
||||
|
||||
</config-description:config-descriptions>
|
||||
Reference in New Issue
Block a user