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