refactoring : ajout d'un décorateur pour lire des données sans chager la position...
[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, indefinitely=True) :
144 "exécution de la chanson avec l'alternance couplets / refrains"
145 if indefinitely == False :
146 iterable = self.verses
147 else :
148 iterable = cycle(self.verses)
149 for verse in iterable :
150 if self.songStartsWithChorus :
151 for note in self.chorus :
152 yield note, 0
153
154 #print "---partie---"
155 repeats = len(verse[0].lyrics)
156 if repeats > 1 :
157 for i in range(repeats) :
158 # couplet
159 #print "---couplet%d---" % i
160 for note in verse :
161 yield note, i
162 # refrain
163 #print "---refrain---"
164 for note in self.chorus :
165 yield note, 0
166 else :
167 for note in verse :
168 yield note, 0
169
170 def pprint(self) :
171 for note, verseIndex in self.iterNotes(indefinitely=False) :
172 print note, note.lyrics[verseIndex]
173
174
175 def assignNotesFromMidiNoteNumbers(self):
176 # TODO faire le mapping bande hauteur midi
177 for i in range(len(self.midiNoteNumbers)):
178 noteInExtendedScale = 0
179 while self.midiNoteNumbers[i] > self.scale[noteInExtendedScale] and noteInExtendedScale < len(self.scale)-1:
180 noteInExtendedScale += 1
181 if self.midiNoteNumbers[i]<self.scale[noteInExtendedScale]:
182 noteInExtendedScale -= 1
183 self.notes.append(noteInExtendedScale)
184
185
186 class Barline(object) :
187
188 def __init__(self, node, measureNotes) :
189 self.node = node
190 location = self.location = node.getAttribute('location') or 'right'
191 try :
192 repeatN = node.getElementsByTagName('repeat')[0]
193 repeat = {'direction' : repeatN.getAttribute('direction'),
194 'times' : int(repeatN.getAttribute('times') or 1)}
195 if location == 'left' :
196 repeat['note'] = measureNotes[0]
197 elif location == 'right' :
198 repeat['note'] = measureNotes[-1]
199 else :
200 raise ValueError(location)
201 self.repeat = repeat
202 except IndexError :
203 self.repeat = None
204
205 def __str__(self) :
206 if self.repeat :
207 if self.location == 'left' :
208 return '|:'
209 elif self.location == 'right' :
210 return ':|'
211 return '|'
212
213 __repr__ = __str__
214
215
216 class Tone(object) :
217
218 @staticmethod
219 def midi_to_step_alter_octave(midi):
220 stepIndex = midi % 12
221 step, alter = CHROM_SCALE[stepIndex]
222 octave = midi / 12 - 1
223 return step, alter, octave
224
225
226 def __init__(self, *args) :
227 if len(args) == 3 :
228 self.step, self.alter, self.octave = args
229 elif len(args) == 1 :
230 midi = args[0]
231 self.step, self.alter, self.octave = Tone.midi_to_step_alter_octave(midi)
232
233 @property
234 def midi(self) :
235 mid = DIATO_SCALE[self.step]
236 mid = mid + (self.octave - OCTAVE_REF) * 12
237 mid = mid + self.alter
238 return mid
239
240
241 @property
242 def name(self) :
243 name = u'%s%d' % (self.step, self.octave)
244 if self.alter < 0 :
245 alterext = 'b'
246 else :
247 alterext = '#'
248 name = '%s%s' % (name, abs(self.alter) * alterext)
249 return name
250
251 @property
252 def nom(self) :
253 name = FR_NOTES[self.step]
254 if self.alter < 0 :
255 alterext = u'♭'
256 else :
257 alterext = u'#'
258 name = u'%s%s' % (name, abs(self.alter) * alterext)
259 return name
260
261
262
263 class Note(Tone) :
264 scale = [55, 57, 59, 60, 62, 64, 65, 67, 69, 71, 72]
265
266 def __init__(self, node, divisions, previous) :
267 self.node = node
268 self.isRest = False
269 self.tiedStart = False
270 self.tiedStop = False
271
272 tieds = _getElementsByPath(node, 'notations/tied', [])
273 for tied in tieds :
274 if tied.getAttribute('type') == 'start' :
275 self.tiedStart = True
276 elif tied.getAttribute('type') == 'stop' :
277 self.tiedStop = True
278
279 self.step = _getNodeValue(node, 'pitch/step', None)
280 if self.step is not None :
281 self.octave = int(_getNodeValue(node, 'pitch/octave'))
282 self.alter = int(_getNodeValue(node, 'pitch/alter', 0))
283 elif self.node.getElementsByTagName('rest') :
284 self.isRest = True
285 else :
286 NotImplementedError(self.node.toxml('utf-8'))
287
288 self._duration = float(_getNodeValue(node, 'duration'))
289 self.lyrics = []
290 for ly in node.getElementsByTagName('lyric') :
291 self.lyrics.append(Lyric(ly))
292
293 self.divisions = divisions
294 self.previous = previous
295 self.next = None
296
297 def __str__(self) :
298 return (u'%5s %2s %2d %4s' % (self.nom, self.name, self.midi, round(self.duration, 2))).encode('utf-8')
299
300 def __repr__(self) :
301 return self.name.encode('utf-8')
302
303 def addDuration(self, note) :
304 self._duration = self.duration + note.duration
305 self.divisions = 1
306
307 @property
308 def duration(self) :
309 return self._duration / self.divisions
310
311 @property
312 def column(self):
313 return self.scale.index(self.midi)
314
315
316 class Lyric(object) :
317
318 _syllabicModifiers = {
319 'single' : u'%s',
320 'begin' : u'%s -',
321 'middle' : u'- %s -',
322 'end' : u'- %s'
323 }
324
325 def __init__(self, node) :
326 self.node = node
327 self.syllabic = _getNodeValue(node, 'syllabic', 'single')
328 self.text = _getNodeValue(node, 'text')
329
330 def syllabus(self):
331 text = self._syllabicModifiers[self.syllabic] % self.text
332 return text
333
334 def __str__(self) :
335 return self.syllabus().encode('utf-8')
336 __repr__ = __str__
337
338
339
340
341 def _getNodeValue(node, path, default=_marker) :
342 try :
343 for name in path.split('/') :
344 node = node.getElementsByTagName(name)[0]
345 return node.firstChild.nodeValue
346 except :
347 if default is _marker :
348 raise
349 else :
350 return default
351
352 def _getElementsByPath(node, path, default=_marker) :
353 try :
354 parts = path.split('/')
355 for name in parts[:-1] :
356 node = node.getElementsByTagName(name)[0]
357 return node.getElementsByTagName(parts[-1])
358 except IndexError :
359 if default is _marker :
360 raise
361 else :
362 return default
363
364 def musicXml2Song(input, partIndex=0, autoDetectChorus=True, printNotes=False) :
365 if isinstance(input, StringTypes) :
366 input = open(input, 'r')
367
368 d = parse(input)
369 doc = d.documentElement
370
371 # TODO conversion préalable score-timewise -> score-partwise
372 assert doc.nodeName == u'score-partwise'
373
374 parts = doc.getElementsByTagName('part')
375 leadPart = parts[partIndex]
376
377 part = Part(leadPart, autoDetectChorus=autoDetectChorus)
378
379 if printNotes :
380 part.pprint()
381
382 return part
383
384
385
386 def main() :
387 usage = "%prog musicXmlFile.xml [options]"
388 op = OptionParser(usage)
389 op.add_option("-i", "--part-index", dest="partIndex"
390 , default = 0
391 , help = "Index de la partie qui contient le champ.")
392
393 op.add_option("-p", '--print', dest='printNotes'
394 , action="store_true"
395 , default = False
396 , help = "Affiche les notes sur la sortie standard (debug)")
397
398 op.add_option("-c", '--no-chorus', dest='autoDetectChorus'
399 , action="store_false"
400 , default = True
401 , help = "désactive la détection du refrain")
402
403
404 options, args = op.parse_args()
405
406 if len(args) != 1 :
407 raise SystemExit(op.format_help())
408
409 musicXml2Song(args[0],
410 partIndex=options.partIndex,
411 autoDetectChorus=options.autoDetectChorus,
412 printNotes=options.printNotes)
413
414
415 if __name__ == '__main__' :
416 sys.exit(main())