]> git.basschouten.com Git - openhab-addons.git/blob
40bb18b5bdf8457596dbbe8959edbfae11012784
[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.voicerss.internal;
14
15 import java.io.File;
16 import java.io.IOException;
17 import java.util.Collections;
18 import java.util.HashSet;
19 import java.util.Locale;
20 import java.util.Map;
21 import java.util.Set;
22
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.core.OpenHAB;
26 import org.openhab.core.audio.AudioException;
27 import org.openhab.core.audio.AudioFormat;
28 import org.openhab.core.audio.AudioStream;
29 import org.openhab.core.config.core.ConfigurableService;
30 import org.openhab.core.voice.TTSException;
31 import org.openhab.core.voice.TTSService;
32 import org.openhab.core.voice.Voice;
33 import org.openhab.voice.voicerss.internal.cloudapi.CachedVoiceRSSCloudImpl;
34 import org.osgi.framework.Constants;
35 import org.osgi.service.component.annotations.Activate;
36 import org.osgi.service.component.annotations.Component;
37 import org.osgi.service.component.annotations.Modified;
38 import org.slf4j.Logger;
39 import org.slf4j.LoggerFactory;
40
41 /**
42  * This is a TTS service implementation for using VoiceRSS TTS service.
43  *
44  * @author Jochen Hiller - Initial contribution and API
45  * @author Laurent Garnier - add support for OGG and AAC audio formats
46  */
47 @NonNullByDefault
48 @Component(configurationPid = "org.openhab.voicerss", property = Constants.SERVICE_PID + "=org.openhab.voicerss")
49 @ConfigurableService(category = "voice", label = "VoiceRSS Text-to-Speech", description_uri = "voice:voicerss")
50 public class VoiceRSSTTSService implements TTSService {
51
52     /** Cache folder name is below userdata/voicerss/cache. */
53     private static final String CACHE_FOLDER_NAME = "voicerss" + File.separator + "cache";
54
55     // API Key comes from ConfigAdmin
56     private static final String CONFIG_API_KEY = "apiKey";
57
58     /**
59      * Map from openHAB AudioFormat Codec to VoiceRSS API Audio Codec
60      */
61     private static final Map<String, String> CODEC_MAP = Map.of(AudioFormat.CODEC_PCM_SIGNED, "WAV",
62             AudioFormat.CODEC_PCM_UNSIGNED, "WAV", AudioFormat.CODEC_PCM_ALAW, "WAV", AudioFormat.CODEC_PCM_ULAW, "WAV",
63             AudioFormat.CODEC_MP3, "MP3", AudioFormat.CODEC_VORBIS, "OGG", AudioFormat.CODEC_AAC, "AAC");
64
65     /**
66      * Map from openHAB AudioFormat Frequency to VoiceRSS API Audio Frequency
67      */
68     private static final Map<Long, String> FREQUENCY_MAP = Map.of(8_000L, "8khz", 11_025L, "11khz", 12_000L, "12khz",
69             16_000L, "16khz", 22_050L, "22khz", 24_000L, "24khz", 32_000L, "32khz", 44_100L, "44khz", 48_000L, "48khz");
70
71     private final Logger logger = LoggerFactory.getLogger(VoiceRSSTTSService.class);
72
73     private @Nullable String apiKey;
74
75     /**
76      * We need the cached implementation to allow for FixedLengthAudioStream.
77      */
78     private @Nullable CachedVoiceRSSCloudImpl voiceRssImpl;
79
80     /**
81      * Set of supported voices
82      */
83     private @Nullable Set<Voice> voices;
84
85     /**
86      * Set of supported audio formats
87      */
88     private @Nullable Set<AudioFormat> audioFormats;
89
90     /**
91      * DS activate, with access to ConfigAdmin
92      */
93     @Activate
94     protected void activate(@Nullable Map<String, Object> config) {
95         try {
96             modified(config);
97             voiceRssImpl = initVoiceImplementation();
98             voices = initVoices();
99             audioFormats = initAudioFormats();
100
101             logger.debug("Using VoiceRSS cache folder {}", getCacheFolderName());
102         } catch (IllegalStateException e) {
103             logger.warn("Failed to activate VoiceRSS: {}", e.getMessage(), e);
104         }
105     }
106
107     @Modified
108     protected void modified(@Nullable Map<String, Object> config) {
109         if (config != null) {
110             apiKey = config.containsKey(CONFIG_API_KEY) ? config.get(CONFIG_API_KEY).toString() : null;
111         }
112     }
113
114     @Override
115     public Set<Voice> getAvailableVoices() {
116         Set<Voice> localVoices = voices;
117         return localVoices == null ? Set.of() : Collections.unmodifiableSet(localVoices);
118     }
119
120     @Override
121     public Set<AudioFormat> getSupportedFormats() {
122         Set<AudioFormat> localFormats = audioFormats;
123         return localFormats == null ? Set.of() : Collections.unmodifiableSet(localFormats);
124     }
125
126     @Override
127     public AudioStream synthesize(String text, Voice voice, AudioFormat requestedFormat) throws TTSException {
128         logger.debug("Synthesize '{}' for voice '{}' in format {}", text, voice.getUID(), requestedFormat);
129         CachedVoiceRSSCloudImpl voiceRssCloud = voiceRssImpl;
130         if (voiceRssCloud == null) {
131             throw new TTSException("The service is not correctly initialized");
132         }
133         // Validate known api key
134         String key = apiKey;
135         if (key == null) {
136             throw new TTSException("Missing API key, configure it first before using");
137         }
138         // trim text
139         String trimmedText = text.trim();
140         if (trimmedText.isEmpty()) {
141             throw new TTSException("The passed text is empty");
142         }
143         Set<Voice> localVoices = voices;
144         if (localVoices == null || !localVoices.contains(voice)) {
145             throw new TTSException("The passed voice is unsupported");
146         }
147
148         // now create the input stream for given text, locale, voice, codec and format.
149         try {
150             File cacheAudioFile = voiceRssCloud.getTextToSpeechAsFile(key, trimmedText,
151                     voice.getLocale().toLanguageTag(), voice.getLabel(), getApiAudioCodec(requestedFormat),
152                     getApiAudioFormat(requestedFormat));
153             return new VoiceRSSAudioStream(cacheAudioFile, requestedFormat);
154         } catch (AudioException ex) {
155             throw new TTSException("Could not create AudioStream: " + ex.getMessage(), ex);
156         } catch (IOException ex) {
157             throw new TTSException("Could not read from VoiceRSS service: " + ex.getMessage(), ex);
158         }
159     }
160
161     /**
162      * Initializes voices.
163      *
164      * @return The voices of this instance
165      * @throws IllegalStateException if voiceRssImpl is null
166      */
167     private Set<Voice> initVoices() throws IllegalStateException {
168         CachedVoiceRSSCloudImpl voiceRssCloud = voiceRssImpl;
169         if (voiceRssCloud == null) {
170             throw new IllegalStateException("The service is not correctly initialized");
171         }
172         Set<Voice> voices = new HashSet<>();
173         for (Locale locale : voiceRssCloud.getAvailableLocales()) {
174             for (String voiceLabel : voiceRssCloud.getAvailableVoices(locale)) {
175                 voices.add(new VoiceRSSVoice(locale, voiceLabel));
176             }
177         }
178         return voices;
179     }
180
181     /**
182      * Initializes audioFormats
183      *
184      * @return The audio formats of this instance
185      * @throws IllegalStateException if voiceRssImpl is null
186      */
187     private Set<AudioFormat> initAudioFormats() throws IllegalStateException {
188         CachedVoiceRSSCloudImpl voiceRssCloud = voiceRssImpl;
189         if (voiceRssCloud == null) {
190             throw new IllegalStateException("The service is not correctly initialized");
191         }
192         Set<AudioFormat> audioFormats = new HashSet<>();
193         for (String codec : voiceRssCloud.getAvailableAudioCodecs()) {
194             switch (codec) {
195                 case "MP3":
196                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_MP3, null, 16, 64000,
197                             44_100L));
198                     break;
199                 case "OGG":
200                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_OGG, AudioFormat.CODEC_VORBIS, null, 16,
201                             null, 44_100L));
202                     break;
203                 case "AAC":
204                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_NONE, AudioFormat.CODEC_AAC, null, 16, null,
205                             44_100L));
206                     break;
207                 case "WAV":
208                     // Consider only mono formats
209                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
210                             8, 64_000, 8_000L));
211                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
212                             16, 128_000, 8_000L));
213                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
214                             8, 88_200, 11_025L));
215                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
216                             16, 176_400, 11_025L));
217                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
218                             8, 96_000, 12_000L));
219                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
220                             16, 192_000, 12_000L));
221                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
222                             8, 128_000, 16_000L));
223                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
224                             16, 256_000, 16_000L));
225                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
226                             8, 176_400, 22_050L));
227                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
228                             16, 352_800, 22_050L));
229                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
230                             8, 192_000, 24_000L));
231                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
232                             16, 384_000, 24_000L));
233                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
234                             8, 256_000, 32_000L));
235                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
236                             16, 512_000, 32_000L));
237                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
238                             8, 352_800, 44_100L));
239                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
240                             16, 705_600, 44_100L));
241                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_UNSIGNED, false,
242                             8, 384_000, 48_000L));
243                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_SIGNED, false,
244                             16, 768_000, 48_000L));
245                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ALAW, null, 8,
246                             64_000, 8_000L));
247                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ALAW, null, 8,
248                             88_200, 11_025L));
249                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ALAW, null, 8,
250                             176_400, 22_050L));
251                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ALAW, null, 8,
252                             352_800, 44_100L));
253                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ULAW, null, 8,
254                             64_000, 8_000L));
255                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ULAW, null, 8,
256                             88_200, 11_025L));
257                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ULAW, null, 8,
258                             176_400, 22_050L));
259                     audioFormats.add(new AudioFormat(AudioFormat.CONTAINER_WAVE, AudioFormat.CODEC_PCM_ULAW, null, 8,
260                             352_800, 44_100L));
261                     break;
262                 default:
263                     logger.debug("Audio codec {} not yet supported", codec);
264                     break;
265             }
266         }
267         return audioFormats;
268     }
269
270     /**
271      * Map {@link AudioFormat#getCodec() codec} to VoiceRSS API codec.
272      *
273      * @throws TTSException if {@code format} is not supported
274      */
275     private String getApiAudioCodec(AudioFormat format) throws TTSException {
276         final String internalCodec = format.getCodec();
277         final String apiCodec = CODEC_MAP.get(internalCodec != null ? internalCodec : AudioFormat.CODEC_PCM_SIGNED);
278
279         if (apiCodec == null) {
280             throw new TTSException("Unsupported audio format: " + format);
281         }
282
283         return apiCodec;
284     }
285
286     /**
287      * Map {@link AudioFormat#getBitDepth() bit depth} and {@link AudioFormat#getFrequency() frequency} to VoiceRSS API
288      * format.
289      *
290      * @throws TTSException if {@code format} is not supported
291      */
292     private String getApiAudioFormat(AudioFormat format) throws TTSException {
293         final Integer formatBitDepth = format.getBitDepth();
294         final int bitDepth = formatBitDepth != null ? formatBitDepth.intValue() : 16;
295         final Long formatFrequency = format.getFrequency();
296         final Long frequency = formatFrequency != null ? formatFrequency.longValue() : 44_100L;
297         final String apiFrequency = FREQUENCY_MAP.get(frequency);
298
299         if (apiFrequency == null || (bitDepth != 8 && bitDepth != 16)) {
300             throw new TTSException("Unsupported audio format: " + format);
301         }
302
303         String codec = format.getCodec();
304         switch (codec != null ? codec : AudioFormat.CODEC_PCM_SIGNED) {
305             case AudioFormat.CODEC_PCM_ALAW:
306                 return "alaw_" + apiFrequency + "_mono";
307             case AudioFormat.CODEC_PCM_ULAW:
308                 return "ulaw_" + apiFrequency + "_mono";
309             case AudioFormat.CODEC_PCM_SIGNED:
310             case AudioFormat.CODEC_PCM_UNSIGNED:
311             case AudioFormat.CODEC_MP3:
312             case AudioFormat.CODEC_VORBIS:
313             case AudioFormat.CODEC_AAC:
314                 return apiFrequency + "_" + bitDepth + "bit_mono";
315             default:
316                 throw new TTSException("Unsupported audio format: " + format);
317         }
318     }
319
320     private CachedVoiceRSSCloudImpl initVoiceImplementation() throws IllegalStateException {
321         return new CachedVoiceRSSCloudImpl(getCacheFolderName(), true);
322     }
323
324     private String getCacheFolderName() {
325         // we assume that this folder does NOT have a trailing separator
326         return OpenHAB.getUserDataFolder() + File.separator + CACHE_FOLDER_NAME;
327     }
328
329     @Override
330     public String getId() {
331         return "voicerss";
332     }
333
334     @Override
335     public String getLabel(@Nullable Locale locale) {
336         return "VoiceRSS";
337     }
338 }