import glob import os import sys from hashlib import sha256 from pathlib import Path import re from mido import MidiFile, Message, tempo2bpm from pprint import pprint from dataclasses import dataclass, field from dataclasses_json import dataclass_json, config as dj_config from binascii import hexlify from pebble import ProcessPool, ProcessExpired import multiprocessing as mp from concurrent.futures import TimeoutError import json import psycopg from psycopg.errors import DeadlockDetected from psycopg.types.json import Jsonb from psycopg_pool import ConnectionPool from typing import List import traceback import math import signal import random dbpool = ConnectionPool('host=localhost dbname=composer_new_dataset_v3', min_size=2, max_size=2) @dataclass_json @dataclass class Note: onBeat: float offBeat: float onVelocity: float offVelocity: float sourceTrack: int note: int @dataclass class AugmentEvent: evt: Message trackIdx: int absoluteTime: int beat: float @dataclass_json @dataclass class Text: kind: str value: str @dataclass_json @dataclass class Track: name: str text: str = field(default="", metadata=dj_config(exclude=lambda x: len(x) == 0)) lyrics: str = field(default="", metadata=dj_config(exclude=lambda x: len(x) == 0)) texts: List[Text] = field(default_factory=lambda: [], metadata=dj_config(exclude=lambda x: len(x) == 0)) OFF = 'off' class Channel: def __init__(self, index): self.notes = [ OFF for i in range(128) ] self.note_tracks = [ 0 for i in range(128) ] self.vels = [ 0 for i in range(128) ] self.instruments = {} self.index = index self.program = None def push(self, ae): handler = getattr(self, ae.evt.type, None) if handler is None: return handler(ae) def note_on(self, ae): if ae.evt.velocity == 0: return self.note_off(ae) if self.notes[ae.evt.note] == OFF: self.notes[ae.evt.note] = ae.beat self.note_tracks[ae.evt.note] = ae.trackIdx self.vels[ae.evt.note] = ae.evt.velocity def note_off(self, ae): note = ae.evt.note if self.notes[note] == OFF: return if self.program not in self.instruments: self.instruments[self.program] = [] self.instruments[self.program].append( Note(onBeat=self.notes[note], offBeat=ae.beat, onVelocity=self.vels[note], offVelocity=ae.evt.velocity, sourceTrack=self.note_tracks[note], note=note) ) self.notes[note] = OFF def program_change(self, ae): self.program = ae.evt.program def to_dict(self): return { 'index': self.index, 'isDrumIndex': self.index == 10, 'instruments': [ { 'program': k, 'notes': [ n.to_dict() for n in v ] } for k, v in sorted(self.instruments.items(), key=lambda x: -1 if x[0] is None else x[0]) ] } #MetaMessage('track_name', name='LuvTXG ', time=0) #MetaMessage('set_tempo', tempo=544464, time=0) #MetaMessage('time_signature', numerator=4, denominator=4, clocks_per_click=24, notated_32nd_notes_per_beat=8, time=0) class SkipException(Exception): pass class ExistsException(Exception): pass def process(path): try: mid = MidiFile(path) except EOFError: raise SkipException(f'File ended early: {path}') except Exception as e: if re.match('.*(list index|MThd|must be in range|decode key|EOFError|running status|MTrk)', str(e)): raise SkipException(f'Failed to parse (acceptable error): {path}') raise e if mid.type == 2: raise SkipException(f'Skipping type 2 file: {path}') tempo_evt = None time_signature_evt = None aug_evts = [] tracks = [] for i, track in enumerate(mid.tracks): time = 0 track_out = Track(track.name.replace('\x00', '')) tracks.append(track_out) for evt in track: time += evt.time aug_evts.append(AugmentEvent(evt, i, time, time / mid.ticks_per_beat)) if evt.type == 'text': if track_out.text != '': track_out.text += evt.text.replace('\x00', '') else: alltexts = [ t.text for t in track_out.texts if t.kind == 'text' and len(t.text) > 0 ] alltexts.append(evt.text.replace('\x00', '')) if len(alltexts) > 20: # probably lyrics track_out.text = ''.join(alltexts) track_out.texts = [ t for t in track_out.texts if t.kind != 'text' ] elif evt.type == 'lyrics': track_out.lyrics += evt.text.replace('\x00', '') elif str(evt.type) in ['copyright', 'marker', 'cue_marker']: track_out.texts.append(Text(evt.type, evt.text.replace('\x00', ''))) elif str(evt.type) in ['instrument_name', 'device_name']: track_out.texts.append(Text(evt.type, evt.name.replace('\x00', ''))) aug_evts = list(sorted(aug_evts, key=lambda e: e.absoluteTime)) channels = {} for ae in aug_evts: if ae.evt.type == 'set_tempo': #if tempo_evt is not None and tempo_evt.tempo != ae.evt.tempo: # raise Exception(f'Tempo change - skipping') if tempo_evt is None: tempo_evt = ae.evt elif ae.evt.type == 'time_signature': if time_signature_evt is not None and not (time_signature_evt.numerator == ae.evt.numerator and time_signature_evt.denominator == ae.evt.denominator): raise SkipException('Time signature change - skipping') time_signature_evt = ae.evt if ae.evt.is_meta or ae.evt.type == 'sysex': continue chan = ae.evt.channel if chan not in channels: channels[chan] = Channel(chan) channels[chan].push(ae) live_channels = [ c for i, c in sorted(channels.items(), key=lambda x: x[0]) if len(c.instruments) > 0 ] return { 'tempo': tempo2bpm(tempo_evt.tempo) if tempo_evt is not None else 120, 'time_signature': f'{time_signature_evt.numerator}/{time_signature_evt.denominator}' if time_signature_evt is not None else '4/4', 'tracks': [ t.to_dict() for t in tracks ], 'channels': [ c.to_dict() for c in live_channels ] } def hash_notes(first_beat, ns): def quantize(v): return int(v * 192) // 192 ns = sorted(ns, key=lambda n: (n["onBeat"], n["offBeat"], n["note"], n["onVelocity"])) preimage = [] for n in ns: preimage.extend([ str(quantize(n["onBeat"] - first_beat)), str(quantize(n["offBeat"] - first_beat)), str(int(n["note"])), str(int(n["onVelocity"])) ]) preimage = "\n".join(preimage).encode('utf-8') h = sha256(preimage).digest() return int.from_bytes(h[:8], "big", signed=True) def process_insert_txn(res, cur, path, path_hash): cur.execute("insert into files (tempo, time_signature, path, hash, dataset_name) values (%s, %s, %s, %s, %s) on conflict (hash) do nothing returning id", (res['tempo'], res['time_signature'], str(path), path_hash, str(path).split('/')[1])) rows = cur.fetchall() if len(rows) == 0: raise ExistsException('File already in') file_id = rows[0][0] track_ids = [] for i, track in enumerate(res['tracks']): cur.execute("insert into tracks (file_id, track_index, name, text, lyrics, texts) values (%s, %s, %s, %s, %s, %s)", (file_id, i, track['name'], track.get('text', None), track.get('lyrics', None), Jsonb(track.get('texts', [])))) track_ids.append(cur.fetchone()[0]) for channel in res['channels']: for instrument in channel['instruments']: notes = instrument["notes"] if len(notes) == 0: continue cur.execute("insert into instruments (file_id, channel_index, program) values (%s, %s, %s) returning id", (file_id, channel["index"], instrument["program"])) instrument_id = cur.fetchone()[0] track_idxs = set(note["sourceTrack"] for note in notes) for track_idx in track_idxs: track_id = track_ids[track_idx] track_notes = [n for n in notes if n["sourceTrack"] == track_idx] first_beat = int(4 * (track_notes[0]["onBeat"] // 4)) last_beat = int(math.ceil(max(note["offBeat"] for note in track_notes))) clip_length = 20 clip_stride = 16 def add_clip(start, end): clip_notes = [ n for n in track_notes if ( n["onBeat"] >= start and n["offBeat"] <= end ) ] if len(clip_notes) == 0: return h = hash_notes(first_beat, clip_notes) cur.execute("insert into clips (hash, file_id, instrument_id, track_id, \"start\", \"length\") values (%s, %s, %s, %s, %s, %s) on conflict (instrument_id, track_id, start, length) do nothing", (h, file_id, instrument_id, track_id, start, end - start)) #for pos in range(first_beat, last_beat + clip_stride, clip_stride): # add_clip(pos, pos + clip_length) #add_clip(first_beat, int(4 * math.ceil(max(n["offBeat"] for n in track_notes) / 4))) cur.executemany("insert into notes (instrument_id, track_id, pitch, \"start\", \"end\", velocity, off_velocity) values (%s, %s, %s, %s, %s, %s, %s)", ((instrument_id, track_id, note["note"], note["onBeat"], note["offBeat"], note["onVelocity"], note["offVelocity"]) for note in track_notes)) def process_insert(path_hash_data): for i in range(100): try: with dbpool.connection() as conn: conn.autocommit = True with conn.transaction(), conn.cursor() as cur: path, path_hash, _ = path_hash_data cur.execute('select id from files where hash = %s', (path_hash,)) rows = cur.fetchall() if len(rows) == 0: res = process(path) if all(len(inst["notes"]) == 0 for channel in res["channels"] for inst in channel["instruments"]): return process_insert_txn(res, cur, path, path_hash) cur.execute("insert into file_names (file_id, name, source) values (%s, %s, 'collect') on conflict (file_id, name) do nothing", (rows[0][0], str(path))) except DeadlockDetected as e: if i < 99: print('retry on deadlock') continue except ExistsException: pass break else: raise RuntimeError('insert retries exceeded') def main(glob, process_insert): def generate_paths(): for p in sys.argv[1:]: for path in Path(p).rglob('*'): if not re.match(glob, str(path), flags=re.IGNORECASE): continue if not os.path.isfile(path): continue with open(path, 'rb') as f: data = f.read() path_hash_hex = hexlify(sha256(data).digest()).decode('utf-8') yield path, path_hash_hex, data with ProcessPool(max_workers=12) as pool: future = pool.map(process_insert, generate_paths(), timeout=30) iterator = future.result() while True: try: result = next(iterator) except SkipException: pass except StopIteration: break except TimeoutError as error: print("function took longer than %d seconds" % error.args[1]) except ProcessExpired as error: print("%s. Exit code: %d" % (error, error.exitcode)) except Exception as error: print(error) print(error.traceback) # Python's traceback of remote process if __name__ == '__main__': mp.set_start_method('spawn') main('.*\.midi?$', process_insert)