1   package org.daisy.pipeline.tts;
2   
3   import static java.lang.Math.addExact;
4   import static java.lang.Math.multiplyExact;
5   import static java.lang.Math.toIntExact;
6   import java.util.HashMap;
7   import java.util.Map;
8   
9   import org.daisy.pipeline.tts.TTSEngine.SynthesisResult;
10  import org.daisy.pipeline.tts.TTSRegistry.TTSResource;
11  import org.daisy.pipeline.tts.TTSService.SynthesisException;
12  
13  import net.sf.saxon.s9api.XdmNode;
14  
15  public class TimedTTSExecutor {
16  
17  	private static final int FIRST_CHARACTERS = 2500;
18  	private static final int SHORT_SENTENCE_THRESHOLD = 25;
19  	private Map<TTSEngine,Integer> totalCharacters = new HashMap<>(); // only count first 2500 characters
20  	private Map<TTSEngine,Integer> maxMillisecPerCharacter = new HashMap<>();
21  	private Map<TTSEngine,Integer> maxMillisecOfShortSentence = new HashMap<>(); // shorter than 25 characters
22  
23  	/**
24  	 * The maximum number of milliseconds the TTS engine is allowed to spend on a sentence. This
25  	 * takes into account {@link TTSEngine#expectedMillisecPerWord()}, the number of words processed
26  	 * so far, and the time the engine has spent on previous sentences. A large enough safety factor
27  	 * is taken into account for long words and other deviations.
28  	 */
29  	private int maximumMillisec(TTSEngine engine, int sentenceSize) {
30  		int wordCount = 1 + sentenceSize / 6; // ~6 characters/word
31  		Integer n;
32  		synchronized(totalCharacters) {
33  			n = totalCharacters.get(engine);
34  			Integer safeMillisec = null;
35  			if (n == null || n < FIRST_CHARACTERS)
36  				// initially base timeout on engine.expectedMillisecPerWord()
37  				// adding initial offset because the processing time is not always directly
38  				// proportional to the length of the sentence
39  				safeMillisec = addExact(5000,
40  				                        multiplyExact(wordCount,
41  				                                      multiplyExact(10,
42  				                                                    engine.expectedMillisecPerWord())));
43  			if (n == null)
44  				return safeMillisec;
45  			else {
46  				// in the long run base timeout on maxMillisecPerCharacter and maxMillisecOfShortSentence
47  				Integer estimatedMillisec = multiplyExact(maxMillisecPerCharacter.get(engine), sentenceSize);
48  				Integer minMillisec = maxMillisecOfShortSentence.get(engine);
49  				if (minMillisec != null) {
50  					if (estimatedMillisec < minMillisec)
51  						estimatedMillisec = minMillisec;
52  				} else if (sentenceSize < SHORT_SENTENCE_THRESHOLD)
53  					estimatedMillisec = multiplyExact(maxMillisecPerCharacter.get(engine), SHORT_SENTENCE_THRESHOLD);
54  				if (n < FIRST_CHARACTERS)
55  					// interpolate
56  					return toIntExact(
57  						addExact(
58  							multiplyExact((long)safeMillisec, FIRST_CHARACTERS - n) / FIRST_CHARACTERS,
59  							multiplyExact((long)estimatedMillisec, 3 * n) / FIRST_CHARACTERS));
60  				else
61  					return multiplyExact(3, estimatedMillisec);
62  			}
63  		}
64  	}
65  
66  	public SynthesisResult synthesizeWithTimeout(
67  		TTSTimeout timeout, TTSTimeout.ThreadFreeInterrupter interrupter, TTSLog.Entry log,
68  		XdmNode sentence, int sentenceSize, TTSEngine engine, Voice voice,
69  		TTSResource threadResources
70  	) throws SynthesisException, TimeoutException {
71  		long startTime = System.currentTimeMillis();
72  		int timeoutSec = maximumMillisec(engine, sentenceSize) / 1000;
73  		// add 1 so it can never be 0 seconds
74  		timeoutSec += 1;
75  		if (log != null)
76  			log.setTimeout(timeoutSec);
77  		timeout.enableForCurrentThread(interrupter, timeoutSec);
78  		try {
79  			SynthesisResult result = engine.synthesize(
80  				sentence, voice, threadResources);
81  			Long millisecElapsed = System.currentTimeMillis() - startTime;
82  			if (log != null)
83  				log.setTimeElapsed((float)millisecElapsed / 1000);
84  			synchronized(totalCharacters) {
85  				Long millisecElapsedPerCharacter = sentenceSize == 0
86  					? 0
87  					: millisecElapsed / sentenceSize;
88  				int max = 1; // don't go below 1 ms
89  				if (maxMillisecPerCharacter.containsKey(engine))
90  					max = maxMillisecPerCharacter.get(engine);
91  				else
92  					maxMillisecPerCharacter.put(engine, max);
93  				if (millisecElapsedPerCharacter > max)
94  					maxMillisecPerCharacter.put(engine, toIntExact(millisecElapsedPerCharacter));
95  				if (sentenceSize < SHORT_SENTENCE_THRESHOLD
96  				    && maxMillisecOfShortSentence.containsKey(engine)
97  				    && millisecElapsed > maxMillisecOfShortSentence.get(engine))
98  					maxMillisecOfShortSentence.put(engine, toIntExact(millisecElapsed));
99  				Integer n = totalCharacters.get(engine);
100 				if (n == null) n = 0;
101 				if (n < FIRST_CHARACTERS)
102 					totalCharacters.put(engine, n + sentenceSize);
103 			}
104 			return result;
105 		} catch (InterruptedException e) {
106 			// assuming that the thread was interrupted by the timeout
107 			throw new TimeoutException(timeoutSec, e);
108 		} finally {
109 			timeout.disable();
110 		}
111 	}
112 
113 	public static class TimeoutException extends RuntimeException {
114 
115 		private final int seconds;
116 
117 		private TimeoutException(int seconds, InterruptedException cause) {
118 			super("TTS timed out after " + seconds + " seconds", cause);
119 			this.seconds = seconds;
120 		}
121 
122 		/**
123 		 * @return The timeout in seconds.
124 		 */
125 		public int getSeconds() {
126 			return seconds;
127 		}
128 	}
129 }