Skip to content

Commit 6fc79b6

Browse files
added test for voice separation
1 parent 1a3fe16 commit 6fc79b6

File tree

8 files changed

+567
-60
lines changed

8 files changed

+567
-60
lines changed

partitura/music_utils/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .voice_separation import estimate_voices as voice_estimation
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from .vosa import estimate_voices

partitura/music_utils/voice_separation/score_representation.py renamed to partitura/music_utils/voice_separation/_vs_utils.py

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python
22
# -*- coding: utf-8 -*-
33
"""
4-
Base classes to represent score elements
4+
Base classes to represent score elements for voice separation
55
66
TODO
77
----
@@ -12,11 +12,11 @@
1212
import numpy as np
1313
from collections import defaultdict
1414

15-
from madmom.io.midi import MIDIFile
15+
# from madmom.io.midi import MIDIFile
1616

1717
from statistics import mode
1818

19-
SCORE_DTYPES = [('pitch', 'i4'), ('onset', 'f4'), ('duration', 'f4')]
19+
from ...utils import add_field
2020

2121

2222
def sort_by_pitch(sounding_notes):
@@ -106,10 +106,6 @@ def __init__(self, pitch, onset, duration, note_id, velocity=None, voice=None):
106106

107107
self.is_grace = self.duration == 0
108108
self._grace = None
109-
110-
# I think this is no longer used... remove?
111-
self.array = np.array([(self.pitch, self.onset, self.duration)],
112-
dtype=SCORE_DTYPES)
113109
self._voice = voice
114110
self.velocity = None
115111

@@ -495,14 +491,26 @@ def has_voice_info(self):
495491

496492

497493
class VSScore(VSBaseScore):
498-
"""Class to represent a score
494+
"""Class to represent a score for voice separation
495+
496+
TODO:
497+
* rename this class or simplify to avoid overlap in naming
498+
conventions with the main package
499+
* better handle grace notes
499500
"""
500501

501502
def __init__(self, score, delete_gracenotes=True):
502503

504+
# Score
503505
self.score = score
504506

507+
# Get the IDs of the notes
508+
if 'id' not in self.score.dtype.names:
509+
self.score = add_field(self.score, [('id', int)])
510+
self.score['id'] = np.arange(len(self.score), dtype=int)
511+
505512
if delete_gracenotes:
513+
# TODO: Handle grace notes correctly
506514
self.score = self.score[score['duration'] != 0]
507515
else:
508516
grace_note_idxs = np.where(score['duration'] == 0)[0]
@@ -520,12 +528,12 @@ def __init__(self, score, delete_gracenotes=True):
520528

521529
self.notes = []
522530

523-
for i, n in enumerate(self.score):
531+
for n in self.score:
524532

525533
note = VSNote(pitch=n['pitch'],
526534
onset=n['onset'],
527535
duration=n['duration'],
528-
note_id=i,
536+
note_id=n['id'],
529537
velocity=n['velocity'] if 'velocity' in self.score.dtype.names else None)
530538

531539
self.notes.append(note)
@@ -543,31 +551,31 @@ def __init__(self, score, delete_gracenotes=True):
543551

544552
self.contigs = None
545553

546-
def write_midi(self, outfile, tempo=120, default_vel=60, skip_notes_wo_voice=True):
547-
note_info = []
554+
# def write_midi(self, outfile, tempo=120, default_vel=60, skip_notes_wo_voice=True):
555+
# note_info = []
548556

549-
if skip_notes_wo_voice:
550-
out_notes = [n for n in self.notes if n.voice is not None]
551-
else:
552-
out_notes = self.notes
553-
for n in out_notes:
557+
# if skip_notes_wo_voice:
558+
# out_notes = [n for n in self.notes if n.voice is not None]
559+
# else:
560+
# out_notes = self.notes
561+
# for n in out_notes:
554562

555-
pitch = n.pitch
556-
onset = n.onset
557-
duration = n.duration
558-
velocity = n.velocity if n.velocity is not None else default_vel
559-
channel = n.voice if n.voice is not None else 0
563+
# pitch = n.pitch
564+
# onset = n.onset
565+
# duration = n.duration
566+
# velocity = n.velocity if n.velocity is not None else default_vel
567+
# channel = n.voice if n.voice is not None else 0
560568

561-
note_info.append((onset, pitch, duration, velocity, channel))
569+
# note_info.append((onset, pitch, duration, velocity, channel))
562570

563-
note_info = np.array(sorted(note_info), dtype=np.float)
564-
# onsets start at 0 (MIDI files cannot represent anacrusis)
565-
note_info[:, 0] -= note_info[:, 0].min()
571+
# note_info = np.array(sorted(note_info), dtype=np.float)
572+
# # onsets start at 0 (MIDI files cannot represent anacrusis)
573+
# note_info[:, 0] -= note_info[:, 0].min()
566574

567-
mf = MIDIFile.from_notes(note_info,
568-
tempo=tempo,
569-
unit='beats')
570-
mf.save(outfile)
575+
# mf = MIDIFile.from_notes(note_info,
576+
# tempo=tempo,
577+
# unit='beats')
578+
# mf.save(outfile)
571579

