Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
59.52% covered (warning)
59.52%
125 / 210
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
ApiWikispeechListen
59.52% covered (warning)
59.52%
125 / 210
66.67% covered (warning)
66.67%
4 / 6
84.77
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
1
 execute
21.51% covered (danger)
21.51%
20 / 93
0.00% covered (danger)
0.00%
0 / 1
94.73
 validateParameters
100.00% covered (success)
100.00%
49 / 49
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%
34 / 34
100.00% covered (success)
100.00%
1 / 1
1
 getExamplesMessages
0.00% covered (danger)
0.00%
0 / 12
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 FormatJson;
18use MediaWiki\Http\HttpRequestFactory;
19use MediaWiki\Logger\LoggerFactory;
20use MediaWiki\MediaWikiServices;
21use MediaWiki\Revision\RevisionStore;
22use MediaWiki\Wikispeech\InputTextValidator;
23use MediaWiki\Wikispeech\Segment\DeletedRevisionException;
24use MediaWiki\Wikispeech\Segment\RemoteWikiPageProviderException;
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.13
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 VoiceHandler $voiceHandler
84     * @param string $modulePrefix
85     */
86    public function __construct(
87        ApiMain $mainModule,
88        string $moduleName,
89        WANObjectCache $cache,
90        RevisionStore $revisionStore,
91        HttpRequestFactory $requestFactory,
92        UtteranceGenerator $utteranceGenerator,
93        VoiceHandler $voiceHandler,
94        string $modulePrefix = ''
95    ) {
96        $this->config = $this->getConfig();
97        $this->cache = $cache;
98        $this->revisionStore = $revisionStore;
99        $this->requestFactory = $requestFactory;
100        $this->logger = LoggerFactory::getInstance( 'Wikispeech' );
101        $this->config = MediaWikiServices::getInstance()
102            ->getConfigFactory()
103            ->makeConfig( 'wikispeech' );
104        $this->speechoidConnector = new SpeechoidConnector(
105            $this->config,
106            $requestFactory
107        );
108        $cache = MediaWikiServices::getInstance()->getMainWANObjectCache();
109        $this->voiceHandler = $voiceHandler;
110        $this->listenMetricEntry = new ListenMetricsEntry();
111        $this->utteranceGenerator = $utteranceGenerator;
112        $utteranceGenerator->setContext( $this->getContext() );
113
114        parent::__construct( $mainModule, $moduleName, $modulePrefix );
115    }
116
117    /**
118     * Execute an API request.
119     *
120     * @since 0.1.3
121     */
122    public function execute() {
123        $started = microtime( true );
124        $this->listenMetricEntry->setTimestamp( MWTimestamp::getInstance() );
125
126        $inputParameters = $this->extractRequestParams();
127
128        $language = $inputParameters['lang'];
129        $voice = $inputParameters['voice'];
130        $this->validateParameters( $inputParameters );
131
132        if ( !$voice ) {
133            $voice = $this->voiceHandler->getDefaultVoice( $language );
134            if ( !$voice ) {
135                throw new ConfigException( 'Invalid default voice configuration.' );
136            }
137        }
138
139        if ( isset( $inputParameters['message-key'] ) ) {
140            $messageKey = $inputParameters['message-key'];
141            $consumerUrl = $inputParameters['consumer-url'] ?? null;
142
143            $utterances = $this->utteranceGenerator->getUtterancesForMessageKey(
144                $messageKey,
145                $language,
146                $voice,
147                $consumerUrl
148            );
149            $result = [];
150            foreach ( $utterances as $segmentHash => $utterance ) {
151                $metadataJson = $utterance->getSynthesisMetadata() ?? '[]';
152                $tokens = FormatJson::parse( $metadataJson, FormatJson::FORCE_ASSOC )->getValue();
153                $result[] = [
154                    'segment-hash' => $segmentHash,
155                    'audio' => $utterance->getAudio(),
156                    'tokens' => $tokens
157                ];
158            }
159
160            $this->getResult()->addValue(
161            null,
162            $this->getModuleName(),
163            [ 'utterances' => $result ]
164            );
165            return;
166        }
167
168        if ( isset( $inputParameters['revision'] ) ) {
169            try {
170                $response = $this->utteranceGenerator->getUtteranceForRevisionAndSegment(
171                    $voice,
172                    $language,
173                    $inputParameters['revision'],
174                    $inputParameters['segment'],
175                    $inputParameters['consumer-url'],
176                    $this->listenMetricEntry
177                );
178            } catch ( RemoteWikiPageProviderException ) {
179                $this->dieWithError( [
180                    'apierror-wikispeech-listen-failed-getting-page-from-consumer',
181                    $inputParameters['revision'],
182                    $inputParameters['consumer-url']
183                ] );
184            } catch ( DeletedRevisionException ) {
185                $this->dieWithError( 'apierror-wikispeech-listen-deleted-revision' );
186            }
187        } else {
188            try {
189                $speechoidResponse = $this->speechoidConnector->synthesize(
190                    $language,
191                    $voice,
192                    $inputParameters
193                );
194            } catch ( Throwable $exception ) {
195                $this->dieWithException( $exception );
196            }
197            $response = [
198                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Phan doesn't understand dieWithException()
199                'audio' => $speechoidResponse['audio_data'],
200                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable Phan doesn't understand dieWithException()
201                'tokens' => $speechoidResponse['tokens']
202            ];
203        }
204        $this->getResult()->addValue(
205            null,
206            $this->getModuleName(),
207            $response
208        );
209
210        $charactersInSegment = 0;
211        foreach ( $response['tokens'] as $token ) {
212            $charactersInSegment += mb_strlen( $token['orth'] );
213            // whitespace and sentence ends counts too
214            $charactersInSegment += 1;
215        }
216        $this->listenMetricEntry->setCharactersInSegment( $charactersInSegment );
217        $this->listenMetricEntry->setLanguage( $inputParameters['lang'] );
218        $this->listenMetricEntry->setVoice( $voice );
219        $this->listenMetricEntry->setPageRevisionId( $inputParameters['revision'] );
220        $this->listenMetricEntry->setSegmentHash( $inputParameters['segment'] );
221        $this->listenMetricEntry->setConsumerUrl( $inputParameters['consumer-url'] );
222        $this->listenMetricEntry->setRemoteWikiHash(
223            UtteranceStore::evaluateRemoteWikiHash( $inputParameters['consumer-url'] )
224        );
225        $this->listenMetricEntry->setMillisecondsSpeechInUtterance(
226            $response['tokens'][count( $response['tokens'] ) - 1]['endtime']
227        );
228        $this->listenMetricEntry->setMicrosecondsSpent( intval( 1000000 * ( microtime( true ) - $started ) ) );
229
230        // All other metrics fields has been set in other functions of this or
231        // other classes. For now the value of utteranceSynthesized() isn't
232        // used.
233        if ( !$inputParameters['skip-journal-metrics']
234            && $this->config->get( 'WikispeechListenDoJournalMetrics' ) ) {
235            $metricsJournal = new ListenMetricsEntryFileJournal( $this->config );
236            try {
237                $metricsJournal->appendEntry( $this->listenMetricEntry );
238            } catch ( Throwable $exception ) {
239                // Catch everything. This should not bother the user!
240                $this->logger->warning(
241                    'Exception caught while appending to metrics journal {exception}',
242                    [ 'exception' => $exception ]
243                );
244            }
245        }
246    }
247
248    /**
249     * Validate the parameters for language and voice.
250     *
251     * The parameter values are checked against the extension
252     * configuration. These may differ from what is actually running
253     * on the Speechoid service.
254     *
255     * @since 0.1.3
256     * @param array $parameters Request parameters.
257     * @throws ApiUsageException
258     */
259    private function validateParameters( $parameters ) {
260        if (
261            isset( $parameters['consumer-url'] ) &&
262            !$this->config->get( 'WikispeechProducerMode' ) ) {
263            $this->dieWithError( 'apierror-wikispeech-consumer-not-allowed' );
264        }
265        if (
266            isset( $parameters['revision'] ) &&
267            !isset( $parameters['segment'] )
268        ) {
269            $this->dieWithError( [
270                'apierror-invalidparammix-mustusewith',
271                'revision',
272                'segment'
273            ] );
274        }
275        if (
276            isset( $parameters['segment'] ) &&
277            !isset( $parameters['revision'] )
278        ) {
279            $this->dieWithError( [
280                'apierror-invalidparammix-mustusewith',
281                'segment',
282                'revision'
283            ] );
284        }
285        $this->requireOnlyOneParameter(
286            $parameters,
287            'revision',
288            'text',
289            'ipa',
290            'message-key'
291        );
292        $voices = $this->config->get( 'WikispeechVoices' );
293        $language = $parameters['lang'];
294
295        // Validate language.
296        $validLanguages = array_keys( $voices );
297        if ( !in_array( $language, $validLanguages ) ) {
298            $this->dieWithError( [
299                'apierror-wikispeech-listen-invalid-language',
300                $language,
301                self::makeValuesString( $validLanguages )
302            ] );
303        }
304
305        // Validate voice.
306        $voice = $parameters['voice'];
307        if ( $voice ) {
308            $validVoices = $voices[$language];
309            if ( !in_array( $voice, $validVoices ) ) {
310                $this->dieWithError( [
311                    'apierror-wikispeech-listen-invalid-voice',
312                    $voice,
313                    self::makeValuesString( $validVoices )
314                ] );
315            }
316        }
317
318        // Validate input text.
319        $input = $parameters['text'] ?? '';
320        try {
321            InputTextValidator::validateText( $input );
322        } catch ( RuntimeException ) {
323            $this->dieWithError(
324                [ 'apierror-wikispeech-listen-invalid-input-too-long',
325                    $this->config->get( 'WikispeechListenMaximumInputCharacters' ), mb_strlen( $input ) ]
326            );
327        }
328    }
329
330    /**
331     * Make a formatted string of values to be used in messages.
332     *
333     * @since 0.1.3
334     * @param array $values Values as strings.
335     * @return string The input strings wrapped in <kbd> tags and
336     *  joined by commas.
337     */
338    private static function makeValuesString( $values ) {
339        $valueStrings = [];
340        foreach ( $values as $value ) {
341            $valueStrings[] = "<kbd>$value</kbd>";
342        }
343        return implode( ', ', $valueStrings );
344    }
345
346    /**
347     * Specify what parameters the API accepts.
348     *
349     * @since 0.1.3
350     * @return array
351     */
352    public function getAllowedParams() {
353        return array_merge(
354            parent::getAllowedParams(),
355            [
356                'lang' => [
357                    ParamValidator::PARAM_TYPE => 'string',
358                    ParamValidator::PARAM_REQUIRED => true
359                ],
360                'text' => [
361                    ParamValidator::PARAM_TYPE => 'string'
362                ],
363                'ipa' => [
364                    ParamValidator::PARAM_TYPE => 'string'
365                ],
366                'revision' => [
367                    ParamValidator::PARAM_TYPE => 'integer'
368                ],
369                'segment' => [
370                    ParamValidator::PARAM_TYPE => 'string'
371                ],
372                'voice' => [
373                    ParamValidator::PARAM_TYPE => 'string'
374                ],
375                'consumer-url' => [
376                    ParamValidator::PARAM_TYPE => 'string'
377                ],
378                'skip-journal-metrics' => [
379                    ParamValidator::PARAM_TYPE => 'boolean',
380                    ParamValidator::PARAM_DEFAULT => false
381                ],
382                'message-key' => [
383                    ParamValidator::PARAM_TYPE => 'string'
384                ]
385            ]
386        );
387    }
388
389    /**
390     * Give examples of usage.
391     *
392     * @since 0.1.3
393     * @return array
394     */
395    public function getExamplesMessages() {
396        return [
397            'action=wikispeech-listen&format=json&lang=en&text=Read this'
398            => 'apihelp-wikispeech-listen-example-1',
399            'action=wikispeech-listen&format=json&lang=en&text=Read this&voice=cmu-slt-hsmm'
400            => 'apihelp-wikispeech-listen-example-2',
401            'action=wikispeech-listen&format=json&lang=en&revision=1&segment=hash1234'
402            => 'apihelp-wikispeech-listen-example-3',
403            // phpcs:ignore Generic.Files.LineLength
404            'action=wikispeech-listen&format=json&lang=en&revision=1&segment=hash1234&consumer-url=https://consumer.url/w'
405            => 'apihelp-wikispeech-listen-example-4',
406            'action=wikispeech-listen&format=json&lang=en&message-key=wikispeech-error-loading-audio-title'
407            => 'apihelp-wikispeech-listen-example-5',
408
409        ];
410    }
411}