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