]> git.basschouten.com Git - openhab-addons.git/blob
fb249fa6370cb8a1673bf411d4456d2beb0c8316
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.voice.googletts.internal;
14
15 import static org.openhab.voice.googletts.internal.GoogleTTSService.*;
16
17 import java.io.File;
18 import java.util.Collections;
19 import java.util.HashSet;
20 import java.util.Locale;
21 import java.util.Map;
22 import java.util.Set;
23
24 import org.eclipse.jdt.annotation.NonNullByDefault;
25 import org.eclipse.jdt.annotation.Nullable;
26 import org.openhab.core.OpenHAB;
27 import org.openhab.core.audio.AudioFormat;
28 import org.openhab.core.audio.AudioStream;
29 import org.openhab.core.audio.ByteArrayAudioStream;
30 import org.openhab.core.auth.client.oauth2.OAuthFactory;
31 import org.openhab.core.config.core.ConfigurableService;
32 import org.openhab.core.voice.TTSException;
33 import org.openhab.core.voice.TTSService;
34 import org.openhab.core.voice.Voice;
35 import org.openhab.voice.googletts.internal.dto.AudioEncoding;
36 import org.osgi.framework.Constants;
37 import org.osgi.service.cm.ConfigurationAdmin;
38 import org.osgi.service.component.annotations.Activate;
39 import org.osgi.service.component.annotations.Component;
40 import org.osgi.service.component.annotations.Modified;
41 import org.osgi.service.component.annotations.Reference;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
44
45 /**
46  * Voice service implementation.
47  *
48  * @author Gabor Bicskei - Initial contribution
49  */
50 @Component(configurationPid = SERVICE_PID, property = Constants.SERVICE_PID + "=" + SERVICE_PID)
51 @ConfigurableService(category = SERVICE_CATEGORY, label = SERVICE_NAME
52         + " Text-to-Speech", description_uri = SERVICE_CATEGORY + ":" + SERVICE_ID)
53 public class GoogleTTSService implements TTSService {
54     /**
55      * Service name
56      */
57     static final String SERVICE_NAME = "Google Cloud";
58
59     /**
60      * Service id
61      */
62     static final String SERVICE_ID = "googletts";
63
64     /**
65      * Service category
66      */
67     static final String SERVICE_CATEGORY = "voice";
68
69     /**
70      * Service pid
71      */
72     static final String SERVICE_PID = "org.openhab." + SERVICE_CATEGORY + "." + SERVICE_ID;
73
74     /**
75      * Cache folder under $userdata
76      */
77     private static final String CACHE_FOLDER_NAME = "cache";
78
79     /**
80      * Configuration parameters
81      */
82     private static final String PARAM_CLIENT_ID = "clientId";
83     private static final String PARAM_CLIEND_SECRET = "clientSecret";
84     static final String PARAM_AUTHCODE = "authcode";
85     private static final String PARAM_PITCH = "pitch";
86     private static final String PARAM_SPEAKING_RATE = "speakingRate";
87     private static final String PARAM_VOLUME_GAIN_DB = "volumeGainDb";
88     private static final String PARAM_PURGE_CACHE = "purgeCache";
89
90     /**
91      * Logger.
92      */
93     private final Logger logger = LoggerFactory.getLogger(GoogleTTSService.class);
94
95     /**
96      * Set of supported audio formats
97      */
98     private Set<AudioFormat> audioFormats = new HashSet<>();
99
100     /**
101      * Google Cloud TTS API implementation
102      */
103     private @NonNullByDefault({}) GoogleCloudAPI apiImpl;
104     private final ConfigurationAdmin configAdmin;
105     private final OAuthFactory oAuthFactory;
106
107     /**
108      * All voices for all supported locales
109      */
110     private Set<Voice> allVoices = new HashSet<>();
111
112     private final GoogleTTSConfig config = new GoogleTTSConfig();
113
114     @Activate
115     public GoogleTTSService(final @Reference ConfigurationAdmin configAdmin,
116             final @Reference OAuthFactory oAuthFactory) {
117         this.configAdmin = configAdmin;
118         this.oAuthFactory = oAuthFactory;
119     }
120
121     /**
122      * DS activate, with access to ConfigAdmin
123      */
124     @Activate
125     protected void activate(Map<String, Object> config) {
126         // create cache folder
127         File userData = new File(OpenHAB.getUserDataFolder());
128         File cacheFolder = new File(new File(userData, CACHE_FOLDER_NAME), SERVICE_PID);
129         if (!cacheFolder.exists()) {
130             cacheFolder.mkdirs();
131         }
132         logger.debug("Using cache folder {}", cacheFolder.getAbsolutePath());
133
134         apiImpl = new GoogleCloudAPI(configAdmin, oAuthFactory, cacheFolder);
135         updateConfig(config);
136     }
137
138     /**
139      * Initializing audio formats. Google supports 3 formats:
140      * LINEAR16
141      * Uncompressed 16-bit signed little-endian samples (Linear PCM). Audio content returned as LINEAR16
142      * also contains a WAV header.
143      * MP3
144      * MP3 audio.
145      * OGG_OPUS
146      * Opus encoded audio wrapped in an ogg container. This is not supported by openHAB.
147      *
148      * @return Set of supported AudioFormats
149      */
150     private Set<AudioFormat> initAudioFormats() {
151         logger.trace("Initializing audio formats");
152         Set<AudioFormat> result = new HashSet<>();
153         for (String format : apiImpl.getSupportedAudioFormats()) {
154             AudioFormat audioFormat = getAudioFormat(format);
155             if (audioFormat != null) {
156                 result.add(audioFormat);
157                 logger.trace("Audio format supported: {}", format);
158             } else {
159                 logger.trace("Audio format not supported: {}", format);
160             }
161         }
162         return Collections.unmodifiableSet(result);
163     }
164
165     /**
166      * Loads available voices from Google API
167      *
168      * @return Set of available voices.
169      */
170     private Set<Voice> initVoices() {
171         logger.trace("Initializing voices");
172         Set<Voice> result = new HashSet<>();
173         for (Locale locale : apiImpl.getSupportedLocales()) {
174             result.addAll(apiImpl.getVoicesForLocale(locale));
175         }
176         if (logger.isTraceEnabled()) {
177             for (Voice voice : result) {
178                 logger.trace("Google Cloud TTS voice: {}", voice.getLabel());
179             }
180         }
181         return Collections.unmodifiableSet(result);
182     }
183
184     /**
185      * Called by the framework when the configuration was updated.
186      *
187      * @param newConfig Updated configuration
188      */
189     @Modified
190     private void updateConfig(Map<String, Object> newConfig) {
191         logger.debug("Updating configuration");
192         if (newConfig != null) {
193             // client id
194             String param = newConfig.containsKey(PARAM_CLIENT_ID) ? newConfig.get(PARAM_CLIENT_ID).toString() : null;
195             config.clientId = param;
196             if (param == null) {
197                 logger.warn("Missing client id configuration to access Google Cloud TTS API.");
198             }
199             // client secret
200             param = newConfig.containsKey(PARAM_CLIEND_SECRET) ? newConfig.get(PARAM_CLIEND_SECRET).toString() : null;
201             config.clientSecret = param;
202             if (param == null) {
203                 logger.warn("Missing client secret configuration to access Google Cloud TTS API.");
204             }
205             // authcode
206             param = newConfig.containsKey(PARAM_AUTHCODE) ? newConfig.get(PARAM_AUTHCODE).toString() : null;
207             config.authcode = param;
208
209             // pitch
210             param = newConfig.containsKey(PARAM_PITCH) ? newConfig.get(PARAM_PITCH).toString() : null;
211             if (param != null) {
212                 config.pitch = Double.parseDouble(param);
213             }
214
215             // speakingRate
216             param = newConfig.containsKey(PARAM_SPEAKING_RATE) ? newConfig.get(PARAM_SPEAKING_RATE).toString() : null;
217             if (param != null) {
218                 config.speakingRate = Double.parseDouble(param);
219             }
220
221             // volumeGainDb
222             param = newConfig.containsKey(PARAM_VOLUME_GAIN_DB) ? newConfig.get(PARAM_VOLUME_GAIN_DB).toString() : null;
223             if (param != null) {
224                 config.volumeGainDb = Double.parseDouble(param);
225             }
226
227             // purgeCache
228             param = newConfig.containsKey(PARAM_PURGE_CACHE) ? newConfig.get(PARAM_PURGE_CACHE).toString() : null;
229             if (param != null) {
230                 config.purgeCache = Boolean.parseBoolean(param);
231             }
232             logger.trace("New configuration: {}", config.toString());
233
234             if (config.clientId != null && !config.clientId.isEmpty() && config.clientSecret != null
235                     && !config.clientSecret.isEmpty()) {
236                 apiImpl.setConfig(config);
237                 if (apiImpl.isInitialized()) {
238                     allVoices = initVoices();
239                     audioFormats = initAudioFormats();
240                 }
241             }
242         } else {
243             logger.warn("Missing Google Cloud TTS configuration.");
244         }
245     }
246
247     @Override
248     public String getId() {
249         return SERVICE_ID;
250     }
251
252     @Override
253     public String getLabel(@Nullable Locale locale) {
254         return SERVICE_NAME;
255     }
256
257     @Override
258     public Set<Voice> getAvailableVoices() {
259         return allVoices;
260     }
261
262     @Override
263     public Set<AudioFormat> getSupportedFormats() {
264         return audioFormats;
265     }
266
267     /**
268      * Helper to create AudioFormat objects from Google names.
269      *
270      * @param format Google audio format.
271      * @return Audio format object.
272      */
273     private @Nullable AudioFormat getAudioFormat(String format) {
274         Integer bitDepth = 16;
275         Long frequency = 44100L;
276
277         AudioEncoding encoding = AudioEncoding.valueOf(format);
278
279         switch (encoding) {
280             case MP3:
281                 // we use by default: MP3, 44khz_16bit_mono with bitrate 64 kbps
282                 return new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_MP3, null, bitDepth, 64000,
283                         frequency);
284             case LINEAR16:
285                 // we use by default: wav, 44khz_16bit_mono
286                 return new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, null, bitDepth, null,
287                         frequency);
288             default:
289                 logger.warn("Audio format {} is not yet supported.", format);
290                 return null;
291         }
292     }
293
294     /**
295      * Checks parameters and calls the API to synthesize voice.
296      *
297      * @param text Input text.
298      * @param voice Selected voice.
299      * @param requestedFormat Format that is supported by the target sink as well.
300      * @return Output audio stream
301      * @throws TTSException in case the service is unavailable or a parameter is invalid.
302      */
303     @Override
304     public AudioStream synthesize(String text, Voice voice, AudioFormat requestedFormat) throws TTSException {
305         logger.debug("Synthesize '{}' for voice '{}' in format {}", text, voice.getUID(), requestedFormat);
306         // Validate known api key
307         if (!apiImpl.isInitialized()) {
308             throw new TTSException("Missing service configuration.");
309         }
310         // Validate arguments
311         // trim text
312         String trimmedText = text.trim();
313         if (trimmedText.isEmpty()) {
314             throw new TTSException("The passed text is null or empty");
315         }
316         if (!this.allVoices.contains(voice)) {
317             throw new TTSException("The passed voice is unsupported");
318         }
319         boolean isAudioFormatSupported = false;
320         for (AudioFormat currentAudioFormat : this.audioFormats) {
321             if (currentAudioFormat.isCompatible(requestedFormat)) {
322                 isAudioFormatSupported = true;
323                 break;
324             }
325         }
326         if (!isAudioFormatSupported) {
327             throw new TTSException("The passed AudioFormat is unsupported");
328         }
329
330         // create the audio byte array for given text, locale, format
331         byte[] audio = apiImpl.synthesizeSpeech(trimmedText, (GoogleTTSVoice) voice, requestedFormat.getCodec());
332         if (audio == null) {
333             throw new TTSException("Could not synthesize text via Google Cloud TTS Service");
334         }
335         return new ByteArrayAudioStream(audio, requestedFormat);
336     }
337 }