Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
51.75% covered (warning)
51.75%
133 / 257
31.58% covered (danger)
31.58%
6 / 19
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpeechoidConnector
51.75% covered (warning)
51.75%
133 / 257
31.58% covered (danger)
31.58%
6 / 19
571.21
0.00% covered (danger)
0.00%
0 / 1
 __construct
86.36% covered (warning)
86.36%
19 / 22
0.00% covered (danger)
0.00%
0 / 1
5.06
 synthesize
73.53% covered (warning)
73.53%
25 / 34
0.00% covered (danger)
0.00%
0 / 1
9.19
 synthesizeText
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
2
 listDefaultVoicePerLanguage
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
3.01
 requestDefaultVoices
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 requestLexicons
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 findLexiconByLocale
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 requestTextProcessors
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
12
 findLexiconByLanguage
75.00% covered (warning)
75.00%
15 / 20
0.00% covered (danger)
0.00%
0 / 1
9.00
 lookupLexiconEntries
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 updateLexiconEntry
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
2
 deleteLexiconEntry
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 addLexiconEntry
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
42
 toIpa
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 map
71.43% covered (warning)
71.43%
20 / 28
0.00% covered (danger)
0.00%
0 / 1
4.37
 fromIpa
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isQueueOverloaded
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getAvailableNonQueuedConnectionSlots
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 unparseUrl
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
8
1<?php
2
3namespace MediaWiki\Wikispeech;
4
5/**
6 * @file
7 * @ingroup Extensions
8 * @license GPL-2.0-or-later
9 */
10
11use Config;
12use FormatJson;
13use InvalidArgumentException;
14use MediaWiki\Http\HttpRequestFactory;
15use Status;
16
17/**
18 * Provide Speechoid access.
19 *
20 * @since 0.1.5
21 */
22class SpeechoidConnector {
23
24    /** @var Config */
25    private $config;
26
27    /** @var string Speechoid URL, without trailing slash. For non queued (non-TTS) operations. */
28    private $url;
29
30    /** @var string Speechoid queue URL, without trailing slash. For queued (TTS) operations. */
31    private $haproxyQueueUrl;
32
33    /** @var string Speechoid queue status URL, without trailing slash. */
34    private $haproxyStatsUrl;
35
36    /** @var string Speechoid symbol set URL, without trailing slash. */
37    private $symbolSetUrl;
38
39    /** @var int Default timeout awaiting HTTP response in seconds. */
40    private $defaultHttpResponseTimeoutSeconds;
41
42    /** @var HttpRequestFactory */
43    private $requestFactory;
44
45    /**
46     * @since 0.1.5
47     * @param Config $config
48     * @param HttpRequestFactory $requestFactory
49     */
50    public function __construct( $config, $requestFactory ) {
51        $this->config = $config;
52        $this->url = rtrim( $config->get( 'WikispeechSpeechoidUrl' ), '/' );
53        $this->symbolSetUrl = rtrim( $config->get( 'WikispeechSymbolSetUrl' ), '/' );
54        if ( !$this->symbolSetUrl ) {
55            $parsedUrl = parse_url( $this->url );
56            $parsedUrl['port'] = 8771;
57            $this->symbolSetUrl = $this->unparseUrl( $parsedUrl );
58        }
59        $this->haproxyQueueUrl = rtrim( $config->get( 'WikispeechSpeechoidHaproxyQueueUrl' ), '/' );
60        if ( !$this->haproxyQueueUrl ) {
61            $parsedUrl = parse_url( $this->url );
62            $parsedUrl['port'] = 10001;
63            $this->haproxyQueueUrl = $this->unparseUrl( $parsedUrl );
64        }
65        $this->haproxyStatsUrl = rtrim( $config->get( 'WikispeechSpeechoidHaproxyStatsUrl' ), '/' );
66        if ( !$this->haproxyStatsUrl ) {
67            $parsedUrl = parse_url( $this->url );
68            $parsedUrl['port'] = 10002;
69            $this->haproxyStatsUrl = $this->unparseUrl( $parsedUrl );
70        }
71        if ( $config->get( 'WikispeechSpeechoidResponseTimeoutSeconds' ) ) {
72            $this->defaultHttpResponseTimeoutSeconds = intval(
73                $config->get( 'WikispeechSpeechoidResponseTimeoutSeconds' )
74            );
75        }
76        $this->requestFactory = $requestFactory;
77    }
78
79    /**
80     * Make a request to Speechoid to synthesize the provided text or ipa string.
81     *
82     * @since 0.1.5
83     * @param string $language
84     * @param string $voice
85     * @param array $parameters Should contain either 'text', 'ipa' or 'ssml'.
86     *  Determines input string and type.
87     * @param int|null $responseTimeoutSeconds Seconds before timing out awaiting response.
88     *  Falsy value defaults to config value WikispeechSpeechoidResponseTimeoutSeconds,
89     *  which if falsy (e.g. 0) defaults to MediaWiki default.
90     * @return array Response from Speechoid, parsed as associative array.
91     * @throws SpeechoidConnectorException On Speechoid I/O errors.
92     */
93    public function synthesize(
94        $language,
95        $voice,
96        $parameters,
97        $responseTimeoutSeconds = null
98    ): array {
99        $postData = [
100            'lang' => $language,
101            'voice' => $voice
102        ];
103        $options = [];
104        if ( $responseTimeoutSeconds ) {
105            $options['timeout'] = $responseTimeoutSeconds;
106        } elseif ( $this->defaultHttpResponseTimeoutSeconds ) {
107            $options['timeout'] = $this->defaultHttpResponseTimeoutSeconds;
108        }
109        if ( isset( $parameters['ipa'] ) ) {
110            $postData['input'] = $parameters['ipa'];
111            $postData['input_type'] = 'ipa';
112        } elseif ( isset( $parameters['text'] ) ) {
113            $postData['input'] = $parameters['text'];
114        } elseif ( isset( $parameters['ssml'] ) ) {
115            $postData['input'] = $parameters['ssml'];
116            $postData['input_type'] = 'ssml';
117        } else {
118            throw new InvalidArgumentException(
119                '$parameters must contain one of "text", "ipa" or "ssml".'
120            );
121        }
122        $options = [ 'postData' => $postData ];
123        $responseString = $this->requestFactory->post( $this->haproxyQueueUrl, $options );
124        if ( !$responseString ) {
125            throw new SpeechoidConnectorException(
126                'Unable to communicate with Speechoid. ' .
127                $this->haproxyQueueUrl . var_export( $options, true )
128            );
129        }
130        $status = FormatJson::parse(
131            $responseString,
132            FormatJson::FORCE_ASSOC
133        );
134        if ( !$status->isOK() ) {
135            throw new SpeechoidConnectorException( $responseString );
136        }
137        return $status->getValue();
138    }
139
140    /**
141     * Make a request to Speechoid to synthesize the provided text.
142     *
143     * @since 0.1.8
144     * @param string $language
145     * @param string $voice
146     * @param string $text
147     * @param int|null $responseTimeoutSeconds
148     * @return array
149     */
150    public function synthesizeText(
151        $language,
152        $voice,
153        $text,
154        $responseTimeoutSeconds = null
155    ): array {
156        return $this->synthesize(
157            $language,
158            $voice,
159            [ 'text' => $text ],
160            $responseTimeoutSeconds
161        );
162    }
163
164    /**
165     * Retrieve and parse default voices per language from Speechoid.
166     *
167     * @since 0.1.5
168     * @return array Map language => voice
169     * @throws SpeechoidConnectorException On Speechoid I/O- or JSON parse errors.
170     */
171    public function listDefaultVoicePerLanguage(): array {
172        $defaultVoicesJson = $this->requestDefaultVoices();
173        $status = FormatJson::parse(
174            $defaultVoicesJson,
175            FormatJson::FORCE_ASSOC
176        );
177        if ( !$status->isOK() ) {
178            throw new SpeechoidConnectorException( 'Unexpected response from Speechoid.' );
179        }
180        $defaultVoices = $status->getValue();
181        $defaultVoicePerLanguage = [];
182        foreach ( $defaultVoices as $voice ) {
183            $defaultVoicePerLanguage[ $voice['lang'] ] = $voice['default_voice'];
184        }
185        return $defaultVoicePerLanguage;
186    }
187
188    /**
189     * Retrieve default voices par language from Speechoid
190     *
191     * @since 0.1.6
192     * @return string JSON response
193     * @throws SpeechoidConnectorException On Speechoid I/O error or
194     *  if URL is invalid.
195     */
196    public function requestDefaultVoices(): string {
197        if ( !filter_var( $this->url, FILTER_VALIDATE_URL ) ) {
198            throw new SpeechoidConnectorException( 'No Speechoid URL provided.' );
199        }
200        $responseString = $this->requestFactory->get( $this->url . '/default_voices' );
201        if ( !$responseString ) {
202            throw new SpeechoidConnectorException( 'Unable to communicate with Speechoid.' );
203        }
204        return $responseString;
205    }
206
207    /**
208     * An array of items such as:
209     * {
210     *   "name": "sv_se_nst_lex:sv-se.nst",
211     *   "symbolSetName": "sv-se_ws-sampa",
212     *   "locale": "sv_SE",
213     *   "entryCount": 919476
214     * }
215     *
216     * This list includes all registered lexicons,
217     * including those that are not in use by any voice.
218     *
219     * @since 0.1.8
220     * @return array Parsed JSON response as an associative array
221     * @throws SpeechoidConnectorException
222     */
223    public function requestLexicons(): array {
224        $json = $this->requestFactory->get(
225            $this->url . '/lexserver/lexicon/list'
226        );
227        if ( !$json ) {
228            throw new SpeechoidConnectorException( 'Unable to communicate with Speechoid.' );
229        }
230        $status = FormatJson::parse(
231            $json,
232            FormatJson::FORCE_ASSOC
233        );
234        if ( !$status->isOK() ) {
235            throw new SpeechoidConnectorException( 'Unexpected response from Speechoid.' );
236        }
237        return $status->getValue();
238    }
239
240    /**
241     * This includes all registered lexicons,
242     * including those that are not in use by any voice.
243     *
244     * Case insensitive prefix matching query.
245     * I.e. $locale 'en' will match both 'en_US' and 'en_NZ'.
246     *
247     * @see requestLexicons
248     * @since 0.1.8
249     * @param string $locale
250     * @return string|null Name of lexicon, or null if not found.
251     * @throws SpeechoidConnectorException
252     */
253    public function findLexiconByLocale(
254        string $locale
255    ): ?string {
256        $locale = strtolower( $locale );
257        $lexicons = $this->requestLexicons();
258        $matches = [];
259        foreach ( $lexicons as $lexicon ) {
260            $lexiconLocale = $lexicon['locale'];
261            $lexiconLocale = strtolower( $lexiconLocale );
262            $isMatching = str_starts_with( $lexiconLocale, $locale );
263            if ( $isMatching ) {
264                $matches[] = $lexicon;
265            }
266        }
267        $numberOfMatches = count( $matches );
268        if ( $numberOfMatches === 0 ) {
269            return null;
270        } elseif ( $numberOfMatches > 1 ) {
271            throw new SpeechoidConnectorException(
272                'Multiple lexicons matches locale:' .
273                FormatJson::encode( $matches, true )
274            );
275        }
276        return $matches[0]['name'];
277    }
278
279    /**
280     * An array of items such as:
281     * {
282     *   "components": [
283     *      {
284     *     "call": "marytts_preproc",
285     *        "mapper": {
286     *          "from": "sv-se_ws-sampa",
287     *          "to": "sv-se_sampa_mary"
288     *        },
289     *        "module": "adapters.marytts_adapter"
290     *      },
291     *      {
292     *        "call": "lexLookup",
293     *        "lexicon": "sv_se_nst_lex:sv-se.nst",
294     *        "module": "adapters.lexicon_client"
295     *      }
296     *      ],
297     *      "config_file": "wikispeech_server/conf/voice_config_marytts.json",
298     *      "default": true,
299     *      "lang": "sv",
300     *      "name": "marytts_textproc_sv"
301     * }
302     *
303     * This list includes the lexicons for all registered voices,
304     * even if the voice is currently unavailable.
305     *
306     * @since 0.1.8
307     * @return array Parsed JSON response as associative array
308     * @throws SpeechoidConnectorException
309     */
310    public function requestTextProcessors(): array {
311        $json = $this->requestFactory->get(
312            $this->url . '/textprocessing/textprocessors'
313        );
314        if ( !$json ) {
315            throw new SpeechoidConnectorException( 'Unable to communicate with Speechoid.' );
316        }
317        $status = FormatJson::parse(
318            $json,
319            FormatJson::FORCE_ASSOC
320        );
321        if ( !$status->isOK() ) {
322            throw new SpeechoidConnectorException( 'Unexpected response from Speechoid.' );
323        }
324        return $status->getValue();
325    }
326
327    /**
328     * This includes the lexicons for all registered voices,
329     * even if the voice is currently unavailable.
330     * Response is in form such as 'sv_se_nst_lex:sv-se.nst',
331     * where prefix and suffix split by : is used differently throughout Speechoid
332     * e.g combined, prefix only or suffix only, for identifying items.
333     *
334     * @see requestTextProcessors
335     * @since 0.1.8
336     * @param string $language Case insensitive language code, e.g. 'en'.
337     * @return string|null Name of lexicon, or null if not found.
338     * @throws SpeechoidConnectorException
339     */
340    public function findLexiconByLanguage(
341        string $language
342    ): ?string {
343        $language = strtolower( $language );
344        $lexicons = $this->requestTextProcessors();
345        $matches = [];
346        foreach ( $lexicons as $lexicon ) {
347            $lexiconLang = strtolower( $lexicon['lang'] );
348            if ( $lexiconLang == $language ) {
349                $matches[] = $lexicon;
350            }
351        }
352        $numberOfMatches = count( $matches );
353        if ( $numberOfMatches === 0 ) {
354            return null;
355        } elseif ( $numberOfMatches > 1 ) {
356            throw new SpeechoidConnectorException(
357                'Multiple lexicon matches language' .
358                FormatJson::encode( $matches, true )
359            );
360        }
361        foreach ( $matches[0]['components'] as $component ) {
362            if (
363                array_key_exists( 'call', $component ) &&
364                $component['call'] === 'lexLookup'
365            ) {
366                return $component['lexicon'];
367            }
368        }
369        return null;
370    }
371
372    /**
373     * An array of items such as:
374     * {
375     *   "id": 808498,
376     *   "lexRef": {
377     *     "dbRef": "sv_se_nst_lex",
378     *       "lexName": "sv-se.nst"
379     *     },
380     *   "strn": "tomten",
381     *   "language": "sv-se",
382     *   "partOfSpeech": "NN",
383     *   "morphology": "SIN|DEF|NOM|UTR",
384     *   "wordParts": "tomten",
385     *   "lemma": {
386     *     "id": 92909,
387     *     "strn": "tomte",
388     *     "paradigm": "s2b-bÃ¥ge"
389     *   },
390     *   "transcriptions": [
391     *     {
392     *       "id": 814660,
393     *       "entryId": 808498,
394     *       "strn": "\"\" t O m . t e n",
395     *       "language": "sv-se",
396     *       "sources": [
397     *         "nst"
398     *       ]
399     *    }
400     *  ],
401     *  "status": {
402     *     "id": 808498,
403     *     "name": "imported",
404     *     "source": "nst",
405     *     "timestamp": "2018-06-18T08:51:25Z",
406     *     "current": true
407     *   }
408     * }
409     *
410     * @since 0.1.8
411     * @param string $lexicon
412     * @param string[] $words
413     * @return Status If successful, value contains deserialized json response.
414     * @throws SpeechoidConnectorException
415     * @throws InvalidArgumentException If words array is empty.
416     */
417    public function lookupLexiconEntries(
418        string $lexicon,
419        array $words
420    ): Status {
421        if ( $words === [] ) {
422            throw new InvalidArgumentException( 'Must contain at least one word' );
423        }
424        $url = wfAppendQuery(
425            $this->url . '/lexserver/lexicon/lookup',
426            [
427                'lexicons' => $lexicon,
428                'words' => implode( ',', $words )
429            ]
430        );
431        $responseString = $this->requestFactory->get( $url );
432        if ( !$responseString ) {
433            throw new SpeechoidConnectorException( "Unable to communicate with Speechoid.  '$url'" );
434        }
435        return FormatJson::parse( $responseString, FormatJson::FORCE_ASSOC );
436    }
437
438    /**
439     * @since 0.1.8
440     * @param string $json A single entry object item.
441     *  I.e. not an array as returned by {@link lookupLexiconEntries}.
442     * @return Status If successful, value contains deserialized json response (updated entry item)
443     */
444    public function updateLexiconEntry(
445        string $json
446    ): Status {
447        $responseString = $this->requestFactory->get(
448            wfAppendQuery(
449                $this->url . '/lexserver/lexicon/updateentry',
450                [ 'entry' => $json ]
451            )
452        );
453        return FormatJson::parse( $responseString, FormatJson::FORCE_ASSOC );
454    }
455
456    /**
457     * Deletes a lexicon entry item
458     *
459     * @since 0.1.8
460     * @param string $lexiconName
461     * @param int $identity
462     * @return Status
463     */
464    public function deleteLexiconEntry(
465        string $lexiconName,
466        int $identity
467    ): Status {
468        $responseString = $this->requestFactory->get(
469            $this->url . '/lexserver/lexicon/delete_entry/' .
470           urlencode( $lexiconName ) . '/' . $identity
471        );
472        // If successful, returns something like:
473        // deleted entry id '11' from lexicon 'sv'
474        // where the lexicon is the second part of the lexicon name:lang.
475        if ( mb_ereg_match(
476            "deleted entry id '(.+)' from lexicon '(.+)'",
477            $responseString
478        ) ) {
479            return Status::newGood( $responseString );
480        }
481        return Status::newFatal( $responseString );
482    }
483
484    /**
485     * {
486     *   "strn": "flesk",
487     *   "language": "sv-se",
488     *   "partOfSpeech": "NN",
489     *   "morphology": "SIN-PLU|IND|NOM|NEU",
490     *   "wordParts": "flesk",
491     *   "lemma": {
492     *     "strn": "flesk",
493     *     "reading": "",
494     *     "paradigm": "s7n-övriga ex träd"
495     *   },
496     *   "transcriptions": [
497     *     {
498     *       "strn": "\" f l E s k",
499     *       "language": "sv-se"
500     *     }
501     *   ]
502     * }
503     *
504     * @since 0.1.8
505     * @param string $lexiconName E.g. 'wikispeech_lexserver_testdb:sv'
506     * @param string $json A single entry object item.
507     *  I.e. not an array as returned by {@link lookupLexiconEntries}.
508     * @return Status value set to int identity of newly created entry.
509     * @throws SpeechoidConnectorException
510     */
511    public function addLexiconEntry(
512        string $lexiconName,
513        string $json
514    ): Status {
515        $responseString = $this->requestFactory->get(
516            wfAppendQuery(
517                $this->url . '/lexserver/lexicon/addentry',
518                [
519                    'lexicon_name' => $lexiconName,
520                    'entry' => $json
521                ]
522            )
523        );
524        // @todo how do we know if this was successful? Always return 200
525
526        $deserializedStatus = FormatJson::parse( $responseString, FormatJson::FORCE_ASSOC );
527        if ( !$deserializedStatus->isOK() ) {
528            throw new SpeechoidConnectorException( "Failed to parse response as JSON: $responseString" );
529        }
530        /** @var array $deserializedResponse */
531        $deserializedResponse = $deserializedStatus->getValue();
532        if ( !array_key_exists( 'ids', $deserializedResponse ) ) {
533            return Status::newFatal( 'Unexpected Speechoid response. No `ids` field.' );
534        }
535        /** @var array $ids */
536        $ids = $deserializedResponse['ids'];
537        $numberOfIdentities = count( $ids );
538        if ( $numberOfIdentities === 0 ) {
539            return Status::newFatal( 'Unexpected Speechoid response. No `ids` values.' );
540        } elseif ( $numberOfIdentities > 1 ) {
541            return Status::newFatal( 'Unexpected Speechoid response. Multiple `ids` values.' );
542        }
543        if ( !is_int( $ids[0] ) ) {
544            return Status::newFatal( 'Unexpected Speechoid response. Ids[0] is a non integer value.' );
545        }
546        return Status::newGood( $ids[0] );
547    }
548
549    /**
550     * Convert a string to IPA from the symbolset used for the given language
551     *
552     * @since 0.1.10
553     * @param string $string
554     * @param string $language Tell Speechoid to use the symbol set
555     *  for this language.
556     * @return Status
557     */
558    public function toIpa( string $string, string $language ): Status {
559        return $this->map( $string, $language, true );
560    }
561
562    /**
563     * Convert a string to or from IPA
564     *
565     * @since 0.1.8
566     * @param string $string
567     * @param string $language Tell Speechoid to use the symbol set
568     *  for this language.
569     * @param bool $toIpa Converts to IPA if true, otherwise from IPA
570     * @return Status
571     */
572    private function map( string $string, string $language, bool $toIpa ): Status {
573        // Get the symbol set to convert to
574        $lexicon = $this->findLexiconByLanguage( $language );
575        $symbolsetRequestUrl = "$this->url/lexserver/lexicon/info/$lexicon";
576        $symbolSetResponse = $this->requestFactory->get( $symbolsetRequestUrl );
577        $symbolSetStatus = FormatJson::parse(
578            $symbolSetResponse,
579            FormatJson::FORCE_ASSOC
580        );
581        if ( !$symbolSetStatus->isOK() ) {
582            return Status::newFatal(
583                "Failed to parse response from $symbolsetRequestUrl as JSON: " .
584                $symbolSetResponse
585            );
586        }
587        $symbolSet = $symbolSetStatus->getValue()['symbolSetName'];
588
589        if ( $toIpa ) {
590            $from = $symbolSet;
591            $to = 'ipa';
592        } else {
593            $from = 'ipa';
594            $to = $symbolSet;
595        }
596        $mapRequestUrl = "$this->symbolSetUrl/mapper/map/$from/$to/" .
597            rawurlencode( $string );
598        $mapResponse = $this->requestFactory->get( $mapRequestUrl );
599        $mapStatus = FormatJson::parse( $mapResponse, FormatJson::FORCE_ASSOC );
600        if ( !$mapStatus->isOK() ) {
601            return Status::newFatal(
602                "Failed to parse response from $mapRequestUrl as JSON: " .
603                $mapResponse
604            );
605        }
606        return Status::newGood( $mapStatus->getValue()['Result'] );
607    }
608
609    /**
610     * Convert a string from IPA to the symbolset used for the given language
611     *
612     * @since 0.1.10
613     * @param string $string
614     * @param string $language Tell Speechoid to use the symbol set
615     *  for this language.
616     * @return Status
617     */
618    public function fromIpa( string $string, string $language ): Status {
619        return $this->map( $string, $language, false );
620    }
621
622    /**
623     * Queue is overloaded if there are already the maximum number of current
624     * connections processed by the backend at the same time as the queue
625     * contains more than X connections waiting for their turn,
626     * where X =
627     * WikispeechSpeechoidHaproxyOverloadFactor multiplied with
628     * the maximum number of current connections to the backend.
629     *
630     * @see HaproxyStatusParser::isQueueOverloaded()
631     * @since 0.1.10
632     * @return bool Whether or not connection queue is overloaded
633     */
634    public function isQueueOverloaded(): bool {
635        $statsResponse = $this->requestFactory->get(
636            $this->haproxyStatsUrl . '/stats;csv;norefresh'
637        );
638        $parser = new HaproxyStatusParser( $statsResponse );
639        return $parser->isQueueOverloaded(
640            $this->config->get( 'WikispeechSpeechoidHaproxyFrontendPxName' ),
641            $this->config->get( 'WikispeechSpeechoidHaproxyFrontendSvName' ),
642            $this->config->get( 'WikispeechSpeechoidHaproxyBackendPxName' ),
643            $this->config->get( 'WikispeechSpeechoidHaproxyBackendSvName' ),
644            floatval( $this->config->get( 'WikispeechSpeechoidHaproxyOverloadFactor' ) )
645        );
646    }
647
648    /**
649     * Counts number of requests that currently could be sent to the queue
650     * and immediately would be passed down to backend.
651     *
652     * If this value is greater than 0, then the next request sent via the queue
653     * will be immediately processed by the backend.
654     *
655     * If this value is less than 1, then the next connection will be queued,
656     * given that the currently processing requests will not have had time to finish by then.
657     *
658     * If this value is less than 1, then the value is the inverse size of the known queue.
659     * Note that the OS on the HAProxy server might be buffering connections in the TCP-stack
660     * and that HAProxy will not be aware of such connections. A negative number might therefor
661     * not represent a perfect count of current connection lined up in the queue.
662     *
663     * The idea with this function is to see if there are available resources that could
664     * be used for pre-synthesis of utterances during otherwise idle time.
665     *
666     * @see HaproxyStatusParser::getAvailableNonQueuedConnectionSlots()
667     * @since 0.1.10
668     * @return int Positive number if available slots, else inverted size of queue.
669     */
670    public function getAvailableNonQueuedConnectionSlots(): int {
671        $statsResponse = $this->requestFactory->get(
672            $this->haproxyStatsUrl . '/stats;csv;norefresh'
673        );
674        $parser = new HaproxyStatusParser( $statsResponse );
675        return $parser->getAvailableNonQueuedConnectionSlots(
676            $this->config->get( 'WikispeechSpeechoidHaproxyFrontendPxName' ),
677            $this->config->get( 'WikispeechSpeechoidHaproxyFrontendSvName' ),
678            $this->config->get( 'WikispeechSpeechoidHaproxyBackendPxName' ),
679            $this->config->get( 'WikispeechSpeechoidHaproxyBackendSvName' )
680        );
681    }
682
683    /**
684     * Converts the output from {@link parse_url} to an URL.
685     *
686     * @since 0.1.10
687     * @param array $parsedUrl
688     * @return string
689     */
690    private function unparseUrl( array $parsedUrl ): string {
691        $scheme = isset( $parsedUrl['scheme'] ) ? $parsedUrl['scheme'] . '://' : '';
692        $host = $parsedUrl['host'] ?? '';
693        $port = isset( $parsedUrl['port'] ) ? ':' . $parsedUrl['port'] : '';
694        $user = $parsedUrl['user'] ?? '';
695        $pass = isset( $parsedUrl['pass'] ) ? ':' . $parsedUrl['pass'] : '';
696        $pass = ( $user || $pass ) ? "$pass@" : '';
697        $path = $parsedUrl['path'] ?? '';
698        $query = isset( $parsedUrl['query'] ) ? '?' . $parsedUrl['query'] : '';
699        $fragment = isset( $parsedUrl['fragment'] ) ? '#' . $parsedUrl['fragment'] : '';
700        return "$scheme$user$pass$host$port$path$query$fragment";
701    }
702
703}