Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.38% covered (warning)
64.38%
141 / 219
57.14% covered (warning)
57.14%
4 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiWikispeechListen
64.38% covered (warning)
64.38%
141 / 219
57.14% covered (warning)
57.14%
4 / 7
70.66
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
1
 execute
25.00% covered (danger)
25.00%
15 / 60
0.00% covered (danger)
0.00%
0 / 1
43.17
 getUtteranceForRevisionAndSegment
47.73% covered (danger)
47.73%
21 / 44
0.00% covered (danger)
0.00%
0 / 1
8.57
 validateParameters
100.00% covered (success)
100.00%
48 / 48
100.00% covered (success)
100.00%
1 / 1
11
 makeValuesString
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getAllowedParams
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3namespace MediaWiki\Wikispeech\Api;
4
5/**
6 * @file
7 * @ingroup API
8 * @ingroup Extensions
9 * @license GPL-2.0-or-later
10 */
11
12use ApiBase;
13use ApiMain;
14use ApiUsageException;
15use Config;
16use ConfigException;
17use MediaWiki\Http\HttpRequestFactory;
18use MediaWiki\Logger\LoggerFactory;
19use MediaWiki\MediaWikiServices;
20use MediaWiki\Revision\RevisionStore;
21use MediaWiki\Wikispeech\InputTextValidator;
22use MediaWiki\Wikispeech\Segment\DeletedRevisionException;
23use MediaWiki\Wikispeech\Segment\RemoteWikiPageProviderException;
24use MediaWiki\Wikispeech\Segment\SegmentPageFactory;
25use MediaWiki\Wikispeech\SpeechoidConnector;
26use MediaWiki\Wikispeech\Utterance\UtteranceGenerator;
27use MediaWiki\Wikispeech\Utterance\UtteranceStore;
28use MediaWiki\Wikispeech\VoiceHandler;
29use MWTimestamp;
30use Psr\Log\LoggerInterface;
31use RuntimeException;
32use Throwable;
33use WANObjectCache;
34use 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 */
46class 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}