Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
61.33% |
92 / 150 |
|
33.33% |
2 / 6 |
CRAP | |
0.00% |
0 / 1 |
| UtteranceGenerator | |
61.33% |
92 / 150 |
|
33.33% |
2 / 6 |
53.58 | |
0.00% |
0 / 1 |
| __construct | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
1 | |||
| setUtteranceStore | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| setContext | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getUtterance | |
73.49% |
61 / 83 |
|
0.00% |
0 / 1 |
13.25 | |||
| getUtteranceForRevisionAndSegment | |
68.57% |
24 / 35 |
|
0.00% |
0 / 1 |
4.50 | |||
| getUtterancesForMessageKey | |
0.00% |
0 / 24 |
|
0.00% |
0 / 1 |
30 | |||
| 1 | <?php |
| 2 | |
| 3 | namespace MediaWiki\Wikispeech\Utterance; |
| 4 | |
| 5 | /** |
| 6 | * @file |
| 7 | * @ingroup Extensions |
| 8 | * @license GPL-2.0-or-later |
| 9 | */ |
| 10 | |
| 11 | use ConfigException; |
| 12 | use ExternalStoreException; |
| 13 | use FormatJson; |
| 14 | use InvalidArgumentException; |
| 15 | use MediaWiki\Context\IContextSource; |
| 16 | use MediaWiki\Logger\LoggerFactory; |
| 17 | use MediaWiki\Wikispeech\Api\ListenMetricsEntry; |
| 18 | use MediaWiki\Wikispeech\InputTextValidator; |
| 19 | use MediaWiki\Wikispeech\Segment\Segment; |
| 20 | use MediaWiki\Wikispeech\Segment\SegmentMessagesFactory; |
| 21 | use MediaWiki\Wikispeech\Segment\SegmentPageFactory; |
| 22 | use MediaWiki\Wikispeech\Segment\TextFilter\Sv\SwedishFilter; |
| 23 | use MediaWiki\Wikispeech\SpeechoidConnector; |
| 24 | use MediaWiki\Wikispeech\SpeechoidConnectorException; |
| 25 | use MediaWiki\Wikispeech\VoiceHandler; |
| 26 | use Psr\Log\LoggerInterface; |
| 27 | use RuntimeException; |
| 28 | use Wikimedia\ObjectCache\WANObjectCache; |
| 29 | |
| 30 | /** |
| 31 | * @since 0.1.11 |
| 32 | */ |
| 33 | class 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 | } |