572580
def write_txt(self, outfile, skip_notes_wo_voice=False):
573581

@@ -588,13 +596,23 @@ def write_txt(self, outfile, skip_notes_wo_voice=False):
588596

589597
@property
590598
def note_array(self):
591-
599+
"""
600+
TODO:
601+
Check that all notes have the same type of id
602+
"""
592603
out_array = []
604+
593605
for n in self.notes:
594-
out_note = (n.pitch, n.onset, n.duration, n.voice if n.voice is not None else -1)
606+
out_note = (n.pitch, n.onset, n.duration,
607+
n.voice if n.voice is not None else -1, n.id)
595608
out_array.append(out_note)
596609

597-
return np.array(out_array)
610+
return np.array(out_array, dtype=[('pitch', 'i4'),
611+
('onset', 'f4'),
612+
('duration', 'f4'),
613+
('voice', 'i4'),
614+
('id', type(self.notes[0].id))]
615+
)
598616

599617
def make_contigs(self):
600618

partitura/music_utils/voice_separation/vosa.py

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,8 @@
1111
from collections import defaultdict
1212
import numpy.ma as ma
1313

14-
try:
15-
from score_representation import VSScore, VSNote, VoiceManager, Contig, NoteStream
16-
except ModuleNotFoundError:
17-
from .score_representation import VSScore, VSNote, VoiceManager, Contig, NoteStream
18-
14+
from ._vs_utils import VSScore, VSNote, VoiceManager, Contig, NoteStream
15+
from ...utils import add_field
1916

