Une partition peut commencer par un silence.
[minwii.git] / src / minwii / musicxml.py
1 # -*- coding: utf-8 -*-
2 """
3 conversion d'un fichier musicxml en objet song minwii.
4
5 $Id$
6 $URL$
7 """
8 import sys
9 from types import StringTypes
10 from xml.dom.minidom import parse
11 from optparse import OptionParser
12 from itertools import cycle
13 #from Song import Song
14
15 # Do4 <=> midi 60
16 OCTAVE_REF = 4
17 DIATO_SCALE = {'C' : 60,
18 'D' : 62,
19 'E' : 64,
20 'F' : 65,
21 'G' : 67,
22 'A' : 69,
23 'B' : 71}
24
25 CHROM_SCALE = { 0 : ('C', 0),
26 1 : ('C', 1),
27 2 : ('D', 0),
28 3 : ('E', -1),
29 4 : ('E', 0),
30 5 : ('F', 0),
31 6 : ('F', 1),
32 7 : ('G', 0),
33 8 : ('G', 1),
34 9 : ('A', 0),
35 10 : ('B', -1),
36 11 : ('B', 0)}
37
38
39 FR_NOTES = {'C' : u'Do',
40 'D' : u'Ré',
41 'E' : u'Mi',
42 'F' : u'Fa',
43 'G' : u'Sol',
44 'A' : u'La',
45 'B' : u'Si'}
46
47 _marker = []
48
49 class Part(object) :
50
51 def __init__(self, node, autoDetectChorus=True) :
52 self.node = node
53 self.notes = []
54 self.repeats = []
55 self.distinctNotes = []
56 self.quarterNoteDuration = 500
57 self._parseMusic()
58 self.verses = [[]]
59 self.chorus = []
60 if autoDetectChorus :
61 self._findChorus()
62 self._findVersesLoops()
63
64 def _parseMusic(self) :
65 divisions = 0
66 previous = None
67 distinctNotesDict = {}
68
69 for measureNode in self.node.getElementsByTagName('measure') :
70 measureNotes = []
71
72 # iteration sur les notes
73 # divisions de la noire
74 divisions = int(_getNodeValue(measureNode, 'attributes/divisions', divisions))
75 for noteNode in measureNode.getElementsByTagName('note') :
76 note = Note(noteNode, divisions, previous)
77 if (not note.isRest) and (not note.tiedStop) :
78 measureNotes.append(note)
79 if previous :
80 previous.next = note
81 elif note.tiedStop :
82 assert previous.tiedStart
83 previous.addDuration(note)
84 continue
85 else :
86 try :
87 previous.addDuration(note)
88 except AttributeError :
89 # can occur if part starts with a rest.
90 if previous is not None :
91 # something else is wrong.
92 raise
93 continue
94 previous = note
95
96 self.notes.extend(measureNotes)
97
98 for note in measureNotes :
99 if not distinctNotesDict.has_key(note.midi) :
100 distinctNotesDict[note.midi] = True
101 self.distinctNotes.append(note)
102
103 # barres de reprises
104 try :
105 barlineNode = measureNode.getElementsByTagName('barline')[0]
106 except IndexError :
107 continue
108
109 barline = Barline(barlineNode, measureNotes)
110 if barline.repeat :
111 self.repeats.append(barline)
112
113 self.distinctNotes.sort(lambda a, b : cmp(a.midi, b.midi))
114 sounds = self.node.getElementsByTagName('sound')
115 tempo = 120
116 for sound in sounds :
117 if sound.hasAttribute('tempo') :
118 tempo = float(sound.getAttribute('tempo'))
119 break
120
121 self.quarterNoteDuration = int(round(60000/tempo))
122
123
124
125 def _findChorus(self):
126 """ le refrain correspond aux notes pour lesquelles
127 il n'existe q'une seule syllable attachée.
128 """
129 start = stop = None
130 for i, note in enumerate(self.notes) :
131 ll = len(note.lyrics)
132 if start is None and ll == 1 :
133 start = i
134 elif start is not None and ll > 1 :
135 stop = i
136 break
137 if not (start or stop) :
138 self.chorus = []
139 else :
140 self.chorus = self.notes[start:stop]
141
142 def _findVersesLoops(self) :
143 "recherche des couplets / boucles"
144 verse = self.verses[0]
145 for note in self.notes[:-1] :
146 verse.append(note)
147 ll = len(note.lyrics)
148 nll = len(note.next.lyrics)
149 if ll != nll :
150 verse = []
151 self.verses.append(verse)
152 verse.append(self.notes[-1])
153
154
155 def iterNotes(self, indefinitely=True) :
156 "exécution de la chanson avec l'alternance couplets / refrains"
157 if indefinitely == False :
158 iterable = self.verses
159 else :
160 iterable = cycle(self.verses)
161 for verse in iterable :
162 #print "---partie---"
163 repeats = len(verse[0].lyrics)
164 if repeats > 1 :
165 for i in range(repeats) :
166 # couplet
167 #print "---couplet%d---" % i
168 for note in verse :
169 yield note, i
170 # refrain
171 #print "---refrain---"
172 for note in self.chorus :
173 yield note, 0
174 else :
175 for note in verse :
176 yield note, 0
177
178 def pprint(self) :
179 for note, verseIndex in self.iterNotes(indefinitely=False) :
180 print note, note.lyrics[verseIndex]
181
182
183 def assignNotesFromMidiNoteNumbers(self):
184 # TODO faire le mapping bande hauteur midi
185 for i in range(len(self.midiNoteNumbers)):
186 noteInExtendedScale = 0
187 while self.midiNoteNumbers[i] > self.scale[noteInExtendedScale] and noteInExtendedScale < len(self.scale)-1:
188 noteInExtendedScale += 1
189 if self.midiNoteNumbers[i]<self.scale[noteInExtendedScale]:
190 noteInExtendedScale -= 1
191 self.notes.append(noteInExtendedScale)
192
193
194 class Barline(object) :
195
196 def __init__(self, node, measureNotes) :
197 self.node = node
198 location = self.location = node.getAttribute('location') or 'right'
199 try :
200 repeatN = node.getElementsByTagName('repeat')[0]
201 repeat = {'direction' : repeatN.getAttribute('direction'),
202 'times' : int(repeatN.getAttribute('times') or 1)}
203 if location == 'left' :
204 repeat['note'] = measureNotes[0]
205 elif location == 'right' :
206 repeat['note'] = measureNotes[-1]
207 else :
208 raise ValueError(location)
209 self.repeat = repeat
210 except IndexError :
211 self.repeat = None
212
213 def __str__(self) :
214 if self.repeat :
215 if self.location == 'left' :
216 return '|:'
217 elif self.location == 'right' :
218 return ':|'
219 return '|'
220
221 __repr__ = __str__
222
223
224 class Tone(object) :
225
226 @staticmethod
227 def midi_to_step_alter_octave(midi):
228 stepIndex = midi % 12
229 step, alter = CHROM_SCALE[stepIndex]
230 octave = midi / 12 - 1
231 return step, alter, octave
232
233
234 def __init__(self, *args) :
235 if len(args) == 3 :
236 self.step, self.alter, self.octave = args
237 elif len(args) == 1 :
238 midi = args[0]
239 self.step, self.alter, self.octave = Tone.midi_to_step_alter_octave(midi)
240
241 @property
242 def midi(self) :
243 mid = DIATO_SCALE[self.step]
244 mid = mid + (self.octave - OCTAVE_REF) * 12
245 mid = mid + self.alter
246 return mid
247
248
249 @property
250 def name(self) :
251 name = u'%s%d' % (self.step, self.octave)
252 if self.alter < 0 :
253 alterext = 'b'
254 else :
255 alterext = '#'
256 name = '%s%s' % (name, abs(self.alter) * alterext)
257 return name
258
259 @property
260 def nom(self) :
261 name = FR_NOTES[self.step]
262 if self.alter < 0 :
263 alterext = u'♭'
264 else :
265 alterext = u'#'
266 name = u'%s%s' % (name, abs(self.alter) * alterext)
267 return name
268
269
270
271 class Note(Tone) :
272 scale = [55, 57, 59, 60, 62, 64, 65, 67, 69, 71, 72]
273
274 def __init__(self, node, divisions, previous) :
275 self.node = node
276 self.isRest = False
277 self.tiedStart = False
278 self.tiedStop = False
279
280 tieds = _getElementsByPath(node, 'notations/tied', [])
281 for tied in tieds :
282 if tied.getAttribute('type') == 'start' :
283 self.tiedStart = True
284 elif tied.getAttribute('type') == 'stop' :
285 self.tiedStop = True
286
287 self.step = _getNodeValue(node, 'pitch/step', None)
288 if self.step is not None :
289 self.octave = int(_getNodeValue(node, 'pitch/octave'))
290 self.alter = int(_getNodeValue(node, 'pitch/alter', 0))
291 elif self.node.getElementsByTagName('rest') :
292 self.isRest = True
293 else :
294 NotImplementedError(self.node.toxml('utf-8'))
295
296 self._duration = float(_getNodeValue(node, 'duration'))
297 self.lyrics = []
298 for ly in node.getElementsByTagName('lyric') :
299 self.lyrics.append(Lyric(ly))
300
301 self.divisions = divisions
302 self.previous = previous
303 self.next = None
304
305 def __str__(self) :
306 return (u'%5s %2s %2d %4s' % (self.nom, self.name, self.midi, round(self.duration, 2))).encode('utf-8')
307
308 def __repr__(self) :
309 return self.name.encode('utf-8')
310
311 def addDuration(self, note) :
312 self._duration = self.duration + note.duration
313 self.divisions = 1
314
315 @property
316 def duration(self) :
317 return self._duration / self.divisions
318
319 @property
320 def column(self):
321 return self.scale.index(self.midi)
322
323
324 class Lyric(object) :
325
326 _syllabicModifiers = {
327 'single' : u'%s',
328 'begin' : u'%s -',
329 'middle' : u'- %s -',
330 'end' : u'- %s'
331 }
332
333 def __init__(self, node) :
334 self.node = node
335 self.syllabic = _getNodeValue(node, 'syllabic', 'single')
336 self.text = _getNodeValue(node, 'text')
337
338 def syllabus(self):
339 text = self._syllabicModifiers[self.syllabic] % self.text
340 return text
341
342 def __str__(self) :
343 return self.syllabus().encode('utf-8')
344 __repr__ = __str__
345
346
347
348
349 def _getNodeValue(node, path, default=_marker) :
350 try :
351 for name in path.split('/') :
352 node = node.getElementsByTagName(name)[0]
353 return node.firstChild.nodeValue
354 except :
355 if default is _marker :
356 raise
357 else :
358 return default
359
360 def _getElementsByPath(node, path, default=_marker) :
361 try :
362 parts = path.split('/')
363 for name in parts[:-1] :
364 node = node.getElementsByTagName(name)[0]
365 return node.getElementsByTagName(parts[-1])
366 except IndexError :
367 if default is _marker :
368 raise
369 else :
370 return default
371
372 def musicXml2Song(input, partIndex=0, autoDetectChorus=True, printNotes=False) :
373 if isinstance(input, StringTypes) :
374 input = open(input, 'r')
375
376 d = parse(input)
377 doc = d.documentElement
378
379 # TODO conversion préalable score-timewise -> score-partwise
380 assert doc.nodeName == u'score-partwise'
381
382 parts = doc.getElementsByTagName('part')
383 leadPart = parts[partIndex]
384
385 part = Part(leadPart, autoDetectChorus=autoDetectChorus)
386
387 if printNotes :
388 part.pprint()
389
390 return part
391
392
393
394 def main() :
395 usage = "%prog musicXmlFile.xml [options]"
396 op = OptionParser(usage)
397 op.add_option("-i", "--part-index", dest="partIndex"
398 , default = 0
399 , help = "Index de la partie qui contient le champ.")
400
401 op.add_option("-p", '--print', dest='printNotes'
402 , action="store_true"
403 , default = False
404 , help = "Affiche les notes sur la sortie standard (debug)")
405
406 op.add_option("-c", '--no-chorus', dest='autoDetectChorus'
407 , action="store_false"
408 , default = True
409 , help = "désactive la détection du refrain")
410
411
412 options, args = op.parse_args()
413
414 if len(args) != 1 :
415 raise SystemExit(op.format_help())
416
417 musicXml2Song(args[0],
418 partIndex=options.partIndex,
419 autoDetectChorus=options.autoDetectChorus,
420 printNotes=options.printNotes)
421
422
423 if __name__ == '__main__' :
424 sys.exit(main())