2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.voice.googletts.internal;
15 import static org.openhab.voice.googletts.internal.GoogleTTSService.*;
17 import java.io.ByteArrayInputStream;
19 import java.io.IOException;
20 import java.io.InputStream;
21 import java.util.Collections;
22 import java.util.HashSet;
23 import java.util.Locale;
27 import org.eclipse.jdt.annotation.NonNullByDefault;
28 import org.eclipse.jdt.annotation.Nullable;
29 import org.openhab.core.OpenHAB;
30 import org.openhab.core.audio.AudioFormat;
31 import org.openhab.core.audio.AudioStream;
32 import org.openhab.core.audio.ByteArrayAudioStream;
33 import org.openhab.core.audio.utils.AudioWaveUtils;
34 import org.openhab.core.auth.client.oauth2.OAuthFactory;
35 import org.openhab.core.config.core.ConfigurableService;
36 import org.openhab.core.voice.TTSException;
37 import org.openhab.core.voice.TTSService;
38 import org.openhab.core.voice.Voice;
39 import org.openhab.voice.googletts.internal.dto.AudioEncoding;
40 import org.osgi.framework.Constants;
41 import org.osgi.service.cm.ConfigurationAdmin;
42 import org.osgi.service.component.annotations.Activate;
43 import org.osgi.service.component.annotations.Component;
44 import org.osgi.service.component.annotations.Deactivate;
45 import org.osgi.service.component.annotations.Modified;
46 import org.osgi.service.component.annotations.Reference;
47 import org.slf4j.Logger;
48 import org.slf4j.LoggerFactory;
51 * Voice service implementation.
53 * @author Gabor Bicskei - Initial contribution
55 @Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + SERVICE_PID)
56 @ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME
57 + " Text-to-Speech", description_uri = SERVICE_CATEGORY + ":" + SERVICE_ID)
58 public class GoogleTTSService implements TTSService {
62 static final String SERVICE_NAME = "Google Cloud";
67 static final String SERVICE_ID = "googletts";
72 static final String SERVICE_CATEGORY = "voice";
77 static final String SERVICE_PID = "org.openhab." + SERVICE_CATEGORY + "." + SERVICE_ID;
80 * Cache folder under $userdata
82 private static final String CACHE_FOLDER_NAME = "cache";
85 * Configuration parameters
87 private static final String PARAM_CLIENT_ID = "clientId";
88 private static final String PARAM_CLIEND_SECRET = "clientSecret";
89 static final String PARAM_AUTHCODE = "authcode";
90 private static final String PARAM_PITCH = "pitch";
91 private static final String PARAM_SPEAKING_RATE = "speakingRate";
92 private static final String PARAM_VOLUME_GAIN_DB = "volumeGainDb";
93 private static final String PARAM_PURGE_CACHE = "purgeCache";
98 private final Logger logger = LoggerFactory.getLogger(GoogleTTSService.class);
101 * Set of supported audio formats
103 private Set<AudioFormat> audioFormats = new HashSet<>();
106 * Google Cloud TTS API implementation
108 private @NonNullByDefault({}) GoogleCloudAPI apiImpl;
109 private final ConfigurationAdmin configAdmin;
110 private final OAuthFactory oAuthFactory;
113 * All voices for all supported locales
115 private Set<Voice> allVoices = new HashSet<>();
117 private final GoogleTTSConfig config = new GoogleTTSConfig();
120 public GoogleTTSService(final @Reference ConfigurationAdmin configAdmin,
121 final @Reference OAuthFactory oAuthFactory) {
122 this.configAdmin = configAdmin;
123 this.oAuthFactory = oAuthFactory;
127 * DS activate, with access to ConfigAdmin
130 protected void activate(Map<String, Object> config) {
131 // create cache folder
132 File userData = new File(OpenHAB.getUserDataFolder());
133 File cacheFolder = new File(new File(userData, CACHE_FOLDER_NAME), SERVICE_PID);
134 if (!cacheFolder.exists()) {
135 cacheFolder.mkdirs();
137 logger.debug("Using cache folder {}", cacheFolder.getAbsolutePath());
139 apiImpl = new GoogleCloudAPI(configAdmin, oAuthFactory, cacheFolder);
140 updateConfig(config);
144 protected void dispose() {
146 audioFormats.clear();
151 * Initializing audio formats. Google supports 3 formats:
153 * Uncompressed 16-bit signed little-endian samples (Linear PCM). Audio content returned as LINEAR16
154 * also contains a WAV header.
158 * Opus encoded audio wrapped in an ogg container. This is not supported by openHAB.
160 * @return Set of supported AudioFormats
162 private Set<AudioFormat> initAudioFormats() {
163 logger.trace("Initializing audio formats");
164 Set<AudioFormat> result = new HashSet<>();
165 for (String format : apiImpl.getSupportedAudioFormats()) {
166 AudioFormat audioFormat = getAudioFormat(format);
167 if (audioFormat != null) {
168 result.add(audioFormat);
169 logger.trace("Audio format supported: {}", format);
171 logger.trace("Audio format not supported: {}", format);
174 return Collections.unmodifiableSet(result);
178 * Loads available voices from Google API
180 * @return Set of available voices.
182 private Set<Voice> initVoices() {
183 logger.trace("Initializing voices");
184 Set<Voice> result = new HashSet<>();
185 for (Locale locale : apiImpl.getSupportedLocales()) {
186 result.addAll(apiImpl.getVoicesForLocale(locale));
188 if (logger.isTraceEnabled()) {
189 for (Voice voice : result) {
190 logger.trace("Google Cloud TTS voice: {}", voice.getLabel());
193 return Collections.unmodifiableSet(result);
197 * Called by the framework when the configuration was updated.
199 * @param newConfig Updated configuration
202 private void updateConfig(Map<String, Object> newConfig) {
203 logger.debug("Updating configuration");
204 if (newConfig != null) {
206 String param = newConfig.containsKey(PARAM_CLIENT_ID) ? newConfig.get(PARAM_CLIENT_ID).toString() : null;
207 config.clientId = param;
209 logger.warn("Missing client id configuration to access Google Cloud TTS API.");
212 param = newConfig.containsKey(PARAM_CLIEND_SECRET) ? newConfig.get(PARAM_CLIEND_SECRET).toString() : null;
213 config.clientSecret = param;
215 logger.warn("Missing client secret configuration to access Google Cloud TTS API.");
218 param = newConfig.containsKey(PARAM_AUTHCODE) ? newConfig.get(PARAM_AUTHCODE).toString() : null;
219 config.authcode = param;
222 param = newConfig.containsKey(PARAM_PITCH) ? newConfig.get(PARAM_PITCH).toString() : null;
224 config.pitch = Double.parseDouble(param);
228 param = newConfig.containsKey(PARAM_SPEAKING_RATE) ? newConfig.get(PARAM_SPEAKING_RATE).toString() : null;
230 config.speakingRate = Double.parseDouble(param);
234 param = newConfig.containsKey(PARAM_VOLUME_GAIN_DB) ? newConfig.get(PARAM_VOLUME_GAIN_DB).toString() : null;
236 config.volumeGainDb = Double.parseDouble(param);
240 param = newConfig.containsKey(PARAM_PURGE_CACHE) ? newConfig.get(PARAM_PURGE_CACHE).toString() : null;
242 config.purgeCache = Boolean.parseBoolean(param);
244 logger.trace("New configuration: {}", config.toString());
246 if (config.clientId != null && !config.clientId.isEmpty() && config.clientSecret != null
247 && !config.clientSecret.isEmpty()) {
248 apiImpl.setConfig(config);
249 if (apiImpl.isInitialized()) {
250 allVoices = initVoices();
251 audioFormats = initAudioFormats();
255 logger.warn("Missing Google Cloud TTS configuration.");
260 public String getId() {
265 public String getLabel(@Nullable Locale locale) {
270 public Set<Voice> getAvailableVoices() {
275 public Set<AudioFormat> getSupportedFormats() {
280 * Helper to create AudioFormat objects from Google names.
282 * @param format Google audio format.
283 * @return Audio format object.
285 private @Nullable AudioFormat getAudioFormat(String format) {
286 Integer bitDepth = 16;
287 Long frequency = 44100L;
289 AudioEncoding encoding = AudioEncoding.valueOf(format);
293 // we use by default: MP3, 44khz_16bit_mono with bitrate 64 kbps
294 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_MP3, null, bitDepth, 64000,
297 // we use by default: wav, 44khz_16bit_mono
298 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, null, bitDepth, null,
301 logger.warn("Audio format {} is not yet supported.", format);
307 * Checks parameters and calls the API to synthesize voice.
309 * @param text Input text.
310 * @param voice Selected voice.
311 * @param requestedFormat Format that is supported by the target sink as well.
312 * @return Output audio stream
313 * @throws TTSException in case the service is unavailable or a parameter is invalid.
316 public AudioStream synthesize(String text, Voice voice, AudioFormat requestedFormat) throws TTSException {
317 logger.debug("Synthesize '{}' for voice '{}' in format {}", text, voice.getUID(), requestedFormat);
318 // Validate known api key
319 if (!apiImpl.isInitialized()) {
320 throw new TTSException("Missing service configuration.");
322 // Validate arguments
324 String trimmedText = text.trim();
325 if (trimmedText.isEmpty()) {
326 throw new TTSException("The passed text is null or empty");
328 if (!this.allVoices.contains(voice)) {
329 throw new TTSException("The passed voice is unsupported");
331 boolean isAudioFormatSupported = false;
332 for (AudioFormat currentAudioFormat : this.audioFormats) {
333 if (currentAudioFormat.isCompatible(requestedFormat)) {
334 isAudioFormatSupported = true;
338 if (!isAudioFormatSupported) {
339 throw new TTSException("The passed AudioFormat is unsupported");
342 // create the audio byte array for given text, locale, format
343 byte[] audio = apiImpl.synthesizeSpeech(trimmedText, (GoogleTTSVoice) voice, requestedFormat.getCodec());
345 throw new TTSException("Could not synthesize text via Google Cloud TTS Service");
348 // compute the real format returned by google if wave file
349 AudioFormat finalFormat = requestedFormat;
350 if (AudioFormat.CONTAINER_WAVE.equals(requestedFormat.getContainer())) {
351 finalFormat = parseAudioFormat(audio);
354 return new ByteArrayAudioStream(audio, finalFormat);
357 private AudioFormat parseAudioFormat(byte[] audio) throws TTSException {
358 try (InputStream inputStream = new ByteArrayInputStream(audio)) {
359 return AudioWaveUtils.parseWavFormat(inputStream);
360 } catch (IOException e) {
361 throw new TTSException("Cannot parse WAV format", e);