File size: 9,236 Bytes
ff7b988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
import gzip
import json
import pathlib
import shutil
import tempfile
from enum import Enum
from io import BytesIO

import pretty_midi

from .align import create_beat_to_time_fn
from .assets import retrieve_asset

_TICKS_PER_SECOND = 4096
_QUANTIZE = lambda t: round(t * _TICKS_PER_SECOND) / _TICKS_PER_SECOND
_SEGMENT_MIDI_PITCH = 75


class Split(Enum):
    TRAIN = 0
    VALID = 1
    TEST = 2


class HooktheoryConfig(Enum):
    MELODY_TRANSCRIPTION = 0


class HooktheoryAlignment(Enum):
    USER = 0
    REFINED = 1


class Note:
    def __init__(self, onset, pitch, offset=None):
        if not isinstance(onset, float):
            raise TypeError()
        if not isinstance(pitch, int):
            raise TypeError()
        if offset is not None and not isinstance(offset, float):
            raise TypeError()
        if onset < 0:
            raise ValueError("Onset is negative")
        if offset is not None and offset <= onset:
            raise ValueError("Offset is before onset")
        if pitch < 0 or pitch >= 128:
            raise ValueError("Pitch is outside of MIDI range")
        self.onset = _QUANTIZE(onset)
        self.pitch = pitch
        self.offset = None if offset is None else _QUANTIZE(offset)


class MelodyTranscriptionExample:
    def __init__(self, segment_start, segment_end, melody, uid=None, audio_tag=None):
        if not isinstance(segment_start, float):
            raise TypeError()
        if not isinstance(segment_end, float):
            raise TypeError()
        if not all(isinstance(n, Note) for n in melody):
            raise TypeError()
        if segment_start < 0:
            raise ValueError("Segment start is negative")
        if segment_end <= segment_start:
            raise ValueError("Segment end before segment start")

        segment_start = _QUANTIZE(segment_start)
        segment_end = _QUANTIZE(segment_end)

        melody = sorted(melody, key=lambda n: (n.onset, n.pitch, n.offset))
        if any((n.onset < segment_start or n.onset > segment_end) for n in melody):
            raise ValueError("Onset outside of segment range")
        if any(
            n.offset is not None
            and (n.offset < segment_start or n.offset > segment_end)
            for n in melody
        ):
            raise ValueError("Offset outside of segment range")
        for i in range(len(melody) - 1):
            if melody[i].onset == melody[i + 1].onset:
                raise ValueError("Simultaneous onsets detected")
            if melody[i].offset is not None and melody[i].offset > melody[i + 1].onset:
                raise ValueError("Notes are not monophonic")

        self.segment_start = segment_start
        self.segment_end = segment_end
        self.melody = melody
        self.uid = uid
        self.audio_tag = audio_tag

    @classmethod
    def from_midi(
        cls, midi, segment_start=None, segment_end=None, uid=None, audio_tag=None
    ):
        midi = as_pretty_midi(midi)
        segment = []
        melody = []
        for i in midi.instruments:
            for n in i.notes:
                if i.is_drum and n.pitch == _SEGMENT_MIDI_PITCH:
                    segment.append(n.start)
                elif not i.is_drum:
                    melody.append(Note(onset=n.start, pitch=n.pitch, offset=n.end))

        if segment_start is None or segment_end is None:
            if len(segment) != 2:
                raise ValueError("Unknown segment")
            segment_start, segment_end = sorted(segment)

        return cls(
            segment_start=segment_start,
            segment_end=segment_end,
            melody=melody,
            uid=uid,
            audio_tag=audio_tag,
        )

    def to_midi(self, velocity=100):
        midi = pretty_midi.PrettyMIDI(resolution=_TICKS_PER_SECOND, initial_tempo=60.0)

        segment = pretty_midi.Instrument(0, is_drum=True, name="SEGMENT")
        for t in [self.segment_start, self.segment_end]:
            segment.notes.append(
                pretty_midi.Note(
                    start=t,
                    end=t + (1 / _TICKS_PER_SECOND),
                    pitch=_SEGMENT_MIDI_PITCH,
                    velocity=127,
                )
            )

        melody = pretty_midi.Instrument(0, name="MELODY")
        for i, n in enumerate(self.melody):
            offset = n.offset
            if offset is None:
                try:
                    offset = self.melody[i + 1].onset
                except IndexError:
                    offset = n.onset + 1
            melody.notes.append(
                pretty_midi.Note(
                    start=n.onset, end=offset, pitch=n.pitch, velocity=velocity
                )
            )

        midi.instruments = [segment, melody]

        with tempfile.NamedTemporaryFile() as f:
            midi.write(f.name)
            with open(f.name, "rb") as f:
                return f.read()


