-
Notifications
You must be signed in to change notification settings - Fork 21
/
cli.py
executable file
·1658 lines (1348 loc) · 55.5 KB
/
cli.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# coding=utf-8
# ly2video - generate performances video from LilyPond source files
# Copyright (C) 2012 Jiri "FireTight" Szabo
# Copyright (C) 2012 Adam Spiers
# Copyright (C) 2014 Emmanuel Leguy
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# For more information about this program, please visit
# <https://github.com/aspiers/ly2video/>.
# Used to determine --version output for released versions, not
# when running from a git check-out:
import itertools
import os
import pipes
import re
import shutil
import subprocess
import sys
import traceback
from collections import namedtuple
from distutils.version import StrictVersion
from argparse import ArgumentParser
from struct import pack
from fractions import Fraction
from io import BytesIO
from PIL import Image, ImageDraw, ImageFont
import mido
from ly2video.utils import *
from ly2video.video import *
from pprint import pprint, pformat
from pexpect.popen_spawn import PopenSpawn
from pexpect import EOF
VERSION = '0.5.0'
GLOBAL_STAFF_SIZE = 20
C_MAJOR_SCALE_STEPS = [
# Maps notes of the C major scale into semi-tones above C.
# This is needed to map the pitch of ly2video.ly.tools.Pitch notes
# into MIDI pitch values within a given octave.
0, # c
2, # d
4, # e
5, # f
7, # g
9, # a
11, # b
]
NOTE_NAMES = [
"C", "C#/Db", "D", "D#/Eb", "E", "F", "F#/Gb",
"G", "G#/Ab", "A", "A#/Bb", "B"
]
NOTE_ALTERATIONS = [
'eses', 'eseh', 'es', 'eh', '', 'ih', 'is', 'isih', 'isis'
]
class LySrcLocation(object):
"""
Represents a location within a .ly file. Note that line numbers
count from 0, but are displayed counting from 1, since that
matches what editors such as emacs and vim show.
Addtional pitch info is stored.
- octave: int, 0 is c', 1 is c'', -1 is c, and so on
- notename: int, 0,1,2,3,4,5,6 for c,d,e,f,g,a,b
- alteration: Fraction, 0: no alteration, 1/2: SHARP, -1/2: FLAT, and so on
"""
__slots__ = ['filename', 'lineNum', 'columnNum', 'octave', 'notename', 'alteration']
def __init__(self, filename, lineNum, columnNum, octave, notename, alteration):
self.filename = filename
self.lineNum = lineNum
self.columnNum = columnNum
self.octave = octave
self.notename = notename
self.alteration = alteration
def __str__(self):
return "%s:%d:%d" % (self.filename, self.lineNum + 1, self.columnNum)
def coords(self):
return (self.lineNum, self.columnNum)
def getAbsolutePitch(self):
accidentalSemitoneSteps = 2 * self.alteration
pitch = (self.octave + 5) * 12 + \
C_MAJOR_SCALE_STEPS[self.notename] + \
accidentalSemitoneSteps
token = noteToken(self.octave, self.notename, self.alteration)
return pitch, token
def preprocessLyFile(lyFile, lilypondVersion):
version = getLyVersion(lyFile)
progress("Version in %s: %s" %
(lyFile, version if version else "unspecified"))
if version and version != lilypondVersion:
progress("Will convert to: %s" % lilypondVersion)
newLyFile = tmpPath('converted.ly')
if os.system("convert-ly '%s' >> '%s'" % (lyFile, newLyFile)) == 0:
return newLyFile
else:
warn("Convert of input file has failed. " +
"This could cause some problems.")
newLyFile = tmpPath('unconverted.ly')
with open(newLyFile, 'w', encoding='utf-8') as new:
with open(lyFile, encoding='utf-8') as old:
new.write(''.join(old.readlines()))
debug("new ly file is " + newLyFile)
output_divider_line()
return newLyFile
def runLilyPond(lyFileName, dpi, *args):
progress("Generating PNG and MIDI files ...")
cmd = [
"lilypond",
"--png",
"-I", runDir,
"-dmidi-extension=midi", # default on Windows is .mid
"-dresolution=%d" % dpi
] + list(args) + [lyFileName]
output_divider_line()
os.chdir(tmpPath())
# the "*** Warning..." part may be inserted INSIDE UTF-8 character sequence,
# so we add a preprocessor to remove it before decode the output to text string
output = safeRun(
cmd, exitcode=9,
preprocessor=lambda s: re.sub(
b"\n\*\*\* Warning: GenericResourceDir doesn't point to a valid resource directory\.\s*\n"
b"\s*the .+ option can be used to set this.\n\n",
b"", s)
)
output_divider_line()
progress("Generated PNG and MIDI files")
return output
def getLeftmostGrobsByMoment(output, dpi, leftPaperMarginPx):
"""
Parse the ly2video data output by LilyPond, and return a
sorted list of (moment, xcoord) tuples where each X co-ordinate
corresponds to the left-most grob at that moment.
"""
lines = output.split('\n')
leftmostGrobs = {}
currentLySrcFile = None
prefix = '^ly2video:\\s+'
for line in lines:
if not line.startswith('ly2video: '):
continue
# Allow ly2video to embed comments in output for debugging
# purposes.
if re.match(prefix + '#', line):
continue
m = re.match(prefix +
# X-extents
'\\(\\s*(-?\\d+\\.\\d+),\\s*(-?\\d+\\.\\d+)\\s*\\)'
# pitch (octave/notename/alteration)
'\\s+pitch\\s+(-?\\d+):(\\d+):(-?\\d+(?:/\\d+)?)'
# delimiter
'\\s+@\\s+'
# moment
'(-?\\d+\\.\\d+)'
# delimiter
'\\s+from\\s+'
# file:line:char
'(.+): *(\d+):(\d+)'
'\\r?$', line)
if not m:
bug("Failed to parse ly2video line:\n%s" % line)
left, right, octave, notename, alteration, moment, filename, line, column = m.groups()
if currentLySrcFile is None or currentLySrcFile != filename:
currentLySrcFile = filename
debug("Current .ly source file: %s" % currentLySrcFile)
left = float(left)
right = float(right)
octave = int(octave)
notename = int(notename)
alteration = Fraction(alteration)
centre = (left + right) / 2
moment = float(moment)
line = int(line) - 1 # LilyPond counts from 1
column = int(column)
x = int(round(staffSpacesToPixels(centre, dpi))) + leftPaperMarginPx
if moment not in leftmostGrobs or x < leftmostGrobs[moment][0]:
location = LySrcLocation(
filename, line, column, octave, notename, alteration)
leftmostGrobs[moment] = [x, location]
debug("leftmost grob (%2d, %s) for moment %9f is now x =%5d @ %3d:%d"
% (location.getAbsolutePitch()[0], location.getAbsolutePitch()[1],
moment, x, line + 1, column))
groblist = [tuple([moment] + leftmostGrobs[moment])
for moment in sorted(leftmostGrobs.keys())]
if not groblist:
bug("Didn't find any notes; something must have gone wrong "
"with the use of dump-spacetime-info.")
return groblist
def getMeasuresIndices(output, dpi, leftPaperMarginPx):
ret = []
ret.append(leftPaperMarginPx)
lines = output.split('\n')
for line in lines:
if not line.startswith('ly2videoBar: '):
continue
m = re.match('^ly2videoBar:\\s+'
# X-extents
'\\(\\s*(-?\\d+\\.\\d+),\\s*(-?\\d+\\.\\d+)\\s*\\)'
# delimiter
'\\s+@\\s+'
# moment
'(-?\\d+\\.\\d+)'
'$', line)
if not m:
bug("Failed to parse ly2videoBar line:\n%s" % line)
left, right, moment = m.groups()
left = float(left)
right = float(right)
centre = (left + right) / 2
moment = float(moment)
x = int(round(staffSpacesToPixels(centre, dpi))) + leftPaperMarginPx
if x not in ret:
ret.append(x)
ret.sort()
return ret
def findStaffLines(imageFile, lineLength):
"""
Takes a image and returns y co-ordinates of staff lines in pixels.
Params:
- imageFile: filename of image containing staff lines
- lineLength: required length of line for acceptance as staff line
Returns a list of y co-ordinates of staff lines.
"""
progress("Looking for staff lines in %s" % imageFile)
image = Image.open(imageFile)
x, ys = findStaffLinesInImage(image, lineLength)
return ys
def generateTitleFrame(titleText, width, height, ttfFile):
"""
Generates frame with name of song and its author.
Params:
- titleText: collection of name of song and its author
- width: pixel width of frames (and video)
- height: pixel height of frames (and video)
- ttfFile: path to TTF file to use for title text
"""
# create image of title screen
titleScreen = Image.new("RGB", (width, height), (255, 255, 255))
# it will draw text on titleScreen
drawer = ImageDraw.Draw(titleScreen)
# font for song's name, args - font type, size
nameFont = ImageFont.truetype(ttfFile, int(height / 15))
# font for author
authorFont = ImageFont.truetype(ttfFile, int(height / 25))
# args - position of left upper corner of rectangle (around text),
# text, font and color (black)
drawer.text(((width - nameFont.getsize(titleText.name)[0]) / 2,
(height - nameFont.getsize(titleText.name)[1]) / 2 -
height / 25),
titleText.name, font=nameFont, fill=(0, 0, 0))
# same thing
drawer.text(((width - authorFont.getsize(titleText.author)[0]) / 2,
(height / 2) + height / 25),
titleText.author, font=authorFont, fill=(0, 0, 0))
return titleScreen
def staffSpacesToPixels(ss, dpi):
staffSpacePoints = GLOBAL_STAFF_SIZE / 4
points = ss * staffSpacePoints
pointsPerInch = 72.27 # Donald Knuth's TeX points
inches = points / pointsPerInch
return inches * dpi
def mmToPixel(mm, dpi):
pixelsPerMm = dpi / 25.4
return mm * pixelsPerMm
def pixelsToMm(pixels, dpi):
inchesPerPixel = 1.0 / dpi
mmPerPixel = inchesPerPixel * 25.4
return pixels * mmPerPixel
def writePaperHeader(fFile, width, height, dpi, numOfLines, lilypondVersion):
"""
Writes own paper block into given file.
Params:
- fFile: given opened file
- width: pixel width of final video
- height: pixel height of final video
- dpi: resolution in DPI
- numOfLines: number of staff lines
- lilypondVersion: version of LilyPond
"""
fFile.write("\\paper {\n")
fFile.write(" page-breaking = #ly:one-line-breaking\n")
# make sure we have enough margin to be cropped
topPixels = height / 2
bottomPixels = height / 2
leftPixels = 200
rightPixels = 200
topMm = round(pixelsToMm(topPixels, dpi))
bottomMm = round(pixelsToMm(bottomPixels, dpi))
leftMm = round(pixelsToMm(leftPixels, dpi))
rightMm = round(pixelsToMm(rightPixels, dpi))
fFile.write(" top-margin = %d\\mm %% %d pixels\n" % (topMm, topPixels))
fFile.write(" bottom-margin = %d\\mm %% %d pixels\n" % (bottomMm, bottomPixels))
fFile.write(" left-margin = %d\\mm %% %d pixels\n" % (leftMm, leftPixels))
fFile.write(" right-margin = %d\\mm %% %d pixels\n" % (rightMm, rightPixels))
fFile.write(" oddFooterMarkup = \\markup \\null\n")
fFile.write(" evenFooterMarkup = \\markup \\null\n")
fFile.write("}\n")
fFile.write("#(set-global-staff-size %d)\n" % GLOBAL_STAFF_SIZE)
progress("Margins in mm: left=%d top=%d right=%d bottom=%d"
% (leftMm, topMm, rightMm, bottomMm))
progress("Margins in px: left=%d top=%d right=%d bottom=%d"
% (leftPixels, topPixels, rightPixels, bottomPixels))
return leftPixels
def getTemposList(midiFile):
"""
Returns a list of tempo changes in midiFile. Each tempo change is
represented as a (tick, tempoValue) tuple.
"""
midiHeader = midiFile.tracks[0]
temposList = []
for event in midiHeader:
# if it's SetTempoEvent
if event.type == 'set_tempo':
bpm = mido.tempo2bpm(event.tempo)
debug("tick %6d: tempo change to %.3f bpm" %
(event.time, bpm))
temposList.append((event.time, bpm))
return temposList
def getNotesInTicks(midiFile):
"""
Returns a tuple of the following items:
- a dict mapping ticks to a list of NoteOn events in that tick
- a dict mapping NoteOn events to their corresponding pitch bends
"""
notesInTicks = {}
pitchBends = {}
# for every channel in MIDI (except the first one)
for i in range(1, len(midiFile.tracks)):
debug("Reading MIDI track %d" % i)
track = midiFile.tracks[i]
pendingPitchBend = None
for event in track:
tick = event.time
eventClass = event.type
if pendingPitchBend:
if pendingPitchBend.tick != tick:
bug("Found orphaned pitch bend in tick %d" %
pendingPitchBend.tick)
if not eventClass == 'note_on':
bug("Pitch bend was not followed by NoteOn in tick %d" %
tick)
if event.velocity == 0:
bug("Pitch bend was followed by NoteOff")
if eventClass == 'pitchwheel':
bend = event.pitch
debug(" tick %6d: %s(%d)" %
(tick, eventClass, bend))
if bend != 0:
pendingPitchBend = event
continue
elif eventClass == 'note_on':
if event.velocity == 0:
# velocity is zero (that's basically "NoteOffEvent")
debug(" tick %6d: NoteOffEvent(%d)" %
(tick, event.note))
continue
else:
if pendingPitchBend:
pitchBends[event] = pendingPitchBend
pendingPitchBend = None
debug(" tick %6d: %s(%d)" %
(tick, eventClass, event.note))
else:
debug(" tick %6d: %s - skipping" %
(tick, eventClass))
continue
# add it into notesInTicks
if tick not in notesInTicks:
notesInTicks[tick] = []
notesInTicks[tick].append(event)
return notesInTicks, pitchBends
def make_time_abs(midiFile):
"""
Changes the time of all messages to absolute time in ticks
"""
for track in midiFile.tracks:
time = 0
for event in track:
time += event.time
event.time = time
def getMidiEvents(midiFileName):
"""
Extracts useful information from a given MIDI file and returns it.
Params:
- midiFileName: name of MIDI file (string)
Returns a tuple of the following items:
- midiResolution: the resolution of the MIDI file
- temposList: as returned by getTemposList()
- midiTicks: a sorted list of which ticks contain NoteOn events.
The last tick corresponds to the earliest
EndOfTrackEvent found across all MIDI channels.
- notesInTicks: as returned by getNotesInTicks()
- pitchBends: as returned by getNotesInTicks()
"""
# open MIDI with external library
midiFile = mido.MidiFile(midiFileName)
# and make ticks absolute
make_time_abs(midiFile)
# get MIDI resolution and header
midiResolution = midiFile.ticks_per_beat
progress("MIDI resolution (ticks per beat) is %d" % midiResolution)
temposList = getTemposList(midiFile)
output_divider_line()
notesInTicks, pitchBends = getNotesInTicks(midiFile)
# get all ticks with notes and sorts it
midiTicks = sorted(notesInTicks.keys())
# find the tick corresponding to the earliest EndOfTrackEvent
# across all MIDI channels, and append it
endOfTrack = -1
for eventsList in midiFile.tracks[1:]:
if eventsList[-1].type == 'end_of_track':
if endOfTrack < eventsList[-1].time:
endOfTrack = eventsList[-1].time
midiTicks.append(endOfTrack)
progress("MIDI: Parsing MIDI file has ended.")
return (midiResolution, temposList, midiTicks, notesInTicks, pitchBends)
def pitchToken(pitch):
pitch = int(pitch)
token = NOTE_NAMES[pitch % 12].lower()
if pitch < 4 * 12:
token += "," * (4 - pitch // 12)
else:
token += "'" * (pitch // 12 - 4)
return token
def noteToken(octave, notename, alteration):
token = NOTE_NAMES[C_MAJOR_SCALE_STEPS[notename]].lower()
token += NOTE_ALTERATIONS[4 + int(alteration * 4)]
if octave < -1:
token += "," * (-octave - 1)
else:
token += "'" * (octave + 1)
return token
def getMidiPitches(events, pitchBends):
"""
Build dicts tracking which pitches (modulo the octave)
are present in the current tick and index.
"""
midiPitches = {}
for event in events:
pitch = event.note
if pitch in pitchBends:
pitch += float(pitchBends[pitch].pitch) / 4096 # TODO:
midiPitches[pitch] = event
return midiPitches
def getNoteIndices(leftmostGrobsByMoment,
midiResolution, midiTicks, notesInTicks, pitchBends):
"""
Build a list of note indices which align with the ticks in
midiTicks, by aligning the moments in the space-time data from
LilyPond with the MIDI ticks. As a side-effect, any MIDI ticks
which do not match notes in these indices are removed from
midiTicks.
If the (leftmost) grob at a given moment is found to have no
corresponding MIDI event (e.g. when the grob is on the right-hand
side of a tie), it is skipped.
If none of the MIDI events at a given moment are found to have a
corresponding grob (e.g. when notes are hidden via \hideNotes, or
generated via a ChordName), they are skipped and the containing
tick is removed from midiTicks.
Parameters:
- leftmostGrobsByMoment:
A sorted list mapping each moment to a (x, line, column) tuple
for the left-most grob at that moment
- midiResolution
- midiTicks:
A sorted list of which ticks contain NoteOn events. The
last tick corresponds to the earliest EndOfTrackEvent found
across all MIDI channels.
- notesInTicks: as returned by getNotesInTicks()
- pitchBends: as returned by getNotesInTicks()
Returns:
- alignedNoteIndices:
a sorted list containing all the
indices aligned in order with the MIDI ticks
Side-effect:
- midiTicks is potentially trimmed down
"""
# index into list of MIDI ticks
midiIndex = 0
originalTickCount = len(midiTicks)
ticksSkipped = 0
lastChord = []
# final indices of notes
alignedNoteIndices = []
# index into list of note indices
i = 0
currentLySrcFile = None
index = None
while i < len(leftmostGrobsByMoment):
if midiIndex == len(midiTicks):
warn("Ran out of MIDI indices after %d. Current index: %d" %
(midiIndex, index))
break
moment, index, lySrcLocation = leftmostGrobsByMoment[i]
if currentLySrcFile is None or \
currentLySrcFile != lySrcLocation.filename:
currentLySrcFile = lySrcLocation.filename
debug("Current .ly source file: %s" % currentLySrcFile)
midiTick = midiTicks[midiIndex]
grobTick = int(round(moment * midiResolution * 4))
grobPitchValue, grobPitchToken = lySrcLocation.getAbsolutePitch()
if grobPitchToken == 'q':
if len(lastChord) < 2:
bug("Encountered a 'q' repeated chord token at %s "
"but didn't have a last chord saved." % lySrcLocation)
grobPitchValue = lastChord[0]
debug("%-3s @ %3d:%3d | grob(time=%3.4f, x=%5d, tick=%5d) | "
"MIDI(tick=%5d)" %
(grobPitchToken, lySrcLocation.lineNum + 1,
lySrcLocation.columnNum,
moment, index, grobTick, midiTick))
if midiTick not in notesInTicks:
# This should mean that we reached the tick corresponding
# to the final EndOfTrackEvent (see getMidiEvents()).
midiIndex += 1
if midiIndex < len(midiTicks):
bug("No notes in tick %d (%d/%d)" %
(midiTick, midiIndex, len(midiTicks)))
debug(" no notes in final tick %d" % midiTick)
continue
events = notesInTicks[midiTick]
midiPitches = getMidiPitches(events, pitchBends)
if midiTick < grobTick:
# No grobs matched this MIDI tick - maybe it was a note
# hidden by \hideNotes, or notes from a chord. So let's
# skip the tick.
ticksSkipped += 1
midiTicks.pop(midiIndex)
msg = " WARNING: skipping MIDI tick %d since " \
"no grob matched; contents:" % midiTick
for event in events:
msg += ("\n pitch %d time %d" %
(event.note, event.time))
progress(msg)
continue
i += 1
if grobTick < midiTick:
# No MIDI events found for this grob. This is probably
# due to a tied note, or a ChordName for which the
# corresponding chord was excluded from the MIDI output.
# FIXME: make sure.
debug(" No MIDI events for this grob; "
"probably a tie/ChordName - skipping grob.")
continue
# We're looking at the same point in time in the notated
# music and the MIDI file.
# FIXME: it would be better to compare the pitch of *every*
# grob, not just the leftmost one. This might result in more
# synchronization failures, but over time that could expose
# more edge cases which are not correctly handled right now.
#
# The pitch matching can also fail here if the grob is a
# ChordName, since its pitch might be in a different octave to
# the NoteOn event for the root of the chord.
if grobPitchValue not in midiPitches:
debug(" grob's pitch %d (%s) not found in midiPitches; "
"probably a tie/ChordName" % (grobPitchValue, pitchToken(grobPitchValue)))
midiPitches = [str(event.note) for event in events]
debug(" midiPitches: %s" %
" ".join(["%s (%s)" % (pitch, pitchToken(pitch))
for pitch in sorted(midiPitches)]))
if midiIndex == 0:
# This is the first MIDI event - we can't skip it,
# because then the audio and video would start in
# different places.
progress(" Starting by hovering over the first grob")
else:
progress(" Skipping grob and tick")
ticksSkipped += 1
midiTicks.pop(midiIndex)
continue
midiIndex += 1
alignedNoteIndices.append(index)
if len(midiPitches) > 1:
# technically it would be more correct to save the grob
# pitches not MIDI pitches,
lastChord = midiPitches
else:
lastChord = []
if midiIndex < len(midiTicks) - 1:
# Could happen if last note is a dangling tie?
warn("ran out of notes at MIDI tick %d (%d/%d ticks)" %
(midiTicks[midiIndex], midiIndex + 1, len(midiTicks)))
progress("sync points found: %5d\n"
" from: %5d original indices\n"
" and: %5d original ticks\n"
" last tick used: %5d\n"
" ticks skipped: %5d" %
(len(alignedNoteIndices),
len(leftmostGrobsByMoment),
originalTickCount,
midiIndex, ticksSkipped))
if len(alignedNoteIndices) < 2:
bug("Not enough synchronization points found! Aborting.")
return alignedNoteIndices
def genWavFile(timidity, midiPath):
"""
Call TiMidity++ to convert MIDI to .wav.
It has a weird problem where it converts any '.' into '_'
in the input path, so we run it on the file's relative path
not the absolute path.
"""
progress("Running TiMidity++ on %s to generate .wav audio ..." % midiPath)
dirname, midiFile = os.path.split(midiPath)
os.chdir(dirname)
cmd = [timidity, midiFile, "-Ow"]
progress(safeRun(cmd, exitcode=11))
wavExpected = midiPath.replace('.midi', '.wav')
if not os.path.exists(wavExpected):
bug("TiMidity++ failed to generate %s" % wavExpected)
return wavExpected
def generateSilence(name, length):
"""
Generates silent audio for the title screen.
author: Mister Muffin,
http://blog.mister-muffin.de/2011/06/04/generate-silent-wav/
Params:
- length: length of that silence
"""
#
channels = 2 # number of channels
bps = 16 # bits per sample
sample = 44100 # sample rate
ExtraParamSize = 0
Subchunk1Size = 16 + 2 + ExtraParamSize
Subchunk2Size = int(length * sample * channels * bps / 8)
ChunkSize = 4 + (8 + Subchunk1Size) + (8 + Subchunk2Size)
outdir = tmpPath("silence")
if not os.path.exists(outdir):
os.mkdir(outdir)
out = os.path.join(outdir, name + '.wav')
with open(out, 'wb') as fSilence:
for b in (
'RIFF'.encode('utf-8'), # ChunkID (magic) # 0x00
pack('<I', ChunkSize), # ChunkSize # 0x04
'WAVE'.encode('utf-8'), # Format # 0x08
'fmt '.encode('utf-8'), # Subchunk1ID # 0x0c
pack('<I', Subchunk1Size), # Subchunk1Size # 0x10
pack('<H', 1), # AudioFormat (1=PCM) # 0x14
pack('<H', channels), # NumChannels # 0x16
pack('<I', sample), # SampleRate # 0x18
pack('<I', bps // 8 * channels * sample), # ByteRate # 0x1c
pack('<H', bps // 8 * channels), # BlockAlign # 0x20
pack('<H', bps), # BitsPerSample # 0x22
pack('<H', ExtraParamSize), # ExtraParamSize # 0x22
'data'.encode('utf-8'), # Subchunk2ID # 0x24
pack('<I', Subchunk2Size), # Subchunk2Size # 0x28
('\0' * Subchunk2Size).encode('utf-8')):
fSilence.write(b)
return out
def parseOptions():
parser = ArgumentParser(prog=os.path.basename(sys.argv[0]))
group_inout = parser.add_argument_group(title='Input/output files')
group_inout.add_argument(
"-i", "--input", required=True,
help="input LilyPond file", metavar="INPUT-FILE")
group_inout.add_argument(
"-b", "--beatmap",
help='name of beatmap file for adjusting MIDI tempo',
metavar="BEATMAP-FILE")
group_inout.add_argument(
"--slide-show", dest="slideShow",
help="input file prefix to generate a slide show "
"(see doc/slideshow.txt)",
metavar="SLIDESHOW-PREFIX")
group_inout.add_argument(
"-o", "--output",
help='name of output video (e.g. "myNotes.avi") '
'[INPUT-FILE.avi]',
metavar="OUTPUT-FILE")
group_scroll = parser.add_argument_group(title='Scrolling')
group_scroll.add_argument(
"-m", "--cursor-margins", dest="cursorMargins",
help='width of left/right margins for scrolling '
'in pixels [%(default)s]',
metavar="WIDTH,WIDTH", default='50,100')
group_scroll.add_argument(
"-s", "--scroll-notes", dest="scrollNotes",
help='rather than scrolling the cursor from left to right, '
'scroll the notation from right to left and keep the '
'cursor in the centre',
action="store_true", default=False)
group_video = parser.add_argument_group(title='Video output')
group_video.add_argument(
"-f", "--fps", dest="fps",
help='frame rate of final video [%(default)s]',
type=float, metavar="FPS", default=30.0)
group_video.add_argument(
"-q", "--quality",
help="video encoding quality as used by ffmpeg's -q option "
'(1 is best, 31 is worst) [%(default)s]',
type=int, metavar="N", default=10)
group_video.add_argument(
"-r", "--resolution", dest="dpi",
help='resolution in DPI [%(default)s]',
metavar="DPI", type=int, default=110)
group_video.add_argument(
"-x", "--width",
help='pixel width of final video [%(default)s]',
metavar="WIDTH", type=int, default=1280)
group_video.add_argument(
"-y", "--height",
help='pixel height of final video [%(default)s]',
metavar="HEIGHT", type=int, default=720)
group_cursors = parser.add_argument_group(title='Cursors')
group_cursors.add_argument(
"-c", "--color",
help='name of the cursor color [%(default)s]',
metavar="COLOR", default="red")
group_cursors.add_argument(
"--no-cursor", dest="noteCursor",
help='do not generate a cursor',
action="store_false", default=True)
group_cursors.add_argument(
"--note-cursor", dest="noteCursor",
help='generate a cursor following the score note by note (default)',
action="store_true", default=True)
group_cursors.add_argument(
"--measure-cursor", dest="measureCursor",
help='generate a cursor following the score measure by measure',
action="store_true", default=False)
group_cursors.add_argument(
"--slide-show-cursor", dest="slideShowCursor", type=float,
help="start and end positions on the cursor in the slide show",
nargs=2, metavar=("START", "END"))
group_startend = parser.add_argument_group(
title='Start and end of the video')
group_startend.add_argument(
"-t", "--title-at-start", dest="titleAtStart",
help='adds title screen at the start of video '
'(with name of song and its author)',
action="store_true", default=False)
group_startend.add_argument(
"--title-duration", dest="titleDuration",
help='time to display the title screen [%(default)s]',
type=int, metavar="SECONDS", default=3)
group_startend.add_argument(
"--ttf", "--title-ttf", dest="titleTtfFile",
help='path to TTF font file to use in title',
metavar="FONT-FILE")
group_startend.add_argument(
"-p", "--padding",
help='time to pause on initial and final frames [%(default)s]',
metavar="SECS,SECS", default='1,1')
group_os = parser.add_argument_group(title='External programs')
group_os.add_argument(
"--windows-ffmpeg", dest="winFfmpeg",
help='(for Windows users) folder with ffpeg.exe '
'(e.g. "C:\\ffmpeg\\bin\\")',
metavar="PATH", default="")
group_os.add_argument(
"--windows-timidity", dest="winTimidity",
help='(for Windows users) folder with '
'timidity.exe (e.g. "C:\\timidity\\")',
metavar="PATH", default="")
group_debug = parser.add_argument_group(title='Debug')
group_debug.add_argument(
"-d", "--debug",
help="enable debugging mode",
action="store_true", default=False)
group_debug.add_argument(
"-k", "--keep", dest="keepTempFiles",
help="don't remove temporary working files",
action="store_true", default=False)
group_debug.add_argument(
"-v", "--version", dest="showVersion",
help="show program version",
action="store_true", default=False)
if len(sys.argv) == 1:
parser.print_help()
sys.exit(0)
options = parser.parse_args()
if options.showVersion:
showVersion()
if options.titleAtStart and options.titleTtfFile is None:
fatal("Must specify --title-ttf=FONT-FILE with --title-at-start.")
if options.debug:
setDebug()
return options
def getVersion():
try:
stdout = subprocess.check_output(["git", "describe", "--tags"],
cwd=os.path.dirname(__file__))
m = re.match('^(v\d\S+)', stdout)
if m:
return m.group(1)
except:
#exc_type, exc_value, exc_traceback = sys.exc_info()
#print("%s: %s" % (exc_type.__name__, exc_value))
pass
return VERSION
def showVersion():
print("""ly2video %s
Copyright (C) 2012-2014 Jiri "FireTight" Szabo, Adam Spiers, Emmanuel Leguy
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>.
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.""" % getVersion())
sys.exit(0)
def portableDevNull():
if sys.platform.startswith("linux"):
return "/dev/null"
elif sys.platform.startswith("win"):
return "NUL"
def applyBeatmap(src, dst, beatmap):
prog = "midi-rubato"