Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
66.67% covered (warning)
66.67%
24 / 36
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
EspeakEngine
66.67% covered (warning)
66.67%
24 / 36
75.00% covered (warning)
75.00%
3 / 4
12.00
0.00% covered (danger)
0.00%
0 / 1
 register
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 getAudioData
n/a
0 / 0
n/a
0 / 0
3
 getSsml
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 getSupportedLanguages
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
6
 getLangsFromOutput
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3namespace MediaWiki\Extension\Phonos\Engine;
4
5use DOMDocument;
6use MediaWiki\Extension\Phonos\Exception\PhonosException;
7use Shellbox\Command\BoxedCommand;
8use WANObjectCache;
9
10/**
11 * @link http://espeak.sourceforge.net/
12 */
13class EspeakEngine extends Engine {
14
15    /** @var string */
16    protected $espeakPath;
17
18    /** @var BoxedCommand */
19    protected $espeakCommand;
20
21    protected function register(): void {
22        $this->espeakPath = $this->config->get( 'PhonosEspeak' );
23        $this->espeakCommand = $this->commandFactory
24            ->createBoxed( 'phonos' )
25            ->disableNetwork()
26            ->firejailDefaultSeccomp()
27            ->routeName( 'phonos-espeak' );
28    }
29
30    /**
31     * @inheritDoc
32     * @codeCoverageIgnore
33     * @throws PhonosException
34     */
35    public function getAudioData( AudioParams $params ): string {
36        $persistedAudio = $this->getPersistedAudio( $params );
37
38        if ( $persistedAudio ) {
39            return $persistedAudio;
40        }
41
42        $cmdArgs = [
43            $this->espeakPath,
44            // Read text input from stdin instead of a file
45            '--stdin',
46            // Interpret SSML markup, and ignore other < > tags
47            '-m',
48            // Write speech output to stdout
49            '--stdout',
50        ];
51        $out = $this->espeakCommand
52            ->params( $cmdArgs )
53            ->stdin( $this->getSsml( $params ) )
54            ->execute();
55
56        if ( $out->getExitCode() !== 0 ) {
57            throw new PhonosException( 'phonos-engine-error', [ 'eSpeak', $out->getStderr() ] );
58        }
59
60        // TODO: The above and Engine::convertWavToMp3() should ideally be refactored into
61        //   a single shell script so that there's only one round trip to Shellbox.
62        $out = $this->convertWavToMp3( $out->getStdout() );
63        $this->persistAudio( $params, $out );
64
65        return $out;
66    }
67
68    /**
69     * @inheritDoc
70     *
71     * Espeak has its own syntax for phonemes: http://espeak.sourceforge.net/phonemes.html
72     * It is supposed to also support SSML, but seems to ignore the phoneme 'ph' attribute
73     * and just uses the text.
74     */
75    public function getSsml( AudioParams $params ): string {
76        $ssmlDoc = new DOMDocument( '1.0' );
77
78        $speakNode = $ssmlDoc->createElement( 'speak' );
79        $speakNode->setAttribute( 'xmlns', 'http://www.w3.org/2001/10/synthesis' );
80        $speakNode->setAttribute( 'version', '1.1' );
81        $speakNode->setAttribute( 'xml:lang', $params->getLang() );
82        $ssmlDoc->appendChild( $speakNode );
83
84        // phoneme element spec: https://www.w3.org/TR/speech-synthesis/#S3.1.10
85        $phoneme = $ssmlDoc->createElement( 'phoneme', $params->getText() );
86        $phoneme->setAttribute( 'alphabet', 'ipa' );
87        $phoneme->setAttribute( 'ph', $params->getIpa() );
88
89        $speakNode->appendChild( $phoneme );
90
91        return $ssmlDoc->saveXML();
92    }
93
94    /**
95     * @inheritDoc
96     */
97    public function getSupportedLanguages(): ?array {
98        return $this->wanCache->getWithSetCallback(
99            $this->wanCache->makeKey( 'phonos-espeak-langs', $this->espeakPath ),
100            WANObjectCache::TTL_MONTH,
101            function () {
102                $out = $this->espeakCommand
103                    ->params( [ $this->espeakPath, '--voices' ] )
104                    ->execute();
105                if ( $out->getExitCode() !== 0 ) {
106                    throw new PhonosException( 'phonos-engine-error', [ 'eSpeak', $out->getStderr() ] );
107                }
108                return $this->getLangsFromOutput( $out->getStdout() );
109            }
110        );
111    }
112
113    /**
114     * Get languages from the espeak --voices output.
115     *
116     * The output is formatted like the following, so we split on whitespace and return the 2nd element.
117     *
118     *     Pty Language Age/Gender VoiceName          File          Other Languages
119     *      5  af             M  afrikaans            other/af
120     *      5  an             M  aragonese            europe/an
121     *      5  bg             -  bulgarian            europe/bg
122     *
123     * @param string $output The output text to parse.
124     * @return string[] Array of language codes.
125     */
126    public function getLangsFromOutput( string $output ): array {
127        $lines = explode( "\n", $output );
128        $langs = array_map( static function ( string $line ) {
129            $parts = array_values( array_filter( explode( ' ', $line ) ) );
130            $lang = $parts[1] ?? null;
131            return $lang === 'Language' ? null : $lang;
132        }, $lines );
133        return array_values( array_filter( $langs ) );
134    }
135}