Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
61.84% covered (warning)
61.84%
94 / 152
33.33% covered (danger)
33.33%
2 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
UtteranceGenerator
61.84% covered (warning)
61.84%
94 / 152
33.33% covered (danger)
33.33%
2 / 6
56.00
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 setUtteranceStore
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 setContext
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getUtterance
73.81% covered (warning)
73.81%
62 / 84
0.00% covered (danger)
0.00%
0 / 1
14.59
 getUtteranceForRevisionAndSegment
68.57% covered (warning)
68.57%
24 / 35
0.00% covered (danger)
0.00%
0 / 1
4.50
 getUtterancesForMessageKey
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2
3namespace MediaWiki\Wikispeech\Utterance;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use ConfigException;
12use ExternalStoreException;
13use FormatJson;
14use InvalidArgumentException;
15use MediaWiki\Config\Config;
16use MediaWiki\Context\IContextSource;
17use MediaWiki\Logger\LoggerFactory;
18use MediaWiki\Wikispeech\Api\ListenMetricsEntry;
19use MediaWiki\Wikispeech\InputTextValidator;
20use MediaWiki\Wikispeech\Segment\Segment;
21use MediaWiki\Wikispeech\Segment\SegmentMessagesFactory;
22use MediaWiki\Wikispeech\Segment\SegmentPageFactory;
23use MediaWiki\Wikispeech\Segment\TextFilter\Sv\SwedishFilter;
24use MediaWiki\Wikispeech\SpeechoidConnector;
25use MediaWiki\Wikispeech\SpeechoidConnectorException;
26use MediaWiki\Wikispeech\VoiceHandler;
27use Psr\Log\LoggerInterface;
28use RuntimeException;
29use Wikimedia\ObjectCache\WANObjectCache;
30
31/**
32 * @since 0.1.11
33 */
34class UtteranceGenerator {
35    /**
36     * @var SegmentPageFactory
37     */
38    private $segmentPageFactory;
39
40    /** @var SegmentMessagesFactory */
41    private $segmentMessagesFactory;
42
43    /** @var UtteranceStore */
44    private $utteranceStore;
45
46    /** @var VoiceHandler */
47    private $voiceHandler;
48
49    /** @var LoggerInterface */
50    private $logger;
51
52    /** @var SpeechoidConnector */
53    private $speechoidConnector;
54
55    /** @var InputTextValidator */
56    private $InputTextValidator;
57
58    /** @var IContextSource */
59    private $context;
60
61    /** @var WANObjectCache */
62    private $cache;
63
64    /** @var Config */
65    private $config;
66
67    /**
68     * @since 0.1.16 add `config`.
69     * @since 0.1.13
70     * @param SpeechoidConnector $speechoidConnector
71     * @param UtteranceStore $utteranceStore
72     * @param SegmentPageFactory $segmentPageFactory
73     * @param WANObjectCache $cache
74     * @param SegmentMessagesFactory $segmentMessagesFactory
75     * @param Config $config
76     */
77    public function __construct(
78        SpeechoidConnector $speechoidConnector,
79        UtteranceStore $utteranceStore,
80        SegmentPageFactory $segmentPageFactory,
81        WANObjectCache $cache,
82        SegmentMessagesFactory $segmentMessagesFactory,
83        Config $config
84    ) {
85        $this->logger = LoggerFactory::getInstance( 'Wikispeech' );
86        $this->speechoidConnector = $speechoidConnector;
87        $this->segmentPageFactory = $segmentPageFactory;
88        $this->cache = $cache;
89        $this->utteranceStore = $utteranceStore;
90        $this->segmentMessagesFactory = $segmentMessagesFactory;
91        $this->config = $config;
92    }
93
94    /**
95     * Sets a custom UtteranceStore instance, typically for testing.
96     *
97     * @since 0.1.11
98     * @param UtteranceStore $utteranceStore
99     * @return void
100     */
101    public function setUtteranceStore( UtteranceStore $utteranceStore ): void {
102        $this->utteranceStore = $utteranceStore;
103    }
104
105    /**
106     * @since 0.1.13
107     * @param IContextSource $context
108     */
109    public function setContext( IContextSource $context ) {
110        $this->context = $context;
111    }
112
113    /**
114     * Return the utterance corresponding to the request.
115     *
116     * These are either retrieved from storage or synthesize (and then stored).
117     *
118     * @since 0.1.5
119     * @param string|null $consumerUrl
120     * @param string $voice
121     * @param string $language
122     * @param int $pageId
123     * @param Segment $segment
124     * @param string|null $messageKey
125     * @return array Containing base64 'audio' and synthesisMetadata 'tokens'.
126     * @throws ExternalStoreException
127     * @throws ConfigException
128     * @throws InvalidArgumentException
129     * @throws SpeechoidConnectorException
130     */
131    public function getUtterance(
132        ?string $consumerUrl,
133        string $voice,
134        string $language,
135        int $pageId,
136        Segment $segment,
137        ?string $messageKey = null
138    ) {
139        $segmentHash = $segment->getHash();
140        if ( $segmentHash === null ) {
141            throw new InvalidArgumentException( 'Segment hash must be set.' );
142        }
143
144        if ( !$voice ) {
145            $voice = $this->voiceHandler->getDefaultVoice( $language );
146            if ( !$voice ) {
147                throw new ConfigException( "Invalid default voice configuration." );
148            }
149        }
150        if ( $pageId === 0 ) {
151            if ( $messageKey === null ) {
152                throw new InvalidArgumentException( 'Message key must be set when Page ID is 0.' );
153            }
154            $utterance = $this->utteranceStore->findMessageUtterance(
155                $consumerUrl,
156                $messageKey,
157                $language,
158                $voice,
159                $segmentHash
160            );
161        } else {
162            $utterance = $this->utteranceStore->findUtterance(
163                $consumerUrl,
164                $pageId,
165                $language,
166                $voice,
167                $segmentHash
168            );
169        }
170        if ( !$utterance ) {
171            $this->logger->debug( __METHOD__ . ': Creating new utterance for {pageId} {segmentHash}', [
172                'pageId' => $pageId,
173                'segmentHash' => $segment->getHash()
174            ] );
175
176            // Make a string of all the segment contents.
177            $segmentText = '';
178            foreach ( $segment->getContent() as $content ) {
179                $segmentText .= $content->getString();
180            }
181
182            $this->InputTextValidator = new InputTextValidator();
183            $this->InputTextValidator->validateText( $segmentText );
184
185            /** @var string $ssml text/xml Speech Synthesis Markup Language */
186            $ssml = null;
187            if ( $this->config->get( 'WikispeechUseTextFilters' ) ) {
188                if ( $language === 'sv' ) {
189                    // @todo implement a per language selecting content text filter facade
190                    $textFilter = new SwedishFilter( $segmentText );
191                    $ssml = $textFilter->process();
192                }
193            }
194            if ( $ssml !== null ) {
195                $speechoidResponse = $this->speechoidConnector->synthesize(
196                    $language,
197                    $voice,
198                    [ 'ssml' => $ssml ]
199                );
200            } else {
201                $speechoidResponse = $this->speechoidConnector->synthesizeText(
202                    $language,
203                    $voice,
204                    $segmentText
205                );
206            }
207            if ( $pageId === 0 ) {
208                $this->utteranceStore->createMessageUtterance(
209                    $consumerUrl,
210                    $messageKey,
211                    $language,
212                    $voice,
213                    $segmentHash,
214                    $speechoidResponse['audio_data'],
215                    FormatJson::encode( $speechoidResponse['tokens'] )
216                );
217            } else {
218                $this->utteranceStore->createUtterance(
219                    $consumerUrl,
220                    $pageId,
221                    $language,
222                    $voice,
223                    $segmentHash,
224                    $speechoidResponse['audio_data'],
225                    FormatJson::encode( $speechoidResponse['tokens'] )
226                );
227            }
228
229            return [
230                'audio' => $speechoidResponse['audio_data'],
231                'tokens' => $speechoidResponse['tokens']
232            ];
233        }
234        $this->logger->debug( __METHOD__ . ': Using cached utterance for {pageId} {segmentHash}', [
235            'pageId' => $pageId,
236            'segmentHash' => $segmentHash
237        ] );
238        return [
239            'audio' => $utterance->getAudio(),
240            'tokens' => FormatJson::parse(
241                // @phan-suppress-next-line PhanTypeMismatchArgumentNullable synthesis metadata is set
242                $utterance->getSynthesisMetadata(),
243                FormatJson::FORCE_ASSOC
244            )->getValue()
245        ];
246    }
247
248    /**
249     * Retrieves the matching utterance for a given revision ID and segment hash.
250     *
251     * @since 0.1.13
252     * @param string $voice
253     * @param string $language
254     * @param int $revisionId
255     * @param string $segmentHash
256     * @param string|null $consumerUrl URL to the script path on the consumer,
257     *  if used as a producer.
258     * @param ListenMetricsEntry|null $listenMetricEntry Add page and segment
259     *  information to this entry.
260     * @return array An utterance
261     * @throws RuntimeException
262     */
263    public function getUtteranceForRevisionAndSegment(
264        string $voice,
265        string $language,
266        int $revisionId,
267        string $segmentHash,
268        ?string $consumerUrl = null,
269        ?ListenMetricsEntry $listenMetricEntry = null
270    ): array {
271        $segmentPageResponse = $this->segmentPageFactory
272            ->setSegmentBreakingTags( null )
273            ->setRemoveTags( null )
274            ->setUseSegmentsCache( true )
275            ->setUseRevisionPropertiesCache( true )
276            ->setContextSource( $this->context )
277            ->setConsumerUrl( $consumerUrl )
278            ->setRequirePageRevisionProperties( true )
279            ->segmentPage(
280                null,
281                $revisionId
282            );
283        $segment = $segmentPageResponse->getSegments()->findFirstItemByHash( $segmentHash );
284        if ( $segment === null ) {
285            throw new RuntimeException( 'No such segment. ' .
286                'Did you perhaps reference a segment that was created using incompatible settings ' .
287                'for segmentBreakingTags and/or removeTags?' );
288        }
289        $pageId = $segmentPageResponse->getPageId();
290        if ( $pageId === null ) {
291            throw new RuntimeException( 'Did not retrieve page id for the given revision id.' );
292        }
293
294        if ( $listenMetricEntry ) {
295            $listenMetricEntry->setSegmentIndex(
296                $segmentPageResponse->getSegments()->indexOf( $segment )
297            );
298            $listenMetricEntry->setPageId( $pageId );
299            $listenMetricEntry->setPageTitle(
300                $segmentPageResponse->getTitle()->getText()
301            );
302        }
303
304        return $this->getUtterance(
305            $consumerUrl,
306            $voice,
307            $language,
308            $pageId,
309            $segment
310        );
311    }
312
313    /**
314     * Retrieves the matching utterance for a given message key.
315     *
316     * @since 0.1.14
317     *
318     * @param string $messageKey
319     * @param string $language
320     * @param string $voice
321     * @param string|null $consumerUrl
322     * @throws RuntimeException
323     * @return Utterance[] $utterancesByHash Map of segment hashes to Utterance objects
324     */
325    public function getUtterancesForMessageKey(
326        string $messageKey,
327        string $language,
328        string $voice,
329        ?string $consumerUrl = null
330    ): array {
331        if ( !wfMessage( $messageKey )->exists() ) {
332            throw new InvalidArgumentException( "Invalid message key: $messageKey" );
333        }
334
335        $segmentResponse = $this->segmentMessagesFactory->segmentMessage( $messageKey, $language );
336        $segmentList = $segmentResponse->getSegments();
337        $segments = $segmentList->getSegments();
338
339        $utterancesByHash = [];
340
341        foreach ( $segments as $segment ) {
342
343            $segmentHash = $segment->getHash();
344            if ( $segmentHash === null ) {
345                throw new RuntimeException( 'Segment hash is null' );
346            }
347
348            $utterance = $this->utteranceStore->findMessageUtterance(
349            $consumerUrl,
350            $messageKey,
351            $language,
352            $voice,
353            $segmentHash,
354            false
355            );
356
357            if ( $utterance === null ) {
358                throw new RuntimeException(
359                    "No utterance has been synthesized yet for the message key: " . $messageKey .
360                    ". Please run the 'preSynthesizeMessages.php' maintenance script." );
361            }
362
363            $utterancesByHash[$segmentHash] = $utterance;
364        }
365
366        return $utterancesByHash;
367    }
368
369}