Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
25.82% |
142 / 550 |
|
12.50% |
3 / 24 |
CRAP | |
0.00% |
0 / 1 |
| Score | |
25.82% |
142 / 550 |
|
12.50% |
3 / 24 |
9211.85 | |
0.00% |
0 / 1 |
| throwCallException | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
6 | |||
| getLilypondVersion | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
3 | |||
| fetchLilypondVersion | |
46.15% |
6 / 13 |
|
0.00% |
0 / 1 |
4.41 | |||
| boxedCommand | |
33.33% |
2 / 6 |
|
0.00% |
0 / 1 |
3.19 | |||
| createDirectory | |
0.00% |
0 / 4 |
|
0.00% |
0 / 1 |
12 | |||
| getBaseUrl | |
66.67% |
2 / 3 |
|
0.00% |
0 / 1 |
2.15 | |||
| getBackend | |
84.21% |
16 / 19 |
|
0.00% |
0 / 1 |
4.06 | |||
| render | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| renderScore | |
66.67% |
62 / 93 |
|
0.00% |
0 / 1 |
48.15 | |||
| generateHTML | |
22.79% |
31 / 136 |
|
0.00% |
0 / 1 |
702.54 | |||
| generatePngAndMidi | |
1.55% |
2 / 129 |
|
0.00% |
0 / 1 |
947.99 | |||
| addScript | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| throwCompileException | |
0.00% |
0 / 12 |
|
0.00% |
0 / 1 |
30 | |||
| throwSynthException | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| extractMessage | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
42 | |||
| imageSize | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| getPaperCode | |
0.00% |
0 / 8 |
|
0.00% |
0 / 1 |
6 | |||
| embedLilypondCode | |
0.00% |
0 / 14 |
|
0.00% |
0 / 1 |
12 | |||
| generateAudio | |
0.00% |
0 / 48 |
|
0.00% |
0 / 1 |
42 | |||
| getDurationFromScriptOutput | |
0.00% |
0 / 3 |
|
0.00% |
0 / 1 |
6 | |||
| recordShellout | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
2 | |||
| recordError | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| eraseDirectory | |
28.57% |
2 / 7 |
|
0.00% |
0 / 1 |
6.28 | |||
| debug | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| 1 | <?php |
| 2 | /* |
| 3 | Score, a MediaWiki extension for rendering musical scores with LilyPond. |
| 4 | Copyright © 2011 Alexander Klauer |
| 5 | |
| 6 | This program is free software: you can redistribute it and/or modify |
| 7 | it under the terms of the GNU General Public License as published by |
| 8 | the Free Software Foundation, either version 3 of the License, or |
| 9 | (at your option) any later version. |
| 10 | |
| 11 | This program is distributed in the hope that it will be useful, |
| 12 | but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | GNU General Public License for more details. |
| 15 | |
| 16 | You should have received a copy of the GNU General Public License |
| 17 | along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 18 | |
| 19 | To contact the author: |
| 20 | <Graf.Zahl@gmx.net> |
| 21 | http://en.wikisource.org/wiki/User_talk:GrafZahl |
| 22 | https://github.com/TheCount/score |
| 23 | |
| 24 | */ |
| 25 | |
| 26 | namespace MediaWiki\Extension\Score; |
| 27 | |
| 28 | use Exception; |
| 29 | use MediaWiki\Html\Html; |
| 30 | use MediaWiki\Json\FormatJson; |
| 31 | use MediaWiki\Logger\LoggerFactory; |
| 32 | use MediaWiki\MediaWikiServices; |
| 33 | use MediaWiki\Message\Message; |
| 34 | use MediaWiki\Output\StreamFile; |
| 35 | use MediaWiki\Parser\Parser; |
| 36 | use MediaWiki\Parser\PPFrame; |
| 37 | use MediaWiki\Status\Status; |
| 38 | use MediaWiki\Title\Title; |
| 39 | use MediaWiki\WikiMap\WikiMap; |
| 40 | use Shellbox\Command\BoxedCommand; |
| 41 | use Wikimedia\FileBackend\FileBackend; |
| 42 | use Wikimedia\FileBackend\FSFileBackend; |
| 43 | use Wikimedia\LockManager\NullLockManager; |
| 44 | use Wikimedia\ScopedCallback; |
| 45 | |
| 46 | /** |
| 47 | * Score class. |
| 48 | */ |
| 49 | class Score { |
| 50 | /** |
| 51 | * Version for cache invalidation. |
| 52 | */ |
| 53 | private const CACHE_VERSION = 1; |
| 54 | |
| 55 | /** |
| 56 | * Cache expiry time for the LilyPond version |
| 57 | */ |
| 58 | private const VERSION_TTL = 3600; |
| 59 | |
| 60 | /** |
| 61 | * Supported score languages. |
| 62 | */ |
| 63 | private const SUPPORTED_LANGS = [ 'lilypond', 'ABC' ]; |
| 64 | |
| 65 | /** |
| 66 | * Supported note languages. |
| 67 | * Key is LilyPond filename. Value is language code |
| 68 | */ |
| 69 | public const SUPPORTED_NOTE_LANGUAGES = [ |
| 70 | 'arabic' => 'ar', |
| 71 | 'catalan' => 'ca', |
| 72 | 'deutsch' => 'de', |
| 73 | 'english' => 'en', |
| 74 | 'espanol' => 'es', |
| 75 | 'italiano' => 'it', |
| 76 | 'nederlands' => 'nl', |
| 77 | 'norsk' => 'no', |
| 78 | 'portugues' => 'pt', |
| 79 | 'suomi' => 'fi', |
| 80 | 'svenska' => 'sv', |
| 81 | 'vlaams' => 'vls', |
| 82 | ]; |
| 83 | |
| 84 | /** |
| 85 | * Default language used for notes. |
| 86 | */ |
| 87 | private const DEFAULT_NOTE_LANGUAGE = 'nederlands'; |
| 88 | |
| 89 | /** |
| 90 | * LilyPond version string. |
| 91 | * It defaults to null and is set the first time it is required. |
| 92 | * @var string|null |
| 93 | */ |
| 94 | private static $lilypondVersion = null; |
| 95 | |
| 96 | /** |
| 97 | * FileBackend instance cache |
| 98 | * @var FileBackend|null |
| 99 | */ |
| 100 | private static $backend; |
| 101 | |
| 102 | /** |
| 103 | * Throws proper ScoreException in case of failed shell executions. |
| 104 | * |
| 105 | * @param string $message Message key to display |
| 106 | * @param array $params Message parameters |
| 107 | * @param string $output collected output from stderr. |
| 108 | * @param string|bool $factoryDir The factory directory to replace with "..." |
| 109 | * |
| 110 | * @throws ScoreException always. |
| 111 | */ |
| 112 | private static function throwCallException( $message, array $params, $output, $factoryDir = false ): never { |
| 113 | /* clean up the output a bit */ |
| 114 | if ( $factoryDir ) { |
| 115 | $output = str_replace( $factoryDir, '...', $output ); |
| 116 | } |
| 117 | $params[] = Html::rawElement( 'pre', |
| 118 | // Error messages from LilyPond & abc2ly are always English |
| 119 | [ 'lang' => 'en', 'dir' => 'ltr' ], |
| 120 | htmlspecialchars( $output ) |
| 121 | ); |
| 122 | throw new ScoreException( $message, $params ); |
| 123 | } |
| 124 | |
| 125 | /** |
| 126 | * @return string |
| 127 | * @throws ScoreException if LilyPond could not be executed properly. |
| 128 | */ |
| 129 | public static function getLilypondVersion() { |
| 130 | global $wgScoreLilyPondFakeVersion; |
| 131 | |
| 132 | if ( strlen( $wgScoreLilyPondFakeVersion ) ) { |
| 133 | return $wgScoreLilyPondFakeVersion; |
| 134 | } |
| 135 | if ( self::$lilypondVersion === null ) { |
| 136 | // In case fetchLilypondVersion() throws an exception |
| 137 | self::$lilypondVersion = 'disabled'; |
| 138 | |
| 139 | $cache = MediaWikiServices::getInstance()->getLocalServerObjectCache(); |
| 140 | self::$lilypondVersion = $cache->getWithSetCallback( |
| 141 | $cache->makeGlobalKey( __CLASS__, 'lilypond-version' ), |
| 142 | self::VERSION_TTL, |
| 143 | function () { |
| 144 | return self::fetchLilypondVersion(); |
| 145 | } |
| 146 | ); |
| 147 | } |
| 148 | |
| 149 | return self::$lilypondVersion; |
| 150 | } |
| 151 | |
| 152 | /** |
| 153 | * Determines the version of LilyPond in use without caching |
| 154 | * |
| 155 | * @throws ScoreException if LilyPond could not be executed properly. |
| 156 | * @return string |
| 157 | */ |
| 158 | private static function fetchLilypondVersion() { |
| 159 | global $wgScoreLilyPond, $wgScoreEnvironment; |
| 160 | |
| 161 | $result = self::boxedCommand() |
| 162 | ->routeName( 'score-lilypond' ) |
| 163 | ->params( $wgScoreLilyPond, '--version' ) |
| 164 | ->environment( $wgScoreEnvironment ) |
| 165 | ->includeStderr() |
| 166 | ->execute(); |
| 167 | self::recordShellout( 'lilypond_version' ); |
| 168 | |
| 169 | $output = $result->getStdout(); |
| 170 | if ( $result->getExitCode() != 0 ) { |
| 171 | self::throwCallException( 'score-versionerr', [], $output ); |
| 172 | } |
| 173 | |
| 174 | if ( !preg_match( '/^GNU LilyPond (\S+)/', $output, $m ) ) { |
| 175 | self::throwCallException( 'score-versionerr', [], $output ); |
| 176 | } |
| 177 | return $m[1]; |
| 178 | } |
| 179 | |
| 180 | /** |
| 181 | * Return a BoxedCommand object, or throw an exception if shell execution is |
| 182 | * disabled. |
| 183 | * |
| 184 | * The check for $wgScoreDisableExec should be redundant with checks in the |
| 185 | * callers, since the callers generally need to avoid writing input files. |
| 186 | * We check twice to be safe. |
| 187 | * |
| 188 | * @return BoxedCommand |
| 189 | * @throws ScoreDisabledException |
| 190 | */ |
| 191 | private static function boxedCommand() { |
| 192 | global $wgScoreDisableExec; |
| 193 | |
| 194 | if ( $wgScoreDisableExec ) { |
| 195 | throw new ScoreDisabledException(); |
| 196 | } |
| 197 | |
| 198 | return MediaWikiServices::getInstance()->getShellCommandFactory() |
| 199 | ->createBoxed( 'score' ) |
| 200 | ->disableNetwork() |
| 201 | ->firejailDefaultSeccomp(); |
| 202 | } |
| 203 | |
| 204 | /** |
| 205 | * Creates the specified local directory if it does not exist yet. |
| 206 | * Otherwise does nothing. |
| 207 | * |
| 208 | * @param string $path Local path to directory to be created. |
| 209 | * @param int|null $mode Chmod value of the new directory. |
| 210 | * |
| 211 | * @throws ScoreException if the directory does not exist and could not |
| 212 | * be created. |
| 213 | */ |
| 214 | private static function createDirectory( $path, $mode = null ) { |
| 215 | if ( !is_dir( $path ) ) { |
| 216 | $rc = wfMkdirParents( $path, $mode, __METHOD__ ); |
| 217 | if ( !$rc ) { |
| 218 | throw new ScoreException( 'score-nooutput', [ $path ] ); |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | /** |
| 224 | * @return bool|string |
| 225 | */ |
| 226 | private static function getBaseUrl() { |
| 227 | global $wgScorePath, $wgUploadPath; |
| 228 | if ( $wgScorePath === false ) { |
| 229 | return "{$wgUploadPath}/lilypond"; |
| 230 | } |
| 231 | |
| 232 | return $wgScorePath; |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * @return FileBackend |
| 237 | */ |
| 238 | public static function getBackend() { |
| 239 | global $wgScoreFileBackend; |
| 240 | |
| 241 | if ( $wgScoreFileBackend ) { |
| 242 | return MediaWikiServices::getInstance()->getFileBackendGroup() |
| 243 | ->get( $wgScoreFileBackend ); |
| 244 | } |
| 245 | |
| 246 | if ( !self::$backend ) { |
| 247 | global $wgScoreDirectory, $wgUploadDirectory; |
| 248 | if ( $wgScoreDirectory === false ) { |
| 249 | $dir = "{$wgUploadDirectory}/lilypond"; |
| 250 | } else { |
| 251 | $dir = $wgScoreDirectory; |
| 252 | } |
| 253 | self::$backend = new FSFileBackend( [ |
| 254 | 'name' => 'score-backend', |
| 255 | 'wikiId' => WikiMap::getCurrentWikiId(), |
| 256 | 'lockManager' => new NullLockManager( [] ), |
| 257 | 'containerPaths' => [ 'score-render' => $dir ], |
| 258 | 'fileMode' => 0777, |
| 259 | 'obResetFunc' => wfResetOutputBuffers( ... ), |
| 260 | 'streamMimeFunc' => StreamFile::contentTypeFromPath( ... ), |
| 261 | 'statusWrapper' => Status::wrap( ... ), |
| 262 | 'logger' => LoggerFactory::getInstance( 'score' ), |
| 263 | ] ); |
| 264 | } |
| 265 | |
| 266 | return self::$backend; |
| 267 | } |
| 268 | |
| 269 | /** |
| 270 | * Callback for Parser's hook on 'score' tags. Renders the score code. |
| 271 | * |
| 272 | * @param string|null $code Score code. |
| 273 | * @param array $args Array of score tag attributes. |
| 274 | * @param Parser $parser |
| 275 | * @param PPFrame $frame Expansion frame, not used by this extension. |
| 276 | * |
| 277 | * @throws ScoreException |
| 278 | * @return string Image link HTML, and possibly anchor to MIDI file. |
| 279 | */ |
| 280 | public static function render( $code, array $args, Parser $parser, PPFrame $frame ) { |
| 281 | return self::renderScore( $code, $args, $parser ); |
| 282 | } |
| 283 | |
| 284 | /** |
| 285 | * Renders the score code (LilyPond, ABC, etc.) in a <score>…</score> tag. |
| 286 | * |
| 287 | * @param string|null $code Score code. |
| 288 | * @param array $args Array of score tag attributes. |
| 289 | * @param Parser|null $parser Parser must be set when called during a wiki page parse. |
| 290 | * |
| 291 | * @throws ScoreException |
| 292 | * @return string Image link HTML, and possibly anchor to MIDI file. |
| 293 | */ |
| 294 | public static function renderScore( $code, array $args, ?Parser $parser = null ) { |
| 295 | global $wgTmpDirectory; |
| 296 | |
| 297 | // T388821 |
| 298 | if ( $code === null ) { |
| 299 | return ''; |
| 300 | } |
| 301 | |
| 302 | try { |
| 303 | $baseUrl = self::getBaseUrl(); |
| 304 | $baseStoragePath = self::getBackend()->getRootStoragePath() . '/score-render'; |
| 305 | |
| 306 | // options to self::generateHTML() |
| 307 | $options = []; |
| 308 | |
| 309 | if ( isset( $args['line_width_inches'] ) ) { |
| 310 | $lineWidthInches = abs( (float)$args[ 'line_width_inches' ] ); |
| 311 | if ( $lineWidthInches > 0 ) { |
| 312 | $options['line_width_inches'] = $lineWidthInches; |
| 313 | } |
| 314 | } |
| 315 | |
| 316 | /* temporary working directory to use */ |
| 317 | $fuzz = md5( (string)mt_rand() ); |
| 318 | $options['factory_directory'] = $wgTmpDirectory . "/MWLP.$fuzz"; |
| 319 | |
| 320 | /* Score language selection */ |
| 321 | if ( array_key_exists( 'lang', $args ) ) { |
| 322 | $options['lang'] = $args['lang']; |
| 323 | } else { |
| 324 | $options['lang'] = 'lilypond'; |
| 325 | } |
| 326 | if ( !in_array( $options['lang'], self::SUPPORTED_LANGS, true ) ) { |
| 327 | throw new ScoreException( 'score-invalidlang', |
| 328 | [ htmlspecialchars( $options['lang'] ) ] ); |
| 329 | } |
| 330 | |
| 331 | /* Override MIDI file? */ |
| 332 | if ( array_key_exists( 'override_midi', $args ) ) { |
| 333 | $file = MediaWikiServices::getInstance()->getRepoGroup() |
| 334 | ->findFile( $args['override_midi'] ); |
| 335 | if ( $file === false ) { |
| 336 | throw new ScoreException( 'score-midioverridenotfound', |
| 337 | [ htmlspecialchars( $args['override_midi'] ) ] ); |
| 338 | } |
| 339 | if ( $parser ) { |
| 340 | $parser->getOutput()->addImage( $file->getName() ); |
| 341 | } |
| 342 | |
| 343 | $options['override_midi'] = true; |
| 344 | $options['midi_file'] = $file; |
| 345 | /* Set output stuff in case audio rendering is requested */ |
| 346 | $sha1 = $file->getSha1(); |
| 347 | $audioRelDir = "override-midi/{$sha1[0]}/{$sha1[1]}"; |
| 348 | $audioRel = "$audioRelDir/$sha1.mp3"; |
| 349 | $options['audio_storage_dir'] = "$baseStoragePath/$audioRelDir"; |
| 350 | $options['audio_storage_path'] = "$baseStoragePath/$audioRel"; |
| 351 | $options['audio_url'] = "$baseUrl/$audioRel"; |
| 352 | $options['audio_sha_name'] = "$sha1.mp3"; |
| 353 | if ( $parser ) { |
| 354 | $parser->addTrackingCategory( 'score-deprecated-category' ); |
| 355 | } |
| 356 | } else { |
| 357 | $options['override_midi'] = false; |
| 358 | } |
| 359 | |
| 360 | // Raw rendering? |
| 361 | $options['raw'] = array_key_exists( 'raw', $args ); |
| 362 | |
| 363 | /* Note language selection */ |
| 364 | if ( array_key_exists( 'note-language', $args ) ) { |
| 365 | if ( !$options['raw'] ) { |
| 366 | $options['note-language'] = $args['note-language']; |
| 367 | } else { |
| 368 | throw new ScoreException( 'score-notelanguagewithraw' ); |
| 369 | } |
| 370 | } else { |
| 371 | $options['note-language'] = self::DEFAULT_NOTE_LANGUAGE; |
| 372 | } |
| 373 | if ( !isset( self::SUPPORTED_NOTE_LANGUAGES[$options['note-language']] ) ) { |
| 374 | throw new ScoreException( |
| 375 | 'score-invalidnotelanguage', [ |
| 376 | Message::plaintextParam( $options['note-language'] ), |
| 377 | Message::plaintextParam( implode( ', ', array_keys( self::SUPPORTED_NOTE_LANGUAGES ) ) ) |
| 378 | ] |
| 379 | ); |
| 380 | } |
| 381 | |
| 382 | /* Override audio file? */ |
| 383 | if ( array_key_exists( 'override_audio', $args ) |
| 384 | || array_key_exists( 'override_ogg', $args ) ) { |
| 385 | $overrideAudio = $args['override_ogg'] ?? $args['override_audio']; |
| 386 | $t = Title::newFromText( $overrideAudio, NS_FILE ); |
| 387 | if ( $t === null ) { |
| 388 | throw new ScoreException( 'score-invalidaudiooverride', |
| 389 | [ htmlspecialchars( $overrideAudio ) ] ); |
| 390 | } |
| 391 | if ( !$t->isKnown() ) { |
| 392 | throw new ScoreException( 'score-audiooverridenotfound', |
| 393 | [ htmlspecialchars( $overrideAudio ) ] ); |
| 394 | } |
| 395 | $options['override_audio'] = true; |
| 396 | $options['audio_name'] = $overrideAudio; |
| 397 | if ( $parser ) { |
| 398 | $parser->addTrackingCategory( 'score-deprecated-category' ); |
| 399 | } |
| 400 | } else { |
| 401 | $options['override_audio'] = false; |
| 402 | } |
| 403 | |
| 404 | /* Audio rendering? */ |
| 405 | $options['generate_audio'] = array_key_exists( 'sound', $args ) |
| 406 | || array_key_exists( 'vorbis', $args ); |
| 407 | |
| 408 | if ( $options['generate_audio'] && $options['override_audio'] ) { |
| 409 | throw new ScoreException( 'score-convertoverrideaudio' ); |
| 410 | } |
| 411 | |
| 412 | // Input for cache key |
| 413 | $cacheOptions = [ |
| 414 | 'code' => $code, |
| 415 | 'lang' => $options['lang'], |
| 416 | 'note-language' => $options['note-language'], |
| 417 | 'raw' => $options['raw'], |
| 418 | 'ExtVersion' => self::CACHE_VERSION, |
| 419 | 'LyVersion' => self::getLilypondVersion(), |
| 420 | ]; |
| 421 | |
| 422 | /* image file path and URL prefixes */ |
| 423 | $imageCacheName = \Wikimedia\base_convert( sha1( serialize( $cacheOptions ) ), 16, 36, 31 ); |
| 424 | $imagePrefixEnd = "{$imageCacheName[0]}/" . |
| 425 | "{$imageCacheName[1]}/$imageCacheName"; |
| 426 | $options['dest_storage_path'] = "$baseStoragePath/$imagePrefixEnd"; |
| 427 | $options['dest_url'] = "$baseUrl/$imagePrefixEnd"; |
| 428 | $options['file_name_prefix'] = substr( $imageCacheName, 0, 8 ); |
| 429 | |
| 430 | $html = self::generateHTML( $parser, $code, $options ); |
| 431 | } catch ( ScoreException $e ) { |
| 432 | if ( $parser ) { |
| 433 | if ( $e->isTracked() ) { |
| 434 | $parser->addTrackingCategory( 'score-error-category' ); |
| 435 | } |
| 436 | self::recordError( $e ); |
| 437 | } |
| 438 | $html = $e->getHtml(); |
| 439 | } |
| 440 | |
| 441 | // Mark the page as using the score extension, it makes easier |
| 442 | // to track all those pages. |
| 443 | if ( $parser ) { |
| 444 | $parser->addTrackingCategory( 'score-use-category' ); |
| 445 | } |
| 446 | |
| 447 | return $html; |
| 448 | } |
| 449 | |
| 450 | /** |
| 451 | * Generates the HTML code for a score tag. |
| 452 | * |
| 453 | * @param Parser|null $parser MediaWiki parser, provide when inside parse of wiki page |
| 454 | * @param string $code Score code. |
| 455 | * @param array $options array of rendering options. |
| 456 | * The options keys are: |
| 457 | * - factory_directory: string Path to directory in which files |
| 458 | * may be generated without stepping on someone else's |
| 459 | * toes. The directory may not exist yet. Required. |
| 460 | * - generate_audio: bool Whether to create an audio file in |
| 461 | * TimedMediaHandler. If set to true, the override_audio option |
| 462 | * must be set to false. Required. |
| 463 | * - dest_storage_path: The path of the destination directory relative to |
| 464 | * the current backend. Required. |
| 465 | * - dest_url: The default destination URL. Required. |
| 466 | * - file_name_prefix: The filename prefix used for all files |
| 467 | * in the default destination directory. Required. |
| 468 | * - lang: string Score language. Required. |
| 469 | * - override_midi: bool Whether to use a user-provided MIDI file. |
| 470 | * Required. |
| 471 | * - midi_file: If override_midi is true, MIDI file object. |
| 472 | * - audio_storage_dir: If override_midi and generate_audio are true, the |
| 473 | * backend directory in which the audio file is to be stored. |
| 474 | * - audio_storage_path: string If override_midi and generate_audio are true, |
| 475 | * the backend path at which the generated audio file is to be |
| 476 | * stored. |
| 477 | * - audio_url: string If override_midi and generate_audio is true, |
| 478 | * the URL corresponding to audio_storage_path |
| 479 | * - audio_sha_name: string If override_midi, generated audio file name. |
| 480 | * - override_audio: bool Whether to generate a wikilink to a |
| 481 | * user-provided audio file. If set to true, the sound |
| 482 | * option must be set to false. Required. |
| 483 | * - audio_name: string If override_audio is true, the audio file name |
| 484 | * - raw: bool Whether to assume raw LilyPond code. Ignored if the |
| 485 | * language is not lilypond, required otherwise. |
| 486 | * - note-language: language to use for notes (one of supported by LilyPond) |
| 487 | * |
| 488 | * @return string HTML. |
| 489 | * |
| 490 | * @throws Exception |
| 491 | * @throws ScoreException if an error occurs. |
| 492 | */ |
| 493 | private static function generateHTML( ?Parser $parser, $code, $options ) { |
| 494 | global $wgScoreOfferSourceDownload, $wgScoreUseSvg; |
| 495 | |
| 496 | $cleanup = new ScopedCallback( function () use ( $options ) { |
| 497 | self::eraseDirectory( $options['factory_directory'] ); |
| 498 | } ); |
| 499 | if ( $parser ) { |
| 500 | $parser->getOutput()->addModuleStyles( [ 'ext.score.styles' ] ); |
| 501 | $parser->getOutput()->addModules( [ 'ext.score.popup' ] ); |
| 502 | } |
| 503 | |
| 504 | $backend = self::getBackend(); |
| 505 | $fileIter = $backend->getFileList( |
| 506 | [ 'dir' => $options['dest_storage_path'], 'topOnly' => true ] ); |
| 507 | if ( $fileIter === null ) { |
| 508 | throw new ScoreException( 'score-file-list-error' ); |
| 509 | } |
| 510 | $existingFiles = []; |
| 511 | foreach ( $fileIter as $file ) { |
| 512 | $existingFiles[$file] = true; |
| 513 | } |
| 514 | |
| 515 | /* Generate SVG, PNG and MIDI files if necessary */ |
| 516 | $imageFileName = "{$options['file_name_prefix']}.png"; |
| 517 | $imageSvgFileName = "{$options['file_name_prefix']}.svg"; |
| 518 | $multi1FileName = "{$options['file_name_prefix']}-page1.png"; |
| 519 | $multi1SvgFileName = "{$options['file_name_prefix']}-1.svg"; |
| 520 | $midiFileName = "{$options['file_name_prefix']}.midi"; |
| 521 | $metaDataFileName = "{$options['file_name_prefix']}.json"; |
| 522 | $audioUrl = ''; |
| 523 | |
| 524 | if ( isset( $existingFiles[$metaDataFileName] ) ) { |
| 525 | $metaDataFile = $backend->getFileContents( |
| 526 | [ 'src' => "{$options['dest_storage_path']}/$metaDataFileName" ] ); |
| 527 | if ( $metaDataFile === false ) { |
| 528 | throw new ScoreException( 'score-nocontent', [ $metaDataFileName ] ); |
| 529 | } |
| 530 | $metaData = FormatJson::decode( $metaDataFile, true ); |
| 531 | } else { |
| 532 | $metaData = []; |
| 533 | } |
| 534 | |
| 535 | if ( |
| 536 | !isset( $existingFiles[$metaDataFileName] ) |
| 537 | || ( |
| 538 | !isset( $existingFiles[$imageFileName] ) |
| 539 | && !isset( $existingFiles[$multi1FileName] ) |
| 540 | ) |
| 541 | || ( |
| 542 | $wgScoreUseSvg |
| 543 | && !isset( $existingFiles[$multi1SvgFileName] ) |
| 544 | && !isset( $existingFiles[$imageSvgFileName] ) |
| 545 | ) |
| 546 | || ( |
| 547 | !isset( $metaData[$imageFileName]['size'] ) |
| 548 | && !isset( $metaData[$multi1FileName]['size'] ) |
| 549 | ) |
| 550 | || !isset( $existingFiles[$midiFileName] ) ) { |
| 551 | $existingFiles += self::generatePngAndMidi( $code, $options, $metaData ); |
| 552 | } |
| 553 | |
| 554 | /* Generate audio file if necessary */ |
| 555 | if ( $options['generate_audio'] ) { |
| 556 | $audioFileName = "{$options['file_name_prefix']}.mp3"; |
| 557 | if ( $options['override_midi'] ) { |
| 558 | $audioUrl = $options['audio_url']; |
| 559 | $audioPath = $options['audio_storage_path']; |
| 560 | $exists = $backend->fileExists( [ 'src' => $options['audio_storage_path'] ] ); |
| 561 | if ( |
| 562 | !$exists || |
| 563 | !isset( $metaData[ $options['audio_sha_name'] ]['length'] ) || |
| 564 | !$metaData[ $options['audio_sha_name'] ]['length'] |
| 565 | ) { |
| 566 | $backend->prepare( [ 'dir' => $options['audio_storage_dir'] ] ); |
| 567 | $sourcePath = $options['midi_file']->getLocalRefPath(); |
| 568 | self::generateAudio( $sourcePath, $options, $audioPath, $metaData ); |
| 569 | } |
| 570 | } else { |
| 571 | $audioUrl = "{$options['dest_url']}/$audioFileName"; |
| 572 | $audioPath = "{$options['dest_storage_path']}/$audioFileName"; |
| 573 | if ( |
| 574 | !isset( $existingFiles[$audioFileName] ) || |
| 575 | !isset( $metaData[$audioFileName]['length'] ) || |
| 576 | !$metaData[$audioFileName]['length'] |
| 577 | ) { |
| 578 | // Maybe we just generated it |
| 579 | $sourcePath = "{$options['factory_directory']}/file.midi"; |
| 580 | if ( !file_exists( $sourcePath ) ) { |
| 581 | // No, need to fetch it from the backend |
| 582 | $sourceFileRef = $backend->getLocalReference( |
| 583 | [ 'src' => "{$options['dest_storage_path']}/$midiFileName" ] ); |
| 584 | $sourcePath = $sourceFileRef->getPath(); |
| 585 | } |
| 586 | self::generateAudio( $sourcePath, $options, $audioPath, $metaData ); |
| 587 | } |
| 588 | } |
| 589 | } |
| 590 | |
| 591 | /* return output link(s) */ |
| 592 | if ( isset( $existingFiles[$imageFileName] ) ) { |
| 593 | [ $width, $height ] = $metaData[$imageFileName]['size']; |
| 594 | $attribs = [ |
| 595 | 'src' => "{$options['dest_url']}/$imageFileName", |
| 596 | 'width' => $width, |
| 597 | 'height' => $height, |
| 598 | 'alt' => $code, |
| 599 | ]; |
| 600 | if ( $wgScoreUseSvg ) { |
| 601 | $attribs['srcset'] = "{$options['dest_url']}/$imageSvgFileName 1x"; |
| 602 | } |
| 603 | $link = Html::rawElement( 'img', $attribs ); |
| 604 | } elseif ( isset( $existingFiles[$multi1FileName] ) ) { |
| 605 | $link = ''; |
| 606 | for ( $i = 1; ; ++$i ) { |
| 607 | $fileName = "{$options['file_name_prefix']}-page$i.png"; |
| 608 | if ( !isset( $existingFiles[$fileName] ) ) { |
| 609 | break; |
| 610 | } |
| 611 | $pageNumb = wfMessage( 'score-page' ) |
| 612 | ->inContentLanguage() |
| 613 | ->numParams( $i ) |
| 614 | ->plain(); |
| 615 | [ $width, $height ] = $metaData[$fileName]['size']; |
| 616 | $attribs = [ |
| 617 | 'src' => "{$options['dest_url']}/$fileName", |
| 618 | 'width' => $width, |
| 619 | 'height' => $height, |
| 620 | 'alt' => $pageNumb, |
| 621 | 'title' => $pageNumb, |
| 622 | 'style' => "margin-bottom:1em" |
| 623 | ]; |
| 624 | if ( $wgScoreUseSvg ) { |
| 625 | $svgFileName = "{$options['file_name_prefix']}-$i.svg"; |
| 626 | $attribs['srcset'] = "{$options['dest_url']}/$svgFileName 1x"; |
| 627 | } |
| 628 | $link .= Html::rawElement( 'img', $attribs ); |
| 629 | } |
| 630 | } else { |
| 631 | $link = ''; |
| 632 | } |
| 633 | if ( $options['generate_audio'] ) { |
| 634 | $link .= '<div style="margin-top: 3px;">' . |
| 635 | Html::rawElement( |
| 636 | 'audio', |
| 637 | [ |
| 638 | 'controls' => true |
| 639 | ], |
| 640 | Html::openElement( |
| 641 | 'source', |
| 642 | [ |
| 643 | 'src' => $audioUrl, |
| 644 | 'type' => 'audio/mpeg', |
| 645 | ] |
| 646 | ) . |
| 647 | "<div>" . |
| 648 | wfMessage( 'score-audio-alt' ) |
| 649 | ->rawParams( |
| 650 | Html::element( 'a', [ 'href' => $audioUrl ], |
| 651 | wfMessage( 'score-audio-alt-link' )->text() |
| 652 | ) |
| 653 | ) |
| 654 | ->escaped() . |
| 655 | '</div>' |
| 656 | ) . |
| 657 | '</div>'; |
| 658 | } |
| 659 | if ( $parser && $options['override_audio'] !== false ) { |
| 660 | $link .= $parser->recursiveTagParse( "[[File:{$options['audio_name']}]]" ); |
| 661 | } |
| 662 | |
| 663 | // Clean up the factory directory now |
| 664 | ScopedCallback::consume( $cleanup ); |
| 665 | |
| 666 | $attributes = [ |
| 667 | 'class' => 'mw-ext-score noresize' |
| 668 | ]; |
| 669 | |
| 670 | if ( $options['override_midi'] |
| 671 | || isset( $existingFiles["{$options['file_name_prefix']}.midi"] ) ) { |
| 672 | $attributes['data-mw-midi'] = $options['override_midi'] ? |
| 673 | $options['midi_file']->getUrl() |
| 674 | : "{$options['dest_url']}/{$options['file_name_prefix']}.midi"; |
| 675 | } |
| 676 | |
| 677 | if ( $wgScoreOfferSourceDownload |
| 678 | && isset( $existingFiles["{$options['file_name_prefix']}.ly"] ) |
| 679 | ) { |
| 680 | $attributes['data-mw-source'] = "{$options['dest_url']}/{$options['file_name_prefix']}.ly"; |
| 681 | } |
| 682 | |
| 683 | // Wrap score in div container. |
| 684 | return Html::rawElement( 'div', $attributes, $link ); |
| 685 | } |
| 686 | |
| 687 | /** |
| 688 | * Generates score PNG file(s) and a MIDI file. Stores lilypond file. |
| 689 | * |
| 690 | * @param string $code Score code. |
| 691 | * @param array $options Rendering options. They are the same as for |
| 692 | * Score::generateHTML(). |
| 693 | * @param array &$metaData array to hold information about images |
| 694 | * |
| 695 | * @return array of file names placed in the remote dest dir, with the |
| 696 | * file names in each key. |
| 697 | * |
| 698 | * @throws ScoreException on error. |
| 699 | */ |
| 700 | private static function generatePngAndMidi( $code, $options, &$metaData ) { |
| 701 | global $wgScoreLilyPond, $wgScoreTrim, $wgScoreSafeMode, $wgScoreDisableExec, |
| 702 | $wgScoreGhostscript, $wgScoreAbc2Ly, $wgImageMagickConvertCommand, $wgScoreUseSvg, |
| 703 | $wgShellboxShell, $wgPhpCli, $wgScoreEnvironment, $wgScoreImageMagickConvert; |
| 704 | |
| 705 | if ( $wgScoreDisableExec ) { |
| 706 | throw new ScoreDisabledException(); |
| 707 | } |
| 708 | |
| 709 | if ( $wgScoreSafeMode |
| 710 | && version_compare( self::getLilypondVersion(), '2.23.12', '>=' ) |
| 711 | ) { |
| 712 | throw new ScoreException( 'score-safe-mode' ); |
| 713 | } |
| 714 | |
| 715 | /* Create the working environment */ |
| 716 | $factoryDirectory = $options['factory_directory']; |
| 717 | self::createDirectory( $factoryDirectory, 0700 ); |
| 718 | $factoryMidi = "$factoryDirectory/file.midi"; |
| 719 | |
| 720 | $command = self::boxedCommand() |
| 721 | ->routeName( 'score-lilypond' ) |
| 722 | ->params( |
| 723 | $wgShellboxShell, |
| 724 | 'scripts/generatePngAndMidi.sh' ) |
| 725 | ->outputFileToFile( 'file.midi', $factoryMidi ) |
| 726 | ->outputGlobToFile( 'file', 'png', $factoryDirectory ) |
| 727 | ->outputGlobToFile( 'file', 'svg', $factoryDirectory ) |
| 728 | ->includeStderr() |
| 729 | ->environment( [ |
| 730 | 'SCORE_ABC2LY' => $wgScoreAbc2Ly, |
| 731 | 'SCORE_LILYPOND' => $wgScoreLilyPond, |
| 732 | 'SCORE_USESVG' => $wgScoreUseSvg ? 'yes' : 'no', |
| 733 | 'SCORE_SAFE' => $wgScoreSafeMode ? 'yes' : 'no', |
| 734 | 'SCORE_GHOSTSCRIPT' => $wgScoreGhostscript, |
| 735 | 'SCORE_CONVERT' => $wgScoreImageMagickConvert ?: $wgImageMagickConvertCommand, |
| 736 | 'SCORE_TRIM' => $wgScoreTrim ? 'yes' : 'no', |
| 737 | 'SCORE_PHP' => $wgPhpCli |
| 738 | ] + $wgScoreEnvironment ); |
| 739 | self::addScript( $command, 'generatePngAndMidi.sh' ); |
| 740 | if ( !$wgScoreUseSvg ) { |
| 741 | self::addScript( $command, 'extractPostScriptPageSize.php' ); |
| 742 | } |
| 743 | if ( $options['lang'] === 'lilypond' ) { |
| 744 | if ( $options['raw'] ) { |
| 745 | $lilypondCode = $code; |
| 746 | } else { |
| 747 | $paperConfig = []; |
| 748 | if ( isset( $options['line_width_inches'] ) ) { |
| 749 | $paperConfig['line-width'] = $options['line_width_inches'] . "\in"; |
| 750 | } |
| 751 | $paperCode = self::getPaperCode( $paperConfig ); |
| 752 | |
| 753 | $lilypondCode = self::embedLilypondCode( $code, $options['note-language'], $paperCode ); |
| 754 | } |
| 755 | $command->inputFileFromString( 'file.ly', $lilypondCode ); |
| 756 | } else { |
| 757 | self::addScript( $command, 'removeTagline.php' ); |
| 758 | $command->inputFileFromString( 'file.abc', $code ); |
| 759 | $command->outputFileToString( 'file.ly' ); |
| 760 | $lilypondCode = ''; |
| 761 | } |
| 762 | $result = $command->execute(); |
| 763 | self::recordShellout( 'generate_png_and_midi' ); |
| 764 | |
| 765 | if ( $result->getExitCode() != 0 ) { |
| 766 | self::throwCompileException( $result->getStdout(), $options ); |
| 767 | } |
| 768 | |
| 769 | if ( $result->wasReceived( 'file.ly' ) ) { |
| 770 | $lilypondCode = $result->getFileContents( 'file.ly' ); |
| 771 | } |
| 772 | |
| 773 | $numPages = 0; |
| 774 | for ( $i = 1; ; $i++ ) { |
| 775 | if ( !$result->wasReceived( "file-page$i.png" ) ) { |
| 776 | $numPages = $i - 1; |
| 777 | break; |
| 778 | } |
| 779 | } |
| 780 | |
| 781 | # LilyPond 2.24+ generates file.png and file.svg if there is only one page |
| 782 | if ( $wgScoreUseSvg && $result->wasReceived( 'file.svg' ) ) { |
| 783 | $numPages = 1; |
| 784 | } |
| 785 | |
| 786 | if ( $numPages === 0 ) { |
| 787 | throw new ScoreException( 'score-noimages' ); |
| 788 | } |
| 789 | |
| 790 | $needMidi = false; |
| 791 | $haveMidi = $result->wasReceived( 'file.midi' ); |
| 792 | if ( !$options['raw'] || ( $options['generate_audio'] && !$options['override_midi'] ) ) { |
| 793 | $needMidi = true; |
| 794 | if ( !$haveMidi ) { |
| 795 | throw new ScoreException( 'score-nomidi' ); |
| 796 | } |
| 797 | } |
| 798 | |
| 799 | // Create the destination directory if it doesn't exist |
| 800 | $backend = self::getBackend(); |
| 801 | $status = $backend->prepare( [ 'dir' => $options['dest_storage_path'] ] ); |
| 802 | if ( !$status->isOK() ) { |
| 803 | throw new ScoreBackendException( $status ); |
| 804 | } |
| 805 | |
| 806 | // File names of generated files |
| 807 | $newFiles = []; |
| 808 | // Backend operation batch |
| 809 | $ops = []; |
| 810 | |
| 811 | // Add LY source to its file |
| 812 | $ops[] = [ |
| 813 | 'op' => 'create', |
| 814 | 'content' => $lilypondCode, |
| 815 | 'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.ly", |
| 816 | 'headers' => [ |
| 817 | 'Content-Type' => 'text/x-lilypond; charset=utf-8' |
| 818 | ] |
| 819 | ]; |
| 820 | $newFiles["{$options['file_name_prefix']}.ly"] = true; |
| 821 | |
| 822 | if ( $needMidi ) { |
| 823 | // Add the MIDI file to the batch |
| 824 | $ops[] = [ |
| 825 | 'op' => 'store', |
| 826 | 'src' => $factoryMidi, |
| 827 | 'dst' => "{$options['dest_storage_path']}/{$options['file_name_prefix']}.midi" ]; |
| 828 | $newFiles["{$options['file_name_prefix']}.midi"] = true; |
| 829 | if ( !$status->isOK() ) { |
| 830 | throw new ScoreBackendException( $status ); |
| 831 | } |
| 832 | } |
| 833 | |
| 834 | // Add the PNG and SVG image files |
| 835 | for ( $i = 1; $i <= $numPages; ++$i ) { |
| 836 | $srcPng = "$factoryDirectory/file-page$i.png"; |
| 837 | $srcSvg = "$factoryDirectory/file-$i.svg"; |
| 838 | $dstPngFileName = "{$options['file_name_prefix']}-page$i.png"; |
| 839 | $dstSvgFileName = "{$options['file_name_prefix']}-$i.svg"; |
| 840 | if ( $numPages === 1 ) { |
| 841 | $dstPngFileName = "{$options['file_name_prefix']}.png"; |
| 842 | if ( $wgScoreUseSvg ) { |
| 843 | $srcPng = "$factoryDirectory/file.png"; |
| 844 | $srcSvg = "$factoryDirectory/file.svg"; |
| 845 | $dstSvgFileName = "{$options['file_name_prefix']}.svg"; |
| 846 | } |
| 847 | } |
| 848 | $destPng = "{$options['dest_storage_path']}/$dstPngFileName"; |
| 849 | $ops[] = [ |
| 850 | 'op' => 'store', |
| 851 | 'src' => $srcPng, |
| 852 | 'dst' => $destPng |
| 853 | ]; |
| 854 | [ $width, $height ] = self::imageSize( $srcPng ); |
| 855 | $metaData[$dstPngFileName]['size'] = [ $width, $height ]; |
| 856 | $newFiles[$dstPngFileName] = true; |
| 857 | |
| 858 | if ( $wgScoreUseSvg ) { |
| 859 | $destSvg = "{$options['dest_storage_path']}/$dstSvgFileName"; |
| 860 | $ops[] = [ |
| 861 | 'op' => 'store', |
| 862 | 'src' => $srcSvg, |
| 863 | 'dst' => $destSvg, |
| 864 | 'headers' => [ |
| 865 | 'Content-Type' => 'image/svg+xml' |
| 866 | ] |
| 867 | ]; |
| 868 | $newFiles[$dstSvgFileName] = true; |
| 869 | } |
| 870 | } |
| 871 | |
| 872 | $dstFileName = "{$options['file_name_prefix']}.json"; |
| 873 | $dest = "{$options['dest_storage_path']}/$dstFileName"; |
| 874 | $ops[] = [ |
| 875 | 'op' => 'create', |
| 876 | 'content' => FormatJson::encode( $metaData ), |
| 877 | 'dst' => $dest ]; |
| 878 | |
| 879 | $newFiles[$dstFileName] = true; |
| 880 | |
| 881 | // Execute the batch |
| 882 | $status = $backend->doQuickOperations( $ops ); |
| 883 | if ( !$status->isOK() ) { |
| 884 | throw new ScoreBackendException( $status ); |
| 885 | } |
| 886 | return $newFiles; |
| 887 | } |
| 888 | |
| 889 | /** |
| 890 | * Add an input file from the scripts directory |
| 891 | * |
| 892 | * @param BoxedCommand $command |
| 893 | * @param string $script |
| 894 | */ |
| 895 | private static function addScript( BoxedCommand $command, string $script ) { |
| 896 | $command->inputFileFromFile( "scripts/$script", |
| 897 | __DIR__ . "/../scripts/$script" ); |
| 898 | } |
| 899 | |
| 900 | /** |
| 901 | * Get error information from the output returned by scripts/generatePngAndMidi.sh |
| 902 | * and throw a relevant error. |
| 903 | * |
| 904 | * @param string $stdout |
| 905 | * @param array $options |
| 906 | * @throws ScoreException |
| 907 | */ |
| 908 | private static function throwCompileException( $stdout, $options ): never { |
| 909 | global $wgScoreDebugOutput; |
| 910 | |
| 911 | $message = self::extractMessage( $stdout ); |
| 912 | if ( !$message ) { |
| 913 | $message = [ 'score-compilererr', [] ]; |
| 914 | } elseif ( !$wgScoreDebugOutput && $message[0] === 'score-compilererr' ) { |
| 915 | // when input is not raw, we build the final lilypond file content |
| 916 | // in self::embedLilypondCode. The user input then is not inserted |
| 917 | // on the first line in the file we pass to lilypond and so we need |
| 918 | // to offset error messages back. |
| 919 | $scoreFirstLineOffset = $options['raw'] ? 0 : 7; |
| 920 | $errMsgBeautifier = new LilypondErrorMessageBeautifier( $scoreFirstLineOffset ); |
| 921 | |
| 922 | $stdout = $errMsgBeautifier->beautifyMessage( $stdout ); |
| 923 | } |
| 924 | self::throwCallException( |
| 925 | $message[0], |
| 926 | $message[1], |
| 927 | $stdout |
| 928 | ); |
| 929 | } |
| 930 | |
| 931 | /** |
| 932 | * Get error information from the output returned by scripts/synth.sh |
| 933 | * and throw a relevant error. |
| 934 | * |
| 935 | * @param string $stdout |
| 936 | * @throws ScoreException |
| 937 | */ |
| 938 | private static function throwSynthException( $stdout ): never { |
| 939 | $message = self::extractMessage( $stdout ); |
| 940 | if ( !$message ) { |
| 941 | $message = [ 'score-audioconversionerr', [] ]; |
| 942 | } |
| 943 | self::throwCallException( |
| 944 | $message[0], |
| 945 | $message[1], |
| 946 | $stdout |
| 947 | ); |
| 948 | } |
| 949 | |
| 950 | /** |
| 951 | * Parse the script return value and extract any mw-msg lines. Modify the |
| 952 | * text to remove the lines. Return the first mw-msg line as a message |
| 953 | * key and parameters. If there was no mw-msg line, return null. |
| 954 | * |
| 955 | * @param string &$stdout |
| 956 | * @return array|null |
| 957 | */ |
| 958 | private static function extractMessage( &$stdout ) { |
| 959 | $filteredStdout = ''; |
| 960 | $messageParams = []; |
| 961 | foreach ( explode( "\n", $stdout ) as $line ) { |
| 962 | if ( preg_match( '/^mw-msg:\t/', $line ) ) { |
| 963 | if ( !$messageParams ) { |
| 964 | $messageParams = array_slice( explode( "\t", $line ), 1 ); |
| 965 | } |
| 966 | } else { |
| 967 | if ( $filteredStdout !== '' ) { |
| 968 | $filteredStdout .= "\n"; |
| 969 | } |
| 970 | $filteredStdout .= $line; |
| 971 | } |
| 972 | } |
| 973 | $stdout = $filteredStdout; |
| 974 | if ( $messageParams ) { |
| 975 | $messageName = array_shift( $messageParams ); |
| 976 | // Used messages: |
| 977 | // - score-abc2lynotexecutable |
| 978 | // - score-abcconversionerr |
| 979 | // - score-notexecutable |
| 980 | // - score-compilererr |
| 981 | // - score-nops |
| 982 | // - score-scripterr |
| 983 | // - score-gs-error |
| 984 | // - score-trimerr |
| 985 | // - score-readerr |
| 986 | // - score-pregreplaceerr |
| 987 | // - score-audioconversionerr |
| 988 | // - score-soundfontnotexists |
| 989 | // - score-fallbacknotexecutable |
| 990 | // - score-lamenotexecutable |
| 991 | return [ $messageName, $messageParams ]; |
| 992 | } else { |
| 993 | return null; |
| 994 | } |
| 995 | } |
| 996 | |
| 997 | /** |
| 998 | * Extract the size of one of our generated PNG images |
| 999 | * |
| 1000 | * @param string $filename |
| 1001 | * @return array of ints (width, height) |
| 1002 | */ |
| 1003 | private static function imageSize( $filename ) { |
| 1004 | [ $width, $height ] = getimagesize( $filename ); |
| 1005 | return [ $width, $height ]; |
| 1006 | } |
| 1007 | |
| 1008 | /** |
| 1009 | * @param array $paperConfig |
| 1010 | * @return string |
| 1011 | */ |
| 1012 | private static function getPaperCode( $paperConfig = [] ) { |
| 1013 | $config = array_merge( [ |
| 1014 | "indent" => "0\\mm", |
| 1015 | ], $paperConfig ); |
| 1016 | |
| 1017 | $paperCode = "\\paper {\n"; |
| 1018 | foreach ( $config as $key => $value ) { |
| 1019 | $paperCode .= "\t$key = $value\n"; |
| 1020 | } |
| 1021 | $paperCode .= "}"; |
| 1022 | |
| 1023 | return $paperCode; |
| 1024 | } |
| 1025 | |
| 1026 | /** |
| 1027 | * Embeds simple LilyPond code in a score block. |
| 1028 | * |
| 1029 | * @param string $lilypondCode Simple LilyPond code. |
| 1030 | * @param string $noteLanguage Language of notes. |
| 1031 | * @param string $paperCode |
| 1032 | * |
| 1033 | * @return string Raw lilypond code. |
| 1034 | * |
| 1035 | * @throws ScoreException if determining the LilyPond version fails. |
| 1036 | */ |
| 1037 | private static function embedLilypondCode( $lilypondCode, $noteLanguage, $paperCode ) { |
| 1038 | $version = self::getLilypondVersion(); |
| 1039 | |
| 1040 | // Check if parameters have already been supplied (hybrid-raw mode) |
| 1041 | $options = ""; |
| 1042 | if ( strpos( $lilypondCode, "\\layout" ) === false ) { |
| 1043 | $options .= "\\layout { }\n"; |
| 1044 | } |
| 1045 | if ( strpos( $lilypondCode, "\\midi" ) === false ) { |
| 1046 | $options .= <<<LY |
| 1047 | \\midi { |
| 1048 | \\context { \Score tempoWholesPerMinute = #(ly:make-moment 100 4) } |
| 1049 | } |
| 1050 | LY; |
| 1051 | } |
| 1052 | |
| 1053 | /* Raw code. In Scheme, ##f is false and ##t is true. */ |
| 1054 | /* Set the default MIDI tempo to 100, 60 is a bit too slow */ |
| 1055 | $raw = <<<LILYPOND |
| 1056 | \\header { |
| 1057 | tagline = ##f |
| 1058 | } |
| 1059 | \\version "$version" |
| 1060 | \\language "$noteLanguage" |
| 1061 | \\score { |
| 1062 | |
| 1063 | $lilypondCode |
| 1064 | $options |
| 1065 | |
| 1066 | } |
| 1067 | $paperCode |
| 1068 | LILYPOND; |
| 1069 | |
| 1070 | return $raw; |
| 1071 | } |
| 1072 | |
| 1073 | /** |
| 1074 | * Generates an audio file from a MIDI file using fluidsynth with TiMidity as fallback. |
| 1075 | * |
| 1076 | * @param string $sourceFile The local filename of the MIDI file |
| 1077 | * @param array $options array of rendering options. |
| 1078 | * @param string $remoteDest The backend storage path to upload the audio file to |
| 1079 | * @param array &$metaData Array with metadata information |
| 1080 | * |
| 1081 | * @throws ScoreException if an error occurs. |
| 1082 | */ |
| 1083 | private static function generateAudio( $sourceFile, $options, $remoteDest, &$metaData ) { |
| 1084 | global $wgScoreFluidsynth, $wgScoreSoundfont, $wgScoreLame, $wgScoreDisableExec, |
| 1085 | $wgScoreEnvironment, $wgShellboxShell, $wgPhpCli; |
| 1086 | |
| 1087 | if ( $wgScoreDisableExec ) { |
| 1088 | throw new ScoreDisabledException(); |
| 1089 | } |
| 1090 | |
| 1091 | // Working environment |
| 1092 | $factoryDir = $options['factory_directory']; |
| 1093 | self::createDirectory( $factoryDir, 0700 ); |
| 1094 | $factoryFile = "$factoryDir/file.mp3"; |
| 1095 | |
| 1096 | // Run FluidSynth and LAME |
| 1097 | $command = self::boxedCommand() |
| 1098 | ->routeName( 'score-fluidsynth' ) |
| 1099 | ->params( |
| 1100 | $wgShellboxShell, |
| 1101 | 'scripts/synth.sh' |
| 1102 | ) |
| 1103 | ->environment( [ |
| 1104 | 'SCORE_FLUIDSYNTH' => $wgScoreFluidsynth, |
| 1105 | 'SCORE_SOUNDFONT' => $wgScoreSoundfont, |
| 1106 | 'SCORE_LAME' => $wgScoreLame, |
| 1107 | 'SCORE_PHP' => $wgPhpCli |
| 1108 | ] + $wgScoreEnvironment ) |
| 1109 | ->inputFileFromFile( 'file.midi', $sourceFile ) |
| 1110 | ->outputFileToFile( 'file.mp3', $factoryFile ) |
| 1111 | ->includeStderr() |
| 1112 | // 150 MB max. filesize (for large MIDIs) |
| 1113 | ->fileSizeLimit( 150 * 1024 * 1024 ); |
| 1114 | |
| 1115 | self::addScript( $command, 'synth.sh' ); |
| 1116 | self::addScript( $command, 'getWavDuration.php' ); |
| 1117 | |
| 1118 | $result = $command->execute(); |
| 1119 | self::recordShellout( 'generate_audio' ); |
| 1120 | |
| 1121 | if ( ( $result->getExitCode() != 0 ) || !$result->wasReceived( 'file.mp3' ) ) { |
| 1122 | self::throwSynthException( $result->getStdout() ); |
| 1123 | } |
| 1124 | |
| 1125 | // Move file to the final destination |
| 1126 | $backend = self::getBackend(); |
| 1127 | $status = $backend->doQuickOperation( [ |
| 1128 | 'op' => 'store', |
| 1129 | 'src' => $factoryFile, |
| 1130 | 'dst' => $remoteDest |
| 1131 | ] ); |
| 1132 | |
| 1133 | if ( !$status->isOK() ) { |
| 1134 | throw new ScoreBackendException( $status ); |
| 1135 | } |
| 1136 | |
| 1137 | // Create metadata json |
| 1138 | $metaData[basename( $remoteDest )]['length'] = self::getDurationFromScriptOutput( |
| 1139 | $result->getStdout() ); |
| 1140 | $dstFileName = "{$options['file_name_prefix']}.json"; |
| 1141 | $dest = "{$options['dest_storage_path']}/$dstFileName"; |
| 1142 | |
| 1143 | // Store metadata in backend |
| 1144 | $backend = self::getBackend(); |
| 1145 | $status = $backend->doQuickOperation( [ |
| 1146 | 'op' => 'create', |
| 1147 | 'content' => FormatJson::encode( $metaData ), |
| 1148 | 'dst' => $dest |
| 1149 | ] ); |
| 1150 | |
| 1151 | if ( !$status->isOK() ) { |
| 1152 | throw new ScoreBackendException( $status ); |
| 1153 | } |
| 1154 | } |
| 1155 | |
| 1156 | /** |
| 1157 | * Get the duration of the audio file from the script stdout |
| 1158 | * |
| 1159 | * @param string $stdout The script output |
| 1160 | * @return float duration in seconds |
| 1161 | */ |
| 1162 | private static function getDurationFromScriptOutput( $stdout ) { |
| 1163 | if ( preg_match( '/^wavDuration: ([0-9.]+)$/m', $stdout, $m ) ) { |
| 1164 | return (float)$m[1]; |
| 1165 | } else { |
| 1166 | return 0.0; |
| 1167 | } |
| 1168 | } |
| 1169 | |
| 1170 | /** |
| 1171 | * Track how often we do each type of shellout in statsd |
| 1172 | * |
| 1173 | * @param string $type Type of shellout |
| 1174 | */ |
| 1175 | private static function recordShellout( $type ) { |
| 1176 | $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); |
| 1177 | $statsd->increment( "score.$type" ); |
| 1178 | } |
| 1179 | |
| 1180 | /** |
| 1181 | * Track how often each error occurs in statsd |
| 1182 | * |
| 1183 | * @param ScoreException $ex |
| 1184 | */ |
| 1185 | private static function recordError( ScoreException $ex ) { |
| 1186 | $key = $ex->getStatsdKey(); |
| 1187 | if ( $key === false ) { |
| 1188 | return; |
| 1189 | } |
| 1190 | $statsd = MediaWikiServices::getInstance()->getStatsdDataFactory(); |
| 1191 | $statsd->increment( "score_error.$key" ); |
| 1192 | } |
| 1193 | |
| 1194 | /** |
| 1195 | * Deletes a local directory with no subdirectories with all files in it. |
| 1196 | * |
| 1197 | * @param string $dir Local path to the directory that is to be deleted. |
| 1198 | * |
| 1199 | * @return bool true on success, false on error |
| 1200 | */ |
| 1201 | private static function eraseDirectory( $dir ) { |
| 1202 | if ( file_exists( $dir ) ) { |
| 1203 | // @phan-suppress-next-line PhanPluginUseReturnValueInternalKnown |
| 1204 | array_map( 'unlink', glob( "$dir/*", GLOB_NOSORT ) ); |
| 1205 | $rc = rmdir( $dir ); |
| 1206 | if ( !$rc ) { |
| 1207 | self::debug( "Unable to remove directory $dir\n." ); |
| 1208 | } |
| 1209 | return $rc; |
| 1210 | } |
| 1211 | |
| 1212 | /* Nothing to do */ |
| 1213 | return true; |
| 1214 | } |
| 1215 | |
| 1216 | /** |
| 1217 | * Writes the specified message to the Score debug log. |
| 1218 | * |
| 1219 | * @param string $msg message to log. |
| 1220 | */ |
| 1221 | private static function debug( $msg ) { |
| 1222 | wfDebugLog( 'Score', $msg ); |
| 1223 | } |
| 1224 | |
| 1225 | } |