Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
64.38% |
141 / 219 |
|
57.14% |
4 / 7 |
CRAP | |
0.00% |
0 / 1 |
ApiWikispeechListen | |
64.38% |
141 / 219 |
|
57.14% |
4 / 7 |
70.66 | |
0.00% |
0 / 1 |
__construct | |
100.00% |
22 / 22 |
|
100.00% |
1 / 1 |
1 | |||
execute | |
25.00% |
15 / 60 |
|
0.00% |
0 / 1 |
43.17 | |||
getUtteranceForRevisionAndSegment | |
47.73% |
21 / 44 |
|
0.00% |
0 / 1 |
8.57 | |||
validateParameters | |
100.00% |
48 / 48 |
|
100.00% |
1 / 1 |
11 | |||
makeValuesString | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
getAllowedParams | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
1 | |||
getExamplesMessages | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
2 |
1 | <?php |
2 | |
3 | namespace MediaWiki\Wikispeech\Api; |
4 | |
5 | /** |
6 | * @file |
7 | * @ingroup API |
8 | * @ingroup Extensions |
9 | * @license GPL-2.0-or-later |
10 | */ |
11 | |
12 | use ApiBase; |
13 | use ApiMain; |
14 | use ApiUsageException; |
15 | use Config; |
16 | use ConfigException; |
17 | use MediaWiki\Http\HttpRequestFactory; |
18 | use MediaWiki\Logger\LoggerFactory; |
19 | use MediaWiki\MediaWikiServices; |
20 | use MediaWiki\Revision\RevisionStore; |
21 | use MediaWiki\Wikispeech\InputTextValidator; |
22 | use MediaWiki\Wikispeech\Segment\DeletedRevisionException; |
23 | use MediaWiki\Wikispeech\Segment\RemoteWikiPageProviderException; |
24 | use MediaWiki\Wikispeech\Segment\SegmentPageFactory; |
25 | use MediaWiki\Wikispeech\SpeechoidConnector; |
26 | use MediaWiki\Wikispeech\Utterance\UtteranceGenerator; |
27 | use MediaWiki\Wikispeech\Utterance\UtteranceStore; |
28 | use MediaWiki\Wikispeech\VoiceHandler; |
29 | use MWTimestamp; |
30 | use Psr\Log\LoggerInterface; |
31 | use RuntimeException; |
32 | use Throwable; |
33 | use WANObjectCache; |
34 | use Wikimedia\ParamValidator\ParamValidator; |
35 | |
36 | /** |
37 | * API module to synthezise text as sounds. |
38 | * |
39 | * Segments referenced by client are expected to have been created using |
40 | * the default configuration settings for segmentBreakingTags and removeTags. |
41 | * If not, segments might be incompatible, causing this API to not find |
42 | * the requested corresponding utterances. |
43 | * |
44 | * @since 0.1.3 |
45 | */ |
46 | class ApiWikispeechListen extends ApiBase { |
47 | |
48 | /** @var Config */ |
49 | private $config; |
50 | |
51 | /** @var WANObjectCache */ |
52 | private $cache; |
53 | |
54 | /** @var RevisionStore */ |
55 | private $revisionStore; |
56 | |
57 | /** @var HttpRequestFactory */ |
58 | private $requestFactory; |
59 | |
60 | /** @var LoggerInterface */ |
61 | private $logger; |
62 | |
63 | /** @var SpeechoidConnector */ |
64 | private $speechoidConnector; |
65 | |
66 | /** @var UtteranceGenerator */ |
67 | private $utteranceGenerator; |
68 | |
69 | /** @var VoiceHandler */ |
70 | private $voiceHandler; |
71 | |
72 | /** @var ListenMetricsEntry */ |
73 | private $listenMetricEntry; |
74 | |
75 | /** |
76 | * @since 0.1.5 |
77 | * @param ApiMain $mainModule |
78 | * @param string $moduleName |
79 | * @param WANObjectCache $cache |
80 | * @param RevisionStore $revisionStore |
81 | * @param HttpRequestFactory $requestFactory |
82 | * @param UtteranceGenerator $utteranceGenerator |
83 | * @param string $modulePrefix |
84 | */ |
85 | public function __construct( |
86 | ApiMain $mainModule, |
87 | string $moduleName, |
88 | WANObjectCache $cache, |
89 | RevisionStore $revisionStore, |
90 | HttpRequestFactory $requestFactory, |
91 | UtteranceGenerator $utteranceGenerator, |
92 | string $modulePrefix = '' |
93 | ) { |
94 | $this->config = $this->getConfig(); |
95 | $this->cache = $cache; |
96 | $this->revisionStore = $revisionStore; |
97 | $this->requestFactory = $requestFactory; |
98 | $this->logger = LoggerFactory::getInstance( 'Wikispeech' ); |
99 | $this->config = MediaWikiServices::getInstance() |
100 | ->getConfigFactory() |
101 | ->makeConfig( 'wikispeech' ); |
102 | $this->speechoidConnector = new SpeechoidConnector( |
103 | $this->config, |
104 | $requestFactory |
105 | ); |
106 | $cache = MediaWikiServices::getInstance()->getMainWANObjectCache(); |
107 | $this->voiceHandler = new VoiceHandler( |
108 | $this->logger, |
109 | $this->config, |
110 | $this->speechoidConnector, |
111 | $cache |
112 | ); |
113 | $this->listenMetricEntry = new ListenMetricsEntry(); |
114 | $this->utteranceGenerator = $utteranceGenerator; |
115 | |
116 | parent::__construct( $mainModule, $moduleName, $modulePrefix ); |
117 | } |
118 | |
119 | /** |
120 | * Execute an API request. |
121 | * |
122 | * @since 0.1.3 |
123 | */ |
124 | public function execute() { |
125 | $started = microtime( true ); |
126 | $this->listenMetricEntry->setTimestamp( MWTimestamp::getInstance() ); |
127 | |
128 | $inputParameters = $this->extractRequestParams(); |
129 | $this->validateParameters( $inputParameters ); |
130 | |
131 | $language = $inputParameters['lang']; |
132 | $voice = $inputParameters['voice']; |
133 | if ( !$voice ) { |
134 | $voice = $this->voiceHandler->getDefaultVoice( $language ); |
135 | if ( !$voice ) { |
136 | throw new ConfigException( 'Invalid default voice configuration.' ); |
137 | } |
138 | } |
139 | if ( isset( $inputParameters['revision'] ) ) { |
140 | $response = $this->getUtteranceForRevisionAndSegment( |
141 | $voice, |
142 | $language, |
143 | $inputParameters['revision'], |
144 | $inputParameters['segment'], |
145 | $inputParameters['consumer-url'] |
146 | ); |
147 | } else { |
148 | try { |
149 | $speechoidResponse = $this->speechoidConnector->synthesize( |
150 | $language, |
151 | $voice, |
152 | $inputParameters |
153 | ); |
154 | } catch ( Throwable $exception ) { |
155 | $this->dieWithException( $exception ); |
156 | } |
157 | $response = [ |
158 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Phan doesn't understand dieWithException() |
159 | 'audio' => $speechoidResponse['audio_data'], |
160 | // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Phan doesn't understand dieWithException() |
161 | 'tokens' => $speechoidResponse['tokens'] |
162 | ]; |
163 | } |
164 | $this->getResult()->addValue( |
165 | null, |
166 | $this->getModuleName(), |
167 | $response |
168 | ); |
169 | |
170 | $charactersInSegment = 0; |
171 | foreach ( $response['tokens'] as $token ) { |
172 | $charactersInSegment += mb_strlen( $token['orth'] ); |
173 | // whitespace and sentence ends counts too |
174 | $charactersInSegment += 1; |
175 | } |
176 | $this->listenMetricEntry->setCharactersInSegment( $charactersInSegment ); |
177 | $this->listenMetricEntry->setLanguage( $inputParameters['lang'] ); |
178 | $this->listenMetricEntry->setVoice( $voice ); |
179 | $this->listenMetricEntry->setPageRevisionId( $inputParameters['revision'] ); |
180 | $this->listenMetricEntry->setSegmentHash( $inputParameters['segment'] ); |
181 | $this->listenMetricEntry->setConsumerUrl( $inputParameters['consumer-url'] ); |
182 | $this->listenMetricEntry->setRemoteWikiHash( |
183 | UtteranceStore::evaluateRemoteWikiHash( $inputParameters['consumer-url'] ) |
184 | ); |
185 | $this->listenMetricEntry->setMillisecondsSpeechInUtterance( |
186 | $response['tokens'][count( $response['tokens'] ) - 1]['endtime'] |
187 | ); |
188 | $this->listenMetricEntry->setMicrosecondsSpent( intval( 1000000 * ( microtime( true ) - $started ) ) ); |
189 | |
190 | // All other metrics fields has been set in other functions of this class. |
191 | // For now the value of utteranceSynthesized() isn't used. |
192 | if ( !$inputParameters['skip-journal-metrics'] |
193 | && $this->config->get( 'WikispeechListenDoJournalMetrics' ) ) { |
194 | $metricsJournal = new ListenMetricsEntryFileJournal( $this->config ); |
195 | try { |
196 | $metricsJournal->appendEntry( $this->listenMetricEntry ); |
197 | } catch ( Throwable $exception ) { |
198 | // Catch everything. This should not bother the user! |
199 | $this->logger->warning( |
200 | 'Exception caught while appending to metrics journal {exception}', |
201 | [ 'exception' => $exception ] |
202 | ); |
203 | } |
204 | } |
205 | } |
206 | |
207 | /** |
208 | * Retrieves the matching utterance for a given revision id and segment hash . |
209 | * |
210 | * @since 0.1.5 |
211 | * @param string $voice |
212 | * @param string $language |
213 | * @param int $revisionId |
214 | * @param string $segmentHash |
215 | * @param string|null $consumerUrl URL to the script path on the consumer, if used as a producer. |
216 | * @return array An utterance |
217 | */ |
218 | private function getUtteranceForRevisionAndSegment( |
219 | string $voice, |
220 | string $language, |
221 | int $revisionId, |
222 | string $segmentHash, |
223 | ?string $consumerUrl = null |
224 | ): array { |
225 | $segmentPageFactory = new SegmentPageFactory( |
226 | $this->cache, |
227 | // todo inject config factory |
228 | MediaWikiServices::getInstance()->getConfigFactory() |
229 | ); |
230 | try { |
231 | $segmentPageResponse = $segmentPageFactory |
232 | ->setSegmentBreakingTags( null ) |
233 | ->setRemoveTags( null ) |
234 | ->setUseSegmentsCache( true ) |
235 | ->setUseRevisionPropertiesCache( true ) |
236 | ->setContextSource( $this->getContext() ) |
237 | ->setRevisionStore( $this->revisionStore ) |
238 | ->setHttpRequestFactory( $this->requestFactory ) |
239 | ->setConsumerUrl( $consumerUrl ) |
240 | ->setRequirePageRevisionProperties( true ) |
241 | ->segmentPage( |
242 | null, |
243 | $revisionId |
244 | ); |
245 | } catch ( RemoteWikiPageProviderException ) { |
246 | $this->dieWithError( [ |
247 | 'apierror-wikispeech-listen-failed-getting-page-from-consumer', |
248 | $revisionId, |
249 | $consumerUrl |
250 | ] ); |
251 | } catch ( DeletedRevisionException ) { |
252 | $this->dieWithError( 'apierror-wikispeech-listen-deleted-revision' ); |
253 | } |
254 | $segment = $segmentPageResponse->getSegments()->findFirstItemByHash( $segmentHash ); |
255 | if ( $segment === null ) { |
256 | throw new RuntimeException( 'No such segment. ' . |
257 | 'Did you perhaps reference a segment that was created using incompatible settings ' . |
258 | 'for segmentBreakingTags and/or removeTags?' ); |
259 | } |
260 | $pageId = $segmentPageResponse->getPageId(); |
261 | if ( $pageId === null ) { |
262 | throw new RuntimeException( 'Did not retrieve page id for the given revision id.' ); |
263 | } |
264 | |
265 | $this->listenMetricEntry->setSegmentIndex( $segmentPageResponse->getSegments()->indexOf( $segment ) ); |
266 | $this->listenMetricEntry->setPageId( $pageId ); |
267 | $this->listenMetricEntry->setPageTitle( $segmentPageResponse->getTitle()->getText() ); |
268 | |
269 | return $this->utteranceGenerator->getUtterance( |
270 | $consumerUrl, |
271 | $voice, |
272 | $language, |
273 | $pageId, |
274 | $segment |
275 | ); |
276 | } |
277 | |
278 | /** |
279 | * Validate the parameters for language and voice. |
280 | * |
281 | * The parameter values are checked against the extension |
282 | * configuration. These may differ from what is actually running |
283 | * on the Speechoid service. |
284 | * |
285 | * @since 0.1.3 |
286 | * @param array $parameters Request parameters. |
287 | * @throws ApiUsageException |
288 | */ |
289 | private function validateParameters( $parameters ) { |
290 | if ( |
291 | isset( $parameters['consumer-url'] ) && |
292 | !$this->config->get( 'WikispeechProducerMode' ) ) { |
293 | $this->dieWithError( 'apierror-wikispeech-consumer-not-allowed' ); |
294 | } |
295 | if ( |
296 | isset( $parameters['revision'] ) && |
297 | !isset( $parameters['segment'] ) |
298 | ) { |
299 | $this->dieWithError( [ |
300 | 'apierror-invalidparammix-mustusewith', |
301 | 'revision', |
302 | 'segment' |
303 | ] ); |
304 | } |
305 | if ( |
306 | isset( $parameters['segment'] ) && |
307 | !isset( $parameters['revision'] ) |
308 | ) { |
309 | $this->dieWithError( [ |
310 | 'apierror-invalidparammix-mustusewith', |
311 | 'segment', |
312 | 'revision' |
313 | ] ); |
314 | } |
315 | $this->requireOnlyOneParameter( |
316 | $parameters, |
317 | 'revision', |
318 | 'text', |
319 | 'ipa' |
320 | ); |
321 | $voices = $this->config->get( 'WikispeechVoices' ); |
322 | $language = $parameters['lang']; |
323 | |
324 | // Validate language. |
325 | $validLanguages = array_keys( $voices ); |
326 | if ( !in_array( $language, $validLanguages ) ) { |
327 | $this->dieWithError( [ |
328 | 'apierror-wikispeech-listen-invalid-language', |
329 | $language, |
330 | self::makeValuesString( $validLanguages ) |
331 | ] ); |
332 | } |
333 | |
334 | // Validate voice. |
335 | $voice = $parameters['voice']; |
336 | if ( $voice ) { |
337 | $validVoices = $voices[$language]; |
338 | if ( !in_array( $voice, $validVoices ) ) { |
339 | $this->dieWithError( [ |
340 | 'apierror-wikispeech-listen-invalid-voice', |
341 | $voice, |
342 | self::makeValuesString( $validVoices ) |
343 | ] ); |
344 | } |
345 | } |
346 | |
347 | // Validate input text. |
348 | $input = $parameters['text'] ?? ''; |
349 | try { |
350 | InputTextValidator::validateText( $input ); |
351 | } catch ( RuntimeException ) { |
352 | $this->dieWithError( |
353 | [ 'apierror-wikispeech-listen-invalid-input-too-long', |
354 | $this->config->get( 'WikispeechListenMaximumInputCharacters' ), mb_strlen( $input ) ] |
355 | ); |
356 | } |
357 | } |
358 | |
359 | /** |
360 | * Make a formatted string of values to be used in messages. |
361 | * |
362 | * @since 0.1.3 |
363 | * @param array $values Values as strings. |
364 | * @return string The input strings wrapped in <kbd> tags and |
365 | * joined by commas. |
366 | */ |
367 | private static function makeValuesString( $values ) { |
368 | $valueStrings = []; |
369 | foreach ( $values as $value ) { |
370 | $valueStrings[] = "<kbd>$value</kbd>"; |
371 | } |
372 | return implode( ', ', $valueStrings ); |
373 | } |
374 | |
375 | /** |
376 | * Specify what parameters the API accepts. |
377 | * |
378 | * @since 0.1.3 |
379 | * @return array |
380 | */ |
381 | public function getAllowedParams() { |
382 | return array_merge( |
383 | parent::getAllowedParams(), |
384 | [ |
385 | 'lang' => [ |
386 | ParamValidator::PARAM_TYPE => 'string', |
387 | ParamValidator::PARAM_REQUIRED => true |
388 | ], |
389 | 'text' => [ |
390 | ParamValidator::PARAM_TYPE => 'string' |
391 | ], |
392 | 'ipa' => [ |
393 | ParamValidator::PARAM_TYPE => 'string' |
394 | ], |
395 | 'revision' => [ |
396 | ParamValidator::PARAM_TYPE => 'integer' |
397 | ], |
398 | 'segment' => [ |
399 | ParamValidator::PARAM_TYPE => 'string' |
400 | ], |
401 | 'voice' => [ |
402 | ParamValidator::PARAM_TYPE => 'string' |
403 | ], |
404 | 'consumer-url' => [ |
405 | ParamValidator::PARAM_TYPE => 'string' |
406 | ], |
407 | 'skip-journal-metrics' => [ |
408 | ParamValidator::PARAM_TYPE => 'boolean', |
409 | ParamValidator::PARAM_DEFAULT => false |
410 | ] |
411 | ] |
412 | ); |
413 | } |
414 | |
415 | /** |
416 | * Give examples of usage. |
417 | * |
418 | * @since 0.1.3 |
419 | * @return array |
420 | */ |
421 | public function getExamplesMessages() { |
422 | return [ |
423 | 'action=wikispeech-listen&format=json&lang=en&text=Read this' |
424 | => 'apihelp-wikispeech-listen-example-1', |
425 | 'action=wikispeech-listen&format=json&lang=en&text=Read this&voice=cmu-slt-hsmm' |
426 | => 'apihelp-wikispeech-listen-example-2', |
427 | 'action=wikispeech-listen&format=json&lang=en&revision=1&segment=hash1234' |
428 | => 'apihelp-wikispeech-listen-example-3', |
429 | // phpcs:ignore Generic.Files.LineLength |
430 | 'action=wikispeech-listen&format=json&lang=en&revision=1&segment=hash1234&consumer-url=https://consumer.url/w' |
431 | => 'apihelp-wikispeech-listen-example-4', |
432 | ]; |
433 | } |
434 | } |