2017
# Maximal cost of a jump (in Chew and Wu (2006) is 2 ** 31)
2118
MAX_COST = 1000
@@ -36,7 +33,7 @@ def pairwise_cost(prev, nxt):
3633
Returns
3734
-------
3835
cost : np.ndarray
39-
Cost of connecting each note in the last onset of the
36+
Cost of connecting each note in the last onset of the
4037
previous of the contig to each note in the first onset
4138
of the next contig. (index [i, j] represents the cost
4239
from connecting i in `prev` to j in `nxt`.
@@ -285,13 +282,67 @@ def estimate_voices(self):
285282
keep_loop = False
286283

287284

288-
if __name__ == '__main__':
285+
def estimate_voices(notearray):
286+
"""
287+
Voice estimation using the voice separation algorithm
288+
proposed in [1]
289+
290+
Parameters
291+
----------
292+
notearray : numpy structured array
293+
Structured array containing score information.
294+
Required fields are `pitch` (MIDI pitch),
295+
`onset` (starting time of the notes) and
296+
`duration` (duration of the notes). Additionally,
297+
It might be useful to have an `id` field containing
298+
the ID's of the notes. If this field is not contained
299+
in the array, ID's will be created for the notes.
300+
301+
Returns
302+
-------
303+
v_notearray : numpy structured array
304+
Structured array containing score information.
305+
This array contains `pitch`, `onset`, `duration`
306+
`voice` and `id`.
307+
308+
References
309+
----------
310+
[1] Elaine Chew and Xiaodan Wu (2006) Separating Voices in
311+
Polyphonic Music: A Contig Mapping Approach.
312+
313+
TODO
314+
----
315+
* Handle grace notes correctly. The current version simply
316+
deletes all grace notes.
317+
"""
318+
319+
if notearray.dtype.fields is None:
320+
raise ValueError('`notearray` must be a structured numpy array')
321+
322+
for field in ('pitch', 'onset', 'duration'):
323+
if field not in notearray.dtype.names:
324+
raise ValueError('Input array does not contain the field {0}'.format(field))
325+
326+
if 'id' not in notearray.dtype.names:
327+
print("The input score does not contain note ID's. They will be created")
328+
329+
input_array = add_field(notearray, [('id', int)])
330+
input_array['id'] = np.arange(len(notearray), dtype=int)
331+
332+
else:
333+
input_array = notearray
334+
335+
# Indices for sorting the output such that it matches
336+
# the original input (VoSA reorders the notes by onset and
337+
# by pitch)
338+
orig_idxs = np.arange(len(notearray))
289339

290-
import partitura
340+
id_sort_idxs = np.sort(input_array['id'])
291341

292-
fn = './Three-Part_Invention_No_13_(fragment).musicxml'
342+
# Perform voice separation
343+
v_notearray = VoSA(input_array[id_sort_idxs]).note_array
293344

294-
xml = partitura.musicxml.xml_to_notearray(fn)
345+
# Sort output according to the note id's
346+
v_notearray = v_notearray[v_notearray['id'].argsort()]
295347

296-
vsa = VoSA(xml)
297-
vsa.write_midi('test.mid')
348+
return v_notearray[np.argsort(orig_idxs[id_sort_idxs])]

partitura/utils.py

Lines changed: 53 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
#!/usr/bin/env python
22

3+
import numpy as np
34
from functools import wraps
45
from collections import defaultdict
56

7+
68
def cached_property(func, name=None):
79
"""
810
cached_property(func, name=None) -> a descriptor
@@ -23,14 +25,14 @@ def cached_property(func, name=None):
2325
>>> # subsequent access of foo (fast):
2426
>>> x.foo
2527
"""
26-
if name is None :
28+
if name is None:
2729
name = func.__name__
2830

2931
@wraps(func)
3032
def _get(self):
31-
try :
33+
try:
3234
return self.__dict__[name]
33-
except KeyError :
35+
except KeyError:
3436
self.__dict__[name] = func(self)
3537
return self.__dict__[name]
3638

@@ -39,13 +41,13 @@ def _set(self, value):
3941
self.__dict__[name] = value
4042

4143
@wraps(func)
42-
def _del(self ):
44+
def _del(self):
4345
self.__dict__.pop(name, None)
4446

4547
return property(_get, _set, _del)
4648

4749

48-
def iter_subclasses(cls, _seen = None):
50+
def iter_subclasses(cls, _seen=None):
4951
"""
5052
iter_subclasses(cls)
5153
@@ -69,14 +71,15 @@ def iter_subclasses(cls, _seen = None):
6971
>>> [cls.__name__ for cls in iter_subclasses(object)] #doctest: +ELLIPSIS
7072
['type', ...'tuple', ...]
7173
"""
72-
74+
7375
if not isinstance(cls, type):
7476
raise TypeError('iter_subclasses must be called with '
7577
'new-style classes, not %.100r' % cls)
76-
if _seen is None: _seen = set()
78+
if _seen is None:
79+
_seen = set()
7780
try:
7881
subs = cls.__subclasses__()
79-
except TypeError: # fails only when cls is type
82+
except TypeError: # fails only when cls is type
8083
subs = cls.__subclasses__(cls)
8184
for sub in subs:
8285
if sub not in _seen:
@@ -90,6 +93,7 @@ class ComparableMixin(object):
9093
"""source:
9194
http://regebro.wordpress.com/2010/12/13/python-implementing-rich-comparison-the-correct-way/
9295
"""
96+
9397
def _compare(self, other, method):
9498
try:
9599
return method(self._cmpkey(), other._cmpkey())
@@ -99,22 +103,22 @@ def _compare(self, other, method):
99103
return NotImplemented
100104

101105
def __lt__(self, other):
102-
return self._compare(other, lambda s,o: s < o)
106+
return self._compare(other, lambda s, o: s < o)
103107

104108
def __le__(self, other):
105-
return self._compare(other, lambda s,o: s <= o)
109+
return self._compare(other, lambda s, o: s <= o)
106110

107111
def __eq__(self, other):
108-
return self._compare(other, lambda s,o: s == o)
112+
return self._compare(other, lambda s, o: s == o)
109113

110114
def __ge__(self, other):
111-
return self._compare(other, lambda s,o: s >= o)
115+
return self._compare(other, lambda s, o: s >= o)
112116

113117
def __gt__(self, other):
114-
return self._compare(other, lambda s,o: s > o)
118+
return self._compare(other, lambda s, o: s > o)
115119

116120
def __ne__(self, other):
117-
return self._compare(other, lambda s,o: s != o)
121+
return self._compare(other, lambda s, o: s != o)
118122

119123

120124
def partition(func, iterable):
@@ -125,5 +129,39 @@ def partition(func, iterable):
125129
"""
126130
result = defaultdict(list)
127131
for v in iterable:
128-
result[func(v)].append(v)
132+
result[func(v)].append(v)
129133
return result
134+
135+
136+
def add_field(a, descr):
137+
"""Return a new array that is like "a", but has additional fields.
138+
139+
From https://stackoverflow.com/questions/1201817/adding-a-field-to-a-structured-numpy-array
140+
141+
Arguments:
142+
a -- a structured numpy array
143+
descr -- a numpy type description of the new fields
144+
145+
The contents of "a" are copied over to the appropriate fields in
146+
the new array, whereas the new fields are uninitialized. The
147+
arguments are not modified.
148+
149+
>>> sa = np.array([(1, 'Foo'), (2, 'Bar')], \
150+
dtype=[('id', int), ('name', 'S3')])
151+
>>> sa.dtype.descr == np.dtype([('id', int), ('name', 'S3')])
152+
True
153+
>>> sb = add_field(sa, [('score', float)])
154+
>>> sb.dtype.descr == np.dtype([('id', int), ('name', 'S3'), \
155+
('score', float)])
156+
True
157+
>>> np.all(sa['id'] == sb['id'])
158+
True
159+
>>> np.all(sa['name'] == sb['name'])
160+
True
161+
"""
162+
if a.dtype.fields is None:
163+
raise ValueError("`A` must be a structured numpy array")
164+
b = np.empty(a.shape, dtype=a.dtype.descr + descr)
165+
for name in a.dtype.names:
166+
b[name] = a[name]
167+
return b

0 commit comments

Comments
 (0)