]> git.basschouten.com Git - openhab-addons.git/blob
5b26dbd99eea9c5054b02b8a81cfb88f49cdebd4
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.mimic.internal;
14
15 import java.io.IOException;
16 import java.io.InputStream;
17 import java.io.UnsupportedEncodingException;
18 import java.math.BigInteger;
19 import java.net.URLEncoder;
20 import java.nio.charset.StandardCharsets;
21 import java.security.MessageDigest;
22 import java.security.NoSuchAlgorithmException;
23 import java.util.HashSet;
24 import java.util.List;
25 import java.util.Locale;
26 import java.util.Map;
27 import java.util.Set;
28 import java.util.concurrent.ExecutionException;
29 import java.util.concurrent.TimeUnit;
30 import java.util.concurrent.TimeoutException;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.eclipse.jetty.client.HttpClient;
35 import org.eclipse.jetty.client.api.Response;
36 import org.eclipse.jetty.client.util.InputStreamResponseListener;
37 import org.eclipse.jetty.client.util.StringContentProvider;
38 import org.eclipse.jetty.http.HttpHeader;
39 import org.eclipse.jetty.http.HttpStatus;
40 import org.openhab.core.audio.AudioFormat;
41 import org.openhab.core.audio.AudioStream;
42 import org.openhab.core.config.core.ConfigurableService;
43 import org.openhab.core.io.net.http.HttpClientFactory;
44 import org.openhab.core.io.net.http.HttpRequestBuilder;
45 import org.openhab.core.voice.AbstractCachedTTSService;
46 import org.openhab.core.voice.TTSCache;
47 import org.openhab.core.voice.TTSException;
48 import org.openhab.core.voice.TTSService;
49 import org.openhab.core.voice.Voice;
50 import org.openhab.voice.mimic.internal.dto.VoiceDto;
51 import org.osgi.framework.Constants;
52 import org.osgi.service.component.annotations.Activate;
53 import org.osgi.service.component.annotations.Component;
54 import org.osgi.service.component.annotations.Modified;
55 import org.osgi.service.component.annotations.Reference;
56 import org.slf4j.Logger;
57 import org.slf4j.LoggerFactory;
58
59 import com.google.gson.Gson;
60 import com.google.gson.GsonBuilder;
61 import com.google.gson.JsonSyntaxException;
62
63 /**
64  * Mimic Voice service implementation.
65  *
66  * @author Gwendal Roulleau - Initial contribution
67  */
68 @Component(configurationPid = MimicTTSService.SERVICE_PID, property = Constants.SERVICE_PID + "="
69         + MimicTTSService.SERVICE_PID, service = TTSService.class)
70 @ConfigurableService(category = MimicTTSService.SERVICE_CATEGORY, label = MimicTTSService.SERVICE_NAME
71         + " Text-to-Speech", description_uri = MimicTTSService.SERVICE_CATEGORY + ":" + MimicTTSService.SERVICE_ID)
72 @NonNullByDefault
73 public class MimicTTSService extends AbstractCachedTTSService {
74
75     private final Logger logger = LoggerFactory.getLogger(MimicTTSService.class);
76
77     static final String SERVICE_CATEGORY = "voice";
78     static final String SERVICE_ID = "mimictts";
79     static final String SERVICE_PID = "org.openhab." + SERVICE_CATEGORY + "." + SERVICE_ID;
80     static final String SERVICE_NAME = "Mimic";
81
82     /**
83      * Configuration parameters
84      */
85     private static final String PARAM_URL = "url";
86     private static final String PARAM_SPEAKINGRATE = "speakingRate";
87     private static final String PARAM_AUDIOVOLATITLITY = "audioVolatility";
88     private static final String PARAM_PHONEMEVOLATITLITY = "phonemeVolatility";
89
90     /**
91      * Url
92      */
93     private static final String LIST_VOICES_URL = "/api/voices";
94     private static final String SYNTHETIZE_URL = "/api/tts";
95
96     /** The only wave format supported */
97     private static final AudioFormat AUDIO_FORMAT = new AudioFormat(AudioFormat.CONTAINER_WAVE,
98             AudioFormat.CODEC_PCM_SIGNED, false, 16, 52000, 22050L, 1);
99
100     private Set<Voice> availableVoices = new HashSet<>();
101
102     private final MimicConfiguration config = new MimicConfiguration();
103
104     private final Gson gson = new GsonBuilder().create();
105
106     private final HttpClient httpClient;
107
108     @Activate
109     public MimicTTSService(final @Reference HttpClientFactory httpClientFactory, @Reference TTSCache ttsCache,
110             Map<String, Object> config) {
111         super(ttsCache);
112         updateConfig(config);
113         this.httpClient = httpClientFactory.getCommonHttpClient();
114     }
115
116     /**
117      * Called by the framework when the configuration was updated.
118      *
119      * @param newConfig Updated configuration
120      */
121     @Modified
122     private void updateConfig(Map<String, Object> newConfig) {
123         logger.debug("Updating configuration");
124
125         // client id
126         Object param = newConfig.get(PARAM_URL);
127         if (param == null) {
128             logger.warn("Missing URL to access Mimic TTS API. Using localhost");
129         } else {
130             config.url = param.toString();
131         }
132
133         // audio volatility
134         try {
135             param = newConfig.get(PARAM_AUDIOVOLATITLITY);
136             if (param != null) {
137                 config.audioVolatility = Double.parseDouble(param.toString());
138             }
139         } catch (NumberFormatException e) {
140             logger.warn("Cannot parse audioVolatility parameter. Using default");
141         }
142
143         // phoneme volatility
144         try {
145             param = newConfig.get(PARAM_PHONEMEVOLATITLITY);
146             if (param != null) {
147                 config.phonemeVolatility = Double.parseDouble(param.toString());
148             }
149         } catch (NumberFormatException e) {
150             logger.warn("Cannot parse phonemeVolatility parameter. Using default");
151         }
152
153         // speakingRate
154         try {
155             param = newConfig.get(PARAM_SPEAKINGRATE);
156             if (param != null) {
157                 config.speakingRate = Double.parseDouble(param.toString());
158             }
159         } catch (NumberFormatException e) {
160             logger.warn("Cannot parse speakingRate parameter. Using default");
161         }
162
163         refreshVoices();
164     }
165
166     @Override
167     public String getId() {
168         return SERVICE_ID;
169     }
170
171     @Override
172     public String getLabel(@Nullable Locale locale) {
173         return SERVICE_NAME;
174     }
175
176     @Override
177     public Set<Voice> getAvailableVoices() {
178         return availableVoices;
179     }
180
181     public void refreshVoices() {
182         String url = config.url + LIST_VOICES_URL;
183         availableVoices.clear();
184         try {
185             String responseVoices = HttpRequestBuilder.getFrom(url).getContentAsString();
186             VoiceDto[] mimicVoiceResponse = gson.fromJson(responseVoices, VoiceDto[].class);
187             if (mimicVoiceResponse == null) {
188                 logger.warn("Cannot get mimic voices from the URL {}", url);
189                 return;
190             } else if (mimicVoiceResponse.length == 0) {
191                 logger.debug("Voice set response from Mimic is empty ?!");
192                 return;
193             }
194             for (VoiceDto voiceDto : mimicVoiceResponse) {
195                 List<String> speakers = voiceDto.speakers;
196                 if (speakers != null && !speakers.isEmpty()) {
197                     for (String speaker : speakers) {
198                         availableVoices.add(new MimicVoice(voiceDto.key, voiceDto.language, voiceDto.name, speaker));
199                     }
200                 } else {
201                     availableVoices.add(new MimicVoice(voiceDto.key, voiceDto.language, voiceDto.name, null));
202                 }
203             }
204         } catch (IOException | JsonSyntaxException e) {
205             logger.warn("Cannot get mimic voices from the URL {}, error {}", url, e.getMessage());
206         }
207     }
208
209     @Override
210     public Set<AudioFormat> getSupportedFormats() {
211         return Set.<AudioFormat> of(AUDIO_FORMAT);
212     }
213
214     /**
215      * Checks parameters and calls the API to synthesize voice.
216      *
217      * @param text Input text.
218      * @param voice Selected voice.
219      * @param requestedFormat Format that is supported by the target sink as well.
220      * @return Output audio stream
221      * @throws TTSException in case the service is unavailable or a parameter is invalid.
222      */
223     @Override
224     public AudioStream synthesizeForCache(String text, Voice voice, AudioFormat requestedFormat) throws TTSException {
225         if (!availableVoices.contains(voice)) {
226             // let a chance for the service to update :
227             refreshVoices();
228             if (!availableVoices.contains(voice)) {
229                 throw new TTSException("Voice " + voice.getUID() + " not available for MimicTTS");
230             }
231         }
232
233         logger.debug("Synthesize '{}' for voice '{}' in format {}", text, voice.getUID(), requestedFormat);
234         // Validate arguments
235         // trim text
236         String trimmedText = text.trim();
237         if (trimmedText.isEmpty()) {
238             throw new TTSException("The passed text is empty");
239         }
240         if (!AUDIO_FORMAT.isCompatible(requestedFormat)) {
241             throw new TTSException("The passed AudioFormat is unsupported");
242         }
243
244         String encodedVoice;
245         try {
246             encodedVoice = URLEncoder.encode(((MimicVoice) voice).getTechnicalName(),
247                     StandardCharsets.UTF_8.toString());
248         } catch (UnsupportedEncodingException e) {
249             throw new IllegalArgumentException("Cannot encode voice in URL " + ((MimicVoice) voice).getTechnicalName());
250         }
251
252         // create the url for given locale, format
253         String urlTTS = config.url + SYNTHETIZE_URL + "?voice=" + encodedVoice + "&noiseScale=" + config.audioVolatility
254                 + "&noiseW=" + config.phonemeVolatility + "&lengthScale=" + config.speakingRate + "&audioTarget=client";
255         logger.debug("Querying mimic with URL {}", urlTTS);
256
257         // prepare the response as an inputstream
258         InputStreamResponseListener inputStreamResponseListener = new InputStreamResponseListener();
259         // we will use a POST method for the text
260         StringContentProvider textContentProvider = new StringContentProvider(text, StandardCharsets.UTF_8);
261         if (text.startsWith("<speak>")) {
262             httpClient.POST(urlTTS).header("Content-Type", "application/ssml+xml").content(textContentProvider)
263                     .accept("audio/wav").send(inputStreamResponseListener);
264         } else {
265             httpClient.POST(urlTTS).content(textContentProvider).accept("audio/wav").send(inputStreamResponseListener);
266         }
267
268         // compute the estimated timeout using a "stupid" method based on text length, as the response time depends on
269         // the requested text. Average speaker speed estimated to 10/second.
270         // Will use a safe margin multiplicator (x5) to accept very slow mimic server
271         // So the constant chosen is 5 * 10 = /2
272         int timeout = text.length() / 2;
273
274         // check response status and return AudioStream
275         Response response;
276         try {
277             response = inputStreamResponseListener.get(timeout, TimeUnit.SECONDS);
278             if (response.getStatus() == HttpStatus.OK_200) {
279                 String lengthHeader = response.getHeaders().get(HttpHeader.CONTENT_LENGTH);
280                 long length;
281                 try {
282                     length = Long.parseLong(lengthHeader);
283                 } catch (NumberFormatException e) {
284                     throw new TTSException(
285                             "Cannot get Content-Length header from mimic response. Are you sure to query a mimic TTS server at "
286                                     + urlTTS + " ?");
287                 }
288
289                 InputStream inputStreamFromMimic = inputStreamResponseListener.getInputStream();
290                 return new InputStreamAudioStream(inputStreamFromMimic, AUDIO_FORMAT, length);
291             } else {
292                 String errorMessage = "Cannot get wav from mimic url " + urlTTS + " with HTTP response code "
293                         + response.getStatus() + " for reason " + response.getReason();
294                 TTSException ttsException = new TTSException(errorMessage);
295                 response.abort(ttsException);
296                 throw ttsException;
297             }
298         } catch (InterruptedException | TimeoutException | ExecutionException e) {
299             String errorMessage = "Cannot get wav from mimic url " + urlTTS;
300             throw new TTSException(errorMessage, e);
301         }
302     }
303
304     @Override
305     public String getCacheKey(String text, Voice voice, AudioFormat requestedFormat) {
306         MessageDigest md;
307         try {
308             md = MessageDigest.getInstance("MD5");
309         } catch (NoSuchAlgorithmException e) {
310             return "nomd5algorithm";
311         }
312         byte[] binaryKey = ((text + voice.getUID() + requestedFormat.toString() + config.speakingRate
313                 + config.audioVolatility + config.phonemeVolatility).getBytes());
314         return String.format("%032x", new BigInteger(1, md.digest(binaryKey)));
315     }
316 }