1   package org.daisy.pipeline.tts.calabash.impl;
2   
3   import java.io.File;
4   import java.math.BigDecimal;
5   import java.math.MathContext;
6   import java.util.ArrayList;
7   import java.util.Collection;
8   import java.util.Collections;
9   import java.util.concurrent.BlockingQueue;
10  import java.util.concurrent.ConcurrentLinkedQueue;
11  import java.util.concurrent.PriorityBlockingQueue;
12  import java.util.HashMap;
13  import java.util.IllformedLocaleException;
14  import java.util.List;
15  import java.util.Locale;
16  import java.util.Map;
17  import java.util.Optional;
18  
19  import javax.sound.sampled.AudioFileFormat;
20  
21  import net.sf.saxon.s9api.Axis;
22  import net.sf.saxon.s9api.Processor;
23  import net.sf.saxon.s9api.XdmNode;
24  import net.sf.saxon.s9api.XdmSequenceIterator;
25  
26  import org.daisy.common.messaging.MessageAppender;
27  import org.daisy.common.properties.Properties;
28  import org.daisy.common.properties.Properties.Property;
29  import org.daisy.pipeline.audio.AudioServices;
30  import org.daisy.pipeline.css.speech.VoiceFamilyList;
31  import org.daisy.pipeline.tts.AudioFootprintMonitor;
32  import org.daisy.pipeline.tts.calabash.impl.EncodingThread.EncodingException;
33  import org.daisy.pipeline.tts.config.VoiceConfigExtension;
34  import org.daisy.pipeline.tts.DefaultSSMLMarkSplitter;
35  import org.daisy.pipeline.tts.SSMLMarkSplitter;
36  import org.daisy.pipeline.tts.Sentence;
37  import org.daisy.pipeline.tts.TTSEngine;
38  import org.daisy.pipeline.tts.TTSLog;
39  import org.daisy.pipeline.tts.TTSLog.ErrorCode;
40  import org.daisy.pipeline.tts.TTSRegistry;
41  import org.daisy.pipeline.tts.TTSService.SynthesisException;
42  import org.daisy.pipeline.tts.TimedTTSExecutor;
43  import org.daisy.pipeline.tts.Voice;
44  import org.daisy.pipeline.tts.VoiceInfo.Gender;
45  import org.daisy.pipeline.tts.VoiceManager;
46  
47  import org.slf4j.Logger;
48  import org.slf4j.LoggerFactory;
49  
50  import com.google.common.collect.Iterables;
51  
52  /**
53   * SSMLtoAudio splits the input SSML sentences into sections called
54   * ContiguousText. Every section contains a list of contiguous sentences that
55   * may be eventually stored into different audio files, but it is guaranteed
56   * that such audio files won't contain sentences of other sections in such a
57   * manner that the list of audio files will mirror the document order. Within
58   * a section, every sentence has the same single sample rate to prevent us from
59   * resampling the audio data before sending them to encoders.
60   * 
61   * Once all the sentences have been assigned to TTS voices, they are sorted by
62   * size and stored in a shared queue of ContiguousText (actually, only the
63   * smallest sentences are sorted that way). SSMLtoAudio creates threads to
64   * consume this queue.
65   * 
66   * The TextToPcmThreads send PCM data to EncodingThreads via a queue of
67   * ContiguousPCM. These PCM packets are then processed from the longest (with
68   * respect to the number of samples) to the shortest in order to make it likely
69   * that the threads will finish at the same time. When all the TextToPcmThreads
70   * are joined, the pipeline pushes an EndOfQueue marker to every EncodingThreads
71   * to notify that they must stop waiting for more PCM packets than the ones
72   * already pushed.
73   * 
74   * The queue of PCM chunks, along with the queues of other TTS steps, share a
75   * global max size so as to make sure that they won't grow too much if the
76   * encoding is slower than the synthesizing (see AudioFootprintMonitor).
77   * 
78   * If an EncodingThread fails to encode samples, it won't set the URI attribute
79   * of the audio chunks. In that way, SSMLtoAudio is informed not to include the
80   * corresponding text into the list of audio clips.
81   * 
82   */
83  public class SSMLtoAudio implements FormatSpecifications {
84  
85  	private static final Property THREADS_NUMBER = Properties.getProperty("org.daisy.pipeline.tts.threads.number",
86  	                                                                      false,
87  	                                                                      "Number of threads for speech synthesis and audio encoding",
88  	                                                                      false,
89  	                                                                      ""+Runtime.getRuntime().availableProcessors());
90  	private static final Property ENCODING_THREADS_NUMBER = Properties.getProperty("org.daisy.pipeline.tts.threads.encoding.number",
91  	                                                                               false,
92  	                                                                               "Number of threads for audio encoding",
93  	                                                                               false,
94  	                                                                               null);
95  	private static final Property SPEAKING_THREADS_NUMBER = Properties.getProperty("org.daisy.pipeline.tts.threads.speaking.number",
96  	                                                                               false,
97  	                                                                               "Number of threads for speech synthesis",
98  	                                                                               false,
99  	                                                                               null);
100 	private static final Property THREADS_MEMLIMIT = Properties.getProperty("org.daisy.pipeline.tts.threads.each.memlimit",
101 	                                                                        false,
102 	                                                                        "Maximum amount of memory consumed by each speech synthesis thread (in Mb)",
103 	                                                                        false,
104 	                                                                        "20"); // 20 Mb
105 
106 	private TTSEngine mLastTTS; //used if no TTS is found for the current sentence
107 	private TTSRegistry mTTSRegistry;
108 	private Logger mLogger;
109 	private ContiguousText mCurrentSection;
110 	private File mAudioDir; //where all the sound files will be stored
111 	private final AudioFileFormat.Type mAudioFileFormat;
112 	/*
113 	 * The maximum number of sentences that a section (ContiguousText) can contain.
114 	 */
115 	private final int mMaxSentencesPerSection;
116 	private int mSentenceCounter = 0;
117 	private long mTotalTextSize;
118 	private int mDocumentPosition;
119 	private Map<TTSEngine, List<ContiguousText>> mOrganizedText;
120 	private AudioFootprintMonitor mAudioFootprintMonitor;
121 	private Processor mProc;
122 	private VoiceManager mVoiceManager;
123 	private TimedTTSExecutor mExecutor = new TimedTTSExecutor();
124 	private final Map<String,String> mProperties;
125 	private TTSLog mTTSlog;
126 	private int mErrorCounter;
127 
128 	int numberOfCores = getPropertyAsInt(THREADS_NUMBER).get(); // for unit tests to override
129 
130 	public SSMLtoAudio(File audioDir, AudioFileFormat.Type audioFileFormat,
131 	        int maxSentencesPerSection, TTSRegistry ttsregistry, Logger logger,
132 	        AudioFootprintMonitor audioFootprintMonitor, Processor proc,
133 	        Map<String,String> properties, VoiceConfigExtension configExt, TTSLog logs) {
134 		mTTSRegistry = ttsregistry;
135 		mLogger = logger;
136 		mCurrentSection = null;
137 		mDocumentPosition = 0;
138 		mOrganizedText = new HashMap<TTSEngine, List<ContiguousText>>();
139 		mAudioFootprintMonitor = audioFootprintMonitor;
140 		mProc = proc;
141 		mAudioDir = audioDir;
142 		mAudioFileFormat = audioFileFormat;
143 		mMaxSentencesPerSection = maxSentencesPerSection;
144 		mTTSlog = logs;
145 		/*
146 		 * initialize the TTS engines
147 		 */
148 		mProperties = properties;
149 		mVoiceManager = new VoiceManager(
150 			ttsregistry.getWorkingEngines(properties, mTTSlog, mLogger),
151 			configExt.getVoiceDeclarations());
152 	}
153 
154 	/**
155 	 * @param node Document node of SSML document
156 	 */
157 	public void feedSSML(XdmNode doc) throws SynthesisException {
158 		XdmSequenceIterator iter = doc.axisIterator(Axis.CHILD);
159 		if (iter.hasNext()) {
160 			traverse((XdmNode)iter.next(), null);
161 		}
162 		endSection();
163 	}
164 
165 	private void traverse(XdmNode node, Locale lang) throws SynthesisException {
166 		if (SentenceTag.equals(node.getNodeName())) {
167 			if (!dispatchSSML(node, lang))
168 				mErrorCounter++;
169 			if (mMaxSentencesPerSection > 0 && ++mSentenceCounter % mMaxSentencesPerSection == 0)
170 				endSection();
171 		} else {
172 			String langAttr = node.getAttributeValue(Sentence_attr_lang);
173 			if (langAttr != null) {
174 				try {
175 					lang = (new Locale.Builder()).setLanguageTag(langAttr).build();
176 				} catch (IllformedLocaleException e) {
177 					lang = null;
178 				}
179 			}
180 			XdmSequenceIterator iter = node.axisIterator(Axis.CHILD);
181 			while (iter.hasNext())
182 				traverse((XdmNode)iter.next(), lang);
183 		}
184 	}
185 
186 	/**
187 	 * The SSML is assumed to be pushed in document order.
188 	 *
189 	 * @param ssml The input SSML
190 	 * @param lang The parent language
191 	 * @return true when the SSML was successfully converted to speech, false when there was an error
192 	 **/
193 	// package private for tests
194 	boolean dispatchSSML(XdmNode ssml, Locale lang) throws SynthesisException {
195 		VoiceFamilyList voiceFamily; {
196 			String attr = ssml.getAttributeValue(Sentence_attr_voice_family);
197 			if (attr != null && !"".equals(attr))
198 				try {
199 					voiceFamily = VoiceFamilyList.of(attr);
200 				} catch (IllegalArgumentException e) {
201 					throw new SynthesisException("invalid voice-family attribute: " + attr, e); // should not happen
202 				}
203 			else
204 				voiceFamily = null;
205 		}
206 		{
207 			String langAttr = ssml.getAttributeValue(Sentence_attr_lang);
208 			if (langAttr != null) {
209 				try {
210 					lang = (new Locale.Builder()).setLanguageTag(langAttr).build();
211 				} catch (IllformedLocaleException e) {
212 					lang = null;
213 				}
214 			}
215 		}
216 		String id = ssml.getAttributeValue(Sentence_attr_id);
217 		TTSLog.Entry logEntry = mTTSlog.getOrCreateEntry(id);
218 		logEntry.setSSML(ssml);
219 		Iterable<Voice> voices = mVoiceManager.findAvailableVoices(lang, voiceFamily);
220 		Voice preferredVoice = Iterables.getFirst(voices, null);
221 		logEntry.setSelectedVoice(preferredVoice);
222 		if (preferredVoice == null) {
223 			String err = "could not find any installed voice "
224 			                + (voiceFamily != null ? ("matching with `voice-family: " + voiceFamily + "` and ") : "")
225 			                + "providing the language '" + lang + "'";
226 			logEntry.addError(new TTSLog.Error(TTSLog.ErrorCode.AUDIO_MISSING, err));
227 			return false;
228 		}
229 
230 		TTSEngine newSynth = mVoiceManager.getTTS(preferredVoice);
231 		if (newSynth == null) {
232 			/*
233 			 * Should not happen since findAvailableVoice() returns only a
234 			 * non-null voice if a TTSService can provide it
235 			 */
236 			String err = "could not find any TTS engine for the voice "
237 			                + "{engine: " + preferredVoice.getEngine()
238 			                +  ", name: " + preferredVoice.getName() + "}";
239 			logEntry.addError(new TTSLog.Error(TTSLog.ErrorCode.AUDIO_MISSING, err));
240 			return false;
241 		}
242 
243 		if (!mVoiceManager.matches(preferredVoice, lang, voiceFamily)) {
244 			logEntry.addError(new TTSLog.Error(TTSLog.ErrorCode.UNEXPECTED_VOICE,
245 			        "no voice matches exactly with the requested characteristics"));
246 		}
247 
248 		/*
249 		 * If a TTS engine has no reserved threads, its sentences are pushed to
250 		 * a global list whose key is null. Otherwise the TTS Service is used as
251 		 * key.
252 		 */
253 		TTSEngine poolkey = null;
254 		if (newSynth.reservedThreadNum() > 0) {
255 			poolkey = newSynth;
256 		}
257 
258 		if (newSynth != mLastTTS) {
259 			if (mLastTTS != null
260 			        && (poolkey != null || mLastTTS.reservedThreadNum() > 0))
261 				endSection(); // necessary because the same thread wouldn't be able to
262 				              // concatenate outputs of different formats
263 			mLastTTS = newSynth;
264 		}
265 
266 		if (mCurrentSection == null) {
267 			//happen the first time and whenever endSection() is called
268 			mCurrentSection = new ContiguousText(mDocumentPosition++, mAudioDir);
269 			List<ContiguousText> listOfSections = mOrganizedText.get(poolkey);
270 			if (listOfSections == null) {
271 				listOfSections = new ArrayList<ContiguousText>();
272 				mOrganizedText.put(poolkey, listOfSections);
273 			}
274 
275 			listOfSections.add(mCurrentSection);
276 		}
277 		mCurrentSection.sentences.add(new Sentence(newSynth, voices, ssml));
278 		return true;
279 	}
280 
281 	private void endSection() {
282 		mCurrentSection = null;
283 	}
284 
285 	public Iterable<SoundFileLink> blockingRun(AudioServices audioServices)
286 	        throws SynthesisException, InterruptedException, EncodingException {
287 
288 		MessageAppender activeBlock = MessageAppender.getActiveBlock(); // px:ssml-to-audio step
289 
290 		//SSML mark splitter shared by the threads:
291 		SSMLMarkSplitter ssmlSplitter = new DefaultSSMLMarkSplitter(mProc); // StructuredSSMLSplitter makes assumptions
292 		                                                                    // that are not always met, e.g. marks may
293 		                                                                    // be contained within a a prosody element
294 
295 		reorganizeSections();
296 
297 		//threading layout
298 		int reservedThreadNum = 0;
299 		for (TTSEngine tts : mOrganizedText.keySet()) {
300 			if (tts != null && tts.reservedThreadNum() > 0)
301 				reservedThreadNum += tts.reservedThreadNum();
302 		}
303 		int ttsThreadNum = numberOfCores;
304 		int encodingThreadNum = getPropertyAsInt(ENCODING_THREADS_NUMBER).orElse(ttsThreadNum);
305 		int regularTTSthreadNum = getPropertyAsInt(SPEAKING_THREADS_NUMBER).orElse(ttsThreadNum);
306 		int totalTTSThreads = regularTTSthreadNum + reservedThreadNum;
307 		int maxMemPerTTSThread = getPropertyAsInt(THREADS_MEMLIMIT).get() * 1048576;
308 		mLogger.info("Number of encoding threads: " + encodingThreadNum);
309 		mLogger.info("Number of regular text-to-speech threads: " + regularTTSthreadNum);
310 		mLogger.info("Number of reserved text-to-speech threads: " + reservedThreadNum);
311 		mLogger.info("Max TTS memory footprint (encoding excluded): "
312 		        + mAudioFootprintMonitor.getSpaceForTTS() / 1000000 + "MB");
313 		mLogger.info("Max encoding memory footprint: "
314 		        + mAudioFootprintMonitor.getSpaceForEncoding() / 1000000 + "MB");
315 
316 		//input queue common to all the threads
317 		BlockingQueue<ContiguousPCM> pcmQueue = new PriorityBlockingQueue<ContiguousPCM>();
318 
319 		//start the TTS threads
320 		TextToPcmThread[] tpt = new TextToPcmThread[totalTTSThreads];
321 		List<ContiguousText> text = mOrganizedText.get(null);
322 		if (text == null) {
323 			text = Collections.EMPTY_LIST;
324 		}
325 		int i = 0;
326 		ConcurrentLinkedQueue<ContiguousText> stext = new ConcurrentLinkedQueue<ContiguousText>(text);
327 		if (regularTTSthreadNum > 0) {
328 			long textSize = stext.stream().mapToLong(ContiguousText::getStringSize).sum();
329 			BigDecimal portion = textSize == 0
330 				? BigDecimal.ZERO
331 				: new BigDecimal(textSize).divide(new BigDecimal(mTotalTextSize), MathContext.DECIMAL128)
332 				                          .divide(new BigDecimal(regularTTSthreadNum), MathContext.DECIMAL128);
333 			for (; i < regularTTSthreadNum; ++i) {
334 				tpt[i] = new TextToPcmThread();
335 				tpt[i].start(stext, pcmQueue, mExecutor, mTTSRegistry, mVoiceManager, ssmlSplitter,
336 				             mLogger, mAudioFootprintMonitor, maxMemPerTTSThread, mTTSlog, activeBlock,
337 				             mTotalTextSize, portion);
338 			}
339 		}
340 		for (Map.Entry<TTSEngine, List<ContiguousText>> e : mOrganizedText.entrySet()) {
341 			TTSEngine tts = e.getKey();
342 			if (tts != null) { //tts = null is handled by the previous loop
343 				stext = new ConcurrentLinkedQueue<ContiguousText>(e.getValue());
344 				if (tts.reservedThreadNum() > 0) {
345 					long textSize = stext.stream().mapToLong(ContiguousText::getStringSize).sum();
346 					BigDecimal portion = textSize == 0
347 						? BigDecimal.ZERO
348 						: new BigDecimal(textSize).divide(new BigDecimal(mTotalTextSize), MathContext.DECIMAL128)
349 						                          .divide(new BigDecimal(tts.reservedThreadNum()), MathContext.DECIMAL128);
350 					for (int j = 0; j < tts.reservedThreadNum(); ++i, ++j) {
351 						tpt[i] = new TextToPcmThread();
352 						tpt[i].start(stext, pcmQueue, mExecutor, mTTSRegistry, mVoiceManager, ssmlSplitter,
353 						             mLogger, mAudioFootprintMonitor, maxMemPerTTSThread, mTTSlog,
354 						             activeBlock, mTotalTextSize, portion);
355 					}
356 				}
357 			}
358 		}
359 		mLogger.info("Text-to-speech threads started.");
360 
361 		//start the encoding threads
362 		EncodingThread[] encodingTh = new EncodingThread[encodingThreadNum];
363 		for (int j = 0; j < encodingTh.length; ++j) {
364 			encodingTh[j] = new EncodingThread();
365 			encodingTh[j].start(mAudioFileFormat, audioServices, pcmQueue,
366 			                    mAudioFootprintMonitor, mProperties, mTTSlog, activeBlock);
367 		}
368 		mLogger.info("Encoding threads started.");
369 
370 		//collect the sound fragments
371 		Collection<SoundFileLink>[] fragments = new Collection[tpt.length];
372 		for (int j = 0; j < tpt.length; ++j) {
373 			fragments[j] = tpt[j].getSoundFragments();
374 		}
375 		for (int j = 0; j < tpt.length; ++j) {
376 			mErrorCounter += tpt[j].getErrorCount();
377 		}
378 
379 		//send END notifications and wait for the encoding threads to finish
380 		mLogger.info("Text-to-speech finished. Waiting for audio encoding to finish...");
381 		for (int k = 0; k < encodingTh.length; ++k) {
382 			pcmQueue.add(ContiguousPCM.EndOfQueue);
383 		}
384 		for (int j = 0; j < encodingTh.length; ++j) {
385 			try {
386 				encodingTh[j].waitToFinish();
387 			} catch (EncodingException e) {
388 				while (++j < encodingTh.length)
389 					// FIXME: interrupt instead of waiting
390 					try { encodingTh[j].waitToFinish(); }
391 					catch (EncodingException _e) {}
392 				throw e;
393 			}
394 		}
395 
396 		mLogger.info("Audio encoding finished.");
397 
398 		return Iterables.concat(fragments);
399 	}
400 
401 	double getErrorRate() {
402 		return (double)mErrorCounter/mSentenceCounter;
403 	}
404 
405 	private void reorganizeSections() {
406 		mTotalTextSize = 0;
407 		int sectionCount = 0;
408 		for (List<ContiguousText> sections : mOrganizedText.values()) {
409 
410 			// compute the sections' size: needed for displaying the progress,
411 			// splitting the sections and sorting them
412 			for (ContiguousText section : sections) {
413 				section.computeSize();
414 				mTotalTextSize += section.getStringSize();
415 			}
416 
417 			// split up the sections that are too big
418 			// FIXME: Explain what is "too big".
419 			// FIXME: Code was disabled for now because it does not make sense to let the maximum
420 			// size depend on the total size (and mTotalTextSize isn't even constant).
421 			/*int maxSize = (int) (mTotalTextSize / 15);
422 			List<ContiguousText> newSections = new ArrayList<ContiguousText>();
423 			List<ContiguousText> toRemove = new ArrayList<ContiguousText>();
424 			for (ContiguousText section : sections) {
425 				if (section.getStringSize() >= maxSize) {
426 					toRemove.add(section);
427 					splitSection(section, maxSize, newSections);
428 				}
429 			}
430 			sections.removeAll(toRemove);
431 			sections.addAll(newSections);*/
432 
433 			// sort the sections according to their size in descending order
434 			// FIXME: Explain why this is done.
435 			Collections.sort(sections);
436 
437 			// keep sorted only the smallest sections (50% of total) so the biggest sections won't
438 			// necessarily be processed at the same time, as that may consume too much memory.
439 			Collections.shuffle(sections.subList(0, sections.size() / 2));
440 
441 			sectionCount += sections.size();
442 		}
443 		mLogger.debug("Number of synthesizable TTS sections: " + sectionCount);
444 	}
445 
446 	//we can dispense with this function as soon as we take into consideration the size of the SSML
447 	//sentences, rather than creating a new section every 10 sentences or so.
448 	private void splitSection(ContiguousText section, int maxSize,
449 	        List<ContiguousText> newSections) {
450 		int left = 0;
451 		int count = 0;
452 		ContiguousText currentSection = new ContiguousText(section.getDocumentPosition(),
453 		        section.getAudioOutputDir());
454 		currentSection.setStringsize(0);
455 		for (int right = 0; right < section.sentences.size(); ++right) {
456 			if (currentSection.getStringSize() > maxSize) {
457 				currentSection.sentences = section.sentences.subList(left, right);
458 				currentSection.setDocumentSplitPosition(count);
459 				newSections.add(currentSection);
460 				currentSection = new ContiguousText(section.getDocumentPosition(), section
461 				        .getAudioOutputDir());
462 				currentSection.setStringsize(0);
463 				left = right;
464 				++count;
465 			}
466 			currentSection.setStringsize(currentSection.getStringSize()
467 			        + section.sentences.get(right).getSize());
468 		}
469 		currentSection.sentences = section.sentences.subList(left, section.sentences.size());
470 		currentSection.setDocumentSplitPosition(count);
471 		newSections.add(currentSection);
472 	}
473 
474 	private Optional<Integer> getPropertyAsInt(Property prop) {
475 		String str = prop.getValue(mProperties);
476 		if (str != null) {
477 			try {
478 				return Optional.of(Integer.valueOf(str));
479 			} catch (NumberFormatException e) {
480 				mTTSlog.addGeneralError(
481 					ErrorCode.WARNING, str + " is not a valid value for property " + prop.getName(), e);
482 			}
483 		}
484 		return Optional.empty();
485 	}
486 }