1   package org.daisy.pipeline.tts;
2   
3   import java.io.StringReader;
4   import java.util.ArrayList;
5   import java.util.Collection;
6   import java.util.Collections;
7   import java.util.HashMap;
8   import java.util.List;
9   import java.util.Map;
10  import java.util.concurrent.ConcurrentHashMap;
11  
12  import javax.xml.transform.URIResolver;
13  import javax.xml.transform.sax.SAXSource;
14  
15  import net.sf.saxon.s9api.DocumentBuilder;
16  import net.sf.saxon.s9api.Processor;
17  import net.sf.saxon.s9api.SaxonApiException;
18  import net.sf.saxon.s9api.XdmNode;
19  
20  import org.daisy.common.properties.Properties;
21  import org.daisy.common.properties.Properties.Property;
22  import org.daisy.pipeline.tts.TTSEngine.SynthesisResult;
23  import org.daisy.pipeline.tts.TTSLog.ErrorCode;
24  import org.daisy.pipeline.tts.TTSService;
25  import org.daisy.pipeline.tts.TTSService.ServiceDisabledException;
26  import org.daisy.pipeline.tts.TTSService.SynthesisException;
27  import org.daisy.pipeline.tts.TTSTimeout;
28  import org.daisy.pipeline.tts.TTSTimeout.ThreadFreeInterrupter;
29  import org.daisy.pipeline.tts.Voice.MarkSupport;
30  
31  import org.slf4j.Logger;
32  import org.slf4j.LoggerFactory;
33  
34  import org.osgi.service.component.annotations.Component;
35  import org.osgi.service.component.annotations.Reference;
36  import org.osgi.service.component.annotations.ReferenceCardinality;
37  import org.osgi.service.component.annotations.ReferencePolicy;
38  
39  import org.xml.sax.InputSource;
40  
41  @Component(
42  	name = "tts-registry",
43  	service = { TTSRegistry.class }
44  )
45  public class TTSRegistry {
46  
47  	public static class TTSResource {
48  		public boolean invalid = false;
49  	}
50  
51  	private static Logger logger = LoggerFactory.getLogger(TTSRegistry.class);
52  	// for parsing test SSML
53  	private static DocumentBuilder xmlParser = new Processor(false).newDocumentBuilder();
54  	private Map<String,TTSServiceWrapper> ttsServices = new ConcurrentHashMap<>();
55  	//Services and resources used by the current running steps (some of them may not be active anymore):
56  	private Map<String,List<TTSResource>> ttsResources = new ConcurrentHashMap<>();
57  	private static XdmNode testSSMLWithMark = null;
58  	private static XdmNode testSSMLWithoutMark = null;
59  
60  	/**
61  	 * Service component callback
62  	 */
63  	@Reference(
64  		name = "TTSService",
65  		unbind = "-",
66  		service = TTSService.class,
67  		cardinality = ReferenceCardinality.MULTIPLE,
68  		policy = ReferencePolicy.STATIC
69  	)
70  	public void addTTS(TTSService tts) {
71  		String name = tts.getName();
72  		logger.info("Adding TTSService " + name);
73  		// wrap in a TTSService that tests the allocated TTSEngine before making it available
74  		ttsServices.put(name, new TTSServiceWrapper(tts));
75  		ttsResources.put(name, new ArrayList<TTSResource>());
76  	}
77  
78  	/**
79  	 * List all available TTS services, including the disabled ones (in which case {@link
80  	 * TTSService#newEngine} will throw a {@link TTSService.ServiceDisabledException}),
81  	 * and the services that may not allocate a working {@link TTSEngine} for other
82  	 * reasons (because of missing configuration, because the list of available voices
83  	 * can not be retrieved, because the engine failed a test, etc.).
84  	 */
85  	public Collection<TTSService> getServices() {
86  		return Collections.unmodifiableCollection(ttsServices.values());
87  	}
88  
89  	/**
90  	 * Allocate a list of working engines.
91  	 *
92  	 * @param properties Key-value pairs for the allocation of engines. See {@link TTSService#newEngine}.
93  	 * @param ttsLog     For logging engine allocations errors. May be null.
94  	 * @param log        For logging the engine status summary. May be null.
95  	 */
96  	public Collection<TTSEngine> getWorkingEngines(Map<String,String> properties,
97  	                                               TTSLog ttsLog,
98  	                                               Logger log) {
99  		List<TTSEngine> workingEngines = new ArrayList<>();
100 		List<String> workingEngineNames = log != null ? new ArrayList<>() : null;
101 		List<String> disabledEngines = log != null ? new ArrayList<>() : null;
102 		List<String> enginesWithError = log != null ? new ArrayList<>() : null;
103 		for (TTSServiceWrapper service : ttsServices.values()) {
104 			try {
105 				workingEngines.add(service.newEngine(properties, ttsLog));
106 				if (log != null)
107 					workingEngineNames.add(service.getName());
108 			} catch (ServiceDisabledException e) {
109 				if (log != null) {
110 					log.debug(service.getName() + " is disabled", e);
111 					disabledEngines.add(service.getName());
112 				}
113 			} catch (Throwable e) {
114 				// Show the full error with stack trace only in the main and TTS log. A short version is included
115 				// in the engine status summary. An engine that could not be activated is not an error
116 				// unless no engines could be activated at all. This is to not confuse users because it
117 				// is normal that only a part of the engines work.
118 				String msg = service.getName() + " could not be activated: " + e.getMessage();
119 				if (ttsLog != null)
120 					ttsLog.addGeneralError(ErrorCode.WARNING, msg, e);
121 				else if (log != null) {
122 					log.warn(msg + " (Please see detailed log for more info.)");
123 					log.debug("Error stack trace:", e);
124 				}
125 				if (log != null)
126 					enginesWithError.add(service.getName());
127 			}
128 		}
129 		if (log != null) {
130 			if (workingEngines.size() == 0) {
131 				log.error("No available TTS engines");
132 				if (!enginesWithError.isEmpty())
133 					log.error("Some engines could not be activated: " + String.join(", ", enginesWithError));
134 			} else {
135 				log.info("Available TTS engines: " + String.join(", ", workingEngineNames));
136 				if (!enginesWithError.isEmpty())
137 					log.warn("Some engines could not be activated: " + String.join(", ", enginesWithError));
138 			}
139 			if (!disabledEngines.isEmpty())
140 				log.info("Some engines are disabled: " + String.join(", ", disabledEngines));
141 		}
142 		return workingEngines;
143 	}
144 
145 	public TTSResource allocateResourceFor(TTSEngine tts) throws SynthesisException,
146 	        InterruptedException {
147 		List<TTSResource> resources = null;
148 		synchronized (ttsResources) {
149 			resources = ttsResources.get(tts.getProvider().getName());
150 		}
151 		if (resources == null)
152 			return null;
153 		TTSResource r = tts.allocateThreadResources();
154 		if (r == null)
155 			r = new TTSResource();
156 		resources.add(r);
157 		return r;
158 	}
159 
160 	/**
161 	 * TTSService wrapper that tests the allocated TTSEngine before making it available.
162 	 */
163 	private static class TTSServiceWrapper implements TTSService {
164 
165 		private final TTSService service;
166 		private final String name;
167 		private final Property enabled;
168 
169 		TTSServiceWrapper(TTSService wrap) {
170 			this.service = wrap;
171 			this.name = wrap.getName();
172 			this.enabled = Properties.getProperty("org.daisy.pipeline.tts." + name + ".enabled",
173 		                                          true,
174 		                                          "Enable " + wrap.getDisplayName(),
175 		                                          false,
176 		                                          "true");
177 		}
178 
179 		@Override
180 		public String getName() {
181 			return name;
182 		}
183 
184 		@Override
185 		public String getDisplayName() {
186 			return service.getDisplayName();
187 		}
188 
189 		@Override
190 		public TTSEngine newEngine(Map<String,String> params) throws Throwable {
191 			return newEngine(params, null);
192 		}
193 
194 		TTSEngine newEngine(Map<String,String> params, TTSLog ttsLog) throws Throwable {
195 			String str = enabled.getValue(params);
196 			if (str != null && "false".equals(str.toLowerCase()) || "0".equals(str))
197 				throw new ServiceDisabledException(
198 				    "In order to enable the " + name + " service, set property '" + enabled.getName() + "' to 'true'");
199 			/*
200 			 * Set timeouts on all the {@link TTSService} and {@link TTSEngine} API calls.
201 			 */
202 			TTSTimeout timeout = new TTSTimeout();
203 			TTSEngine engine; {
204 				timeout.enableForCurrentThread(2);
205 				try {
206 					engine = service.newEngine(params);
207 				} finally {
208 					timeout.disable();
209 				}
210 			}
211 			/*
212 			 * Test the engine using a piece of SSML. Useless attributes and
213 			 * namespaces are inserted on purpose. A <mark> is included for
214 			 * engines that support marks. It is inserted somewhere in the middle
215 			 * of the string because the SAPI adapter ignores marks that appear
216 			 * at the end.
217 			 */
218 			if (engine.handlesMarks()) {
219 				if (testSSMLWithMark == null) {
220 					String ssml = "<s:speak version=\"1.0\" xmlns:s=\"http://www.w3.org/2001/10/synthesis\">"
221 						+ "<s:s xmlns:tmp=\"http://\" id=\"s1\"><s:token>small</s:token>"
222 						+ "<s:mark name=\"mark\"></s:mark> sentence</s:s></s:speak>";
223 					try {
224 						testSSMLWithMark = xmlParser.build(new SAXSource(new InputSource(new StringReader(ssml))));
225 					} catch (SaxonApiException e) {
226 						throw new RuntimeException(e); // should not happen
227 					}
228 				}
229 			} else {
230 				if (testSSMLWithoutMark == null) {
231 					String ssml = "<s:speak version=\"1.0\" xmlns:s=\"http://www.w3.org/2001/10/synthesis\">"
232 						+ "<s:s xmlns:tmp=\"http://\" id=\"s1\"><s:token>small</s:token> sentence</s:s></s:speak>";
233 					try {
234 						testSSMLWithoutMark = xmlParser.build(new SAXSource(new InputSource(new StringReader(ssml))));
235 					} catch (SaxonApiException e) {
236 						throw new RuntimeException(e); // should not happen
237 					}
238 				}
239 			}
240 			/* Get a voice for the test*/
241 			Voice firstVoice; {
242 				int timeoutSecs = 30;
243 				timeout.enableForCurrentThread(timeoutSecs);
244 				try {
245 					firstVoice = null;
246 					for (Voice v : engine.getAvailableVoices()) {
247 						if (!engine.handlesMarks() || v.getMarkSupport() != MarkSupport.MARK_NOT_SUPPORTED) {
248 							firstVoice = v;
249 							break;
250 						}
251 					}
252 					if (firstVoice == null) {
253 						throw new Exception("No voices available");
254 					}
255 				} catch (InterruptedException e) {
256 					throw new Exception("Timeout while retrieving voices (exceeded " + timeoutSecs + " seconds)");
257 				} catch (Exception e) {
258 					// No need to include something like "Failed to retreive voices", because if
259 					// there is something wrong with the connection with the engine, failing to
260 					// retreive the voices is the first thing that will go wrong because that is the
261 					// first thing that happens.
262 					throw e;
263 				} finally {
264 					timeout.disable();
265 				}
266 			}
267 			TTSResource resource; {
268 				timeout.enableForCurrentThread(2);
269 				try {
270 					resource = engine.allocateThreadResources();
271 				} catch (Exception e) {
272 					throw new Exception("Could not allocate resources: " + e.getMessage(), e);
273 				} finally {
274 					timeout.disable();
275 				}
276 			}
277 			// synthesize
278 			SynthesisResult result; {
279 				try {
280 					XdmNode ssml = engine.handlesMarks() ? testSSMLWithMark : testSSMLWithoutMark;
281 					// create a custom interrupter in case the engine hangs
282 					TTSTimeout.ThreadFreeInterrupter interrupter = new ThreadFreeInterrupter() {
283 							@Override
284 							public void threadFreeInterrupt() {
285 								if (ttsLog != null)
286 									ttsLog.addGeneralError(
287 										ErrorCode.WARNING,
288 										"Timeout while initializing " + service.getName()
289 										+ ". Forcing interruption of the current work of " + service.getName() + "...");
290 								engine.interruptCurrentWork(resource);
291 							}
292 						};
293 					result = new TimedTTSExecutor().synthesizeWithTimeout(
294 					    timeout, interrupter, null, ssml, Sentence.computeSize(ssml),
295 						engine, firstVoice, resource);
296 				} catch (Exception e) {
297 					// No need to include something like "Test failed". Assume that the error has
298 					// enough information to know that it happened during synthesis.
299 					throw e;
300 				} finally {
301 					if (resource != null)
302 						timeout.enableForCurrentThread(2);
303 					try {
304 						engine.releaseThreadResources(resource);
305 					} catch (Exception e) {
306 						if (ttsLog != null)
307 							ttsLog.addGeneralError(
308 								ErrorCode.WARNING,
309 								"Error while releasing resource of " + service.getName() + ": " + e.getMessage(),
310 								e);
311 					} finally {
312 						timeout.disable();
313 					}
314 				}
315 			}
316 			timeout.close();
317 			// check that the output buffer is big enough
318 			String msg = "";
319 			if (result.audio.getFrameLength() * result.audio.getFormat().getFrameSize() < 2500) {
320 				msg = "Audio output is not big enough. ";
321 			}
322 			if (engine.handlesMarks()) {
323 				// check that the result contains a single mark
324 				String details = " voice: "+ firstVoice;
325 				if (result.marks.size() != 1) {
326 					msg += "One bookmark event expected, but received " + result.marks.size() + " events instead. " + details;
327 				} else {
328 					int offset = result.marks.get(0);
329 					if (offset < 2500) {
330 						msg += "Expecting mark offset to be bigger, got " + offset + " as offset. "+details;
331 					}
332 				}
333 			}
334 			if (!msg.isEmpty()) {
335 				throw new Exception("Test failed: " + msg);
336 			}
337 			return engine;
338 		}
339 	}
340 }