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