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