_CONFIG_TO_TAGS = {
    HooktheoryConfig.MELODY_TRANSCRIPTION: {
        "require": ["AUDIO_AVAILABLE", "MELODY"],
        # NOTE: Tempo changes are weird on Hooktheory and likely imply a bad alignment
        "deny": ["TEMPO_CHANGES"],
    },
}


def as_pretty_midi(midi):
    if isinstance(midi, bytes):
        midi = pretty_midi.PrettyMIDI(BytesIO(midi))
    elif isinstance(midi, str) or isinstance(midi, pathlib.Path):
        midi = pretty_midi.PrettyMIDI(str(midi))
    elif isinstance(midi, pretty_midi.PrettyMIDI):
        pass
    else:
        raise TypeError()
    return midi


def load_hooktheory_raw(
    config=HooktheoryConfig.MELODY_TRANSCRIPTION,
    alignment=HooktheoryAlignment.REFINED,
    additional_required_tags=[],
    additional_denied_tags=[],
):
    if isinstance(config, str):
        config = HooktheoryConfig[config.upper().strip()]
    if isinstance(alignment, str):
        alignment = HooktheoryAlignment[alignment.upper().strip()]

    # Build required tags list
    require = _CONFIG_TO_TAGS[config]["require"]
    require = require + additional_required_tags
    if alignment is not None:
        require.append(
            "USER_ALIGNMENT"
            if alignment == HooktheoryAlignment.USER
            else "REFINED_ALIGNMENT"
        )

    # Build denied tags list
    deny = _CONFIG_TO_TAGS[config]["deny"]
    deny = deny + additional_denied_tags

    # Load dataset
    with gzip.open(retrieve_asset("HOOKTHEORY"), "r") as f:
        hooktheory = json.load(f)

    # Check tags
    all_tags = set()
    for attrs in hooktheory.values():
        for tag in attrs["tags"]:
            all_tags.add(tag)
    for tag in require + deny:
        if tag not in all_tags:
            raise ValueError(f"Invalid tag: {tag}")

    # Filter dataset
    hooktheory = {
        k: v
        for k, v in hooktheory.items()
        if all(tag in v["tags"] for tag in require)
        and all(tag not in v["tags"] for tag in deny)
    }

    return hooktheory


def iter_archive(archive_path):
    with tempfile.TemporaryDirectory() as d:
        shutil.unpack_archive(str(archive_path), d)
        midi_paths = list(pathlib.Path(d).glob("*.mid*"))
        uids = [p.stem for p in midi_paths]
        if len(set(uids)) != len(uids):
            raise ValueError("Duplicate UID")
        for p in sorted(midi_paths):
            yield MelodyTranscriptionExample.from_midi(p, uid=p.stem)


def iter_rwc_ryy(vox_only=False):
    asset_tag = "RWC_RYYVOX_MIDI" if vox_only else "RWC_RYY_MIDI"
    for e in iter_archive(retrieve_asset(asset_tag)):
        e.audio_tag = f"RWC_AUDIO_{e.uid}"
        yield e


def iter_hooktheory(
    alignment=HooktheoryAlignment.REFINED,
    split=None,
    default_octave=4,
    tqdm=lambda x: x,
    **kwargs,
):
    if isinstance(alignment, str):
        alignment = HooktheoryAlignment[alignment.upper().strip()]
    if isinstance(split, str):
        split = Split[split.upper().strip()]

    hooktheory_raw = load_hooktheory_raw(
        config=HooktheoryConfig.MELODY_TRANSCRIPTION, alignment=alignment
    )
    if split is not None:
        hooktheory_raw = {
            k: v for k, v in hooktheory_raw.items() if v["split"] == split.name
        }

    for uid, attrs in tqdm(hooktheory_raw.items()):
        youtube_id = attrs["youtube"]["id"]
        assert youtube_id is not None

        alignment_ = attrs["alignment"][alignment.name.lower()]
        assert alignment_ is not None and len(alignment_["times"]) >= 2
        beat_to_time = create_beat_to_time_fn(alignment_["beats"], alignment_["times"])
        segment_start = float(beat_to_time(0))
        segment_end = float(beat_to_time(attrs["annotations"]["num_beats"]))

        melody = attrs["annotations"]["melody"]
        assert melody is not None and len(melody) > 0
        melody = [
            Note(
                onset=float(beat_to_time(n["onset"])),
                pitch=(1 + default_octave + n["octave"]) * 12 + n["pitch_class"],
                offset=float(beat_to_time(n["offset"])),
            )
            for n in melody
        ]

        yield MelodyTranscriptionExample(
            uid=uid,
            audio_tag=f"YOUTUBE_{youtube_id}",
            segment_start=segment_start,
            segment_end=segment_end,
            melody=melody,
        )