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